diff --git a/src/games/game.h b/src/games/game.h index 5081ac48b..95c6066d7 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -412,12 +412,29 @@ class GameNodeRep : public std::enable_shared_from_this { public: using Children = ElementCollection; + /// @brief A range class for iterating over a node's (action, child) pairs. + class Actions { + private: + const GameNodeRep *m_owner{nullptr}; + + public: + class iterator; + + 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; + Game GetGame() const; const std::string &GetLabel() const { return m_label; } @@ -451,6 +468,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(const GameNodeRep *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 +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 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..fb3bccd6e 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 const 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..03feb56e7 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -23,6 +23,9 @@ #include #include #include +#include +#include +#include #include "gambit.h" #include "gametree.h" @@ -747,54 +750,18 @@ 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 (i == j && precedes) { - s1 = iset1; - s2 = iset2; - return false; - } + if (m_infosetParents.empty() && !m_root->IsTerminal()) { + const_cast(this)->BuildInfosetParents(); + } - if (n > iset1->m_members.size() && precedes) { - s1 = iset1; - s2 = iset2; - return false; - } - } - } - } + if (GetRoot()->IsTerminal()) { + return true; } - return true; + return std::all_of(m_infosetParents.cbegin(), m_infosetParents.cend(), + [](const auto &pair) { return pair.second.size() <= 1; }); } //------------------------------------------------------------------------ @@ -871,6 +838,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 +879,77 @@ std::vector GameTreeRep::BuildConsistentPlaysRecursiveImpl(GameNo return consistent_plays; } +void GameTreeRep::BuildInfosetParents() +{ + if (m_root->IsTerminal()) { + m_infosetParents[m_root->m_infoset].insert(nullptr); + return; + } + + 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[GamePlayer(m_chance)].emplace(nullptr); + + position.emplace(m_root->GetActions().begin()); + 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); + } + + while (!position.empty()) { + 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->shared_from_this()).pop(); + position.pop(); + path_choices.erase(node->m_infoset->shared_from_this()); + continue; + } + else { + std::tie(action, child) = *current_it; + ++current_it; + path_choices[node->m_infoset->shared_from_this()] = action; + } + } + else { + std::tie(action, node) = std::get(current_edge); + position.pop(); + child = node->GetChild(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->shared_from_this(); + auto prior_action = prior_actions.at(child_player).top(); + 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()) { + const GameAction replay_action = path_choices.at(child->m_infoset->shared_from_this()); + position.emplace(AbsentMindedEdge{replay_action, child}); + } + else { + position.emplace(child->GetActions().begin()); + } + prior_actions.at(child_player).emplace(nullptr); + } + } +} + //------------------------------------------------------------------------ // 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..80c7d90e8 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -49,14 +49,56 @@ 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_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 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), -def test_game_is_not_perfect_recall(): - game = games.read_from_file("not_perfect_recall.efg") - assert not game.is_perfect_recall + # 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): + """ + Verify the IsPerfectRecall implementation against a suite of games + with and without the perfect recall, from both files and generation. + """ + 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 def test_getting_payoff_by_label_string(): @@ -95,7 +137,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/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-driver-two-players.efg b/tests/test_games/noPR-AM-driver-two-players.efg new file mode 100644 index 000000000..816359cb6 --- /dev/null +++ b/tests/test_games/noPR-AM-driver-two-players.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-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-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-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 } 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 }