From b8d718fd081ebe4fa6e6e925540da8f4151afb82 Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 24 Jun 2025 09:25:03 +0100 Subject: [PATCH 1/3] Refactor implementation of GameTreeRep::IsPerfectRecall, add a new iterator class to zip actions and children; Improve BuildInfosetParents to treat correctly absent-mindedness --- src/games/game.h | 195 ++++++++++++++++-- src/games/gameagg.h | 2 +- src/games/gamebagg.h | 2 +- src/games/gameobject.h | 2 + src/games/gametable.h | 2 +- src/games/gametree.cc | 164 +++++++++++---- src/games/gametree.h | 5 +- tests/test_extensive.py | 30 ++- tests/test_games/noPR-AM.efg | 13 ++ tests/test_games/noPR-action-AM.efg | 18 ++ tests/test_games/noPR-action-selten-horse.efg | 12 ++ .../noPR-information-no-deflate.efg | 15 ++ 12 files changed, 394 insertions(+), 66 deletions(-) create mode 100644 tests/test_games/noPR-AM.efg create mode 100644 tests/test_games/noPR-action-AM.efg create mode 100644 tests/test_games/noPR-action-selten-horse.efg create mode 100644 tests/test_games/noPR-information-no-deflate.efg diff --git a/src/games/game.h b/src/games/game.h index 5081ac48b..1fe85126e 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -57,6 +57,82 @@ using GameNode = GameObjectPtr; class GameRep; using Game = std::shared_ptr; +template class ElementCollection { + P m_owner{nullptr}; + const std::vector *m_container{nullptr}; + +public: + class iterator { + P m_owner{nullptr}; + const std::vector *m_container{nullptr}; + size_t m_index{0}; + + public: + using iterator_category = std::bidirectional_iterator_tag; + using difference_type = std::ptrdiff_t; + using value_type = GameObjectPtr; + using pointer = value_type *; + using reference = value_type &; + + iterator() = default; + iterator(const P &p_owner, const std::vector *p_container, size_t p_index = 0) + : m_owner(p_owner), m_container(p_container), m_index(p_index) + { + } + iterator(const iterator &) = default; + ~iterator() = default; + iterator &operator=(const iterator &) = default; + + bool operator==(const iterator &p_iter) const + { + return m_owner == p_iter.m_owner && m_container == p_iter.m_container && + m_index == p_iter.m_index; + } + bool operator!=(const iterator &p_iter) const + { + return m_owner != p_iter.m_owner || m_container != p_iter.m_container || + m_index != p_iter.m_index; + } + + iterator &operator++() + { + m_index++; + return *this; + } + iterator &operator--() + { + m_index--; + return *this; + } + value_type operator*() const { return m_container->at(m_index); } + const P &GetOwner() const { return m_owner; } + }; + + ElementCollection() = default; + explicit ElementCollection(const P &p_owner, const std::vector *p_container) + : m_owner(p_owner), m_container(p_container) + { + } + ElementCollection(const ElementCollection &) = default; + ~ElementCollection() = default; + ElementCollection &operator=(const ElementCollection &) = default; + + bool operator==(const ElementCollection &p_other) const + { + return m_owner == p_other.m_owner && m_container == p_other.m_container; + } + + bool empty() const { return m_container->empty(); } + size_t size() const { return m_container->size(); } + GameObjectPtr front() const { return m_container->front(); } + GameObjectPtr back() const { return m_container->back(); } + + iterator begin() const { return {m_owner, m_container, 0}; } + iterator end() const { return {m_owner, m_container, (m_owner) ? m_container->size() : 0}; } + iterator cbegin() const { return {m_owner, m_container, 0}; } + iterator cend() const { return {m_owner, m_container, (m_owner) ? m_container->size() : 0}; } +}; + // // Forward declarations of classes defined elsewhere. // @@ -412,11 +488,22 @@ class GameNodeRep : public std::enable_shared_from_this { public: using Children = ElementCollection; - GameNodeRep(GameRep *e, GameNodeRep *p); - ~GameNodeRep(); + /// @brief A range class for iterating over a node's (action, child) pairs. + class Actions { + private: + GameNode m_owner{nullptr}; - bool IsValid() const { return m_valid; } - void Invalidate() { m_valid = false; } + public: + class iterator; + + Actions(const GameNode p_owner); + + iterator begin() const; + iterator end() const; + }; + + /// @brief Returns a collection for iterating over this node's (action, child) pairs. + Actions GetActions() const; Game GetGame() const; @@ -451,6 +538,97 @@ class GameNodeRep : public std::enable_shared_from_this { bool IsSubgameRoot() const; }; +class GameNodeRep::Actions::iterator { +public: + /// @name Iterator + //@{ + using iterator_category = std::forward_iterator_tag; + using value_type = std::pair; + using difference_type = std::ptrdiff_t; + using pointer = value_type *; + using reference = value_type; + //@} + +private: + /// @brief An iterator to the action at the parent's information set. + GameInfosetRep::Actions::iterator m_action_it; + /// @brief An iterator to the child node. + GameNodeRep::Children::iterator m_child_it; + +public: + /// @name Lifecycle + //@{ + /// Default constructor. Creates an iterator in a past-the-end state. + iterator() = default; + + /// Creates a new iterator that zips an action iterator and a child iterator. + iterator(GameInfosetRep::Actions::iterator p_action_it, + GameNodeRep::Children::iterator p_child_it); + //@} + + /// @name Iterator Operations + //@{ + /// Returns the current action-child pair. + reference operator*() const { return {*m_action_it, *m_child_it}; } + + /// Advances the iterator to the next pair (pre-increment). + iterator &operator++() + { + ++m_action_it; + ++m_child_it; + return *this; + } + + /// Advances the iterator to the next pair (post-increment). + iterator operator++(int) + { + iterator tmp = *this; + ++(*this); + return tmp; + } + + /// Compares two iterators for equality. + bool operator==(const iterator &p_other) const + { + // Comparing one of the wrapped iterators is sufficient as they move in lockstep. + return m_child_it == p_other.m_child_it; + } + + /// Compares two iterators for inequality. + bool operator!=(const iterator &p_other) const { return !(*this == p_other); } + //@} + + GameNode GetOwner() const; +}; + +inline GameNodeRep::Actions::Actions(GameNode p_owner) : m_owner(p_owner) {} + +inline GameNodeRep::Actions GameNodeRep::GetActions() const { return Actions(this); } + +inline GameNodeRep::Actions::iterator GameNodeRep::Actions::begin() const +{ + if (m_owner->IsTerminal()) { + return end(); + } + return {m_owner->GetInfoset()->GetActions().begin(), m_owner->GetChildren().begin()}; +} + +inline GameNodeRep::Actions::iterator GameNodeRep::Actions::end() const +{ + if (m_owner->IsTerminal()) { + return {}; + } + return {m_owner->GetInfoset()->GetActions().end(), m_owner->GetChildren().end()}; +} + +inline GameNodeRep::Actions::iterator::iterator(GameInfosetRep::Actions::iterator p_action_it, + GameNodeRep::Children::iterator p_child_it) + : m_action_it(p_action_it), m_child_it(p_child_it) +{ +} + +inline GameNode GameNodeRep::Actions::iterator::GetOwner() const { return m_child_it.GetOwner(); } + /// This is the class for representing an arbitrary finite game. class GameRep : public std::enable_shared_from_this { friend class GameOutcomeRep; @@ -611,15 +789,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 true if the game is perfect recall. If not, - /// a pair of violating information sets is returned in the parameters. - virtual bool IsPerfectRecall(GameInfoset &, GameInfoset &) const = 0; /// Returns true if the game is perfect recall - bool IsPerfectRecall() const - { - GameInfoset s, t; - return IsPerfectRecall(s, t); - } + virtual bool IsPerfectRecall() const = 0; //@} /// @name Writing data files diff --git a/src/games/gameagg.h b/src/games/gameagg.h index 3c0c8a716..d829dbab1 100644 --- a/src/games/gameagg.h +++ b/src/games/gameagg.h @@ -84,7 +84,7 @@ class GameAGGRep : public GameRep { //@{ bool IsTree() const override { return false; } bool IsAgg() const override { return true; } - bool IsPerfectRecall(GameInfoset &, GameInfoset &) const override { return true; } + bool IsPerfectRecall() const override { return true; } 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 0c0be0f27..a3deffc9d 100644 --- a/src/games/gamebagg.h +++ b/src/games/gamebagg.h @@ -91,7 +91,7 @@ class GameBAGGRep : public GameRep { //@{ bool IsTree() const override { return false; } virtual bool IsBagg() const { return true; } - bool IsPerfectRecall(GameInfoset &, GameInfoset &) const override { return true; } + 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 Rational GetMinPayoff() const override { return Rational(baggPtr->getMinPayoff()); } diff --git a/src/games/gameobject.h b/src/games/gameobject.h index eaecd1c89..871a67f91 100644 --- a/src/games/gameobject.h +++ b/src/games/gameobject.h @@ -141,6 +141,8 @@ template class ElementCollection { return *this; } value_type operator*() const { return m_container->at(m_index); } + + inline P& GetOwner() const { return m_owner; } }; ElementCollection() = default; diff --git a/src/games/gametable.h b/src/games/gametable.h index d9fb2bb96..874851d70 100644 --- a/src/games/gametable.h +++ b/src/games/gametable.h @@ -57,7 +57,7 @@ class GameTableRep : public GameExplicitRep { //@{ bool IsTree() const override { return false; } bool IsConstSum() const override; - bool IsPerfectRecall(GameInfoset &, GameInfoset &) const override { return true; } + bool IsPerfectRecall() const override { return true; } //@} /// @name Dimensions of the game diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 87465a12b..72591e17c 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -23,6 +23,8 @@ #include #include #include +#include +#include #include "gambit.h" #include "gametree.h" @@ -747,54 +749,57 @@ bool GameTreeRep::IsConstSum() const } } -bool GameTreeRep::IsPerfectRecall(GameInfoset &s1, GameInfoset &s2) const +bool GameTreeRep::IsPerfectRecall() const { - for (auto player : m_players) { - for (size_t i = 1; i <= player->m_infosets.size(); i++) { - auto iset1 = player->m_infosets[i - 1]; - for (size_t j = 1; j <= player->m_infosets.size(); j++) { - auto iset2 = player->m_infosets[j - 1]; - - bool precedes = false; - GameAction action = nullptr; - - for (size_t m = 1; m <= iset2->m_members.size(); m++) { - size_t n; - for (n = 1; n <= iset1->m_members.size(); n++) { - if (iset2->GetMember(m)->IsSuccessorOf(iset1->GetMember(n)) && - iset1->GetMember(n) != iset2->GetMember(m)) { - precedes = true; - for (const auto &act : iset1->GetActions()) { - if (iset2->GetMember(m)->IsSuccessorOf(iset1->GetMember(n)->GetChild(act))) { - if (action != nullptr && action != act) { - s1 = iset1; - s2 = iset2; - return false; - } - action = act; - } - } - break; - } - } + if (m_infosetParents.empty() && !GetRoot()->IsTerminal()) { + const_cast(this)->BuildInfosetParents(); + } - if (i == j && precedes) { - s1 = iset1; - s2 = iset2; - return false; - } + // ==================================================================== - if (n > iset1->m_members.size() && precedes) { - s1 = iset1; - s2 = iset2; - return false; - } - } + std::cerr << "\n--- m_infosetParents ---\n"; + + if (m_infosetParents.empty() && !m_root->IsTerminal()) { + std::cerr << " (Cache is empty or game is trivial)\n"; + } + + // Iterate through the map to print its contents. + // Assumes m_infosetParents uses raw pointers as keys/values now. + for (const auto &[infoset_ptr, parent_actions_set] : m_infosetParents) { + // Print the information set identifier. + std::cerr << " - Infoset " << infoset_ptr->GetPlayer()->GetNumber() << "." + << infoset_ptr->GetNumber() << " (Player '" << infoset_ptr->GetPlayer()->GetLabel() + << "'):\n"; + + if (parent_actions_set.empty()) { + std::cerr << " - (No parent actions recorded)\n"; + } + + // Print each recorded parent action for this infoset. + for (const auto &action_ptr : parent_actions_set) { + if (action_ptr) { + // If the action is not null, print its label and the infoset it belongs to. + std::cerr << " - Reached via Action '" << action_ptr->GetLabel() << "' (from Infoset " + << action_ptr->GetInfoset()->GetPlayer()->GetNumber() << "." + << action_ptr->GetInfoset()->GetNumber() << ")\n"; + } + else { + // This case is for the root or for players who haven't acted yet on a path. + std::cerr << " - Reached via null action\n"; } } } + std::cerr << "---------------------------\n"; + // ==================================================================== + // DEBUGGING PRINTS END HERE + // ==================================================================== - return true; + if (GetRoot()->IsTerminal()) { + return true; + } + + return std::all_of(m_infosetParents.cbegin(), m_infosetParents.cend(), + [](const auto &pair) { return pair.second.size() <= 1; }); } //------------------------------------------------------------------------ @@ -871,6 +876,7 @@ void GameTreeRep::ClearComputedValues() const player->m_strategies.clear(); } const_cast(this)->m_nodePlays.clear(); + const_cast(this)->m_infosetParents.clear(); m_computedValues = false; } @@ -911,6 +917,82 @@ std::vector GameTreeRep::BuildConsistentPlaysRecursiveImpl(GameNo return consistent_plays; } +void GameTreeRep::BuildInfosetParents() +{ + // The main traversal stack. It holds iterators that explore the children nodes. + // It does not contain entries for the nodes that are skipped over + // or where the previously taken decision is taken again due to absent-mindedness. + std::stack position; + // tracks actions taken by each player on the current path + std::map> prior_actions; + // stores the first action choice made for an infoset on a given exploration path + std::map> initial_choice; + + if (m_root->IsTerminal()) { + m_infosetParents[m_root->GetInfoset()].insert(nullptr); + return; + } + + for (auto player : m_players) { + prior_actions[player].emplace(nullptr); + } + prior_actions[m_chance].emplace(nullptr); + + position.emplace(m_root->GetActions().begin()); + prior_actions[m_root->GetPlayer()].emplace(nullptr); + m_infosetParents[m_root->GetInfoset()].insert(nullptr); + + while (!position.empty()) { + auto ¤t_it = position.top(); + auto parent = current_it.GetOwner(); + + if (current_it != parent->GetActions().end()) { + auto [action, child] = *current_it; + + prior_actions[parent->GetPlayer()].top() = action; + initial_choice[parent->GetInfoset()] = {parent, action}; + + // records every emplace made onto the prior_actions stack during a fast-forward. + std::vector fast_forward_history; + + // fast forward absent-minded child nodes + auto initial_choice_it = initial_choice.find(child->GetInfoset()); + while (initial_choice_it != initial_choice.end()) { + auto initial_action = initial_choice_it->second.second; + auto prior_action_ff = prior_actions[child->GetPlayer()].top(); + m_infosetParents[child->GetInfoset()].insert(prior_action_ff); + + auto newchild = child->GetChild(initial_action); + + prior_actions[child->GetPlayer()].emplace(initial_action); + fast_forward_history.emplace_back(child->GetPlayer()); + + child = newchild; + initial_choice_it = initial_choice.find(newchild->GetInfoset()); + } + + if (!child->IsTerminal()) { + auto child_player = child->GetPlayer(); + auto prior_action_desc = prior_actions[child_player].top(); + m_infosetParents[child->GetInfoset()].insert(prior_action_desc); + position.emplace(child->GetActions().begin()); + prior_actions[child_player].emplace(nullptr); + } + + ++current_it; + + for (auto it = fast_forward_history.rbegin(); it != fast_forward_history.rend(); ++it) { + prior_actions.at(*it).pop(); + } + } + else { + prior_actions.at(parent->GetPlayer()).pop(); + position.pop(); + initial_choice.erase(parent->GetInfoset()); + } + } +} + //------------------------------------------------------------------------ // GameTreeRep: Writing data files //------------------------------------------------------------------------ diff --git a/src/games/gametree.h b/src/games/gametree.h index 710c0d42d..9899ab41e 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -39,6 +39,7 @@ class GameTreeRep : public GameExplicitRep { std::size_t m_numNodes = 1; std::size_t m_numNonterminalNodes = 0; std::map> m_nodePlays; + std::map> m_infosetParents; /// @name Private auxiliary functions //@{ @@ -72,8 +73,7 @@ class GameTreeRep : public GameExplicitRep { //@{ bool IsTree() const override { return true; } bool IsConstSum() const override; - using GameRep::IsPerfectRecall; - bool IsPerfectRecall(GameInfoset &, GameInfoset &) const override; + bool IsPerfectRecall() const override; /// Turn on or off automatic canonicalization of the game void SetCanonicalization(bool p_doCanon) const { @@ -160,6 +160,7 @@ class GameTreeRep : public GameExplicitRep { private: std::vector BuildConsistentPlaysRecursiveImpl(GameNodeRep *node); + void BuildInfosetParents(); }; template class TreeMixedStrategyProfileRep : public MixedStrategyProfileRep { diff --git a/tests/test_extensive.py b/tests/test_extensive.py index 81027e38e..a68336d82 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -49,14 +49,28 @@ def test_game_add_players_nolabel(): game.add_player() -def test_game_is_perfect_recall(): - game = games.read_from_file("perfect_recall.efg") - assert game.is_perfect_recall +@pytest.mark.parametrize("game_filename,expected_result", [ + # Games that have perfect recall + ("e01.efg", True), + ("e02.efg", True), + ("cent3.efg", True), + ("poker.efg", True), + ("basic_extensive_game.efg", True), - -def test_game_is_not_perfect_recall(): - game = games.read_from_file("not_perfect_recall.efg") - assert not game.is_perfect_recall + # Games that do not have perfect recall + ("wichardt.efg", False), # forgetting past action + ("noPR-action-selten-horse.efg", False), # forgetting past action + ("noPR-information-no-deflate.efg", False), # forgetting past information + ("noPR-AM.efg", False), # absent-mindedness + ("noPR-action-AM.efg", False), # absent-mindedness + forgetting past action +]) +def test_is_perfect_recall(game_filename: str, expected_result: bool): + """ + Verify the IsPerfectRecall implementation against a suite of games + with and without the perfect recall property. + """ + game = games.read_from_file(game_filename) + assert game.is_perfect_recall == expected_result def test_getting_payoff_by_label_string(): @@ -95,7 +109,7 @@ def test_outcome_index_exception_label(): "game,strategy_labels,np_arrays_of_rsf", [ ############################################################################### - # # 1 player; reduction; generic payoffs + # 1 player; reduction; generic payoffs ( games.create_reduction_one_player_generic_payoffs_efg(), [["11", "12", "2*", "3*", "4*"]], diff --git a/tests/test_games/noPR-AM.efg b/tests/test_games/noPR-AM.efg new file mode 100644 index 000000000..816359cb6 --- /dev/null +++ b/tests/test_games/noPR-AM.efg @@ -0,0 +1,13 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +p "" 1 1 "" { "1" "2" } 0 +p "" 2 1 "" { "1" "2" } 0 +p "" 1 1 "" { "1" "2" } 0 +t "" 1 "Outcome 1" { 1, 1 } +p "" 2 2 "" { "1" "2" "3" } 0 +t "" 2 "Outcome 2" { 0, 2 } +t "" 5 "Outcome 5" { 0, 5 } +t "" 6 "Outcome 6" { 0, 6 } +t "" 3 "Outcome 3" { 0, 3 } +t "" 4 "Outcome 4" { 2, 0 } diff --git a/tests/test_games/noPR-action-AM.efg b/tests/test_games/noPR-action-AM.efg new file mode 100644 index 000000000..92121a12e --- /dev/null +++ b/tests/test_games/noPR-action-AM.efg @@ -0,0 +1,18 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +p "" 1 1 "" { "1" "2" } 0 +p "" 1 1 "" { "1" "2" } 0 +p "" 2 1 "" { "1" "2" } 0 +t "" 1 "Outcome 1" { 1, -1 } +t "" 2 "Outcome 2" { 2, -2 } +p "" 2 2 "" { "1" "2" } 0 +t "" 3 "Outcome 2" { 3, -3 } +t "" 4 "Outcome 2" { 4, -4 } +p "" 1 1 "" { "1" "2" } 0 +p "" 2 3 "" { "1" "2" } 0 +t "" 5 "Outcome 2" { 5, -5 } +t "" 6 "Outcome 2" { 6, -6 } +p "" 2 4 "" { "1" "2" } 0 +t "" 7 "Outcome 2" { 7, -7 } +t "" 8 "Outcome 2" { 8, -8 } diff --git a/tests/test_games/noPR-action-selten-horse.efg b/tests/test_games/noPR-action-selten-horse.efg new file mode 100644 index 000000000..dc377081e --- /dev/null +++ b/tests/test_games/noPR-action-selten-horse.efg @@ -0,0 +1,12 @@ +EFG 2 R "Selten's Horse': 2 players, imperfect recall" { "Player 1" "Player 2" } +"" + +p "" 1 1 "(1,1)" { "R" "L" } 0 +p "" 2 1 "(2,1)" { "R" "L" } 0 +t "" 1 "Outcome 1" { 1, 1 } +p "" 1 2 "(1,2)" { "R" "L" } 0 +t "" 2 "Outcome 2" { 4, 4 } +t "" 3 "Outcome 3" { 0, 0 } +p "" 1 2 "(1,2)" { "R" "L" } 0 +t "" 4 "Outcome 4" { 3, 2 } +t "" 5 "Outcome 5" { 0, 0 } diff --git a/tests/test_games/noPR-information-no-deflate.efg b/tests/test_games/noPR-information-no-deflate.efg new file mode 100644 index 000000000..af5abc77d --- /dev/null +++ b/tests/test_games/noPR-information-no-deflate.efg @@ -0,0 +1,15 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +p "" 1 1 "" { "1" "2" "3" } 0 +p "" 2 1 "" { "1" "2" } 0 +t "" 1 "h" { 8, -8 } +t "" 2 "g" { 7, -7 } +p "" 2 1 "" { "1" "2" } 0 +t "" 0 +p "" 2 2 "" { "1" "2" } 0 +t "" 5 "d" { 4, -4 } +t "" 6 "c" { 3, -3 } +p "" 2 2 "" { "1" "2" } 0 +t "" 7 "b" { 2, -2 } +t "" 8 "a" { 1, -1 } From c46ecd1294aa38775515b01524a832416182f725 Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 22 Jul 2025 09:52:59 +0100 Subject: [PATCH 2/3] Extended test suite for the PR-checker --- src/games/gametree.cc | 147 +++++++----------- tests/test_extensive.py | 46 ++++-- tests/test_games/gilboa_two_am_agents.efg | 14 ++ .../test_games/noPR-AM-driver-one-player.efg | 12 ++ ...-AM.efg => noPR-AM-driver-two-players.efg} | 0 tests/test_games/noPR-action-AM-two-hops.efg | 19 +++ tests/test_games/noPR-action-AM2.efg | 18 +++ 7 files changed, 152 insertions(+), 104 deletions(-) create mode 100644 tests/test_games/gilboa_two_am_agents.efg create mode 100644 tests/test_games/noPR-AM-driver-one-player.efg rename tests/test_games/{noPR-AM.efg => noPR-AM-driver-two-players.efg} (100%) create mode 100644 tests/test_games/noPR-action-AM-two-hops.efg create mode 100644 tests/test_games/noPR-action-AM2.efg diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 72591e17c..e68817d69 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -25,6 +25,7 @@ #include #include #include +#include #include "gambit.h" #include "gametree.h" @@ -751,48 +752,9 @@ bool GameTreeRep::IsConstSum() const bool GameTreeRep::IsPerfectRecall() const { - if (m_infosetParents.empty() && !GetRoot()->IsTerminal()) { - const_cast(this)->BuildInfosetParents(); - } - - // ==================================================================== - - std::cerr << "\n--- m_infosetParents ---\n"; - if (m_infosetParents.empty() && !m_root->IsTerminal()) { - std::cerr << " (Cache is empty or game is trivial)\n"; - } - - // Iterate through the map to print its contents. - // Assumes m_infosetParents uses raw pointers as keys/values now. - for (const auto &[infoset_ptr, parent_actions_set] : m_infosetParents) { - // Print the information set identifier. - std::cerr << " - Infoset " << infoset_ptr->GetPlayer()->GetNumber() << "." - << infoset_ptr->GetNumber() << " (Player '" << infoset_ptr->GetPlayer()->GetLabel() - << "'):\n"; - - if (parent_actions_set.empty()) { - std::cerr << " - (No parent actions recorded)\n"; - } - - // Print each recorded parent action for this infoset. - for (const auto &action_ptr : parent_actions_set) { - if (action_ptr) { - // If the action is not null, print its label and the infoset it belongs to. - std::cerr << " - Reached via Action '" << action_ptr->GetLabel() << "' (from Infoset " - << action_ptr->GetInfoset()->GetPlayer()->GetNumber() << "." - << action_ptr->GetInfoset()->GetNumber() << ")\n"; - } - else { - // This case is for the root or for players who haven't acted yet on a path. - std::cerr << " - Reached via null action\n"; - } - } + const_cast(this)->BuildInfosetParents(); } - std::cerr << "---------------------------\n"; - // ==================================================================== - // DEBUGGING PRINTS END HERE - // ==================================================================== if (GetRoot()->IsTerminal()) { return true; @@ -919,76 +881,71 @@ std::vector GameTreeRep::BuildConsistentPlaysRecursiveImpl(GameNo void GameTreeRep::BuildInfosetParents() { - // The main traversal stack. It holds iterators that explore the children nodes. - // It does not contain entries for the nodes that are skipped over - // or where the previously taken decision is taken again due to absent-mindedness. - std::stack position; - // tracks actions taken by each player on the current path - std::map> prior_actions; - // stores the first action choice made for an infoset on a given exploration path - std::map> initial_choice; - if (m_root->IsTerminal()) { - m_infosetParents[m_root->GetInfoset()].insert(nullptr); + m_infosetParents[m_root->m_infoset].insert(nullptr); return; } - for (auto player : m_players) { - prior_actions[player].emplace(nullptr); + using AbsentMindedEdge = std::pair; + using ActiveEdge = std::variant; + std::stack position; + + std::map> prior_actions; + std::map path_choices; + + for (auto player_rep : m_players) { + prior_actions[GamePlayer(player_rep)].emplace(nullptr); } - prior_actions[m_chance].emplace(nullptr); + prior_actions[GamePlayer(m_chance)].emplace(nullptr); position.emplace(m_root->GetActions().begin()); - prior_actions[m_root->GetPlayer()].emplace(nullptr); - m_infosetParents[m_root->GetInfoset()].insert(nullptr); + prior_actions[m_root->m_infoset->m_player].emplace(nullptr); + if (m_root->m_infoset) { + m_infosetParents[m_root->m_infoset].insert(nullptr); + } while (!position.empty()) { - auto ¤t_it = position.top(); - auto parent = current_it.GetOwner(); - - if (current_it != parent->GetActions().end()) { - auto [action, child] = *current_it; - - prior_actions[parent->GetPlayer()].top() = action; - initial_choice[parent->GetInfoset()] = {parent, action}; - - // records every emplace made onto the prior_actions stack during a fast-forward. - std::vector fast_forward_history; - - // fast forward absent-minded child nodes - auto initial_choice_it = initial_choice.find(child->GetInfoset()); - while (initial_choice_it != initial_choice.end()) { - auto initial_action = initial_choice_it->second.second; - auto prior_action_ff = prior_actions[child->GetPlayer()].top(); - m_infosetParents[child->GetInfoset()].insert(prior_action_ff); + ActiveEdge ¤t_edge = position.top(); + GameNode child, node; + GameAction action; + + if (std::holds_alternative(current_edge)) { + auto ¤t_it = std::get(current_edge); + node = current_it.GetOwner(); + + if (current_it == node->GetActions().end()) { + prior_actions.at(node->m_infoset->m_player).pop(); + position.pop(); + path_choices.erase(node->m_infoset); + continue; + } + else { + std::tie(action, child) = *current_it; + ++current_it; + path_choices[node->m_infoset] = action; + } + } + else { + std::tie(action, node) = std::get(current_edge); + position.pop(); + child = node->GetChild(action); + } - auto newchild = child->GetChild(initial_action); + prior_actions.at(node->m_infoset->m_player).top() = action; - prior_actions[child->GetPlayer()].emplace(initial_action); - fast_forward_history.emplace_back(child->GetPlayer()); + if (!child->IsTerminal()) { + auto child_player = child->m_infoset->m_player; + auto prior_action = prior_actions.at(child_player).top(); + m_infosetParents[child->m_infoset].insert(prior_action); - child = newchild; - initial_choice_it = initial_choice.find(newchild->GetInfoset()); + if (path_choices.find(child->m_infoset) != path_choices.end()) { + const GameAction replay_action = path_choices.at(child->m_infoset); + position.emplace(AbsentMindedEdge{replay_action, child}); } - - if (!child->IsTerminal()) { - auto child_player = child->GetPlayer(); - auto prior_action_desc = prior_actions[child_player].top(); - m_infosetParents[child->GetInfoset()].insert(prior_action_desc); + else { position.emplace(child->GetActions().begin()); - prior_actions[child_player].emplace(nullptr); - } - - ++current_it; - - for (auto it = fast_forward_history.rbegin(); it != fast_forward_history.rend(); ++it) { - prior_actions.at(*it).pop(); } - } - else { - prior_actions.at(parent->GetPlayer()).pop(); - position.pop(); - initial_choice.erase(parent->GetInfoset()); + prior_actions.at(child_player).emplace(nullptr); } } } diff --git a/tests/test_extensive.py b/tests/test_extensive.py index a68336d82..80c7d90e8 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -49,27 +49,55 @@ def test_game_add_players_nolabel(): game.add_player() -@pytest.mark.parametrize("game_filename,expected_result", [ - # Games that have perfect recall +@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), ("poker.efg", True), ("basic_extensive_game.efg", True), - # Games that do not have perfect recall - ("wichardt.efg", False), # forgetting past action + # 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), + + # 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 - ("noPR-AM.efg", False), # absent-mindedness - ("noPR-action-AM.efg", False), # absent-mindedness + forgetting past action + ("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_filename: str, expected_result: bool): +def test_is_perfect_recall(game_input, expected_result: bool): """ Verify the IsPerfectRecall implementation against a suite of games - with and without the perfect recall property. + with and without the perfect recall, from both files and generation. """ - game = games.read_from_file(game_filename) + 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_perfect_recall == expected_result diff --git a/tests/test_games/gilboa_two_am_agents.efg b/tests/test_games/gilboa_two_am_agents.efg new file mode 100644 index 000000000..1c8645f86 --- /dev/null +++ b/tests/test_games/gilboa_two_am_agents.efg @@ -0,0 +1,14 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +c "" 1 "" { "1" 1/2 "2" 1/2 } 0 +p "" 1 1 "" { "1" "2" } 0 +t "" 1 "Outcome 1" { 1, -1 } +p "" 1 2 "" { "1" "2" } 0 +t "" 2 "Outcome 2" { 2, -2 } +t "" 3 "Outcome 3" { 3, -3 } +p "" 1 2 "" { "1" "2" } 0 +p "" 1 1 "" { "1" "2" } 0 +t "" 4 "Outcome 4" { 4, -4 } +t "" 5 "Outcome 5" { 5, -5 } +t "" 6 "Outcome 6" { 6, -6 } diff --git a/tests/test_games/noPR-AM-driver-one-player.efg b/tests/test_games/noPR-AM-driver-one-player.efg new file mode 100644 index 000000000..9e4395612 --- /dev/null +++ b/tests/test_games/noPR-AM-driver-one-player.efg @@ -0,0 +1,12 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +p "" 1 1 "" { "1" "2" } 0 +p "" 1 2 "" { "1" "2" } 0 +p "" 1 1 "" { "1" "2" } 0 +t "" 1 "Outcome 1" { 1, -1 } +p "" 1 3 "" { "1" "2" } 0 +t "" 2 "Outcome 2" { 2, -2 } +t "" 5 "Outcome 5" { 5, -5 } +t "" 3 "Outcome 3" { 3, -3 } +t "" 4 "Outcome 4" { 4, -4 } diff --git a/tests/test_games/noPR-AM.efg b/tests/test_games/noPR-AM-driver-two-players.efg similarity index 100% rename from tests/test_games/noPR-AM.efg rename to tests/test_games/noPR-AM-driver-two-players.efg diff --git a/tests/test_games/noPR-action-AM-two-hops.efg b/tests/test_games/noPR-action-AM-two-hops.efg new file mode 100644 index 000000000..95bba158d --- /dev/null +++ b/tests/test_games/noPR-action-AM-two-hops.efg @@ -0,0 +1,19 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +p "" 1 1 "" { "1" "2" } 0 +p "" 2 1 "" { "1" "2" } 0 +p "" 1 1 "" { "1" "2" } 0 +p "" 2 1 "" { "1" "2" } 0 +p "" 1 2 "" { "1" "2" } 0 +t "" 1 "Outcome 1" { 1, 1 } +p "" 1 1 "" { "1" "2" } 0 +t "" 10 "Outcome 10" { 10, -10 } +t "" 11 "Outcome 11" { 11, -11 } +t "" 12 "Outcome 12" { 12, -12 } +p "" 2 2 "" { "1" "2" "3" } 0 +t "" 5 "Outcome 2" { 0, 2 } +t "" 6 "Outcome 5" { 0, 5 } +t "" 7 "Outcome 6" { 0, 6 } +t "" 8 "Outcome 3" { 0, 3 } +t "" 9 "Outcome 4" { 2, 0 } diff --git a/tests/test_games/noPR-action-AM2.efg b/tests/test_games/noPR-action-AM2.efg new file mode 100644 index 000000000..bc3ddbd29 --- /dev/null +++ b/tests/test_games/noPR-action-AM2.efg @@ -0,0 +1,18 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +p "" 1 1 "" { "1" "2" } 0 +p "" 1 1 "" { "1" "2" } 0 +p "" 1 2 "" { "1" "2" } 0 +t "" 1 "Outcome 1" { 1, -1 } +t "" 2 "Outcome 2" { 2, -2 } +p "" 2 1 "" { "1" "2" } 0 +t "" 3 "Outcome 2" { 3, -3 } +t "" 4 "Outcome 2" { 4, -4 } +p "" 1 1 "" { "1" "2" } 0 +p "" 1 3 "" { "1" "2" } 0 +t "" 5 "Outcome 2" { 5, -5 } +t "" 6 "Outcome 2" { 6, -6 } +p "" 2 2 "" { "1" "2" } 0 +t "" 7 "Outcome 2" { 7, -7 } +t "" 8 "Outcome 2" { 8, -8 } From 953f60689e8c88195d1ddacbaa383790dd49e87b Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 1 Aug 2025 12:58:43 +0100 Subject: [PATCH 3/3] =?UTF-8?q?Refactor=20BuildInfosetParents=20defining?= =?UTF-8?q?=20the=C2=A0position=20using=20ActiveEdge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/games/game.h | 90 +++++------------------------------------- src/games/gameobject.h | 2 +- src/games/gametree.cc | 18 ++++----- 3 files changed, 20 insertions(+), 90 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index 1fe85126e..95c6066d7 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -57,82 +57,6 @@ using GameNode = GameObjectPtr; class GameRep; using Game = std::shared_ptr; -template class ElementCollection { - P m_owner{nullptr}; - const std::vector *m_container{nullptr}; - -public: - class iterator { - P m_owner{nullptr}; - const std::vector *m_container{nullptr}; - size_t m_index{0}; - - public: - using iterator_category = std::bidirectional_iterator_tag; - using difference_type = std::ptrdiff_t; - using value_type = GameObjectPtr; - using pointer = value_type *; - using reference = value_type &; - - iterator() = default; - iterator(const P &p_owner, const std::vector *p_container, size_t p_index = 0) - : m_owner(p_owner), m_container(p_container), m_index(p_index) - { - } - iterator(const iterator &) = default; - ~iterator() = default; - iterator &operator=(const iterator &) = default; - - bool operator==(const iterator &p_iter) const - { - return m_owner == p_iter.m_owner && m_container == p_iter.m_container && - m_index == p_iter.m_index; - } - bool operator!=(const iterator &p_iter) const - { - return m_owner != p_iter.m_owner || m_container != p_iter.m_container || - m_index != p_iter.m_index; - } - - iterator &operator++() - { - m_index++; - return *this; - } - iterator &operator--() - { - m_index--; - return *this; - } - value_type operator*() const { return m_container->at(m_index); } - const P &GetOwner() const { return m_owner; } - }; - - ElementCollection() = default; - explicit ElementCollection(const P &p_owner, const std::vector *p_container) - : m_owner(p_owner), m_container(p_container) - { - } - ElementCollection(const ElementCollection &) = default; - ~ElementCollection() = default; - ElementCollection &operator=(const ElementCollection &) = default; - - bool operator==(const ElementCollection &p_other) const - { - return m_owner == p_other.m_owner && m_container == p_other.m_container; - } - - bool empty() const { return m_container->empty(); } - size_t size() const { return m_container->size(); } - GameObjectPtr front() const { return m_container->front(); } - GameObjectPtr back() const { return m_container->back(); } - - iterator begin() const { return {m_owner, m_container, 0}; } - iterator end() const { return {m_owner, m_container, (m_owner) ? m_container->size() : 0}; } - iterator cbegin() const { return {m_owner, m_container, 0}; } - iterator cend() const { return {m_owner, m_container, (m_owner) ? m_container->size() : 0}; } -}; - // // Forward declarations of classes defined elsewhere. // @@ -491,17 +415,23 @@ class GameNodeRep : public std::enable_shared_from_this { /// @brief A range class for iterating over a node's (action, child) pairs. class Actions { private: - GameNode m_owner{nullptr}; + const GameNodeRep *m_owner{nullptr}; public: class iterator; - Actions(const GameNode p_owner); + Actions(const GameNodeRep *p_owner); iterator begin() const; iterator end() const; }; + GameNodeRep(GameRep *e, GameNodeRep *p); + ~GameNodeRep(); + + bool IsValid() const { return m_valid; } + void Invalidate() { m_valid = false; } + /// @brief Returns a collection for iterating over this node's (action, child) pairs. Actions GetActions() const; @@ -601,9 +531,9 @@ class GameNodeRep::Actions::iterator { GameNode GetOwner() const; }; -inline GameNodeRep::Actions::Actions(GameNode p_owner) : m_owner(p_owner) {} +inline GameNodeRep::Actions::Actions(const GameNodeRep *p_owner) : m_owner(p_owner) {} -inline GameNodeRep::Actions GameNodeRep::GetActions() const { return Actions(this); } +inline GameNodeRep::Actions GameNodeRep::GetActions() const { return {Actions(this)}; } inline GameNodeRep::Actions::iterator GameNodeRep::Actions::begin() const { diff --git a/src/games/gameobject.h b/src/games/gameobject.h index 871a67f91..fb3bccd6e 100644 --- a/src/games/gameobject.h +++ b/src/games/gameobject.h @@ -142,7 +142,7 @@ template class ElementCollection { } value_type operator*() const { return m_container->at(m_index); } - inline P& GetOwner() const { return m_owner; } + inline const P &GetOwner() const { return m_owner; } }; ElementCollection() = default; diff --git a/src/games/gametree.cc b/src/games/gametree.cc index e68817d69..03feb56e7 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -899,7 +899,7 @@ void GameTreeRep::BuildInfosetParents() prior_actions[GamePlayer(m_chance)].emplace(nullptr); position.emplace(m_root->GetActions().begin()); - prior_actions[m_root->m_infoset->m_player].emplace(nullptr); + prior_actions[m_root->m_infoset->m_player->shared_from_this()].emplace(nullptr); if (m_root->m_infoset) { m_infosetParents[m_root->m_infoset].insert(nullptr); } @@ -914,15 +914,15 @@ void GameTreeRep::BuildInfosetParents() node = current_it.GetOwner(); if (current_it == node->GetActions().end()) { - prior_actions.at(node->m_infoset->m_player).pop(); + prior_actions.at(node->m_infoset->m_player->shared_from_this()).pop(); position.pop(); - path_choices.erase(node->m_infoset); + path_choices.erase(node->m_infoset->shared_from_this()); continue; } else { std::tie(action, child) = *current_it; ++current_it; - path_choices[node->m_infoset] = action; + path_choices[node->m_infoset->shared_from_this()] = action; } } else { @@ -931,15 +931,15 @@ void GameTreeRep::BuildInfosetParents() child = node->GetChild(action); } - prior_actions.at(node->m_infoset->m_player).top() = action; + prior_actions.at(node->m_infoset->m_player->shared_from_this()).top() = action; if (!child->IsTerminal()) { - auto child_player = child->m_infoset->m_player; + auto child_player = child->m_infoset->m_player->shared_from_this(); auto prior_action = prior_actions.at(child_player).top(); - m_infosetParents[child->m_infoset].insert(prior_action); + m_infosetParents[child->m_infoset].insert(prior_action ? prior_action.get() : nullptr); - if (path_choices.find(child->m_infoset) != path_choices.end()) { - const GameAction replay_action = path_choices.at(child->m_infoset); + if (path_choices.find(child->m_infoset->shared_from_this()) != path_choices.end()) { + const GameAction replay_action = path_choices.at(child->m_infoset->shared_from_this()); position.emplace(AbsentMindedEdge{replay_action, child}); } else {