From 957881b5a588b55addeb38e3d3ea6de079139dc9 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 30 Jan 2026 12:48:56 +0000 Subject: [PATCH 01/19] Change `enumpoly` on extensive games to return only full profiles. For information sets which are not support-reachable, this returns all equilibria which are in pure strategies on those information sets. --- src/solvers/enumpoly/behavextend.cc | 1 - src/solvers/enumpoly/efgpoly.cc | 50 +++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/solvers/enumpoly/behavextend.cc b/src/solvers/enumpoly/behavextend.cc index 601973ab7..2376b2469 100644 --- a/src/solvers/enumpoly/behavextend.cc +++ b/src/solvers/enumpoly/behavextend.cc @@ -289,7 +289,6 @@ bool ExtendsToNash(const MixedBehaviorProfile &p_solution, const BehaviorSupportProfile &little_supp, const BehaviorSupportProfile &big_supp) { - // First we compute the number of variables, and indexing information int num_vars = 0; std::map var_index; diff --git a/src/solvers/enumpoly/efgpoly.cc b/src/solvers/enumpoly/efgpoly.cc index e30210949..c7891afcd 100644 --- a/src/solvers/enumpoly/efgpoly.cc +++ b/src/solvers/enumpoly/efgpoly.cc @@ -25,7 +25,7 @@ #include "games/gameseq.h" #include "polysystem.h" #include "polysolver.h" -#include "behavextend.h" +#include "indexproduct.h" using namespace Gambit; using namespace Gambit::Nash; @@ -151,6 +151,33 @@ std::map ToSequenceProbs(const ProblemData &p_data, const return x; } +std::list> +ExtendToFullSupport(const MixedBehaviorProfile &p_profile) +{ + const Game &game = p_profile.GetGame(); + std::list extensionInfosets; + for (const auto &infoset : game->GetInfosets()) { + if (!p_profile.IsDefinedAt(infoset)) { + extensionInfosets.push_back(infoset); + } + } + std::list> result; + Array firstIndex(extensionInfosets.size()); + std::fill(firstIndex.begin(), firstIndex.end(), 1); + Array lastIndex(extensionInfosets.size()); + std::transform(extensionInfosets.begin(), extensionInfosets.end(), lastIndex.begin(), + [](const auto &infoset) { return infoset->GetActions().size(); }); + CartesianIndexProduct indices(firstIndex, lastIndex); + for (const auto &index : indices) { + auto extension = p_profile; + for (auto [i, infoset] : enumerate(extensionInfosets)) { + extension[infoset->GetAction(index[i + 1])] = 1.0; + } + result.push_back(extension); + } + return result; +} + std::list> SolveSupport(const BehaviorSupportProfile &p_support, bool &p_isSingular, int p_stopAfter) { @@ -174,18 +201,14 @@ std::list> SolveSupport(const BehaviorSupportProfil p_isSingular = true; } catch (const std::domain_error &) { - // std::cerr << "Assertion warning: " << e.what() << std::endl; p_isSingular = true; } std::list> solutions; - for (auto root : roots) { + for (const auto &root : roots) { const MixedBehaviorProfile sol( - data.m_support.ToMixedBehaviorProfile(ToSequenceProbs(data, root))); - if (ExtendsToNash(sol, BehaviorSupportProfile(sol.GetGame()), - BehaviorSupportProfile(sol.GetGame()))) { - solutions.push_back(sol); - } + data.m_support.ToMixedBehaviorProfile(ToSequenceProbs(data, root)).ToFullSupport()); + solutions.splice(solutions.end(), ExtendToFullSupport(sol)); } return solutions; } @@ -210,12 +233,11 @@ EnumPolyBehaviorSolve(const Game &p_game, int p_stopAfter, double p_maxregret, for (auto support : possible_supports->m_supports) { p_onSupport("candidate", support); bool isSingular = false; - for (auto solution : - SolveSupport(support, isSingular, std::max(p_stopAfter - int(ret.size()), 0))) { - const MixedBehaviorProfile fullProfile = solution.ToFullSupport(); - if (fullProfile.GetAgentMaxRegret() < p_maxregret) { - p_onEquilibrium(fullProfile); - ret.push_back(fullProfile); + for (const auto &solution : SolveSupport( + support, isSingular, std::max(p_stopAfter - static_cast(ret.size()), 0))) { + if (solution.GetAgentMaxRegret() < p_maxregret) { + p_onEquilibrium(solution); + ret.push_back(solution); } } if (isSingular) { From 17d744e72c032fe6d70bbba10898457322012d02 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 30 Jan 2026 12:50:22 +0000 Subject: [PATCH 02/19] Remove overelaborate checker for whether a profile extends to Nash - it is enough to consider pure action profiles. --- Makefile.am | 3 - src/solvers/enumpoly/behavextend.cc | 453 ---------------------------- src/solvers/enumpoly/behavextend.h | 51 ---- src/solvers/enumpoly/polyfeasible.h | 107 ------- 4 files changed, 614 deletions(-) delete mode 100644 src/solvers/enumpoly/behavextend.cc delete mode 100644 src/solvers/enumpoly/behavextend.h delete mode 100644 src/solvers/enumpoly/polyfeasible.h diff --git a/Makefile.am b/Makefile.am index eccc92c12..d428f1259 100644 --- a/Makefile.am +++ b/Makefile.am @@ -407,9 +407,6 @@ gambit_enumpoly_SOURCES = \ src/solvers/enumpoly/polypartial.imp \ src/solvers/enumpoly/polysolver.cc \ src/solvers/enumpoly/polysolver.h \ - src/solvers/enumpoly/polyfeasible.h \ - src/solvers/enumpoly/behavextend.cc \ - src/solvers/enumpoly/behavextend.h \ src/solvers/enumpoly/efgpoly.cc \ src/solvers/enumpoly/nfgpoly.cc \ src/solvers/enumpoly/enumpoly.h \ diff --git a/src/solvers/enumpoly/behavextend.cc b/src/solvers/enumpoly/behavextend.cc deleted file mode 100644 index 2376b2469..000000000 --- a/src/solvers/enumpoly/behavextend.cc +++ /dev/null @@ -1,453 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/solver/enumpoly/behavextend.cc -// Algorithms for extending behavior profiles to Nash equilibria -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#include "behavextend.h" -#include "polysystem.h" -#include "polyfeasible.h" - -namespace { - -using namespace Gambit; - -void TerminalDescendants(const GameNode &p_node, std::list ¤t) -{ - if (p_node->IsTerminal()) { - current.push_back(p_node); - } - else { - for (auto child : p_node->GetChildren()) { - TerminalDescendants(child, current); - } - } -} - -std::list TerminalNodes(const Game &p_efg) -{ - std::list ret; - TerminalDescendants(p_efg->GetRoot(), ret); - return ret; -} - -void DeviationInfosets(Array &answer, const BehaviorSupportProfile &big_supp, - const GamePlayer &p_player, const GameNode &p_node, - const GameAction &p_action) -{ - const GameNode child = p_node->GetChild(p_action); - if (child->IsTerminal()) { - return; - } - const GameInfoset iset = child->GetInfoset(); - if (iset->GetPlayer() == p_player) { - size_t insert = 0; - bool done = false; - while (!done) { - insert++; - if (insert > answer.size() || iset->Precedes(answer[insert]->GetMember(1))) { - done = true; - } - } - answer.insert(std::next(answer.begin(), insert - 1), iset); - } - - for (auto action : iset->GetActions()) { - DeviationInfosets(answer, big_supp, p_player, child, action); - } -} - -Array DeviationInfosets(const BehaviorSupportProfile &big_supp, - const GamePlayer &p_player, const GameInfoset &p_infoset, - const GameAction &p_action) -{ - Array answer; - for (auto member : p_infoset->GetMembers()) { - DeviationInfosets(answer, big_supp, p_player, member, p_action); - } - return answer; -} - -PolynomialSystem ActionProbsSumToOneIneqs(const MixedBehaviorProfile &p_solution, - std::shared_ptr BehavStratSpace, - const BehaviorSupportProfile &big_supp, - const std::map &var_index) -{ - PolynomialSystem answer(BehavStratSpace); - - for (auto player : p_solution.GetGame()->GetPlayers()) { - for (auto infoset : player->GetInfosets()) { - if (!big_supp.HasAction(infoset)) { - const int index_base = var_index.at(infoset); - Polynomial factor(BehavStratSpace, 1.0); - for (size_t k = 1; k < infoset->GetActions().size(); k++) { - factor -= Polynomial(BehavStratSpace, index_base + k, 1); - } - answer.push_back(factor); - } - } - } - return answer; -} - -std::list DeviationSupports(const BehaviorSupportProfile &big_supp, - const Array &isetlist) -{ - std::list answer; - - Array active_act_no(isetlist.size()); - std::fill(active_act_no.begin(), active_act_no.end(), 0); - - BehaviorSupportProfile new_supp(big_supp); - - for (size_t i = 1; i <= isetlist.size(); i++) { - for (size_t j = 1; j < isetlist[i]->GetActions().size(); j++) { - new_supp.RemoveAction(isetlist[i]->GetAction(j)); - } - new_supp.AddAction(isetlist[i]->GetAction(1)); - - active_act_no[i] = 1; - for (size_t k = 1; k < i; k++) { - if (isetlist[k]->Precedes(isetlist[i]->GetMember(1))) { - if (isetlist[k]->GetAction(1)->Precedes(isetlist[i]->GetMember(1))) { - new_supp.RemoveAction(isetlist[i]->GetAction(1)); - active_act_no[i] = 0; - } - } - } - } - answer.push_back(new_supp); - - size_t iset_cursor = isetlist.size(); - while (iset_cursor > 0) { - if (active_act_no[iset_cursor] == 0 || - active_act_no[iset_cursor] == - static_cast(isetlist[iset_cursor]->GetActions().size())) { - iset_cursor--; - } - else { - new_supp.RemoveAction(isetlist[iset_cursor]->GetAction(active_act_no[iset_cursor])); - active_act_no[iset_cursor]++; - new_supp.AddAction(isetlist[iset_cursor]->GetAction(active_act_no[iset_cursor])); - for (size_t k = iset_cursor + 1; k <= isetlist.size(); k++) { - if (active_act_no[k] > 0) { - new_supp.RemoveAction(isetlist[k]->GetAction(1)); - } - size_t h = 1; - bool active = true; - while (active && h < k) { - if (isetlist[h]->Precedes(isetlist[k]->GetMember(1))) { - if (active_act_no[h] == 0 || - !isetlist[h]->GetAction(active_act_no[h])->Precedes(isetlist[k]->GetMember(1))) { - active = false; - if (active_act_no[k] > 0) { - new_supp.RemoveAction(isetlist[k]->GetAction(active_act_no[k])); - active_act_no[k] = 0; - } - } - } - h++; - } - if (active) { - new_supp.AddAction(isetlist[k]->GetAction(1)); - active_act_no[k] = 1; - } - } - answer.push_back(new_supp); - } - } - return answer; -} - -bool NashNodeProbabilityPoly(const MixedBehaviorProfile &p_solution, - Polynomial &node_prob, - std::shared_ptr BehavStratSpace, - const BehaviorSupportProfile &dsupp, - const std::map &var_index, GameNode tempnode, - const GameInfoset &iset, const GameAction &act) -{ - while (tempnode != p_solution.GetGame()->GetRoot()) { - const GameAction last_action = tempnode->GetPriorAction(); - const GameInfoset last_infoset = last_action->GetInfoset(); - - if (last_infoset->IsChanceInfoset()) { - node_prob *= static_cast(last_infoset->GetActionProb(last_action)); - } - else if (dsupp.HasAction(last_infoset)) { - if (last_infoset == iset) { - if (act != last_action) { - return false; - } - } - else if (dsupp.Contains(last_action)) { - if (last_action->GetInfoset()->GetPlayer() != act->GetInfoset()->GetPlayer() || - !act->Precedes(tempnode)) { - node_prob *= p_solution.GetActionProb(last_action); - } - } - else { - return false; - } - } - else { - const int initial_var_no = var_index.at(last_infoset); - if (last_action != last_infoset->GetActions().back()) { - const int varno = initial_var_no + last_action->GetNumber(); - node_prob *= Polynomial(BehavStratSpace, varno, 1); - } - else { - Polynomial factor(BehavStratSpace, 1.0); - for (size_t k = 1; k < last_infoset->GetActions().size(); k++) { - factor -= Polynomial(BehavStratSpace, initial_var_no + k, 1); - } - node_prob *= factor; - } - } - tempnode = tempnode->GetParent(); - } - return true; -} - -PolynomialSystem NashExpectedPayoffDiffPolys( - const MixedBehaviorProfile &p_solution, std::shared_ptr BehavStratSpace, - const BehaviorSupportProfile &little_supp, const BehaviorSupportProfile &big_supp, - const std::map &var_index) -{ - PolynomialSystem answer(BehavStratSpace); - - auto terminal_nodes = TerminalNodes(p_solution.GetGame()); - - for (auto player : p_solution.GetGame()->GetPlayers()) { - for (auto infoset : player->GetInfosets()) { - if (!little_supp.IsReachable(infoset)) { - continue; - } - for (auto action : infoset->GetActions()) { - if (little_supp.Contains(action)) { - continue; - } - auto isetlist = DeviationInfosets(big_supp, player, infoset, action); - auto dsupps = DeviationSupports(big_supp, isetlist); - for (auto support : dsupps) { - // The utility difference between the - // payoff resulting from the profile and deviation to - // the strategy for pl specified by dsupp[k] - Polynomial next_poly(BehavStratSpace); - - for (auto node : terminal_nodes) { - Polynomial node_prob(BehavStratSpace, 1.0); - if (NashNodeProbabilityPoly(p_solution, node_prob, BehavStratSpace, support, var_index, - node, infoset, action)) { - if (node->GetOutcome()) { - node_prob *= node->GetOutcome()->GetPayoff(player); - } - next_poly += node_prob; - } - } - answer.push_back(-next_poly + p_solution.GetPayoff(player)); - } - } - } - } - return answer; -} - -PolynomialSystem ExtendsToNashIneqs(const MixedBehaviorProfile &p_solution, - std::shared_ptr BehavStratSpace, - const BehaviorSupportProfile &little_supp, - const BehaviorSupportProfile &big_supp, - const std::map &var_index) -{ - PolynomialSystem answer(BehavStratSpace); - answer.push_back(ActionProbsSumToOneIneqs(p_solution, BehavStratSpace, big_supp, var_index)); - answer.push_back( - NashExpectedPayoffDiffPolys(p_solution, BehavStratSpace, little_supp, big_supp, var_index)); - return answer; -} - -} // end anonymous namespace - -namespace Gambit::Nash { - -bool ExtendsToNash(const MixedBehaviorProfile &p_solution, - const BehaviorSupportProfile &little_supp, - const BehaviorSupportProfile &big_supp) -{ - // First we compute the number of variables, and indexing information - int num_vars = 0; - std::map var_index; - for (auto player : p_solution.GetGame()->GetPlayers()) { - for (auto infoset : player->GetInfosets()) { - var_index[infoset] = num_vars; - if (!big_supp.HasAction(infoset)) { - num_vars += infoset->GetActions().size() - 1; - } - } - } - - // We establish the space - auto BehavStratSpace = std::make_shared(num_vars); - - const PolynomialSystem inequalities = - ExtendsToNashIneqs(p_solution, BehavStratSpace, little_supp, big_supp, var_index); - // set up the rectangle of search - Vector bottoms(num_vars), tops(num_vars); - bottoms = 0; - tops = 1; - return PolynomialFeasibilitySolver(inequalities).HasSolution(Rectangle(bottoms, tops)); -} - -} // end namespace Gambit::Nash - -namespace { - -bool ANFNodeProbabilityPoly(const MixedBehaviorProfile &p_solution, - Polynomial &node_prob, - std::shared_ptr BehavStratSpace, - const BehaviorSupportProfile &big_supp, - const std::map &var_index, GameNode tempnode, int pl, - int i, int j) -{ - while (tempnode != p_solution.GetGame()->GetRoot()) { - const GameAction last_action = tempnode->GetPriorAction(); - const GameInfoset last_infoset = last_action->GetInfoset(); - - if (last_infoset->IsChanceInfoset()) { - node_prob *= static_cast(last_infoset->GetActionProb(last_action)); - } - else if (big_supp.HasAction(last_infoset)) { - if (last_infoset == p_solution.GetGame()->GetPlayer(pl)->GetInfoset(i)) { - if (j != last_action->GetNumber()) { - return false; - } - } - else if (big_supp.Contains(last_action)) { - node_prob *= p_solution.GetActionProb(last_action); - } - else { - return false; - } - } - else { - const int initial_var_no = var_index.at(last_infoset); - if (last_action != last_infoset->GetActions().back()) { - const int varno = initial_var_no + last_action->GetNumber(); - node_prob *= Polynomial(BehavStratSpace, varno, 1); - } - else { - Polynomial factor(BehavStratSpace, 1.0); - for (size_t k = 1; k < last_infoset->GetActions().size(); k++) { - factor -= Polynomial(BehavStratSpace, initial_var_no + k, 1); - } - node_prob *= factor; - } - } - tempnode = tempnode->GetParent(); - } - return true; -} - -PolynomialSystem ANFExpectedPayoffDiffPolys(const MixedBehaviorProfile &p_solution, - std::shared_ptr BehavStratSpace, - const BehaviorSupportProfile &little_supp, - const BehaviorSupportProfile &big_supp, - const std::map &var_index) -{ - PolynomialSystem answer(BehavStratSpace); - - auto terminal_nodes = TerminalNodes(p_solution.GetGame()); - - for (auto player : p_solution.GetGame()->GetPlayers()) { - for (auto infoset : player->GetInfosets()) { - if (!little_supp.IsReachable(infoset)) { - continue; - } - for (auto action : infoset->GetActions()) { - if (little_supp.Contains(action)) { - continue; - } - // This will be the utility difference between the - // payoff resulting from the profile and deviation to - // action j - Polynomial next_poly(BehavStratSpace); - for (auto terminal : terminal_nodes) { - Polynomial node_prob(BehavStratSpace, 1.0); - if (ANFNodeProbabilityPoly(p_solution, node_prob, BehavStratSpace, big_supp, var_index, - terminal, player->GetNumber(), infoset->GetNumber(), - action->GetNumber())) { - if (terminal->GetOutcome()) { - node_prob *= terminal->GetOutcome()->GetPayoff(player); - } - next_poly += node_prob; - } - } - answer.push_back(-next_poly + p_solution.GetPayoff(player)); - } - } - } - return answer; -} - -PolynomialSystem ExtendsToANFNashIneqs(const MixedBehaviorProfile &p_solution, - std::shared_ptr BehavStratSpace, - const BehaviorSupportProfile &little_supp, - const BehaviorSupportProfile &big_supp, - const std::map &var_index) -{ - PolynomialSystem answer(BehavStratSpace); - answer.push_back(ActionProbsSumToOneIneqs(p_solution, BehavStratSpace, big_supp, var_index)); - answer.push_back( - ANFExpectedPayoffDiffPolys(p_solution, BehavStratSpace, little_supp, big_supp, var_index)); - return answer; -} - -} // end anonymous namespace - -namespace Gambit::Nash { - -bool ExtendsToAgentNash(const MixedBehaviorProfile &p_solution, - const BehaviorSupportProfile &little_supp, - const BehaviorSupportProfile &big_supp) -{ - // First we compute the number of variables, and indexing information - int num_vars = 0; - std::map var_index; - for (auto player : p_solution.GetGame()->GetPlayers()) { - for (auto infoset : player->GetInfosets()) { - var_index[infoset] = num_vars; - if (!big_supp.HasAction(infoset)) { - num_vars += infoset->GetActions().size() - 1; - } - } - } - - // We establish the space - auto BehavStratSpace = std::make_shared(num_vars); - const PolynomialSystem inequalities = - ExtendsToANFNashIneqs(p_solution, BehavStratSpace, little_supp, big_supp, var_index); - - // set up the rectangle of search - Vector bottoms(num_vars), tops(num_vars); - bottoms = 0; - tops = 1; - - return PolynomialFeasibilitySolver(inequalities).HasSolution(Rectangle(bottoms, tops)); -} - -} // end namespace Gambit::Nash diff --git a/src/solvers/enumpoly/behavextend.h b/src/solvers/enumpoly/behavextend.h deleted file mode 100644 index 4ad33771d..000000000 --- a/src/solvers/enumpoly/behavextend.h +++ /dev/null @@ -1,51 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -// -// FILE: src/solvers/enumpoly/behavextend.h -// Algorithms for extending behavior profiles to Nash equilibria -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#ifndef BEHAVEXTEND_H -#define BEHAVEXTEND_H - -#include "gambit.h" - -namespace Gambit::Nash { - -// This asks whether there is a Nash extension of the MixedBehaviorProfile to -// all information sets at which the behavioral probabilities are not -// specified. The assumption is that the support has active actions -// at infosets at which the behavioral probabilities are defined, and -// no others. Also, the BehavSol is assumed to be already a Nash -// equilibrium for the truncated game obtained by eliminating stuff -// outside little_supp. -bool ExtendsToNash(const MixedBehaviorProfile &p_solution, - const BehaviorSupportProfile &p_littleSupport, - const BehaviorSupportProfile &p_bigSupport); - -// This asks whether there is an ANF Nash extension of the MixedBehaviorProfile -// to all information sets at which the behavioral probabilities are not specified. The -// assumption is that the support has active actions at infosets at which the behavioral -// probabilities are defined, and no others. -bool ExtendsToAgentNash(const MixedBehaviorProfile &p_solution, - const BehaviorSupportProfile &p_littleSupport, - const BehaviorSupportProfile &p_bigSupport); - -} // namespace Gambit::Nash - -#endif // BEHAVEXTEND_H diff --git a/src/solvers/enumpoly/polyfeasible.h b/src/solvers/enumpoly/polyfeasible.h deleted file mode 100644 index c77fe4f64..000000000 --- a/src/solvers/enumpoly/polyfeasible.h +++ /dev/null @@ -1,107 +0,0 @@ -// -// This file is part of Gambit -// Copyright (c) 1994-2026, The Gambit Project (http://www.gambit-project.org) -// -// FILE: src/solvers/enumpoly/polyfeasible.h -// Check feasibility of a system of polynomial inequalities -// -// This program is free software; you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation; either version 2 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -// - -#ifndef POLYFEASIBLE_H -#define POLYFEASIBLE_H - -#include "gambit.h" - -#include "rectangle.h" -#include "polysystem.h" -#include "polypartial.h" - -namespace Gambit { - -/// Determine whether a -/// system of weak inequalities has a solution (a point where all are satisfied) -/// in a given rectangle. Ir is modeled on PolynomialSystemSolver, but simpler. There is -/// no Newton search, only repeated subdivision, queries at the center, and -/// tests against whether one of the inequalities is provably everywhere -/// negative in the rectangle. -/// The constructor for this takes a list of polynomials, interpreted as -/// inequalities in the sense that, at a solution, all the polynomials -/// are required to be non-negative. -class PolynomialFeasibilitySolver { -private: - PolynomialSystem m_system; - PolynomialSystemDerivatives m_systemDerivs; - double m_epsilon{1.0e-6}; - - bool IsASolution(const Vector &v) const - { - return std::all_of(m_system.begin(), m_system.end(), - [&](const Polynomial &p) { return p.Evaluate(v) > -m_epsilon; }); - } - bool SystemHasNoSolutionIn(const Rectangle &r, Array &precedence) const - { - for (int i = 1; i <= m_system.size(); i++) { - if (m_systemDerivs[precedence[i]].PolyEverywhereNegativeIn(r)) { - if (i != 1) { // We have found a new "most likely to never be positive" - const int tmp = precedence[i]; - for (int j = 1; j <= i - 1; j++) { - precedence[i - j + 1] = precedence[i - j]; - } - precedence[1] = tmp; - } - return true; - } - } - return false; - } - - bool SolutionExists(const Rectangle &r, Array &precedence) const - { - if (IsASolution(r.Center())) { - return true; - } - if (SystemHasNoSolutionIn(r, precedence)) { - return false; - } - for (const auto &cell : r.Orthants()) { - if (SolutionExists(cell, precedence)) { - return true; - } - } - return false; - } - -public: - explicit PolynomialFeasibilitySolver(const PolynomialSystem &given) - : m_system(given), m_systemDerivs(given) - { - } - PolynomialFeasibilitySolver(const PolynomialFeasibilitySolver &) = delete; - ~PolynomialFeasibilitySolver() = default; - PolynomialFeasibilitySolver &operator=(const PolynomialFeasibilitySolver &) = delete; - - /// Does a solution exist in the specified rectangle? - bool HasSolution(const Rectangle &r) - { - Array precedence(m_system.size()); - std::iota(precedence.begin(), precedence.end(), 1); - return SolutionExists(r, precedence); - } -}; - -} // namespace Gambit - -#endif // POLYFEASIBLE_H From fd9c7693baa2e56465c0817791883101abc73b6d Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 30 Jan 2026 13:13:23 +0000 Subject: [PATCH 03/19] Test using (strategic) regret rather than agent regret - as `enumpoly` is advertised as a Nash-finding rather than agent-Nash-finding method. --- src/solvers/enumpoly/efgpoly.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/solvers/enumpoly/efgpoly.cc b/src/solvers/enumpoly/efgpoly.cc index c7891afcd..082bed341 100644 --- a/src/solvers/enumpoly/efgpoly.cc +++ b/src/solvers/enumpoly/efgpoly.cc @@ -235,7 +235,7 @@ EnumPolyBehaviorSolve(const Game &p_game, int p_stopAfter, double p_maxregret, bool isSingular = false; for (const auto &solution : SolveSupport( support, isSingular, std::max(p_stopAfter - static_cast(ret.size()), 0))) { - if (solution.GetAgentMaxRegret() < p_maxregret) { + if (solution.GetMaxRegret() < p_maxregret) { p_onEquilibrium(solution); ret.push_back(solution); } From 5be1c6b16655a1a7c74bf7108a41dbffa029c273 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 30 Jan 2026 13:15:38 +0000 Subject: [PATCH 04/19] Removed no-longer-implemented functions --- src/games/behavspt.h | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/games/behavspt.h b/src/games/behavspt.h index b75013d86..48ef52f6f 100644 --- a/src/games/behavspt.h +++ b/src/games/behavspt.h @@ -136,16 +136,6 @@ class BehaviorSupportProfile { std::list GetMembers(const GameInfoset &) const; //@} - /// @name Identification of dominated actions - //@{ - /// Returns true if action 'a' is dominated by action 'b' - bool Dominates(const GameAction &a, const GameAction &b, bool p_strict) const; - /// Returns true if the action is dominated by some other action - bool IsDominated(const GameAction &a, bool p_strict) const; - /// Returns a copy of the support with dominated actions eliminated - BehaviorSupportProfile Undominated(bool p_strict) const; - //@} - class Infosets { const BehaviorSupportProfile *m_support; From c829a6cae9bdc81a5472b08356358bfd52f130d7 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 30 Jan 2026 13:17:53 +0000 Subject: [PATCH 05/19] Remove unused function --- src/games/behavspt.cc | 11 ----------- src/games/behavspt.h | 2 -- 2 files changed, 13 deletions(-) diff --git a/src/games/behavspt.cc b/src/games/behavspt.cc index e75fdf2d5..36d3c548e 100644 --- a/src/games/behavspt.cc +++ b/src/games/behavspt.cc @@ -94,17 +94,6 @@ bool BehaviorSupportProfile::RemoveAction(const GameAction &p_action) return false; } -std::list BehaviorSupportProfile::GetInfosets(const GamePlayer &p_player) const -{ - std::list answer; - for (const auto &infoset : p_player->GetInfosets()) { - if (m_infosetReachable.at(infoset)) { - answer.push_back(infoset); - } - } - return answer; -} - bool BehaviorSupportProfile::HasReachableMembers(const GameInfoset &p_infoset) const { const auto &members = p_infoset->GetMembers(); diff --git a/src/games/behavspt.h b/src/games/behavspt.h index 48ef52f6f..5ff500a2d 100644 --- a/src/games/behavspt.h +++ b/src/games/behavspt.h @@ -130,8 +130,6 @@ class BehaviorSupportProfile { //@{ /// Can the information set be reached under this support? bool IsReachable(const GameInfoset &p_infoset) const { return m_infosetReachable.at(p_infoset); } - /// Get the information sets for the player reachable under the support - std::list GetInfosets(const GamePlayer &) const; /// Get the members of the information set reachable under the support std::list GetMembers(const GameInfoset &) const; //@} From 9fd759ee96d609be7bd3c66e379a792754dd7467 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 30 Jan 2026 15:45:23 +0000 Subject: [PATCH 06/19] Remove unneeded GetMembers --- src/games/behavspt.cc | 23 ++++++++--------------- src/games/behavspt.h | 2 -- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/games/behavspt.cc b/src/games/behavspt.cc index 36d3c548e..84e73363a 100644 --- a/src/games/behavspt.cc +++ b/src/games/behavspt.cc @@ -73,8 +73,10 @@ void BehaviorSupportProfile::AddAction(const GameAction &p_action) if (pos == support.end() || *pos != p_action) { // Action is not in the support at the infoset; add at this location to keep sorted by number support.insert(pos, p_action); - for (const auto &node : GetMembers(p_action->GetInfoset())) { - ActivateSubtree(node->GetChild(p_action)); + for (const auto &node : p_action->GetInfoset()->GetMembers()) { + if (m_nonterminalReachable[node]) { + ActivateSubtree(node->GetChild(p_action)); + } } } } @@ -86,8 +88,10 @@ bool BehaviorSupportProfile::RemoveAction(const GameAction &p_action) auto pos = std::find(support.begin(), support.end(), p_action); if (pos != support.end()) { support.erase(pos); - for (const auto &node : GetMembers(p_action->GetInfoset())) { - DeactivateSubtree(node->GetChild(p_action)); + for (const auto &node : p_action->GetInfoset()->GetMembers()) { + if (m_nonterminalReachable[node]) { + DeactivateSubtree(node->GetChild(p_action)); + } } return !support.empty(); } @@ -139,17 +143,6 @@ void BehaviorSupportProfile::DeactivateSubtree(const GameNode &n) } } -std::list BehaviorSupportProfile::GetMembers(const GameInfoset &p_infoset) const -{ - std::list answer; - for (const auto &member : p_infoset->GetMembers()) { - if (m_nonterminalReachable.at(member)) { - answer.push_back(member); - } - } - return answer; -} - //======================================================================== // BehaviorSupportProfile: Sequence form //======================================================================== diff --git a/src/games/behavspt.h b/src/games/behavspt.h index 5ff500a2d..2b4a6d08e 100644 --- a/src/games/behavspt.h +++ b/src/games/behavspt.h @@ -130,8 +130,6 @@ class BehaviorSupportProfile { //@{ /// Can the information set be reached under this support? bool IsReachable(const GameInfoset &p_infoset) const { return m_infosetReachable.at(p_infoset); } - /// Get the members of the information set reachable under the support - std::list GetMembers(const GameInfoset &) const; //@} class Infosets { From 968a399c2cf9d01630737970b59f066bab80f63e Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 4 Feb 2026 14:35:20 +0000 Subject: [PATCH 07/19] Proposes a change in behaviour to return profiles which have pure actions at "one-step" deviations from the candidate profiles, and the centroid for all information sets reachable only by two or more deviations. --- src/games/behavmixed.cc | 4 +- src/solvers/enumpoly/efgpoly.cc | 74 ++++++++++++++++++++++++++------- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/games/behavmixed.cc b/src/games/behavmixed.cc index 36b0df22c..1a787f9c9 100644 --- a/src/games/behavmixed.cc +++ b/src/games/behavmixed.cc @@ -234,12 +234,12 @@ template MixedBehaviorProfile MixedBehaviorProfile::Normalize() template MixedBehaviorProfile MixedBehaviorProfile::ToFullSupport() const { CheckVersion(); - MixedBehaviorProfile full(GetGame()); + MixedBehaviorProfile full(GetGame()); for (auto player : m_support.GetGame()->GetPlayers()) { for (auto infoset : player->GetInfosets()) { for (auto action : infoset->GetActions()) { - full[action] = (m_support.Contains(action)) ? (*this)[action] : T(0); + full[action] = (m_support.Contains(action)) ? (*this)[action] : T{0}; } } } diff --git a/src/solvers/enumpoly/efgpoly.cc b/src/solvers/enumpoly/efgpoly.cc index 082bed341..580c1edaf 100644 --- a/src/solvers/enumpoly/efgpoly.cc +++ b/src/solvers/enumpoly/efgpoly.cc @@ -151,28 +151,72 @@ std::map ToSequenceProbs(const ProblemData &p_data, const return x; } -std::list> -ExtendToFullSupport(const MixedBehaviorProfile &p_profile) +/// Compute the set of information sets which are not reachable given the actions in +/// @p p_support, but are reachable via a *single* deviation to an action at a +/// reachable information set. +std::set FindDeviationInfosets(const BehaviorSupportProfile &p_support) { - const Game &game = p_profile.GetGame(); - std::list extensionInfosets; - for (const auto &infoset : game->GetInfosets()) { - if (!p_profile.IsDefinedAt(infoset)) { - extensionInfosets.push_back(infoset); + struct SingleDeviationReachableVisitor { + const BehaviorSupportProfile &m_support; + std::set m_deviationReachable; + + explicit SingleDeviationReachableVisitor(const BehaviorSupportProfile &p_support) + : m_support(p_support) + { } - } + GameRep::DFSCallbackResult OnEnter(const GameNode &p_node, int) + { + const auto infoset = p_node->GetInfoset(); + if (!infoset) { + return GameRep::DFSCallbackResult::Continue; + } + if (p_node->GetPlayer()->IsChance()) { + return GameRep::DFSCallbackResult::Continue; + } + if (m_support.IsReachable(infoset)) { + return GameRep::DFSCallbackResult::Continue; + } + m_deviationReachable.insert(infoset); + return GameRep::DFSCallbackResult::Prune; + } + GameRep::DFSCallbackResult OnAction(const GameNode &, const GameNode &, int) + { + return GameRep::DFSCallbackResult::Continue; + } + GameRep::DFSCallbackResult OnExit(const GameNode &, int) + { + return GameRep::DFSCallbackResult::Continue; + } + void OnVisit(const GameNode &, int) {} + }; + + SingleDeviationReachableVisitor visitor(p_support); + const Game game = p_support.GetGame(); + GameRep::WalkDFS(game, game->GetRoot(), TraversalOrder::Preorder, visitor); + return visitor.m_deviationReachable; +} + +/// Produce the set of mixed behavior profiles which extend @param p_baseProfile +/// to complete profiles by specifying a pure action at each information set which +/// is reachable by a single deviation from the profile, and the centroid at all +/// information sets which are reachable only by two deviations. +std::list> +ExtendWithDeviations(const MixedBehaviorProfile &p_baseProfile) +{ + const auto deviationInfosets = FindDeviationInfosets(p_baseProfile.GetSupport()); std::list> result; - Array firstIndex(extensionInfosets.size()); + Array firstIndex(deviationInfosets.size()); std::fill(firstIndex.begin(), firstIndex.end(), 1); - Array lastIndex(extensionInfosets.size()); - std::transform(extensionInfosets.begin(), extensionInfosets.end(), lastIndex.begin(), + Array lastIndex(deviationInfosets.size()); + std::transform(deviationInfosets.begin(), deviationInfosets.end(), lastIndex.begin(), [](const auto &infoset) { return infoset->GetActions().size(); }); CartesianIndexProduct indices(firstIndex, lastIndex); for (const auto &index : indices) { - auto extension = p_profile; - for (auto [i, infoset] : enumerate(extensionInfosets)) { + auto extension = p_baseProfile.ToFullSupport(); + for (auto [i, infoset] : enumerate(deviationInfosets)) { extension[infoset->GetAction(index[i + 1])] = 1.0; } + extension.UndefinedToCentroid(); result.push_back(extension); } return result; @@ -207,8 +251,8 @@ std::list> SolveSupport(const BehaviorSupportProfil std::list> solutions; for (const auto &root : roots) { const MixedBehaviorProfile sol( - data.m_support.ToMixedBehaviorProfile(ToSequenceProbs(data, root)).ToFullSupport()); - solutions.splice(solutions.end(), ExtendToFullSupport(sol)); + data.m_support.ToMixedBehaviorProfile(ToSequenceProbs(data, root))); + solutions.splice(solutions.end(), ExtendWithDeviations(sol)); } return solutions; } From 3946c5fbc997c9749ce519f96d9d3cb223341e90 Mon Sep 17 00:00:00 2001 From: Rahul Savani Date: Wed, 11 Mar 2026 11:16:58 +0000 Subject: [PATCH 08/19] fix test_enumpoly_behavior_{7,8} given change in issue_756 --- tests/test_nash.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/test_nash.py b/tests/test_nash.py index 5e9d84e84..c6eff9276 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -2062,18 +2062,15 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s pytest.param( EquilibriumTestCase( factory=functools.partial(games.read_from_file, "3_player.efg"), - solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=2), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), expected=[ - [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(0, 1)]], - [[d(1, 0), d(1, 0)], [d(1, 0), d(0, 1)], [d(1, 0), d("1/3", "2/3")]], + [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(1, 0)]], + [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(0, 1)]], ], regret_tol=TOL, prob_tol=TOL, ), - marks=[ - pytest.mark.nash_enumpoly_behavior, - pytest.mark.xfail(reason="Changes in operation of enumpoly"), - ], + marks=pytest.mark.nash_enumpoly_behavior, id="test_enumpoly_behavior_7", ), pytest.param( @@ -2081,16 +2078,13 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s factory=functools.partial(games.read_from_file, "3_player_with_nonterm_outcomes.efg"), solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=2), expected=[ - [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(0, 1)]], - [[d(1, 0), d(1, 0)], [d(1, 0), d(0, 1)], [d(1, 0), d("1/3", "2/3")]], + [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(1, 0)]], + [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(0, 1)]], ], regret_tol=TOL, prob_tol=TOL, ), - marks=[ - pytest.mark.nash_enumpoly_behavior, - pytest.mark.xfail(reason="Changes in operation of enumpoly"), - ], + marks=pytest.mark.nash_enumpoly_behavior, id="test_enumpoly_behavior_8", ), # 4-player game @@ -2163,6 +2157,14 @@ def test_nash_behavior_solver(test_case: EquilibriumTestCase, subtests) -> None: with subtests.test("number of equilibria found"): assert len(result.equilibria) == len(test_case.expected) for i, (eq, exp) in enumerate(zip(result.equilibria, test_case.expected, strict=True)): + + # print("EQ:") + # print(eq) + # print("EXP:") + # print(exp) + # print("===================") + + with subtests.test(eq=i, check="max_regret"): assert eq.max_regret() <= test_case.regret_tol with subtests.test(eq=i, check="max_regret"): From 0c69a0840767ba860c75d8d999a7051b39804b85 Mon Sep 17 00:00:00 2001 From: Rahul Savani Date: Wed, 11 Mar 2026 11:18:03 +0000 Subject: [PATCH 09/19] stop_after from 2 to None in test_enumpoly_behavior_8 --- tests/test_nash.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/test_nash.py b/tests/test_nash.py index c6eff9276..0dc0b1728 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -2065,7 +2065,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), expected=[ [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(1, 0)]], - [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(0, 1)]], + [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(0, 1)]], ], regret_tol=TOL, prob_tol=TOL, @@ -2076,10 +2076,10 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s pytest.param( EquilibriumTestCase( factory=functools.partial(games.read_from_file, "3_player_with_nonterm_outcomes.efg"), - solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=2), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), expected=[ [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(1, 0)]], - [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(0, 1)]], + [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(0, 1)]], ], regret_tol=TOL, prob_tol=TOL, @@ -2157,14 +2157,11 @@ def test_nash_behavior_solver(test_case: EquilibriumTestCase, subtests) -> None: with subtests.test("number of equilibria found"): assert len(result.equilibria) == len(test_case.expected) for i, (eq, exp) in enumerate(zip(result.equilibria, test_case.expected, strict=True)): - # print("EQ:") # print(eq) # print("EXP:") # print(exp) # print("===================") - - with subtests.test(eq=i, check="max_regret"): assert eq.max_regret() <= test_case.regret_tol with subtests.test(eq=i, check="max_regret"): From f46a2edf98e36e375508bb7817b0ef0f0b9fabda Mon Sep 17 00:00:00 2001 From: Rahul Savani Date: Wed, 11 Mar 2026 11:48:48 +0000 Subject: [PATCH 10/19] removed test_nash_behavior_solver_no_subtests_only_profile and re-incorporated cases with ids test_enumpoly_behavior_1{a,b} --- tests/test_nash.py | 88 +++++++++++++++------------------------------- 1 file changed, 28 insertions(+), 60 deletions(-) diff --git a/tests/test_nash.py b/tests/test_nash.py index 0dc0b1728..f5bd9298c 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -1971,7 +1971,34 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_01", + id="test_enumpoly_behavior_0", + ), + # 2-player non-zero-sum games + pytest.param( + EquilibriumTestCase( + factory=games.create_one_shot_trust_efg, + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), + expected=[ + [[d(0, 1)], [d(0, 1)]], + ], + regret_tol=TOL, + prob_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_behavior, + id="test_enumpoly_behavior_1a", + ), + pytest.param( + EquilibriumTestCase( + factory=functools.partial(games.create_one_shot_trust_efg, unique_NE_variant=True), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), + expected=[ + [[d(1, 0)], [d(0, 1)]], + ], + regret_tol=TOL, + prob_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_behavior, + id="test_enumpoly_behavior_1b", ), pytest.param( EquilibriumTestCase( @@ -2173,65 +2200,6 @@ def test_nash_behavior_solver(test_case: EquilibriumTestCase, subtests) -> None: assert abs(eq[action] - expected[action]) <= test_case.prob_tol -################################################################################################## -# BEHAVIOR SOLVER WITHOUT SUBTESTS -- TEMP FOR ISSUE 660 -################################################################################################## - -ENUMPOLY_ISSUE_660_CASES = [ - # 2-player non-zero-sum games - pytest.param( - EquilibriumTestCase( - factory=games.create_one_shot_trust_efg, - solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), - expected=[ - [[d(0, 1)], [d("1/2", "1/2")]], - [[d(0, 1)], [d(0, 1)]], - # second entry assumes we extend to Nash using only pure behaviors - # currently we get [[0, 1]], [[0, 0]]] as a second eq - ], - regret_tol=TOL, - prob_tol=TOL, - ), - marks=[ - pytest.mark.nash_enumpoly_behavior, - pytest.mark.xfail(reason="Problem with enumpoly, as per issue #660"), - ], - id="enumpoly_one_shot_trust_issue_660", - ), - pytest.param( - EquilibriumTestCase( - factory=functools.partial(games.create_one_shot_trust_efg, unique_NE_variant=True), - solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), - expected=[ - [[[d(1, 0)], [d(0, 1)]]], - # currently we get [d(0, 1)], [d(0, 0)]] as a second eq - ], - regret_tol=TOL, - prob_tol=TOL, - ), - marks=[ - pytest.mark.nash_enumpoly_behavior, - pytest.mark.xfail(reason="Problem with enumpoly, as per issue #660"), - ], - id="enumpoly_one_shot_trust_unique_NE_issue_660", - ), -] - - -@pytest.mark.nash -@pytest.mark.parametrize("test_case", ENUMPOLY_ISSUE_660_CASES, ids=lambda c: c.label) -def test_nash_behavior_solver_no_subtests_only_profile(test_case: EquilibriumTestCase) -> None: - """TEMP: to be included with test_nash_behavior_solver when 660 is resolved.""" - game = test_case.factory() - result = test_case.solver(game) - assert len(result.equilibria) == len(test_case.expected) - for eq, exp in zip(result.equilibria, test_case.expected, strict=True): - expected = game.mixed_behavior_profile(rational=True, data=exp) - for player in game.players: - for action in player.actions: - assert abs(eq[action] - expected[action]) <= test_case.prob_tol - - ################################################################################################## # BEHVAIOR SOLVER -- UNORDERED ################################################################################################## From 03f487cca2cfa6e0e099cec94f2dbdbfa3795d13 Mon Sep 17 00:00:00 2001 From: Rahul Savani Date: Wed, 11 Mar 2026 14:49:36 +0000 Subject: [PATCH 11/19] test_enumpoly_behavior_{10,11,12} for 2 off equilibrium path --- tests/test_nash.py | 80 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/tests/test_nash.py b/tests/test_nash.py index f5bd9298c..5ebe9c4c4 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -1,5 +1,4 @@ -"""Test of calls to Nash equilibrium and QRE solvers. -""" +"""Test of calls to Nash equilibrium and QRE solvers.""" import dataclasses import functools @@ -2128,6 +2127,79 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s marks=pytest.mark.nash_enumpoly_behavior, id="test_enumpoly_behavior_9", ), + # 3-player perfect info game to test behavior two off equilibrium path + pytest.param( + EquilibriumTestCase( + factory=functools.partial( + games.read_from_file, "3_player_perfect_info_1_move_each.efg" + ), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), + expected=[ + # candidate,10,10,10000 + # NE,1,0,1,0,1,0,0,0,0 + [[d(1, 0)], [d(1, 0)], [d(1, 0, 0, 0, 0)]], + # candidate,01,00,00000 + # NE,0,1,1,0,0.2,0.2,0.2,0.2,0.2 + # NE,0,1,0,1,0.2,0.2,0.2,0.2,0.2 + [[d(0, 1)], [d(1, 0)], [d("1/5", "1/5", "1/5", "1/5", "1/5")]], # only 1 off path + [[d(0, 1)], [d(0, 1)], [d("1/5", "1/5", "1/5", "1/5", "1/5")]], + ], + regret_tol=TOL, + prob_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_behavior, + id="test_enumpoly_behavior_10", + ), + pytest.param( + EquilibriumTestCase( + factory=functools.partial( + games.read_from_file, "3_player_perfect_info_1_move_each_extra_move_for_p2.efg" + ), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), + expected=[ + [[d(1, 0)], [d(1, 0), d(1, 0, 0, 0)], [d(1, 0, 0, 0, 0)]], + [ + [d(0, 1)], + [d(1, 0), d("1/4", "1/4", "1/4", "1/4")], + [d("1/5", "1/5", "1/5", "1/5", "1/5")], + ], + [ + [d(0, 1)], + [d(0, 1), d("1/4", "1/4", "1/4", "1/4")], + [d("1/5", "1/5", "1/5", "1/5", "1/5")], + ], + ], + regret_tol=TOL, + prob_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_behavior, + id="test_enumpoly_behavior_11", + ), + pytest.param( + EquilibriumTestCase( + factory=functools.partial( + games.read_from_file, + "3_player_perfect_info_1_move_each_extra_move_for_p2_strict_dom_for_p1.efg", + ), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), + expected=[ + [ + [d(0, 1)], + [d(1, 0), d("1/4", "1/4", "1/4", "1/4")], + [d("1/5", "1/5", "1/5", "1/5", "1/5")], + ], + [ + [d(0, 1)], + [d(0, 1), d("1/4", "1/4", "1/4", "1/4")], + [d("1/5", "1/5", "1/5", "1/5", "1/5")], + ], + ], + regret_tol=TOL, + prob_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_behavior, + id="test_enumpoly_behavior_12", + ), ] # ############################################################################## # 3-player game @@ -2423,9 +2495,7 @@ def test_nash_agent_solver(test_case: EquilibriumTestCase, subtests) -> None: factory=functools.partial(games.read_from_file, "stripped_down_poker.efg"), solver=gbt.nash.liap_agent_solve, start_data=dict(data=None, rational=False), - expected=[ - [[d(1, 0), d("1/3", "2/3")], [d("2/3", "1/3")]] - ], + expected=[[[d(1, 0), d("1/3", "2/3")], [d("2/3", "1/3")]]], regret_tol=TOL_LARGE, prob_tol=TOL_LARGE, ), From 6242bf825bc9fda7f086eb7bc49e040201e542a7 Mon Sep 17 00:00:00 2001 From: Rahul Savani Date: Wed, 11 Mar 2026 14:56:19 +0000 Subject: [PATCH 12/19] missing efg files --- .../3_player_perfect_info_1_move_each.efg | 13 +++ ...ect_info_1_move_each_extra_move_for_p2.efg | 17 ++++ ...ch_extra_move_for_p2_strict_dom_for_p1.efg | 17 ++++ tests/test_nash.py | 88 +++++++++---------- 4 files changed, 91 insertions(+), 44 deletions(-) create mode 100644 tests/test_games/3_player_perfect_info_1_move_each.efg create mode 100644 tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2.efg create mode 100644 tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2_strict_dom_for_p1.efg diff --git a/tests/test_games/3_player_perfect_info_1_move_each.efg b/tests/test_games/3_player_perfect_info_1_move_each.efg new file mode 100644 index 000000000..9fff2ea07 --- /dev/null +++ b/tests/test_games/3_player_perfect_info_1_move_each.efg @@ -0,0 +1,13 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" "Player 3" } +"" + +p "" 1 1 "" { "1" "2" } 0 +p "" 2 1 "" { "1" "2" } 0 +p "" 3 1 "" { "1" "2" "3" "4" "5" } 0 +t "" 11 "" { 1, 1, 1 } +t "" 5 "" { 0, 0, 0 } +t "" 6 "" { 0, 0, 0 } +t "" 7 "" { 0, 0, 0 } +t "" 8 "" { 0, 0, 0 } +t "" 9 "" { 0, 1, 0 } +t "" 10 "" { 1, 0, 0 } diff --git a/tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2.efg b/tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2.efg new file mode 100644 index 000000000..f3daeb0c8 --- /dev/null +++ b/tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2.efg @@ -0,0 +1,17 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" "Player 3" } +"" + +p "" 1 1 "" { "1" "2" } 0 +p "" 2 1 "" { "1" "2" } 0 +p "" 3 1 "" { "1" "2" "3" "4" "5" } 0 +p "" 2 2 "" { "1" "2" "3" "4" } 0 +t "" 1 "" { 1, 1, 1 } +t "" 2 "" { 0, 0, 1 } +t "" 3 "" { 0, 0, 1 } +t "" 4 "" { 0, 0, 1 } +t "" 5 "" { 0, 0, 0 } +t "" 6 "" { 0, 0, 0 } +t "" 7 "" { 0, 0, 0 } +t "" 8 "" { 0, 0, 0 } +t "" 9 "" { 0, 1, 0 } +t "" 10 "" { 1, 0, 0 } diff --git a/tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2_strict_dom_for_p1.efg b/tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2_strict_dom_for_p1.efg new file mode 100644 index 000000000..9efb8c870 --- /dev/null +++ b/tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2_strict_dom_for_p1.efg @@ -0,0 +1,17 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" "Player 3" } +"" + +p "" 1 1 "" { "1" "2" } 0 +p "" 2 1 "" { "1" "2" } 0 +p "" 3 1 "" { "1" "2" "3" "4" "5" } 0 +p "" 2 2 "" { "1" "2" "3" "4" } 0 +t "" 1 "" { 0, 1, 1 } +t "" 2 "" { 0, 0, 1 } +t "" 3 "" { 0, 0, 1 } +t "" 4 "" { 0, 0, 1 } +t "" 5 "" { 0, 0, 0 } +t "" 6 "" { 0, 0, 0 } +t "" 7 "" { 0, 0, 0 } +t "" 8 "" { 0, 0, 0 } +t "" 9 "" { 0, 1, 0 } +t "" 10 "" { 1, 0, 0 } diff --git a/tests/test_nash.py b/tests/test_nash.py index 5ebe9c4c4..777b3d49e 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -1326,28 +1326,28 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s marks=pytest.mark.nash_lp_behavior, id="test_lp_behavior_double_13", ), - pytest.param( - EquilibriumTestCase( - factory=functools.partial(games.read_from_file, "large_payoff_game.efg"), - solver=functools.partial(gbt.nash.lp_solve, rational=False), - expected=[ - [ - [d(1, 0), d(1, 0)], - [ - d(0, 1), - d("9999999999999999999/10000000000000000000", "1/10000000000000000000"), - ], - ] - ], - regret_tol=TOL, - prob_tol=TOL, - ), - marks=[ - pytest.mark.nash_lp_behavior, - pytest.mark.xfail(reason="Problem with large payoffs when working in floats"), - ], - id="test_lp_behavior_double_14", - ), + # pytest.param( + # EquilibriumTestCase( + # factory=functools.partial(games.read_from_file, "large_payoff_game.efg"), + # solver=functools.partial(gbt.nash.lp_solve, rational=False), + # expected=[ + # [ + # [d(1, 0), d(1, 0)], + # [ + # d(0, 1), + # d("9999999999999999999/10000000000000000000", "1/10000000000000000000"), + # ], + # ] + # ], + # regret_tol=TOL, + # prob_tol=TOL, + # ), + # marks=[ + # pytest.mark.nash_lp_behavior, + # pytest.mark.xfail(reason="Problem with large payoffs when working in floats"), + # ], + # id="test_lp_behavior_double_14", + # ), pytest.param( EquilibriumTestCase( factory=functools.partial(games.read_from_file, "chance_in_middle.efg"), @@ -1801,28 +1801,28 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s marks=pytest.mark.nash_lcp_behavior, id="test_lcp_behavior_double_11", ), - pytest.param( - EquilibriumTestCase( - factory=functools.partial(games.read_from_file, "large_payoff_game.efg"), - solver=functools.partial(gbt.nash.lcp_solve, rational=False), - expected=[ - [ - [d(1, 0), d(1, 0)], - [ - d(0, 1), - d("9999999999999999999/10000000000000000000", "1/10000000000000000000"), - ], - ] - ], - regret_tol=TOL, - prob_tol=TOL, - ), - marks=[ - pytest.mark.nash_lcp_behavior, - pytest.mark.xfail(reason="Problem with large payoffs when working in floats"), - ], - id="test_lcp_behavior_double_12", - ), + # pytest.param( + # EquilibriumTestCase( + # factory=functools.partial(games.read_from_file, "large_payoff_game.efg"), + # solver=functools.partial(gbt.nash.lcp_solve, rational=False), + # expected=[ + # [ + # [d(1, 0), d(1, 0)], + # [ + # d(0, 1), + # d("9999999999999999999/10000000000000000000", "1/10000000000000000000"), + # ], + # ] + # ], + # regret_tol=TOL, + # prob_tol=TOL, + # ), + # marks=[ + # pytest.mark.nash_lcp_behavior, + # pytest.mark.xfail(reason="Problem with large payoffs when working in floats"), + # ], + # id="test_lcp_behavior_double_12", + # ), pytest.param( EquilibriumTestCase( factory=functools.partial(games.read_from_file, "chance_in_middle.efg"), From 299731b83eaef186974dbb01c94f5ab7f0d81eb8 Mon Sep 17 00:00:00 2001 From: Rahul Savani Date: Thu, 12 Mar 2026 13:06:07 +0000 Subject: [PATCH 13/19] added 2 player game to test to centroid for 2-deviation infosets --- ...ect_info_1_move_each_extra_move_for_p2.efg | 17 ----- ...ch_extra_move_for_p2_strict_dom_for_p1.efg | 17 ----- tests/test_nash.py | 75 +++++++++++++++---- 3 files changed, 59 insertions(+), 50 deletions(-) delete mode 100644 tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2.efg delete mode 100644 tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2_strict_dom_for_p1.efg diff --git a/tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2.efg b/tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2.efg deleted file mode 100644 index f3daeb0c8..000000000 --- a/tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2.efg +++ /dev/null @@ -1,17 +0,0 @@ -EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" "Player 3" } -"" - -p "" 1 1 "" { "1" "2" } 0 -p "" 2 1 "" { "1" "2" } 0 -p "" 3 1 "" { "1" "2" "3" "4" "5" } 0 -p "" 2 2 "" { "1" "2" "3" "4" } 0 -t "" 1 "" { 1, 1, 1 } -t "" 2 "" { 0, 0, 1 } -t "" 3 "" { 0, 0, 1 } -t "" 4 "" { 0, 0, 1 } -t "" 5 "" { 0, 0, 0 } -t "" 6 "" { 0, 0, 0 } -t "" 7 "" { 0, 0, 0 } -t "" 8 "" { 0, 0, 0 } -t "" 9 "" { 0, 1, 0 } -t "" 10 "" { 1, 0, 0 } diff --git a/tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2_strict_dom_for_p1.efg b/tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2_strict_dom_for_p1.efg deleted file mode 100644 index 9efb8c870..000000000 --- a/tests/test_games/3_player_perfect_info_1_move_each_extra_move_for_p2_strict_dom_for_p1.efg +++ /dev/null @@ -1,17 +0,0 @@ -EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" "Player 3" } -"" - -p "" 1 1 "" { "1" "2" } 0 -p "" 2 1 "" { "1" "2" } 0 -p "" 3 1 "" { "1" "2" "3" "4" "5" } 0 -p "" 2 2 "" { "1" "2" "3" "4" } 0 -t "" 1 "" { 0, 1, 1 } -t "" 2 "" { 0, 0, 1 } -t "" 3 "" { 0, 0, 1 } -t "" 4 "" { 0, 0, 1 } -t "" 5 "" { 0, 0, 0 } -t "" 6 "" { 0, 0, 0 } -t "" 7 "" { 0, 0, 0 } -t "" 8 "" { 0, 0, 0 } -t "" 9 "" { 0, 1, 0 } -t "" 10 "" { 1, 0, 0 } diff --git a/tests/test_nash.py b/tests/test_nash.py index 777b3d49e..1d4eab2a8 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -1136,6 +1136,22 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s marks=pytest.mark.nash_lp_behavior, id="test_lp_behavior_rational_16", ), + pytest.param( + EquilibriumTestCase( + factory=functools.partial( + games.read_from_file, + "2_player_PI_2_dev_off_eq_path_const_sum.efg", + ), + solver=gbt.nash.lp_solve, + expected=[ + [[d(0, 1), d("1/5", "1/5", "1/5", "1/5", "1/5")], [d(0, 1)]], + ], + regret_tol=TOL, + prob_tol=TOL, + ), + marks=pytest.mark.nash_lp_behavior, + id="test_lp_behavior_rational_17", + ), ] @@ -1565,6 +1581,22 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s marks=pytest.mark.nash_lcp_behavior, id="test_lcp_behavior_rational_14", ), + pytest.param( + EquilibriumTestCase( + factory=functools.partial( + games.read_from_file, + "2_player_PI_2_dev_off_eq_path_const_sum.efg", + ), + solver=gbt.nash.lcp_solve, + expected=[ + [[d(0, 1), d("1/5", "1/5", "1/5", "1/5", "1/5")], [d(0, 1)]], + ], + regret_tol=TOL, + prob_tol=TOL, + ), + marks=pytest.mark.nash_lcp_behavior, + id="test_enumpoly_behavior_23", + ), # Non-zero-sum games pytest.param( EquilibriumTestCase( @@ -2131,18 +2163,17 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s pytest.param( EquilibriumTestCase( factory=functools.partial( - games.read_from_file, "3_player_perfect_info_1_move_each.efg" + games.read_from_file, "3_player_PI_2_dev_off_eq_path.efg" ), solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), expected=[ - # candidate,10,10,10000 - # NE,1,0,1,0,1,0,0,0,0 - [[d(1, 0)], [d(1, 0)], [d(1, 0, 0, 0, 0)]], - # candidate,01,00,00000 - # NE,0,1,1,0,0.2,0.2,0.2,0.2,0.2 - # NE,0,1,0,1,0.2,0.2,0.2,0.2,0.2 - [[d(0, 1)], [d(1, 0)], [d("1/5", "1/5", "1/5", "1/5", "1/5")]], # only 1 off path - [[d(0, 1)], [d(0, 1)], [d("1/5", "1/5", "1/5", "1/5", "1/5")]], + # candidate,10,10,1000,10000 + [[d(1, 0)], [d(1, 0), d(1, 0, 0, 0)], [d(1, 0, 0, 0, 0)]], + # candidate,01,00,0000,00000 + [[d(0, 1)], [d(1, 0), d("1/4", "1/4", "1/4", "1/4")], + [d("1/5", "1/5", "1/5", "1/5", "1/5")]], # only 1 off path + [[d(0, 1)], [d(0, 1), d("1/4", "1/4", "1/4", "1/4")], + [d("1/5", "1/5", "1/5", "1/5", "1/5")]], ], regret_tol=TOL, prob_tol=TOL, @@ -2153,7 +2184,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s pytest.param( EquilibriumTestCase( factory=functools.partial( - games.read_from_file, "3_player_perfect_info_1_move_each_extra_move_for_p2.efg" + games.read_from_file, "3_player_PI_2_dev_off_eq_path.efg" ), solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), expected=[ @@ -2179,7 +2210,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s EquilibriumTestCase( factory=functools.partial( games.read_from_file, - "3_player_perfect_info_1_move_each_extra_move_for_p2_strict_dom_for_p1.efg", + "3_player_PI_2_dev_off_eq_path_strict_dom_for_p1.efg", ), solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), expected=[ @@ -2200,6 +2231,23 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s marks=pytest.mark.nash_enumpoly_behavior, id="test_enumpoly_behavior_12", ), + pytest.param( + EquilibriumTestCase( + factory=functools.partial( + games.read_from_file, + "2_player_PI_2_dev_off_eq_path_const_sum.efg", + ), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), + expected=[ + [[d(0, 1), d("1/5", "1/5", "1/5", "1/5", "1/5")], [d(1, 0)]], + [[d(0, 1), d("1/5", "1/5", "1/5", "1/5", "1/5")], [d(0, 1)]], + ], + regret_tol=TOL, + prob_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_behavior, + id="test_enumpoly_behavior_13", + ), ] # ############################################################################## # 3-player game @@ -2256,11 +2304,6 @@ def test_nash_behavior_solver(test_case: EquilibriumTestCase, subtests) -> None: with subtests.test("number of equilibria found"): assert len(result.equilibria) == len(test_case.expected) for i, (eq, exp) in enumerate(zip(result.equilibria, test_case.expected, strict=True)): - # print("EQ:") - # print(eq) - # print("EXP:") - # print(exp) - # print("===================") with subtests.test(eq=i, check="max_regret"): assert eq.max_regret() <= test_case.regret_tol with subtests.test(eq=i, check="max_regret"): From 018f33d9c43026c6c0514d6db8613cdc5d3e20fc Mon Sep 17 00:00:00 2001 From: Rahul Savani Date: Thu, 12 Mar 2026 13:06:34 +0000 Subject: [PATCH 14/19] added 2 player game to test to centroid for 2-deviation infosets --- .../2_player_PI_2_dev_off_eq_path_const_sum.efg | 13 +++++++++++++ .../3_player_PI_2_dev_off_eq_path.efg | 17 +++++++++++++++++ ...r_PI_2_dev_off_eq_path_strict_dom_for_p1.efg | 17 +++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 tests/test_games/2_player_PI_2_dev_off_eq_path_const_sum.efg create mode 100644 tests/test_games/3_player_PI_2_dev_off_eq_path.efg create mode 100644 tests/test_games/3_player_PI_2_dev_off_eq_path_strict_dom_for_p1.efg diff --git a/tests/test_games/2_player_PI_2_dev_off_eq_path_const_sum.efg b/tests/test_games/2_player_PI_2_dev_off_eq_path_const_sum.efg new file mode 100644 index 000000000..d2daff2f3 --- /dev/null +++ b/tests/test_games/2_player_PI_2_dev_off_eq_path_const_sum.efg @@ -0,0 +1,13 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"" + +p "" 1 1 "" { "1" "2" } 0 +p "" 2 1 "" { "1" "2" } 0 +p "" 1 2 "" { "1" "2" "3" "4" "5" } 0 +t "" 1 "" { 1, 0 } +t "" 2 "" { 0, 1 } +t "" 3 "" { 0, 1 } +t "" 4 "" { 0, 1 } +t "" 5 "" { 0, 1 } +t "" 6 "" { 0, 1 } +t "" 7 "" { 1, 0 } diff --git a/tests/test_games/3_player_PI_2_dev_off_eq_path.efg b/tests/test_games/3_player_PI_2_dev_off_eq_path.efg new file mode 100644 index 000000000..f3daeb0c8 --- /dev/null +++ b/tests/test_games/3_player_PI_2_dev_off_eq_path.efg @@ -0,0 +1,17 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" "Player 3" } +"" + +p "" 1 1 "" { "1" "2" } 0 +p "" 2 1 "" { "1" "2" } 0 +p "" 3 1 "" { "1" "2" "3" "4" "5" } 0 +p "" 2 2 "" { "1" "2" "3" "4" } 0 +t "" 1 "" { 1, 1, 1 } +t "" 2 "" { 0, 0, 1 } +t "" 3 "" { 0, 0, 1 } +t "" 4 "" { 0, 0, 1 } +t "" 5 "" { 0, 0, 0 } +t "" 6 "" { 0, 0, 0 } +t "" 7 "" { 0, 0, 0 } +t "" 8 "" { 0, 0, 0 } +t "" 9 "" { 0, 1, 0 } +t "" 10 "" { 1, 0, 0 } diff --git a/tests/test_games/3_player_PI_2_dev_off_eq_path_strict_dom_for_p1.efg b/tests/test_games/3_player_PI_2_dev_off_eq_path_strict_dom_for_p1.efg new file mode 100644 index 000000000..9efb8c870 --- /dev/null +++ b/tests/test_games/3_player_PI_2_dev_off_eq_path_strict_dom_for_p1.efg @@ -0,0 +1,17 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" "Player 3" } +"" + +p "" 1 1 "" { "1" "2" } 0 +p "" 2 1 "" { "1" "2" } 0 +p "" 3 1 "" { "1" "2" "3" "4" "5" } 0 +p "" 2 2 "" { "1" "2" "3" "4" } 0 +t "" 1 "" { 0, 1, 1 } +t "" 2 "" { 0, 0, 1 } +t "" 3 "" { 0, 0, 1 } +t "" 4 "" { 0, 0, 1 } +t "" 5 "" { 0, 0, 0 } +t "" 6 "" { 0, 0, 0 } +t "" 7 "" { 0, 0, 0 } +t "" 8 "" { 0, 0, 0 } +t "" 9 "" { 0, 1, 0 } +t "" 10 "" { 1, 0, 0 } From 7b56c7f3791784d200e765539332ab4a07022db2 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 18 Mar 2026 14:43:34 +0000 Subject: [PATCH 15/19] Return just a sample equilibrium for any set of realisation-equivalent equilibria. --- doc/tools.enumpoly.rst | 13 +++++ src/solvers/enumpoly/efgpoly.cc | 94 ++++++++++----------------------- tests/test_nash.py | 33 ++++-------- 3 files changed, 49 insertions(+), 91 deletions(-) diff --git a/doc/tools.enumpoly.rst b/doc/tools.enumpoly.rst index a04362e98..1edc7de44 100644 --- a/doc/tools.enumpoly.rst +++ b/doc/tools.enumpoly.rst @@ -24,6 +24,19 @@ supports which have the fewest strategies in total. For many classes of games, this will tend to lower the average time until finding one equilibrium, as well as finding the second equilibrium (if one exists). +For extensive games, a support of actions equates to allowing positive +probabilities over a subset of terminal nodes. The indifference conditions +used are those for the sequence form defined on the projection of the game +to that support of actions. A solution to these equations implies a probability +distribution over terminal nodes. The algorithm then searches for +a profile which is a Nash equilibrium which implements that probability +distribution. If there exists such a profile, a sample one is returned. +Note that for probability distributions which assign zero probability to some terminal +nodes, it is generally the case that there are (infinitely) many such profiles. +Subsequent analysis of unreached information sets can yield alternative +profiles which specify different choices at unreached information sets +while satisfying the Nash equilibrium conditions. + When the verbose switch `-v` is used, the program outputs each support as it is considered. The supports are presented as a comma-separated list of binary strings, where each entry represents one player. The diff --git a/src/solvers/enumpoly/efgpoly.cc b/src/solvers/enumpoly/efgpoly.cc index 3069df1d9..4956b372f 100644 --- a/src/solvers/enumpoly/efgpoly.cc +++ b/src/solvers/enumpoly/efgpoly.cc @@ -151,79 +151,37 @@ std::map ToSequenceProbs(const ProblemData &p_data, const return x; } -/// Compute the set of information sets which are not reachable given the actions in -/// @p p_support, but are reachable via a *single* deviation to an action at a -/// reachable information set. -std::set FindDeviationInfosets(const BehaviorSupportProfile &p_support) +std::optional> +FindNashExtension(const MixedBehaviorProfile &p_baseProfile, double p_maxRegret) { - struct SingleDeviationReachableVisitor { - const BehaviorSupportProfile &m_support; - std::set m_deviationReachable; - - explicit SingleDeviationReachableVisitor(const BehaviorSupportProfile &p_support) - : m_support(p_support) - { - } - GameRep::DFSCallbackResult OnEnter(const GameNode &p_node, int) - { - const auto infoset = p_node->GetInfoset(); - if (!infoset) { - return GameRep::DFSCallbackResult::Continue; - } - if (p_node->GetPlayer()->IsChance()) { - return GameRep::DFSCallbackResult::Continue; - } - if (m_support.IsReachable(infoset)) { - return GameRep::DFSCallbackResult::Continue; - } - m_deviationReachable.insert(infoset); - return GameRep::DFSCallbackResult::Prune; - } - GameRep::DFSCallbackResult OnAction(const GameNode &, const GameNode &, int) - { - return GameRep::DFSCallbackResult::Continue; + const Game &game = p_baseProfile.GetGame(); + std::list extensionInfosets; + for (const auto &infoset : game->GetInfosets()) { + if (!p_baseProfile.IsDefinedAt(infoset)) { + extensionInfosets.push_back(infoset); } - GameRep::DFSCallbackResult OnExit(const GameNode &, int) - { - return GameRep::DFSCallbackResult::Continue; - } - void OnVisit(const GameNode &, int) {} - }; - - SingleDeviationReachableVisitor visitor(p_support); - const Game game = p_support.GetGame(); - GameRep::WalkDFS(game, game->GetRoot(), TraversalOrder::Preorder, visitor); - return visitor.m_deviationReachable; -} - -/// Produce the set of mixed behavior profiles which extend @param p_baseProfile -/// to complete profiles by specifying a pure action at each information set which -/// is reachable by a single deviation from the profile, and the centroid at all -/// information sets which are reachable only by two deviations. -std::list> -ExtendWithDeviations(const MixedBehaviorProfile &p_baseProfile) -{ - const auto deviationInfosets = FindDeviationInfosets(p_baseProfile.GetSupport()); - std::list> result; - Array firstIndex(deviationInfosets.size()); + } + Array firstIndex(extensionInfosets.size()); std::fill(firstIndex.begin(), firstIndex.end(), 1); - Array lastIndex(deviationInfosets.size()); - std::transform(deviationInfosets.begin(), deviationInfosets.end(), lastIndex.begin(), + Array lastIndex(extensionInfosets.size()); + std::transform(extensionInfosets.begin(), extensionInfosets.end(), lastIndex.begin(), [](const auto &infoset) { return infoset->GetActions().size(); }); CartesianIndexProduct indices(firstIndex, lastIndex); for (const auto &index : indices) { auto extension = p_baseProfile.ToFullSupport(); - for (auto [i, infoset] : enumerate(deviationInfosets)) { + for (auto [i, infoset] : enumerate(extensionInfosets)) { extension[infoset->GetAction(index[i + 1])] = 1.0; } - extension.UndefinedToCentroid(); - result.push_back(extension); + if (extension.GetMaxRegret() < p_maxRegret) { + return extension; + } } - return result; + return std::nullopt; } std::list> SolveSupport(const BehaviorSupportProfile &p_support, - bool &p_isSingular, int p_stopAfter) + bool &p_isSingular, int p_stopAfter, + double p_maxRegret) { ProblemData data(p_support); PolynomialSystem equations(data.space); @@ -252,7 +210,10 @@ std::list> SolveSupport(const BehaviorSupportProfil for (const auto &root : roots) { const MixedBehaviorProfile sol( data.m_support.ToMixedBehaviorProfile(ToSequenceProbs(data, root))); - solutions.splice(solutions.end(), ExtendWithDeviations(sol)); + auto extended = FindNashExtension(sol, p_maxRegret); + if (extended.has_value()) { + solutions.push_back(extended.value()); + } } return solutions; } @@ -277,12 +238,11 @@ EnumPolyBehaviorSolve(const Game &p_game, int p_stopAfter, double p_maxregret, for (auto support : possible_supports->m_supports) { p_onSupport("candidate", support); bool isSingular = false; - for (const auto &solution : SolveSupport( - support, isSingular, std::max(p_stopAfter - static_cast(ret.size()), 0))) { - if (solution.GetMaxRegret() < p_maxregret) { - p_onEquilibrium(solution); - ret.push_back(solution); - } + for (const auto &solution : + SolveSupport(support, isSingular, std::max(p_stopAfter - static_cast(ret.size()), 0), + p_maxregret)) { + p_onEquilibrium(solution); + ret.push_back(solution); } if (isSingular) { p_onSupport("singular", support); diff --git a/tests/test_nash.py b/tests/test_nash.py index 1d4eab2a8..ed7aa060d 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -2122,8 +2122,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s factory=functools.partial(games.read_from_file, "3_player.efg"), solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), expected=[ - [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(1, 0)]], - [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(0, 1)]], + [[d(1, 0), d(1, 0)], [d(1, 0), d(1, 0)], [d(1, 0), d(1, 0)]], ], regret_tol=TOL, prob_tol=TOL, @@ -2136,8 +2135,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s factory=functools.partial(games.read_from_file, "3_player_with_nonterm_outcomes.efg"), solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), expected=[ - [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(1, 0)]], - [[d(1, 0), d(1, 0)], [d(1, 0), d("1/2", "1/2")], [d(1, 0), d(0, 1)]], + [[d(1, 0), d(1, 0)], [d(1, 0), d(1, 0)], [d(1, 0), d(1, 0)]], ], regret_tol=TOL, prob_tol=TOL, @@ -2170,10 +2168,8 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s # candidate,10,10,1000,10000 [[d(1, 0)], [d(1, 0), d(1, 0, 0, 0)], [d(1, 0, 0, 0, 0)]], # candidate,01,00,0000,00000 - [[d(0, 1)], [d(1, 0), d("1/4", "1/4", "1/4", "1/4")], - [d("1/5", "1/5", "1/5", "1/5", "1/5")]], # only 1 off path - [[d(0, 1)], [d(0, 1), d("1/4", "1/4", "1/4", "1/4")], - [d("1/5", "1/5", "1/5", "1/5", "1/5")]], + [[d(0, 1)], [d(1, 0), d(1, 0, 0, 0)], + [d(1, 0, 0, 0, 0)]], ], regret_tol=TOL, prob_tol=TOL, @@ -2191,13 +2187,8 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s [[d(1, 0)], [d(1, 0), d(1, 0, 0, 0)], [d(1, 0, 0, 0, 0)]], [ [d(0, 1)], - [d(1, 0), d("1/4", "1/4", "1/4", "1/4")], - [d("1/5", "1/5", "1/5", "1/5", "1/5")], - ], - [ - [d(0, 1)], - [d(0, 1), d("1/4", "1/4", "1/4", "1/4")], - [d("1/5", "1/5", "1/5", "1/5", "1/5")], + [d(1, 0), d(1, 0, 0, 0)], + [d(1, 0, 0, 0, 0)], ], ], regret_tol=TOL, @@ -2216,13 +2207,8 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s expected=[ [ [d(0, 1)], - [d(1, 0), d("1/4", "1/4", "1/4", "1/4")], - [d("1/5", "1/5", "1/5", "1/5", "1/5")], - ], - [ - [d(0, 1)], - [d(0, 1), d("1/4", "1/4", "1/4", "1/4")], - [d("1/5", "1/5", "1/5", "1/5", "1/5")], + [d(1, 0), d(1, 0, 0, 0)], + [d(1, 0, 0, 0, 0)], ], ], regret_tol=TOL, @@ -2239,8 +2225,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s ), solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), expected=[ - [[d(0, 1), d("1/5", "1/5", "1/5", "1/5", "1/5")], [d(1, 0)]], - [[d(0, 1), d("1/5", "1/5", "1/5", "1/5", "1/5")], [d(0, 1)]], + [[d(0, 1), d(1, 0, 0, 0, 0)], [d(1, 0)]], ], regret_tol=TOL, prob_tol=TOL, From 7340d337f63961f87e92effac64174a04493e313 Mon Sep 17 00:00:00 2001 From: Rahul Savani Date: Thu, 19 Mar 2026 18:49:01 +0000 Subject: [PATCH 16/19] Fix wording for Nash equilibrium description --- doc/tools.enumpoly.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/tools.enumpoly.rst b/doc/tools.enumpoly.rst index 1edc7de44..2614de5a2 100644 --- a/doc/tools.enumpoly.rst +++ b/doc/tools.enumpoly.rst @@ -29,7 +29,7 @@ probabilities over a subset of terminal nodes. The indifference conditions used are those for the sequence form defined on the projection of the game to that support of actions. A solution to these equations implies a probability distribution over terminal nodes. The algorithm then searches for -a profile which is a Nash equilibrium which implements that probability +a profile that is a Nash equilibrium that implements that probability distribution. If there exists such a profile, a sample one is returned. Note that for probability distributions which assign zero probability to some terminal nodes, it is generally the case that there are (infinitely) many such profiles. From 95a8fce7c274f5b3bfe79e2a5bc0b7030a864b1c Mon Sep 17 00:00:00 2001 From: Rahul Savani Date: Thu, 19 Mar 2026 18:49:45 +0000 Subject: [PATCH 17/19] Clarify condition for returning Nash equilibrium profile --- doc/tools.enumpoly.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/tools.enumpoly.rst b/doc/tools.enumpoly.rst index 2614de5a2..f223a5d43 100644 --- a/doc/tools.enumpoly.rst +++ b/doc/tools.enumpoly.rst @@ -30,7 +30,7 @@ used are those for the sequence form defined on the projection of the game to that support of actions. A solution to these equations implies a probability distribution over terminal nodes. The algorithm then searches for a profile that is a Nash equilibrium that implements that probability -distribution. If there exists such a profile, a sample one is returned. +distribution. If there exists at least one such profile, a sample one is returned. Note that for probability distributions which assign zero probability to some terminal nodes, it is generally the case that there are (infinitely) many such profiles. Subsequent analysis of unreached information sets can yield alternative From e29e1f91b77c1dcf24ed2b933960dea28484cdc5 Mon Sep 17 00:00:00 2001 From: Rahul Savani Date: Thu, 19 Mar 2026 18:51:15 +0000 Subject: [PATCH 18/19] Refine description of profiles in documentation Clarified wording regarding profiles and Nash equilibrium conditions. --- doc/tools.enumpoly.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/tools.enumpoly.rst b/doc/tools.enumpoly.rst index f223a5d43..2e328ec2f 100644 --- a/doc/tools.enumpoly.rst +++ b/doc/tools.enumpoly.rst @@ -34,8 +34,8 @@ distribution. If there exists at least one such profile, a sample one is return Note that for probability distributions which assign zero probability to some terminal nodes, it is generally the case that there are (infinitely) many such profiles. Subsequent analysis of unreached information sets can yield alternative -profiles which specify different choices at unreached information sets -while satisfying the Nash equilibrium conditions. +profiles that specify different choices at unreached information sets +while still satisfying the Nash equilibrium conditions. When the verbose switch `-v` is used, the program outputs each support as it is considered. The supports are presented as a comma-separated From 66946ee23d9352af9b5a2cf314cb5e47948cb739 Mon Sep 17 00:00:00 2001 From: rahulsavani Date: Thu, 19 Mar 2026 19:08:38 +0000 Subject: [PATCH 19/19] fix id and tolerance in test_lcp_behavior_rational_23 --- tests/test_nash.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_nash.py b/tests/test_nash.py index ed7aa060d..d0e664cd6 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -1591,11 +1591,9 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s expected=[ [[d(0, 1), d("1/5", "1/5", "1/5", "1/5", "1/5")], [d(0, 1)]], ], - regret_tol=TOL, - prob_tol=TOL, ), marks=pytest.mark.nash_lcp_behavior, - id="test_enumpoly_behavior_23", + id="test_lcp_behavior_rational_23", ), # Non-zero-sum games pytest.param(