From 57e1d31aca21ad2f3272bf07c9f207cabe3f0ca2 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 2 Apr 2026 08:10:01 +0100 Subject: [PATCH 1/8] 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 fe6a4d1b8..bd17c2827 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -939,6 +939,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(); } diff --git a/src/games/gametree.cc b/src/games/gametree.cc index fd0ae033d..6be6ede9a 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -859,6 +859,23 @@ bool GameTreeRep::IsAbsentMinded(const GameInfoset &p_infoset) const return contains(m_absentMindedInfosets, p_infoset.get()); } +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 //------------------------------------------------------------------------ @@ -924,6 +941,7 @@ void GameTreeRep::ClearComputedValues() const m_ownPriorActionInfo = nullptr; const_cast(this)->m_unreachableNodes = nullptr; m_absentMindedInfosets.clear(); + m_absentMindedReentries.clear(); m_subgames.clear(); m_computedValues = false; } @@ -1100,6 +1118,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 aaf86ff7f..003f99035 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -47,6 +47,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_subgames; /// @name Private auxiliary functions @@ -99,6 +100,8 @@ 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 GetSubgames() const override; //@} From 7659ad0271cff8b80600f76dd836732f82b4c4b3 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 2 Apr 2026 08:10:59 +0100 Subject: [PATCH 2/8] 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 42498ad87..c5289e3f9 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 @@ -471,6 +470,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); @@ -480,6 +480,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 6e3d63f4a5e9474789bf428a382d8bf99117c575 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 2 Apr 2026 08:11:21 +0100 Subject: [PATCH 3/8] 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 b45c8d10e..fe9db8e2c 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 e126a63866be04a243ea1b5bc10199daef67ca64 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 2 Apr 2026 10:02:17 +0100 Subject: [PATCH 4/8] =?UTF-8?q?Check=C2=A0IsAbsentMindedReentry=20for=20no?= =?UTF-8?q?des=20only=20if=20the=20node=20is=20a=20member=20of=20an=20abse?= =?UTF-8?q?nt-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 c5289e3f9..477a9d061 100644 --- a/src/games/behavmixed.cc +++ b/src/games/behavmixed.cc @@ -482,13 +482,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 f115429e4817cad9216a83d56fb43ff20259d95b Mon Sep 17 00:00:00 2001 From: drdkad Date: Sat, 11 Apr 2026 13:11:51 +0100 Subject: [PATCH 5/8] Expose absent-minded reentries as (infoset, node) pairs via GetAbsentMindedReentries --- src/games/game.h | 9 +++------ src/games/gametree.cc | 21 +++++++++++---------- src/games/gametree.h | 5 ++--- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index bd17c2827..dfb85be23 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -939,13 +939,10 @@ 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 + /// Returns (infoset, node) pairs where the node is a reentry of an absent-minded infoset + virtual std::vector> GetAbsentMindedReentries() const { - if (p_node->GetGame().get() != this) { - throw MismatchException(); - } - return false; + return {}; } /// Returns a list of all subgame roots in the game virtual std::vector GetSubgames() const { throw UndefinedException(); } diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 6be6ede9a..37b568b1b 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -831,6 +831,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()) { @@ -859,21 +860,21 @@ bool GameTreeRep::IsAbsentMinded(const GameInfoset &p_infoset) const return contains(m_absentMindedInfosets, p_infoset.get()); } -bool GameTreeRep::IsAbsentMindedReentry(const GameNode &p_node) const +std::vector> GameTreeRep::GetAbsentMindedReentries() const { - if (p_node->GetGame().get() != this) { - throw MismatchException(); - } - if (!m_unreachableNodes && !m_root->IsTerminal()) { BuildUnreachableNodes(); } + if (m_absentMindedReentries.empty()) { + return {}; + } - auto it = m_absentMindedReentries.find(p_node->m_infoset); - if (it == m_absentMindedReentries.end()) { - return false; + 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 contains(it->second, p_node.get()); + return result; } //------------------------------------------------------------------------ @@ -1118,7 +1119,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 003f99035..395381e93 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -47,7 +47,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; mutable std::vector m_subgames; /// @name Private auxiliary functions @@ -100,8 +100,7 @@ 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; //@} From 8c25404adb614a8d79cd308d78c8a48076a7ab6b Mon Sep 17 00:00:00 2001 From: drdkad Date: Sat, 11 Apr 2026 13:14:08 +0100 Subject: [PATCH 6/8] 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 477a9d061..ee923cf7b 100644 --- a/src/games/behavmixed.cc +++ b/src/games/behavmixed.cc @@ -482,18 +482,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 81cc0b4f94e122557c50e5fffdeb2220f489d9b3 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 14 Apr 2026 09:47:21 +0100 Subject: [PATCH 7/8] Update ChangeLog --- ChangeLog | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ChangeLog b/ChangeLog index 4368c739e..a32cc4a08 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,5 +1,11 @@ # Changelog +## [16.7.0] - unreleased + +### Changed +- Clarified definition of information set realisation probability to avoid double-counting of + multiple realisations due to absent-mindedness. (#826) + ## [16.6.0] - 2026-03-24 ### Changed From adf3817d4dd592b3e33354bdfabd2d4d3223b353 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 14 Apr 2026 09:58:30 +0100 Subject: [PATCH 8/8] Updated documentation --- src/games/behavmixed.h | 9 +++++++++ src/pygambit/behavmixed.pxi | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/games/behavmixed.h b/src/games/behavmixed.h index 15392606d..8973e6d5b 100644 --- a/src/games/behavmixed.h +++ b/src/games/behavmixed.h @@ -242,6 +242,15 @@ template class MixedBehaviorProfile { T GetAgentLiapValue() const; const T &GetRealizProb(const GameNode &node) const; + /// @brief Computes the probability the information set \p p_infoset is reached. + /// @details Computes the probability that \p p_infoset is reached assuming + /// all players play according to the profile. If \p p_infoset is an + /// absent-minded infoset, this probability is the probability any + /// member node is reached; multiple visits do not contribute further + /// to the probability. + /// @param[in] p_infoset The information set to compute the realization probability. + /// @sa GetRealizProb(const GameNode &) const + /// GetBeliefProb(const GameNode &) const T GetInfosetProb(const GameInfoset &p_infoset) const; std::optional GetBeliefProb(const GameNode &node) const; Vector GetPayoff(const GameNode &node) const; diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 6fb71c836..0889e439c 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -754,10 +754,14 @@ class MixedBehaviorProfile: def infoset_prob(self, infoset: NodeReference) -> ProfileDType: """Returns the probability with which an information set is reached. + For absent-minded information sets, this returns the probability that any + member is reached; a second or subsequent visit to the information set does + not contribute further to the realization probability. + Parameters ---------- infoset : Infoset or str - The information set to get the payoff for. If a string is passed, the + The information set to get the probability of. If a string is passed, the information set is determined by finding the information set with that label, if any. Raises