diff --git a/ChangeLog b/ChangeLog index 8a09c43df..03b8be635 100644 --- a/ChangeLog +++ b/ChangeLog @@ -4,6 +4,10 @@ ### Changed - `Game.comment` has been renamed to `Game.description` +- With behaviour profiles that reach some information sets with probability zero, beliefs, action + values, and infoset values are not well-defined. These functions now return a + `std::optional` in C++ and type or `None` in Python, where nulls indicate these quantities + are not defined. (#446) ### Added - Implement linear-time algorithm to find all root nodes of proper subgames, using an adaptation of @@ -20,10 +24,6 @@ root node is a member of an absent-minded infoset. (#584) - Removed spurious warning in graphical interface when loading file as a command-line argument (or also by clicking on a file in MSW, as that uses the command-line mechanism). (#801) - -## [16.5.1] - unreleased - -### Fixed - `Game.reveal` raised a null pointer access exception or dumped core in some cases (#749) ## [16.5.0] - 2026-01-05 diff --git a/src/games/behavmixed.cc b/src/games/behavmixed.cc index 1a787f9c9..42498ad87 100644 --- a/src/games/behavmixed.cc +++ b/src/games/behavmixed.cc @@ -260,8 +260,11 @@ template T MixedBehaviorProfile::GetAgentLiapValue() const { CheckVersion(); EnsureRegrets(); - auto value = static_cast(0); + T value{0}; for (auto infoset : m_support.GetGame()->GetInfosets()) { + if (GetInfosetProb(infoset) == T{0}) { + continue; + } for (auto action : m_support.GetActions(infoset)) { value += sqr(std::max(m_cache.m_actionValues[action] - m_cache.m_infosetValues[infoset], static_cast(0))); @@ -285,10 +288,14 @@ template T MixedBehaviorProfile::GetInfosetProb(const GameInfoset & [&](const auto &node) -> T { return m_cache.m_realizProbs[node]; }); } -template const T &MixedBehaviorProfile::GetBeliefProb(const GameNode &node) const +template +std::optional MixedBehaviorProfile::GetBeliefProb(const GameNode &node) const { CheckVersion(); EnsureBeliefs(); + if (!node->GetInfoset() || GetInfosetProb(node->GetInfoset()) == T{0}) { + return std::nullopt; + } return m_cache.m_beliefs[node]; } @@ -309,13 +316,17 @@ const T &MixedBehaviorProfile::GetPayoff(const GamePlayer &p_player, { CheckVersion(); EnsureNodeValues(); - return m_cache.m_nodeValues[p_node][p_player]; + return m_cache.m_nodeValues.at(p_node).at(p_player); } -template const T &MixedBehaviorProfile::GetPayoff(const GameInfoset &p_infoset) const +template +std::optional MixedBehaviorProfile::GetPayoff(const GameInfoset &p_infoset) const { CheckVersion(); EnsureRegrets(); + if (GetInfosetProb(p_infoset) == T{0}) { + return std::nullopt; + } return m_cache.m_infosetValues[p_infoset]; } @@ -331,17 +342,23 @@ template T MixedBehaviorProfile::GetActionProb(const GameAction &ac return m_probs[m_profileIndex.at(action)]; } -template const T &MixedBehaviorProfile::GetPayoff(const GameAction &act) const +template std::optional MixedBehaviorProfile::GetPayoff(const GameAction &act) const { CheckVersion(); EnsureActionValues(); + if (GetInfosetProb(act->GetInfoset()) == T{0}) { + return std::nullopt; + } return m_cache.m_actionValues[act]; } -template const T &MixedBehaviorProfile::GetRegret(const GameAction &act) const +template T MixedBehaviorProfile::GetRegret(const GameAction &act) const { CheckVersion(); EnsureRegrets(); + if (GetInfosetProb(act->GetInfoset()) == T{0}) { + return T{0}; + } return m_cache.m_regret.at(act); } @@ -349,6 +366,9 @@ template T MixedBehaviorProfile::GetRegret(const GameInfoset &p_inf { CheckVersion(); EnsureRegrets(); + if (GetInfosetProb(p_infoset) == T{0}) { + return T{0}; + } T br_payoff = maximize_function(p_infoset->GetActions(), [this](const auto &action) -> T { return m_cache.m_actionValues.at(action); }); diff --git a/src/games/behavmixed.h b/src/games/behavmixed.h index 01a36ccdc..d535560c2 100644 --- a/src/games/behavmixed.h +++ b/src/games/behavmixed.h @@ -241,11 +241,11 @@ template class MixedBehaviorProfile { const T &GetRealizProb(const GameNode &node) const; T GetInfosetProb(const GameInfoset &p_infoset) const; - const T &GetBeliefProb(const GameNode &node) const; + std::optional GetBeliefProb(const GameNode &node) const; Vector GetPayoff(const GameNode &node) const; - const T &GetPayoff(const GamePlayer &player, const GameNode &node) const; - const T &GetPayoff(const GameInfoset &p_infoset) const; - const T &GetPayoff(const GameAction &act) const; + const T &GetPayoff(const GamePlayer &p_player, const GameNode &p_node) const; + std::optional GetPayoff(const GameInfoset &p_infoset) const; + std::optional GetPayoff(const GameAction &act) const; T GetActionProb(const GameAction &act) const; /// @brief Computes the regret to playing \p p_action @@ -256,7 +256,7 @@ template class MixedBehaviorProfile { /// @param[in] p_action The action to compute the regret for. /// @sa GetRegret(const GameInfoset &) const /// GetAgentMaxRegret() const - const T &GetRegret(const GameAction &p_action) const; + T GetRegret(const GameAction &p_action) const; /// @brief Computes the regret at information set \p p_infoset /// @details Computes the regret at the information set to the player of playing diff --git a/src/games/behavpure.cc b/src/games/behavpure.cc index 3452b4b2c..437d06403 100644 --- a/src/games/behavpure.cc +++ b/src/games/behavpure.cc @@ -71,17 +71,6 @@ T PureBehaviorProfile::GetPayoff(const GameNode &p_node, const GamePlayer &p_pla template double PureBehaviorProfile::GetPayoff(const GameNode &, const GamePlayer &) const; template Rational PureBehaviorProfile::GetPayoff(const GameNode &, const GamePlayer &) const; -template T PureBehaviorProfile::GetPayoff(const GameAction &p_action) const -{ - PureBehaviorProfile copy(*this); - copy.SetAction(p_action); - return copy.GetPayoff(p_action->GetInfoset()->GetPlayer()); -} - -// Explicit instantiations -template double PureBehaviorProfile::GetPayoff(const GameAction &) const; -template Rational PureBehaviorProfile::GetPayoff(const GameAction &) const; - MixedBehaviorProfile PureBehaviorProfile::ToMixedBehaviorProfile() const { MixedBehaviorProfile temp(m_efg); diff --git a/src/games/behavpure.h b/src/games/behavpure.h index 9c4c045a6..d3a17261a 100644 --- a/src/games/behavpure.h +++ b/src/games/behavpure.h @@ -61,8 +61,6 @@ class PureBehaviorProfile { template T GetPayoff(const GamePlayer &p_player) const; /// Get the payoff to the player, conditional on reaching a node template T GetPayoff(const GameNode &, const GamePlayer &) const; - /// Get the payoff to playing the action, conditional on the profile - template T GetPayoff(const GameAction &) const; /// Convert to a mixed behavior representation MixedBehaviorProfile ToMixedBehaviorProfile() const; diff --git a/src/gui/analysis.cc b/src/gui/analysis.cc index 096fab2c2..2ded2c819 100644 --- a/src/gui/analysis.cc +++ b/src/gui/analysis.cc @@ -240,9 +240,9 @@ std::string AnalysisProfileList::GetBeliefProb(const GameNode &p_node, int p_ } try { - if (m_behavProfiles[index]->GetInfosetProb(p_node->GetInfoset()) > Rational(0)) { - return lexical_cast(m_behavProfiles[index]->GetBeliefProb(p_node), - m_doc->GetStyle().NumDecimals()); + auto belief = m_behavProfiles[index]->GetBeliefProb(p_node); + if (belief.has_value()) { + return lexical_cast(belief.value(), m_doc->GetStyle().NumDecimals()); } // We don't compute assessments yet! return "*"; @@ -295,9 +295,9 @@ std::string AnalysisProfileList::GetInfosetValue(const GameNode &p_node, int } try { - if (m_behavProfiles[index]->GetInfosetProb(p_node->GetInfoset()) > Rational(0)) { - return lexical_cast(m_behavProfiles[index]->GetPayoff(p_node->GetInfoset()), - m_doc->GetStyle().NumDecimals()); + auto payoff = m_behavProfiles[index]->GetPayoff(p_node->GetInfoset()); + if (payoff.has_value()) { + return lexical_cast(payoff.value(), m_doc->GetStyle().NumDecimals()); } // In the absence of beliefs, this is not well-defined in general return "*"; @@ -367,10 +367,10 @@ std::string AnalysisProfileList::GetActionValue(const GameNode &p_node, int p } try { - if (m_behavProfiles[index]->GetInfosetProb(p_node->GetInfoset()) > Rational(0)) { - return lexical_cast( - m_behavProfiles[index]->GetPayoff(p_node->GetInfoset()->GetAction(p_act)), - m_doc->GetStyle().NumDecimals()); + std::optional actionValue = + m_behavProfiles[index]->GetPayoff(p_node->GetInfoset()->GetAction(p_act)); + if (actionValue.has_value()) { + return lexical_cast(actionValue.value(), m_doc->GetStyle().NumDecimals()); } // In the absence of beliefs, this is not well-defined return "*"; diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index cfe7805d5..6fb71c836 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -587,10 +587,13 @@ class MixedBehaviorProfile: self._check_validity() return self._is_defined_at(self.game._resolve_infoset(infoset, "is_defined_at")) - def belief(self, node: NodeReference) -> ProfileDType: + def belief(self, node: NodeReference) -> ProfileDType | None: """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`. + Parameters ---------- node @@ -600,6 +603,10 @@ class MixedBehaviorProfile: ------ MismatchError If `node` is not in the same game as the profile + + See Also + -------- + MixedBehaviorProfile.infoset_prob """ self._check_validity() return self._belief(self.game._resolve_node(node, "belief")) @@ -661,10 +668,13 @@ class MixedBehaviorProfile: raise ValueError("node_value() is not defined for the chance player") return self._node_value(resolved_player, resolved_node) - def infoset_value(self, infoset: InfosetReference) -> ProfileDType: + def infoset_value(self, infoset: InfosetReference) -> ProfileDType | None: """Returns the expected payoff to the player conditional on reaching an information set, if all players play according to the profile. + If the information set is not reachable, the expected payoff is not well-defined. + In this case, the function returns `None`. + Parameters ---------- infoset : Infoset or str @@ -679,6 +689,10 @@ class MixedBehaviorProfile: If `infoset` is a string and no information set in the game has that label. ValueError If `infoset` resolves to an infoset that belongs to the chance player + + See Also + -------- + MixedBehaviorProfile.infoset_prob """ self._check_validity() resolved_infoset = self.game._resolve_infoset(infoset, "infoset_value") @@ -686,10 +700,13 @@ class MixedBehaviorProfile: raise ValueError("infoset_value() is not defined for the chance player") return self._infoset_value(resolved_infoset) - def action_value(self, action: ActionReference) -> ProfileDType: + def action_value(self, action: ActionReference) -> ProfileDType | None: """Returns the expected payoff to the player of playing an action conditional on reaching its information set, if all players play according to the profile. + If the information set is not reachable, the expected payoff is not well-defined. + In this case, the function returns `None`. + Parameters ---------- action : Action or str @@ -704,6 +721,10 @@ class MixedBehaviorProfile: If `action` is a string and no action in the game has that label. ValueError If `action` resolves to an action that belongs to the chance player + + See Also + -------- + MixedBehaviorProfile.infoset_prob """ self._check_validity() resolved_action = self.game._resolve_action(action, "action_value") @@ -945,7 +966,10 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): return deref(self.profile).GetPayoff(player.player) def _belief(self, node: Node) -> float: - return deref(self.profile).GetBeliefProb(node.node) + cdef optional[double] value = deref(self.profile).GetBeliefProb(node.node) + if value.has_value(): + return value.value() + return None def _realiz_prob(self, node: Node) -> float: return deref(self.profile).GetRealizProb(node.node) @@ -953,14 +977,20 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): def _infoset_prob(self, infoset: Infoset) -> float: return deref(self.profile).GetInfosetProb(infoset.infoset) - def _infoset_value(self, infoset: Infoset) -> float: - return deref(self.profile).GetPayoff(infoset.infoset) + def _infoset_value(self, infoset: Infoset) -> float | None: + cdef optional[double] value = deref(self.profile).GetPayoff(infoset.infoset) + if value.has_value(): + return value.value() + return None def _node_value(self, player: Player, node: Node) -> float: return deref(self.profile).GetPayoff(player.player, node.node) - def _action_value(self, action: Action) -> float: - return deref(self.profile).GetPayoff(action.action) + def _action_value(self, action: Action) -> float | None: + cdef optional[double] value = deref(self.profile).GetPayoff(action.action) + if value.has_value(): + return value.value() + return None def _action_regret(self, action: Action) -> float: return deref(self.profile).GetRegret(action.action) @@ -1047,7 +1077,10 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): return rat_to_py(deref(self.profile).GetPayoff(player.player)) def _belief(self, node: Node) -> Rational: - return rat_to_py(deref(self.profile).GetBeliefProb(node.node)) + cdef optional[c_Rational] value = deref(self.profile).GetBeliefProb(node.node) + if value.has_value(): + return rat_to_py(value.value()) + return None def _realiz_prob(self, node: Node) -> Rational: return rat_to_py(deref(self.profile).GetRealizProb(node.node)) @@ -1055,14 +1088,20 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _infoset_prob(self, infoset: Infoset) -> Rational: return rat_to_py(deref(self.profile).GetInfosetProb(infoset.infoset)) - def _infoset_value(self, infoset: Infoset) -> Rational: - return rat_to_py(deref(self.profile).GetPayoff(infoset.infoset)) + def _infoset_value(self, infoset: Infoset) -> Rational | None: + cdef optional[c_Rational] value = deref(self.profile).GetPayoff(infoset.infoset) + if value.has_value(): + return rat_to_py(value.value()) + return None def _node_value(self, player: Player, node: Node) -> Rational: return rat_to_py(deref(self.profile).GetPayoff(player.player, node.node)) - def _action_value(self, action: Action) -> Rational: - return rat_to_py(deref(self.profile).GetPayoff(action.action)) + def _action_value(self, action: Action) -> Rational | None: + cdef optional[c_Rational] value = deref(self.profile).GetPayoff(action.action) + if value.has_value(): + return rat_to_py(value.value()) + return None def _action_regret(self, action: Action) -> Rational: return rat_to_py(deref(self.profile).GetRegret(action.action)) diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index e926c1911..95ae67295 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -4,6 +4,7 @@ from libcpp.memory cimport shared_ptr, unique_ptr from libcpp.list cimport list as stdlist from libcpp.vector cimport vector as stdvector from libcpp.set cimport set as stdset +from libcpp.optional cimport optional cdef extern from "gambit.h": @@ -362,12 +363,12 @@ cdef extern from "games/behavmixed.h" namespace "Gambit": T getitem "operator[]"(int) except +IndexError T getaction "operator[]"(c_GameAction) except +IndexError T GetPayoff(c_GamePlayer) except + - T GetBeliefProb(c_GameNode) except + + optional[T] GetBeliefProb(c_GameNode) except + T GetRealizProb(c_GameNode) except + T GetInfosetProb(c_GameInfoset) except + - T GetPayoff(c_GameInfoset) except + + optional[T] GetPayoff(c_GameInfoset) except + T GetPayoff(c_GamePlayer, c_GameNode) except + - T GetPayoff(c_GameAction) except + + optional[T] GetPayoff(c_GameAction) except + T GetRegret(c_GameAction) except + T GetRegret(c_GameInfoset) except + T GetAgentMaxRegret() except + diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index c6a3e2834..7bee9a447 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -784,7 +784,7 @@ class Game: Changed from reporting minimum payoff in any (non-null) outcome to the minimum payoff in any play of the game. - See also + See Also -------- Game.max_payoff Player.min_payoff @@ -799,7 +799,7 @@ class Game: Changed from reporting maximum payoff in any (non-null) outcome to the maximum payoff in any play of the game. - See also + See Also -------- Game.min_payoff Player.max_payoff diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index 8a39c038b..acfaade98 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -83,7 +83,7 @@ def enumpure_solve(game: libgbt.Game) -> NashComputationResult: res : NashComputationResult The result represented as a ``NashComputationResult`` object. - See also + See Also -------- enumpure_agent_solve """ @@ -113,7 +113,7 @@ def enumpure_agent_solve(game: libgbt.Game) -> NashComputationResult: res : NashComputationResult The result represented as a ``NashComputationResult`` object. - See also + See Also -------- enumpure_solve """ diff --git a/src/pygambit/player.pxi b/src/pygambit/player.pxi index 4ca523b1f..90483f25f 100644 --- a/src/pygambit/player.pxi +++ b/src/pygambit/player.pxi @@ -261,7 +261,7 @@ class Player: Changed from reporting minimum payoff in any (non-null) outcome to the minimum payoff in any play of the game. - See also + See Also -------- Player.max_payoff Game.min_payoff @@ -276,7 +276,7 @@ class Player: Changed from reporting maximum payoff in any (non-null) outcome to the maximum payoff in any play of the game. - See also + See Also -------- Player.min_payoff Game.max_payoff diff --git a/src/pygambit/stratspt.pxi b/src/pygambit/stratspt.pxi index 9b5a84374..8370f974f 100644 --- a/src/pygambit/stratspt.pxi +++ b/src/pygambit/stratspt.pxi @@ -121,7 +121,7 @@ class StrategySupportProfile: def __and__(self, other: StrategySupportProfile) -> StrategySupportProfile: """Operator version of set intersection on support profiles. - See also + See Also -------- intersection """ @@ -130,7 +130,7 @@ class StrategySupportProfile: def __or__(self, other: StrategySupportProfile) -> StrategySupportProfile: """Operator version of set union on support profiles. - See also + See Also -------- union """ @@ -139,7 +139,7 @@ class StrategySupportProfile: def __sub__(self, other: StrategySupportProfile) -> StrategySupportProfile: """Operator version of set difference on support profiles. - See also + See Also -------- difference """ diff --git a/src/solvers/enumpure/enumpure.h b/src/solvers/enumpure/enumpure.h index 7b2563a02..52927dd0f 100644 --- a/src/solvers/enumpure/enumpure.h +++ b/src/solvers/enumpure/enumpure.h @@ -68,7 +68,9 @@ inline bool IsAgentNash(const PureBehaviorProfile &p_profile) auto current = p_profile.GetPayoff(player); for (const auto &infoset : player->GetInfosets()) { for (const auto &action : infoset->GetActions()) { - if (p_profile.GetPayoff(action) > current) { + auto deviation = p_profile; + deviation.SetAction(action); + if (deviation.GetPayoff(player) > current) { return false; } } diff --git a/src/solvers/liap/efgliap.cc b/src/solvers/liap/efgliap.cc index 24bd326c5..8b827db40 100644 --- a/src/solvers/liap/efgliap.cc +++ b/src/solvers/liap/efgliap.cc @@ -74,9 +74,9 @@ AgentLyapunovFunction::PenalizedLiapValue(const MixedBehaviorProfile &p_ double value = 0.0; // Liapunov function proper. for (const auto &infoset : p_profile.GetGame()->GetInfosets()) { - double infosetValue = p_profile.GetPayoff(infoset); + double infosetValue = p_profile.GetPayoff(infoset).value(); value += sum_function(infoset->GetActions(), [&](const auto &action) -> double { - return sqr(std::max(m_scale * (p_profile.GetPayoff(action) - infosetValue), 0.0)); + return sqr(std::max(m_scale * (p_profile.GetPayoff(action).value() - infosetValue), 0.0)); }); } // Penalty function for non-negativity constraint for each action diff --git a/src/tools/util.h b/src/tools/util.h index e8f3cd8d9..656b999a5 100644 --- a/src/tools/util.h +++ b/src/tools/util.h @@ -229,7 +229,10 @@ void MixedBehaviorProfileDetailRenderer::Render(const MixedBehaviorProfile m_stream << lexical_cast(p_profile[action], m_numDecimals); m_stream << " "; m_stream << std::setw(11); - m_stream << lexical_cast(p_profile.GetPayoff(action), m_numDecimals); + std::optional actionValue = p_profile.GetPayoff(action); + if (actionValue.has_value()) { + m_stream << lexical_cast(actionValue.value(), m_numDecimals); + } m_stream << std::endl; } } @@ -253,7 +256,13 @@ void MixedBehaviorProfileDetailRenderer::Render(const MixedBehaviorProfile m_stream << std::setw(7) << node->GetNumber() << " "; } m_stream << std::setw(11); - m_stream << lexical_cast(p_profile.GetBeliefProb(node), m_numDecimals); + auto belief = p_profile.GetBeliefProb(node); + if (belief.has_value()) { + m_stream << lexical_cast(belief.value(), m_numDecimals); + } + else { + m_stream << ""; + } m_stream << " "; m_stream << std::setw(11); m_stream << lexical_cast(p_profile.GetRealizProb(node), m_numDecimals); diff --git a/tests/test_behav.py b/tests/test_behav.py index 62ec7bbf3..b45c8d10e 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -2097,3 +2097,30 @@ def test_tree_representation_error(game: gbt.Game, rational_flag: bool, data: li """ with pytest.raises(gbt.UndefinedOperationError): game.mixed_behavior_profile(rational=rational_flag, data=data) + + +def test_undefined_action_value(): + """Test that undefined action values return `None`.""" + game = gbt.catalog.load("selten1975/fig1") + action = game.players[2].infosets[0].actions[0] + for rat in [False, True]: + profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) + assert profile.action_value(action) is None + + +def test_undefined_belief(): + """Test that undefined beliefs return `None`.""" + game = gbt.catalog.load("selten1975/fig1") + node = game.players[2].infosets[0].members[0] + for rat in [False, True]: + profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) + assert profile.belief(node) is None + + +def test_undefined_infoset_value(): + """Test that undefined infoset values return `None`.""" + game = gbt.catalog.load("selten1975/fig1") + infoset = game.players[2].infosets[0] + for rat in [False, True]: + profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) + assert profile.infoset_value(infoset) is None