Skip to content

Commit f925ffb

Browse files
authored
ENH: Add is_absent_minded property to Infoset (#676)
Adds a new property to report whether an information set is absent-minded (that is, it can be visited more than once on a play of the game).
1 parent 1023c24 commit f925ffb

10 files changed

Lines changed: 87 additions & 17 deletions

File tree

ChangeLog

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [16.5.0] - unreleased
44

5+
### Added
6+
- Implement `IsAbsentMinded()` on information sets (C++) and `Infoset.is_absent_minded` (Python)
7+
to detect if an information is absent-minded.
8+
59
### Changed
610
- In the graphical interface, removed option to configure information set link drawing; information sets
711
are always drawn and indicators are always drawn if an information set spans multiple levels.

doc/pygambit.api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ Information about the game
158158
Infoset.label
159159
Infoset.game
160160
Infoset.is_chance
161+
Infoset.is_absent_minded
161162
Infoset.player
162163
Infoset.actions
163164
Infoset.members

src/games/game.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,14 @@ class GameRep : public std::enable_shared_from_this<GameRep> {
759759

760760
/// Returns true if the game is perfect recall
761761
virtual bool IsPerfectRecall() const = 0;
762+
/// Returns true if the information set is absent-minded
763+
virtual bool IsAbsentMinded(const GameInfoset &p_infoset) const
764+
{
765+
if (p_infoset->GetGame().get() != this) {
766+
throw MismatchException();
767+
}
768+
return false;
769+
}
762770
//@}
763771

764772
/// @name Writing data files

src/games/gametree.cc

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,19 @@ bool GameTreeRep::IsPerfectRecall() const
819819
[](const auto &pair) { return pair.second.size() <= 1; });
820820
}
821821

822+
bool GameTreeRep::IsAbsentMinded(const GameInfoset &p_infoset) const
823+
{
824+
if (p_infoset->GetGame().get() != this) {
825+
throw MismatchException();
826+
}
827+
828+
if (!m_unreachableNodes && !m_root->IsTerminal()) {
829+
BuildUnreachableNodes();
830+
}
831+
832+
return contains(m_absentMindedInfosets, p_infoset.get());
833+
}
834+
822835
//------------------------------------------------------------------------
823836
// GameTreeRep: Managing the representation
824837
//------------------------------------------------------------------------
@@ -870,6 +883,7 @@ void GameTreeRep::ClearComputedValues() const
870883
const_cast<GameTreeRep *>(this)->m_nodePlays.clear();
871884
m_ownPriorActionInfo = nullptr;
872885
const_cast<GameTreeRep *>(this)->m_unreachableNodes = nullptr;
886+
m_absentMindedInfosets.clear();
873887
m_computedValues = false;
874888
}
875889

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

1011-
void GameTreeRep::BuildUnreachableNodes()
1025+
void GameTreeRep::BuildUnreachableNodes() const
10121026
{
10131027
m_unreachableNodes = std::make_unique<std::set<GameNodeRep *>>();
10141028

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

src/games/gametree.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class GameTreeRep : public GameExplicitRep {
4747
std::map<GameNodeRep *, std::vector<GameNodeRep *>> m_nodePlays;
4848
mutable std::shared_ptr<OwnPriorActionInfo> m_ownPriorActionInfo;
4949
mutable std::unique_ptr<std::set<GameNodeRep *>> m_unreachableNodes;
50+
mutable std::set<GameInfosetRep *> m_absentMindedInfosets;
5051

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

9193
/// @name Players
@@ -171,7 +173,7 @@ class GameTreeRep : public GameExplicitRep {
171173
private:
172174
std::vector<GameNodeRep *> BuildConsistentPlaysRecursiveImpl(GameNodeRep *node);
173175
void BuildOwnPriorActions() const;
174-
void BuildUnreachableNodes();
176+
void BuildUnreachableNodes() const;
175177
};
176178

177179
template <class T> class TreeMixedStrategyProfileRep : public MixedStrategyProfileRep<T> {

src/pygambit/gambit.pxd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ cdef extern from "games/game.h":
300300
stdvector[c_GameNode] GetPlays(c_GameInfoset) except +
301301
stdvector[c_GameNode] GetPlays(c_GameAction) except +
302302
bool IsPerfectRecall() except +
303+
bool IsAbsentMinded(c_GameInfoset) except +
303304

304305
c_GameInfoset AppendMove(c_GameNode, c_GamePlayer, int) except +ValueError
305306
c_GameInfoset AppendMove(c_GameNode, c_GameInfoset) except +ValueError

src/pygambit/infoset.pxi

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,18 @@ class Infoset:
161161
"""Whether the information set belongs to the chance player."""
162162
return self.infoset.deref().IsChanceInfoset()
163163

164+
@property
165+
def is_absent_minded(self) -> bool:
166+
"""
167+
Whether the information set is absent-minded.
168+
169+
An information set is absent-minded if there exists a path of play
170+
in the game tree that intersects the information set more than once.
171+
172+
.. versionadded:: 16.5.0
173+
"""
174+
return self.infoset.deref().GetGame().deref().IsAbsentMinded(self.infoset)
175+
164176
@property
165177
def actions(self) -> InfosetActions:
166178
"""The set of actions at the information set."""

tests/test_extensive.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,39 +49,25 @@ def test_game_add_players_nolabel():
4949

5050
@pytest.mark.parametrize("game_input,expected_result", [
5151
# Games with perfect recall from files (game_input is a string)
52-
("e01.efg", True),
5352
("e02.efg", True),
54-
("cent3.efg", True),
5553
("stripped_down_poker.efg", True),
56-
("basic_extensive_game.efg", True),
57-
5854
# Games with perfect recall from generated games (game_input is a gbt.Game object)
5955
# - Centipede games
60-
(games.Centipede.get_test_data(N=3, m0=2, m1=7)[0], True),
6156
(games.Centipede.get_test_data(N=4, m0=2, m1=7)[0], True),
6257
# - Two-player binary tree games
63-
(games.BinEfgTwoPlayer.get_test_data(level=3)[0], True),
6458
(games.BinEfgTwoPlayer.get_test_data(level=4)[0], True),
6559
# - Three-player binary tree games
6660
(games.BinEfgThreePlayer.get_test_data(level=3)[0], True),
6761
6862
# Games with imperfect recall from files (game_input is a string)
6963
# - imperfect recall without absent-mindedness
7064
("wichardt.efg", False), # forgetting past action; Wichardt (GEB, 2008)
71-
("noPR-action-selten-horse.efg", False), # forgetting past action
72-
("noPR-information-no-deflate.efg", False), # forgetting past information
7365
("gilboa_two_am_agents.efg", False), # forgetting past information; Gilboa (GEB, 1997)
7466
# - imperfect recall with absent-mindedness
7567
("noPR-AM-driver-one-player.efg", False), # 1 players, one infoset unreached
7668
("noPR-AM-driver-two-players.efg", False), # 2 players, one infoset unreached
7769
("noPR-action-AM.efg", False), # 2 players + forgetting past action; P1 has one infoset
78-
("noPR-action-AM2.efg", False), # 2 players + forgetting past action; P1 has >1 infoset
7970
("noPR-action-AM-two-hops.efg", False), # 2 players, one AM-infoset each
80-
81-
# Games with imperfect recall from generated games (game_input is a gbt.Game object)
82-
# - One-player binary tree games
83-
(games.BinEfgOnePlayerIR.get_test_data(level=3)[0], False),
84-
(games.BinEfgOnePlayerIR.get_test_data(level=4)[0], False),
8571
])
8672
def test_is_perfect_recall(game_input, expected_result: bool):
8773
"""

tests/test_infosets.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,44 @@ def test_infoset_own_prior_actions(game_file, expected_results):
162162
}
163163

164164
assert actual_details == expected_set
165+
166+
167+
def _get_node_by_path(game, path: list[str]) -> gbt.Node:
168+
"""
169+
Helper to find a node by following a sequence of action labels.
170+
171+
Parameters
172+
----------
173+
path : list[str]
174+
A list of action labels in Node->Root order.
175+
"""
176+
node = game.root
177+
for action_label in reversed(path):
178+
node = node.children[action_label]
179+
180+
return node
181+
182+
183+
@pytest.mark.parametrize("game_input, expected_am_paths", [
184+
# Games without absent-mindedness
185+
("e02.efg", []),
186+
("stripped_down_poker.efg", []),
187+
("basic_extensive_game.efg", []),
188+
("gilboa_two_am_agents.efg", []), # forgetting past information; Gilboa (GEB, 1997)
189+
("wichardt.efg", []), # forgetting past action; Wichardt (GEB, 2008)
190+
191+
# Games with absent-mindedness
192+
("noPR-AM-driver-two-players.efg", [[]]),
193+
("noPR-action-AM.efg", [[]]),
194+
("noPR-action-AM-two-hops.efg", [["2", "1", "1", "1", "1"], ["1", "1", "1"]]),
195+
])
196+
def test_infoset_is_absent_minded(game_input, expected_am_paths):
197+
"""
198+
Verify the is_absent_minded property of information sets.
199+
"""
200+
game = games.read_from_file(game_input)
201+
202+
expected_infosets = {_get_node_by_path(game, path).infoset for path in expected_am_paths}
203+
actual_infosets = {infoset for infoset in game.infosets if infoset.is_absent_minded}
204+
205+
assert actual_infosets == expected_infosets

tests/test_node.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def _get_path_of_action_labels(node: gbt.Node) -> list[str]:
140140
(
141141
"binary_3_levels_generic_payoffs.efg",
142142
[
143-
# Format: ([Path Leaf->Root], (Player Label, Infoset Num, Action Label) or None)
143+
# Format: (Path in Node->Root order, (Player Label, Infoset Num, Action Label) or None)
144144
([], None),
145145
(["Left"], None),
146146
(["Left", "Left"], ("Player 1", 0, "Left")),

0 commit comments

Comments
 (0)