From 1e034c138fe6d65064094d0b82e8b60c3089a5e6 Mon Sep 17 00:00:00 2001 From: Uveys Al-Salehi Date: Wed, 26 Aug 2026 02:12:52 +0400 Subject: [PATCH 1/6] Add is_degenerate check for two-player games (#961) --- src/pygambit/game.pxi | 123 +++++++++++++++++++++++++++++++++++++++ tests/test_degeneracy.py | 93 +++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 tests/test_degeneracy.py diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 925256eae5..aaeb24c09f 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -25,6 +25,7 @@ import pathlib import cython import numpy as np +from scipy.optimize import linprog import pygambit.gameiter @@ -986,3 +987,125 @@ class Game: for old, new in remap.items(): c_labels[old.encode("utf-8")] = new.encode("utf-8") self.game.deref().RelabelPlayers(c_labels) + + def is_degenerate(self, certificate=False): + """Check whether this two-player game is degenerate. + + A two-player game is *nondegenerate* if no mixed strategy of support + size ``k`` has more than ``k`` pure best responses. A degenerate game + violates this -- for example, a pure strategy with two pure best + responses. The check searches over supports (a discrete search); the + problem is NP-hard in general. + + Parameters + ---------- + certificate : bool, default False + If True, also return a witness when the game is degenerate. + + Returns + ------- + bool or tuple + Whether the game is degenerate. If ``certificate=True``, returns + ``(is_degenerate, witness)``. When degenerate, ``witness`` is + ``(player, strategy)``: ``player`` is 0 (row) or 1 (column), and + ``strategy`` is the mixed strategy (of support size k) whose + opponent has more than k best responses has more best responses + than its support size. When nondegenerate, ``witness`` is None. + + Raises + ------ + ValueError + If the game does not have exactly two players. + + Examples + -------- + >>> import pygambit as gbt + >>> g = gbt.Game.from_arrays([[3, 3], [2, 5], [0, 6]], + ... [[3, 3], [2, 6], [3, 1]]) + >>> g.is_degenerate() + True + + >>> g = gbt.Game.from_arrays([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]) + >>> g.is_degenerate() + False + + >>> g = gbt.Game.from_arrays([[2, 1], [1, 1]], [[1, 1], [1, 2]]) + >>> g.is_degenerate(certificate=True) + (True, (0, (1.0, 0.0))) + """ + + if len(self.players) != 2: + raise ValueError("is_degenerate() is only defined for two-player games") + + M1, M2 = (np.asarray(a, dtype=float) for a in self.to_arrays()) + m, n = M1.shape + + def feasible(M, S, T): + """Search for a small-support strategy with too many best responses. + + Given a candidate support ``S`` for the strategizing player and a + candidate best-response set ``T`` for the opponent (with + ``len(T) == len(S) + 1``), determine whether there exists a mixed + strategy supported on ``S`` such that every pure strategy in ``T`` + is an equal, maximal best response for the opponent. If such a + strategy exists, it witnesses degeneracy, because the opponent then + has more best responses (``len(T)``) than the support size + (``len(S)``). + + Parameters + ---------- + M : numpy.ndarray + The opponent's payoff matrix, oriented so the strategizing + player indexes axis 0 and the opponent indexes axis 1. + S : sequence of int + Indices (axis 0) forming the candidate support. + T : sequence of int + Indices (axis 1) of the candidate best responses; + ``len(T) == len(S) + 1``. + + Returns + ------- + numpy.ndarray or None + A full-length probability vector supported on ``S`` that makes + every strategy in ``T`` an equal, maximal best response, or + None if no such strategy exists. + + + This is solved as a linear feasibility problem: find x >= 0 on S + with sum 1 making the opponent's payoff equal across all of T (tie + constraints) and no greater for any strategy outside T (maximality). + + """ + + S, T = list(S), list(T) + k = len(S) + sub = M[S, :] + A_eq = [sub[:, T[t]] - sub[:, T[0]] for t in range(1, len(T))] + A_eq.append(np.ones(k)) + b_eq = [0.0] * (len(T) - 1) + [1.0] + others = [j for j in range(M.shape[1]) if j not in T] + A_ub = [sub[:, j] - sub[:, T[0]] for j in others] + b_ub = [0.0] * len(others) + res = linprog(np.zeros(k), A_ub or None, b_ub or None, + A_eq, b_eq, bounds=[(0, None)] * k, method="highs") + if not res.success: + return None + x = np.zeros(M.shape[0]) + x[S] = res.x + return x + + for k in range(1, min(m, n) + 1): + # P1 mixes over k rows -> can P2 get k+1 tied best responses? (use M2) + for S in itertools.combinations(range(m), k): + for T in itertools.combinations(range(n), k + 1): + x = feasible(M2, S, T) + if x is not None: + return (True, (0, tuple(x.tolist()))) if certificate else True + # P2 mixes over k cols -> can P1 get k+1 tied best responses? (use M1.T) + for S in itertools.combinations(range(n), k): + for T in itertools.combinations(range(m), k + 1): + y = feasible(M1.T, S, T) + if y is not None: + return (True, (1, tuple(y.tolist()))) if certificate else True + + return (False, None) if certificate else False diff --git a/tests/test_degeneracy.py b/tests/test_degeneracy.py new file mode 100644 index 0000000000..bdfcb99cad --- /dev/null +++ b/tests/test_degeneracy.py @@ -0,0 +1,93 @@ +import numpy as np +import pytest + +import pygambit as gbt + + +def _best_response_count(responder_payoff, strategizer_mix, tol=1e-7): + payoffs = np.asarray(strategizer_mix, float) @ responder_payoff + return int(np.sum(payoffs >= payoffs.max() - tol)) + + +# --- Nondegenerate (#6) --- +def test_matching_pennies_not_degenerate(): + g = gbt.Game.from_arrays([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]) + assert g.is_degenerate() is False + + +# --- Degenerate, pure, player 1 side (#2) — literature examples --- +def test_degenerate_DGRB2010(): + g = gbt.Game.from_arrays([[3, 3], [2, 5], [0, 6]], [[3, 3], [2, 6], [3, 1]]) + assert g.is_degenerate() is True + + +def test_degenerate_NN2007(): + g = gbt.Game.from_arrays([[0, 6], [2, 5], [3, 3]], [[1, 0], [0, 2], [4, 4]]) + assert g.is_degenerate() is True + + +# --- Degenerate, pure, player 2 side (#3) --- +def test_degenerate_player2_pure(): + g = gbt.Game.from_arrays([[5, 0], [5, 2]], [[1, 2], [3, 4]]) + assert g.is_degenerate() is True + + +# --- Degenerate via LP, player 1 mixing (#4) --- +def test_degenerate_LP_player1_mixing(): + g = gbt.Game.from_arrays([[1, 2, 3], [4, 5, 6], [7, 8, 9]], + [[6, 0, 3], [0, 6, 3], [1, 2, 5]]) + assert g.is_degenerate() is True + + +# --- Not two players (#1) --- +def test_rejects_three_player(): + g = gbt.Game.new_table([2, 2, 2]) # <-- verify constructor name! + with pytest.raises(ValueError): + g.is_degenerate() + + +# --- certificate=True, nondegenerate --- +def test_certificate_false_case(): + g = gbt.Game.from_arrays([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]) + result, cert = g.is_degenerate(certificate=True) + assert result is False + assert cert is None + + +# --- certificate validity: player 0 witness (#cert, #2) --- +def test_certificate_witnesses_degeneracy_player0(): + A = np.array([[2, 1], [1, 1]], float) + B = np.array([[1, 1], [1, 2]], float) + g = gbt.Game.from_arrays(A, B) + is_deg, (player, strategy) = g.is_degenerate(certificate=True) + assert is_deg is True + strategy = np.asarray(strategy, float) + mat = B if player == 0 else A.T + n_br = _best_response_count(mat, strategy) + assert n_br > int(np.sum(strategy > 1e-7)) + + +# --- certificate validity: player 1 witness (#5, catches the index bug) --- +def test_certificate_witnesses_degeneracy_player1(): + A = np.array([[3, 0], [0, 3], [1.5, 1.5]], float) + B = np.array([[4, 3], [2, 6], [3, 1]], float) + g = gbt.Game.from_arrays(A, B) + is_deg, (player, strategy) = g.is_degenerate(certificate=True) + assert is_deg is True + assert player == 1 + strategy = np.asarray(strategy, float) + n_br = _best_response_count(A.T, strategy) + assert n_br > int(np.sum(strategy > 1e-7)) + + +# --- 4x5 mixed-degeneracy witness validity --- +def test_4x5_mixed_witness_valid(): + A = [[7, 1, 2, 1, 3], [1, 7, 1, 2, 1], [2, 2, 7, 1, 2], [1, 3, 1, 7, 6]] + B = [[6, 0, 3, 1, 2], [0, 6, 3, 1, 0], [1, 2, 5, 0, 1], [2, 1, 0, 4, 3]] + g = gbt.Game.from_arrays(A, B) + is_deg, (player, strategy) = g.is_degenerate(certificate=True) + assert is_deg is True + strategy = np.asarray(strategy, float) + mat = np.array(B, float) if player == 0 else np.array(A, float).T + n_br = _best_response_count(mat, strategy) + assert n_br > int(np.sum(strategy > 1e-7)) From dfebae1fa34931f87414acf6c12c3dbb3288792f Mon Sep 17 00:00:00 2001 From: Uveys Al-Salehi Date: Wed, 21 Oct 2026 23:44:22 +0100 Subject: [PATCH 2/6] Use exact LP via pycddlib in Game.is_degenerate (#961) Replace scipy's floating-point linprog with exact rational LP (pycddlib/cdd.gmp) so ties are detected exactly, per maintainer guidance. Pure-strategy ties are checked directly; LPs handle supports of size >= 2. - Raise NotImplementedError for games without exactly two players - Return the witness strategy as a numpy array of Rational - Add pycddlib>=3.0 as a dependency - Check certificates in tests with exact Fraction arithmetic; add mixed-only, rational-witness and near-tie tests - List Game.is_degenerate in the API docs; document the cddlib/GMP build requirement - Install cddlib/GMP in Linux and macOS CI --- .github/workflows/python.yml | 3 + doc/developer.build.rst | 8 ++ doc/pygambit.api.rst | 1 + pyproject.toml | 1 + src/pygambit/game.pxi | 177 ++++++++++++++++++++--------------- tests/test_degeneracy.py | 71 ++++++++++---- 6 files changed, 167 insertions(+), 94 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index f3524db756..221dfcbda4 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -31,6 +31,7 @@ jobs: - name: Set up dependencies run: | python -m pip install --upgrade pip + sudo apt-get update && sudo apt-get install -y libcdd-dev libgmp-dev python -m pip install build - name: Build source distribution run: @@ -62,6 +63,7 @@ jobs: - name: Set up dependencies run: | python -m pip install --upgrade pip + brew install cddlib gmp - name: Build extension run: | python -m pip install -v ".[test,doc]" @@ -87,6 +89,7 @@ jobs: - name: Set up dependencies run: | python -m pip install --upgrade pip + brew install cddlib gmp - name: Build extension run: | python -m pip install -v ".[test,doc]" diff --git a/doc/developer.build.rst b/doc/developer.build.rst index fc3d2e9ea1..393f3c892d 100644 --- a/doc/developer.build.rst +++ b/doc/developer.build.rst @@ -207,6 +207,14 @@ Use `pip` to install from the **root directory of the source tree**: draw game trees in the tutorials and the games catalog, is installed separately rather than as part of the "doc" extra, since it is not needed by the installed `pygambit` package itself. +.. note:: + + `pygambit` depends on `pycddlib `_ for exact + linear programming. On Linux and macOS, `pip` builds pycddlib from source, which + requires the cddlib and GMP development libraries + (e.g. ``sudo apt install libcdd-dev libgmp-dev`` on Debian/Ubuntu, or + ``brew install cddlib gmp`` on macOS). + Once installed, simply ``import pygambit`` in your Python shell or script to get started. diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 5f026f6ad2..c4c57f5ad2 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -115,6 +115,7 @@ Information about the game Game.is_const_sum Game.is_perfect_recall Game.has_perfect_recall + Game.is_degenerate Game.players Game.min_payoff Game.max_payoff diff --git a/pyproject.toml b/pyproject.toml index 90206b25e1..68c68be132 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "scipy", "pandas", "click", + "pycddlib>=3.0", ] [project.scripts] diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index aaeb24c09f..412db122bc 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -23,9 +23,10 @@ import io import itertools import pathlib +import cdd +import cdd.gmp import cython import numpy as np -from scipy.optimize import linprog import pygambit.gameiter @@ -992,10 +993,15 @@ class Game: """Check whether this two-player game is degenerate. A two-player game is *nondegenerate* if no mixed strategy of support - size ``k`` has more than ``k`` pure best responses. A degenerate game - violates this -- for example, a pure strategy with two pure best - responses. The check searches over supports (a discrete search); the - problem is NP-hard in general. + size ``k`` has more than ``k`` pure best responses. A game that + violates this is *degenerate*; for example, a pure strategy with two + pure best responses. + + The check is exact: payoffs are treated as rational numbers and each + candidate is tested with exact linear programming (pycddlib), so ties + are detected exactly rather than up to a numerical tolerance. The + search is exhaustive over supports, so its running time grows + exponentially with the number of strategies. Parameters ---------- @@ -1006,17 +1012,28 @@ class Game: ------- bool or tuple Whether the game is degenerate. If ``certificate=True``, returns - ``(is_degenerate, witness)``. When degenerate, ``witness`` is - ``(player, strategy)``: ``player`` is 0 (row) or 1 (column), and - ``strategy`` is the mixed strategy (of support size k) whose - opponent has more than k best responses has more best responses - than its support size. When nondegenerate, ``witness`` is None. + ``(is_degenerate, witness)``. When the game is degenerate, + ``witness`` is ``(player, strategy)``, where ``player`` is 0 (row) + or 1 (column) and ``strategy`` is a numpy array of `Rational` + probabilities: a mixed strategy of that player, with support size + k, against which the opponent has more than k pure best responses. + When the game is nondegenerate, ``witness`` is None. Raises ------ - ValueError + NotImplementedError If the game does not have exactly two players. + Notes + ----- + For each support size ``k``, each candidate support ``S`` (size ``k``) + of one player and each candidate set ``T`` (size ``k + 1``) of the + opponent's strategies, a linear feasibility problem asks whether some + mixed strategy on ``S`` makes every strategy in ``T`` a best response: + the opponent's payoffs are equal across ``T`` and no greater outside + ``T``. The game is degenerate if and only if one of these problems is + feasible. + Examples -------- >>> import pygambit as gbt @@ -1031,81 +1048,93 @@ class Game: >>> g = gbt.Game.from_arrays([[2, 1], [1, 1]], [[1, 1], [1, 2]]) >>> g.is_degenerate(certificate=True) - (True, (0, (1.0, 0.0))) - """ + (True, (0, array([Rational(1, 1), Rational(0, 1)], dtype=object))) + + A degenerate game in which no pure strategy has tied best responses; + only a mixed strategy reveals it: + >>> g = gbt.Game.from_arrays([[1, 2, 3], [6, 5, 4]], + ... [[2, 0, 1], [0, 2, 1]]) + >>> g.is_degenerate(certificate=True) + (True, (0, array([Rational(1, 2), Rational(1, 2)], dtype=object))) + """ if len(self.players) != 2: - raise ValueError("is_degenerate() is only defined for two-player games") + raise NotImplementedError( + "is_degenerate() is only implemented for two-player games" + ) - M1, M2 = (np.asarray(a, dtype=float) for a in self.to_arrays()) + M1, M2 = self.to_arrays() m, n = M1.shape - - def feasible(M, S, T): - """Search for a small-support strategy with too many best responses. - - Given a candidate support ``S`` for the strategizing player and a - candidate best-response set ``T`` for the opponent (with - ``len(T) == len(S) + 1``), determine whether there exists a mixed - strategy supported on ``S`` such that every pure strategy in ``T`` - is an equal, maximal best response for the opponent. If such a - strategy exists, it witnesses degeneracy, because the opponent then - has more best responses (``len(T)``) than the support size - (``len(S)``). - - Parameters - ---------- - M : numpy.ndarray - The opponent's payoff matrix, oriented so the strategizing - player indexes axis 0 and the opponent indexes axis 1. - S : sequence of int - Indices (axis 0) forming the candidate support. - T : sequence of int - Indices (axis 1) of the candidate best responses; - ``len(T) == len(S) + 1``. - - Returns - ------- - numpy.ndarray or None - A full-length probability vector supported on ``S`` that makes - every strategy in ``T`` an equal, maximal best response, or - None if no such strategy exists. - - - This is solved as a linear feasibility problem: find x >= 0 on S - with sum 1 making the opponent's payoff equal across all of T (tie - constraints) and no greater for any strategy outside T (maximality). - - """ - - S, T = list(S), list(T) + zero, one = Rational(0), Rational(1) + + for player, payoff_matrix in ((0, M2), (1, M1.T)): + for i, payoffs in enumerate(payoff_matrix): + if np.count_nonzero(payoffs == max(payoffs)) > 1: + if not certificate: + return True + strategy = np.full(payoff_matrix.shape[0], zero, dtype=object) + strategy[i] = one + return True, (player, strategy) + + def tied_best_response_strategy(M, S, T): + """Return a mixed strategy on rows ``S`` of ``M`` against which every + column in ``T`` is a best response, or None if there is none.""" k = len(S) sub = M[S, :] - A_eq = [sub[:, T[t]] - sub[:, T[0]] for t in range(1, len(T))] - A_eq.append(np.ones(k)) - b_eq = [0.0] * (len(T) - 1) + [1.0] + ref = T[0] others = [j for j in range(M.shape[1]) if j not in T] - A_ub = [sub[:, j] - sub[:, T[0]] for j in others] - b_ub = [0.0] * len(others) - res = linprog(np.zeros(k), A_ub or None, b_ub or None, - A_eq, b_eq, bounds=[(0, None)] * k, method="highs") - if not res.success: + + # In cdd's format, a row [b, a_1, ..., a_k] means b + a.x >= 0, + # or b + a.x == 0 if its index is in the linearity set. + rows, equalities = [], [] + for t in T[1:]: + equalities.append(len(rows)) + rows.append([zero] + list(sub[:, ref] - sub[:, t])) + equalities.append(len(rows)) + rows.append([one] + [-one] * k) + for j in others: + rows.append([zero] + list(sub[:, ref] - sub[:, j])) + for r in range(k): + rows.append([zero] + [one if c == r else zero for c in range(k)]) + + mat = cdd.gmp.matrix_from_array( + rows, + lin_set=frozenset(equalities), + rep_type=cdd.RepType.INEQUALITY, + obj_type=cdd.LPObjType.MAX, + obj_func=[zero] * (k + 1), + ) + lp = cdd.gmp.linprog_from_matrix(mat) + cdd.gmp.linprog_solve(lp) + + if lp.status == cdd.LPStatusType.OPTIMAL: + x = np.full(M.shape[0], zero, dtype=object) + x[list(S)] = [Rational(v) for v in lp.primal_solution] + return x + if lp.status in (cdd.LPStatusType.INCONSISTENT, + cdd.LPStatusType.STRUC_INCONSISTENT): return None - x = np.zeros(M.shape[0]) - x[S] = res.x - return x + # The feasible region is bounded and the objective is zero, + # so no other status should occur. + raise RuntimeError(f"unexpected LP status {lp.status!r}") - for k in range(1, min(m, n) + 1): - # P1 mixes over k rows -> can P2 get k+1 tied best responses? (use M2) + for k in range(2, min(m, n) + 1): + # Player 0 mixes over k rows; can player 1 have k + 1 best responses? for S in itertools.combinations(range(m), k): for T in itertools.combinations(range(n), k + 1): - x = feasible(M2, S, T) - if x is not None: - return (True, (0, tuple(x.tolist()))) if certificate else True - # P2 mixes over k cols -> can P1 get k+1 tied best responses? (use M1.T) + strategy = tied_best_response_strategy(M2, S, T) + if strategy is not None: + if not certificate: + return True + return True, (0, strategy) + + # Player 1 mixes over k columns; can player 0 have k + 1 best responses? for S in itertools.combinations(range(n), k): for T in itertools.combinations(range(m), k + 1): - y = feasible(M1.T, S, T) - if y is not None: - return (True, (1, tuple(y.tolist()))) if certificate else True + strategy = tied_best_response_strategy(M1.T, S, T) + if strategy is not None: + if not certificate: + return True + return True, (1, strategy) return (False, None) if certificate else False diff --git a/tests/test_degeneracy.py b/tests/test_degeneracy.py index bdfcb99cad..7aa41445fa 100644 --- a/tests/test_degeneracy.py +++ b/tests/test_degeneracy.py @@ -1,12 +1,24 @@ +from fractions import Fraction + import numpy as np import pytest import pygambit as gbt -def _best_response_count(responder_payoff, strategizer_mix, tol=1e-7): - payoffs = np.asarray(strategizer_mix, float) @ responder_payoff - return int(np.sum(payoffs >= payoffs.max() - tol)) +def _assert_valid_witness(A, B, player, strategy): + """Check exactly that the opponent has more pure best responses to + ``strategy`` than its support size.""" + responder = B if player == 0 else [list(col) for col in zip(*A, strict=True)] + strategy = [Fraction(p) for p in strategy] + assert len(strategy) == len(responder) + assert all(p >= 0 for p in strategy) + assert sum(strategy) == 1 + payoffs = [sum(p * Fraction(row[j]) for p, row in zip(strategy, responder, strict=True)) + for j in range(len(responder[0]))] + best_responses = sum(v == max(payoffs) for v in payoffs) + support_size = sum(p > 0 for p in strategy) + assert best_responses > support_size # --- Nondegenerate (#6) --- @@ -41,8 +53,8 @@ def test_degenerate_LP_player1_mixing(): # --- Not two players (#1) --- def test_rejects_three_player(): - g = gbt.Game.new_table([2, 2, 2]) # <-- verify constructor name! - with pytest.raises(ValueError): + g = gbt.Game.new_table([2, 2, 2]) + with pytest.raises(NotImplementedError, match="two-player"): g.is_degenerate() @@ -56,28 +68,23 @@ def test_certificate_false_case(): # --- certificate validity: player 0 witness (#cert, #2) --- def test_certificate_witnesses_degeneracy_player0(): - A = np.array([[2, 1], [1, 1]], float) - B = np.array([[1, 1], [1, 2]], float) + A = [[2, 1], [1, 1]] + B = [[1, 1], [1, 2]] g = gbt.Game.from_arrays(A, B) is_deg, (player, strategy) = g.is_degenerate(certificate=True) assert is_deg is True - strategy = np.asarray(strategy, float) - mat = B if player == 0 else A.T - n_br = _best_response_count(mat, strategy) - assert n_br > int(np.sum(strategy > 1e-7)) + _assert_valid_witness(A, B, player, strategy) # --- certificate validity: player 1 witness (#5, catches the index bug) --- def test_certificate_witnesses_degeneracy_player1(): - A = np.array([[3, 0], [0, 3], [1.5, 1.5]], float) - B = np.array([[4, 3], [2, 6], [3, 1]], float) + A = [[3, 0], [0, 3], [Fraction(3, 2), Fraction(3, 2)]] + B = [[4, 3], [2, 6], [3, 1]] g = gbt.Game.from_arrays(A, B) is_deg, (player, strategy) = g.is_degenerate(certificate=True) assert is_deg is True assert player == 1 - strategy = np.asarray(strategy, float) - n_br = _best_response_count(A.T, strategy) - assert n_br > int(np.sum(strategy > 1e-7)) + _assert_valid_witness(A, B, player, strategy) # --- 4x5 mixed-degeneracy witness validity --- @@ -87,7 +94,31 @@ def test_4x5_mixed_witness_valid(): g = gbt.Game.from_arrays(A, B) is_deg, (player, strategy) = g.is_degenerate(certificate=True) assert is_deg is True - strategy = np.asarray(strategy, float) - mat = np.array(B, float) if player == 0 else np.array(A, float).T - n_br = _best_response_count(mat, strategy) - assert n_br > int(np.sum(strategy > 1e-7)) + _assert_valid_witness(A, B, player, strategy) + + +# --- Degenerate only through a mixed strategy (no pure ties) --- +def test_degenerate_only_via_mixed_strategy(): + A = [[1, 2, 3], [6, 5, 4]] + B = [[2, 0, 1], [0, 2, 1]] + g = gbt.Game.from_arrays(A, B) + is_deg, (player, strategy) = g.is_degenerate(certificate=True) + assert is_deg is True + assert player == 0 + assert list(strategy) == [Fraction(1, 2), Fraction(1, 2)] + _assert_valid_witness(A, B, player, strategy) + + +# --- Witness is a numpy array of exact rationals --- +def test_certificate_is_exact_rational_array(): + g = gbt.Game.from_arrays([[2, 1], [1, 1]], [[1, 1], [1, 2]]) + _, (_, strategy) = g.is_degenerate(certificate=True) + assert isinstance(strategy, np.ndarray) + assert all(isinstance(p, gbt.Rational) for p in strategy) + + +# --- Payoff ties that floating point would blur must still count exactly --- +def test_near_tie_is_not_degenerate(): + eps = Fraction(1, 10**12) + g = gbt.Game.from_arrays([[1, 0], [0, 1]], [[1, 1 + eps], [1 + eps, 1]]) + assert g.is_degenerate() is False From 6f5faf5afa9ff8ad7db86fb0fbf10e445f33f510 Mon Sep 17 00:00:00 2001 From: Uveys Date: Sat, 24 Oct 2026 05:03:50 +0100 Subject: [PATCH 3/6] Rewrite is_degenerate tests; import pycddlib inside the method (#961) Restructure tests/test_degeneracy.py to follow the style of the existing suite: a parametrized table of degenerate and nondegenerate games (from SageMath, the catalog and test_games) run with and without a certificate, dataclass cases pinning unique witnesses, and tests for every exit of the method, including faked LP statuses. Import cdd inside is_degenerate so that importing pygambit does not require pycddlib, clarify the docstring on the witness's support, and update examples to StrategicGame. --- src/pygambit/game.pxi | 22 +-- tests/test_degeneracy.py | 299 +++++++++++++++++++++++++-------------- 2 files changed, 208 insertions(+), 113 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 412db122bc..4b7d3e2414 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -23,8 +23,6 @@ import io import itertools import pathlib -import cdd -import cdd.gmp import cython import numpy as np @@ -1015,8 +1013,9 @@ class Game: ``(is_degenerate, witness)``. When the game is degenerate, ``witness`` is ``(player, strategy)``, where ``player`` is 0 (row) or 1 (column) and ``strategy`` is a numpy array of `Rational` - probabilities: a mixed strategy of that player, with support size - k, against which the opponent has more than k pure best responses. + probabilities: a mixed strategy of that player against which the + opponent has more pure best responses than the size of the + strategy's support. When the game is nondegenerate, ``witness`` is None. Raises @@ -1037,24 +1036,24 @@ class Game: Examples -------- >>> import pygambit as gbt - >>> g = gbt.Game.from_arrays([[3, 3], [2, 5], [0, 6]], - ... [[3, 3], [2, 6], [3, 1]]) + >>> g = gbt.StrategicGame.from_arrays([[3, 3], [2, 5], [0, 6]], + ... [[3, 3], [2, 6], [3, 1]]) >>> g.is_degenerate() True - >>> g = gbt.Game.from_arrays([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]) + >>> g = gbt.StrategicGame.from_arrays([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]) >>> g.is_degenerate() False - >>> g = gbt.Game.from_arrays([[2, 1], [1, 1]], [[1, 1], [1, 2]]) + >>> g = gbt.StrategicGame.from_arrays([[2, 1], [1, 1]], [[1, 1], [1, 2]]) >>> g.is_degenerate(certificate=True) (True, (0, array([Rational(1, 1), Rational(0, 1)], dtype=object))) A degenerate game in which no pure strategy has tied best responses; only a mixed strategy reveals it: - >>> g = gbt.Game.from_arrays([[1, 2, 3], [6, 5, 4]], - ... [[2, 0, 1], [0, 2, 1]]) + >>> g = gbt.StrategicGame.from_arrays([[1, 2, 3], [6, 5, 4]], + ... [[2, 0, 1], [0, 2, 1]]) >>> g.is_degenerate(certificate=True) (True, (0, array([Rational(1, 2), Rational(1, 2)], dtype=object))) """ @@ -1063,6 +1062,9 @@ class Game: "is_degenerate() is only implemented for two-player games" ) + import cdd + import cdd.gmp + M1, M2 = self.to_arrays() m, n = M1.shape zero, one = Rational(0), Rational(1) diff --git a/tests/test_degeneracy.py b/tests/test_degeneracy.py index 7aa41445fa..70ece02d9f 100644 --- a/tests/test_degeneracy.py +++ b/tests/test_degeneracy.py @@ -1,124 +1,217 @@ +import dataclasses +import types +import typing from fractions import Fraction +import cdd +import cdd.gmp import numpy as np import pytest import pygambit as gbt +from . import games -def _assert_valid_witness(A, B, player, strategy): - """Check exactly that the opponent has more pure best responses to - ``strategy`` than its support size.""" - responder = B if player == 0 else [list(col) for col in zip(*A, strict=True)] - strategy = [Fraction(p) for p in strategy] - assert len(strategy) == len(responder) +# Payoff difference far below double precision: float(1 + EPS) == 1.0, so an +# implementation using floating point would see a tie where there is none. +EPS = Fraction(1, 10**20) + + +def _assert_valid_witness(game: gbt.Game, player: int, strategy): + """Check, in exact arithmetic, that the opponent has more pure best responses + to `strategy` than the size of its support.""" + A, B = game.to_arrays() + responder = B if player == 0 else A.T + assert isinstance(strategy, np.ndarray) + assert all(isinstance(p, gbt.Rational) for p in strategy) + assert len(strategy) == responder.shape[0] assert all(p >= 0 for p in strategy) assert sum(strategy) == 1 - payoffs = [sum(p * Fraction(row[j]) for p, row in zip(strategy, responder, strict=True)) - for j in range(len(responder[0]))] - best_responses = sum(v == max(payoffs) for v in payoffs) - support_size = sum(p > 0 for p in strategy) + payoffs = strategy @ responder + best_responses = np.count_nonzero(payoffs == np.max(payoffs)) + support_size = np.count_nonzero(strategy > 0) assert best_responses > support_size -# --- Nondegenerate (#6) --- -def test_matching_pennies_not_degenerate(): - g = gbt.Game.from_arrays([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]) - assert g.is_degenerate() is False - - -# --- Degenerate, pure, player 1 side (#2) — literature examples --- -def test_degenerate_DGRB2010(): - g = gbt.Game.from_arrays([[3, 3], [2, 5], [0, 6]], [[3, 3], [2, 6], [3, 1]]) - assert g.is_degenerate() is True - - -def test_degenerate_NN2007(): - g = gbt.Game.from_arrays([[0, 6], [2, 5], [3, 3]], [[1, 0], [0, 2], [4, 4]]) - assert g.is_degenerate() is True +@pytest.mark.parametrize("game,expected_result", [ + # Nondegenerate games + # - 2x2: no support of size 2 can have 3 best responses, so no LP is solved + (gbt.StrategicGame.from_arrays([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]), + False), # matching pennies; SageMath + (gbt.StrategicGame.from_arrays([[2, 5], [0, 4]], [[2, 0], [5, 4]]), + False), # prisoner's dilemma; SageMath + # - larger games, where every candidate LP is infeasible + (gbt.StrategicGame.from_arrays([[0, -1, 1], [1, 0, -1], [-1, 1, 0]], + [[0, 1, -1], [-1, 0, 1], [1, -1, 0]]), + False), # rock-paper-scissors; SageMath + (gbt.catalog.load("journals/other/shapley1974/fig2"), False), + (gbt.catalog.load("journals/other/shapley1974/fig3"), False), + (gbt.catalog.load("journals/dcg/vonstengel1999/6x6_game_with_75_eq"), + False), # constructed to be nondegenerate with many equilibria + (games.read_from_file("coordination_4x4_payoff.nfg"), False), + # - one player has a single strategy, so only pure strategies are checked + (gbt.StrategicGame.from_arrays([[1, 2, 3]], [[1, 2, 3]]), False), + # - near-ties which are not ties in exact arithmetic + (gbt.StrategicGame.from_arrays([[1, 0], [0, 1]], [[1, 1 + EPS], [1 + EPS, 1]]), + False), # pure strategies + (gbt.StrategicGame.from_arrays([[1, 2, 3], [6, 5, 4]], [[2, 0, 1 - EPS], [0, 2, 1 - EPS]]), + False), # mixing (1/2, 1/2) leaves column 3 just short of a best response + + # Degenerate games + # - a pure strategy has more than one pure best response + (gbt.StrategicGame.from_arrays([[3, 3], [2, 5], [0, 6]], [[3, 3], [2, 6], [3, 1]]), + True), # Avis, Rosenberg, Savani and von Stengel (2010), via SageMath + (gbt.StrategicGame.from_arrays([[0, 6], [2, 5], [3, 3]], [[1, 0], [0, 2], [4, 4]]), + True), # Nisan, Roughgarden, Tardos and Vazirani (2007), via SageMath + (gbt.StrategicGame.from_arrays([[2, 1], [1, 1]], [[1, 1], [1, 2]]), True), # SageMath + (gbt.StrategicGame.from_arrays([[0, -1, 1, 1, -1], [1, 0, -1, -1, 1], + [-1, 1, 0, 1, -1], [-1, 1, -1, 0, 1], + [1, -1, 1, -1, 0]], + [[0, 1, -1, -1, 1], [-1, 0, 1, 1, -1], + [1, -1, 0, -1, 1], [1, -1, 1, 0, -1], + [-1, 1, -1, 1, 0]]), + True), # rock-paper-scissors-lizard-Spock; SageMath + (gbt.StrategicGame.from_arrays([[0, 0, 0]] * 3, [[0, 0, 0]] * 3), True), # SageMath + (gbt.StrategicGame.from_arrays([[-75, 18, 45, 33], [42, -8, -77, -18], + [83, 18, 11, 40], [-10, -38, 76, -9]], + [[62, 64, 87, 51], [-41, -27, -69, 52], + [-17, 25, -97, -82], [30, 31, -1, 50]]), + True), # SageMath; only the column player's pure strategies have ties + (gbt.StrategicGame.from_arrays([[1, 2, 3]], [[3, 3, 1]]), True), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), + True), # extensive game, via its reduced strategic form + # - only a mixed strategy has more best responses than its support size + (gbt.StrategicGame.from_arrays([[1, 2, 3], [6, 5, 4]], [[2, 0, 1], [0, 2, 1]]), + True), # row player mixes + (gbt.StrategicGame.from_arrays([[3, 0], [0, 3], [Fraction(3, 2), Fraction(3, 2)]], + [[4, 3], [2, 6], [3, 1]]), + True), # column player mixes; SageMath + (gbt.StrategicGame.from_arrays([[1, 2, 3], [4, 6, 5], [9, 8, 7]], + [[5, 0, 1], [2, 0, 1], [0, 2, 1]]), + True), # found only after the LPs for rows {1, 2} and {1, 3} are infeasible +]) +def test_is_degenerate(game: gbt.Game, expected_result: bool): + """ + Verify is_degenerate against degenerate and nondegenerate games, with and + without a certificate, checking any witness in exact arithmetic. + """ + assert game.is_degenerate() is expected_result + result, witness = game.is_degenerate(certificate=True) + assert result is expected_result + if expected_result: + _assert_valid_witness(game, *witness) + else: + assert witness is None + + +@dataclasses.dataclass +class CertificateTestCase: + """TestCase for a game whose degeneracy has exactly one witness.""" + factory: typing.Callable[[], gbt.Game] + expected_player: int + expected_strategy: list + + +CERTIFICATE_CASES = [ + pytest.param( + CertificateTestCase( + # Only the row player's single strategy has tied best responses, (3, 3). + factory=lambda: gbt.StrategicGame.from_arrays([[1, 2, 3]], [[3, 3, 1]]), + expected_player=0, + expected_strategy=[1], + ), + id="pure_row_player" + ), + pytest.param( + CertificateTestCase( + # Only the first column gives the row player tied payoffs, (5, 5). + factory=lambda: gbt.StrategicGame.from_arrays([[5, 0], [5, 2]], [[1, 2], [3, 4]]), + expected_player=1, + expected_strategy=[1, 0], + ), + id="pure_column_player" + ), + pytest.param( + CertificateTestCase( + # Mixing (x, 1 - x) gives the column player (2x, 2 - 2x, 1): all equal only + # at x = 1/2. With two rows, the column player cannot have a witness. + factory=lambda: gbt.StrategicGame.from_arrays([[1, 2, 3], [6, 5, 4]], + [[2, 0, 1], [0, 2, 1]]), + expected_player=0, + expected_strategy=[Fraction(1, 2), Fraction(1, 2)], + ), + id="mixed_row_player" + ), + pytest.param( + CertificateTestCase( + # Mixing (y, 1 - y) gives the row player (3y, 3 - 3y, 3/2): all equal only + # at y = 1/2. With two columns, the row player cannot have a witness. + factory=lambda: gbt.StrategicGame.from_arrays( + [[3, 0], [0, 3], [Fraction(3, 2), Fraction(3, 2)]], [[4, 3], [2, 6], [3, 1]] + ), + expected_player=1, + expected_strategy=[Fraction(1, 2), Fraction(1, 2)], + ), + id="mixed_column_player" + ), + pytest.param( + CertificateTestCase( + # Rows {1, 2}: columns 2 and 3 always pay 0 and 1, never tied. + # Rows {1, 3}: (5x, 2 - 2x, 1) are never all equal. + # Rows {2, 3}: (2x, 2 - 2x, 1) are all equal at x = 1/2. + factory=lambda: gbt.StrategicGame.from_arrays( + [[1, 2, 3], [4, 6, 5], [9, 8, 7]], [[5, 0, 1], [2, 0, 1], [0, 2, 1]] + ), + expected_player=0, + expected_strategy=[0, Fraction(1, 2), Fraction(1, 2)], + ), + id="mixed_after_infeasible_supports" + ), +] + + +@pytest.mark.parametrize("test_case", CERTIFICATE_CASES) +def test_is_degenerate_certificate(test_case: CertificateTestCase): + """ + Verify the witness returned by is_degenerate(certificate=True) for games + in which it is unique. + """ + game = test_case.factory() + result, (player, strategy) = game.is_degenerate(certificate=True) + assert result is True + assert player == test_case.expected_player + assert list(strategy) == test_case.expected_strategy + _assert_valid_witness(game, player, strategy) + + +@pytest.mark.parametrize("strategies", [[3], [2, 2, 2]]) +def test_is_degenerate_rejects_non_two_player(strategies: list): + """is_degenerate is only defined for two-player games.""" + game = gbt.StrategicGame(strategies) + with pytest.raises(NotImplementedError, match="two-player"): + game.is_degenerate() -# --- Degenerate, pure, player 2 side (#3) --- -def test_degenerate_player2_pure(): - g = gbt.Game.from_arrays([[5, 0], [5, 2]], [[1, 2], [3, 4]]) - assert g.is_degenerate() is True +def _fake_lp_status(monkeypatch, status): + """Make every LP solved by cdd report `status`.""" + monkeypatch.setattr(cdd.gmp, "linprog_from_matrix", + lambda mat: types.SimpleNamespace(status=status)) + monkeypatch.setattr(cdd.gmp, "linprog_solve", lambda lp: None) -# --- Degenerate via LP, player 1 mixing (#4) --- -def test_degenerate_LP_player1_mixing(): - g = gbt.Game.from_arrays([[1, 2, 3], [4, 5, 6], [7, 8, 9]], - [[6, 0, 3], [0, 6, 3], [1, 2, 5]]) - assert g.is_degenerate() is True +def _rock_paper_scissors() -> gbt.Game: + return gbt.StrategicGame.from_arrays([[0, -1, 1], [1, 0, -1], [-1, 1, 0]], + [[0, 1, -1], [-1, 0, 1], [1, -1, 0]]) -# --- Not two players (#1) --- -def test_rejects_three_player(): - g = gbt.Game.new_table([2, 2, 2]) - with pytest.raises(NotImplementedError, match="two-player"): - g.is_degenerate() - - -# --- certificate=True, nondegenerate --- -def test_certificate_false_case(): - g = gbt.Game.from_arrays([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]) - result, cert = g.is_degenerate(certificate=True) - assert result is False - assert cert is None - - -# --- certificate validity: player 0 witness (#cert, #2) --- -def test_certificate_witnesses_degeneracy_player0(): - A = [[2, 1], [1, 1]] - B = [[1, 1], [1, 2]] - g = gbt.Game.from_arrays(A, B) - is_deg, (player, strategy) = g.is_degenerate(certificate=True) - assert is_deg is True - _assert_valid_witness(A, B, player, strategy) - - -# --- certificate validity: player 1 witness (#5, catches the index bug) --- -def test_certificate_witnesses_degeneracy_player1(): - A = [[3, 0], [0, 3], [Fraction(3, 2), Fraction(3, 2)]] - B = [[4, 3], [2, 6], [3, 1]] - g = gbt.Game.from_arrays(A, B) - is_deg, (player, strategy) = g.is_degenerate(certificate=True) - assert is_deg is True - assert player == 1 - _assert_valid_witness(A, B, player, strategy) - - -# --- 4x5 mixed-degeneracy witness validity --- -def test_4x5_mixed_witness_valid(): - A = [[7, 1, 2, 1, 3], [1, 7, 1, 2, 1], [2, 2, 7, 1, 2], [1, 3, 1, 7, 6]] - B = [[6, 0, 3, 1, 2], [0, 6, 3, 1, 0], [1, 2, 5, 0, 1], [2, 1, 0, 4, 3]] - g = gbt.Game.from_arrays(A, B) - is_deg, (player, strategy) = g.is_degenerate(certificate=True) - assert is_deg is True - _assert_valid_witness(A, B, player, strategy) - - -# --- Degenerate only through a mixed strategy (no pure ties) --- -def test_degenerate_only_via_mixed_strategy(): - A = [[1, 2, 3], [6, 5, 4]] - B = [[2, 0, 1], [0, 2, 1]] - g = gbt.Game.from_arrays(A, B) - is_deg, (player, strategy) = g.is_degenerate(certificate=True) - assert is_deg is True - assert player == 0 - assert list(strategy) == [Fraction(1, 2), Fraction(1, 2)] - _assert_valid_witness(A, B, player, strategy) - - -# --- Witness is a numpy array of exact rationals --- -def test_certificate_is_exact_rational_array(): - g = gbt.Game.from_arrays([[2, 1], [1, 1]], [[1, 1], [1, 2]]) - _, (_, strategy) = g.is_degenerate(certificate=True) - assert isinstance(strategy, np.ndarray) - assert all(isinstance(p, gbt.Rational) for p in strategy) +def test_is_degenerate_struc_inconsistent_lp(monkeypatch): + """An LP that cdd reports as structurally inconsistent has no witness.""" + _fake_lp_status(monkeypatch, cdd.LPStatusType.STRUC_INCONSISTENT) + assert _rock_paper_scissors().is_degenerate(certificate=True) == (False, None) -# --- Payoff ties that floating point would blur must still count exactly --- -def test_near_tie_is_not_degenerate(): - eps = Fraction(1, 10**12) - g = gbt.Game.from_arrays([[1, 0], [0, 1]], [[1, 1 + eps], [1 + eps, 1]]) - assert g.is_degenerate() is False +def test_is_degenerate_unexpected_lp_status(monkeypatch): + """An LP status other than optimal or infeasible is an error.""" + _fake_lp_status(monkeypatch, cdd.LPStatusType.UNBOUNDED) + with pytest.raises(RuntimeError, match="unexpected LP status"): + _rock_paper_scissors().is_degenerate() From 36897996a24f6bb6c8d5cf4cd99ce01f1bbbe8fb Mon Sep 17 00:00:00 2001 From: Uveys Date: Sun, 25 Oct 2026 19:30:38 +0000 Subject: [PATCH 4/6] Point macOS CI builds at Homebrew's cddlib and GMP pycddlib has no macOS wheels, so it is compiled from source in CI. Add Homebrew's include and library directories to the compiler and linker search paths, and build for arm64 only, since Homebrew's cddlib and GMP on the macOS runners are arm64-only. --- .github/workflows/python.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 221dfcbda4..627b1dd4e0 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -66,6 +66,10 @@ jobs: brew install cddlib gmp - name: Build extension run: | + # pycddlib builds from source against Homebrew's (arm64-only) cddlib and GMP + export CFLAGS="-I$(brew --prefix)/include" + export LDFLAGS="-L$(brew --prefix)/lib" + export ARCHFLAGS="-arch arm64" python -m pip install -v ".[test,doc]" pip install "gtdraw @ git+https://github.com/gambitproject/gtdraw.git@main" - name: Run tests @@ -92,6 +96,10 @@ jobs: brew install cddlib gmp - name: Build extension run: | + # pycddlib builds from source against Homebrew's (arm64-only) cddlib and GMP + export CFLAGS="-I$(brew --prefix)/include" + export LDFLAGS="-L$(brew --prefix)/lib" + export ARCHFLAGS="-arch arm64" python -m pip install -v ".[test,doc]" pip install "gtdraw @ git+https://github.com/gambitproject/gtdraw.git@main" - name: Run tests From e1cb1290a845407c4230b434aa2b3f79c363de96 Mon Sep 17 00:00:00 2001 From: Uveys Date: Mon, 26 Oct 2026 03:08:00 +0000 Subject: [PATCH 5/6] Install cddlib and GMP in the catalog.am lint job The job installs pygambit, which builds pycddlib from source, so it needs the cddlib and GMP development libraries like the Linux test job. --- .github/workflows/lint.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f098605eb1..ee5d109ae5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -74,6 +74,7 @@ jobs: cache-dependency-path: pyproject.toml - name: Install pygambit (editable, picks up catalog/games directly) run: | + sudo apt-get update && sudo apt-get install -y libcdd-dev libgmp-dev python -m pip install -v -e ".[doc]" pip install "gtdraw @ git+https://github.com/gambitproject/gtdraw.git@main" - name: Regenerate catalog/catalog.am From dbec86699a25f7e6c1453b624e0361c6eca1512d Mon Sep 17 00:00:00 2001 From: Uveys Date: Tue, 27 Oct 2026 14:46:52 +0000 Subject: [PATCH 6/6] Name is_degenerate test cases and use gbt.Rational Give each game in the parametrized table a pytest id instead of a trailing comment, build test games with gbt.Rational as the rest of the test suite does, and store certificate test games directly rather than through factory functions. --- tests/test_degeneracy.py | 145 ++++++++++++++++++++------------------- 1 file changed, 76 insertions(+), 69 deletions(-) diff --git a/tests/test_degeneracy.py b/tests/test_degeneracy.py index 70ece02d9f..afd6f7db2c 100644 --- a/tests/test_degeneracy.py +++ b/tests/test_degeneracy.py @@ -1,7 +1,5 @@ import dataclasses import types -import typing -from fractions import Fraction import cdd import cdd.gmp @@ -14,7 +12,7 @@ # Payoff difference far below double precision: float(1 + EPS) == 1.0, so an # implementation using floating point would see a tie where there is none. -EPS = Fraction(1, 10**20) +EPS = gbt.Rational(1, 10**20) def _assert_valid_witness(game: gbt.Game, player: int, strategy): @@ -35,60 +33,68 @@ def _assert_valid_witness(game: gbt.Game, player: int, strategy): @pytest.mark.parametrize("game,expected_result", [ # Nondegenerate games - # - 2x2: no support of size 2 can have 3 best responses, so no LP is solved - (gbt.StrategicGame.from_arrays([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]), - False), # matching pennies; SageMath - (gbt.StrategicGame.from_arrays([[2, 5], [0, 4]], [[2, 0], [5, 4]]), - False), # prisoner's dilemma; SageMath - # - larger games, where every candidate LP is infeasible - (gbt.StrategicGame.from_arrays([[0, -1, 1], [1, 0, -1], [-1, 1, 0]], - [[0, 1, -1], [-1, 0, 1], [1, -1, 0]]), - False), # rock-paper-scissors; SageMath - (gbt.catalog.load("journals/other/shapley1974/fig2"), False), - (gbt.catalog.load("journals/other/shapley1974/fig3"), False), - (gbt.catalog.load("journals/dcg/vonstengel1999/6x6_game_with_75_eq"), - False), # constructed to be nondegenerate with many equilibria - (games.read_from_file("coordination_4x4_payoff.nfg"), False), - # - one player has a single strategy, so only pure strategies are checked - (gbt.StrategicGame.from_arrays([[1, 2, 3]], [[1, 2, 3]]), False), - # - near-ties which are not ties in exact arithmetic - (gbt.StrategicGame.from_arrays([[1, 0], [0, 1]], [[1, 1 + EPS], [1 + EPS, 1]]), - False), # pure strategies - (gbt.StrategicGame.from_arrays([[1, 2, 3], [6, 5, 4]], [[2, 0, 1 - EPS], [0, 2, 1 - EPS]]), - False), # mixing (1/2, 1/2) leaves column 3 just short of a best response - - # Degenerate games - # - a pure strategy has more than one pure best response - (gbt.StrategicGame.from_arrays([[3, 3], [2, 5], [0, 6]], [[3, 3], [2, 6], [3, 1]]), - True), # Avis, Rosenberg, Savani and von Stengel (2010), via SageMath - (gbt.StrategicGame.from_arrays([[0, 6], [2, 5], [3, 3]], [[1, 0], [0, 2], [4, 4]]), - True), # Nisan, Roughgarden, Tardos and Vazirani (2007), via SageMath - (gbt.StrategicGame.from_arrays([[2, 1], [1, 1]], [[1, 1], [1, 2]]), True), # SageMath - (gbt.StrategicGame.from_arrays([[0, -1, 1, 1, -1], [1, 0, -1, -1, 1], - [-1, 1, 0, 1, -1], [-1, 1, -1, 0, 1], - [1, -1, 1, -1, 0]], - [[0, 1, -1, -1, 1], [-1, 0, 1, 1, -1], - [1, -1, 0, -1, 1], [1, -1, 1, 0, -1], - [-1, 1, -1, 1, 0]]), - True), # rock-paper-scissors-lizard-Spock; SageMath - (gbt.StrategicGame.from_arrays([[0, 0, 0]] * 3, [[0, 0, 0]] * 3), True), # SageMath - (gbt.StrategicGame.from_arrays([[-75, 18, 45, 33], [42, -8, -77, -18], - [83, 18, 11, 40], [-10, -38, 76, -9]], - [[62, 64, 87, 51], [-41, -27, -69, 52], - [-17, 25, -97, -82], [30, 31, -1, 50]]), - True), # SageMath; only the column player's pure strategies have ties - (gbt.StrategicGame.from_arrays([[1, 2, 3]], [[3, 3, 1]]), True), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), - True), # extensive game, via its reduced strategic form - # - only a mixed strategy has more best responses than its support size - (gbt.StrategicGame.from_arrays([[1, 2, 3], [6, 5, 4]], [[2, 0, 1], [0, 2, 1]]), - True), # row player mixes - (gbt.StrategicGame.from_arrays([[3, 0], [0, 3], [Fraction(3, 2), Fraction(3, 2)]], - [[4, 3], [2, 6], [3, 1]]), - True), # column player mixes; SageMath - (gbt.StrategicGame.from_arrays([[1, 2, 3], [4, 6, 5], [9, 8, 7]], - [[5, 0, 1], [2, 0, 1], [0, 2, 1]]), - True), # found only after the LPs for rows {1, 2} and {1, 3} are infeasible + pytest.param(gbt.StrategicGame.from_arrays([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]), + False, id="matching_pennies"), + pytest.param(gbt.StrategicGame.from_arrays([[2, 5], [0, 4]], [[2, 0], [5, 4]]), + False, id="prisoners_dilemma"), + pytest.param(gbt.StrategicGame.from_arrays([[0, -1, 1], [1, 0, -1], [-1, 1, 0]], + [[0, 1, -1], [-1, 0, 1], [1, -1, 0]]), + False, id="rock_paper_scissors"), + pytest.param(gbt.catalog.load("journals/other/shapley1974/fig2"), + False, id="shapley1974_fig2"), + pytest.param(gbt.catalog.load("journals/other/shapley1974/fig3"), + False, id="shapley1974_fig3"), + pytest.param(gbt.catalog.load("journals/dcg/vonstengel1999/6x6_game_with_75_eq"), + False, id="vonstengel1999_6x6"), + pytest.param(games.read_from_file("coordination_4x4_payoff.nfg"), + False, id="coordination_4x4"), + pytest.param(gbt.StrategicGame.from_arrays([[1, 2, 3]], [[1, 2, 3]]), + False, id="single_row_no_ties"), + # Payoffs differ by EPS, so these are not ties in exact arithmetic + pytest.param(gbt.StrategicGame.from_arrays([[1, 0], [0, 1]], [[1, 1 + EPS], [1 + EPS, 1]]), + False, id="near_tie_pure"), + pytest.param(gbt.StrategicGame.from_arrays([[1, 2, 3], [6, 5, 4]], + [[2, 0, 1 - EPS], [0, 2, 1 - EPS]]), + False, id="near_tie_mixed"), + + # Degenerate games: a pure strategy has more than one pure best response + pytest.param(gbt.StrategicGame.from_arrays([[3, 3], [2, 5], [0, 6]], + [[3, 3], [2, 6], [3, 1]]), + True, id="avis_rosenberg_savani_vonstengel2010"), + pytest.param(gbt.StrategicGame.from_arrays([[0, 6], [2, 5], [3, 3]], + [[1, 0], [0, 2], [4, 4]]), + True, id="nisan_roughgarden_tardos_vazirani2007"), + pytest.param(gbt.StrategicGame.from_arrays([[2, 1], [1, 1]], [[1, 1], [1, 2]]), + True, id="sage_2x2"), + pytest.param(gbt.StrategicGame.from_arrays([[0, -1, 1, 1, -1], [1, 0, -1, -1, 1], + [-1, 1, 0, 1, -1], [-1, 1, -1, 0, 1], + [1, -1, 1, -1, 0]], + [[0, 1, -1, -1, 1], [-1, 0, 1, 1, -1], + [1, -1, 0, -1, 1], [1, -1, 1, 0, -1], + [-1, 1, -1, 1, 0]]), + True, id="rock_paper_scissors_lizard_spock"), + pytest.param(gbt.StrategicGame.from_arrays([[0, 0, 0]] * 3, [[0, 0, 0]] * 3), + True, id="all_zero_3x3"), + pytest.param(gbt.StrategicGame.from_arrays([[-75, 18, 45, 33], [42, -8, -77, -18], + [83, 18, 11, 40], [-10, -38, 76, -9]], + [[62, 64, 87, 51], [-41, -27, -69, 52], + [-17, 25, -97, -82], [30, 31, -1, 50]]), + True, id="sage_4x4_column_ties_only"), + pytest.param(gbt.StrategicGame.from_arrays([[1, 2, 3]], [[3, 3, 1]]), + True, id="single_row_tie"), + pytest.param(gbt.catalog.load("journals/ijgt/selten1975/fig2"), + True, id="selten1975_fig2_extensive"), + # Degenerate games: only a mixed strategy has too many best responses + pytest.param(gbt.StrategicGame.from_arrays([[1, 2, 3], [6, 5, 4]], + [[2, 0, 1], [0, 2, 1]]), + True, id="mixed_row_player"), + pytest.param(gbt.StrategicGame.from_arrays([[3, 0], [0, 3], + [gbt.Rational(3, 2), gbt.Rational(3, 2)]], + [[4, 3], [2, 6], [3, 1]]), + True, id="mixed_column_player"), + pytest.param(gbt.StrategicGame.from_arrays([[1, 2, 3], [4, 6, 5], [9, 8, 7]], + [[5, 0, 1], [2, 0, 1], [0, 2, 1]]), + True, id="mixed_after_infeasible_supports"), ]) def test_is_degenerate(game: gbt.Game, expected_result: bool): """ @@ -107,7 +113,7 @@ def test_is_degenerate(game: gbt.Game, expected_result: bool): @dataclasses.dataclass class CertificateTestCase: """TestCase for a game whose degeneracy has exactly one witness.""" - factory: typing.Callable[[], gbt.Game] + game: gbt.Game expected_player: int expected_strategy: list @@ -116,7 +122,7 @@ class CertificateTestCase: pytest.param( CertificateTestCase( # Only the row player's single strategy has tied best responses, (3, 3). - factory=lambda: gbt.StrategicGame.from_arrays([[1, 2, 3]], [[3, 3, 1]]), + game=gbt.StrategicGame.from_arrays([[1, 2, 3]], [[3, 3, 1]]), expected_player=0, expected_strategy=[1], ), @@ -125,7 +131,7 @@ class CertificateTestCase: pytest.param( CertificateTestCase( # Only the first column gives the row player tied payoffs, (5, 5). - factory=lambda: gbt.StrategicGame.from_arrays([[5, 0], [5, 2]], [[1, 2], [3, 4]]), + game=gbt.StrategicGame.from_arrays([[5, 0], [5, 2]], [[1, 2], [3, 4]]), expected_player=1, expected_strategy=[1, 0], ), @@ -135,10 +141,10 @@ class CertificateTestCase: CertificateTestCase( # Mixing (x, 1 - x) gives the column player (2x, 2 - 2x, 1): all equal only # at x = 1/2. With two rows, the column player cannot have a witness. - factory=lambda: gbt.StrategicGame.from_arrays([[1, 2, 3], [6, 5, 4]], - [[2, 0, 1], [0, 2, 1]]), + game=gbt.StrategicGame.from_arrays([[1, 2, 3], [6, 5, 4]], + [[2, 0, 1], [0, 2, 1]]), expected_player=0, - expected_strategy=[Fraction(1, 2), Fraction(1, 2)], + expected_strategy=[gbt.Rational(1, 2), gbt.Rational(1, 2)], ), id="mixed_row_player" ), @@ -146,11 +152,12 @@ class CertificateTestCase: CertificateTestCase( # Mixing (y, 1 - y) gives the row player (3y, 3 - 3y, 3/2): all equal only # at y = 1/2. With two columns, the row player cannot have a witness. - factory=lambda: gbt.StrategicGame.from_arrays( - [[3, 0], [0, 3], [Fraction(3, 2), Fraction(3, 2)]], [[4, 3], [2, 6], [3, 1]] + game=gbt.StrategicGame.from_arrays( + [[3, 0], [0, 3], [gbt.Rational(3, 2), gbt.Rational(3, 2)]], + [[4, 3], [2, 6], [3, 1]], ), expected_player=1, - expected_strategy=[Fraction(1, 2), Fraction(1, 2)], + expected_strategy=[gbt.Rational(1, 2), gbt.Rational(1, 2)], ), id="mixed_column_player" ), @@ -159,11 +166,11 @@ class CertificateTestCase: # Rows {1, 2}: columns 2 and 3 always pay 0 and 1, never tied. # Rows {1, 3}: (5x, 2 - 2x, 1) are never all equal. # Rows {2, 3}: (2x, 2 - 2x, 1) are all equal at x = 1/2. - factory=lambda: gbt.StrategicGame.from_arrays( + game=gbt.StrategicGame.from_arrays( [[1, 2, 3], [4, 6, 5], [9, 8, 7]], [[5, 0, 1], [2, 0, 1], [0, 2, 1]] ), expected_player=0, - expected_strategy=[0, Fraction(1, 2), Fraction(1, 2)], + expected_strategy=[0, gbt.Rational(1, 2), gbt.Rational(1, 2)], ), id="mixed_after_infeasible_supports" ), @@ -176,7 +183,7 @@ def test_is_degenerate_certificate(test_case: CertificateTestCase): Verify the witness returned by is_degenerate(certificate=True) for games in which it is unique. """ - game = test_case.factory() + game = test_case.game result, (player, strategy) = game.is_degenerate(certificate=True) assert result is True assert player == test_case.expected_player