diff --git a/ChangeLog b/ChangeLog index fa2db956f5..843ce768cc 100644 --- a/ChangeLog +++ b/ChangeLog @@ -3,6 +3,35 @@ ### Added - Added `Game.has_perfect_recall(player)`, and `GameRep::HasPerfectRecall(const GamePlayer &)` in C++, reporting whether an individual player has perfect recall. (#1107) +- Added `Game.get_outcomes()`, returning a materialized list of outcome labels, replacing + `Game.outcomes`. +- Added `Game.get_outcome_payoffs(label)`, returning the payoff to each player at the outcome + labeled `label`. +- Added `Game.set_outcome_payoffs(label, payoffs)`, setting the payoffs at the outcome labeled + `label`; `payoffs` must be a complete mapping over the game's players, as with + `Game.make_outcome`. +- Added `Game.relabel_outcomes(labels, strict=True)`, simultaneously reassigning the labels of + the game's outcomes, following the same pattern as `Game.relabel_players`/ + `Game.relabel_strategies`. Added `GameRep::RelabelOutcomes` in C++. + +### Changed +- `Game.make_outcome` now returns `None` instead of the `Outcome` created; use the `label` + already passed in, together with `Game.get_outcome_payoffs`/`Game.relabel_outcomes`, to refer + to it afterward. + +### Removed +- `Outcome` has been removed as a public-facing class in pygambit, following `Action`, + `Infoset`, `Player`, `Strategy`, and `Node`: no public method holds or returns a live handle + into a game's internal outcome list any longer. `Game.outcomes` has been removed; use + `Game.get_outcomes()`, `Game.get_outcome_payoffs()`, `Game.set_outcome_payoffs()`, and + `Game.relabel_outcomes()` instead. + +### Fixed +- `GameTableRep`'s constructor (used by `Game.from_arrays`/`Game.from_dict`, which build a + non-sparse table with one outcome per contingency) gave every outcome the same empty label, + violating the invariant that every non-null outcome has a unique, nonempty label. Outcomes + are now labeled `"1"`, `"2"`, and so on, following the existing convention for default + player/strategy labels. ## [17.0.0-alpha.3] - 2026-08-31 diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 06ff96e368..6a66ed49ee 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -14,7 +14,6 @@ Representation of games :toctree: api/ Game - Outcome Node TreeLayout TreeLayoutCoordinates @@ -95,6 +94,8 @@ Transforming game components Game.set_strategies Game.make_outcome Game.make_outcome_null + Game.relabel_outcomes + Game.set_outcome_payoffs Information about the game @@ -110,7 +111,6 @@ Information about the game Game.is_perfect_recall Game.has_perfect_recall Game.players - Game.outcomes Game.min_payoff Game.max_payoff Game.get_min_payoff @@ -121,18 +121,13 @@ Information about the game Game.get_sequences Game.contingencies Game.get_outcome + Game.get_outcomes + Game.get_outcome_payoffs Game.get_payoffs Game.get_subgame_roots Game.get_minimal_subgame Game.get_strategy_unreachable -.. autosummary:: - :toctree: api/ - - Outcome.label - Outcome.number - Outcome.game - .. autosummary:: :toctree: api/ diff --git a/doc/tutorials/02_extensive_form.ipynb b/doc/tutorials/02_extensive_form.ipynb index 9b5767969b..dd3a687cdf 100644 --- a/doc/tutorials/02_extensive_form.ipynb +++ b/doc/tutorials/02_extensive_form.ipynb @@ -158,13 +158,7 @@ "cell_type": "markdown", "id": "382ba37d", "metadata": {}, - "source": [ - "Now that we have the moves of the game defined, we add payoffs.\n", - "\n", - "Payoffs are associated with an `Outcome`; each `Outcome` has a vector of payoffs, one for each player, and optionally an identifying text label.\n", - "\n", - "First we add the outcome associated with the Seller proving themselves trustworthy:" - ] + "source": "Now that we have the moves of the game defined, we add payoffs.\n\nPayoffs are associated with an outcome, identified by a text label; each outcome has a vector of payoffs, one for each player.\n\nFirst we add the outcome associated with the Seller proving themselves trustworthy:" }, { "cell_type": "code", diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 5198d980ce..fab307ac26 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -851,16 +851,7 @@ "id": "a892dc2b", "metadata": {}, "outputs": [], - "source": [ - "for outcome in g.outcomes:\n", - " outcome[\"Alice\"] = outcome[\"Alice\"] * 2\n", - " outcome[\"Bob\"] = outcome[\"Bob\"] * 2\n", - "\n", - "(\n", - " gbt.nash.liap_solve(g.mixed_strategy_profile(), maxregret=1e-1)\n", - " .equilibria[0].max_regret() / (g.max_payoff - g.min_payoff)\n", - ")" - ] + "source": "for label in g.get_outcomes():\n payoffs = g.get_outcome_payoffs(label)\n g.set_outcome_payoffs(label, {player: value * 2 for player, value in payoffs})\n\n(\n gbt.nash.liap_solve(g.mixed_strategy_profile(), maxregret=1e-1)\n .equilibria[0].max_regret() / (g.max_payoff - g.min_payoff)\n)" }, { "cell_type": "markdown", diff --git a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb index ba282c149e..1303f0dad6 100644 --- a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb +++ b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb @@ -198,31 +198,7 @@ } }, "outputs": [], - "source": [ - "def winner(h):\n", - " match h[2:]:\n", - " case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n", - " return \"Alice\" if CARD_VALUE[h[0]] > CARD_VALUE[h[1]] else \"Bob\"\n", - " case (\"Check\", \"Bet\", \"Fold\"):\n", - " return \"Bob\"\n", - " case (\"Bet\", \"Fold\"):\n", - " return \"Alice\"\n", - "\n", - "def pot_size(h):\n", - " match h[2:]:\n", - " case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Fold\") | (\"Bet\", \"Fold\"):\n", - " return 1\n", - " case (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n", - " return 2\n", - "\n", - "for (win, amount), group in g._get_groups(H.plays.by(lambda h: (winner(h), pot_size(h)))).items():\n", - " lose = \"Bob\" if win == \"Alice\" else \"Alice\"\n", - " g.make_outcome(\n", - " selector_for_histories(group), {win: amount, lose: -amount}, f\"{win} wins {amount}\"\n", - " )\n", - "\n", - "print(\"Total outcomes created:\", len(list(g.outcomes)))" - ] + "source": "def winner(h):\n match h[2:]:\n case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n return \"Alice\" if CARD_VALUE[h[0]] > CARD_VALUE[h[1]] else \"Bob\"\n case (\"Check\", \"Bet\", \"Fold\"):\n return \"Bob\"\n case (\"Bet\", \"Fold\"):\n return \"Alice\"\n\ndef pot_size(h):\n match h[2:]:\n case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Fold\") | (\"Bet\", \"Fold\"):\n return 1\n case (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n return 2\n\nfor (win, amount), group in g._get_groups(H.plays.by(lambda h: (winner(h), pot_size(h)))).items():\n lose = \"Bob\" if win == \"Alice\" else \"Alice\"\n g.make_outcome(\n selector_for_histories(group), {win: amount, lose: -amount}, f\"{win} wins {amount}\"\n )\n\nprint(\"Total outcomes created:\", len(g.get_outcomes()))" }, { "cell_type": "markdown", diff --git a/src/games/game.cc b/src/games/game.cc index 67ec8c400b..8621a9c6c4 100644 --- a/src/games/game.cc +++ b/src/games/game.cc @@ -277,6 +277,45 @@ void GameRep::RelabelPlayers(const std::map &p_labels) } } +//------------------------------------------------------------------------ +// GameRep: Outcomes +//------------------------------------------------------------------------ + +void GameRep::RelabelOutcomes(const std::map &p_labels) +{ + // Resolve each key to exactly one outcome of the game. + std::map assignment; + std::set relabeled; + for (const auto &[old_label, new_label] : p_labels) { + GameOutcomeRep *match = nullptr; + for (const auto &outcome : m_outcomes) { + if (outcome->GetLabel() == old_label) { + if (match) { + throw ValueException("Outcome label '" + old_label + "' is ambiguous in this game"); + } + match = outcome.get(); + } + } + if (!match) { + throw ValueException("No outcome with label '" + old_label + "' in this game"); + } + assignment[match] = new_label; + relabeled.insert(match); + } + // Replacement labels must be legal, unique against untouched outcomes, and pairwise distinct + std::set targets; + for (const auto &[outcome, new_label] : assignment) { + CheckOutcomeLabel(new_label, relabeled); + if (!targets.insert(new_label).second) { + throw ValueException("Outcome label '" + new_label + + "' would be duplicated by the relabelling"); + } + } + for (const auto &[outcome, new_label] : assignment) { + outcome->m_label = new_label; + } +} + //======================================================================== // MixedStrategyProfileRep //======================================================================== diff --git a/src/games/game.h b/src/games/game.h index 69726d54fb..73fbdca2d4 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -1473,6 +1473,9 @@ class GameRep : public std::enable_shared_from_this { { throw UndefinedException(); } + /// Reassign outcome labels. Keys of p_labels are current labels; values are their + /// replacements. + void RelabelOutcomes(const std::map &p_labels); //@} /// @name Nodes diff --git a/src/games/gametable.cc b/src/games/gametable.cc index ce07e94883..07a078a730 100644 --- a/src/games/gametable.cc +++ b/src/games/gametable.cc @@ -368,7 +368,8 @@ GameTableRep::GameTableRep(const std::vector &dim, bool p_sparseOutcomes /* else { m_outcomes = std::vector>(m_results.size()); std::generate(m_outcomes.begin(), m_outcomes.end(), [this, outc = 1]() mutable { - return std::make_shared(this, outc++, ""); + const auto number = outc++; + return std::make_shared(this, number, std::to_string(number)); }); std::transform(m_outcomes.begin(), m_outcomes.end(), m_results.begin(), [](const std::shared_ptr &c) { return c.get(); }); diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index aae361a7ba..bfe8f40ff1 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -449,7 +449,7 @@ def check_filters(game: gbt.Game) -> bool: return False if len(game.get_histories(gbt.H.after())) != n_nodes: return False - if n_outcomes is not None and len(game.outcomes) != n_outcomes: + if n_outcomes is not None and len(game.get_outcomes()) != n_outcomes: return False if n_players is not None and len(game.players) != n_players: return False diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 6db7104539..e0140f3402 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -304,6 +304,7 @@ cdef extern from "games/game.h": int NumOutcomes() except + c_GameOutcome GetOutcome(int) except +IndexError Outcomes GetOutcomes() except + + void RelabelOutcomes(stdmap[string, string]) except +ValueError int NumNodes() except + c_GameNode GetRoot() except + diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index 248c8cfa68..ac1665248b 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -191,7 +191,6 @@ class NodeIndexedVector(_LabeledVector): ###################### include "strategy.pxi" -include "outcome.pxi" include "node.pxi" include "hsel.pxi" include "stratspt.pxi" diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 43670aac67..49586ea47b 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -150,9 +150,11 @@ class Game: players = list(g.players) for profile in itertools.product(*(range(s) for s in shape)): contingency = {p: str(i + 1) for p, i in zip(players, profile, strict=True)} - outcome = g._get_outcome_object(contingency) + resolved_outcome = g._get_contingency_outcome(contingency, "from_arrays") for array, player in zip(arrays, players, strict=True): - outcome[player] = array[profile] + resolved_outcome.deref().SetPayoff( + g._resolve_player(player, "from_arrays"), _to_number(array[profile]) + ) g.title = title return g @@ -239,9 +241,11 @@ class Game: players = list(g.players) for profile in itertools.product(*(range(s) for s in shape)): contingency = {p: str(i + 1) for p, i in zip(players, profile, strict=True)} - outcome = g._get_outcome_object(contingency) + resolved_outcome = g._get_contingency_outcome(contingency, "from_dict") for array, player in zip(arrays, players, strict=True): - outcome[player] = array[profile] + resolved_outcome.deref().SetPayoff( + g._resolve_player(player, "from_dict"), _to_number(array[profile]) + ) g.title = title return g @@ -519,10 +523,14 @@ class Game: """The set of players in the game.""" return GamePlayers.wrap(self.game) - @property - def outcomes(self) -> GameOutcomes: - """The set of outcomes in the game.""" - return GameOutcomes.wrap(self.game) + def get_outcomes(self) -> list[str]: + """Returns the labels of the outcomes in the game. + + .. versionadded:: 17.0.0 + """ + return [ + o.deref().GetLabel().decode("utf-8") for o in self.game.deref().GetOutcomes() + ] @property def contingencies(self) -> pygambit.gameiter.Contingencies: @@ -744,7 +752,7 @@ class Game: return {} result: dict = {} for a in infoset_handle.deref().GetActions(): - result[a.deref().GetLabel().decode("utf-8")] = _decode_prob( + result[a.deref().GetLabel().decode("utf-8")] = _decode_number( cython.cast(string, infoset_handle.deref().GetActionProb(a)) ) return result @@ -1162,26 +1170,31 @@ class Game: f"{location.__class__.__name__}" ) resolved_node = self._resolve_node(location, "get_outcome") - return Outcome.wrap( + resolved_outcome: c_GameOutcome = ( cython.cast(Node, resolved_node).node.deref().GetOutcome() - ).label - return self._get_outcome_object(location).label + ) + else: + resolved_outcome = self._get_contingency_outcome(location, "get_outcome") + if resolved_outcome.deref().IsNull(): + return None + return resolved_outcome.deref().GetLabel().decode("utf-8") - def _get_outcome_object(self, contingency: typing.Mapping) -> Outcome: - """Returns the `Outcome` object attached at a pure-strategy - `contingency` in a strategic (table) game. Not part of the public - API; `get_outcome` returns only the label. Used internally by - `from_arrays`/`from_dict`, which need to mutate the outcome's - payoffs directly. + @cython.cfunc + def _get_contingency_outcome( + self, contingency: typing.Mapping, funcname: str + ) -> c_GameOutcome: + """Resolve the outcome attached at a pure-strategy `contingency` in a + strategic (table) game, as a raw C++ handle. Not part of the public API; + used internally by `get_outcome`, `from_arrays`, and `from_dict`. """ if self.game.deref().IsAgg(): raise UndefinedOperationError( - "get_outcome(): operation not defined for games not in " - "strategic (table) representation" + f"{funcname}(): operation not defined for games not in " + f"strategic (table) representation" ) - resolved = self._resolve_contingency(contingency, "get_outcome") + resolved = self._resolve_contingency(contingency, funcname) psp = self._make_pure_strategy_profile(resolved) - return Outcome.wrap(deref(deref(psp).deref()).GetOutcome()) + return deref(deref(psp).deref()).GetOutcome() def get_payoffs(self, contingency: typing.Mapping) -> PayoffVector: """Returns the payoff to each player at a pure-strategy contingency. @@ -1690,6 +1703,35 @@ class Game: f"{funcname}(): player '{player}' has no strategy with label '{label}'" ) + @cython.cfunc + def _resolve_outcome( + self, label: typing.Any, funcname: str, argname: str = "label" + ) -> c_GameOutcome: + """Resolve `label` to the C++ handle of one of the game's outcomes. + + Not part of the public API -- used internally to bridge an outcome label to + the underlying C++ object without ever constructing a Python wrapper for it. + + Raises + ------ + KeyError + If no outcome has label `label`. + TypeError + If `label` is not a `str`. + ValueError + If `label` is an empty string or all spaces. + """ + if not isinstance(label, str): + raise TypeError( + f"{funcname}(): {argname} must be str, not {label.__class__.__name__}" + ) + if not label.strip(): + raise ValueError(f"{funcname}(): {argname} cannot be an empty string or all spaces") + for outcome in self.game.deref().GetOutcomes(): + if outcome.deref().GetLabel().decode("utf-8") == label: + return outcome + raise KeyError(f"{funcname}(): no outcome with label '{label}'") + def _resolve_node(self, node: typing.Any, funcname: str, argname: str = "node") -> Node: """Resolve an attempt to reference a node of the game. A bare `Node` is not accepted -- every public method that reaches this already requires a @@ -2938,10 +2980,42 @@ class Game: self._resolve_contingency(entry, funcname, "location") for entry in entries ] + @cython.cfunc + def _resolve_payoff_mapping(self, payoffs: typing.Mapping, funcname: str) -> dict: + """Validate `payoffs` as a complete mapping from the game's players to payoff + values: every player of the game must appear exactly once. Not part of the + public API; shared by `make_outcome` and `set_outcome_payoffs`. + + Raises + ------ + TypeError + If `payoffs` is not a mapping. + KeyError + If a key of `payoffs` matches no player of the game. + ValueError + If a player appears more than once in `payoffs`, or `payoffs` does not + specify exactly one value for each player of the game. + """ + if not hasattr(payoffs, "items"): + raise TypeError( + f"{funcname}(): payoffs must be a mapping, not {payoffs.__class__.__name__}" + ) + resolved_payoffs = {} + for player, value in payoffs.items(): + self._resolve_player(player, funcname, "payoffs") + if player in resolved_payoffs: + raise ValueError(f"{funcname}(): each player may appear only once in payoffs") + resolved_payoffs[player] = value + if set(resolved_payoffs) != set(self.players): + raise ValueError( + f"{funcname}(): payoffs must be specified for each player of the game" + ) + return resolved_payoffs + def make_outcome(self, location, payoffs: typing.Mapping, - label: str) -> Outcome: + label: str) -> None: """Create an outcome with `payoffs` and `label` and attach it at `location`. For an extensive game, `location` is a `Selector` (an `H`-built @@ -2973,11 +3047,6 @@ class Game: The label of the new outcome; must be nonempty and, after the operation, unique within the game. - Returns - ------- - Outcome - A reference to the newly-created outcome. - Raises ------ TypeError @@ -2991,25 +3060,18 @@ class Game: UndefinedOperationError If the game is in action-graph representation, where outcomes are not represented explicitly. + + See Also + -------- + get_outcome_payoffs : Get the payoffs at an outcome. + set_outcome_payoffs : Set the payoffs at an outcome. + relabel_outcomes : Change the labels of the game's outcomes. """ if self.game.deref().IsAgg(): raise UndefinedOperationError( "make_outcome(): operation not defined for games in action-graph representation" ) - if not hasattr(payoffs, "items"): - raise TypeError( - f"make_outcome(): payoffs must be a mapping, not {payoffs.__class__.__name__}" - ) - resolved_payoffs = {} - for player, value in payoffs.items(): - self._resolve_player(player, "make_outcome", "payoffs") - if player in resolved_payoffs: - raise ValueError("make_outcome(): each player may appear only once in payoffs") - resolved_payoffs[player] = value - if set(resolved_payoffs) != set(self.players): - raise ValueError( - "make_outcome(): payoffs must be specified for each player of the game" - ) + resolved_payoffs = self._resolve_payoff_mapping(payoffs, "make_outcome") c_payoffs = stdvector[c_Number]() for player in self.players: c_payoffs.push_back(_to_number(resolved_payoffs[player])) @@ -3018,9 +3080,8 @@ class Game: c_nodes = stdvector[c_GameNode]() for n in resolved: c_nodes.push_back(cython.cast(Node, n).node) - return Outcome.wrap( - self.game.deref().MakeOutcome(c_nodes, c_payoffs, label.encode("utf-8")) - ) + self.game.deref().MakeOutcome(c_nodes, c_payoffs, label.encode("utf-8")) + return c_contingencies = stdvector[stdvector[c_GameStrategy]]() for contingency in resolved: c_one = stdvector[c_GameStrategy]() @@ -3029,9 +3090,7 @@ class Game: self._resolve_strategy(player, contingency[player], "make_outcome") ) c_contingencies.push_back(c_one) - return Outcome.wrap( - self.game.deref().MakeOutcome(c_contingencies, c_payoffs, label.encode("utf-8")) - ) + self.game.deref().MakeOutcome(c_contingencies, c_payoffs, label.encode("utf-8")) def make_outcome_null(self, location) -> None: """Reset the outcome at `location` to the null outcome. @@ -3091,6 +3150,138 @@ class Game: c_contingencies.push_back(c_one) self.game.deref().MakeOutcomeNull(c_contingencies) + def relabel_outcomes(self, labels: typing.Mapping[str, str], strict: bool = True) -> None: + """Simultaneously reassign the labels of the game's outcomes. + + `labels` maps current outcome labels to their replacements. The reassignment + is simultaneous, so labels can be swapped directly, e.g. ``{"a": "b", "b": "a"}``. + + .. versionadded:: 17.0.0 + + Parameters + ---------- + labels : Mapping[str, str] + A mapping from current outcome labels to replacement labels. + Entries whose key equals their value are ignored. + strict : bool, default True + If `True`, every key of `labels` must be the label of an outcome of the + game, and unknown keys raise ``KeyError``. If `False`, unknown keys are + ignored. + + Raises + ------ + KeyError + When `strict` is `True`, if a key of `labels` matches no outcome of the game. + TypeError + If `labels` is not a mapping, or any key or value is not a string. + ValueError + If a key of `labels` matches more than one outcome; or if any replacement + label is empty, is not a valid label, or would result in a duplicate label. + UndefinedOperationError + If the game is in action-graph representation, where outcomes are not + represented explicitly. + + See Also + -------- + relabel_players : Simultaneously reassign the labels of the game's players. + relabel_strategies : Change the labels of a player's strategies. + """ + if self.game.deref().IsAgg(): + raise UndefinedOperationError( + "relabel_outcomes(): operation not defined for games in " + "action-graph representation" + ) + if not hasattr(labels, "items"): + raise TypeError( + f"relabel_outcomes(): labels must be a mapping, " + f"not {labels.__class__.__name__}" + ) + current = [ + o.deref().GetLabel().decode("utf-8") for o in self.game.deref().GetOutcomes() + ] + remap = _compute_relabeling( + current, labels, "relabel_outcomes", "outcome", strict, "in this game" + ) + if not remap: + return + c_labels = stdmap[string, string]() + for old, new in remap.items(): + c_labels[old.encode("utf-8")] = new.encode("utf-8") + self.game.deref().RelabelOutcomes(c_labels) + + def get_outcome_payoffs(self, label: str) -> PayoffVector: + """Returns the payoff to each player at the outcome labeled `label`. + + .. versionadded:: 17.0.0 + + Parameters + ---------- + label : str + The label of the outcome. + + Returns + ------- + PayoffVector + + Raises + ------ + KeyError + If no outcome has label `label`. + TypeError + If `label` is not a `str`. + ValueError + If `label` is an empty string or all whitespace. + + See Also + -------- + set_outcome_payoffs : Set the payoffs at an outcome. + get_payoffs : Get the payoffs at a pure-strategy contingency. + """ + resolved_outcome: c_GameOutcome = self._resolve_outcome(label, "get_outcome_payoffs") + values = {} + for player in self.players: + resolved_player = self._resolve_player(player, "get_outcome_payoffs") + values[player] = _decode_number(resolved_outcome.deref().GetPayoff[string]( + resolved_player + )) + return PayoffVector(values) + + def set_outcome_payoffs(self, label: str, payoffs: typing.Mapping) -> None: + """Sets the payoff to each player at the outcome labeled `label`. + + .. versionadded:: 17.0.0 + + Parameters + ---------- + label : str + The label of the outcome to modify. + payoffs : Mapping + A complete mapping from the game's players (or their labels) to payoffs. + Every player must be present; zeroes must be given explicitly. + + Raises + ------ + KeyError + If no outcome has label `label`; or if a key of `payoffs` matches no player. + TypeError + If `payoffs` is not a mapping, or `label` is not a `str`. + ValueError + If `label` is an empty string or all whitespace; or if `payoffs` is not a + complete mapping over exactly the game's players. + + See Also + -------- + get_outcome_payoffs : Get the payoffs at an outcome. + make_outcome : Create a new outcome with payoffs. + """ + resolved_outcome: c_GameOutcome = self._resolve_outcome(label, "set_outcome_payoffs") + resolved_payoffs = self._resolve_payoff_mapping(payoffs, "set_outcome_payoffs") + for player in self.players: + resolved_outcome.deref().SetPayoff( + self._resolve_player(player, "set_outcome_payoffs"), + _to_number(resolved_payoffs[player]) + ) + def relabel_strategies(self, player: str, labels: typing.Mapping[str, str], diff --git a/src/pygambit/gamecollections.pxi b/src/pygambit/gamecollections.pxi index 95037821a6..2df4013470 100644 --- a/src/pygambit/gamecollections.pxi +++ b/src/pygambit/gamecollections.pxi @@ -3,8 +3,7 @@ # Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) # # FILE: src/pygambit/gamecollections.pxi -# Cython wrappers for the collections of nodes, subgames, outcomes, and players -# belonging to a game +# Cython wrappers for the collections belonging to a game # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -21,58 +20,6 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # -@cython.cclass -class GameOutcomes: - """Represents the set of outcomes in a game.""" - game = cython.declare(c_Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameOutcomes outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: c_Game) -> GameOutcomes: - obj: GameOutcomes = GameOutcomes.__new__(GameOutcomes) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameOutcomes(game={Game.wrap(self.game)})" - - def __len__(self) -> int: - """The number of outcomes in the game.""" - return self.game.deref().GetOutcomes().size() - - def __iter__(self) -> typing.Iterator[Outcome]: - for outcome in self.game.deref().GetOutcomes(): - yield Outcome.wrap(outcome) - - def __getitem__(self, label: str) -> Outcome: - """Returns the outcome with text label `label`. - - Parameters - ---------- - label : str - The text label of the outcome to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. - - Raises - ------ - KeyError - If no outcome in the game has label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one outcome has label `label`. - TypeError - If `label` is not a string. - - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference an outcome by its label, or iterate - over the collection. String lookup now requires an exact match of the label; - previously, leading/trailing whitespace was stripped from `label` before comparison. - """ - return _resolve_by_label(self, label, "Game", "outcome", "outcomes") - - @cython.cclass class GamePlayers: """The labels of the (personal) players in a game. diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index d88a7bd701..7689cfdaf2 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -31,8 +31,8 @@ well-defined. @cython.cfunc -def _decode_prob(py_string: string) -> object: - """Internal: decode a probability formatted by the C++ core as ``Decimal`` or +def _decode_number(py_string: string) -> object: + """Internal: decode a number formatted by the C++ core as ``Decimal`` or ``Rational``, matching whichever representation was used to specify it.""" if "." in py_string.decode("ascii"): return decimal.Decimal(py_string.decode("ascii")) @@ -244,7 +244,7 @@ class Node: ) result: dict = {} for a in resolved.deref().GetActions(): - result[a.deref().GetLabel().decode("utf-8")] = _decode_prob( + result[a.deref().GetLabel().decode("utf-8")] = _decode_number( cython.cast(string, resolved.deref().GetActionProb(a)) ) return result diff --git a/src/pygambit/outcome.pxi b/src/pygambit/outcome.pxi deleted file mode 100644 index 91703ed817..0000000000 --- a/src/pygambit/outcome.pxi +++ /dev/null @@ -1,160 +0,0 @@ -# -# This file is part of Gambit -# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -# -# FILE: src/pygambit/outcome.pxi -# Cython wrapper for outcomes -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -# -import cython - -import typing - - -@cython.cclass -class Outcome: - """An outcome in a ``Game``.""" - outcome = cython.declare(c_GameOutcome) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create an Outcome outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(outcome: c_GameOutcome) -> Outcome: - obj: Outcome = Outcome.__new__(Outcome) - obj.outcome = outcome - return obj - - def __repr__(self) -> str: - if self.outcome.deref().IsNull(): - return f"Outcome(game={self.game}, label=None)" - if self.label: - return f"Outcome(game={self.game}, label='{self.label}')" - else: - return f"Outcome(game={self.game}, number={self.number})" - - def __eq__(self, other: typing.Any): - if not isinstance(other, Outcome): - return NotImplemented - if (self.outcome.deref().IsNull() - or cython.cast(Outcome, other).outcome.deref().IsNull()): - # Null outcomes are not equal to anything, including themselves (cf. nan). - return False - return self.outcome.deref() == cython.cast(Outcome, other).outcome.deref() - - def __hash__(self) -> int: - return cython.cast(cython.long, self.outcome.deref()) - - def __bool__(self) -> bool: - """``True`` for a real outcome; the null outcome is falsy.""" - return not self.outcome.deref().IsNull() - - @property - def game(self) -> Game: - """Returns the game with which this outcome is associated.""" - return Game.wrap(self.outcome.deref().GetGame()) - - @property - def label(self) -> str | None: - """The text label associated with this outcome. - - The null outcome's label is ``None``; testing ``outcome.label is None`` is the - idiomatic nullity check. - - .. versionchanged:: 16.7.0 - An outcome label must be nonempty and unique within the game; an empty or duplicate - label now raises ``ValueError``. - - .. 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 null outcome resolves with label ``None``. - """ - if self.outcome.deref().IsNull(): - return None - return self.outcome.deref().GetLabel().decode("utf-8") - - @label.setter - def label(self, value: str) -> None: - self.outcome.deref().SetLabel(value.encode("utf-8")) - - @property - def number(self) -> int | None: - """Returns the number of the outcome in the game. - Outcomes are numbered starting with 0. - - The null outcome is not a member of the game's outcomes, so it has no number. - - .. versionchanged:: 17.0.0 - The null outcome resolves here with number ``None``. - """ - if self.outcome.deref().IsNull(): - return None - return self.outcome.deref().GetNumber() - 1 - - def __getitem__( - self, player: str - ) -> decimal.Decimal | Rational: - """The payoff to `player` at the outcome. - - The null outcome reports a zero payoff to every player of its game. - - Raises - ------ - KeyError - If no player of the outcome's game has label `player`. - """ - game: Game = self.game - resolved_player: c_GamePlayer = game._resolve_player(player, "Outcome.__getitem__") - payoff = ( - self.outcome.deref().GetPayoff[string](resolved_player).decode("ascii") - ) - if "." in payoff: - return decimal.Decimal(payoff) - else: - return Rational(payoff) - - def __setitem__(self, player: str, value: typing.Any) -> None: - """Set the payoff to `player` at the outcome. - - Parameters - ---------- - player : str - The label of the player for which to set the payoff. - value : Any - The value of the payoff. This can be any numeric type, or any object that - has a string representation which can be interpreted as a number. - - Raises - ------ - KeyError - If no player of the outcome's game has label `player`. - ValueError - If `value` cannot be interpreted as a number. - UndefinedOperationError - If this is the null outcome; payoffs cannot be set on it. - """ - if self.outcome.deref().IsNull(): - raise UndefinedOperationError( - "Payoffs cannot be set on the null outcome; " - "use Game.make_outcome to create and attach an outcome" - ) - game: Game = self.game - resolved_player: c_GamePlayer = game._resolve_player(player, "Outcome.__setitem__") - self.outcome.deref().SetPayoff(resolved_player, _to_number(value)) diff --git a/tests/games.py b/tests/games.py index ffdbe4565b..9b49eb2d36 100644 --- a/tests/games.py +++ b/tests/games.py @@ -817,7 +817,7 @@ def create_binary_tree(self, g, path, whose_turn, depth, max_depth): if depth == max_depth: g.make_outcome( gbt.H.path(*path), {str(p): 0 for p in self.players}, - f"leaf_{len(list(g.outcomes))}" + f"leaf_{len(g.get_outcomes())}" ) else: current_player = str(whose_turn + 1) diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 869a1a2652..44a36ce747 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -148,7 +148,7 @@ def test_catalog_games_filter_n_outcomes(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.outcomes) == 3 + assert len(g.get_outcomes()) == 3 def test_catalog_games_filter_n_players(all_games): diff --git a/tests/test_file.py b/tests/test_file.py index f79d7db367..e6da363d12 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -20,12 +20,12 @@ def _parse_nfg(text: str) -> gbt.Game: def test_read_efg_empty_outcome_labels_are_normalized(): g = _parse_efg(LEGACY_EFG_HEADER + 't "" 1 "" { 1, -1 }\nt "" 2 "" { 2, -2 }\n') - assert [o.label for o in g.outcomes] == ["_1", "_2"] + assert g.get_outcomes() == ["_1", "_2"] def test_read_efg_repeated_outcome_id_consistent(): g = _parse_efg(LEGACY_EFG_HEADER + 't "" 1 "" { 1, -1 }\nt "" 1 "" { 1, -1 }\n') - assert len(g.outcomes) == 1 + assert len(g.get_outcomes()) == 1 def test_read_efg_empty_action_labels_are_normalized(): diff --git a/tests/test_game.py b/tests/test_game.py index c61fa4862d..6ad07338da 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -99,7 +99,6 @@ def test_from_dict(): COLLECTION_GETTERS = [ pytest.param(lambda g: g.players, id="GamePlayers"), - pytest.param(lambda g: g.outcomes, id="GameOutcomes"), ] diff --git a/tests/test_outcome_mutations.py b/tests/test_outcome_mutations.py index 07e4e11b45..d89417d540 100644 --- a/tests/test_outcome_mutations.py +++ b/tests/test_outcome_mutations.py @@ -6,21 +6,22 @@ def test_make_outcome_attaches_to_all_given_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - outcome = game.make_outcome( + game.make_outcome( gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1, "Bob": -1}, "shared" ) - assert game.get_outcome(gbt.H.path("U")) == outcome.label - assert game.get_outcome(gbt.H.path("M")) == outcome.label + assert game.get_outcome(gbt.H.path("U")) == "shared" + assert game.get_outcome(gbt.H.path("M")) == "shared" assert game.get_outcome(gbt.H.path("D")) is None - assert outcome["Alice"] == 1 - assert outcome["Bob"] == -1 + payoffs = game.get_outcome_payoffs("shared") + assert payoffs["Alice"] == 1 + assert payoffs["Bob"] == -1 def test_make_outcome_accepts_selector(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - outcome = game.make_outcome(gbt.H.path("U"), {"Alice": 1, "Bob": -1}, "shared") - assert game.get_outcome(gbt.H.path("U")) == outcome.label + game.make_outcome(gbt.H.path("U"), {"Alice": 1, "Bob": -1}, "shared") + assert game.get_outcome(gbt.H.path("U")) == "shared" assert game.get_outcome(gbt.H.path("M")) is None assert game.get_outcome(gbt.H.path("D")) is None @@ -28,10 +29,10 @@ def test_make_outcome_accepts_selector(): def test_make_outcome_accepts_selector_matching_several_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - outcome = game.make_outcome(gbt.H.plays, {"Alice": 1, "Bob": -1}, "shared") - assert game.get_outcome(gbt.H.path("U")) == outcome.label - assert game.get_outcome(gbt.H.path("M")) == outcome.label - assert game.get_outcome(gbt.H.path("D")) == outcome.label + game.make_outcome(gbt.H.plays, {"Alice": 1, "Bob": -1}, "shared") + assert game.get_outcome(gbt.H.path("U")) == "shared" + assert game.get_outcome(gbt.H.path("M")) == "shared" + assert game.get_outcome(gbt.H.path("D")) == "shared" def test_make_outcome_accepts_grouped_selector_pooled(): @@ -39,12 +40,12 @@ def test_make_outcome_accepts_grouped_selector_pooled(): receives the same outcome, regardless of grouping.""" game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - outcome = game.make_outcome( + game.make_outcome( gbt.H.path(...).by(lambda h: h[0]), {"Alice": 1, "Bob": -1}, "shared" ) - assert game.get_outcome(gbt.H.path("U")) == outcome.label - assert game.get_outcome(gbt.H.path("M")) == outcome.label - assert game.get_outcome(gbt.H.path("D")) == outcome.label + assert game.get_outcome(gbt.H.path("U")) == "shared" + assert game.get_outcome(gbt.H.path("M")) == "shared" + assert game.get_outcome(gbt.H.path("D")) == "shared" def test_make_outcome_error_location_not_a_selector(): @@ -57,13 +58,13 @@ def test_make_outcome_error_location_not_a_selector(): def test_make_outcome_attaches_at_contingencies(): game = gbt.Game.new_table([2, 2]) - outcome = game.make_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.label - assert game.get_outcome({"1": "2", "2": "2"}) == outcome.label + assert game.get_outcome({"1": "1", "2": "1"}) == "diagonal" + assert game.get_outcome({"1": "2", "2": "2"}) == "diagonal" assert not game.get_outcome({"1": "1", "2": "2"}) - assert outcome["1"] == 2 + assert game.get_outcome_payoffs("diagonal")["1"] == 2 def test_make_outcome_absorbs_fully_covered_outcome_and_reuses_label(): @@ -71,7 +72,8 @@ def test_make_outcome_absorbs_fully_covered_outcome_and_reuses_label(): game.append_move(gbt.H.path(), "Alice", ["U", "D"]) game.make_outcome(gbt.H.path("U"), {"Alice": 1}, "w") game.make_outcome(gbt.H.path(...), {"Alice": 2}, "w") - assert [(o.label, o["Alice"]) for o in game.outcomes] == [("w", 2)] + assert game.get_outcomes() == ["w"] + assert game.get_outcome_payoffs("w")["Alice"] == 2 def test_make_outcome_label_of_partially_covered_outcome_refused(): @@ -80,7 +82,7 @@ def test_make_outcome_label_of_partially_covered_outcome_refused(): game.make_outcome(gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1}, "w") with pytest.raises(ValueError): game.make_outcome(gbt.H.path("D"), {"Alice": 2}, "w") - assert len(game.outcomes) == 1 + assert len(game.get_outcomes()) == 1 @pytest.mark.parametrize("bad_label", ["", "win"]) @@ -90,7 +92,7 @@ def test_make_outcome_bad_label_raises_and_leaves_game_unchanged(bad_label: str) game.make_outcome(gbt.H.path("win"), {"A": 1, "B": 2}, "win") with pytest.raises(ValueError): game.make_outcome(gbt.H.path("lose"), {"A": 3, "B": 4}, bad_label) - assert [o.label for o in game.outcomes] == ["win"] + assert game.get_outcomes() == ["win"] def test_make_outcome_incomplete_payoffs_raises(): @@ -155,30 +157,30 @@ def test_make_outcome_null_resets_given_contingencies_to_null(): def test_make_outcome_null_removes_fully_orphaned_outcome(): game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) - outcome_count = len(game.outcomes) + outcome_count = len(game.get_outcomes()) p1, p2 = game.players s1 = next(iter(game.get_strategies(p1))) s2 = next(iter(game.get_strategies(p2))) game.make_outcome_null({p1: s1, p2: s2}) - assert len(game.outcomes) == outcome_count - 1 + assert len(game.get_outcomes()) == outcome_count - 1 def test_make_outcome_null_keeps_partially_referenced_outcome(): game = gbt.Game.new_tree(["Alice"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) game.make_outcome(gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1}, "shared") - outcome_count = len(game.outcomes) + outcome_count = len(game.get_outcomes()) game.make_outcome_null(gbt.H.path("U")) - assert len(game.outcomes) == outcome_count + assert len(game.get_outcomes()) == outcome_count assert game.get_outcome(gbt.H.path("M")) is not None def test_make_outcome_null_on_already_null_node_is_a_no_op(): game = gbt.Game.new_tree(["Alice"]) game.append_move(gbt.H.path(), "Alice", ["U", "D"]) - outcome_count = len(game.outcomes) + outcome_count = len(game.get_outcomes()) game.make_outcome_null(gbt.H.path("U")) - assert outcome_count == len(game.outcomes) + assert outcome_count == len(game.get_outcomes()) assert game.get_outcome(gbt.H.path("U")) is None @@ -186,7 +188,7 @@ def test_outcome_relabel_duplicate_rejected_and_label_unchanged(): game = gbt.Game.new_tree(players=["A", "B"]) game.append_move(gbt.H.path(), "A", ["win", "lose"]) game.make_outcome(gbt.H.path("win"), {"A": 1, "B": 2}, "win") - outcome = game.make_outcome(gbt.H.path("lose"), {"A": 0, "B": 0}, "lose") + game.make_outcome(gbt.H.path("lose"), {"A": 0, "B": 0}, "lose") with pytest.raises(ValueError): - outcome.label = "win" - assert outcome.label == "lose" + game.relabel_outcomes({"lose": "win"}) + assert set(game.get_outcomes()) == {"win", "lose"} diff --git a/tests/test_outcome_queries.py b/tests/test_outcome_queries.py index 0760a33e45..5b19164386 100644 --- a/tests/test_outcome_queries.py +++ b/tests/test_outcome_queries.py @@ -5,68 +5,80 @@ from . import games +def _labeled_table_game() -> gbt.Game: + """A 2x2 table game whose two outcomes are each given a distinct, nonempty + label at creation, so they can be addressed by `relabel_outcomes`/ + `get_outcome_payoffs`/`set_outcome_payoffs`.""" + game = gbt.Game.new_table([2, 2]) + game.make_outcome({"1": "1", "2": "1"}, {"1": 0, "2": 0}, "o1") + game.make_outcome([{"1": "1", "2": "2"}, {"1": "2", "2": "1"}, {"1": "2", "2": "2"}], + {"1": 0, "2": 0}, "o2") + return game + + @pytest.mark.parametrize("label", games.VALID_LABELS) -def test_outcome_label(label: str): - game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) - outcome = next(iter(game.outcomes)) - outcome.label = label - assert outcome.label == label +def test_outcome_relabel(label: str): + game = _labeled_table_game() + game.relabel_outcomes({"o1": label}) + assert label in game.get_outcomes() @pytest.mark.parametrize("label", games.INVALID_LABELS) -def test_outcome_label_invalid_raises_valueerror(label: str): - game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) - outcome = next(iter(game.outcomes)) +def test_outcome_relabel_invalid_raises_valueerror(label: str): + game = _labeled_table_game() with pytest.raises(ValueError): - outcome.label = label + game.relabel_outcomes({"o1": label}) @pytest.mark.parametrize("label", games.UNICODE_LABELS) -def test_outcome_label_unicode_accepted(label: str): +def test_outcome_relabel_unicode_accepted(label: str): """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" - game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) - outcome = next(iter(game.outcomes)) - outcome.label = label - assert outcome.label == label - - -@pytest.mark.parametrize( - "game,label", - [(gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]), "outcome label")] -) -def test_outcome_index_label(game: gbt.Game, label: str): - outcome = next(iter(game.outcomes)) - outcome.label = label - assert outcome == game.outcomes[label] - assert game.outcomes[label].label == label + game = _labeled_table_game() + game.relabel_outcomes({"o1": label}) + assert label in game.get_outcomes() @pytest.mark.parametrize( "game", [gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]])] ) -def test_outcome_index_unmatched_label(game: gbt.Game): +def test_outcome_payoffs_unmatched_label_raises_keyerror(game: gbt.Game): with pytest.raises(KeyError): - _ = game.outcomes["not an outcome"] + _ = game.get_outcome_payoffs("not an outcome") @pytest.mark.parametrize( "game", [gbt.Game.new_table([2, 2])] ) -def test_outcome_index_invalid_type(game: gbt.Game): +def test_outcome_payoffs_invalid_label_type_raises_typeerror(game: gbt.Game): with pytest.raises(TypeError): - _ = game.outcomes[1.3] + _ = game.get_outcome_payoffs(1.3) + + +@pytest.mark.parametrize( + "game", + [ + gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]), + gbt.Game.from_dict({"a": [[0, 0], [0, 0]], "b": [[0, 0], [0, 0]]}), + ], + ids=["from_arrays", "from_dict"], +) +def test_outcomes_have_unique_nonempty_labels_at_construction(game: gbt.Game): + """`from_arrays`/`from_dict` eagerly create one outcome per contingency + (unlike the sparse-by-default `Game.new_table`); each of those must still + satisfy the invariant that every non-null outcome has a unique, nonempty + label.""" + labels = game.get_outcomes() + assert len(labels) == 4 + assert all(label for label in labels) + assert len(set(labels)) == len(labels) def test_outcome_payoff_by_player_label(): - game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) - pl1, pl2 = list(game.players) - game.relabel_players({pl1: "joe", pl2: "dan"}) - out1, out2, *_ = list(game.outcomes) - out1["joe"] = 1 - out1["dan"] = 2 - out2["joe"] = 3 - out2["dan"] = 4 - assert out1["joe"] == 1 - assert out1["dan"] == 2 - assert out2["joe"] == 3 - assert out2["dan"] == 4 + game = _labeled_table_game() + game.relabel_players({"1": "joe", "2": "dan"}) + game.set_outcome_payoffs("o1", {"joe": 1, "dan": 2}) + game.set_outcome_payoffs("o2", {"joe": 3, "dan": 4}) + assert game.get_outcome_payoffs("o1")["joe"] == 1 + assert game.get_outcome_payoffs("o1")["dan"] == 2 + assert game.get_outcome_payoffs("o2")["joe"] == 3 + assert game.get_outcome_payoffs("o2")["dan"] == 4 diff --git a/tests/test_players.py b/tests/test_players.py index a4fc648e31..b678d46844 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -458,7 +458,7 @@ def test_set_players_add_then_drop_round_trips(): game = gbt.Game.from_arrays([[1, 2], [3, 4]], [[5, 6], [7, 8]]) labels = list(game.players) game.set_players(labels + ["X"]) - assert all(outcome["X"] == 0 for outcome in game.outcomes) + assert all(game.get_payoffs(c)["X"] == 0 for c in game.contingencies) game.set_players(labels, drop=True) assert list(game.players) == labels assert game.to_arrays()[0].tolist() == [[1, 2], [3, 4]] diff --git a/tests/test_profile_invalidation.py b/tests/test_profile_invalidation.py index a0ffb77351..dfad65bfc3 100644 --- a/tests/test_profile_invalidation.py +++ b/tests/test_profile_invalidation.py @@ -10,7 +10,11 @@ def test_mixed_strategy_profile_game_structure_changed_no_tree(): profiles = [game.mixed_strategy_profile(rational=b) for b in [False, True]] player = next(iter(game.players)) distribution = {s: 0 for s in game.get_strategies(player)} - next(iter(game.outcomes))[player] = 3 + game.make_outcome( + {p: "1" for p in game.players}, + {p: (3 if p == player else 0) for p in game.players}, + "trigger", + ) for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): profile.copy()