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
4 changes: 4 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [16.5.0] - unreleased

### Added
- Implement `IsAbsentMinded()` on information sets (C++) and `Infoset.is_absent_minded` (Python)
to detect if an information is absent-minded.

### Changed
- In the graphical interface, removed option to configure information set link drawing; information sets
are always drawn and indicators are always drawn if an information set spans multiple levels.
Expand Down
1 change: 1 addition & 0 deletions doc/pygambit.api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ Information about the game
Infoset.label
Infoset.game
Infoset.is_chance
Infoset.is_absent_minded
Infoset.player
Infoset.actions
Infoset.members
Expand Down
8 changes: 8 additions & 0 deletions src/games/game.h
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,14 @@ class GameRep : public std::enable_shared_from_this<GameRep> {

/// Returns true if the game is perfect recall
virtual bool IsPerfectRecall() const = 0;
/// Returns true if the information set is absent-minded
virtual bool IsAbsentMinded(const GameInfoset &p_infoset) const
{
if (p_infoset->GetGame().get() != this) {
throw MismatchException();
}
return false;
}
//@}

/// @name Writing data files
Expand Down
17 changes: 16 additions & 1 deletion src/games/gametree.cc
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,19 @@ bool GameTreeRep::IsPerfectRecall() const
[](const auto &pair) { return pair.second.size() <= 1; });
}

bool GameTreeRep::IsAbsentMinded(const GameInfoset &p_infoset) const
{
if (p_infoset->GetGame().get() != this) {
throw MismatchException();
}

if (!m_unreachableNodes && !m_root->IsTerminal()) {
BuildUnreachableNodes();
}

return contains(m_absentMindedInfosets, p_infoset.get());
}

//------------------------------------------------------------------------
// GameTreeRep: Managing the representation
//------------------------------------------------------------------------
Expand Down Expand Up @@ -870,6 +883,7 @@ void GameTreeRep::ClearComputedValues() const
const_cast<GameTreeRep *>(this)->m_nodePlays.clear();
m_ownPriorActionInfo = nullptr;
const_cast<GameTreeRep *>(this)->m_unreachableNodes = nullptr;
m_absentMindedInfosets.clear();
m_computedValues = false;
}

Expand Down Expand Up @@ -1008,7 +1022,7 @@ std::set<GameAction> GameTreeRep::GetOwnPriorActions(const GameInfoset &p_infose
return result;
}

void GameTreeRep::BuildUnreachableNodes()
void GameTreeRep::BuildUnreachableNodes() const
{
m_unreachableNodes = std::make_unique<std::set<GameNodeRep *>>();

Expand Down Expand Up @@ -1052,6 +1066,7 @@ void GameTreeRep::BuildUnreachableNodes()
if (!child->IsTerminal()) {
// Check for Absent-Minded Re-entry of the infoset
if (path_choices.find(child->m_infoset->shared_from_this()) != path_choices.end()) {
m_absentMindedInfosets.insert(child->m_infoset);
const GameAction replay_action = path_choices.at(child->m_infoset->shared_from_this());
position.emplace(AbsentMindedEdge{replay_action, child});

Expand Down
4 changes: 3 additions & 1 deletion src/games/gametree.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class GameTreeRep : public GameExplicitRep {
std::map<GameNodeRep *, std::vector<GameNodeRep *>> m_nodePlays;
mutable std::shared_ptr<OwnPriorActionInfo> m_ownPriorActionInfo;
mutable std::unique_ptr<std::set<GameNodeRep *>> m_unreachableNodes;
mutable std::set<GameInfosetRep *> m_absentMindedInfosets;

/// @name Private auxiliary functions
//@{
Expand Down Expand Up @@ -86,6 +87,7 @@ class GameTreeRep : public GameExplicitRep {
Rational GetPlayerMinPayoff(const GamePlayer &) const override;
/// Returns the largest payoff to the player in any play of the game
Rational GetPlayerMaxPayoff(const GamePlayer &) const override;
bool IsAbsentMinded(const GameInfoset &p_infoset) const override;
//@}

/// @name Players
Expand Down Expand Up @@ -171,7 +173,7 @@ class GameTreeRep : public GameExplicitRep {
private:
std::vector<GameNodeRep *> BuildConsistentPlaysRecursiveImpl(GameNodeRep *node);
void BuildOwnPriorActions() const;
void BuildUnreachableNodes();
void BuildUnreachableNodes() const;
};

template <class T> class TreeMixedStrategyProfileRep : public MixedStrategyProfileRep<T> {
Expand Down
1 change: 1 addition & 0 deletions src/pygambit/gambit.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ cdef extern from "games/game.h":
stdvector[c_GameNode] GetPlays(c_GameInfoset) except +
stdvector[c_GameNode] GetPlays(c_GameAction) except +
bool IsPerfectRecall() except +
bool IsAbsentMinded(c_GameInfoset) except +

c_GameInfoset AppendMove(c_GameNode, c_GamePlayer, int) except +ValueError
c_GameInfoset AppendMove(c_GameNode, c_GameInfoset) except +ValueError
Expand Down
12 changes: 12 additions & 0 deletions src/pygambit/infoset.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,18 @@ class Infoset:
"""Whether the information set belongs to the chance player."""
return self.infoset.deref().IsChanceInfoset()

@property
def is_absent_minded(self) -> bool:
"""
Whether the information set is absent-minded.

An information set is absent-minded if there exists a path of play
in the game tree that intersects the information set more than once.

.. versionadded:: 16.5.0
"""
return self.infoset.deref().GetGame().deref().IsAbsentMinded(self.infoset)

@property
def actions(self) -> InfosetActions:
"""The set of actions at the information set."""
Expand Down
14 changes: 0 additions & 14 deletions tests/test_extensive.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,39 +49,25 @@ def test_game_add_players_nolabel():

@pytest.mark.parametrize("game_input,expected_result", [
# Games with perfect recall from files (game_input is a string)
("e01.efg", True),
("e02.efg", True),
("cent3.efg", True),
("stripped_down_poker.efg", True),
("basic_extensive_game.efg", True),

# Games with perfect recall from generated games (game_input is a gbt.Game object)
# - Centipede games
(games.Centipede.get_test_data(N=3, m0=2, m1=7)[0], True),
(games.Centipede.get_test_data(N=4, m0=2, m1=7)[0], True),
# - Two-player binary tree games
(games.BinEfgTwoPlayer.get_test_data(level=3)[0], True),
(games.BinEfgTwoPlayer.get_test_data(level=4)[0], True),
# - Three-player binary tree games
(games.BinEfgThreePlayer.get_test_data(level=3)[0], True),

# Games with imperfect recall from files (game_input is a string)
# - imperfect recall without absent-mindedness
("wichardt.efg", False), # forgetting past action; Wichardt (GEB, 2008)
("noPR-action-selten-horse.efg", False), # forgetting past action
("noPR-information-no-deflate.efg", False), # forgetting past information
("gilboa_two_am_agents.efg", False), # forgetting past information; Gilboa (GEB, 1997)
# - imperfect recall with absent-mindedness
("noPR-AM-driver-one-player.efg", False), # 1 players, one infoset unreached
("noPR-AM-driver-two-players.efg", False), # 2 players, one infoset unreached
("noPR-action-AM.efg", False), # 2 players + forgetting past action; P1 has one infoset
("noPR-action-AM2.efg", False), # 2 players + forgetting past action; P1 has >1 infoset
("noPR-action-AM-two-hops.efg", False), # 2 players, one AM-infoset each

# Games with imperfect recall from generated games (game_input is a gbt.Game object)
# - One-player binary tree games
(games.BinEfgOnePlayerIR.get_test_data(level=3)[0], False),
(games.BinEfgOnePlayerIR.get_test_data(level=4)[0], False),
])
def test_is_perfect_recall(game_input, expected_result: bool):
"""
Expand Down
41 changes: 41 additions & 0 deletions tests/test_infosets.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,44 @@ def test_infoset_own_prior_actions(game_file, expected_results):
}

assert actual_details == expected_set


def _get_node_by_path(game, path: list[str]) -> gbt.Node:
"""
Helper to find a node by following a sequence of action labels.

Parameters
----------
path : list[str]
A list of action labels in Node->Root order.
"""
node = game.root
for action_label in reversed(path):
node = node.children[action_label]

return node


@pytest.mark.parametrize("game_input, expected_am_paths", [
# Games without absent-mindedness
("e02.efg", []),
("stripped_down_poker.efg", []),
("basic_extensive_game.efg", []),
("gilboa_two_am_agents.efg", []), # forgetting past information; Gilboa (GEB, 1997)
("wichardt.efg", []), # forgetting past action; Wichardt (GEB, 2008)

# Games with absent-mindedness
("noPR-AM-driver-two-players.efg", [[]]),
("noPR-action-AM.efg", [[]]),
("noPR-action-AM-two-hops.efg", [["2", "1", "1", "1", "1"], ["1", "1", "1"]]),
])
def test_infoset_is_absent_minded(game_input, expected_am_paths):
"""
Verify the is_absent_minded property of information sets.
"""
game = games.read_from_file(game_input)

expected_infosets = {_get_node_by_path(game, path).infoset for path in expected_am_paths}
actual_infosets = {infoset for infoset in game.infosets if infoset.is_absent_minded}

assert actual_infosets == expected_infosets
2 changes: 1 addition & 1 deletion tests/test_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ def _get_path_of_action_labels(node: gbt.Node) -> list[str]:
(
"binary_3_levels_generic_payoffs.efg",
[
# Format: ([Path Leaf->Root], (Player Label, Infoset Num, Action Label) or None)
# Format: (Path in Node->Root order, (Player Label, Infoset Num, Action Label) or None)
([], None),
(["Left"], None),
(["Left", "Left"], ("Player 1", 0, "Left")),
Expand Down