Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions doc/pygambit.api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/games/game.h
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ class GameNodeRep : public std::enable_shared_from_this<GameNodeRep> {

bool IsSuccessorOf(GameNode from) const;
bool IsSubgameRoot() const;
bool IsStrategyReachable() const;
};

class GameNodeRep::Actions::iterator {
Expand Down
37 changes: 36 additions & 1 deletion src/games/gametree.cc
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,18 @@ bool GameNodeRep::IsSubgameRoot() const
return true;
}

bool GameNodeRep::IsStrategyReachable() const
{
auto tree_game = static_cast<GameTreeRep *>(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<GameNodeRep *>(this));
}

void GameTreeRep::DeleteParent(GameNode p_node)
{
if (p_node->m_game != this) {
Expand Down Expand Up @@ -800,6 +812,7 @@ void GameTreeRep::ClearComputedValues() const
}
const_cast<GameTreeRep *>(this)->m_nodePlays.clear();
const_cast<GameTreeRep *>(this)->m_infosetParents.clear();
const_cast<GameTreeRep *>(this)->m_unreachableNodes = nullptr;
m_computedValues = false;
}

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

void GameTreeRep::BuildInfosetParents()
{
m_infosetParents.clear();
m_unreachableNodes = std::make_unique<std::set<GameNodeRep *>>();

if (m_root->IsTerminal()) {
m_infosetParents[m_root->m_infoset].insert(nullptr);
return;
Expand Down Expand Up @@ -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();
Expand All @@ -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<GameNodeRep *> 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());
Expand Down
1 change: 1 addition & 0 deletions src/games/gametree.h
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class GameTreeRep : public GameExplicitRep {
std::size_t m_numNonterminalNodes = 0;
std::map<GameNodeRep *, std::vector<GameNodeRep *>> m_nodePlays;
std::map<GameInfosetRep *, std::set<GameActionRep *>> m_infosetParents;
mutable std::unique_ptr<std::set<GameNodeRep *>> m_unreachableNodes;

/// @name Private auxiliary functions
//@{
Expand Down
1 change: 1 addition & 0 deletions src/pygambit/gambit.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
13 changes: 13 additions & 0 deletions src/pygambit/node.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions tests/test_games/AM-driver-one-infoset.efg
Original file line number Diff line number Diff line change
@@ -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 }
6 changes: 3 additions & 3 deletions tests/test_games/AM-driver-subgame.efg
Original file line number Diff line number Diff line change
@@ -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 }
51 changes: 51 additions & 0 deletions tests/test_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading