From 1098403e64a4c32e2e44f14181f2881eb085738e Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 19 Nov 2025 17:44:42 +0000 Subject: [PATCH 01/11] Add tests and a new game --- tests/test_games/AM-driver-one-infoset.efg | 8 ++++++++ tests/test_node.py | 23 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 tests/test_games/AM-driver-one-infoset.efg 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..b055ea7ab --- /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 "" { "1" "2" } 0 +p "" 1 1 "" { "1" "2" } 0 +t "" 1 "Outcome 1" { 1, -1 } +t "" 2 "Outcome 2" { 2, -2 } +t "" 3 "Outcome 3" { 3, -3 } diff --git a/tests/test_node.py b/tests/test_node.py index 0e063ed95..0820f2b03 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -112,6 +112,29 @@ def test_legacy_is_subgame_root_set(game: gbt.Game, expected_result: set): assert legacy_roots == expected_roots +@pytest.mark.parametrize("game, expected_reachable_indices", [ + # Games without Absent-Mindedness where all nodes are reachable. + (games.read_from_file("e02.efg"), set(range(7))), + (games.read_from_file("wichardt.efg"), set(range(15))), + (games.read_from_file("subgames.efg"), set(range(37))), + + # Games with absent-mindedness where some nodes are unreachable. + (games.read_from_file("AM-driver-one-infoset.efg"), {0, 1, 2, 4}), + (games.read_from_file("AM-driver-subgame.efg"), {0, 1, 2, 6}), +]) +def test_is_strategy_reachable(game: gbt.Game, expected_reachable_indices: set): + """ + Tests `node.is_strategy_reachable` by comparing the set of reachable nodes + against a pre-computed set of node indices. + """ + all_nodes = list(game.nodes) + + expected_reachable = {all_nodes[i] for i in expected_reachable_indices} + actual_reachable = {node for node in all_nodes if node.is_strategy_reachable} + + assert actual_reachable == expected_reachable + + 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") From 3b3c40af462d2fd5a6f43f33202791b2de89088f Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 19 Nov 2025 17:48:46 +0000 Subject: [PATCH 02/11] Update ChangeLog/api.rst --- ChangeLog | 3 +++ doc/pygambit.api.rst | 1 + 2 files changed, 4 insertions(+) diff --git a/ChangeLog b/ChangeLog index 73cda989a..3a8da996b 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 is particularly useful for + identifying unreachable parts of the game tree in games with absent-mindedness. ## [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 From 452551d226a32b4fe31a01c7a8e42e44a5bd9abc Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 20 Nov 2025 07:35:16 +0000 Subject: [PATCH 03/11] Add m_reachableNodes to GameTreeRep and populate it during the BuildInfosetParents traversal. Introduce the public method GameNodeRep::IsStrategyReachable() to query the cache. --- src/games/game.h | 1 + src/games/gametree.cc | 18 +++++++++++++++++- src/games/gametree.h | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) 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..773db2217 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_reachableNodes.empty() && !tree_game->GetRoot()->IsTerminal()) { + tree_game->BuildInfosetParents(); + } + + return tree_game->m_reachableNodes.find(const_cast(this)) != + tree_game->m_reachableNodes.end(); +} + 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_reachableNodes.clear(); m_computedValues = false; } @@ -842,6 +855,9 @@ std::vector GameTreeRep::BuildConsistentPlaysRecursiveImpl(GameNo void GameTreeRep::BuildInfosetParents() { + m_reachableNodes.clear(); + m_reachableNodes.insert(m_root.get()); + if (m_root->IsTerminal()) { m_infosetParents[m_root->m_infoset].insert(nullptr); return; @@ -893,7 +909,7 @@ void GameTreeRep::BuildInfosetParents() } prior_actions.at(node->m_infoset->m_player->shared_from_this()).top() = action; - + m_reachableNodes.insert(child.get()); if (!child->IsTerminal()) { auto child_player = child->m_infoset->m_player->shared_from_this(); auto prior_action = prior_actions.at(child_player).top(); diff --git a/src/games/gametree.h b/src/games/gametree.h index 085cd557b..e2e37c4f0 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::set m_reachableNodes; /// @name Private auxiliary functions //@{ From 2cca3f5d058bb3de1dc9337c9f48818a6db05c51 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 20 Nov 2025 07:36:50 +0000 Subject: [PATCH 04/11] Expose the C++ IsStrategyReachable method to Python as a read-only property on the pygambit.Node class. --- src/pygambit/gambit.pxd | 1 + src/pygambit/node.pxi | 13 +++++++++++++ 2 files changed, 14 insertions(+) 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. From 295532f76e3f1da84681d6e914b4a37a2578c373 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 20 Nov 2025 08:06:20 +0000 Subject: [PATCH 05/11] Update ChangeLog --- ChangeLog | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ChangeLog b/ChangeLog index 3a8da996b..c57a14287 100644 --- a/ChangeLog +++ b/ChangeLog @@ -11,8 +11,8 @@ ### 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 is particularly useful for - identifying unreachable parts of the game tree in games with absent-mindedness. + 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 From 44c58cb686e929f4de8d372dc496a7309ea61b96 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 20 Nov 2025 18:54:00 +0000 Subject: [PATCH 06/11] Invert reachability logic for space efficiency --- src/games/gametree.cc | 36 +++++++++++++++++++++++++++++------- src/games/gametree.h | 2 +- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 773db2217..4a23f2429 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -392,12 +392,13 @@ bool GameNodeRep::IsStrategyReachable() const { auto tree_game = static_cast(m_game); - if (tree_game->m_reachableNodes.empty() && !tree_game->GetRoot()->IsTerminal()) { + if (tree_game->m_unreachableNodes.empty() && !tree_game->GetRoot()->IsTerminal()) { tree_game->BuildInfosetParents(); } - return tree_game->m_reachableNodes.find(const_cast(this)) != - tree_game->m_reachableNodes.end(); + // A node is reachable if it is NOT in the set of unreachable nodes. + return tree_game->m_unreachableNodes.find(const_cast(this)) == + tree_game->m_unreachableNodes.end(); } void GameTreeRep::DeleteParent(GameNode p_node) @@ -812,7 +813,7 @@ void GameTreeRep::ClearComputedValues() const } const_cast(this)->m_nodePlays.clear(); const_cast(this)->m_infosetParents.clear(); - const_cast(this)->m_reachableNodes.clear(); + const_cast(this)->m_unreachableNodes.clear(); m_computedValues = false; } @@ -855,8 +856,8 @@ std::vector GameTreeRep::BuildConsistentPlaysRecursiveImpl(GameNo void GameTreeRep::BuildInfosetParents() { - m_reachableNodes.clear(); - m_reachableNodes.insert(m_root.get()); + m_infosetParents.clear(); + m_unreachableNodes.clear(); if (m_root->IsTerminal()) { m_infosetParents[m_root->m_infoset].insert(nullptr); @@ -909,7 +910,6 @@ void GameTreeRep::BuildInfosetParents() } prior_actions.at(node->m_infoset->m_player->shared_from_this()).top() = action; - m_reachableNodes.insert(child.get()); if (!child->IsTerminal()) { auto child_player = child->m_infoset->m_player->shared_from_this(); auto prior_action = prior_actions.at(child_player).top(); @@ -918,6 +918,28 @@ 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 &action_child_pair : child->GetActions()) { + const GameAction current_action = action_child_pair.first; + if (current_action != replay_action) { + const GameNode unreachable_subtree_root = action_child_pair.second; + + std::stack nodes_to_visit; + nodes_to_visit.push(unreachable_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 e2e37c4f0..7a80eb72c 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -40,7 +40,7 @@ class GameTreeRep : public GameExplicitRep { std::size_t m_numNonterminalNodes = 0; std::map> m_nodePlays; std::map> m_infosetParents; - mutable std::set m_reachableNodes; + mutable std::set m_unreachableNodes; /// @name Private auxiliary functions //@{ From 7afd28dc28de2302368447a61cfd7b2f7d3526f4 Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 21 Nov 2025 08:43:12 +0000 Subject: [PATCH 07/11] Use variable unpacking --- src/games/gametree.cc | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 4a23f2429..10b895375 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -920,13 +920,11 @@ void GameTreeRep::BuildInfosetParents() position.emplace(AbsentMindedEdge{replay_action, child}); // Start of the traversal of unreachable subtrees - for (const auto &action_child_pair : child->GetActions()) { - const GameAction current_action = action_child_pair.first; + for (const auto &[current_action, subtree_root] : child->GetActions()) { if (current_action != replay_action) { - const GameNode unreachable_subtree_root = action_child_pair.second; std::stack nodes_to_visit; - nodes_to_visit.push(unreachable_subtree_root.get()); + nodes_to_visit.push(subtree_root.get()); while (!nodes_to_visit.empty()) { GameNodeRep *current_unreachable_node = nodes_to_visit.top(); From 80a2791568abd32d25c48826ae5242a1979a692f Mon Sep 17 00:00:00 2001 From: drdkad Date: Sat, 22 Nov 2025 06:48:29 +0000 Subject: [PATCH 08/11] Write the tests in terms of paths of action labels --- tests/test_node.py | 60 +++++++++++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/tests/test_node.py b/tests/test_node.py index 0820f2b03..6ac5f204e 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -112,27 +112,55 @@ def test_legacy_is_subgame_root_set(game: gbt.Game, expected_result: set): assert legacy_roots == expected_roots -@pytest.mark.parametrize("game, expected_reachable_indices", [ - # Games without Absent-Mindedness where all nodes are reachable. - (games.read_from_file("e02.efg"), set(range(7))), - (games.read_from_file("wichardt.efg"), set(range(15))), - (games.read_from_file("subgames.efg"), set(range(37))), - - # Games with absent-mindedness where some nodes are unreachable. - (games.read_from_file("AM-driver-one-infoset.efg"), {0, 1, 2, 4}), - (games.read_from_file("AM-driver-subgame.efg"), {0, 1, 2, 6}), +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: gbt.Game, expected_reachable_indices: set): +def test_is_strategy_reachable(game_file: str, expected_unreachable_paths: list[list[str]]): """ - Tests `node.is_strategy_reachable` by comparing the set of reachable nodes - against a pre-computed set of node indices. + 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 in an order-independent way. """ - all_nodes = list(game.nodes) + game = games.read_from_file(game_file) + nodes = game.nodes - expected_reachable = {all_nodes[i] for i in expected_reachable_indices} - actual_reachable = {node for node in all_nodes if node.is_strategy_reachable} + actual_unreachable_paths = [ + get_path_of_action_labels(node) for node in nodes if not node.is_strategy_reachable + ] - assert actual_reachable == expected_reachable + assert actual_unreachable_paths == expected_unreachable_paths def test_append_move_error_player_actions(): From 490c0712965e812c2b08535408898f0d02b34c5e Mon Sep 17 00:00:00 2001 From: drdkad Date: Sat, 22 Nov 2025 06:49:04 +0000 Subject: [PATCH 09/11] Update labels in two games with absent-mindedness --- tests/test_games/AM-driver-one-infoset.efg | 4 ++-- tests/test_games/AM-driver-subgame.efg | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_games/AM-driver-one-infoset.efg b/tests/test_games/AM-driver-one-infoset.efg index b055ea7ab..1544a8dd4 100644 --- a/tests/test_games/AM-driver-one-infoset.efg +++ b/tests/test_games/AM-driver-one-infoset.efg @@ -1,8 +1,8 @@ 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 } 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 } From 7cecf0fe8c5ebc48a56529f814de9da4b7a43f16 Mon Sep 17 00:00:00 2001 From: drdkad Date: Sat, 22 Nov 2025 06:51:19 +0000 Subject: [PATCH 10/11] Improve a docstring description --- tests/test_node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_node.py b/tests/test_node.py index 6ac5f204e..c8f7c412e 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -151,7 +151,7 @@ def test_is_strategy_reachable(game_file: str, expected_unreachable_paths: list[ """ 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 in an order-independent way. + list of paths against a known-correct list. """ game = games.read_from_file(game_file) nodes = game.nodes From 91b6e41a7ab2536e5dad1f32fe5a96b756281c2f Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 24 Nov 2025 13:30:15 +0000 Subject: [PATCH 11/11] Fix caching using unique_ptr; use Gambit::contains for idiomatic lookups --- src/games/gametree.cc | 11 +++++------ src/games/gametree.h | 2 +- tests/test_node.py | 4 ++-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 10b895375..a98c5b216 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -392,13 +392,12 @@ bool GameNodeRep::IsStrategyReachable() const { auto tree_game = static_cast(m_game); - if (tree_game->m_unreachableNodes.empty() && !tree_game->GetRoot()->IsTerminal()) { + if (!tree_game->m_unreachableNodes) { tree_game->BuildInfosetParents(); } // A node is reachable if it is NOT in the set of unreachable nodes. - return tree_game->m_unreachableNodes.find(const_cast(this)) == - tree_game->m_unreachableNodes.end(); + return !contains(*tree_game->m_unreachableNodes, const_cast(this)); } void GameTreeRep::DeleteParent(GameNode p_node) @@ -813,7 +812,7 @@ void GameTreeRep::ClearComputedValues() const } const_cast(this)->m_nodePlays.clear(); const_cast(this)->m_infosetParents.clear(); - const_cast(this)->m_unreachableNodes.clear(); + const_cast(this)->m_unreachableNodes = nullptr; m_computedValues = false; } @@ -857,7 +856,7 @@ std::vector GameTreeRep::BuildConsistentPlaysRecursiveImpl(GameNo void GameTreeRep::BuildInfosetParents() { m_infosetParents.clear(); - m_unreachableNodes.clear(); + m_unreachableNodes = std::make_unique>(); if (m_root->IsTerminal()) { m_infosetParents[m_root->m_infoset].insert(nullptr); @@ -929,7 +928,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); + 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 7a80eb72c..e5249488b 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -40,7 +40,7 @@ class GameTreeRep : public GameExplicitRep { std::size_t m_numNonterminalNodes = 0; std::map> m_nodePlays; std::map> m_infosetParents; - mutable std::set m_unreachableNodes; + mutable std::unique_ptr> m_unreachableNodes; /// @name Private auxiliary functions //@{ diff --git a/tests/test_node.py b/tests/test_node.py index c8f7c412e..09d1d4dfb 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -112,7 +112,7 @@ 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]: +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. @@ -157,7 +157,7 @@ def test_is_strategy_reachable(game_file: str, expected_unreachable_paths: list[ nodes = game.nodes actual_unreachable_paths = [ - get_path_of_action_labels(node) for node in nodes if not node.is_strategy_reachable + _get_path_of_action_labels(node) for node in nodes if not node.is_strategy_reachable ] assert actual_unreachable_paths == expected_unreachable_paths