Skip to content

Commit 010baa6

Browse files
authored
ENH: Implement pure strategy node reachability check (#632)
This adds a property which checks whether a node is reachable by a pure strategy profile. A node may be unreachable in a game with (an) absent-minded information set, as reaching that node may require contradictory actions to be played if the node's history would require different actions to be played on multiple visits to an information set. Closes #629.
1 parent d4948df commit 010baa6

10 files changed

Lines changed: 118 additions & 4 deletions

File tree

ChangeLog

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010

1111
### Added
1212
- Tests for EFG Nash solvers -- `enumpoly_solve`, `lp_solve`, `lcp_solve` -- in behavior stratgegies
13+
- In `pygambit`, `Node` objects now have a read-only property `is_strategy_reachable` to determine
14+
if the node is reachable by at least one pure strategy profile. This proves useful for identifying
15+
unreachable parts of the game tree in games with absent-mindedness. (#629)
1316

1417
### Removed
1518
- Eliminating dominated actions has been removed from the GUI as it was implementing a non-standard

doc/pygambit.api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ Information about the game
141141
Node.parent
142142
Node.is_subgame_root
143143
Node.is_terminal
144+
Node.is_strategy_reachable
144145
Node.prior_action
145146
Node.prior_sibling
146147
Node.next_sibling

src/games/game.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,7 @@ class GameNodeRep : public std::enable_shared_from_this<GameNodeRep> {
500500

501501
bool IsSuccessorOf(GameNode from) const;
502502
bool IsSubgameRoot() const;
503+
bool IsStrategyReachable() const;
503504
};
504505

505506
class GameNodeRep::Actions::iterator {

src/games/gametree.cc

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,18 @@ bool GameNodeRep::IsSubgameRoot() const
388388
return true;
389389
}
390390

391+
bool GameNodeRep::IsStrategyReachable() const
392+
{
393+
auto tree_game = static_cast<GameTreeRep *>(m_game);
394+
395+
if (!tree_game->m_unreachableNodes) {
396+
tree_game->BuildInfosetParents();
397+
}
398+
399+
// A node is reachable if it is NOT in the set of unreachable nodes.
400+
return !contains(*tree_game->m_unreachableNodes, const_cast<GameNodeRep *>(this));
401+
}
402+
391403
void GameTreeRep::DeleteParent(GameNode p_node)
392404
{
393405
if (p_node->m_game != this) {
@@ -800,6 +812,7 @@ void GameTreeRep::ClearComputedValues() const
800812
}
801813
const_cast<GameTreeRep *>(this)->m_nodePlays.clear();
802814
const_cast<GameTreeRep *>(this)->m_infosetParents.clear();
815+
const_cast<GameTreeRep *>(this)->m_unreachableNodes = nullptr;
803816
m_computedValues = false;
804817
}
805818

@@ -842,6 +855,9 @@ std::vector<GameNodeRep *> GameTreeRep::BuildConsistentPlaysRecursiveImpl(GameNo
842855

843856
void GameTreeRep::BuildInfosetParents()
844857
{
858+
m_infosetParents.clear();
859+
m_unreachableNodes = std::make_unique<std::set<GameNodeRep *>>();
860+
845861
if (m_root->IsTerminal()) {
846862
m_infosetParents[m_root->m_infoset].insert(nullptr);
847863
return;
@@ -893,7 +909,6 @@ void GameTreeRep::BuildInfosetParents()
893909
}
894910

895911
prior_actions.at(node->m_infoset->m_player->shared_from_this()).top() = action;
896-
897912
if (!child->IsTerminal()) {
898913
auto child_player = child->m_infoset->m_player->shared_from_this();
899914
auto prior_action = prior_actions.at(child_player).top();
@@ -902,6 +917,26 @@ void GameTreeRep::BuildInfosetParents()
902917
if (path_choices.find(child->m_infoset->shared_from_this()) != path_choices.end()) {
903918
const GameAction replay_action = path_choices.at(child->m_infoset->shared_from_this());
904919
position.emplace(AbsentMindedEdge{replay_action, child});
920+
921+
// Start of the traversal of unreachable subtrees
922+
for (const auto &[current_action, subtree_root] : child->GetActions()) {
923+
if (current_action != replay_action) {
924+
925+
std::stack<GameNodeRep *> nodes_to_visit;
926+
nodes_to_visit.push(subtree_root.get());
927+
928+
while (!nodes_to_visit.empty()) {
929+
GameNodeRep *current_unreachable_node = nodes_to_visit.top();
930+
nodes_to_visit.pop();
931+
m_unreachableNodes->insert(current_unreachable_node);
932+
933+
for (const auto &unreachable_child : current_unreachable_node->GetChildren()) {
934+
nodes_to_visit.push(unreachable_child.get());
935+
}
936+
}
937+
}
938+
}
939+
// End of the traversal of unreachable subtrees
905940
}
906941
else {
907942
position.emplace(child->GetActions().begin());

src/games/gametree.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class GameTreeRep : public GameExplicitRep {
4040
std::size_t m_numNonterminalNodes = 0;
4141
std::map<GameNodeRep *, std::vector<GameNodeRep *>> m_nodePlays;
4242
std::map<GameInfosetRep *, std::set<GameActionRep *>> m_infosetParents;
43+
mutable std::unique_ptr<std::set<GameNodeRep *>> m_unreachableNodes;
4344

4445
/// @name Private auxiliary functions
4546
//@{

src/pygambit/gambit.pxd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ cdef extern from "games/game.h":
218218
bint IsTerminal() except +
219219
bint IsSuccessorOf(c_GameNode) except +
220220
bint IsSubgameRoot() except +
221+
bint IsStrategyReachable() except +
221222
c_GameAction GetPriorAction() except +
222223

223224
cdef cppclass c_GameRep "GameRep":

src/pygambit/node.pxi

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,19 @@ class Node:
212212
"""
213213
return self.node.deref().IsSubgameRoot()
214214

215+
@property
216+
def is_strategy_reachable(self) -> bool:
217+
"""Returns whether this node is reachable by any pure strategy profile.
218+
219+
A node is considered reachable if there exists at least one pure
220+
strategy profile where the resulting path of play passes
221+
through that node.
222+
223+
In games with absent-mindedness, some nodes may be unreachable because
224+
any path to them requires conflicting choices at the same information set.
225+
"""
226+
return self.node.deref().IsStrategyReachable()
227+
215228
@property
216229
def outcome(self) -> typing.Optional[Outcome]:
217230
"""Returns the outcome attached to the node.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" }
2+
""
3+
4+
p "" 1 1 "" { "S" "T" } 0
5+
p "" 1 1 "" { "S" "T" } 0
6+
t "" 1 "Outcome 1" { 1, -1 }
7+
t "" 2 "Outcome 2" { 2, -2 }
8+
t "" 3 "Outcome 3" { 3, -3 }
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" }
22
""
33

4-
p "" 1 1 "" { "1" "2" } 0
5-
p "" 1 1 "" { "1" "2" } 0
4+
p "" 1 1 "" { "S" "T" } 0
5+
p "" 1 1 "" { "S" "T" } 0
66
t "" 1 "Outcome 1" { 1, -1 }
7-
p "" 2 1 "" { "1" "2" } 0
7+
p "" 2 1 "" { "r" "l" } 0
88
t "" 2 "Outcome 2" { 2, -2 }
99
t "" 3 "Outcome 3" { 3, -3 }
1010
t "" 4 "Outcome 4" { 4, -4 }

tests/test_node.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,57 @@ def test_legacy_is_subgame_root_set(game: gbt.Game, expected_result: set):
112112
assert legacy_roots == expected_roots
113113

114114

115+
def _get_path_of_action_labels(node: gbt.Node) -> list[str]:
116+
"""
117+
Computes the path of action labels from the root to the given node.
118+
Returns a list of strings.
119+
"""
120+
if not isinstance(node, gbt.Node):
121+
raise TypeError(f"Input must be a pygambit.Node, but got {type(node).__name__}")
122+
123+
path = []
124+
current_node = node
125+
while current_node.parent:
126+
path.append(current_node.prior_action.label)
127+
current_node = current_node.parent
128+
129+
return path[::-1]
130+
131+
132+
@pytest.mark.parametrize("game_file, expected_unreachable_paths", [
133+
# Games without absent-mindedness, where all nodes are reachable
134+
("e02.efg", []),
135+
("wichardt.efg", []),
136+
("subgames.efg", []),
137+
138+
# An absent-minded driver game with an unreachable terminal node
139+
(
140+
"AM-driver-one-infoset.efg",
141+
[["S", "T"]]
142+
),
143+
144+
# An absent-minded driver game with an unreachable subtree
145+
(
146+
"AM-driver-subgame.efg",
147+
[["S", "T"], ["S", "T", "r"], ["S", "T", "l"]]
148+
),
149+
])
150+
def test_is_strategy_reachable(game_file: str, expected_unreachable_paths: list[list[str]]):
151+
"""
152+
Tests `node.is_strategy_reachable` by collecting all unreachable nodes,
153+
converting them to their action-label paths, and comparing the resulting
154+
list of paths against a known-correct list.
155+
"""
156+
game = games.read_from_file(game_file)
157+
nodes = game.nodes
158+
159+
actual_unreachable_paths = [
160+
_get_path_of_action_labels(node) for node in nodes if not node.is_strategy_reachable
161+
]
162+
163+
assert actual_unreachable_paths == expected_unreachable_paths
164+
165+
115166
def test_append_move_error_player_actions():
116167
"""Test to ensure there are actions when appending with a player"""
117168
game = games.read_from_file("basic_extensive_game.efg")

0 commit comments

Comments
 (0)