From d3058fbcb8fbd96cb43ce45d154bd157b5a5a6a0 Mon Sep 17 00:00:00 2001 From: Michail Filippou <41775400+choosen23@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:40:42 +0300 Subject: [PATCH 1/2] Fix CI on current black and mypy. Two checks in the tox env currently fail on main, so CI stops before the tests run. This affects every open pull request. `black --check src/` fails because recent releases collapse these short `.format` calls onto a single line. `mypy` fails on the return annotation of `imitation_dynamics`, which declares `Tuple[float, float]` but yields a pair of arrays. No behaviour change. --- src/nashpy/algorithms/support_enumeration.py | 4 +--- src/nashpy/game.py | 4 +--- src/nashpy/learning/imitation_dynamics.py | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/nashpy/algorithms/support_enumeration.py b/src/nashpy/algorithms/support_enumeration.py index 6c3727a..52b4fe9 100644 --- a/src/nashpy/algorithms/support_enumeration.py +++ b/src/nashpy/algorithms/support_enumeration.py @@ -254,7 +254,5 @@ def support_enumeration( An even number of ({}) equilibria was returned. This indicates that the game is degenerate. Consider using another algorithm to investigate. - """.format( - count - ) + """.format(count) warnings.warn(warning, RuntimeWarning) diff --git a/src/nashpy/game.py b/src/nashpy/game.py index 493d05f..3965c9f 100644 --- a/src/nashpy/game.py +++ b/src/nashpy/game.py @@ -61,9 +61,7 @@ def __repr__(self) -> str: {} Column player: -{}""".format( - tpe, *self.payoff_matrices - ) +{}""".format(tpe, *self.payoff_matrices) def __getitem__(self, key: Any) -> npt.NDArray: row_strategy, column_strategy = key diff --git a/src/nashpy/learning/imitation_dynamics.py b/src/nashpy/learning/imitation_dynamics.py index 1b2297c..8d0c1f1 100644 --- a/src/nashpy/learning/imitation_dynamics.py +++ b/src/nashpy/learning/imitation_dynamics.py @@ -35,7 +35,7 @@ def imitation_dynamics( iterations=1000, random_seed=None, threshold=0.5, -) -> Generator[Tuple[float, float], Any, None]: +) -> Generator[Tuple[npt.NDArray, npt.NDArray], Any, None]: """ Simulate the imitation dynamics for a given game represented by payoff matrices A and B. From fd0607b1b476b1d77811275b6e9fbc7556485f0a Mon Sep 17 00:00:00 2001 From: Michail Filippou <41775400+choosen23@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:33:13 +0300 Subject: [PATCH 2/2] Normalise payoffs in vertex enumeration. The best response polytope is {x >= 0, Mx <= 1}, so its vertex coordinates scale as 1 / payoff. `non_trivial_vertices` discards the origin using `np.isclose(v, 0)`, which falls back to an absolute tolerance of 1e-8. Once payoffs are large enough every legitimate vertex falls below that tolerance and is discarded, so `vertex_enumeration` yields nothing at all. Rock-Paper-Scissors and Battle of the Sexes are both affected at a payoff scale of 1e9, having been solved correctly at 1e6. Neither game is degenerate, and multiplying every payoff by a constant cannot change the equilibria. Nash equilibria are invariant under a positive rescaling of each player's payoffs, so normalising before the polytopes are built keeps them well conditioned without changing the result. --- src/nashpy/algorithms/vertex_enumeration.py | 10 ++++++ tests/unit/test_vertex_enumeration.py | 34 +++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/nashpy/algorithms/vertex_enumeration.py b/src/nashpy/algorithms/vertex_enumeration.py index a3df497..97d54de 100644 --- a/src/nashpy/algorithms/vertex_enumeration.py +++ b/src/nashpy/algorithms/vertex_enumeration.py @@ -39,6 +39,16 @@ def vertex_enumeration( if np.min(B) < 0: B = B + abs(np.min(B)) + # The best response polytope is {x >= 0, Mx <= 1}, so its vertex coordinates + # scale as 1 / payoff. With large payoffs every vertex falls below the + # absolute tolerance used to discard the origin and the algorithm returns + # nothing. Nash equilibria are invariant under a positive rescaling of each + # player's payoffs, so normalise here to keep the polytope well conditioned. + if np.max(A) > 0: + A = A / np.max(A) + if np.max(B) > 0: + B = B / np.max(B) + number_of_row_strategies, row_dimension = A.shape max_label = number_of_row_strategies + row_dimension full_labels = set(range(max_label)) diff --git a/tests/unit/test_vertex_enumeration.py b/tests/unit/test_vertex_enumeration.py index d72e5ba..26e5399 100644 --- a/tests/unit/test_vertex_enumeration.py +++ b/tests/unit/test_vertex_enumeration.py @@ -42,3 +42,37 @@ def test_with_negative_utilities(self): equilibrium = next(vertex_enumeration(A, B)) for strategy, expected_strategy in zip(equilibrium, expected_equilibrium): assert all(np.isclose(strategy, expected_strategy)), strategy + + def test_with_large_utilities(self): + """ + Nash equilibria are invariant under a positive rescaling of payoffs, so + scaling a game up must not change the equilibria that are found. + + Regression test: previously the vertices of the best response polytope + fell below the absolute tolerance used to discard the origin, and the + algorithm silently returned no equilibria. + """ + A = np.array([[3, 0], [0, 2]]) + B = np.array([[2, 0], [0, 3]]) + + expected_equilibria = sorted( + [ + (np.array([1, 0]), np.array([1, 0])), + (np.array([3 / 5, 2 / 5]), np.array([2 / 5, 3 / 5])), + (np.array([0, 1]), np.array([0, 1])), + ], + key=lambda a: list(np.round(a[0], 4)), + ) + + for scale in (1, 10**6, 10**9, 10**12): + equilibria = sorted( + vertex_enumeration(A * scale, B * scale), + key=lambda a: list(np.round(a[0], 4)), + ) + assert len(equilibria) == 3, (scale, len(equilibria)) + for equilibrium, expected in zip(equilibria, expected_equilibria): + for strategy, expected_strategy in zip(equilibrium, expected): + assert all(np.isclose(strategy, expected_strategy)), ( + scale, + strategy, + )