From a76e89047806c2a08fc85ac5e2c05e1d746404c8 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 2 Apr 2026 08:10:01 +0100 Subject: [PATCH 01/16] 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 03dccc285..3d04d2d37 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -1055,6 +1055,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 bcefbf45c..b76524648 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 //------------------------------------------------------------------------ @@ -962,6 +979,7 @@ void GameTreeRep::ClearComputedValues() const const_cast(this)->m_unreachableNodes = nullptr; m_absentMindedInfosets.clear(); m_subgameData.Invalidate(); + m_absentMindedReentries.clear(); m_computedValues = false; } @@ -1187,6 +1205,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 3e36af398..3e8abcb79 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -50,6 +50,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. @@ -125,8 +126,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 c9ba550374d59dff38324dcb2f9eba8800996eb0 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 2 Apr 2026 08:10:59 +0100 Subject: [PATCH 02/16] 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 e2e48ace1ce8f1d2b6d8a196ebea804b43d0563e Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 2 Apr 2026 08:11:21 +0100 Subject: [PATCH 03/16] Add tests for absent-minded infoset probability --- tests/test_behav.py | 83 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/test_behav.py b/tests/test_behav.py index b4d786e28..889bff912 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -895,6 +895,55 @@ 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), + # 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( + 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("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_label,infoset_label,payoff,rational_flag", [ @@ -1785,6 +1834,40 @@ def test_agent_max_regret_versus_non_agent( "2/7", True, ), + # Information set I1 = {root, reentry_node}; 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"], [], "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"], ["1", "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], [], 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], ["1", "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"], [], "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"], ["1", "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"], ["1", "1"], "1", True, + ), ], ) def test_node_belief_reference( From bbf8c24ad9bebd4f0ae64111772b69515fd9259e Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 2 Apr 2026 10:02:17 +0100 Subject: [PATCH 04/16] =?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 0198425dd0416bf51f994936ebfd0239b3e2c26e Mon Sep 17 00:00:00 2001 From: drdkad Date: Sat, 11 Apr 2026 13:11:51 +0100 Subject: [PATCH 05/16] 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 3d04d2d37..e2a7429b6 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -1063,6 +1063,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 b76524648..68d7b0f5c 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; } //------------------------------------------------------------------------ @@ -1205,7 +1222,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 3e8abcb79..47bcb9ffa 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -50,7 +50,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. @@ -126,10 +126,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 7363bd27f609954f6e92e8044f2ee739f2fc8260 Mon Sep 17 00:00:00 2001 From: drdkad Date: Sat, 11 Apr 2026 13:14:08 +0100 Subject: [PATCH 06/16] 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 8c20b70421e4c8a6e8a0af618a9a4b8944600af7 Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 5 Jun 2026 09:55:38 +0100 Subject: [PATCH 07/16] 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 68d7b0f5c..9c460d5b0 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 {}; @@ -1092,6 +1090,8 @@ void GameTreeRep::BuildOwnPriorActions() const { if (m_root->IsTerminal()) { m_ownPriorActionInfo = std::make_shared(); + m_absentMindedInfosets.clear(); + m_absentMindedReentries.clear(); return; } @@ -1099,6 +1099,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()) { @@ -1117,6 +1124,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; } @@ -1132,6 +1144,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; } @@ -1145,6 +1158,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 @@ -1219,10 +1234,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 0e2a09f4f998c499fe41ffabd37756ac2b1a1db0 Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 5 Jun 2026 10:09:07 +0100 Subject: [PATCH 08/16] Add absent-minded reach-probability fixture that would fail the previous pure-strategy reentry detection --- .../test_games/noPR-action-AM-three-chain.efg | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/test_games/noPR-action-AM-three-chain.efg 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 c88dd7bbc909f82dea017a503bb654f99d93f6c8 Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 5 Jun 2026 11:31:03 +0100 Subject: [PATCH 09/16] 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 1d0aaebbbcba57dc2e8c6cf32fd7d8584699b6f5 Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 5 Jun 2026 11:57:33 +0100 Subject: [PATCH 10/16] 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 e2a7429b6..a6a82f03a 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -1055,14 +1055,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 9c460d5b0..e89c4f0cf 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 47bcb9ffa..4cb6e09dd 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -126,8 +126,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 1910009bd9bed35f15ba5aee96e0cbad452ece3d Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 12 Jun 2026 15:31:24 +0100 Subject: [PATCH 11/16] 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 4d5bf225815f40a093d0dc95315d173c044aa8c5 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 17 Jun 2026 13:59:43 +0100 Subject: [PATCH 12/16] 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 9bb9aa3db2045d4da41d22d95591b0c8c3b57135 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 17 Jun 2026 14:00:40 +0100 Subject: [PATCH 13/16] 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}, From 910a7a09ee9b9a343606c3c4b11b0f75ca6238ab Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 30 Jun 2026 10:01:06 +0100 Subject: [PATCH 14/16] add infoset labels --- .../test_games/noPR-AM-driver-one-player.efg | 20 +++++++++---------- .../test_games/noPR-action-AM-three-chain.efg | 12 +++++------ tests/test_games/noPR-action-AM.efg | 18 ++++++++--------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/tests/test_games/noPR-AM-driver-one-player.efg b/tests/test_games/noPR-AM-driver-one-player.efg index 9e4395612..8cb3cd984 100644 --- a/tests/test_games/noPR-AM-driver-one-player.efg +++ b/tests/test_games/noPR-AM-driver-one-player.efg @@ -1,12 +1,12 @@ -EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +EFG 2 R "AM-driver variation" { "Player 1" } "" -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 } +p "" 1 1 "Absent-minded" { "1" "2" } 0 +p "" 1 2 "Second" { "1" "2" } 0 +p "" 1 1 "Absent-minded" { "1" "2" } 0 +t "" 1 "Outcome 1" { 1 } +p "" 1 3 "Third" { "1" "2" } 0 +t "" 2 "Outcome 2" { 2 } +t "" 5 "Outcome 5" { 5 } +t "" 3 "Outcome 3" { 3 } +t "" 4 "Outcome 4" { 4 } diff --git a/tests/test_games/noPR-action-AM-three-chain.efg b/tests/test_games/noPR-action-AM-three-chain.efg index 1e8e43b4e..42c639e0c 100644 --- a/tests/test_games/noPR-action-AM-three-chain.efg +++ b/tests/test_games/noPR-action-AM-three-chain.efg @@ -5,14 +5,14 @@ but not pure-strategy-reachable; likewise, both members of Player 2's infoset ar 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 +p "" 1 1 "Absent-minded" { "1" "2" } 0 +p "" 1 2 "Second" { "1" "2" } 0 +p "" 1 1 "Absent-minded" { "1" "2" } 0 t "" 1 "Outcome 1" { 1, -1 } -p "" 1 1 "" { "1" "2" } 0 +p "" 1 1 "Absent-minded" { "1" "2" } 0 t "" 2 "Outcome 2" { 2, -2 } -p "" 2 1 "" { "1" "2" } 0 -p "" 2 1 "" { "1" "2" } 0 +p "" 2 1 "Player 2" { "1" "2" } 0 +p "" 2 1 "Player 2" { "1" "2" } 0 t "" 3 "Outcome 3" { 3, -3 } t "" 4 "Outcome 4" { 4, -4 } t "" 5 "Outcome 5" { 5, -5 } diff --git a/tests/test_games/noPR-action-AM.efg b/tests/test_games/noPR-action-AM.efg index 92121a12e..2439880e0 100644 --- a/tests/test_games/noPR-action-AM.efg +++ b/tests/test_games/noPR-action-AM.efg @@ -1,18 +1,18 @@ -EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } -"" +EFG 2 R "AM-game with two players" { "Player 1" "Player 2" } +"This game is a counter-example showing that the legacy RSF generator fails for AM-games" -p "" 1 1 "" { "1" "2" } 0 -p "" 1 1 "" { "1" "2" } 0 -p "" 2 1 "" { "1" "2" } 0 +p "" 1 1 "Absent-minded" { "1" "2" } 0 +p "" 1 1 "Absent-minded" { "1" "2" } 0 +p "" 2 1 "Response 1" { "1" "2" } 0 t "" 1 "Outcome 1" { 1, -1 } t "" 2 "Outcome 2" { 2, -2 } -p "" 2 2 "" { "1" "2" } 0 +p "" 2 2 "Response 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 +p "" 1 1 "Absent-minded" { "1" "2" } 0 +p "" 2 3 "Response 3" { "1" "2" } 0 t "" 5 "Outcome 2" { 5, -5 } t "" 6 "Outcome 2" { 6, -6 } -p "" 2 4 "" { "1" "2" } 0 +p "" 2 4 "Response 4" { "1" "2" } 0 t "" 7 "Outcome 2" { 7, -7 } t "" 8 "Outcome 2" { 8, -8 } From b0e435aaaff4247ef84492267990f963b7f2bcd6 Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 30 Jun 2026 10:01:29 +0100 Subject: [PATCH 15/16] update the tests following the new indexing convention --- tests/test_behav.py | 56 ++++++++++++++++++++--------------------- tests/test_extensive.py | 2 +- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/tests/test_behav.py b/tests/test_behav.py index 889bff912..f6e01ad8d 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -896,41 +896,41 @@ def test_infoset_prob_by_label_reference( @pytest.mark.parametrize( - "game,player_idx,infoset_idx,prob,rational_flag", + "game,infoset_label,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), + (games.read_from_file("noPR-AM-driver-one-player.efg"), "Absent-minded", 1.0, False), + (games.read_from_file("noPR-AM-driver-one-player.efg"), "Second", 0.5, False), + (games.read_from_file("noPR-AM-driver-one-player.efg"), "Third", 0.125, False), + (games.read_from_file("noPR-AM-driver-one-player.efg"), "Absent-minded", "1", True), + (games.read_from_file("noPR-AM-driver-one-player.efg"), "Second", "1/2", True), + (games.read_from_file("noPR-AM-driver-one-player.efg"), "Third", "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), - # 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), + (games.read_from_file("noPR-action-AM.efg"), "Absent-minded", 1.0, False), + (games.read_from_file("noPR-action-AM.efg"), "Response 1", 0.25, False), + (games.read_from_file("noPR-action-AM.efg"), "Response 2", 0.25, False), + (games.read_from_file("noPR-action-AM.efg"), "Response 3", 0.25, False), + (games.read_from_file("noPR-action-AM.efg"), "Response 4", 0.25, False), + (games.read_from_file("noPR-action-AM.efg"), "Absent-minded", "1", True), + (games.read_from_file("noPR-action-AM.efg"), "Response 1", "1/4", True), + (games.read_from_file("noPR-action-AM.efg"), "Response 2", "1/4", True), + (games.read_from_file("noPR-action-AM.efg"), "Response 3", "1/4", True), + (games.read_from_file("noPR-action-AM.efg"), "Response 4", "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"), "Absent-minded", 1.0, False), + (games.read_from_file("noPR-action-AM-three-chain.efg"), "Second", 0.5, False), + (games.read_from_file("noPR-action-AM-three-chain.efg"), "Player 2", 0.0625, False), + (games.read_from_file("noPR-action-AM-three-chain.efg"), "Absent-minded", "1", True), + (games.read_from_file("noPR-action-AM-three-chain.efg"), "Second", "1/2", True), + (games.read_from_file("noPR-action-AM-three-chain.efg"), "Player 2", "1/16", True), ], ) def test_absent_minded_infoset_prob( - game: gbt.Game, player_idx: int, infoset_idx: int, prob: str | float, rational_flag: bool + game: gbt.Game, infoset_label: str, 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]) + ip = profile.infoset_prob(game.infosets[infoset_label]) assert ip == (gbt.Rational(prob) if rational_flag else prob) @@ -941,7 +941,7 @@ def test_nature_rooted_game_root_reached_with_certainty(rational_flag: bool): 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 + assert profile.infoset_prob(game.root.infoset) == one @pytest.mark.parametrize( diff --git a/tests/test_extensive.py b/tests/test_extensive.py index e5873e41e..a18c24a29 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -64,7 +64,7 @@ def test_game_add_players_nolabel(): (gbt.catalog.load("journals/geb/wichardt2008"), False), # 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-one-player.efg", False), # 1 player, 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-AM-two-hops.efg", False), # 2 players, one AM-infoset each From cd6d9c4eac41962ef325e70c2d1c5234e6d35535 Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 30 Jun 2026 13:07:11 +0100 Subject: [PATCH 16/16] document absent-minded conventions --- src/pygambit/behavmixed.pxi | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 5817a5acb..2eae3b508 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -591,8 +591,17 @@ class MixedBehaviorProfile: """Returns the conditional probability that a node is reached, given that its information set is reached. - If the information set is not reachable, the belief is not well-defined. - In this case, the function returns `None`. + The conditioning event is that the information set is reached at least once, + so beliefs are normalized by the upper-frontier probability returned by + `infoset_prob` (following :cite:p:`HalPas21`), rather than by the sum of the + members' realization probabilities. For a non-absent-minded information set + the two approaches agree. For an absent-minded information set they need not: + the beliefs over its members may sum to more than one. + + If the information set is reached with zero probability under the profile, the + belief is not well-defined and the function returns `None`. This is the same + reach probability returned by `infoset_prob`, so a `None` belief corresponds + exactly to `infoset_prob` being zero there. Parameters ---------- @@ -751,13 +760,21 @@ class MixedBehaviorProfile: self._check_validity() return self._realiz_prob(self.game._resolve_node(node, "realiz_prob")) - def infoset_prob(self, infoset: NodeReference) -> ProfileDType: + def infoset_prob(self, infoset: InfosetReference) -> ProfileDType: """Returns the probability with which an information set is reached. + This is the probability that the information set is reached *at least once* + under the profile: the realization probability of its upper frontier, i.e. the + members not preceded by another member of the same information set. + For a non-absent-minded information set, its upper frontier coincides with it. + For an absent-minded information set, with a play passing through it more than once, + the members below its frontier are excluded, so this is generally less than + the sum of the members' realization probabilities; see :cite:p:`HalPas21`. + 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 for. If a string is passed, the information set is determined by finding the information set with that label, if any. Raises @@ -766,6 +783,10 @@ class MixedBehaviorProfile: If `infoset` is an ``Infoset`` from a different game. KeyError If `infoset` is a string and no information set in the game has that label. + + See Also + -------- + MixedBehaviorProfile.belief """ self._check_validity() return self._infoset_prob(self.game._resolve_infoset(infoset, "infoset_prob"))