From a45b58c1d68ad8ead3f1b5a7bc2353784443f3c8 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 27 Nov 2025 10:10:17 +0000 Subject: [PATCH 01/10] Refactor tree traversal and add Own Prior Action API: (i) Replace `BuildInfosetParents` with `BuildOwnPriorActions` and `BuildUnreachableNodes`; (ii) Introduce `m_nodeOwnPriorAction` and `m_infosetOwnPriorActions` --- src/games/game.h | 3 + src/games/gametree.cc | 158 +++++++++++++++++++++++++++++++++------ src/games/gametree.h | 11 ++- src/pygambit/gambit.pxd | 3 + src/pygambit/infoset.pxi | 21 ++++++ src/pygambit/node.pxi | 10 +++ 6 files changed, 180 insertions(+), 26 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index 9dc724967..82856f521 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -247,6 +247,8 @@ class GameInfosetRep : public std::enable_shared_from_this { bool Precedes(GameNode) const; + std::set GetOwnPriorActions() const; + const Number &GetActionProb(const GameAction &p_action) const { if (p_action->GetInfoset().get() != this) { @@ -492,6 +494,7 @@ class GameNodeRep : public std::enable_shared_from_this { bool IsTerminal() const { return m_children.empty(); } GamePlayer GetPlayer() const { return (m_infoset) ? m_infoset->GetPlayer() : nullptr; } GameAction GetPriorAction() const; // returns null if root node + GameAction GetOwnPriorAction() const; GameNode GetParent() const { return (m_parent) ? m_parent->shared_from_this() : nullptr; } GameNode GetNextSibling() const; GameNode GetPriorSibling() const; diff --git a/src/games/gametree.cc b/src/games/gametree.cc index a98c5b216..2f9d15abe 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -323,6 +323,21 @@ GameAction GameNodeRep::GetPriorAction() const return nullptr; } +GameAction GameNodeRep::GetOwnPriorAction() const +{ + auto tree_game = static_cast(m_game); + + return tree_game->GetOwnPriorAction(std::const_pointer_cast(shared_from_this())); +} + +std::set GameInfosetRep::GetOwnPriorActions() const +{ + auto tree_game = static_cast(m_game); + + return tree_game->GetOwnPriorActions( + std::const_pointer_cast(shared_from_this())); +} + void GameNodeRep::DeleteOutcome(GameOutcomeRep *outc) { m_game->IncrementVersion(); @@ -393,7 +408,7 @@ bool GameNodeRep::IsStrategyReachable() const auto tree_game = static_cast(m_game); if (!tree_game->m_unreachableNodes) { - tree_game->BuildInfosetParents(); + tree_game->BuildUnreachableNodes(); } // A node is reachable if it is NOT in the set of unreachable nodes. @@ -750,15 +765,15 @@ bool GameTreeRep::IsConstSum() const bool GameTreeRep::IsPerfectRecall() const { - if (m_infosetParents.empty() && !m_root->IsTerminal()) { - const_cast(this)->BuildInfosetParents(); + if (m_nodeOwnPriorAction.empty() && !m_root->IsTerminal()) { + const_cast(this)->BuildOwnPriorActions(); } if (GetRoot()->IsTerminal()) { return true; } - return std::all_of(m_infosetParents.cbegin(), m_infosetParents.cend(), + return std::all_of(m_infosetOwnPriorActions.cbegin(), m_infosetOwnPriorActions.cend(), [](const auto &pair) { return pair.second.size() <= 1; }); } @@ -811,7 +826,8 @@ void GameTreeRep::ClearComputedValues() const player->m_strategies.clear(); } const_cast(this)->m_nodePlays.clear(); - const_cast(this)->m_infosetParents.clear(); + const_cast(this)->m_nodeOwnPriorAction.clear(); + const_cast(this)->m_infosetOwnPriorActions.clear(); const_cast(this)->m_unreachableNodes = nullptr; m_computedValues = false; } @@ -853,22 +869,24 @@ std::vector GameTreeRep::BuildConsistentPlaysRecursiveImpl(GameNo return consistent_plays; } -void GameTreeRep::BuildInfosetParents() +void GameTreeRep::BuildOwnPriorActions() { - m_infosetParents.clear(); - m_unreachableNodes = std::make_unique>(); + m_nodeOwnPriorAction.clear(); + m_infosetOwnPriorActions.clear(); if (m_root->IsTerminal()) { - m_infosetParents[m_root->m_infoset].insert(nullptr); return; } - using AbsentMindedEdge = std::pair; - using ActiveEdge = std::variant; - std::stack position; + m_nodeOwnPriorAction[m_root.get()] = nullptr; + if (m_root->m_infoset) { + m_infosetOwnPriorActions[m_root->m_infoset].insert(nullptr); + } + + using ActiveEdge = GameNodeRep::Actions::iterator; + std::stack position; std::map> prior_actions; - std::map path_choices; for (auto player_rep : m_players) { prior_actions[GamePlayer(player_rep)].emplace(nullptr); @@ -876,11 +894,107 @@ void GameTreeRep::BuildInfosetParents() prior_actions[GamePlayer(m_chance)].emplace(nullptr); position.emplace(m_root->GetActions().begin()); - prior_actions[m_root->m_infoset->m_player->shared_from_this()].emplace(nullptr); if (m_root->m_infoset) { - m_infosetParents[m_root->m_infoset].insert(nullptr); + prior_actions[m_root->m_infoset->m_player->shared_from_this()].emplace(nullptr); } + while (!position.empty()) { + ActiveEdge ¤t_edge = position.top(); + + GameNode child, node; + GameAction action; + + node = current_edge.GetOwner(); + + if (current_edge == node->GetActions().end()) { + if (node->m_infoset) { + prior_actions.at(node->m_infoset->m_player->shared_from_this()).pop(); + } + position.pop(); + continue; + } + else { + std::tie(action, child) = *current_edge; + ++current_edge; + } + + if (node->m_infoset) { + prior_actions.at(node->m_infoset->m_player->shared_from_this()).top() = action; + } + + if (!child->IsTerminal()) { + if (child->m_infoset) { + auto child_player = child->m_infoset->m_player->shared_from_this(); + auto prior_action = prior_actions.at(child_player).top(); + GameActionRep *raw_prior = prior_action ? prior_action.get() : nullptr; + + m_nodeOwnPriorAction[child.get()] = raw_prior; + m_infosetOwnPriorActions[child->m_infoset].insert(raw_prior); + + position.emplace(child->GetActions().begin()); + prior_actions.at(child_player).emplace(nullptr); + } + else { + position.emplace(child->GetActions().begin()); + } + } + } +} + +GameAction GameTreeRep::GetOwnPriorAction(GameNode node) const +{ + if (const_cast(this)->m_nodeOwnPriorAction.empty()) { + const_cast(this)->BuildOwnPriorActions(); + } + + auto it = m_nodeOwnPriorAction.find(node.get()); + if (it != m_nodeOwnPriorAction.end()) { + if (it->second) { + return it->second->shared_from_this(); + } + } + return nullptr; +} + +std::set GameTreeRep::GetOwnPriorActions(GameInfoset iset) const +{ + if (const_cast(this)->m_nodeOwnPriorAction.empty()) { + const_cast(this)->BuildOwnPriorActions(); + } + + std::set result; + auto it = m_infosetOwnPriorActions.find(iset.get()); + + if (it != m_infosetOwnPriorActions.end()) { + for (auto *ptr : it->second) { + if (ptr) { + result.insert(ptr->shared_from_this()); + } + else { + result.insert(nullptr); + } + } + } + return result; +} + +void GameTreeRep::BuildUnreachableNodes() +{ + m_unreachableNodes = std::make_unique>(); + + if (m_root->IsTerminal()) { + return; + } + + using AbsentMindedEdge = std::pair; + using ActiveEdge = std::variant; + + std::stack position; + + std::map path_choices; + + position.emplace(m_root->GetActions().begin()); + while (!position.empty()) { ActiveEdge ¤t_edge = position.top(); GameNode child, node; @@ -891,7 +1005,6 @@ void GameTreeRep::BuildInfosetParents() node = current_it.GetOwner(); if (current_it == node->GetActions().end()) { - prior_actions.at(node->m_infoset->m_player->shared_from_this()).pop(); position.pop(); path_choices.erase(node->m_infoset->shared_from_this()); continue; @@ -908,17 +1021,15 @@ void GameTreeRep::BuildInfosetParents() child = node->GetChild(action); } - prior_actions.at(node->m_infoset->m_player->shared_from_this()).top() = action; if (!child->IsTerminal()) { - auto child_player = child->m_infoset->m_player->shared_from_this(); - auto prior_action = prior_actions.at(child_player).top(); - m_infosetParents[child->m_infoset].insert(prior_action ? prior_action.get() : nullptr); - + // Check for Absent-Minded Re-entry of the infoset if (path_choices.find(child->m_infoset->shared_from_this()) != path_choices.end()) { + const GameAction replay_action = path_choices.at(child->m_infoset->shared_from_this()); + position.emplace(AbsentMindedEdge{replay_action, child}); - // Start of the traversal of unreachable subtrees + // Mark siblings and the nodes in their subtrees as unreachable for (const auto &[current_action, subtree_root] : child->GetActions()) { if (current_action != replay_action) { @@ -928,6 +1039,7 @@ void GameTreeRep::BuildInfosetParents() while (!nodes_to_visit.empty()) { GameNodeRep *current_unreachable_node = nodes_to_visit.top(); nodes_to_visit.pop(); + m_unreachableNodes->insert(current_unreachable_node); for (const auto &unreachable_child : current_unreachable_node->GetChildren()) { @@ -936,12 +1048,10 @@ void GameTreeRep::BuildInfosetParents() } } } - // End of the traversal of unreachable subtrees } else { position.emplace(child->GetActions().begin()); } - prior_actions.at(child_player).emplace(nullptr); } } } diff --git a/src/games/gametree.h b/src/games/gametree.h index e5249488b..1fb1a9c35 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -39,8 +39,10 @@ class GameTreeRep : public GameExplicitRep { std::size_t m_numNodes = 1; std::size_t m_numNonterminalNodes = 0; std::map> m_nodePlays; - std::map> m_infosetParents; + std::map m_nodeOwnPriorAction; + std::map> m_infosetOwnPriorActions; mutable std::unique_ptr> m_unreachableNodes; + mutable std::unique_ptr> m_unreachableNodes_old; /// @name Private auxiliary functions //@{ @@ -93,6 +95,8 @@ class GameTreeRep : public GameExplicitRep { size_t NumNodes() const override { return m_numNodes; } /// Returns the number of non-terminal nodes in the game size_t NumNonterminalNodes() const override { return m_numNonterminalNodes; } + /// Returns the last action taken by the node's owner before reaching this node + GameAction GetOwnPriorAction(GameNode node) const; //@} void DeleteOutcome(const GameOutcome &) override; @@ -117,6 +121,8 @@ class GameTreeRep : public GameExplicitRep { std::vector GetInfosets() const override; /// Sort the information sets for each player in a canonical order void SortInfosets() override; + /// Returns the set of actions taken by the infoset's owner before reaching this infoset + std::set GetOwnPriorActions(GameInfoset infoset) const; //@} /// @name Modification @@ -155,7 +161,8 @@ class GameTreeRep : public GameExplicitRep { private: std::vector BuildConsistentPlaysRecursiveImpl(GameNodeRep *node); - void BuildInfosetParents(); + void BuildOwnPriorActions(); + void BuildUnreachableNodes(); }; template class TreeMixedStrategyProfileRep : public MixedStrategyProfileRep { diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index b3ef8faf2..afdf9b33d 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -3,6 +3,7 @@ from libcpp.string cimport string from libcpp.memory cimport shared_ptr, unique_ptr from libcpp.list cimport list as stdlist from libcpp.vector cimport vector as stdvector +from libcpp.set cimport set as stdset cdef extern from "gambit.h": @@ -145,6 +146,7 @@ cdef extern from "games/game.h": bint IsChanceInfoset() except + bint Precedes(c_GameNode) except + + stdset[c_GameAction] GetOwnPriorActions() except + cdef cppclass c_GamePlayerRep "GamePlayerRep": cppclass Infosets: @@ -220,6 +222,7 @@ cdef extern from "games/game.h": bint IsSubgameRoot() except + bint IsStrategyReachable() except + c_GameAction GetPriorAction() except + + c_GameAction GetOwnPriorAction() except + cdef cppclass c_GameRep "GameRep": cppclass Players: diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index b7354c4ac..3146d27e4 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -166,6 +166,27 @@ class Infoset: """The set of actions at the information set.""" return InfosetActions.wrap(self.infoset) + @property + def own_prior_actions(self) -> typing.Set[typing.Optional[Action]]: + """The set of actions taken by the player immediately preceding the member nodes + in the information set. + + Returns a set containing Action objects. If a node in the information set + is reached without the player having moved previously, None will be + included in the set. + """ + cdef stdset[c_GameAction] c_actions = self.infoset.deref().GetOwnPriorActions() + cdef c_GameAction action + py_result = set() + + for action in c_actions: + if action != cython.cast(c_GameAction, NULL): + py_result.add(Action.wrap(action)) + else: + py_result.add(None) + + return py_result + @property def members(self) -> InfosetMembers: """The set of nodes which are members of the information set. diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index ac748e16b..2779456b0 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -176,6 +176,16 @@ class Node: return Action.wrap(self.node.deref().GetPriorAction()) return None + @property + def own_prior_action(self) -> typing.Optional[Action]: + """The last action taken by the node's owner before reaching this node. + + If the player has not moved previously on the path to this node, None is returned. + """ + if self.node.deref().GetOwnPriorAction() != cython.cast(c_GameAction, NULL): + return Action.wrap(self.node.deref().GetOwnPriorAction()) + return None + @property def prior_sibling(self) -> typing.Optional[Node]: """The node which is immediately before this one in its parent's children. From 753d611676d93dc444338cd9faf985e7948a0395 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 27 Nov 2025 21:03:19 +0000 Subject: [PATCH 02/10] Add tests for Own Prior Action API: `infoset.own_prior_actions` and `node.own_prior_action` --- tests/test_infosets.py | 94 ++++++++++++++++++++++++++++++++++++++++++ tests/test_node.py | 89 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 180 insertions(+), 3 deletions(-) diff --git a/tests/test_infosets.py b/tests/test_infosets.py index a6f5b6d6a..fedda1954 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -69,3 +69,97 @@ def test_infoset_plays(): } # paths=[0, 1, 0], [1, 1, 0], [0, 1], [1, 1] assert set(test_infoset.plays) == expected_set_of_plays + + +@pytest.mark.parametrize("game_file, expected_results", [ + # Perfect recall game + ( + "binary_3_levels_generic_payoffs.efg", + [ + # Player 1, Infoset 0 (Root): + # No prior history. + ("Player 1", 0, {None}), + + # Player 1, Infoset 1: + # Reached via "Left" from Infoset 0. + ("Player 1", 1, {("Player 1", 0, "Left")}), + + # Player 1, Infoset 2: + # Reached via "Right" from Infoset 0. + ("Player 1", 2, {("Player 1", 0, "Right")}), + + # Player 2, Infoset 0: + # No prior history. + ("Player 2", 0, {None}), + ] + ), + # Imperfect recall games, no absent-mindedness + ( + "wichardt.efg", + [ + # Player 1, Infoset 0 (Root): + # No prior history. + ("Player 1", 0, {None}), + + # Player 1, Infoset 1: + # Reachable via "L" or "R" from Infoset 0. + ("Player 1", 1, {("Player 1", 0, "L"), ("Player 1", 0, "R")}), + + # Player 2, Infoset 0: + # No prior history. + ("Player 2", 0, {None}), + ] + ), + ( + "subgames.efg", + [ + ("Player 1", 0, {None}), + ("Player 1", 1, {None}), + ("Player 1", 2, {("Player 1", 1, "1")}), + ("Player 1", 3, {("Player 1", 5, "1"), ("Player 1", 1, "2")}), + ("Player 1", 4, {("Player 1", 1, "2")}), + ("Player 1", 5, {("Player 1", 4, "2")}), + ("Player 1", 6, {("Player 1", 1, "2")}), + ("Player 2", 0, {None}), + ("Player 2", 1, {("Player 2", 0, "2")}), + ("Player 2", 2, {("Player 2", 1, "1")}), + ("Player 2", 3, {("Player 2", 2, "1")}), + ("Player 2", 4, {("Player 2", 2, "2")}), + ("Player 2", 5, {("Player 2", 4, "1")}), + ] + ), + # An absent-minded driver game + ( + "AM-driver-subgame.efg", + [ + # Player 1, Infoset 0: + # One member is the root (no prior history), + # the other is reached via "S" from this same infoset. + ("Player 1", 0, {None, ("Player 1", 0, "S")}), + + # Player 2, Infoset 0: + # No prior history. + ("Player 2", 0, {None}), + ] + ), +]) +def test_infoset_own_prior_actions(game_file, expected_results): + """ + Tests `infoset.own_prior_actions` by collecting the action details + (player label, infoset num, label) and comparing against expected sets. + """ + game = games.read_from_file(game_file) + + for player_label, infoset_num, expected_set in expected_results: + player = game.players[player_label] + infoset = player.infosets[infoset_num] + + actual_actions = infoset.own_prior_actions + + # Updated to capture player.label instead of player.number + actual_details = { + (a.infoset.player.label, a.infoset.number, a.label) if a is not None else None + for a in actual_actions + } + + assert actual_details == expected_set diff --git a/tests/test_node.py b/tests/test_node.py index 09d1d4dfb..6d713c9b7 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -126,7 +126,90 @@ def _get_path_of_action_labels(node: gbt.Node) -> list[str]: path.append(current_node.prior_action.label) current_node = current_node.parent - return path[::-1] + return path + + +@pytest.mark.parametrize("game_file, expected_node_data", [ + ( + "binary_3_levels_generic_payoffs.efg", + [ + # Format: ([Path Leaf->Root], (Player Label, Infoset Num, Action Label) or None) + ([], None), + (["Left"], None), + (["Left", "Left"], ("Player 1", 0, "Left")), + (["Right", "Left"], ("Player 1", 0, "Left")), + (["Right"], None), + (["Left", "Right"], ("Player 1", 0, "Right")), + (["Right", "Right"], ("Player 1", 0, "Right")), + ] + ), + ( + "wichardt.efg", + [ + ([], None), + (["R"], ("Player 1", 0, "R")), + (["r", "R"], None), + (["l", "R"], None), + (["L"], ("Player 1", 0, "L")), + (["r", "L"], None), + (["l", "L"], None), + ] + ), + ( + "subgames.efg", + [ + ([], None), + (["1"], None), + (["2"], None), + (["1", "2"], ("Player 2", 0, "2")), + (["2", "1", "2"], ("Player 1", 1, "1")), + (["2", "2"], ("Player 2", 0, "2")), + (["1", "2", "2"], ("Player 2", 1, "1")), + (["1", "1", "2", "2"], ("Player 1", 1, "2")), + (["1", "1", "1", "2", "2"], ("Player 2", 2, "1")), + (["2", "1", "2", "2"], ("Player 1", 1, "2")), + (["1", "2", "1", "2", "2"], ("Player 2", 2, "2")), + (["2", "2", "1", "2", "2"], ("Player 2", 2, "2")), + (["1", "2", "2", "1", "2", "2"], ("Player 1", 4, "2")), + (["1", "1", "2", "2", "1", "2", "2"], ("Player 2", 4, "1")), + (["1", "1", "1", "2", "2", "1", "2", "2"], ("Player 1", 5, "1")), + (["2", "1", "1", "2", "2", "1", "2", "2"], ("Player 1", 5, "1")), + (["2", "2", "2", "1", "2", "2"], ("Player 1", 4, "2")), + (["2", "2", "2"], ("Player 1", 1, "2")), + ] + ), + ( + "AM-driver-subgame.efg", + [ + ([], None), + (["S"], ("Player 1", 0, "S")), + (["T", "S"], None), + ] + ), +]) +def test_node_own_prior_action_non_terminal(game_file, expected_node_data): + """ + Tests `node.own_prior_action` for non-terminal nodes. + Also verifies that all terminal nodes return None. + """ + game = games.read_from_file(game_file) + + actual_node_data = [] + + for node in game.nodes: + if node.is_terminal: + assert node.own_prior_action is None, \ + f"Terminal node at {_get_path_of_action_labels(node)} must be None" + else: + # Collection: Only collect data for non-terminal nodes + opa = node.own_prior_action + details = ( + (opa.infoset.player.label, opa.infoset.number, opa.label) + if opa is not None else None + ) + actual_node_data.append((_get_path_of_action_labels(node), details)) + + assert actual_node_data == expected_node_data @pytest.mark.parametrize("game_file, expected_unreachable_paths", [ @@ -138,13 +221,13 @@ def _get_path_of_action_labels(node: gbt.Node) -> list[str]: # An absent-minded driver game with an unreachable terminal node ( "AM-driver-one-infoset.efg", - [["S", "T"]] + [["T", "S"]] ), # An absent-minded driver game with an unreachable subtree ( "AM-driver-subgame.efg", - [["S", "T"], ["S", "T", "r"], ["S", "T", "l"]] + [["T", "S"], ["r", "T", "S"], ["l", "T", "S"]] ), ]) def test_is_strategy_reachable(game_file: str, expected_unreachable_paths: list[list[str]]): From de5b63dec13d8727adfc8de90d12657e8aecf86b Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 28 Nov 2025 08:58:44 +0000 Subject: [PATCH 03/10] Small fixes --- src/games/gametree.cc | 8 ++++---- src/games/gametree.h | 1 - src/pygambit/infoset.pxi | 12 ++++++------ tests/test_infosets.py | 1 - tests/test_node.py | 2 +- 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 2f9d15abe..ce0439ab4 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -943,7 +943,7 @@ void GameTreeRep::BuildOwnPriorActions() GameAction GameTreeRep::GetOwnPriorAction(GameNode node) const { - if (const_cast(this)->m_nodeOwnPriorAction.empty()) { + if (m_nodeOwnPriorAction.empty()) { const_cast(this)->BuildOwnPriorActions(); } @@ -956,14 +956,14 @@ GameAction GameTreeRep::GetOwnPriorAction(GameNode node) const return nullptr; } -std::set GameTreeRep::GetOwnPriorActions(GameInfoset iset) const +std::set GameTreeRep::GetOwnPriorActions(GameInfoset infoset) const { - if (const_cast(this)->m_nodeOwnPriorAction.empty()) { + if (m_nodeOwnPriorAction.empty()) { const_cast(this)->BuildOwnPriorActions(); } std::set result; - auto it = m_infosetOwnPriorActions.find(iset.get()); + auto it = m_infosetOwnPriorActions.find(infoset.get()); if (it != m_infosetOwnPriorActions.end()) { for (auto *ptr : it->second) { diff --git a/src/games/gametree.h b/src/games/gametree.h index 1fb1a9c35..a0d37904d 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -42,7 +42,6 @@ class GameTreeRep : public GameExplicitRep { std::map m_nodeOwnPriorAction; std::map> m_infosetOwnPriorActions; mutable std::unique_ptr> m_unreachableNodes; - mutable std::unique_ptr> m_unreachableNodes_old; /// @name Private auxiliary functions //@{ diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index 3146d27e4..df0d8be4e 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -175,17 +175,17 @@ class Infoset: is reached without the player having moved previously, None will be included in the set. """ - cdef stdset[c_GameAction] c_actions = self.infoset.deref().GetOwnPriorActions() - cdef c_GameAction action - py_result = set() + c_actions: stdset[c_GameAction] = self.infoset.deref().GetOwnPriorActions() + action: c_GameAction + result = set() for action in c_actions: if action != cython.cast(c_GameAction, NULL): - py_result.add(Action.wrap(action)) + result.add(Action.wrap(action)) else: - py_result.add(None) + result.add(None) - return py_result + return result @property def members(self) -> InfosetMembers: diff --git a/tests/test_infosets.py b/tests/test_infosets.py index fedda1954..2e90243cb 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -156,7 +156,6 @@ def test_infoset_own_prior_actions(game_file, expected_results): actual_actions = infoset.own_prior_actions - # Updated to capture player.label instead of player.number actual_details = { (a.infoset.player.label, a.infoset.number, a.label) if a is not None else None for a in actual_actions diff --git a/tests/test_node.py b/tests/test_node.py index 6d713c9b7..c9eef0c31 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -201,7 +201,7 @@ def test_node_own_prior_action_non_terminal(game_file, expected_node_data): assert node.own_prior_action is None, \ f"Terminal node at {_get_path_of_action_labels(node)} must be None" else: - # Collection: Only collect data for non-terminal nodes + # Only collect data for non-terminal nodes opa = node.own_prior_action details = ( (opa.infoset.player.label, opa.infoset.number, opa.label) From fc88f070edd6430c6843122e756c60c68c3069e8 Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 1 Dec 2025 08:09:44 +0000 Subject: [PATCH 04/10] Update ChangeLog and pygambit.api.rst --- ChangeLog | 4 ++++ doc/pygambit.api.rst | 2 ++ 2 files changed, 6 insertions(+) diff --git a/ChangeLog b/ChangeLog index 6a0fe36e2..67f0828d2 100644 --- a/ChangeLog +++ b/ChangeLog @@ -10,6 +10,10 @@ ### Added - Tests for EFG Nash solvers -- `enumpoly_solve`, `lp_solve`, `lcp_solve` -- in behavior stratgegies +- In `pygambit`, `Node` objects now have a read-only property `own_prior_action` and + `Infoset` objects have a read-only property `own_prior_actions` to retrieve the last action + or the set of last actions taken by the player before reaching the node or information set, respectively. + Returns `None` if no prior action exists. (#582) - In `pygambit`, `Node` objects now have a read-only property `is_strategy_reachable` to determine if the node is reachable by at least one pure strategy profile. This proves useful for identifying unreachable parts of the game tree in games with absent-mindedness. (#629) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 17edddeb5..4b74f40f7 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -149,6 +149,7 @@ Information about the game Node.player Node.is_successor_of Node.plays + Node.own_prior_action .. autosummary:: @@ -162,6 +163,7 @@ Information about the game Infoset.members Infoset.precedes Infoset.plays + Infoset.own_prior_actions .. autosummary:: From 939f7fe5997306a2eab92b65c53c023b5945f42e Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 1 Dec 2025 11:33:27 +0000 Subject: [PATCH 05/10] Update ChangeLog and tests --- ChangeLog | 7 +++---- tests/test_node.py | 3 ++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ChangeLog b/ChangeLog index 67f0828d2..3a4f9cf3d 100644 --- a/ChangeLog +++ b/ChangeLog @@ -10,10 +10,9 @@ ### Added - Tests for EFG Nash solvers -- `enumpoly_solve`, `lp_solve`, `lcp_solve` -- in behavior stratgegies -- In `pygambit`, `Node` objects now have a read-only property `own_prior_action` and - `Infoset` objects have a read-only property `own_prior_actions` to retrieve the last action - or the set of last actions taken by the player before reaching the node or information set, respectively. - Returns `None` if no prior action exists. (#582) +- In `pygambit`, `Node` objects now have a read-only property `own_prior_action` and `Infoset` objects + have a read-only property `own_prior_actions` to retrieve the last action or the set of last actions + taken by the player before reaching the node or information set, respectively. (#582) - In `pygambit`, `Node` objects now have a read-only property `is_strategy_reachable` to determine if the node is reachable by at least one pure strategy profile. This proves useful for identifying unreachable parts of the game tree in games with absent-mindedness. (#629) diff --git a/tests/test_node.py b/tests/test_node.py index c9eef0c31..752574f80 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -198,8 +198,9 @@ def test_node_own_prior_action_non_terminal(game_file, expected_node_data): for node in game.nodes: if node.is_terminal: - assert node.own_prior_action is None, \ + assert node.own_prior_action is None, ( f"Terminal node at {_get_path_of_action_labels(node)} must be None" + ) else: # Only collect data for non-terminal nodes opa = node.own_prior_action From bf187133af77d8b0883af3a9d6c14aafef06d04f Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 1 Dec 2025 11:47:02 +0000 Subject: [PATCH 06/10] Move GetOwnPriorAction interface to GameRep class --- src/games/game.h | 10 ++++++++++ src/games/gametree.cc | 38 +++++++++----------------------------- src/games/gametree.h | 4 ++-- 3 files changed, 21 insertions(+), 31 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index 82856f521..9c3e6e4cf 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -902,6 +902,11 @@ class GameRep : public std::enable_shared_from_this { virtual std::vector GetInfosets() const { throw UndefinedException(); } /// Sort the information sets for each player in a canonical order virtual void SortInfosets() {} + /// Returns the set of actions taken by the infoset's owner before reaching this infoset + virtual std::set GetOwnPriorActions(const GameInfoset &p_infoset) const + { + throw UndefinedException(); + } //@} /// @name Outcomes @@ -929,6 +934,11 @@ class GameRep : public std::enable_shared_from_this { virtual size_t NumNodes() const = 0; /// Returns the number of non-terminal nodes in the game virtual size_t NumNonterminalNodes() const = 0; + /// Returns the last action taken by the node's owner before reaching this node + virtual GameAction GetOwnPriorAction(const GameNode &p_node) const + { + throw UndefinedException(); + } //@} /// @name Modification diff --git a/src/games/gametree.cc b/src/games/gametree.cc index ce0439ab4..29258791b 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -325,17 +325,12 @@ GameAction GameNodeRep::GetPriorAction() const GameAction GameNodeRep::GetOwnPriorAction() const { - auto tree_game = static_cast(m_game); - - return tree_game->GetOwnPriorAction(std::const_pointer_cast(shared_from_this())); + return m_game->GetOwnPriorAction(std::const_pointer_cast(shared_from_this())); } std::set GameInfosetRep::GetOwnPriorActions() const { - auto tree_game = static_cast(m_game); - - return tree_game->GetOwnPriorActions( - std::const_pointer_cast(shared_from_this())); + return m_game->GetOwnPriorActions(std::const_pointer_cast(shared_from_this())); } void GameNodeRep::DeleteOutcome(GameOutcomeRep *outc) @@ -941,38 +936,31 @@ void GameTreeRep::BuildOwnPriorActions() } } -GameAction GameTreeRep::GetOwnPriorAction(GameNode node) const +GameAction GameTreeRep::GetOwnPriorAction(const GameNode &p_node) const { if (m_nodeOwnPriorAction.empty()) { const_cast(this)->BuildOwnPriorActions(); } - auto it = m_nodeOwnPriorAction.find(node.get()); - if (it != m_nodeOwnPriorAction.end()) { - if (it->second) { - return it->second->shared_from_this(); - } + auto it = m_nodeOwnPriorAction.find(p_node.get()); + if (it != m_nodeOwnPriorAction.end() && it->second) { + return it->second->shared_from_this(); } return nullptr; } -std::set GameTreeRep::GetOwnPriorActions(GameInfoset infoset) const +std::set GameTreeRep::GetOwnPriorActions(const GameInfoset &p_infoset) const { if (m_nodeOwnPriorAction.empty()) { const_cast(this)->BuildOwnPriorActions(); } std::set result; - auto it = m_infosetOwnPriorActions.find(infoset.get()); + auto it = m_infosetOwnPriorActions.find(p_infoset.get()); if (it != m_infosetOwnPriorActions.end()) { for (auto *ptr : it->second) { - if (ptr) { - result.insert(ptr->shared_from_this()); - } - else { - result.insert(nullptr); - } + result.insert(ptr ? ptr->shared_from_this() : nullptr); } } return result; @@ -990,9 +978,7 @@ void GameTreeRep::BuildUnreachableNodes() using ActiveEdge = std::variant; std::stack position; - std::map path_choices; - position.emplace(m_root->GetActions().begin()); while (!position.empty()) { @@ -1024,24 +1010,18 @@ void GameTreeRep::BuildUnreachableNodes() if (!child->IsTerminal()) { // Check for Absent-Minded Re-entry of the infoset if (path_choices.find(child->m_infoset->shared_from_this()) != path_choices.end()) { - const GameAction replay_action = path_choices.at(child->m_infoset->shared_from_this()); - position.emplace(AbsentMindedEdge{replay_action, child}); // Mark siblings and the nodes in their subtrees as unreachable for (const auto &[current_action, subtree_root] : child->GetActions()) { if (current_action != replay_action) { - std::stack nodes_to_visit; nodes_to_visit.push(subtree_root.get()); - while (!nodes_to_visit.empty()) { GameNodeRep *current_unreachable_node = nodes_to_visit.top(); nodes_to_visit.pop(); - m_unreachableNodes->insert(current_unreachable_node); - for (const auto &unreachable_child : current_unreachable_node->GetChildren()) { nodes_to_visit.push(unreachable_child.get()); } diff --git a/src/games/gametree.h b/src/games/gametree.h index a0d37904d..75af95ce2 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -95,7 +95,7 @@ class GameTreeRep : public GameExplicitRep { /// Returns the number of non-terminal nodes in the game size_t NumNonterminalNodes() const override { return m_numNonterminalNodes; } /// Returns the last action taken by the node's owner before reaching this node - GameAction GetOwnPriorAction(GameNode node) const; + GameAction GetOwnPriorAction(const GameNode &p_node) const override; //@} void DeleteOutcome(const GameOutcome &) override; @@ -121,7 +121,7 @@ class GameTreeRep : public GameExplicitRep { /// Sort the information sets for each player in a canonical order void SortInfosets() override; /// Returns the set of actions taken by the infoset's owner before reaching this infoset - std::set GetOwnPriorActions(GameInfoset infoset) const; + std::set GetOwnPriorActions(const GameInfoset &p_infoset) const override; //@} /// @name Modification From 6b54f0f89c27b6d70a1db5f5ab30a41e035b979f Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 1 Dec 2025 11:47:27 +0000 Subject: [PATCH 07/10] Update Python properties to return list instead of set --- src/pygambit/infoset.pxi | 24 +++++++++++------------- src/pygambit/node.pxi | 6 +++++- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index df0d8be4e..d17b441ad 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -167,25 +167,23 @@ class Infoset: return InfosetActions.wrap(self.infoset) @property - def own_prior_actions(self) -> typing.Set[typing.Optional[Action]]: + def own_prior_actions(self) -> typing.List[typing.Optional[Action]]: """The set of actions taken by the player immediately preceding the member nodes in the information set. - Returns a set containing Action objects. If a node in the information set - is reached without the player having moved previously, None will be - included in the set. + Returns + ------- + list of Action or None + A list containing Action objects. If a node in the information set + is reached without the player having moved previously, None will be + included in the list. """ c_actions: stdset[c_GameAction] = self.infoset.deref().GetOwnPriorActions() - action: c_GameAction - result = set() - for action in c_actions: - if action != cython.cast(c_GameAction, NULL): - result.add(Action.wrap(action)) - else: - result.add(None) - - return result + return [ + Action.wrap(action) if action != cython.cast(c_GameAction, NULL) else None + for action in c_actions + ] @property def members(self) -> InfosetMembers: diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 2779456b0..0f7dd8e56 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -180,7 +180,11 @@ class Node: def own_prior_action(self) -> typing.Optional[Action]: """The last action taken by the node's owner before reaching this node. - If the player has not moved previously on the path to this node, None is returned. + Returns + ------- + Action or None + The action object, or None if the player has not moved previously + on the path to this node. """ if self.node.deref().GetOwnPriorAction() != cython.cast(c_GameAction, NULL): return Action.wrap(self.node.deref().GetOwnPriorAction()) From f9aae5b56ba24ae51e2d1ce808e432cd7127e325 Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 1 Dec 2025 12:43:31 +0000 Subject: [PATCH 08/10] =?UTF-8?q?Add=20OwnPriorActionInfo;=C2=A0use=20muta?= =?UTF-8?q?ble=20std::shared=5Fptr;=C2=A0remove=20const=5Fcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/games/gametree.cc | 41 +++++++++++++++++++++-------------------- src/games/gametree.h | 11 ++++++++--- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 29258791b..a0c7ae048 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -760,15 +760,16 @@ bool GameTreeRep::IsConstSum() const bool GameTreeRep::IsPerfectRecall() const { - if (m_nodeOwnPriorAction.empty() && !m_root->IsTerminal()) { - const_cast(this)->BuildOwnPriorActions(); + if (!m_ownPriorActionInfo && !m_root->IsTerminal()) { + BuildOwnPriorActions(); } if (GetRoot()->IsTerminal()) { return true; } - return std::all_of(m_infosetOwnPriorActions.cbegin(), m_infosetOwnPriorActions.cend(), + return std::all_of(m_ownPriorActionInfo->infoset_map.cbegin(), + m_ownPriorActionInfo->infoset_map.cend(), [](const auto &pair) { return pair.second.size() <= 1; }); } @@ -821,8 +822,7 @@ void GameTreeRep::ClearComputedValues() const player->m_strategies.clear(); } const_cast(this)->m_nodePlays.clear(); - const_cast(this)->m_nodeOwnPriorAction.clear(); - const_cast(this)->m_infosetOwnPriorActions.clear(); + m_ownPriorActionInfo = nullptr; const_cast(this)->m_unreachableNodes = nullptr; m_computedValues = false; } @@ -864,18 +864,18 @@ std::vector GameTreeRep::BuildConsistentPlaysRecursiveImpl(GameNo return consistent_plays; } -void GameTreeRep::BuildOwnPriorActions() +void GameTreeRep::BuildOwnPriorActions() const { - m_nodeOwnPriorAction.clear(); - m_infosetOwnPriorActions.clear(); + auto info = std::make_shared(); if (m_root->IsTerminal()) { + m_ownPriorActionInfo = info; return; } - m_nodeOwnPriorAction[m_root.get()] = nullptr; + info->node_map[m_root.get()] = nullptr; if (m_root->m_infoset) { - m_infosetOwnPriorActions[m_root->m_infoset].insert(nullptr); + info->infoset_map[m_root->m_infoset].insert(nullptr); } using ActiveEdge = GameNodeRep::Actions::iterator; @@ -923,8 +923,8 @@ void GameTreeRep::BuildOwnPriorActions() auto prior_action = prior_actions.at(child_player).top(); GameActionRep *raw_prior = prior_action ? prior_action.get() : nullptr; - m_nodeOwnPriorAction[child.get()] = raw_prior; - m_infosetOwnPriorActions[child->m_infoset].insert(raw_prior); + info->node_map[child.get()] = raw_prior; + info->infoset_map[child->m_infoset].insert(raw_prior); position.emplace(child->GetActions().begin()); prior_actions.at(child_player).emplace(nullptr); @@ -934,16 +934,17 @@ void GameTreeRep::BuildOwnPriorActions() } } } + m_ownPriorActionInfo = info; } GameAction GameTreeRep::GetOwnPriorAction(const GameNode &p_node) const { - if (m_nodeOwnPriorAction.empty()) { - const_cast(this)->BuildOwnPriorActions(); + if (!m_ownPriorActionInfo) { + BuildOwnPriorActions(); } - auto it = m_nodeOwnPriorAction.find(p_node.get()); - if (it != m_nodeOwnPriorAction.end() && it->second) { + auto it = m_ownPriorActionInfo->node_map.find(p_node.get()); + if (it != m_ownPriorActionInfo->node_map.end() && it->second) { return it->second->shared_from_this(); } return nullptr; @@ -951,14 +952,14 @@ GameAction GameTreeRep::GetOwnPriorAction(const GameNode &p_node) const std::set GameTreeRep::GetOwnPriorActions(const GameInfoset &p_infoset) const { - if (m_nodeOwnPriorAction.empty()) { - const_cast(this)->BuildOwnPriorActions(); + if (!m_ownPriorActionInfo) { + BuildOwnPriorActions(); } std::set result; - auto it = m_infosetOwnPriorActions.find(p_infoset.get()); + auto it = m_ownPriorActionInfo->infoset_map.find(p_infoset.get()); - if (it != m_infosetOwnPriorActions.end()) { + if (it != m_ownPriorActionInfo->infoset_map.end()) { for (auto *ptr : it->second) { result.insert(ptr ? ptr->shared_from_this() : nullptr); } diff --git a/src/games/gametree.h b/src/games/gametree.h index 75af95ce2..9320e291d 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -32,6 +32,12 @@ class GameTreeRep : public GameExplicitRep { friend class GameInfosetRep; friend class GameActionRep; +private: + struct OwnPriorActionInfo { + std::map node_map; + std::map> infoset_map; + }; + protected: mutable bool m_computedValues{false}; std::shared_ptr m_root; @@ -39,8 +45,7 @@ class GameTreeRep : public GameExplicitRep { std::size_t m_numNodes = 1; std::size_t m_numNonterminalNodes = 0; std::map> m_nodePlays; - std::map m_nodeOwnPriorAction; - std::map> m_infosetOwnPriorActions; + mutable std::shared_ptr m_ownPriorActionInfo; mutable std::unique_ptr> m_unreachableNodes; /// @name Private auxiliary functions @@ -160,7 +165,7 @@ class GameTreeRep : public GameExplicitRep { private: std::vector BuildConsistentPlaysRecursiveImpl(GameNodeRep *node); - void BuildOwnPriorActions(); + void BuildOwnPriorActions() const; void BuildUnreachableNodes(); }; From e80f2bd50ed7e5f5cb4108033428c053ff462abc Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 2 Dec 2025 09:52:07 +0000 Subject: [PATCH 09/10] Move variable declarations to point of initialization, use structured binding --- src/games/gametree.cc | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index a0c7ae048..ab203d6c8 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -895,11 +895,7 @@ void GameTreeRep::BuildOwnPriorActions() const while (!position.empty()) { ActiveEdge ¤t_edge = position.top(); - - GameNode child, node; - GameAction action; - - node = current_edge.GetOwner(); + auto node = current_edge.GetOwner(); if (current_edge == node->GetActions().end()) { if (node->m_infoset) { @@ -908,10 +904,9 @@ void GameTreeRep::BuildOwnPriorActions() const position.pop(); continue; } - else { - std::tie(action, child) = *current_edge; - ++current_edge; - } + + auto [action, child] = *current_edge; + ++current_edge; if (node->m_infoset) { prior_actions.at(node->m_infoset->m_player->shared_from_this()).top() = action; From fd3623095cb1858bc7b734ad8e257d5a9bc869a6 Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 2 Dec 2025 09:54:07 +0000 Subject: [PATCH 10/10] Add cross references --- src/pygambit/infoset.pxi | 5 +++++ src/pygambit/node.pxi | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index d17b441ad..1b4a94236 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -177,6 +177,11 @@ class Infoset: A list containing Action objects. If a node in the information set is reached without the player having moved previously, None will be included in the list. + .. versionadded:: 16.5.0 + + See Also + -------- + Node.own_prior_action """ c_actions: stdset[c_GameAction] = self.infoset.deref().GetOwnPriorActions() diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 0f7dd8e56..e5663d4bc 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -185,6 +185,11 @@ class Node: Action or None The action object, or None if the player has not moved previously on the path to this node. + .. versionadded:: 16.5.0 + + See Also + -------- + Infoset.own_prior_actions """ if self.node.deref().GetOwnPriorAction() != cython.cast(c_GameAction, NULL): return Action.wrap(self.node.deref().GetOwnPriorAction())