diff --git a/ChangeLog b/ChangeLog index cf2523b45..836f91adc 100644 --- a/ChangeLog +++ b/ChangeLog @@ -11,6 +11,10 @@ `logit_estimate`, `ipa_solve`, and `gnm_solve` now also accept a rational-precision profile directly (as a starting point or perturbation vector), converting it internally. (#721, #458) +- Added `Game.get_outcome`, which returns the `Outcome` attached to a pure-strategy contingency + of a game in strategic (table) representation. +- Added `Game.get_payoffs`, which returns the payoff to each player at a pure-strategy + contingency, for a game in any representation. ### Changed - Command-line tools are now implemented in `pygambit` rather than C++-based compiled programs. @@ -25,6 +29,26 @@ - `Game.new_table` no longer creates an outcome for every contingency; a new strategic game has no outcomes, and every contingency is initially null. Outcomes are created as they are needed; games built by `Game.from_arrays` and `Game.from_dict` are unaffected. (#1061) +- `Game.contingencies` now yields contingencies as a mapping from player label to strategy + label, rather than a list of per-player strategy indices. +- `Player.strategies` now iterates strategy labels (`str`) rather than `Strategy` objects; + indexing by label is no longer supported (a label is already in hand once iterated) -- use + `in` to test membership. +- `Game.strategy_support_profile`'s `strategies` filter callable is now called as + `strategies(player, label)` (two positional arguments) rather than with a single `Strategy`. +- `Game.get_behavior`'s `player` and `strategy` parameters are now labels (`str`) rather than + accepting a `Player`/`Strategy` object; `StrategyBehavior.player`/`.strategy` now return + labels rather than `Player`/`Strategy` objects. + +### Removed +- Removed `Game.__getitem__`, replaced by `Game.get_outcome`/`Game.get_payoffs`. +- Removed `Game.strategies`; iterate `Game.players` and each player's `strategies` instead. +- Removed `Strategy.action`; use `Game.get_behavior(player, strategy)[infoset]` (or `.get`), + already the documented fuller form of the same lookup. +- Removed `Strategy`. Strategies are now identified purely by label (`str`), as returned by + `Player.strategies`; a `Strategy` object carried no information a label didn't already + carry, now that strategy labels are unique within a player. `GameStrategyRep` in the C++ + core is unaffected by this change (a separate, later piece of work). ## [17.0.0-alpha.2] - 2026-08-21 diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 438012388..e664b68d4 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -19,7 +19,6 @@ Representation of games Node Infoset Action - Strategy Subgame @@ -109,12 +108,13 @@ Information about the game Game.outcomes Game.min_payoff Game.max_payoff - Game.strategies Game.root Game.actions Game.infosets Game.nodes Game.contingencies + Game.get_outcome + Game.get_payoffs Game.subgames Game.minimal_subgame @@ -192,16 +192,6 @@ Information about the game Action.prob Action.plays -.. autosummary:: - - :toctree: api/ - - Strategy.label - Strategy.game - Strategy.player - Strategy.number - Strategy.action - .. autosummary:: :toctree: api/ diff --git a/doc/tutorials/01_quickstart.ipynb b/doc/tutorials/01_quickstart.ipynb index 9b83fa285..0a4e6894e 100644 --- a/doc/tutorials/01_quickstart.ipynb +++ b/doc/tutorials/01_quickstart.ipynb @@ -76,14 +76,7 @@ "id": "9d8203e8", "metadata": {}, "outputs": [], - "source": [ - "tom, jerry = g.players\n", - "g.relabel_players({tom.label: \"Tom\", jerry.label: \"Jerry\"})\n", - "\n", - "for player in g.players:\n", - " cooperate, defect = player.strategies\n", - " g.relabel_strategies(player, {cooperate.label: \"Cooperate\", defect.label: \"Defect\"})" - ] + "source": "tom, jerry = g.players\ng.relabel_players({tom.label: \"Tom\", jerry.label: \"Jerry\"})\n\nfor player in g.players:\n cooperate, defect = player.strategies\n g.relabel_strategies(player, {cooperate: \"Cooperate\", defect: \"Defect\"})" }, { "cell_type": "markdown", @@ -103,13 +96,7 @@ "id": "61030607", "metadata": {}, "outputs": [], - "source": [ - "# Each contingency gets an outcome, created and attached in one step\n", - "g.make_outcome((\"Cooperate\", \"Cooperate\"), {\"Tom\": -1, \"Jerry\": -1}, \"Both cooperate\")\n", - "g.make_outcome((\"Cooperate\", \"Defect\"), {\"Tom\": -3, \"Jerry\": 0}, \"Tom cooperates, Jerry defects\")\n", - "g.make_outcome((\"Defect\", \"Cooperate\"), {\"Tom\": 0, \"Jerry\": -3}, \"Tom defects, Jerry cooperates\")\n", - "g.make_outcome((\"Defect\", \"Defect\"), {\"Tom\": -2, \"Jerry\": -2}, \"Both defect\")" - ] + "source": "# Each contingency gets an outcome, created and attached in one step\ng.make_outcome(\n {\"Tom\": \"Cooperate\", \"Jerry\": \"Cooperate\"}, {\"Tom\": -1, \"Jerry\": -1}, \"Both cooperate\"\n)\ng.make_outcome(\n {\"Tom\": \"Cooperate\", \"Jerry\": \"Defect\"},\n {\"Tom\": -3, \"Jerry\": 0},\n \"Tom cooperates, Jerry defects\",\n)\ng.make_outcome(\n {\"Tom\": \"Defect\", \"Jerry\": \"Cooperate\"},\n {\"Tom\": 0, \"Jerry\": -3},\n \"Tom defects, Jerry cooperates\",\n)\ng.make_outcome(\n {\"Tom\": \"Defect\", \"Jerry\": \"Defect\"}, {\"Tom\": -2, \"Jerry\": -2}, \"Both defect\"\n)" }, { "cell_type": "code", diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 113330199..aa297e9b6 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -160,7 +160,7 @@ "source": [ "The loop above causes each of the newly-appended moves to be in new information sets, reflecting the fact that Alice's decision depends on the knowledge of which card she holds.\n", "\n", - "In contrast, Bob does not know Alice\u2019s card, and therefore cannot distinguish between the two nodes at which he has to make his decision:\n", + "In contrast, Bob does not know Alice’s card, and therefore cannot distinguish between the two nodes at which he has to make his decision:\n", "\n", " - Chance player chooses King, then Alice Bets: `g.root.children[\"King\"].children[\"Bet\"]`\n", " - Chance player chooses Queen, then Alice Bets: `g.root.children[\"Queen\"].children[\"Bet\"]`\n", @@ -396,7 +396,7 @@ "id": "1f121d48", "metadata": {}, "source": [ - "Now let's look at Bob\u2019s strategy:" + "Now let's look at Bob’s strategy:" ] }, { @@ -414,7 +414,7 @@ "id": "e906c4c4", "metadata": {}, "source": [ - "Bob Calls Alice\u2019s Bet two-thirds of the time.\n", + "Bob Calls Alice’s Bet two-thirds of the time.\n", "\n", "Since Bob has just one information set, we can get its representative node and index\n", "the profile directly by it to read off a single action's probability:" @@ -576,9 +576,7 @@ "id": "d4ecff88", "metadata": {}, "outputs": [], - "source": [ - "[s.label for s in g.players[\"Alice\"].strategies]" - ] + "source": "list(g.players[\"Alice\"].strategies)" }, { "cell_type": "markdown", @@ -637,7 +635,7 @@ "id": "56e2f847", "metadata": {}, "outputs": [], - "source": "gnm_payoffs = gnm_eqm.payoffs\ngnm_strategy_values = gnm_eqm.strategy_values\nfor player in g.players:\n print(\n f\"{player.label}'s expected payoffs playing:\"\n )\n for strategy in player.strategies:\n print(\n f\"Strategy {strategy.label}: {gnm_strategy_values[player.label][strategy.label]:.4f}\"\n )\n print(\n f\"{player.label}'s overall expected payoff: {gnm_payoffs[player.label]:.4f}\"\n )\n print()" + "source": "gnm_payoffs = gnm_eqm.payoffs\ngnm_strategy_values = gnm_eqm.strategy_values\nfor player in g.players:\n print(\n f\"{player.label}'s expected payoffs playing:\"\n )\n for strategy in player.strategies:\n print(\n f\"Strategy {strategy}: {gnm_strategy_values[player.label][strategy]:.4f}\"\n )\n print(\n f\"{player.label}'s overall expected payoff: {gnm_payoffs[player.label]:.4f}\"\n )\n print()" }, { "cell_type": "markdown", diff --git a/doc/tutorials/interoperability_tutorials/gamut.ipynb b/doc/tutorials/interoperability_tutorials/gamut.ipynb index f45790846..7052f6735 100644 --- a/doc/tutorials/interoperability_tutorials/gamut.ipynb +++ b/doc/tutorials/interoperability_tutorials/gamut.ipynb @@ -391,7 +391,7 @@ "id": "gamut-bos-gen", "metadata": {}, "outputs": [], - "source": "g_chicken = gbt.catalog.generate_gamut(\n \"Chicken\",\n params={\n \"int_payoffs\": True,\n \"int_mult\": 1,\n \"normalize\": True,\n \"min_payoff\": 0,\n \"max_payoff\": 4,\n },\n gamut_jar=\"~/Downloads/gamut.jar\",\n)\ng_chicken.title = \"Chicken\"\nfor player in g_chicken.players:\n labels = {strategy.label: label\n for strategy, label in zip(player.strategies, [\"Swerve\", \"Straight\"], strict=True)}\n g_chicken.relabel_strategies(player, labels)\ng_chicken" + "source": "g_chicken = gbt.catalog.generate_gamut(\n \"Chicken\",\n params={\n \"int_payoffs\": True,\n \"int_mult\": 1,\n \"normalize\": True,\n \"min_payoff\": 0,\n \"max_payoff\": 4,\n },\n gamut_jar=\"~/Downloads/gamut.jar\",\n)\ng_chicken.title = \"Chicken\"\nfor player in g_chicken.players:\n labels = {strategy: label\n for strategy, label in zip(player.strategies, [\"Swerve\", \"Straight\"], strict=True)}\n g_chicken.relabel_strategies(player, labels)\ng_chicken" }, { "cell_type": "markdown", diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index 1e3f35df8..dfb021f49 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -156,7 +156,7 @@ "id": "b684325e", "metadata": {}, "outputs": [], - "source": "gbt_matrix_rps_game = gbt.catalog.generate_openspiel(\"matrix_rps\")\n\ngbt_matrix_rps_game.title = \"Rock-Paper-Scissors\"\n\nfor player in gbt_matrix_rps_game.players:\n names = [\"Rock\", \"Paper\", \"Scissors\"]\n labels = {strategy.label: name\n for strategy, name in zip(player.strategies, names, strict=True)}\n gbt_matrix_rps_game.relabel_strategies(player, labels)\n\ngbt_matrix_rps_game" + "source": "gbt_matrix_rps_game = gbt.catalog.generate_openspiel(\"matrix_rps\")\n\ngbt_matrix_rps_game.title = \"Rock-Paper-Scissors\"\n\nfor player in gbt_matrix_rps_game.players:\n names = [\"Rock\", \"Paper\", \"Scissors\"]\n labels = {strategy: name\n for strategy, name in zip(player.strategies, names, strict=True)}\n gbt_matrix_rps_game.relabel_strategies(player, labels)\n\ngbt_matrix_rps_game" }, { "cell_type": "markdown", @@ -343,18 +343,7 @@ "id": "fcd42af0", "metadata": {}, "outputs": [], - "source": [ - "p1_payoffs, p2_payoffs = gbt_prisoners_dilemma_game.to_arrays(dtype=float)\n", - "p1, p2 = gbt_prisoners_dilemma_game.players\n", - "ops_prisoners_dilemma_game = pyspiel.create_matrix_game(\n", - " gbt_prisoners_dilemma_game.title,\n", - " \"Classic Prisoner's Dilemma\", # description\n", - " [strategy.label for strategy in p1.strategies],\n", - " [strategy.label for strategy in p2.strategies],\n", - " p1_payoffs,\n", - " p2_payoffs\n", - ")" - ] + "source": "p1_payoffs, p2_payoffs = gbt_prisoners_dilemma_game.to_arrays(dtype=float)\np1, p2 = gbt_prisoners_dilemma_game.players\nops_prisoners_dilemma_game = pyspiel.create_matrix_game(\n gbt_prisoners_dilemma_game.title,\n \"Classic Prisoner's Dilemma\", # description\n list(p1.strategies),\n list(p2.strategies),\n p1_payoffs,\n p2_payoffs\n)" }, { "cell_type": "markdown", diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index dfbac9775..1a01325eb 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -448,7 +448,8 @@ def check_filters(game: gbt.Game) -> bool: return False if n_players is not None and len(game.players) != n_players: return False - return not (n_strategies is not None and len(game.strategies) != n_strategies) + total_strategies = sum(len(list(p.strategies)) for p in game.players) + return not (n_strategies is not None and total_strategies != n_strategies) def append_record( slug: str, diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index e603f0a9d..2ecf2f72c 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -235,7 +235,7 @@ def render_support_csv( else: fields = [ "".join( - "1" if strategy.label in support[player.label] else "0" + "1" if strategy in support[player.label] else "0" for strategy in player.strategies ) for player in support.game.players @@ -271,10 +271,9 @@ def _render_strategy_detail(profile: gbt.MixedStrategyProfile, decimals: int) -> probs = profile[player.label] values = profile.strategy_values[player.label] for strategy in player.strategies: - name = _name_or_number(strategy) - prob = format_value(probs[strategy.label], decimals) - value = format_value(values[strategy.label], decimals) - lines.append(f"{name:>8} {prob:>10} {value:>11}") + prob = format_value(probs[strategy], decimals) + value = format_value(values[strategy], decimals) + lines.append(f"{strategy:>8} {prob:>10} {value:>11}") return "\n".join(lines) @@ -338,7 +337,7 @@ def read_strategy_profiles_csv( raise ValueError(f"Error reading strategy profile from '{path}': {exc}") from None profile = game.mixed_strategy_profile(rational=True) for player in game.players: - profile[player.label] = {s.label: next(values) for s in player.strategies} + profile[player.label] = {s: next(values) for s in player.strategies} profiles.append(profile) return profiles diff --git a/src/pygambit/cli/logit.py b/src/pygambit/cli/logit.py index 9a34f2c69..1a867f110 100644 --- a/src/pygambit/cli/logit.py +++ b/src/pygambit/cli/logit.py @@ -72,7 +72,7 @@ def _read_frequencies(path: str, game: gbt.Game) -> gbt.MixedStrategyProfileDoub frequencies = game.mixed_strategy_profile(rational=False) it = iter(values) for player in game.players: - frequencies[player.label] = {s.label: next(it) for s in player.strategies} + frequencies[player.label] = {s: next(it) for s in player.strategies} return frequencies diff --git a/src/pygambit/cli/simpdiv.py b/src/pygambit/cli/simpdiv.py index 1f929e94e..137e80a2c 100644 --- a/src/pygambit/cli/simpdiv.py +++ b/src/pygambit/cli/simpdiv.py @@ -50,7 +50,7 @@ def _default_start(game: gbt.Game) -> gbt.MixedStrategyProfileRational: start = game.mixed_strategy_profile(rational=True) for player in game.players: first_strategy = next(iter(player.strategies)) - start[player.label] = {first_strategy.label: 1} + start[player.label] = {first_strategy: 1} return start diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index e49bda1d5..1036b5c85 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -101,7 +101,6 @@ def _resolve_by_label(collection, label: str, scope: str, kind: str, kind_plural PlayerReference = Player | str -StrategyReference = Strategy | str InfosetReference = Infoset | str ActionReference = Action | str NodeReference = Node | str diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 1d8c1f176..a8a5bc89c 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -466,57 +466,6 @@ class GameInfosets: return _resolve_by_label(self, label, "Game", "infoset", "infosets") -@cython.cclass -class GameStrategies: - """Represents the set of all strategies in the game.""" - game = cython.declare(Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameStrategies outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: Game) -> GameStrategies: - obj: GameStrategies = GameStrategies.__new__(GameStrategies) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameStrategies(game={self.game})" - - def __len__(self) -> int: - return sum(len(p.strategies) for p in self.game.players) - - def __iter__(self) -> typing.Iterator[Strategy]: - for player in self.game.players: - yield from player.strategies - - def __getitem__(self, label: str) -> Strategy: - """Returns the strategy with text label `label`. - - Parameters - ---------- - label : str - The text label of the strategy to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. - - Raises - ------ - KeyError - If no strategy in the game has label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one strategy has label `label`. - TypeError - If `label` is not a string. - - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference a strategy 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", "strategy", "strategies") - - @cython.cclass class Game: """A game, the fundamental unit of analysis in game theory. @@ -632,9 +581,12 @@ class Game: raise ValueError("All specified arrays must have the same shape") shape = arrays[0].shape g = Game.wrap(NewTable(list(shape), False)) + players = list(g.players) for profile in itertools.product(*(range(s) for s in shape)): - for array, player in zip(arrays, g.players, strict=True): - g[profile][player] = array[profile] + contingency = {p.label: str(i + 1) for p, i in zip(players, profile, strict=True)} + outcome = g.get_outcome(contingency) + for array, player in zip(arrays, players, strict=True): + outcome[player] = array[profile] g.title = title return g @@ -657,15 +609,21 @@ class Game: """ arrays = [] - shape = tuple(len(player.strategies) for player in self.players) - for player in self.players: + players = list(self.players) + shape = tuple(len(player.strategies) for player in players) + for player in players: array = np.zeros(shape=shape, dtype=object) for profile in itertools.product(*(range(s) for s in shape)): + contingency = { + p.label: list(p.strategies)[i] + for p, i in zip(players, profile, strict=True) + } + payoffs = self.get_payoffs(contingency) try: - array[profile] = dtype(self[profile][player]) + array[profile] = dtype(payoffs[player.label]) except (ValueError, TypeError, IndexError, KeyError): raise ValueError( - f"Payoff '{self[profile][player]}' cannot be " + f"Payoff '{payoffs[player.label]}' cannot be " f"converted to requested type '{dtype}'" ) from None arrays.append(array) @@ -711,9 +669,12 @@ class Game: g.relabel_players( {player.label: label for player, label in zip(g.players, payoffs, strict=True)} ) + players = list(g.players) for profile in itertools.product(*(range(s) for s in shape)): - for array, player in zip(arrays, g.players, strict=True): - g[profile][player] = array[profile] + contingency = {p.label: str(i + 1) for p, i in zip(players, profile, strict=True)} + outcome = g.get_outcome(contingency) + for array, player in zip(arrays, players, strict=True): + outcome[player] = array[profile] g.title = title return g @@ -815,11 +776,6 @@ class Game: """The set of players in the game.""" return GamePlayers.wrap(self.game) - @property - def strategies(self) -> GameStrategies: - """The set of strategies in the game.""" - return GameStrategies.wrap(self) - @property def outcomes(self) -> GameOutcomes: """The set of outcomes in the game.""" @@ -962,18 +918,18 @@ class Game: ) def get_behavior(self, - player: Player | str, - strategy: Strategy | str) -> StrategyBehavior: + player: str, + strategy: str) -> StrategyBehavior: """Return the mapping from information sets to actions prescribed by a strategy. .. versionadded:: 17.0.0 Parameters ---------- - player : Player or str - The player whose strategy to view. - strategy : Strategy or str - The strategy to view. + player : str + The label of the player whose strategy to view. + strategy : str + The label of the strategy to view. Returns ------- @@ -983,128 +939,143 @@ class Game: ------ UndefinedOperationError If the game does not have a tree representation. - MismatchError - If `player` is from a different game, or `strategy` belongs to a different player. KeyError - If `strategy` is a string and `player` has no strategy with that label. - - See Also - -------- - Strategy.action : The action prescribed at a single information set. + If no player has the label `player`, or `player` has no strategy with + the label `strategy`. """ if not self.is_tree: raise UndefinedOperationError( "get_behavior(): only defined for games with a tree representation" ) - resolved_player = cython.cast(Player, self._resolve_player(player, "get_behavior")) - if isinstance(strategy, Strategy): - if strategy.player != resolved_player: - raise MismatchError( - f"get_behavior(): strategy must belong to player " - f"'{resolved_player.label}'" - ) - resolved_strategy = strategy - elif isinstance(strategy, str): - if not strategy.strip(): - raise ValueError( - "get_behavior(): strategy cannot be an empty string or all spaces" + resolved_player = cython.cast(Player, self.players[player]) + self._resolve_strategy(resolved_player, strategy, "get_behavior") # validate eagerly + return StrategyBehavior.wrap(self, resolved_player.label, strategy) + + def _resolve_contingency(self, contingency: typing.Any, funcname: str, + argname: str = "contingency") -> dict: + """Resolve a pure-strategy contingency to a dict from ``Player`` to strategy label. + + `contingency` must be a complete mapping from the game's players' labels to the + label of the strategy played by that player. Each strategy label is validated + (but not resolved to a handle) eagerly, so the whole mapping is checked before + any use is made of it. + """ + if not hasattr(contingency, "items"): + raise TypeError(f"{funcname}(): {argname} must be a mapping") + resolved = {} + for player_label, strategy_label in contingency.items(): + if not isinstance(player_label, str): + raise TypeError( + f"{funcname}(): {argname} keys must be player labels (str), " + f"not {player_label.__class__.__name__}" ) - resolved_strategy = resolved_player.strategies[strategy] - else: - raise TypeError( - f"get_behavior(): strategy must be Strategy or str, " - f"not {strategy.__class__.__name__}" + player = cython.cast(Player, self.players[player_label]) + if player in resolved: + raise ValueError(f"{funcname}(): each player may appear only once in {argname}") + self._resolve_strategy(player, strategy_label, funcname, argname) + resolved[player] = strategy_label + if set(resolved) != set(self.players): + raise ValueError( + f"{funcname}(): {argname} must specify exactly one strategy " + f"for each player of the game" ) - return StrategyBehavior.wrap(resolved_player, resolved_strategy) + return resolved - def _get_contingency(self, *args): + @cython.cfunc + def _make_pure_strategy_profile(self, resolved: dict) -> shared_ptr[c_PureStrategyProfile]: + """Build a C++ pure-strategy profile from a dict mapping ``Player`` to strategy + label.""" psp: shared_ptr[c_PureStrategyProfile] = make_shared[c_PureStrategyProfile]( self.game.deref().NewPureStrategyProfile() ) - - for (pl, st) in enumerate(args): - deref(deref(psp).deref()).SetStrategy( - self.game.deref().GetPlayer(pl+1).deref().GetStrategy(st+1) + for player in self.players: + resolved_player: Player = cython.cast(Player, player) + handle = self._resolve_strategy( + resolved_player, resolved[resolved_player], "_make_pure_strategy_profile" ) + deref(deref(psp).deref()).SetStrategy(handle) + return psp - if self.is_tree or self.game.deref().IsAgg(): - return DerivedGameOutcome.wrap(self.game, psp) - return Outcome.wrap(deref(deref(psp).deref()).GetOutcome()) + def get_outcome(self, contingency: typing.Mapping) -> Outcome: + """Returns the `Outcome` attached to a pure-strategy contingency. - def __getitem__(self, contingency): - """Returns the `Outcome` associated with a profile of pure strategies. + Only defined for games in strategic (table) representation; for extensive-form + and action-graph games, a pure-strategy contingency has no single stored outcome + to return (see `get_payoffs`). + + .. versionadded:: 17.0.0 + + Parameters + ---------- + contingency : Mapping + A complete mapping from the game's players' labels to the label of the + strategy played by that player. - Each strategy in the profile may be given as a ``Strategy``, its text label, - or its integer index within the corresponding player's strategies. + Returns + ------- + Outcome + The outcome attached to `contingency` (possibly the null outcome). Raises ------ - TypeError - If `contingency` is not a tuple-like object, or contains an element - that is not an ``int``, ``str``, or ``Strategy``. + UndefinedOperationError + If the game is not in strategic (table) representation. + ValueError + If `contingency` does not specify exactly one strategy for each player + of the game, or a key is an empty or all-whitespace string. KeyError - If the number of elements in `contingency` does not equal the - number of players. - IndexError - If an integer index is out of range for the corresponding player, - or a label or ``Strategy`` does not belong to that player. - MismatchError - If a ``Strategy`` belongs to a different game. - - .. note:: - Unlike the game's object collections, strategies within a contingency can be referenced - by integer index, as a contingency is a coordinate in the players' strategy spaces; - labels and ``Strategy`` objects are also accepted. + If a player label, or a player's strategy label, does not match any + player, or that player's strategies, in the game. + TypeError + If `contingency` is not a mapping, or a key or value is not a `str`. """ - return self._get_contingency( - *tuple(self._resolve_contingency(contingency, "Game.__getitem__")) - ) + if self.is_tree or self.game.deref().IsAgg(): + raise UndefinedOperationError( + "get_outcome(): operation not defined for games not in " + "strategic (table) representation" + ) + resolved = self._resolve_contingency(contingency, "get_outcome") + psp = self._make_pure_strategy_profile(resolved) + return Outcome.wrap(deref(deref(psp).deref()).GetOutcome()) - def _resolve_contingency(self, contingency: typing.Any, funcname: str, - argname: str = "contingency") -> list: - """Resolve a pure-strategy contingency to a list of per-player strategy indices. + def get_payoffs(self, contingency: typing.Mapping) -> PayoffVector: + """Returns the payoff to each player at a pure-strategy contingency. + + Works for any game representation. For extensive-form and action-graph games + the payoffs are computed, not read from a stored outcome, and are always + returned as ``Rational`` regardless of the game's own numerical representation. + + .. versionadded:: 17.0.0 + + Parameters + ---------- + contingency : Mapping + A complete mapping from the game's players' labels to the label of the + strategy played by that player. + + Returns + ------- + PayoffVector + The payoff to each player, keyed by player label. - Each element may be a ``Strategy``, its label, or its index within the - corresponding player's strategies. + Raises + ------ + ValueError + If `contingency` does not specify exactly one strategy for each player + of the game, or a key is an empty or all-whitespace string. + KeyError + If a player label, or a player's strategy label, does not match any + player, or that player's strategies, in the game. + TypeError + If `contingency` is not a mapping, or a key or value is not a `str`. """ - players = list(self.players) - try: - if len(contingency) != len(players): - raise KeyError( - f"{funcname}(): number of strategies in {argname} is not equal to " - f"the number of players" - ) - except TypeError: - raise TypeError(f"{funcname}(): {argname} must be a tuple-like object") from None - cont = [0 for _ in players] - for (pl, st) in enumerate(contingency): - player = players[pl] - if isinstance(st, int): - if st < 0 or st >= len(player.strategies): - raise IndexError( - f"{funcname}(): strategy index {st} out of range for player {pl}" - ) - cont[pl] = st - elif isinstance(st, str): - try: - cont[pl] = [s.label for s in player.strategies].index(st) - except ValueError: - raise IndexError(f"{funcname}(): player {pl} has no strategy labelled '{st}'") - elif isinstance(st, Strategy): - if st.game != self: - raise MismatchError(f"{funcname}(): {argname} must be composed of " - f"strategies from the same game") - try: - cont[pl] = list(player.strategies).index(st) - except ValueError: - raise IndexError( - f"{funcname}(): strategy '{st}' not available to player {pl}" - ) - else: - raise TypeError( - f"{funcname}(): {argname} must contain ints, strategy labels, or strategies" - ) - return cont + resolved = self._resolve_contingency(contingency, "get_payoffs") + psp = self._make_pure_strategy_profile(resolved) + values = {} + for p in self.players: + player = cython.cast(Player, p) + values[player.label] = rat_to_py(deref(deref(psp).deref()).GetPayoff(player.player)) + return PayoffVector(values) def _fill_strategy_profile(self, profile: MixedStrategyProfile, @@ -1121,7 +1092,7 @@ class Game: f"Number of elements does not match number of strategies for {p}" ) profile[p.label] = { - s.label: typefunc(v) for s, v in zip(p.strategies, d, strict=True) + s: typefunc(v) for s, v in zip(p.strategies, d, strict=True) } return profile @@ -1199,11 +1170,11 @@ class Game: profile = self.mixed_strategy_profile() for player in self.players: weights = scipy.stats.dirichlet( - alpha=[1 for strategy in player.strategies], + alpha=[1 for _ in player.strategies], seed=gen ).rvs(size=1)[0] profile[player.label] = dict( - zip((s.label for s in player.strategies), weights, strict=True) + zip(player.strategies, weights, strict=True) ) return profile elif denom < 1: @@ -1220,7 +1191,7 @@ class Game: [denom + k] ) distribution = { - strategy.label: Rational(hi - lo - 1, denom) + strategy: Rational(hi - lo - 1, denom) for strategy, (hi, lo) in zip( player.strategies, zip(sample[1:], sample[:-1], strict=True), @@ -1367,8 +1338,8 @@ class Game: ---------- strategies : function, optional By default the support profile contains all strategies for all players. - If specified, only strategies for which the supplied function returns `True` - are included. + If specified, called as ``strategies(player, label)`` for each strategy of + each player; only strategies for which it returns `True` are included. Returns ------- @@ -1376,11 +1347,14 @@ class Game: """ profile = StrategySupportProfile.wrap(make_shared[c_StrategySupportProfile](self.game)) if strategies is not None: - for strategy in self.strategies: - if not strategies(strategy): - if not (deref(profile.profile) - .RemoveStrategy(cython.cast(Strategy, strategy).strategy)): - raise ValueError("attempted to remove the last strategy for player") + for player in self.players: + for label in player.strategies: + if not strategies(player, label): + handle = self._resolve_strategy( + player, label, "strategy_support_profile" + ) + if not deref(profile.profile).RemoveStrategy(handle): + raise ValueError("attempted to remove the last strategy for player") return profile def behavior_support_profile( @@ -1615,47 +1589,35 @@ class Game: f"{funcname}(): {argname} must be Outcome or str, not {outcome.__class__.__name__}" ) - def _resolve_strategy(self, - strategy: typing.Any, - funcname: str, - argname: str = "strategy") -> Strategy: - """Resolve an attempt to reference a strategy of the game. + @cython.cfunc + def _resolve_strategy(self, player: Player, label, funcname: str, + argname: str = "strategy") -> c_GameStrategy: + """Resolve `label` to the C++ handle of one of `player`'s strategies. - Parameters - ---------- - strategy : Any - An object to resolve as a reference to a strategy. - funcname : str - The name of the function to raise any exception on behalf of. - argname : str, default 'strategy' - The name of the argument being checked + Not part of the public API -- used internally to bridge a strategy label to + the underlying C++ object without ever constructing a Python wrapper for it. Raises ------ - MismatchError - If `strategy` is a `Strategy` from a different game. KeyError - If `strategy` is a string and no strategy in the game has that label. + If `player` has no strategy with label `label`. TypeError - If `strategy` is not a `Strategy` or a `str` + If `label` is not a `str`. ValueError - If `strategy` is an empty `str` or all spaces + If `label` is an empty string or all spaces. """ - if isinstance(strategy, Strategy): - if strategy.game != self: - raise MismatchError(f"{funcname}(): {argname} must be part of the same game") - return strategy - elif isinstance(strategy, str): - if not strategy.strip(): - raise ValueError( - f"{funcname}(): {argname} cannot be an empty string or all spaces" - ) - try: - return self.strategies[strategy] - except KeyError: - raise KeyError(f"{funcname}(): no strategy with label '{strategy}'") - raise TypeError( - f"{funcname}(): {argname} must be Strategy or str, not {strategy.__class__.__name__}" + if not isinstance(label, str): + raise TypeError( + f"{funcname}(): {argname} must be a strategy label (str), " + f"not {label.__class__.__name__}" + ) + if not label.strip(): + raise ValueError(f"{funcname}(): {argname} cannot be an empty string or all spaces") + for strategy in player.player.deref().GetStrategies(): + if strategy.deref().GetLabel().decode("utf-8") == label: + return strategy + raise KeyError( + f"{funcname}(): player '{player.label}' has no strategy with label '{label}'" ) def _resolve_node(self, node: typing.Any, funcname: str, argname: str = "node") -> Node: @@ -2675,24 +2637,24 @@ class Game: self.game.deref().SetPlayers(c_labels) def make_outcome(self, - nodes, + location, payoffs: typing.Mapping, label: str) -> Outcome: - """Create an outcome with `payoffs` and `label` and attach it at `nodes`. + """Create an outcome with `payoffs` and `label` and attach it at `location`. - For an extensive game, `nodes` is a ``Node`` or an iterable of nodes. For a - strategic game, `nodes` is a pure-strategy contingency — a sequence giving one - strategy per player, each a ``Strategy`` or a strategy label — or an iterable - of such contingencies. + For an extensive game, `location` is a ``Node`` or an iterable of nodes. For a + strategic game, `location` is a pure-strategy contingency — a complete mapping + from the game's players' labels to strategy labels — or an iterable of such + contingencies. - Any outcome all of whose references are among `nodes` is absorbed by the + Any outcome all of whose references are among `location` is absorbed by the operation: it is removed from the game, and `label` may reuse its label. .. versionadded:: 17.0.0 Parameters ---------- - nodes : Node, contingency, or iterable of these + location : Node, contingency, or iterable of these Where to attach the new outcome. Nonempty; each node or contingency may be referenced only once. payoffs : Mapping @@ -2710,10 +2672,12 @@ class Game: Raises ------ MismatchError - If any node, strategy, or player is from a different game. + If any node is from a different game, or `payoffs` names a `Player` from a + different game. ValueError - If `nodes` is empty or contains a repeat; if `payoffs` is not a complete - mapping over exactly the game's players; or if `label` is empty or is held + If `location` is empty or contains a repeat; if `payoffs` is not a complete + mapping over exactly the game's players; if a contingency does not specify + exactly one strategy for each player; or if `label` is empty or is held by an outcome that is not absorbed by the operation. UndefinedOperationError If the game is in action-graph representation, where outcomes are not @@ -2741,28 +2705,33 @@ class Game: for player in self.players: c_payoffs.push_back(_to_number(resolved_payoffs[player])) if self.is_tree: - resolved_nodes = self._resolve_nodes(nodes, "make_outcome") + resolved_nodes = self._resolve_nodes(location, "make_outcome") c_nodes = stdvector[c_GameNode]() for n in resolved_nodes: c_nodes.push_back(cython.cast(Node, n).node) return Outcome.wrap( self.game.deref().MakeOutcome(c_nodes, c_payoffs, label.encode("utf-8")) ) - try: - entries = list(nodes) - except TypeError: - raise TypeError( - "make_outcome(): nodes must be a contingency or an iterable of contingencies" - ) from None - if entries and all(isinstance(e, (Strategy, str, int)) for e in entries): - entries = [entries] - players = list(self.players) + 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") c_one = stdvector[c_GameStrategy]() - for pl, index in enumerate(self._resolve_contingency(entry, "make_outcome", "nodes")): - strategy = list(players[pl].strategies)[index] - c_one.push_back(cython.cast(Strategy, strategy).strategy) + for player in self.players: + resolved_player: Player = cython.cast(Player, player) + c_one.push_back( + self._resolve_strategy(resolved_player, resolved[resolved_player], + "make_outcome") + ) c_contingencies.push_back(c_one) return Outcome.wrap( self.game.deref().MakeOutcome(c_contingencies, c_payoffs, label.encode("utf-8")) @@ -2909,7 +2878,7 @@ class Game: f"relabel_strategies(): labels must be a mapping, " f"not {labels.__class__.__name__}" ) - current = [strategy.label for strategy in resolved_player.strategies] + current = list(resolved_player.strategies) c_labels = stdmap[string, string]() for old, new in labels.items(): if not isinstance(old, str) or not isinstance(new, str): @@ -2997,7 +2966,7 @@ class Game: raise TypeError("set_strategies(): strategies must be an iterable of str") if not labels: raise UndefinedOperationError("set_strategies(): `strategies` must be a nonempty list") - current = [strategy.label for strategy in resolved_player.strategies] + current = list(resolved_player.strategies) if len(set(current)) != len(current): raise ValueError( "set_strategies(): the player has duplicate strategy labels, " diff --git a/src/pygambit/gameiter.py b/src/pygambit/gameiter.py index 0f719c878..1ee22b852 100644 --- a/src/pygambit/gameiter.py +++ b/src/pygambit/gameiter.py @@ -27,16 +27,16 @@ class Contingencies: """ An object representing the contingencies of strategies in a strategic game. - Contingencies may be restricted by specifying the strategies of any number - of players via calls to __getitem__. + Contingencies may be restricted to a single strategy for one or more players via + repeated calls to __getitem__, each specifying a (player, strategy label) pair. """ def __init__(self, game, cont=None): self.game = game self.cont = cont if cont is not None else {} - def __getitem__(self, strategy): + def __getitem__(self, key): + player, strategy = key cont = dict(self.cont) - player = [p for p in self.game.players if strategy in p.strategies][0] cont[player] = strategy return Contingencies(self.game, cont) @@ -49,11 +49,10 @@ def __len__(self): def __iter__(self): if len(self.cont) == len(self.game.players): - yield [list(player.strategies).index(self.cont[player]) - for player in self.game.players] + yield {player.label: self.cont[player] for player in self.game.players} else: players = list(self.game.players) nextpl = min(pl for (pl, player) in enumerate(players) if player not in self.cont) for strategy in players[nextpl].strategies: - yield from self[strategy] + yield from self[players[nextpl], strategy] diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index d3f8beef9..53a346f9e 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -700,7 +700,7 @@ def ipa_solve( for player in game.players: strategies = list(player.strategies) perturbation[player.label] = { - s.label: (1.0 if s is strategies[0] else 0.0) for s in strategies + s: (1.0 if s == strategies[0] else 0.0) for s in strategies } elif isinstance(perturbation, libgbt.MixedStrategyProfile): game = perturbation.game @@ -819,7 +819,7 @@ def gnm_solve( for player in game.players: strategies = list(player.strategies) perturbation[player.label] = { - s.label: (1.0 if s is strategies[0] else 0.0) for s in strategies + s: (1.0 if s == strategies[0] else 0.0) for s in strategies } elif isinstance(perturbation, libgbt.MixedStrategyProfile): game = perturbation.game diff --git a/src/pygambit/nashlrs.py b/src/pygambit/nashlrs.py index eb80c15e7..46bc4108d 100644 --- a/src/pygambit/nashlrs.py +++ b/src/pygambit/nashlrs.py @@ -13,14 +13,19 @@ def _generate_lrs_input(game: gbt.Game) -> str: - s = f"{len(game.players[0].strategies)} {len(game.players[1].strategies)}\n\n" - for st1 in game.players[0].strategies: - s += " ".join(str(gbt.Rational(game[st1, st2][game.players[0]])) - for st2 in game.players[1].strategies) + "\n" + p1, p2 = game.players + s = f"{len(p1.strategies)} {len(p2.strategies)}\n\n" + for st1 in p1.strategies: + s += " ".join( + str(game.get_payoffs({p1.label: st1, p2.label: st2})[p1.label]) + for st2 in p2.strategies + ) + "\n" s += "\n" - for st1 in game.players[0].strategies: - s += " ".join(str(gbt.Rational(game[st1, st2][game.players[1]])) - for st2 in game.players[1].strategies) + "\n" + for st1 in p1.strategies: + s += " ".join( + str(game.get_payoffs({p1.label: st1, p2.label: st2})[p2.label]) + for st2 in p2.strategies + ) + "\n" return s @@ -62,7 +67,8 @@ def main(): eqa = lrsnash_solve(game, "./lrsnash") for eqm in eqa: print("NE," + - ",".join(str(eqm[strat]) for player in game.players for strat in player.strategies)) + ",".join(str(eqm[player.label][strat]) + for player in game.players for strat in player.strategies)) if __name__ == "__main__": diff --git a/src/pygambit/nashphc.py b/src/pygambit/nashphc.py index d8737e7e4..611d4c8fe 100644 --- a/src/pygambit/nashphc.py +++ b/src/pygambit/nashphc.py @@ -118,18 +118,28 @@ def _run_phc(phcpack_path: pathlib.Path | str, equations: list[str]) -> list[dic # Use this table to assign letters to player strategy variables # Skip 'e', 'i', and 'j', because PHC doesn't allow these in variable names. -_playerletters = [c for c in string.ascii_lowercase if c != ["e", "i", "j"]] +_playerletters = [c for c in string.ascii_lowercase if c not in ("e", "i", "j")] + + +def _strategy_index(player: gbt.Player, label: str) -> int: + """The index of the strategy labeled `label` within `player`'s full strategy list. + + This is the basis of the PHC variable-naming scheme (player letter + this index), + which must stay stable across supports, so it is always computed against the full + list of the player's strategies, never a support-restricted subset. + """ + return list(player.strategies).index(label) def _contingencies( support: gbt.StrategySupportProfile, skip_player: gbt.Player -) -> typing.Generator[list[gbt.Strategy], None, None]: - """Generate all contingencies of strategies in `support` for all players - except player `skip_player`. +) -> typing.Generator[list[str | None], None, None]: + """Generate all contingencies of strategy labels in `support` for all players + except player `skip_player`, whose entry is `None`. """ for profile in itertools.product( - *[[strategy for strategy in player.strategies if strategy in support] + *[[strategy for strategy in player.strategies if strategy in support[player.label]] if player != skip_player else [None] for player in support.game.players] ): @@ -140,21 +150,30 @@ def _equilibrium_equations(support: gbt.StrategySupportProfile, player: gbt.Play """Generate the equations that the strategy of `player` must satisfy in any totally-mixed equilibrium on `support`. """ - payoffs = {strategy: [] for strategy in player.strategies if strategy in support} + players = list(support.game.players) + player_support = support[player.label] + payoffs = {strategy: [] for strategy in player.strategies if strategy in player_support} - strategies = list(support[player]) + strategies = list(player_support) for profile in _contingencies(support, player): - contingency = "*".join(f"{_playerletters[strat.player.number]}{strat.number}" - for strat in profile if strat is not None) + contingency = "*".join( + f"{_playerletters[p.number]}{_strategy_index(p, strat)}" + for p, strat in zip(players, profile, strict=True) if strat is not None + ) for strategy in strategies: profile[player.number] = strategy - if support.game[profile][player] != 0: - payoffs[strategy].append(f"({support.game[profile][player]}*{contingency})") + payoff_vec = support.game.get_payoffs( + {p.label: strat for p, strat in zip(players, profile, strict=True)} + ) + if payoff_vec[player.label] != 0: + payoffs[strategy].append(f"({payoff_vec[player.label]}*{contingency})") payoffs = {s: "+".join(v) for s, v in payoffs.items()} equations = [f"({payoffs[strategies[0]]})-({payoffs[s]})" for s in strategies[1:]] - equations.append("+".join(_playerletters[player.number] + str(strat.number) - for strat in strategies) + "-1") + equations.append( + "+".join(_playerletters[player.number] + str(_strategy_index(player, s)) + for s in strategies) + "-1" + ) return equations @@ -163,7 +182,7 @@ def _is_nash(profile: gbt.MixedStrategyProfile, maxregret: float, negtol: float) regret of `maxregret` and a tolerance of (small) negative probabilities of `negtol`.""" for player in profile.game.players: for strategy in player.strategies: - if profile[strategy] < -negtol: + if profile[player.label][strategy] < -negtol: return False return profile.max_regret() < maxregret @@ -172,17 +191,21 @@ def _solution_to_profile(game: gbt.Game, entry: dict) -> gbt.MixedStrategyProfil profile = game.mixed_strategy_profile() for player in game.players: playerchar = _playerletters[player.number] - for strategy in player.strategies: + distribution = {} + for i, strategy in enumerate(player.strategies): try: - profile[strategy] = entry["vars"][playerchar + str(strategy.number)].real + distribution[strategy] = entry["vars"][playerchar + str(i)].real except KeyError: - profile[strategy] = 0.0 + distribution[strategy] = 0.0 + profile[player.label] = distribution return profile def _format_support(support, label: str) -> str: - strings = ["".join(str(int(strategy in support)) for strategy in player.strategies) - for player in support.game.players] + strings = [ + "".join(str(int(strategy in support[player.label])) for strategy in player.strategies) + for player in support.game.players + ] return label + "," + ",".join(strings) @@ -192,7 +215,7 @@ def _format_profile(profile: gbt.MixedStrategyProfileDouble, label: str, `label`. """ return (f"{label}," + - ",".join(["{p:.{decimals}f}".format(p=profile[s], decimals=decimals) + ",".join(["{p:.{decimals}f}".format(p=profile[player.label][s], decimals=decimals) for player in profile.game.players for s in player.strategies])) @@ -202,8 +225,11 @@ def _profile_from_support(support: gbt.StrategySupportProfile) -> gbt.MixedStrat """ profile = support.game.mixed_strategy_profile() for player in support.game.players: - for strategy in player.strategies: - profile[strategy] = 1.0 if strategy in support else 0.0 + player_support = support[player.label] + profile[player.label] = { + strategy: (1.0 if strategy in player_support else 0.0) + for strategy in player.strategies + } return profile diff --git a/src/pygambit/outcome.pxi b/src/pygambit/outcome.pxi index b3a56c1f7..447cae158 100644 --- a/src/pygambit/outcome.pxi +++ b/src/pygambit/outcome.pxi @@ -160,75 +160,3 @@ class Outcome: resolved_player = cython.cast(Player, self.game._resolve_player(player, "Outcome.__setitem__")) self.outcome.deref().SetPayoff(resolved_player.player, _to_number(value)) - - -@cython.cclass -class DerivedGameOutcome: - """Represents an outcome in a strategic game derived from a game in another representation. - Such outcomes are one-to-one with the set of pure strategy profiles. - """ - c_game = cython.declare(c_Game) - psp = cython.declare(shared_ptr[c_PureStrategyProfile]) - - def __init__(self): - raise ValueError("Cannot create an Outcome outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: c_Game, psp: shared_ptr[c_PureStrategyProfile]) -> DerivedGameOutcome: - obj: DerivedGameOutcome = DerivedGameOutcome.__new__(DerivedGameOutcome) - obj.c_game = game - obj.psp = psp - return obj - - @property - def game(self) -> Game: - """Returns the game with which this outcome is associated.""" - return Game.wrap(self.c_game) - - def __repr__(self): - return f"" - - def __eq__(self, other: typing.Any) -> bool: - return ( - isinstance(other, DerivedGameOutcome) and - deref(self.psp).deref() == deref(cython.cast(DerivedGameOutcome, other).psp).deref() - ) - - def __getitem__(self, player: Player | str) -> Rational: - """The payoff to `player` at the outcome. - - Parameters - ---------- - player : Player or str - A reference to the player to get the payoff for - - Returns - ------- - Rational - The expected payoff to the player. Because this is calculated in a derived - strategic game, it will always be represented as a ``Rational`` even if - game data are represented as ``Decimal``. - - Raises - ------ - MismatchError - If `player` is a ``Player`` from a different game than the outcome. - """ - resolved_player = cython.cast(Player, - self.game._resolve_player(player, "Outcome.__getitem__")) - return rat_to_py(deref(deref(self.psp).deref()).GetPayoff(resolved_player.player)) - - def delete(self): - raise UndefinedOperationError("Cannot modify outcomes in a derived strategic game.") - - @property - def label(self) -> str: - """The text label associated with this outcome.""" - return "(%s)" % ( - ",".join( - [deref(deref(self.psp).deref()).GetStrategy(cython.cast(Player, player).player) - .deref().GetLabel().c_str().decode() - for player in self.game.players] - ) - ) diff --git a/src/pygambit/player.pxi b/src/pygambit/player.pxi index 2916db8ed..ae928e880 100644 --- a/src/pygambit/player.pxi +++ b/src/pygambit/player.pxi @@ -132,7 +132,13 @@ class PlayerActions: @cython.cclass class PlayerStrategies: - """The set of strategies available to a player.""" + """The labels of the strategies available to a player. + + .. versionchanged:: 17.0.0 + Iterates over strategy labels (``str``) rather than ``Strategy`` objects; + indexing by label is no longer supported (a label is already in hand once + iterated) -- use ``in`` to test membership. + """ player = cython.declare(c_GamePlayer) def __init__(self, *args, **kwargs) -> None: @@ -152,35 +158,15 @@ class PlayerStrategies: """The number of strategies for the player in the game.""" return self.player.deref().GetStrategies().size() - def __iter__(self) -> typing.Iterator[Strategy]: + def __iter__(self) -> typing.Iterator[str]: for strategy in self.player.deref().GetStrategies(): - yield Strategy.wrap(strategy) - - def __getitem__(self, label: str) -> Strategy: - """Returns the player's strategy with text label `label`. - - Parameters - ---------- - label : str - The text label of the strategy to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. - - Raises - ------ - KeyError - If the player has no strategy with label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one of the player's strategies - has label `label`. - TypeError - If `label` is not a string. + yield strategy.deref().GetLabel().decode("utf-8") - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference a strategy 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, "Player", "strategy", "strategies") + def __contains__(self, label: str) -> bool: + return any( + strategy.deref().GetLabel().decode("utf-8") == label + for strategy in self.player.deref().GetStrategies() + ) @cython.cclass diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index e5cb93b0b..01348adc0 100644 --- a/src/pygambit/qre.py +++ b/src/pygambit/qre.py @@ -232,10 +232,10 @@ def _estimate_strategy_empirical( data: libgbt.MixedStrategyProfile ) -> LogitQREMixedStrategyFitResult: flattened_data = [ - data[p.label][s.label] for p in data.game.players for s in p.strategies + data[p.label][s] for p in data.game.players for s in p.strategies ] strategy_regrets = data.normalize().strategy_regrets - regrets = [[-strategy_regrets[player.label][s.label] for s in player.strategies] + regrets = [[-strategy_regrets[player.label][s] for s in player.strategies] for player in data.game.players] res = scipy.optimize.minimize( lambda x: -_empirical_log_like(x[0], regrets, flattened_data), @@ -245,7 +245,7 @@ def _estimate_strategy_empirical( log_probs = iter(_empirical_log_logit_probs(res.x[0], regrets)) profile = data.game.mixed_strategy_profile() for player in data.game.players: - profile[player.label] = {s.label: math.exp(next(log_probs)) for s in player.strategies} + profile[player.label] = {s: math.exp(next(log_probs)) for s in player.strategies} return LogitQREMixedStrategyFitResult( data, "empirical", res.x[0], profile, -res.fun ) diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index 999da977d..71db7bfec 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -20,115 +20,6 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # -@cython.cclass -class Strategy: - """A plan of action for a ``Player`` in a ``Game``.""" - strategy = cython.declare(c_GameStrategy) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create a Strategy outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(strategy: c_GameStrategy) -> Strategy: - obj: Strategy = Strategy.__new__(Strategy) - obj.strategy = strategy - return obj - - def __repr__(self) -> str: - if self.label: - return f"Strategy(player={self.player}, label='{self.label}')" - else: - return f"Strategy(player={self.player}, number={self.number})" - - def __eq__(self, other: typing.Any) -> bool: - return ( - isinstance(other, Strategy) and - self.strategy.deref() == cython.cast(Strategy, other).strategy.deref() - ) - - def __hash__(self) -> int: - return cython.cast(cython.long, self.strategy.deref()) - - @property - def label(self) -> str: - """The text label of the strategy. - - .. versionchanged:: 17.0.0 - A label may now be any well-formed UTF-8 text, not just ASCII; it must still - contain no control characters, and must not begin/end with whitespace or have - two consecutive whitespace characters. "Whitespace" means any Unicode space - separator (e.g. U+00A0 NO-BREAK SPACE), not just the ASCII space. - - The label is now read-only, and must be nonempty and unique among the player's - strategies; use `Game.relabel_strategies` to change it. - """ - return self.strategy.deref().GetLabel().decode("utf-8") - - @property - def game(self) -> Game: - """The game to which the strategy belongs.""" - return Game.wrap(self.strategy.deref().GetPlayer().deref().GetGame()) - - @property - def player(self) -> Player: - """The player to which the strategy belongs.""" - return Player.wrap(self.strategy.deref().GetPlayer()) - - @property - def number(self) -> int: - """The number of the strategy.""" - return self.strategy.deref().GetNumber() - 1 - - def action(self, infoset: Infoset | str) -> Action | None: - """Get the action prescribed by a strategy for a given information set. - - .. versionadded:: 16.4.0 - - Parameters - ---------- - infoset - The information set for which to find the prescribed action. - Can be an Infoset object or its string label. - - Returns - ------- - Action or None - The prescribed action or None if the strategy is not defined for this - information set, that is, the information set is unreachable under this strategy. - - Raises - ------ - UndefinedOperationError - If the game is not an extensive-form (tree) game. - ValueError - If the information set belongs to a different player than the strategy. - - See Also - -------- - Game.get_behavior : - A map-like view of the strategy's full mapping from information sets to actions. - """ - if not self.game.is_tree: - raise UndefinedOperationError( - "Strategy.action is only defined for strategies in extensive-form games." - ) - - resolved_infoset: Infoset = self.game._resolve_infoset(infoset, "Strategy.action") - - if resolved_infoset.player != self.player: - raise ValueError( - f"Information set {resolved_infoset} belongs to player " - f"'{resolved_infoset.player.label}', but this strategy " - f"belongs to player '{self.player.label}'." - ) - - action: c_GameAction = self.strategy.deref().GetAction(resolved_infoset.infoset) - if not action: - return None - return Action.wrap(action) - - @cython.cclass class StrategyBehavior: """A read-only, map-like view of the actions prescribed by a reduced strategy. @@ -140,39 +31,55 @@ class StrategyBehavior: .. versionadded:: 17.0.0 """ - _player = cython.declare(Player) - _strategy = cython.declare(Strategy) + _game = cython.declare(Game) + _player_label = cython.declare(str) + _strategy_label = cython.declare(str) def __init__(self, *args, **kwargs) -> None: raise ValueError("Cannot create a StrategyBehavior outside a Game.") @staticmethod @cython.cfunc - def wrap(player: Player, strategy: Strategy) -> StrategyBehavior: + def wrap(game: Game, player_label: str, strategy_label: str) -> StrategyBehavior: obj: StrategyBehavior = StrategyBehavior.__new__(StrategyBehavior) - obj._player = player - obj._strategy = strategy + obj._game = game + obj._player_label = player_label + obj._strategy_label = strategy_label return obj def __repr__(self) -> str: - return f"StrategyBehavior(player={self._player}, strategy={self._strategy})" + return ( + f"StrategyBehavior(player='{self._player_label}', " + f"strategy='{self._strategy_label}')" + ) @property - def player(self) -> Player: - """The player to which the strategy belongs.""" - return self._player + def player(self) -> str: + """The label of the player to which the strategy belongs.""" + return self._player_label @property - def strategy(self) -> Strategy: - """The strategy of which this is the behavior.""" - return self._strategy + def strategy(self) -> str: + """The label of the strategy of which this is the behavior.""" + return self._strategy_label + + def _action_at(self, infoset: Infoset) -> Action | None: + """The action prescribed by the strategy at `infoset`, or None if unreachable.""" + player = cython.cast(Player, self._game.players[self._player_label]) + handle = self._game._resolve_strategy( + player, self._strategy_label, "StrategyBehavior" + ) + action: c_GameAction = handle.deref().GetAction(cython.cast(Infoset, infoset).infoset) + if not action: + return None + return Action.wrap(action) def _resolve_key(self, key: Infoset | str) -> Infoset: """Resolve `key` to an information set at which the player has the move.""" - infoset = self._player.game._resolve_infoset(key, "StrategyBehavior", "key") - if infoset.player != self._player: + infoset = self._game._resolve_infoset(key, "StrategyBehavior", "key") + if infoset.player.label != self._player_label: raise ValueError( - f"Player '{self._player.label}' does not have the move at {infoset}." + f"Player '{self._player_label}' does not have the move at {infoset}." ) return infoset @@ -188,17 +95,17 @@ class StrategyBehavior: If the information set belongs to a different player. """ infoset = self._resolve_key(key) - action = self._strategy.action(infoset) + action = self._action_at(infoset) if action is None: raise KeyError( - f"Strategy '{self._strategy.label}' prescribes no action at {infoset}." + f"Strategy '{self._strategy_label}' prescribes no action at {infoset}." ) return action def get(self, key: Infoset | str, default: typing.Any = None) -> Action | None: """Return the action prescribed at `key`, or `default` if none is prescribed.""" infoset = self._resolve_key(key) - action = self._strategy.action(infoset) + action = self._action_at(infoset) return default if action is None else action def __contains__(self, key: typing.Any) -> bool: @@ -206,11 +113,12 @@ class StrategyBehavior: infoset = self._resolve_key(key) except (KeyError, ValueError, TypeError): return False - return self._strategy.action(infoset) is not None + return self._action_at(infoset) is not None def __iter__(self) -> typing.Iterator[Infoset]: - for infoset in self._player.infosets: - if self._strategy.action(infoset) is not None: + player = self._game.players[self._player_label] + for infoset in player.infosets: + if self._action_at(infoset) is not None: yield infoset def __len__(self) -> int: @@ -222,11 +130,11 @@ class StrategyBehavior: def values(self) -> list[Action]: """The prescribed actions, in the order of `keys`.""" - return [self._strategy.action(infoset) for infoset in self] + return [self._action_at(infoset) for infoset in self] def items(self) -> list[tuple[Infoset, Action]]: """(information set, action) pairs, in the order of `keys`.""" - return [(infoset, self._strategy.action(infoset)) for infoset in self] + return [(infoset, self._action_at(infoset)) for infoset in self] @cython.cclass diff --git a/src/pygambit/stratmixed.pxi b/src/pygambit/stratmixed.pxi index 34a0ce5e5..97c578e76 100644 --- a/src/pygambit/stratmixed.pxi +++ b/src/pygambit/stratmixed.pxi @@ -242,7 +242,10 @@ class MixedStrategyProfile: """ self._check_validity() resolved_player = self.game._resolve_player(player, "__getitem__") - values = {s.label: self._getprob_strategy(s) for s in resolved_player.strategies} + values = { + s: self._getprob_strategy(resolved_player, s) + for s in resolved_player.strategies + } return MixedStrategy.wrap(resolved_player, values) def _setprob_player( @@ -261,7 +264,7 @@ class MixedStrategyProfile: f"a mixed strategy must be set from a Mapping from strategy label to " f"weight, not {distribution.__class__.__name__}" ) - labels = {s.label for s in player.strategies} + labels = set(player.strategies) given = set(distribution.keys()) unknown = given - labels if unknown: @@ -281,7 +284,7 @@ class MixedStrategyProfile: if all(v == 0 for v in values.values()): raise ValueError("a mixed strategy's weights must not all be zero") for s in player.strategies: - self._setprob_strategy(s, values[s.label]) + self._setprob_strategy(player, s, values[s]) def __setitem__(self, player: str, distribution: collections.abc.Mapping) -> None: """Sets the mixed strategy for the player with label `player`. @@ -383,7 +386,9 @@ class MixedStrategyProfile: """ self._check_validity() return StrategyValuesVector({ - p.label: StrategyValueVector({s.label: self._strategy_value(s) for s in p.strategies}) + p.label: StrategyValueVector({ + s: self._strategy_value(p, s) for s in p.strategies + }) for p in self.game.players }) @@ -403,9 +408,9 @@ class MixedStrategyProfile: """ self._check_validity() return StrategyRegretsVector({ - p.label: StrategyRegretVector( - {s.label: self._strategy_regret(s) for s in p.strategies} - ) + p.label: StrategyRegretVector({ + s: self._strategy_regret(p, s) for s in p.strategies + }) for p in self.game.players }) @@ -534,12 +539,12 @@ class MixedStrategyProfile: """The game on which this profile is defined.""" raise NotImplementedError - def _getprob_strategy(self, strategy: Strategy) -> ProfileDType: - """Returns the probability with which strategy is played.""" + def _getprob_strategy(self, player: Player, label: str) -> ProfileDType: + """Returns the probability with which player's strategy `label` is played.""" raise NotImplementedError - def _setprob_strategy(self, strategy: Strategy, value: typing.Any) -> None: - """Sets the probability with which strategy is played.""" + def _setprob_strategy(self, player: Player, label: str, value: typing.Any) -> None: + """Sets the probability with which player's strategy `label` is played.""" raise NotImplementedError def _to_prob(self, value: typing.Any) -> ProfileDType: @@ -552,12 +557,12 @@ class MixedStrategyProfile: """Returns the expected payoff to player.""" raise NotImplementedError - def _strategy_value(self, strategy: Strategy) -> ProfileDType: - """Returns the expected payoff to playing strategy.""" + def _strategy_value(self, player: Player, label: str) -> ProfileDType: + """Returns the expected payoff to playing player's strategy `label`.""" raise NotImplementedError - def _strategy_regret(self, strategy: Strategy) -> ProfileDType: - """Returns the regret to playing strategy.""" + def _strategy_regret(self, player: Player, label: str) -> ProfileDType: + """Returns the regret to playing player's strategy `label`.""" raise NotImplementedError def _player_regret(self, player: Player) -> ProfileDType: @@ -611,8 +616,10 @@ class MixedStrategyProfileDouble(MixedStrategyProfile): def __len__(self) -> int: return len(self.game.players) - def _getprob_strategy(self, strategy: Strategy) -> float: - return deref(self.profile).getitem_strategy(strategy.strategy) + def _getprob_strategy(self, player: Player, label: str) -> float: + game: Game = cython.cast(Game, self.game) + handle = game._resolve_strategy(player, label, "_getprob_strategy") + return deref(self.profile).getitem_strategy(handle) @cython.cfunc def _ensure_unshared(self) -> cython.void: @@ -622,9 +629,11 @@ class MixedStrategyProfileDouble(MixedStrategyProfile): if self.profile.use_count() != 1: self.profile = make_shared[c_MixedStrategyProfile[double]](deref(self.profile)) - def _setprob_strategy(self, strategy: Strategy, value) -> None: + def _setprob_strategy(self, player: Player, label: str, value) -> None: + game: Game = cython.cast(Game, self.game) + handle = game._resolve_strategy(player, label, "_setprob_strategy") self._ensure_unshared() - setitem_mspd_strategy(deref(self.profile), strategy.strategy, value) + setitem_mspd_strategy(deref(self.profile), handle, value) def _to_prob(self, value: typing.Any) -> float: normalized = _to_number_string(value) @@ -637,11 +646,15 @@ class MixedStrategyProfileDouble(MixedStrategyProfile): def _payoff(self, player: Player) -> float: return deref(self.profile).GetPayoff(player.player) - def _strategy_value(self, strategy: Strategy) -> float: - return deref(self.profile).GetPayoff(strategy.strategy) + def _strategy_value(self, player: Player, label: str) -> float: + game: Game = cython.cast(Game, self.game) + handle = game._resolve_strategy(player, label, "_strategy_value") + return deref(self.profile).GetPayoff(handle) - def _strategy_regret(self, strategy: Strategy) -> float: - return deref(self.profile).GetRegret(strategy.strategy) + def _strategy_regret(self, player: Player, label: str) -> float: + game: Game = cython.cast(Game, self.game) + handle = game._resolve_strategy(player, label, "_strategy_regret") + return deref(self.profile).GetRegret(handle) def _player_regret(self, player: Player) -> float: return deref(self.profile).GetRegret(player.player) @@ -703,8 +716,10 @@ class MixedStrategyProfileRational(MixedStrategyProfile): def __len__(self) -> int: return len(self.game.players) - def _getprob_strategy(self, strategy: Strategy) -> Rational: - return rat_to_py(deref(self.profile).getitem_strategy(strategy.strategy)) + def _getprob_strategy(self, player: Player, label: str) -> Rational: + game: Game = cython.cast(Game, self.game) + handle = game._resolve_strategy(player, label, "_getprob_strategy") + return rat_to_py(deref(self.profile).getitem_strategy(handle)) @cython.cfunc def _ensure_unshared(self) -> cython.void: @@ -714,12 +729,14 @@ class MixedStrategyProfileRational(MixedStrategyProfile): if self.profile.use_count() != 1: self.profile = make_shared[c_MixedStrategyProfile[c_Rational]](deref(self.profile)) - def _setprob_strategy(self, strategy: Strategy, value) -> None: + def _setprob_strategy(self, player: Player, label: str, value) -> None: if not isinstance(value, (int, fractions.Fraction)): raise TypeError("probability should be int or Fraction instance; received {}" .format(value.__class__.__name__)) + game: Game = cython.cast(Game, self.game) + handle = game._resolve_strategy(player, label, "_setprob_strategy") self._ensure_unshared() - setitem_mspr_strategy(deref(self.profile), strategy.strategy, + setitem_mspr_strategy(deref(self.profile), handle, to_rational(str(value).encode("ascii"))) def _to_prob(self, value: typing.Any) -> Rational: @@ -728,11 +745,15 @@ class MixedStrategyProfileRational(MixedStrategyProfile): def _payoff(self, player: Player) -> Rational: return rat_to_py(deref(self.profile).GetPayoff(player.player)) - def _strategy_value(self, strategy: Strategy) -> Rational: - return rat_to_py(deref(self.profile).GetPayoff(strategy.strategy)) + def _strategy_value(self, player: Player, label: str) -> Rational: + game: Game = cython.cast(Game, self.game) + handle = game._resolve_strategy(player, label, "_strategy_value") + return rat_to_py(deref(self.profile).GetPayoff(handle)) - def _strategy_regret(self, strategy: Strategy) -> Rational: - return rat_to_py(deref(self.profile).GetRegret(strategy.strategy)) + def _strategy_regret(self, player: Player, label: str) -> Rational: + game: Game = cython.cast(Game, self.game) + handle = game._resolve_strategy(player, label, "_strategy_regret") + return rat_to_py(deref(self.profile).GetRegret(handle)) def _player_regret(self, player: Player) -> Rational: return rat_to_py(deref(self.profile).GetRegret(player.player)) @@ -763,7 +784,7 @@ class MixedStrategyProfileRational(MixedStrategyProfile): profile: MixedStrategyProfileDouble = self.game.mixed_strategy_profile() for player in self.game.players: profile[player.label] = { - s.label: float(self._getprob_strategy(s)) for s in player.strategies + s: float(self._getprob_strategy(player, s)) for s in player.strategies } return profile diff --git a/src/pygambit/stratspt.pxi b/src/pygambit/stratspt.pxi index f4e1b2096..296a3e46b 100644 --- a/src/pygambit/stratspt.pxi +++ b/src/pygambit/stratspt.pxi @@ -141,7 +141,7 @@ class StrategySupportProfile: """ resolved_player: Player = self.game.players[player] strategies = tuple( - Strategy.wrap(s).label + s.deref().GetLabel().decode("utf-8") for s in deref(self.profile).GetStrategies(resolved_player.player) ) return StrategySupport.wrap(resolved_player, strategies) @@ -161,7 +161,7 @@ class StrategySupportProfile: Every entry of `strategies` must be one of the player's strategy labels, and at least one must be given. """ - labels = {s.label for s in player.strategies} + labels = set(player.strategies) given = set(strategies) unknown = given - labels if unknown: @@ -171,14 +171,17 @@ class StrategySupportProfile: if not given: raise ValueError("a support must contain at least one strategy for the player") self._ensure_unshared() + game: Game = cython.cast(Game, player.game) # Strategies to keep are added first, so that a subsequent removal is never asked # to remove the last remaining strategy for the player. for s in player.strategies: - if s.label in given: - deref(self.profile).AddStrategy(cython.cast(Strategy, s).strategy) + if s in given: + deref(self.profile).AddStrategy(game._resolve_strategy(player, s, "_set_support")) for s in player.strategies: - if s.label not in given: - deref(self.profile).RemoveStrategy(cython.cast(Strategy, s).strategy) + if s not in given: + deref(self.profile).RemoveStrategy( + game._resolve_strategy(player, s, "_set_support") + ) def __setitem__(self, player: str, strategies: typing.Iterable[str]) -> None: """Sets the support for the player with label `player` to exactly the given @@ -255,11 +258,10 @@ class StrategySupportProfile: If no player in the game has the label `player`, or the player has no strategy with the label `strategy`. """ - resolved_player: Player = self.game.players[player] - resolved_strategy: Strategy = resolved_player.strategies[strategy] - return deref(self.profile).IsDominated( - cython.cast(Strategy, resolved_strategy).strategy, strict, external - ) + game: Game = cython.cast(Game, self.game) + resolved_player: Player = game.players[player] + handle = game._resolve_strategy(resolved_player, strategy, "is_dominated", "strategy") + return deref(self.profile).IsDominated(handle, strict, external) def _undominated_strategies_solve( diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 4351378df..824f7435f 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -23,7 +23,7 @@ def _table_game(payoffs: dict, title: str) -> gbt.Game: strategies = {"a": s1a, "b": s1b, "A": s2a, "B": s2b} for (row, col), (v1, v2) in payoffs.items(): game.make_outcome( - (strategies[row], strategies[col]), {p1: v1, p2: v2}, f"{row}{col}" + {p1.label: strategies[row], p2.label: strategies[col]}, {p1: v1, p2: v2}, f"{row}{col}" ) return game diff --git a/tests/cli/test_common.py b/tests/cli/test_common.py index 240ca80d8..02dc824f2 100644 --- a/tests/cli/test_common.py +++ b/tests/cli/test_common.py @@ -167,5 +167,5 @@ def test_strategy_support_partial_support(self, nfg_matching_pennies_text): support = game.strategy_support_profile() p1 = next(iter(game.players)) first_strategy = next(iter(p1.strategies)) - support[p1.label] = [first_strategy.label] + support[p1.label] = [first_strategy] assert common.render_support_csv(support, "candidate") == "candidate,10,11" diff --git a/tests/test_actions.py b/tests/test_actions.py index dadf98b18..317357f8e 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -314,19 +314,17 @@ def node_at(path: list[str]) -> gbt.Node: (games.read_from_file("basic_extensive_game.efg"), "Player 3", "2", ["U1", "U2"], "D3"), ], ) -def test_strategy_action_defined( +def test_get_behavior_prescribed_action_defined( game, player_label, strategy_label, infoset_path, expected_action_label ): - """Verify `Strategy.action` retrieves the correct action for defined actions.""" - player = game.players[player_label] - strategy = player.strategies[strategy_label] + """Verify `Game.get_behavior` retrieves the correct action for defined actions.""" node = game.root for action_label in infoset_path: node = node.children[action_label] infoset = node.infoset expected_action = infoset.actions[expected_action_label] - prescribed_action = strategy.action(infoset) + prescribed_action = game.get_behavior(player_label, strategy_label).get(infoset) assert prescribed_action == expected_action @@ -343,12 +341,10 @@ def test_strategy_action_defined( (games.read_from_file("cent3.efg"), "Player 2", "2", "(2,5)", None), ], ) -def test_strategy_action_undefined_returns_none( +def test_get_behavior_prescribed_action_undefined_returns_none( game, player_label, strategy_label, infoset_label, infoset_path ): - """Verify `Strategy.action` returns None when called on an unreached player's infoset""" - player = game.players[player_label] - strategy = player.strategies[strategy_label] + """Verify `Game.get_behavior` returns None when called on an unreached player's infoset""" if infoset_label is not None: infoset = game.infosets[infoset_label] else: @@ -357,7 +353,7 @@ def test_strategy_action_undefined_returns_none( node = node.children[action_label] infoset = node.infoset - prescribed_action = strategy.action(infoset) + prescribed_action = game.get_behavior(player_label, strategy_label).get(infoset) assert prescribed_action is None @@ -374,34 +370,22 @@ def test_strategy_action_undefined_returns_none( (games.read_from_file("basic_extensive_game.efg"), "Player 3", []), ], ) -def test_strategy_action_raises_value_error_for_wrong_player( +def test_get_behavior_raises_value_error_for_wrong_player( game, player_label, other_infoset_path ): """ - Verify `Strategy.action` raises ValueError when the infoset belongs + Verify `Game.get_behavior`'s result raises ValueError when the infoset belongs to a different player than the strategy. """ player = game.players[player_label] - strategy = next(iter(player.strategies)) + behavior = game.get_behavior(player_label, next(iter(player.strategies))) node = game.root for action_label in other_infoset_path: node = node.children[action_label] other_players_infoset = node.infoset with pytest.raises(ValueError): - strategy.action(other_players_infoset) - - -def test_strategy_action_raises_error_for_strategic_game(): - """Verify `Strategy.action` retrieves the action prescribed by the strategy""" - game_efg = gbt.catalog.load("journals/ijgt/selten1975/fig2") - game_nfg = game_efg.from_arrays(game_efg.to_arrays()[0], game_efg.to_arrays()[1]) - alice = next(iter(game_nfg.players)) - strategy = next(iter(alice.strategies)) - test_infoset = next(iter(game_efg.infosets)) - - with pytest.raises(gbt.UndefinedOperationError): - strategy.action(test_infoset) + behavior.get(other_players_infoset) def test_player_actions_len(): diff --git a/tests/test_agg.py b/tests/test_agg.py index 80515a1e3..7c5e76e57 100644 --- a/tests/test_agg.py +++ b/tests/test_agg.py @@ -68,13 +68,13 @@ def test_agg_fraction_and_long_decimal_payoffs_parsed_exactly(): s1 = list(p1.strategies) profile = game.mixed_strategy_profile(rational=True) - profile[p0.label] = {s0[0].label: gbt.Rational(1), s0[1].label: gbt.Rational(0)} - profile[p1.label] = {s1[0].label: gbt.Rational(0), s1[1].label: gbt.Rational(1)} + profile[p0.label] = {s0[0]: gbt.Rational(1), s0[1]: gbt.Rational(0)} + profile[p1.label] = {s1[0]: gbt.Rational(0), s1[1]: gbt.Rational(1)} assert profile.payoffs[p0.label] == gbt.Rational(1, 3) profile = game.mixed_strategy_profile(rational=True) - profile[p0.label] = {s0[0].label: gbt.Rational(0), s0[1].label: gbt.Rational(1)} - profile[p1.label] = {s1[0].label: gbt.Rational(0), s1[1].label: gbt.Rational(1)} + profile[p0.label] = {s0[0]: gbt.Rational(0), s0[1]: gbt.Rational(1)} + profile[p1.label] = {s1[0]: gbt.Rational(0), s1[1]: gbt.Rational(1)} assert profile.payoffs[p0.label] == gbt.Rational("0.123456789012345") assert profile.payoffs[p1.label] == gbt.Rational("0.123456789012345") @@ -92,9 +92,9 @@ def test_bagg_fraction_type_distribution_parsed_exactly(): dbl = game.mixed_strategy_profile(rational=False) for profile, one, zero in [(exact, gbt.Rational(1), gbt.Rational(0)), (dbl, 1.0, 0.0)]: s0, s1, s2 = list(p1t0.strategies), list(p1t1.strategies), list(p2.strategies) - profile[p1t0.label] = {s0[0].label: one, s0[1].label: zero} - profile[p1t1.label] = {s1[0].label: zero, s1[1].label: one} - profile[p2.label] = {s2[0].label: one, s2[1].label: zero} + profile[p1t0.label] = {s0[0]: one, s0[1]: zero} + profile[p1t1.label] = {s1[0]: zero, s1[1]: one} + profile[p2.label] = {s2[0]: one, s2[1]: zero} assert exact.payoffs[p2.label] == gbt.Rational(22) assert float(exact.payoffs[p2.label]) == dbl.payoffs[p2.label] @@ -114,7 +114,7 @@ def test_agg_bagg_mixed_strategy_profile_rational_exact_payoff(game_path): for player in game.players: strategies = list(player.strategies) profile[player.label] = { - strategies[0].label: gbt.Rational(10, 11), strategies[1].label: gbt.Rational(1, 11) + strategies[0]: gbt.Rational(10, 11), strategies[1]: gbt.Rational(1, 11) } for player in game.players: assert profile.payoffs[player.label] == gbt.Rational(-5, 11) @@ -135,12 +135,14 @@ def test_agg_bagg_rational_algorithms_find_exact_mixed_equilibrium(game_path): for result in results: mixed = [ eq for eq in result.equilibria - if any(0 < eq[s.player.label][s.label] < 1 for s in game.strategies) + if any( + 0 < eq[p.label][s] < 1 for p in game.players for s in p.strategies + ) ] assert len(mixed) == 1 for player in game.players: for strategy in player.strategies: - assert mixed[0][player.label][strategy.label] in ( + assert mixed[0][player.label][strategy] in ( gbt.Rational(10, 11), gbt.Rational(1, 11) ) assert mixed[0].max_regret() == 0 @@ -149,7 +151,7 @@ def test_agg_bagg_rational_algorithms_find_exact_mixed_equilibrium(game_path): def _set_pure_profile(profile, players, contingency): for player, strat_index in zip(players, contingency, strict=True): profile[player.label] = { - strategy.label: gbt.Rational(1) if i == strat_index else gbt.Rational(0) + strategy: gbt.Rational(1) if i == strat_index else gbt.Rational(0) for i, strategy in enumerate(player.strategies) } @@ -163,7 +165,11 @@ def test_bagg_pure_strategy_payoff_matches_degenerate_mixed_profile(game_path): game = games.read_from_file(game_path) players = list(game.players) for contingency in itertools.product(*(range(len(list(p.strategies))) for p in players)): - pure_payoffs = [game[contingency][p] for p in players] + labeled = { + p.label: list(p.strategies)[i] + for p, i in zip(players, contingency, strict=True) + } + pure_payoffs = [game.get_payoffs(labeled)[p.label] for p in players] profile = game.mixed_strategy_profile(rational=True) _set_pure_profile(profile, players, contingency) @@ -186,7 +192,11 @@ def test_bagg_pure_strategy_payoff_with_multiple_players_and_types(): tuple(i % size for i, size in enumerate(sizes)), ] for contingency in contingencies: - pure_payoffs = [game[contingency][p] for p in players] + labeled = { + p.label: list(p.strategies)[i] + for p, i in zip(players, contingency, strict=True) + } + pure_payoffs = [game.get_payoffs(labeled)[p.label] for p in players] profile = game.mixed_strategy_profile(rational=True) _set_pure_profile(profile, players, contingency) diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 3d854f0e2..d77413d97 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -162,7 +162,7 @@ def test_catalog_games_filter_n_strategies(all_games): assert len(filtered_games) < len(all_games) if len(filtered_games) > 0: g = gbt.catalog.load(filtered_games.Game.iloc[0]) - assert len(g.strategies) == 4 + assert sum(len(list(p.strategies)) for p in g.players) == 4 def test_catalog_games_filter_bad_filter(): diff --git a/tests/test_extensive.py b/tests/test_extensive.py index 3165c0248..2369aff24 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -99,34 +99,33 @@ def test_is_perfect_recall(game_input, expected_result: bool): def test_getting_payoff_by_label_string(): game = games.read_from_file("sample_extensive_game.efg") - assert game[[0, 0]]["Player 1"] == 2 - assert game[[0, 1]]["Player 1"] == 2 - assert game[[1, 0]]["Player 1"] == 4 - assert game[[1, 1]]["Player 1"] == 6 - assert game[[0, 0]]["Player 2"] == 3 - assert game[[0, 1]]["Player 2"] == 3 - assert game[[1, 0]]["Player 2"] == 5 - assert game[[1, 1]]["Player 2"] == 7 + s1 = list(game.players["Player 1"].strategies) + s2 = list(game.players["Player 2"].strategies) + assert game.get_payoffs({"Player 1": s1[0], "Player 2": s2[0]})["Player 1"] == 2 + assert game.get_payoffs({"Player 1": s1[0], "Player 2": s2[1]})["Player 1"] == 2 + assert game.get_payoffs({"Player 1": s1[1], "Player 2": s2[0]})["Player 1"] == 4 + assert game.get_payoffs({"Player 1": s1[1], "Player 2": s2[1]})["Player 1"] == 6 + assert game.get_payoffs({"Player 1": s1[0], "Player 2": s2[0]})["Player 2"] == 3 + assert game.get_payoffs({"Player 1": s1[0], "Player 2": s2[1]})["Player 2"] == 3 + assert game.get_payoffs({"Player 1": s1[1], "Player 2": s2[0]})["Player 2"] == 5 + assert game.get_payoffs({"Player 1": s1[1], "Player 2": s2[1]})["Player 2"] == 7 -def test_getting_payoff_by_player(): +def test_getting_payoff_player_object_key_raises(): game = games.read_from_file("sample_extensive_game.efg") player1 = game.players["Player 1"] - player2 = game.players["Player 2"] - assert game[[0, 0]][player1] == 2 - assert game[[0, 1]][player1] == 2 - assert game[[1, 0]][player1] == 4 - assert game[[1, 1]][player1] == 6 - assert game[[0, 0]][player2] == 3 - assert game[[0, 1]][player2] == 3 - assert game[[1, 0]][player2] == 5 - assert game[[1, 1]][player2] == 7 + s1 = next(iter(player1.strategies)) + s2 = next(iter(game.players["Player 2"].strategies)) + with pytest.raises(TypeError): + _ = game.get_payoffs({player1: s1, "Player 2": s2}) def test_outcome_index_exception_label(): game = games.read_from_file("sample_extensive_game.efg") + s1 = next(iter(game.players["Player 1"].strategies)) + s2 = next(iter(game.players["Player 2"].strategies)) with pytest.raises(KeyError): - _ = game[[0, 0]]["Not a player"] + _ = game.get_payoffs({"Player 1": s1, "Player 2": s2})["Not a player"] @pytest.mark.parametrize( @@ -392,7 +391,7 @@ def test_reduced_strategic_form( for player, labels, exp_raw, arr in zip( game.players, strategy_labels, np_arrays_of_rsf, arrays, strict=True ): - assert labels == [s.label for s in player.strategies] + assert labels == list(player.strategies) assert (arr == games.vectorized_make_rational(exp_raw)).all() @@ -506,7 +505,7 @@ def test_reduced_strategy_maps(game: gbt.Game, strategy_maps: list): """ for player, expected_maps in zip(game.players, strategy_maps, strict=True): for strategy, expected in zip(player.strategies, expected_maps, strict=True): - behavior = game.get_behavior(player, strategy) + behavior = game.get_behavior(player.label, strategy) assert tuple( "*" if (action := behavior.get(infoset)) is None else str(action.number + 1) for infoset in player.infosets diff --git a/tests/test_file.py b/tests/test_file.py index 0ac63b7b0..13782f363 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -62,13 +62,13 @@ def test_read_efg_repeated_infoset_duplicate_labels_consistent(): def test_read_nfg_empty_strategy_labels_are_normalized(): g = _parse_nfg('NFG 1 R "t" { "A" "B" }\n\n{ { "" "" }\n{ "x" "y" }\n}\n""\n' + _NFG_PAYOFF_BODY) - assert [s.label for s in g.players["A"].strategies] == ["_1", "_2"] + assert list(g.players["A"].strategies) == ["_1", "_2"] def test_read_nfg_duplicate_strategy_labels_are_normalized(): g = _parse_nfg('NFG 1 R "t" { "A" "B" }\n\n{ { "l" "l" }\n{ "x" "y" }\n}\n""\n' + _NFG_PAYOFF_BODY) - assert [s.label for s in g.players["A"].strategies] == ["l_1", "l_2"] + assert list(g.players["A"].strategies) == ["l_1", "l_2"] def test_read_nfg_strategy_labels_swap_default_numbering(): @@ -78,7 +78,7 @@ def test_read_nfg_strategy_labels_swap_default_numbering(): """ g = _parse_nfg('NFG 1 R "t" { "A" "B" }\n\n{ { "2" "1" }\n{ "x" "y" }\n}\n""\n' + _NFG_PAYOFF_BODY) - assert [s.label for s in g.players["A"].strategies] == ["2", "1"] + assert list(g.players["A"].strategies) == ["2", "1"] def test_string_empty(): diff --git a/tests/test_game.py b/tests/test_game.py index f65aee245..4b579a669 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -97,89 +97,101 @@ def test_from_dict(): assert pl2.label == "b" -def test_game_get_outcome_by_index(): +def test_game_get_outcome(): game = gbt.Game.new_table([2, 2]) - game.make_outcome((0, 0), {"1": 0, "2": 0}, "top left") - assert game[0, 0] == next(iter(game.outcomes)) + game.make_outcome({"1": "1", "2": "1"}, {"1": 0, "2": 0}, "top left") + assert game.get_outcome({"1": "1", "2": "1"}) == next(iter(game.outcomes)) -def test_game_get_outcome_by_label(): +def test_game_get_outcome_by_relabeled_strategies(): game = gbt.Game.new_table([2, 2]) pl1, pl2 = game.players - game.relabel_strategies(pl1, {next(iter(pl1.strategies)).label: "defect"}) - game.relabel_strategies(pl2, {next(iter(pl2.strategies)).label: "cooperate"}) - game.make_outcome(("defect", "cooperate"), {"1": 0, "2": 0}, "corner") - assert game["defect", "cooperate"] == next(iter(game.outcomes)) + game.relabel_strategies(pl1, {next(iter(pl1.strategies)): "defect"}) + game.relabel_strategies(pl2, {next(iter(pl2.strategies)): "cooperate"}) + game.make_outcome({pl1.label: "defect", pl2.label: "cooperate"}, {"1": 0, "2": 0}, "corner") + assert game.get_outcome({pl1.label: "defect", pl2.label: "cooperate"}) == \ + next(iter(game.outcomes)) -def test_game_get_outcome_invalid_tuple_size(): +def test_game_get_outcome_incomplete_contingency_raises(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(ValueError): + _ = game.get_outcome({"1": "1"}) + + +def test_game_get_outcome_unknown_player_raises(): game = gbt.Game.new_table([2, 2]) with pytest.raises(KeyError): - _ = game[0, 0, 0] + _ = game.get_outcome({"1": "1", "2": "1", "3": "1"}) -def test_game_outcomes_non_tuple(): +def test_game_get_outcome_non_mapping_raises(): game = gbt.Game.new_table([2, 2]) with pytest.raises(TypeError): - _ = game[42] + _ = game.get_outcome(42) -def test_game_outcomes_type_exception(): +def test_game_get_outcome_non_str_value_raises(): game = gbt.Game.new_table([2, 2]) with pytest.raises(TypeError): - _ = game[1.23, 1] + _ = game.get_outcome({"1": 1.23, "2": "1"}) -def test_game_get_outcome_index_out_of_range(): +def test_game_get_outcome_unknown_strategy_label_raises(): game = gbt.Game.new_table([2, 2]) - with pytest.raises(IndexError): - _ = game[0, 3] + with pytest.raises(KeyError): + _ = game.get_outcome({"1": "1", "2": "99"}) -def test_game_get_outcome_unmatched_label(): +def test_game_get_outcome_unmatched_label_after_relabel_raises(): game = gbt.Game.new_table([2, 2]) pl1, pl2 = game.players - game.relabel_strategies(pl1, {next(iter(pl1.strategies)).label: "defect"}) - game.relabel_strategies(pl2, {next(iter(pl2.strategies)).label: "cooperate"}) - with pytest.raises(IndexError): - _ = game["defect", "defect"] + game.relabel_strategies(pl1, {next(iter(pl1.strategies)): "defect"}) + game.relabel_strategies(pl2, {next(iter(pl2.strategies)): "cooperate"}) + with pytest.raises(KeyError): + _ = game.get_outcome({pl1.label: "defect", pl2.label: "defect"}) -def test_game_get_outcome_with_strategies(): +def test_game_get_outcome_player_object_key_raises(): game = gbt.Game.new_table([2, 2]) pl1, pl2 = game.players - game.make_outcome( - (next(iter(pl1.strategies)), next(iter(pl2.strategies))), {"1": 0, "2": 0}, "corner" - ) - assert ( - game[next(iter(pl1.strategies)), next(iter(pl2.strategies))] - == next(iter(game.outcomes)) - ) + with pytest.raises(TypeError): + _ = game.get_outcome({pl1: "1", pl2.label: "1"}) -def test_game_get_outcome_with_bad_strategies(): - game = gbt.Game.new_table([2, 2]) - player = next(iter(game.players)) - strategy = next(iter(player.strategies)) - with pytest.raises(IndexError): - _ = game[strategy, strategy] +def test_game_get_outcome_tree_raises(): + game = gbt.Game.new_tree(["Alice"]) + game.append_move(game.root, "Alice", ["a", "b"]) + with pytest.raises(gbt.UndefinedOperationError): + _ = game.get_outcome({"Alice": "a"}) -def test_game_dereference_invalid(): - game = gbt.Game.new_tree() - game.set_players(["One"]) - player = game.players["One"] - strategy = next(iter(player.strategies)) - game.append_move(game.root, player, ["a", "b"]) - with pytest.raises(RuntimeError): - _ = strategy.label +def test_game_get_payoffs(): + game = gbt.Game.new_table([2, 2]) + game.make_outcome({"1": "1", "2": "1"}, {"1": 3, "2": -3}, "top left") + payoffs = game.get_payoffs({"1": "1", "2": "1"}) + assert payoffs["1"] == 3 + assert payoffs["2"] == -3 + + +def test_game_get_payoffs_tree(): + game = gbt.Game.new_tree(["Alice"]) + game.append_move(game.root, "Alice", ["a", "b"]) + alice = game.players["Alice"] + infoset = game.root.infoset + strategy = next( + s for s in alice.strategies if game.get_behavior("Alice", s).get(infoset).label == "a" + ) + game.make_outcome(game.root.children["a"], {"Alice": 1}, "a-outcome") + payoffs = game.get_payoffs({"Alice": strategy}) + assert payoffs["Alice"] == 1 def test_mixed_strategy_profile_game_structure_changed_no_tree(): game = gbt.Game.from_arrays([[2, 2], [0, 0]], [[0, 0], [1, 1]]) profiles = [game.mixed_strategy_profile(rational=b) for b in [False, True]] player = next(iter(game.players)) - distribution = {s.label: 0 for s in player.strategies} + distribution = {s: 0 for s in player.strategies} next(iter(game.outcomes))[player] = 3 for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): @@ -214,7 +226,7 @@ def test_mixed_strategy_profile_game_structure_changed_tree(): profiles = [game.mixed_strategy_profile(rational=b) for b in [False, True]] player = next(iter(game.players)) game.set_move_actions(game.root.infoset, ["D1"], drop=True) - distribution = {s.label: 0 for s in player.strategies} + distribution = {s: 0 for s in player.strategies} for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): profile.as_behavior() @@ -302,7 +314,6 @@ def test_mixed_behavior_profile_game_structure_changed(): COLLECTION_GETTERS = [ pytest.param(lambda g: g.players, id="GamePlayers"), pytest.param(lambda g: g.outcomes, id="GameOutcomes"), - pytest.param(lambda g: g.strategies, id="GameStrategies"), pytest.param(lambda g: g.infosets, id="GameInfosets"), pytest.param(lambda g: g.actions, id="GameActions"), pytest.param(lambda g: g.players["Alice"].strategies, id="PlayerStrategies"), diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index 8b7822151..fe9d8ddeb 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -73,32 +73,6 @@ def test_resolve_outcome_invalid(game: gbt.Game, outcome: str, exception: BaseEx game._resolve_outcome(outcome, "test_resolve_outcome_invalid") -@pytest.mark.parametrize( - "game", - [ - games.read_from_file("sample_extensive_game.efg"), - ] -) -def test_resolve_strategy(game: gbt.Game) -> None: - _test_valid_resolutions(game.strategies, - lambda label, fn: game._resolve_strategy(label, fn)) - - -@pytest.mark.parametrize( - "game,strategy,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"), "doesntexist", KeyError), - ] -) -def test_resolve_strategy_invalid( - game: gbt.Game, strategy: str, exception: BaseException -) -> None: - with pytest.raises(exception): - game._resolve_strategy(strategy, "test_resolve_strategy_invalid") - - @pytest.mark.parametrize( "game", [ diff --git a/tests/test_mixed.py b/tests/test_mixed.py index 005199845..60c2fd989 100644 --- a/tests/test_mixed.py +++ b/tests/test_mixed.py @@ -15,17 +15,18 @@ def _set_action_probs(profile: gbt.MixedStrategyProfile, probs: list, rational_flag: bool): """Set the action probabilities in a strategy profile called ```profile``` according to a - list with probabilities in the order of ```profile.game.strategies``` + list with probabilities in the order of each player's strategies, in player order. """ # assumes rationals given as strings convert = (lambda p: gbt.Rational(p)) if rational_flag else (lambda p: p) - if len(probs) != len(profile.game.strategies): + total_strategies = sum(len(list(p.strategies)) for p in profile.game.players) + if len(probs) != total_strategies: raise ValueError("probs must have one entry per strategy in the game") offset = 0 for player in profile.game.players: k = len(player.strategies) profile[player.label] = { - s.label: convert(p) + s: convert(p) for s, p in zip(player.strategies, probs[offset:offset + k], strict=True) } offset += k @@ -167,9 +168,9 @@ def test_set_and_get_probability_by_strategy_label( """ prob = gbt.Rational(prob) if rational_flag else prob profile = game.mixed_strategy_profile(rational=rational_flag) - player = game.strategies[strategy_label].player + player = next(p for p in game.players if strategy_label in p.strategies) profile[player.label] = { - s.label: (prob if s.label == strategy_label else 0) for s in player.strategies + s: (prob if s == strategy_label else 0) for s in player.strategies } assert profile[player.label][strategy_label] == prob @@ -199,7 +200,7 @@ def test_set_and_get_probabilities_by_player_label( profile_data = [gbt.Rational(p) for p in profile_data] if rational_flag else profile_data profile = game.mixed_strategy_profile(rational=rational_flag) player = game.players[player_label] - expected = dict(zip((s.label for s in player.strategies), profile_data, strict=True)) + expected = dict(zip(player.strategies, profile_data, strict=True)) profile[player_label] = expected assert profile[player_label] == expected @@ -432,7 +433,7 @@ def test_profile_indexing_by_player_label_reference( if rational_flag: strategy_data = [gbt.Rational(prob) for prob in strategy_data] player = game.players[player_label] - expected = dict(zip((s.label for s in player.strategies), strategy_data, strict=True)) + expected = dict(zip(player.strategies, strategy_data, strict=True)) assert profile[player_label] == expected @@ -678,7 +679,7 @@ def test_strategy_value_reference( for i, s in enumerate(player.strategies): sv = strategy_values_for_player[i] sv = gbt.Rational(sv) if rational_flag else sv - assert profile.strategy_values[player.label][s.label] == sv + assert profile.strategy_values[player.label][s] == sv @pytest.mark.parametrize( @@ -1106,9 +1107,9 @@ def test_strategy_regret_consistency(game: gbt.Game, rational_flag: bool): for player in game.players: player_strategy_values = strategy_values[player.label] for strategy in player.strategies: - assert strategy_regrets[player.label][strategy.label] == ( - max(player_strategy_values[s.label] for s in player.strategies) - - player_strategy_values[strategy.label] + assert strategy_regrets[player.label][strategy] == ( + max(player_strategy_values[s] for s in player.strategies) + - player_strategy_values[strategy] ) @@ -1197,7 +1198,7 @@ def test_liap_value_consistency( profile.liap_value() - sum( [ - max(strategy_values[player.label][strategy.label] - payoffs[player.label], 0) + max(strategy_values[player.label][strategy] - payoffs[player.label], 0) ** 2 for player in game.players for strategy in player.strategies @@ -1291,7 +1292,7 @@ def test_player_regret_max_regret_consistency( for p in game.players: p_regret = max( [ - max(strategy_values[p.label][strategy.label] - payoffs[p.label], 0) + max(strategy_values[p.label][strategy] - payoffs[p.label], 0) for strategy in p.strategies ] ) @@ -1384,8 +1385,8 @@ def test_linearity_payoff_property( profile_data = [ [ - alpha * profile1[player.label][strategy.label] - + (1 - alpha) * profile2[player.label][strategy.label] + alpha * profile1[player.label][strategy] + + (1 - alpha) * profile2[player.label][strategy] for strategy in player.strategies ] for player in game.players @@ -1487,8 +1488,8 @@ def test_payoff_and_strategy_value_consistency( abs( sum( [ - profile[player.label][strategy.label] - * player_strategy_values[strategy.label] + profile[player.label][strategy] + * player_strategy_values[strategy] for strategy in player.strategies ] ) @@ -1568,12 +1569,12 @@ def test_vectorized_quantities_consistency(game: gbt.Game, profile_data, rationa assert isinstance(player_strategy_values, gbt.StrategyIndexedVector) assert isinstance(player_strategy_regrets, gbt.StrategyRegretVector) - best_response_value = max(player_strategy_values[s.label] for s in player.strategies) + best_response_value = max(player_strategy_values[s] for s in player.strategies) assert player_regrets[player.label] == best_response_value - payoffs[player.label] for strategy in player.strategies: assert ( - player_strategy_regrets[strategy.label] - == best_response_value - player_strategy_values[strategy.label] + player_strategy_regrets[strategy] + == best_response_value - player_strategy_values[strategy] ) # equal to an equivalent plain dict or same-type vector, but never to a vector of a @@ -1683,8 +1684,8 @@ def test_property_linearity_strategy_value( profile_data = [ [ - alpha * profile1[player.label][strategy.label] - + (1 - alpha) * profile2[player.label][strategy.label] + alpha * profile1[player.label][strategy] + + (1 - alpha) * profile2[player.label][strategy] for strategy in player.strategies ] for player in game.players @@ -1697,10 +1698,10 @@ def test_property_linearity_strategy_value( for player in game.players: for strategy in player.strategies: convex_comb = ( - alpha * strategy_values1[player.label][strategy.label] - + (1 - alpha) * strategy_values2[player.label][strategy.label] + alpha * strategy_values1[player.label][strategy] + + (1 - alpha) * strategy_values2[player.label][strategy] ) - assert abs(strategy_values3[player.label][strategy.label] - convex_comb) <= tol + assert abs(strategy_values3[player.label][strategy] - convex_comb) <= tol def _get_answers_one_order( @@ -1828,10 +1829,8 @@ def _get_and_check_answers( PROBS_1A_doub, PROBS_2A_doub, False, - lambda profile, strategy: profile.strategy_regrets[strategy.player.label][ - strategy.label - ], - lambda game: game.strategies, + lambda profile, strategy: profile.strategy_regrets[strategy[0]][strategy[1]], + lambda game: [(p.label, s) for p in game.players for s in p.strategies], id="regret_coord_doub", ), pytest.param( @@ -1839,10 +1838,8 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda profile, strategy: profile.strategy_regrets[strategy.player.label][ - strategy.label - ], - lambda game: game.strategies, + lambda profile, strategy: profile.strategy_regrets[strategy[0]][strategy[1]], + lambda game: [(p.label, s) for p in game.players for s in p.strategies], id="regret_coord_rat", ), # 2x2x2 nfg @@ -1851,10 +1848,8 @@ def _get_and_check_answers( PROBS_1B_doub, PROBS_2B_doub, False, - lambda profile, strategy: profile.strategy_regrets[strategy.player.label][ - strategy.label - ], - lambda game: game.strategies, + lambda profile, strategy: profile.strategy_regrets[strategy[0]][strategy[1]], + lambda game: [(p.label, s) for p in game.players for s in p.strategies], id="regret_2x2x2_doub", ), pytest.param( @@ -1862,10 +1857,8 @@ def _get_and_check_answers( PROBS_1B_rat, PROBS_2B_rat, True, - lambda profile, strategy: profile.strategy_regrets[strategy.player.label][ - strategy.label - ], - lambda game: game.strategies, + lambda profile, strategy: profile.strategy_regrets[strategy[0]][strategy[1]], + lambda game: [(p.label, s) for p in game.players for s in p.strategies], id="regret_2x2x2_rat", ), # stripped-down poker @@ -1874,10 +1867,8 @@ def _get_and_check_answers( PROBS_1B_doub, PROBS_2B_doub, False, - lambda profile, strategy: profile.strategy_regrets[strategy.player.label][ - strategy.label - ], - lambda game: game.strategies, + lambda profile, strategy: profile.strategy_regrets[strategy[0]][strategy[1]], + lambda game: [(p.label, s) for p in game.players for s in p.strategies], id="regret_poker_doub", ), pytest.param( @@ -1885,10 +1876,8 @@ def _get_and_check_answers( PROBS_1B_rat, PROBS_2B_rat, True, - lambda profile, strategy: profile.strategy_regrets[strategy.player.label][ - strategy.label - ], - lambda game: game.strategies, + lambda profile, strategy: profile.strategy_regrets[strategy[0]][strategy[1]], + lambda game: [(p.label, s) for p in game.players for s in p.strategies], id="regret_poker_rat", ), ################################################################################# @@ -1899,10 +1888,8 @@ def _get_and_check_answers( PROBS_1A_doub, PROBS_2A_doub, False, - lambda profile, strategy: profile.strategy_values[strategy.player.label][ - strategy.label - ], - lambda game: game.strategies, + lambda profile, strategy: profile.strategy_values[strategy[0]][strategy[1]], + lambda game: [(p.label, s) for p in game.players for s in p.strategies], id="strat_value_coord_doub", ), pytest.param( @@ -1910,10 +1897,8 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda profile, strategy: profile.strategy_values[strategy.player.label][ - strategy.label - ], - lambda game: game.strategies, + lambda profile, strategy: profile.strategy_values[strategy[0]][strategy[1]], + lambda game: [(p.label, s) for p in game.players for s in p.strategies], id="strat_value_coord_rat", ), # 2x2x2 nfg @@ -1922,10 +1907,8 @@ def _get_and_check_answers( PROBS_1B_doub, PROBS_2B_doub, False, - lambda profile, strategy: profile.strategy_values[strategy.player.label][ - strategy.label - ], - lambda game: game.strategies, + lambda profile, strategy: profile.strategy_values[strategy[0]][strategy[1]], + lambda game: [(p.label, s) for p in game.players for s in p.strategies], id="strat_value_2x2x2_doub", ), pytest.param( @@ -1933,10 +1916,8 @@ def _get_and_check_answers( PROBS_1B_rat, PROBS_2B_rat, True, - lambda profile, strategy: profile.strategy_values[strategy.player.label][ - strategy.label - ], - lambda game: game.strategies, + lambda profile, strategy: profile.strategy_values[strategy[0]][strategy[1]], + lambda game: [(p.label, s) for p in game.players for s in p.strategies], id="strat_value_2x2x2_rat", ), # stripped-down poker @@ -1945,10 +1926,8 @@ def _get_and_check_answers( PROBS_1B_doub, PROBS_2B_doub, False, - lambda profile, strategy: profile.strategy_values[strategy.player.label][ - strategy.label - ], - lambda game: game.strategies, + lambda profile, strategy: profile.strategy_values[strategy[0]][strategy[1]], + lambda game: [(p.label, s) for p in game.players for s in p.strategies], id="strat_value_poker_doub", ), pytest.param( @@ -1956,10 +1935,8 @@ def _get_and_check_answers( PROBS_1B_rat, PROBS_2B_rat, True, - lambda profile, strategy: profile.strategy_values[strategy.player.label][ - strategy.label - ], - lambda game: game.strategies, + lambda profile, strategy: profile.strategy_values[strategy[0]][strategy[1]], + lambda game: [(p.label, s) for p in game.players for s in p.strategies], id="strat_value_poker_rat", ), ################################################################################# diff --git a/tests/test_nash.py b/tests/test_nash.py index 06b2f6872..a365abcac 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -1619,8 +1619,8 @@ def test_nash_strategy_solver(test_case: EquilibriumTestCase, subtests) -> None: expected = game.mixed_strategy_profile(rational=True, data=exp) for player in game.players: for strategy in player.strategies: - eq_prob = eq[player.label][strategy.label] - exp_prob = expected[player.label][strategy.label] + eq_prob = eq[player.label][strategy] + exp_prob = expected[player.label][strategy] assert abs(eq_prob - exp_prob) <= test_case.prob_tol @@ -1641,7 +1641,7 @@ def _one_hot_perturbation(rational: bool) -> gbt.MixedStrategyProfile: for player in game.players: strategies = list(player.strategies) perturbation[player.label] = { - s.label: (one if s is strategies[0] else zero) for s in strategies + s: (one if s == strategies[0] else zero) for s in strategies } return perturbation @@ -1660,8 +1660,8 @@ def _one_hot_perturbation(rational: bool) -> gbt.MixedStrategyProfile: for player in game.players: for strategy in player.strategies: assert ( - rational_eq[player.label][strategy.label] - == pytest.approx(double_eq[player.label][strategy.label]) + rational_eq[player.label][strategy] + == pytest.approx(double_eq[player.label][strategy]) ) @@ -1745,8 +1745,8 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s expected = game.mixed_strategy_profile(rational=True, data=exp) for player in game.players: for strategy in player.strategies: - eq_prob = eq[player.label][strategy.label] - exp_prob = expected[player.label][strategy.label] + eq_prob = eq[player.label][strategy] + exp_prob = expected[player.label][strategy] assert abs(eq_prob - exp_prob) <= test_case.prob_tol @@ -3565,8 +3565,8 @@ def test_qre_solver(test_case: QREquilibriumTestCase, subtests) -> None: exp_profile = game.mixed_strategy_profile(rational=True, data=exp["profile"]) for player in game.players: for s in player.strategies: - found_prob = found.profile[player.label][s.label] - exp_prob = exp_profile[player.label][s.label] + found_prob = found.profile[player.label][s] + exp_prob = exp_profile[player.label][s] assert abs(found_prob - exp_prob) <= test_case.prob_tol diff --git a/tests/test_nashlrs.py b/tests/test_nashlrs.py new file mode 100644 index 000000000..35848df19 --- /dev/null +++ b/tests/test_nashlrs.py @@ -0,0 +1,91 @@ +from unittest.mock import MagicMock + +import pytest + +import pygambit as gbt +from pygambit.nashlrs import _generate_lrs_input, _parse_lrs_output, lrsnash_solve + + +def test_generate_lrs_input_format(): + """The generated input is the game dimensions, then each player's payoff matrix as + space-separated rows, matching lrsnash's expected input format. + """ + game = gbt.Game.from_arrays([[1, 2], [3, 4]], [[5, 6], [7, 8]]) + assert _generate_lrs_input(game) == "2 2\n\n1 2\n3 4\n\n5 6\n7 8\n" + + +def test_generate_lrs_input_rectangular(): + game = gbt.Game.from_arrays([[1, 2, 3]], [[4, 5, 6]]) + assert _generate_lrs_input(game) == "1 3\n\n1 2 3\n\n4 5 6\n" + + +def test_parse_lrs_output_single_equilibrium(): + """Each equilibrium is one block of lines: player 1's rows (prefixed '1'), then + player 2's rows (prefixed '2'), each ending in a value that is not part of the + probability vector and is discarded. + """ + game = gbt.Game.new_table([2, 2]) + txt = "1 1 0 3\n2 0 1 3\n" + profiles = _parse_lrs_output(game, txt) + assert len(profiles) == 1 + assert profiles[0]["1"] == {"1": 1, "2": 0} + assert profiles[0]["2"] == {"1": 0, "2": 1} + + +def test_parse_lrs_output_multiple_equilibria(): + """Equilibria (components) are separated by a blank line.""" + game = gbt.Game.new_table([2, 2]) + txt = "1 1 0 3\n2 0 1 3\n\n1 0 1 3\n2 1 0 3\n" + profiles = _parse_lrs_output(game, txt) + assert len(profiles) == 2 + assert profiles[0]["1"] == {"1": 1, "2": 0} + assert profiles[1]["1"] == {"1": 0, "2": 1} + + +def test_parse_lrs_output_ignores_comment_lines(): + """Lines starting with '*' (lrsnash diagnostic/banner output) are ignored.""" + game = gbt.Game.new_table([2, 2]) + txt = "* some banner text\n1 1 0 3\n2 0 1 3\n* another comment\n" + profiles = _parse_lrs_output(game, txt) + assert len(profiles) == 1 + + +def test_lrsnash_solve_requires_two_players(): + game = gbt.Game.new_table([2, 2, 2]) + with pytest.raises(RuntimeError, match="two-player"): + lrsnash_solve(game, "lrsnash") + + +def test_lrsnash_solve_mocked_subprocess(monkeypatch): + """`lrsnash_solve` writes the game to a temp file, invokes the external tool, and + parses its stdout -- verified here without requiring the real `lrsnash` binary. + """ + game = gbt.Game.from_arrays([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]) + captured = {} + + def _fake_run(cmd, **kwargs): + captured["cmd"] = cmd + result = MagicMock() + result.returncode = 0 + result.stdout = "1 1 0 3\n2 1 0 3\n" + return result + + monkeypatch.setattr("pygambit.nashlrs.subprocess.run", _fake_run) + profiles = lrsnash_solve(game, "./lrsnash") + assert len(profiles) == 1 + assert profiles[0]["1"] == {"1": 1, "2": 0} + assert captured["cmd"][0] == "./lrsnash" + + +def test_lrsnash_solve_nonzero_returncode_raises(monkeypatch): + game = gbt.Game.new_table([2, 2]) + + def _fake_run(cmd, **kwargs): + result = MagicMock() + result.returncode = 1 + result.stdout = "" + return result + + monkeypatch.setattr("pygambit.nashlrs.subprocess.run", _fake_run) + with pytest.raises(ValueError, match="failed"): + lrsnash_solve(game, "./lrsnash") diff --git a/tests/test_nashphc.py b/tests/test_nashphc.py new file mode 100644 index 000000000..7798fb178 --- /dev/null +++ b/tests/test_nashphc.py @@ -0,0 +1,199 @@ +import pathlib + +import pytest + +import pygambit as gbt +from pygambit.nashphc import ( + _contingencies, + _equilibrium_equations, + _format_profile, + _format_support, + _is_nash, + _playerletters, + _process_phc_output, + _profile_from_support, + _solution_to_profile, + _strategy_index, + phcpack_solve, +) + + +@pytest.fixture +def matching_pennies(): + 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") + return game + + +def test_playerletters_excludes_disallowed_variable_letters(): + """PHC does not allow 'e', 'i', or 'j' in variable names.""" + assert "e" not in _playerletters + assert "i" not in _playerletters + assert "j" not in _playerletters + assert len(_playerletters) == 23 + + +def test_strategy_index(matching_pennies): + player = matching_pennies.players["1"] + assert _strategy_index(player, "1") == 0 + assert _strategy_index(player, "2") == 1 + + +def test_contingencies_skips_given_player(matching_pennies): + p1, p2 = matching_pennies.players + support = matching_pennies.strategy_support_profile() + conts = list(_contingencies(support, p1)) + assert all(cont[p1.number] is None for cont in conts) + assert {cont[p2.number] for cont in conts} == {"1", "2"} + + +def test_equilibrium_equations(matching_pennies): + p1, p2 = matching_pennies.players + support = matching_pennies.strategy_support_profile() + equations = _equilibrium_equations(support, p1) + assert equations == [ + "((1*b0)+(-1*b1))-((-1*b0)+(1*b1))", + "a0+a1-1", + ] + + +def test_is_nash_true_for_mixed_equilibrium(matching_pennies): + profile = matching_pennies.mixed_strategy_profile() + profile["1"] = {"1": 0.5, "2": 0.5} + profile["2"] = {"1": 0.5, "2": 0.5} + assert _is_nash(profile, 1e-6, 1e-6) + + +def test_is_nash_false_for_pure_profile(matching_pennies): + profile = matching_pennies.mixed_strategy_profile() + profile["1"] = {"1": 1.0, "2": 0.0} + profile["2"] = {"1": 1.0, "2": 0.0} + assert not _is_nash(profile, 1e-6, 1e-6) + + +def test_solution_to_profile(matching_pennies): + entry = {"vars": {"a0": complex(0.5, 0), "a1": complex(0.5, 0), + "b0": complex(0.5, 0), "b1": complex(0.5, 0)}} + profile = _solution_to_profile(matching_pennies, entry) + assert profile["1"] == {"1": 0.5, "2": 0.5} + assert profile["2"] == {"1": 0.5, "2": 0.5} + + +def test_solution_to_profile_missing_variable_defaults_to_zero(matching_pennies): + entry = {"vars": {"a0": complex(1.0, 0), "b0": complex(1.0, 0)}} + profile = _solution_to_profile(matching_pennies, entry) + assert profile["1"] == {"1": 1.0, "2": 0.0} + assert profile["2"] == {"1": 1.0, "2": 0.0} + + +def test_format_support(matching_pennies): + support = matching_pennies.strategy_support_profile() + assert _format_support(support, "candidate") == "candidate,11,11" + + +def test_profile_from_support_pure_strategy_support(matching_pennies): + support = matching_pennies.strategy_support_profile(lambda player, label: label == "1") + profile = _profile_from_support(support) + assert profile["1"] == {"1": 1.0, "2": 0.0} + assert profile["2"] == {"1": 1.0, "2": 0.0} + + +def test_format_profile(matching_pennies): + profile = matching_pennies.mixed_strategy_profile() + profile["1"] = {"1": 0.5, "2": 0.5} + profile["2"] = {"1": 0.5, "2": 0.5} + assert _format_profile(profile, "test", decimals=2) == "test,0.50,0.50,0.50,0.50" + + +def test_process_phc_output_single_solution_with_diagnostics(): + output = ( + "\nTHE SOLUTIONS :\n\n" + "1 2\n" + "===========================================================================\n" + "solution 1 :\n" + "t : 0.0 0.0\n" + "m : 1\n" + "the solution for t :\n" + " a0 : 0.5 0.0\n" + " a1 : 0.5 0.0\n" + "== err : 1e-16 = rco : 1.0 = res : 0.0 =\n" + "===========================================================================\n" + ) + solutions = _process_phc_output(output) + assert len(solutions) == 1 + assert solutions[0]["vars"] == {"a0": complex(0.5, 0.0), "a1": complex(0.5, 0.0)} + assert solutions[0]["t"] == complex(0.0, 0.0) + assert solutions[0]["m"] == 1 + assert solutions[0]["err"] == 1e-16 + assert solutions[0]["rco"] == 1.0 + assert solutions[0]["res"] == 0.0 + + +def test_process_phc_output_multiple_solutions_terminated_by_timing(): + output = ( + "THE SOLUTIONS :\n\n" + "solution 1 :\n" + " a0 : 1.0 0.0\n" + " a1 : 0.0 0.0\n" + "solution 2 :\n" + " a0 : 0.0 0.0\n" + " a1 : 1.0 0.0\n" + "TIMING INFORMATION\n" + ) + solutions = _process_phc_output(output) + assert len(solutions) == 2 + assert solutions[0]["vars"] == {"a0": complex(1.0, 0.0), "a1": complex(0.0, 0.0)} + assert solutions[1]["vars"] == {"a0": complex(0.0, 0.0), "a1": complex(1.0, 0.0)} + + +def test_phcpack_solve_mocked_subprocess(monkeypatch, matching_pennies): + """`phcpack_solve` writes equations to a temp file, invokes the external PHC binary, + and parses its output file -- verified here without requiring the real `phc` binary. + """ + 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" + ) + captured = {} + + def _fake_run(cmd, **kwargs): + captured["cmd"] = cmd + pathlib.Path(cmd[3]).write_text(phc_output) + result = type("Result", (), {"returncode": 0})() + return result + + monkeypatch.setattr("pygambit.nashphc.subprocess.run", _fake_run) + profiles = phcpack_solve(matching_pennies, "./phc", maxregret=1e-6) + assert len(profiles) == 1 + assert profiles[0]["1"] == {"1": 0.5, "2": 0.5} + assert profiles[0]["2"] == {"1": 0.5, "2": 0.5} + assert captured["cmd"][0] == "./phc" + + +def test_phcpack_solve_nonzero_returncode_raises_and_reports_singular(monkeypatch, + matching_pennies): + reported = [] + + def _fake_run(cmd, **kwargs): + result = type("Result", (), {"returncode": 1})() + return result + + monkeypatch.setattr("pygambit.nashphc.subprocess.run", _fake_run) + import pygambit.nash as nash + + for support in nash.possible_nash_supports(matching_pennies): + from pygambit.nashphc import _solve_support + profiles = _solve_support( + support, "./phc", maxregret=1e-6, negtol=1e-6, + onsupport=lambda x, label: reported.append(label), + ) + assert profiles == [] + assert "singular" in reported diff --git a/tests/test_outcomes.py b/tests/test_outcomes.py index 40b2e819d..7abca712a 100644 --- a/tests/test_outcomes.py +++ b/tests/test_outcomes.py @@ -28,10 +28,12 @@ def test_make_outcome_attaches_to_all_given_nodes(): def test_make_outcome_attaches_at_contingencies(): game = gbt.Game.new_table([2, 2]) - outcome = game.make_outcome([(0, 0), (1, 1)], {"1": 2, "2": -2}, "diagonal") - assert game[0, 0] == outcome - assert game[1, 1] == outcome - assert not game[0, 1] + outcome = game.make_outcome( + [{"1": "1", "2": "1"}, {"1": "2", "2": "2"}], {"1": 2, "2": -2}, "diagonal" + ) + assert game.get_outcome({"1": "1", "2": "1"}) == outcome + assert game.get_outcome({"1": "2", "2": "2"}) == outcome + assert not game.get_outcome({"1": "1", "2": "2"}) assert outcome["1"] == 2 diff --git a/tests/test_players.py b/tests/test_players.py index 9851da872..29e4bc7f0 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -177,7 +177,7 @@ def test_strategic_game_set_players_add(): new_player = game.players["Player 3"] assert len(game.players) == 3 assert len(new_player.strategies) == 1 - assert next(iter(new_player.strategies)).label == "1" + assert next(iter(new_player.strategies)) == "1" def test_extensive_game_set_players_add(): @@ -192,11 +192,11 @@ def test_extensive_game_set_players_add(): def test_strategic_game_set_strategies_add(): game = gbt.Game.new_table([2, 2]) pl1, pl2 = game.players - game.set_strategies(pl1, [s.label for s in pl1.strategies] + ["new strategy"]) + game.set_strategies(pl1, list(pl1.strategies) + ["new strategy"]) assert len(pl1.strategies) == 3 # This second add also ensures that we are testing the case where there # are null outcomes in the table - game.set_strategies(pl2, [s.label for s in pl2.strategies] + ["new strategy"]) + game.set_strategies(pl2, list(pl2.strategies) + ["new strategy"]) assert len(pl2.strategies) == 3 @@ -213,12 +213,11 @@ def _tag_contingencies(game: gbt.Game) -> None: """ players = list(game.players) for n, contingency in enumerate(game.contingencies, start=1): - strategies = [list(p.strategies)[i] for p, i in zip(players, contingency, strict=True)] payoffs = { - player: int(f"{pl_index}{strategy.label}") - for pl_index, (player, strategy) in enumerate(zip(players, strategies, strict=True)) + player: int(f"{pl_index}{contingency[player.label]}") + for pl_index, player in enumerate(players) } - game.make_outcome(tuple(strategies), payoffs, f"c{n}") + game.make_outcome(contingency, payoffs, f"c{n}") def test_strategic_game_set_strategies_drop_preserves_other_payoffs(): @@ -228,22 +227,24 @@ def test_strategic_game_set_strategies_drop_preserves_other_payoffs(): # Record expected payoffs by label (a stable identity), for the # strategies of pl1 that survive dropping its second strategy. - surviving = [s.label for s in pl1.strategies if s.label != "2"] + surviving = [s for s in pl1.strategies if s != "2"] expected = { - (s1.label, s2.label, s3.label): - tuple(game[s1, s2, s3][p] for p in (pl1, pl2, pl3)) - for s1 in pl1.strategies if s1.label in surviving + (s1, s2, s3): + game.get_payoffs({pl1.label: s1, pl2.label: s2, pl3.label: s3}) + for s1 in pl1.strategies if s1 in surviving for s2 in pl2.strategies for s3 in pl3.strategies } game.set_strategies(pl1, surviving, drop=True) - assert [s.label for s in pl1.strategies] == surviving + assert list(pl1.strategies) == surviving for s1 in pl1.strategies: for s2 in pl2.strategies: for s3 in pl3.strategies: - key = (s1.label, s2.label, s3.label) - actual = tuple(game[s1, s2, s3][p] for p in (pl1, pl2, pl3)) + key = (s1, s2, s3) + actual = game.get_payoffs( + {pl1.label: s1, pl2.label: s2, pl3.label: s3} + ) assert actual == expected[key] @@ -252,20 +253,21 @@ def test_strategic_game_set_strategies_drop_first_preserves_other_payoffs(): pl1, pl2 = game.players _tag_contingencies(game) - surviving = [s.label for s in pl1.strategies if s.label != "1"] + surviving = [s for s in pl1.strategies if s != "1"] expected = { - (s1.label, s2.label): tuple(game[s1, s2][p] for p in (pl1, pl2)) - for s1 in pl1.strategies if s1.label in surviving + (s1, s2): game.get_payoffs({pl1.label: s1, pl2.label: s2}) + for s1 in pl1.strategies if s1 in surviving for s2 in pl2.strategies } game.set_strategies(pl1, surviving, drop=True) - assert [s.label for s in pl1.strategies] == surviving + assert list(pl1.strategies) == surviving for s1 in pl1.strategies: for s2 in pl2.strategies: - key = (s1.label, s2.label) - assert tuple(game[s1, s2][p] for p in (pl1, pl2)) == expected[key] + key = (s1, s2) + actual = game.get_payoffs({pl1.label: s1, pl2.label: s2}) + assert actual == expected[key] def test_strategic_game_set_strategies_empty(): @@ -275,19 +277,12 @@ def test_strategic_game_set_strategies_empty(): game.set_strategies(pl1, [], drop=True) -def test_player_strategy_by_label(): - game = gbt.Game.new_table([2, 2]) - pl1 = next(iter(game.players)) - game.relabel_strategies(pl1, {next(iter(pl1.strategies)).label: "Cooperate"}) - assert pl1.strategies["Cooperate"].label == "Cooperate" - - @pytest.mark.parametrize("label", games.VALID_LABELS) def test_set_strategies_label_valid(label): game = gbt.Game.new_table([2, 2]) pl1 = next(iter(game.players)) - game.set_strategies(pl1, [s.label for s in pl1.strategies] + [label]) - assert [s.label for s in pl1.strategies][-1] == label + game.set_strategies(pl1, list(pl1.strategies) + [label]) + assert list(pl1.strategies)[-1] == label @pytest.mark.parametrize("label", games.INVALID_LABELS) @@ -295,7 +290,7 @@ def test_set_strategies_label_invalid_raises_valueerror(label): game = gbt.Game.new_table([2, 2]) pl1 = next(iter(game.players)) with pytest.raises(ValueError): - game.set_strategies(pl1, [s.label for s in pl1.strategies] + [label]) + game.set_strategies(pl1, list(pl1.strategies) + [label]) def test_set_strategies_requires_iterable_of_str(): @@ -312,7 +307,7 @@ def test_strategy_label_empty_raises_valueerror(): pl1 = next(iter(game.players)) strategy = next(iter(pl1.strategies)) with pytest.raises(ValueError): - game.relabel_strategies(pl1, {strategy.label: ""}) + game.relabel_strategies(pl1, {strategy: ""}) def test_strategy_label_duplicate_within_player_raises_valueerror(): @@ -320,21 +315,7 @@ def test_strategy_label_duplicate_within_player_raises_valueerror(): pl1 = next(iter(game.players)) s1, s2 = pl1.strategies with pytest.raises(ValueError): - game.relabel_strategies(pl1, {s2.label: s1.label}) - - -def test_player_strategy_bad_label(): - game = gbt.Game.new_table([2, 2]) - pl1 = next(iter(game.players)) - with pytest.raises(KeyError): - _ = pl1.strategies["Cooperate"] - - -def test_player_strategy_bad_type(): - game = gbt.Game.new_table([2, 2]) - pl1 = next(iter(game.players)) - with pytest.raises(TypeError): - _ = pl1.strategies[1.3] + game.relabel_strategies(pl1, {s2: s1}) def test_player_sequence_count(): @@ -418,7 +399,7 @@ def test_player_get_min_payoff_null_outcome(): pl1, pl2 = game.players assert pl1.min_payoff == 1 assert pl2.min_payoff == 2 - game.set_strategies(pl1, [s.label for s in pl1.strategies] + ["new strategy"]) + game.set_strategies(pl1, list(pl1.strategies) + ["new strategy"]) # Currently the outcomes associated with the new entries in the table # are null outcomes. So now minimum payoff should be zero from those. for player in game.players: @@ -444,7 +425,7 @@ def test_player_get_max_payoff_null_outcome(): pl1, pl2 = game.players assert pl1.max_payoff == -1 assert pl2.max_payoff == -2 - game.set_strategies(pl1, [s.label for s in pl1.strategies] + ["new strategy"]) + game.set_strategies(pl1, list(pl1.strategies) + ["new strategy"]) # Currently the outcomes associated with the new entries in the table # are null outcomes. So now minimum payoff should be zero from those. for player in game.players: @@ -454,19 +435,19 @@ def test_player_get_max_payoff_null_outcome(): def test_set_strategies_duplicate_label_raises_and_leaves_game_unchanged(): game = gbt.Game.new_table([2, 2]) pl = next(iter(game.players)) - labels = [s.label for s in pl.strategies] + labels = list(pl.strategies) with pytest.raises(ValueError): game.set_strategies(pl, labels + [labels[0]]) - assert [s.label for s in pl.strategies] == labels + assert list(pl.strategies) == labels def test_set_strategies_empty_label_raises_and_leaves_game_unchanged(): game = gbt.Game.new_table([2, 2]) pl = next(iter(game.players)) - labels = [s.label for s in pl.strategies] + labels = list(pl.strategies) with pytest.raises(ValueError): game.set_strategies(pl, labels + [""]) - assert [s.label for s in pl.strategies] == labels + assert list(pl.strategies) == labels def test_set_players_empty_raises(): diff --git a/tests/test_qre.py b/tests/test_qre.py index 419ada3a2..decb97797 100644 --- a/tests/test_qre.py +++ b/tests/test_qre.py @@ -47,8 +47,8 @@ def test_logit_estimate_strategy_rational_and_float_data_agree(): for player in game.players: for strategy in player.strategies: assert ( - rational_result.profile[player.label][strategy.label] - == pytest.approx(float_result.profile[player.label][strategy.label]) + rational_result.profile[player.label][strategy] + == pytest.approx(float_result.profile[player.label][strategy]) ) diff --git a/tests/test_strategic.py b/tests/test_strategic.py index 2791c9c11..57c700e90 100644 --- a/tests/test_strategic.py +++ b/tests/test_strategic.py @@ -73,9 +73,9 @@ def test_relabel_strategies_swap(): """Swap is well-defined; strategies keep their positions.""" game = gbt.Game.new_table([2, 2]) player, _ = game.players - a, b = (strategy.label for strategy in player.strategies) + a, b = player.strategies game.relabel_strategies(player, {a: b, b: a}) - assert [strategy.label for strategy in player.strategies] == [b, a] + assert list(player.strategies) == [b, a] def test_relabel_strategies_duplicate_raises_valueerror(): @@ -84,7 +84,7 @@ def test_relabel_strategies_duplicate_raises_valueerror(): untouched strategies alone would let the second through.""" game = gbt.Game.new_table([2, 2]) player, _ = game.players - a, b = (strategy.label for strategy in player.strategies) + a, b = player.strategies with pytest.raises(ValueError): game.relabel_strategies(player, {a: b}) with pytest.raises(ValueError): @@ -96,29 +96,29 @@ def test_relabel_strategies_bad_label_raises_and_leaves_game_unchanged(bad: str) """The whole mapping is validated before any label is written.""" game = gbt.Game.new_table([2, 2]) player, _ = game.players - a, b = (strategy.label for strategy in player.strategies) + a, b = player.strategies with pytest.raises(ValueError): game.relabel_strategies(player, {a: "X", b: bad}) - assert [strategy.label for strategy in player.strategies] == [a, b] + assert list(player.strategies) == [a, b] def test_relabel_strategies_unknown_label_strictness(): game = gbt.Game.new_table([2, 2]) player, _ = game.players - a = next(iter(player.strategies)).label + a = next(iter(player.strategies)) with pytest.raises(KeyError): game.relabel_strategies(player, {"no-such-strategy": "X"}) game.relabel_strategies(player, {"no-such-strategy": "X", a: "Y"}, strict=False) - assert next(iter(player.strategies)).label == "Y" + assert next(iter(player.strategies)) == "Y" def test_relabel_strategies_scope_is_the_player(): """Strategy labels are unique within a player, not within the game.""" game = gbt.Game.new_table([2, 2]) one, two = game.players - game.relabel_strategies(one, {next(iter(one.strategies)).label: "X"}) - game.relabel_strategies(two, {next(iter(two.strategies)).label: "X"}) - assert [next(iter(p.strategies)).label for p in game.players] == ["X", "X"] + game.relabel_strategies(one, {next(iter(one.strategies)): "X"}) + game.relabel_strategies(two, {next(iter(two.strategies)): "X"}) + assert [next(iter(p.strategies)) for p in game.players] == ["X", "X"] def test_relabel_strategies_tree_game_raises(): @@ -129,8 +129,12 @@ def test_relabel_strategies_tree_game_raises(): def _payoffs_by_label(game: gbt.Game) -> dict: one, two = game.players - return {(s.label, t.label): (game[s, t][one], game[s, t][two]) - for s in one.strategies for t in two.strategies} + result = {} + for s in one.strategies: + for t in two.strategies: + payoffs = game.get_payoffs({one.label: s, two.label: t}) + result[s, t] = (payoffs[one.label], payoffs[two.label]) + return result def test_set_strategies_reorder_carries_outcomes(): @@ -138,11 +142,11 @@ def test_set_strategies_reorder_carries_outcomes(): it had, identified by the labels of its strategies.""" game = gbt.Game.from_arrays([[1, 2], [3, 4]], [[5, 6], [7, 8]]) player, _ = game.players - a, b = (s.label for s in player.strategies) + a, b = player.strategies kept = list(player.strategies) before = _payoffs_by_label(game) game.set_strategies(player, [b, a]) - assert [s.label for s in player.strategies] == [b, a] + assert list(player.strategies) == [b, a] assert list(player.strategies) == list(reversed(kept)) assert _payoffs_by_label(game) == before @@ -152,19 +156,25 @@ def test_set_strategies_add_drop_and_reorder_together(): the outcomes at its contingencies.""" game = gbt.Game.from_arrays([[1, 2], [3, 4]], [[5, 6], [7, 8]]) player, other = game.players - a, b = (s.label for s in player.strategies) - kept = {t.label: game[a, t.label][player] for t in other.strategies} + a, b = player.strategies + kept = { + t: game.get_payoffs({player.label: a, other.label: t})[player.label] + for t in other.strategies + } game.set_strategies(player, ["X", a], drop=True) - assert [s.label for s in player.strategies] == ["X", a] - assert {t.label: game[a, t.label][player] for t in other.strategies} == kept + assert list(player.strategies) == ["X", a] + assert { + t: game.get_payoffs({player.label: a, other.label: t})[player.label] + for t in other.strategies + } == kept def test_set_strategies_unconfirmed_drop_and_disabled_add_raise(): game = gbt.Game.new_table([2, 2]) player, _ = game.players - a, b = (s.label for s in player.strategies) + a, b = player.strategies with pytest.raises(ValueError): game.set_strategies(player, [a]) with pytest.raises(ValueError): game.set_strategies(player, [a, b, "X"], add=False) - assert [s.label for s in player.strategies] == [a, b] + assert list(player.strategies) == [a, b] diff --git a/tests/test_stratprofiles.py b/tests/test_stratprofiles.py index e9db259cc..c5dc3d355 100644 --- a/tests/test_stratprofiles.py +++ b/tests/test_stratprofiles.py @@ -31,7 +31,7 @@ def test_getitem_rejects_non_str(): def test_predicate_construction(): game = games.read_from_file("mixed_strategy.nfg") - profile = game.strategy_support_profile(lambda x: x.label != "3") + profile = game.strategy_support_profile(lambda player, label: label != "3") assert set(profile["Player 1"]) == {"1", "2"} assert set(profile["Player 2"]) == {"1", "2"} @@ -39,7 +39,7 @@ def test_predicate_construction(): def test_predicate_construction_error(): game = games.read_from_file("mixed_strategy.nfg") with pytest.raises(ValueError): - game.strategy_support_profile(lambda x: x.player.label != "Player 1") + game.strategy_support_profile(lambda player, label: player.label != "Player 1") def test_iter_yields_one_support_per_player(): @@ -118,7 +118,7 @@ def test_is_dominated_unknown_strategy(): def test_restrict(): game = games.read_from_file("mixed_strategy.nfg") - profile = game.strategy_support_profile(lambda x: x.label != "3") + profile = game.strategy_support_profile(lambda player, label: label != "3") restricted = profile.restrict() assert len(restricted.players["Player 1"].strategies) == 2 assert len(restricted.players["Player 2"].strategies) == 2 @@ -132,4 +132,4 @@ def test_undominated(): if new_profile == profile: break profile = new_profile - assert profile == game.strategy_support_profile(lambda x: x.label == "1") + assert profile == game.strategy_support_profile(lambda player, label: label == "1")