diff --git a/ChangeLog b/ChangeLog index 6a0fe36e2..3a4f9cf3d 100644 --- a/ChangeLog +++ b/ChangeLog @@ -10,6 +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. (#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:: diff --git a/src/games/game.h b/src/games/game.h index 9dc724967..9c3e6e4cf 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; @@ -899,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 @@ -926,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 a98c5b216..ab203d6c8 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -323,6 +323,16 @@ GameAction GameNodeRep::GetPriorAction() const return nullptr; } +GameAction GameNodeRep::GetOwnPriorAction() const +{ + return m_game->GetOwnPriorAction(std::const_pointer_cast(shared_from_this())); +} + +std::set GameInfosetRep::GetOwnPriorActions() const +{ + return m_game->GetOwnPriorActions(std::const_pointer_cast(shared_from_this())); +} + void GameNodeRep::DeleteOutcome(GameOutcomeRep *outc) { m_game->IncrementVersion(); @@ -393,7 +403,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 +760,16 @@ bool GameTreeRep::IsConstSum() const bool GameTreeRep::IsPerfectRecall() const { - if (m_infosetParents.empty() && !m_root->IsTerminal()) { - const_cast(this)->BuildInfosetParents(); + if (!m_ownPriorActionInfo && !m_root->IsTerminal()) { + BuildOwnPriorActions(); } if (GetRoot()->IsTerminal()) { return true; } - return std::all_of(m_infosetParents.cbegin(), m_infosetParents.cend(), + return std::all_of(m_ownPriorActionInfo->infoset_map.cbegin(), + m_ownPriorActionInfo->infoset_map.cend(), [](const auto &pair) { return pair.second.size() <= 1; }); } @@ -811,7 +822,7 @@ void GameTreeRep::ClearComputedValues() const player->m_strategies.clear(); } const_cast(this)->m_nodePlays.clear(); - const_cast(this)->m_infosetParents.clear(); + m_ownPriorActionInfo = nullptr; const_cast(this)->m_unreachableNodes = nullptr; m_computedValues = false; } @@ -853,22 +864,24 @@ std::vector GameTreeRep::BuildConsistentPlaysRecursiveImpl(GameNo return consistent_plays; } -void GameTreeRep::BuildInfosetParents() +void GameTreeRep::BuildOwnPriorActions() const { - m_infosetParents.clear(); - m_unreachableNodes = std::make_unique>(); + auto info = std::make_shared(); if (m_root->IsTerminal()) { - m_infosetParents[m_root->m_infoset].insert(nullptr); + m_ownPriorActionInfo = info; return; } - using AbsentMindedEdge = std::pair; - using ActiveEdge = std::variant; - std::stack position; + info->node_map[m_root.get()] = nullptr; + if (m_root->m_infoset) { + info->infoset_map[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 +889,94 @@ 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(); + auto 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; + } + + auto [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; + + 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); + } + else { + position.emplace(child->GetActions().begin()); + } + } + } + m_ownPriorActionInfo = info; +} + +GameAction GameTreeRep::GetOwnPriorAction(const GameNode &p_node) const +{ + if (!m_ownPriorActionInfo) { + BuildOwnPriorActions(); + } + + 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; +} + +std::set GameTreeRep::GetOwnPriorActions(const GameInfoset &p_infoset) const +{ + if (!m_ownPriorActionInfo) { + BuildOwnPriorActions(); + } + + std::set result; + auto it = m_ownPriorActionInfo->infoset_map.find(p_infoset.get()); + + if (it != m_ownPriorActionInfo->infoset_map.end()) { + for (auto *ptr : it->second) { + result.insert(ptr ? ptr->shared_from_this() : 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 +987,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,40 +1003,31 @@ 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) { - 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()); } } } } - // 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..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,7 +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_infosetParents; + mutable std::shared_ptr m_ownPriorActionInfo; mutable std::unique_ptr> m_unreachableNodes; /// @name Private auxiliary functions @@ -93,6 +99,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(const GameNode &p_node) const override; //@} void DeleteOutcome(const GameOutcome &) override; @@ -117,6 +125,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(const GameInfoset &p_infoset) const override; //@} /// @name Modification @@ -155,7 +165,8 @@ class GameTreeRep : public GameExplicitRep { private: std::vector BuildConsistentPlaysRecursiveImpl(GameNodeRep *node); - void BuildInfosetParents(); + void BuildOwnPriorActions() const; + 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..1b4a94236 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -166,6 +166,30 @@ class Infoset: """The set of actions at the information set.""" return InfosetActions.wrap(self.infoset) + @property + 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 + ------- + 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. + .. versionadded:: 16.5.0 + + See Also + -------- + Node.own_prior_action + """ + c_actions: stdset[c_GameAction] = self.infoset.deref().GetOwnPriorActions() + + return [ + Action.wrap(action) if action != cython.cast(c_GameAction, NULL) else None + for action in c_actions + ] + @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..e5663d4bc 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -176,6 +176,25 @@ 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. + + Returns + ------- + 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()) + return None + @property def prior_sibling(self) -> typing.Optional[Node]: """The node which is immediately before this one in its parent's children. diff --git a/tests/test_infosets.py b/tests/test_infosets.py index a6f5b6d6a..2e90243cb 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -69,3 +69,96 @@ 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 + + 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..752574f80 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -126,7 +126,91 @@ 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: + # 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 +222,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]]):