diff --git a/ChangeLog b/ChangeLog index e6bd1f6f2..fcb6c900c 100644 --- a/ChangeLog +++ b/ChangeLog @@ -24,6 +24,8 @@ - Max regret is calculated correctly for strategy and behavior profiles on games with zero player strategies or actions (is defined to be 0 trivially) (#904) - Corrected calculation of total number of actions for a player in `pygambit` (#938) +- Corrected a regression in action graph games that left the internal data structure not fully initialised, + leading to segmentation faults. ### Changed - Added a new welcome/landing window on launching the GUI without a game. This has the effect of diff --git a/src/games/gameagg.cc b/src/games/gameagg.cc index 6341459c4..a702e8b01 100644 --- a/src/games/gameagg.cc +++ b/src/games/gameagg.cc @@ -186,6 +186,7 @@ GameAGGRep::GameAGGRep(std::shared_ptr p_aggPtr) : aggPtr(p_aggPtr) s->m_label = std::to_string(st++); }); } + IndexStrategies(); } Game GameAGGRep::Copy() const diff --git a/src/games/gamebagg.cc b/src/games/gamebagg.cc index dee69915a..fea3bb695 100644 --- a/src/games/gamebagg.cc +++ b/src/games/gamebagg.cc @@ -223,6 +223,7 @@ GameBAGGRep::GameBAGGRep(std::shared_ptr _baggPtr) }); } } + IndexStrategies(); } Game GameBAGGRep::Copy() const diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 5e7b272c0..b3afbefc4 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -281,7 +281,8 @@ cdef extern from "games/game.h": iterator begin() except + iterator end() except + - int IsTree() except + + bool IsTree() except + + bool IsAgg() except + string GetTitle() except + void SetTitle(string) except + diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 928371786..ed2806ef2 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -972,8 +972,8 @@ class Game: self.game.deref().GetPlayer(pl+1).deref().GetStrategy(st+1) ) - if self.is_tree: - return TreeGameOutcome.wrap(self.game, psp) + if self.is_tree or self.game.deref().IsAgg(): + return DerivedGameOutcome.wrap(self.game, psp) else: outcome = Outcome.wrap(deref(deref(psp).deref()).GetOutcome()) if outcome.outcome != cython.cast(c_GameOutcome, NULL): diff --git a/src/pygambit/outcome.pxi b/src/pygambit/outcome.pxi index 06ff80a0e..6f3e62f23 100644 --- a/src/pygambit/outcome.pxi +++ b/src/pygambit/outcome.pxi @@ -126,8 +126,10 @@ class Outcome: @cython.cclass -class TreeGameOutcome: - """Represents an outcome in a strategic game derived from an extensive game.""" +class DerivedGameOutcome: + """Represents an outcome in a strategic game derived from a game in another representation. + Such outcomes are one-to-one with the set of pure strategy profiles. + """ c_game = cython.declare(c_Game) psp = cython.declare(shared_ptr[c_PureStrategyProfile]) @@ -136,8 +138,8 @@ class TreeGameOutcome: @staticmethod @cython.cfunc - def wrap(game: c_Game, psp: shared_ptr[c_PureStrategyProfile]) -> TreeGameOutcome: - obj: TreeGameOutcome = TreeGameOutcome.__new__(TreeGameOutcome) + def wrap(game: c_Game, psp: shared_ptr[c_PureStrategyProfile]) -> DerivedGameOutcome: + obj: DerivedGameOutcome = DerivedGameOutcome.__new__(DerivedGameOutcome) obj.c_game = game obj.psp = psp return obj @@ -152,8 +154,8 @@ class TreeGameOutcome: def __eq__(self, other: typing.Any) -> bool: return ( - isinstance(other, TreeGameOutcome) and - deref(self.psp).deref() == deref(cython.cast(TreeGameOutcome, other).psp).deref() + isinstance(other, DerivedGameOutcome) and + deref(self.psp).deref() == deref(cython.cast(DerivedGameOutcome, other).psp).deref() ) def __getitem__(self, player: Player | str) -> Rational: diff --git a/tests/games.py b/tests/games.py index 312854b43..b3e1753f1 100644 --- a/tests/games.py +++ b/tests/games.py @@ -14,6 +14,10 @@ def read_from_file(fn: str) -> gbt.Game: return gbt.read_efg(pathlib.Path("tests/test_games") / fn) elif fn.endswith(".nfg"): return gbt.read_nfg(pathlib.Path("tests/test_games") / fn) + elif fn.endswith(".agg"): + return gbt.read_agg(pathlib.Path("tests/test_games") / fn) + elif fn.endswith(".bagg"): + return gbt.read_bagg(pathlib.Path("tests/test_games") / fn) else: raise ValueError(f"Unknown file extension in {fn}") diff --git a/tests/test_games/2x2.agg b/tests/test_games/2x2.agg index 3d1ef04d6..e29e5e8c9 100644 --- a/tests/test_games/2x2.agg +++ b/tests/test_games/2x2.agg @@ -1,20 +1,4 @@ #AGG -# Generated by GAMUT v1.0.1 -# Random Symmetric Action Graph Game -# Game Parameter Values: -# Random seed: 1306765487422 -# Cmd Line: -players 2 -actions 2 -g RandomSymmetricAGG -output SpecialOutput -random_params -f 2x2.agg -# Players: 2 -# Actions: 2 2 -# players: 2 -# actions: [2] -# graph: RandomGraph -# graph_params: null -# Graph Params: -# { nodes: 2, edges: 4, sym_edges: false, reflex_ok: true } -# Players: 2 -# Actions: [ 2 2 ] - #number of players: 2 #number of action nodes: @@ -45,5 +29,5 @@ #now the payoff values: one row per action node. #For each row: first, the type of the payoff format #Then payoffs are given in lexicographical order of the input configurations -0 35.622809717175556 -3.7188980070375948 -0 -10.180526107272556 95.1203958671928 +0 35 -4 +0 -10 95 diff --git a/tests/test_games/2x2_small_payoffs.agg b/tests/test_games/2x2_small_payoffs.agg new file mode 100644 index 000000000..cdbd6fe60 --- /dev/null +++ b/tests/test_games/2x2_small_payoffs.agg @@ -0,0 +1,34 @@ +#AGG + +#number of players: +2 +#number of action nodes: +2 +#number of func nodes: +0 + +#sizes of action sets: +2 2 + +#action sets: +0 1 +0 1 + + +#the action graph: +2 0 1 +2 1 0 + +#the types of func nodes: +#0: sum +#1: existence +#2: highest +#3: lowest + + +#the payoffs: +#now the payoff values: one row per action node. +#For each row: first, the type of the payoff format +#Then payoffs are given in lexicographical order of the input configurations +0 4 -1 +0 -2 5 diff --git a/tests/test_games/Bayesian-Coffee-3-2-2-3.bagg b/tests/test_games/Bayesian-Coffee-3-2-2-3.bagg new file mode 100644 index 000000000..5efd66b52 --- /dev/null +++ b/tests/test_games/Bayesian-Coffee-3-2-2-3.bagg @@ -0,0 +1,211 @@ +#BAGG +3 +13 +18 +2 2 2 +0.5 0.5 +0.5 0.5 +0.5 0.5 +7 7 +7 7 +7 7 +0 1 2 3 4 5 6 +0 7 8 9 10 11 12 +0 1 2 3 4 5 6 +0 7 8 9 10 11 12 +0 1 2 3 4 5 6 +0 7 8 9 10 11 12 +0 +3 13 19 25 +3 14 20 26 +3 15 21 27 +3 16 22 28 +3 17 23 29 +3 18 24 30 +3 13 19 25 +3 14 20 26 +3 15 21 27 +3 16 22 28 +3 17 23 29 +3 18 24 30 +2 1 7 +2 2 8 +2 3 9 +2 4 10 +2 5 11 +2 6 12 +3 14 16 17 +5 13 15 16 17 18 +3 14 17 18 +3 13 14 17 +5 13 14 15 16 18 +3 14 15 17 +2 15 18 +0 +2 13 16 +2 15 18 +0 +2 13 16 + +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 +0 + +1 +1 +[ ] 89 + +1 +10 +[ 1 0 0 ] 94 +[ 3 0 0 ] 61 +[ 2 0 0 ] 35 +[ 1 2 0 ] 29 +[ 2 1 0 ] 58 +[ 1 1 0 ] 98 +[ 1 0 2 ] 38 +[ 1 1 1 ] 43 +[ 2 0 1 ] 35 +[ 1 0 1 ] 58 + +1 +6 +[ 3 0 0 ] 68 +[ 1 2 0 ] 65 +[ 2 1 0 ] 31 +[ 1 0 0 ] 11 +[ 1 1 0 ] 34 +[ 2 0 0 ] 58 + +1 +10 +[ 3 0 0 ] 96 +[ 1 2 0 ] 79 +[ 2 1 0 ] 86 +[ 1 0 2 ] 55 +[ 1 1 1 ] 79 +[ 2 0 1 ] 24 +[ 1 0 0 ] 37 +[ 1 0 1 ] 36 +[ 1 1 0 ] 99 +[ 2 0 0 ] 90 + +1 +10 +[ 1 0 0 ] 9 +[ 1 2 0 ] 38 +[ 1 1 0 ] 27 +[ 1 0 2 ] 50 +[ 1 1 1 ] 41 +[ 1 0 1 ] 96 +[ 3 0 0 ] 74 +[ 2 0 1 ] 82 +[ 2 1 0 ] 75 +[ 2 0 0 ] 99 + +1 +6 +[ 1 0 0 ] 55 +[ 1 2 0 ] 70 +[ 1 1 0 ] 46 +[ 3 0 0 ] 15 +[ 2 1 0 ] 60 +[ 2 0 0 ] 29 + +1 +10 +[ 1 0 0 ] 81 +[ 1 0 2 ] 23 +[ 1 0 1 ] 63 +[ 1 2 0 ] 54 +[ 1 1 1 ] 60 +[ 1 1 0 ] 0 +[ 3 0 0 ] 71 +[ 2 1 0 ] 14 +[ 2 0 1 ] 78 +[ 2 0 0 ] 37 + +1 +10 +[ 1 0 0 ] 3 +[ 3 0 0 ] 30 +[ 2 0 0 ] 7 +[ 1 2 0 ] 85 +[ 2 1 0 ] 35 +[ 1 1 0 ] 26 +[ 1 0 2 ] 41 +[ 1 1 1 ] 52 +[ 2 0 1 ] 79 +[ 1 0 1 ] 48 + +1 +6 +[ 3 0 0 ] 48 +[ 1 2 0 ] 58 +[ 2 1 0 ] 58 +[ 1 0 0 ] 79 +[ 1 1 0 ] 5 +[ 2 0 0 ] 3 + +1 +10 +[ 3 0 0 ] 53 +[ 1 2 0 ] 81 +[ 2 1 0 ] 1 +[ 1 0 2 ] 79 +[ 1 1 1 ] 34 +[ 2 0 1 ] 90 +[ 1 0 0 ] 21 +[ 1 0 1 ] 86 +[ 1 1 0 ] 38 +[ 2 0 0 ] 70 + +1 +10 +[ 3 0 0 ] 82 +[ 1 0 2 ] 72 +[ 2 0 1 ] 77 +[ 1 2 0 ] 16 +[ 1 1 1 ] 64 +[ 2 1 0 ] 33 +[ 1 0 0 ] 83 +[ 1 1 0 ] 75 +[ 1 0 1 ] 51 +[ 2 0 0 ] 16 + +1 +6 +[ 3 0 0 ] 43 +[ 1 2 0 ] 84 +[ 2 1 0 ] 0 +[ 1 0 0 ] 20 +[ 1 1 0 ] 87 +[ 2 0 0 ] 73 + +1 +10 +[ 3 0 0 ] 7 +[ 1 2 0 ] 46 +[ 2 1 0 ] 13 +[ 1 0 2 ] 46 +[ 1 1 1 ] 59 +[ 2 0 1 ] 26 +[ 1 0 0 ] 68 +[ 1 0 1 ] 67 +[ 1 1 0 ] 87 +[ 2 0 0 ] 78 diff --git a/tests/test_nash.py b/tests/test_nash.py index 1e7ff092e..a3b171d0f 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -159,6 +159,66 @@ class QREquilibriumTestCase: marks=pytest.mark.nash_enumpure_strategy, id="test_enumpure_8", ), + # Action graph games + pytest.param( + EquilibriumTestCase( + factory=functools.partial(games.read_from_file, "2x2.agg"), + solver=gbt.nash.enumpure_solve, + expected=[ + [d(1, 0), d(1, 0)], + [d(0, 1), d(0, 1)], + ], + ), + marks=pytest.mark.nash_enumpure_strategy, + id="test_enumpure_9", + ), + pytest.param( + EquilibriumTestCase( + factory=functools.partial(games.read_from_file, "2x2_small_payoffs.agg"), + solver=gbt.nash.enumpure_solve, + expected=[ + [d(1, 0), d(1, 0)], + [d(0, 1), d(0, 1)], + ], + ), + marks=pytest.mark.nash_enumpure_strategy, + id="test_enumpure_10", + ), + # Bayesian Action graph games + pytest.param( + EquilibriumTestCase( + factory=functools.partial(games.read_from_file, "Bayesian-Coffee-3-2-2-3.bagg"), + solver=gbt.nash.enumpure_solve, + expected=[ + [ + [0, 1, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + ], + [ + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + ], + [ + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 0], + ], + ], + ), + marks=pytest.mark.nash_enumpure_strategy, + id="test_enumpure_11", + ), ] @@ -311,6 +371,43 @@ class QREquilibriumTestCase: marks=pytest.mark.nash_enummixed_strategy, id="test_enumixed_double_5", ), + # Action graph games + pytest.param( + EquilibriumTestCase( + factory=functools.partial(games.read_from_file, "2x2.agg"), + solver=functools.partial(gbt.nash.enummixed_solve, rational=False), + expected=[ + [d(1, 0), d(1, 0)], + [d(0, 1), d(0, 1)], + [ + d("10/11", "1/11"), + d("10/11", "1/11"), + ], + ], + prob_tol=TOL, + regret_tol=TOL, + ), + marks=pytest.mark.nash_enummixed_strategy, + id="test_enummixed_double_7", + ), + pytest.param( + EquilibriumTestCase( + factory=functools.partial(games.read_from_file, "2x2_small_payoffs.agg"), + solver=functools.partial(gbt.nash.enummixed_solve, rational=False), + expected=[ + [d(1, 0), d(1, 0)], + [d(0, 1), d(0, 1)], + [ + d("1/2", "1/2"), + d("1/2", "1/2"), + ], + ], + prob_tol=TOL, + regret_tol=TOL, + ), + marks=pytest.mark.nash_enummixed_strategy, + id="test_enummixed_double_8", + ), ] @@ -753,6 +850,27 @@ class QREquilibriumTestCase: marks=pytest.mark.nash_lcp_strategy, id="test_lcp_strategy_double_11", ), + # Action graph game + pytest.param( + EquilibriumTestCase( + factory=functools.partial(games.read_from_file, "2x2.agg"), + solver=functools.partial( + gbt.nash.lcp_solve, rational=True, use_strategic=True, stop_after=None + ), + expected=[ + [d(1, 0), d(1, 0)], + [ + d("10/11", "1/11"), + d("10/11", "1/11"), + ], + [d(0, 1), d(0, 1)], + ], + regret_tol=TOL, + prob_tol=TOL, + ), + marks=pytest.mark.nash_lcp_strategy, + id="test_lcp_strategy_double_12", + ), ] @@ -772,6 +890,26 @@ class QREquilibriumTestCase: marks=pytest.mark.nash_logit_strategy, id="test_logic_strategy_1", ), + pytest.param( + EquilibriumTestCase( + factory=functools.partial(games.read_from_file, "Bayesian-Coffee-3-2-2-3.bagg"), + solver=gbt.nash.logit_solve, + expected=[ + [ + [0.9124962637548039, 0.08750373624519617, 0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.9124962637547669, 0.08750373624523317, 0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [0.9124962637547208, 0.08750373624527921, 0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + ] + ], + prob_tol=TOL_LARGE, + regret_tol=TOL_LARGE, + ), + marks=pytest.mark.nash_logit_strategy, + id="test_logit_strategy_2", + ), ] @@ -2158,16 +2296,13 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s # 3-player perfect info game to test behavior two off equilibrium path pytest.param( EquilibriumTestCase( - factory=functools.partial( - games.read_from_file, "3_player_PI_2_dev_off_eq_path.efg" - ), + factory=functools.partial(games.read_from_file, "3_player_PI_2_dev_off_eq_path.efg"), solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), expected=[ # candidate,10,10,1000,10000 [[d(1, 0)], [d(1, 0), d(1, 0, 0, 0)], [d(1, 0, 0, 0, 0)]], # candidate,01,00,0000,00000 - [[d(0, 1)], [d(1, 0), d(1, 0, 0, 0)], - [d(1, 0, 0, 0, 0)]], + [[d(0, 1)], [d(1, 0), d(1, 0, 0, 0)], [d(1, 0, 0, 0, 0)]], ], regret_tol=TOL, prob_tol=TOL, @@ -2177,9 +2312,7 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s ), pytest.param( EquilibriumTestCase( - factory=functools.partial( - games.read_from_file, "3_player_PI_2_dev_off_eq_path.efg" - ), + factory=functools.partial(games.read_from_file, "3_player_PI_2_dev_off_eq_path.efg"), solver=functools.partial(gbt.nash.enumpoly_solve, stop_after=None), expected=[ [[d(1, 0)], [d(1, 0), d(1, 0, 0, 0)], [d(1, 0, 0, 0, 0)]], @@ -2261,8 +2394,9 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s ), pytest.param( EquilibriumTestCase( - factory=functools.partial(games.read_from_file, - "chance_root_5_moves_no_nonterm_player_nodes.efg"), + factory=functools.partial( + games.read_from_file, "chance_root_5_moves_no_nonterm_player_nodes.efg" + ), solver=gbt.nash.logit_solve, expected=[ [[]] # Zero-dimension edge case (two players)