diff --git a/ChangeLog b/ChangeLog index c98fe0f2e..9bbacc535 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 185f69335..cf60b3d24 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 diff --git a/src/games/game.h b/src/games/game.h index 529dbc507..ac6a18bd7 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -721,6 +721,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 d829dbab1..1b0acd859 100644 --- a/src/games/gameagg.h +++ b/src/games/gameagg.h @@ -85,6 +85,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 a3deffc9d..ebae98782 100644 --- a/src/games/gamebagg.h +++ b/src/games/gamebagg.h @@ -92,6 +92,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 874851d70..0c2172feb 100644 --- a/src/games/gametable.h +++ b/src/games/gametable.h @@ -58,6 +58,7 @@ class GameTableRep : public GameExplicitRep { bool IsTree() const override { return false; } bool IsConstSum() 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 de0c008a2..beb6ca2ec 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -736,6 +736,19 @@ bool GameTreeRep::IsConstSum() const } } +bool GameTreeRep::IsAbsentMinded() const +{ + if (m_infosetParents.empty() && !m_root->IsTerminal()) { + const_cast(this)->BuildInfosetParents(); + } + + if (GetRoot()->IsTerminal()) { + return true; + } + + return !m_absentMindedInfosets.empty(); +} + bool GameTreeRep::IsPerfectRecall() const { if (m_infosetParents.empty() && !m_root->IsTerminal()) { @@ -800,6 +813,7 @@ void GameTreeRep::ClearComputedValues() const } const_cast(this)->m_nodePlays.clear(); const_cast(this)->m_infosetParents.clear(); + const_cast(this)->m_absentMindedInfosets.clear(); m_computedValues = false; } @@ -900,6 +914,7 @@ void GameTreeRep::BuildInfosetParents() m_infosetParents[child->m_infoset].insert(prior_action ? prior_action.get() : nullptr); 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 085cd557b..9d00dd189 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -40,6 +40,7 @@ class GameTreeRep : public GameExplicitRep { std::size_t m_numNonterminalNodes = 0; std::map> m_nodePlays; std::map> m_infosetParents; + std::set m_absentMindedInfosets; /// @name Private auxiliary functions //@{ @@ -74,6 +75,7 @@ class GameTreeRep : public GameExplicitRep { bool IsTree() const override { return true; } bool IsConstSum() const override; bool IsPerfectRecall() const override; + bool IsAbsentMinded() const override; //@} /// @name Players diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index e492d9c00..6f3b6e583 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -295,6 +295,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 082b08b62..f6c2d63dc 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -777,6 +777,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. diff --git a/tests/test_extensive.py b/tests/test_extensive.py index b30c15398..a26b49f9c 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -49,6 +49,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),