diff --git a/ChangeLog b/ChangeLog index c98fe0f2e..55304142f 100644 --- a/ChangeLog +++ b/ChangeLog @@ -2,6 +2,11 @@ ## [16.5.0] - unreleased +### Added +- Implement `GameTreeRep::GetOwnPriorActions` (C++) and `Game.get_own_prior_actions` (Python) + to compute, for a given information set, the set of last actions taken by the player acting + in the information set before reaching it. (#582) + ### 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..7a35e168a 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -109,6 +109,7 @@ Information about the game Game.infosets Game.nodes Game.contingencies + Game.get_own_prior_actions .. autosummary:: :toctree: api/ diff --git a/src/games/game.h b/src/games/game.h index 529dbc507..addacd58e 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -719,6 +719,8 @@ class GameRep : public std::enable_shared_from_this { /// Returns the set of terminal nodes which are descendants of members of an action virtual std::vector GetPlays(GameAction action) const { throw UndefinedException(); } + /// Returns, for a given infoset, the set of the most recent action(s) of the player active in it + virtual std::vector GetOwnPriorActions(GameInfoset infoset) const = 0; /// Returns true if the game is perfect recall virtual bool IsPerfectRecall() const = 0; //@} diff --git a/src/games/gameagg.h b/src/games/gameagg.h index d829dbab1..d1304158c 100644 --- a/src/games/gameagg.h +++ b/src/games/gameagg.h @@ -84,6 +84,10 @@ class GameAGGRep : public GameRep { //@{ bool IsTree() const override { return false; } bool IsAgg() const override { return true; } + std::vector GetOwnPriorActions(GameInfoset infoset) const override + { + throw UndefinedException(); + } bool IsPerfectRecall() const override { return true; } bool IsConstSum() const override; /// Returns the smallest payoff to any player in any outcome of the game diff --git a/src/games/gamebagg.h b/src/games/gamebagg.h index a3deffc9d..e453d5e56 100644 --- a/src/games/gamebagg.h +++ b/src/games/gamebagg.h @@ -91,6 +91,10 @@ class GameBAGGRep : public GameRep { //@{ bool IsTree() const override { return false; } virtual bool IsBagg() const { return true; } + std::vector GetOwnPriorActions(GameInfoset infoset) const override + { + throw UndefinedException(); + } bool IsPerfectRecall() const override { return true; } bool IsConstSum() const override { throw UndefinedException(); } /// Returns the smallest payoff to any player in any outcome of the game diff --git a/src/games/gametable.h b/src/games/gametable.h index 874851d70..9d150ee62 100644 --- a/src/games/gametable.h +++ b/src/games/gametable.h @@ -57,6 +57,11 @@ class GameTableRep : public GameExplicitRep { //@{ bool IsTree() const override { return false; } bool IsConstSum() const override; + std::vector GetOwnPriorActions(GameInfoset infoset) const override + { + throw UndefinedException(); + } + bool IsPerfectRecall() const override { return true; } //@} diff --git a/src/games/gametree.cc b/src/games/gametree.cc index de0c008a2..02c683153 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -736,6 +736,26 @@ bool GameTreeRep::IsConstSum() const } } +std::vector GameTreeRep::GetOwnPriorActions(GameInfoset infoset) const +{ + if (m_infosetParents.empty() && !m_root->IsTerminal()) { + const_cast(this)->BuildInfosetParents(); + } + + auto it = m_infosetParents.find(infoset.get()); + + // If the infoset is unreachable, return an empty vector. + if (it == m_infosetParents.end()) { + return {}; + } + + std::vector own_prior_actions; + for (auto action_ptr : it->second) { + own_prior_actions.emplace_back((action_ptr) ? action_ptr->shared_from_this() : nullptr); + } + return own_prior_actions; +} + bool GameTreeRep::IsPerfectRecall() const { if (m_infosetParents.empty() && !m_root->IsTerminal()) { diff --git a/src/games/gametree.h b/src/games/gametree.h index 085cd557b..6dd182f01 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -73,6 +73,7 @@ class GameTreeRep : public GameExplicitRep { //@{ bool IsTree() const override { return true; } bool IsConstSum() const override; + std::vector GetOwnPriorActions(GameInfoset infoset) const override; bool IsPerfectRecall() const override; //@} diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index e492d9c00..65ae14b87 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 + + stdvector[c_GameAction] GetOwnPriorActions(c_GameInfoset) 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..dd1d6bb8f 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -777,6 +777,37 @@ class Game: """Whether the game is constant sum.""" return self.game.deref().IsConstSum() + def get_own_prior_actions( + self, + infoset: typing.Union[Infoset, str] + ) -> typing.List[typing.Optional[Action]]: + """Return the list of actions which immediately precede `infoset` in the graph of + the player's information set. + + An "own prior action" is an action such that, for a given member node of the information + set, it is the action most recently played by the player on the path to that node. + If there is a member node where there is no such action, that is, the player has not + yet played prior to reaching that node, the own prior action is null, which is represented + by `None` in the list of actions returned. + + If a member node is not reachable due to the path to the node passing through an + absent-minded information set, that node has no own prior action. + + .. versionadded:: 16.5 + + Returns + ------- + list of {Action, None} + The list of the prior actions. + """ + infoset = self._resolve_infoset(infoset, "get_own_prior_actions") + return [ + None if not action else Action.wrap(action) + for action in self.game.deref().GetOwnPriorActions( + cython.cast(Infoset, infoset).infoset + ) + ] + @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..ceb53170f 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -49,6 +49,88 @@ def test_game_add_players_nolabel(): game.add_player() +@pytest.mark.parametrize( + "game_file, infoset_specification, expected_actions", + [ + # ====================================================================== + # Game 1: binary_3_levels_generic_payoffs.efg (Perfect Recall) + # ====================================================================== + # Player 1's root infoset. Reachable + ("binary_3_levels_generic_payoffs.efg", ("Player 1", 0), [None]), + # Player 1's second infoset. Reached after P1's own action 0 ("1"). + ( + "binary_3_levels_generic_payoffs.efg", + ("Player 1", 1), + [("Player 1", 0, 0)], + ), + # Player 1's third infoset. Reached after P1's own action 1 ("2"). + ( + "binary_3_levels_generic_payoffs.efg", + ("Player 1", 2), + [("Player 1", 0, 1)], + ), + # Player 2's only infoset. Reachable + ("binary_3_levels_generic_payoffs.efg", ("Player 2", 0), [None]), + # ====================================================================== + # Game 2: wichardt.efg (Imperfect Recall) + # ====================================================================== + # The root infoset for Player 1. Reachable + ("wichardt.efg", ("Player 1", 0), [None]), + # Player 1's second infoset. It can be reached after either action 0 ("L") or 1 ("R"). + ( + "wichardt.efg", + ("Player 1", 1), + [("Player 1", 0, 0), ("Player 1", 0, 1)], + ), + # Player 2's only infoset. Reachable. + ("wichardt.efg", ("Player 2", 0), [None]), + # ====================================================================== + # Game 3: noPR-action-AM-two-hops.efg (Absent-Mindedness) + # ====================================================================== + # Player 1's infoset 0. Has the property of Absent-Mindedness: + # Contains the root vertex and can be further reached via two different prior actions. + ( + "noPR-action-AM-two-hops.efg", + ("Player 1", 0), + [None, ("Player 1", 0, 0), ("Player 1", 1, 1)], + ), + # Player 1's infoset 1. Reached via a single prior action. + ("noPR-action-AM-two-hops.efg", ("Player 1", 1), [("Player 1", 0, 0)]), + # Player 2's infoset 0. Reached via a single prior action. + ("noPR-action-AM-two-hops.efg", ("Player 2", 0), [None, ("Player 2", 0, 0)]), + # Player 2's infoset 1. This infoset is unreachable. + ("noPR-action-AM-two-hops.efg", ("Player 2", 1), []), + ], +) +def test_get_own_prior_actions( + game_file: str, + infoset_specification: tuple[str, int], + expected_actions: list, +): + """ + Verifies get_own_prior_actions returns correct sets of actions for various infosets: + root, perfect recall, imperfect recall, absent-minded, and unreachable cases. + """ + game = games.read_from_file(game_file) + player_label, infoset_num = infoset_specification + + player = game.players[player_label] + infoset = player.infosets[infoset_num] + + result_actions = game.get_own_prior_actions(infoset) + + results = [ + None if action is None else ( + action.infoset.player.label, + action.infoset.number, + action.number, + ) + for action in result_actions + ] + + assert sorted(results, key=str) == sorted(expected_actions, key=str) + + @pytest.mark.parametrize("game_input,expected_result", [ # Games with perfect recall from files (game_input is a string) ("e01.efg", True),