diff --git a/src/pygambit/action.pxi b/src/pygambit/action.pxi deleted file mode 100644 index 2539c787db..0000000000 --- a/src/pygambit/action.pxi +++ /dev/null @@ -1,42 +0,0 @@ -# -# This file is part of Gambit -# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -# -# FILE: src/pygambit/action.pxi -# Cython wrapper for actions -# -# This program is free software; you can redistribute it and/or modify -# It under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -# - -Branch = collections.namedtuple("Branch", ["node", "label"]) -Branch.__doc__ = """The action labeled `label`, taken at `node`. - -Returned by `Node.prior_action` and `Node.own_prior_action`; `node` is the node at -which the action was taken (not the node it leads to), so ``branch.node.actions`` -and, for a chance event, ``branch.node.action_probs[branch.label]`` are always -well-defined. - -.. versionadded:: 17.0.0 -""" - - -@cython.cfunc -def _decode_prob(py_string: string) -> object: - """Internal: decode a probability formatted by the C++ core as ``Decimal`` or - ``Rational``, matching whichever representation was used to specify it.""" - if "." in py_string.decode("ascii"): - return decimal.Decimal(py_string.decode("ascii")) - else: - return Rational(py_string.decode("ascii")) diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 8ad83db2fc..94538d0bf9 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -460,20 +460,117 @@ class MixedBehaviorProfile: for node in self.game.get_infosets(player): yield node.infoset + # The public API above is implemented once here and dispatches to the hooks below, + # each of which is implemented by a concrete dtype-specific subclass + # (MixedBehaviorProfileDouble/MixedBehaviorProfileRational). + + def _check_validity(self) -> None: + """Raises GameStructureChangedError if the game has structurally changed since + this profile was created. + """ + raise NotImplementedError + + @property + def _game(self) -> Game: + """The game on which this profile is defined.""" + raise NotImplementedError + @cython.cfunc def _getprob_action(self, index: c_GameAction) -> object: + """Returns the probability with which action `index` is played.""" raise NotImplementedError @cython.cfunc def _setprob_action(self, index: c_GameAction, value: typing.Any) -> cython.void: + """Sets the probability with which action `index` is played.""" + raise NotImplementedError + + def _to_prob(self, value: typing.Any) -> ProfileDType: + """Coerces value (int, float, str, Decimal, or Rational) into this profile's + native probability type. + """ + raise NotImplementedError + + def _is_defined_at(self, infoset: Infoset) -> bool: + """Returns whether the profile specifies a probability distribution at infoset.""" + raise NotImplementedError + + def _payoff(self, player: str) -> ProfileDType: + """Returns the expected payoff to player.""" + raise NotImplementedError + + def _belief(self, node: Node) -> ProfileDType | None: + """Returns the belief probability of reaching node, conditional on play having + reached its information set; None if the information set is unreached. + """ + raise NotImplementedError + + def _realiz_prob(self, node: Node) -> ProfileDType: + """Returns the probability that play reaches node.""" + raise NotImplementedError + + def _infoset_prob(self, infoset: _InfosetOrEvent) -> ProfileDType: + """Returns the probability that play reaches infoset.""" + raise NotImplementedError + + def _infoset_value(self, infoset: Infoset) -> ProfileDType | None: + """Returns the expected payoff to the player owning infoset, conditional on + reaching it; None if it is unreached. + """ + raise NotImplementedError + + def _node_value(self, player: str, node: Node) -> ProfileDType: + """Returns the expected payoff to player, conditional on reaching node.""" raise NotImplementedError @cython.cfunc def _action_value(self, action: c_GameAction) -> object: + """Returns the expected payoff to playing action, conditional on reaching its + information set; None if the information set is unreached. + """ raise NotImplementedError @cython.cfunc def _action_regret(self, action: c_GameAction) -> object: + """Returns the regret to playing action.""" + raise NotImplementedError + + def _infoset_regret(self, infoset: Infoset) -> ProfileDType: + """Returns the regret of the player owning infoset for their behavior at it.""" + raise NotImplementedError + + def _agent_max_regret(self) -> ProfileDType: + """Returns the maximum regret of any player at any information set.""" + raise NotImplementedError + + def _max_regret(self) -> ProfileDType: + """Returns the maximum regret of any player over their whole strategy.""" + raise NotImplementedError + + def _agent_liap_value(self) -> ProfileDType: + """Returns the agent-form Lyapunov value of the profile.""" + raise NotImplementedError + + def _liap_value(self) -> ProfileDType: + """Returns the Lyapunov value of the profile.""" + raise NotImplementedError + + def _copy(self) -> MixedBehaviorProfile: + """Creates a copy of the profile.""" + raise NotImplementedError + + def _as_strategy(self) -> MixedStrategyProfile: + """Creates the equivalent mixed strategy profile.""" + raise NotImplementedError + + def _as_float(self) -> MixedBehaviorProfileDouble: + """Creates a floating-point copy of the profile.""" + raise NotImplementedError + + def _normalize(self) -> MixedBehaviorProfile: + """Creates a copy of the profile, normalized so each information set's action + probabilities sum to one. + """ raise NotImplementedError def _mixed_action_at(self, infoset: Infoset) -> MixedAction: diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi index 8979984bd6..1abee090a1 100644 --- a/src/pygambit/behavspt.pxi +++ b/src/pygambit/behavspt.pxi @@ -24,49 +24,24 @@ from cython.operator cimport dereference as deref @cython.cclass -class ActionSupport: +class ActionSupport(_LabelSet): """A set of actions at a specified information set in a `BehaviorSupportProfile`. An immutable snapshot taken from a ``BehaviorSupportProfile`` at retrieval time: it does not reflect later changes to the profile. The information set is accessible via `infoset`. """ - _infoset = cython.declare(Infoset) - _actions = cython.declare(tuple) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create an ActionSupport outside a Game.") - @staticmethod @cython.cfunc def wrap(infoset: Infoset, actions: tuple) -> ActionSupport: obj: ActionSupport = ActionSupport.__new__(ActionSupport) - obj._infoset = infoset - obj._actions = actions + obj._owner = infoset + obj._labels = actions return obj @property def infoset(self) -> Infoset: - return self._infoset - - def __repr__(self) -> str: - return str(list(self._actions)) - - def __eq__(self, other: typing.Any) -> bool: - if isinstance(other, (set, frozenset, list, tuple)): - return set(self._actions) == set(other) - if not isinstance(other, ActionSupport) or self.infoset != other.infoset: - return False - return set(self._actions) == set(cython.cast(ActionSupport, other)._actions) - - def __len__(self) -> int: - return len(self._actions) - - def __iter__(self) -> typing.Generator[str, None, None]: - yield from self._actions - - def __contains__(self, label: str) -> bool: - return label in self._actions + return self._owner @cython.cclass diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index 4e331a6d6e..d7863a2c31 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -29,6 +29,7 @@ import sys import click +import numpy as np import pygambit as gbt @@ -107,6 +108,22 @@ def print_banner(description: str, extra_lines: tuple[str, ...] = ()) -> None: click.echo(err=True) +def load_game( + quiet: bool, + description: str, + file: str | None, + prog_name: str, + extra_lines: tuple[str, ...] = (), +) -> gbt.Game: + """Standard tool startup, shared by every `gambit-*` CLI tool's `main()`: print + the banner (see `print_banner`) unless `quiet`, then read the game from `file` + (or standard input). + """ + if not quiet: + print_banner(description, extra_lines) + return read_game(open_game_file(file, prog_name)) + + def version_option(description: str, extra_lines: tuple[str, ...] = ()) -> callable: """A ``-v``/``--version`` option which prints the tool's banner and exits, matching the behavior of the C++ command-line tools. See `print_banner` for @@ -374,3 +391,58 @@ def read_behavior_profiles_csv( profile[node] = {a: next(values) for a in node.infoset.actions} profiles.append(profile) return profiles + + +def _validate_random_start_options( + n: int | None, seed: int | None, start_file: str | None +) -> None: + """Shared validation for the `-n`/`-R`/`-s` starting-point options common to + gambit-gnm, gambit-ipa, and gambit-liap: `-n` and `-s` are mutually exclusive, + and `-R` requires `-n`. + """ + if n is not None and start_file is not None: + raise ValueError("The -n and -s options are mutually exclusive.") + if seed is not None and n is None: + raise ValueError("The -R option requires -n.") + + +def resolve_strategy_starts( + game: gbt.Game, + n: int | None, + seed: int | None, + start_file: str | None, + default_count: int = 1, +) -> list[gbt.MixedStrategyProfile]: + """Resolve strategy starting points for a `-n`/`-R`/`-s`-style tool: read from + `start_file` if given, otherwise `n` uniform-random draws (`default_count` if `n` + is not given), seeded by `seed`. Shared by gambit-gnm, gambit-ipa, and + gambit-liap's non-agent form. + """ + _validate_random_start_options(n, seed, start_file) + if start_file is not None: + return read_strategy_profiles_csv(start_file, game) + gen = np.random.default_rng(seed) + return [ + game.random_strategy_profile(gen=gen) + for _ in range(n if n is not None else default_count) + ] + + +def resolve_behavior_starts( + game: gbt.Game, + n: int | None, + seed: int | None, + start_file: str | None, + default_count: int = 1, +) -> list[gbt.MixedBehaviorProfile]: + """Behavior-profile counterpart to `resolve_strategy_starts`; see there for the + shared `-n`/`-R`/`-s` semantics. Used by gambit-liap's agent form. + """ + _validate_random_start_options(n, seed, start_file) + if start_file is not None: + return read_behavior_profiles_csv(start_file, game) + gen = np.random.default_rng(seed) + return [ + game.random_behavior_profile(gen=gen) + for _ in range(n if n is not None else default_count) + ] diff --git a/src/pygambit/cli/enummixed.py b/src/pygambit/cli/enummixed.py index 96039fdecb..edd95189d4 100644 --- a/src/pygambit/cli/enummixed.py +++ b/src/pygambit/cli/enummixed.py @@ -29,9 +29,7 @@ from .common import ( handle_errors, - open_game_file, - print_banner, - read_game, + load_game, render_profile_csv, version_option, ) @@ -61,9 +59,7 @@ @version_option(DESCRIPTION) @handle_errors def main(file: str | None, decimals: int | None, cliques: bool, quiet: bool) -> None: - if not quiet: - print_banner(DESCRIPTION) - game = read_game(open_game_file(file, PROG_NAME)) + game = load_game(quiet, DESCRIPTION, file, PROG_NAME) rational = decimals is None def render(profile) -> None: diff --git a/src/pygambit/cli/enumpoly.py b/src/pygambit/cli/enumpoly.py index 42b0ce7901..9675c1d8ea 100644 --- a/src/pygambit/cli/enumpoly.py +++ b/src/pygambit/cli/enumpoly.py @@ -38,9 +38,7 @@ from .common import ( handle_errors, - open_game_file, - print_banner, - read_game, + load_game, render_profile_csv, render_support_csv, validate_stop_after, @@ -113,9 +111,7 @@ def main( quiet: bool, verbose: bool, ) -> None: - if not quiet: - print_banner(DESCRIPTION) - game = read_game(open_game_file(file, PROG_NAME)) + game = load_game(quiet, DESCRIPTION, file, PROG_NAME) if not game.is_perfect_recall: raise ValueError("Computing equilibria of games with imperfect recall is not supported.") diff --git a/src/pygambit/cli/enumpure.py b/src/pygambit/cli/enumpure.py index 4b7fd7a34c..4f80bbd6e4 100644 --- a/src/pygambit/cli/enumpure.py +++ b/src/pygambit/cli/enumpure.py @@ -29,9 +29,7 @@ from .common import ( handle_errors, - open_game_file, - print_banner, - read_game, + load_game, render_profile_csv, render_profile_detail, version_option, @@ -62,9 +60,7 @@ @version_option(DESCRIPTION) @handle_errors def main(file: str | None, strategic: bool, agent: bool, detail: bool, quiet: bool) -> None: - if not quiet: - print_banner(DESCRIPTION) - game = read_game(open_game_file(file, PROG_NAME)) + game = load_game(quiet, DESCRIPTION, file, PROG_NAME) def render(profile) -> None: is_behavior = hasattr(profile, "as_strategy") diff --git a/src/pygambit/cli/gnm.py b/src/pygambit/cli/gnm.py index 1100c8b999..5ca1746ef6 100644 --- a/src/pygambit/cli/gnm.py +++ b/src/pygambit/cli/gnm.py @@ -27,7 +27,6 @@ from __future__ import annotations import click -import numpy as np import pygambit as gbt @@ -36,8 +35,8 @@ open_game_file, print_banner, read_game, - read_strategy_profiles_csv, render_profile_csv, + resolve_strategy_starts, version_option, ) @@ -148,20 +147,8 @@ def main( raise ValueError("Value for -i (local Newton iterations) must be at least 1") if steps <= 0: raise ValueError("Value for -c (steps in support cell) must be at least 1") - if n_vectors is not None and start_file is not None: - raise ValueError("The -n and -s options are mutually exclusive.") - if seed is not None and n_vectors is None: - raise ValueError("The -R option requires -n.") game = read_game(open_game_file(file, PROG_NAME)) - - if start_file is not None: - perturbations = read_strategy_profiles_csv(start_file, game) - else: - gen = np.random.default_rng(seed) - perturbations = [ - game.random_strategy_profile(gen=gen) - for _ in range(n_vectors if n_vectors is not None else 1) - ] + perturbations = resolve_strategy_starts(game, n_vectors, seed, start_file) def render(profile, label: str = "NE") -> None: click.echo(render_profile_csv(profile, label, decimals)) diff --git a/src/pygambit/cli/ipa.py b/src/pygambit/cli/ipa.py index 9a49d5483d..d12ed82352 100644 --- a/src/pygambit/cli/ipa.py +++ b/src/pygambit/cli/ipa.py @@ -27,17 +27,14 @@ from __future__ import annotations import click -import numpy as np import pygambit as gbt from .common import ( handle_errors, - open_game_file, - print_banner, - read_game, - read_strategy_profiles_csv, + load_game, render_profile_csv, + resolve_strategy_starts, version_option, ) @@ -102,22 +99,8 @@ def main( quiet: bool, verbose: bool, ) -> None: - if not quiet: - print_banner(DESCRIPTION, _EXTRA_BANNER) - if n_vectors is not None and start_file is not None: - raise ValueError("The -n and -s options are mutually exclusive.") - if seed is not None and n_vectors is None: - raise ValueError("The -R option requires -n.") - game = read_game(open_game_file(file, PROG_NAME)) - - if start_file is not None: - perturbations = read_strategy_profiles_csv(start_file, game) - else: - gen = np.random.default_rng(seed) - perturbations = [ - game.random_strategy_profile(gen=gen) - for _ in range(n_vectors if n_vectors is not None else 1) - ] + game = load_game(quiet, DESCRIPTION, file, PROG_NAME, _EXTRA_BANNER) + perturbations = resolve_strategy_starts(game, n_vectors, seed, start_file) def render(profile, label: str = "NE") -> None: click.echo(render_profile_csv(profile, label, decimals)) diff --git a/src/pygambit/cli/lcp.py b/src/pygambit/cli/lcp.py index e84c551fdb..eed718fb4c 100644 --- a/src/pygambit/cli/lcp.py +++ b/src/pygambit/cli/lcp.py @@ -31,9 +31,7 @@ from .common import ( handle_errors, - open_game_file, - print_banner, - read_game, + load_game, render_profile_csv, render_profile_detail, validate_stop_after, @@ -93,9 +91,7 @@ def main( detail: bool, quiet: bool, ) -> None: - if not quiet: - print_banner(DESCRIPTION) - game = read_game(open_game_file(file, PROG_NAME)) + game = load_game(quiet, DESCRIPTION, file, PROG_NAME) rational = decimals is None render_decimals = decimals or 0 use_strategic = strategic or not game.is_tree diff --git a/src/pygambit/cli/liap.py b/src/pygambit/cli/liap.py index 1c75a5bb85..4609de9702 100644 --- a/src/pygambit/cli/liap.py +++ b/src/pygambit/cli/liap.py @@ -27,18 +27,15 @@ from __future__ import annotations import click -import numpy as np import pygambit as gbt from .common import ( handle_errors, - open_game_file, - print_banner, - read_behavior_profiles_csv, - read_game, - read_strategy_profiles_csv, + load_game, render_profile_csv, + resolve_behavior_starts, + resolve_strategy_starts, version_option, ) @@ -126,13 +123,7 @@ def main( quiet: bool, verbose: bool, ) -> None: - if not quiet: - print_banner(DESCRIPTION) - if n_tries is not None and start_file is not None: - raise ValueError("The -n and -s options are mutually exclusive.") - if seed is not None and n_tries is None: - raise ValueError("The -R option requires -n.") - game = read_game(open_game_file(file, PROG_NAME)) + game = load_game(quiet, DESCRIPTION, file, PROG_NAME) use_agent = agent and game.is_tree def render(profile, label: str = "NE") -> None: @@ -147,14 +138,7 @@ def render_event(event) -> None: render(event.profile, "end") if use_agent: - starts = ( - read_behavior_profiles_csv(start_file, game) - if start_file is not None - else [ - game.random_behavior_profile(gen=np.random.default_rng(seed)) - for _ in range(n_tries if n_tries is not None else _DEFAULT_TRIES) - ] - ) + starts = resolve_behavior_starts(game, n_tries, seed, start_file, _DEFAULT_TRIES) for start in starts: gbt.nash.liap_agent_solve( start, @@ -164,14 +148,7 @@ def render_event(event) -> None: event_callback=render_event, ) else: - starts = ( - read_strategy_profiles_csv(start_file, game) - if start_file is not None - else [ - game.random_strategy_profile(gen=np.random.default_rng(seed)) - for _ in range(n_tries if n_tries is not None else _DEFAULT_TRIES) - ] - ) + starts = resolve_strategy_starts(game, n_tries, seed, start_file, _DEFAULT_TRIES) for start in starts: gbt.nash.liap_solve( start, diff --git a/src/pygambit/cli/logit.py b/src/pygambit/cli/logit.py index 3b660e0bd8..9351244145 100644 --- a/src/pygambit/cli/logit.py +++ b/src/pygambit/cli/logit.py @@ -31,9 +31,7 @@ from .common import ( handle_errors, - open_game_file, - print_banner, - read_game, + load_game, render_profile_csv, version_option, ) @@ -152,9 +150,7 @@ def main( terminal_only: bool, quiet: bool, ) -> None: - if not quiet: - print_banner(DESCRIPTION) - game = read_game(open_game_file(file, PROG_NAME)) + game = load_game(quiet, DESCRIPTION, file, PROG_NAME) if not game.is_perfect_recall: raise ValueError("Computing equilibria of games with imperfect recall is not supported.") diff --git a/src/pygambit/cli/lp.py b/src/pygambit/cli/lp.py index 3e388423eb..01f9b8ae97 100644 --- a/src/pygambit/cli/lp.py +++ b/src/pygambit/cli/lp.py @@ -31,9 +31,7 @@ from .common import ( handle_errors, - open_game_file, - print_banner, - read_game, + load_game, render_profile_csv, render_profile_detail, version_option, @@ -71,9 +69,7 @@ def main( detail: bool, quiet: bool, ) -> None: - if not quiet: - print_banner(DESCRIPTION) - game = read_game(open_game_file(file, PROG_NAME)) + game = load_game(quiet, DESCRIPTION, file, PROG_NAME) rational = decimals is None render_decimals = decimals or 0 diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 4d52bc0021..964d9c29cd 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -3,7 +3,6 @@ from libcpp.string cimport string from libcpp.memory cimport shared_ptr, unique_ptr from libcpp.list cimport list as stdlist from libcpp.vector cimport vector as stdvector -from libcpp.set cimport set as stdset from libcpp.map cimport map as stdmap from libcpp.optional cimport optional from libcpp.pair cimport pair @@ -100,12 +99,7 @@ cdef extern from "games/game.h": shared_ptr[c_PureStrategyProfileRep] deref "operator->"() except + c_PureStrategyProfile(c_PureStrategyProfile) except + - cdef cppclass c_PureBehaviorProfile "PureBehaviorProfile": - c_PureBehaviorProfile(c_Game) except + - cdef cppclass c_GameStrategyRep "GameStrategyRep": - int GetNumber() except + - int GetId() except + c_GamePlayer GetPlayer() except + string GetLabel() except + c_GameAction GetAction(c_GameInfoset) except + @@ -150,7 +144,6 @@ cdef extern from "games/game.h": string GetLabel() except + void SetLabel(string) except +ValueError - c_GameAction GetAction(int) except +IndexError Actions GetActions() except + c_Number GetActionProb(c_GameAction) except +IndexError @@ -159,7 +152,6 @@ cdef extern from "games/game.h": bint IsChanceInfoset() except + bint Precedes(c_GameNode) except + - stdset[c_GameAction] GetOwnPriorActions() except + cdef cppclass c_GamePlayerRep "GamePlayerRep": cppclass Infosets: @@ -198,12 +190,10 @@ cdef extern from "games/game.h": string GetLabel() except + - c_GameStrategy GetStrategy(int) except +IndexError Strategies GetStrategies() except + Sequences GetSequences() except + - c_GameInfoset GetInfoset(int) except +IndexError Infosets GetInfosets() except + cdef cppclass c_GameOutcomeRep "GameOutcomeRep": @@ -260,21 +250,10 @@ cdef extern from "games/game.h": iterator begin() except + iterator end() except + - cppclass InfosetCollection: - cppclass iterator: - c_GameInfoset operator *() - iterator operator++() - bint operator ==(iterator) - bint operator !=(iterator) - int size() except + - iterator begin() except + - iterator end() except + - c_Game GetGame() except + c_GameNode GetRoot() except + c_GameSubgame GetParent() except + SubgameCollection GetChildren() except + - InfosetCollection GetSubgameDifference() except + cdef cppclass c_GameRep "GameRep": cppclass Players: @@ -327,21 +306,15 @@ cdef extern from "games/game.h": Outcomes GetOutcomes() except + int NumNodes() except + - int NumNonterminalNodes() except + c_GameNode GetRoot() except + Nodes GetNodes() except + - c_GameStrategy GetStrategy(int) except +IndexError void RelabelStrategies(c_GamePlayer, stdmap[string, string]) except +ValueError void SetStrategies(c_GamePlayer, stdvector[string]) except +ValueError int MixedProfileLength() except + - c_GameInfoset GetInfoset(int) except +IndexError Array[int] NumInfosets() except + - c_GameAction GetAction(int) except +IndexError - int BehavProfileLength() except + - bool IsConstSum() except + c_Rational GetMinPayoff() except + c_Rational GetPlayerMinPayoff(c_GamePlayer) except + @@ -355,7 +328,6 @@ cdef extern from "games/game.h": c_GameInfoset AppendMove(c_GameNode, c_GamePlayer, stdvector[string]) except +ValueError c_GameInfoset AppendMove(c_GameNode, c_GameInfoset) except +ValueError - c_GameInfoset InsertMove(c_GameNode, c_GamePlayer, int) except +ValueError c_GameInfoset InsertMove(c_GameNode, c_GamePlayer, stdvector[string]) except +ValueError c_GameInfoset InsertMove(c_GameNode, c_GameInfoset) except +ValueError c_GameInfoset AppendEvent(c_GameNode, stdvector[string], @@ -416,9 +388,7 @@ cdef extern from "games/stratmixed.h" namespace "Gambit": T GetRegret(c_GameStrategy) except + T GetRegret(c_GamePlayer) except + T GetMaxRegret() except + - T GetPayoffDeriv(int, c_GameStrategy, c_GameStrategy) except + T GetLiapValue() except + - c_MixedStrategyProfile[T] ToFullSupport() except + c_MixedStrategyProfile(c_MixedStrategyProfile[T]) except + cdef extern from "games/behavmixed.h" namespace "Gambit": diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index fc7d15fecb..2d73be109a 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -188,7 +188,6 @@ class NodeIndexedVector(_LabeledVector): # Includes ###################### -include "action.pxi" include "infoset.pxi" include "strategy.pxi" include "outcome.pxi" @@ -197,5 +196,8 @@ include "stratspt.pxi" include "behavspt.pxi" include "stratmixed.pxi" include "behavmixed.pxi" +include "gamecollections.pxi" +include "gamehelpers.pxi" include "game.pxi" +include "gameio.pxi" include "nash.pxi" diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 0f72faa02c..84c511663f 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -26,324 +26,10 @@ import pathlib import cython import numpy as np -import scipy.stats import pygambit.gameiter ctypedef string (*GameWriter)(const c_Game &) except +IOError -ctypedef c_Game (*GameParser)(const string &) except +IOError - - -@cython.cfunc -def read_game(filepath_or_buffer: str | pathlib.Path | io.IOBase, - parser: GameParser): - - g = cython.declare(Game) - if isinstance(filepath_or_buffer, io.TextIOBase): - data = filepath_or_buffer.read().encode("utf-8") - elif isinstance(filepath_or_buffer, io.IOBase): - data = filepath_or_buffer.read() - else: - with open(filepath_or_buffer, "rb") as f: - data = f.read() - try: - g = Game.wrap(parser(data)) - except Exception as exc: - raise ValueError(f"Parse error in game file: {exc}") from None - return g - - -def read_gbt(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: - """Construct a game from its serialised representation in a GBT file. - - Parameters - ---------- - filepath_or_buffer : str, pathlib.Path or io.IOBase - The path to the file containing the game representation or file-like object - - Returns - ------- - Game - A game constructed from the representation in the file. - - Raises - ------ - IOError - If the file cannot be opened or read - ValueError - If the contents of the file are not a valid game representation. - - See Also - -------- - read_efg, read_nfg, read_agg, read_bagg - """ - return read_game(filepath_or_buffer, parser=ParseGbtGame) - - -def read_efg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: - """Construct a game from its serialised representation in an EFG file. - - Parameters - ---------- - filepath_or_buffer : str, pathlib.Path or io.IOBase - The path to the file containing the game representation or file-like object - - Returns - ------- - Game - A game constructed from the representation in the file. - - Raises - ------ - IOError - If the file cannot be opened or read - ValueError - If the contents of the file are not a valid game representation. - - See Also - -------- - read_gbt, read_nfg, read_agg, read_bagg - """ - return read_game(filepath_or_buffer, parser=ParseEfgGame) - - -def read_nfg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: - """Construct a game from its serialised representation in a NFG file. - - Parameters - ---------- - filepath_or_buffer : str, pathlib.Path or io.IOBase - The path to the file containing the game representation or file-like object - - Returns - ------- - Game - A game constructed from the representation in the file. - - Raises - ------ - IOError - If the file cannot be opened or read - ValueError - If the contents of the file are not a valid game representation. - - See Also - -------- - read_gbt, read_efg, read_agg, read_bagg - """ - return read_game(filepath_or_buffer, parser=ParseNfgGame) - - -def read_agg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: - """Construct a game from its serialised representation in an AGG file. - - Parameters - ---------- - filepath_or_buffer : str, pathlib.Path or io.IOBase - The path to the file containing the game representation or file-like object - - Returns - ------- - Game - A game constructed from the representation in the file. - - Raises - ------ - IOError - If the file cannot be opened or read - ValueError - If the contents of the file are not a valid game representation. - - See Also - -------- - read_gbt, read_efg, read_nfg, read_bagg - """ - return read_game(filepath_or_buffer, parser=ParseAggGame) - - -def read_bagg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: - """Construct a game from its serialised representation in a BAGG file. - - Parameters - ---------- - filepath_or_buffer : str, pathlib.Path or io.IOBase - The path to the file containing the game representation or file-like object - - Returns - ------- - Game - A game constructed from the representation in the file. - - Raises - ------ - IOError - If the file cannot be opened or read - ValueError - If the contents of the file are not a valid game representation. - - See Also - -------- - read_gbt, read_efg, read_nfg, read_agg - """ - return read_game(filepath_or_buffer, parser=ParseBaggGame) - - -@cython.cclass -class GameNodes: - """Represents the set of nodes in a game.""" - game = cython.declare(c_Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameNodes outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: c_Game) -> GameNodes: - obj: GameNodes = GameNodes.__new__(GameNodes) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameNodes(game={Game.wrap(self.game)})" - - def __len__(self) -> int: - """The number of nodes in the game.""" - if not self.game.deref().IsTree(): - return 0 - return self.game.deref().NumNodes() - - def __iter__(self) -> typing.Iterator[Node]: - """Iterate over the game nodes in the depth-first traversal order.""" - if not self.game.deref().IsTree(): - return - - for node in self.game.deref().GetNodes(): - yield Node.wrap(node) - - -@cython.cclass -class GameSubgames: - """Represents the set of subgames in a game.""" - game = cython.declare(c_Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameSubgames outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: c_Game) -> GameSubgames: - obj: GameSubgames = GameSubgames.__new__(GameSubgames) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameSubgames(game={Game.wrap(self.game)})" - - def __len__(self) -> int: - """The number of subgames in the game.""" - if not self.game.deref().IsTree(): - return 0 - return self.game.deref().GetSubgames().size() - - def __iter__(self) -> typing.Iterator[Subgame]: - """Iterate over the game subgames in postorder.""" - if not self.game.deref().IsTree(): - return - for subgame in self.game.deref().GetSubgames(): - yield Subgame.wrap(subgame) - - -@cython.cclass -class GameOutcomes: - """Represents the set of outcomes in a game.""" - game = cython.declare(c_Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameOutcomes outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: c_Game) -> GameOutcomes: - obj: GameOutcomes = GameOutcomes.__new__(GameOutcomes) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameOutcomes(game={Game.wrap(self.game)})" - - def __len__(self) -> int: - """The number of outcomes in the game.""" - return self.game.deref().GetOutcomes().size() - - def __iter__(self) -> typing.Iterator[Outcome]: - for outcome in self.game.deref().GetOutcomes(): - yield Outcome.wrap(outcome) - - def __getitem__(self, label: str) -> Outcome: - """Returns the outcome with text label `label`. - - Parameters - ---------- - label : str - The text label of the outcome to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. - - Raises - ------ - KeyError - If no outcome in the game has label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one outcome has label `label`. - TypeError - If `label` is not a string. - - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference an outcome by its label, or iterate - over the collection. String lookup now requires an exact match of the label; - previously, leading/trailing whitespace was stripped from `label` before comparison. - """ - return _resolve_by_label(self, label, "Game", "outcome", "outcomes") - - -@cython.cclass -class GamePlayers: - """The labels of the (personal) players in a game. - - .. versionchanged:: 17.0.0 - Iterates over player labels (``str``) rather than ``Player`` objects; - indexing by label is no longer supported (a label is already in hand once - iterated) -- use ``in`` to test membership. The chance player is no longer - exposed here (it never was included in iteration); the ``Infoset``/``Event`` - split on ``Node`` already distinguishes personal from chance nodes. - """ - game = cython.declare(c_Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GamePlayers outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: c_Game) -> GamePlayers: - obj: GamePlayers = GamePlayers.__new__(GamePlayers) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GamePlayers(game={Game.wrap(self.game)})" - - def __len__(self) -> int: - """Returns the number of players in the game.""" - return self.game.deref().NumPlayers() - - def __iter__(self) -> typing.Iterator[str]: - for player in self.game.deref().GetPlayers(): - yield player.deref().GetLabel().decode("utf-8") - - def __contains__(self, label: str) -> bool: - return any( - player.deref().GetLabel().decode("utf-8") == label - for player in self.game.deref().GetPlayers() - ) @cython.cclass @@ -1205,38 +891,14 @@ class Game: if denom is None: profile = self.mixed_strategy_profile() for player in self.players: - strategies = self.get_strategies(player) - weights = scipy.stats.dirichlet( - alpha=[1 for _ in strategies], - seed=gen - ).rvs(size=1)[0] - profile[player] = dict( - zip(strategies, weights, strict=True) - ) + profile[player] = _dirichlet_distribution(self.get_strategies(player), gen) return profile elif denom < 1: raise ValueError("random_strategy_profile(): denom must be positive") else: profile = self.mixed_strategy_profile(rational=True) for player in self.players: - strategies = self.get_strategies(player) - k = len(strategies) - sample = ( - [0] + - sorted( - (gen or np.random).choice(np.arange(1, denom+k), size=k-1, replace=False) - ) + - [denom + k] - ) - distribution = { - strategy: Rational(hi - lo - 1, denom) - for strategy, (hi, lo) in zip( - strategies, - zip(sample[1:], sample[:-1], strict=True), - strict=True - ) - } - profile[player] = distribution + profile[player] = _grid_distribution(self.get_strategies(player), denom, gen) return profile def _fill_behavior_profile(self, @@ -1341,13 +1003,7 @@ class Game: profile = self.mixed_behavior_profile() for player in self.players: for node in self.get_infosets(player): - infoset = node.infoset - weights = scipy.stats.dirichlet( - alpha=[1 for action in infoset.actions], seed=gen - ).rvs(size=1)[0] - profile[node] = dict( - zip(infoset.actions, weights, strict=True) - ) + profile[node] = _dirichlet_distribution(node.infoset.actions, gen) return profile elif denom < 1: raise ValueError("random_behavior_profile(): denom must be positive") @@ -1355,24 +1011,7 @@ class Game: profile = self.mixed_behavior_profile(rational=True) for player in self.players: for node in self.get_infosets(player): - infoset = node.infoset - k = len(infoset.actions) - sample = ( - [0] + - sorted( - (gen or np.random).choice( - np.arange(1, denom+k), size=k-1, replace=False - ) - ) + - [denom + k] - ) - distribution = { - a: Rational(hi - lo - 1, denom) - for a, hi, lo in zip( - infoset.actions, sample[1:], sample[:-1], strict=True - ) - } - profile[node] = distribution + profile[node] = _grid_distribution(node.infoset.actions, denom, gen) return profile def strategy_support_profile( @@ -1583,56 +1222,6 @@ class Game: return p raise KeyError(f"{funcname}(): no player with label '{player}'") - def _resolve_outcome(self, - outcome: typing.Any, funcname: str, argname: str = "outcome") -> Outcome: - """Resolve an attempt to reference an outcome of the game. - - Parameters - ---------- - outcome : Any - An object to resolve as a reference to an outcome. - funcname : str - The name of the function to raise any exception on behalf of. - argname : str, default 'outcome' - The name of the argument being checked - - Raises - ------ - MismatchError - If `outcome` is an `Outcome` from a different game. - KeyError - If `outcome` is a string and no outcome in the game has that label. - TypeError - If `outcome` is not an `Outcome`, `NodeOutcome`, or a `str` - ValueError - If `outcome` is an empty `str` or all spaces, or is a `NodeOutcome` that - resolves to no outcome (no outcome is attached to its node). - """ - if isinstance(outcome, NodeOutcome): - resolved = cython.cast(NodeOutcome, outcome)._resolve() - if resolved is None: - raise ValueError( - f"{funcname}(): {argname} resolves to no outcome " - f"(no outcome is attached to the node)" - ) - outcome = resolved - if isinstance(outcome, Outcome): - if outcome.game != self: - raise MismatchError(f"{funcname}(): {argname} must be part of the same game") - return outcome - elif isinstance(outcome, str): - if not outcome.strip(): - raise ValueError( - f"{funcname}(): {argname} cannot be an empty string or all spaces" - ) - try: - return self.outcomes[outcome] - except KeyError: - raise KeyError(f"{funcname}(): no outcome with label '{outcome}'") - raise TypeError( - f"{funcname}(): {argname} must be Outcome or str, not {outcome.__class__.__name__}" - ) - @cython.cfunc def _resolve_strategy(self, player: str, label, funcname: str, argname: str = "strategy") -> c_GameStrategy: @@ -1751,18 +1340,11 @@ class Game: information set, or to no information set at all (the node is terminal). """ resolved_node = self._resolve_node(infoset, funcname, argname) - resolved = cython.cast(Infoset, resolved_node.infoset) - if not resolved: - if resolved_node.event: - raise ValueError( - f"{funcname}(): {argname} resolves to a chance event, " - f"not a personal player's information set" - ) - raise ValueError( - f"{funcname}(): {argname} resolves to no information set " - f"(the node is terminal)" - ) - return resolved + return cython.cast(Infoset, _resolve_infoset_or_event_kind( + resolved_node.infoset, resolved_node.event, + "information set", "a personal player's information set", "a chance event", + funcname, argname + )) def _resolve_event(self, event: typing.Any, funcname: str, argname: str = "event") -> Event: @@ -1791,18 +1373,11 @@ class Game: chance event, or to no event at all (the node is terminal). """ resolved_node = self._resolve_node(event, funcname, argname) - resolved = cython.cast(Event, resolved_node.event) - if not resolved: - if resolved_node.infoset: - raise ValueError( - f"{funcname}(): {argname} resolves to a personal player's " - f"information set, not a chance event" - ) - raise ValueError( - f"{funcname}(): {argname} resolves to no event " - f"(the node is terminal)" - ) - return resolved + return cython.cast(Event, _resolve_infoset_or_event_kind( + resolved_node.event, resolved_node.infoset, + "event", "a chance event", "a personal player's information set", + funcname, argname + )) def _resolve_infoset_or_event(self, infoset: typing.Any, @@ -2260,22 +1835,10 @@ class Game: if not labels: raise UndefinedOperationError("set_move_actions(): `actions` must be a nonempty list") current = list(resolved_infoset.actions) - if len(set(current)) != len(current): - raise ValueError( - "set_move_actions(): the information set has duplicate action labels, " - "so matching by label is not well-defined" - ) - current_set = set(current) - declared = set(labels) - added = [label for label in labels if label not in current_set] - missing = [label for label in current if label not in declared] - if added and not add: - raise ValueError(f"set_move_actions(): would create new actions {added}") - if missing and not drop: - raise ValueError( - f"set_move_actions(): would delete actions {missing} and the subtrees they " - f"lead to; pass drop=True to confirm" - ) + _reconcile_labels( + current, labels, add, drop, "set_move_actions", + "information set", "action", "actions", "the subtrees they lead to" + ) c_labels = stdvector[string]() for label in labels: c_labels.push_back(label.encode("utf-8")) @@ -2354,22 +1917,10 @@ class Game: "set_event_actions(): `probs` must be a nonempty mapping" ) current = list(resolved_event.actions) - if len(set(current)) != len(current): - raise ValueError( - "set_event_actions(): the information set has duplicate action labels, " - "so matching by label is not well-defined" - ) - current_set = set(current) - declared = set(labels) - added = [label for label in labels if label not in current_set] - missing = [label for label in current if label not in declared] - if added and not add: - raise ValueError(f"set_event_actions(): would create new actions {added}") - if missing and not drop: - raise ValueError( - f"set_event_actions(): would delete actions {missing} and the subtrees they " - f"lead to; pass drop=True to confirm" - ) + _reconcile_labels( + current, labels, add, drop, "set_event_actions", + "information set", "action", "actions", "the subtrees they lead to" + ) c_labels = stdvector[string]() c_probs = stdvector[c_Number]() for label in labels: @@ -2504,24 +2055,15 @@ class Game: f"not {labels.__class__.__name__}" ) current = list(resolved_infoset.actions) + remap = _compute_relabeling( + current, labels, "relabel_actions", "action", strict, + "at this information set" + ) + if not remap: + return c_labels = stdmap[string, string]() - for old, new in labels.items(): - if not isinstance(old, str) or not isinstance(new, str): - raise TypeError("relabel_actions(): labels must map str to str") - matches = current.count(old) - if matches > 1: - raise ValueError( - f"relabel_actions(): label '{old}' is ambiguous at this information set" - ) - if matches == 0: - if strict: - raise KeyError(f"relabel_actions(): no action with label '{old}'") - continue - if new == old: - continue + for old, new in remap.items(): c_labels[old.encode("utf-8")] = new.encode("utf-8") - if c_labels.empty(): - return self.game.deref().RelabelActions(resolved_infoset._resolve(), c_labels) def make_infoset(self, @@ -2687,20 +2229,10 @@ class Game: if not labels: raise UndefinedOperationError("set_players(): `players` must be a nonempty list") current = list(self.players) - if len(set(current)) != len(current): - raise ValueError( - "set_players(): the game has duplicate player labels, " - "so matching by label is not well-defined" - ) - added = [label for label in labels if label not in current] - if added and not add: - raise ValueError(f"set_players(): would create new players {added}") - missing = [label for label in current if label not in labels] - if missing and not drop: - raise ValueError( - f"set_players(): would delete players {missing} and their payoffs at " - f"every outcome; pass drop=True to confirm" - ) + _, missing = _reconcile_labels( + current, labels, add, drop, "set_players", + "game", "player", "players", "their payoffs at every outcome" + ) for label in missing: if self.is_tree and len(self.get_infosets(label)) > 0: raise UndefinedOperationError( @@ -2717,6 +2249,40 @@ class Game: c_labels.push_back(label.encode("utf-8")) self.game.deref().SetPlayers(c_labels) + def _resolve_outcome_location(self, location, funcname: str) -> tuple: + """Resolve `location` for `make_outcome`/`make_outcome_null`: for a tree game, + into a list of `Node`; for a strategic game, into a list of pure-strategy + contingencies (each a mapping from player label to strategy label). + + Returns (is_tree, resolved). + + Raises + ------ + MismatchError + If any node is from a different game. + TypeError + If `location` is not a contingency or an iterable of contingencies + (strategic game only). + ValueError + If `location` is empty or contains a repeat, or (strategic game only) if + a contingency does not specify exactly one strategy for each player. + """ + if self.is_tree: + return True, self._resolve_nodes(location, funcname) + if isinstance(location, collections.abc.Mapping): + entries = [location] + else: + try: + entries = list(location) + except TypeError: + raise TypeError( + f"{funcname}(): location must be a contingency or an " + f"iterable of contingencies" + ) from None + return False, [ + self._resolve_contingency(entry, funcname, "location") for entry in entries + ] + def make_outcome(self, location, payoffs: typing.Mapping, @@ -2784,31 +2350,20 @@ class Game: c_payoffs = stdvector[c_Number]() for player in self.players: c_payoffs.push_back(_to_number(resolved_payoffs[player])) - if self.is_tree: - resolved_nodes = self._resolve_nodes(location, "make_outcome") + is_tree, resolved = self._resolve_outcome_location(location, "make_outcome") + if is_tree: c_nodes = stdvector[c_GameNode]() - for n in resolved_nodes: + for n in resolved: c_nodes.push_back(cython.cast(Node, n).node) return Outcome.wrap( self.game.deref().MakeOutcome(c_nodes, c_payoffs, label.encode("utf-8")) ) - if isinstance(location, collections.abc.Mapping): - entries = [location] - else: - try: - entries = list(location) - except TypeError: - raise TypeError( - "make_outcome(): location must be a contingency or an " - "iterable of contingencies" - ) from None c_contingencies = stdvector[stdvector[c_GameStrategy]]() - for entry in entries: - resolved = self._resolve_contingency(entry, "make_outcome", "location") + for contingency in resolved: c_one = stdvector[c_GameStrategy]() for player in self.players: c_one.push_back( - self._resolve_strategy(player, resolved[player], "make_outcome") + self._resolve_strategy(player, contingency[player], "make_outcome") ) c_contingencies.push_back(c_one) return Outcome.wrap( @@ -2849,30 +2404,19 @@ class Game: "make_outcome_null(): operation not defined for games in " "action-graph representation" ) - if self.is_tree: - resolved_nodes = self._resolve_nodes(location, "make_outcome_null") + is_tree, resolved = self._resolve_outcome_location(location, "make_outcome_null") + if is_tree: c_nodes = stdvector[c_GameNode]() - for n in resolved_nodes: + for n in resolved: c_nodes.push_back(cython.cast(Node, n).node) self.game.deref().MakeOutcomeNull(c_nodes) return - if isinstance(location, collections.abc.Mapping): - entries = [location] - else: - try: - entries = list(location) - except TypeError: - raise TypeError( - "make_outcome_null(): location must be a contingency or an " - "iterable of contingencies" - ) from None c_contingencies = stdvector[stdvector[c_GameStrategy]]() - for entry in entries: - resolved = self._resolve_contingency(entry, "make_outcome_null", "location") + for contingency in resolved: c_one = stdvector[c_GameStrategy]() for player in self.players: c_one.push_back( - self._resolve_strategy(player, resolved[player], "make_outcome_null") + self._resolve_strategy(player, contingency[player], "make_outcome_null") ) c_contingencies.push_back(c_one) self.game.deref().MakeOutcomeNull(c_contingencies) @@ -2934,24 +2478,15 @@ class Game: current = [ s.deref().GetLabel().decode("utf-8") for s in resolved_player.deref().GetStrategies() ] + remap = _compute_relabeling( + current, labels, "relabel_strategies", "strategy", strict, + "for this player" + ) + if not remap: + return c_labels = stdmap[string, string]() - for old, new in labels.items(): - if not isinstance(old, str) or not isinstance(new, str): - raise TypeError("relabel_strategies(): labels must map str to str") - matches = current.count(old) - if matches > 1: - raise ValueError( - f"relabel_strategies(): label '{old}' is ambiguous for this player" - ) - if matches == 0: - if strict: - raise KeyError(f"relabel_strategies(): no strategy with label '{old}'") - continue - if new == old: - continue + for old, new in remap.items(): c_labels[old.encode("utf-8")] = new.encode("utf-8") - if c_labels.empty(): - return self.game.deref().RelabelStrategies(resolved_player, c_labels) def set_strategies(self, @@ -3022,20 +2557,10 @@ class Game: current = [ s.deref().GetLabel().decode("utf-8") for s in resolved_player.deref().GetStrategies() ] - if len(set(current)) != len(current): - raise ValueError( - "set_strategies(): the player has duplicate strategy labels, " - "so matching by label is not well-defined" - ) - added = [label for label in labels if label not in current] - if added and not add: - raise ValueError(f"set_strategies(): would create new strategies {added}") - missing = [label for label in current if label not in labels] - if missing and not drop: - raise ValueError( - f"set_strategies(): would delete strategies {missing} and the outcomes " - f"at their contingencies; pass drop=True to confirm" - ) + _reconcile_labels( + current, labels, add, drop, "set_strategies", + "player", "strategy", "strategies", "the outcomes at their contingencies" + ) c_labels = stdvector[string]() for label in labels: c_labels.push_back(label.encode("utf-8")) @@ -3091,26 +2616,16 @@ class Game: self.game.deref().GetChance().deref().GetLabel().decode("utf-8") if self.is_tree else None ) + remap = _compute_relabeling( + current, labels, "relabel_players", "player", strict, + "in this game", reserved=chance_label, + reserved_desc="the chance player's label is reserved" + ) + if not remap: + return c_labels = stdmap[string, string]() - for old, new in labels.items(): - if not isinstance(old, str) or not isinstance(new, str): - raise TypeError("relabel_players(): labels must map str to str") - if old == chance_label: - raise ValueError("relabel_players(): the chance player's label is reserved") - matches = current.count(old) - if matches > 1: - raise ValueError( - f"relabel_players(): label '{old}' is ambiguous in this game" - ) - if matches == 0: - if strict: - raise KeyError(f"relabel_players(): no player with label '{old}'") - continue - if new == old: - continue + for old, new in remap.items(): c_labels[old.encode("utf-8")] = new.encode("utf-8") - if c_labels.empty(): - return self.game.deref().RelabelPlayers(c_labels) @@ -3122,7 +2637,7 @@ class NodeCoordinates: @cython.cfunc -def _layout_tree(game: Game) -> dict[GameNode, NodeCoordinates]: +def _layout_tree(game: Game) -> dict[Node, NodeCoordinates]: layout = CreateLayout(game.game) data = {} for node in game.nodes: @@ -3132,5 +2647,5 @@ def _layout_tree(game: Game) -> dict[GameNode, NodeCoordinates]: return data -def layout_tree(game: Game) -> dict[GameNode, dict]: +def layout_tree(game: Game) -> dict[Node, NodeCoordinates]: return _layout_tree(game) diff --git a/src/pygambit/gamecollections.pxi b/src/pygambit/gamecollections.pxi new file mode 100644 index 0000000000..ee2928192b --- /dev/null +++ b/src/pygambit/gamecollections.pxi @@ -0,0 +1,179 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/gamecollections.pxi +# Cython wrappers for the collections of nodes, subgames, outcomes, and players +# belonging to a game +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# + +@cython.cclass +class GameNodes: + """Represents the set of nodes in a game.""" + game = cython.declare(c_Game) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create GameNodes outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(game: c_Game) -> GameNodes: + obj: GameNodes = GameNodes.__new__(GameNodes) + obj.game = game + return obj + + def __repr__(self) -> str: + return f"GameNodes(game={Game.wrap(self.game)})" + + def __len__(self) -> int: + """The number of nodes in the game.""" + if not self.game.deref().IsTree(): + return 0 + return self.game.deref().NumNodes() + + def __iter__(self) -> typing.Iterator[Node]: + """Iterate over the game nodes in the depth-first traversal order.""" + if not self.game.deref().IsTree(): + return + + for node in self.game.deref().GetNodes(): + yield Node.wrap(node) + + +@cython.cclass +class GameSubgames: + """Represents the set of subgames in a game.""" + game = cython.declare(c_Game) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create GameSubgames outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(game: c_Game) -> GameSubgames: + obj: GameSubgames = GameSubgames.__new__(GameSubgames) + obj.game = game + return obj + + def __repr__(self) -> str: + return f"GameSubgames(game={Game.wrap(self.game)})" + + def __len__(self) -> int: + """The number of subgames in the game.""" + if not self.game.deref().IsTree(): + return 0 + return self.game.deref().GetSubgames().size() + + def __iter__(self) -> typing.Iterator[Subgame]: + """Iterate over the game subgames in postorder.""" + if not self.game.deref().IsTree(): + return + for subgame in self.game.deref().GetSubgames(): + yield Subgame.wrap(subgame) + + +@cython.cclass +class GameOutcomes: + """Represents the set of outcomes in a game.""" + game = cython.declare(c_Game) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create GameOutcomes outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(game: c_Game) -> GameOutcomes: + obj: GameOutcomes = GameOutcomes.__new__(GameOutcomes) + obj.game = game + return obj + + def __repr__(self) -> str: + return f"GameOutcomes(game={Game.wrap(self.game)})" + + def __len__(self) -> int: + """The number of outcomes in the game.""" + return self.game.deref().GetOutcomes().size() + + def __iter__(self) -> typing.Iterator[Outcome]: + for outcome in self.game.deref().GetOutcomes(): + yield Outcome.wrap(outcome) + + def __getitem__(self, label: str) -> Outcome: + """Returns the outcome with text label `label`. + + Parameters + ---------- + label : str + The text label of the outcome to return. Lookup is by exact match; + leading/trailing whitespace is stripped from `label`. + + Raises + ------ + KeyError + If no outcome in the game has label `label`. + ValueError + If `label` is empty or all whitespace, or if more than one outcome has label `label`. + TypeError + If `label` is not a string. + + .. versionchanged:: 16.7.0 + Integer indexing is no longer supported; reference an outcome by its label, or iterate + over the collection. String lookup now requires an exact match of the label; + previously, leading/trailing whitespace was stripped from `label` before comparison. + """ + return _resolve_by_label(self, label, "Game", "outcome", "outcomes") + + +@cython.cclass +class GamePlayers: + """The labels of the (personal) players in a game. + + .. versionchanged:: 17.0.0 + Iterates over player labels (``str``) rather than ``Player`` objects; + indexing by label is no longer supported (a label is already in hand once + iterated) -- use ``in`` to test membership. The chance player is no longer + exposed here (it never was included in iteration); the ``Infoset``/``Event`` + split on ``Node`` already distinguishes personal from chance nodes. + """ + game = cython.declare(c_Game) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create GamePlayers outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(game: c_Game) -> GamePlayers: + obj: GamePlayers = GamePlayers.__new__(GamePlayers) + obj.game = game + return obj + + def __repr__(self) -> str: + return f"GamePlayers(game={Game.wrap(self.game)})" + + def __len__(self) -> int: + """Returns the number of players in the game.""" + return self.game.deref().NumPlayers() + + def __iter__(self) -> typing.Iterator[str]: + for player in self.game.deref().GetPlayers(): + yield player.deref().GetLabel().decode("utf-8") + + def __contains__(self, label: str) -> bool: + return any( + player.deref().GetLabel().decode("utf-8") == label + for player in self.game.deref().GetPlayers() + ) diff --git a/src/pygambit/gamehelpers.pxi b/src/pygambit/gamehelpers.pxi new file mode 100644 index 0000000000..10e755612c --- /dev/null +++ b/src/pygambit/gamehelpers.pxi @@ -0,0 +1,135 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/gamehelpers.pxi +# Private helpers shared by several of Game's label-reconciliation, relabeling, +# and random-profile-generation methods +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# + +import numpy as np +import scipy.stats + + +def _reconcile_labels( + current: list, labels: list, add: bool, drop: bool, funcname: str, + owner_noun: str, item_singular: str, item_plural: str, consequence: str +) -> tuple: + """Shared add/drop reconciliation for a "set the collection to be `labels`, + matching by label" operation (`set_move_actions`/`set_event_actions`/ + `set_players`/`set_strategies`): determines which of `labels` are new + (`added`) and which of `current` are missing from `labels` (`missing`). + + Raises ValueError if `current` has duplicate labels (matching by label is then + ill-defined), or if a nonempty `added`/`missing` is not confirmed by `add`/`drop`. + """ + if len(set(current)) != len(current): + raise ValueError( + f"{funcname}(): the {owner_noun} has duplicate {item_singular} labels, " + f"so matching by label is not well-defined" + ) + current_set = set(current) + declared = set(labels) + added = [label for label in labels if label not in current_set] + missing = [label for label in current if label not in declared] + if added and not add: + raise ValueError(f"{funcname}(): would create new {item_plural} {added}") + if missing and not drop: + raise ValueError( + f"{funcname}(): would delete {item_plural} {missing} and {consequence}; " + f"pass drop=True to confirm" + ) + return added, missing + + +def _compute_relabeling( + current: list, labels: typing.Mapping, funcname: str, item_noun: str, strict: bool, + ambiguous_desc: str, reserved: str | None = None, reserved_desc: str = "" +) -> dict: + """Shared validation for a "simultaneously reassign labels" operation + (`relabel_actions`/`relabel_strategies`/`relabel_players`): validates `labels` + (a mapping from current label to replacement) against `current`, and returns + a plain `dict` of only the entries that are a real, confirmed relabeling + (unknown keys dropped when not `strict`; no-op entries where key equals value + are dropped unconditionally). + + Raises TypeError if `labels` is not a str-to-str mapping; KeyError if `strict` + and a key of `labels` matches no current label; ValueError if a key matches + `reserved`, or matches more than one current label. + """ + remap = {} + for old, new in labels.items(): + if not isinstance(old, str) or not isinstance(new, str): + raise TypeError(f"{funcname}(): labels must map str to str") + if reserved is not None and old == reserved: + raise ValueError(f"{funcname}(): {reserved_desc}") + matches = current.count(old) + if matches > 1: + raise ValueError(f"{funcname}(): label '{old}' is ambiguous {ambiguous_desc}") + if matches == 0: + if strict: + raise KeyError(f"{funcname}(): no {item_noun} with label '{old}'") + continue + if new == old: + continue + remap[old] = new + return remap + + +def _resolve_infoset_or_event_kind( + this: object, other: object, this_bare: str, this_full: str, other_full: str, + funcname: str, argname: str +) -> object: + """Shared error-raising shape for `_resolve_infoset`/`_resolve_event`: `this` is + the already-resolved `Infoset`/`Event` of the desired kind, falsy if the + anchoring node's partition element is not of that kind; `other` is the opposite + kind, consulted only to raise a more specific error when `this` does not apply. + """ + if not this: + if other: + raise ValueError(f"{funcname}(): {argname} resolves to {other_full}, not {this_full}") + raise ValueError( + f"{funcname}(): {argname} resolves to no {this_bare} (the node is terminal)" + ) + return this + + +def _dirichlet_distribution(items: list, gen: object) -> dict: + """A uniform-random probability distribution over `items` (a flat Dirichlet(1,...,1) + draw), keyed by item, as `float`. Shared by `random_strategy_profile`/ + `random_behavior_profile`'s `denom=None` case. + """ + weights = scipy.stats.dirichlet(alpha=[1 for _ in items], seed=gen).rvs(size=1)[0] + return dict(zip(items, weights, strict=True)) + + +def _grid_distribution(items: list, denom: int, gen: object) -> dict: + """A uniform-random probability distribution over `items`, restricted to the grid + with denominator `denom` (a uniformly-random composition of `denom` into + `len(items)` nonnegative parts), keyed by item, as `Rational`. Shared by + `random_strategy_profile`/`random_behavior_profile`'s `denom` grid case. + """ + k = len(items) + sample = ( + [0] + + sorted((gen or np.random).choice(np.arange(1, denom + k), size=k - 1, replace=False)) + + [denom + k] + ) + return { + item: Rational(hi - lo - 1, denom) + for item, (hi, lo) in zip(items, zip(sample[1:], sample[:-1], strict=True), strict=True) + } diff --git a/src/pygambit/gameio.pxi b/src/pygambit/gameio.pxi new file mode 100644 index 0000000000..b97d1a6598 --- /dev/null +++ b/src/pygambit/gameio.pxi @@ -0,0 +1,180 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/gameio.pxi +# Functions to construct a Game by reading its serialized representation from a file +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# + +import io +import pathlib + +ctypedef c_Game (*GameParser)(const string &) except +IOError + + +@cython.cfunc +def read_game(filepath_or_buffer: str | pathlib.Path | io.IOBase, + parser: GameParser): + + g = cython.declare(Game) + if isinstance(filepath_or_buffer, io.TextIOBase): + data = filepath_or_buffer.read().encode("utf-8") + elif isinstance(filepath_or_buffer, io.IOBase): + data = filepath_or_buffer.read() + else: + with open(filepath_or_buffer, "rb") as f: + data = f.read() + try: + g = Game.wrap(parser(data)) + except Exception as exc: + raise ValueError(f"Parse error in game file: {exc}") from None + return g + + +def read_gbt(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: + """Construct a game from its serialised representation in a GBT file. + + Parameters + ---------- + filepath_or_buffer : str, pathlib.Path or io.IOBase + The path to the file containing the game representation or file-like object + + Returns + ------- + Game + A game constructed from the representation in the file. + + Raises + ------ + IOError + If the file cannot be opened or read + ValueError + If the contents of the file are not a valid game representation. + + See Also + -------- + read_efg, read_nfg, read_agg, read_bagg + """ + return read_game(filepath_or_buffer, parser=ParseGbtGame) + + +def read_efg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: + """Construct a game from its serialised representation in an EFG file. + + Parameters + ---------- + filepath_or_buffer : str, pathlib.Path or io.IOBase + The path to the file containing the game representation or file-like object + + Returns + ------- + Game + A game constructed from the representation in the file. + + Raises + ------ + IOError + If the file cannot be opened or read + ValueError + If the contents of the file are not a valid game representation. + + See Also + -------- + read_gbt, read_nfg, read_agg, read_bagg + """ + return read_game(filepath_or_buffer, parser=ParseEfgGame) + + +def read_nfg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: + """Construct a game from its serialised representation in a NFG file. + + Parameters + ---------- + filepath_or_buffer : str, pathlib.Path or io.IOBase + The path to the file containing the game representation or file-like object + + Returns + ------- + Game + A game constructed from the representation in the file. + + Raises + ------ + IOError + If the file cannot be opened or read + ValueError + If the contents of the file are not a valid game representation. + + See Also + -------- + read_gbt, read_efg, read_agg, read_bagg + """ + return read_game(filepath_or_buffer, parser=ParseNfgGame) + + +def read_agg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: + """Construct a game from its serialised representation in an AGG file. + + Parameters + ---------- + filepath_or_buffer : str, pathlib.Path or io.IOBase + The path to the file containing the game representation or file-like object + + Returns + ------- + Game + A game constructed from the representation in the file. + + Raises + ------ + IOError + If the file cannot be opened or read + ValueError + If the contents of the file are not a valid game representation. + + See Also + -------- + read_gbt, read_efg, read_nfg, read_bagg + """ + return read_game(filepath_or_buffer, parser=ParseAggGame) + + +def read_bagg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: + """Construct a game from its serialised representation in a BAGG file. + + Parameters + ---------- + filepath_or_buffer : str, pathlib.Path or io.IOBase + The path to the file containing the game representation or file-like object + + Returns + ------- + Game + A game constructed from the representation in the file. + + Raises + ------ + IOError + If the file cannot be opened or read + ValueError + If the contents of the file are not a valid game representation. + + See Also + -------- + read_gbt, read_efg, read_nfg, read_agg + """ + return read_game(filepath_or_buffer, parser=ParseBaggGame) diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index 30d1cde811..14f6824c24 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -20,59 +20,6 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # -@cython.cclass -class InfosetMembers: - """The set of nodes which are members of an information set.""" - infoset = cython.declare(c_GameInfoset) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create InfosetMembers outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(infoset: c_GameInfoset) -> InfosetMembers: - obj: InfosetMembers = InfosetMembers.__new__(InfosetMembers) - obj.infoset = infoset - return obj - - def __repr__(self) -> str: - return ( - f"InfosetMembers(infoset={_wrap_infoset_or_event(self.infoset.deref().GetMember(1))})" - ) - - def __len__(self) -> int: - return self.infoset.deref().GetMembers().size() - - def __iter__(self) -> typing.Iterator[Node]: - for member in self.infoset.deref().GetMembers(): - yield Node.wrap(member) - - def __getitem__(self, label: str) -> Node: - """Returns the member node with text label `label`. - - Parameters - ---------- - label : str - The text label of the member node to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. - - Raises - ------ - KeyError - If the information set has no member with label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one member has label `label`. - TypeError - If `label` is not a string. - - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference a member by its label, or iterate - over the collection. String lookup now requires an exact match of the label; - previously, leading/trailing whitespace was stripped from `label` before comparison. - """ - return _resolve_by_label(self, label, "Infoset", "member", "members") - - @cython.cclass class _InfosetOrEvent: """Shared implementation for `Infoset` and `Event`: a lazy, node-anchored view over @@ -190,13 +137,19 @@ class _InfosetOrEvent: return [a.deref().GetLabel().decode("utf-8") for a in resolved.deref().GetActions()] @property - def members(self) -> InfosetMembers: - """The set of nodes which are members of the information set. + def members(self) -> list[Node]: + """The nodes which are members of the information set. + + The order of information set members is the order in which they are + encountered in the pre-order depth first traversal of the game tree. - The iteration order of information set members is the order in which they - are encountered in the pre-order depth first traversal of the game tree. + .. versionchanged:: 17.0.0 + Returns a plain ``list`` rather than a lazily-resolved collection; a + member is no longer accessible by label, following the removal of + ``Action``/``Strategy`` label-indexed collections elsewhere in the API. """ - return InfosetMembers.wrap(self._resolve()) + resolved: c_GameInfoset = self._resolve() + return [Node.wrap(member) for member in resolved.deref().GetMembers()] @property def player(self) -> str: @@ -265,12 +218,3 @@ class Event(_InfosetOrEvent): if resolved != cython.cast(c_GameInfoset, NULL) and not resolved.deref().IsChanceInfoset(): return cython.cast(c_GameInfoset, NULL) return resolved - - -@cython.cfunc -def _wrap_infoset_or_event(node: c_GameNode) -> object: - """Wraps `node` as an `Infoset` or `Event`, whichever currently applies; only - valid to call when `node` is known to belong to one or the other (not terminal).""" - if node.deref().GetInfoset().deref().IsChanceInfoset(): - return Event.wrap(node) - return Infoset.wrap(node) diff --git a/src/pygambit/levelk.py b/src/pygambit/levelk.py index f7a167565c..156a6b6dbd 100644 --- a/src/pygambit/levelk.py +++ b/src/pygambit/levelk.py @@ -22,12 +22,14 @@ """Provides support for level-k/cognitive hierarchy modeling """ +import dataclasses import math +import numpy import scipy.optimize import scipy.stats -from .profiles import Solution +import pygambit.gambit as libgbt def logit_br(game, profile, lam): @@ -45,28 +47,21 @@ def do_sum(maxi, logpi, lam, values): return br -class CognitiveHierarchyProfile(Solution): +@dataclasses.dataclass(frozen=True) +class CognitiveHierarchyProfile: """Container class representing a CH solution. """ - def __init__(self, tau, lam, profile): - Solution.__init__(self, profile) - self._tau = tau - self._lam = lam + tau: float + lam: float + profile: libgbt.MixedStrategyProfileDouble + logL: float | None = None def __repr__(self): return ( f"" + f"tau={self.tau}, lam={self.lam}: {self.profile}>" ) - @property - def tau(self): - return self._tau - - @property - def lam(self): - return self._lam - def compute_coghier(game, tau, lam): """ @@ -109,16 +104,16 @@ def objective(params, game, data): penalty += tau*tau tau = 0.0 profile = compute_coghier(game, tau, lam) - logL = log_like(profile, data) + logL = log_like(profile.profile, data) return penalty - logL results = [] - for lam in scipy.linspace(min_lam, max_lam, grid_size): + for lam in numpy.linspace(min_lam, max_lam, grid_size): if verbose: print(f"Searching lambda={lam:.3f}") - for tau in scipy.linspace(min_tau, max_tau, grid_size): + for tau in numpy.linspace(min_tau, max_tau, grid_size): profile = compute_coghier(game, tau, lam) - profile.logL = log_like(profile, data) + profile = dataclasses.replace(profile, logL=log_like(profile.profile, data)) results.append(profile) results.sort(key=lambda x: x.logL) results = results[:sample_cands] @@ -136,7 +131,7 @@ def objective(params, game, data): disp=0) end_tau, end_lam = list(params) profile = compute_coghier(game, end_tau, end_lam) - profile.logL = log_like(profile, data) + profile = dataclasses.replace(profile, logL=log_like(profile.profile, data)) results.append(profile) if verbose: print(f"{profile.tau:f},{profile.lam:f},{profile.logL:f}") diff --git a/src/pygambit/nash.pxi b/src/pygambit/nash.pxi index d3b471c7fe..b4e8d66775 100644 --- a/src/pygambit/nash.pxi +++ b/src/pygambit/nash.pxi @@ -200,10 +200,8 @@ cdef public string InvokeLogitStrategyEventCallback( cdef public string InvokeLogitBehaviorEventCallback( callback, qre: shared_ptr[c_LogitQREMixedBehaviorProfile] ): - ret = LogitQREMixedBehaviorProfile() - ret.thisptr = qre try: - callback(ret) + callback(LogitQREMixedBehaviorProfile.wrap(qre)) except BaseException as e: return f"{type(e).__name__}: {e}".encode("utf-8") return b"" @@ -810,11 +808,17 @@ def _logit_strategy_branch(game: Game, class LogitQREMixedBehaviorProfile: thisptr = cython.declare(shared_ptr[c_LogitQREMixedBehaviorProfile]) - def __init__(self, game=None): - if game is not None: - self.thisptr = make_shared[c_LogitQREMixedBehaviorProfile]( - cython.cast(Game, game).game - ) + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create a LogitQREMixedBehaviorProfile outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(profile: shared_ptr[c_LogitQREMixedBehaviorProfile]) -> LogitQREMixedBehaviorProfile: + obj: LogitQREMixedBehaviorProfile = ( + LogitQREMixedBehaviorProfile.__new__(LogitQREMixedBehaviorProfile) + ) + obj.thisptr = profile + return obj def __repr__(self): return f"LogitQREMixedBehaviorProfile(lam={self.lam},profile={self.profile})" @@ -856,12 +860,12 @@ def _logit_behavior_estimate(profile: MixedBehaviorProfileDouble, """Estimate QRE corresponding to mixed behavior profile using maximum likelihood along the principal branch. """ - ret = LogitQREMixedBehaviorProfile(profile.game) - ret.thisptr = LogitBehaviorEstimateWrapper( - profile.profile, local_max, first_step, max_accel, - MakeLogitEventCallback[c_LogitQREMixedBehaviorProfile](event_callback) + return LogitQREMixedBehaviorProfile.wrap( + LogitBehaviorEstimateWrapper( + profile.profile, local_max, first_step, max_accel, + MakeLogitEventCallback[c_LogitQREMixedBehaviorProfile](event_callback) + ) ) - return ret def _logit_behavior_lambda(game: Game, @@ -876,15 +880,11 @@ def _logit_behavior_lambda(game: Game, iter(lam) except TypeError: lam = [lam] - ret = [] - for profile in LogitBehaviorAtLambdaWrapper( - game.game, lam, first_step, max_accel, - MakeLogitEventCallback[c_LogitQREMixedBehaviorProfile](event_callback) - ): - qre = LogitQREMixedBehaviorProfile() - qre.thisptr = profile - ret.append(qre) - return ret + return [LogitQREMixedBehaviorProfile.wrap(profile) + for profile in LogitBehaviorAtLambdaWrapper( + game.game, lam, first_step, max_accel, + MakeLogitEventCallback[c_LogitQREMixedBehaviorProfile](event_callback) + )] def _logit_behavior_branch(game: Game, @@ -892,9 +892,4 @@ def _logit_behavior_branch(game: Game, first_step: float, max_accel: float): solns = LogitBehaviorPrincipalBranchWrapper(game.game, maxregret, first_step, max_accel) - ret = [] - for profile_ptr in make_list_of_pointer(solns): - p = LogitQREMixedBehaviorProfile() - p.thisptr = profile_ptr - ret.append(p) - return ret + return [LogitQREMixedBehaviorProfile.wrap(profile) for profile in make_list_of_pointer(solns)] diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index e1e03a178a..5c330d6cf7 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -68,6 +68,48 @@ class NashComputationResult: parameters: dict = dataclasses.field(default_factory=dict) +def _validate_stop_after(funcname: str, stop_after: int | None) -> None: + """Shared validation for a `stop_after` keyword argument, accepted by `lcp_solve`/ + `enumpoly_solve`: `None` is always valid; otherwise must be a positive `int` + (not `bool`). + """ + if stop_after is not None and ( + isinstance(stop_after, bool) + or not isinstance(stop_after, Integral) + or stop_after <= 0 + ): + raise ValueError( + f"{funcname}(): stop_after argument must be a positive integer; got {stop_after}" + ) + + +def _normalize_perturbation( + perturbation: libgbt.Game | libgbt.MixedStrategyProfile +) -> tuple[libgbt.Game, libgbt.MixedStrategyProfileDouble]: + """Shared normalization of a `perturbation` parameter, accepted by `ipa_solve`/ + `gnm_solve`: a `Game` becomes a profile with probability 1 on each player's first + strategy and 0 elsewhere; a `MixedStrategyProfile` is converted to floating-point + via `as_float()`. Returns (game, perturbation). + """ + if isinstance(perturbation, libgbt.Game): + game = perturbation + perturbation = game.mixed_strategy_profile(rational=False) + for player in game.players: + strategies = game.get_strategies(player) + perturbation[player] = { + s: (1.0 if s == strategies[0] else 0.0) for s in strategies + } + elif isinstance(perturbation, libgbt.MixedStrategyProfile): + game = perturbation.game + perturbation = perturbation.as_float() + else: + raise TypeError( + f"parameter must be Game or MixedStrategyProfile, " + f"not {perturbation.__class__.__name__}" + ) + return game, perturbation + + def enumpure_solve( game: libgbt.Game, nash_callback: Callable[[libgbt.MixedStrategyProfileRational], None] | None = None, @@ -321,15 +363,9 @@ def lcp_solve( raise ValueError( "lcp_solve(): max_depth can only be used on the strategic representation" ) - if stop_after is not None and ( - isinstance(stop_after, bool) - or not isinstance(stop_after, Integral) - or stop_after <= 0 - ): - raise ValueError( - f"lcp_solve(): stop_after argument must be a positive integer; got {stop_after}" - ) - if not game.is_tree or use_strategic: + _validate_stop_after("lcp_solve", stop_after) + use_strategic = not game.is_tree or use_strategic + if use_strategic: if rational: equilibria = libgbt._lcp_strategy_solve_rational( game, stop_after, max_depth or 0, nash_callback @@ -346,7 +382,7 @@ def lcp_solve( game=game, method="lcp", rational=rational, - use_strategic=not game.is_tree or use_strategic, + use_strategic=use_strategic, equilibria=equilibria, parameters={"stop_after": stop_after, "max_depth": max_depth} ) @@ -392,7 +428,8 @@ def lp_solve( RuntimeError If game has more than two players or is not constant sum. """ - if not game.is_tree or use_strategic: + use_strategic = not game.is_tree or use_strategic + if use_strategic: if rational: equilibria = libgbt._lp_strategy_solve_rational(game, nash_callback) else: @@ -548,7 +585,7 @@ def liap_agent_solve( The result represented as a ``NashComputationResult`` object. """ if maxregret <= 0.0: - raise ValueError("liap_solve(): maxregret argument must be positive") + raise ValueError("liap_agent_solve(): maxregret argument must be positive") start = start.as_float() equilibria = libgbt._liap_behavior_solve( start, maxregret=maxregret, maxiter=maxiter, @@ -694,22 +731,7 @@ def ipa_solve( res : NashComputationResult The result represented as a ``NashComputationResult`` object. """ - if isinstance(perturbation, libgbt.Game): - game = perturbation - perturbation = game.mixed_strategy_profile(rational=False) - for player in game.players: - strategies = game.get_strategies(player) - perturbation[player] = { - s: (1.0 if s == strategies[0] else 0.0) for s in strategies - } - elif isinstance(perturbation, libgbt.MixedStrategyProfile): - game = perturbation.game - perturbation = perturbation.as_float() - else: - raise TypeError( - f"parameter must be Game or MixedStrategyProfile, " - f"not {perturbation.__class__.__name__}" - ) + game, perturbation = _normalize_perturbation(perturbation) return NashComputationResult( game=game, method="ipa", @@ -813,22 +835,7 @@ def gnm_solve( res : NashComputationResult The result represented as a ``NashComputationResult`` object. """ - if isinstance(perturbation, libgbt.Game): - game = perturbation - perturbation = game.mixed_strategy_profile(rational=False) - for player in game.players: - strategies = game.get_strategies(player) - perturbation[player] = { - s: (1.0 if s == strategies[0] else 0.0) for s in strategies - } - elif isinstance(perturbation, libgbt.MixedStrategyProfile): - game = perturbation.game - perturbation = perturbation.as_float() - else: - raise TypeError( - f"parameter must be Game or MixedStrategyProfile, " - f"not {perturbation.__class__.__name__}" - ) + game, perturbation = _normalize_perturbation(perturbation) if end_lambda >= 0.0: raise ValueError(f"end_lambda must be a negative number; got {end_lambda}") if steps <= 0: @@ -973,15 +980,7 @@ def enumpoly_solve( ----- PHCpack is available at https://homepages.math.uic.edu/~jan/PHCpack/phcpack.html """ - if stop_after is not None and ( - isinstance(stop_after, bool) - or not isinstance(stop_after, Integral) - or stop_after <= 0 - ): - raise ValueError( - f"enumpoly_solve(): " - f"stop_after argument must be a positive integer; got {stop_after}" - ) + _validate_stop_after("enumpoly_solve", stop_after) if maxregret <= 0.0: raise ValueError( f"enumpoly_solve(): maxregret must be a positive number; got {maxregret}" @@ -991,8 +990,9 @@ def enumpoly_solve( f"enumpoly_solve(): " f"max_rectangles argument must be a positive number; got {max_rectangles}" ) + use_strategic = not game.is_tree or use_strategic if phcpack_path is not None: - if game.is_tree and not use_strategic: + if not use_strategic: raise ValueError( "enumpoly_solve(): only solving on the strategic representation is " "supported by the PHCpack implementation" @@ -1007,13 +1007,13 @@ def enumpoly_solve( game=game, method="enumpoly", rational=False, - use_strategic=False, + use_strategic=True, parameters={"stop_after": stop_after, "maxregret": maxregret, "phcpack_path": phcpack_path}, equilibria=equilibria, ) - if not game.is_tree or use_strategic: + if use_strategic: equilibria = libgbt._enumpoly_strategy_solve( game, stop_after, maxregret, max_rectangles, nash_callback, event_callback ) @@ -1025,7 +1025,7 @@ def enumpoly_solve( game=game, method="enumpoly", rational=False, - use_strategic=not game.is_tree or use_strategic, + use_strategic=use_strategic, parameters={"stop_after": stop_after, "maxregret": maxregret, "max_rectangles": max_rectangles}, equilibria=equilibria, @@ -1092,7 +1092,8 @@ def logit_solve( raise ValueError("logit_solve(): first_step argument must be positive") if max_accel < 1.0: raise ValueError("logit_solve(): max_accel argument must be at least 1.0") - if not game.is_tree or use_strategic: + use_strategic = not game.is_tree or use_strategic + if use_strategic: equilibria = libgbt._logit_strategy_solve( game, maxregret, first_step, max_accel, event_callback ) @@ -1104,7 +1105,7 @@ def logit_solve( game=game, method="logit", rational=False, - use_strategic=not game.is_tree or use_strategic, + use_strategic=use_strategic, equilibria=equilibria, parameters={"first_step": first_step, "max_accel": max_accel}, ) diff --git a/src/pygambit/nashlrs.py b/src/pygambit/nashlrs.py index cde87774b0..94ef835077 100644 --- a/src/pygambit/nashlrs.py +++ b/src/pygambit/nashlrs.py @@ -6,7 +6,6 @@ import itertools import pathlib import subprocess -import sys import pygambit as gbt import pygambit.util as util @@ -53,25 +52,3 @@ def lrsnash_solve(game: gbt.Game, if result.returncode != 0: raise ValueError(f"PHC run failed with return code {result.returncode}") return _parse_lrs_output(game, result.stdout) - - -def _read_game(fn: str) -> gbt.Game: - for reader in [gbt.read_efg, gbt.read_nfg, gbt.read_agg]: - try: - return reader(fn) - except Exception: - pass - raise OSError(f"Unable to read or parse {fn}") - - -def main(): - game = _read_game(sys.argv[1]) - eqa = lrsnash_solve(game, "./lrsnash") - for eqm in eqa: - print("NE," + - ",".join(str(eqm[player][strat]) - for player in game.players for strat in game.get_strategies(player))) - - -if __name__ == "__main__": - main() diff --git a/src/pygambit/nashphc.py b/src/pygambit/nashphc.py index 370596b75a..0d26c488e3 100644 --- a/src/pygambit/nashphc.py +++ b/src/pygambit/nashphc.py @@ -8,7 +8,6 @@ import pathlib import string import subprocess -import sys import typing import pygambit as gbt @@ -279,21 +278,3 @@ def phcpack_solve(game: gbt.Game, phcpack_path: pathlib.Path | str, for support in gbt.nash.possible_nash_supports(game) for eqm in _solve_support(support, phcpack_path, maxregret, negtol) ] - - -def _read_game(fn: str) -> gbt.Game: - for reader in [gbt.read_efg, gbt.read_nfg, gbt.read_agg]: - try: - return reader(fn) - except Exception: - pass - raise OSError(f"Unable to read or parse {fn}") - - -def main(): - game = _read_game(sys.argv[1]) - phcpack_solve(game, "./phc", maxregret=1.0e-6) - - -if __name__ == "__main__": - main() diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 0205644698..d8e8fa98a3 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -20,6 +20,28 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # +Branch = collections.namedtuple("Branch", ["node", "label"]) +Branch.__doc__ = """The action labeled `label`, taken at `node`. + +Returned by `Node.prior_action` and `Node.own_prior_action`; `node` is the node at +which the action was taken (not the node it leads to), so ``branch.node.actions`` +and, for a chance event, ``branch.node.action_probs[branch.label]`` are always +well-defined. + +.. versionadded:: 17.0.0 +""" + + +@cython.cfunc +def _decode_prob(py_string: string) -> object: + """Internal: decode a probability formatted by the C++ core as ``Decimal`` or + ``Rational``, matching whichever representation was used to specify it.""" + if "." in py_string.decode("ascii"): + return decimal.Decimal(py_string.decode("ascii")) + else: + return Rational(py_string.decode("ascii")) + + @cython.cclass class NodeChildren: """The set of nodes which are direct descendants of a node.""" @@ -253,8 +275,8 @@ class Node: return Event.wrap(self.node) @property - def members(self) -> InfosetMembers: - """The set of nodes which are members of the information set or event to which + def members(self) -> list[Node]: + """The nodes which are members of the information set or event to which this node currently belongs -- whichever applies. Equivalent to ``self.infoset.members`` or ``self.event.members``, whichever is not falsy; unlike those, this is well-defined regardless of which currently applies. diff --git a/src/pygambit/outcome.pxi b/src/pygambit/outcome.pxi index 92c14884c1..91703ed817 100644 --- a/src/pygambit/outcome.pxi +++ b/src/pygambit/outcome.pxi @@ -20,8 +20,6 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # import cython -from cython.operator cimport dereference as deref -from libcpp.memory cimport shared_ptr import typing diff --git a/src/pygambit/profiles.py b/src/pygambit/profiles.py deleted file mode 100644 index 7979422f02..0000000000 --- a/src/pygambit/profiles.py +++ /dev/null @@ -1,47 +0,0 @@ -# -# This file is part of Gambit -# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -# -# FILE: src/python/gambit/profiles.py -# Base classes for strategy profiles -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -# -""" -Base classes for strategy profiles. -""" - - -class Solution: - """ - Generic object representing a strategy profile which is - (part of) a solution of a game. - """ - def __init__(self, profile): - self._profile = profile - - def __len__(self): - return len(self._profile) - - def __getitem__(self, i): - return self._profile[i] - - def __setitem__(self, i, v): - raise TypeError( - "solution profile object does not support probability assignment" - ) - - def __getattr__(self, attr): - return getattr(self._profile, attr) diff --git a/src/pygambit/stratspt.pxi b/src/pygambit/stratspt.pxi index ed609ec4dc..8a393eafae 100644 --- a/src/pygambit/stratspt.pxi +++ b/src/pygambit/stratspt.pxi @@ -26,7 +26,43 @@ from libcpp.memory cimport unique_ptr @cython.cclass -class StrategySupport: +class _LabelSet: + """Shared implementation for `StrategySupport` and `ActionSupport`: an immutable + snapshot of a set of labels (strategies, or actions at an information set) taken + from a support profile at retrieval time, together with the owner (a player label, + or an information set) the labels belong to. + + Not exported; only `StrategySupport` and `ActionSupport` are part of the public API. + """ + _owner = cython.declare(object) + _labels = cython.declare(tuple) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError(f"Cannot create an {type(self).__name__} outside a Game.") + + def __repr__(self) -> str: + return str(list(self._labels)) + + def __eq__(self, other: typing.Any) -> bool: + if isinstance(other, (set, frozenset, list, tuple)): + return set(self._labels) == set(other) + if (not isinstance(other, type(self)) or + self._owner != cython.cast(_LabelSet, other)._owner): + return False + return set(self._labels) == set(cython.cast(_LabelSet, other)._labels) + + def __len__(self) -> int: + return len(self._labels) + + def __iter__(self) -> typing.Generator[str, None, None]: + yield from self._labels + + def __contains__(self, label: str) -> bool: + return label in self._labels + + +@cython.cclass +class StrategySupport(_LabelSet): """The labels of the strategies for a specified player in a `StrategySupportProfile`. @@ -39,42 +75,17 @@ class StrategySupport: labels. Iterates over strategy labels (``str``) rather than ``Strategy`` objects. """ - _player = cython.declare(str) - _strategies = cython.declare(tuple) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create a StrategySupport outside a Game.") - @staticmethod @cython.cfunc def wrap(player: str, strategies: tuple) -> StrategySupport: obj: StrategySupport = StrategySupport.__new__(StrategySupport) - obj._player = player - obj._strategies = strategies + obj._owner = player + obj._labels = strategies return obj @property def player(self) -> str: - return self._player - - def __repr__(self) -> str: - return str(list(self._strategies)) - - def __eq__(self, other: typing.Any) -> bool: - if isinstance(other, (set, frozenset, list, tuple)): - return set(self._strategies) == set(other) - if not isinstance(other, StrategySupport) or self.player != other.player: - return False - return set(self._strategies) == set(cython.cast(StrategySupport, other)._strategies) - - def __len__(self) -> int: - return len(self._strategies) - - def __iter__(self) -> typing.Generator[str, None, None]: - yield from self._strategies - - def __contains__(self, label: str) -> bool: - return label in self._strategies + return self._owner @cython.cclass diff --git a/tests/test_game.py b/tests/test_game.py index c8d46dbe84..d3a9cfb9be 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -304,16 +304,9 @@ def test_mixed_behavior_profile_game_structure_changed(): profile.__getitem__(game.root) -def _bob_response_infoset(g): - return next( - n.infoset for n in g.get_infosets("Bob") if n.infoset.label == "Bob's response" - ) - - COLLECTION_GETTERS = [ pytest.param(lambda g: g.players, id="GamePlayers"), pytest.param(lambda g: g.outcomes, id="GameOutcomes"), - pytest.param(lambda g: _bob_response_infoset(g).members, id="InfosetMembers"), ] diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index 8713fd630e..7795dd7b84 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -25,30 +25,6 @@ def _test_valid_resolutions(collection: list, resolver: typing.Callable) -> None assert objects[0] == resolver(label, "test") -@pytest.mark.parametrize( - "game", - [ - games.read_from_file("sample_extensive_game.efg"), - ] -) -def test_resolve_outcome(game: gbt.Game) -> None: - _test_valid_resolutions(game.outcomes, - lambda label, fn: game._resolve_outcome(label, fn)) - - -@pytest.mark.parametrize( - "game,outcome,exception", - [ - (games.read_from_file("sample_extensive_game.efg"), "", ValueError), - (games.read_from_file("sample_extensive_game.efg"), " ", ValueError), - (games.read_from_file("sample_extensive_game.efg"), "nosuchoutcome", KeyError), - ] -) -def test_resolve_outcome_invalid(game: gbt.Game, outcome: str, exception: BaseException) -> None: - with pytest.raises(exception): - game._resolve_outcome(outcome, "test_resolve_outcome_invalid") - - @pytest.mark.parametrize( "game", [ diff --git a/tests/test_infosets.py b/tests/test_infosets.py index ce95bf764f..3ad6b3ba1c 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -464,6 +464,20 @@ def test_infoset_proxy_reresolves_after_split(): assert list(proxy.members) == [node] +def test_infoset_members_is_a_plain_snapshot_list(): + """`members` returns a plain `list`, not a lazily-resolved view: it supports + integer indexing, and a list obtained before a mutation keeps reflecting the + information set as it was at the time, rather than tracking its owner.""" + game = games.read_from_file("basic_extensive_game.efg") + node = game.root.children["U1"] + members = node.infoset.members + assert isinstance(members, list) + assert node in (members[0], members[1]) + game.make_infoset(node, node.player) + assert len(members) == 2 + assert list(node.infoset.members) == [node] + + def test_reveal_splits_infoset_by_action(): """Revealing the deal to Bob separates his single infoset into per-card singletons; the other player's structure is untouched.""" diff --git a/tests/test_nash.py b/tests/test_nash.py index 1f2ff7a51f..d49b599686 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -3660,3 +3660,63 @@ def test_logit_solve_lambda_error_with_invalid_max_accel(): gbt.qre.logit_solve_lambda(game=game, lam=[1, 2, 3], max_accel=0) with pytest.raises(ValueError, match="at least 1.0"): gbt.qre.logit_solve_lambda(game=game, lam=[1, 2, 3], max_accel=0.1) + + +def test_logit_solve_branch_and_lambda_on_extensive_game(): + """`logit_solve_branch`/`logit_solve_lambda`, on a tree game with `use_strategic` + left at its default `False`, dispatch to the behavior-form (`LogitQREMixedBehaviorProfile`) + code path rather than the strategy-form one exercised by the other tests in this module.""" + game = games.create_stripped_down_poker_efg() + branch = gbt.qre.logit_solve_branch(game, maxregret=0.01, first_step=0.1, max_accel=1.1) + assert len(branch) > 0 + assert all(isinstance(p, gbt.LogitQREMixedBehaviorProfile) for p in branch) + + events = [] + lam_results = gbt.qre.logit_solve_lambda( + game, lam=[0.5, 1.0], first_step=0.1, max_accel=1.1, + event_callback=lambda ev: events.append(ev), + ) + assert [p.lam for p in lam_results] == pytest.approx([0.5, 1.0]) + assert all(isinstance(p, gbt.LogitQREMixedBehaviorProfile) for p in lam_results) + assert len(events) > 0 + + +def test_lp_solve_reports_use_strategic_for_native_strategic_game(): + """A game that is natively strategic (`is_tree` is False) is always solved on the + strategic representation, regardless of the `use_strategic` argument -- the + reported `use_strategic` on the result must reflect that, not just echo the + argument as passed.""" + game = games.read_from_file("const_sum_game.nfg") + assert not game.is_tree + res = gbt.nash.lp_solve(game, use_strategic=False) + assert res.use_strategic is True + + +def test_enumpoly_solve_phcpack_reports_use_strategic_true(monkeypatch): + """`enumpoly_solve(..., phcpack_path=...)` always solves on the strategic + representation (enforced by the check just above the PHCpack call) -- the + reported `use_strategic` must say so, not hardcode a stale `False`.""" + import pathlib + + game = gbt.Game.new_table([2, 2]) + game.make_outcome({"1": "1", "2": "1"}, {"1": 1, "2": -1}, "a") + game.make_outcome({"1": "1", "2": "2"}, {"1": -1, "2": 1}, "b") + game.make_outcome({"1": "2", "2": "1"}, {"1": -1, "2": 1}, "c") + game.make_outcome({"1": "2", "2": "2"}, {"1": 1, "2": -1}, "d") + phc_output = ( + "THE SOLUTIONS :\n\n" + "solution 1 :\n" + " a0 : 5.0E-01 0.0E+00\n" + " a1 : 5.0E-01 0.0E+00\n" + " b0 : 5.0E-01 0.0E+00\n" + " b1 : 5.0E-01 0.0E+00\n" + "TIMING INFORMATION\n" + ) + + def _fake_run(cmd, **kwargs): + pathlib.Path(cmd[3]).write_text(phc_output) + return type("Result", (), {"returncode": 0})() + + monkeypatch.setattr("pygambit.nashphc.subprocess.run", _fake_run) + res = gbt.nash.enumpoly_solve(game, phcpack_path="./phc") + assert res.use_strategic is True