From cf795053abc02291bbfcafe6e177225723934a72 Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 20 Mar 2026 11:15:45 +0000 Subject: [PATCH 01/17] Add infoset-to-subgame-root map as Phase 3 of BuildSubgameRoots() --- src/games/gametree.cc | 14 ++++++++++++++ src/games/gametree.h | 1 + 2 files changed, 15 insertions(+) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index e82d52f08..06cf8a990 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -28,6 +28,7 @@ #include #include #include +#include #include #include "gambit.h" @@ -924,6 +925,7 @@ void GameTreeRep::ClearComputedValues() const const_cast(this)->m_unreachableNodes = nullptr; m_absentMindedInfosets.clear(); m_subgames.clear(); + m_infosetSubgameRoot.clear(); m_computedValues = false; } @@ -1227,6 +1229,18 @@ void GameTreeRep::BuildSubgameRoots() const BridgeVisitor bridge_visitor{disc, hull, m_subgames}; WalkDFS(game, m_root, TraversalOrder::Postorder, bridge_visitor); + + // Phase 3: Map each infoset to its nearest subgame root ancestor + std::unordered_set subgameRootSet(m_subgames.begin(), m_subgames.end()); + for (const auto &player : GetPlayersWithChance()) { + for (const auto &infoset : player->m_infosets) { + auto *n = infoset->m_members.front().get(); + while (n && !contains(subgameRootSet, n)) { + n = n->m_parent; + } + m_infosetSubgameRoot[infoset.get()] = n; + } + } } std::vector GameTreeRep::GetSubgames() const diff --git a/src/games/gametree.h b/src/games/gametree.h index 2a87cf56a..c7371282e 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -48,6 +48,7 @@ class GameTreeRep final : public GameExplicitRep { mutable std::unique_ptr> m_unreachableNodes; mutable std::set m_absentMindedInfosets; mutable std::vector m_subgames; + mutable std::map m_infosetSubgameRoot; /// @name Private auxiliary functions //@{ From d1f6977c7e9cbf1ba17bd08334756d96c1e7d5ba Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 20 Mar 2026 14:35:46 +0000 Subject: [PATCH 02/17] Add GameSubgameRep class with parent-child relationships --- src/games/game.h | 38 ++++++++++++++++++- src/games/gametree.cc | 85 ++++++++++++++++++++++++++++++++++++++----- src/games/gametree.h | 8 +++- 3 files changed, 119 insertions(+), 12 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index aa0ff5c53..df034facb 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,10 @@ 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(); } + + virtual GameSubgame GetRootSubgame() const { throw UndefinedException(); } + virtual GameNode GetSubgameRoot(const GameInfoset &) const { throw UndefinedException(); } //@} @@ -1252,6 +1286,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 06cf8a990..b67f39354 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -285,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 ? p : nullptr; +} + +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 //======================================================================== @@ -859,6 +881,29 @@ bool GameTreeRep::IsAbsentMinded(const GameInfoset &p_infoset) const return contains(m_absentMindedInfosets, p_infoset.get()); } +GameSubgame GameTreeRep::GetRootSubgame() const +{ + if (m_subgameCache.empty()) { + BuildSubgameRoots(); + } + return m_subgameCache.at(m_root.get()); +} + +GameNode GameTreeRep::GetSubgameRoot(const GameInfoset &p_infoset) const +{ + if (p_infoset->GetGame().get() != this) { + throw MismatchException(); + } + if (m_subgameCache.empty()) { + BuildSubgameRoots(); + } + auto *n = p_infoset->m_members.front().get(); + while (n && m_subgameCache.find(n) == m_subgameCache.end()) { + n = n->m_parent; + } + return n->shared_from_this(); +} + //------------------------------------------------------------------------ // GameTreeRep: Managing the representation //------------------------------------------------------------------------ @@ -925,7 +970,10 @@ void GameTreeRep::ClearComputedValues() const const_cast(this)->m_unreachableNodes = nullptr; m_absentMindedInfosets.clear(); m_subgames.clear(); - m_infosetSubgameRoot.clear(); + for (const auto &[node, subgame] : m_subgameCache) { + subgame->Invalidate(); + } + m_subgameCache.clear(); m_computedValues = false; } @@ -1230,29 +1278,48 @@ void GameTreeRep::BuildSubgameRoots() const BridgeVisitor bridge_visitor{disc, hull, m_subgames}; WalkDFS(game, m_root, TraversalOrder::Postorder, bridge_visitor); - // Phase 3: Map each infoset to its nearest subgame root ancestor + // Phase 3: Build GameSubgameRep objects with subgame differences std::unordered_set subgameRootSet(m_subgames.begin(), m_subgames.end()); + for (const auto &player : GetPlayersWithChance()) { for (const auto &infoset : player->m_infosets) { auto *n = infoset->m_members.front().get(); while (n && !contains(subgameRootSet, n)) { n = n->m_parent; } - m_infosetSubgameRoot[infoset.get()] = n; + auto [it, inserted] = m_subgameCache.try_emplace(n, nullptr); + if (inserted) { + it->second = std::make_shared(const_cast(this), n); + } + it->second->m_subgameDifference.push_back(infoset); + } + } + + // Phase 4: Establish parent-child relationships + for (auto *sroot : m_subgames) { + if (sroot == m_root.get()) { + continue; + } + auto *parent_node = sroot->m_parent; + while (parent_node && !contains(subgameRootSet, parent_node)) { + parent_node = parent_node->m_parent; + } + if (parent_node) { + m_subgameCache.at(sroot)->m_parent = m_subgameCache.at(parent_node); + m_subgameCache.at(parent_node)->m_children.push_back(m_subgameCache.at(sroot)); } } } -std::vector GameTreeRep::GetSubgames() const +std::vector GameTreeRep::GetSubgames() const { - if (m_subgames.empty()) { + if (m_subgameCache.empty()) { BuildSubgameRoots(); } - - std::vector result; - result.reserve(m_subgames.size()); + std::vector result; + result.reserve(m_subgameCache.size()); for (auto *rep : m_subgames) { - result.emplace_back(rep->shared_from_this()); + result.emplace_back(m_subgameCache.at(rep)); } return result; } diff --git a/src/games/gametree.h b/src/games/gametree.h index c7371282e..38f2614ff 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -24,6 +24,7 @@ #define GAMETREE_H #include "gameexpl.h" +#include namespace Gambit { @@ -31,6 +32,7 @@ class GameTreeRep final : public GameExplicitRep { friend class GameNodeRep; friend class GameInfosetRep; friend class GameActionRep; + friend class GameSubgameRep; struct OwnPriorActionInfo { std::map node_map; @@ -48,7 +50,7 @@ class GameTreeRep final : public GameExplicitRep { mutable std::unique_ptr> m_unreachableNodes; mutable std::set m_absentMindedInfosets; mutable std::vector m_subgames; - mutable std::map m_infosetSubgameRoot; + mutable std::unordered_map> m_subgameCache; /// @name Private auxiliary functions //@{ @@ -101,7 +103,9 @@ 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 GetRootSubgame() const override; + GameNode GetSubgameRoot(const GameInfoset &) const override; //@} /// @name Players From a361d30f96068290e39a36631b826a8446479001 Mon Sep 17 00:00:00 2001 From: drdkad Date: Sat, 21 Mar 2026 12:21:15 +0000 Subject: [PATCH 03/17] Build subgame tree and subgame differences in one pass --- src/games/gametree.cc | 71 ++++++++++++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index b67f39354..3426249dd 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -1278,37 +1278,58 @@ void GameTreeRep::BuildSubgameRoots() const BridgeVisitor bridge_visitor{disc, hull, m_subgames}; WalkDFS(game, m_root, TraversalOrder::Postorder, bridge_visitor); - // Phase 3: Build GameSubgameRep objects with subgame differences - std::unordered_set subgameRootSet(m_subgames.begin(), m_subgames.end()); - - for (const auto &player : GetPlayersWithChance()) { - for (const auto &infoset : player->m_infosets) { - auto *n = infoset->m_members.front().get(); - while (n && !contains(subgameRootSet, n)) { - n = n->m_parent; + // 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_infoset_visited; + + 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); } - auto [it, inserted] = m_subgameCache.try_emplace(n, nullptr); - if (inserted) { - it->second = std::make_shared(const_cast(this), n); + if (m_infoset_visited.insert(node->m_infoset).second) { + m_cache.at(m_stack.back()) + ->m_subgameDifference.emplace_back(node->m_infoset->shared_from_this()); } - it->second->m_subgameDifference.push_back(infoset); + return DFSCallbackResult::Continue; } - } - // Phase 4: Establish parent-child relationships - for (auto *sroot : m_subgames) { - if (sroot == m_root.get()) { - continue; - } - auto *parent_node = sroot->m_parent; - while (parent_node && !contains(subgameRootSet, parent_node)) { - parent_node = parent_node->m_parent; + DFSCallbackResult OnExit(const GameNode &p_node, int) + { + if (!m_stack.empty() && m_stack.back() == p_node.get()) { + m_stack.pop_back(); + } + return DFSCallbackResult::Continue; } - if (parent_node) { - m_subgameCache.at(sroot)->m_parent = m_subgameCache.at(parent_node); - m_subgameCache.at(parent_node)->m_children.push_back(m_subgameCache.at(sroot)); + + static DFSCallbackResult OnAction(GameNode, GameNode, int) + { + return DFSCallbackResult::Continue; } - } + static void OnVisit(GameNode, int) {} + }; + + const std::unordered_set subgame_root_set(m_subgames.begin(), m_subgames.end()); + + SubgameVisitor subgame_visitor{subgame_root_set, m_subgameCache, + const_cast(this)}; + WalkDFS(game, m_root, TraversalOrder::Preorder, subgame_visitor); } std::vector GameTreeRep::GetSubgames() const From 72fd153512e2f7b8d9cb038e4db5c532c66cac04 Mon Sep 17 00:00:00 2001 From: drdkad Date: Sat, 21 Mar 2026 12:23:12 +0000 Subject: [PATCH 04/17] Expose GameSubgameRep to Python --- src/pygambit/gambit.pxd | 36 ++++++++++++ src/pygambit/game.pxi | 118 ++++++++++++++++++++++++++++++++++++++++ src/pygambit/node.pxi | 63 +++++++++++++++++++++ 3 files changed, 217 insertions(+) diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 95ae67295..ac29125c9 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,10 @@ 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 GetRootSubgame() except + + c_GameNode GetSubgameRoot(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..1077bc9ae 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,92 @@ 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.5.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) + + @property + def root_subgame(self) -> Subgame: + """The root subgame of the game (the subgame containing the entire game tree). + + .. versionadded:: 16.5.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 Subgame.wrap(self.game.deref().GetRootSubgame()) + + @property + def terminal_subgames(self) -> list[Subgame]: + """The terminal subgames (subgames with no child subgames). + + .. versionadded:: 16.5.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 [sg for sg in self.subgames + if cython.cast(Subgame, sg).subgame.deref().GetChildren().size() == 0] + + def subgame_root(self, infoset: typing.Union[Infoset, str]) -> Node: + """Returns the root node of the smallest subgame containing `infoset`. + + Parameters + ---------- + infoset : Infoset or str + The information set to query. + + Returns + ------- + Node + The root node of the smallest containing subgame. + + .. versionadded:: 16.5.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, "subgame_root") + return Node.wrap( + self.game.deref().GetSubgameRoot(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..a945021bc 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -259,3 +259,66 @@ 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.5.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.""" + return Game.wrap(self.subgame.deref().GetGame()) + + @property + def root(self) -> Node: + """Returns the root node of the subgame.""" + 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.""" + 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.""" + return [Subgame.wrap(child) for child in self.subgame.deref().GetChildren()] + + @property + def difference(self) -> list[Infoset]: + """Returns the information sets in the subgame difference. + + The subgame difference consists of information sets that belong + to this subgame but not to any of its child subgames. + """ + return [Infoset.wrap(infoset) for infoset in self.subgame.deref().GetSubgameDifference()] From c083f6448d45d1756be001b8f146b70b2f4f86eb Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 1 Jun 2026 08:06:14 +0100 Subject: [PATCH 05/17] =?UTF-8?q?Expose=20terminal=20subgames=20via=20GetT?= =?UTF-8?q?erminalSubgames(),=20returning=20subgames=20with=20no=20child?= =?UTF-8?q?=20subgames;=20add=C2=A0Game.terminal=5Fsubgames=20property?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/games/game.h | 2 ++ src/games/gametree.cc | 15 +++++++++++++++ src/pygambit/gambit.pxd | 1 + src/pygambit/game.pxi | 3 +-- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index df034facb..8b961e8a6 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -975,6 +975,8 @@ class GameRep : public std::enable_shared_from_this { virtual GameSubgame GetRootSubgame() const { throw UndefinedException(); } virtual GameNode GetSubgameRoot(const GameInfoset &) const { throw UndefinedException(); } + /// Returns the terminal subgames (subgames with no child subgames) + virtual std::vector GetTerminalSubgames() const { throw UndefinedException(); } //@} diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 3426249dd..8e3a87d7c 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -1345,6 +1345,21 @@ std::vector GameTreeRep::GetSubgames() const return result; } +std::vector GameTreeRep::GetTerminalSubgames() const +{ + if (m_subgameCache.empty()) { + BuildSubgameRoots(); + } + std::vector result; + for (auto *rep : m_subgames) { + const auto &subgame = m_subgameCache.at(rep); + if (subgame->GetChildren().size() == 0) { + result.emplace_back(subgame); + } + } + return result; +} + //------------------------------------------------------------------------ // GameTreeRep: Writing data files //------------------------------------------------------------------------ diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index ac29125c9..4aae9fb8b 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -351,6 +351,7 @@ cdef extern from "games/game.h": c_GameSubgame GetRootSubgame() except + c_GameNode GetSubgameRoot(c_GameInfoset) except + stdvector[c_GameSubgame] GetSubgames() except + + stdvector[c_GameSubgame] GetTerminalSubgames() except + c_Game NewTree() except + c_Game NewTable(stdvector[int]) except + diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 1077bc9ae..7200c38f5 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -890,8 +890,7 @@ class Game: raise UndefinedOperationError( "Operation only defined for games with a tree representation" ) - return [sg for sg in self.subgames - if cython.cast(Subgame, sg).subgame.deref().GetChildren().size() == 0] + return [Subgame.wrap(sg) for sg in self.game.deref().GetTerminalSubgames()] def subgame_root(self, infoset: typing.Union[Infoset, str]) -> Node: """Returns the root node of the smallest subgame containing `infoset`. From 8ca2a0f0a8f4ef7cc76ebf052e8ebb5e20e4d51b Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 1 Jun 2026 08:16:56 +0100 Subject: [PATCH 06/17] Make IsSubgameRoot() O(1) using the subgame cache Replace the linear contains() scan over m_subgames with a hash lookup of m_subgameCache --- src/games/gametree.cc | 6 ++---- src/games/gametree.h | 1 + 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 8e3a87d7c..68901778d 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -397,18 +397,16 @@ 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()) { + if (tree_game->m_subgameCache.empty()) { tree_game->BuildSubgameRoots(); } - return contains(tree_game->m_subgames, const_cast(this)); + return tree_game->m_subgameCache.count(const_cast(this)) > 0; } bool GameNodeRep::IsStrategyReachable() const diff --git a/src/games/gametree.h b/src/games/gametree.h index 38f2614ff..fe2a75b43 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -106,6 +106,7 @@ class GameTreeRep final : public GameExplicitRep { std::vector GetSubgames() const override; GameSubgame GetRootSubgame() const override; GameNode GetSubgameRoot(const GameInfoset &) const override; + std::vector GetTerminalSubgames() const override; //@} /// @name Players From d108dbe980e096ca78c548a774dd9c3e9a9e3cc8 Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 1 Jun 2026 10:49:45 +0100 Subject: [PATCH 07/17] add a new game to test the child-parent relationships --- tests/test_games/subgame-8-roots.efg | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/test_games/subgame-8-roots.efg 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 } From f75825b6dd1c69dcb381e356a140bb9f5e6cb277 Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 1 Jun 2026 21:36:01 +0100 Subject: [PATCH 08/17] remove an unnecessary friend declaration --- src/games/gametree.h | 1 - 1 file changed, 1 deletion(-) diff --git a/src/games/gametree.h b/src/games/gametree.h index fe2a75b43..d56cb07c9 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -32,7 +32,6 @@ class GameTreeRep final : public GameExplicitRep { friend class GameNodeRep; friend class GameInfosetRep; friend class GameActionRep; - friend class GameSubgameRep; struct OwnPriorActionInfo { std::map node_map; From 88380f43c33f17e8e934cec4d8b203176cdf90ce Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 1 Jun 2026 21:42:57 +0100 Subject: [PATCH 09/17] add tests --- tests/test_node.py | 180 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/tests/test_node.py b/tests/test_node.py index 8478c5b5a..c582ef660 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): @@ -215,6 +215,184 @@ 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. + + `terminal_subgames` lists the subgame roots with no children, in the postorder. + + `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, ...]]] + terminal_subgames: list[list[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()}, + terminal_subgames=[[]], + 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",)}, + }, + terminal_subgames=[ + ["L", "L", "L", "L", "L"], + ["R", "L", "L", "L", "L"], + ["R", "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_root_subgame_is_game_root(test_case: SubgameStructureTestCase): + """`game.root_subgame` is rooted at `game.root` and has no parent.""" + game = test_case.factory() + assert game.root_subgame.root == game.root + assert game.root_subgame.parent is None + + +@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_terminal_subgames(test_case: SubgameStructureTestCase): + """`game.terminal_subgames` matches the expected terminal subgames, in + postorder. + """ + game = test_case.factory() + actual = [_get_path_of_action_labels(sg.root) for sg in game.terminal_subgames] + assert actual == test_case.terminal_subgames + + +@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) +def test_subgame_differences(test_case: SubgameStructureTestCase): + """Each subgame's difference matches the expected infoset set.""" + game = test_case.factory() + actual = { + tuple(_get_path_of_action_labels(sg.root)): + {(i.player.label, i.number) for i in sg.difference} + for sg in game.subgames + } + assert actual == test_case.differences + + +def test_empty_tree_has_no_subgames(): + game = gbt.Game.new_tree() + assert list(game.subgames) == [] + assert list(game.terminal_subgames) == [] + assert game.root_subgame is None + + @pytest.mark.parametrize("game_file, expected_node_data", [ ( "binary_3_levels_generic_payoffs.efg", From bf2f97894a640038ff4b9c9bb0cdbf32424901e1 Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 1 Jun 2026 21:43:29 +0100 Subject: [PATCH 10/17] update changelog and api.rst --- ChangeLog | 8 ++++++++ doc/pygambit.api.rst | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/ChangeLog b/ChangeLog index e916d8aa6..64e81417f 100644 --- a/ChangeLog +++ b/ChangeLog @@ -2,6 +2,14 @@ ## [16.7.0] - unreleased +### Added +- Implement `GameSubgameRep` (C++) and `Subgame` (Python), a first-class object representing a subgame, + exposing its root node, parent and child subgames, and the infosets constituting its difference + (the infosets belonging to the subgame but not to any of its child subgames). + On `Game`, add `subgames` in the reverse topological order produced by the detection algorithm, + `root_subgame`, `terminal_subgames`, and `subgame_root` (the smallest subgame containing a given infoset). + Builds on the subgame-root detection added in 16.6.0. (#585) + ### Fixed - Corrected resizing of row and column index labels in strategic form so pivoting works correctly. (#844) - Corrected incorrect output of strategic game tables to .nfg files if strategies have previously been diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 7f8eea604..60efd331c 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,10 @@ Information about the game Game.infosets Game.nodes Game.contingencies + Game.subgames + Game.root_subgame + Game.terminal_subgames + Game.subgame_root .. autosummary:: :toctree: api/ @@ -149,7 +154,15 @@ Information about the game Node.player Node.is_successor_of Node.plays - Node.own_prior_action + +.. autosummary:: + :toctree: api/ + + Subgame.game + Subgame.root + Subgame.parent + Subgame.children + Subgame.difference .. autosummary:: From d02c17f19166d8ab70c8e0f1dbf0ed55213c5cb0 Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 1 Jun 2026 21:44:54 +0100 Subject: [PATCH 11/17] add comments and fix typos --- src/games/game.h | 2 ++ src/games/gametree.cc | 17 +++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index 8b961e8a6..9990c11ca 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -973,7 +973,9 @@ class GameRep : public std::enable_shared_from_this { /// Returns a list of all subgame roots in the game virtual std::vector GetSubgames() const { throw UndefinedException(); } + /// Returns the subgame rooted at the root of the game, or null if the game has no subgames virtual GameSubgame GetRootSubgame() const { throw UndefinedException(); } + /// Returns the root of the smallest subgame containing the information set virtual GameNode GetSubgameRoot(const GameInfoset &) const { throw UndefinedException(); } /// Returns the terminal subgames (subgames with no child subgames) virtual std::vector GetTerminalSubgames() const { throw UndefinedException(); } diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 68901778d..9c4942660 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -292,7 +292,7 @@ void GameTreeRep::Reveal(GameInfoset p_atInfoset, GamePlayer p_player) GameSubgame GameSubgameRep::GetParent() const { auto p = m_parent.lock(); - return p ? p : nullptr; + return p; } GameSubgameRep::SubgameCollection GameSubgameRep::GetChildren() const @@ -884,7 +884,8 @@ GameSubgame GameTreeRep::GetRootSubgame() const if (m_subgameCache.empty()) { BuildSubgameRoots(); } - return m_subgameCache.at(m_root.get()); + auto it = m_subgameCache.find(m_root.get()); + return (it == m_subgameCache.end()) ? nullptr : it->second; } GameNode GameTreeRep::GetSubgameRoot(const GameInfoset &p_infoset) const @@ -896,7 +897,7 @@ GameNode GameTreeRep::GetSubgameRoot(const GameInfoset &p_infoset) const BuildSubgameRoots(); } auto *n = p_infoset->m_members.front().get(); - while (n && m_subgameCache.find(n) == m_subgameCache.end()) { + while (m_subgameCache.find(n) == m_subgameCache.end()) { n = n->m_parent; } return n->shared_from_this(); @@ -1177,7 +1178,7 @@ void GameTreeRep::BuildUnreachableNodes() const void GameTreeRep::BuildSubgameRoots() const { - if (!m_subgames.empty()) { + if (!m_subgameCache.empty() || m_root->IsTerminal()) { return; } @@ -1283,7 +1284,7 @@ void GameTreeRep::BuildSubgameRoots() const GameTreeRep *m_game; // Subgame roots on the current DFS path, innermost at back std::vector m_stack; - std::unordered_set m_infoset_visited; + std::unordered_set m_infosetVisited; DFSCallbackResult OnEnter(const GameNode &p_node, int) { @@ -1301,7 +1302,7 @@ void GameTreeRep::BuildSubgameRoots() const m_cache.emplace(node, std::move(subgame)); m_stack.push_back(node); } - if (m_infoset_visited.insert(node->m_infoset).second) { + if (m_infosetVisited.insert(node->m_infoset).second) { m_cache.at(m_stack.back()) ->m_subgameDifference.emplace_back(node->m_infoset->shared_from_this()); } @@ -1336,7 +1337,7 @@ std::vector GameTreeRep::GetSubgames() const BuildSubgameRoots(); } std::vector result; - result.reserve(m_subgameCache.size()); + result.reserve(m_subgames.size()); for (auto *rep : m_subgames) { result.emplace_back(m_subgameCache.at(rep)); } @@ -1351,7 +1352,7 @@ std::vector GameTreeRep::GetTerminalSubgames() const std::vector result; for (auto *rep : m_subgames) { const auto &subgame = m_subgameCache.at(rep); - if (subgame->GetChildren().size() == 0) { + if (subgame->m_children.empty()) { result.emplace_back(subgame); } } From f4cbcbf39a0179d6b98350be25c7df02cd045046 Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 1 Jun 2026 21:46:34 +0100 Subject: [PATCH 12/17] Return null from GetRootSubgame() for games with one terminal root node --- src/pygambit/game.pxi | 19 ++++++++++++------- src/pygambit/node.pxi | 26 ++++++++++++++++++++------ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 7200c38f5..17eebf6bb 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -845,7 +845,7 @@ class Game: Iteration over this property yields the subgames in postorder (children before parents). - .. versionadded:: 16.5.0 + .. versionadded:: 16.7.0 Raises ------ @@ -859,10 +859,12 @@ class Game: return GameSubgames.wrap(self.game) @property - def root_subgame(self) -> Subgame: - """The root subgame of the game (the subgame containing the entire game tree). + def root_subgame(self) -> Subgame | None: + """The root subgame of the game (the subgame containing the entire + game tree), or None if the game has no subgames (for example, a tree + consisting of a single terminal node). - .. versionadded:: 16.5.0 + .. versionadded:: 16.7.0 Raises ------ @@ -873,13 +875,16 @@ class Game: raise UndefinedOperationError( "Operation only defined for games with a tree representation" ) - return Subgame.wrap(self.game.deref().GetRootSubgame()) + subgame = self.game.deref().GetRootSubgame() + if subgame != cython.cast(c_GameSubgame, NULL): + return Subgame.wrap(subgame) + return None @property def terminal_subgames(self) -> list[Subgame]: """The terminal subgames (subgames with no child subgames). - .. versionadded:: 16.5.0 + .. versionadded:: 16.7.0 Raises ------ @@ -905,7 +910,7 @@ class Game: Node The root node of the smallest containing subgame. - .. versionadded:: 16.5.0 + .. versionadded:: 16.7.0 Raises ------ diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index a945021bc..234fcf503 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. @@ -265,7 +265,7 @@ class Node: class Subgame: """A subgame in a ``Game``. - .. versionadded:: 16.5.0 + .. versionadded:: 16.7.0 """ subgame = cython.declare(c_GameSubgame) @@ -293,17 +293,26 @@ class Subgame: @property def game(self) -> Game: - """Gets the ``Game`` to which the subgame belongs.""" + """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.""" + """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.""" + """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) @@ -311,7 +320,10 @@ class Subgame: @property def children(self) -> list[Subgame]: - """Returns the immediate child subgames of this subgame.""" + """Returns the immediate child subgames of this subgame. + + .. versionadded:: 16.7.0 + """ return [Subgame.wrap(child) for child in self.subgame.deref().GetChildren()] @property @@ -320,5 +332,7 @@ class Subgame: The subgame difference consists of information sets that belong to this subgame but not to any of its child subgames. + + .. versionadded:: 16.7.0 """ return [Infoset.wrap(infoset) for infoset in self.subgame.deref().GetSubgameDifference()] From 7a87b62f7cb7e3d9cf3a68e3d96985729969a6c2 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 3 Jun 2026 13:26:48 +0100 Subject: [PATCH 13/17] remove the root and terminal subgames across the files --- ChangeLog | 4 ++-- doc/pygambit.api.rst | 2 -- src/games/game.h | 5 ----- src/games/gametree.cc | 24 ------------------------ src/games/gametree.h | 2 -- src/pygambit/gambit.pxd | 2 -- src/pygambit/game.pxi | 39 --------------------------------------- tests/test_node.py | 35 ----------------------------------- 8 files changed, 2 insertions(+), 111 deletions(-) diff --git a/ChangeLog b/ChangeLog index 64e81417f..96dc6b6a0 100644 --- a/ChangeLog +++ b/ChangeLog @@ -6,8 +6,8 @@ - Implement `GameSubgameRep` (C++) and `Subgame` (Python), a first-class object representing a subgame, exposing its root node, parent and child subgames, and the infosets constituting its difference (the infosets belonging to the subgame but not to any of its child subgames). - On `Game`, add `subgames` in the reverse topological order produced by the detection algorithm, - `root_subgame`, `terminal_subgames`, and `subgame_root` (the smallest subgame containing a given infoset). + On `Game`, add `subgames` (in the reverse topological order produced by the detection algorithm) + and `subgame_root` (the smallest subgame containing a given infoset). Builds on the subgame-root detection added in 16.6.0. (#585) ### Fixed diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 60efd331c..4cc88f55e 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -111,8 +111,6 @@ Information about the game Game.nodes Game.contingencies Game.subgames - Game.root_subgame - Game.terminal_subgames Game.subgame_root .. autosummary:: diff --git a/src/games/game.h b/src/games/game.h index 9990c11ca..49e7ed593 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -972,13 +972,8 @@ class GameRep : public std::enable_shared_from_this { } /// Returns a list of all subgame roots in the game virtual std::vector GetSubgames() const { throw UndefinedException(); } - - /// Returns the subgame rooted at the root of the game, or null if the game has no subgames - virtual GameSubgame GetRootSubgame() const { throw UndefinedException(); } /// Returns the root of the smallest subgame containing the information set virtual GameNode GetSubgameRoot(const GameInfoset &) const { throw UndefinedException(); } - /// Returns the terminal subgames (subgames with no child subgames) - virtual std::vector GetTerminalSubgames() const { throw UndefinedException(); } //@} diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 9c4942660..c438cf7e2 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -879,15 +879,6 @@ bool GameTreeRep::IsAbsentMinded(const GameInfoset &p_infoset) const return contains(m_absentMindedInfosets, p_infoset.get()); } -GameSubgame GameTreeRep::GetRootSubgame() const -{ - if (m_subgameCache.empty()) { - BuildSubgameRoots(); - } - auto it = m_subgameCache.find(m_root.get()); - return (it == m_subgameCache.end()) ? nullptr : it->second; -} - GameNode GameTreeRep::GetSubgameRoot(const GameInfoset &p_infoset) const { if (p_infoset->GetGame().get() != this) { @@ -1344,21 +1335,6 @@ std::vector GameTreeRep::GetSubgames() const return result; } -std::vector GameTreeRep::GetTerminalSubgames() const -{ - if (m_subgameCache.empty()) { - BuildSubgameRoots(); - } - std::vector result; - for (auto *rep : m_subgames) { - const auto &subgame = m_subgameCache.at(rep); - if (subgame->m_children.empty()) { - result.emplace_back(subgame); - } - } - return result; -} - //------------------------------------------------------------------------ // GameTreeRep: Writing data files //------------------------------------------------------------------------ diff --git a/src/games/gametree.h b/src/games/gametree.h index d56cb07c9..d1175a233 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -103,9 +103,7 @@ class GameTreeRep final : public GameExplicitRep { Rational GetPlayerMaxPayoff(const GamePlayer &) const override; bool IsAbsentMinded(const GameInfoset &p_infoset) const override; std::vector GetSubgames() const override; - GameSubgame GetRootSubgame() const override; GameNode GetSubgameRoot(const GameInfoset &) const override; - std::vector GetTerminalSubgames() const override; //@} /// @name Players diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 4aae9fb8b..9917ced21 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -348,10 +348,8 @@ 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 GetRootSubgame() except + c_GameNode GetSubgameRoot(c_GameInfoset) except + stdvector[c_GameSubgame] GetSubgames() except + - stdvector[c_GameSubgame] GetTerminalSubgames() except + c_Game NewTree() except + c_Game NewTable(stdvector[int]) except + diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 17eebf6bb..09f9d2153 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -858,45 +858,6 @@ class Game: ) return GameSubgames.wrap(self.game) - @property - def root_subgame(self) -> Subgame | None: - """The root subgame of the game (the subgame containing the entire - game tree), or None if the game has no subgames (for example, a tree - consisting of a single terminal node). - - .. 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" - ) - subgame = self.game.deref().GetRootSubgame() - if subgame != cython.cast(c_GameSubgame, NULL): - return Subgame.wrap(subgame) - return None - - @property - def terminal_subgames(self) -> list[Subgame]: - """The terminal subgames (subgames with no child subgames). - - .. 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 [Subgame.wrap(sg) for sg in self.game.deref().GetTerminalSubgames()] - def subgame_root(self, infoset: typing.Union[Infoset, str]) -> Node: """Returns the root node of the smallest subgame containing `infoset`. diff --git a/tests/test_node.py b/tests/test_node.py index c582ef660..c26c8bca9 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -230,8 +230,6 @@ class SubgameStructureTestCase: `children` maps each subgame-root path to the set of its child subgame paths. - `terminal_subgames` lists the subgame roots with no children, in the postorder. - `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. @@ -240,7 +238,6 @@ class SubgameStructureTestCase: roots: list[list[str]] parents: dict[tuple[str, ...], tuple[str, ...] | None] children: dict[tuple[str, ...], set[tuple[str, ...]]] - terminal_subgames: list[list[str]] differences: dict[tuple[str, ...], set[tuple[str, int]]] @@ -254,7 +251,6 @@ class SubgameStructureTestCase: roots=[[]], parents={(): None}, children={(): set()}, - terminal_subgames=[[]], differences={(): {("Player 1", 0), ("Player 1", 1), ("Player 2", 0)}}, ), id="wichardt_no_nontrivial_subgames", @@ -296,12 +292,6 @@ class SubgameStructureTestCase: ("R",): set(), (): {("L",), ("R",)}, }, - terminal_subgames=[ - ["L", "L", "L", "L", "L"], - ["R", "L", "L", "L", "L"], - ["R", "L"], - ["R"], - ], differences={ ("L", "L", "L", "L", "L"): { ("Player 1", 3), ("Player 2", 2), ("Player 2", 3), @@ -331,14 +321,6 @@ def test_subgames_postorder_sequence(test_case: SubgameStructureTestCase): assert actual == test_case.roots -@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) -def test_root_subgame_is_game_root(test_case: SubgameStructureTestCase): - """`game.root_subgame` is rooted at `game.root` and has no parent.""" - game = test_case.factory() - assert game.root_subgame.root == game.root - assert game.root_subgame.parent is None - - @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.""" @@ -364,16 +346,6 @@ def test_subgame_children(test_case: SubgameStructureTestCase): assert actual == test_case.children -@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) -def test_terminal_subgames(test_case: SubgameStructureTestCase): - """`game.terminal_subgames` matches the expected terminal subgames, in - postorder. - """ - game = test_case.factory() - actual = [_get_path_of_action_labels(sg.root) for sg in game.terminal_subgames] - assert actual == test_case.terminal_subgames - - @pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) def test_subgame_differences(test_case: SubgameStructureTestCase): """Each subgame's difference matches the expected infoset set.""" @@ -386,13 +358,6 @@ def test_subgame_differences(test_case: SubgameStructureTestCase): assert actual == test_case.differences -def test_empty_tree_has_no_subgames(): - game = gbt.Game.new_tree() - assert list(game.subgames) == [] - assert list(game.terminal_subgames) == [] - assert game.root_subgame is None - - @pytest.mark.parametrize("game_file, expected_node_data", [ ( "binary_3_levels_generic_payoffs.efg", From 9482d6d715b49433c487f604f6d9802abe30bee2 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 3 Jun 2026 14:11:34 +0100 Subject: [PATCH 14/17] Return Subgame from Game.minimal_subgame() (previously Node from subgame_root()) --- ChangeLog | 2 +- doc/pygambit.api.rst | 2 +- src/games/game.h | 4 ++-- src/games/gametree.cc | 8 +++++--- src/games/gametree.h | 2 +- src/pygambit/gambit.pxd | 2 +- src/pygambit/game.pxi | 14 +++++++------- tests/test_node.py | 15 +++++++++++++++ 8 files changed, 33 insertions(+), 16 deletions(-) diff --git a/ChangeLog b/ChangeLog index 96dc6b6a0..097d13cbd 100644 --- a/ChangeLog +++ b/ChangeLog @@ -7,7 +7,7 @@ exposing its root node, parent and child subgames, and the infosets constituting its difference (the infosets belonging to the subgame but not to any of its child subgames). On `Game`, add `subgames` (in the reverse topological order produced by the detection algorithm) - and `subgame_root` (the smallest subgame containing a given infoset). + and `minimal_subgame` (the minimal subgame containing a given infoset). Builds on the subgame-root detection added in 16.6.0. (#585) ### Fixed diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 4cc88f55e..af70f1dfa 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -111,7 +111,7 @@ Information about the game Game.nodes Game.contingencies Game.subgames - Game.subgame_root + Game.minimal_subgame .. autosummary:: :toctree: api/ diff --git a/src/games/game.h b/src/games/game.h index 49e7ed593..5fa619e4a 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -972,8 +972,8 @@ class GameRep : public std::enable_shared_from_this { } /// Returns a list of all subgame roots in the game virtual std::vector GetSubgames() const { throw UndefinedException(); } - /// Returns the root of the smallest subgame containing the information set - virtual GameNode GetSubgameRoot(const GameInfoset &) const { throw UndefinedException(); } + /// Returns the smallest subgame containing the information set + virtual GameSubgame GetMinimalSubgame(const GameInfoset &) const { throw UndefinedException(); } //@} diff --git a/src/games/gametree.cc b/src/games/gametree.cc index c438cf7e2..b73232b91 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -879,7 +879,7 @@ bool GameTreeRep::IsAbsentMinded(const GameInfoset &p_infoset) const return contains(m_absentMindedInfosets, p_infoset.get()); } -GameNode GameTreeRep::GetSubgameRoot(const GameInfoset &p_infoset) const +GameSubgame GameTreeRep::GetMinimalSubgame(const GameInfoset &p_infoset) const { if (p_infoset->GetGame().get() != this) { throw MismatchException(); @@ -888,10 +888,12 @@ GameNode GameTreeRep::GetSubgameRoot(const GameInfoset &p_infoset) const BuildSubgameRoots(); } auto *n = p_infoset->m_members.front().get(); - while (m_subgameCache.find(n) == m_subgameCache.end()) { + auto it = m_subgameCache.find(n); + while (it == m_subgameCache.end()) { n = n->m_parent; + it = m_subgameCache.find(n); } - return n->shared_from_this(); + return it->second; } //------------------------------------------------------------------------ diff --git a/src/games/gametree.h b/src/games/gametree.h index d1175a233..576f5d9da 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -103,7 +103,7 @@ class GameTreeRep final : public GameExplicitRep { Rational GetPlayerMaxPayoff(const GamePlayer &) const override; bool IsAbsentMinded(const GameInfoset &p_infoset) const override; std::vector GetSubgames() const override; - GameNode GetSubgameRoot(const GameInfoset &) const override; + GameSubgame GetMinimalSubgame(const GameInfoset &) const override; //@} /// @name Players diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 9917ced21..e2a9b9203 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -348,7 +348,7 @@ cdef extern from "games/game.h": c_PureStrategyProfile NewPureStrategyProfile() # except + doesn't compile c_MixedStrategyProfile[T] NewMixedStrategyProfile[T](T) # except + doesn't compile - c_GameNode GetSubgameRoot(c_GameInfoset) except + + c_GameSubgame GetMinimalSubgame(c_GameInfoset) except + stdvector[c_GameSubgame] GetSubgames() except + c_Game NewTree() except + diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 09f9d2153..811d70489 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -858,8 +858,8 @@ class Game: ) return GameSubgames.wrap(self.game) - def subgame_root(self, infoset: typing.Union[Infoset, str]) -> Node: - """Returns the root node of the smallest subgame containing `infoset`. + def minimal_subgame(self, infoset: typing.Union[Infoset, str]) -> Subgame: + """Returns the smallest subgame containing `infoset`. Parameters ---------- @@ -868,8 +868,8 @@ class Game: Returns ------- - Node - The root node of the smallest containing subgame. + Subgame + The smallest subgame containing `infoset`. .. versionadded:: 16.7.0 @@ -884,9 +884,9 @@ class Game: raise UndefinedOperationError( "Operation only defined for games with a tree representation" ) - resolved_infoset = self._resolve_infoset(infoset, "subgame_root") - return Node.wrap( - self.game.deref().GetSubgameRoot(cython.cast(Infoset, resolved_infoset).infoset) + 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): diff --git a/tests/test_node.py b/tests/test_node.py index c26c8bca9..e439c24fa 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -358,6 +358,21 @@ def test_subgame_differences(test_case: SubgameStructureTestCase): assert actual == test_case.differences +@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", From f888b0f3b33aa13c17dbf5b1585f8e13e12b4ae9 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 3 Jun 2026 15:13:06 +0100 Subject: [PATCH 15/17] Merge subgame caches in a SubgameData struct with a validity flag --- src/games/gametree.cc | 47 +++++++++++++++++++------------------------ src/games/gametree.h | 22 ++++++++++++++++++-- 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index b73232b91..3d72a25a2 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -402,11 +402,8 @@ bool GameNodeRep::IsSubgameRoot() const } auto *tree_game = static_cast(m_game); - if (tree_game->m_subgameCache.empty()) { - tree_game->BuildSubgameRoots(); - } - - return tree_game->m_subgameCache.count(const_cast(this)) > 0; + tree_game->BuildSubgameRoots(); + return tree_game->m_subgameData.m_subgameByRoot.count(const_cast(this)) > 0; } bool GameNodeRep::IsStrategyReachable() const @@ -884,14 +881,12 @@ GameSubgame GameTreeRep::GetMinimalSubgame(const GameInfoset &p_infoset) const if (p_infoset->GetGame().get() != this) { throw MismatchException(); } - if (m_subgameCache.empty()) { - BuildSubgameRoots(); - } + BuildSubgameRoots(); auto *n = p_infoset->m_members.front().get(); - auto it = m_subgameCache.find(n); - while (it == m_subgameCache.end()) { + auto it = m_subgameData.m_subgameByRoot.find(n); + while (it == m_subgameData.m_subgameByRoot.end()) { n = n->m_parent; - it = m_subgameCache.find(n); + it = m_subgameData.m_subgameByRoot.find(n); } return it->second; } @@ -961,11 +956,7 @@ void GameTreeRep::ClearComputedValues() const m_ownPriorActionInfo = nullptr; const_cast(this)->m_unreachableNodes = nullptr; m_absentMindedInfosets.clear(); - m_subgames.clear(); - for (const auto &[node, subgame] : m_subgameCache) { - subgame->Invalidate(); - } - m_subgameCache.clear(); + m_subgameData.Invalidate(); m_computedValues = false; } @@ -1171,7 +1162,11 @@ void GameTreeRep::BuildUnreachableNodes() const void GameTreeRep::BuildSubgameRoots() const { - if (!m_subgameCache.empty() || m_root->IsTerminal()) { + if (m_subgameData.m_valid) { + return; + } + if (m_root->IsTerminal()) { + m_subgameData.m_valid = true; return; } @@ -1267,7 +1262,7 @@ 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 @@ -1317,22 +1312,22 @@ void GameTreeRep::BuildSubgameRoots() const static void OnVisit(GameNode, int) {} }; - const std::unordered_set subgame_root_set(m_subgames.begin(), m_subgames.end()); + const std::unordered_set subgame_root_set( + m_subgameData.m_subgamePostorder.begin(), m_subgameData.m_subgamePostorder.end()); - SubgameVisitor subgame_visitor{subgame_root_set, m_subgameCache, + 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 { - if (m_subgameCache.empty()) { - BuildSubgameRoots(); - } + BuildSubgameRoots(); std::vector result; - result.reserve(m_subgames.size()); - for (auto *rep : m_subgames) { - result.emplace_back(m_subgameCache.at(rep)); + 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 576f5d9da..c94404f89 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -48,8 +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; - mutable std::unordered_map> m_subgameCache; + // 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 //@{ @@ -188,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; }; From 278e3828c17365faa8acf9666efca69aa83b6c8b Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 5 Jun 2026 09:28:24 +0100 Subject: [PATCH 16/17] Final adjustments to initial subgame implementation. --- ChangeLog | 7 +------ doc/pygambit.api.rst | 2 +- src/pygambit/node.pxi | 11 ----------- 3 files changed, 2 insertions(+), 18 deletions(-) diff --git a/ChangeLog b/ChangeLog index 097d13cbd..9735f90d4 100644 --- a/ChangeLog +++ b/ChangeLog @@ -3,12 +3,7 @@ ## [16.7.0] - unreleased ### Added -- Implement `GameSubgameRep` (C++) and `Subgame` (Python), a first-class object representing a subgame, - exposing its root node, parent and child subgames, and the infosets constituting its difference - (the infosets belonging to the subgame but not to any of its child subgames). - On `Game`, add `subgames` (in the reverse topological order produced by the detection algorithm) - and `minimal_subgame` (the minimal subgame containing a given infoset). - Builds on the subgame-root detection added in 16.6.0. (#585) +- Implement `GameSubgameRep` (C++) and `Subgame` (Python), a first-class object representing a subgame. (#585) ### Fixed - Corrected resizing of row and column index labels in strategic form so pivoting works correctly. (#844) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index af70f1dfa..f291c538f 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -152,6 +152,7 @@ Information about the game Node.player Node.is_successor_of Node.plays + Node.own_prior_action .. autosummary:: :toctree: api/ @@ -160,7 +161,6 @@ Information about the game Subgame.root Subgame.parent Subgame.children - Subgame.difference .. autosummary:: diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 234fcf503..0f082d3b1 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -325,14 +325,3 @@ class Subgame: .. versionadded:: 16.7.0 """ return [Subgame.wrap(child) for child in self.subgame.deref().GetChildren()] - - @property - def difference(self) -> list[Infoset]: - """Returns the information sets in the subgame difference. - - The subgame difference consists of information sets that belong - to this subgame but not to any of its child subgames. - - .. versionadded:: 16.7.0 - """ - return [Infoset.wrap(infoset) for infoset in self.subgame.deref().GetSubgameDifference()] From 6dcfee831ba5284c9ba1d1228e3334c94c895add Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 5 Jun 2026 09:46:52 +0100 Subject: [PATCH 17/17] Remove test for subgame differences (removed from API for now) --- tests/test_catalog.py | 2 +- tests/test_node.py | 12 ------------ 2 files changed, 1 insertion(+), 13 deletions(-) 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_node.py b/tests/test_node.py index 9438146a3..ce479afab 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -360,18 +360,6 @@ def test_subgame_children(test_case: SubgameStructureTestCase): assert actual == test_case.children -@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) -def test_subgame_differences(test_case: SubgameStructureTestCase): - """Each subgame's difference matches the expected infoset set.""" - game = test_case.factory() - actual = { - tuple(_get_path_of_action_labels(sg.root)): - {(i.player.label, i.number) for i in sg.difference} - for sg in game.subgames - } - assert actual == test_case.differences - - @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."""