diff --git a/ChangeLog b/ChangeLog index 4368c739e..1767b272c 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,5 +1,12 @@ # Changelog +## [16.7.0] - unreleased + +### Fixed +- Corrected a regression in action graph games that left the internal data structure not fully initialised, + leading to segmentation faults. + + ## [16.6.0] - 2026-03-24 ### Changed 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 95ae67295..8a95315f2 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -249,7 +249,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 7bee9a447..011321fd4 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -858,8 +858,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..28870d1a7 100644 --- a/tests/games.py +++ b/tests/games.py @@ -14,6 +14,8 @@ 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) else: raise ValueError(f"Unknown file extension in {fn}") diff --git a/tests/test_nash.py b/tests/test_nash.py index d0e664cd6..50491ef39 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -159,6 +159,19 @@ class QREquilibriumTestCase: marks=pytest.mark.nash_enumpure_strategy, id="test_enumpure_8", ), + # Action graph game + pytest.param( + EquilibriumTestCase( + factory=functools.partial(games.read_from_file, "2x2.agg"), + solver=functools.partial(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", + ), ] @@ -232,6 +245,23 @@ class QREquilibriumTestCase: marks=pytest.mark.nash_enummixed_strategy, id="test_enumixed_rational_5", ), + # Action graph game + pytest.param( + EquilibriumTestCase( + factory=functools.partial(games.read_from_file, "2x2.agg"), + solver=functools.partial(gbt.nash.enummixed_solve, rational=True), + expected=[ + [d(1, 0), d(1, 0)], + [d(0, 1), d(0, 1)], + [ + d("4186770418979088/4641467073735727", "454696654756639/4641467073735727"), + d("4186770418979088/4641467073735727", "454696654756639/4641467073735727"), + ], + ], + ), + marks=pytest.mark.nash_enummixed_strategy, + id="test_enummixed_rational_6", + ), ] @@ -589,6 +619,25 @@ class QREquilibriumTestCase: marks=pytest.mark.nash_lcp_strategy, id="test_lcp_strategy_rational_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("4186770418979088/4641467073735727", "454696654756639/4641467073735727"), + d("4186770418979088/4641467073735727", "454696654756639/4641467073735727"), + ], + [d(0, 1), d(0, 1)], + ], + ), + marks=pytest.mark.nash_lcp_strategy, + id="test_lcp_strategy_rational_12", + ), ] @@ -2158,16 +2207,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 +2223,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)]],