From d53349ab85bf09e6bb107a102dbbfd518e85856a Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 31 Oct 2025 09:09:29 +0000 Subject: [PATCH 01/18] =?UTF-8?q?Add=20a=20new=20data=20member=20to=C2=A0G?= =?UTF-8?q?ameTreeRep:=C2=A0std::set=20m=5FabsentMindedInfose?= =?UTF-8?q?ts;=20Augment=20the=20existing=C2=A0BuildUnreachableNodes()?= =?UTF-8?q?=C2=A0traversal=20to=20populate=C2=A0m=5FabsentMindedInfosets?= =?UTF-8?q?=C2=A0that=20tracks=20all=20absent-minded=20information=20sets.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/games/game.h | 2 ++ src/games/gameagg.h | 1 + src/games/gamebagg.h | 1 + src/games/gametable.h | 1 + src/games/gametree.cc | 15 +++++++++++++++ src/games/gametree.h | 2 ++ 6 files changed, 22 insertions(+) diff --git a/src/games/game.h b/src/games/game.h index f98827b57..8b0fa3b22 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -759,6 +759,8 @@ class GameRep : public std::enable_shared_from_this { /// Returns true if the game is perfect recall virtual bool IsPerfectRecall() const = 0; + /// Returns true if the game has at least one absent-minded infoset + virtual bool IsAbsentMinded() const = 0; //@} /// @name Writing data files diff --git a/src/games/gameagg.h b/src/games/gameagg.h index bd12f7da7..148ed335a 100644 --- a/src/games/gameagg.h +++ b/src/games/gameagg.h @@ -81,6 +81,7 @@ class GameAGGRep : public GameRep { bool IsTree() const override { return false; } bool IsAgg() const override { return true; } bool IsPerfectRecall() const override { return true; } + bool IsAbsentMinded() const override { return false; } bool IsConstSum() const override; /// Returns the smallest payoff to any player in any outcome of the game Rational GetMinPayoff() const override { return Rational(aggPtr->getMinPayoff()); } diff --git a/src/games/gamebagg.h b/src/games/gamebagg.h index 3918a58d2..1e57987e8 100644 --- a/src/games/gamebagg.h +++ b/src/games/gamebagg.h @@ -88,6 +88,7 @@ class GameBAGGRep : public GameRep { bool IsTree() const override { return false; } virtual bool IsBagg() const { return true; } bool IsPerfectRecall() const override { return true; } + bool IsAbsentMinded() const override { return false; } bool IsConstSum() const override { throw UndefinedException(); } /// Returns the smallest payoff to any player in any outcome of the game Rational GetMinPayoff() const override { return Rational(baggPtr->getMinPayoff()); } diff --git a/src/games/gametable.h b/src/games/gametable.h index 3aab1dd38..927868df8 100644 --- a/src/games/gametable.h +++ b/src/games/gametable.h @@ -64,6 +64,7 @@ class GameTableRep : public GameExplicitRep { Rational GetPlayerMaxPayoff(const GamePlayer &) const override; bool IsPerfectRecall() const override { return true; } + bool IsAbsentMinded() const override { return false; } //@} /// @name Dimensions of the game diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 33509228e..2fd21a4ee 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -804,6 +804,19 @@ Rational GameTreeRep::GetPlayerMaxPayoff(const GamePlayer &p_player) const p_player, m_root, [](const Rational &a, const Rational &b) { return std::max(a, b); }); } +bool GameTreeRep::IsAbsentMinded() const +{ + if (!m_ownPriorActionInfo && !m_root->IsTerminal()) { + const_cast(this)->BuildOwnPriorActions(); + } + + if (GetRoot()->IsTerminal()) { + return true; + } + + return !m_absentMindedInfosets.empty(); +} + bool GameTreeRep::IsPerfectRecall() const { if (!m_ownPriorActionInfo && !m_root->IsTerminal()) { @@ -870,6 +883,7 @@ void GameTreeRep::ClearComputedValues() const const_cast(this)->m_nodePlays.clear(); m_ownPriorActionInfo = nullptr; const_cast(this)->m_unreachableNodes = nullptr; + const_cast(this)->m_absentMindedInfosets.clear(); m_computedValues = false; } @@ -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}); diff --git a/src/games/gametree.h b/src/games/gametree.h index e77500964..182f3f447 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -47,6 +47,7 @@ class GameTreeRep : public GameExplicitRep { std::map> m_nodePlays; mutable std::shared_ptr m_ownPriorActionInfo; mutable std::unique_ptr> m_unreachableNodes; + std::set m_absentMindedInfosets; /// @name Private auxiliary functions //@{ @@ -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 override; //@} /// @name Players From 7d35bcde19e47069e5aeecc3ec1495fb382fee5b Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 31 Oct 2025 09:33:53 +0000 Subject: [PATCH 02/18] add the absent-minded checker to PyGambit --- src/pygambit/gambit.pxd | 1 + src/pygambit/game.pxi | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 50e56d7e9..9aac39cec 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -299,6 +299,7 @@ cdef extern from "games/game.h": stdvector[c_GameNode] GetPlays(c_GameNode) except + stdvector[c_GameNode] GetPlays(c_GameInfoset) except + stdvector[c_GameNode] GetPlays(c_GameAction) except + + bool IsAbsentMinded() except + bool IsPerfectRecall() except + c_GameInfoset AppendMove(c_GameNode, c_GamePlayer, int) except +ValueError diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 69ca46587..773a5e3b9 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -779,6 +779,15 @@ class Game: """Whether the game is constant sum.""" return self.game.deref().IsConstSum() + @property + def is_absent_minded(self) -> bool: + """Whether the game has absent-mindedness. + + By convention, games with a strategic representation have perfect recall as they + are treated as simultaneous-move games, thus, they do not have absent-mindedness. + """ + return self.game.deref().IsAbsentMinded() + @property def is_perfect_recall(self) -> bool: """Whether the game is perfect recall. From ea07c976d2134686b622c5c800496ca5d1e4df28 Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 31 Oct 2025 09:59:45 +0000 Subject: [PATCH 03/18] edit ChangeLog / api.rst --- ChangeLog | 5 +++++ doc/pygambit.api.rst | 1 + 2 files changed, 6 insertions(+) diff --git a/ChangeLog b/ChangeLog index 251d827a1..72c9ba125 100644 --- a/ChangeLog +++ b/ChangeLog @@ -2,6 +2,11 @@ ## [16.5.0] - unreleased +### Added +- Implement `IsAbsentMinded()` (C++) and `is_absent_minded` (Python) to detect +if a game has absent-mindedness (a player, moving along a single path of play, +can re-enter an information set visited previously). + ### 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. diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 4b74f40f7..6c2428529 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -98,6 +98,7 @@ Information about the game Game.comment Game.is_const_sum Game.is_tree + Game.is_absent_minded Game.is_perfect_recall Game.players Game.outcomes From 49b230f28e75bbd9008cc3cec10f47ed6cf43e8d Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 31 Oct 2025 10:05:51 +0000 Subject: [PATCH 04/18] Add tests --- tests/test_extensive.py | 49 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_extensive.py b/tests/test_extensive.py index 7b8470285..c3271f827 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -47,6 +47,55 @@ def test_game_add_players_nolabel(): game.add_player() +@pytest.mark.parametrize("game_input,expected_result", [ + # Games without absent-mindedness (includes all games with perfect recall) + ("e01.efg", False), + ("e02.efg", False), + ("cent3.efg", False), + ("poker.efg", False), + ("basic_extensive_game.efg", False), + ("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) + + # Generated games without absent-mindedness + # - Centipede games + (games.Centipede.get_test_data(N=3, m0=2, m1=7)[0], False), + (games.Centipede.get_test_data(N=4, m0=2, m1=7)[0], False), + # - Two-player binary tree games + (games.BinEfgTwoPlayer.get_test_data(level=3)[0], False), + (games.BinEfgTwoPlayer.get_test_data(level=4)[0], False), + # - Three-player binary tree games + (games.BinEfgThreePlayer.get_test_data(level=3)[0], False), + # - One-player imperfect recall binary tree games + (games.BinEfgOnePlayerIR.get_test_data(level=3)[0], False), + (games.BinEfgOnePlayerIR.get_test_data(level=4)[0], False), + + + # Games with absent-mindedness (a subset of games with imperfect recall) + ("noPR-AM-driver-one-player.efg", True), # 1 players, one infoset unreached + ("noPR-AM-driver-two-players.efg", True), # 2 players, one infoset unreached + ("noPR-action-AM.efg", True), # 2 players + forgetting past action; P1 has one infoset + ("noPR-action-AM2.efg", True), # 2 players + forgetting past action; P1 has >1 infoset + ("noPR-action-AM-two-hops.efg", True), # 2 players, one AM-infoset each +]) +def test_is_absent_minded(game_input, expected_result: bool): + """ + Verify the IsAbsentMinded implementation against a suite of games + with and without the absent-mindedness property. + """ + game = None + if isinstance(game_input, str): + game = games.read_from_file(game_input) + elif isinstance(game_input, gbt.Game): + game = game_input + else: + pytest.fail(f"Unknown type for game_input: {type(game_input)}") + + assert game.is_absent_minded == expected_result + + @pytest.mark.parametrize("game_input,expected_result", [ # Games with perfect recall from files (game_input is a string) ("e01.efg", True), From c3e210edb907af196ccf2235c06644c8735f4d6f Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 5 Nov 2025 18:12:52 +0000 Subject: [PATCH 05/18] Add the new property Infoset.is_absent_minded / GameInfosetRep::IsAbsentMinded --- src/games/game.cc | 17 +++++++++++++++++ src/games/game.h | 4 ++-- src/games/gameagg.h | 1 - src/games/gamebagg.h | 1 - src/games/gametree.h | 2 +- src/pygambit/gambit.pxd | 1 - src/pygambit/game.pxi | 9 --------- src/pygambit/infoset.pxi | 5 +++++ 8 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/games/game.cc b/src/games/game.cc index 37619653a..cafa01e5e 100644 --- a/src/games/game.cc +++ b/src/games/game.cc @@ -46,6 +46,23 @@ GameOutcomeRep::GameOutcomeRep(GameRep *p_game, int p_number) : m_game(p_game), } } +//======================================================================== +// class GameInfosetRep +//======================================================================== + +bool GameInfosetRep::IsAbsentMinded() const +{ + if (this->GetGame() m_infosetParents.empty() && !m_root->IsTerminal()) { + const_cast(this)->BuildInfosetParents(); + } + + if (GetRoot()->IsTerminal()) { + return true; + } + + return !m_absentMindedInfosets.empty(); +} + //======================================================================== // class GameStrategyRep //======================================================================== diff --git a/src/games/game.h b/src/games/game.h index 8b0fa3b22..43fede2e2 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -225,6 +225,8 @@ class GameInfosetRep : public std::enable_shared_from_this { bool IsChanceInfoset() const; + bool IsAbsentMinded() const; + void SetLabel(const std::string &p_label) { m_label = p_label; } const std::string &GetLabel() const { return m_label; } @@ -759,8 +761,6 @@ class GameRep : public std::enable_shared_from_this { /// Returns true if the game is perfect recall virtual bool IsPerfectRecall() const = 0; - /// Returns true if the game has at least one absent-minded infoset - virtual bool IsAbsentMinded() const = 0; //@} /// @name Writing data files diff --git a/src/games/gameagg.h b/src/games/gameagg.h index 148ed335a..bd12f7da7 100644 --- a/src/games/gameagg.h +++ b/src/games/gameagg.h @@ -81,7 +81,6 @@ class GameAGGRep : public GameRep { bool IsTree() const override { return false; } bool IsAgg() const override { return true; } bool IsPerfectRecall() const override { return true; } - bool IsAbsentMinded() const override { return false; } bool IsConstSum() const override; /// Returns the smallest payoff to any player in any outcome of the game Rational GetMinPayoff() const override { return Rational(aggPtr->getMinPayoff()); } diff --git a/src/games/gamebagg.h b/src/games/gamebagg.h index 1e57987e8..3918a58d2 100644 --- a/src/games/gamebagg.h +++ b/src/games/gamebagg.h @@ -88,7 +88,6 @@ class GameBAGGRep : public GameRep { bool IsTree() const override { return false; } virtual bool IsBagg() const { return true; } bool IsPerfectRecall() const override { return true; } - bool IsAbsentMinded() const override { return false; } bool IsConstSum() const override { throw UndefinedException(); } /// Returns the smallest payoff to any player in any outcome of the game Rational GetMinPayoff() const override { return Rational(baggPtr->getMinPayoff()); } diff --git a/src/games/gametree.h b/src/games/gametree.h index 182f3f447..21e9146cb 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -87,7 +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 override; + bool IsAbsentMinded() const; //@} /// @name Players diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 9aac39cec..50e56d7e9 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -299,7 +299,6 @@ cdef extern from "games/game.h": stdvector[c_GameNode] GetPlays(c_GameNode) except + stdvector[c_GameNode] GetPlays(c_GameInfoset) except + stdvector[c_GameNode] GetPlays(c_GameAction) except + - bool IsAbsentMinded() except + bool IsPerfectRecall() except + c_GameInfoset AppendMove(c_GameNode, c_GamePlayer, int) except +ValueError diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 773a5e3b9..69ca46587 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -779,15 +779,6 @@ class Game: """Whether the game is constant sum.""" return self.game.deref().IsConstSum() - @property - def is_absent_minded(self) -> bool: - """Whether the game has absent-mindedness. - - By convention, games with a strategic representation have perfect recall as they - are treated as simultaneous-move games, thus, they do not have absent-mindedness. - """ - return self.game.deref().IsAbsentMinded() - @property def is_perfect_recall(self) -> bool: """Whether the game is perfect recall. diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index 2bd862f2e..43cf7f35d 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -161,6 +161,11 @@ 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 has absent-mindedness.""" + return self.infoset.deref().IsAbsentMinded() + @property def actions(self) -> InfosetActions: """The set of actions at the information set.""" From 9cd43399ace246d0d48422f6f072600197696a6e Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 24 Nov 2025 16:13:58 +0000 Subject: [PATCH 06/18] Refactor IsAbsentMinded as a method in the GameInfosetRep class --- src/games/game.cc | 17 ----------------- src/games/gametable.h | 1 - src/games/gametree.cc | 18 ++++++++++++++++++ src/games/gametree.h | 2 +- src/pygambit/gambit.pxd | 2 ++ 5 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/games/game.cc b/src/games/game.cc index cafa01e5e..37619653a 100644 --- a/src/games/game.cc +++ b/src/games/game.cc @@ -46,23 +46,6 @@ GameOutcomeRep::GameOutcomeRep(GameRep *p_game, int p_number) : m_game(p_game), } } -//======================================================================== -// class GameInfosetRep -//======================================================================== - -bool GameInfosetRep::IsAbsentMinded() const -{ - if (this->GetGame() m_infosetParents.empty() && !m_root->IsTerminal()) { - const_cast(this)->BuildInfosetParents(); - } - - if (GetRoot()->IsTerminal()) { - return true; - } - - return !m_absentMindedInfosets.empty(); -} - //======================================================================== // class GameStrategyRep //======================================================================== diff --git a/src/games/gametable.h b/src/games/gametable.h index 927868df8..3aab1dd38 100644 --- a/src/games/gametable.h +++ b/src/games/gametable.h @@ -64,7 +64,6 @@ class GameTableRep : public GameExplicitRep { Rational GetPlayerMaxPayoff(const GamePlayer &) const override; bool IsPerfectRecall() const override { return true; } - bool IsAbsentMinded() const override { return false; } //@} /// @name Dimensions of the game diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 2fd21a4ee..0c663fd2d 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -207,6 +207,15 @@ bool GameInfosetRep::Precedes(GameNode p_node) const return false; } +bool GameInfosetRep::IsAbsentMinded() const +{ + auto *tree = dynamic_cast(m_game); + if (tree) { + return tree->IsAbsentMinded(this); + } + return false; +} + GameAction GameTreeRep::InsertAction(GameInfoset p_infoset, GameAction p_action /* =nullptr */) { if (p_action && p_action->GetInfoset() != p_infoset) { @@ -832,6 +841,15 @@ bool GameTreeRep::IsPerfectRecall() const [](const auto &pair) { return pair.second.size() <= 1; }); } +bool GameTreeRep::IsAbsentMinded(const GameInfosetRep *infoset) const +{ + if (m_infosetParents.empty() && !m_root->IsTerminal()) { + const_cast(this)->BuildInfosetParents(); + } + + return m_absentMindedInfosets.count(const_cast(infoset)); +} + //------------------------------------------------------------------------ // GameTreeRep: Managing the representation //------------------------------------------------------------------------ diff --git a/src/games/gametree.h b/src/games/gametree.h index 21e9146cb..db8ec02a3 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -87,7 +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; + bool IsAbsentMinded(const GameInfosetRep *) const; //@} /// @name Players diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 50e56d7e9..4ec8a3baa 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -148,6 +148,8 @@ cdef extern from "games/game.h": bint Precedes(c_GameNode) except + stdset[c_GameAction] GetOwnPriorActions() except + + bint IsAbsentMinded() except + + cdef cppclass c_GamePlayerRep "GamePlayerRep": cppclass Infosets: cppclass iterator: From c817984bad77128e3b9ee002d904749d2c48b86d Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 5 Dec 2025 08:20:45 +0000 Subject: [PATCH 07/18] Rebase on updated master --- src/games/gametree.cc | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 0c663fd2d..2d7c91090 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -813,18 +813,18 @@ Rational GameTreeRep::GetPlayerMaxPayoff(const GamePlayer &p_player) const p_player, m_root, [](const Rational &a, const Rational &b) { return std::max(a, b); }); } -bool GameTreeRep::IsAbsentMinded() const -{ - if (!m_ownPriorActionInfo && !m_root->IsTerminal()) { - const_cast(this)->BuildOwnPriorActions(); - } +// bool GameTreeRep::IsAbsentMinded() const +//{ +// if (!m_ownPriorActionInfo && !m_root->IsTerminal()) { +// const_cast(this)->BuildOwnPriorActions(); +// } - if (GetRoot()->IsTerminal()) { - return true; - } +// if (GetRoot()->IsTerminal()) { +// return true; +// } - return !m_absentMindedInfosets.empty(); -} +// return !m_absentMindedInfosets.empty(); +//} bool GameTreeRep::IsPerfectRecall() const { @@ -843,8 +843,8 @@ bool GameTreeRep::IsPerfectRecall() const bool GameTreeRep::IsAbsentMinded(const GameInfosetRep *infoset) const { - if (m_infosetParents.empty() && !m_root->IsTerminal()) { - const_cast(this)->BuildInfosetParents(); + if (!m_ownPriorActionInfo && !m_root->IsTerminal()) { + const_cast(this)->BuildOwnPriorActions(); } return m_absentMindedInfosets.count(const_cast(infoset)); From f2582a986cea855b0bdfa7ad2565f0781cfd24f7 Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 8 Dec 2025 10:14:35 +0000 Subject: [PATCH 08/18] Call BuildUnreachableNodes() to populate m_absentMindedInfosets --- src/games/gametree.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 2d7c91090..93904f395 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -843,8 +843,8 @@ bool GameTreeRep::IsPerfectRecall() const bool GameTreeRep::IsAbsentMinded(const GameInfosetRep *infoset) const { - if (!m_ownPriorActionInfo && !m_root->IsTerminal()) { - const_cast(this)->BuildOwnPriorActions(); + if (!m_unreachableNodes && !m_root->IsTerminal()) { + const_cast(this)->BuildUnreachableNodes(); } return m_absentMindedInfosets.count(const_cast(infoset)); From 2823a5598960b3c415f8ba6d9e5bcab9d5fa8eec Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 8 Dec 2025 10:39:24 +0000 Subject: [PATCH 09/18] Update tests to check is_absent_minded on Infoset, make the perfect recall suite more compact --- tests/test_extensive.py | 63 ----------------------------------------- tests/test_infosets.py | 28 ++++++++++++++++++ 2 files changed, 28 insertions(+), 63 deletions(-) diff --git a/tests/test_extensive.py b/tests/test_extensive.py index c3271f827..db815cdf4 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -47,69 +47,14 @@ def test_game_add_players_nolabel(): game.add_player() -@pytest.mark.parametrize("game_input,expected_result", [ - # Games without absent-mindedness (includes all games with perfect recall) - ("e01.efg", False), - ("e02.efg", False), - ("cent3.efg", False), - ("poker.efg", False), - ("basic_extensive_game.efg", False), - ("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) - - # Generated games without absent-mindedness - # - Centipede games - (games.Centipede.get_test_data(N=3, m0=2, m1=7)[0], False), - (games.Centipede.get_test_data(N=4, m0=2, m1=7)[0], False), - # - Two-player binary tree games - (games.BinEfgTwoPlayer.get_test_data(level=3)[0], False), - (games.BinEfgTwoPlayer.get_test_data(level=4)[0], False), - # - Three-player binary tree games - (games.BinEfgThreePlayer.get_test_data(level=3)[0], False), - # - One-player imperfect recall binary tree games - (games.BinEfgOnePlayerIR.get_test_data(level=3)[0], False), - (games.BinEfgOnePlayerIR.get_test_data(level=4)[0], False), - - - # Games with absent-mindedness (a subset of games with imperfect recall) - ("noPR-AM-driver-one-player.efg", True), # 1 players, one infoset unreached - ("noPR-AM-driver-two-players.efg", True), # 2 players, one infoset unreached - ("noPR-action-AM.efg", True), # 2 players + forgetting past action; P1 has one infoset - ("noPR-action-AM2.efg", True), # 2 players + forgetting past action; P1 has >1 infoset - ("noPR-action-AM-two-hops.efg", True), # 2 players, one AM-infoset each -]) -def test_is_absent_minded(game_input, expected_result: bool): - """ - Verify the IsAbsentMinded implementation against a suite of games - with and without the absent-mindedness property. - """ - game = None - if isinstance(game_input, str): - game = games.read_from_file(game_input) - elif isinstance(game_input, gbt.Game): - game = game_input - else: - pytest.fail(f"Unknown type for game_input: {type(game_input)}") - - assert game.is_absent_minded == expected_result - - @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), @@ -117,20 +62,12 @@ def test_is_absent_minded(game_input, expected_result: bool): # 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): """ diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 2e90243cb..c4df5578c 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -162,3 +162,31 @@ def test_infoset_own_prior_actions(game_file, expected_results): } assert actual_details == expected_set + + +@pytest.mark.parametrize("game_input,expected_infosets", [ + # Games without absent-mindedness + ("e02.efg", set()), + ("stripped_down_poker.efg", set()), + ("basic_extensive_game.efg", set()), + ("gilboa_two_am_agents.efg", set()), # forgetting past information; Gilboa (GEB, 1997) + ("wichardt.efg", set()), # forgetting past action; Wichardt (GEB, 2008) + + # Games with absent-mindedness + ("noPR-AM-driver-two-players.efg", {("Player 1", 0)}), + ("noPR-action-AM.efg", {("Player 1", 0)}), + ("noPR-action-AM-two-hops.efg", {("Player 1", 0), ("Player 2", 0)}), +]) +def test_infoset_is_absent_minded(game_input, expected_infosets): + """ + Verify the is_absent_minded property of information sets. + """ + game = games.read_from_file(game_input) + + actual_infosets = { + (infoset.player.label, infoset.number) + for infoset in game.infosets + if infoset.is_absent_minded + } + + assert actual_infosets == expected_infosets From 6fc131cc15c73f5834193220a6d066fdb3b5b27a Mon Sep 17 00:00:00 2001 From: drdkad Date: Mon, 8 Dec 2025 10:50:19 +0000 Subject: [PATCH 10/18] Update ChangeLog and pygambit.api.rst --- ChangeLog | 5 ++--- doc/pygambit.api.rst | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ChangeLog b/ChangeLog index 72c9ba125..74531f854 100644 --- a/ChangeLog +++ b/ChangeLog @@ -3,9 +3,8 @@ ## [16.5.0] - unreleased ### Added -- Implement `IsAbsentMinded()` (C++) and `is_absent_minded` (Python) to detect -if a game has absent-mindedness (a player, moving along a single path of play, -can re-enter an information set visited previously). +- Implement `IsAbsentMinded()` on information sets (C++) and `Infoset.is_absent_minded` (Python) + to detect if an information set has absent-mindedness. ### Changed - In the graphical interface, removed option to configure information set link drawing; information sets diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 6c2428529..b432a6a24 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -98,7 +98,6 @@ Information about the game Game.comment Game.is_const_sum Game.is_tree - Game.is_absent_minded Game.is_perfect_recall Game.players Game.outcomes @@ -159,6 +158,7 @@ Information about the game Infoset.label Infoset.game Infoset.is_chance + Infoset.is_absent_minded Infoset.player Infoset.actions Infoset.members From 6ae2e79ad1e2b0323d1fdf4bc46dc991da812ca3 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 10 Dec 2025 10:26:14 +0000 Subject: [PATCH 11/18] Update Infoset.is_absent_minded to access the property via the parent Game object --- src/pygambit/gambit.pxd | 3 +-- src/pygambit/infoset.pxi | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 4ec8a3baa..335705471 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -148,8 +148,6 @@ cdef extern from "games/game.h": bint Precedes(c_GameNode) except + stdset[c_GameAction] GetOwnPriorActions() except + - bint IsAbsentMinded() except + - cdef cppclass c_GamePlayerRep "GamePlayerRep": cppclass Infosets: cppclass iterator: @@ -302,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 diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index 43cf7f35d..f3ad64133 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -164,7 +164,7 @@ class Infoset: @property def is_absent_minded(self) -> bool: """Whether the information set has absent-mindedness.""" - return self.infoset.deref().IsAbsentMinded() + return self.infoset.deref().GetGame().deref().IsAbsentMinded(self.infoset) @property def actions(self) -> InfosetActions: From ba4c11629046efef6a2517ced8d968838cbdc020 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 10 Dec 2025 10:28:20 +0000 Subject: [PATCH 12/18] Update tests to refer to an infoset by one of its members --- tests/test_infosets.py | 43 +++++++++++++++++++++++++++--------------- tests/test_node.py | 2 +- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/tests/test_infosets.py b/tests/test_infosets.py index c4df5578c..64b46222b 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -164,29 +164,42 @@ def test_infoset_own_prior_actions(game_file, expected_results): assert actual_details == expected_set -@pytest.mark.parametrize("game_input,expected_infosets", [ +def _get_infoset_by_path(game, path: list[str]) -> gbt.Infoset: + """ + Helper to find an infoset 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.infoset + + +@pytest.mark.parametrize("game_input, expected_am_paths", [ # Games without absent-mindedness - ("e02.efg", set()), - ("stripped_down_poker.efg", set()), - ("basic_extensive_game.efg", set()), - ("gilboa_two_am_agents.efg", set()), # forgetting past information; Gilboa (GEB, 1997) - ("wichardt.efg", set()), # forgetting past action; Wichardt (GEB, 2008) + ("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", {("Player 1", 0)}), - ("noPR-action-AM.efg", {("Player 1", 0)}), - ("noPR-action-AM-two-hops.efg", {("Player 1", 0), ("Player 2", 0)}), + ("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_infosets): +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) - actual_infosets = { - (infoset.player.label, infoset.number) - for infoset in game.infosets - if infoset.is_absent_minded - } + expected_infosets = {_get_infoset_by_path(game, path) for path in expected_am_paths} + actual_infosets = {infoset for infoset in game.infosets if infoset.is_absent_minded} assert actual_infosets == expected_infosets diff --git a/tests/test_node.py b/tests/test_node.py index bb3fcc040..944d51e8e 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -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")), From adb07f7580abcdb80ef7cfbadf7416738dd5c659 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 10 Dec 2025 11:37:20 +0000 Subject: [PATCH 13/18] Move IsAbsentMinded method from GameInfosetRep to GameRep (as a virtual method) and implement it in GameTreeRep --- src/games/game.h | 4 ++-- src/games/gametree.cc | 34 ++++++++-------------------------- src/games/gametree.h | 6 +++--- 3 files changed, 13 insertions(+), 31 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index 43fede2e2..3b7e8dcf7 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -225,8 +225,6 @@ class GameInfosetRep : public std::enable_shared_from_this { bool IsChanceInfoset() const; - bool IsAbsentMinded() const; - void SetLabel(const std::string &p_label) { m_label = p_label; } const std::string &GetLabel() const { return m_label; } @@ -761,6 +759,8 @@ class GameRep : public std::enable_shared_from_this { /// Returns true if the game is perfect recall virtual bool IsPerfectRecall() const = 0; + /// Returns true if an infoset of a given game is absent-minded + virtual bool IsAbsentMinded(const GameInfoset &) const { return false; } //@} /// @name Writing data files diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 93904f395..a4c56a194 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -207,15 +207,6 @@ bool GameInfosetRep::Precedes(GameNode p_node) const return false; } -bool GameInfosetRep::IsAbsentMinded() const -{ - auto *tree = dynamic_cast(m_game); - if (tree) { - return tree->IsAbsentMinded(this); - } - return false; -} - GameAction GameTreeRep::InsertAction(GameInfoset p_infoset, GameAction p_action /* =nullptr */) { if (p_action && p_action->GetInfoset() != p_infoset) { @@ -813,19 +804,6 @@ Rational GameTreeRep::GetPlayerMaxPayoff(const GamePlayer &p_player) const p_player, m_root, [](const Rational &a, const Rational &b) { return std::max(a, b); }); } -// bool GameTreeRep::IsAbsentMinded() const -//{ -// if (!m_ownPriorActionInfo && !m_root->IsTerminal()) { -// const_cast(this)->BuildOwnPriorActions(); -// } - -// if (GetRoot()->IsTerminal()) { -// return true; -// } - -// return !m_absentMindedInfosets.empty(); -//} - bool GameTreeRep::IsPerfectRecall() const { if (!m_ownPriorActionInfo && !m_root->IsTerminal()) { @@ -841,13 +819,17 @@ bool GameTreeRep::IsPerfectRecall() const [](const auto &pair) { return pair.second.size() <= 1; }); } -bool GameTreeRep::IsAbsentMinded(const GameInfosetRep *infoset) const +bool GameTreeRep::IsAbsentMinded(const GameInfoset &p_infoset) const { + if (p_infoset->GetGame().get() != this) { + throw MismatchException(); + } + if (!m_unreachableNodes && !m_root->IsTerminal()) { - const_cast(this)->BuildUnreachableNodes(); + BuildUnreachableNodes(); } - return m_absentMindedInfosets.count(const_cast(infoset)); + return contains(m_absentMindedInfosets, p_infoset.get()); } //------------------------------------------------------------------------ @@ -1040,7 +1022,7 @@ std::set GameTreeRep::GetOwnPriorActions(const GameInfoset &p_infose return result; } -void GameTreeRep::BuildUnreachableNodes() +void GameTreeRep::BuildUnreachableNodes() const { m_unreachableNodes = std::make_unique>(); diff --git a/src/games/gametree.h b/src/games/gametree.h index db8ec02a3..9c96939f1 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -47,7 +47,7 @@ class GameTreeRep : public GameExplicitRep { std::map> m_nodePlays; mutable std::shared_ptr m_ownPriorActionInfo; mutable std::unique_ptr> m_unreachableNodes; - std::set m_absentMindedInfosets; + mutable std::set m_absentMindedInfosets; /// @name Private auxiliary functions //@{ @@ -87,7 +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 GameInfosetRep *) const; + bool IsAbsentMinded(const GameInfoset &p_infoset) const override; //@} /// @name Players @@ -173,7 +173,7 @@ class GameTreeRep : public GameExplicitRep { private: std::vector BuildConsistentPlaysRecursiveImpl(GameNodeRep *node); void BuildOwnPriorActions() const; - void BuildUnreachableNodes(); + void BuildUnreachableNodes() const; }; template class TreeMixedStrategyProfileRep : public MixedStrategyProfileRep { From 2bdb2a7524103cfde3619c5c064a759f62fa09b8 Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 12 Dec 2025 05:54:55 +0000 Subject: [PATCH 14/18] Update `GameRep` base implementation to check game ownership of infosets --- src/games/game.h | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index 3b7e8dcf7..02792debd 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -759,8 +759,14 @@ class GameRep : public std::enable_shared_from_this { /// Returns true if the game is perfect recall virtual bool IsPerfectRecall() const = 0; - /// Returns true if an infoset of a given game is absent-minded - virtual bool IsAbsentMinded(const GameInfoset &) const { return false; } + /// 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 From c43c81f2194ebefa3a07a8e28d11290216f341f9 Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 12 Dec 2025 05:56:02 +0000 Subject: [PATCH 15/18] Use path-based node lookup in the tests --- tests/test_infosets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 64b46222b..4066939d0 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -164,9 +164,9 @@ def test_infoset_own_prior_actions(game_file, expected_results): assert actual_details == expected_set -def _get_infoset_by_path(game, path: list[str]) -> gbt.Infoset: +def _get_node_by_path(game, path: list[str]) -> gbt.Node: """ - Helper to find an infoset by following a sequence of action labels. + Helper to find a node by following a sequence of action labels. Parameters ---------- @@ -177,7 +177,7 @@ def _get_infoset_by_path(game, path: list[str]) -> gbt.Infoset: for action_label in reversed(path): node = node.children[action_label] - return node.infoset + return node @pytest.mark.parametrize("game_input, expected_am_paths", [ @@ -199,7 +199,7 @@ def test_infoset_is_absent_minded(game_input, expected_am_paths): """ game = games.read_from_file(game_input) - expected_infosets = {_get_infoset_by_path(game, path) for path in expected_am_paths} + 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 From f6acd5e3895f353f0c4b2ff83f26d15753320b7f Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 12 Dec 2025 05:57:52 +0000 Subject: [PATCH 16/18] Update documentation and ChangeLog for consistent terminology --- ChangeLog | 2 +- src/pygambit/infoset.pxi | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ChangeLog b/ChangeLog index 74531f854..d087d961d 100644 --- a/ChangeLog +++ b/ChangeLog @@ -4,7 +4,7 @@ ### Added - Implement `IsAbsentMinded()` on information sets (C++) and `Infoset.is_absent_minded` (Python) - to detect if an information set has absent-mindedness. + to detect if an information is absent-minded. ### Changed - In the graphical interface, removed option to configure information set link drawing; information sets diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index f3ad64133..d7087e79b 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -163,7 +163,12 @@ class Infoset: @property def is_absent_minded(self) -> bool: - """Whether the information set has absent-mindedness.""" + """ + 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. + """ return self.infoset.deref().GetGame().deref().IsAbsentMinded(self.infoset) @property From 44b8889ce0910b4d9c58e2bd7b29f2cc692b1f77 Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 12 Dec 2025 05:59:23 +0000 Subject: [PATCH 17/18] Remove const_cast for m_absentMindedInfosets --- src/games/gametree.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index a4c56a194..ba0b51a17 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -883,7 +883,7 @@ void GameTreeRep::ClearComputedValues() const const_cast(this)->m_nodePlays.clear(); m_ownPriorActionInfo = nullptr; const_cast(this)->m_unreachableNodes = nullptr; - const_cast(this)->m_absentMindedInfosets.clear(); + m_absentMindedInfosets.clear(); m_computedValues = false; } From ebd9c93d0bc1178744ac2e51027d16a01e102b7c Mon Sep 17 00:00:00 2001 From: Ted Turocy Date: Fri, 12 Dec 2025 13:38:14 +0000 Subject: [PATCH 18/18] Update infoset.pxi --- src/pygambit/infoset.pxi | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index d7087e79b..82b341654 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -168,6 +168,8 @@ class Infoset: 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)