From 13f4f5726aa41a8e21edd740e4634f2c57463c48 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Sat, 1 Aug 2026 18:39:39 -0400 Subject: [PATCH 1/4] Addresses two sources of inconsistency in the output of `enumpoly`: 1. In the polynomial system solver, checks whether perturbations attempting to move off of a singular point are successful, rather than failing silently. 2. Make the iterator over sequences iterate sequences in a deterministic order. --- src/games/behavspt.cc | 26 ++++---- src/games/behavspt.h | 12 +++- src/solvers/enumpoly/polysolver.cc | 11 +++ tests/test_nash.py | 104 +++++++---------------------- 4 files changed, 58 insertions(+), 95 deletions(-) diff --git a/src/games/behavspt.cc b/src/games/behavspt.cc index 5f9e390f17..48a93d5ca7 100644 --- a/src/games/behavspt.cc +++ b/src/games/behavspt.cc @@ -223,39 +223,39 @@ size_t BehaviorSupportProfile::Sequences::size() const BehaviorSupportProfile::Sequences::iterator BehaviorSupportProfile::Sequences::begin() const { - return {m_support->GetSequenceMap(), false}; + return {m_support->GetSequenceMap(), m_support->GetPlayers(), false}; } BehaviorSupportProfile::Sequences::iterator BehaviorSupportProfile::Sequences::end() const { - return {m_support->GetSequenceMap(), true}; + return {m_support->GetSequenceMap(), m_support->GetPlayers(), true}; } BehaviorSupportProfile::Sequences::iterator::iterator( - const std::shared_ptr p_sequences, bool p_end) - : m_sequences(p_sequences) + const std::shared_ptr p_sequences, const GameRep::Players &p_players, bool p_end) + : m_sequences(p_sequences), m_players(p_players) { if (p_end) { - m_currentPlayer = m_sequences->cend(); + m_currentPlayer = m_players.end(); } else { - m_currentPlayer = m_sequences->cbegin(); - m_currentSequence = m_currentPlayer->second.cbegin(); + m_currentPlayer = m_players.begin(); + m_currentSequence = m_sequences->at(*m_currentPlayer).cbegin(); } } BehaviorSupportProfile::Sequences::iterator & BehaviorSupportProfile::Sequences::iterator::operator++() { - if (m_currentPlayer == m_sequences->cend()) { + if (m_currentPlayer == m_players.end()) { return *this; } m_currentSequence++; - if (m_currentSequence != m_currentPlayer->second.cend()) { + if (m_currentSequence != m_sequences->at(*m_currentPlayer).cend()) { return *this; } - m_currentPlayer++; - if (m_currentPlayer != m_sequences->cend()) { - m_currentSequence = m_currentPlayer->second.cbegin(); + ++m_currentPlayer; + if (m_currentPlayer != m_players.end()) { + m_currentSequence = m_sequences->at(*m_currentPlayer).cbegin(); } return *this; } @@ -265,7 +265,7 @@ bool BehaviorSupportProfile::Sequences::iterator::operator==(const iterator &it) if (m_sequences != it.m_sequences || m_currentPlayer != it.m_currentPlayer) { return false; } - if (m_currentPlayer == m_sequences->end()) { + if (m_currentPlayer == m_players.end()) { return true; } return (m_currentSequence == it.m_currentSequence); diff --git a/src/games/behavspt.h b/src/games/behavspt.h index 87342f7d87..1ec2acbd6a 100644 --- a/src/games/behavspt.h +++ b/src/games/behavspt.h @@ -158,12 +158,20 @@ class BehaviorSupportProfile { public: class iterator { + // Sequences are visited grouped by player, in the game's canonical player order + // (GameRep::Players), rather than in SequenceMap's own key order. SequenceMap is + // keyed by GamePlayer, whose ordering is based on object addresses; iterating the + // map directly would therefore make the visitation order (and hence, downstream, + // any polynomial variable numbering derived from it) depend on heap layout, which + // can differ from run to run. const std::shared_ptr m_sequences; - SequenceMap::const_iterator m_currentPlayer; + GameRep::Players m_players; + GameRep::Players::iterator m_currentPlayer; std::vector::const_iterator m_currentSequence; public: - iterator(const std::shared_ptr p_sequences, bool p_end); + iterator(const std::shared_ptr p_sequences, const GameRep::Players &p_players, + bool p_end); GameSequence operator*() const { return *m_currentSequence; } GameSequence operator->() const { return *m_currentSequence; } diff --git a/src/solvers/enumpoly/polysolver.cc b/src/solvers/enumpoly/polysolver.cc index 9c51fff6c7..181c7cb3d3 100644 --- a/src/solvers/enumpoly/polysolver.cc +++ b/src/solvers/enumpoly/polysolver.cc @@ -72,6 +72,7 @@ bool PolynomialSystemSolver::NewtonRootInRectangle(const Rectangle &r, newpoint = NewtonStep(point); } catch (SingularMatrixException &) { + bool perturbed = false; for (int i = 1; i <= point.size(); i++) { Vector perturbed_point(point); if (r.Side(i).UpperBound() > point[i]) { @@ -82,11 +83,21 @@ bool PolynomialSystemSolver::NewtonRootInRectangle(const Rectangle &r, } try { newpoint = point + (NewtonStep(perturbed_point) - perturbed_point); + perturbed = true; break; } catch (SingularMatrixException &) { } } + if (!perturbed) { + // The Jacobian is singular at this point, and remains singular under every + // single-coordinate perturbation tried. This is a local degeneracy of the + // search at this point/rectangle, not evidence that the system of equations + // for the support as a whole is degenerate -- so we treat it the same as any + // other failure to confirm a root here, leaving it to the caller to subdivide + // the rectangle further. + return false; + } } if (!r.SameCenterDoubleLengths().Contains(newpoint)) { diff --git a/tests/test_nash.py b/tests/test_nash.py index 065f7fb286..ac10da0fd6 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -2443,18 +2443,31 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s marks=pytest.mark.nash_enumpoly_behavior, id="test_enumpoly_behavior_13", ), + # 3-player game equivalent to a simultaneous-move 2x2x2 game (nine equilibria total, + # including two totally-mixed ones); this previously required an unordered check due + # to run-to-run variation in the order equilibria were found (see #589) + pytest.param( + EquilibriumTestCase( + factory=functools.partial(games.read_from_file, "mixed_behavior_game.efg"), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), + expected=[ + [[d("1/2", "1/2")], [d("2/5", "3/5")], [d("1/4", "3/4")]], + [[d("2/5", "3/5")], [d("1/2", "1/2")], [d("1/3", "2/3")]], + [[d("1/2", "1/2")], [d("1/2", "1/2")], [d(1, 0)]], + [[d("1/3", "2/3")], [d(1, 0)], [d("1/4", "3/4")]], + [[d(1, 0)], [d(1, 0)], [d(1, 0)]], + [[d(1, 0)], [d(0, 1)], [d(0, 1)]], + [[d(0, 1)], [d("1/4", "3/4")], [d("1/3", "2/3")]], + [[d(0, 1)], [d(1, 0)], [d(0, 1)]], + [[d(0, 1)], [d(0, 1)], [d(1, 0)]], + ], + regret_tol=TOL, + prob_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_behavior, + id="test_enumpoly_behavior_14", + ), ] -# ############################################################################## -# 3-player game -# ( -# games.read_from_file("mixed_behavior_game.efg"), -# [ -# [[["1/2", "1/2"]], [["2/5", "3/5"]], [["1/4", "3/4"]]], -# [[["2/5", "3/5"]], [["1/2", "1/2"]], [["1/3", "2/3"]]], -# ], -# 2, # 9 in total found by enumpoly (see unordered test) -# ), -# ############################################################################## LOGIT_BEHAVIOR_CASES = [ @@ -2525,75 +2538,6 @@ def test_nash_behavior_solver(test_case: EquilibriumTestCase, subtests) -> None: assert abs(eq[action] - expected[action]) <= test_case.prob_tol -################################################################################################## -# BEHVAIOR SOLVER -- UNORDERED -################################################################################################## - -ENUMPOLY_BEHAVIOR_UNORDERED_CASES = [ - pytest.param( - EquilibriumTestCase( - factory=functools.partial(games.read_from_file, "mixed_behavior_game.efg"), - solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=9), - expected=[ - [[["2/5", "3/5"]], [["1/2", "1/2"]], [["1/3", "2/3"]]], - [[["1/2", "1/2"]], [["2/5", "3/5"]], [["1/4", "3/4"]]], - [[["1/2", "1/2"]], [["1/2", "1/2"]], [[1, 0]]], - [[["1/3", "2/3"]], [[1, 0]], [["1/4", "3/4"]]], - [[[1, 0]], [[1, 0]], [[1, 0]]], - [[[1, 0]], [[0, 1]], [[0, 1]]], - [[[0, 1]], [["1/4", "3/4"]], [["1/3", "2/3"]]], - [[[0, 1]], [[1, 0]], [[0, 1]]], - [[[0, 1]], [[0, 1]], [[1, 0]]], - ], - regret_tol=TOL, - prob_tol=TOL, - ), - marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_unordered_1", - ), -] - - -@pytest.mark.nash -@pytest.mark.parametrize("test_case", ENUMPOLY_BEHAVIOR_UNORDERED_CASES, ids=lambda c: c.label) -def test_nash_behavior_solver_unordered(test_case: EquilibriumTestCase, subtests) -> None: - """Test calls of Nash solvers in EFGs in mixed behaviors -- UNORDERED - - Subtests: - - Agent max regret no more than `test_case.regret_tol` - - Agent max regret no more than max regret (+ `test_case.regret_tol`) - - Equilibria that are output are distinct and all appear in the expected set - Equilibria are deemed to match if the maximum difference in probabilities is no more - than `test_case.prob_tol` - """ - - def are_the_same(game, found, candidate): - for p in game.players: - for a in p.actions: - if not abs(found[a] - candidate[a]) <= TOL: - return False - return True - - game = test_case.factory() - result = test_case.solver(game) - with subtests.test("number of equilibria found"): - assert len(result.equilibria) == len(test_case.expected) - for i, eq in enumerate(result.equilibria): - with subtests.test(eq=i, check="agent_max_regret"): - assert eq.max_regret() <= test_case.regret_tol - with subtests.test(eq=i, check="max_regret"): - assert eq.agent_max_regret() <= eq.max_regret() + test_case.regret_tol - with subtests.test(eq=i, check="strategy_profile"): - found = False - for exp in test_case.expected[:]: - expected = game.mixed_behavior_profile(rational=True, data=exp) - if are_the_same(game, eq, expected): - test_case.expected.remove(exp) - found = True - break - assert found - - ################################################################################################## # AGENTS NASH SOLVERS (IN MIXED BEHAVIORS) ################################################################################################## From 2d0be6e3a6e73f64eeb146eddf68980ec585ae76 Mon Sep 17 00:00:00 2001 From: Rahul Savani Date: Mon, 17 Aug 2026 07:50:23 +0100 Subject: [PATCH 2/4] add 4x4 coord game for enum poly strategy; and extend to stop_after=None for enum poly behav --- tests/test_nash.py | 63 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/tests/test_nash.py b/tests/test_nash.py index 84639adb7a..dbb2e3c78e 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -430,7 +430,7 @@ class QREquilibriumTestCase: marks=pytest.mark.nash_enumpoly_strategy, id="test_enumpoly_strategy_1", ), - # coordination game with 3 pure and 4 mixed equilibria + # 3x3 coordination game with 3 pure and 4 mixed equilibria pytest.param( EquilibriumTestCase( factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=3), @@ -450,6 +450,34 @@ class QREquilibriumTestCase: marks=pytest.mark.nash_enumpoly_strategy, id="test_enumpoly_strategy_2", ), + # 4x4 coordination game + pytest.param( + EquilibriumTestCase( + factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=4), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None, use_strategic=True), + expected=[ + [d(1, 0, 0, 0), d(1, 0, 0, 0)], + [d(0, 1, 0, 0), d(0, 1, 0, 0)], + [d(0, 0, 1, 0), d(0, 0, 1, 0)], + [d(0, 0, 0, 1), d(0, 0, 0, 1)], + [d("1/2", "1/2", 0, 0), d("1/2", "1/2", 0, 0)], + [d("1/2", 0, "1/2", 0), d("1/2", 0, "1/2", 0)], + [d("1/2", 0, 0, "1/2"), d("1/2", 0, 0, "1/2")], + [d(0, "1/2", "1/2", 0), d(0, "1/2", "1/2", 0)], + [d(0, "1/2", 0, "1/2"), d(0, "1/2", 0, "1/2")], + [d(0, 0, "1/2", "1/2"), d(0, 0, "1/2", "1/2")], + [d("1/3", "1/3", "1/3", 0), d("1/3", "1/3", "1/3", 0)], + [d("1/3", "1/3", 0, "1/3"), d("1/3", "1/3", 0, "1/3")], + [d("1/3", 0, "1/3", "1/3"), d("1/3", 0, "1/3", "1/3")], + [d(0, "1/3", "1/3", "1/3"), d(0, "1/3", "1/3", "1/3")], + [d("1/4", "1/4", "1/4", "1/4"), d("1/4", "1/4", "1/4", "1/4")], + ], + regret_tol=TOL, + prob_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_strategy, + id="test_enumpoly_behavior_2b", + ), # A three-player game with a unique Nash equilibrium in irrational mixed strategies # (nau2004 sec4 catalog game) pytest.param( @@ -737,6 +765,7 @@ class QREquilibriumTestCase: marks=pytest.mark.nash_lcp_strategy, id="test_lcp_strategy_rational_9", ), + # 4x4 coordination game pytest.param( EquilibriumTestCase( factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=4), @@ -899,6 +928,7 @@ class QREquilibriumTestCase: marks=pytest.mark.nash_lcp_strategy, id="test_lcp_strategy_double_9", ), + # 4x4 coordination game pytest.param( EquilibriumTestCase( factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=4), @@ -2354,7 +2384,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s marks=pytest.mark.nash_enumpoly_behavior, id="test_enumpoly_behavior_5", ), - # 3-player games + # 3x3 coordination game pytest.param( EquilibriumTestCase( factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=3), @@ -2374,12 +2404,27 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s marks=pytest.mark.nash_enumpoly_behavior, id="test_enumpoly_behavior_6", ), + # 4x4 coordination game pytest.param( EquilibriumTestCase( - factory=functools.partial(games.read_from_file, "3_player.efg"), + factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=4), solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), expected=[ - [[d(1, 0), d(1, 0)], [d(1, 0), d(1, 0)], [d(1, 0), d(1, 0)]], + [[d("1/4", "1/4", "1/4", "1/4")], [d("1/4", "1/4", "1/4", "1/4")]], + [[d("1/3", "1/3", "1/3", 0)], [d("1/3", "1/3", "1/3", 0)]], + [[d("1/3", "1/3", 0, "1/3")], [d("1/3", "1/3", 0, "1/3")]], + [[d("1/2", "1/2", 0, 0)], [d("1/2", "1/2", 0, 0)]], + [[d("1/3", 0, "1/3", "1/3")], [d("1/3", 0, "1/3", "1/3")]], + [[d("1/2", 0, "1/2", 0)], [d("1/2", 0, "1/2", 0)]], + [[d("1/2", 0, 0, "1/2")], [d("1/2", 0, 0, "1/2")]], + [[d(1, 0, 0, 0)], [d(1, 0, 0, 0)]], + [[d(0, "1/3", "1/3", "1/3")], [d(0, "1/3", "1/3", "1/3")]], + [[d(0, "1/2", "1/2", 0)], [d(0, "1/2", "1/2", 0)]], + [[d(0, "1/2", 0, "1/2")], [d(0, "1/2", 0, "1/2")]], + [[d(0, 1, 0, 0)], [d(0, 1, 0, 0)]], + [[d(0, 0, "1/2", "1/2")], [d(0, 0, "1/2", "1/2")]], + [[d(0, 0, 1, 0)], [d(0, 0, 1, 0)]], + [[d(0, 0, 0, 1)], [d(0, 0, 0, 1)]], ], regret_tol=TOL, prob_tol=TOL, @@ -2387,9 +2432,10 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s marks=pytest.mark.nash_enumpoly_behavior, id="test_enumpoly_behavior_7", ), + # 3-player games pytest.param( EquilibriumTestCase( - factory=functools.partial(games.read_from_file, "3_player_with_nonterm_outcomes.efg"), + 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, 0)], [d(1, 0), d(1, 0)]], @@ -2400,13 +2446,12 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s marks=pytest.mark.nash_enumpoly_behavior, id="test_enumpoly_behavior_8", ), - # 4-player game pytest.param( EquilibriumTestCase( - factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=4), - solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=1), + 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/4", "1/4", "1/4", "1/4")], [d("1/4", "1/4", "1/4", "1/4")]], + [[d(1, 0), d(1, 0)], [d(1, 0), d(1, 0)], [d(1, 0), d(1, 0)]], ], regret_tol=TOL, prob_tol=TOL, From 2686503d6938be7567e24bb4f58c991bb4dcda8f Mon Sep 17 00:00:00 2001 From: Rahul Savani Date: Mon, 17 Aug 2026 11:30:50 +0100 Subject: [PATCH 3/4] added books/vonstengel2022/fig10.5 to ENUMPOLY_{STRATEGY,BEHAVIOR} tests --- tests/test_nash.py | 123 ++++++++++++++++++++++++++++----------------- 1 file changed, 77 insertions(+), 46 deletions(-) diff --git a/tests/test_nash.py b/tests/test_nash.py index dbb2e3c78e..f46cc27e06 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -412,24 +412,7 @@ class QREquilibriumTestCase: ENUMPOLY_STRATEGY_CASES = [ - # 2x2x2 strategic form game based on local max cut -- 2 pure and 1 mixed - pytest.param( - EquilibriumTestCase( - factory=functools.partial( - games.read_from_file, "2x2x2_nfg_from_local_max_cut_2_pure_1_mixed_eq.nfg" - ), - solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), - expected=[ - [d(1, 0), d(0, 1), d(1, 0)], - [d(0, 1), d(1, 0), d(0, 1)], - [d("1/2", "1/2"), d("1/2", "1/2"), d("1/2", "1/2")], - ], - prob_tol=TOL, - regret_tol=TOL, - ), - marks=pytest.mark.nash_enumpoly_strategy, - id="test_enumpoly_strategy_1", - ), + # 2-player games # 3x3 coordination game with 3 pure and 4 mixed equilibria pytest.param( EquilibriumTestCase( @@ -448,9 +431,9 @@ class QREquilibriumTestCase: regret_tol=TOL, ), marks=pytest.mark.nash_enumpoly_strategy, - id="test_enumpoly_strategy_2", + id="test_enumpoly_strategy_1", ), - # 4x4 coordination game + # 4x4 coordination game pytest.param( EquilibriumTestCase( factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=4), @@ -476,7 +459,42 @@ class QREquilibriumTestCase: prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_strategy, - id="test_enumpoly_behavior_2b", + id="test_enumpoly_strategy_2", + ), + # Figure 10.5 efg from Game Theory Basics + pytest.param( + EquilibriumTestCase( + factory=functools.partial(gbt.catalog.load, "books/vonstengel2022/fig10.5"), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None, use_strategic=True), + expected=[ + [d(1, 0), d(1, 0, 0, 0)], + [d("1/2", "1/2"), d(0, "2/5", "3/5", 0)], + [d("1/4", "3/4"), d(0, 0, "1/3", "2/3")], + ], + regret_tol=TOL, + prob_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_strategy, + id="test_enumpoly_strategy_3", + ), + # 3-player games + # 2x2x2 strategic form game based on local max cut -- 2 pure and 1 mixed + pytest.param( + EquilibriumTestCase( + factory=functools.partial( + games.read_from_file, "2x2x2_nfg_from_local_max_cut_2_pure_1_mixed_eq.nfg" + ), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), + expected=[ + [d(1, 0), d(0, 1), d(1, 0)], + [d(0, 1), d(1, 0), d(0, 1)], + [d("1/2", "1/2"), d("1/2", "1/2"), d("1/2", "1/2")], + ], + prob_tol=TOL, + regret_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_strategy, + id="test_enumpoly_strategy_4", ), # A three-player game with a unique Nash equilibrium in irrational mixed strategies # (nau2004 sec4 catalog game) @@ -495,7 +513,7 @@ class QREquilibriumTestCase: regret_tol=TOL, ), marks=pytest.mark.nash_enumpoly_strategy, - id="test_enumpoly_strategy_3", + id="test_enumpoly_strategy_5", ), # A three-player 2x2x2 game with 3 pure, 2 incompletely mixed, and a # continuum of completely mixed Nash equilibria (nau2004 sec5 catalog game) @@ -512,7 +530,7 @@ class QREquilibriumTestCase: regret_tol=TOL, ), marks=pytest.mark.nash_enumpoly_strategy, - id="test_enumpoly_strategy_4", + id="test_enumpoly_strategy_6", ), ] @@ -765,7 +783,7 @@ class QREquilibriumTestCase: marks=pytest.mark.nash_lcp_strategy, id="test_lcp_strategy_rational_9", ), - # 4x4 coordination game + # 4x4 coordination game pytest.param( EquilibriumTestCase( factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=4), @@ -928,7 +946,7 @@ class QREquilibriumTestCase: marks=pytest.mark.nash_lcp_strategy, id="test_lcp_strategy_double_9", ), - # 4x4 coordination game + # 4x4 coordination game pytest.param( EquilibriumTestCase( factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=4), @@ -2274,9 +2292,6 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s ENUMPOLY_BEHAVIOR_CASES = [ - # ############################################################# - # Examples where Nash pure behaviors and agent-form pure equillibrium behaviors coincide - # ############################################################# # 2-player zero-sum games pytest.param( EquilibriumTestCase( @@ -2289,7 +2304,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_0", + id="test_enumpoly_behavior_1", ), # 2-player non-zero-sum games pytest.param( @@ -2303,7 +2318,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_1a", + id="test_enumpoly_behavior_2", ), pytest.param( EquilibriumTestCase( @@ -2316,7 +2331,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_1b", + id="test_enumpoly_behavior_3", ), pytest.param( EquilibriumTestCase( @@ -2329,7 +2344,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_2", + id="test_enumpoly_behavior_4", ), pytest.param( EquilibriumTestCase( @@ -2344,7 +2359,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_3", + id="test_enumpoly_behavior_5", ), pytest.param( EquilibriumTestCase( @@ -2362,7 +2377,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_4", + id="test_enumpoly_behavior_6", ), pytest.param( EquilibriumTestCase( @@ -2382,9 +2397,9 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_5", + id="test_enumpoly_behavior_7", ), - # 3x3 coordination game + # 3x3 coordination game pytest.param( EquilibriumTestCase( factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=3), @@ -2402,9 +2417,9 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_6", + id="test_enumpoly_behavior_8", ), - # 4x4 coordination game + # 4x4 coordination game pytest.param( EquilibriumTestCase( factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=4), @@ -2430,7 +2445,23 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_7", + id="test_enumpoly_behavior_9", + ), + # Figure 10.5 efg from Game Theory Basics + pytest.param( + EquilibriumTestCase( + factory=functools.partial(gbt.catalog.load, "books/vonstengel2022/fig10.5"), + solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), + expected=[ + [[d("1/2", "1/2")], [d("2/5", "3/5"), d(0, 1), d(1, 0)]], + [[d("1/4", "3/4")], [d(0, 1), d(1, 0), d("1/3", "2/3")]], + [[d(1, 0)], [d(1, 0), d(1, 0), d(1, 0)]] + ], + regret_tol=TOL, + prob_tol=TOL, + ), + marks=pytest.mark.nash_enumpoly_behavior, + id="test_enumpoly_behavior_10", ), # 3-player games pytest.param( @@ -2444,7 +2475,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_8", + id="test_enumpoly_behavior_11", ), pytest.param( EquilibriumTestCase( @@ -2457,7 +2488,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_9", + id="test_enumpoly_behavior_12", ), # 3-player perfect info game to test behavior two off equilibrium path pytest.param( @@ -2474,7 +2505,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_10", + id="test_enumpoly_behavior_13", ), pytest.param( EquilibriumTestCase( @@ -2492,7 +2523,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_11", + id="test_enumpoly_behavior_14", ), pytest.param( EquilibriumTestCase( @@ -2512,7 +2543,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_12", + id="test_enumpoly_behavior_15", ), pytest.param( EquilibriumTestCase( @@ -2528,7 +2559,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_13", + id="test_enumpoly_behavior_16", ), # 3-player game equivalent to a simultaneous-move 2x2x2 game (nine equilibria total, # including two totally-mixed ones); this previously required an unordered check due @@ -2552,7 +2583,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s prob_tol=TOL, ), marks=pytest.mark.nash_enumpoly_behavior, - id="test_enumpoly_behavior_14", + id="test_enumpoly_behavior_17", ), ] From b30b517ac96207ceaa9a3dd0c526d4a664ae7e0a Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 18 Aug 2026 18:20:51 +0100 Subject: [PATCH 4/4] Update ChangeLog --- ChangeLog | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ChangeLog b/ChangeLog index 57cbe6baab..0787cdffce 100644 --- a/ChangeLog +++ b/ChangeLog @@ -8,6 +8,9 @@ strategy with an empty label; it is now labeled `"1"`, as `Game.new_table` already labels every other player's initial strategies. (#1047) - Numerical data in action graph games is now read with exact precision, to match other game formats. (#879) +- `enumpoly_solve` correctly handles failed perturbations at singular Jacobians (#591) +- `enumpoly_solve` iterates sequences of actions in a deterministic order, pre-empting the + possibility of equilibria being found in orders that differ across runs. (#1016) ### Removed - Assigning to `Strategy.label` has been removed; use `Game.relabel_strategies`, which enforces