diff --git a/ChangeLog b/ChangeLog index 73cda989a..c57a14287 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 `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) ## [16.4.1] - unreleased diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 185f69335..17edddeb5 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -141,6 +141,7 @@ Information about the game Node.parent Node.is_subgame_root Node.is_terminal + Node.is_strategy_reachable Node.prior_action Node.prior_sibling Node.next_sibling diff --git a/src/games/game.h b/src/games/game.h index 2af71a604..9dc724967 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -500,6 +500,7 @@ class GameNodeRep : public std::enable_shared_from_this { bool IsSuccessorOf(GameNode from) const; bool IsSubgameRoot() const; + bool IsStrategyReachable() const; }; class GameNodeRep::Actions::iterator { diff --git a/src/games/gametree.cc b/src/games/gametree.cc index de0c008a2..a98c5b216 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -388,6 +388,18 @@ bool GameNodeRep::IsSubgameRoot() const return true; } +bool GameNodeRep::IsStrategyReachable() const +{ + auto tree_game = static_cast(m_game); + + if (!tree_game->m_unreachableNodes) { + tree_game->BuildInfosetParents(); + } + + // A node is reachable if it is NOT in the set of unreachable nodes. + return !contains(*tree_game->m_unreachableNodes, const_cast(this)); +} + void GameTreeRep::DeleteParent(GameNode p_node) { if (p_node->m_game != this) { @@ -800,6 +812,7 @@ void GameTreeRep::ClearComputedValues() const } const_cast(this)->m_nodePlays.clear(); const_cast(this)->m_infosetParents.clear(); + const_cast(this)->m_unreachableNodes = nullptr; m_computedValues = false; } @@ -842,6 +855,9 @@ std::vector GameTreeRep::BuildConsistentPlaysRecursiveImpl(GameNo void GameTreeRep::BuildInfosetParents() { + m_infosetParents.clear(); + m_unreachableNodes = std::make_unique>(); + if (m_root->IsTerminal()) { m_infosetParents[m_root->m_infoset].insert(nullptr); return; @@ -893,7 +909,6 @@ void GameTreeRep::BuildInfosetParents() } 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(); @@ -902,6 +917,26 @@ void GameTreeRep::BuildInfosetParents() 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 + 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()); diff --git a/src/games/gametree.h b/src/games/gametree.h index 085cd557b..e5249488b 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -40,6 +40,7 @@ class GameTreeRep : public GameExplicitRep { std::size_t m_numNonterminalNodes = 0; std::map> m_nodePlays; std::map> m_infosetParents; + mutable std::unique_ptr> m_unreachableNodes; /// @name Private auxiliary functions //@{ diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index e492d9c00..b537967b8 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -218,6 +218,7 @@ cdef extern from "games/game.h": bint IsTerminal() except + bint IsSuccessorOf(c_GameNode) except + bint IsSubgameRoot() except + + bint IsStrategyReachable() except + c_GameAction GetPriorAction() except + cdef cppclass c_GameRep "GameRep": diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index d13be56b6..ac748e16b 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -212,6 +212,19 @@ class Node: """ return self.node.deref().IsSubgameRoot() + @property + def is_strategy_reachable(self) -> bool: + """Returns whether this node is reachable by any pure strategy profile. + + A node is considered reachable if there exists at least one pure + strategy profile where the resulting path of play passes + through that node. + + In games with absent-mindedness, some nodes may be unreachable because + any path to them requires conflicting choices at the same information set. + """ + return self.node.deref().IsStrategyReachable() + @property def outcome(self) -> typing.Optional[Outcome]: """Returns the outcome attached to the node. diff --git a/tests/test_games/AM-driver-one-infoset.efg b/tests/test_games/AM-driver-one-infoset.efg new file mode 100644 index 000000000..1544a8dd4 --- /dev/null +++ b/tests/test_games/AM-driver-one-infoset.efg @@ -0,0 +1,8 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +p "" 1 1 "" { "S" "T" } 0 +p "" 1 1 "" { "S" "T" } 0 +t "" 1 "Outcome 1" { 1, -1 } +t "" 2 "Outcome 2" { 2, -2 } +t "" 3 "Outcome 3" { 3, -3 } diff --git a/tests/test_games/AM-driver-subgame.efg b/tests/test_games/AM-driver-subgame.efg index 3244fc8b0..1669a23ea 100644 --- a/tests/test_games/AM-driver-subgame.efg +++ b/tests/test_games/AM-driver-subgame.efg @@ -1,10 +1,10 @@ EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } "" -p "" 1 1 "" { "1" "2" } 0 -p "" 1 1 "" { "1" "2" } 0 +p "" 1 1 "" { "S" "T" } 0 +p "" 1 1 "" { "S" "T" } 0 t "" 1 "Outcome 1" { 1, -1 } -p "" 2 1 "" { "1" "2" } 0 +p "" 2 1 "" { "r" "l" } 0 t "" 2 "Outcome 2" { 2, -2 } t "" 3 "Outcome 3" { 3, -3 } t "" 4 "Outcome 4" { 4, -4 } diff --git a/tests/test_node.py b/tests/test_node.py index 0e063ed95..09d1d4dfb 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -112,6 +112,57 @@ def test_legacy_is_subgame_root_set(game: gbt.Game, expected_result: set): assert legacy_roots == expected_roots +def _get_path_of_action_labels(node: gbt.Node) -> list[str]: + """ + Computes the path of action labels from the root to the given node. + Returns a list of strings. + """ + if not isinstance(node, gbt.Node): + raise TypeError(f"Input must be a pygambit.Node, but got {type(node).__name__}") + + path = [] + current_node = node + while current_node.parent: + path.append(current_node.prior_action.label) + current_node = current_node.parent + + return path[::-1] + + +@pytest.mark.parametrize("game_file, expected_unreachable_paths", [ + # Games without absent-mindedness, where all nodes are reachable + ("e02.efg", []), + ("wichardt.efg", []), + ("subgames.efg", []), + + # An absent-minded driver game with an unreachable terminal node + ( + "AM-driver-one-infoset.efg", + [["S", "T"]] + ), + + # An absent-minded driver game with an unreachable subtree + ( + "AM-driver-subgame.efg", + [["S", "T"], ["S", "T", "r"], ["S", "T", "l"]] + ), +]) +def test_is_strategy_reachable(game_file: str, expected_unreachable_paths: list[list[str]]): + """ + Tests `node.is_strategy_reachable` by collecting all unreachable nodes, + converting them to their action-label paths, and comparing the resulting + list of paths against a known-correct list. + """ + game = games.read_from_file(game_file) + nodes = game.nodes + + actual_unreachable_paths = [ + _get_path_of_action_labels(node) for node in nodes if not node.is_strategy_reachable + ] + + assert actual_unreachable_paths == expected_unreachable_paths + + def test_append_move_error_player_actions(): """Test to ensure there are actions when appending with a player""" game = games.read_from_file("basic_extensive_game.efg")