From 156083bcfc148c6d890a652f004d0dd863035489 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 23 Dec 2025 15:45:09 +0000 Subject: [PATCH 1/2] Draft structure of organising test cases as dataclasses, using subtests. --- tests/requirements.txt | 1 + tests/test_nash.py | 108 ++++++++++++++++++++++++++++------------- 2 files changed, 75 insertions(+), 34 deletions(-) diff --git a/tests/requirements.txt b/tests/requirements.txt index 545fd9b00..37cbc5ded 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,4 +1,5 @@ pytest +pytest-subtests nbformat nbclient ipykernel diff --git a/tests/test_nash.py b/tests/test_nash.py index adf5552df..4967528e7 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -7,6 +7,11 @@ lp_solve, lcp_solve, and enumpoly_solve, all in mixed behaviors. """ +import dataclasses +import functools +import typing +from fractions import Fraction as Q + import pytest import pygambit as gbt @@ -37,49 +42,84 @@ def test_enummixed_double(): # For floating-point results are not exact, so we skip testing exact values for now +def d(*probs) -> tuple: + """Helper function to let us write d() to be suggestive of + "probability distribution on simplex" ("Delta") + """ + return tuple(probs) + + +@dataclasses.dataclass +class EquilibriumTestCase: + """Summarising the data relevant for a test fixture of a call to an equilibrium solver.""" + label: str + factory: typing.Callable[[], gbt.Game] + expected: list + + +NASH_ENUMMIXED_RATIONAL_CASES = [ + EquilibriumTestCase( + label="test1", + factory=games.create_stripped_down_poker_efg, + expected=[ + [d(Q("1/3"), Q("2/3"), 0, 0), d(Q("2/3"), Q("1/3"))], + ] + ), + EquilibriumTestCase( + label="test2", + factory=games.create_one_shot_trust_efg, + expected=[ + [d(0, 1), d(Q("1/2"), Q("1/2"))], + [d(0, 1), d(0, 1)], + ] + ), + EquilibriumTestCase( + label="test3", + factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=3), + expected=[ + [d(1, 0, 0), d(1, 0, 0)], + [d(Q("1/2"), Q("1/2"), 0), d(Q("1/2"), Q("1/2"), 0)], + [d(Q("1/3"), Q("1/3"), Q("1/3")), d(Q("1/3"), Q("1/3"), Q("1/3"))], + [d(Q("1/2"), 0, Q("1/2")), d(Q("1/2"), 0, Q("1/2"))], + [d(0, 1, 0), d(0, 1, 0)], + [d(0, Q("1/2"), Q("1/2")), d(0, Q("1/2"), Q("1/2"))], + [d(0, 0, 1), d(0, 0, 1)], + ] + ), + EquilibriumTestCase( + label="test4", + factory=games.create_EFG_for_6x6_bimatrix_with_long_LH_paths_and_unique_eq, + expected=[ + [d(Q("1/30"), Q("1/6"), Q("3/10"), Q("3/10"), Q("1/6"), Q("1/30")), + d(Q("1/6"), Q("1/30"), Q("3/10"), Q("3/10"), Q("1/30"), Q("1/6"))], + ] + ), +] + + @pytest.mark.nash @pytest.mark.nash_enummixed_strategy @pytest.mark.parametrize( - "game,mixed_strategy_prof_data", - [ - # Zero-sum games - (games.create_stripped_down_poker_efg(), [[["1/3", "2/3", 0, 0], ["2/3", "1/3"]]]), - # Non-zero-sum games - (games.create_one_shot_trust_efg(), [[[0, 1], ["1/2", "1/2"]], - [[0, 1], [0, 1]]]), - ( - games.create_EFG_for_nxn_bimatrix_coordination_game(3), - [ - [[1, 0, 0], [1, 0, 0]], - [["1/2", "1/2", 0], ["1/2", "1/2", 0]], - [["1/3", "1/3", "1/3"], ["1/3", "1/3", "1/3"]], - [["1/2", 0, "1/2"], ["1/2", 0, "1/2"]], - [[0, 1, 0], [0, 1, 0]], - [[0, "1/2", "1/2"], [0, "1/2", "1/2"]], - [[0, 0, 1], [0, 0, 1]], - ], - ), - ( - games.create_EFG_for_6x6_bimatrix_with_long_LH_paths_and_unique_eq(), - [ - [["1/30", "1/6", "3/10", "3/10", "1/6", "1/30"], - ["1/6", "1/30", "3/10", "3/10", "1/30", "1/6"]], - ], - ), - ] + "test_case", NASH_ENUMMIXED_RATIONAL_CASES, ids=lambda c: c.label ) -def test_enummixed_rational(game: gbt.Game, mixed_strategy_prof_data: list): +def test_enummixed_rational( + test_case: EquilibriumTestCase, + subtests, +) -> None: """Test calls of enumeration of extreme mixed strategy equilibria, rational precision Tests max regret being zero (internal consistency) and compares the computed sequence of - extreme equilibria to a previosuly computed sequence (regression test) + extreme equilibria to a previously-computed sequence (regression test) """ + game = test_case.factory() result = gbt.nash.enummixed_solve(game, rational=True) - assert len(result.equilibria) == len(mixed_strategy_prof_data) - for eq, exp in zip(result.equilibria, mixed_strategy_prof_data, strict=True): - assert eq.max_regret() == 0 - expected = game.mixed_strategy_profile(rational=True, data=exp) - assert eq == expected + 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)): + with subtests.test(eq=i, check="max_regret"): + assert eq.max_regret() == 0 + with subtests.test(eq=i, check="strategy_profile"): + assert eq == game.mixed_strategy_profile(rational=True, data=exp) @pytest.mark.nash From 549b2e6f64b5188fa0c0078da35f7674e675b573 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Tue, 23 Dec 2025 16:10:49 +0000 Subject: [PATCH 2/2] Suggestion for writing a generic Nash tester. --- tests/test_nash.py | 113 ++++++++++++++++++++++++++------------------- 1 file changed, 66 insertions(+), 47 deletions(-) diff --git a/tests/test_nash.py b/tests/test_nash.py index 4967528e7..ec196f0c7 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -10,11 +10,11 @@ import dataclasses import functools import typing -from fractions import Fraction as Q import pytest import pygambit as gbt +from pygambit import Rational as Q from . import games @@ -52,74 +52,93 @@ def d(*probs) -> tuple: @dataclasses.dataclass class EquilibriumTestCase: """Summarising the data relevant for a test fixture of a call to an equilibrium solver.""" - label: str factory: typing.Callable[[], gbt.Game] + solver: typing.Callable[[gbt.Game], gbt.nash.NashComputationResult] expected: list + regret_tol: float | gbt.Rational = Q(0) + prob_tol: float | gbt.Rational = Q(0) NASH_ENUMMIXED_RATIONAL_CASES = [ - EquilibriumTestCase( - label="test1", - factory=games.create_stripped_down_poker_efg, - expected=[ - [d(Q("1/3"), Q("2/3"), 0, 0), d(Q("2/3"), Q("1/3"))], - ] - ), - EquilibriumTestCase( - label="test2", - factory=games.create_one_shot_trust_efg, - expected=[ - [d(0, 1), d(Q("1/2"), Q("1/2"))], - [d(0, 1), d(0, 1)], - ] + pytest.param( + EquilibriumTestCase( + factory=games.create_stripped_down_poker_efg, + solver=functools.partial(gbt.nash.enummixed_solve, rational=True), + expected=[ + [d(Q("1/3"), Q("2/3"), 0, 0), d(Q("2/3"), Q("1/3"))], + ], + ), + marks=pytest.mark.nash_enummixed_strategy, + id="test1", ), - EquilibriumTestCase( - label="test3", - factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=3), - expected=[ - [d(1, 0, 0), d(1, 0, 0)], - [d(Q("1/2"), Q("1/2"), 0), d(Q("1/2"), Q("1/2"), 0)], - [d(Q("1/3"), Q("1/3"), Q("1/3")), d(Q("1/3"), Q("1/3"), Q("1/3"))], - [d(Q("1/2"), 0, Q("1/2")), d(Q("1/2"), 0, Q("1/2"))], - [d(0, 1, 0), d(0, 1, 0)], - [d(0, Q("1/2"), Q("1/2")), d(0, Q("1/2"), Q("1/2"))], - [d(0, 0, 1), d(0, 0, 1)], - ] + pytest.param( + EquilibriumTestCase( + factory=games.create_one_shot_trust_efg, + solver=functools.partial(gbt.nash.enummixed_solve, rational=True), + expected=[ + [d(0, 1), d(Q("1/2"), Q("1/2"))], + [d(0, 1), d(0, 1)], + ], + ), + marks=pytest.mark.nash_enummixed_strategy, + id="test2", ), - EquilibriumTestCase( - label="test4", - factory=games.create_EFG_for_6x6_bimatrix_with_long_LH_paths_and_unique_eq, - expected=[ - [d(Q("1/30"), Q("1/6"), Q("3/10"), Q("3/10"), Q("1/6"), Q("1/30")), - d(Q("1/6"), Q("1/30"), Q("3/10"), Q("3/10"), Q("1/30"), Q("1/6"))], - ] + pytest.param( + EquilibriumTestCase( + factory=functools.partial(games.create_EFG_for_nxn_bimatrix_coordination_game, n=3), + solver=functools.partial(gbt.nash.enummixed_solve, rational=True), + expected=[ + [d(1, 0, 0), d(1, 0, 0)], + [d(Q("1/2"), Q("1/2"), 0), d(Q("1/2"), Q("1/2"), 0)], + [d(Q("1/3"), Q("1/3"), Q("1/3")), d(Q("1/3"), Q("1/3"), Q("1/3"))], + [d(Q("1/2"), 0, Q("1/2")), d(Q("1/2"), 0, Q("1/2"))], + [d(0, 1, 0), d(0, 1, 0)], + [d(0, Q("1/2"), Q("1/2")), d(0, Q("1/2"), Q("1/2"))], + [d(0, 0, 1), d(0, 0, 1)], + ] + ), + marks=pytest.mark.nash_enummixed_strategy, + id="test3", ), + pytest.param( + EquilibriumTestCase( + factory=games.create_EFG_for_6x6_bimatrix_with_long_LH_paths_and_unique_eq, + solver=functools.partial(gbt.nash.enummixed_solve, rational=True), + expected=[ + [d(Q("1/30"), Q("1/6"), Q("3/10"), Q("3/10"), Q("1/6"), Q("1/30")), + d(Q("1/6"), Q("1/30"), Q("3/10"), Q("3/10"), Q("1/30"), Q("1/6"))], + ] + ), + marks=pytest.mark.nash_enummixed_strategy, + id="test4", + ) ] @pytest.mark.nash -@pytest.mark.nash_enummixed_strategy @pytest.mark.parametrize( "test_case", NASH_ENUMMIXED_RATIONAL_CASES, ids=lambda c: c.label ) -def test_enummixed_rational( - test_case: EquilibriumTestCase, - subtests, -) -> None: - """Test calls of enumeration of extreme mixed strategy equilibria, rational precision - - Tests max regret being zero (internal consistency) and compares the computed sequence of - extreme equilibria to a previously-computed sequence (regression test) +def test_nash_strategy_solver(test_case: EquilibriumTestCase, subtests) -> None: + """Test calls of Nash solvers. + + Subtests: + - Max regret no more than `test_case.regret_tol` + - Equilibria are output in the expected order. Equilibria are deemed to match if the maximum + difference in probabilities is no more than `test_case.prob_tol` """ game = test_case.factory() - result = gbt.nash.enummixed_solve(game, rational=True) + result = test_case.solver(game) 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)): with subtests.test(eq=i, check="max_regret"): - assert eq.max_regret() == 0 + assert eq.max_regret() <= test_case.regret_tol with subtests.test(eq=i, check="strategy_profile"): - assert eq == game.mixed_strategy_profile(rational=True, data=exp) + expected = game.mixed_strategy_profile(rational=True, data=exp) + for player in game.players: + for strategy in player.strategies: + assert abs(eq[strategy] - expected[strategy]) <= test_case.prob_tol @pytest.mark.nash