From 1a56f16195f88f1768e34ace14c5faff1bcb436e Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 2 Apr 2026 08:10:01 +0100 Subject: [PATCH 01/15] Track absent-minded reentry nodes in game tree via m_absentMindedReentries and IsAbsentMindedReentry --- src/games/game.h | 8 ++++++++ src/games/gametree.cc | 19 +++++++++++++++++++ src/games/gametree.h | 3 +++ 3 files changed, 30 insertions(+) diff --git a/src/games/game.h b/src/games/game.h index 5fa619e4a..bdffc2daf 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -970,6 +970,14 @@ class GameRep : public std::enable_shared_from_this { } return false; } + /// Returns whether the path from the root to p_node passes through its infoset more than once + virtual bool IsAbsentMindedReentry(const GameNode &p_node) const + { + if (p_node->GetGame().get() != this) { + throw MismatchException(); + } + return false; + } /// Returns a list of all subgame roots in the game virtual std::vector GetSubgames() const { throw UndefinedException(); } /// Returns the smallest subgame containing the information set diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 149968d3e..e3ade6545 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -891,6 +891,23 @@ GameSubgame GameTreeRep::GetMinimalSubgame(const GameInfoset &p_infoset) const return it->second; } +bool GameTreeRep::IsAbsentMindedReentry(const GameNode &p_node) const +{ + if (p_node->GetGame().get() != this) { + throw MismatchException(); + } + + if (!m_unreachableNodes && !m_root->IsTerminal()) { + BuildUnreachableNodes(); + } + + auto it = m_absentMindedReentries.find(p_node->m_infoset); + if (it == m_absentMindedReentries.end()) { + return false; + } + return contains(it->second, p_node.get()); +} + //------------------------------------------------------------------------ // GameTreeRep: Managing the representation //------------------------------------------------------------------------ @@ -957,6 +974,7 @@ void GameTreeRep::ClearComputedValues() const const_cast(this)->m_unreachableNodes = nullptr; m_absentMindedInfosets.clear(); m_subgameData.Invalidate(); + m_absentMindedReentries.clear(); m_computedValues = false; } @@ -1132,6 +1150,7 @@ void GameTreeRep::BuildUnreachableNodes() const // 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); + m_absentMindedReentries[child->m_infoset].insert(child.get()); 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 c94404f89..63801ff42 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -48,6 +48,7 @@ class GameTreeRep final : public GameExplicitRep { mutable std::shared_ptr m_ownPriorActionInfo; mutable std::unique_ptr> m_unreachableNodes; mutable std::set m_absentMindedInfosets; + mutable std::map> m_absentMindedReentries; // The subgames of the game, held in two synchronized forms: // m_subgamePostorder for iteration (children before parents), // m_subgameByRoot for O(1) lookup by root node and ownership of the GameSubgameRep objects. @@ -119,8 +120,10 @@ class GameTreeRep final : public GameExplicitRep { /// 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; + bool IsAbsentMindedReentry(const GameNode &p_node) const override; std::vector GetSubgames() const override; GameSubgame GetMinimalSubgame(const GameInfoset &) const override; + /// Returns whether the path from the root to p_node passes through its infoset more than once //@} /// @name Players From 29b84cad260758d2e496842897a65cadab309839 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 2 Apr 2026 08:10:59 +0100 Subject: [PATCH 02/15] Fix infoset realisation probability for the absent-minded case using cached m_infosetProbs in ComputeRealizationProbs --- src/games/behavmixed.cc | 14 ++++++++++++-- src/games/behavmixed.h | 2 ++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/games/behavmixed.cc b/src/games/behavmixed.cc index 0887d1988..f10ddcb45 100644 --- a/src/games/behavmixed.cc +++ b/src/games/behavmixed.cc @@ -284,8 +284,7 @@ template T MixedBehaviorProfile::GetInfosetProb(const GameInfoset & { CheckVersion(); EnsureRealizations(); - return sum_function(p_infoset->GetMembers(), - [&](const auto &node) -> T { return m_cache.m_realizProbs[node]; }); + return m_cache.m_infosetProbs[p_infoset]; } template @@ -475,6 +474,7 @@ T MixedBehaviorProfile::DiffNodeValue(const GameNode &p_node, const GamePlaye template void MixedBehaviorProfile::ComputeRealizationProbs() const { m_cache.m_realizProbs.clear(); + m_cache.m_infosetProbs.clear(); const auto &game = m_support.GetGame(); m_cache.m_realizProbs[game->GetRoot()] = static_cast(1); @@ -484,6 +484,16 @@ template void MixedBehaviorProfile::ComputeRealizationProbs() const m_cache.m_realizProbs[child] = incomingProb * GetActionProb(action); } } + + for (const auto &infoset : game->GetInfosets()) { + m_cache.m_infosetProbs[infoset] = + sum_function(infoset->GetMembers(), [&](const auto &node) -> T { + if (game->IsAbsentMindedReentry(node)) { + return static_cast(0); + } + return m_cache.m_realizProbs[node]; + }); + } } template void MixedBehaviorProfile::ComputeBeliefs() const diff --git a/src/games/behavmixed.h b/src/games/behavmixed.h index d535560c2..15392606d 100644 --- a/src/games/behavmixed.h +++ b/src/games/behavmixed.h @@ -46,6 +46,7 @@ template class MixedBehaviorProfile { Level m_level{Level::None}; std::map m_realizProbs, m_beliefs; + std::map m_infosetProbs; std::map> m_nodeValues; std::map m_infosetValues; std::map m_actionValues; @@ -60,6 +61,7 @@ template class MixedBehaviorProfile { { m_level = Level::None; m_realizProbs.clear(); + m_infosetProbs.clear(); m_beliefs.clear(); m_nodeValues.clear(); m_infosetValues.clear(); From e999d2d3fccfd439e4f18877d171b2a8663b9918 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 2 Apr 2026 08:11:21 +0100 Subject: [PATCH 03/15] Add tests for absent-minded infoset probability --- tests/test_behav.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_behav.py b/tests/test_behav.py index 54dcd1c3a..0516e9411 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -761,6 +761,37 @@ def test_infoset_prob_by_label_reference( assert profile.infoset_prob(label) == (gbt.Rational(prob) if rational_flag else prob) +@pytest.mark.parametrize( + "game,player_idx,infoset_idx,prob,rational_flag", + [ + # P1 infoset 1 is absent-minded (root + one reentry) + (games.read_from_file("noPR-AM-driver-one-player.efg"), 0, 0, 1.0, False), + (games.read_from_file("noPR-AM-driver-one-player.efg"), 0, 1, 0.5, False), + (games.read_from_file("noPR-AM-driver-one-player.efg"), 0, 2, 0.125, False), + (games.read_from_file("noPR-AM-driver-one-player.efg"), 0, 0, "1", True), + (games.read_from_file("noPR-AM-driver-one-player.efg"), 0, 1, "1/2", True), + (games.read_from_file("noPR-AM-driver-one-player.efg"), 0, 2, "1/8", True), + # P1 infoset 1 has 3 members (root + both children are reentries) + (games.read_from_file("noPR-action-AM.efg"), 0, 0, 1.0, False), + (games.read_from_file("noPR-action-AM.efg"), 1, 0, 0.25, False), + (games.read_from_file("noPR-action-AM.efg"), 1, 1, 0.25, False), + (games.read_from_file("noPR-action-AM.efg"), 1, 2, 0.25, False), + (games.read_from_file("noPR-action-AM.efg"), 1, 3, 0.25, False), + (games.read_from_file("noPR-action-AM.efg"), 0, 0, "1", True), + (games.read_from_file("noPR-action-AM.efg"), 1, 0, "1/4", True), + (games.read_from_file("noPR-action-AM.efg"), 1, 1, "1/4", True), + (games.read_from_file("noPR-action-AM.efg"), 1, 2, "1/4", True), + (games.read_from_file("noPR-action-AM.efg"), 1, 3, "1/4", True), + ], +) +def test_absent_minded_infoset_prob( + game: gbt.Game, player_idx: int, infoset_idx: int, prob: str | float, rational_flag: bool +): + profile = game.mixed_behavior_profile(rational=rational_flag) + ip = profile.infoset_prob(game.players[player_idx].infosets[infoset_idx]) + assert ip == (gbt.Rational(prob) if rational_flag else prob) + + @pytest.mark.parametrize( "game,player_idx,infoset_idx,payoff,rational_flag", [ From 695e08ee6e2b470aa40c4b5a4310be31e6b8d674 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 2 Apr 2026 10:02:17 +0100 Subject: [PATCH 04/15] =?UTF-8?q?Check=C2=A0IsAbsentMindedReentry=20for=20?= =?UTF-8?q?nodes=20only=20if=20the=20node=20is=20a=20member=20of=20an=20ab?= =?UTF-8?q?sent-minded=20information=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/games/behavmixed.cc | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/games/behavmixed.cc b/src/games/behavmixed.cc index f10ddcb45..e817aa568 100644 --- a/src/games/behavmixed.cc +++ b/src/games/behavmixed.cc @@ -486,13 +486,18 @@ template void MixedBehaviorProfile::ComputeRealizationProbs() const } for (const auto &infoset : game->GetInfosets()) { - m_cache.m_infosetProbs[infoset] = - sum_function(infoset->GetMembers(), [&](const auto &node) -> T { - if (game->IsAbsentMindedReentry(node)) { - return static_cast(0); - } - return m_cache.m_realizProbs[node]; - }); + if (game->IsAbsentMinded(infoset)) { + m_cache.m_infosetProbs[infoset] = + sum_function(infoset->GetMembers(), [&](const auto &node) -> T { + return game->IsAbsentMindedReentry(node) ? static_cast(0) + : m_cache.m_realizProbs[node]; + }); + } + else { + m_cache.m_infosetProbs[infoset] = + sum_function(infoset->GetMembers(), + [&](const auto &node) -> T { return m_cache.m_realizProbs[node]; }); + } } } From 89a4dd91c6e62c1dce4c2bef3f0d7f92e538d417 Mon Sep 17 00:00:00 2001 From: drdkad Date: Sat, 11 Apr 2026 13:11:51 +0100 Subject: [PATCH 05/15] Expose absent-minded reentries as (infoset, node) pairs via GetAbsentMindedReentries --- src/games/game.h | 5 +++++ src/games/gametree.cc | 27 ++++++++++++++++++++++----- src/games/gametree.h | 5 +++-- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index bdffc2daf..fc321f741 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -978,6 +978,11 @@ class GameRep : public std::enable_shared_from_this { } return false; } + /// Returns (infoset, node) pairs where the node is a reentry of an absent-minded infoset + virtual std::vector> GetAbsentMindedReentries() const + { + return {}; + } /// Returns a list of all subgame roots in the game virtual std::vector GetSubgames() const { throw UndefinedException(); } /// Returns the smallest subgame containing the information set diff --git a/src/games/gametree.cc b/src/games/gametree.cc index e3ade6545..5d54647d3 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -848,6 +848,7 @@ Rational GameTreeRep::GetPlayerMaxPayoff(const GamePlayer &p_player) const return maximize_function(range, value_fn); }); } + bool GameTreeRep::IsPerfectRecall() const { if (!m_ownPriorActionInfo && !m_root->IsTerminal()) { @@ -901,11 +902,27 @@ bool GameTreeRep::IsAbsentMindedReentry(const GameNode &p_node) const BuildUnreachableNodes(); } - auto it = m_absentMindedReentries.find(p_node->m_infoset); - if (it == m_absentMindedReentries.end()) { - return false; + return std::any_of(m_absentMindedReentries.begin(), m_absentMindedReentries.end(), + [&](const auto &entry) { + return entry.first == p_node->m_infoset && entry.second == p_node.get(); + }); +} + +std::vector> GameTreeRep::GetAbsentMindedReentries() const +{ + if (!m_unreachableNodes && !m_root->IsTerminal()) { + BuildUnreachableNodes(); + } + if (m_absentMindedReentries.empty()) { + return {}; } - return contains(it->second, p_node.get()); + + std::vector> result; + result.reserve(m_absentMindedReentries.size()); + for (const auto &[infoset, node] : m_absentMindedReentries) { + result.emplace_back(infoset->shared_from_this(), node->shared_from_this()); + } + return result; } //------------------------------------------------------------------------ @@ -1150,7 +1167,7 @@ void GameTreeRep::BuildUnreachableNodes() const // 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); - m_absentMindedReentries[child->m_infoset].insert(child.get()); + m_absentMindedReentries.emplace_back(child->m_infoset, child.get()); 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 63801ff42..ccb6eff08 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -48,7 +48,7 @@ class GameTreeRep final : public GameExplicitRep { mutable std::shared_ptr m_ownPriorActionInfo; mutable std::unique_ptr> m_unreachableNodes; mutable std::set m_absentMindedInfosets; - mutable std::map> m_absentMindedReentries; + mutable std::vector> m_absentMindedReentries; // The subgames of the game, held in two synchronized forms: // m_subgamePostorder for iteration (children before parents), // m_subgameByRoot for O(1) lookup by root node and ownership of the GameSubgameRep objects. @@ -120,10 +120,11 @@ class GameTreeRep final : public GameExplicitRep { /// 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; + /// Returns whether the path from the root to p_node passes through its infoset more than once bool IsAbsentMindedReentry(const GameNode &p_node) const override; + std::vector> GetAbsentMindedReentries() const override; std::vector GetSubgames() const override; GameSubgame GetMinimalSubgame(const GameInfoset &) const override; - /// Returns whether the path from the root to p_node passes through its infoset more than once //@} /// @name Players From 8b4ae9fec0a21e5f732b99c06ee7aa4c89b8de31 Mon Sep 17 00:00:00 2001 From: drdkad Date: Sat, 11 Apr 2026 13:14:08 +0100 Subject: [PATCH 06/15] Implement sum-then-subtract realisation probability for absent-minded infosets --- src/games/behavmixed.cc | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/games/behavmixed.cc b/src/games/behavmixed.cc index e817aa568..a63b3f6d9 100644 --- a/src/games/behavmixed.cc +++ b/src/games/behavmixed.cc @@ -486,18 +486,11 @@ template void MixedBehaviorProfile::ComputeRealizationProbs() const } for (const auto &infoset : game->GetInfosets()) { - if (game->IsAbsentMinded(infoset)) { - m_cache.m_infosetProbs[infoset] = - sum_function(infoset->GetMembers(), [&](const auto &node) -> T { - return game->IsAbsentMindedReentry(node) ? static_cast(0) - : m_cache.m_realizProbs[node]; - }); - } - else { - m_cache.m_infosetProbs[infoset] = - sum_function(infoset->GetMembers(), - [&](const auto &node) -> T { return m_cache.m_realizProbs[node]; }); - } + m_cache.m_infosetProbs[infoset] = sum_function( + infoset->GetMembers(), [&](const auto &node) -> T { return m_cache.m_realizProbs[node]; }); + } + for (const auto &[infoset, node] : game->GetAbsentMindedReentries()) { + m_cache.m_infosetProbs[infoset] -= m_cache.m_realizProbs[node]; } } From 90a2026d7ff02266264a78bab3f894ad7081eadb Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 5 Jun 2026 09:55:38 +0100 Subject: [PATCH 07/15] Detect absent-minded reentries in BuildOwnPriorActions, not BuildUnreachableNodes Infoset realization probability subtracts reentries from the member sum. That set was collected in BuildUnreachableNodes, which prunes non-reached subtrees for pure-strategy reachability. For the added test, the old approach yields 9/8 instead of 1 for Player 1's absent-minded infoset. m_unreachableNodes and ComputeRealizationProbs are unchanged. --- src/games/gametree.cc | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 5d54647d3..e3b9be26f 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -869,11 +869,9 @@ bool GameTreeRep::IsAbsentMinded(const GameInfoset &p_infoset) const if (p_infoset->GetGame().get() != this) { throw MismatchException(); } - - if (!m_unreachableNodes && !m_root->IsTerminal()) { - BuildUnreachableNodes(); + if (!m_ownPriorActionInfo) { + BuildOwnPriorActions(); } - return contains(m_absentMindedInfosets, p_infoset.get()); } @@ -910,8 +908,8 @@ bool GameTreeRep::IsAbsentMindedReentry(const GameNode &p_node) const std::vector> GameTreeRep::GetAbsentMindedReentries() const { - if (!m_unreachableNodes && !m_root->IsTerminal()) { - BuildUnreachableNodes(); + if (!m_ownPriorActionInfo) { + BuildOwnPriorActions(); } if (m_absentMindedReentries.empty()) { return {}; @@ -1037,6 +1035,8 @@ void GameTreeRep::BuildOwnPriorActions() const { if (m_root->IsTerminal()) { m_ownPriorActionInfo = std::make_shared(); + m_absentMindedInfosets.clear(); + m_absentMindedReentries.clear(); return; } @@ -1044,6 +1044,13 @@ void GameTreeRep::BuildOwnPriorActions() const std::shared_ptr m_info; std::map> m_priorActions; + // A node is a re-entry of its information set iff an ancestor on the current + // root-to-node path shares that information set. m_pathMemberCount counts, per information + // set, how many nodes on the current path belong to it. + std::map m_pathMemberCount; + std::set m_absentMindedInfosets; + std::vector> m_absentMindedReentries; + explicit OwnPriorActionsVisitor(const GameTreeRep *p_game) : m_info(std::make_shared()) { @@ -1062,6 +1069,11 @@ void GameTreeRep::BuildOwnPriorActions() const m_info->infoset_map[infoset].insert(raw_prior); stack.emplace(nullptr); + + if (m_pathMemberCount[infoset]++ > 0) { + m_absentMindedInfosets.insert(infoset); + m_absentMindedReentries.emplace_back(infoset, p_node.get()); + } } return DFSCallbackResult::Continue; } @@ -1077,6 +1089,7 @@ void GameTreeRep::BuildOwnPriorActions() const { if (auto *infoset = p_node->m_infoset) { m_priorActions.at(infoset->m_player->shared_from_this()).pop(); + m_pathMemberCount[infoset]--; } return DFSCallbackResult::Continue; } @@ -1090,6 +1103,8 @@ void GameTreeRep::BuildOwnPriorActions() const visitor); m_ownPriorActionInfo = visitor.m_info; + m_absentMindedInfosets = std::move(visitor.m_absentMindedInfosets); + m_absentMindedReentries = std::move(visitor.m_absentMindedReentries); } GameAction GameTreeRep::GetOwnPriorAction(const GameNode &p_node) const @@ -1164,10 +1179,9 @@ void GameTreeRep::BuildUnreachableNodes() const } if (!child->IsTerminal()) { - // Check for Absent-Minded Re-entry of the infoset + // On a re-entry, a pure strategy replays the action chosen at the earlier visit, + // so only that branch is reachable; prune the rest. if (path_choices.find(child->m_infoset->shared_from_this()) != path_choices.end()) { - m_absentMindedInfosets.insert(child->m_infoset); - m_absentMindedReentries.emplace_back(child->m_infoset, child.get()); const GameAction replay_action = path_choices.at(child->m_infoset->shared_from_this()); position.emplace(AbsentMindedEdge{replay_action, child}); From 83fe575659fb4c0d60d2c166bae784a3b7fbc1b4 Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 5 Jun 2026 10:09:07 +0100 Subject: [PATCH 08/15] Add absent-minded reach-probability fixture that would fail the previous pure-strategy reentry detection --- tests/test_behav.py | 8 ++++++++ .../test_games/noPR-action-AM-three-chain.efg | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 tests/test_games/noPR-action-AM-three-chain.efg diff --git a/tests/test_behav.py b/tests/test_behav.py index 0516e9411..e46afc2fe 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -782,6 +782,14 @@ def test_infoset_prob_by_label_reference( (games.read_from_file("noPR-action-AM.efg"), 1, 1, "1/4", True), (games.read_from_file("noPR-action-AM.efg"), 1, 2, "1/4", True), (games.read_from_file("noPR-action-AM.efg"), 1, 3, "1/4", True), + # P1 infoset 1 has 3 members (3-node chain with the last member being + # behavioral-strategy-reachable, but not pure-strategy-reachable) + (games.read_from_file("noPR-action-AM-three-chain.efg"), 0, 0, 1.0, False), + (games.read_from_file("noPR-action-AM-three-chain.efg"), 0, 1, 0.5, False), + (games.read_from_file("noPR-action-AM-three-chain.efg"), 1, 0, 0.0625, False), + (games.read_from_file("noPR-action-AM-three-chain.efg"), 0, 0, "1", True), + (games.read_from_file("noPR-action-AM-three-chain.efg"), 0, 1, "1/2", True), + (games.read_from_file("noPR-action-AM-three-chain.efg"), 1, 0, "1/16", True), ], ) def test_absent_minded_infoset_prob( diff --git a/tests/test_games/noPR-action-AM-three-chain.efg b/tests/test_games/noPR-action-AM-three-chain.efg new file mode 100644 index 000000000..eda2a02f4 --- /dev/null +++ b/tests/test_games/noPR-action-AM-three-chain.efg @@ -0,0 +1,20 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"Test fixture for information-set reach probability under absent-mindedness. +Player 1's first information set has a member that is behavioural-strategy-reachable, +but not pure-strategy-reachable; likewise, both members of Player 2's infoset are +behavioural-strategy-reachable but not pure-strategy-reachable. +Both are missed by re-entry detection that rides on pure-strategy reachability." + +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 1 "" { "1" "2" } 0 +t "" 2 "Outcome 2" { 2, -2 } +p "" 2 1 "" { "1" "1" } 0 +p "" 2 1 "" { "1" "1" } 0 +t "" 3 "Outcome 3" { 3, -3 } +t "" 4 "Outcome 4" { 4, -4 } +t "" 5 "Outcome 5" { 5, -5 } +t "" 6 "Outcome 6" { 6, -6 } +t "" 7 "Outcome 7" { 7, -7 } From 79778f26846cd5497392e2cbbf2be0d262282299 Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 5 Jun 2026 11:31:03 +0100 Subject: [PATCH 09/15] fix typo in labels --- tests/test_games/noPR-action-AM-three-chain.efg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_games/noPR-action-AM-three-chain.efg b/tests/test_games/noPR-action-AM-three-chain.efg index eda2a02f4..1e8e43b4e 100644 --- a/tests/test_games/noPR-action-AM-three-chain.efg +++ b/tests/test_games/noPR-action-AM-three-chain.efg @@ -11,8 +11,8 @@ p "" 1 1 "" { "1" "2" } 0 t "" 1 "Outcome 1" { 1, -1 } p "" 1 1 "" { "1" "2" } 0 t "" 2 "Outcome 2" { 2, -2 } -p "" 2 1 "" { "1" "1" } 0 -p "" 2 1 "" { "1" "1" } 0 +p "" 2 1 "" { "1" "2" } 0 +p "" 2 1 "" { "1" "2" } 0 t "" 3 "Outcome 3" { 3, -3 } t "" 4 "Outcome 4" { 4, -4 } t "" 5 "Outcome 5" { 5, -5 } From 12287415200755d4545e3affdf347db41f6cbfac Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 5 Jun 2026 11:57:33 +0100 Subject: [PATCH 10/15] Deprecate IsAbsentMindedReentry --- src/games/game.h | 8 -------- src/games/gametree.cc | 16 ---------------- src/games/gametree.h | 2 -- 3 files changed, 26 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index fc321f741..7f9d5bedb 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -970,14 +970,6 @@ class GameRep : public std::enable_shared_from_this { } return false; } - /// Returns whether the path from the root to p_node passes through its infoset more than once - virtual bool IsAbsentMindedReentry(const GameNode &p_node) const - { - if (p_node->GetGame().get() != this) { - throw MismatchException(); - } - return false; - } /// Returns (infoset, node) pairs where the node is a reentry of an absent-minded infoset virtual std::vector> GetAbsentMindedReentries() const { diff --git a/src/games/gametree.cc b/src/games/gametree.cc index e3b9be26f..94fad8ebb 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -890,22 +890,6 @@ GameSubgame GameTreeRep::GetMinimalSubgame(const GameInfoset &p_infoset) const return it->second; } -bool GameTreeRep::IsAbsentMindedReentry(const GameNode &p_node) const -{ - if (p_node->GetGame().get() != this) { - throw MismatchException(); - } - - if (!m_unreachableNodes && !m_root->IsTerminal()) { - BuildUnreachableNodes(); - } - - return std::any_of(m_absentMindedReentries.begin(), m_absentMindedReentries.end(), - [&](const auto &entry) { - return entry.first == p_node->m_infoset && entry.second == p_node.get(); - }); -} - std::vector> GameTreeRep::GetAbsentMindedReentries() const { if (!m_ownPriorActionInfo) { diff --git a/src/games/gametree.h b/src/games/gametree.h index ccb6eff08..8b7279313 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -120,8 +120,6 @@ class GameTreeRep final : public GameExplicitRep { /// 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; - /// Returns whether the path from the root to p_node passes through its infoset more than once - bool IsAbsentMindedReentry(const GameNode &p_node) const override; std::vector> GetAbsentMindedReentries() const override; std::vector GetSubgames() const override; GameSubgame GetMinimalSubgame(const GameInfoset &) const override; From 552e21e00bc98440eb2cb111156638581a5aa45a Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 12 Jun 2026 15:30:45 +0100 Subject: [PATCH 11/15] Test chance infoset realisation probability in a nature-rooted game --- tests/test_behav.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_behav.py b/tests/test_behav.py index e46afc2fe..218efe065 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -800,6 +800,16 @@ def test_absent_minded_infoset_prob( assert ip == (gbt.Rational(prob) if rational_flag else prob) +@pytest.mark.parametrize("rational_flag", [False, True]) +def test_nature_rooted_game_root_reached_with_certainty(rational_flag: bool): + """The chance root infoset is reached with probability one.""" + game = gbt.catalog.load("journals/geb/gilboa1997/fig2") + profile = game.mixed_behavior_profile(rational=rational_flag) + one = gbt.Rational(1) if rational_flag else 1.0 + assert profile.realiz_prob(game.root) == one + assert profile.infoset_prob(game.players.chance.infosets[0]) == one + + @pytest.mark.parametrize( "game,player_idx,infoset_idx,payoff,rational_flag", [ From ad46f0297bdc6eac29a4799625fca7e5d5340ace Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 12 Jun 2026 15:31:24 +0100 Subject: [PATCH 12/15] Populate realisation probabilities for chance information sets Previously only personal players' infosets were cached -- querying a chance infoset returned a default-inserted zero --- src/games/behavmixed.cc | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/games/behavmixed.cc b/src/games/behavmixed.cc index a63b3f6d9..08bc48d2e 100644 --- a/src/games/behavmixed.cc +++ b/src/games/behavmixed.cc @@ -485,9 +485,12 @@ template void MixedBehaviorProfile::ComputeRealizationProbs() const } } - for (const auto &infoset : game->GetInfosets()) { - m_cache.m_infosetProbs[infoset] = sum_function( - infoset->GetMembers(), [&](const auto &node) -> T { return m_cache.m_realizProbs[node]; }); + for (const auto &player : game->GetPlayersWithChance()) { + for (const auto &infoset : player->GetInfosets()) { + m_cache.m_infosetProbs[infoset] = + sum_function(infoset->GetMembers(), + [&](const auto &node) -> T { return m_cache.m_realizProbs[node]; }); + } } for (const auto &[infoset, node] : game->GetAbsentMindedReentries()) { m_cache.m_infosetProbs[infoset] -= m_cache.m_realizProbs[node]; From 542e9961b99cd99f2304c5be3a9c6c67650d5270 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 17 Jun 2026 13:59:43 +0100 Subject: [PATCH 13/15] Correct beliefs for members of absent-minded infosets; add documentation docstrings --- src/games/behavmixed.cc | 9 ++++++--- src/games/behavmixed.h | 11 ++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/games/behavmixed.cc b/src/games/behavmixed.cc index 08bc48d2e..8ab01f2f5 100644 --- a/src/games/behavmixed.cc +++ b/src/games/behavmixed.cc @@ -500,10 +500,13 @@ template void MixedBehaviorProfile::ComputeRealizationProbs() const template void MixedBehaviorProfile::ComputeBeliefs() const { m_cache.m_beliefs.clear(); - + // Normalise each member's realization probability by the infoset's upper-frontier probability + // (m_infosetProbs, computed in ComputeRealizationProbs), following Halpern and Pass (2021). + // For an absent-minded infoset the frontier excludes the reentry members, so the member beliefs + // may sum to above 1; for a non-absent-minded infoset the frontier is all members and this is + // the standard Selten (1975) normalization. for (const auto &infoset : m_support.GetGame()->GetInfosets()) { - const T infosetProb = sum_function( - infoset->GetMembers(), [&](const auto &node) -> T { return m_cache.m_realizProbs[node]; }); + const T infosetProb = m_cache.m_infosetProbs[infoset]; if (infosetProb == static_cast(0)) { continue; } diff --git a/src/games/behavmixed.h b/src/games/behavmixed.h index 15392606d..6827713ce 100644 --- a/src/games/behavmixed.h +++ b/src/games/behavmixed.h @@ -74,10 +74,11 @@ template class MixedBehaviorProfile { /// @name Auxiliary functions for cached computation of interesting values //@{ - /// Compute the realisation probabilities of all nodes + /// Compute the realization probabilities of all nodes, and of information sets + /// (the probability a given information set is reached at least once) void ComputeRealizationProbs() const; - /// Compute the realisation probabilities of information sets, and beliefs at - /// information sets reached with positive probability + /// Compute beliefs (conditional reach probabilities) at information sets reached + /// with positive probability, normalized over each set's upper frontier void ComputeBeliefs() const; /// Compute the expected payoffs conditional on reaching each node void ComputeNodeValues() const; @@ -243,6 +244,10 @@ template class MixedBehaviorProfile { const T &GetRealizProb(const GameNode &node) const; T GetInfosetProb(const GameInfoset &p_infoset) const; + /// Returns the belief at a given non-terminal node: + /// the probability of reaching it conditional on reaching its infoset, + /// normalised over the infoset's upper frontier (Halpern and Pass, 2021). + /// Returns std::nullopt for a terminal node, or if the infoset is not reached. std::optional GetBeliefProb(const GameNode &node) const; Vector GetPayoff(const GameNode &node) const; const T &GetPayoff(const GamePlayer &p_player, const GameNode &p_node) const; From 23d43c0e20ee76a4b5a0ddb3639e7551e6d958c1 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 17 Jun 2026 14:00:24 +0100 Subject: [PATCH 14/15] Add pygambit tests covering node beliefs for absent-minded infosets --- tests/test_behav.py | 69 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/test_behav.py b/tests/test_behav.py index 218efe065..a68f87394 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -1524,6 +1524,75 @@ def test_agent_max_regret_versus_non_agent( "2/7", True, ), + # Information set I1 = {root, reentry_node}; where reentry_node is reached by ["1", "1"]. + # The upper frontier of I1 is {root}, so the conditioning event has + # probability realiz(root) = 1 for every profile, and beliefs are normalised by it: + # belief(root) = 1, belief(reentry_node) = realiz(reentry_node). + ( + games.read_from_file("noPR-AM-driver-one-player.efg"), + ZERO, + ["1/2", "1/2", "1/2", "1/2", "1/2", "1/2"], + 0, + 0, + "1", + True, + ), + ( + games.read_from_file("noPR-AM-driver-one-player.efg"), + ZERO, + ["1/2", "1/2", "1/2", "1/2", "1/2", "1/2"], + 0, + 1, + "1/4", + True, + ), + ( + games.read_from_file("noPR-AM-driver-one-player.efg"), + TOL, + [0.5, 0.5, 0.5, 0.5, 0.5, 0.5], + 0, + 0, + 1.0, + False, + ), + ( + games.read_from_file("noPR-AM-driver-one-player.efg"), + TOL, + [0.5, 0.5, 0.5, 0.5, 0.5, 0.5], + 0, + 1, + 0.25, + False, + ), + # asymmetric: p(I1,1)=2/3, p(I2,1)=3/4; realiz(reentry_node)=1/2 + ( + games.read_from_file("noPR-AM-driver-one-player.efg"), + ZERO, + ["2/3", "1/3", "3/4", "1/4", "1/2", "1/2"], + 0, + 0, + "1", + True, + ), + ( + games.read_from_file("noPR-AM-driver-one-player.efg"), + ZERO, + ["2/3", "1/3", "3/4", "1/4", "1/2", "1/2"], + 0, + 1, + "1/2", + True, + ), + # reentry reached with prob 1 + ( + games.read_from_file("noPR-AM-driver-one-player.efg"), + ZERO, + ["1", "0", "1", "0", "1", "0"], + 0, + 1, + "1", + True, + ), ], ) def test_node_belief_reference( From d48604865ea3837cdc02581bfd5bbe49f043387c Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 17 Jun 2026 14:00:40 +0100 Subject: [PATCH 15/15] Add Halpern, Pass (2021) to the bibliography --- doc/references.bib | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/doc/references.bib b/doc/references.bib index 4c17e1cf8..f39c14834 100644 --- a/doc/references.bib +++ b/doc/references.bib @@ -38,6 +38,17 @@ @article{GovWil04 category = {articles_equilibria} } +@article{HalPas21, + author = {Halpern, J. Y. and Pass, R.}, + title = {Sequential equilibrium in games of imperfect recall}, + journal = {ACM Transactions on Economics and Computation}, + volume = {9}, + number = {4}, + pages = {1--26}, + year = {2021}, + category = {articles_general} +} + @article{Jiang11, author = {Jiang, A. X. and Leyton-Brown, K. and Bhat, N.}, title = {Action-graph games},