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 diff --git a/src/games/behavmixed.cc b/src/games/behavmixed.cc index 42498ad87..ee923cf7b 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,14 @@ 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 { return m_cache.m_realizProbs[node]; }); + } + for (const auto &[infoset, node] : game->GetAbsentMindedReentries()) { + m_cache.m_infosetProbs[infoset] -= m_cache.m_realizProbs[node]; + } } template void MixedBehaviorProfile::ComputeBeliefs() const diff --git a/src/games/behavmixed.h b/src/games/behavmixed.h index d535560c2..8973e6d5b 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(); @@ -240,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/games/game.h b/src/games/game.h index fe6a4d1b8..dfb85be23 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -939,6 +939,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(); } diff --git a/src/games/gametree.cc b/src/games/gametree.cc index fd0ae033d..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,6 +860,23 @@ bool GameTreeRep::IsAbsentMinded(const GameInfoset &p_infoset) const return contains(m_absentMindedInfosets, p_infoset.get()); } +std::vector> GameTreeRep::GetAbsentMindedReentries() const +{ + if (!m_unreachableNodes && !m_root->IsTerminal()) { + BuildUnreachableNodes(); + } + if (m_absentMindedReentries.empty()) { + return {}; + } + + 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; +} + //------------------------------------------------------------------------ // GameTreeRep: Managing the representation //------------------------------------------------------------------------ @@ -924,6 +942,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 +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.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 aaf86ff7f..395381e93 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::vector> m_absentMindedReentries; mutable std::vector m_subgames; /// @name Private auxiliary functions @@ -99,6 +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; + std::vector> GetAbsentMindedReentries() const override; std::vector GetSubgames() const override; //@} 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 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", [