diff --git a/ChangeLog b/ChangeLog index 879cf50d2..f495389e8 100644 --- a/ChangeLog +++ b/ChangeLog @@ -3,6 +3,7 @@ ## [16.7.0] - unreleased ### Added +- Implement `GameSubgameRep` (C++) and `Subgame` (Python), a first-class object representing a subgame. (#585) - Games can be materialised directly from OpenSpiel games if `pyspiel` is installed. (#917) ### Fixed diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 5841f02be..da49ec907 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -20,6 +20,7 @@ Representation of games Infoset Action Strategy + Subgame Creating, reading, and writing games @@ -109,6 +110,8 @@ Information about the game Game.infosets Game.nodes Game.contingencies + Game.subgames + Game.minimal_subgame .. autosummary:: :toctree: api/ @@ -151,6 +154,14 @@ Information about the game Node.plays Node.own_prior_action +.. autosummary:: + :toctree: api/ + + Subgame.game + Subgame.root + Subgame.parent + Subgame.children + .. autosummary:: :toctree: api/ diff --git a/src/games/game.h b/src/games/game.h index aa0ff5c53..5fa619e4a 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -58,6 +58,9 @@ using GamePlayer = GameObjectPtr; class GameNodeRep; using GameNode = GameObjectPtr; +class GameSubgameRep; +using GameSubgame = GameObjectPtr; + class GameRep; using Game = std::shared_ptr; @@ -608,6 +611,34 @@ inline void ValidateDistribution(const Array &p_probs, const bool p_norm } } +class GameSubgameRep : public std::enable_shared_from_this { + friend class GameTreeRep; + + bool m_valid{true}; + GameRep *m_game; + GameNodeRep *m_root; + std::weak_ptr m_parent; + std::vector> m_children; + std::vector> m_subgameDifference; + +public: + using SubgameCollection = ElementCollection; + using InfosetCollection = ElementCollection; + + GameSubgameRep(GameRep *p_game, GameNodeRep *p_root) : m_game(p_game), m_root(p_root) {} + ~GameSubgameRep() = default; + + bool IsValid() const { return m_valid; } + void Invalidate() { m_valid = false; } + + Game GetGame() const; + GameNode GetRoot() const { return m_root->shared_from_this(); } + + GameSubgame GetParent() const; + SubgameCollection GetChildren() const; + InfosetCollection GetSubgameDifference() const; +}; + enum class TraversalOrder { Preorder, Postorder }; class CartesianProductSpace { @@ -940,7 +971,9 @@ class GameRep : public std::enable_shared_from_this { return false; } /// Returns a list of all subgame roots in the game - virtual std::vector GetSubgames() const { throw UndefinedException(); } + virtual std::vector GetSubgames() const { throw UndefinedException(); } + /// Returns the smallest subgame containing the information set + virtual GameSubgame GetMinimalSubgame(const GameInfoset &) const { throw UndefinedException(); } //@} @@ -1252,6 +1285,8 @@ inline GamePlayerRep::Infosets GamePlayerRep::GetInfosets() const return Infosets(std::const_pointer_cast(shared_from_this()), &m_infosets); } +inline Game GameSubgameRep::GetGame() const { return m_game->shared_from_this(); } + //======================================================================= /// Factory function to create new game tree diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 85a79d94b..149968d3e 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -28,6 +28,7 @@ #include #include #include +#include #include #include "gambit.h" @@ -284,6 +285,28 @@ void GameTreeRep::Reveal(GameInfoset p_atInfoset, GamePlayer p_player) InvalidateTreeOrdering(); } +//======================================================================== +// class GameSubgameRep +//======================================================================== + +GameSubgame GameSubgameRep::GetParent() const +{ + auto p = m_parent.lock(); + return p; +} + +GameSubgameRep::SubgameCollection GameSubgameRep::GetChildren() const +{ + return SubgameCollection(std::const_pointer_cast(shared_from_this()), + &m_children); +} + +GameSubgameRep::InfosetCollection GameSubgameRep::GetSubgameDifference() const +{ + return InfosetCollection(std::const_pointer_cast(shared_from_this()), + &m_subgameDifference); +} + //======================================================================== // class GameNodeRep //======================================================================== @@ -374,18 +397,13 @@ bool GameNodeRep::IsSuccessorOf(GameNode p_node) const bool GameNodeRep::IsSubgameRoot() const { - // TODO: Currently O(S) per call where S = number of subgames. - // Will become O(1) when GameSubgameRep adds a back-pointer (like m_infoset). if (m_children.empty()) { return !GetParent(); } auto *tree_game = static_cast(m_game); - if (tree_game->m_subgames.empty()) { - tree_game->BuildSubgameRoots(); - } - - return contains(tree_game->m_subgames, const_cast(this)); + tree_game->BuildSubgameRoots(); + return tree_game->m_subgameData.m_subgameByRoot.count(const_cast(this)) > 0; } bool GameNodeRep::IsStrategyReachable() const @@ -858,6 +876,21 @@ bool GameTreeRep::IsAbsentMinded(const GameInfoset &p_infoset) const return contains(m_absentMindedInfosets, p_infoset.get()); } +GameSubgame GameTreeRep::GetMinimalSubgame(const GameInfoset &p_infoset) const +{ + if (p_infoset->GetGame().get() != this) { + throw MismatchException(); + } + BuildSubgameRoots(); + auto *n = p_infoset->m_members.front().get(); + auto it = m_subgameData.m_subgameByRoot.find(n); + while (it == m_subgameData.m_subgameByRoot.end()) { + n = n->m_parent; + it = m_subgameData.m_subgameByRoot.find(n); + } + return it->second; +} + //------------------------------------------------------------------------ // GameTreeRep: Managing the representation //------------------------------------------------------------------------ @@ -923,7 +956,7 @@ void GameTreeRep::ClearComputedValues() const m_ownPriorActionInfo = nullptr; const_cast(this)->m_unreachableNodes = nullptr; m_absentMindedInfosets.clear(); - m_subgames.clear(); + m_subgameData.Invalidate(); m_computedValues = false; } @@ -1129,7 +1162,11 @@ void GameTreeRep::BuildUnreachableNodes() const void GameTreeRep::BuildSubgameRoots() const { - if (!m_subgames.empty()) { + if (m_subgameData.m_valid) { + return; + } + if (m_root->IsTerminal()) { + m_subgameData.m_valid = true; return; } @@ -1242,20 +1279,72 @@ void GameTreeRep::BuildSubgameRoots() const SpanVisitor span_visitor{disc, hull}; WalkDFS(game, m_root, TraversalOrder::Postorder, span_visitor); - BridgeVisitor bridge_visitor{disc, hull, m_subgames}; + BridgeVisitor bridge_visitor{disc, hull, m_subgameData.m_subgamePostorder}; WalkDFS(game, m_root, TraversalOrder::Postorder, bridge_visitor); + + // Phase 3: Build subgame tree with subgame differences + struct SubgameVisitor { + const std::unordered_set &m_roots; + std::unordered_map> &m_cache; + GameTreeRep *m_game; + // Subgame roots on the current DFS path, innermost at back + std::vector m_stack; + std::unordered_set m_infosetVisited; + + DFSCallbackResult OnEnter(const GameNode &p_node, int) + { + if (p_node->IsTerminal()) { + return DFSCallbackResult::Continue; + } + GameNodeRep *node = p_node.get(); + if (contains(m_roots, node)) { + auto subgame = std::make_shared(m_game, node); + if (!m_stack.empty()) { + auto &parent_subgame = m_cache.at(m_stack.back()); + subgame->m_parent = parent_subgame; + parent_subgame->m_children.push_back(subgame); + } + m_cache.emplace(node, std::move(subgame)); + m_stack.push_back(node); + } + if (m_infosetVisited.insert(node->m_infoset).second) { + m_cache.at(m_stack.back()) + ->m_subgameDifference.emplace_back(node->m_infoset->shared_from_this()); + } + return DFSCallbackResult::Continue; + } + + DFSCallbackResult OnExit(const GameNode &p_node, int) + { + if (!m_stack.empty() && m_stack.back() == p_node.get()) { + m_stack.pop_back(); + } + return DFSCallbackResult::Continue; + } + + static DFSCallbackResult OnAction(GameNode, GameNode, int) + { + return DFSCallbackResult::Continue; + } + static void OnVisit(GameNode, int) {} + }; + + const std::unordered_set subgame_root_set( + m_subgameData.m_subgamePostorder.begin(), m_subgameData.m_subgamePostorder.end()); + + SubgameVisitor subgame_visitor{subgame_root_set, m_subgameData.m_subgameByRoot, + const_cast(this)}; + WalkDFS(game, m_root, TraversalOrder::Preorder, subgame_visitor); + m_subgameData.m_valid = true; } -std::vector GameTreeRep::GetSubgames() const +std::vector GameTreeRep::GetSubgames() const { - if (m_subgames.empty()) { - BuildSubgameRoots(); - } - - std::vector result; - result.reserve(m_subgames.size()); - for (auto *rep : m_subgames) { - result.emplace_back(rep->shared_from_this()); + BuildSubgameRoots(); + std::vector result; + result.reserve(m_subgameData.m_subgamePostorder.size()); + for (auto *rep : m_subgameData.m_subgamePostorder) { + result.emplace_back(m_subgameData.m_subgameByRoot.at(rep)); } return result; } diff --git a/src/games/gametree.h b/src/games/gametree.h index 2a87cf56a..c94404f89 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -24,6 +24,7 @@ #define GAMETREE_H #include "gameexpl.h" +#include namespace Gambit { @@ -47,7 +48,25 @@ class GameTreeRep final : public GameExplicitRep { mutable std::shared_ptr m_ownPriorActionInfo; mutable std::unique_ptr> m_unreachableNodes; mutable std::set m_absentMindedInfosets; - mutable std::vector m_subgames; + // The subgames of the game, held in two synchronized forms: + // m_subgamePostorder for iteration (children before parents), + // m_subgameByRoot for O(1) lookup by root node and ownership of the GameSubgameRep objects. + struct SubgameData { + std::vector m_subgamePostorder; + std::unordered_map> m_subgameByRoot; + bool m_valid{false}; + + void Invalidate() + { + for (const auto &[node, subgame] : m_subgameByRoot) { + subgame->Invalidate(); + } + m_subgamePostorder.clear(); + m_subgameByRoot.clear(); + m_valid = false; + } + }; + mutable SubgameData m_subgameData; /// @name Private auxiliary functions //@{ @@ -100,7 +119,8 @@ class GameTreeRep final : 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; - std::vector GetSubgames() const override; + std::vector GetSubgames() const override; + GameSubgame GetMinimalSubgame(const GameInfoset &) const override; //@} /// @name Players @@ -185,6 +205,7 @@ class GameTreeRep final : public GameExplicitRep { std::vector BuildConsistentPlaysRecursiveImpl(GameNodeRep *node); void BuildOwnPriorActions() const; void BuildUnreachableNodes() const; + void EnsureSubgames() const; void BuildSubgameRoots() const; }; diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 95ae67295..e2a9b9203 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -81,6 +81,11 @@ cdef extern from "games/game.h": cdef cppclass c_GameStrategy "GameObjectPtr": c_GameStrategyRep *deref "get"() except +RuntimeError + cdef cppclass c_GameSubgame "GameObjectPtr": + bool operator ==(c_GameSubgame) except + + bool operator !=(c_GameSubgame) except + + c_GameSubgameRep *deref "get"() except +RuntimeError + cdef cppclass c_PureStrategyProfile "PureStrategyProfile": shared_ptr[c_PureStrategyProfileRep] deref "operator->"() except + c_PureStrategyProfile(c_PureStrategyProfile) except + @@ -219,6 +224,33 @@ cdef extern from "games/game.h": c_GameAction GetPriorAction() except + c_GameAction GetOwnPriorAction() except + + cdef cppclass c_GameSubgameRep "GameSubgameRep": + cppclass SubgameCollection: + cppclass iterator: + c_GameSubgame operator *() + iterator operator++() + bint operator ==(iterator) + bint operator !=(iterator) + int size() except + + iterator begin() except + + iterator end() except + + + cppclass InfosetCollection: + cppclass iterator: + c_GameInfoset operator *() + iterator operator++() + bint operator ==(iterator) + bint operator !=(iterator) + int size() except + + iterator begin() except + + iterator end() except + + + c_Game GetGame() except + + c_GameNode GetRoot() except + + c_GameSubgame GetParent() except + + SubgameCollection GetChildren() except + + InfosetCollection GetSubgameDifference() except + + cdef cppclass c_GameRep "GameRep": cppclass Players: cppclass iterator: @@ -316,6 +348,9 @@ cdef extern from "games/game.h": c_PureStrategyProfile NewPureStrategyProfile() # except + doesn't compile c_MixedStrategyProfile[T] NewMixedStrategyProfile[T](T) # except + doesn't compile + c_GameSubgame GetMinimalSubgame(c_GameInfoset) except + + stdvector[c_GameSubgame] GetSubgames() except + + c_Game NewTree() except + c_Game NewTable(stdvector[int]) except + diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 7bee9a447..811d70489 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -211,6 +211,38 @@ class GameNodes: yield Node.wrap(node) +@cython.cclass +class GameSubgames: + """Represents the set of subgames in a game.""" + game = cython.declare(c_Game) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create GameSubgames outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(game: c_Game) -> GameSubgames: + obj: GameSubgames = GameSubgames.__new__(GameSubgames) + obj.game = game + return obj + + def __repr__(self) -> str: + return f"GameSubgames(game={Game.wrap(self.game)})" + + def __len__(self) -> int: + """The number of subgames in the game.""" + if not self.game.deref().IsTree(): + return 0 + return self.game.deref().GetSubgames().size() + + def __iter__(self) -> typing.Iterator[Subgame]: + """Iterate over the game subgames in postorder.""" + if not self.game.deref().IsTree(): + return + for subgame in self.game.deref().GetSubgames(): + yield Subgame.wrap(subgame) + + @cython.cclass class GameOutcomes: """Represents the set of outcomes in a game.""" @@ -806,6 +838,57 @@ class Game: """ return rat_to_py(self.game.deref().GetMaxPayoff()) + @property + def subgames(self) -> GameSubgames: + """The set of subgames in the game. + + Iteration over this property yields the subgames in postorder + (children before parents). + + .. versionadded:: 16.7.0 + + Raises + ------ + UndefinedOperationError + If the game does not have a tree representation. + """ + if not self.is_tree: + raise UndefinedOperationError( + "Operation only defined for games with a tree representation" + ) + return GameSubgames.wrap(self.game) + + def minimal_subgame(self, infoset: typing.Union[Infoset, str]) -> Subgame: + """Returns the smallest subgame containing `infoset`. + + Parameters + ---------- + infoset : Infoset or str + The information set to query. + + Returns + ------- + Subgame + The smallest subgame containing `infoset`. + + .. versionadded:: 16.7.0 + + Raises + ------ + UndefinedOperationError + If the game does not have a tree representation. + MismatchError + If `infoset` is from a different game. + """ + if not self.is_tree: + raise UndefinedOperationError( + "Operation only defined for games with a tree representation" + ) + resolved_infoset = self._resolve_infoset(infoset, "minimal_subgame") + return Subgame.wrap( + self.game.deref().GetMinimalSubgame(cython.cast(Infoset, resolved_infoset).infoset) + ) + def set_chance_probs(self, infoset: Infoset | str, probs: typing.Sequence): """Set the action probabilities at chance information set `infoset`. diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 3d46c4ae3..0f082d3b1 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -48,7 +48,7 @@ class NodeChildren: def __getitem__(self, action: int | str | Action) -> Node: """Returns the successor node which is reached after 'action' is played. - .. versionchanged: 16.5.0 + .. versionchanged:: 16.5.0 Previously indexing by string searched the labels of the child nodes, rather than referring to actions. This implements the more natural interpretation that strings refer to action labels. @@ -259,3 +259,69 @@ class Node: """Returns a list of all terminal `Node` objects consistent with it. """ return [Node.wrap(n) for n in self.node.deref().GetGame().deref().GetPlays(self.node)] + + +@cython.cclass +class Subgame: + """A subgame in a ``Game``. + + .. versionadded:: 16.7.0 + """ + subgame = cython.declare(c_GameSubgame) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create a Subgame outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(subgame: c_GameSubgame) -> Subgame: + obj: Subgame = Subgame.__new__(Subgame) + obj.subgame = subgame + return obj + + def __repr__(self) -> str: + return f"Subgame(root={self.root})" + + def __eq__(self, other: typing.Any) -> bool: + return ( + isinstance(other, Subgame) and + self.subgame.deref() == cython.cast(Subgame, other).subgame.deref() + ) + + def __hash__(self) -> int: + return cython.cast(cython.long, self.subgame.deref()) + + @property + def game(self) -> Game: + """Gets the ``Game`` to which the subgame belongs. + + .. versionadded:: 16.7.0 + """ + return Game.wrap(self.subgame.deref().GetGame()) + + @property + def root(self) -> Node: + """Returns the root node of the subgame. + + .. versionadded:: 16.7.0 + """ + return Node.wrap(self.subgame.deref().GetRoot()) + + @property + def parent(self) -> typing.Optional[Subgame]: + """Returns the parent subgame, or None if this is the root subgame. + + .. versionadded:: 16.7.0 + """ + parent: c_GameSubgame = self.subgame.deref().GetParent() + if parent != cython.cast(c_GameSubgame, NULL): + return Subgame.wrap(parent) + return None + + @property + def children(self) -> list[Subgame]: + """Returns the immediate child subgames of this subgame. + + .. versionadded:: 16.7.0 + """ + return [Subgame.wrap(child) for child in self.subgame.deref().GetChildren()] diff --git a/tests/test_catalog.py b/tests/test_catalog.py index f019d4a01..f807fdde2 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -182,7 +182,7 @@ def test_catalog_games_include_descriptions(): # --------------------------------------------------------------------------- _MOCK_NFG = gbt.Game.new_table([2, 2]).to_nfg() -_MOCK_EFG = gbt.catalog.load("bagwell1995").to_efg() +_MOCK_EFG = gbt.catalog.load("journals/geb/bagwell1995").to_efg() def _setup_pyspiel_mock( diff --git a/tests/test_games/subgame-8-roots.efg b/tests/test_games/subgame-8-roots.efg new file mode 100644 index 000000000..5dba31001 --- /dev/null +++ b/tests/test_games/subgame-8-roots.efg @@ -0,0 +1,48 @@ +EFG 2 R "Game with 8 subgames" { "Player 1" "Player 2" } +"" + +p "" 1 1 "" { "L" "R" } 0 +p "" 2 1 "" { "L" "R" } 0 +p "" 1 2 "" { "L" "R" } 0 +p "" 1 3 "" { "L" "R" } 0 +p "" 2 2 "" { "L" "R" } 0 +p "" 1 4 "" { "L" "R" } 0 +p "" 2 3 "" { "L" "R" } 0 +t "" 1 "Outcome 1" { 1, -1 } +p "" 2 4 "" { "L" "R" } 0 +t "" 2 "Outcome 2" { 2, -2 } +t "" 3 "Outcome 3" { 3, -3 } +p "" 2 4 "" { "L" "R" } 0 +p "" 2 3 "" { "L" "R" } 0 +t "" 4 "Outcome 4" { 4, -4 } +t "" 5 "Outcome 5" { 5, -5 } +t "" 6 "Outcome 6" { 6, -6 } +p "" 1 5 "" { "L" "R" } 0 +p "" 1 6 "" { "L" "R" } 0 +t "" 7 "Outcome 7" { 7, -7 } +t "" 8 "Outcome 8" { 8, -8 } +p "" 1 6 "" { "L" "R" } 0 +t "" 9 "Outcome 9" { 9, -9 } +t "" 10 "Outcome 10" { 10, -10 } +t "" 11 "Outcome 11" { 11, -11 } +p "" 1 3 "" { "L" "R" } 0 +t "" 12 "Outcome 12" { 12, -12 } +t "" 13 "Outcome 13" { 13, -13 } +p "" 1 7 "" { "L" "R" } 0 +t "" 14 "Outcome 14" { 14, -14 } +t "" 15 "Outcome 15" { 15, -15 } +p "" 2 5 "" { "L" "R" } 0 +p "" 2 6 "" { "L" "R" } 0 +t "" 16 "Outcome 16" { 16, -16 } +p "" 1 8 "" { "L" "R" } 0 +t "" 17 "Outcome 17" { 17, -17 } +p "" 1 9 "" { "L" "R" } 0 +t "" 18 "Outcome 18" { 18, -18 } +t "" 19 "Outcome 19" { 19, -19 } +p "" 2 7 "" { "L" "R" } 0 +p "" 1 10 "" { "L" "R" } 0 +p "" 1 9 "" { "L" "R" } 0 +t "" 20 "Outcome 20" { 20, -20 } +t "" 21 "Outcome 21" { 21, -21 } +t "" 22 "Outcome 22" { 22, -22 } +t "" 23 "Outcome 23" { 23, -23 } diff --git a/tests/test_node.py b/tests/test_node.py index fb3011dd6..ce479afab 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -97,7 +97,7 @@ def test_is_successor_of(): def _get_path_of_action_labels(node: gbt.Node) -> list[str]: """ - Computes the path of action labels from the root to the given node. + Computes the path of action labels from a given node to the root. Returns a list of strings. """ if not isinstance(node, gbt.Node): @@ -229,6 +229,152 @@ def test_subgame_roots(test_case: SubgameRootsTestCase): assert sorted(actual_paths) == sorted(test_case.expected_paths) +# ============================================================================ +# Subgame tree / GameSubgame +# ============================================================================ +@dataclasses.dataclass +class SubgameStructureTestCase: + """Expected subgame structure of a game. + + `roots` lists each subgame root as a node->root action-label path, in the + postorder `game.subgames` is expected to produce (children before parents). + + `parents` maps each subgame-root path to its expected parent path + (or None for the root subgame). + + `children` maps each subgame-root path to the set of its child subgame paths. + + `differences` maps each subgame-root path to the set of + (player_label, infoset_number) keys in that subgame's difference --- + the information sets belonging to the subgame but not to any child subgame. + """ + factory: typing.Callable[[], gbt.Game] + roots: list[list[str]] + parents: dict[tuple[str, ...], tuple[str, ...] | None] + children: dict[tuple[str, ...], set[tuple[str, ...]]] + differences: dict[tuple[str, ...], set[tuple[str, int]]] + + +SUBGAME_STRUCTURE_CASES = [ + # ------------------------------------------------------------------------ + # EF game with the only subgame + # ------------------------------------------------------------------------ + pytest.param( + SubgameStructureTestCase( + factory=functools.partial(games.read_from_file, "wichardt.efg"), + roots=[[]], + parents={(): None}, + children={(): set()}, + differences={(): {("Player 1", 0), ("Player 1", 1), ("Player 2", 0)}}, + ), + id="wichardt_no_nontrivial_subgames", + ), + # ------------------------------------------------------------------------ + # Tree with eight subgames + # ------------------------------------------------------------------------ + pytest.param( + SubgameStructureTestCase( + factory=functools.partial(games.read_from_file, "subgame-8-roots.efg"), + roots=[ + ["L", "L", "L", "L", "L"], + ["R", "L", "L", "L", "L"], + ["L", "L", "L", "L"], + ["L", "L"], + ["R", "L"], + ["L"], + ["R"], + [], + ], + parents={ + ("L", "L", "L", "L", "L"): ("L", "L", "L", "L"), + ("R", "L", "L", "L", "L"): ("L", "L", "L", "L"), + ("L", "L", "L", "L"): ("L", "L"), + ("L", "L"): ("L",), + ("R", "L"): ("L",), + ("L",): (), + ("R",): (), + (): None, + }, + children={ + ("L", "L", "L", "L", "L"): set(), + ("R", "L", "L", "L", "L"): set(), + ("L", "L", "L", "L"): {("L", "L", "L", "L", "L"), + ("R", "L", "L", "L", "L")}, + ("L", "L"): {("L", "L", "L", "L")}, + ("R", "L"): set(), + ("L",): {("L", "L"), ("R", "L")}, + ("R",): set(), + (): {("L",), ("R",)}, + }, + differences={ + ("L", "L", "L", "L", "L"): { + ("Player 1", 3), ("Player 2", 2), ("Player 2", 3), + }, + ("R", "L", "L", "L", "L"): {("Player 1", 4), ("Player 1", 5)}, + ("L", "L", "L", "L"): {("Player 2", 1)}, + ("L", "L"): {("Player 1", 1), ("Player 1", 2)}, + ("R", "L"): {("Player 1", 6)}, + ("L",): {("Player 2", 0)}, + ("R",): { + ("Player 1", 7), ("Player 1", 8), ("Player 1", 9), + ("Player 2", 4), ("Player 2", 5), ("Player 2", 6), + }, + (): {("Player 1", 0)}, + }, + ), + id="eight_subgames", + ), +] + + +@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) +def test_subgames_postorder_sequence(test_case: SubgameStructureTestCase): + """`game.subgames` produces the expected postorder sequence of roots.""" + game = test_case.factory() + actual = [_get_path_of_action_labels(sg.root) for sg in game.subgames] + assert actual == test_case.roots + + +@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) +def test_subgame_parent_links(test_case: SubgameStructureTestCase): + """Each subgame's `parent` matches the expected parent path.""" + game = test_case.factory() + for sg in game.subgames: + path = tuple(_get_path_of_action_labels(sg.root)) + parent_path = ( + None if sg.parent is None + else tuple(_get_path_of_action_labels(sg.parent.root)) + ) + assert parent_path == test_case.parents[path] + + +@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) +def test_subgame_children(test_case: SubgameStructureTestCase): + """Each subgame's `children` match the expected set of child paths.""" + game = test_case.factory() + actual = { + tuple(_get_path_of_action_labels(sg.root)): + {tuple(_get_path_of_action_labels(c.root)) for c in sg.children} + for sg in game.subgames + } + assert actual == test_case.children + + +@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) +def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): + """`game.minimal_subgame(infoset)` returns the smallest subgame containing the infoset.""" + game = test_case.factory() + expected_path_for_key = { + key: path + for path, keys in test_case.differences.items() + for key in keys + } + for infoset in game.infosets: + key = (infoset.player.label, infoset.number) + actual_path = tuple(_get_path_of_action_labels(game.minimal_subgame(infoset).root)) + assert actual_path == expected_path_for_key[key] + + @pytest.mark.parametrize("game_file, expected_node_data", [ ( "binary_3_levels_generic_payoffs.efg",