diff --git a/ChangeLog b/ChangeLog index 251d827a1..d087d961d 100644 --- a/ChangeLog +++ b/ChangeLog @@ -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. diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 4b74f40f7..b432a6a24 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -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 diff --git a/src/games/game.h b/src/games/game.h index f98827b57..02792debd 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -759,6 +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 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 diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 33509228e..ba0b51a17 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -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 //------------------------------------------------------------------------ @@ -870,6 +883,7 @@ void GameTreeRep::ClearComputedValues() const const_cast(this)->m_nodePlays.clear(); m_ownPriorActionInfo = nullptr; const_cast(this)->m_unreachableNodes = nullptr; + m_absentMindedInfosets.clear(); m_computedValues = false; } @@ -1008,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>(); @@ -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..9c96939f1 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; + mutable 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 GameInfoset &p_infoset) const override; //@} /// @name Players @@ -171,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 { diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 50e56d7e9..335705471 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -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 diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index 2bd862f2e..82b341654 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -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.""" diff --git a/tests/test_extensive.py b/tests/test_extensive.py index 7b8470285..db815cdf4 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -49,18 +49,12 @@ 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), @@ -68,20 +62,12 @@ def test_game_add_players_nolabel(): # 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..4066939d0 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -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 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")),