From 4040d316363c69d3c8eb72e499db55c69216e230 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 2 Sep 2026 08:30:17 +0100 Subject: [PATCH 1/2] Add HasPerfectRecall for individual players --- ChangeLog | 6 ++++++ doc/pygambit.api.rst | 1 + src/games/game.h | 8 ++++++++ src/games/gametree.cc | 15 +++++++++++++++ src/games/gametree.h | 1 + src/pygambit/gambit.pxd | 1 + src/pygambit/game.pxi | 32 ++++++++++++++++++++++++++++++++ tests/test_extensive.py | 25 +++++++++++++++++++++++++ 8 files changed, 89 insertions(+) diff --git a/ChangeLog b/ChangeLog index 4a9d60f97..fa2db956f 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,9 @@ +## [17.0.0-beta.1] - unreleased + +### Added +- Added `Game.has_perfect_recall(player)`, and `GameRep::HasPerfectRecall(const GamePlayer &)` + in C++, reporting whether an individual player has perfect recall. (#1107) + ## [17.0.0-alpha.3] - 2026-08-31 ### Added diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index e8ef2c8a6..48c90043f 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -102,6 +102,7 @@ Information about the game Game.is_const_sum Game.is_tree Game.is_perfect_recall + Game.has_perfect_recall Game.players Game.outcomes Game.min_payoff diff --git a/src/games/game.h b/src/games/game.h index aba2722b2..69726d54f 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -1248,6 +1248,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 player has perfect recall + virtual bool HasPerfectRecall(const GamePlayer &p_player) const + { + if (p_player->GetGame().get() != this) { + throw MismatchException(); + } + return true; + } /// Returns true if the information set is absent-minded virtual bool IsAbsentMinded(const GameInfoset &p_infoset) const { diff --git a/src/games/gametree.cc b/src/games/gametree.cc index b3c7a424c..ffb2e1572 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -1035,6 +1035,21 @@ bool GameTreeRep::IsPerfectRecall() const [](const auto &pair) { return pair.second.size() <= 1; }); } +bool GameTreeRep::HasPerfectRecall(const GamePlayer &p_player) const +{ + if (p_player->GetGame().get() != this) { + throw MismatchException(); + } + EnsureOwnPriorActions(); + + // Restriction of the check in IsPerfectRecall() to the information sets belonging to p_player. + return std::all_of(m_ownPriorActionInfo->infoset_map.cbegin(), + m_ownPriorActionInfo->infoset_map.cend(), + [player = p_player.get()](const auto &pair) { + return pair.first->m_player != player || pair.second.size() <= 1; + }); +} + bool GameTreeRep::IsAbsentMinded(const GameInfoset &p_infoset) const { if (p_infoset->GetGame().get() != this) { diff --git a/src/games/gametree.h b/src/games/gametree.h index c3452345d..36078ca57 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -112,6 +112,7 @@ class GameTreeRep final : public GameExplicitRep { bool IsTree() const override { return true; } bool IsConstSum() const override; bool IsPerfectRecall() const override; + bool HasPerfectRecall(const GamePlayer &p_player) const override; /// Returns the smallest payoff to the player in any play of the game Rational GetPlayerMinPayoff(const GamePlayer &) const override; diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 964d9c29c..68bfe36ab 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -324,6 +324,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 HasPerfectRecall(c_GamePlayer) except + bool IsAbsentMinded(c_GameInfoset) except + c_GameInfoset AppendMove(c_GameNode, c_GamePlayer, stdvector[string]) except +ValueError diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 84c511663..ef442a59e 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -556,6 +556,38 @@ class Game: """ return self.game.deref().IsPerfectRecall() + def has_perfect_recall(self, player: str) -> bool: + """Returns whether `player` has perfect recall. + + A player has perfect recall if, at each of the player's information sets, every + member node is reached by the same sequence of the player's own prior actions; + that is, the player never forgets an action they took previously, nor information + they previously knew. A game has perfect recall if and only if every player does. + + By convention, in games with a strategic representation every player has perfect + recall as such games are treated as simultaneous-move games. + + .. versionadded:: 17.0.0 + + Parameters + ---------- + player : str + The label of the player. + + Raises + ------ + KeyError + If no player in the game has label `player`. + ValueError + If `player` is an empty string or all whitespace. + + See Also + -------- + Game.is_perfect_recall + """ + resolved_player = self._resolve_player(player, "has_perfect_recall") + return self.game.deref().HasPerfectRecall(resolved_player) + @property def min_payoff(self) -> decimal.Decimal | Rational: """The minimum payoff to any player in any play of the game. diff --git a/tests/test_extensive.py b/tests/test_extensive.py index dbe8b1db6..b0854cc01 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -97,6 +97,31 @@ def test_is_perfect_recall(game_input, expected_result: bool): assert game.is_perfect_recall == expected_result +@pytest.mark.parametrize("game_input,expected_result", [ + (gbt.catalog.load("journals/geb/wichardt2008"), {"Player 1": False, "Player 2": True}), + ("noPR-information-no-deflate.efg", {"Player 1": True, "Player 2": False}), + ("noPR-action-AM.efg", {"Player 1": False, "Player 2": True}), + ("stripped_down_poker.efg", {"Alice": True, "Bob": True}), + ("gilboa_two_am_agents.efg", {"Player 1": False, "Player 2": True}), + ("2x2.agg", {"1": True, "2": True}), +]) +def test_has_perfect_recall(game_input, expected_result: dict): + """ + Verify the HasPerfectRecall implementation, for individual players, against games + with and without perfect recall, and in each representation. + """ + game = (games.read_from_file(game_input) if isinstance(game_input, str) else game_input) + assert set(game.players) == set(expected_result) + for player, expected in expected_result.items(): + assert game.has_perfect_recall(player) == expected + + +def test_has_perfect_recall_trivial_game(): + game = gbt.Game.new_tree(players=["Alice", "Bob"]) + assert game.has_perfect_recall("Alice") + assert game.has_perfect_recall("Bob") + + def test_getting_payoff_by_label_string(): game = games.read_from_file("sample_extensive_game.efg") s1 = game.get_strategies("Player 1") From 389a68a672054b318acfabd90d986350206a7165 Mon Sep 17 00:00:00 2001 From: Ted Turocy Date: Wed, 2 Sep 2026 11:03:47 +0100 Subject: [PATCH 2/2] Update docstring for perfect recall explanation Added documentation regarding perfect recall in games. --- src/pygambit/game.pxi | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index ef442a59e..f5bfac7aa 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -553,6 +553,10 @@ class Game: By convention, games with a strategic representation have perfect recall as they are treated as simultaneous-move games. + + See Also + -------- + Game.has_perfect_recall """ return self.game.deref().IsPerfectRecall()