From d1df82ab61a90b9933dc6fb5b97d75b80db889b6 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 16 Mar 2026 21:30:54 +0000 Subject: [PATCH 1/4] Clarify definitions of behaviour profile values for unreached information sets. This clarifies how quantities related to mixed behaviours profiles are defined for information sets which are not reached. * Beliefs, action values, infoset values, and node values are not well-defined in this case. Therefore, these functions now return a `std::optional` in C++, with `std::nullopt` when not defined. The Python equivalents now return `None` * Agent regret concepts (action regret, infoset regret, max agent regret) are well-defined despite information sets not being reachable: from the definition of the (multi)agent form of a game, these are zero. * Along similar lines to the above, in the agent Lyapunov value, the contribution to the value of unreached information sets is set to zero. Closes #446. --- src/games/behavmixed.cc | 37 +++++++++++++---- src/games/behavmixed.h | 10 ++--- src/gui/analysis.cc | 20 +++++----- src/pygambit/behavmixed.pxi | 80 +++++++++++++++++++++++++++---------- src/pygambit/gambit.pxd | 9 +++-- src/solvers/liap/efgliap.cc | 4 +- src/tools/util.h | 13 +++++- tests/test_behav.py | 36 +++++++++++++++++ 8 files changed, 159 insertions(+), 50 deletions(-) diff --git a/src/games/behavmixed.cc b/src/games/behavmixed.cc index 36b0df22c..5eef18b04 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]; } @@ -304,18 +311,25 @@ template Vector MixedBehaviorProfile::GetPayoff(const GameNode & } template -const T &MixedBehaviorProfile::GetPayoff(const GamePlayer &p_player, - const GameNode &p_node) const +std::optional MixedBehaviorProfile::GetPayoff(const GamePlayer &p_player, + const GameNode &p_node) const { CheckVersion(); EnsureNodeValues(); + if (p_node->GetInfoset() && GetInfosetProb(p_node->GetInfoset()) == T{0}) { + return std::nullopt; + } return m_cache.m_nodeValues[p_node][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 +345,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 +369,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..156c36570 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; + std::optional GetPayoff(const GamePlayer &player, const GameNode &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/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..915cea7cf 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 @@ -630,10 +633,13 @@ class MixedBehaviorProfile: return self._payoff(resolved_player) def node_value(self, player: PlayerReference, - node: NodeReference) -> ProfileDType: + node: NodeReference) -> ProfileDType | None: """Returns the expected payoff to `player` conditional on play reaching `node`, if all players play according to the profile. + If the node's information set is not reachable, in general the node value + is not well-defined. In this case, the function returns `None`. + Parameters ---------- player : Player or str @@ -661,10 +667,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 @@ -686,10 +695,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 +716,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 +961,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 +972,23 @@ 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 _node_value(self, player: Player, node: Node) -> float | None: + cdef optional[double] value = deref(self.profile).GetPayoff(player.player, node.node) + if value.has_value(): + return value.value() + return None - 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 +1075,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 +1086,23 @@ 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 _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 _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 | None: + cdef optional[c_Rational] value = deref(self.profile).GetPayoff(player.player, node.node) + if value.has_value(): + return rat_to_py(value.value()) + return None + + 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..73ea42385 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 + - T GetPayoff(c_GamePlayer, c_GameNode) except + - T GetPayoff(c_GameAction) except + + optional[T] GetPayoff(c_GameInfoset) except + + optional[T] GetPayoff(c_GamePlayer, c_GameNode) except + + optional[T] GetPayoff(c_GameAction) except + T GetRegret(c_GameAction) except + T GetRegret(c_GameInfoset) except + T GetAgentMaxRegret() except + 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..016fb9491 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -2097,3 +2097,39 @@ 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_node_value(): + """Test that undefined node values 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.node_value(node.player, 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 From b8afc7aedcb18bbe41c1478f1bbef42ba2cf88a8 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 23 Mar 2026 09:34:35 +0000 Subject: [PATCH 2/4] Make See Also consistent, add entry to changelog. --- ChangeLog | 8 ++++---- src/pygambit/behavmixed.pxi | 14 +++++++++++++- src/pygambit/game.pxi | 4 ++-- src/pygambit/nash.py | 4 ++-- src/pygambit/player.pxi | 4 ++-- src/pygambit/stratspt.pxi | 6 +++--- 6 files changed, 26 insertions(+), 14 deletions(-) diff --git a/ChangeLog b/ChangeLog index 8a09c43df..62c7e846a 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, infoset values, and node 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/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 915cea7cf..d1e41ed47 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -603,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")) @@ -659,6 +663,10 @@ class MixedBehaviorProfile: `node` is a string and no node in the game has that label. ValueError If `player` resolves to the chance player + + See Also + -------- + MixedBehaviorProfile.infoset_prob """ self._check_validity() resolved_player = self.game._resolve_player(player, "node_value") @@ -688,6 +696,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") @@ -717,7 +729,7 @@ class MixedBehaviorProfile: ValueError If `action` resolves to an action that belongs to the chance player - See also + See Also -------- MixedBehaviorProfile.infoset_prob """ 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 """ From e6e57f756c1491854990c7504b39d4e613f4ae2e Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 23 Mar 2026 09:41:39 +0000 Subject: [PATCH 3/4] Remove misleading GetPayoff call from pure behavior profile --- src/games/behavpure.cc | 11 ----------- src/games/behavpure.h | 2 -- src/solvers/enumpure/enumpure.h | 4 +++- 3 files changed, 3 insertions(+), 14 deletions(-) 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/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; } } From 842b0ec9800f5e19ca585140217933e71f04ad8d Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 23 Mar 2026 09:53:18 +0000 Subject: [PATCH 4/4] Node values are well-defined! --- ChangeLog | 2 +- src/games/behavmixed.cc | 9 +++------ src/games/behavmixed.h | 2 +- src/pygambit/behavmixed.pxi | 23 +++++------------------ src/pygambit/gambit.pxd | 2 +- tests/test_behav.py | 9 --------- 6 files changed, 11 insertions(+), 36 deletions(-) diff --git a/ChangeLog b/ChangeLog index 62c7e846a..03b8be635 100644 --- a/ChangeLog +++ b/ChangeLog @@ -5,7 +5,7 @@ ### Changed - `Game.comment` has been renamed to `Game.description` - With behaviour profiles that reach some information sets with probability zero, beliefs, action - values, infoset values, and node values are not well-defined. These functions now return a + 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) diff --git a/src/games/behavmixed.cc b/src/games/behavmixed.cc index 5eef18b04..eb4b3ee26 100644 --- a/src/games/behavmixed.cc +++ b/src/games/behavmixed.cc @@ -311,15 +311,12 @@ template Vector MixedBehaviorProfile::GetPayoff(const GameNode & } template -std::optional MixedBehaviorProfile::GetPayoff(const GamePlayer &p_player, - const GameNode &p_node) const +const T &MixedBehaviorProfile::GetPayoff(const GamePlayer &p_player, + const GameNode &p_node) const { CheckVersion(); EnsureNodeValues(); - if (p_node->GetInfoset() && GetInfosetProb(p_node->GetInfoset()) == T{0}) { - return std::nullopt; - } - return m_cache.m_nodeValues[p_node][p_player]; + return m_cache.m_nodeValues.at(p_node).at(p_player); } template diff --git a/src/games/behavmixed.h b/src/games/behavmixed.h index 156c36570..d535560c2 100644 --- a/src/games/behavmixed.h +++ b/src/games/behavmixed.h @@ -243,7 +243,7 @@ template class MixedBehaviorProfile { T GetInfosetProb(const GameInfoset &p_infoset) const; std::optional GetBeliefProb(const GameNode &node) const; Vector GetPayoff(const GameNode &node) const; - std::optional GetPayoff(const GamePlayer &player, const GameNode &node) 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; diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index d1e41ed47..6fb71c836 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -637,13 +637,10 @@ class MixedBehaviorProfile: return self._payoff(resolved_player) def node_value(self, player: PlayerReference, - node: NodeReference) -> ProfileDType | None: + node: NodeReference) -> ProfileDType: """Returns the expected payoff to `player` conditional on play reaching `node`, if all players play according to the profile. - If the node's information set is not reachable, in general the node value - is not well-defined. In this case, the function returns `None`. - Parameters ---------- player : Player or str @@ -663,10 +660,6 @@ class MixedBehaviorProfile: `node` is a string and no node in the game has that label. ValueError If `player` resolves to the chance player - - See Also - -------- - MixedBehaviorProfile.infoset_prob """ self._check_validity() resolved_player = self.game._resolve_player(player, "node_value") @@ -990,11 +983,8 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): return value.value() return None - def _node_value(self, player: Player, node: Node) -> float | None: - cdef optional[double] value = deref(self.profile).GetPayoff(player.player, node.node) - 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 | None: cdef optional[double] value = deref(self.profile).GetPayoff(action.action) @@ -1104,11 +1094,8 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): return rat_to_py(value.value()) return None - def _node_value(self, player: Player, node: Node) -> Rational | None: - cdef optional[c_Rational] value = deref(self.profile).GetPayoff(player.player, node.node) - 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 | None: cdef optional[c_Rational] value = deref(self.profile).GetPayoff(action.action) diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 73ea42385..95ae67295 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -367,7 +367,7 @@ cdef extern from "games/behavmixed.h" namespace "Gambit": T GetRealizProb(c_GameNode) except + T GetInfosetProb(c_GameInfoset) except + optional[T] GetPayoff(c_GameInfoset) except + - optional[T] GetPayoff(c_GamePlayer, c_GameNode) except + + T GetPayoff(c_GamePlayer, c_GameNode) except + optional[T] GetPayoff(c_GameAction) except + T GetRegret(c_GameAction) except + T GetRegret(c_GameInfoset) except + diff --git a/tests/test_behav.py b/tests/test_behav.py index 016fb9491..b45c8d10e 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -2117,15 +2117,6 @@ def test_undefined_belief(): assert profile.belief(node) is None -def test_undefined_node_value(): - """Test that undefined node values 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.node_value(node.player, node) is None - - def test_undefined_infoset_value(): """Test that undefined infoset values return `None`.""" game = gbt.catalog.load("selten1975/fig1")