diff --git a/src/games/game.h b/src/games/game.h index 7cb3a315f..06a13850f 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -836,6 +836,8 @@ class GameRep : public std::enable_shared_from_this { } return false; } + /// + virtual GameNode GetSubgameRoot(const GameInfoset &) const { throw UndefinedException(); } //@} /// @name Writing data files diff --git a/src/games/gametree.cc b/src/games/gametree.cc index c630c50a2..17d70123c 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -24,9 +24,11 @@ #include #include #include +#include #include #include #include +#include #include "gambit.h" #include "gametree.h" @@ -371,32 +373,10 @@ bool GameNodeRep::IsSuccessorOf(GameNode p_node) const bool GameNodeRep::IsSubgameRoot() const { - // First take care of a couple easy cases - if (m_children.empty() || m_infoset->m_members.size() > 1) { - return false; - } - if (!m_parent) { - return true; - } - - // A node is a subgame root if and only if in every information set, - // either all members succeed the node in the tree, - // or all members do not succeed the node in the tree. - for (auto player : m_game->GetPlayers()) { - for (auto infoset : player->GetInfosets()) { - const bool precedes = infoset->m_members.front()->IsSuccessorOf( - std::const_pointer_cast(shared_from_this())); - if (std::any_of(std::next(infoset->m_members.begin()), infoset->m_members.end(), - [this, precedes](const std::shared_ptr &m) { - return m->IsSuccessorOf(std::const_pointer_cast( - shared_from_this())) != precedes; - })) { - return false; - } - } + if (m_children.empty()) { + return !GetParent(); } - - return true; + return m_game->GetSubgameRoot(m_infoset->shared_from_this()) == shared_from_this(); } bool GameNodeRep::IsStrategyReachable() const @@ -884,6 +864,7 @@ void GameTreeRep::ClearComputedValues() const m_ownPriorActionInfo = nullptr; const_cast(this)->m_unreachableNodes = nullptr; m_absentMindedInfosets.clear(); + m_infosetSubgameRoot.clear(); m_computedValues = false; } @@ -1093,6 +1074,209 @@ void GameTreeRep::BuildUnreachableNodes() const } } +//------------------------------------------------------------------------ +// Subgame Root Finder +//------------------------------------------------------------------------ + +namespace { // Anonymous namespace +struct SubgameScratchData { + /// DSU structures + /// + /// Maps each infoset to its parent in the union-find structure. + /// After path compression, points to the component leader directly. + std::unordered_map dsu_parent; + /// Maps each component leader to the highest node (candidate root) in that component + std::unordered_map subgame_root_candidate; + + /// DSU Find + path compression: Finds the leader of the component containing p_infoset. + /// + /// @param p_infoset The infoset to find the component leader for + /// @return Pointer to the leader infoset of the component + /// + /// Postcondition: Path from p_infoset to leader is compressed (flattened) + GameInfosetRep *FindSet(GameInfosetRep *p_infoset) + { + // Initialize/retrieve current parent + auto *leader = dsu_parent.try_emplace(p_infoset, p_infoset).first->second; + while (dsu_parent.at(leader) != leader) { + leader = dsu_parent.at(leader); + } + // Path compression + auto *curr = p_infoset; + while (curr != leader) { + auto &parent_ref = dsu_parent.at(curr); + curr = parent_ref; + parent_ref = leader; + } + return leader; + } + + /// DSU Union: Merges the components containing two infosets, updates the subgame root candidate. + /// + /// @param p_start_infoset The infoset whose component should absorb the other + /// @param p_current_infoset The infoset whose component is being merged + /// @param p_current_node The node being processed, considered as potential subgame root + /// + /// Postcondition: Both infosets belong to the same component + /// Postcondition: The component's subgame_root_candidate is updated to the highest node + void UnionSets(GameInfosetRep *p_start_infoset, GameInfosetRep *p_current_infoset, + GameNodeRep *p_current_node) + { + auto *leader_start = FindSet(p_start_infoset); + auto *leader_current = FindSet(p_current_infoset); + + if (leader_start == leader_current) { + subgame_root_candidate[leader_start] = p_current_node; + return; + } + + // Check if candidate exists before accessing + auto it = subgame_root_candidate.find(leader_current); + auto *existing_candidate = (it != subgame_root_candidate.end()) ? it->second : nullptr; + auto *best_candidate = existing_candidate ? existing_candidate : p_current_node; + + dsu_parent[leader_current] = leader_start; + subgame_root_candidate[leader_start] = best_candidate; + } +}; + +/// Generates a single connected component starting from a given node. +/// +/// The local frontier driving the exploration is a priority queue. +/// This design choice ensures that nodes are processed before their ancestors. +/// +/// Starting from p_start_node, explores the game tree by: +/// 1. Adding all members of each encountered infoset (horizontal expansion of the frontier) +/// 2. Moving to parent nodes (vertical expansion of the frontier) +/// 3. When hitting nodes from previously-generated components (external collision) +/// it merges the components and adds the root of the external component to the frontier +/// +/// The component gathers all infosets that share the same minimal subgame root. +/// The highest node processed that empties the frontier without adding any new nodes +/// to horizontal expansion becomes the subgame root candidate. +/// +/// @param p_data The DSU data structure to update with component information +/// @param p_start_node The node to start component generation from +/// @param p_node_ordering A map providing a strict total ordering (DFS Preorder) of nodes +/// +/// Precondition: p_start_node must be non-terminal +/// Precondition: p_start_node's infoset must not already be in p_data.dsu_parent +/// Postcondition: p_data.subgame_root_candidate[leader] contains the highest node +/// in the newly-formed component +void GenerateComponent(SubgameScratchData &p_data, GameNodeRep *p_start_node, + const std::unordered_map &p_node_ordering) +{ + auto node_cmp = [&p_node_ordering](const GameNodeRep *a, const GameNodeRep *b) { + return p_node_ordering.at(a) < p_node_ordering.at(b); + }; + + std::priority_queue, decltype(node_cmp)> + local_frontier(node_cmp); + + std::unordered_set visited_this_component; + + local_frontier.push(p_start_node); + visited_this_component.insert(p_start_node); + auto *start_infoset = p_start_node->GetInfoset().get(); + + while (!local_frontier.empty()) { + auto *curr = local_frontier.top(); + local_frontier.pop(); + + auto *curr_infoset = curr->GetInfoset().get(); + const bool is_external_collision = p_data.dsu_parent.count(curr_infoset); + + p_data.UnionSets(start_infoset, curr_infoset, curr); + + if (is_external_collision) { + // We hit a node belonging to a previously generated component. + auto *candidate_root = p_data.subgame_root_candidate.at(p_data.FindSet(curr_infoset)); + if (!visited_this_component.count(candidate_root)) { + local_frontier.push(candidate_root); + visited_this_component.insert(candidate_root); + } + } + else { + // First time seeing this infoset: add all its members to the frontier. + for (const auto &member_sp : filter_if(curr->GetInfoset()->GetMembers(), + [curr](const auto &m) { return m.get() != curr; })) { + auto *member = member_sp.get(); + local_frontier.push(member); + visited_this_component.insert(member); + } + } + + if (!local_frontier.empty()) { + if (auto parent_sp = curr->GetParent()) { + auto *parent = parent_sp.get(); + if (!visited_this_component.count(parent)) { + local_frontier.push(parent); + visited_this_component.insert(parent); + } + } + } + } +} + +/// For each infoset in the game, computes the root of the smallest subgame containing it. +/// +/// Processes nodes in reverse DFS order (postorder), building a component for each node, +/// skipping a node if it is: +/// 1. A member of an infoset already belonging to some component, or +/// 2. Terminal +/// +/// @param p_game The game tree +/// @return Map from each infoset to the root of its smallest containing subgame +/// +/// Precondition: p_game must be a valid game tree +/// Postcondition: Every infoset in the game is mapped to exactly one subgame root node +/// Returns: Empty map if the game root is terminal (trivial game) +std::map FindSubgameRoots(const Game &p_game) +{ + if (p_game->GetRoot()->IsTerminal()) { + return {}; + } + + // Pre-compute DFS numbers locally. + std::unordered_map node_ordering; + int counter = 0; + for (const auto &node : p_game->GetNodes(TraversalOrder::Preorder)) { + node_ordering[node.get()] = counter++; + } + + SubgameScratchData data; + + // Define filter predicate + auto is_unvisited_infoset = [&data](const auto &node) { + return !data.dsu_parent.count(node->GetInfoset().get()); + }; + + // Process nodes in postorder + for (const auto &node : + filter_if(p_game->GetNonterminalNodes(TraversalOrder::Postorder), is_unvisited_infoset)) { + GenerateComponent(data, node.get(), node_ordering); + } + + std::map result; + + using InfosetsWithChance = + NestedElementCollection; + + for (const auto &infoset : InfosetsWithChance(p_game)) { + auto *ptr = infoset.get(); + result[ptr] = data.subgame_root_candidate.at(data.FindSet(ptr)); + } + + return result; +} + +} // end anonymous namespace + +void GameTreeRep::BuildSubgameRoots() const +{ + m_infosetSubgameRoot = FindSubgameRoots(std::const_pointer_cast(shared_from_this())); +} + //------------------------------------------------------------------------ // GameTreeRep: Writing data files //------------------------------------------------------------------------ diff --git a/src/games/gametree.h b/src/games/gametree.h index 9c96939f1..75c99b59b 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -48,6 +48,7 @@ class GameTreeRep : public GameExplicitRep { mutable std::shared_ptr m_ownPriorActionInfo; mutable std::unique_ptr> m_unreachableNodes; mutable std::set m_absentMindedInfosets; + mutable std::map m_infosetSubgameRoot; /// @name Private auxiliary functions //@{ @@ -88,6 +89,13 @@ class GameTreeRep : public GameExplicitRep { /// Returns the largest payoff to the player in any play of the game Rational GetPlayerMaxPayoff(const GamePlayer &) const override; bool IsAbsentMinded(const GameInfoset &p_infoset) const override; + GameNode GetSubgameRoot(const GameInfoset &infoset) const override + { + if (m_infosetSubgameRoot.empty()) { + const_cast(this)->BuildSubgameRoots(); + } + return {m_infosetSubgameRoot.at(infoset.get())->shared_from_this()}; + } //@} /// @name Players @@ -174,6 +182,7 @@ class GameTreeRep : public GameExplicitRep { std::vector BuildConsistentPlaysRecursiveImpl(GameNodeRep *node); void BuildOwnPriorActions() const; void BuildUnreachableNodes() const; + void BuildSubgameRoots() const; }; template class TreeMixedStrategyProfileRep : public MixedStrategyProfileRep { diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 0234f7920..4b4c00669 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -301,6 +301,7 @@ cdef extern from "games/game.h": stdvector[c_GameNode] GetPlays(c_GameAction) except + bool IsPerfectRecall() except + bool IsAbsentMinded(c_GameInfoset) except + + c_GameNode GetSubgameRoot(c_GameInfoset) except + c_GameInfoset AppendMove(c_GameNode, c_GamePlayer, int) except +ValueError c_GameInfoset AppendMove(c_GameNode, c_GameInfoset) except +ValueError diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 1ed7fff42..1f77230a2 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -756,6 +756,10 @@ class Game: """ return self.game.deref().IsPerfectRecall() + def subgame_root(self, infoset: typing.Union[Infoset, str]) -> Node: + infoset = self._resolve_infoset(infoset, "subgame_root") + return Node.wrap(self.game.deref().GetSubgameRoot(cython.cast(Infoset, infoset).infoset)) + @property def min_payoff(self) -> decimal.Decimal | Rational: """The minimum payoff to any player in any play of the game. diff --git a/tests/test_games/AM-subgames.efg b/tests/test_games/AM-subgames.efg new file mode 100644 index 000000000..7adfe10ce --- /dev/null +++ b/tests/test_games/AM-subgames.efg @@ -0,0 +1,14 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +p "" 1 1 "" { "1" "2" } 0 +p "" 1 1 "" { "1" "2" } 0 +p "" 2 1 "" { "1" "2" } 0 +t "" 1 "Outcome 1" { 1, -1 } +t "" 2 "Outcome 2" { 2, -2 } +p "" 2 3 "" { "1" "2" } 0 +t "" 3 "Outcome 3" { 3, -3 } +t "" 4 "Outcome 4" { 4, -4 } +p "" 2 2 "" { "1" "2" } 0 +t "" 5 "Outcome 5" { 5, -5 } +t "" 6 "Outcome 6" { 6, -6 } diff --git a/tests/test_games/subgame-roots-finder-multiple-merges.efg b/tests/test_games/subgame-roots-finder-multiple-merges.efg new file mode 100644 index 000000000..bb6c6b0f0 --- /dev/null +++ b/tests/test_games/subgame-roots-finder-multiple-merges.efg @@ -0,0 +1,55 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +p "" 1 1 "" { "1" "1" } 0 +t "" 1 "Outcome 1" { 1, -1 } +p "" 2 1 "" { "1" "1" "1" "1" "1" } 0 +p "" 1 2 "" { "1" "1" } 0 +t "" 2 "Outcome 2" { 2, -2 } +p "" 1 3 "" { "1" "1" } 0 +t "" 3 "Outcome 3" { 3, -3 } +t "" 4 "Outcome 4" { 4, -4 } +p "" 1 3 "" { "1" "1" } 0 +p "" 1 2 "" { "1" "1" } 0 +t "" 5 "Outcome 5" { 5, -5 } +t "" 6 "Outcome 6" { 6, -6 } +t "" 7 "Outcome 7" { 7, -7 } +p "" 2 2 "" { "1" "1" } 0 +t "" 8 "Outcome 8" { 8, -8 } +p "" 1 4 "" { "1" "1" } 0 +p "" 2 3 "" { "1" "1" } 0 +t "" 9 "Outcome 9" { 9, -9 } +t "" 10 "Outcome 10" { 10, -10 } +p "" 2 4 "" { "1" "1" } 0 +t "" 11 "Outcome 11" { 11, -11 } +p "" 1 5 "" { "1" "1" } 0 +p "" 1 6 "" { "1" "1" } 0 +p "" 2 5 "" { "1" "1" } 0 +t "" 12 "Outcome 12" { 12, -12 } +t "" 13 "Outcome 13" { 13, -13 } +t "" 14 "Outcome 14" { 14, -14 } +p "" 1 6 "" { "1" "1" } 0 +p "" 2 6 "" { "1" "1" } 0 +c "" 1 "" { "1" 1/2 "1" 1/2 } 0 +p "" 2 5 "" { "1" "1" } 0 +p "" 2 3 "" { "1" "1" } 0 +t "" 15 "Outcome 15" { 15, -15 } +t "" 16 "Outcome 16" { 16, -16 } +t "" 17 "Outcome 17" { 17, -17 } +p "" 2 5 "" { "1" "1" } 0 +t "" 18 "Outcome 18" { 18, -18 } +t "" 19 "Outcome 19" { 19, -19 } +t "" 20 "Outcome 20" { 20, -20 } +p "" 2 6 "" { "1" "1" } 0 +p "" 2 7 "" { "1" "1" } 0 +t "" 21 "Outcome 21" { 21, -21 } +t "" 22 "Outcome 22" { 22, -22 } +p "" 2 7 "" { "1" "1" } 0 +t "" 23 "Outcome 23" { 23, -23 } +t "" 24 "Outcome 24" { 24, -24 } +p "" 1 7 "" { "1" "1" } 0 +t "" 25 "Outcome 25" { 25, -25 } +t "" 26 "Outcome 26" { 26, -26 } +p "" 1 7 "" { "1" "1" } 0 +t "" 27 "Outcome 27" { 27, -27 } +t "" 28 "Outcome 28" { 28, -28 } diff --git a/tests/test_games/subgames.efg b/tests/test_games/subgame-roots-finder-multiple-roots-and-merge.efg similarity index 100% rename from tests/test_games/subgames.efg rename to tests/test_games/subgame-roots-finder-multiple-roots-and-merge.efg diff --git a/tests/test_games/subgame-roots-finder-one-merge.efg b/tests/test_games/subgame-roots-finder-one-merge.efg new file mode 100644 index 000000000..83d6fa7ad --- /dev/null +++ b/tests/test_games/subgame-roots-finder-one-merge.efg @@ -0,0 +1,28 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +p "" 1 1 "" { "1" "1" } 0 +t "" 1 "Outcome 1" { 0, 0 } +p "" 1 2 "" { "1" "1" } 0 +p "" 2 1 "" { "1" "1" } 0 +p "" 1 3 "" { "1" "1" } 0 +t "" 2 "Outcome 2" { 1, 1 } +t "" 3 "Outcome 3" { 2, -2 } +t "" 4 "Outcome 4" { 3, -3 } +p "" 2 1 "" { "1" "1" } 0 +p "" 1 4 "" { "1" "1" } 0 +p "" 2 2 "" { "1" "1" } 0 +p "" 1 3 "" { "1" "1" } 0 +t "" 5 "Outcome 5" { 4, -4 } +t "" 6 "Outcome 6" { 5, -5 } +p "" 1 3 "" { "1" "1" } 0 +t "" 7 "Outcome 7" { 7, -7 } +t "" 8 "Outcome 8" { 8, -8 } +t "" 9 "Outcome 9" { 9, -9 } +p "" 1 4 "" { "1" "1" } 0 +p "" 2 3 "" { "1" "1" } 0 +t "" 10 "Outcome 10" { 10, -10 } +t "" 11 "Outcome 11" { 11, -11 } +p "" 2 3 "" { "1" "1" } 0 +t "" 12 "Outcome 12" { 12, -12 } +t "" 13 "Outcome 13" { 13, -13 } diff --git a/tests/test_games/subgame-roots-finder-small-subgames-and-merges.efg b/tests/test_games/subgame-roots-finder-small-subgames-and-merges.efg new file mode 100644 index 000000000..e54ada511 --- /dev/null +++ b/tests/test_games/subgame-roots-finder-small-subgames-and-merges.efg @@ -0,0 +1,48 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +p "" 2 1 "" { "1" "2" } 0 +p "" 1 1 "" { "1" "2" } 0 +t "" 1 "Outcome 1" { 1, -1 } +t "" 2 "Outcome 2" { 2, -2 } +p "" 1 2 "" { "1" "2" } 0 +p "" 2 2 "" { "1" "2" } 0 +t "" 3 "Outcome 3" { 3, -3 } +p "" 1 3 "" { "1" "2" } 0 +p "" 2 3 "" { "1" "1" } 0 +t "" 4 "Outcome 4" { 4, -4 } +p "" 2 4 "" { "1" "1" } 0 +t "" 20 "Outcome 20" { 20, -20 } +t "" 21 "Outcome 21" { 21, -21 } +p "" 2 4 "" { "1" "1" } 0 +p "" 2 3 "" { "1" "1" } 0 +t "" 5 "Outcome 5" { 5, -5 } +t "" 22 "Outcome 22" { 22, -22 } +t "" 23 "Outcome 23" { 23, -23 } +p "" 2 2 "" { "1" "2" } 0 +p "" 2 5 "" { "1" "2" } 0 +p "" 1 4 "" { "1" "2" } 0 +p "" 2 6 "" { "1" "2" } 0 +t "" 6 "Outcome 6" { 6, -6 } +t "" 7 "Outcome 7" { 7, -7 } +t "" 8 "Outcome 8" { 8, -8 } +p "" 1 5 "" { "1" "2" } 0 +p "" 2 7 "" { "1" "2" } 0 +t "" 9 "Outcome 9" { 9, -9 } +t "" 10 "Outcome 10" { 10, -10 } +p "" 2 7 "" { "1" "2" } 0 +p "" 1 6 "" { "1" "2" } 0 +p "" 2 8 "" { "1" "2" } 0 +p "" 1 4 "" { "1" "2" } 0 +t "" 11 "Outcome 11" { 11, -11 } +t "" 12 "Outcome 12" { 12, -12 } +p "" 1 4 "" { "1" "2" } 0 +t "" 13 "Outcome 13" { 13, -13 } +t "" 14 "Outcome 14" { 14, -14 } +t "" 15 "Outcome 15" { 15, -15 } +p "" 1 6 "" { "1" "2" } 0 +t "" 16 "Outcome 16" { 16, -16 } +t "" 17 "Outcome 17" { 17, -17 } +p "" 1 7 "" { "1" "2" } 0 +t "" 18 "Outcome 18" { 18, -18 } +t "" 19 "Outcome 19" { 19, -19 } diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 4066939d0..86854bfa3 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -111,21 +111,23 @@ def test_infoset_plays(): ] ), ( - "subgames.efg", + "subgame-roots-finder-small-subgames-and-merges.efg", [ ("Player 1", 0, {None}), ("Player 1", 1, {None}), ("Player 1", 2, {("Player 1", 1, "1")}), - ("Player 1", 3, {("Player 1", 5, "1"), ("Player 1", 1, "2")}), + ("Player 1", 3, {("Player 1", 1, "2"), ("Player 1", 5, "1")}), ("Player 1", 4, {("Player 1", 1, "2")}), ("Player 1", 5, {("Player 1", 4, "2")}), ("Player 1", 6, {("Player 1", 1, "2")}), ("Player 2", 0, {None}), ("Player 2", 1, {("Player 2", 0, "2")}), - ("Player 2", 2, {("Player 2", 1, "1")}), - ("Player 2", 3, {("Player 2", 2, "1")}), - ("Player 2", 4, {("Player 2", 2, "2")}), + ("Player 2", 2, {("Player 2", 1, "2"), ("Player 2", 3, "1")}), + ("Player 2", 3, {("Player 2", 2, "1"), ("Player 2", 1, "2")}), + ("Player 2", 4, {("Player 2", 1, "1")}), ("Player 2", 5, {("Player 2", 4, "1")}), + ("Player 2", 6, {("Player 2", 4, "2")}), + ("Player 2", 7, {("Player 2", 6, "1")}), ] ), # An absent-minded driver game diff --git a/tests/test_node.py b/tests/test_node.py index 012e9ce0a..384edfdea 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -93,32 +93,6 @@ def test_is_successor_of(): game.root.is_successor_of(game.players[0]) -@pytest.mark.parametrize("game, expected_result", [ - # Games without Absent-Mindedness for which the legacy method is known to be correct. - (games.read_from_file("wichardt.efg"), {0}), - (games.read_from_file("e02.efg"), {0, 2, 4}), - (games.read_from_file("subgames.efg"), {0, 1, 4, 7, 11, 13, 34}), - - pytest.param( - games.read_from_file("AM-driver-subgame.efg"), - {0, 3}, # The correct set of subgame roots - marks=pytest.mark.xfail( - reason="Current method does not detect roots of proper subgames " - "that are members of AM-infosets." - ) - ), -]) -def test_legacy_is_subgame_root_set(game: gbt.Game, expected_result: set): - """ - Tests the legacy `node.is_subgame_root` against an expected set of nodes. - Includes both passing cases and games with Absent-Mindedness where it is expected to fail. - """ - list_nodes = list(game.nodes) - expected_roots = {list_nodes[i] for i in expected_result} - legacy_roots = {node for node in game.nodes if node.is_subgame_root} - assert legacy_roots == expected_roots - - def _get_path_of_action_labels(node: gbt.Node) -> list[str]: """ Computes the path of action labels from the root to the given node. @@ -163,22 +137,26 @@ def _get_path_of_action_labels(node: gbt.Node) -> list[str]: ] ), ( - "subgames.efg", + "subgame-roots-finder-small-subgames-and-merges.efg", [ ([], None), (["1"], None), (["2"], None), (["1", "2"], ("Player 2", 0, "2")), (["2", "1", "2"], ("Player 1", 1, "1")), + (["1", "2", "1", "2"], ("Player 2", 1, "2")), + (["1", "1", "2", "1", "2"], ("Player 2", 2, "1")), + (["2", "2", "1", "2"], ("Player 2", 1, "2")), + (["1", "2", "2", "1", "2"], ("Player 2", 3, "1")), (["2", "2"], ("Player 2", 0, "2")), (["1", "2", "2"], ("Player 2", 1, "1")), (["1", "1", "2", "2"], ("Player 1", 1, "2")), - (["1", "1", "1", "2", "2"], ("Player 2", 2, "1")), + (["1", "1", "1", "2", "2"], ("Player 2", 4, "1")), (["2", "1", "2", "2"], ("Player 1", 1, "2")), - (["1", "2", "1", "2", "2"], ("Player 2", 2, "2")), - (["2", "2", "1", "2", "2"], ("Player 2", 2, "2")), + (["1", "2", "1", "2", "2"], ("Player 2", 4, "2")), + (["2", "2", "1", "2", "2"], ("Player 2", 4, "2")), (["1", "2", "2", "1", "2", "2"], ("Player 1", 4, "2")), - (["1", "1", "2", "2", "1", "2", "2"], ("Player 2", 4, "1")), + (["1", "1", "2", "2", "1", "2", "2"], ("Player 2", 6, "1")), (["1", "1", "1", "2", "2", "1", "2", "2"], ("Player 1", 5, "1")), (["2", "1", "1", "2", "2", "1", "2", "2"], ("Player 1", 5, "1")), (["2", "2", "2", "1", "2", "2"], ("Player 1", 4, "2")), @@ -220,11 +198,59 @@ def test_node_own_prior_action_non_terminal(game_file, expected_node_data): assert actual_node_data == expected_node_data +# ============================================================================== +# Test Suite for the Subgame Root Checker +# ============================================================================== +@pytest.mark.parametrize("game, expected_paths_list", [ + # Empty game + (gbt.Game.new_tree(), [[]]), + + # --- Games without Absent-Mindedness. --- + # Perfect Information + (games.read_from_file("e02.efg"), [[], ["L"], ["L", "L"]]), + (games.Centipede.get_test_data(N=5, m0=2, m1=7)[0], + [["Push", "Push"], ["Push", "Push", "Push", "Push"], ["Push", "Push", "Push"], ["Push"], []]), + + # Perfect Recall + (games.read_from_file("binary_3_levels_generic_payoffs.efg"), [[]]), + + # No perfect recall + (games.read_from_file("wichardt.efg"), [[]]), + (games.read_from_file("subgame-roots-finder-one-merge.efg"), [[], ["1"]]), + (games.read_from_file("subgame-roots-finder-small-subgames-and-merges.efg"), + [["2"], ["1"], ["1", "2", "2"], ["2", "1", "2"], [], + ["1", "1", "1", "2", "2"], ["2", "2", "2"]]), + (games.read_from_file("subgame-roots-finder-multiple-merges.efg"), + [[], ["1", "1"], ["1"], ["1", "1", "1"]]), + + # --- Games with Absent-Mindedness. --- + (games.read_from_file("AM-subgames.efg"), [[], ["1", "1"], ["2"], ["2", "1"]]), + (games.read_from_file("noPR-action-AM-two-hops.efg"), [[], ["2", "1", "1"]]), +]) +def test_subgame_root_consistency(game: gbt.Game, expected_paths_list: list): + """ + Tests `game.subgame_root` and `node.is_subgame_root` for consistency and correctness + by comparing the paths of the identified root nodes against the expected paths. + """ + roots_from_property = {node for node in game.nodes if node.is_subgame_root} + + # For trivial games with no infosets, check the property-based roots + if len(game.infosets) == 0: + actual_paths = [_get_path_of_action_labels(node) for node in roots_from_property] + assert actual_paths == expected_paths_list + else: + roots_from_lookup = {game.subgame_root(infoset) for infoset in game.infosets} + assert roots_from_lookup == roots_from_property + + actual_paths = [_get_path_of_action_labels(node) for node in roots_from_lookup] + assert sorted(actual_paths) == sorted(expected_paths_list) + + @pytest.mark.parametrize("game_file, expected_unreachable_paths", [ # Games without absent-mindedness, where all nodes are reachable ("e02.efg", []), ("wichardt.efg", []), - ("subgames.efg", []), + ("subgame-roots-finder-small-subgames-and-merges.efg", []), # An absent-minded driver game with an unreachable terminal node (