diff --git a/ChangeLog b/ChangeLog index eff2de8ec..67e40760a 100644 --- a/ChangeLog +++ b/ChangeLog @@ -35,6 +35,9 @@ 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. +- Added `Game.get_strategies(player)`, `Game.get_sequences(player)`, `Game.get_min_payoff(player)`, + and `Game.get_max_payoff(player)`, replacing `Player.strategies`/`.sequences`/`.min_payoff`/ + `.max_payoff` now that `Player` has been removed. ### Changed - `Infoset` is now a lazy, node-anchored view (like `Node.player`/`Node.outcome`), constructed @@ -53,6 +56,19 @@ - `Game.behavior_support_profile`'s `actions` filter callable is now called as `actions(node, action)` (two positional arguments, where `node` is a representative node of the information set and `action` is the action's label) rather than with a single `Action`. +- `Node.player` now returns the player's label (`str`), or `None` at a terminal node, directly + rather than a lazy `NodePlayer` proxy object. `Infoset.player`/`Event.player`/`Sequence.player` + now likewise return a label (`str`) rather than a `Player` object. +- `StrategySupport.player`, `BehaviorSupport.player`, `MixedStrategy.player`, and + `MixedBehavior.player` now return a label (`str`) rather than a `Player` object. +- `Game.players` now iterates player labels (`str`) rather than `Player` objects; indexing by + label is no longer supported (a label is already in hand once iterated) -- use `in` to test + membership. +- `Game.append_move`, `Game.insert_move`, `Game.reveal`, `Game.relabel_strategies`, and + `Game.set_strategies` now take `player` as a label (`str`) rather than accepting a `Player` + object. +- `Game.strategy_support_profile`'s `strategies` filter callable's `player` argument is now a + label (`str`) rather than a `Player` object. - Command-line tools are now implemented in `pygambit` rather than C++-based compiled programs. The `gambit-convert` utility has been removed. - Refreshed GUI icons and standardised dialog layout and formatting @@ -78,11 +94,16 @@ (`pip install gtdraw`) to run tutorials locally or build the documentation. - `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`. +- Removed `Player`. Players are now identified purely by label (`str`), as returned by iterating + `Game.players`. `Player.strategies`/`.sequences`/`.min_payoff`/`.max_payoff` are replaced by + `Game.get_strategies`/`Game.get_sequences`/`Game.get_min_payoff`/`Game.get_max_payoff`; + `Player.number` had no remaining use once labels are unambiguous (recover via + `list(game.players).index(label)` if truly needed); `Player.is_chance` and `Game.players.chance` + are removed outright, with no replacement -- test `Node.event`/`Node.infoset` truthiness + instead. `GamePlayerRep` in the C++ core is unaffected by this change (a separate, later piece + of work). - `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. diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index cc9b45bf5..e8ef2c8a6 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -14,7 +14,6 @@ Representation of games :toctree: api/ Game - Player Outcome Node Infoset @@ -107,9 +106,13 @@ Information about the game Game.outcomes Game.min_payoff Game.max_payoff + Game.get_min_payoff + Game.get_max_payoff Game.root Game.get_infosets Game.get_events + Game.get_strategies + Game.get_sequences Game.nodes Game.contingencies Game.get_outcome @@ -117,18 +120,6 @@ Information about the game Game.subgames Game.minimal_subgame -.. autosummary:: - :toctree: api/ - - Player.label - Player.number - Player.game - Player.strategies - Player.is_chance - Player.min_payoff - Player.max_payoff - Player.sequences - .. autosummary:: :toctree: api/ diff --git a/doc/tutorials/01_quickstart.ipynb b/doc/tutorials/01_quickstart.ipynb index 0a4e6894e..da7e16aa3 100644 --- a/doc/tutorials/01_quickstart.ipynb +++ b/doc/tutorials/01_quickstart.ipynb @@ -76,7 +76,7 @@ "id": "9d8203e8", "metadata": {}, "outputs": [], - "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\"})" + "source": "tom, jerry = g.players\ng.relabel_players({tom: \"Tom\", jerry: \"Jerry\"})\n\nfor player in g.players:\n cooperate, defect = g.get_strategies(player)\n g.relabel_strategies(player, {cooperate: \"Cooperate\", defect: \"Defect\"})" }, { "cell_type": "markdown", @@ -268,15 +268,7 @@ "id": "980bf6b1", "metadata": {}, "outputs": [], - "source": [ - "payoffs = msp.payoffs\n", - "for player in g.players:\n", - " print(f\"{player.label} plays the equilibrium strategy:\")\n", - " print(f\"Probability of cooperating: {msp[player.label]['Cooperate']}\")\n", - " print(f\"Probability of defecting: {msp[player.label]['Defect']}\")\n", - " print(f\"Payoff: {payoffs[player.label]}\")\n", - " print()" - ] + "source": "payoffs = msp.payoffs\nfor player in g.players:\n print(f\"{player} plays the equilibrium strategy:\")\n print(f\"Probability of cooperating: {msp[player]['Cooperate']}\")\n print(f\"Probability of defecting: {msp[player]['Defect']}\")\n print(f\"Payoff: {payoffs[player]}\")\n print()" }, { "cell_type": "markdown", diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 58e6ca5cb..0c3b3c81f 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -82,9 +82,7 @@ "cell_type": "markdown", "id": "d9796238", "metadata": {}, - "source": [ - "In addition to the two named players, Gambit also instantiates a chance player." - ] + "source": "In addition to the two named players, Gambit also instantiates a chance player internally to represent moves of chance. It isn't exposed as a value you can access directly -- `Game.players` lists only the personal players; chance moves are identified via `Node.event` instead (see below)." }, { "cell_type": "code", @@ -92,11 +90,7 @@ "id": "841f9f74", "metadata": {}, "outputs": [], - "source": [ - "print(g.players[\"Alice\"])\n", - "print(g.players[\"Bob\"])\n", - "print(g.players.chance)" - ] + "source": "list(g.players)" }, { "cell_type": "markdown", @@ -538,7 +532,7 @@ "id": "d4ecff88", "metadata": {}, "outputs": [], - "source": "list(g.players[\"Alice\"].strategies)" + "source": "g.get_strategies(\"Alice\")" }, { "cell_type": "markdown", @@ -597,7 +591,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}: {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()" + "source": "gnm_payoffs = gnm_eqm.payoffs\ngnm_strategy_values = gnm_eqm.strategy_values\nfor player in g.players:\n print(\n f\"{player}'s expected payoffs playing:\"\n )\n for strategy in g.get_strategies(player):\n print(\n f\"Strategy {strategy}: {gnm_strategy_values[player][strategy]:.4f}\"\n )\n print(\n f\"{player}'s overall expected payoff: {gnm_payoffs[player]:.4f}\"\n )\n print()" }, { "cell_type": "markdown", @@ -618,23 +612,7 @@ "id": "d18a91f0", "metadata": {}, "outputs": [], - "source": [ - "for player in g.players:\n", - " print(\n", - " f\"{player.label}'s expected payoffs:\"\n", - " )\n", - " gnm_action_values = gnm_eqm.as_behavior().action_values\n", - " lcp_action_values = eqm.action_values\n", - " for node in g.get_infosets(player.label):\n", - " for action in node.actions:\n", - " print(\n", - " f\"At information set {node.infoset.number}, \"\n", - " f\"when playing {action} - \"\n", - " f\"gnm: {gnm_action_values[node][action]:.4f}\"\n", - " f\", lcp: {str(lcp_action_values[node][action])}\"\n", - " )\n", - " print()" - ] + "source": "for player in g.players:\n print(\n f\"{player}'s expected payoffs:\"\n )\n gnm_action_values = gnm_eqm.as_behavior().action_values\n lcp_action_values = eqm.action_values\n for node in g.get_infosets(player):\n for action in node.actions:\n print(\n f\"At information set {node.infoset.number}, \"\n f\"when playing {action} - \"\n f\"gnm: {gnm_action_values[node][action]:.4f}\"\n f\", lcp: {str(lcp_action_values[node][action])}\"\n )\n print()" }, { "cell_type": "markdown", diff --git a/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb b/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb index 5ac1957ab..cee1fec17 100644 --- a/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb +++ b/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb @@ -84,12 +84,7 @@ "id": "6e3e9303-453a-4bac-a449-fa8fda2ba5ec", "metadata": {}, "outputs": [], - "source": [ - "eq = pure_Nash_equilibria[0]\n", - "for behavior in eq.as_behavior():\n", - " for infoset, probs in behavior:\n", - " print(infoset.player.label, \"infoset:\", infoset.number, \"behavior probabilities:\", probs)" - ] + "source": "eq = pure_Nash_equilibria[0]\nfor behavior in eq.as_behavior():\n for infoset, probs in behavior:\n print(infoset.player, \"infoset:\", infoset.number, \"behavior probabilities:\", probs)" }, { "cell_type": "markdown", diff --git a/doc/tutorials/interoperability_tutorials/gamut.ipynb b/doc/tutorials/interoperability_tutorials/gamut.ipynb index 7052f6735..be8088061 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\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(\n g_chicken.get_strategies(player), [\"Swerve\", \"Straight\"], strict=True\n )}\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 a97e86cf2..0b8e7ea64 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: 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(\n gbt_matrix_rps_game.get_strategies(player), names, strict=True\n )}\n gbt_matrix_rps_game.relabel_strategies(player, labels)\n\ngbt_matrix_rps_game" }, { "cell_type": "markdown", @@ -343,7 +343,7 @@ "id": "fcd42af0", "metadata": {}, "outputs": [], - "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)" + "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 gbt_prisoners_dilemma_game.get_strategies(p1),\n gbt_prisoners_dilemma_game.get_strategies(p2),\n p1_payoffs,\n p2_payoffs\n)" }, { "cell_type": "markdown", diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index b27ab1189..8ad83db2f 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -233,7 +233,7 @@ class MixedBehavior: and can no longer be assigned into. Set a player's whole behavior via ``MixedBehaviorProfile.__setitem__`` instead. """ - _player = cython.declare(Player) + _player = cython.declare(str) _values = cython.declare(dict) def __init__(self, *args, **kwargs) -> None: @@ -241,15 +241,15 @@ class MixedBehavior: @staticmethod @cython.cfunc - def wrap(player: Player, values: dict) -> MixedBehavior: + def wrap(player: str, values: dict) -> MixedBehavior: obj: MixedBehavior = MixedBehavior.__new__(MixedBehavior) obj._player = player obj._values = values return obj @property - def player(self) -> Player: - """The player for whom this mixed behavior strategy is defined.""" + def player(self) -> str: + """The label of the player for whom this mixed behavior strategy is defined.""" return self._player def __repr__(self) -> str: @@ -316,7 +316,7 @@ class MixedBehavior: infoset = cython.cast(Infoset, index.infoset) if not infoset: raise ValueError("node is terminal, has no information set") - if infoset.player != self.player: + if infoset.player != self._player: raise MismatchError("node must belong to this player") return self._values[infoset] @@ -348,14 +348,14 @@ class MixedBehaviorProfile: raise ValueError("Cannot create a MixedBehaviorProfile outside a Game.") def __repr__(self) -> str: - return str({player.label: self[player.label] for player in self.game.players}) + return str({player: self[player] for player in self.game.players}) def _repr_latex_(self) -> str: return ( r"$\left\{" + ",".join( - r"\text{" + player.label + "}:" + - self[player.label]._repr_latex_().replace("$", "") + r"\text{" + player + "}:" + + self[player]._repr_latex_().replace("$", "") for player in self.game.players ) + r"\right\}$" @@ -381,7 +381,7 @@ class MixedBehaviorProfile: The player's mixed behavior specified in the profile """ for player in self.game.players: - yield self[player.label] + yield self[player] def __getitem__(self, index: typing.Any) -> MixedBehavior | MixedAction: """Access a component of the mixed behavior profile specified by `index`. @@ -412,12 +412,11 @@ class MixedBehaviorProfile: if isinstance(index, Node): return self._mixed_action_at(self._resolve_infoset_for_node(index)) if isinstance(index, str): - resolved_player = self.game._resolve_player(index, "__getitem__") values = { node.infoset: self._mixed_action_at(node.infoset) - for node in self.game.get_infosets(resolved_player.label) + for node in self.game.get_infosets(index) } - return MixedBehavior.wrap(resolved_player, values) + return MixedBehavior.wrap(index, values) raise TypeError( f"profile index must be str or Node, not {index.__class__.__name__}" ) @@ -448,7 +447,7 @@ class MixedBehaviorProfile: def _all_infosets(self) -> typing.Iterator[Infoset]: """Iterates over every information set and event in the game.""" for player in self.game.players: - for node in self.game.get_infosets(player.label): + for node in self.game.get_infosets(player): yield node.infoset for node in self.game.get_events(): yield node.event @@ -458,7 +457,7 @@ class MixedBehaviorProfile: player, excluding the chance player's. """ for player in self.game.players: - for node in self.game.get_infosets(player.label): + for node in self.game.get_infosets(player): yield node.infoset @cython.cfunc @@ -643,7 +642,7 @@ class MixedBehaviorProfile: well-defined payoff; ``self.game.players`` already excludes it. """ self._check_validity() - return PayoffVector({p.label: self._payoff(p) for p in self.game.players}) + return PayoffVector({p: self._payoff(p) for p in self.game.players}) @property def node_values(self) -> NodeValuesVector: @@ -652,7 +651,7 @@ class MixedBehaviorProfile: """ self._check_validity() return NodeValuesVector({ - p.label: NodeValueVector({n: self._node_value(p, n) for n in self.game.nodes}) + p: NodeValueVector({n: self._node_value(p, n) for n in self.game.nodes}) for p in self.game.players }) @@ -968,8 +967,9 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): # normalized is a fraction-form string (e.g. "1/2"), which float() rejects return float(Rational(normalized)) - def _payoff(self, player: Player) -> float: - return deref(self.profile).GetPayoff(player.player) + def _payoff(self, player: str) -> float: + game: Game = cython.cast(Game, self.game) + return deref(self.profile).GetPayoff(game._resolve_player(player, "_payoff")) def _belief(self, node: Node) -> float: cdef optional[double] value = deref(self.profile).GetBeliefProb(node.node) @@ -989,8 +989,10 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): return value.value() return None - def _node_value(self, player: Player, node: Node) -> float: - return deref(self.profile).GetPayoff(player.player, node.node) + def _node_value(self, player: str, node: Node) -> float: + game: Game = cython.cast(Game, self.game) + resolved_player = game._resolve_player(player, "_node_value") + return deref(self.profile).GetPayoff(resolved_player, node.node) @cython.cfunc def _action_value(self, action: c_GameAction) -> object: @@ -1098,8 +1100,9 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _to_prob(self, value: typing.Any) -> Rational: return Rational(_to_number_string(value)) - def _payoff(self, player: Player) -> Rational: - return rat_to_py(deref(self.profile).GetPayoff(player.player)) + def _payoff(self, player: str) -> Rational: + game: Game = cython.cast(Game, self.game) + return rat_to_py(deref(self.profile).GetPayoff(game._resolve_player(player, "_payoff"))) def _belief(self, node: Node) -> Rational: cdef optional[c_Rational] value = deref(self.profile).GetBeliefProb(node.node) @@ -1119,8 +1122,10 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): return rat_to_py(value.value()) return None - def _node_value(self, player: Player, node: Node) -> Rational: - return rat_to_py(deref(self.profile).GetPayoff(player.player, node.node)) + def _node_value(self, player: str, node: Node) -> Rational: + game: Game = cython.cast(Game, self.game) + resolved_player = game._resolve_player(player, "_node_value") + return rat_to_py(deref(self.profile).GetPayoff(resolved_player, node.node)) @cython.cfunc def _action_value(self, action: c_GameAction) -> object: @@ -1161,7 +1166,7 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _as_float(self) -> MixedBehaviorProfileDouble: profile: MixedBehaviorProfileDouble = self.game.mixed_behavior_profile() for player in self.game.players: - for node in self.game.get_infosets(player.label): + for node in self.game.get_infosets(player): infoset = node.infoset profile._setprob_infoset( infoset, diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi index 3ddb517c8..8979984bd 100644 --- a/src/pygambit/behavspt.pxi +++ b/src/pygambit/behavspt.pxi @@ -78,7 +78,7 @@ class BehaviorSupport: does not reflect later changes to the profile. The player is accessible via `player`. """ - _player = cython.declare(Player) + _player = cython.declare(str) _values = cython.declare(dict) def __init__(self, *args, **kwargs) -> None: @@ -86,15 +86,15 @@ class BehaviorSupport: @staticmethod @cython.cfunc - def wrap(player: Player, values: dict) -> BehaviorSupport: + def wrap(player: str, values: dict) -> BehaviorSupport: obj: BehaviorSupport = BehaviorSupport.__new__(BehaviorSupport) obj._player = player obj._values = values return obj @property - def player(self) -> Player: - """The player for whom this behavior support is defined.""" + def player(self) -> str: + """The label of the player for whom this behavior support is defined.""" return self._player def __repr__(self) -> str: @@ -120,8 +120,7 @@ class BehaviorSupport: support : ActionSupport The support at an information set belonging to the player """ - for node in self.player.game.get_infosets(self.player.label): - yield self[node.infoset] + yield from self._values.values() def __getitem__(self, infoset: Infoset) -> ActionSupport: """Returns the action support at `infoset`. @@ -136,7 +135,7 @@ class BehaviorSupport: MismatchError If `infoset` does not belong to this player. """ - if infoset.player != self.player: + if infoset.player != self._player: raise MismatchError("infoset must belong to this player") return self._values[infoset] @@ -186,7 +185,7 @@ class BehaviorSupportProfile: The player's behavior support specified in the profile """ for player in self.game.players: - yield self[player.label] + yield self[player] def __getitem__(self, index: typing.Any) -> BehaviorSupport | ActionSupport: """Access a component of the support profile specified by `index`. @@ -220,12 +219,11 @@ class BehaviorSupportProfile: raise MismatchError("infoset must be part of the same game") return self._action_support_at(resolved_infoset) if isinstance(index, str): - resolved_player: Player = self.game.players[index] values = { node.infoset: self._action_support_at(node.infoset) - for node in self.game.get_infosets(resolved_player.label) + for node in self.game.get_infosets(index) } - return BehaviorSupport.wrap(resolved_player, values) + return BehaviorSupport.wrap(index, values) raise TypeError( f"profile index must be str, Node, or Infoset, not {index.__class__.__name__}" ) diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 7ea19caa6..2f70acdd5 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -423,7 +423,7 @@ def check_filters(game: gbt.Game) -> bool: n_game_actions = sum( len(node.infoset.actions) for player in game.players - for node in game.get_infosets(player.label) + for node in game.get_infosets(player) ) if n_game_actions != n_actions: return False @@ -432,7 +432,7 @@ def check_filters(game: gbt.Game) -> bool: if n_infosets is not None: if not game.is_tree: return False - if sum(len(game.get_infosets(p.label)) for p in game.players) != n_infosets: + if sum(len(game.get_infosets(p)) for p in game.players) != n_infosets: return False if is_const_sum is not None and game.is_const_sum != is_const_sum: return False @@ -453,7 +453,7 @@ def check_filters(game: gbt.Game) -> bool: return False if n_players is not None and len(game.players) != n_players: return False - total_strategies = sum(len(list(p.strategies)) for p in game.players) + total_strategies = sum(len(game.get_strategies(p)) for p in game.players) return not (n_strategies is not None and total_strategies != n_strategies) def append_record( diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index d71dd8f54..4e331a6d6 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -204,12 +204,12 @@ def render_profile_csv( values = [ prob for player in profile.game.players - for _infoset, action in profile[player.label] + for _infoset, action in profile[player] for _label, prob in action ] else: values = [ - prob for player in profile.game.players for _label, prob in profile[player.label] + prob for player in profile.game.players for _label, prob in profile[player] ] return ",".join([label, *(format_value(v, decimals, fixed, as_float) for v in values)]) @@ -230,13 +230,13 @@ def render_support_csv( for action in action_support.infoset.actions ) for player in support.game.players - for action_support in support[player.label] + for action_support in support[player] ] else: fields = [ "".join( - "1" if strategy in support[player.label] else "0" - for strategy in player.strategies + "1" if strategy in support[player] else "0" + for strategy in support.game.get_strategies(player) ) for player in support.game.players ] @@ -264,13 +264,13 @@ def _name_or_number(obj) -> str: def _render_strategy_detail(profile: gbt.MixedStrategyProfile, decimals: int) -> str: lines = [] - for player in profile.game.players: - lines.append(f"Strategy profile for player {player.number + 1}:") + for number, player in enumerate(profile.game.players, start=1): + lines.append(f"Strategy profile for player {number}:") lines.append("Strategy Prob Value") lines.append("-------- ----------- -----------") - probs = profile[player.label] - values = profile.strategy_values[player.label] - for strategy in player.strategies: + probs = profile[player] + values = profile.strategy_values[player] + for strategy in profile.game.get_strategies(player): prob = format_value(probs[strategy], decimals) value = format_value(values[strategy], decimals) lines.append(f"{strategy:>8} {prob:>10} {value:>11}") @@ -282,11 +282,11 @@ def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> action_values = profile.action_values beliefs = profile.beliefs realiz_probs = profile.realiz_probs - for player in profile.game.players: - lines.append(f"Behavior profile for player {player.number + 1}:") + for number, player in enumerate(profile.game.players, start=1): + lines.append(f"Behavior profile for player {number}:") lines.append("Infoset Action Prob Value") lines.append("------- ------- ----------- -----------") - for infoset, mixed_action in profile[player.label]: + for infoset, mixed_action in profile[player]: infoset_name = _name_or_number(infoset) values = action_values[next(iter(infoset.members))] for action in infoset.actions: @@ -300,7 +300,7 @@ def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> lines.append("") lines.append("Infoset Node Belief Prob") lines.append("------- ------- ----------- -----------") - for infoset, _mixed_action in profile[player.label]: + for infoset, _mixed_action in profile[player]: infoset_name = _name_or_number(infoset) for node in infoset.members: node_name = _name_or_number(node) @@ -324,7 +324,9 @@ def read_strategy_profiles_csv( Values are parsed as exact rationals; a method which requires floating-point starting points converts the result via `~MixedStrategyProfile.as_float`. """ - strategies = [strategy for player in game.players for strategy in player.strategies] + strategies = [ + strategy for player in game.players for strategy in game.get_strategies(player) + ] profiles = [] for line in pathlib.Path(path).read_text().splitlines(): line = line.strip() @@ -337,7 +339,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: next(values) for s in player.strategies} + profile[player] = {s: next(values) for s in game.get_strategies(player)} profiles.append(profile) return profiles @@ -354,7 +356,7 @@ def read_behavior_profiles_csv( count = sum( len(node.infoset.actions) for player in game.players - for node in game.get_infosets(player.label) + for node in game.get_infosets(player) ) profiles = [] for line in pathlib.Path(path).read_text().splitlines(): @@ -368,7 +370,7 @@ def read_behavior_profiles_csv( raise ValueError(f"Error reading behavior profile from '{path}': {exc}") from None profile = game.mixed_behavior_profile(rational=True) for player in game.players: - for node in game.get_infosets(player.label): + for node in game.get_infosets(player): profile[node] = {a: next(values) for a in node.infoset.actions} profiles.append(profile) return profiles diff --git a/src/pygambit/cli/logit.py b/src/pygambit/cli/logit.py index 1a867f110..3b660e0bd 100644 --- a/src/pygambit/cli/logit.py +++ b/src/pygambit/cli/logit.py @@ -63,7 +63,7 @@ def _read_frequencies(path: str, game: gbt.Game) -> gbt.MixedStrategyProfileDoub comma-separated list of counts, one per strategy, in the same order as a profile's CSV row, matching the C++ tool's `ReadProfile`. """ - count = sum(len(list(player.strategies)) for player in game.players) + count = sum(len(game.get_strategies(player)) for player in game.players) try: fields = pathlib.Path(path).read_text().split(",") values = [float(fields[i]) for i in range(count)] @@ -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: next(it) for s in player.strategies} + frequencies[player] = {s: next(it) for s in game.get_strategies(player)} return frequencies diff --git a/src/pygambit/cli/simpdiv.py b/src/pygambit/cli/simpdiv.py index 137e80a2c..77939ec15 100644 --- a/src/pygambit/cli/simpdiv.py +++ b/src/pygambit/cli/simpdiv.py @@ -49,8 +49,8 @@ def _default_start(game: gbt.Game) -> gbt.MixedStrategyProfileRational: """Each player's first strategy, matching the C++ library's `SimpdivDefaultStart`.""" start = game.mixed_strategy_profile(rational=True) for player in game.players: - first_strategy = next(iter(player.strategies)) - start[player.label] = {first_strategy: 1} + first_strategy = next(iter(game.get_strategies(player))) + start[player] = {first_strategy: 1} return start diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index 474a2bef8..fc7d15fec 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -100,7 +100,6 @@ def _resolve_by_label(collection, label: str, scope: str, kind: str, kind_plural return matches[0] -PlayerReference = Player | str NodeReference = Node | str NodeReferenceSet = typing.Iterable[NodeReference] @@ -192,7 +191,6 @@ class NodeIndexedVector(_LabeledVector): include "action.pxi" include "infoset.pxi" include "strategy.pxi" -include "player.pxi" include "outcome.pxi" include "node.pxi" include "stratspt.pxi" diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 4a4ec5dd3..0f72faa02 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -307,7 +307,15 @@ class GameOutcomes: @cython.cclass class GamePlayers: - """Represents a collection of players in a game.""" + """The labels of the (personal) players in a game. + + .. versionchanged:: 17.0.0 + Iterates over player labels (``str``) rather than ``Player`` objects; + indexing by label is no longer supported (a label is already in hand once + iterated) -- use ``in`` to test membership. The chance player is no longer + exposed here (it never was included in iteration); the ``Infoset``/``Event`` + split on ``Node`` already distinguishes personal from chance nodes. + """ game = cython.declare(c_Game) def __init__(self, *args, **kwargs) -> None: @@ -327,39 +335,15 @@ class GamePlayers: """Returns the number of players in the game.""" return self.game.deref().NumPlayers() - def __iter__(self) -> typing.Iterator[Player]: + def __iter__(self) -> typing.Iterator[str]: for player in self.game.deref().GetPlayers(): - yield Player.wrap(player) - - def __getitem__(self, label: str) -> Player: - """Returns the player with text label `label`. - - Parameters - ---------- - label : str - The text label of the player to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. - - Raises - ------ - KeyError - If no player in the game has label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one player has label `label`. - TypeError - If `label` is not a string. - - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference a player 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", "player", "players") + yield player.deref().GetLabel().decode("utf-8") - @property - def chance(self) -> Player: - """Returns the chance player associated with the game.""" - return Player.wrap(self.game.deref().GetChance()) + def __contains__(self, label: str) -> bool: + return any( + player.deref().GetLabel().decode("utf-8") == label + for player in self.game.deref().GetPlayers() + ) @cython.cclass @@ -479,7 +463,7 @@ class Game: g = Game.wrap(NewTable(list(shape), False)) players = list(g.players) for profile in itertools.product(*(range(s) for s in shape)): - contingency = {p.label: str(i + 1) for p, i in zip(players, profile, strict=True)} + contingency = {p: 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] @@ -506,20 +490,21 @@ class Game: arrays = [] players = list(self.players) - shape = tuple(len(player.strategies) for player in players) + player_strategies = {player: self.get_strategies(player) for player in players} + shape = tuple(len(player_strategies[player]) 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] + p: player_strategies[p][i] for p, i in zip(players, profile, strict=True) } payoffs = self.get_payoffs(contingency) try: - array[profile] = dtype(payoffs[player.label]) + array[profile] = dtype(payoffs[player]) except (ValueError, TypeError, IndexError, KeyError): raise ValueError( - f"Payoff '{payoffs[player.label]}' cannot be " + f"Payoff '{payoffs[player]}' cannot be " f"converted to requested type '{dtype}'" ) from None arrays.append(array) @@ -563,11 +548,11 @@ class Game: shape = arrays[0].shape g = Game.wrap(NewTable(list(shape), False)) g.relabel_players( - {player.label: label for player, label in zip(g.players, payoffs, strict=True)} + {player: 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)): - contingency = {p.label: str(i + 1) for p, i in zip(players, profile, strict=True)} + contingency = {p: 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] @@ -662,10 +647,10 @@ class Game: Raises ------ UndefinedOperationError - If the game does not have a tree representation, or if `player` is the - chance player; use `get_events` for the chance player's events. + If the game does not have a tree representation. KeyError - If no player in the game has label `player`. + If no player in the game has label `player`; the chance player has no + label reachable this way -- use `get_events` for its events. ValueError If `player` is an empty string or all whitespace. """ @@ -673,15 +658,10 @@ class Game: raise UndefinedOperationError( "Operation only defined for games with a tree representation" ) - resolved_player = cython.cast(Player, self._resolve_player(player, "get_infosets")) - if resolved_player.is_chance: - raise UndefinedOperationError( - "get_infosets(): `player` must be a personal player; " - "use get_events() for the chance player's events" - ) + resolved_player = self._resolve_player(player, "get_infosets") return [ Node.wrap(infoset.deref().GetMember(1)) - for infoset in resolved_player.player.deref().GetInfosets() + for infoset in resolved_player.deref().GetInfosets() ] def get_events(self) -> list[Node]: @@ -715,6 +695,116 @@ class Game: for event in self.game.deref().GetChance().deref().GetInfosets() ] + def get_strategies(self, player: str) -> list[str]: + """Returns a snapshot of the labels of the strategies belonging to `player`. + + This is a materialized snapshot, not a live view: it reflects the game's + state at the moment of the call, and does not change if the game is + subsequently mutated. + + Parameters + ---------- + player : str + The label of the player whose strategies to return. + + Returns + ------- + list of str + The labels of `player`'s strategies, in order. + + .. versionadded:: 17.0.0 + + Raises + ------ + KeyError + If no player in the game has label `player`. + ValueError + If `player` is an empty string or all whitespace. + """ + resolved_player = self._resolve_player(player, "get_strategies") + return [ + s.deref().GetLabel().decode("utf-8") for s in resolved_player.deref().GetStrategies() + ] + + def get_sequences(self, player: str) -> list[Sequence]: + """Returns a snapshot of the sequences belonging to `player`. + + This is a materialized snapshot, not a live view: it reflects the game's + state at the moment of the call, and does not change if the game is + subsequently mutated. + + Parameters + ---------- + player : str + The label of the player whose sequences to return. + + Returns + ------- + list of Sequence + `player`'s sequences. + + .. versionadded:: 17.0.0 + + Raises + ------ + KeyError + If no player in the game has label `player`. + ValueError + If `player` is an empty string or all whitespace. + """ + resolved_player = self._resolve_player(player, "get_sequences") + return [Sequence.wrap(s) for s in resolved_player.deref().GetSequences()] + + def get_min_payoff(self, player: str) -> Rational: + """Returns the smallest payoff for `player` in any play of the game. + + .. versionadded:: 17.0.0 + + Parameters + ---------- + player : str + The label of the player. + + Raises + ------ + KeyError + If no player in the game has label `player`. + ValueError + If `player` is an empty string or all whitespace. + + See Also + -------- + Game.get_max_payoff + Game.min_payoff + """ + resolved_player = self._resolve_player(player, "get_min_payoff") + return rat_to_py(self.game.deref().GetPlayerMinPayoff(resolved_player)) + + def get_max_payoff(self, player: str) -> Rational: + """Returns the largest payoff for `player` in any play of the game. + + .. versionadded:: 17.0.0 + + Parameters + ---------- + player : str + The label of the player. + + Raises + ------ + KeyError + If no player in the game has label `player`. + ValueError + If `player` is an empty string or all whitespace. + + See Also + -------- + Game.get_min_payoff + Game.max_payoff + """ + resolved_player = self._resolve_player(player, "get_max_payoff") + return rat_to_py(self.game.deref().GetPlayerMaxPayoff(resolved_player)) + @property def players(self) -> GamePlayers: """The set of players in the game.""" @@ -791,7 +881,7 @@ class Game: See Also -------- Game.max_payoff - Player.min_payoff + Game.get_min_payoff """ return rat_to_py(self.game.deref().GetMinPayoff()) @@ -806,7 +896,7 @@ class Game: See Also -------- Game.min_payoff - Player.max_payoff + Game.get_max_payoff """ return rat_to_py(self.game.deref().GetMaxPayoff()) @@ -893,13 +983,13 @@ class Game: raise UndefinedOperationError( "get_behavior(): only defined for games with a tree representation" ) - 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) + self._resolve_strategy(player, strategy, "get_behavior") # validate eagerly + return StrategyBehavior.wrap(self, player, 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. + """Resolve a pure-strategy contingency to a dict from player label 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 @@ -915,11 +1005,10 @@ class Game: f"{funcname}(): {argname} keys must be player labels (str), " f"not {player_label.__class__.__name__}" ) - player = cython.cast(Player, self.players[player_label]) - if player in resolved: + if player_label 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 + self._resolve_strategy(player_label, strategy_label, funcname, argname) + resolved[player_label] = strategy_label if set(resolved) != set(self.players): raise ValueError( f"{funcname}(): {argname} must specify exactly one strategy " @@ -929,15 +1018,14 @@ class Game: @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.""" + """Build a C++ pure-strategy profile from a dict mapping player label to + strategy label.""" psp: shared_ptr[c_PureStrategyProfile] = make_shared[c_PureStrategyProfile]( self.game.deref().NewPureStrategyProfile() ) - for player in self.players: - resolved_player: Player = cython.cast(Player, player) + for player_label in self.players: handle = self._resolve_strategy( - resolved_player, resolved[resolved_player], "_make_pure_strategy_profile" + player_label, resolved[player_label], "_make_pure_strategy_profile" ) deref(deref(psp).deref()).SetStrategy(handle) return psp @@ -1018,9 +1106,10 @@ class Game: 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)) + for player in self.players: + values[player] = rat_to_py( + deref(deref(psp).deref()).GetPayoff(self._resolve_player(player, "get_payoffs")) + ) return PayoffVector(values) def _fill_strategy_profile(self, @@ -1033,12 +1122,13 @@ class Game: if len(data) != len(self.players): raise ValueError("Number of elements does not match number of players") for (p, d) in zip(self.players, data, strict=True): - if len(p.strategies) != len(d): + strategies = self.get_strategies(p) + if len(strategies) != len(d): raise ValueError( f"Number of elements does not match number of strategies for {p}" ) - profile[p.label] = { - s: typefunc(v) for s, v in zip(p.strategies, d, strict=True) + profile[p] = { + s: typefunc(v) for s, v in zip(strategies, d, strict=True) } return profile @@ -1115,12 +1205,13 @@ class Game: if denom is None: profile = self.mixed_strategy_profile() for player in self.players: + strategies = self.get_strategies(player) weights = scipy.stats.dirichlet( - alpha=[1 for _ in player.strategies], + alpha=[1 for _ in strategies], seed=gen ).rvs(size=1)[0] - profile[player.label] = dict( - zip(player.strategies, weights, strict=True) + profile[player] = dict( + zip(strategies, weights, strict=True) ) return profile elif denom < 1: @@ -1128,7 +1219,8 @@ class Game: else: profile = self.mixed_strategy_profile(rational=True) for player in self.players: - k = len(player.strategies) + strategies = self.get_strategies(player) + k = len(strategies) sample = ( [0] + sorted( @@ -1139,12 +1231,12 @@ class Game: distribution = { strategy: Rational(hi - lo - 1, denom) for strategy, (hi, lo) in zip( - player.strategies, + strategies, zip(sample[1:], sample[:-1], strict=True), strict=True ) } - profile[player.label] = distribution + profile[player] = distribution return profile def _fill_behavior_profile(self, @@ -1157,7 +1249,7 @@ class Game: if len(data) != len(self.players): raise ValueError("Number of elements does not match number of players") for (p, d) in zip(self.players, data): - p_infosets = self.get_infosets(p.label) + p_infosets = self.get_infosets(p) if len(p_infosets) != len(d): raise ValueError(f"Number of elements does not match number of infosets for {p}") for (node, v) in zip(p_infosets, d, strict=True): @@ -1248,7 +1340,7 @@ class Game: if denom is None: profile = self.mixed_behavior_profile() for player in self.players: - for node in self.get_infosets(player.label): + for node in self.get_infosets(player): infoset = node.infoset weights = scipy.stats.dirichlet( alpha=[1 for action in infoset.actions], seed=gen @@ -1262,7 +1354,7 @@ class Game: else: profile = self.mixed_behavior_profile(rational=True) for player in self.players: - for node in self.get_infosets(player.label): + for node in self.get_infosets(player): infoset = node.infoset k = len(infoset.actions) sample = ( @@ -1302,7 +1394,7 @@ class Game: profile = StrategySupportProfile.wrap(make_shared[c_StrategySupportProfile](self.game)) if strategies is not None: for player in self.players: - for label in player.strategies: + for label in self.get_strategies(player): if not strategies(player, label): handle = self._resolve_strategy( player, label, "strategy_support_profile" @@ -1331,7 +1423,7 @@ class Game: profile = BehaviorSupportProfile.wrap(make_shared[c_BehaviorSupportProfile](self.game)) if actions is not None: for player in self.players: - for node in self.get_infosets(player.label): + for node in self.get_infosets(player): infoset_handle: c_GameInfoset = cython.cast(Infoset, node.infoset)._resolve() for action in infoset_handle.deref().GetActions(): if not actions(node, action.deref().GetLabel().decode("utf-8")): @@ -1460,40 +1552,36 @@ class Game: """ return self._to_format(WriteLaTeXFile, filepath_or_buffer) - def _resolve_player(self, - player: typing.Any, funcname: str, argname: str = "player") -> Player: - """Resolve an attempt to reference a player of the game. - ... + @cython.cfunc + def _resolve_player( + self, player: typing.Any, funcname: str, argname: str = "player" + ) -> c_GamePlayer: + """Resolve `label` to the C++ handle of one of the game's (personal) players. + + Not part of the public API -- used internally to bridge a player label to + the underlying C++ object without ever constructing a Python wrapper for it. + + Raises + ------ + KeyError + If no player has label `player`. TypeError - If `player` is not a `Player`, `NodePlayer`, or a `str` + If `player` is not a `str`. ValueError - If `player` is an empty `str` or is a `NodePlayer` that resolves to no player - (terminal node). + If `player` is an empty string or all spaces. """ - if isinstance(player, NodePlayer): - resolved = cython.cast(NodePlayer, player)._resolve() - if resolved is None: - raise ValueError( - f"{funcname}(): {argname} resolves to no player " - f"(the node is terminal)" - ) - player = resolved - if isinstance(player, Player): - if player.game != self: - raise MismatchError(f"{funcname}(): {argname} must be part of the same game") - return player - elif isinstance(player, str): - if not player.strip(): - raise ValueError( - f"{funcname}(): {argname} cannot be an empty string or all spaces" - ) - try: - return self.players[player] - except KeyError: - raise KeyError(f"{funcname}(): no player with label '{player}'") - raise TypeError( - f"{funcname}(): {argname} must be Player or str, not {player.__class__.__name__}" - ) + if not isinstance(player, str): + raise TypeError( + f"{funcname}(): {argname} must be str, not {player.__class__.__name__}" + ) + if not player.strip(): + raise ValueError( + f"{funcname}(): {argname} cannot be an empty string or all spaces" + ) + for p in self.game.deref().GetPlayers(): + if p.deref().GetLabel().decode("utf-8") == player: + return p + raise KeyError(f"{funcname}(): no player with label '{player}'") def _resolve_outcome(self, outcome: typing.Any, funcname: str, argname: str = "outcome") -> Outcome: @@ -1546,7 +1634,7 @@ class Game: ) @cython.cfunc - def _resolve_strategy(self, player: Player, label, funcname: str, + def _resolve_strategy(self, player: str, label, funcname: str, argname: str = "strategy") -> c_GameStrategy: """Resolve `label` to the C++ handle of one of `player`'s strategies. @@ -1556,7 +1644,7 @@ class Game: Raises ------ KeyError - If `player` has no strategy with label `label`. + If no player has label `player`, or `player` has no strategy with label `label`. TypeError If `label` is not a `str`. ValueError @@ -1569,11 +1657,12 @@ class Game: ) if not label.strip(): raise ValueError(f"{funcname}(): {argname} cannot be an empty string or all spaces") - for strategy in player.player.deref().GetStrategies(): + resolved_player: c_GamePlayer = self._resolve_player(player, funcname, "player") + for strategy in resolved_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}'" + f"{funcname}(): player '{player}' has no strategy with label '{label}'" ) def _resolve_node(self, node: typing.Any, funcname: str, argname: str = "node") -> Node: @@ -1781,7 +1870,7 @@ class Game: return probs def append_move(self, nodes: Node | NodeReferenceSet, - player: Player | str, + player: str, actions: list[str]) -> None: """Add a move for `player` at terminal `nodes`. All elements of `nodes` become part of a new information set, with actions labeled according to `actions`. @@ -1791,21 +1880,16 @@ class Game: Raises ------ UndefinedOperationError - If `nodes` are not all terminal, `actions` is empty, or `player` is the - chance player. + If `nodes` are not all terminal, or `actions` is empty. MismatchError - If an element from `nodes` is a `Node` from a different game, - or `player` is a `Player` from a different game. + If an element from `nodes` is a `Node` from a different game. + KeyError + If no player in the game has label `player`. ValueError If `nodes` has duplicated elements, or is empty; or if `actions` contains an empty or a duplicated label. """ - resolved_player = cython.cast(Player, self._resolve_player(player, "append_move")) - if resolved_player.is_chance: - raise UndefinedOperationError( - "append_move(): `player` must be a personal player; " - "use append_event() to add a chance move" - ) + resolved_player = self._resolve_player(player, "append_move") if not actions: raise UndefinedOperationError("append_move(): `actions` must be a nonempty list") if any(not label for label in actions): @@ -1820,7 +1904,7 @@ class Game: c_actions = stdvector[string]() for label in actions: c_actions.push_back(label.encode("utf-8")) - self.game.deref().AppendMove(resolved_node.node, resolved_player.player, c_actions) + self.game.deref().AppendMove(resolved_node.node, resolved_player, c_actions) resolved_infoset = cython.cast(Infoset, resolved_node.infoset) for n in resolved_nodes[1:]: self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_infoset._resolve()) @@ -1916,7 +2000,7 @@ class Game: self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_event._resolve()) def insert_move(self, node: Node | str, - player: Player | str, actions: list[str]) -> None: + player: str, actions: list[str]) -> None: """Insert a move for `player` prior to the node `node`, with actions labeled according to `actions`. `node` becomes the first child of the newly-inserted node. @@ -1925,20 +2009,16 @@ class Game: Raises ------ UndefinedOperationError - If `actions` is empty, or `player` is the chance player. + If `actions` is empty. MismatchError - If `node` is a `Node` from a different game, or `player` is a `Player` from a - different game. + If `node` is a `Node` from a different game. + KeyError + If no player in the game has label `player`. ValueError If `actions` contains an empty or a duplicated label. """ resolved_node = cython.cast(Node, self._resolve_node(node, "insert_move")) - resolved_player = cython.cast(Player, self._resolve_player(player, "insert_move")) - if resolved_player.is_chance: - raise UndefinedOperationError( - "insert_move(): `player` must be a personal player; " - "use insert_event() to insert a chance move" - ) + resolved_player = self._resolve_player(player, "insert_move") if not actions: raise UndefinedOperationError("insert_move(): `actions` must be a nonempty list") if any(not label for label in actions): @@ -1948,7 +2028,7 @@ class Game: c_actions = stdvector[string]() for label in actions: c_actions.push_back(label.encode("utf-8")) - self.game.deref().InsertMove(resolved_node.node, resolved_player.player, c_actions) + self.game.deref().InsertMove(resolved_node.node, resolved_player, c_actions) def insert_infoset(self, node: Node | str, infoset: NodeReference) -> None: @@ -2495,7 +2575,7 @@ class Game: "make_infoset(): operation only defined for games with a tree representation" ) resolved_nodes = self._resolve_nodes(nodes, "make_infoset") - resolved_player = cython.cast(Player, self._resolve_player(player, "make_infoset")) + resolved_player = self._resolve_player(player, "make_infoset") for n in resolved_nodes: if n.is_terminal: raise UndefinedOperationError( @@ -2504,12 +2584,11 @@ class Game: c_nodes = stdvector[c_GameNode]() for n in resolved_nodes: c_nodes.push_back(cython.cast(Node, n).node) - self.game.deref().MakeInfoset(c_nodes, resolved_player.player, - (label or "").encode()) + self.game.deref().MakeInfoset(c_nodes, resolved_player, (label or "").encode()) def reveal(self, infoset: NodeReference, - player: Player | str) -> None: + player: str) -> None: """Reveals the move made at the information set or event `infoset` to `player`. Revealing the move modifies all subsequent information sets for `player` such @@ -2527,31 +2606,28 @@ class Game: infoset : Node or str A node belonging to the information set or event of the move to reveal to the player, or such a node's label. - player : Player or str - The player to which to reveal the move at this information set. + player : str + The label of the player to which to reveal the move at this information set. Raises ------ MismatchError - If `infoset` is a `Node` from a different game, or - `player` is a `Player` from a different game. + If `infoset` is a `Node` from a different game. + KeyError + If no player in the game has label `player`. UndefinedOperationError - If `infoset` is absent-minded, or if `player` is the chance player. + If `infoset` is absent-minded. """ resolved_infoset = cython.cast( _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "reveal") ) - resolved_player = cython.cast(Player, self._resolve_player(player, "reveal")) - if resolved_player.is_chance: - raise UndefinedOperationError( - "reveal(): `player` must be a personal player" - ) + resolved_player = self._resolve_player(player, "reveal") if resolved_infoset.is_absent_minded: raise UndefinedOperationError( "reveal(): revealing the move at an absent-minded information set " "is not well-defined" ) - self.game.deref().Reveal(resolved_infoset._resolve(), resolved_player.player) + self.game.deref().Reveal(resolved_infoset._resolve(), resolved_player) def set_players(self, players: list[str], @@ -2610,7 +2686,7 @@ class Game: raise TypeError("set_players(): players must be an iterable of str") if not labels: raise UndefinedOperationError("set_players(): `players` must be a nonempty list") - current = [player.label for player in self.players] + current = list(self.players) if len(set(current)) != len(current): raise ValueError( "set_players(): the game has duplicate player labels, " @@ -2626,13 +2702,12 @@ class Game: f"every outcome; pass drop=True to confirm" ) for label in missing: - resolved = self.players[label] - if self.is_tree and len(self.get_infosets(resolved.label)) > 0: + if self.is_tree and len(self.get_infosets(label)) > 0: raise UndefinedOperationError( f"set_players(): player '{label}' has decisions in the game " f"and cannot be deleted" ) - if not self.is_tree and len(resolved.strategies) != 1: + if not self.is_tree and len(self.get_strategies(label)) != 1: raise UndefinedOperationError( f"set_players(): player '{label}' has more than one strategy " f"and cannot be deleted" @@ -2678,8 +2753,7 @@ class Game: Raises ------ MismatchError - If any node is from a different game, or `payoffs` names a `Player` from a - different game. + If any node is from a different game. ValueError 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 @@ -2699,10 +2773,10 @@ class Game: ) resolved_payoffs = {} for player, value in payoffs.items(): - resolved = cython.cast(Player, self._resolve_player(player, "make_outcome", "payoffs")) - if resolved in resolved_payoffs: + 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[resolved] = value + 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" @@ -2733,10 +2807,8 @@ class Game: resolved = self._resolve_contingency(entry, "make_outcome", "location") c_one = stdvector[c_GameStrategy]() 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") + self._resolve_strategy(player, resolved[player], "make_outcome") ) c_contingencies.push_back(c_one) return Outcome.wrap( @@ -2799,16 +2871,14 @@ class Game: resolved = self._resolve_contingency(entry, "make_outcome_null", "location") c_one = stdvector[c_GameStrategy]() 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_null") + self._resolve_strategy(player, resolved[player], "make_outcome_null") ) c_contingencies.push_back(c_one) self.game.deref().MakeOutcomeNull(c_contingencies) def relabel_strategies(self, - player: Player | str, + player: str, labels: typing.Mapping[str, str], strict: bool = True) -> None: """Simultaneously reassign the labels of `player`'s strategies. @@ -2822,9 +2892,8 @@ class Game: Parameters ---------- - player : Player or str - The player whose strategies to relabel. If a string is passed, the player - is determined by finding the player with that label, if any. + player : str + The label of the player whose strategies to relabel. labels : Mapping[str, str] A mapping from current strategy labels to replacement labels. Entries whose key equals their value are ignored. @@ -2835,10 +2904,8 @@ class Game: Raises ------ - MismatchError - If `player` is a `Player` from a different game. KeyError - If `player` is a string matching no player; or, when `strict` is `True`, + If no player in the game has label `player`; or, when `strict` is `True`, if a key of `labels` matches no strategy of `player`. TypeError If `labels` is not a mapping, or any key or value is not a string. @@ -2858,13 +2925,15 @@ class Game: raise UndefinedOperationError( "Relabelling strategies is only applicable to games in strategic form" ) - resolved_player = cython.cast(Player, self._resolve_player(player, "relabel_strategies")) + resolved_player = self._resolve_player(player, "relabel_strategies") if not hasattr(labels, "items"): raise TypeError( f"relabel_strategies(): labels must be a mapping, " f"not {labels.__class__.__name__}" ) - current = list(resolved_player.strategies) + current = [ + s.deref().GetLabel().decode("utf-8") for s in resolved_player.deref().GetStrategies() + ] c_labels = stdmap[string, string]() for old, new in labels.items(): if not isinstance(old, str) or not isinstance(new, str): @@ -2883,10 +2952,10 @@ class Game: c_labels[old.encode("utf-8")] = new.encode("utf-8") if c_labels.empty(): return - self.game.deref().RelabelStrategies(resolved_player.player, c_labels) + self.game.deref().RelabelStrategies(resolved_player, c_labels) def set_strategies(self, - player: Player | str, + player: str, strategies: list[str], drop: bool = False, add: bool = True) -> None: @@ -2908,8 +2977,8 @@ class Game: Parameters ---------- - player : Player or str - The player whose strategies to set. + player : str + The label of the player whose strategies to set. strategies : list of str The labels of the strategies the player is to have, in order. Must be nonempty and without duplicates; each label must be a valid, nonempty label. @@ -2922,10 +2991,8 @@ class Game: Raises ------ - MismatchError - If `player` is a `Player` from a different game. KeyError - If `player` is a string matching no player. + If no player in the game has label `player`. TypeError If `strategies` is a string, or not an iterable of strings. UndefinedOperationError @@ -2943,7 +3010,7 @@ class Game: raise UndefinedOperationError( "Setting strategies is only applicable to games in strategic form" ) - resolved_player = cython.cast(Player, self._resolve_player(player, "set_strategies")) + resolved_player = self._resolve_player(player, "set_strategies") if isinstance(strategies, str) or not hasattr(strategies, "__iter__"): raise TypeError("set_strategies(): strategies must be an iterable of str") labels = list(strategies) @@ -2952,7 +3019,9 @@ 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 = list(resolved_player.strategies) + current = [ + s.deref().GetLabel().decode("utf-8") for s in resolved_player.deref().GetStrategies() + ] if len(set(current)) != len(current): raise ValueError( "set_strategies(): the player has duplicate strategy labels, " @@ -2970,7 +3039,7 @@ class Game: c_labels = stdvector[string]() for label in labels: c_labels.push_back(label.encode("utf-8")) - self.game.deref().SetStrategies(resolved_player.player, c_labels) + self.game.deref().SetStrategies(resolved_player, c_labels) def relabel_players(self, labels: typing.Mapping[str, str], @@ -3017,8 +3086,11 @@ class Game: f"relabel_players(): labels must be a mapping, " f"not {labels.__class__.__name__}" ) - current = [player.label for player in self.players] - chance_label = self.players.chance.label if self.is_tree else None + current = list(self.players) + chance_label = ( + self.game.deref().GetChance().deref().GetLabel().decode("utf-8") + if self.is_tree else None + ) c_labels = stdmap[string, string]() for old, new in labels.items(): if not isinstance(old, str) or not isinstance(new, str): diff --git a/src/pygambit/gameiter.py b/src/pygambit/gameiter.py index 1ee22b852..30bc8e9d2 100644 --- a/src/pygambit/gameiter.py +++ b/src/pygambit/gameiter.py @@ -44,15 +44,15 @@ def __len__(self): ncont = 1 for player in self.game.players: if player not in self.cont: - ncont *= len(player.strategies) + ncont *= len(self.game.get_strategies(player)) return ncont def __iter__(self): if len(self.cont) == len(self.game.players): - yield {player.label: self.cont[player] for player in self.game.players} + yield {player: 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: + for strategy in self.game.get_strategies(players[nextpl]): yield from self[players[nextpl], strategy] diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index 601106195..30d1cde81 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -199,9 +199,9 @@ class _InfosetOrEvent: return InfosetMembers.wrap(self._resolve()) @property - def player(self) -> Player: - """The player who has the move at this information set.""" - return Player.wrap(self._resolve().deref().GetPlayer()) + def player(self) -> str: + """The label of the player who has the move at this information set.""" + return self._resolve().deref().GetPlayer().deref().GetLabel().decode("utf-8") @cython.cclass diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index 53a346f9e..e1e03a178 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -698,8 +698,8 @@ def ipa_solve( game = perturbation perturbation = game.mixed_strategy_profile(rational=False) for player in game.players: - strategies = list(player.strategies) - perturbation[player.label] = { + strategies = game.get_strategies(player) + perturbation[player] = { s: (1.0 if s == strategies[0] else 0.0) for s in strategies } elif isinstance(perturbation, libgbt.MixedStrategyProfile): @@ -817,8 +817,8 @@ def gnm_solve( game = perturbation perturbation = game.mixed_strategy_profile(rational=False) for player in game.players: - strategies = list(player.strategies) - perturbation[player.label] = { + strategies = game.get_strategies(player) + perturbation[player] = { s: (1.0 if s == strategies[0] else 0.0) for s in strategies } elif isinstance(perturbation, libgbt.MixedStrategyProfile): diff --git a/src/pygambit/nashlrs.py b/src/pygambit/nashlrs.py index 46bc4108d..cde87774b 100644 --- a/src/pygambit/nashlrs.py +++ b/src/pygambit/nashlrs.py @@ -14,17 +14,19 @@ def _generate_lrs_input(game: gbt.Game) -> str: p1, p2 = game.players - s = f"{len(p1.strategies)} {len(p2.strategies)}\n\n" - for st1 in p1.strategies: + s1 = game.get_strategies(p1) + s2 = game.get_strategies(p2) + s = f"{len(s1)} {len(s2)}\n\n" + for st1 in s1: s += " ".join( - str(game.get_payoffs({p1.label: st1, p2.label: st2})[p1.label]) - for st2 in p2.strategies + str(game.get_payoffs({p1: st1, p2: st2})[p1]) + for st2 in s2 ) + "\n" s += "\n" - for st1 in p1.strategies: + for st1 in s1: s += " ".join( - str(game.get_payoffs({p1.label: st1, p2.label: st2})[p2.label]) - for st2 in p2.strategies + str(game.get_payoffs({p1: st1, p2: st2})[p2]) + for st2 in s2 ) + "\n" return s @@ -67,8 +69,8 @@ def main(): eqa = lrsnash_solve(game, "./lrsnash") for eqm in eqa: print("NE," + - ",".join(str(eqm[player.label][strat]) - for player in game.players for strat in player.strategies)) + ",".join(str(eqm[player][strat]) + for player in game.players for strat in game.get_strategies(player))) if __name__ == "__main__": diff --git a/src/pygambit/nashphc.py b/src/pygambit/nashphc.py index 611d4c8fe..370596b75 100644 --- a/src/pygambit/nashphc.py +++ b/src/pygambit/nashphc.py @@ -121,57 +121,62 @@ def _run_phc(phcpack_path: pathlib.Path | str, equations: list[str]) -> list[dic _playerletters = [c for c in string.ascii_lowercase if c not in ("e", "i", "j")] -def _strategy_index(player: gbt.Player, label: str) -> int: +def _strategy_index(game: gbt.Game, player: str, 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) + return game.get_strategies(player).index(label) def _contingencies( support: gbt.StrategySupportProfile, - skip_player: gbt.Player + skip_player: str ) -> 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`. """ + game = support.game for profile in itertools.product( - *[[strategy for strategy in player.strategies if strategy in support[player.label]] + *[[strategy for strategy in game.get_strategies(player) if strategy in support[player]] if player != skip_player else [None] - for player in support.game.players] + for player in game.players] ): yield list(profile) -def _equilibrium_equations(support: gbt.StrategySupportProfile, player: gbt.Player) -> list: +def _equilibrium_equations(support: gbt.StrategySupportProfile, player: str) -> list: """Generate the equations that the strategy of `player` must satisfy in any totally-mixed equilibrium on `support`. """ - players = list(support.game.players) - player_support = support[player.label] - payoffs = {strategy: [] for strategy in player.strategies if strategy in player_support} + game = support.game + players = list(game.players) + player_index = {p: i for i, p in enumerate(players)} + player_support = support[player] + payoffs = { + strategy: [] for strategy in game.get_strategies(player) if strategy in player_support + } strategies = list(player_support) for profile in _contingencies(support, player): contingency = "*".join( - f"{_playerletters[p.number]}{_strategy_index(p, strat)}" + f"{_playerletters[player_index[p]]}{_strategy_index(game, p, strat)}" for p, strat in zip(players, profile, strict=True) if strat is not None ) for strategy in strategies: - profile[player.number] = strategy - payoff_vec = support.game.get_payoffs( - {p.label: strat for p, strat in zip(players, profile, strict=True)} + profile[player_index[player]] = strategy + payoff_vec = game.get_payoffs( + {p: 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})") + if payoff_vec[player] != 0: + payoffs[strategy].append(f"({payoff_vec[player]}*{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(_strategy_index(player, s)) + "+".join(_playerletters[player_index[player]] + str(_strategy_index(game, player, s)) for s in strategies) + "-1" ) return equations @@ -181,30 +186,31 @@ def _is_nash(profile: gbt.MixedStrategyProfile, maxregret: float, negtol: float) """Check if the profile is an (approximate) Nash equilibrium, allowing a maximum 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[player.label][strategy] < -negtol: + for strategy in profile.game.get_strategies(player): + if profile[player][strategy] < -negtol: return False return profile.max_regret() < maxregret def _solution_to_profile(game: gbt.Game, entry: dict) -> gbt.MixedStrategyProfileDouble: profile = game.mixed_strategy_profile() - for player in game.players: - playerchar = _playerletters[player.number] + for i, player in enumerate(game.players): + playerchar = _playerletters[i] distribution = {} - for i, strategy in enumerate(player.strategies): + for j, strategy in enumerate(game.get_strategies(player)): try: - distribution[strategy] = entry["vars"][playerchar + str(i)].real + distribution[strategy] = entry["vars"][playerchar + str(j)].real except KeyError: distribution[strategy] = 0.0 - profile[player.label] = distribution + profile[player] = distribution return profile def _format_support(support, label: str) -> str: + game = support.game strings = [ - "".join(str(int(strategy in support[player.label])) for strategy in player.strategies) - for player in support.game.players + "".join(str(int(strategy in support[player])) for strategy in game.get_strategies(player)) + for player in game.players ] return label + "," + ",".join(strings) @@ -214,21 +220,23 @@ def _format_profile(profile: gbt.MixedStrategyProfileDouble, label: str, """Render the mixed strategy profile `profile` to a one-line string with the given `label`. """ + game = profile.game return (f"{label}," + - ",".join(["{p:.{decimals}f}".format(p=profile[player.label][s], decimals=decimals) - for player in profile.game.players for s in player.strategies])) + ",".join(["{p:.{decimals}f}".format(p=profile[player][s], decimals=decimals) + for player in game.players for s in game.get_strategies(player)])) def _profile_from_support(support: gbt.StrategySupportProfile) -> gbt.MixedStrategyProfileDouble: """Construct a mixed strategy profile corresponding to the (pure strategy) equilibrium on `support`. """ - profile = support.game.mixed_strategy_profile() - for player in support.game.players: - player_support = support[player.label] - profile[player.label] = { + game = support.game + profile = game.mixed_strategy_profile() + for player in game.players: + player_support = support[player] + profile[player] = { strategy: (1.0 if strategy in player_support else 0.0) - for strategy in player.strategies + for strategy in game.get_strategies(player) } return profile diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 433ac1d93..020564469 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -150,79 +150,6 @@ class NodeOutcome: return hash(self._resolve()) -@cython.cclass -class NodePlayer: - """The player associated with a node: the one who makes the decision, if the node - is personal, or the chance player, if the node is an event. - - A lazy, node-anchored view: holds the node and resolves its player on each access, - so the value reflects the current state of the game even after the game is mutated. - - .. versionadded:: 16.7.0 - """ - node = cython.declare(c_GameNode) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create a NodePlayer outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(node: c_GameNode) -> NodePlayer: - obj: NodePlayer = NodePlayer.__new__(NodePlayer) - obj.node = node - return obj - - @cython.cfunc - def _resolve(self) -> Player: - if self.node.deref().GetPlayer() != cython.cast(c_GamePlayer, NULL): - return Player.wrap(self.node.deref().GetPlayer()) - return None - - def __getattr__(self, name): - if name.startswith("_"): - raise AttributeError(f"'NodePlayer' object has no attribute '{name}'") - resolved = self._resolve() - if resolved is None: - raise AttributeError( - f"node has no player (terminal node); cannot access '{name}'" - ) - return getattr(resolved, name) - - @property - def label(self): - resolved = self._resolve() - if resolved is None: - raise AttributeError("node has no player (terminal node); cannot access 'label'") - return resolved.label - - @label.setter - def label(self, value): - resolved = self._resolve() - if resolved is None: - raise AttributeError("node has no player (terminal node); cannot set 'label'") - resolved.label = value - - def __repr__(self) -> str: - resolved = self._resolve() - return repr(resolved) if resolved is not None else "None" - - def __eq__(self, other: typing.Any) -> bool: - mine = self._resolve() - if isinstance(other, NodePlayer): - other = cython.cast(NodePlayer, other)._resolve() - if mine is None or other is None: - return mine is None and other is None - return mine == other - - def __bool__(self) -> bool: - return self._resolve() is not None - - def __hash__(self) -> int: - # Hash by the resolved player (transitional). - resolved = self._resolve() - return hash(resolved) if resolved is not None else 0 - - @cython.cclass class Node: """A node in a ``Game``.""" @@ -389,20 +316,21 @@ class Node: return result @property - def player(self) -> NodePlayer: - """The player associated with this node: the one who makes the decision, if this is - a personal node, or the chance player, if this is an event. + def player(self) -> str | None: + """The label of the player associated with this node: the one who makes the + decision, if this is a personal node, or the chance player, if this is an + event. - Returns a lazy, node-anchored view resolved on each access, so the value reflects - the current state of the game even if the game is mutated after this property is read. - For a terminal node, which has no player, the view is falsy and equals ``None``. - At a chance node the view resolves to the chance player (``is_chance`` is ``True``). + `None` for a terminal node, which has no player. - .. versionchanged:: 16.7.0 - Now returns a lazily-evaluated, node-anchored view rather than capturing the - player at the time of access. + .. versionchanged:: 17.0.0 + Returns the player's label (or `None`) directly, rather than a lazy, + node-anchored view. """ - return NodePlayer.wrap(self.node) + player: c_GamePlayer = self.node.deref().GetPlayer() + if not (player != cython.cast(c_GamePlayer, NULL)): + return None + return player.deref().GetLabel().decode("utf-8") @property def parent(self) -> Node | None: diff --git a/src/pygambit/outcome.pxi b/src/pygambit/outcome.pxi index 447cae158..92c14884c 100644 --- a/src/pygambit/outcome.pxi +++ b/src/pygambit/outcome.pxi @@ -111,7 +111,7 @@ class Outcome: return self.outcome.deref().GetNumber() - 1 def __getitem__( - self, player: Player | str + self, player: str ) -> decimal.Decimal | Rational: """The payoff to `player` at the outcome. @@ -119,34 +119,34 @@ class Outcome: Raises ------ - MismatchError - If `player` is a ``Player`` from a different game than the outcome. + KeyError + If no player of the outcome's game has label `player`. """ - resolved_player = cython.cast(Player, - self.game._resolve_player(player, "Outcome.__getitem__")) + game: Game = self.game + resolved_player: c_GamePlayer = game._resolve_player(player, "Outcome.__getitem__") payoff = ( - self.outcome.deref().GetPayoff[string](resolved_player.player).decode("ascii") + self.outcome.deref().GetPayoff[string](resolved_player).decode("ascii") ) if "." in payoff: return decimal.Decimal(payoff) else: return Rational(payoff) - def __setitem__(self, player: Player | str, value: typing.Any) -> None: + def __setitem__(self, player: str, value: typing.Any) -> None: """Set the payoff to `player` at the outcome. Parameters ---------- - player : Player or str - A reference to the player for which to set the payoff. + 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 ------ - MismatchError - If `player` is a ``Player`` from a different game than the outcome. + KeyError + If no player of the outcome's game has label `player`. ValueError If `value` cannot be interpreted as a number. UndefinedOperationError @@ -157,6 +157,6 @@ class Outcome: "Payoffs cannot be set on the null outcome; " "use Game.make_outcome to create and attach an outcome" ) - resolved_player = cython.cast(Player, - self.game._resolve_player(player, "Outcome.__setitem__")) - self.outcome.deref().SetPayoff(resolved_player.player, _to_number(value)) + 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/src/pygambit/player.pxi b/src/pygambit/player.pxi deleted file mode 100644 index 5801057fa..000000000 --- a/src/pygambit/player.pxi +++ /dev/null @@ -1,192 +0,0 @@ -# -# This file is part of Gambit -# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -# -# FILE: src/pygambit/player.pxi -# Cython wrapper for players -# -# 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 - - -@cython.cclass -class PlayerStrategies: - """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: - raise ValueError("Cannot create PlayerStrategies outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(player: c_GamePlayer) -> PlayerStrategies: - obj: PlayerStrategies = PlayerStrategies.__new__(PlayerStrategies) - obj.player = player - return obj - - def __repr__(self) -> str: - return f"PlayerStrategies(player={Player.wrap(self.player)})" - - def __len__(self) -> int: - """The number of strategies for the player in the game.""" - return self.player.deref().GetStrategies().size() - - def __iter__(self) -> typing.Iterator[str]: - for strategy in self.player.deref().GetStrategies(): - yield strategy.deref().GetLabel().decode("utf-8") - - def __contains__(self, label: str) -> bool: - return any( - strategy.deref().GetLabel().decode("utf-8") == label - for strategy in self.player.deref().GetStrategies() - ) - - -@cython.cclass -class PlayerSequences: - """The collection of sequences available to a player.""" - player = cython.declare(c_GamePlayer) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create PlayerSequences outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(player: c_GamePlayer) -> PlayerSequences: - obj: PlayerSequences = PlayerSequences.__new__(PlayerSequences) - obj.player = player - return obj - - def __repr__(self) -> str: - return f"PlayerSequences(player={Player.wrap(self.player)})" - - def __len__(self) -> int: - """The number of sequences for the player in the game.""" - return self.player.deref().GetSequences().size() - - def __iter__(self) -> typing.Iterator[Sequence]: - for sequence in self.player.deref().GetSequences(): - yield Sequence.wrap(sequence) - - -@cython.cclass -class Player: - """A player in a ``Game``.""" - player = cython.declare(c_GamePlayer) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create a Player outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(player: c_GamePlayer) -> Player: - obj: Player = Player.__new__(Player) - obj.player = player - return obj - - def __repr__(self) -> str: - if self.is_chance: - return f"ChancePlayer(game={self.game})" - if self.label: - return f"Player(game={self.game}, label='{self.label}')" - else: - return f"Player(game={self.game}, number={self.number})" - - def __eq__(self, other: typing.Any): - if not isinstance(other, Player): - return NotImplemented - return self.player.deref() == cython.cast(Player, other).player.deref() - - def __hash__(self) -> int: - return cython.cast(cython.long, self.player.deref()) - - @property - def game(self) -> Game: - """Gets the ``Game`` to which the player belongs.""" - return Game.wrap(self.player.deref().GetGame()) - - @property - def label(self) -> str: - """The text label of the player. - - .. 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 game's - players; use `Game.relabel_players` to change it. - """ - return self.player.deref().GetLabel().decode("utf-8") - - @property - def number(self) -> int: - """Returns the number of the player in its game. - Players are numbered starting with 0. - """ - return self.player.deref().GetNumber() - 1 - - @property - def is_chance(self) -> bool: - """Returns whether the player is the chance player.""" - return self.player.deref().IsChance() != 0 - - @property - def strategies(self) -> PlayerStrategies: - """Returns the collection of strategies belonging to the player.""" - return PlayerStrategies.wrap(self.player) - - @property - def sequences(self) -> PlayerSequences: - """Returns the collection of sequences belonging to the player.""" - return PlayerSequences.wrap(self.player) - - @property - def min_payoff(self) -> Rational: - """Returns the smallest payoff for the player in any play of the game. - - .. versionchanged:: 16.5.0 - Changed from reporting minimum payoff in any (non-null) outcome to the minimum - payoff in any play of the game. - - See Also - -------- - Player.max_payoff - Game.min_payoff - """ - return rat_to_py(self.player.deref().GetGame().deref().GetPlayerMinPayoff(self.player)) - - @property - def max_payoff(self) -> Rational: - """Returns the largest payoff for the player in any play of the game. - - .. versionchanged:: 16.5.0 - Changed from reporting maximum payoff in any (non-null) outcome to the maximum - payoff in any play of the game. - - See Also - -------- - Player.min_payoff - Game.max_payoff - """ - return rat_to_py(self.player.deref().GetGame().deref().GetPlayerMaxPayoff(self.player)) diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index 585cc7804..22de0655e 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] for p in data.game.players for s in p.strategies + data[p][s] for p in data.game.players for s in data.game.get_strategies(p) ] strategy_regrets = data.normalize().strategy_regrets - regrets = [[-strategy_regrets[player.label][s] for s in player.strategies] + regrets = [[-strategy_regrets[player][s] for s in data.game.get_strategies(player)] for player in data.game.players] res = scipy.optimize.minimize( lambda x: -_empirical_log_like(x[0], regrets, flattened_data), @@ -245,7 +245,9 @@ 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: math.exp(next(log_probs)) for s in player.strategies} + profile[player] = { + s: math.exp(next(log_probs)) for s in data.game.get_strategies(player) + } return LogitQREMixedStrategyFitResult( data, "empirical", res.x[0], profile, -res.fun ) @@ -257,14 +259,14 @@ def _estimate_behavior_empirical( flattened_data = [ data[node][a] for p in data.game.players - for node in data.game.get_infosets(p.label) + for node in data.game.get_infosets(p) for a in node.infoset.actions ] normalized = data.normalize() regrets = [ [-normalized.action_regrets[node][a] for a in node.infoset.actions] for player in data.game.players - for node in data.game.get_infosets(player.label) + for node in data.game.get_infosets(player) ] res = scipy.optimize.minimize( lambda x: -_empirical_log_like(x[0], regrets, flattened_data), @@ -274,7 +276,7 @@ def _estimate_behavior_empirical( profile = data.game.mixed_behavior_profile() log_probs = iter(_empirical_log_logit_probs(res.x[0], regrets)) for player in data.game.players: - for node in data.game.get_infosets(player.label): + for node in data.game.get_infosets(player): profile[node] = { a: math.exp(next(log_probs)) for a in node.infoset.actions } diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index 4d891de9c..6e506fc4e 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -66,9 +66,8 @@ class StrategyBehavior: def _action_at(self, infoset: Infoset) -> str | None: """The label of 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" + self._player_label, self._strategy_label, "StrategyBehavior" ) action: c_GameAction = handle.deref().GetAction(cython.cast(Infoset, infoset)._resolve()) if not action: @@ -84,7 +83,7 @@ class StrategyBehavior: raise MismatchError("StrategyBehavior: key must be part of the same game") else: infoset = self._game._resolve_infoset(key, "StrategyBehavior", "key") - if infoset.player.label != self._player_label: + if infoset.player != self._player_label: raise ValueError( f"Player '{self._player_label}' does not have the move at {infoset}." ) @@ -148,7 +147,7 @@ class StrategyBehavior: @cython.cclass class Sequence: - """A sequence ``Player`` in a ``Game``. + """A sequence for a player in a ``Game``. .. versionadded:: 16.7.0 """ @@ -165,7 +164,7 @@ class Sequence: return obj def __repr__(self) -> str: - return f"Sequence(player={self.player}, actions={self.actions})" + return f"Sequence(player='{self.player}', actions={self.actions})" def __eq__(self, other: typing.Any) -> bool: return ( @@ -182,9 +181,9 @@ class Sequence: return Game.wrap(self.sequence.deref().GetPlayer().deref().GetGame()) @property - def player(self) -> Player: - """The player to which the sequence belongs.""" - return Player.wrap(self.sequence.deref().GetPlayer()) + def player(self) -> str: + """The label of the player to which the sequence belongs.""" + return self.sequence.deref().GetPlayer().deref().GetLabel().decode("utf-8") @property def parent(self) -> Sequence | None: @@ -197,7 +196,7 @@ class Sequence: def children(self) -> list[Sequence]: """The immediate children (successors) of the sequence.""" ret: list[Sequence] = [] - for seq in self.player.sequences: + for seq in self.game.get_sequences(self.player): if seq.parent == self: ret.append(seq) return ret diff --git a/src/pygambit/stratmixed.pxi b/src/pygambit/stratmixed.pxi index 97c578e76..4e8cda088 100644 --- a/src/pygambit/stratmixed.pxi +++ b/src/pygambit/stratmixed.pxi @@ -72,7 +72,7 @@ class MixedStrategy: and can no longer be assigned into. Set a player's whole distribution via ``MixedStrategyProfile.__setitem__`` instead. """ - _player = cython.declare(Player) + _player = cython.declare(str) _values = cython.declare(dict) def __init__(self, *args, **kwargs) -> None: @@ -80,15 +80,15 @@ class MixedStrategy: @staticmethod @cython.cfunc - def wrap(player: Player, values: dict) -> MixedStrategy: + def wrap(player: str, values: dict) -> MixedStrategy: obj: MixedStrategy = MixedStrategy.__new__(MixedStrategy) obj._player = player obj._values = values return obj @property - def player(self) -> Player: - """The player for whom this mixed strategy is defined.""" + def player(self) -> str: + """The label of the player for whom this mixed strategy is defined.""" return self._player def __repr__(self) -> str: @@ -189,14 +189,14 @@ class MixedStrategyProfile: raise ValueError("Cannot create a MixedStrategyProfile outside a Game.") def __repr__(self) -> str: - return str({player.label: self[player.label] for player in self.game.players}) + return str({player: self[player] for player in self.game.players}) def _repr_latex_(self) -> str: return ( r"$\left\{" + ",".join( - r"\text{" + player.label + "}:" + - self[player.label]._repr_latex_().replace("$", "") + r"\text{" + player + "}:" + + self[player]._repr_latex_().replace("$", "") for player in self.game.players ) + r"\right\}$" @@ -222,7 +222,7 @@ class MixedStrategyProfile: The player's mixed strategy specified in the profile """ for player in self.game.players: - yield self[player.label] + yield self[player] def __getitem__(self, player: str) -> MixedStrategy: """Returns a snapshot of the mixed strategy for the player with label `player`, @@ -241,15 +241,14 @@ class MixedStrategyProfile: If `player` is not a str. """ self._check_validity() - resolved_player = self.game._resolve_player(player, "__getitem__") values = { - s: self._getprob_strategy(resolved_player, s) - for s in resolved_player.strategies + s: self._getprob_strategy(player, s) + for s in self.game.get_strategies(player) } - return MixedStrategy.wrap(resolved_player, values) + return MixedStrategy.wrap(player, values) def _setprob_player( - self, player: Player, distribution: collections.abc.Mapping, sparse: bool + self, player: str, distribution: collections.abc.Mapping, sparse: bool ) -> None: """Validates and sets the whole mixed strategy for player. @@ -264,7 +263,8 @@ class MixedStrategyProfile: f"a mixed strategy must be set from a Mapping from strategy label to " f"weight, not {distribution.__class__.__name__}" ) - labels = set(player.strategies) + strategies = self.game.get_strategies(player) + labels = set(strategies) given = set(distribution.keys()) unknown = given - labels if unknown: @@ -283,7 +283,7 @@ class MixedStrategyProfile: raise ValueError("a mixed strategy's weights must be non-negative") 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: + for s in strategies: self._setprob_strategy(player, s, values[s]) def __setitem__(self, player: str, distribution: collections.abc.Mapping) -> None: @@ -322,8 +322,9 @@ class MixedStrategyProfile: defaulting omitted ones to zero. """ self._check_validity() - resolved_player = self.game._resolve_player(player, "__setitem__") - self._setprob_player(resolved_player, distribution, sparse=True) + game: Game = self.game + game._resolve_player(player, "__setitem__") # validate eagerly + self._setprob_player(player, distribution, sparse=True) def set_mixed_strategy( self, player: str, distribution: collections.abc.Mapping, sparse: bool = False @@ -368,8 +369,9 @@ class MixedStrategyProfile: __setitem__ """ self._check_validity() - resolved_player = self.game._resolve_player(player, "set_mixed_strategy") - self._setprob_player(resolved_player, distribution, sparse=sparse) + game: Game = self.game + game._resolve_player(player, "set_mixed_strategy") # validate eagerly + self._setprob_player(player, distribution, sparse=sparse) @property def payoffs(self) -> PayoffVector: @@ -377,7 +379,7 @@ class MixedStrategyProfile: the profile. """ self._check_validity() - return PayoffVector({p.label: self._payoff(p) for p in self.game.players}) + return PayoffVector({p: self._payoff(p) for p in self.game.players}) @property def strategy_values(self) -> StrategyValuesVector: @@ -386,8 +388,8 @@ class MixedStrategyProfile: """ self._check_validity() return StrategyValuesVector({ - p.label: StrategyValueVector({ - s: self._strategy_value(p, s) for s in p.strategies + p: StrategyValueVector({ + s: self._strategy_value(p, s) for s in self.game.get_strategies(p) }) for p in self.game.players }) @@ -408,8 +410,8 @@ class MixedStrategyProfile: """ self._check_validity() return StrategyRegretsVector({ - p.label: StrategyRegretVector({ - s: self._strategy_regret(p, s) for s in p.strategies + p: StrategyRegretVector({ + s: self._strategy_regret(p, s) for s in self.game.get_strategies(p) }) for p in self.game.players }) @@ -425,7 +427,7 @@ class MixedStrategyProfile: max_regret """ self._check_validity() - return PlayerRegretVector({p.label: self._player_regret(p) for p in self.game.players}) + return PlayerRegretVector({p: self._player_regret(p) for p in self.game.players}) def max_regret(self) -> ProfileDType: """Returns the maximum regret of any player. @@ -539,11 +541,11 @@ class MixedStrategyProfile: """The game on which this profile is defined.""" raise NotImplementedError - def _getprob_strategy(self, player: Player, label: str) -> ProfileDType: + def _getprob_strategy(self, player: str, label: str) -> ProfileDType: """Returns the probability with which player's strategy `label` is played.""" raise NotImplementedError - def _setprob_strategy(self, player: Player, label: str, value: typing.Any) -> None: + def _setprob_strategy(self, player: str, label: str, value: typing.Any) -> None: """Sets the probability with which player's strategy `label` is played.""" raise NotImplementedError @@ -553,19 +555,19 @@ class MixedStrategyProfile: """ raise NotImplementedError - def _payoff(self, player: Player) -> ProfileDType: + def _payoff(self, player: str) -> ProfileDType: """Returns the expected payoff to player.""" raise NotImplementedError - def _strategy_value(self, player: Player, label: str) -> ProfileDType: + def _strategy_value(self, player: str, label: str) -> ProfileDType: """Returns the expected payoff to playing player's strategy `label`.""" raise NotImplementedError - def _strategy_regret(self, player: Player, label: str) -> ProfileDType: + def _strategy_regret(self, player: str, label: str) -> ProfileDType: """Returns the regret to playing player's strategy `label`.""" raise NotImplementedError - def _player_regret(self, player: Player) -> ProfileDType: + def _player_regret(self, player: str) -> ProfileDType: """Returns the regret of player for playing their mixed strategy.""" raise NotImplementedError @@ -616,7 +618,7 @@ class MixedStrategyProfileDouble(MixedStrategyProfile): def __len__(self) -> int: return len(self.game.players) - def _getprob_strategy(self, player: Player, label: str) -> float: + def _getprob_strategy(self, player: str, 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) @@ -629,7 +631,7 @@ class MixedStrategyProfileDouble(MixedStrategyProfile): if self.profile.use_count() != 1: self.profile = make_shared[c_MixedStrategyProfile[double]](deref(self.profile)) - def _setprob_strategy(self, player: Player, label: str, value) -> None: + def _setprob_strategy(self, player: str, label: str, value) -> None: game: Game = cython.cast(Game, self.game) handle = game._resolve_strategy(player, label, "_setprob_strategy") self._ensure_unshared() @@ -643,21 +645,23 @@ class MixedStrategyProfileDouble(MixedStrategyProfile): # normalized is a fraction-form string (e.g. "1/2"), which float() rejects return float(Rational(normalized)) - def _payoff(self, player: Player) -> float: - return deref(self.profile).GetPayoff(player.player) + def _payoff(self, player: str) -> float: + game: Game = cython.cast(Game, self.game) + return deref(self.profile).GetPayoff(game._resolve_player(player, "_payoff")) - def _strategy_value(self, player: Player, label: str) -> float: + def _strategy_value(self, player: str, 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, player: Player, label: str) -> float: + def _strategy_regret(self, player: str, 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) + def _player_regret(self, player: str) -> float: + game: Game = cython.cast(Game, self.game) + return deref(self.profile).GetRegret(game._resolve_player(player, "_player_regret")) def _max_regret(self) -> float: return deref(self.profile).GetMaxRegret() @@ -716,7 +720,7 @@ class MixedStrategyProfileRational(MixedStrategyProfile): def __len__(self) -> int: return len(self.game.players) - def _getprob_strategy(self, player: Player, label: str) -> Rational: + def _getprob_strategy(self, player: str, 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)) @@ -729,7 +733,7 @@ class MixedStrategyProfileRational(MixedStrategyProfile): if self.profile.use_count() != 1: self.profile = make_shared[c_MixedStrategyProfile[c_Rational]](deref(self.profile)) - def _setprob_strategy(self, player: Player, label: str, value) -> None: + def _setprob_strategy(self, player: str, 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__)) @@ -742,21 +746,24 @@ class MixedStrategyProfileRational(MixedStrategyProfile): def _to_prob(self, value: typing.Any) -> Rational: return Rational(_to_number_string(value)) - def _payoff(self, player: Player) -> Rational: - return rat_to_py(deref(self.profile).GetPayoff(player.player)) + def _payoff(self, player: str) -> Rational: + game: Game = cython.cast(Game, self.game) + return rat_to_py(deref(self.profile).GetPayoff(game._resolve_player(player, "_payoff"))) - def _strategy_value(self, player: Player, label: str) -> Rational: + def _strategy_value(self, player: str, 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, player: Player, label: str) -> Rational: + def _strategy_regret(self, player: str, 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)) + def _player_regret(self, player: str) -> Rational: + game: Game = cython.cast(Game, self.game) + resolved_player = game._resolve_player(player, "_player_regret") + return rat_to_py(deref(self.profile).GetRegret(resolved_player)) def _max_regret(self) -> Rational: return rat_to_py(deref(self.profile).GetMaxRegret()) @@ -783,8 +790,9 @@ class MixedStrategyProfileRational(MixedStrategyProfile): def _as_float(self) -> MixedStrategyProfileDouble: profile: MixedStrategyProfileDouble = self.game.mixed_strategy_profile() for player in self.game.players: - profile[player.label] = { - s: float(self._getprob_strategy(player, s)) for s in player.strategies + profile[player] = { + s: float(self._getprob_strategy(player, s)) + for s in self.game.get_strategies(player) } return profile diff --git a/src/pygambit/stratspt.pxi b/src/pygambit/stratspt.pxi index 296a3e46b..ed609ec4d 100644 --- a/src/pygambit/stratspt.pxi +++ b/src/pygambit/stratspt.pxi @@ -39,7 +39,7 @@ class StrategySupport: labels. Iterates over strategy labels (``str``) rather than ``Strategy`` objects. """ - _player = cython.declare(Player) + _player = cython.declare(str) _strategies = cython.declare(tuple) def __init__(self, *args, **kwargs) -> None: @@ -47,14 +47,14 @@ class StrategySupport: @staticmethod @cython.cfunc - def wrap(player: Player, strategies: tuple) -> StrategySupport: + def wrap(player: str, strategies: tuple) -> StrategySupport: obj: StrategySupport = StrategySupport.__new__(StrategySupport) obj._player = player obj._strategies = strategies return obj @property - def player(self) -> Player: + def player(self) -> str: return self._player def __repr__(self) -> str: @@ -122,7 +122,7 @@ class StrategySupportProfile: The player's strategy support specified in the profile """ for player in self.game.players: - yield self[player.label] + yield self[player] def __getitem__(self, player: str) -> StrategySupport: """Return a `StrategySupport` representing the labels of the strategies in the @@ -139,12 +139,15 @@ class StrategySupportProfile: KeyError If no player in the game has the label `player`. """ - resolved_player: Player = self.game.players[player] + game: Game = self.game + resolved_player: c_GamePlayer = game._resolve_player( + player, "StrategySupportProfile.__getitem__" + ) strategies = tuple( s.deref().GetLabel().decode("utf-8") - for s in deref(self.profile).GetStrategies(resolved_player.player) + for s in deref(self.profile).GetStrategies(resolved_player) ) - return StrategySupport.wrap(resolved_player, strategies) + return StrategySupport.wrap(player, strategies) @cython.cfunc def _ensure_unshared(self) -> cython.void: @@ -155,13 +158,15 @@ class StrategySupportProfile: self.profile = make_shared[c_StrategySupportProfile](deref(self.profile)) @cython.cfunc - def _set_support(self, player: Player, strategies: object) -> cython.void: + def _set_support(self, player: str, strategies: object) -> cython.void: """Validates and sets the whole support for player. Every entry of `strategies` must be one of the player's strategy labels, and at least one must be given. """ - labels = set(player.strategies) + game: Game = self.game + player_strategies = game.get_strategies(player) + labels = set(player_strategies) given = set(strategies) unknown = given - labels if unknown: @@ -171,13 +176,12 @@ 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: + for s in player_strategies: if s in given: deref(self.profile).AddStrategy(game._resolve_strategy(player, s, "_set_support")) - for s in player.strategies: + for s in player_strategies: if s not in given: deref(self.profile).RemoveStrategy( game._resolve_strategy(player, s, "_set_support") @@ -206,8 +210,7 @@ class StrategySupportProfile: If any entry of `strategies` is not one of the player's strategy labels, or if `strategies` is empty. """ - resolved_player: Player = self.game.players[player] - self._set_support(resolved_player, strategies) + self._set_support(player, strategies) def copy(self) -> StrategySupportProfile: """Creates a copy of the support profile. @@ -258,9 +261,8 @@ class StrategySupportProfile: If no player in the game has the label `player`, or the player has no strategy with the label `strategy`. """ - game: Game = cython.cast(Game, self.game) - resolved_player: Player = game.players[player] - handle = game._resolve_strategy(resolved_player, strategy, "is_dominated", "strategy") + game: Game = self.game + handle = game._resolve_strategy(player, strategy, "is_dominated", "strategy") return deref(self.profile).IsDominated(handle, strict, external) diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index b5b5707dc..9d913b2ef 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -18,12 +18,12 @@ def _table_game(payoffs: dict, title: str) -> gbt.Game: game = gbt.Game.new_table([2, 2]) game.title = title p1, p2 = game.players - s1a, s1b = p1.strategies - s2a, s2b = p2.strategies + s1a, s1b = game.get_strategies(p1) + s2a, s2b = game.get_strategies(p2) strategies = {"a": s1a, "b": s1b, "A": s2a, "B": s2b} for (row, col), (v1, v2) in payoffs.items(): game.make_outcome( - {p1.label: strategies[row], p2.label: strategies[col]}, {p1: v1, p2: v2}, f"{row}{col}" + {p1: strategies[row], p2: 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 02dc824f2..1b490dd5c 100644 --- a/tests/cli/test_common.py +++ b/tests/cli/test_common.py @@ -166,6 +166,6 @@ def test_strategy_support_partial_support(self, nfg_matching_pennies_text): game = gbt.read_nfg(io.BytesIO(nfg_matching_pennies_text.encode())) support = game.strategy_support_profile() p1 = next(iter(game.players)) - first_strategy = next(iter(p1.strategies)) - support[p1.label] = [first_strategy] + first_strategy = next(iter(game.get_strategies(p1))) + support[p1] = [first_strategy] assert common.render_support_csv(support, "candidate") == "candidate,10,11" diff --git a/tests/games.py b/tests/games.py index 1bb6f9382..07fc521c7 100644 --- a/tests/games.py +++ b/tests/games.py @@ -12,19 +12,19 @@ def all_infosets(game: gbt.Game) -> list[gbt.Infoset]: """All Infosets belonging to a personal player, across every player, in the canonical order `Game.infosets` used to yield before its removal (17.0.0).""" - return [n.infoset for p in game.players for n in game.get_infosets(p.label)] + return [n.infoset for p in game.players for n in game.get_infosets(p)] -def player_infosets(player: gbt.Player) -> list[gbt.Infoset]: +def player_infosets(game: gbt.Game, player: str) -> list[gbt.Infoset]: """All Infosets belonging to `player`, in canonical order, matching `Player.infosets` before its removal (17.0.0).""" - return [n.infoset for n in player.game.get_infosets(player.label)] + return [n.infoset for n in game.get_infosets(player)] -def find_infoset(player: gbt.Player, label: str) -> gbt.Infoset: +def find_infoset(game: gbt.Game, player: str, label: str) -> gbt.Infoset: """Find the Infoset belonging to `player` with the given label, matching `Player.infosets[label]` before its removal (17.0.0).""" - return next(i for i in player_infosets(player) if i.label == label) + return next(i for i in player_infosets(game, player) if i.label == label) def find_infoset_in_game(game: gbt.Game, label: str) -> gbt.Infoset: @@ -546,7 +546,7 @@ def get_map_test_data(cls, **params): game = cls(params) gbt_game = game.gbt_game() maps = [ - [tuple(sig) if len(gbt_game.get_infosets(player.label)) > 0 else () for sig in sigs] + [tuple(sig) if len(gbt_game.get_infosets(player)) > 0 else () for sig in sigs] for player, sigs in zip(gbt_game.players, game.reduced_strategies(), strict=True) ] return (gbt_game, maps) @@ -725,7 +725,7 @@ def gbt_game(self): left = n.children["L"] g.make_infoset( list(left.infoset.members) + [n.children["R"]], - left.infoset.player.label, + left.infoset.player, left.infoset.label or None, ) return g diff --git a/tests/test_actions.py b/tests/test_actions.py index 6c7b1e3c0..970f21b08 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -80,8 +80,8 @@ def test_relabel_actions_scope_is_the_information_set(): and free to take the same new label. """ game = games.create_stripped_down_poker_efg() - king = games.find_infoset(game.players["Alice"], "Alice has King") - queen = games.find_infoset(game.players["Alice"], "Alice has Queen") + king = games.find_infoset(game, "Alice", "Alice has King") + queen = games.find_infoset(game, "Alice", "Alice has Queen") game.relabel_actions(next(iter(king.members)), {"Bet": "Raise"}) assert list(king.actions) == ["Raise", "Fold"] assert list(queen.actions) == ["Bet", "Fold"] @@ -104,7 +104,7 @@ def test_relabel_actions_non_str_label_raises_typeerror(labels: dict): def test_set_move_actions_drop_shrinks_actions_and_children(): game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game.players["Alice"], "Alice has King") + infoset = games.find_infoset(game, "Alice", "Alice has King") node = next(iter(infoset.members)) action_count = len(infoset.actions) remaining = list(infoset.actions)[1:] @@ -115,7 +115,7 @@ def test_set_move_actions_drop_shrinks_actions_and_children(): def test_set_move_actions_cannot_remove_the_only_action(): game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game.players["Alice"], "Alice has King") + infoset = games.find_infoset(game, "Alice", "Alice has King") node = next(iter(infoset.members)) last = next(iter(infoset.actions)) game.set_move_actions(node, [last], drop=True) @@ -144,19 +144,19 @@ def test_set_move_actions_reorder_carries_subtrees(): def test_set_move_actions_add_drop_and_reorder_together(): game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game.players["Alice"], "Alice has King") + infoset = games.find_infoset(game, "Alice", "Alice has King") node = next(iter(infoset.members)) nodes_before = len(game.nodes) game.set_move_actions(node, ["Raise", "Fold"], drop=True) assert list(infoset.actions) == ["Raise", "Fold"] # "Bet" and its subtree (Bob's node and its two terminals) go; "Raise" adds one. assert len(game.nodes) == nodes_before - 3 + 1 - assert len(games.find_infoset(game.players["Bob"], "Bob's response").members) == 1 + assert len(games.find_infoset(game, "Bob", "Bob's response").members) == 1 def test_set_move_actions_unconfirmed_drop_and_disabled_add_raise(): game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game.players["Alice"], "Alice has King") + infoset = games.find_infoset(game, "Alice", "Alice has King") node = next(iter(infoset.members)) before = game.to_efg() with pytest.raises(ValueError): @@ -179,7 +179,7 @@ def test_set_move_actions_bad_labels_raise_and_leave_game_unchanged(bad_labels): """Duplicate, empty, and invalid labels in `actions` are rejected in C++, after the Python guards pass; the game must be unmodified by the failure.""" game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game.players["Alice"], "Alice has King") + infoset = games.find_infoset(game, "Alice", "Alice has King") node = next(iter(infoset.members)) before = game.to_efg() with pytest.raises(ValueError): @@ -242,7 +242,7 @@ def test_set_event_actions_raises_at_a_move(): """`set_event_actions` is only for an event; `set_move_actions` is the corresponding operation for a personal player's move.""" game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game.players["Alice"], "Alice has King") + infoset = games.find_infoset(game, "Alice", "Alice has King") with pytest.raises(ValueError): game.set_event_actions(next(iter(infoset.members)), {"Bet": 1}) @@ -348,8 +348,7 @@ def test_get_behavior_raises_value_error_for_wrong_player( Verify `Game.get_behavior`'s result raises ValueError when the infoset belongs to a different player than the strategy. """ - player = game.players[player_label] - behavior = game.get_behavior(player_label, next(iter(player.strategies))) + behavior = game.get_behavior(player_label, next(iter(game.get_strategies(player_label)))) node = game.root for action_label in other_infoset_path: node = node.children[action_label] diff --git a/tests/test_agg.py b/tests/test_agg.py index 7c5e76e57..65cf7ae65 100644 --- a/tests/test_agg.py +++ b/tests/test_agg.py @@ -57,26 +57,26 @@ def test_agg_fraction_and_long_decimal_payoffs_parsed_exactly(): assert game.max_payoff == gbt.Rational(1, 3) assert game.min_payoff == -10 for player in game.players: - assert player.max_payoff == gbt.Rational(1, 3) - assert player.min_payoff == -10 + assert game.get_max_payoff(player) == gbt.Rational(1, 3) + assert game.get_min_payoff(player) == -10 # pure-strategy payoff lookup (AGGPureStrategyProfileRep::GetPayoff) also reports the # exact value, not a double-rounded approximation -- for both the fraction and the long # decimal payoff p0, p1 = game.players - s0 = list(p0.strategies) - s1 = list(p1.strategies) + s0 = game.get_strategies(p0) + s1 = game.get_strategies(p1) profile = game.mixed_strategy_profile(rational=True) - 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[p0] = {s0[0]: gbt.Rational(1), s0[1]: gbt.Rational(0)} + profile[p1] = {s1[0]: gbt.Rational(0), s1[1]: gbt.Rational(1)} + assert profile.payoffs[p0] == gbt.Rational(1, 3) profile = game.mixed_strategy_profile(rational=True) - 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") + profile[p0] = {s0[0]: gbt.Rational(0), s0[1]: gbt.Rational(1)} + profile[p1] = {s1[0]: gbt.Rational(0), s1[1]: gbt.Rational(1)} + assert profile.payoffs[p0] == gbt.Rational("0.123456789012345") + assert profile.payoffs[p1] == gbt.Rational("0.123456789012345") def test_bagg_fraction_type_distribution_parsed_exactly(): @@ -91,12 +91,14 @@ def test_bagg_fraction_type_distribution_parsed_exactly(): exact = game.mixed_strategy_profile(rational=True) 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]: 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] + s0 = game.get_strategies(p1t0) + s1 = game.get_strategies(p1t1) + s2 = game.get_strategies(p2) + profile[p1t0] = {s0[0]: one, s0[1]: zero} + profile[p1t1] = {s1[0]: zero, s1[1]: one} + profile[p2] = {s2[0]: one, s2[1]: zero} + assert exact.payoffs[p2] == gbt.Rational(22) + assert float(exact.payoffs[p2]) == dbl.payoffs[p2] @pytest.mark.parametrize("game_path", ["2x2.agg", "2x2.bagg"]) @@ -112,12 +114,12 @@ def test_agg_bagg_mixed_strategy_profile_rational_exact_payoff(game_path): game = games.read_from_file(game_path) profile = game.mixed_strategy_profile(rational=True) for player in game.players: - strategies = list(player.strategies) - profile[player.label] = { + strategies = game.get_strategies(player) + profile[player] = { 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) + assert profile.payoffs[player] == gbt.Rational(-5, 11) assert profile.max_regret() == 0 @@ -136,23 +138,23 @@ def test_agg_bagg_rational_algorithms_find_exact_mixed_equilibrium(game_path): mixed = [ eq for eq in result.equilibria if any( - 0 < eq[p.label][s] < 1 for p in game.players for s in p.strategies + 0 < eq[p][s] < 1 for p in game.players for s in game.get_strategies(p) ) ] assert len(mixed) == 1 for player in game.players: - for strategy in player.strategies: - assert mixed[0][player.label][strategy] in ( + for strategy in game.get_strategies(player): + assert mixed[0][player][strategy] in ( gbt.Rational(10, 11), gbt.Rational(1, 11) ) assert mixed[0].max_regret() == 0 -def _set_pure_profile(profile, players, contingency): +def _set_pure_profile(game, profile, players, contingency): for player, strat_index in zip(players, contingency, strict=True): - profile[player.label] = { + profile[player] = { strategy: gbt.Rational(1) if i == strat_index else gbt.Rational(0) - for i, strategy in enumerate(player.strategies) + for i, strategy in enumerate(game.get_strategies(player)) } @@ -164,16 +166,18 @@ 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)): + for contingency in itertools.product( + *(range(len(game.get_strategies(p))) for p in players) + ): labeled = { - p.label: list(p.strategies)[i] + p: game.get_strategies(p)[i] for p, i in zip(players, contingency, strict=True) } - pure_payoffs = [game.get_payoffs(labeled)[p.label] for p in players] + pure_payoffs = [game.get_payoffs(labeled)[p] for p in players] profile = game.mixed_strategy_profile(rational=True) - _set_pure_profile(profile, players, contingency) - mixed_payoffs = [profile.payoffs[p.label] for p in players] + _set_pure_profile(game, profile, players, contingency) + mixed_payoffs = [profile.payoffs[p] for p in players] assert pure_payoffs == mixed_payoffs @@ -185,7 +189,7 @@ def test_bagg_pure_strategy_payoff_with_multiple_players_and_types(): """ game = games.read_from_file("Bayesian-Coffee-3-2-2-3.bagg") players = list(game.players) - sizes = [len(list(p.strategies)) for p in players] + sizes = [len(game.get_strategies(p)) for p in players] contingencies = [ tuple(0 for _ in sizes), tuple(size - 1 for size in sizes), @@ -193,13 +197,13 @@ def test_bagg_pure_strategy_payoff_with_multiple_players_and_types(): ] for contingency in contingencies: labeled = { - p.label: list(p.strategies)[i] + p: game.get_strategies(p)[i] for p, i in zip(players, contingency, strict=True) } - pure_payoffs = [game.get_payoffs(labeled)[p.label] for p in players] + pure_payoffs = [game.get_payoffs(labeled)[p] for p in players] profile = game.mixed_strategy_profile(rational=True) - _set_pure_profile(profile, players, contingency) - mixed_payoffs = [profile.payoffs[p.label] for p in players] + _set_pure_profile(game, profile, players, contingency) + mixed_payoffs = [profile.payoffs[p] for p in players] assert pure_payoffs == mixed_payoffs diff --git a/tests/test_behav.py b/tests/test_behav.py index d9450c137..6beeb78aa 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -36,7 +36,7 @@ def test_payoffs_reference(game: gbt.Game, rational_flag: bool, payoffs: tuple): profile = game.mixed_behavior_profile(rational=rational_flag) for payoff, player in zip(payoffs, game.players, strict=True): payoff = gbt.Rational(payoff) if rational_flag else payoff - assert profile.payoffs[player.label] == payoff + assert profile.payoffs[player] == payoff @pytest.mark.parametrize( @@ -201,7 +201,7 @@ def test_profile_indexing_by_player_infoset_action_reference( rational_flag: bool, ): profile = game.mixed_behavior_profile(rational=rational_flag) - infoset = games.find_infoset(game.players[player_label], infoset_label) + infoset = games.find_infoset(game, player_label, infoset_label) node = next(iter(infoset.members)) prob = gbt.Rational(prob) if rational_flag else prob assert profile[node][action_label] == prob @@ -265,8 +265,7 @@ def test_profile_indexing_by_node_reference( ): """profile[node] and profile[player_label][node] resolve to the same MixedAction.""" profile = game.mixed_behavior_profile(rational=rational_flag) - player = game.players[player_label] - infoset = games.find_infoset(player, infoset_label) + infoset = games.find_infoset(game, player_label, infoset_label) node = next(iter(infoset.members)) probs = [gbt.Rational(prob) for prob in probs] if rational_flag else probs expected = dict(zip(infoset.actions, probs, strict=True)) @@ -288,7 +287,7 @@ def test_behavior_indexing_rejects_node_from_different_player( different player than the one being indexed. """ profile = game.mixed_behavior_profile() - other_infoset = games.player_infosets(game.players[other_player_label])[0] + other_infoset = games.player_infosets(game, other_player_label)[0] other_node = next(iter(other_infoset.members)) with pytest.raises(gbt.MismatchError): profile[player_label][other_node] @@ -315,10 +314,11 @@ def test_profile_indexing_by_player_label_reference( profile = game.mixed_behavior_profile(rational=rational_flag) if rational_flag: behav_data = [[gbt.Rational(prob) for prob in probs] for probs in behav_data] - player = game.players[player_label] expected = [ dict(zip(infoset.actions, probs, strict=True)) - for infoset, probs in zip(games.player_infosets(player), behav_data, strict=True) + for infoset, probs in zip( + games.player_infosets(game, player_label), behav_data, strict=True + ) ] assert profile[player_label] == expected @@ -434,7 +434,7 @@ def test_set_probabilities_infoset( profile = game.mixed_behavior_profile(rational=rational_flag) if rational_flag: probs = [gbt.Rational(p) for p in probs] - infoset = games.find_infoset(game.players[player_label], infoset_label) + infoset = games.find_infoset(game, player_label, infoset_label) node = next(iter(infoset.members)) expected = dict(zip(infoset.actions, probs, strict=True)) profile[node] = expected @@ -466,18 +466,21 @@ def test_set_probabilities_player_by_label( profile = game.mixed_behavior_profile(rational=rational_flag) if rational_flag: behav_data = [[gbt.Rational(prob) for prob in probs] for probs in behav_data] - player = game.players[player_label] expected = [ dict(zip(infoset.actions, probs, strict=True)) - for infoset, probs in zip(games.player_infosets(player), behav_data, strict=True) + for infoset, probs in zip( + games.player_infosets(game, player_label), behav_data, strict=True + ) ] - for infoset, distribution in zip(games.player_infosets(player), expected, strict=True): + for infoset, distribution in zip( + games.player_infosets(game, player_label), expected, strict=True + ): profile[next(iter(infoset.members))] = distribution assert profile[player_label] == expected def _p1_node(game: gbt.Game): - return next(iter(games.player_infosets(game.players["Player 1"])[0].members)) + return next(iter(games.player_infosets(game, "Player 1")[0].members)) def test_behavior_setitem_allows_sparse_distribution(): @@ -562,7 +565,7 @@ def test_behavior_indexing_rejects_infoset_object(sparse: bool): """MixedBehaviorProfile's indexing is Node-only; an Infoset object is rejected.""" game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - infoset = games.player_infosets(game.players["Player 1"])[0] + infoset = games.player_infosets(game, "Player 1")[0] with pytest.raises(TypeError): profile[infoset] with pytest.raises(TypeError): @@ -829,7 +832,7 @@ def test_action_values_reference(game: gbt.Game, rational_flag: bool, action_val def test_action_regret_consistency(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) for player in game.players: - for infoset in games.player_infosets(player): + for infoset in games.player_infosets(game, player): node = next(iter(infoset.members)) for action in infoset.actions: assert profile.action_regrets[node][action] == max( @@ -851,7 +854,7 @@ def test_action_regret_consistency(game: gbt.Game, rational_flag: bool): def test_infoset_regret_consistency(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) for player in game.players: - for infoset in games.player_infosets(player): + for infoset in games.player_infosets(game, player): node = next(iter(infoset.members)) assert profile.infoset_regrets[node] == max( profile.action_values[node][a] for a in infoset.actions @@ -934,11 +937,11 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): assert isinstance(beliefs, gbt.BeliefVector) for player in game.players: - player_node_values = node_values[player.label] + player_node_values = node_values[player] assert isinstance(player_node_values, gbt.NodeValueVector) - assert player_node_values[game.root] == payoffs[player.label] + assert player_node_values[game.root] == payoffs[player] - for infoset in games.player_infosets(player): + for infoset in games.player_infosets(game, player): node = next(iter(infoset.members)) infoset_action_values = action_values[node] infoset_action_regrets = action_regrets[node] @@ -1060,11 +1063,11 @@ def test_martingale_property_of_node_value(game: gbt.Game, rational_flag: bool): realiz_probs = profile.realiz_probs node_values = profile.node_values for node in game.nodes: - if node.is_terminal or node.player.is_chance: + if node.is_terminal or bool(node.event): continue expected_val = 0 node_prob = realiz_probs[node] - player_node_values = node_values[node.player.label] + player_node_values = node_values[node.player] for child in node.children: prob = realiz_probs[child] / node_prob expected_val += prob * player_node_values[child] @@ -1087,7 +1090,7 @@ def test_node_value_consistency(game: gbt.Game, rational_flag: bool): node_values = profile.node_values payoffs = profile.payoffs for player in game.players: - assert node_values[player.label][game.root] == payoffs[player.label] + assert node_values[player][game.root] == payoffs[player] @pytest.mark.parametrize( @@ -1395,20 +1398,6 @@ def test_node_belief_reference( assert abs(profile.beliefs[node] - value) <= tol -@pytest.mark.parametrize( - "game,rational_flag", - [ - (games.create_stripped_down_poker_efg(), True), - (games.create_stripped_down_poker_efg(), False), - ], -) -def test_payoff_value_error_with_chance_player(game: gbt.Game, rational_flag: bool): - """The chance player is excluded from payoffs, so looking it up is a KeyError.""" - chance_player = game.players.chance - with pytest.raises(KeyError): - game.mixed_behavior_profile(rational=rational_flag).payoffs[chance_player.label] - - @pytest.mark.parametrize( "game,rational_flag", [ @@ -1446,7 +1435,7 @@ def _all_node_actions(game: gbt.Game) -> list[tuple[gbt.Node, str]]: return [ (node, action) for player in game.players - for node in game.get_infosets(player.label) + for node in game.get_infosets(player) for action in node.actions ] @@ -1715,7 +1704,7 @@ def _get_and_check_answers( PROBS_1A_doub, PROBS_2A_doub, False, - lambda x, y: x.node_values[y[0].label][y[1]], + lambda x, y: x.node_values[y[0]][y[1]], lambda x: list(product(x.players, x.nodes)), ), ( @@ -1723,7 +1712,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.node_values[y[0].label][y[1]], + lambda x, y: x.node_values[y[0]][y[1]], lambda x: list(product(x.players, x.nodes)), ), ( @@ -1731,7 +1720,7 @@ def _get_and_check_answers( PROBS_1B_doub, PROBS_2B_doub, False, - lambda x, y: x.node_values[y[0].label][y[1]], + lambda x, y: x.node_values[y[0]][y[1]], lambda x: list(product(x.players, x.nodes)), ), ( @@ -1739,7 +1728,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.node_values[y[0].label][y[1]], + lambda x, y: x.node_values[y[0]][y[1]], lambda x: list(product(x.players, x.nodes)), ), ###################################################################################### @@ -2020,7 +2009,7 @@ def test_undefined_action_value(): """Test that undefined action values return `None`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") *_, p3 = game.players - infoset = games.player_infosets(p3)[0] + infoset = games.player_infosets(game, p3)[0] node = next(iter(infoset.members)) action = next(iter(infoset.actions)) for rat in [False, True]: @@ -2032,7 +2021,7 @@ def test_undefined_belief(): """Test that undefined beliefs return `None`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") *_, p3 = game.players - node = next(iter(games.player_infosets(p3)[0].members)) + node = next(iter(games.player_infosets(game, p3)[0].members)) for rat in [False, True]: profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) assert profile.beliefs[node] is None @@ -2042,7 +2031,7 @@ def test_undefined_infoset_value(): """Test that undefined infoset values return `None`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") *_, p3 = game.players - node = next(iter(games.player_infosets(p3)[0].members)) + node = next(iter(games.player_infosets(game, p3)[0].members)) for rat in [False, True]: profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) assert profile.infoset_values[node] is None diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index b15b712ad..cfb210d9e 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -8,7 +8,7 @@ def _find_infoset(game, label): """Find the Infoset with the given label, searching across all players.""" for player in game.players: - for node in game.get_infosets(player.label): + for node in game.get_infosets(player): if node.infoset.label == label: return node.infoset raise KeyError(label) @@ -46,7 +46,7 @@ def test_getitem_by_player_label(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() support = profile["Player 1"] - assert support.player == game.players["Player 1"] + assert support.player == "Player 1" infoset = _find_infoset(game, "Infoset 1:1") assert set(support[infoset]) == {"U1", "D1"} @@ -91,7 +91,7 @@ def test_iter_yields_one_support_per_player(): profile = game.behavior_support_profile() supports = list(profile) assert len(supports) == len(game.players) - assert {s.player.label for s in supports} == {p.label for p in game.players} + assert {s.player for s in supports} == set(game.players) def test_behaviorsupport_iter(): diff --git a/tests/test_catalog.py b/tests/test_catalog.py index c8eb50ed4..961f52306 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -65,7 +65,7 @@ def test_catalog_games_filter_n_actions(all_games): n_game_actions = sum( len(node.infoset.actions) for player in g.players - for node in g.get_infosets(player.label) + for node in g.get_infosets(player) ) assert n_game_actions == 2 @@ -87,7 +87,7 @@ def test_catalog_games_filter_n_infosets(all_games): assert len(filtered_games) < len(all_games) if len(filtered_games) > 0: g = gbt.catalog.load(filtered_games.Game.iloc[0]) - assert sum(len(g.get_infosets(p.label)) for p in g.players) == 2 + assert sum(len(g.get_infosets(p)) for p in g.players) == 2 def test_catalog_games_filter_is_const_sum(all_games): @@ -168,7 +168,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 sum(len(list(p.strategies)) for p in g.players) == 4 + assert sum(len(g.get_strategies(p)) 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 587d932c2..dbe8b1db6 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -13,7 +13,7 @@ def test_new_tree(players: list, title: str | None): game = gbt.Game.new_tree(players=players, title=title) assert len(game.players) == len(players) for player, label in zip(game.players, players, strict=True): - assert player.label == label + assert player == label assert game.title == title @@ -56,7 +56,7 @@ def test_game_set_players_label(players: list): game = gbt.Game.new_tree() game.set_players(players) for player, label in zip(game.players, players, strict=True): - assert player.label == label + assert player == label @pytest.mark.parametrize("game_input,expected_result", [ @@ -99,8 +99,8 @@ 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") - s1 = list(game.players["Player 1"].strategies) - s2 = list(game.players["Player 2"].strategies) + s1 = game.get_strategies("Player 1") + s2 = game.get_strategies("Player 2") 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 @@ -111,19 +111,19 @@ def test_getting_payoff_by_label_string(): assert game.get_payoffs({"Player 1": s1[1], "Player 2": s2[1]})["Player 2"] == 7 -def test_getting_payoff_player_object_key_raises(): +def test_getting_payoff_non_str_key_raises(): + """`get_payoffs`'s contingency keys must be player labels (`str`).""" game = games.read_from_file("sample_extensive_game.efg") - player1 = game.players["Player 1"] - s1 = next(iter(player1.strategies)) - s2 = next(iter(game.players["Player 2"].strategies)) + s1 = next(iter(game.get_strategies("Player 1"))) + s2 = next(iter(game.get_strategies("Player 2"))) with pytest.raises(TypeError): - _ = game.get_payoffs({player1: s1, "Player 2": s2}) + _ = game.get_payoffs({1: 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)) + s1 = next(iter(game.get_strategies("Player 1"))) + s2 = next(iter(game.get_strategies("Player 2"))) with pytest.raises(KeyError): _ = game.get_payoffs({"Player 1": s1, "Player 2": s2})["Not a player"] @@ -391,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 == list(player.strategies) + assert labels == game.get_strategies(player) assert (arr == games.vectorized_make_rational(exp_raw)).all() @@ -504,12 +504,12 @@ def test_reduced_strategy_maps(game: gbt.Game, strategy_maps: list): prescribes no action, being unreachable given the strategy's own earlier actions. """ 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.label, strategy) + for strategy, expected in zip(game.get_strategies(player), expected_maps, strict=True): + behavior = game.get_behavior(player, strategy) assert tuple( "*" if (action := behavior.get(infoset)) is None else str(infoset.actions.index(action) + 1) - for infoset in games.player_infosets(player) + for infoset in games.player_infosets(game, player) ) == expected diff --git a/tests/test_file.py b/tests/test_file.py index 9c96ea96a..fe4b33bb5 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 list(g.players["A"].strategies) == ["_1", "_2"] + assert g.get_strategies("A") == ["_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 list(g.players["A"].strategies) == ["l_1", "l_2"] + assert g.get_strategies("A") == ["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 list(g.players["A"].strategies) == ["2", "1"] + assert g.get_strategies("A") == ["2", "1"] def test_string_empty(): diff --git a/tests/test_game.py b/tests/test_game.py index f7cc6cd93..c8d46dbe8 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -16,8 +16,8 @@ def test_from_arrays(): game = gbt.Game.from_arrays(m, m.transpose()) pl1, pl2 = game.players assert len(game.players) == 2 - assert len(pl1.strategies) == 2 - assert len(pl2.strategies) == 2 + assert len(game.get_strategies(pl1)) == 2 + assert len(game.get_strategies(pl2)) == 2 def test_empty_array_to_arrays(): @@ -91,10 +91,10 @@ def test_from_dict(): game = gbt.Game.from_dict({"a": m, "b": m.transpose()}) pl1, pl2 = game.players assert len(game.players) == 2 - assert len(pl1.strategies) == 2 - assert len(pl2.strategies) == 2 - assert pl1.label == "a" - assert pl2.label == "b" + assert len(game.get_strategies(pl1)) == 2 + assert len(game.get_strategies(pl2)) == 2 + assert pl1 == "a" + assert pl2 == "b" def test_game_get_outcome(): @@ -106,10 +106,10 @@ def test_game_get_outcome(): 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)): "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"}) == \ + game.relabel_strategies(pl1, {next(iter(game.get_strategies(pl1))): "defect"}) + game.relabel_strategies(pl2, {next(iter(game.get_strategies(pl2))): "cooperate"}) + game.make_outcome({pl1: "defect", pl2: "cooperate"}, {"1": 0, "2": 0}, "corner") + assert game.get_outcome({pl1: "defect", pl2: "cooperate"}) == \ next(iter(game.outcomes)) @@ -146,17 +146,10 @@ def test_game_get_outcome_unknown_strategy_label_raises(): 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)): "defect"}) - game.relabel_strategies(pl2, {next(iter(pl2.strategies)): "cooperate"}) + game.relabel_strategies(pl1, {next(iter(game.get_strategies(pl1))): "defect"}) + game.relabel_strategies(pl2, {next(iter(game.get_strategies(pl2))): "cooperate"}) with pytest.raises(KeyError): - _ = game.get_outcome({pl1.label: "defect", pl2.label: "defect"}) - - -def test_game_get_outcome_player_object_key_raises(): - game = gbt.Game.new_table([2, 2]) - pl1, pl2 = game.players - with pytest.raises(TypeError): - _ = game.get_outcome({pl1: "1", pl2.label: "1"}) + _ = game.get_outcome({pl1: "defect", pl2: "defect"}) def test_game_get_outcome_tree_raises(): @@ -177,10 +170,10 @@ def test_game_get_payoffs(): 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) == "a" + s for s in game.get_strategies("Alice") + if game.get_behavior("Alice", s).get(infoset) == "a" ) game.make_outcome(game.root.children["a"], {"Alice": 1}, "a-outcome") payoffs = game.get_payoffs({"Alice": strategy}) @@ -191,7 +184,7 @@ 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: 0 for s in player.strategies} + distribution = {s: 0 for s in game.get_strategies(player)} next(iter(game.outcomes))[player] = 3 for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): @@ -214,11 +207,11 @@ def test_mixed_strategy_profile_game_structure_changed_no_tree(): # triggers error via __getitem__ next(profile.__iter__()) with pytest.raises(gbt.GameStructureChangedError): - profile.__setitem__(player.label, distribution) + profile.__setitem__(player, distribution) with pytest.raises(gbt.GameStructureChangedError): - profile.set_mixed_strategy(player.label, distribution) + profile.set_mixed_strategy(player, distribution) with pytest.raises(gbt.GameStructureChangedError): - profile.__getitem__(player.label) + profile.__getitem__(player) def test_mixed_strategy_profile_game_structure_changed_tree(): @@ -226,7 +219,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, ["D1"], drop=True) - distribution = {s: 0 for s in player.strategies} + distribution = {s: 0 for s in game.get_strategies(player)} for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): profile.as_behavior() @@ -250,11 +243,11 @@ def test_mixed_strategy_profile_game_structure_changed_tree(): # triggers error via __getitem__ next(profile.__iter__()) with pytest.raises(gbt.GameStructureChangedError): - profile.__setitem__(player.label, distribution) + profile.__setitem__(player, distribution) with pytest.raises(gbt.GameStructureChangedError): - profile.set_mixed_strategy(player.label, distribution) + profile.set_mixed_strategy(player, distribution) with pytest.raises(gbt.GameStructureChangedError): - profile.__getitem__(player.label) + profile.__getitem__(player) def test_mixed_behavior_profile_game_structure_changed(): @@ -320,7 +313,6 @@ def _bob_response_infoset(g): COLLECTION_GETTERS = [ pytest.param(lambda g: g.players, id="GamePlayers"), pytest.param(lambda g: g.outcomes, id="GameOutcomes"), - pytest.param(lambda g: g.players["Alice"].strategies, id="PlayerStrategies"), pytest.param(lambda g: _bob_response_infoset(g).members, id="InfosetMembers"), ] diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index 19bab5c29..8713fd630 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -25,30 +25,6 @@ def _test_valid_resolutions(collection: list, resolver: typing.Callable) -> None assert objects[0] == resolver(label, "test") -@pytest.mark.parametrize( - "game", - [ - games.read_from_file("sample_extensive_game.efg"), - ] -) -def test_resolve_player(game: gbt.Game) -> None: - _test_valid_resolutions(game.players, - lambda label, fn: game._resolve_player(label, fn)) - - -@pytest.mark.parametrize( - "game,player,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"), "random", KeyError), - ] -) -def test_resolve_player_invalid(game: gbt.Game, player: str, exception: BaseException) -> None: - with pytest.raises(exception): - game._resolve_player(player, "test_resolve_player_invalid") - - @pytest.mark.parametrize( "game", [ @@ -107,7 +83,7 @@ def test_resolve_infoset(game: gbt.Game) -> None: """`_resolve_infoset` resolves a Node to the Infoset it belongs to, or a node's label to the same; any member node of an infoset resolves to an equal Infoset.""" for player in game.players: - for node in game.get_infosets(player.label): + for node in game.get_infosets(player): resolved = game._resolve_infoset(node, "test") assert resolved == node.infoset if node.label: diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 3deb51c68..ce95bf764 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -34,8 +34,8 @@ def test_infoset_label_unicode_accepted(label): def test_infoset_label_duplicate_within_player_raises_valueerror(): game = games.read_from_file("subgames.efg") - player = next(p for p in game.players if len(game.get_infosets(p.label)) >= 2) - first, second = (n.infoset for n in itertools.islice(game.get_infosets(player.label), 2)) + player = next(p for p in game.players if len(game.get_infosets(p)) >= 2) + first, second = (n.infoset for n in itertools.islice(game.get_infosets(player), 2)) first.label = "shared" with pytest.raises(ValueError): second.label = "shared" @@ -59,7 +59,7 @@ def test_make_infoset_change_player_keeps_label(): game = games.read_from_file("basic_extensive_game.efg") _, p2, *_ = game.players members = list(game.root.infoset.members) - game.make_infoset(members, p2.label, "moved") + game.make_infoset(members, p2, "moved") assert game.root.infoset.player == p2 assert game.root.infoset.label == "moved" assert list(game.root.infoset.members) == members @@ -78,7 +78,7 @@ def test_make_infoset_terminal_node_raises(): game = games.read_from_file("basic_extensive_game.efg") terminal = game.root.children["U1"].children["U2"].children["U3"] with pytest.raises(gbt.UndefinedOperationError): - game.make_infoset([terminal], game.root.player.label) + game.make_infoset([terminal], game.root.player) def test_make_infoset_converts_chance_node(): @@ -86,7 +86,7 @@ def test_make_infoset_converts_chance_node(): game = games.read_from_file("stripped_down_poker.efg") chance_node = game.root # the deal is a chance move personal = next(n for n in game.nodes if not n.is_terminal and n.infoset) - game.make_infoset([chance_node], personal.infoset.player.label) + game.make_infoset([chance_node], personal.infoset.player) assert not chance_node.event assert chance_node.infoset assert chance_node.infoset.player == personal.infoset.player @@ -107,14 +107,14 @@ def test_make_infoset_empty_nodes_raises(): """`nodes` must be nonempty.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.make_infoset([], game.root.player.label) + game.make_infoset([], game.root.player) def test_make_infoset_repeated_node_raises(): """Each node may be referenced only once.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.make_infoset([game.root, game.root], game.root.player.label) + game.make_infoset([game.root, game.root], game.root.player) def test_make_infoset_strategic_game_raises(): @@ -360,7 +360,7 @@ def test_infoset_is_absent_minded(test_case: AbsentMindednessTestCase): for path in test_case.expected_am_paths } actual_infosets = { - n.infoset for p in game.players for n in game.get_infosets(p.label) + n.infoset for p in game.players for n in game.get_infosets(p) if n.infoset.is_absent_minded } @@ -445,12 +445,12 @@ def test_make_infoset_across_different_source_players(): game.append_move(game.root.children["b"], "3", ["a", "b"]) # player 3 n2 = game.root.children["a"] n3 = game.root.children["b"] - assert n2.infoset.player == game.players["2"] - assert n3.infoset.player == game.players["3"] + assert n2.infoset.player == "2" + assert n3.infoset.player == "3" game.make_infoset([n2, n3], "1") assert n2.infoset == n3.infoset - assert n2.infoset.player == game.players["1"] - assert n3.infoset.player == game.players["1"] + assert n2.infoset.player == "1" + assert n3.infoset.player == "1" def test_infoset_proxy_reresolves_after_split(): @@ -460,7 +460,7 @@ def test_infoset_proxy_reresolves_after_split(): node = game.root.children["U1"] proxy = node.infoset assert len(proxy.members) == 2 - game.make_infoset(node, node.player.label) + game.make_infoset(node, node.player) assert list(proxy.members) == [node] @@ -477,13 +477,6 @@ def test_reveal_splits_infoset_by_action(): assert len(game.get_infosets("Alice")) == n_alice -def test_reveal_chance_player_raises(): - """`player` must be a personal player; revealing to chance is not well-defined.""" - game = games.create_stripped_down_poker_efg(nonterm_outcomes=True) - with pytest.raises(gbt.UndefinedOperationError): - game.reveal(game.root, game.players.chance) - - def test_reveal_absent_minded_infoset_raises(): """Revealing the move at an absent-minded infoset is rejected (17.0).""" game = gbt.Game.new_tree(players=["Driver", "2"]) @@ -494,11 +487,3 @@ def test_reveal_absent_minded_infoset_raises(): game.append_move(mid.children["Continue"], "2", ["l", "r"]) with pytest.raises(gbt.UndefinedOperationError): game.reveal(game.root, "2") - - -def test_reveal_mismatch_raises(): - """`infoset` and `player` must belong to this game.""" - game1 = games.read_from_file("stripped_down_poker.efg") - game2 = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(gbt.MismatchError): - game1.reveal(game1.root, next(iter(game2.players))) diff --git a/tests/test_io.py b/tests/test_io.py index 7a720a470..3354f5699 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -102,7 +102,7 @@ def test_read_gbt_workspace(tmp_path): game = gbt.read_gbt(game_path) assert game.title == "Prisoner's & test" - assert [player.label for player in game.players] == ["Alice", "Bob"] + assert list(game.players) == ["Alice", "Bob"] def test_read_gbt_rejects_malformed_xml(tmp_path): @@ -160,7 +160,7 @@ def test_write_efg_as_nfg(): def test_write_html(): game = gbt.Game.new_table([2, 2]) alice, bob = game.players - game.relabel_players({alice.label: "Alice", bob.label: "Bob"}) + game.relabel_players({alice: "Alice", bob: "Bob"}) serialized_game = game.to_html() assert isinstance(serialized_game, str) assert "Alice" in serialized_game @@ -170,7 +170,7 @@ def test_write_html(): def test_write_latex(): game = gbt.Game.new_table([2, 2], title="Game title") alice, bob = game.players - game.relabel_players({alice.label: "Alice", bob.label: "Bob"}) + game.relabel_players({alice: "Alice", bob: "Bob"}) serialized_game = game.to_latex() assert "\\begin{game}" in serialized_game assert "[\\textbf{Alice}][\\textbf{Bob}]" in serialized_game diff --git a/tests/test_mixed.py b/tests/test_mixed.py index 60c2fd989..0a70dde70 100644 --- a/tests/test_mixed.py +++ b/tests/test_mixed.py @@ -19,15 +19,17 @@ def _set_action_probs(profile: gbt.MixedStrategyProfile, probs: list, rational_f """ # assumes rationals given as strings convert = (lambda p: gbt.Rational(p)) if rational_flag else (lambda p: p) - total_strategies = sum(len(list(p.strategies)) for p in profile.game.players) + game = profile.game + total_strategies = sum(len(game.get_strategies(p)) for p in 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] = { + for player in game.players: + strategies = game.get_strategies(player) + k = len(strategies) + profile[player] = { s: convert(p) - for s, p in zip(player.strategies, probs[offset:offset + k], strict=True) + for s, p in zip(strategies, probs[offset:offset + k], strict=True) } offset += k @@ -168,11 +170,11 @@ 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 = next(p for p in game.players if strategy_label in p.strategies) - profile[player.label] = { - s: (prob if s == strategy_label else 0) for s in player.strategies + player = next(p for p in game.players if strategy_label in game.get_strategies(p)) + profile[player] = { + s: (prob if s == strategy_label else 0) for s in game.get_strategies(player) } - assert profile[player.label][strategy_label] == prob + assert profile[player][strategy_label] == prob @pytest.mark.parametrize( @@ -199,8 +201,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(player.strategies, profile_data, strict=True)) + expected = dict(zip(game.get_strategies(player_label), profile_data, strict=True)) profile[player_label] = expected assert profile[player_label] == expected @@ -275,11 +276,11 @@ def test_setitem_sparse_rejects_negative_weight(): @pytest.mark.parametrize("sparse", [False, True]) -def test_indexing_rejects_player_object(sparse: bool): - """Unlike Game._resolve_player, MixedStrategyProfile's indexing is str-label only.""" +def test_indexing_rejects_non_string_player(sparse: bool): + """MixedStrategyProfile's indexing accepts only str player labels.""" game = games.read_from_file("coordination_4x4_payoff.nfg") profile = game.mixed_strategy_profile() - player = game.players[P1] + player = 42 with pytest.raises(TypeError): profile[player] with pytest.raises(TypeError): @@ -432,8 +433,7 @@ def test_profile_indexing_by_player_label_reference( profile = game.mixed_strategy_profile(rational=rational_flag) if rational_flag: strategy_data = [gbt.Rational(prob) for prob in strategy_data] - player = game.players[player_label] - expected = dict(zip(player.strategies, strategy_data, strict=True)) + expected = dict(zip(game.get_strategies(player_label), strategy_data, strict=True)) assert profile[player_label] == expected @@ -594,7 +594,7 @@ def test_payoffs_reference( profile = game.mixed_strategy_profile(rational=rational_flag, data=profile_data) for payoff, player in zip(payoffs, profile.game.players, strict=True): payoff = gbt.Rational(payoff) if rational_flag else payoff - assert profile.payoffs[player.label] == payoff + assert profile.payoffs[player] == payoff @pytest.mark.parametrize( @@ -676,10 +676,10 @@ def test_strategy_value_reference( for strategy_values_for_player, player in zip( strategy_values, profile.game.players, strict=True ): - for i, s in enumerate(player.strategies): + for i, s in enumerate(profile.game.get_strategies(player)): sv = strategy_values_for_player[i] sv = gbt.Rational(sv) if rational_flag else sv - assert profile.strategy_values[player.label][s] == sv + assert profile.strategy_values[player][s] == sv @pytest.mark.parametrize( @@ -1071,7 +1071,7 @@ def test_player_regret_max_regret_reference( player_regrets_exp = [gbt.Rational(r) for r in player_regrets_exp] player_regrets = profile.player_regrets for p, r in zip(game.players, player_regrets_exp, strict=True): - assert abs(player_regrets[p.label] - r) <= tol + assert abs(player_regrets[p] - r) <= tol assert abs(profile.max_regret() - max(player_regrets_exp)) <= tol @@ -1105,10 +1105,11 @@ def test_strategy_regret_consistency(game: gbt.Game, rational_flag: bool): strategy_values = profile.strategy_values strategy_regrets = profile.strategy_regrets for player in game.players: - player_strategy_values = strategy_values[player.label] - for strategy in player.strategies: - assert strategy_regrets[player.label][strategy] == ( - max(player_strategy_values[s] for s in player.strategies) + player_strategy_values = strategy_values[player] + strategies = game.get_strategies(player) + for strategy in strategies: + assert strategy_regrets[player][strategy] == ( + max(player_strategy_values[s] for s in strategies) - player_strategy_values[strategy] ) @@ -1198,10 +1199,10 @@ def test_liap_value_consistency( profile.liap_value() - sum( [ - max(strategy_values[player.label][strategy] - payoffs[player.label], 0) + max(strategy_values[player][strategy] - payoffs[player], 0) ** 2 for player in game.players - for strategy in player.strategies + for strategy in game.get_strategies(player) ] ) ) @@ -1292,12 +1293,12 @@ def test_player_regret_max_regret_consistency( for p in game.players: p_regret = max( [ - max(strategy_values[p.label][strategy] - payoffs[p.label], 0) - for strategy in p.strategies + max(strategy_values[p][strategy] - payoffs[p], 0) + for strategy in game.get_strategies(p) ] ) player_regrets.append(p_regret) - assert abs(profile.player_regrets[p.label] - p_regret) <= tol + assert abs(profile.player_regrets[p] - p_regret) <= tol assert abs(profile.max_regret() - max(player_regrets)) <= tol @@ -1385,9 +1386,9 @@ def test_linearity_payoff_property( profile_data = [ [ - alpha * profile1[player.label][strategy] - + (1 - alpha) * profile2[player.label][strategy] - for strategy in player.strategies + alpha * profile1[player][strategy] + + (1 - alpha) * profile2[player][strategy] + for strategy in game.get_strategies(player) ] for player in game.players ] @@ -1399,9 +1400,9 @@ def test_linearity_payoff_property( for player in game.players: assert ( abs( - alpha * payoffs1[player.label] - + (1 - alpha) * payoffs2[player.label] - - payoffs3[player.label] + alpha * payoffs1[player] + + (1 - alpha) * payoffs2[player] + - payoffs3[player] ) <= tol ) @@ -1483,17 +1484,17 @@ def test_payoff_and_strategy_value_consistency( strategy_values = profile.strategy_values payoffs = profile.payoffs for player in game.players: - player_strategy_values = strategy_values[player.label] + player_strategy_values = strategy_values[player] assert ( abs( sum( [ - profile[player.label][strategy] + profile[player][strategy] * player_strategy_values[strategy] - for strategy in player.strategies + for strategy in game.get_strategies(player) ] ) - - payoffs[player.label] + - payoffs[player] ) <= tol ) @@ -1516,8 +1517,8 @@ def test_len_matches_iter_count(game: gbt.Game, rational_flag: bool): assert len(profile) == len(game.players) assert len(profile) == len(list(profile)) for player in game.players: - mixed_strategy = profile[player.label] - assert len(mixed_strategy) == len(player.strategies) + mixed_strategy = profile[player] + assert len(mixed_strategy) == len(game.get_strategies(player)) assert len(mixed_strategy) == len(list(mixed_strategy)) @@ -1563,15 +1564,16 @@ def test_vectorized_quantities_consistency(game: gbt.Game, profile_data, rationa assert isinstance(strategy_regrets, gbt.StrategyRegretsVector) for player in game.players: - player_strategy_values = strategy_values[player.label] - player_strategy_regrets = strategy_regrets[player.label] + player_strategy_values = strategy_values[player] + player_strategy_regrets = strategy_regrets[player] assert isinstance(player_strategy_values, gbt.StrategyValueVector) assert isinstance(player_strategy_values, gbt.StrategyIndexedVector) assert isinstance(player_strategy_regrets, gbt.StrategyRegretVector) - 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: + strategies = game.get_strategies(player) + best_response_value = max(player_strategy_values[s] for s in strategies) + assert player_regrets[player] == best_response_value - payoffs[player] + for strategy in strategies: assert ( player_strategy_regrets[strategy] == best_response_value - player_strategy_values[strategy] @@ -1579,7 +1581,7 @@ def test_vectorized_quantities_consistency(game: gbt.Game, profile_data, rationa # equal to an equivalent plain dict or same-type vector, but never to a vector of a # different quantity, even where the underlying numbers happen to coincide - expected = {p.label: payoffs[p.label] for p in game.players} + expected = {p: payoffs[p] for p in game.players} assert payoffs == expected assert payoffs == gbt.PayoffVector(expected) assert payoffs != player_regrets @@ -1684,9 +1686,9 @@ def test_property_linearity_strategy_value( profile_data = [ [ - alpha * profile1[player.label][strategy] - + (1 - alpha) * profile2[player.label][strategy] - for strategy in player.strategies + alpha * profile1[player][strategy] + + (1 - alpha) * profile2[player][strategy] + for strategy in game.get_strategies(player) ] for player in game.players ] @@ -1696,12 +1698,12 @@ def test_property_linearity_strategy_value( strategy_values2 = profile2.strategy_values strategy_values3 = profile3.strategy_values for player in game.players: - for strategy in player.strategies: + for strategy in game.get_strategies(player): convex_comb = ( - alpha * strategy_values1[player.label][strategy] - + (1 - alpha) * strategy_values2[player.label][strategy] + alpha * strategy_values1[player][strategy] + + (1 - alpha) * strategy_values2[player][strategy] ) - assert abs(strategy_values3[player.label][strategy] - convex_comb) <= tol + assert abs(strategy_values3[player][strategy] - convex_comb) <= tol def _get_answers_one_order( @@ -1770,7 +1772,7 @@ def _get_and_check_answers( PROBS_1A_doub, PROBS_2A_doub, False, - lambda profile, player: profile.payoffs[player.label], + lambda profile, player: profile.payoffs[player], lambda game: game.players, id="payoffs_coord_doub", ), @@ -1779,7 +1781,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda profile, player: profile.payoffs[player.label], + lambda profile, player: profile.payoffs[player], lambda game: game.players, id="payoffs_coord_rat", ), @@ -1789,7 +1791,7 @@ def _get_and_check_answers( PROBS_1B_doub, PROBS_2B_doub, False, - lambda profile, player: profile.payoffs[player.label], + lambda profile, player: profile.payoffs[player], lambda game: game.players, id="payoffs_2x2x2_doub", ), @@ -1798,7 +1800,7 @@ def _get_and_check_answers( PROBS_1B_rat, PROBS_2B_rat, True, - lambda profile, player: profile.payoffs[player.label], + lambda profile, player: profile.payoffs[player], lambda game: game.players, id="payoffs_2x2x2_rat", ), @@ -1808,7 +1810,7 @@ def _get_and_check_answers( PROBS_1B_doub, PROBS_2B_doub, False, - lambda profile, player: profile.payoffs[player.label], + lambda profile, player: profile.payoffs[player], lambda game: game.players, id="payoffs_poker_doub", ), @@ -1817,7 +1819,7 @@ def _get_and_check_answers( PROBS_1B_rat, PROBS_2B_rat, True, - lambda profile, player: profile.payoffs[player.label], + lambda profile, player: profile.payoffs[player], lambda game: game.players, id="payoffs_poker_rat", ), @@ -1830,7 +1832,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, 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], + lambda game: [(p, s) for p in game.players for s in game.get_strategies(p)], id="regret_coord_doub", ), pytest.param( @@ -1839,7 +1841,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, 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], + lambda game: [(p, s) for p in game.players for s in game.get_strategies(p)], id="regret_coord_rat", ), # 2x2x2 nfg @@ -1849,7 +1851,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, 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], + lambda game: [(p, s) for p in game.players for s in game.get_strategies(p)], id="regret_2x2x2_doub", ), pytest.param( @@ -1858,7 +1860,7 @@ def _get_and_check_answers( PROBS_2B_rat, True, 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], + lambda game: [(p, s) for p in game.players for s in game.get_strategies(p)], id="regret_2x2x2_rat", ), # stripped-down poker @@ -1868,7 +1870,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, 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], + lambda game: [(p, s) for p in game.players for s in game.get_strategies(p)], id="regret_poker_doub", ), pytest.param( @@ -1877,7 +1879,7 @@ def _get_and_check_answers( PROBS_2B_rat, True, 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], + lambda game: [(p, s) for p in game.players for s in game.get_strategies(p)], id="regret_poker_rat", ), ################################################################################# @@ -1889,7 +1891,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, 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], + lambda game: [(p, s) for p in game.players for s in game.get_strategies(p)], id="strat_value_coord_doub", ), pytest.param( @@ -1898,7 +1900,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, 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], + lambda game: [(p, s) for p in game.players for s in game.get_strategies(p)], id="strat_value_coord_rat", ), # 2x2x2 nfg @@ -1908,7 +1910,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, 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], + lambda game: [(p, s) for p in game.players for s in game.get_strategies(p)], id="strat_value_2x2x2_doub", ), pytest.param( @@ -1917,7 +1919,7 @@ def _get_and_check_answers( PROBS_2B_rat, True, 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], + lambda game: [(p, s) for p in game.players for s in game.get_strategies(p)], id="strat_value_2x2x2_rat", ), # stripped-down poker @@ -1927,7 +1929,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, 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], + lambda game: [(p, s) for p in game.players for s in game.get_strategies(p)], id="strat_value_poker_doub", ), pytest.param( @@ -1936,7 +1938,7 @@ def _get_and_check_answers( PROBS_2B_rat, True, 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], + lambda game: [(p, s) for p in game.players for s in game.get_strategies(p)], id="strat_value_poker_rat", ), ################################################################################# diff --git a/tests/test_nash.py b/tests/test_nash.py index 33eefe2dc..1f2ff7a51 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -1616,9 +1616,9 @@ def test_nash_strategy_solver(test_case: EquilibriumTestCase, subtests) -> None: with subtests.test(eq=i, check="strategy_profile"): 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] - exp_prob = expected[player.label][strategy] + for strategy in game.get_strategies(player): + eq_prob = eq[player][strategy] + exp_prob = expected[player][strategy] assert abs(eq_prob - exp_prob) <= test_case.prob_tol @@ -1637,8 +1637,8 @@ def _one_hot_perturbation(rational: bool) -> gbt.MixedStrategyProfile: zero = gbt.Rational(0) if rational else 0.0 perturbation = game.mixed_strategy_profile(rational=rational) for player in game.players: - strategies = list(player.strategies) - perturbation[player.label] = { + strategies = list(game.get_strategies(player)) + perturbation[player] = { s: (one if s == strategies[0] else zero) for s in strategies } return perturbation @@ -1656,10 +1656,10 @@ def _one_hot_perturbation(rational: bool) -> gbt.MixedStrategyProfile: ): with subtests.test(eq=i, check="strategy_profile"): for player in game.players: - for strategy in player.strategies: + for strategy in game.get_strategies(player): assert ( - rational_eq[player.label][strategy] - == pytest.approx(double_eq[player.label][strategy]) + rational_eq[player][strategy] + == pytest.approx(double_eq[player][strategy]) ) @@ -1742,9 +1742,9 @@ def test_nash_strategy_solver_w_start(test_case: EquilibriumTestCaseWithStart, s with subtests.test(eq=i, check="strategy_profile"): 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] - exp_prob = expected[player.label][strategy] + for strategy in game.get_strategies(player): + eq_prob = eq[player][strategy] + exp_prob = expected[player][strategy] assert abs(eq_prob - exp_prob) <= test_case.prob_tol @@ -3207,7 +3207,7 @@ def test_nash_behavior_solver(test_case: EquilibriumTestCase, subtests) -> None: with subtests.test(eq=i, check="strategy_profile"): expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: - for node in game.get_infosets(player.label): + for node in game.get_infosets(player): for action in node.actions: assert abs( _action_prob(eq, node, action) @@ -3259,7 +3259,7 @@ def test_nash_behavior_solver_unordered(test_case: EquilibriumTestCase, subtests def are_the_same(game, found, candidate): for p in game.players: - for node in game.get_infosets(p.label): + for node in game.get_infosets(p): for a in node.actions: if not abs( _action_prob(found, node, a) - _action_prob(candidate, node, a) @@ -3426,7 +3426,7 @@ def test_nash_agent_solver(test_case: EquilibriumTestCase, subtests) -> None: with subtests.test(eq=i, check="strategy_profile"): expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: - for node in game.get_infosets(player.label): + for node in game.get_infosets(player): for action in node.actions: assert abs( _action_prob(eq, node, action) @@ -3494,7 +3494,7 @@ def test_nash_agent_w_start_solver(test_case: EquilibriumTestCase, subtests) -> with subtests.test(eq=i, check="strategy_profile"): expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: - for node in game.get_infosets(player.label): + for node in game.get_infosets(player): for action in node.actions: assert abs( _action_prob(eq, node, action) @@ -3571,9 +3571,9 @@ def test_qre_solver(test_case: QREquilibriumTestCase, subtests) -> None: with subtests.test(eq=i, check="strategy_profile"): 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] - exp_prob = exp_profile[player.label][s] + for s in game.get_strategies(player): + found_prob = found.profile[player][s] + exp_prob = exp_profile[player][s] assert abs(found_prob - exp_prob) <= test_case.prob_tol diff --git a/tests/test_nashphc.py b/tests/test_nashphc.py index 7798fb178..886edea3b 100644 --- a/tests/test_nashphc.py +++ b/tests/test_nashphc.py @@ -37,17 +37,17 @@ def test_playerletters_excludes_disallowed_variable_letters(): def test_strategy_index(matching_pennies): - player = matching_pennies.players["1"] - assert _strategy_index(player, "1") == 0 - assert _strategy_index(player, "2") == 1 + assert _strategy_index(matching_pennies, "1", "1") == 0 + assert _strategy_index(matching_pennies, "1", "2") == 1 def test_contingencies_skips_given_player(matching_pennies): - p1, p2 = matching_pennies.players + players = list(matching_pennies.players) + p1, p2 = 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"} + assert all(cont[players.index(p1)] is None for cont in conts) + assert {cont[players.index(p2)] for cont in conts} == {"1", "2"} def test_equilibrium_equations(matching_pennies): diff --git a/tests/test_node.py b/tests/test_node.py index 1a71d8a22..f6e98692f 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -23,7 +23,7 @@ def test_infoset_equality_is_symmetric(): equal from either side.""" game = games.read_from_file("basic_extensive_game.efg") proxy = game.root.infoset - infoset = game.get_infosets(game.root.player.label)[0].infoset + infoset = game.get_infosets(game.root.player)[0].infoset assert proxy == infoset assert infoset == proxy @@ -35,7 +35,7 @@ def test_node_infoset_truthiness(): terminal = game.root.children["U1"].children["D2"].children["U3"] proxy = terminal.infoset assert not proxy - game.append_move(terminal, game.players["Player 1"], ["a", "b"]) + game.append_move(terminal, "Player 1", ["a", "b"]) assert proxy @@ -62,7 +62,7 @@ def test_node_outcome_subscript_tracks_mutation(): game = games.read_from_file("basic_extensive_game.efg") node = game.root.children["U1"].children["D2"].children["U3"] proxy = node.outcome - player = game.players["Player 1"] + player = "Player 1" proxy[player] = 7 assert node.outcome[player] == 7 @@ -100,7 +100,7 @@ def test_null_outcome_reads_zero_payoffs(): def test_null_outcome_payoff_write_raises(): game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.root.outcome[game.players["Player 1"]] = 1 + game.root.outcome["Player 1"] = 1 def test_null_outcome_label_write_raises(): @@ -119,25 +119,16 @@ def test_null_outcome_number_is_none(): def test_get_player(): """Test to ensure that we can retrieve a player for a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.player == game.players["Player 1"] + assert game.root.player == "Player 1" assert not game.root.children["U1"].children["D2"].children["U3"].player -def test_player_equality_is_symmetric(): - """A node-anchored player view and the resolved Player compare equal from either side.""" - game = games.read_from_file("basic_extensive_game.efg") - proxy = game.root.player - player = game.players["Player 1"] - assert proxy == player - assert player == proxy - - def test_node_player_resolves_chance(): - """At a chance node the player view resolves to the chance player.""" + """At a chance node, the player label is the chance player's.""" game = games.read_from_file("stripped_down_poker.efg") chance_node = game.root - assert chance_node.player.is_chance - assert chance_node.player == game.players.chance + assert chance_node.event + assert chance_node.player == "Chance" def test_get_game(): @@ -193,7 +184,7 @@ def test_is_successor_of(): with pytest.raises(TypeError): game.root.is_successor_of("Test") with pytest.raises(TypeError): - game.root.is_successor_of(game.players["Player 1"]) + game.root.is_successor_of("Player 1") def _get_path_of_action_labels(node: gbt.Node) -> list[str]: @@ -471,8 +462,8 @@ def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): for key in keys } for player in game.players: - for node in game.get_infosets(player.label): - key = (node.infoset.player.label, node.infoset.number) + for node in game.get_infosets(player): + key = (node.infoset.player, node.infoset.number) actual_path = tuple(_get_path_of_action_labels(game.minimal_subgame(node).root)) assert actual_path == expected_path_for_key[key] @@ -553,7 +544,7 @@ def test_node_own_prior_action_non_terminal(game_file, expected_node_data): # Only collect data for non-terminal nodes opa = node.own_prior_action if opa is not None: - details = (opa.node.infoset.player.label, opa.node.infoset.number, opa.label) + details = (opa.node.infoset.player, opa.node.infoset.number, opa.label) else: details = None actual_node_data.append((_get_path_of_action_labels(node), details)) @@ -598,22 +589,7 @@ def test_append_move_error_player_actions(): """Test to ensure there are actions when appending with a player""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.append_move(game.root, game.players["Player 1"], []) - - -def test_append_move_error_player_mismatch(): - """Test to ensure the node and the player are from the same game""" - game1 = gbt.Game.new_tree() - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.append_move(game1.root, game2.players["Player 1"], ["a"]) - - -def test_append_move_error_chance_player(): - """Test that `player` cannot be the chance player; use `append_event` instead.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.UndefinedOperationError): - game.append_move(game.root, game.players.chance, ["a", "b"]) + game.append_move(game.root, "Player 1", []) def test_append_move_error_infoset_mismatch(): @@ -628,50 +604,35 @@ def test_append_move_error_empty_label(): """Test that an empty label in `actions` is rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.append_move(game.root, game.players["Player 1"], ["a", ""]) + game.append_move(game.root, "Player 1", ["a", ""]) def test_append_move_error_duplicate_label(): """Test that duplicated labels in `actions` are rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.append_move(game.root, game.players["Player 1"], ["a", "a"]) + game.append_move(game.root, "Player 1", ["a", "a"]) def test_insert_move_error_player_actions(): """Test to ensure there are actions when inserting with a player""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.insert_move(game.root, game.players["Player 1"], []) - - -def test_insert_move_error_player_mismatch(): - """Test to ensure the node and the player are from the same game""" - game1 = gbt.Game.new_tree() - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.insert_move(game1.root, game2.players["Player 1"], ["a"]) - - -def test_insert_move_error_chance_player(): - """Test that `player` cannot be the chance player; use `insert_event` instead.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.UndefinedOperationError): - game.insert_move(game.root, game.players.chance, ["a", "b"]) + game.insert_move(game.root, "Player 1", []) def test_insert_move_error_empty_label(): """Test that an empty label in `actions` is rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.insert_move(game.root, game.players["Player 1"], ["a", ""]) + game.insert_move(game.root, "Player 1", ["a", ""]) def test_insert_move_error_duplicate_label(): """Test that duplicated labels in `actions` are rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.insert_move(game.root, game.players["Player 1"], ["a", "a"]) + game.insert_move(game.root, "Player 1", ["a", "a"]) def test_node_infoset_becomes_null_when_truncated(): @@ -798,7 +759,7 @@ def test_node_move_across_games(): def test_append_move_creates_single_infoset_list_of_nodes(): """Test that appending a list of nodes creates a single infoset.""" game = games.read_from_file("sample_extensive_game.efg") - game.set_players([player.label for player in game.players] + ["Player 3"]) + game.set_players(list(game.players) + ["Player 3"]) nodes = [game.root.children["2"].children["1"], game.root.children["1"].children["1"], game.root.children["1"].children["2"]] @@ -809,7 +770,7 @@ def test_append_move_creates_single_infoset_list_of_nodes(): def test_append_move_same_infoset_list_of_nodes(): """Test that nodes from a list of nodes are resolved in the same infoset.""" game = games.read_from_file("sample_extensive_game.efg") - game.set_players([player.label for player in game.players] + ["Player 3"]) + game.set_players(list(game.players) + ["Player 3"]) node1 = game.root.children["2"].children["1"] node2 = game.root.children["1"].children["1"] game.append_move([node1, node2], "Player 3", ["B", "F"]) @@ -821,7 +782,7 @@ def test_append_move_actions_list_of_nodes(): have the same actions. """ game = games.read_from_file("sample_extensive_game.efg") - game.set_players([player.label for player in game.players] + ["Player 3"]) + game.set_players(list(game.players) + ["Player 3"]) node1 = game.root.children["2"].children["1"] node2 = game.root.children["1"].children["1"] game.append_move([node1, node2], "Player 3", ["B", "F", "S"]) @@ -831,7 +792,7 @@ def test_append_move_actions_list_of_nodes(): def test_append_move_actions_list_of_node_labels(): """Test that nodes from a list of node labels are resolved correctly.""" game = games.read_from_file("sample_extensive_game.efg") - game.set_players([player.label for player in game.players] + ["Player 3"]) + game.set_players(list(game.players) + ["Player 3"]) node1 = game.root.children["2"].children["1"] node2 = game.root.children["1"].children["1"] node1.label = "0" @@ -849,7 +810,7 @@ def test_append_move_actions_list_of_mixed_node_references(): are resolved correctly. """ game = games.read_from_file("sample_extensive_game.efg") - game.set_players([player.label for player in game.players] + ["Player 3"]) + game.set_players(list(game.players) + ["Player 3"]) node1 = game.root.children["2"].children["1"] node2 = game.root.children["1"].children["1"] @@ -867,7 +828,7 @@ def test_append_move_labels_list_of_nodes(): have the same labels per action. """ game = games.read_from_file("sample_extensive_game.efg") - game.set_players([player.label for player in game.players] + ["Player 3"]) + game.set_players(list(game.players) + ["Player 3"]) node1 = game.root.children["2"].children["1"] node2 = game.root.children["1"].children["1"] game.append_move([node1, node2], "Player 3", ["B", "F", "S"]) @@ -880,7 +841,7 @@ def test_append_move_node_list_with_non_terminal_node(): of nodes that has a non-terminal node. """ game = games.read_from_file("sample_extensive_game.efg") - game.set_players([player.label for player in game.players] + ["Player 3"]) + game.set_players(list(game.players) + ["Player 3"]) with pytest.raises(gbt.UndefinedOperationError): game.append_move( [game.root.children["2"], game.root.children["1"].children["2"]], @@ -894,7 +855,7 @@ def test_append_move_node_list_with_duplicate_node_references(): nodes with non-unique node references. """ game = games.read_from_file("sample_extensive_game.efg") - game.set_players([player.label for player in game.players] + ["Player 3"]) + game.set_players(list(game.players) + ["Player 3"]) node = game.root.children["1"].children["2"] node.label = "00" with pytest.raises(ValueError): @@ -910,7 +871,7 @@ def test_append_move_node_list_is_empty(): empty list of nodes. """ game = games.read_from_file("sample_extensive_game.efg") - game.set_players([player.label for player in game.players] + ["Player 3"]) + game.set_players(list(game.players) + ["Player 3"]) with pytest.raises(ValueError): game.append_move([], "Player 3", ["B", "F"]) @@ -920,7 +881,7 @@ def test_append_infoset_node_list_with_non_terminal_node(): a list of nodes that has a non-terminal node. """ game = games.read_from_file("sample_extensive_game.efg") - game.set_players([player.label for player in game.players] + ["Player 3"]) + game.set_players(list(game.players) + ["Player 3"]) seed_node = game.root.children["1"].children["1"] game.append_move(seed_node, "Player 3", ["B", "F"]) with pytest.raises(gbt.UndefinedOperationError): @@ -935,7 +896,7 @@ def test_append_infoset_node_list_with_duplicate_node(): with non-unique elements. """ game = games.read_from_file("sample_extensive_game.efg") - game.set_players([player.label for player in game.players] + ["Player 3"]) + game.set_players(list(game.players) + ["Player 3"]) seed_node = game.root.children["1"].children["1"] game.append_move(seed_node, "Player 3", ["B", "F"]) with pytest.raises(ValueError): @@ -952,7 +913,7 @@ def test_append_infoset_node_list_is_empty(): empty list of nodes. """ game = games.read_from_file("sample_extensive_game.efg") - game.set_players([player.label for player in game.players] + ["Player 3"]) + game.set_players(list(game.players) + ["Player 3"]) seed_node = game.root.children["1"].children["1"] game.append_move(seed_node, "Player 3", ["B", "F"]) with pytest.raises(ValueError): @@ -1164,7 +1125,7 @@ def test_len_after_append_move(): initial_number_of_nodes = len(game.nodes) terminal_node = game.root.children["R"].children["L"].children["L"] # the [1,1,0] terminal - player = game.players["Player 1"] + player = "Player 1" actions_to_add = ["T", "M", "B"] game.append_move(terminal_node, player, actions_to_add) @@ -1219,7 +1180,7 @@ def test_len_after_insert_move(): initial_number_of_nodes = len(game.nodes) node_to_insert_above = game.root.children["L"].children["R"] # the [1, 0] node - player = game.players["Player 2"] + player = "Player 2" actions_to_add = ["a", "b", "c"] game.insert_move(node_to_insert_above, player, actions_to_add) @@ -1231,7 +1192,7 @@ def test_insert_move_actions_labeled(): """Test that the inserted move's actions are labeled according to `actions`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") node = game.root.children["L"].children["R"] - game.insert_move(node, game.players["Player 2"], ["Up", "Down"]) + game.insert_move(node, "Player 2", ["Up", "Down"]) assert list(node.parent.infoset.actions) == ["Up", "Down"] diff --git a/tests/test_outcomes.py b/tests/test_outcomes.py index 897e74fc7..b34dc1ac9 100644 --- a/tests/test_outcomes.py +++ b/tests/test_outcomes.py @@ -65,13 +65,27 @@ def test_make_outcome_incomplete_payoffs_raises(): game.make_outcome(next(iter(game.root.children)), {"Alice": 1}, "w") +class _RepeatedEntryPayoffs: + """A Mapping-like object whose `.items()` may repeat a key. + + Used to exercise `make_outcome`'s "named twice" check, which a plain + ``dict`` literal cannot: duplicate string keys collapse before the + dict is ever constructed. + """ + + def __init__(self, entries): + self._entries = entries + + def items(self): + return self._entries + + def test_make_outcome_payoffs_naming_player_twice_raises(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(game.root, "Alice", ["U", "D"]) - alice = game.players["Alice"] + payoffs = _RepeatedEntryPayoffs([("Alice", 1), ("Alice", 2), ("Bob", 0)]) with pytest.raises(ValueError): - game.make_outcome(next(iter(game.root.children)), - {"Alice": 1, alice: 2, "Bob": 0}, "w") + game.make_outcome(next(iter(game.root.children)), payoffs, "w") def test_make_outcome_null_resets_given_nodes_to_null(): @@ -99,9 +113,9 @@ 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) p1, p2 = game.players - s1 = next(iter(p1.strategies)) - s2 = next(iter(p2.strategies)) - game.make_outcome_null({p1.label: s1, p2.label: s2}) + 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 @@ -181,7 +195,7 @@ def test_outcome_index_invalid_type(game: gbt.Game): 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.label: "joe", pl2.label: "dan"}) + game.relabel_players({pl1: "joe", pl2: "dan"}) out1, out2, *_ = list(game.outcomes) out1["joe"] = 1 out1["dan"] = 2 diff --git a/tests/test_players.py b/tests/test_players.py index 5a9745593..76708e2fd 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -13,9 +13,9 @@ def test_player_count(): @pytest.mark.parametrize("label", games.VALID_LABELS) def test_player_label(label): game = gbt.Game.new_table([2, 2]) - player = next(iter(game.players)) - game.relabel_players({player.label: label}) - assert player.label == label + player, other = game.players + game.relabel_players({player: label}) + assert list(game.players) == [label, other] @pytest.mark.parametrize("label", games.INVALID_LABELS) @@ -23,16 +23,16 @@ def test_player_label_invalid_raises_valueerror(label): game = gbt.Game.new_table([2, 2]) player = next(iter(game.players)) with pytest.raises(ValueError): - game.relabel_players({player.label: label}) + game.relabel_players({player: label}) @pytest.mark.parametrize("label", games.UNICODE_LABELS) def test_player_label_unicode_accepted(label): """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" game = gbt.Game.new_table([2, 2]) - player = next(iter(game.players)) - game.relabel_players({player.label: label}) - assert player.label == label + player, other = game.players + game.relabel_players({player: label}) + assert list(game.players) == [label, other] def test_set_players_requires_iterable_of_str(): @@ -45,18 +45,18 @@ def test_set_players_requires_iterable_of_str(): def test_set_players_duplicate_label_raises_and_leaves_game_unchanged(): game = gbt.Game.new_table([2, 2]) - labels = [player.label for player in game.players] + labels = list(game.players) with pytest.raises(ValueError): game.set_players(labels + [labels[0]]) - assert [player.label for player in game.players] == labels + assert list(game.players) == labels def test_set_players_empty_label_raises_and_leaves_game_unchanged(): game = gbt.Game.new_table([2, 2]) - labels = [player.label for player in game.players] + labels = list(game.players) with pytest.raises(ValueError): game.set_players(labels + [""]) - assert [player.label for player in game.players] == labels + assert list(game.players) == labels def test_set_players_reserved_chance_label_raises_and_leaves_game_unchanged(): @@ -66,17 +66,11 @@ def test_set_players_reserved_chance_label_raises_and_leaves_game_unchanged(): assert len(game.players) == 0 -def test_chance_player_has_label(): - """The chance player is labeled "Chance" by default.""" - game = gbt.Game.new_tree() - assert game.players.chance.label == "Chance" - - def test_chance_player_label_cannot_be_changed(): """The chance player's label is reserved ("Chance") and cannot be changed.""" game = gbt.Game.new_tree() with pytest.raises(ValueError): - game.relabel_players({game.players.chance.label: "Nature"}) + game.relabel_players({"Chance": "Nature"}) def test_regular_player_cannot_be_relabeled_to_chance(): @@ -84,59 +78,47 @@ def test_regular_player_cannot_be_relabeled_to_chance(): game.set_players(["Alice"]) player = next(iter(game.players)) with pytest.raises(ValueError): - game.relabel_players({player.label: "Chance"}) + game.relabel_players({player: "Chance"}) -def test_player_index_by_string(): +def test_player_relabel_visible_via_membership(): game = gbt.Game.new_table([2, 2]) pl1, pl2 = game.players - game.relabel_players({pl1.label: "Alphonse", pl2.label: "Gaston"}) - assert game.players["Alphonse"].label == "Alphonse" - assert game.players["Gaston"].label == "Gaston" - - -def test_player_index_invalid(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(TypeError): - _ = game.players[1.3] - - -def test_player_label_invalid(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(KeyError): - _ = game.players["Not a player"] + game.relabel_players({pl1: "Alphonse", pl2: "Gaston"}) + assert "Alphonse" in game.players + assert "Gaston" in game.players def test_set_empty_player_raises_valueerror(): game = games.create_stripped_down_poker_efg() player = next(iter(game.players)) with pytest.raises(ValueError): - game.relabel_players({player.label: ""}) + game.relabel_players({player: ""}) def test_set_duplicate_player_raises_valueerror(): game = games.create_stripped_down_poker_efg() pl1, pl2, *_ = game.players with pytest.raises(ValueError): - game.relabel_players({pl1.label: pl2.label}) + game.relabel_players({pl1: pl2}) def test_relabel_players_swap(): game = gbt.Game.new_table([2, 2]) - a, b = (player.label for player in game.players) + a, b = game.players game.relabel_players({a: b, b: a}) - assert [player.label for player in game.players] == [b, a] + assert list(game.players) == [b, a] def test_relabel_players_swap_tree(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.relabel_players({"Alice": "Bob", "Bob": "Alice"}) - assert [player.label for player in game.players] == ["Bob", "Alice"] + assert list(game.players) == ["Bob", "Alice"] def test_relabel_players_duplicate_raises_valueerror(): game = gbt.Game.new_table([2, 2, 2]) - a, b, _ = (player.label for player in game.players) + a, b, _ = game.players with pytest.raises(ValueError): game.relabel_players({a: b}) with pytest.raises(ValueError): @@ -146,19 +128,19 @@ def test_relabel_players_duplicate_raises_valueerror(): @pytest.mark.parametrize("bad", ["", " x"]) def test_relabel_players_bad_label_raises_and_leaves_game_unchanged(bad: str): game = gbt.Game.new_table([2, 2]) - a, b = (player.label for player in game.players) + a, b = game.players with pytest.raises(ValueError): game.relabel_players({a: "X", b: bad}) - assert [player.label for player in game.players] == [a, b] + assert list(game.players) == [a, b] def test_relabel_players_unknown_label_strictness(): game = gbt.Game.new_table([2, 2]) - a = next(iter(game.players)).label + a = next(iter(game.players)) with pytest.raises(KeyError): game.relabel_players({"no-such-player": "X"}) game.relabel_players({"no-such-player": "X", a: "Y"}, strict=False) - assert next(iter(game.players)).label == "Y" + assert next(iter(game.players)) == "Y" def test_relabel_players_chance_key_raises_even_when_not_strict(): @@ -172,12 +154,12 @@ def test_relabel_players_chance_key_raises_even_when_not_strict(): def test_strategic_game_set_players_add(): game = gbt.Game.new_table([2, 2]) - labels = [player.label for player in game.players] + labels = list(game.players) game.set_players(labels + ["Player 3"]) - new_player = game.players["Player 3"] + new_player = "Player 3" assert len(game.players) == 3 - assert len(new_player.strategies) == 1 - assert next(iter(new_player.strategies)) == "1" + assert len(game.get_strategies(new_player)) == 1 + assert next(iter(game.get_strategies(new_player))) == "1" def test_extensive_game_set_players_add(): @@ -185,25 +167,25 @@ def test_extensive_game_set_players_add(): game.set_players(["Alice"]) pl1 = next(iter(game.players)) assert len(game.players) == 1 - assert len(game.get_infosets(pl1.label)) == 0 - assert len(pl1.strategies) == 1 + assert len(game.get_infosets(pl1)) == 0 + assert len(game.get_strategies(pl1)) == 1 def test_strategic_game_set_strategies_add(): game = gbt.Game.new_table([2, 2]) pl1, pl2 = game.players - game.set_strategies(pl1, list(pl1.strategies) + ["new strategy"]) - assert len(pl1.strategies) == 3 + game.set_strategies(pl1, list(game.get_strategies(pl1)) + ["new strategy"]) + assert len(game.get_strategies(pl1)) == 3 # This second add also ensures that we are testing the case where there # are null outcomes in the table - game.set_strategies(pl2, list(pl2.strategies) + ["new strategy"]) - assert len(pl2.strategies) == 3 + game.set_strategies(pl2, list(game.get_strategies(pl2)) + ["new strategy"]) + assert len(game.get_strategies(pl2)) == 3 def test_extensive_game_set_strategies(): game = gbt.Game.new_tree(["Alice"]) with pytest.raises(gbt.UndefinedOperationError): - game.set_strategies(game.players["Alice"], ["new strategy"]) + game.set_strategies("Alice", ["new strategy"]) def _tag_contingencies(game: gbt.Game) -> None: @@ -214,7 +196,7 @@ def _tag_contingencies(game: gbt.Game) -> None: players = list(game.players) for n, contingency in enumerate(game.contingencies, start=1): payoffs = { - player: int(f"{pl_index}{contingency[player.label]}") + player: int(f"{pl_index}{contingency[player]}") for pl_index, player in enumerate(players) } game.make_outcome(contingency, payoffs, f"c{n}") @@ -227,23 +209,23 @@ 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 for s in pl1.strategies if s != "2"] + surviving = [s for s in game.get_strategies(pl1) if s != "2"] expected = { (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.get_payoffs({pl1: s1, pl2: s2, pl3: s3}) + for s1 in game.get_strategies(pl1) if s1 in surviving + for s2 in game.get_strategies(pl2) for s3 in game.get_strategies(pl3) } game.set_strategies(pl1, surviving, drop=True) - assert list(pl1.strategies) == surviving - for s1 in pl1.strategies: - for s2 in pl2.strategies: - for s3 in pl3.strategies: + assert list(game.get_strategies(pl1)) == surviving + for s1 in game.get_strategies(pl1): + for s2 in game.get_strategies(pl2): + for s3 in game.get_strategies(pl3): key = (s1, s2, s3) actual = game.get_payoffs( - {pl1.label: s1, pl2.label: s2, pl3.label: s3} + {pl1: s1, pl2: s2, pl3: s3} ) assert actual == expected[key] @@ -253,20 +235,20 @@ def test_strategic_game_set_strategies_drop_first_preserves_other_payoffs(): pl1, pl2 = game.players _tag_contingencies(game) - surviving = [s for s in pl1.strategies if s != "1"] + surviving = [s for s in game.get_strategies(pl1) if s != "1"] expected = { - (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 + (s1, s2): game.get_payoffs({pl1: s1, pl2: s2}) + for s1 in game.get_strategies(pl1) if s1 in surviving + for s2 in game.get_strategies(pl2) } game.set_strategies(pl1, surviving, drop=True) - assert list(pl1.strategies) == surviving - for s1 in pl1.strategies: - for s2 in pl2.strategies: + assert list(game.get_strategies(pl1)) == surviving + for s1 in game.get_strategies(pl1): + for s2 in game.get_strategies(pl2): key = (s1, s2) - actual = game.get_payoffs({pl1.label: s1, pl2.label: s2}) + actual = game.get_payoffs({pl1: s1, pl2: s2}) assert actual == expected[key] @@ -281,8 +263,8 @@ def test_strategic_game_set_strategies_empty(): def test_set_strategies_label_valid(label): game = gbt.Game.new_table([2, 2]) pl1 = next(iter(game.players)) - game.set_strategies(pl1, list(pl1.strategies) + [label]) - assert list(pl1.strategies)[-1] == label + game.set_strategies(pl1, list(game.get_strategies(pl1)) + [label]) + assert list(game.get_strategies(pl1))[-1] == label @pytest.mark.parametrize("label", games.INVALID_LABELS) @@ -290,7 +272,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, list(pl1.strategies) + [label]) + game.set_strategies(pl1, list(game.get_strategies(pl1)) + [label]) def test_set_strategies_requires_iterable_of_str(): @@ -305,7 +287,7 @@ def test_set_strategies_requires_iterable_of_str(): def test_strategy_label_empty_raises_valueerror(): game = gbt.Game.new_table([2, 2]) pl1 = next(iter(game.players)) - strategy = next(iter(pl1.strategies)) + strategy = next(iter(game.get_strategies(pl1))) with pytest.raises(ValueError): game.relabel_strategies(pl1, {strategy: ""}) @@ -313,7 +295,7 @@ def test_strategy_label_empty_raises_valueerror(): def test_strategy_label_duplicate_within_player_raises_valueerror(): game = gbt.Game.new_table([2, 2]) pl1 = next(iter(game.players)) - s1, s2 = pl1.strategies + s1, s2 = game.get_strategies(pl1) with pytest.raises(ValueError): game.relabel_strategies(pl1, {s2: s1}) @@ -323,19 +305,19 @@ def test_player_sequence_count(): game = gbt.catalog.load("books/myerson1991/fig2_1") for player in game.players: action_count = sum( - len(node.infoset.actions) for node in game.get_infosets(player.label) + len(node.infoset.actions) for node in game.get_infosets(player) ) - assert len(player.sequences) == action_count + 1 + assert len(game.get_sequences(player)) == action_count + 1 def test_player_sequence_actions(): game = gbt.catalog.load("books/myerson1991/fig2_1") - player = game.players["Alice"] - sequences = set(tuple(seq.actions) for seq in player.sequences) + player = "Alice" + sequences = set(tuple(seq.actions) for seq in game.get_sequences(player)) reference = ( set( (action, ) - for node in game.get_infosets(player.label) + for node in game.get_infosets(player) for action in node.infoset.actions ) | {tuple()} @@ -345,8 +327,8 @@ def test_player_sequence_actions(): def test_player_sequence_tree(): game = gbt.catalog.load("books/myerson1991/fig2_1") - player = game.players["Alice"] - for seq in player.sequences: + player = "Alice" + for seq in game.get_sequences(player): if not seq.parent: continue assert seq in seq.parent.children @@ -382,78 +364,78 @@ def test_player_get_min_max_payoff(game: gbt.Game, exp_min_payoffs: list, exp_ma for player, exp_min, exp_max in zip( game.players, exp_min_payoffs, exp_max_payoffs, strict=True ): - assert player.min_payoff == exp_min - assert player.max_payoff == exp_max + assert game.get_min_payoff(player) == exp_min + assert game.get_max_payoff(player) == exp_max def test_player_get_min_payoff_nonterminal_outcomes(): - """Test whether `min_payoff` correctly reports minimum payoffs + """Test whether `get_min_payoff` correctly reports minimum payoffs when there are non-terminal outcomes. """ game = games.read_from_file("stripped_down_poker.efg") - assert game.players["Alice"].min_payoff == -2 - assert game.players["Bob"].min_payoff == -2 + assert game.get_min_payoff("Alice") == -2 + assert game.get_min_payoff("Bob") == -2 game.make_outcome(game.root, {"Alice": -1, "Bob": -1}, "outcome") - assert game.players["Alice"].min_payoff == -3 - assert game.players["Bob"].min_payoff == -3 + assert game.get_min_payoff("Alice") == -3 + assert game.get_min_payoff("Bob") == -3 def test_player_get_min_payoff_null_outcome(): - """Test whether `min_payoff` correctly reports minimum payoffs + """Test whether `get_min_payoff` correctly reports minimum payoffs in a strategic game with a null outcome.""" game = gbt.Game.from_arrays([[1, 1], [1, 1]], [[2, 2], [2, 2]]) pl1, pl2 = game.players - assert pl1.min_payoff == 1 - assert pl2.min_payoff == 2 - game.set_strategies(pl1, list(pl1.strategies) + ["new strategy"]) + assert game.get_min_payoff(pl1) == 1 + assert game.get_min_payoff(pl2) == 2 + game.set_strategies(pl1, list(game.get_strategies(pl1)) + ["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: - assert player.min_payoff == 0 + assert game.get_min_payoff(player) == 0 def test_player_get_max_payoff_nonterminal_outcomes(): - """Test whether `max_payoff` correctly reports maximum payoffs + """Test whether `get_max_payoff` correctly reports maximum payoffs when there are non-terminal outcomes. """ game = games.read_from_file("stripped_down_poker.efg") - assert game.players["Alice"].max_payoff == 2 - assert game.players["Bob"].max_payoff == 2 + assert game.get_max_payoff("Alice") == 2 + assert game.get_max_payoff("Bob") == 2 game.make_outcome(game.root, {"Alice": -1, "Bob": -1}, "outcome") - assert game.players["Alice"].max_payoff == 1 - assert game.players["Bob"].max_payoff == 1 + assert game.get_max_payoff("Alice") == 1 + assert game.get_max_payoff("Bob") == 1 def test_player_get_max_payoff_null_outcome(): - """Test whether `max_payoff` correctly reports maximum payoffs + """Test whether `get_max_payoff` correctly reports maximum payoffs in a strategic game with a null outcome.""" game = gbt.Game.from_arrays([[-1, -1], [-1, -1]], [[-2, -2], [-2, -2]]) pl1, pl2 = game.players - assert pl1.max_payoff == -1 - assert pl2.max_payoff == -2 - game.set_strategies(pl1, list(pl1.strategies) + ["new strategy"]) + assert game.get_max_payoff(pl1) == -1 + assert game.get_max_payoff(pl2) == -2 + game.set_strategies(pl1, list(game.get_strategies(pl1)) + ["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: - assert player.max_payoff == 0 + assert game.get_max_payoff(player) == 0 def test_set_strategies_duplicate_label_raises_and_leaves_game_unchanged(): game = gbt.Game.new_table([2, 2]) pl = next(iter(game.players)) - labels = list(pl.strategies) + labels = list(game.get_strategies(pl)) with pytest.raises(ValueError): game.set_strategies(pl, labels + [labels[0]]) - assert list(pl.strategies) == labels + assert list(game.get_strategies(pl)) == 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 = list(pl.strategies) + labels = list(game.get_strategies(pl)) with pytest.raises(ValueError): game.set_strategies(pl, labels + [""]) - assert list(pl.strategies) == labels + assert list(game.get_strategies(pl)) == labels def test_set_players_empty_raises(): @@ -465,26 +447,26 @@ def test_set_players_empty_raises(): def test_set_players_reorder_transposes_table(): """Reordering the players permutes the axes of the payoff table.""" game = gbt.Game.from_arrays([[1, 2, 3], [4, 5, 6]], [[7, 8, 9], [10, 11, 12]]) - a, b = (player.label for player in game.players) + a, b = game.players game.set_players([b, a]) - assert [player.label for player in game.players] == [b, a] + assert list(game.players) == [b, a] assert game.to_arrays()[0].tolist() == [[7, 10], [8, 11], [9, 12]] assert game.to_arrays()[1].tolist() == [[1, 4], [2, 5], [3, 6]] def test_set_players_add_then_drop_round_trips(): game = gbt.Game.from_arrays([[1, 2], [3, 4]], [[5, 6], [7, 8]]) - labels = [player.label for player in game.players] + labels = list(game.players) game.set_players(labels + ["X"]) assert all(outcome["X"] == 0 for outcome in game.outcomes) game.set_players(labels, drop=True) - assert [player.label for player in game.players] == labels + assert list(game.players) == labels assert game.to_arrays()[0].tolist() == [[1, 2], [3, 4]] def test_set_players_drop_requires_deletable_player(): game = gbt.Game.new_table([2, 2]) - a, _ = (player.label for player in game.players) + a, _ = game.players with pytest.raises(gbt.UndefinedOperationError): game.set_players([a], drop=True) tree = games.create_stripped_down_poker_efg() @@ -494,9 +476,9 @@ def test_set_players_drop_requires_deletable_player(): def test_set_players_unconfirmed_drop_and_disabled_add_raise(): game = gbt.Game.new_table([2, 2]) - labels = [player.label for player in game.players] + labels = list(game.players) with pytest.raises(ValueError): game.set_players(labels[:1]) with pytest.raises(ValueError): game.set_players(labels + ["X"], add=False) - assert [player.label for player in game.players] == labels + assert list(game.players) == labels diff --git a/tests/test_qre.py b/tests/test_qre.py index e048f5368..fd5770edc 100644 --- a/tests/test_qre.py +++ b/tests/test_qre.py @@ -13,7 +13,7 @@ def _asymmetric_poker_behavior_data() -> gbt.MixedBehaviorProfile: game = games.create_stripped_down_poker_efg() data = game.mixed_behavior_profile(rational=False) for player in game.players: - for infoset in games.player_infosets(player): + for infoset in games.player_infosets(game, player): node = next(iter(infoset.members)) data[node] = {a: float(i + 2) for i, a in enumerate(infoset.actions)} return data @@ -45,10 +45,10 @@ def test_logit_estimate_strategy_rational_and_float_data_agree(): assert rational_result.lam == pytest.approx(float_result.lam) for player in game.players: - for strategy in player.strategies: + for strategy in game.get_strategies(player): assert ( - rational_result.profile[player.label][strategy] - == pytest.approx(float_result.profile[player.label][strategy]) + rational_result.profile[player][strategy] + == pytest.approx(float_result.profile[player][strategy]) ) @@ -73,7 +73,7 @@ def test_logit_estimate_behavior_completes(use_empirical: bool, local_max: bool) result = gbt.qre.logit_estimate(data, use_empirical=use_empirical, local_max=local_max) assert isinstance(result.profile, gbt.MixedBehaviorProfileDouble) for player in data.game.players: - for infoset in games.player_infosets(player): + for infoset in games.player_infosets(data.game, player): node = next(iter(infoset.members)) probs = dict(result.profile[node]) assert probs.keys() == set(infoset.actions) diff --git a/tests/test_strategic.py b/tests/test_strategic.py index a7a7dc37a..cffb85497 100644 --- a/tests/test_strategic.py +++ b/tests/test_strategic.py @@ -9,7 +9,7 @@ def test_strategic_game_get_infosets(): game = gbt.Game.new_table([2, 2]) player, _ = game.players with pytest.raises(gbt.UndefinedOperationError): - _ = game.get_infosets(player.label) + _ = game.get_infosets(player) def test_strategic_game_root(): @@ -54,9 +54,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 = player.strategies + a, b = game.get_strategies(player) game.relabel_strategies(player, {a: b, b: a}) - assert list(player.strategies) == [b, a] + assert list(game.get_strategies(player)) == [b, a] def test_relabel_strategies_duplicate_raises_valueerror(): @@ -65,7 +65,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 = player.strategies + a, b = game.get_strategies(player) with pytest.raises(ValueError): game.relabel_strategies(player, {a: b}) with pytest.raises(ValueError): @@ -77,44 +77,44 @@ 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 = player.strategies + a, b = game.get_strategies(player) with pytest.raises(ValueError): game.relabel_strategies(player, {a: "X", b: bad}) - assert list(player.strategies) == [a, b] + assert list(game.get_strategies(player)) == [a, b] def test_relabel_strategies_unknown_label_strictness(): game = gbt.Game.new_table([2, 2]) player, _ = game.players - a = next(iter(player.strategies)) + a = next(iter(game.get_strategies(player))) 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)) == "Y" + assert next(iter(game.get_strategies(player))) == "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)): "X"}) - game.relabel_strategies(two, {next(iter(two.strategies)): "X"}) - assert [next(iter(p.strategies)) for p in game.players] == ["X", "X"] + game.relabel_strategies(one, {next(iter(game.get_strategies(one))): "X"}) + game.relabel_strategies(two, {next(iter(game.get_strategies(two))): "X"}) + assert [next(iter(game.get_strategies(p))) for p in game.players] == ["X", "X"] def test_relabel_strategies_tree_game_raises(): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(gbt.UndefinedOperationError): - game.relabel_strategies(game.players["Alice"], {"11": "XY"}) + game.relabel_strategies("Alice", {"11": "XY"}) def _payoffs_by_label(game: gbt.Game) -> dict: one, two = game.players 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]) + for s in game.get_strategies(one): + for t in game.get_strategies(two): + payoffs = game.get_payoffs({one: s, two: t}) + result[s, t] = (payoffs[one], payoffs[two]) return result @@ -123,12 +123,12 @@ 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 = player.strategies - kept = list(player.strategies) + a, b = game.get_strategies(player) + kept = list(game.get_strategies(player)) before = _payoffs_by_label(game) game.set_strategies(player, [b, a]) - assert list(player.strategies) == [b, a] - assert list(player.strategies) == list(reversed(kept)) + assert list(game.get_strategies(player)) == [b, a] + assert list(game.get_strategies(player)) == list(reversed(kept)) assert _payoffs_by_label(game) == before @@ -137,25 +137,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 = player.strategies + a, b = game.get_strategies(player) kept = { - t: game.get_payoffs({player.label: a, other.label: t})[player.label] - for t in other.strategies + t: game.get_payoffs({player: a, other: t})[player] + for t in game.get_strategies(other) } game.set_strategies(player, ["X", a], drop=True) - assert list(player.strategies) == ["X", a] + assert list(game.get_strategies(player)) == ["X", a] assert { - t: game.get_payoffs({player.label: a, other.label: t})[player.label] - for t in other.strategies + t: game.get_payoffs({player: a, other: t})[player] + for t in game.get_strategies(other) } == kept def test_set_strategies_unconfirmed_drop_and_disabled_add_raise(): game = gbt.Game.new_table([2, 2]) player, _ = game.players - a, b = player.strategies + a, b = game.get_strategies(player) with pytest.raises(ValueError): game.set_strategies(player, [a]) with pytest.raises(ValueError): game.set_strategies(player, [a, b, "X"], add=False) - assert list(player.strategies) == [a, b] + assert list(game.get_strategies(player)) == [a, b] diff --git a/tests/test_stratprofiles.py b/tests/test_stratprofiles.py index c5dc3d355..ac882bf56 100644 --- a/tests/test_stratprofiles.py +++ b/tests/test_stratprofiles.py @@ -10,7 +10,7 @@ def test_getitem_labels(): profile = game.strategy_support_profile() support = profile["Player 1"] assert set(support) == {"1", "2", "3"} - assert support.player == game.players["Player 1"] + assert support.player == "Player 1" assert "1" in support assert "not-a-label" not in support @@ -26,7 +26,7 @@ def test_getitem_rejects_non_str(): game = games.read_from_file("mixed_strategy.nfg") profile = game.strategy_support_profile() with pytest.raises(TypeError): - profile[game.players["Player 1"]] + profile[1] def test_predicate_construction(): @@ -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 player, label: player.label != "Player 1") + game.strategy_support_profile(lambda player, label: player != "Player 1") def test_iter_yields_one_support_per_player(): @@ -47,7 +47,7 @@ def test_iter_yields_one_support_per_player(): profile = game.strategy_support_profile() supports = list(profile) assert len(supports) == len(game.players) - assert {s.player.label for s in supports} == {p.label for p in game.players} + assert {s.player for s in supports} == set(game.players) def test_setitem_replaces_support(): @@ -120,8 +120,8 @@ def test_restrict(): game = games.read_from_file("mixed_strategy.nfg") 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 + assert len(restricted.get_strategies("Player 1")) == 2 + assert len(restricted.get_strategies("Player 2")) == 2 def test_undominated():