diff --git a/ChangeLog b/ChangeLog index b89f2ed60..106d98e91 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,6 +1,23 @@ ## [17.0.0-beta.1] - unreleased ### Added +- Added `Game.get_infosets(player)`, returning a materialized list of one representative + `Node` per information set belonging to `player`, replacing `Player.infosets`/`Game.infosets`. + `player` must be a personal player; use `Game.get_events()` for the chance player's events. +- Added `Game.get_events()`, returning a materialized list of one representative `Node` per + chance event in the game. +- Added `Event`, the chance-event counterpart to `Infoset`: a lazy, node-anchored view of the + chance player's information partition. Added `Node.event` alongside the existing + `Node.infoset`, which is now specific to personal players' information sets; each is falsy + when it does not apply to a given node. +- Added `Node.members`, the set of nodes belonging to the same information set or event as `Node`, + equivalent to `Node.infoset.members`/`Node.event.members`, whichever applies. +- Added `Node.actions`, the labels of the actions available at the node's current information + set or event, whichever applies (equivalent to `Node.infoset.actions`/`Node.event.actions`). +- Added `Node.action_probs`, the probability of each action at the node's current chance event, + keyed by label. +- Added `Branch`, a `(node, label)` pair returned by `Node.prior_action`/`Node.own_prior_action`, + replacing `Action`. - Added `BehaviorSupportProfile` to `pygambit`. - Added callback functions to Nash solvers, allowing calling code to take actions when an equilibrium is found or another solver event is emitted. @@ -17,6 +34,22 @@ contingency, for a game in any representation. ### Changed +- `Infoset` is now a lazy, node-anchored view (like `Node.player`/`Node.outcome`), constructed + by anchoring on a representative member node rather than carrying independent identity, and is + now specific to personal players; see `Event` for the chance player. `Infoset.is_chance` has + been removed, as `Infoset` is never chance; test truthiness of `Node.infoset`/`Node.event` + instead. + `Game.append_infoset`, `Game.insert_infoset`, `Game.set_move_actions`, `Game.set_event_actions`, + `Game.relabel_actions`, `Game.reveal`, and `Game.minimal_subgame` now identify an information + set or event via a member `Node` (or its label) rather than an `Infoset` object. +- `Infoset.actions`/`Event.actions`/`Node.actions` now return the plain labels (`list[str]`) of + the actions at the information set or event, rather than `Action` objects. +- `Sequence.actions` now returns a `tuple[str, ...]` of labels rather than `Action` objects. +- `Node.prior_action`/`Node.own_prior_action` now return a `Branch` (a `(node, label)` pair) + rather than an `Action`. +- `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`. - 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 @@ -47,8 +80,6 @@ - `Game.get_behavior`'s `player` and `strategy` parameters are now labels (`str`) rather than accepting a `Player`/`Strategy` object; `StrategyBehavior.player`/`.strategy` now return labels rather than `Player`/`Strategy` objects. - -### Removed - Removed `Game.__getitem__`, replaced by `Game.get_outcome`/`Game.get_payoffs`. - Removed `Game.strategies`; iterate `Game.players` and each player's `strategies` instead. - Removed `Strategy.action`; use `Game.get_behavior(player, strategy)[infoset]` (or `.get`), @@ -57,6 +88,17 @@ `Player.strategies`; a `Strategy` object carried no information a label didn't already carry, now that strategy labels are unique within a player. `GameStrategyRep` in the C++ core is unaffected by this change (a separate, later piece of work). +- Removed `Player.infosets` and `Game.infosets`; use `Game.get_infosets(player)` instead. +- Removed `Game.actions`/`Player.actions`; the total count/collection of actions across a + game or player is now most directly available by iterating `Game.get_infosets(player)` and + each node's `Node.actions`. +- Removed `Action`. Actions are now identified purely by label (`str`), as returned by + `Node.actions`/`Infoset.actions`/`Event.actions`; use `Node.action_probs` for chance-event + action probabilities, previously `Action.prob`, and `Node.prior_action`/`Node.own_prior_action`'s + `Branch` in place of an `Action` reached via a node. `StrategyBehavior`'s prescribed actions + are likewise now labels rather than `Action` objects. Also removed + `Infoset.own_prior_actions`/`Infoset.plays` (and their `Event` mirrors), subsumed by the + existing `Node.own_prior_action`/`Node.plays`. ## [17.0.0-alpha.2] - 2026-08-21 diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index e664b68d4..f7e1395d0 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -18,7 +18,8 @@ Representation of games Outcome Node Infoset - Action + Event + Branch Subgame @@ -109,8 +110,8 @@ Information about the game Game.min_payoff Game.max_payoff Game.root - Game.actions - Game.infosets + Game.get_infosets + Game.get_events Game.nodes Game.contingencies Game.get_outcome @@ -125,8 +126,6 @@ Information about the game Player.number Player.game Player.strategies - Player.infosets - Player.actions Player.is_chance Player.min_payoff Player.max_payoff @@ -154,6 +153,10 @@ Information about the game Node.prior_sibling Node.next_sibling Node.infoset + Node.event + Node.members + Node.actions + Node.action_probs Node.player Node.is_successor_of Node.plays @@ -173,24 +176,23 @@ Information about the game Infoset.label Infoset.game - Infoset.is_chance Infoset.is_absent_minded Infoset.player Infoset.actions Infoset.members Infoset.precedes - Infoset.plays - Infoset.own_prior_actions .. autosummary:: :toctree: api/ - Action.label - Action.infoset - Action.precedes - Action.prob - Action.plays + Event.label + Event.game + Event.is_absent_minded + Event.player + Event.actions + Event.members + Event.precedes .. autosummary:: diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index a11aaf3c6..7088ad471 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -168,7 +168,7 @@ "source": [ "The loop above causes each of the newly-appended moves to be in new information sets, reflecting the fact that Alice's decision depends on the knowledge of which card she holds.\n", "\n", - "In contrast, Bob does not know Alice’s card, and therefore cannot distinguish between the two nodes at which he has to make his decision:\n", + "In contrast, Bob does not know Alice\u2019s card, and therefore cannot distinguish between the two nodes at which he has to make his decision:\n", "\n", " - Chance player chooses King, then Alice Bets: `g.root.children[\"King\"].children[\"Bet\"]`\n", " - Chance player chooses Queen, then Alice Bets: `g.root.children[\"Queen\"].children[\"Bet\"]`\n", @@ -391,12 +391,12 @@ "metadata": {}, "outputs": [], "source": [ - "for action in g.players[\"Alice\"].actions:\n", - " node = next(iter(action.infoset.members))\n", - " print(\n", - " f\"At information set {action.infoset.number}, \"\n", - " f\"Alice plays {action.label} with probability: {eqm[node][action.label]}\"\n", - " )" + "for node in g.get_infosets(\"Alice\"):\n", + " for action in node.actions:\n", + " print(\n", + " f\"At information set {node.infoset.number}, \"\n", + " f\"Alice plays {action} with probability: {eqm[node][action]}\"\n", + " )" ] }, { @@ -404,7 +404,7 @@ "id": "1f121d48", "metadata": {}, "source": [ - "Now let's look at Bob’s strategy:" + "Now let's look at Bob\u2019s strategy:" ] }, { @@ -422,7 +422,7 @@ "id": "e906c4c4", "metadata": {}, "source": [ - "Bob Calls Alice’s Bet two-thirds of the time.\n", + "Bob Calls Alice\u2019s Bet two-thirds of the time.\n", "\n", "Since Bob has just one information set, we can get its representative node and index\n", "the profile directly by it to read off a single action's probability:" @@ -434,11 +434,7 @@ "id": "2966e700", "metadata": {}, "outputs": [], - "source": [ - "(bob_infoset,) = g.players[\"Bob\"].infosets\n", - "bob_node = next(iter(bob_infoset.members))\n", - "eqm[bob_node][\"Call\"]" - ] + "source": "(bob_node,) = g.get_infosets(\"Bob\")\nbob_infoset = bob_node.infoset\neqm[bob_node][\"Call\"]" }, { "cell_type": "markdown", @@ -460,7 +456,7 @@ "bob_action_values = eqm.action_values[bob_node]\n", "for action in bob_infoset.actions:\n", " print(\n", - " f\"When Bob plays {action.label} his expected payoff is {bob_action_values[action.label]}\"\n", + " f\"When Bob plays {action} his expected payoff is {bob_action_values[action]}\"\n", " )" ] }, @@ -671,14 +667,14 @@ " )\n", " gnm_action_values = gnm_eqm.as_behavior().action_values\n", " lcp_action_values = eqm.action_values\n", - " for action in player.actions:\n", - " node = next(iter(action.infoset.members))\n", - " print(\n", - " f\"At information set {action.infoset.number}, \"\n", - " f\"when playing {action.label} - \"\n", - " f\"gnm: {gnm_action_values[node][action.label]:.4f}\"\n", - " f\", lcp: {str(lcp_action_values[node][action.label])}\"\n", - " )\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()" ] }, @@ -885,7 +881,11 @@ "id": "2f79695a", "metadata": {}, "outputs": [], - "source": "small_game = gbt.Game.new_tree()\nsmall_game.append_event(small_game.root, [\"a\", \"b\", \"c\"], [gbt.Rational(1, 3)] * 3)\n[act.prob for act in small_game.root.infoset.actions]" + "source": [ + "small_game = gbt.Game.new_tree()\n", + "small_game.append_event(small_game.root, [\"a\", \"b\", \"c\"], [gbt.Rational(1, 3)] * 3)\n", + "list(small_game.root.action_probs.values())" + ] }, { "cell_type": "markdown", @@ -899,7 +899,13 @@ "id": "5de6acb2", "metadata": {}, "outputs": [], - "source": "small_game.make_event(\n [small_game.root],\n [gbt.Rational(1, 4), gbt.Rational(1, 2), gbt.Rational(1, 4)]\n)\n[act.prob for act in small_game.root.infoset.actions]" + "source": [ + "small_game.make_event(\n", + " [small_game.root],\n", + " [gbt.Rational(1, 4), gbt.Rational(1, 2), gbt.Rational(1, 4)]\n", + ")\n", + "list(small_game.root.action_probs.values())" + ] }, { "cell_type": "markdown", @@ -915,7 +921,13 @@ "id": "c47d2ab6", "metadata": {}, "outputs": [], - "source": "small_game.make_event(\n [small_game.root],\n [gbt.Decimal(\".25\"), gbt.Decimal(\".50\"), gbt.Decimal(\".25\")]\n)\n[act.prob for act in small_game.root.infoset.actions]" + "source": [ + "small_game.make_event(\n", + " [small_game.root],\n", + " [gbt.Decimal(\".25\"), gbt.Decimal(\".50\"), gbt.Decimal(\".25\")]\n", + ")\n", + "list(small_game.root.action_probs.values())" + ] }, { "cell_type": "markdown", @@ -935,7 +947,10 @@ "id": "04329084", "metadata": {}, "outputs": [], - "source": "small_game.make_event([small_game.root], [\"1/4\", \"1/2\", \"1/4\"])\n[act.prob for act in small_game.root.infoset.actions]" + "source": [ + "small_game.make_event([small_game.root], [\"1/4\", \"1/2\", \"1/4\"])\n", + "list(small_game.root.action_probs.values())" + ] }, { "cell_type": "code", @@ -943,7 +958,10 @@ "id": "9015e129", "metadata": {}, "outputs": [], - "source": "small_game.make_event([small_game.root], [\".25\", \".50\", \".25\"])\n[act.prob for act in small_game.root.infoset.actions]" + "source": [ + "small_game.make_event([small_game.root], [\".25\", \".50\", \".25\"])\n", + "list(small_game.root.action_probs.values())" + ] }, { "cell_type": "markdown", @@ -964,7 +982,10 @@ "id": "0a019aa5", "metadata": {}, "outputs": [], - "source": "small_game.make_event([small_game.root], [.25, .50, .25])\n[act.prob for act in small_game.root.infoset.actions]" + "source": [ + "small_game.make_event([small_game.root], [.25, .50, .25])\n", + "list(small_game.root.action_probs.values())" + ] }, { "cell_type": "markdown", diff --git a/src/pygambit/action.pxi b/src/pygambit/action.pxi index f91a83007..2539c787d 100644 --- a/src/pygambit/action.pxi +++ b/src/pygambit/action.pxi @@ -20,104 +20,23 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # -@cython.cclass -class Action: - """A choice available at an ``Infoset`` in a ``Game``.""" - action = cython.declare(c_GameAction) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create an Action outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(action: c_GameAction) -> Action: - obj: Action = Action.__new__(Action) - obj.action = action - return obj - - def __repr__(self) -> str: - if self.label: - return f"Action(infoset={self.infoset}, label='{self.label}')" - else: - return f"Action(infoset={self.infoset}, number={self.number})" - - def __eq__(self, other: typing.Any) -> bool: - return ( - isinstance(other, Action) and - self.action.deref() == cython.cast(Action, other).action.deref() - ) - - def __hash__(self) -> int: - return cython.cast(cython.long, self.action.deref()) - - @property - def number(self) -> int: - """Returns the number of the action at its information set. - Actions are numbered starting with 0. - """ - return self.action.deref().GetNumber() - 1 - - def precedes(self, node: Node) -> bool: - """Returns whether `node` precedes this action in the - extensive game. - - Raises - ------ - MismatchError - If `node` is not in the same game as the action. - """ - if self.infoset.game != node.game: - raise MismatchError("precedes() requires a node from the same game as the action") - return self.action.deref().Precedes(cython.cast(Node, node).node) - - @property - def label(self) -> str: - """The text label of the action. - - .. 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 within its - information set; use `Game.relabel_actions` to change it. - """ - return self.action.deref().GetLabel().decode("utf-8") - - @property - def infoset(self) -> Infoset: - """Get the information set to which the action belongs.""" - return Infoset.wrap(self.action.deref().GetInfoset()) - - @property - def prob(self) -> decimal.Decimal | Rational: - """ - Get the probability a chance action is played. - - Raises - ------ - UndefinedOperationError - If the action does not belong to the chance player. - """ - if not self.infoset.is_chance: - raise UndefinedOperationError( - "action probabilities are only defined at events" - ) - py_string = cython.cast( - string, - self.action.deref().GetInfoset().deref().GetActionProb(self.action) - ) - if "." in py_string.decode("ascii"): - return decimal.Decimal(py_string.decode("ascii")) - else: - return Rational(py_string.decode("ascii")) - - @property - def plays(self) -> list[Node]: - """Returns a list of all terminal `Node` objects consistent with it. - """ - return [ - Node.wrap(n) for n in - self.action.deref().GetInfoset().deref().GetGame().deref().GetPlays(self.action) - ] +Branch = collections.namedtuple("Branch", ["node", "label"]) +Branch.__doc__ = """The action labeled `label`, taken at `node`. + +Returned by `Node.prior_action` and `Node.own_prior_action`; `node` is the node at +which the action was taken (not the node it leads to), so ``branch.node.actions`` +and, for a chance event, ``branch.node.action_probs[branch.label]`` are always +well-defined. + +.. versionadded:: 17.0.0 +""" + + +@cython.cfunc +def _decode_prob(py_string: string) -> object: + """Internal: decode a probability formatted by the C++ core as ``Decimal`` or + ``Rational``, matching whichever representation was used to specify it.""" + if "." in py_string.decode("ascii"): + return decimal.Decimal(py_string.decode("ascii")) + else: + return Rational(py_string.decode("ascii")) diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 8b7253aac..b27ab1189 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -36,8 +36,9 @@ class InfosetIndexedVector(_LabeledVector): _label_kind = "information set" def __getitem__(self, node: Node) -> typing.Any: - infoset = cython.cast(NodeInfoset, node.infoset)._resolve() - if infoset is None: + resolved_node = cython.cast(Node, node) + infoset = resolved_node.infoset or resolved_node.event + if not infoset: raise ValueError("node is terminal, has no information set") try: return self._values[infoset] @@ -312,8 +313,8 @@ class MixedBehavior: ValueError If `index` is a terminal node, which belongs to no information set. """ - infoset = cython.cast(NodeInfoset, index.infoset)._resolve() - if infoset is None: + infoset = cython.cast(Infoset, index.infoset) + if not infoset: raise ValueError("node is terminal, has no information set") if infoset.player != self.player: raise MismatchError("node must belong to this player") @@ -413,7 +414,8 @@ class MixedBehaviorProfile: if isinstance(index, str): resolved_player = self.game._resolve_player(index, "__getitem__") values = { - infoset: self._mixed_action_at(infoset) for infoset in resolved_player.infosets + node.infoset: self._mixed_action_at(node.infoset) + for node in self.game.get_infosets(resolved_player.label) } return MixedBehavior.wrap(resolved_player, values) raise TypeError( @@ -421,34 +423,66 @@ class MixedBehaviorProfile: ) def _resolve_infoset_for_node(self, node: Node) -> Infoset: - """Resolves the information set containing node. + """Resolves the personal player's information set containing node. Raises ------ MismatchError If `node` belongs to a different game. ValueError - If `node` is terminal, and so belongs to no information set. + If `node` resolves to a chance event, or is terminal, and so belongs to + no personal player's information set. """ if node.game != self.game: raise MismatchError("node must belong to this game") - infoset = cython.cast(NodeInfoset, node.infoset)._resolve() - if infoset is None: + infoset = cython.cast(Infoset, node.infoset) + if not infoset: + if node.event: + raise ValueError( + "node belongs to a chance event, not a personal player's " + "information set" + ) raise ValueError("node is terminal, has no information set") return infoset def _all_infosets(self) -> typing.Iterator[Infoset]: - """Iterates over every information set in the game, including the chance - player's, which ``self.game.infosets`` excludes. + """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): + yield node.infoset + for node in self.game.get_events(): + yield node.event + + def _personal_infosets(self) -> typing.Iterator[Infoset]: + """Iterates over every information set in the game belonging to a personal + player, excluding the chance player's. """ - yield from self.game.infosets - yield from self.game.players.chance.infosets + for player in self.game.players: + for node in self.game.get_infosets(player.label): + yield node.infoset + + @cython.cfunc + def _getprob_action(self, index: c_GameAction) -> object: + raise NotImplementedError + + @cython.cfunc + def _setprob_action(self, index: c_GameAction, value: typing.Any) -> cython.void: + raise NotImplementedError + + @cython.cfunc + def _action_value(self, action: c_GameAction) -> object: + raise NotImplementedError + + @cython.cfunc + def _action_regret(self, action: c_GameAction) -> object: + raise NotImplementedError def _mixed_action_at(self, infoset: Infoset) -> MixedAction: """Returns a snapshot of the mixed action at infoset, as of now.""" - return MixedAction.wrap( - infoset, {a.label: self._getprob_action(a) for a in infoset.actions} - ) + values: dict = {} + for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions(): + values[a.deref().GetLabel().decode("utf-8")] = self._getprob_action(a) + return MixedAction.wrap(infoset, values) def _setprob_infoset( self, infoset: Infoset, distribution: collections.abc.Mapping, sparse: bool @@ -465,7 +499,7 @@ class MixedBehaviorProfile: f"a mixed action must be set from a Mapping from action label to " f"weight, not {distribution.__class__.__name__}" ) - labels = {a.label for a in infoset.actions} + labels = set(infoset.actions) given = set(distribution.keys()) unknown = given - labels if unknown: @@ -484,8 +518,8 @@ class MixedBehaviorProfile: raise ValueError("a mixed action's weights must be non-negative") if all(v == 0 for v in values.values()): raise ValueError("a mixed action's weights must not all be zero") - for a in infoset.actions: - self._setprob_action(a, values[a.label]) + for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions(): + self._setprob_action(a, values[a.deref().GetLabel().decode("utf-8")]) def __setitem__(self, index: Node, distribution: collections.abc.Mapping) -> None: """Sets the mixed action at the information set containing `index`. @@ -580,23 +614,22 @@ class MixedBehaviorProfile: infoset = self._resolve_infoset_for_node(index) self._setprob_infoset(infoset, distribution, sparse=sparse) - def is_defined_at(self, infoset: InfosetReference) -> bool: + def is_defined_at(self, infoset: NodeReference) -> bool: """Returns whether the profile has probabilities defined at the information set. A profile can be well-defined if probabilities are not specified at some information sets, as long as those information sets are reached with zero probability. Parameters ---------- - infoset : Infoset or str - The information set to check. If a string is passed, the - information set is determined by finding the information set with that label, if any. + infoset : Node or str + A node belonging to the information set to check, or such a node's label. Raises ------ MismatchError - If `infoset` is an ``Infoset`` from a different game. + If `infoset` is a ``Node`` from a different game. KeyError - If `infoset` is a string and no information set in the game has that label. + If `infoset` is a string and no node in the game has that label. """ self._check_validity() return self._is_defined_at(self.game._resolve_infoset(infoset, "is_defined_at")) @@ -637,7 +670,7 @@ class MixedBehaviorProfile: """ self._check_validity() return InfosetValueVector({ - infoset: self._infoset_value(infoset) for infoset in self.game.infosets + infoset: self._infoset_value(infoset) for infoset in self._personal_infosets() }) @property @@ -655,8 +688,11 @@ class MixedBehaviorProfile: """ self._check_validity() return ActionValuesVector({ - infoset: ActionValueVector({a.label: self._action_value(a) for a in infoset.actions}) - for infoset in self.game.infosets + infoset: ActionValueVector({ + a.deref().GetLabel().decode("utf-8"): self._action_value(a) + for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions() + }) + for infoset in self._personal_infosets() }) @property @@ -725,7 +761,7 @@ class MixedBehaviorProfile: always non-negative. Regret is not defined for the chance player, which takes no decisions; its - information sets are excluded (``self.game.infosets`` already excludes them). + information sets are excluded. See Also -------- @@ -734,8 +770,11 @@ class MixedBehaviorProfile: """ self._check_validity() return ActionRegretsVector({ - infoset: ActionRegretVector({a.label: self._action_regret(a) for a in infoset.actions}) - for infoset in self.game.infosets + infoset: ActionRegretVector({ + a.deref().GetLabel().decode("utf-8"): self._action_regret(a) + for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions() + }) + for infoset in self._personal_infosets() }) @property @@ -749,7 +788,7 @@ class MixedBehaviorProfile: By convention, the regret is always non-negative. Regret is not defined for the chance player, which takes no decisions; its - information sets are excluded (``self.game.infosets`` already excludes them). + information sets are excluded. See Also -------- @@ -758,7 +797,7 @@ class MixedBehaviorProfile: """ self._check_validity() return InfosetRegretVector({ - infoset: self._infoset_regret(infoset) for infoset in self.game.infosets + infoset: self._infoset_regret(infoset) for infoset in self._personal_infosets() }) def agent_max_regret(self) -> ProfileDType: @@ -902,10 +941,11 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): return deref(self.profile).BehaviorProfileLength() def _is_defined_at(self, infoset: Infoset) -> bool: - return deref(self.profile).IsDefinedAt(infoset.infoset) + return deref(self.profile).IsDefinedAt(infoset._resolve()) - def _getprob_action(self, index: Action) -> float: - return deref(self.profile).getaction(index.action) + @cython.cfunc + def _getprob_action(self, index: c_GameAction) -> object: + return deref(self.profile).getaction(index) @cython.cfunc def _ensure_unshared(self) -> cython.void: @@ -915,9 +955,10 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): if self.profile.use_count() != 1: self.profile = make_shared[c_MixedBehaviorProfile[double]](deref(self.profile)) - def _setprob_action(self, index: Action, value) -> None: + @cython.cfunc + def _setprob_action(self, index: c_GameAction, value) -> cython.void: self._ensure_unshared() - setitem_mbpd_action(deref(self.profile), index.action, value) + setitem_mbpd_action(deref(self.profile), index, value) def _to_prob(self, value: typing.Any) -> float: normalized = _to_number_string(value) @@ -939,11 +980,11 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): def _realiz_prob(self, node: Node) -> float: return deref(self.profile).GetRealizProb(node.node) - def _infoset_prob(self, infoset: Infoset) -> float: - return deref(self.profile).GetInfosetProb(infoset.infoset) + def _infoset_prob(self, infoset: _InfosetOrEvent) -> float: + return deref(self.profile).GetInfosetProb(infoset._resolve()) def _infoset_value(self, infoset: Infoset) -> float | None: - cdef optional[double] value = deref(self.profile).GetPayoff(infoset.infoset) + cdef optional[double] value = deref(self.profile).GetPayoff(infoset._resolve()) if value.has_value(): return value.value() return None @@ -951,17 +992,19 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): def _node_value(self, player: Player, node: Node) -> float: return deref(self.profile).GetPayoff(player.player, node.node) - def _action_value(self, action: Action) -> float | None: - cdef optional[double] value = deref(self.profile).GetPayoff(action.action) + @cython.cfunc + def _action_value(self, action: c_GameAction) -> object: + cdef optional[double] value = deref(self.profile).GetPayoff(action) if value.has_value(): return value.value() return None - def _action_regret(self, action: Action) -> float: - return deref(self.profile).GetRegret(action.action) + @cython.cfunc + def _action_regret(self, action: c_GameAction) -> object: + return deref(self.profile).GetRegret(action) def _infoset_regret(self, infoset: Infoset) -> float: - return deref(self.profile).GetRegret(infoset.infoset) + return deref(self.profile).GetRegret(infoset._resolve()) def _agent_max_regret(self) -> float: return deref(self.profile).GetAgentMaxRegret() @@ -1027,10 +1070,11 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): return deref(self.profile).BehaviorProfileLength() def _is_defined_at(self, infoset: Infoset) -> bool: - return deref(self.profile).IsDefinedAt(infoset.infoset) + return deref(self.profile).IsDefinedAt(infoset._resolve()) - def _getprob_action(self, index: Action) -> Rational: - return rat_to_py(deref(self.profile).getaction(index.action)) + @cython.cfunc + def _getprob_action(self, index: c_GameAction) -> object: + return rat_to_py(deref(self.profile).getaction(index)) @cython.cfunc def _ensure_unshared(self) -> cython.void: @@ -1040,14 +1084,15 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): if self.profile.use_count() != 1: self.profile = make_shared[c_MixedBehaviorProfile[c_Rational]](deref(self.profile)) - def _setprob_action(self, index: Action, value: typing.Any) -> None: + @cython.cfunc + def _setprob_action(self, index: c_GameAction, value: typing.Any) -> cython.void: if not isinstance(value, (int, fractions.Fraction)): raise TypeError( f"rational precision profile requires int or Fraction probability, " f"not {value.__class__.__name__}" ) self._ensure_unshared() - setitem_mbpr_action(deref(self.profile), index.action, + setitem_mbpr_action(deref(self.profile), index, to_rational(str(value).encode("ascii"))) def _to_prob(self, value: typing.Any) -> Rational: @@ -1065,11 +1110,11 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _realiz_prob(self, node: Node) -> Rational: return rat_to_py(deref(self.profile).GetRealizProb(node.node)) - def _infoset_prob(self, infoset: Infoset) -> Rational: - return rat_to_py(deref(self.profile).GetInfosetProb(infoset.infoset)) + def _infoset_prob(self, infoset: _InfosetOrEvent) -> Rational: + return rat_to_py(deref(self.profile).GetInfosetProb(infoset._resolve())) def _infoset_value(self, infoset: Infoset) -> Rational | None: - cdef optional[c_Rational] value = deref(self.profile).GetPayoff(infoset.infoset) + cdef optional[c_Rational] value = deref(self.profile).GetPayoff(infoset._resolve()) if value.has_value(): return rat_to_py(value.value()) return None @@ -1077,17 +1122,19 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _node_value(self, player: Player, node: Node) -> Rational: return rat_to_py(deref(self.profile).GetPayoff(player.player, node.node)) - def _action_value(self, action: Action) -> Rational | None: - cdef optional[c_Rational] value = deref(self.profile).GetPayoff(action.action) + @cython.cfunc + def _action_value(self, action: c_GameAction) -> object: + cdef optional[c_Rational] value = deref(self.profile).GetPayoff(action) if value.has_value(): return rat_to_py(value.value()) return None - def _action_regret(self, action: Action) -> Rational: - return rat_to_py(deref(self.profile).GetRegret(action.action)) + @cython.cfunc + def _action_regret(self, action: c_GameAction) -> object: + return rat_to_py(deref(self.profile).GetRegret(action)) def _infoset_regret(self, infoset: Infoset) -> Rational: - return rat_to_py(deref(self.profile).GetRegret(infoset.infoset)) + return rat_to_py(deref(self.profile).GetRegret(infoset._resolve())) def _agent_max_regret(self) -> Rational: return rat_to_py(deref(self.profile).GetAgentMaxRegret()) @@ -1114,10 +1161,14 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _as_float(self) -> MixedBehaviorProfileDouble: profile: MixedBehaviorProfileDouble = self.game.mixed_behavior_profile() for player in self.game.players: - for infoset in player.infosets: + for node in self.game.get_infosets(player.label): + infoset = node.infoset profile._setprob_infoset( infoset, - {a.label: float(self._getprob_action(a)) for a in infoset.actions}, + { + a.deref().GetLabel().decode("utf-8"): float(self._getprob_action(a)) + for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions() + }, sparse=True, ) return profile diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi index bcf3967f2..3ddb517c8 100644 --- a/src/pygambit/behavspt.pxi +++ b/src/pygambit/behavspt.pxi @@ -120,8 +120,8 @@ class BehaviorSupport: support : ActionSupport The support at an information set belonging to the player """ - for infoset in self.player.infosets: - yield self[infoset] + for node in self.player.game.get_infosets(self.player.label): + yield self[node.infoset] def __getitem__(self, infoset: Infoset) -> ActionSupport: """Returns the action support at `infoset`. @@ -193,22 +193,24 @@ class BehaviorSupportProfile: Parameters ---------- - index : str or Infoset + index : str, Node, or Infoset The part of the profile to return: * If `index` is a ``str``, returns a ``BehaviorSupport`` over the player's information sets. The player is determined by finding the player with that label, if any. - * If `index` is an ``Infoset`` (or the value of a node's ``infoset`` - property), returns an ``ActionSupport`` over the actions in the support - at the information set. + * If `index` is a ``Node`` or an ``Infoset`` (e.g. one obtained from + iterating a ``BehaviorSupport``), returns an ``ActionSupport`` over the + actions in the support at the information set. Raises ------ TypeError - If `index` is not a ``str`` or an ``Infoset``. + If `index` is not a ``str``, a ``Node``, or an ``Infoset``. MismatchError - If `index` is an ``Infoset`` from a different game. + If `index` is a ``Node`` or ``Infoset`` from a different game. + ValueError + If `index` is a terminal ``Node``, which belongs to no information set. KeyError If `index` is a ``str`` and no player in the game has that label. """ @@ -220,21 +222,28 @@ class BehaviorSupportProfile: if isinstance(index, str): resolved_player: Player = self.game.players[index] values = { - infoset: self._action_support_at(infoset) for infoset in resolved_player.infosets + node.infoset: self._action_support_at(node.infoset) + for node in self.game.get_infosets(resolved_player.label) } return BehaviorSupport.wrap(resolved_player, values) raise TypeError( - f"profile index must be str or Infoset, not {index.__class__.__name__}" + f"profile index must be str, Node, or Infoset, not {index.__class__.__name__}" ) @cython.cfunc def _resolve_infoset_arg(self, index: object) -> object: - """Resolves index to an Infoset if it is one (or a NodeInfoset, which resolves - via the node it was fetched from), or returns None if index is neither. + """Resolves index to the Infoset it identifies if it is a Node or an Infoset, + or returns None if index is neither (e.g. a player label str). """ - if isinstance(index, NodeInfoset): - resolved = cython.cast(NodeInfoset, index)._resolve() - if resolved is None: + if isinstance(index, Node): + node = cython.cast(Node, index) + resolved = cython.cast(Infoset, node.infoset) + if not resolved: + if node.event: + raise ValueError( + "index resolves to a chance event; a behavior support is only " + "defined for a personal player's information sets" + ) raise ValueError("index resolves to no information set (the node is terminal)") return resolved if isinstance(index, Infoset): @@ -243,9 +252,10 @@ class BehaviorSupportProfile: def _action_support_at(self, infoset: Infoset) -> ActionSupport: """Returns a snapshot of the action support at infoset, as of now.""" - infoset_handle = cython.cast(Infoset, infoset).infoset + infoset_handle = cython.cast(Infoset, infoset)._resolve() actions = tuple( - Action.wrap(a).label for a in deref(self.profile).GetActions(infoset_handle) + a.deref().GetLabel().decode("utf-8") + for a in deref(self.profile).GetActions(infoset_handle) ) return ActionSupport.wrap(infoset, actions) @@ -264,7 +274,7 @@ class BehaviorSupportProfile: Every entry of `actions` must be one of the information set's action labels, and at least one must be given. """ - labels = {a.label for a in infoset.actions} + labels = set(infoset.actions) given = set(actions) unknown = given - labels if unknown: @@ -277,21 +287,23 @@ class BehaviorSupportProfile: # Actions to keep are added first, so that a subsequent removal is never asked # to remove the last remaining action at the information set. (Unlike # RemoveStrategy, RemoveAction does not itself guard against emptying its scope.) - for a in infoset.actions: - if a.label in given: - deref(self.profile).AddAction(cython.cast(Action, a).action) - for a in infoset.actions: - if a.label not in given: - deref(self.profile).RemoveAction(cython.cast(Action, a).action) + action_handles = cython.cast(Infoset, infoset)._resolve().deref().GetActions() + for a in action_handles: + if a.deref().GetLabel().decode("utf-8") in given: + deref(self.profile).AddAction(a) + for a in action_handles: + if a.deref().GetLabel().decode("utf-8") not in given: + deref(self.profile).RemoveAction(a) def __setitem__(self, infoset: typing.Any, actions: typing.Iterable[str]) -> None: """Sets the support at `infoset` to exactly the given actions. Parameters ---------- - infoset : Infoset - The information set whose support is to be set. The value of a node's - ``infoset`` property is also accepted. + infoset : Node or Infoset + A node belonging to the information set whose support is to be set, or + the information set itself (e.g. one obtained from iterating a + ``BehaviorSupport``). actions : Iterable[str] The labels of the actions which should be in the support at the information set. Every other action at the information set is removed @@ -300,16 +312,19 @@ class BehaviorSupportProfile: Raises ------ TypeError - If `infoset` is not an ``Infoset``. + If `infoset` is not a ``Node`` or an ``Infoset``. MismatchError - If `infoset` is an `Infoset` from a different game. + If `infoset` is a `Node` or `Infoset` from a different game. ValueError If any entry of `actions` is not one of the information set's action - labels, or if `actions` is empty. + labels, or if `actions` is empty; or if `infoset` is a terminal node, + which belongs to no information set. """ resolved_infoset = self._resolve_infoset_arg(infoset) if resolved_infoset is None: - raise TypeError(f"profile index must be Infoset, not {infoset.__class__.__name__}") + raise TypeError( + f"profile index must be Node or Infoset, not {infoset.__class__.__name__}" + ) if resolved_infoset.game != self.game: raise MismatchError("infoset must be part of the same game") self._set_support(resolved_infoset, actions) @@ -325,22 +340,29 @@ class BehaviorSupportProfile: """ return BehaviorSupportProfile.wrap(self.profile) - def is_reachable(self, infoset: InfosetReference) -> bool: + def is_reachable(self, infoset: typing.Any) -> bool: """Returns whether `infoset` can be reached under this support, i.e. whether there is some path of play consistent with the support that reaches it. Parameters ---------- - infoset : Infoset or str - The information set to check. If a string is passed, the information set - is determined by finding the information set with that label, if any. + infoset : Node, str, or Infoset + A node belonging to the information set to check, such a node's label, or + the information set itself (e.g. one obtained from iterating a + ``BehaviorSupport``). Raises ------ MismatchError - If `infoset` is an `Infoset` from a different game. + If `infoset` is a `Node` or `Infoset` from a different game. KeyError - If `infoset` is a string and no information set in the game has that label. + If `infoset` is a string and no node in the game has that label. """ - resolved_infoset = self.game._resolve_infoset(infoset, "is_reachable") - return deref(self.profile).IsReachable(cython.cast(Infoset, resolved_infoset).infoset) + resolved_infoset: Infoset + if isinstance(infoset, Infoset): + resolved_infoset = infoset + if resolved_infoset.game != self.game: + raise MismatchError("is_reachable(): infoset must be part of the same game") + else: + resolved_infoset = self.game._resolve_infoset(infoset, "is_reachable") + return deref(self.profile).IsReachable(resolved_infoset._resolve()) diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 1a01325eb..7ea19caa6 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -420,14 +420,19 @@ def check_filters(game: gbt.Game) -> bool: if n_actions is not None: if not game.is_tree: return False - if len(game.actions) != n_actions: + n_game_actions = sum( + len(node.infoset.actions) + for player in game.players + for node in game.get_infosets(player.label) + ) + if n_game_actions != n_actions: return False if n_contingencies is not None and len(game.contingencies) != n_contingencies: return False if n_infosets is not None: if not game.is_tree: return False - if len(game.infosets) != n_infosets: + if sum(len(game.get_infosets(p.label)) 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 diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index 2ecf2f72c..d71dd8f54 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -226,7 +226,7 @@ def render_support_csv( if isinstance(support, gbt.BehaviorSupportProfile): fields = [ "".join( - "1" if action.label in action_support else "0" + "1" if action in action_support else "0" for action in action_support.infoset.actions ) for player in support.game.players @@ -290,11 +290,11 @@ def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> infoset_name = _name_or_number(infoset) values = action_values[next(iter(infoset.members))] for action in infoset.actions: - prob = mixed_action[action.label] - value = values[action.label] + prob = mixed_action[action] + value = values[action] value_text = format_value(value, decimals) if value is not None else "" lines.append( - f"{infoset_name:>7} {_name_or_number(action):>7} " + f"{infoset_name:>7} {action:>7} " f"{format_value(prob, decimals):>11} {value_text:>11}" ) lines.append("") @@ -352,7 +352,9 @@ def read_behavior_profiles_csv( the result via `~MixedBehaviorProfile.as_float`. """ count = sum( - len(list(infoset.actions)) for player in game.players for infoset in player.infosets + len(node.infoset.actions) + for player in game.players + for node in game.get_infosets(player.label) ) profiles = [] for line in pathlib.Path(path).read_text().splitlines(): @@ -366,8 +368,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 infoset in player.infosets: - node = next(iter(infoset.members)) - profile[node] = {a.label: next(values) for a in infoset.actions} + for node in game.get_infosets(player.label): + profile[node] = {a: next(values) for a in node.infoset.actions} profiles.append(profile) return profiles diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index 1036b5c85..474a2bef8 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -101,8 +101,6 @@ def _resolve_by_label(collection, label: str, scope: str, kind: str, kind_plural PlayerReference = Player | str -InfosetReference = Infoset | str -ActionReference = Action | str NodeReference = Node | str NodeReferenceSet = typing.Iterable[NodeReference] diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index a8a5bc89c..78d34c1bf 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -362,110 +362,6 @@ class GamePlayers: return Player.wrap(self.game.deref().GetChance()) -@cython.cclass -class GameActions: - """Represents the set of all actions in a game.""" - game = cython.declare(Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameActions outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: Game) -> GameActions: - obj: GameActions = GameActions.__new__(GameActions) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameActions(game={self.game})" - - def __len__(self) -> int: - return sum(len(s.actions) for s in self.game.infosets) - - def __iter__(self) -> typing.Iterator[Action]: - for infoset in self.game.infosets: - yield from infoset.actions - - def __getitem__(self, label: str) -> Action: - """Returns the action with text label `label`. - - Parameters - ---------- - label : str - The text label of the action to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. - - Raises - ------ - KeyError - If no action in the game has label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one action has label `label`. - TypeError - If `label` is not a string. - - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference an action 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", "action", "actions") - - -@cython.cclass -class GameInfosets: - """Represents the set of all infosets in a game.""" - game = cython.declare(Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameInfosets outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: Game) -> GameInfosets: - obj: GameInfosets = GameInfosets.__new__(GameInfosets) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameInfosets(game={self.game})" - - def __len__(self) -> int: - return sum(len(p.infosets) for p in self.game.players) - - def __iter__(self) -> typing.Iterator[Infoset]: - for player in self.game.players: - yield from player.infosets - - def __getitem__(self, label: str) -> Infoset: - """Returns the information set with text label `label`. - - Parameters - ---------- - label : str - The text label of the infoset to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. - - Raises - ------ - KeyError - If no information set in the game has label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one information set has - label `label`. - TypeError - If `label` is not a string. - - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference an information set 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", "infoset", "infosets") - - @cython.cclass class Game: """A game, the fundamental unit of analysis in game theory. @@ -741,24 +637,69 @@ class Game: def description(self, value: str) -> None: self.game.deref().SetDescription(value.encode("utf-8")) - @property - def actions(self) -> GameActions: - """The set of actions available in the game. + def get_infosets(self, player: str) -> list[Node]: + """Returns a snapshot of the information sets belonging to the personal + player `player`: the decisions at which that player chooses an action. + + One representative member node is returned per information set, in the order + the information sets are encountered in the pre-order depth first traversal of + the game tree. 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 personal player whose information sets to return. + + Returns + ------- + list of Node + One representative member node per information set belonging to `player`. + + .. versionadded:: 17.0.0 Raises ------ UndefinedOperationError - If the game does not have a tree representation. + 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. + KeyError + If no player in the game has label `player`. + ValueError + If `player` is an empty string or all whitespace. """ if not self.is_tree: raise UndefinedOperationError( "Operation only defined for games with a tree representation" ) - return GameActions.wrap(self) + 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" + ) + return [ + Node.wrap(infoset.deref().GetMember(1)) + for infoset in resolved_player.player.deref().GetInfosets() + ] - @property - def infosets(self) -> GameInfosets: - """The set of information sets in the game. + def get_events(self) -> list[Node]: + """Returns a snapshot of the chance player's events: the points of exogenous + randomness, each with a probability distribution over its actions. + + One representative member node is returned per event, in the order the events + are encountered in the pre-order depth first traversal of the game tree. 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. + + Returns + ------- + list of Node + One representative member node per event. + + .. versionadded:: 17.0.0 Raises ------ @@ -769,7 +710,10 @@ class Game: raise UndefinedOperationError( "Operation only defined for games with a tree representation" ) - return GameInfosets.wrap(self) + return [ + Node.wrap(event.deref().GetMember(1)) + for event in self.game.deref().GetChance().deref().GetInfosets() + ] @property def players(self) -> GamePlayers: @@ -886,13 +830,13 @@ class Game: ) return GameSubgames.wrap(self.game) - def minimal_subgame(self, infoset: typing.Union[Infoset, str]) -> Subgame: + def minimal_subgame(self, infoset: NodeReference) -> Subgame: """Returns the smallest subgame containing `infoset`. Parameters ---------- - infoset : Infoset or str - The information set to query. + infoset : Node or str + A node belonging to the information set to query, or such a node's label. Returns ------- @@ -912,9 +856,11 @@ class Game: raise UndefinedOperationError( "Operation only defined for games with a tree representation" ) - resolved_infoset = self._resolve_infoset(infoset, "minimal_subgame") + resolved_infoset = self._resolve_infoset_or_event(infoset, "minimal_subgame") return Subgame.wrap( - self.game.deref().GetMinimalSubgame(cython.cast(Infoset, resolved_infoset).infoset) + self.game.deref().GetMinimalSubgame( + cython.cast(_InfosetOrEvent, resolved_infoset)._resolve() + ) ) def get_behavior(self, @@ -1211,16 +1157,18 @@ 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): - if len(p.infosets) != len(d): + p_infosets = self.get_infosets(p.label) + if len(p_infosets) != len(d): raise ValueError(f"Number of elements does not match number of infosets for {p}") - for (i, v) in zip(p.infosets, d, strict=True): - if len(i.actions) != len(v): + for (node, v) in zip(p_infosets, d, strict=True): + infoset = node.infoset + if len(infoset.actions) != len(v): raise ValueError( f"Number of elements does not match number of " - f"actions for infoset {i} for {p}" + f"actions for infoset {infoset} for {p}" ) - profile[next(iter(i.members))] = { - a.label: typefunc(u) for a, u in zip(i.actions, v, strict=True) + profile[node] = { + a: typefunc(u) for a, u in zip(infoset.actions, v, strict=True) } return profile @@ -1299,34 +1247,40 @@ class Game: ) if denom is None: profile = self.mixed_behavior_profile() - for infoset in self.infosets: - weights = scipy.stats.dirichlet( - alpha=[1 for action in infoset.actions], seed=gen - ).rvs(size=1)[0] - profile[next(iter(infoset.members))] = dict( - zip((a.label for a in infoset.actions), weights, strict=True) - ) + for player in self.players: + for node in self.get_infosets(player.label): + infoset = node.infoset + weights = scipy.stats.dirichlet( + alpha=[1 for action in infoset.actions], seed=gen + ).rvs(size=1)[0] + profile[node] = dict( + zip(infoset.actions, weights, strict=True) + ) return profile elif denom < 1: raise ValueError("random_behavior_profile(): denom must be positive") else: profile = self.mixed_behavior_profile(rational=True) - for infoset in self.infosets: - k = len(infoset.actions) - sample = ( - [0] + - sorted( - (gen or np.random).choice(np.arange(1, denom+k), size=k-1, replace=False) - ) + - [denom + k] - ) - distribution = { - a.label: Rational(hi - lo - 1, denom) - for a, hi, lo in zip( - infoset.actions, sample[1:], sample[:-1], strict=True + for player in self.players: + for node in self.get_infosets(player.label): + infoset = node.infoset + k = len(infoset.actions) + sample = ( + [0] + + sorted( + (gen or np.random).choice( + np.arange(1, denom+k), size=k-1, replace=False + ) + ) + + [denom + k] ) - } - profile[next(iter(infoset.members))] = distribution + distribution = { + a: Rational(hi - lo - 1, denom) + for a, hi, lo in zip( + infoset.actions, sample[1:], sample[:-1], strict=True + ) + } + profile[node] = distribution return profile def strategy_support_profile( @@ -1366,8 +1320,9 @@ class Game: ---------- actions : function, optional By default the support profile contains all actions at all information - sets. If specified, only actions for which the supplied function returns - `True` are included. + sets. If specified, called as ``actions(node, action)`` for each action at + each information set, where ``node`` is a representative node of the + information set; only actions for which it returns `True` are included. Returns ------- @@ -1375,14 +1330,15 @@ class Game: """ profile = BehaviorSupportProfile.wrap(make_shared[c_BehaviorSupportProfile](self.game)) if actions is not None: - for infoset in self.infosets: - for action in infoset.actions: - if not actions(action): - if not (deref(profile.profile) - .RemoveAction(cython.cast(Action, action).action)): - raise ValueError( - "attempted to remove the last action at an information set" - ) + for player in self.players: + for node in self.get_infosets(player.label): + 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")): + if not deref(profile.profile).RemoveAction(action): + raise ValueError( + "attempted to remove the last action at an information set" + ) return profile @cython.cfunc @@ -1681,12 +1637,13 @@ class Game: def _resolve_infoset(self, infoset: typing.Any, funcname: str, argname: str = "infoset") -> Infoset: - """Resolve an attempt to reference an information set of the game. + """Resolve an attempt to reference a personal player's information set of the + game, via a member node or its label. Parameters ---------- - infoset : Any - An object to resolve as a reference to an information set. + infoset : Node or str + A node belonging to the information set, or such a node's label. funcname : str The name of the function to raise any exception on behalf of. argname : str, default 'infoset' @@ -1695,79 +1652,112 @@ class Game: Raises ------ MismatchError - If `infoset` is an `Infoset` from a different game. + If `infoset` is a `Node` from a different game. KeyError - If `infoset` is a string and no information set in the game has that label. + If `infoset` is a string and no node in the game has that label. TypeError - If `infoset` is not an `Infoset`, `NodeInfoset`, or a `str` + If `infoset` is not a `Node` or a `str` ValueError - If `infoset` is an empty `str` or all spaces, or is a `NodeInfoset` that - resolves to no information set (its node is terminal). + If `infoset` resolves to a chance event rather than a personal player's + information set, or to no information set at all (the node is terminal). """ - if isinstance(infoset, NodeInfoset): - resolved = cython.cast(NodeInfoset, infoset)._resolve() - if resolved is None: + resolved_node = self._resolve_node(infoset, funcname, argname) + resolved = cython.cast(Infoset, resolved_node.infoset) + if not resolved: + if resolved_node.event: raise ValueError( - f"{funcname}(): {argname} resolves to no information set " - f"(the node is terminal)" + f"{funcname}(): {argname} resolves to a chance event, " + f"not a personal player's information set" ) - infoset = resolved - if isinstance(infoset, Infoset): - if infoset.game != self: - raise MismatchError(f"{funcname}(): {argname} must be part of the same game") - return infoset - elif isinstance(infoset, str): - if not infoset.strip(): - raise ValueError( - f"{funcname}(): {argname} cannot be an empty string or all spaces" - ) - try: - return self.infosets[infoset] - except KeyError: - raise KeyError(f"{funcname}(): no information set with label '{infoset}'") - raise TypeError( - f"{funcname}(): {argname} must be Infoset or str, not {infoset.__class__.__name__}" - ) + raise ValueError( + f"{funcname}(): {argname} resolves to no information set " + f"(the node is terminal)" + ) + return resolved - def _resolve_action(self, - action: typing.Any, funcname: str, argname: str = "action") -> Action: - """Resolve an attempt to reference an action of the game. + def _resolve_event(self, + event: typing.Any, funcname: str, argname: str = "event") -> Event: + """Resolve an attempt to reference a chance event of the game, via a member + node or its label. Parameters ---------- - action : Any - An object to resolve as a reference to an action. + event : Node or str + A node belonging to the event, or such a node's label. funcname : str The name of the function to raise any exception on behalf of. - argname : str, default 'action' + argname : str, default 'event' The name of the argument being checked Raises ------ MismatchError - If `action` is an `Action` from a different game. + If `event` is a `Node` from a different game. KeyError - If `action` is a string and no action in the game has that label. + If `event` is a string and no node in the game has that label. TypeError - If `action` is not an `Action` or a `str` + If `event` is not a `Node` or a `str` ValueError - If `action` is an empty `str` or all spaces + If `event` resolves to a personal player's information set rather than a + chance event, or to no event at all (the node is terminal). """ - if isinstance(action, Action): - if action.infoset.game != self: - raise MismatchError(f"{funcname}(): {argname} must be part of the same game") - return action - elif isinstance(action, str): - if not action.strip(): + resolved_node = self._resolve_node(event, funcname, argname) + resolved = cython.cast(Event, resolved_node.event) + if not resolved: + if resolved_node.infoset: raise ValueError( - f"{funcname}(): {argname} cannot be an empty string or all spaces" + f"{funcname}(): {argname} resolves to a personal player's " + f"information set, not a chance event" ) - try: - return self.actions[action] - except KeyError: - raise KeyError(f"{funcname}(): no action with label '{action}'") - raise TypeError( - f"{funcname}(): {argname} must be Action or str, not {action.__class__.__name__}" + raise ValueError( + f"{funcname}(): {argname} resolves to no event " + f"(the node is terminal)" + ) + return resolved + + def _resolve_infoset_or_event(self, + infoset: typing.Any, + funcname: str, + argname: str = "infoset") -> typing.Any: + """Resolve an attempt to reference an information set or event of the game + (whichever applies), via a member node or its label. For operations that + apply uniformly to either, such as attaching to an existing one. + + Parameters + ---------- + infoset : Node or str + A node belonging to the information set or event, or such a node's label. + funcname : str + The name of the function to raise any exception on behalf of. + argname : str, default 'infoset' + The name of the argument being checked + + Returns + ------- + Infoset or Event + + Raises + ------ + MismatchError + If `infoset` is a `Node` from a different game. + KeyError + If `infoset` is a string and no node in the game has that label. + TypeError + If `infoset` is not a `Node` or a `str` + ValueError + If `infoset` resolves to no information set or event (the node is + terminal). + """ + resolved_node = self._resolve_node(infoset, funcname, argname) + resolved_infoset = cython.cast(Infoset, resolved_node.infoset) + if resolved_infoset: + return resolved_infoset + resolved_event = cython.cast(Event, resolved_node.event) + if resolved_event: + return resolved_event + raise ValueError( + f"{funcname}(): {argname} resolves to no information set " + f"(the node is terminal)" ) def _resolve_probs(self, @@ -1831,13 +1821,21 @@ class Game: for label in actions: c_actions.push_back(label.encode("utf-8")) self.game.deref().AppendMove(resolved_node.node, resolved_player.player, c_actions) - resolved_infoset = cython.cast(NodeInfoset, resolved_node.infoset)._resolve() + 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.infoset) + self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_infoset._resolve()) def append_infoset(self, nodes: Node | NodeReferenceSet, - infoset: Infoset | str) -> None: - """Add a move in information set `infoset` at terminal `nodes`. + infoset: NodeReference) -> None: + """Add a move in the information set or event `infoset` at terminal `nodes`. + + Parameters + ---------- + nodes : Node or NodeReferenceSet + The nonempty set of terminal nodes at which to add the move. + infoset : Node or str + A node belonging to the information set or event to join, or such a + node's label. Raises ------ @@ -1845,16 +1843,18 @@ class Game: If any element in `nodes` is not a terminal node. MismatchError If an element in `nodes` is a `Node` from a different game, - or `infoset` is an `Infoset` from a different game. + or `infoset` is a `Node` from a different game. ValueError If `nodes` has duplicated elements, or is empty. """ - resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, "append_infoset")) + resolved_infoset = cython.cast( + _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "append_infoset") + ) resolved_nodes = self._resolve_nodes(nodes, "append_infoset", "nodes") if any(len(n.children) > 0 for n in resolved_nodes): raise UndefinedOperationError("append_infoset(): `nodes` must be terminal nodes") for n in resolved_nodes: - self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_infoset.infoset) + self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_infoset._resolve()) def append_event(self, nodes: Node | NodeReferenceSet, actions: list[str], @@ -1911,9 +1911,9 @@ class Game: for p in resolved_probs: c_probs.push_back(_to_number(p)) self.game.deref().AppendEvent(resolved_node.node, c_actions, c_probs) - resolved_infoset = cython.cast(NodeInfoset, resolved_node.infoset)._resolve() + resolved_event = cython.cast(Event, resolved_node.event) for n in resolved_nodes[1:]: - self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_infoset.infoset) + 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: @@ -1951,19 +1951,29 @@ class Game: self.game.deref().InsertMove(resolved_node.node, resolved_player.player, c_actions) def insert_infoset(self, node: Node | str, - infoset: Infoset | str) -> None: - """Insert a move in information set `infoset` prior to the node `node`. - `node` becomes the first child of the newly-inserted node. + infoset: NodeReference) -> None: + """Insert a move in the information set or event `infoset` prior to the node + `node`. `node` becomes the first child of the newly-inserted node. + + Parameters + ---------- + node : Node or str + The node before which to insert the move. + infoset : Node or str + A node belonging to the information set or event to join, or such a + node's label. Raises ------ MismatchError - If `node` is a `Node` from a different game, or `infoset` is an `Infoset` from a + If `node` is a `Node` from a different game, or `infoset` is a `Node` from a different game. """ resolved_node = cython.cast(Node, self._resolve_node(node, "insert_infoset")) - resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, "insert_infoset")) - self.game.deref().InsertMove(resolved_node.node, resolved_infoset.infoset) + resolved_infoset = cython.cast( + _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "insert_infoset") + ) + self.game.deref().InsertMove(resolved_node.node, resolved_infoset._resolve()) def insert_event(self, node: Node | str, actions: list[str], @@ -2111,7 +2121,7 @@ class Game: self.game.deref().DeleteTree(resolved_node.node) def set_move_actions(self, - infoset: Infoset | str, + infoset: NodeReference, actions: list[str], drop: bool = False, add: bool = True) -> None: @@ -2127,8 +2137,9 @@ class Game: Parameters ---------- - infoset : Infoset or str - The (personal player's) move at which to set the actions. + infoset : Node or str + A node belonging to the (personal player's) move at which to set the + actions, or such a node's label. actions : list of str The labels of the actions the move is to have, in order. Must be nonempty and without duplicates; each label must be a valid, nonempty label. @@ -2142,17 +2153,18 @@ class Game: Raises ------ MismatchError - If `infoset` is an `Infoset` from a different game. + If `infoset` is a `Node` from a different game. KeyError - If `infoset` is a string matching no information set. + If `infoset` is a string matching no node. TypeError If `actions` is a string, or not an iterable of strings. UndefinedOperationError - If `actions` is empty, or if `infoset` is an event; use `set_event_actions` - for an event. + If `actions` is empty. ValueError - If a label in `actions` is repeated, empty, or invalid; or if adding or - deleting actions is not confirmed by `add`/`drop`. + If `infoset` resolves to an event rather than a personal player's move + (use `set_event_actions` for an event); or if a label in `actions` is + repeated, empty, or invalid; or if adding or deleting actions is not + confirmed by `add`/`drop`. See Also -------- @@ -2160,11 +2172,6 @@ class Game: relabel_actions : Change the labels of actions, leaving the tree unchanged. """ resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, "set_move_actions")) - if resolved_infoset.is_chance: - raise UndefinedOperationError( - "set_move_actions(): `infoset` must be a personal player's move; " - "use set_event_actions() for an event" - ) if isinstance(actions, str) or not hasattr(actions, "__iter__"): raise TypeError("set_move_actions(): actions must be an iterable of str") labels = list(actions) @@ -2172,7 +2179,7 @@ class Game: raise TypeError("set_move_actions(): actions must be an iterable of str") if not labels: raise UndefinedOperationError("set_move_actions(): `actions` must be a nonempty list") - current = [action.label for action in resolved_infoset.actions] + current = list(resolved_infoset.actions) if len(set(current)) != len(current): raise ValueError( "set_move_actions(): the information set has duplicate action labels, " @@ -2192,14 +2199,14 @@ class Game: c_labels = stdvector[string]() for label in labels: c_labels.push_back(label.encode("utf-8")) - self.game.deref().SetMoveActions(resolved_infoset.infoset, c_labels) + self.game.deref().SetMoveActions(resolved_infoset._resolve(), c_labels) def set_event_actions(self, - infoset: Infoset | str, + event: NodeReference, probs: typing.Mapping, drop: bool = False, add: bool = True) -> None: - """Set the actions at the event `infoset` to be the keys of `probs`, in order, + """Set the actions at the event `event` to be the keys of `probs`, in order, with the given probability distribution. A key of `probs` matching the label of a current action refers to that action, @@ -2217,8 +2224,9 @@ class Game: Parameters ---------- - infoset : Infoset or str - The event at which to set the actions. + event : Node or str + A node belonging to the event at which to set the actions, or such a + node's label. probs : dict-like A mapping from the label of each action the event is to have, in order, to its probability. Must be nonempty, with valid, nonempty keys. Values must be @@ -2233,14 +2241,15 @@ class Game: Raises ------ MismatchError - If `infoset` is an `Infoset` from a different game. + If `event` is a `Node` from a different game. KeyError - If `infoset` is a string matching no information set. + If `event` is a string matching no node. TypeError If `probs` is not a mapping, or a key of `probs` is not a string. UndefinedOperationError - If `probs` is empty, or if `infoset` is not an event; use `set_move_actions` - for a personal player's move. + If `probs` is empty, or if `event` resolves to a personal player's + information set rather than an event; use `set_move_actions` for a + personal player's move. ValueError If a key of `probs` is empty or invalid; if adding or deleting actions is not confirmed by `add`/`drop`; or if the values of `probs` are not non-negative @@ -2252,14 +2261,7 @@ class Game: player's move. relabel_actions : Change the labels of actions, leaving the tree unchanged. """ - resolved_infoset = cython.cast( - Infoset, self._resolve_infoset(infoset, "set_event_actions") - ) - if not resolved_infoset.is_chance: - raise UndefinedOperationError( - "set_event_actions(): `infoset` must be an event; " - "use set_move_actions() for a personal player's move" - ) + resolved_event = cython.cast(Event, self._resolve_event(event, "set_event_actions")) if not isinstance(probs, typing.Mapping): raise TypeError( "set_event_actions(): probs must be a mapping from label to probability" @@ -2271,7 +2273,7 @@ class Game: raise UndefinedOperationError( "set_event_actions(): `probs` must be a nonempty mapping" ) - current = [action.label for action in resolved_infoset.actions] + current = list(resolved_event.actions) if len(set(current)) != len(current): raise ValueError( "set_event_actions(): the information set has duplicate action labels, " @@ -2293,7 +2295,7 @@ class Game: for label in labels: c_labels.push_back(label.encode("utf-8")) c_probs.push_back(_to_number(probs[label])) - self.game.deref().SetEventActions(resolved_infoset.infoset, c_labels, c_probs) + self.game.deref().SetEventActions(resolved_event._resolve(), c_labels, c_probs) def make_event(self, nodes: Node | NodeReferenceSet, @@ -2306,9 +2308,9 @@ class Game: converted, and the move is thereafter resolved by chance. Nodes are removed from whatever information sets or events they currently belong to; any of those which retain members survive, keeping their labels, and those left with no members are deleted. - Any ``Infoset`` object, and any of its ``Action`` objects, referring to a deleted one - becomes invalid, and subsequent use raises ``RuntimeError``. - The resulting event is accessible as ``node.infoset`` for any node in `nodes`. + Any ``Infoset`` object referring to a deleted one becomes invalid, and subsequent use + raises ``RuntimeError``. + The resulting event is accessible as ``node.event`` for any node in `nodes`. The first node in `nodes` determines the action order of the event, and is the frame against which mapping keys in `probs` are resolved. @@ -2356,8 +2358,8 @@ class Game: "make_event(): all nodes must be nonterminal" ) resolved_node = cython.cast(Node, resolved_nodes[0]) - action_labels = [a.label for a in resolved_node.infoset.actions] - if any([a.label for a in n.infoset.actions] != action_labels + action_labels = list((resolved_node.infoset or resolved_node.event).actions) + if any(list((n.infoset or n.event).actions) != action_labels for n in resolved_nodes[1:]): raise ValueError( "make_event(): all nodes must have the same actions, " @@ -2373,7 +2375,7 @@ class Game: self.game.deref().MakeEvent(c_nodes, c_probs, (label or "").encode("utf-8")) def relabel_actions(self, - infoset: Infoset | str, + infoset: NodeReference, labels: typing.Mapping[str, str], strict: bool = True) -> None: """Simultaneously reassign the labels of actions at `infoset`. @@ -2387,10 +2389,9 @@ class Game: Parameters ---------- - infoset : Infoset or str - The information set at which to relabel actions. If a string is passed, - the information set is determined by finding the personal-player - information set with that label, if any. + infoset : Node or str + A node belonging to the information set at which to relabel actions, or + such a node's label. labels : Mapping[str, str] A mapping from current action labels to replacement labels. Entries whose key equals their value are ignored. @@ -2402,9 +2403,9 @@ class Game: Raises ------ MismatchError - If `infoset` is an `Infoset` from a different game. + If `infoset` is a `Node` from a different game. KeyError - If `infoset` is a string matching no information set; or, when `strict` + If `infoset` is a string matching no node; or, when `strict` is `True`, if a key of `labels` matches no action at `infoset`. TypeError If `labels` is not a mapping, or any key or value is not a string. @@ -2414,13 +2415,15 @@ class Game: replacement label is empty, is not a valid label, or would result in a duplicate label at the information set. """ - resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, "relabel_actions")) + resolved_infoset = cython.cast( + _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "relabel_actions") + ) if not hasattr(labels, "items"): raise TypeError( f"relabel_actions(): labels must be a mapping, " f"not {labels.__class__.__name__}" ) - current = [action.label for action in resolved_infoset.actions] + current = list(resolved_infoset.actions) c_labels = stdmap[string, string]() for old, new in labels.items(): if not isinstance(old, str) or not isinstance(new, str): @@ -2439,7 +2442,7 @@ class Game: c_labels[old.encode("utf-8")] = new.encode("utf-8") if c_labels.empty(): return - self.game.deref().RelabelActions(resolved_infoset.infoset, c_labels) + self.game.deref().RelabelActions(resolved_infoset._resolve(), c_labels) def make_infoset(self, nodes: Node | NodeReferenceSet, @@ -2505,9 +2508,9 @@ class Game: (label or "").encode()) def reveal(self, - infoset: Infoset | str, + infoset: NodeReference, player: Player | str) -> None: - """Reveals the move made at `infoset` to `player`. + """Reveals the move made at the information set or event `infoset` to `player`. Revealing the move modifies all subsequent information sets for `player` such that any two nodes which are successors of two different actions at this @@ -2521,20 +2524,23 @@ class Game: Parameters ---------- - infoset : Infoset or str - The information set of the move to reveal to the player + 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. Raises ------ MismatchError - If `infoset` is an `Infoset` from a different game, or + If `infoset` is a `Node` from a different game, or `player` is a `Player` from a different game. UndefinedOperationError If `infoset` is absent-minded, or if `player` is the chance player. """ - resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, "reveal")) + 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( @@ -2545,7 +2551,7 @@ class Game: "reveal(): revealing the move at an absent-minded information set " "is not well-defined" ) - self.game.deref().Reveal(resolved_infoset.infoset, resolved_player.player) + self.game.deref().Reveal(resolved_infoset._resolve(), resolved_player.player) def set_players(self, players: list[str], @@ -2621,7 +2627,7 @@ class Game: ) for label in missing: resolved = self.players[label] - if self.is_tree and len(resolved.infosets) > 0: + if self.is_tree and len(self.get_infosets(resolved.label)) > 0: raise UndefinedOperationError( f"set_players(): player '{label}' has decisions in the game " f"and cannot be deleted" diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index 6a6d5d0e3..601106195 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -36,7 +36,9 @@ class InfosetMembers: return obj def __repr__(self) -> str: - return f"InfosetMembers(infoset={Infoset.wrap(self.infoset)})" + return ( + f"InfosetMembers(infoset={_wrap_infoset_or_event(self.infoset.deref().GetMember(1))})" + ) def __len__(self) -> int: return self.infoset.deref().GetMembers().size() @@ -72,101 +74,74 @@ class InfosetMembers: @cython.cclass -class InfosetActions: - """The set of actions which are available at an information set.""" - infoset = cython.declare(c_GameInfoset) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create InfosetActions outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(infoset: c_GameInfoset) -> InfosetActions: - obj: InfosetActions = InfosetActions.__new__(InfosetActions) - obj.infoset = infoset - return obj - - def __repr__(self) -> str: - return f"InfosetActions(infoset={Infoset.wrap(self.infoset)})" - - def __len__(self): - """The number of actions at the information set.""" - return self.infoset.deref().GetActions().size() - - def __iter__(self) -> typing.Iterator[Action]: - for action in self.infoset.deref().GetActions(): - yield Action.wrap(action) - - def __getitem__(self, label: str) -> Action: - """Returns the action at the information set with text label `label`. - - Parameters - ---------- - label : str - The text label of the action to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. +class _InfosetOrEvent: + """Shared implementation for `Infoset` and `Event`: a lazy, node-anchored view over + an information set, filtered to whichever of the two subclasses' concept currently + applies at the anchoring node (see each subclass's `_try_resolve`). - Raises - ------ - KeyError - If the information set has no action with label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one action at the information - set has label `label`. - TypeError - If `label` is not a string. - - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference an action by its label, or iterate - over the collection. String lookup now requires an exact match of the label; - previously, leading/trailing whitespace was stripped from `label` before comparison. - """ - return _resolve_by_label(self, label, "Infoset", "action", "actions") - - -@cython.cclass -class Infoset: - """An information set in a ``Game``. - - An information set belonging to a personal player is a decision: the point at - which that player chooses an action, and so the object of potential optimisation. - An information set belonging to the chance player is instead called an event: its - probability distribution over actions is exogenously specified, not chosen. + Not exported; only `Infoset` and `Event` are part of the public API. """ - infoset = cython.declare(c_GameInfoset) + node = cython.declare(c_GameNode) def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create an Infoset outside a Game.") + raise ValueError(f"Cannot create an {type(self).__name__} outside a Game.") - @staticmethod @cython.cfunc - def wrap(infoset: c_GameInfoset) -> Infoset: - obj: Infoset = Infoset.__new__(Infoset) - obj.infoset = infoset - return obj + def _try_resolve(self) -> c_GameInfoset: + """Returns the resolved handle, filtered to this subclass's applicable case; + null if that case does not currently apply at the anchoring node (including, + but not limited to, a terminal node).""" + raise NotImplementedError + + @cython.cfunc + def _resolve(self) -> c_GameInfoset: + """Returns the resolved handle, raising if this subclass's case does not + currently apply at the anchoring node.""" + resolved: c_GameInfoset = self._try_resolve() + if resolved == cython.cast(c_GameInfoset, NULL): + raise AttributeError( + f"node's {type(self).__name__.lower()} is currently None" + ) + return resolved def __repr__(self) -> str: + if self._try_resolve() == cython.cast(c_GameInfoset, NULL): + return "None" + name = type(self).__name__ if self.label: - return f"Infoset(player={self.player}, label='{self.label}')" + return f"{name}(player={self.player}, label='{self.label}')" else: - return f"Infoset(player={self.player}, number={self.number})" + return f"{name}(player={self.player}, number={self.number})" def __eq__(self, other: typing.Any): - if not isinstance(other, Infoset): + if type(other) is not type(self): return NotImplemented - return self.infoset.deref() == cython.cast(Infoset, other).infoset.deref() + mine: c_GameInfoset = self._try_resolve() + theirs: c_GameInfoset = cython.cast(_InfosetOrEvent, other)._try_resolve() + if mine == cython.cast(c_GameInfoset, NULL) or theirs == cython.cast(c_GameInfoset, NULL): + return ( + mine == cython.cast(c_GameInfoset, NULL) and + theirs == cython.cast(c_GameInfoset, NULL) + ) + return mine == theirs + + def __bool__(self) -> bool: + return self._try_resolve() != cython.cast(c_GameInfoset, NULL) def __hash__(self) -> int: - return cython.cast(cython.long, self.infoset.deref()) + resolved: c_GameInfoset = self._try_resolve() + if resolved == cython.cast(c_GameInfoset, NULL): + return 0 + return cython.cast(cython.long, resolved.deref()) def precedes(self, node: Node) -> bool: """Return whether this information set precedes `node` in the game tree.""" - return self.infoset.deref().Precedes(cython.cast(Node, node).node) + return self._resolve().deref().Precedes(cython.cast(Node, node).node) @property def game(self) -> Game: """The ``Game`` to which the information set belongs.""" - return Game.wrap(self.infoset.deref().GetGame()) + return Game.wrap(self._resolve().deref().GetGame()) @property def label(self) -> str: @@ -178,25 +153,18 @@ class Infoset: two consecutive whitespace characters. "Whitespace" means any Unicode space separator (e.g. U+00A0 NO-BREAK SPACE), not just the ASCII space. """ - return self.infoset.deref().GetLabel().decode("utf-8") + return self._resolve().deref().GetLabel().decode("utf-8") @label.setter def label(self, value: str) -> None: - self.infoset.deref().SetLabel(value.encode("utf-8")) + self._resolve().deref().SetLabel(value.encode("utf-8")) @property def number(self) -> int: """Returns the number of the information set for its player. Information sets are numbered starting with 0. """ - return self.infoset.deref().GetNumber() - 1 - - @property - def is_chance(self) -> bool: - """Whether the information set belongs to the chance player, i.e. is an event - rather than a decision. - """ - return self.infoset.deref().IsChanceInfoset() + return self._resolve().deref().GetNumber() - 1 @property def is_absent_minded(self) -> bool: @@ -208,36 +176,18 @@ class Infoset: .. versionadded:: 16.5.0 """ - return self.infoset.deref().GetGame().deref().IsAbsentMinded(self.infoset) + resolved: c_GameInfoset = self._resolve() + return resolved.deref().GetGame().deref().IsAbsentMinded(resolved) @property - def actions(self) -> InfosetActions: - """The set of actions at the information set.""" - return InfosetActions.wrap(self.infoset) + def actions(self) -> list[str]: + """The labels of the actions available at the information set, in order. - @property - def own_prior_actions(self) -> list[Action | None]: - """The set of actions taken by the player immediately preceding the member nodes - in the information set. - - Returns - ------- - list of Action or None - A list containing Action objects. If a node in the information set - is reached without the player having moved previously, None will be - included in the list. - .. versionadded:: 16.5.0 - - See Also - -------- - Node.own_prior_action + .. versionchanged:: 17.0.0 + Returns bare labels rather than ``Action`` objects, following its removal. """ - c_actions: stdset[c_GameAction] = self.infoset.deref().GetOwnPriorActions() - - return [ - Action.wrap(action) if action != cython.cast(c_GameAction, NULL) else None - for action in c_actions - ] + resolved: c_GameInfoset = self._resolve() + return [a.deref().GetLabel().decode("utf-8") for a in resolved.deref().GetActions()] @property def members(self) -> InfosetMembers: @@ -246,17 +196,81 @@ class Infoset: The iteration order of information set members is the order in which they are encountered in the pre-order depth first traversal of the game tree. """ - return InfosetMembers.wrap(self.infoset) + return InfosetMembers.wrap(self._resolve()) @property def player(self) -> Player: """The player who has the move at this information set.""" - return Player.wrap(self.infoset.deref().GetPlayer()) + return Player.wrap(self._resolve().deref().GetPlayer()) - @property - def plays(self) -> list[Node]: - """Returns a list of all terminal `Node` objects consistent with it. - """ - return [ - Node.wrap(n) for n in self.infoset.deref().GetGame().deref().GetPlays(self.infoset) - ] + +@cython.cclass +class Infoset(_InfosetOrEvent): + """An information set belonging to a personal player in a ``Game``: the point at + which that player chooses an action, and so the object of potential optimisation. + The corresponding concept for the chance player is an ``Event``. + + A lazy, node-anchored view: holds a member node and resolves the information set + on each access, so the value reflects the current state of the game even after + the game is mutated. For a node currently belonging to no personal player's + information set (a terminal node, or a chance event -- see ``Node.event``), the + view is falsy and equals ``None``. + + .. versionchanged:: 17.0.0 + Now a node-anchored view (see ``Node.infoset``) rather than an object with + identity of its own; equality/hashing are still based on the information set + currently resolved, not on the anchoring node. No longer used for the chance + player's events; see ``Event``. + """ + @staticmethod + @cython.cfunc + def wrap(node: c_GameNode) -> Infoset: + obj: Infoset = Infoset.__new__(Infoset) + obj.node = node + return obj + + @cython.cfunc + def _try_resolve(self) -> c_GameInfoset: + resolved: c_GameInfoset = self.node.deref().GetInfoset() + if resolved != cython.cast(c_GameInfoset, NULL) and resolved.deref().IsChanceInfoset(): + return cython.cast(c_GameInfoset, NULL) + return resolved + + +@cython.cclass +class Event(_InfosetOrEvent): + """An event belonging to the chance player in a ``Game``: a point of exogenous + randomness, with a probability distribution over its actions that is specified + rather than chosen. The corresponding concept for a personal player is an + ``Infoset``. + + A lazy, node-anchored view: holds a member node and resolves the event on each + access, so the value reflects the current state of the game even after the game + is mutated. For a node not currently belonging to a chance event (a terminal + node, or a personal player's information set -- see ``Node.infoset``), the view + is falsy and equals ``None``. + + .. versionadded:: 17.0.0 + """ + @staticmethod + @cython.cfunc + def wrap(node: c_GameNode) -> Event: + obj: Event = Event.__new__(Event) + obj.node = node + return obj + + @cython.cfunc + def _try_resolve(self) -> c_GameInfoset: + resolved: c_GameInfoset = self.node.deref().GetInfoset() + if resolved != cython.cast(c_GameInfoset, NULL) and not resolved.deref().IsChanceInfoset(): + return cython.cast(c_GameInfoset, NULL) + return resolved + + +@cython.cfunc +def _wrap_infoset_or_event(node: c_GameNode) -> object: + """Wraps `node` as an `Infoset` or `Event`, whichever currently applies; only + valid to call when `node` is known to belong to one or the other (not terminal).""" + if node.deref().GetInfoset().deref().IsChanceInfoset(): + return Event.wrap(node) + return Infoset.wrap(node) diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 7504e9294..433ac1d93 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -45,32 +45,31 @@ class NodeChildren: for child in self.parent.deref().GetChildren(): yield Node.wrap(child) - def __getitem__(self, action: str | Action) -> Node: - """Returns the successor node which is reached after `action` is played. - - `action` may be an ``Action`` at this node's infoset, or its label. + def __getitem__(self, action: typing.Any) -> Node: + """Returns the successor node which is reached after the action labeled + `action` is played. Raises ------ KeyError - If `action` is a string and no action with that label exists at the node's - infoset, or if the node is terminal. + If no action with that label exists at the node's infoset, or if the + node is terminal. ValueError - If `action` is an empty or all-whitespace string, or is an ``Action`` - from a different infoset. + If `action` is an empty or all-whitespace string. TypeError - If `action` is not a ``str`` or an ``Action``. + If `action` is not a ``str``. .. versionchanged:: 16.5.0 Previously indexing by string searched the labels of the child nodes, rather than referring to actions. This implements the more natural interpretation that strings refer to action labels. - Relatedly, the collection can now be indexed by an Action object. - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; index by the ``Action`` taken, or its label. - A label matching no action now raises ``KeyError``. + Integer indexing is no longer supported; index by the action's label, or + iterate. A label matching no action now raises ``KeyError``. + + .. versionchanged:: 17.0.0 + No longer indexable by an ``Action`` object, following its removal. """ if isinstance(action, str): if not action.strip(): @@ -81,97 +80,12 @@ class NodeChildren: if act.deref().GetLabel().decode("utf-8") == cython.cast(str, action): return Node.wrap(self.parent.deref().GetChild(act)) raise KeyError(f"No action with label '{action}' at node") - if isinstance(action, Action): - try: - return Node.wrap(self.parent.deref().GetChild(cython.cast(Action, action).action)) - except IndexError: - raise ValueError("Action is from a different infoset than node") from None if isinstance(action, int): raise TypeError( - "node children cannot be indexed by position; index by the action taken " - "(an Action or its label), or iterate. " - "(Integer indexing was removed in 16.7.0.)" + "node children cannot be indexed by position; index by the action's " + "label, or iterate. (Integer indexing was removed in 16.7.0.)" ) - raise TypeError(f"Index must be a str label or an Action, not {action.__class__.__name__}") - - -@cython.cclass -class NodeInfoset: - """The infoset to which a node currently belongs. - - A lazy, node-anchored view: holds the node and resolves its infoset 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 NodeInfoset outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(node: c_GameNode) -> NodeInfoset: - obj: NodeInfoset = NodeInfoset.__new__(NodeInfoset) - obj.node = node - return obj - - @cython.cfunc - def _resolve(self) -> Infoset: - if self.node.deref().GetInfoset() == cython.cast(c_GameInfoset, NULL): - return None - return Infoset.wrap(self.node.deref().GetInfoset()) - - def __getattr__(self, name): - if name.startswith("_"): - raise AttributeError(f"'NodeInfoset' object has no attribute '{name}'") - resolved = self._resolve() - if resolved is None: - raise AttributeError( - f"node's infoset is currently None (terminal node); " - f"cannot access '{name}'" - ) - return getattr(resolved, name) - - @property - def label(self): - resolved = self._resolve() - if resolved is None: - raise AttributeError( - "node's infoset is currently None (terminal node); " - "cannot access 'label'" - ) - return resolved.label - - @label.setter - def label(self, value): - resolved = self._resolve() - if resolved is None: - raise AttributeError( - "node's infoset is currently None (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, NodeInfoset): - other = cython.cast(NodeInfoset, 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 infoset (transitional). - resolved = self._resolve() - return hash(resolved) if resolved is not None else 0 + raise TypeError(f"Index must be a str label, not {action.__class__.__name__}") @cython.cclass @@ -330,7 +244,9 @@ class Node: path = [] node = self while node.parent: - path.append(node.prior_action.number) + path.append( + cython.cast(Node, node).node.deref().GetPriorAction().deref().GetNumber() - 1 + ) node = node.parent return f"Node(game={self.game}, path={path})" @@ -381,16 +297,96 @@ class Node: return Game.wrap(self.node.deref().GetGame()) @property - def infoset(self) -> NodeInfoset: - """The infoset to which this node currently belongs. + def infoset(self) -> Infoset: + """The personal player's information set to which this node currently belongs. 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 belongs to no infoset, the view is falsy and equals ``None``. + For a node that does not currently belong to a personal player's information set + (a terminal node, or a chance event -- see `event`), the view is falsy and equals + ``None``. .. versionchanged:: 16.7.0 + .. versionchanged:: 17.0.0 + No longer resolves to the chance player's events; see `event`. """ - return NodeInfoset.wrap(self.node) + return Infoset.wrap(self.node) + + @property + def event(self) -> Event: + """The chance event to which this node currently belongs. + + 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 node that is not currently a chance event (a terminal node, or a personal + player's information set -- see `infoset`), the view is falsy and equals ``None``. + + .. versionadded:: 17.0.0 + """ + return Event.wrap(self.node) + + @property + def members(self) -> InfosetMembers: + """The set of nodes which are members of the information set or event to which + this node currently belongs -- whichever applies. Equivalent to + ``self.infoset.members`` or ``self.event.members``, whichever is not falsy; + unlike those, this is well-defined regardless of which currently applies. + + .. versionadded:: 17.0.0 + + Raises + ------ + AttributeError + If this node currently belongs to no information set or event (a terminal + node). + """ + infoset: Infoset = self.infoset + if infoset: + return infoset.members + return self.event.members + + @property + def actions(self) -> list[str]: + """The labels of the actions available at the node's current information set + or event, whichever applies. + + .. versionadded:: 17.0.0 + + Raises + ------ + AttributeError + If this node currently belongs to no information set or event (a + terminal node). + """ + infoset: Infoset = self.infoset + if infoset: + return infoset.actions + return self.event.actions + + @property + def action_probs(self) -> dict[str, decimal.Decimal | Rational]: + """The probability of each action at the node's current chance event, keyed + by label. + + .. versionadded:: 17.0.0 + + Raises + ------ + UndefinedOperationError + If the node does not currently belong to a chance event. + """ + event: Event = self.event + if not event: + raise UndefinedOperationError( + "action probabilities are only defined at events" + ) + resolved: c_GameInfoset = event._resolve() + result: dict = {} + for a in resolved.deref().GetActions(): + result[a.deref().GetLabel().decode("utf-8")] = _decode_prob( + cython.cast(string, resolved.deref().GetActionProb(a)) + ) + return result @property def player(self) -> NodePlayer: @@ -419,33 +415,46 @@ class Node: return None @property - def prior_action(self) -> Action | None: - """The action which leads to this node. + def prior_action(self) -> Branch | None: + """The branch -- the parent node and the label of the action taken from it -- + which leads to this node. If this is the root node, None is returned. + + .. versionchanged:: 17.0.0 + Returns a `Branch` (the parent node and the action's label) rather than + an `Action` object, following its removal. """ - if self.node.deref().GetPriorAction() != cython.cast(c_GameAction, NULL): - return Action.wrap(self.node.deref().GetPriorAction()) + prior: c_GameAction = self.node.deref().GetPriorAction() + if prior != cython.cast(c_GameAction, NULL): + return Branch(self.parent, prior.deref().GetLabel().decode("utf-8")) return None @property - def own_prior_action(self) -> Action | None: - """The last action taken by the node's owner before reaching this node. + def own_prior_action(self) -> Branch | None: + """The last branch -- the node and the label of the action taken there -- at + which the node's owner acted before reaching this node. Returns ------- - Action or None - The action object, or None if the player has not moved previously + Branch or None + The node at which the node's owner last acted, paired with the label of + the action taken there, or None if the player has not moved previously on the path to this node. - .. versionadded:: 16.5.0 - See Also - -------- - Infoset.own_prior_actions + .. versionadded:: 16.5.0 + .. versionchanged:: 17.0.0 + Returns a `Branch` (the node and the action's label) rather than an + `Action` object, following its removal. """ - if self.node.deref().GetOwnPriorAction() != cython.cast(c_GameAction, NULL): - return Action.wrap(self.node.deref().GetOwnPriorAction()) - return None + prior: c_GameAction = self.node.deref().GetOwnPriorAction() + if not (prior != cython.cast(c_GameAction, NULL)): + return None + label = prior.deref().GetLabel().decode("utf-8") + cur: c_GameNode = self.node + while cur.deref().GetPriorAction() != prior: + cur = cur.deref().GetParent() + return Branch(Node.wrap(cur.deref().GetParent()), label) @property def prior_sibling(self) -> Node | None: diff --git a/src/pygambit/player.pxi b/src/pygambit/player.pxi index ae928e880..5801057fa 100644 --- a/src/pygambit/player.pxi +++ b/src/pygambit/player.pxi @@ -22,114 +22,6 @@ import cython -@cython.cclass -class PlayerInfosets: - """The set of information sets belonging to a player: decisions for a personal - player, or events for the chance player. - """ - player = cython.declare(c_GamePlayer) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create PlayerInfosets outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(player: c_GamePlayer) -> PlayerInfosets: - obj: PlayerInfosets = PlayerInfosets.__new__(PlayerInfosets) - obj.player = player - return obj - - def __repr__(self) -> str: - return f"PlayerInfosets(player={Player.wrap(self.player)})" - - def __len__(self) -> int: - """The number of information sets belonging to the player.""" - return self.player.deref().GetInfosets().size() - - def __iter__(self) -> typing.Iterator[Infoset]: - for infoset in self.player.deref().GetInfosets(): - yield Infoset.wrap(infoset) - - def __getitem__(self, label: str) -> Infoset: - """Returns the player's information set with text label `label`. - - Parameters - ---------- - label : str - The text label of the infoset to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. - - Raises - ------ - KeyError - If the player has no information set with label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one of the player's - information sets has label `label`. - TypeError - If `label` is not a string. - - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference an information set by its label, - or iterate over the collection. String lookup now requires an exact match of the - label; previously, leading/trailing whitespace was stripped from `label` before - comparison. - """ - return _resolve_by_label(self, label, "Player", "infoset", "infosets") - - -@cython.cclass -class PlayerActions: - """Represents the set of all actions available to a player at some information set.""" - player = cython.declare(Player) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create PlayerActions outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(player: Player) -> PlayerActions: - obj: PlayerActions = PlayerActions.__new__(PlayerActions) - obj.player = player - return obj - - def __repr__(self) -> str: - return f"PlayerActions(player={self.player})" - - def __len__(self) -> int: - return sum(len(s.actions) for s in self.player.infosets) - - def __iter__(self) -> typing.Iterator[Action]: - for infoset in self.player.infosets: - yield from infoset.actions - - def __getitem__(self, label: str) -> Action: - """Returns the player's action with text label `label`. - - Parameters - ---------- - label : str - The text label of the action to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. - - Raises - ------ - KeyError - If the player has no action with label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one of the player's actions - has label `label`. - TypeError - If `label` is not a string. - - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference an action by its label, or iterate - over the collection. String lookup now requires an exact match of the label; - previously, leading/trailing whitespace was stripped from `label` before comparison. - """ - return _resolve_by_label(self, label, "Player", "action", "actions") - - @cython.cclass class PlayerStrategies: """The labels of the strategies available to a player. @@ -269,40 +161,6 @@ class Player: """Returns the collection of sequences belonging to the player.""" return PlayerSequences.wrap(self.player) - @property - def infosets(self) -> PlayerInfosets: - """Returns the set of information sets belonging to the player: decisions for - a personal player, or events for the chance player. - - The iteration order of information sets is the order in which they - are encountered in the pre-order depth first traversal of the game tree. - - Raises - ------ - UndefinedOperationError - If the game does not have a tree representation. - """ - if not self.game.is_tree: - raise UndefinedOperationError( - "Operation only defined for games with a tree representation" - ) - return PlayerInfosets.wrap(self.player) - - @property - def actions(self) -> PlayerActions: - """Returns the set of actions available to the player at some information set. - - Raises - ------ - UndefinedOperationError - If the game does not have a tree representation. - """ - if not self.game.is_tree: - raise UndefinedOperationError( - "Operation only defined for games with a tree representation" - ) - return PlayerActions.wrap(self) - @property def min_payoff(self) -> Rational: """Returns the smallest payoff for the player in any play of the game. diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index 01348adc0..585cc7804 100644 --- a/src/pygambit/qre.py +++ b/src/pygambit/qre.py @@ -255,13 +255,16 @@ def _estimate_behavior_empirical( data: libgbt.MixedBehaviorProfile, ) -> LogitQREMixedBehaviorFitResult: flattened_data = [ - data[next(iter(s.members))][a.label] - for p in data.game.players for s in p.infosets for a in s.actions + data[node][a] + for p in data.game.players + for node in data.game.get_infosets(p.label) + for a in node.infoset.actions ] normalized = data.normalize() regrets = [ - [-normalized.action_regrets[next(iter(infoset.members))][a.label] for a in infoset.actions] - for player in data.game.players for infoset in player.infosets + [-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) ] res = scipy.optimize.minimize( lambda x: -_empirical_log_like(x[0], regrets, flattened_data), @@ -271,9 +274,10 @@ 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 infoset in player.infosets: - node = next(iter(infoset.members)) - profile[node] = {a.label: math.exp(next(log_probs)) for a in infoset.actions} + for node in data.game.get_infosets(player.label): + profile[node] = { + a: math.exp(next(log_probs)) for a in node.infoset.actions + } return LogitQREMixedBehaviorFitResult( data, "empirical", res.x[0], profile, -res.fun ) diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index 71db7bfec..4d891de9c 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -26,7 +26,7 @@ class StrategyBehavior: The keys of the mapping are the information sets of the strategy's player at which the strategy prescribes an action; an unreachable information set is not a key. - The corresponding values are the prescribed ``Action`` objects. + The corresponding values are the labels of the prescribed actions. Iteration yields the keys in the player's information set order. .. versionadded:: 17.0.0 @@ -63,28 +63,36 @@ class StrategyBehavior: """The label of the strategy of which this is the behavior.""" return self._strategy_label - def _action_at(self, infoset: Infoset) -> Action | None: - """The action prescribed by the strategy at `infoset`, or None if unreachable.""" + 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" ) - action: c_GameAction = handle.deref().GetAction(cython.cast(Infoset, infoset).infoset) + action: c_GameAction = handle.deref().GetAction(cython.cast(Infoset, infoset)._resolve()) if not action: return None - return Action.wrap(action) + return action.deref().GetLabel().decode("utf-8") def _resolve_key(self, key: Infoset | str) -> Infoset: """Resolve `key` to an information set at which the player has the move.""" - infoset = self._game._resolve_infoset(key, "StrategyBehavior", "key") + infoset: Infoset + if isinstance(key, Infoset): + infoset = key + if infoset.game != self._game: + 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: raise ValueError( f"Player '{self._player_label}' does not have the move at {infoset}." ) return infoset - def __getitem__(self, key: Infoset | str) -> Action: - """Return the action prescribed at the information set referenced by `key`. + def __getitem__(self, key: Infoset | str) -> str: + """Return the label of the action prescribed at the information set + referenced by `key`. Raises ------ @@ -102,8 +110,9 @@ class StrategyBehavior: ) return action - def get(self, key: Infoset | str, default: typing.Any = None) -> Action | None: - """Return the action prescribed at `key`, or `default` if none is prescribed.""" + def get(self, key: Infoset | str, default: typing.Any = None) -> str | None: + """Return the label of the action prescribed at `key`, or `default` if none + is prescribed.""" infoset = self._resolve_key(key) action = self._action_at(infoset) return default if action is None else action @@ -116,8 +125,8 @@ class StrategyBehavior: return self._action_at(infoset) is not None def __iter__(self) -> typing.Iterator[Infoset]: - player = self._game.players[self._player_label] - for infoset in player.infosets: + for node in self._game.get_infosets(self._player_label): + infoset = node.infoset if self._action_at(infoset) is not None: yield infoset @@ -128,12 +137,12 @@ class StrategyBehavior: """The information sets at which the strategy prescribes an action.""" return list(self) - def values(self) -> list[Action]: - """The prescribed actions, in the order of `keys`.""" + def values(self) -> list[str]: + """The labels of the prescribed actions, in the order of `keys`.""" return [self._action_at(infoset) for infoset in self] - def items(self) -> list[tuple[Infoset, Action]]: - """(information set, action) pairs, in the order of `keys`.""" + def items(self) -> list[tuple[Infoset, str]]: + """(information set, action label) pairs, in the order of `keys`.""" return [(infoset, self._action_at(infoset)) for infoset in self] @@ -194,14 +203,17 @@ class Sequence: return ret @property - def actions(self) -> list[Action]: - """Get the collection of actions defining this sequence. + def actions(self) -> tuple[str, ...]: + """The labels of the actions defining this sequence, in order. - Returns the empty list for the root sequence of the player. + Returns the empty tuple for the root sequence of the player. + + .. versionchanged:: 17.0.0 + Returns bare labels rather than ``Action`` objects. """ - actions: list[Action] = [] + labels: list[str] = [] seq = self.sequence while seq.deref().GetAction() != cython.cast(c_GameAction, NULL): - actions.insert(0, Action.wrap(seq.deref().GetAction())) + labels.insert(0, seq.deref().GetAction().deref().GetLabel().decode("utf-8")) seq = seq.deref().GetParent() - return actions + return tuple(labels) diff --git a/tests/games.py b/tests/games.py index dbe5602f9..67f842ead 100644 --- a/tests/games.py +++ b/tests/games.py @@ -8,6 +8,31 @@ import pygambit as gbt + +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)] + + +def player_infosets(player: gbt.Player) -> 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)] + + +def find_infoset(player: gbt.Player, 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) + + +def find_infoset_in_game(game: gbt.Game, label: str) -> gbt.Infoset: + """Find the Infoset with the given label, searching across all (personal) + players, matching `Game.infosets[label]` before its removal (17.0.0).""" + return next(i for i in all_infosets(game) if i.label == label) + + # Label-validation fixtures. # VALID: accepted by the C++ validator (IsValidLabel in src/games/game.h), including # well-formed UTF-8 text (#862, 17.0.0). A single Unicode whitespace character @@ -528,7 +553,7 @@ def get_map_test_data(cls, **params): game = cls(params) gbt_game = game.gbt_game() maps = [ - [tuple(sig) if len(player.infosets) > 0 else () for sig in sigs] + [tuple(sig) if len(gbt_game.get_infosets(player.label)) > 0 else () for sig in sigs] for player, sigs in zip(gbt_game.players, game.reduced_strategies(), strict=True) ] return (gbt_game, maps) diff --git a/tests/test_actions.py b/tests/test_actions.py index 317357f8e..6c7b1e3c0 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -8,30 +8,30 @@ @pytest.mark.parametrize("label", games.VALID_LABELS) def test_action_label(label: str): game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.infoset.actions)) - game.relabel_actions(game.root.infoset, {action.label: label}) - assert action.label == label + action = next(iter(game.root.actions)) + game.relabel_actions(game.root, {action: label}) + assert label in game.root.actions @pytest.mark.parametrize("label", games.INVALID_LABELS) def test_action_label_invalid_raises_valueerror(label: str): game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.infoset.actions)) + action = next(iter(game.root.actions)) with pytest.raises(ValueError): - game.relabel_actions(game.root.infoset, {action.label: label}) + game.relabel_actions(game.root, {action: label}) def test_relabel_action_empty_raises_valueerror(): game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.infoset.actions)) + action = next(iter(game.root.actions)) with pytest.raises(ValueError): - game.relabel_actions(game.root.infoset, {action.label: ""}) + game.relabel_actions(game.root, {action: ""}) def test_relabel_actions_duplicate_raises_valueerror(): game = games.create_stripped_down_poker_efg() with pytest.raises(ValueError): - game.relabel_actions(game.root.infoset, {"King": "Queen"}) + game.relabel_actions(game.root, {"King": "Queen"}) def test_relabel_actions_simultaneous_swap(): @@ -39,8 +39,8 @@ def test_relabel_actions_simultaneous_swap(): at a time would collide on the intermediate state. """ game = games.create_stripped_down_poker_efg() - game.relabel_actions(game.root.infoset, {"King": "Queen", "Queen": "King"}) - assert [action.label for action in game.root.infoset.actions] == ["Queen", "King"] + game.relabel_actions(game.root, {"King": "Queen", "Queen": "King"}) + assert list(game.root.event.actions) == ["Queen", "King"] def test_relabel_actions_duplicate_targets_raises_valueerror(): @@ -49,19 +49,19 @@ def test_relabel_actions_duplicate_targets_raises_valueerror(): """ game = games.create_stripped_down_poker_efg() with pytest.raises(ValueError): - game.relabel_actions(game.root.infoset, {"King": "Ace", "Queen": "Ace"}) + game.relabel_actions(game.root, {"King": "Ace", "Queen": "Ace"}) def test_relabel_actions_unknown_label_raises_keyerror(): game = games.create_stripped_down_poker_efg() with pytest.raises(KeyError): - game.relabel_actions(game.root.infoset, {"Jack": "Ace"}) + game.relabel_actions(game.root, {"Jack": "Ace"}) def test_relabel_actions_unknown_label_not_strict_is_ignored(): game = games.create_stripped_down_poker_efg() - game.relabel_actions(game.root.infoset, {"Jack": "Ace", "King": "Ace"}, strict=False) - assert [action.label for action in game.root.infoset.actions] == ["Ace", "Queen"] + game.relabel_actions(game.root, {"Jack": "Ace", "King": "Ace"}, strict=False) + assert list(game.root.event.actions) == ["Ace", "Queen"] def test_relabel_actions_failure_leaves_game_unchanged(): @@ -70,8 +70,8 @@ def test_relabel_actions_failure_leaves_game_unchanged(): """ game = games.create_stripped_down_poker_efg() with pytest.raises(ValueError): - game.relabel_actions(game.root.infoset, {"King": "Ace", "Queen": ""}) - assert [action.label for action in game.root.infoset.actions] == ["King", "Queen"] + game.relabel_actions(game.root, {"King": "Ace", "Queen": ""}) + assert list(game.root.event.actions) == ["King", "Queen"] def test_relabel_actions_scope_is_the_information_set(): @@ -80,61 +80,48 @@ 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 = game.players["Alice"].infosets["Alice has King"] - queen = game.players["Alice"].infosets["Alice has Queen"] - game.relabel_actions(king, {"Bet": "Raise"}) - assert [action.label for action in king.actions] == ["Raise", "Fold"] - assert [action.label for action in queen.actions] == ["Bet", "Fold"] - game.relabel_actions(queen, {"Bet": "Raise"}) - assert [action.label for action in queen.actions] == ["Raise", "Fold"] + king = games.find_infoset(game.players["Alice"], "Alice has King") + queen = games.find_infoset(game.players["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"] + game.relabel_actions(next(iter(queen.members)), {"Bet": "Raise"}) + assert list(queen.actions) == ["Raise", "Fold"] def test_relabel_actions_not_a_mapping_raises_typeerror(): game = games.create_stripped_down_poker_efg() with pytest.raises(TypeError): - game.relabel_actions(game.root.infoset, [("King", "Queen")]) + game.relabel_actions(game.root, [("King", "Queen")]) @pytest.mark.parametrize("labels", [{1: "Queen"}, {"King": 1}]) def test_relabel_actions_non_str_label_raises_typeerror(labels: dict): game = games.create_stripped_down_poker_efg() with pytest.raises(TypeError): - game.relabel_actions(game.root.infoset, labels) - - -@pytest.mark.parametrize("game", [games.create_stripped_down_poker_efg()]) -def test_action_precedes(game: gbt.Game): - child = game.root.children["King"] - assert game.root.infoset.actions["King"].precedes(child) - assert not game.root.infoset.actions["Queen"].precedes(child) - - -@pytest.mark.parametrize("game", [games.create_stripped_down_poker_efg()]) -def test_action_precedes_nonnode(game: gbt.Game): - action = next(iter(game.root.infoset.actions)) - with pytest.raises(TypeError): - action.precedes(game) + game.relabel_actions(game.root, labels) def test_set_move_actions_drop_shrinks_actions_and_children(): game = games.create_stripped_down_poker_efg() - infoset = game.players["Alice"].infosets["Alice has King"] + infoset = games.find_infoset(game.players["Alice"], "Alice has King") node = next(iter(infoset.members)) action_count = len(infoset.actions) - remaining = [action.label for action in infoset.actions][1:] - game.set_move_actions(infoset, remaining, drop=True) + remaining = list(infoset.actions)[1:] + game.set_move_actions(node, remaining, drop=True) assert len(infoset.actions) == action_count - 1 assert len(node.children) == action_count - 1 def test_set_move_actions_cannot_remove_the_only_action(): game = games.create_stripped_down_poker_efg() - infoset = game.players["Alice"].infosets["Alice has King"] - last = next(iter(infoset.actions)).label - game.set_move_actions(infoset, [last], drop=True) - assert [action.label for action in infoset.actions] == [last] + infoset = games.find_infoset(game.players["Alice"], "Alice has King") + node = next(iter(infoset.members)) + last = next(iter(infoset.actions)) + game.set_move_actions(node, [last], drop=True) + assert list(infoset.actions) == [last] with pytest.raises(gbt.UndefinedOperationError): - game.set_move_actions(infoset, [], drop=True) + game.set_move_actions(node, [], drop=True) def test_set_move_actions_reorder_carries_subtrees(): @@ -149,33 +136,33 @@ def test_set_move_actions_reorder_carries_subtrees(): members = list(infoset.members) children_before = [{label: member.children[label] for label in ("a", "b", "c")} for member in members] - plays_before = {action.label: set(action.plays) for action in infoset.actions} - game.set_move_actions(infoset, ["c", "a", "b"]) - assert [action.label for action in infoset.actions] == ["c", "a", "b"] + game.set_move_actions(game.root.children["x"], ["c", "a", "b"]) + assert list(infoset.actions) == ["c", "a", "b"] for member, children in zip(members, children_before, strict=True): assert list(member.children) == [children["c"], children["a"], children["b"]] - assert {action.label: set(action.plays) for action in infoset.actions} == plays_before def test_set_move_actions_add_drop_and_reorder_together(): game = games.create_stripped_down_poker_efg() - infoset = game.players["Alice"].infosets["Alice has King"] + infoset = games.find_infoset(game.players["Alice"], "Alice has King") + node = next(iter(infoset.members)) nodes_before = len(game.nodes) - game.set_move_actions(infoset, ["Raise", "Fold"], drop=True) - assert [action.label for action in infoset.actions] == ["Raise", "Fold"] + 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(game.players["Bob"].infosets["Bob's response"].members) == 1 + assert len(games.find_infoset(game.players["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 = game.players["Alice"].infosets["Alice has King"] + infoset = games.find_infoset(game.players["Alice"], "Alice has King") + node = next(iter(infoset.members)) before = game.to_efg() with pytest.raises(ValueError): - game.set_move_actions(infoset, ["Bet"]) + game.set_move_actions(node, ["Bet"]) with pytest.raises(ValueError): - game.set_move_actions(infoset, ["Bet", "Fold", "Raise"], add=False) + game.set_move_actions(node, ["Bet", "Fold", "Raise"], add=False) assert game.to_efg() == before @@ -183,8 +170,8 @@ def test_set_move_actions_raises_at_an_event(): """`set_move_actions` is only for a personal player's move; `set_event_actions` is the corresponding operation for an event.""" game = games.create_stripped_down_poker_efg() - with pytest.raises(gbt.UndefinedOperationError): - game.set_move_actions(game.root.infoset, ["King", "Queen"]) + with pytest.raises(ValueError): + game.set_move_actions(game.root, ["King", "Queen"]) @pytest.mark.parametrize("bad_labels", [["Bet", "Bet"], ["Bet", ""], ["Bet", " x"]]) @@ -192,10 +179,11 @@ 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 = game.players["Alice"].infosets["Alice has King"] + infoset = games.find_infoset(game.players["Alice"], "Alice has King") + node = next(iter(infoset.members)) before = game.to_efg() with pytest.raises(ValueError): - game.set_move_actions(infoset, bad_labels, drop=True) + game.set_move_actions(node, bad_labels, drop=True) assert game.to_efg() == before @@ -204,48 +192,49 @@ def test_set_move_actions_absent_minded_drop_and_add(): set deletes that member with the subtree.""" game = gbt.Game.new_tree(players=["Alice"]) game.append_move(game.root, "Alice", ["a", "b"]) - game.append_infoset(game.root.children["a"], game.root.infoset) - game.set_move_actions(game.root.infoset, ["b", "c"], drop=True) - assert [action.label for action in game.root.infoset.actions] == ["b", "c"] + game.append_infoset(game.root.children["a"], game.root) + game.set_move_actions(game.root, ["b", "c"], drop=True) + assert list(game.root.infoset.actions) == ["b", "c"] assert len(game.root.infoset.members) == 1 assert len(game.nodes) == 3 def test_set_event_actions_reorder_carries_probabilities(): game = games.create_stripped_down_poker_efg() - event = game.root.infoset - game.set_event_actions(event, {"King": "3/4", "Queen": "1/4"}) - game.set_event_actions(event, {"Queen": "1/4", "King": "3/4"}) - assert [(a.label, a.prob) for a in event.actions] == [("Queen", gbt.Rational(1, 4)), - ("King", gbt.Rational(3, 4))] + game.set_event_actions(game.root, {"King": "3/4", "Queen": "1/4"}) + game.set_event_actions(game.root, {"Queen": "1/4", "King": "3/4"}) + assert list(game.root.actions) == ["Queen", "King"] + assert game.root.action_probs == {"Queen": gbt.Rational(1, 4), "King": gbt.Rational(3, 4)} def test_set_event_actions_add_with_probs_mapping(): game = games.create_stripped_down_poker_efg() - event = game.root.infoset nodes_before = len(game.nodes) - game.set_event_actions(event, {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) - assert [(a.label, a.prob) for a in event.actions] == [("Jack", gbt.Rational(1, 2)), - ("King", gbt.Rational(1, 4)), - ("Queen", gbt.Rational(1, 4))] + game.set_event_actions(game.root, {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) + assert list(game.root.actions) == ["Jack", "King", "Queen"] + assert game.root.action_probs == { + "Jack": gbt.Rational(1, 2), "King": gbt.Rational(1, 4), "Queen": gbt.Rational(1, 4) + } assert len(game.nodes) == nodes_before + 1 def test_set_event_actions_drop_with_probs_mapping(): game = games.create_stripped_down_poker_efg() - event = game.root.infoset - game.set_event_actions(event, {"King": 1}, drop=True) - assert [(a.label, a.prob) for a in event.actions] == [("King", 1)] + game.set_event_actions(game.root, {"King": 1}, drop=True) + assert list(game.root.actions) == ["King"] + assert game.root.action_probs == {"King": 1} def test_set_event_actions_unconfirmed_drop_and_disabled_add_raise(): game = games.create_stripped_down_poker_efg() - event = game.root.infoset + _ = game.root.event before = game.to_efg() with pytest.raises(ValueError): - game.set_event_actions(event, {"King": 1}) + game.set_event_actions(game.root, {"King": 1}) with pytest.raises(ValueError): - game.set_event_actions(event, {"King": "1/2", "Queen": "1/4", "Jack": "1/4"}, add=False) + game.set_event_actions( + game.root, {"King": "1/2", "Queen": "1/4", "Jack": "1/4"}, add=False + ) assert game.to_efg() == before @@ -253,9 +242,9 @@ 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 = game.players["Alice"].infosets["Alice has King"] - with pytest.raises(gbt.UndefinedOperationError): - game.set_event_actions(infoset, {"Bet": 1}) + infoset = games.find_infoset(game.players["Alice"], "Alice has King") + with pytest.raises(ValueError): + game.set_event_actions(next(iter(infoset.members)), {"Bet": 1}) def test_set_event_actions_rejects_non_mapping_probs(): @@ -264,7 +253,7 @@ def test_set_event_actions_rejects_non_mapping_probs(): game = games.create_stripped_down_poker_efg() before = game.to_efg() with pytest.raises(TypeError): - game.set_event_actions(game.root.infoset, ["3/4", "1/4"]) + game.set_event_actions(game.root, ["3/4", "1/4"]) assert game.to_efg() == before @@ -272,27 +261,10 @@ def test_set_event_actions_bad_distribution_raises_valueerror(): game = games.create_stripped_down_poker_efg() before = game.to_efg() with pytest.raises(ValueError): - game.set_event_actions(game.root.infoset, {"King": "3/4", "Queen": "3/4"}) + game.set_event_actions(game.root, {"King": "3/4", "Queen": "3/4"}) assert game.to_efg() == before -def test_action_plays(): - """Verify `action.plays` returns plays reachable from a given action.""" - game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - - def node_at(path: list[str]) -> gbt.Node: - node = game.root - for action_label in path: - node = node.children[action_label] - return node - - test_action = node_at(["L"]).infoset.actions["R"] - - expected_set_of_plays = {node_at(["R", "L", "R"]), node_at(["L", "R"])} - - assert set(test_action.plays) == expected_set_of_plays - - @pytest.mark.parametrize( "game, player_label, strategy_label, infoset_path, expected_action_label", [ @@ -322,11 +294,10 @@ def test_get_behavior_prescribed_action_defined( for action_label in infoset_path: node = node.children[action_label] infoset = node.infoset - expected_action = infoset.actions[expected_action_label] prescribed_action = game.get_behavior(player_label, strategy_label).get(infoset) - assert prescribed_action == expected_action + assert prescribed_action == expected_action_label @pytest.mark.parametrize( @@ -346,7 +317,7 @@ def test_get_behavior_prescribed_action_undefined_returns_none( ): """Verify `Game.get_behavior` returns None when called on an unreached player's infoset""" if infoset_label is not None: - infoset = game.infosets[infoset_label] + infoset = games.find_infoset_in_game(game, infoset_label) else: node = game.root for action_label in infoset_path: @@ -386,9 +357,3 @@ def test_get_behavior_raises_value_error_for_wrong_player( with pytest.raises(ValueError): behavior.get(other_players_infoset) - - -def test_player_actions_len(): - game = games.create_stripped_down_poker_efg() - for player in game.players: - assert len(player.actions) == len(list(player.actions)) diff --git a/tests/test_behav.py b/tests/test_behav.py index 48e0d4163..d9450c137 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -18,9 +18,9 @@ def _set_action_probs(profile: gbt.MixedBehaviorProfile, probs: list, rational_f """ convert = (lambda p: gbt.Rational(p)) if rational_flag else (lambda p: p) probs_iter = iter(probs) - for infoset in profile.game.infosets: + for infoset in games.all_infosets(profile.game): node = next(iter(infoset.members)) - profile[node] = {a.label: convert(next(probs_iter)) for a in infoset.actions} + profile[node] = {a: convert(next(probs_iter)) for a in infoset.actions} @pytest.mark.parametrize( @@ -50,8 +50,8 @@ def test_payoffs_reference(game: gbt.Game, rational_flag: bool, payoffs: tuple): ) def test_is_defined_at(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) - for infoset in game.infosets: - assert profile.is_defined_at(infoset) + for infoset in games.all_infosets(game): + assert profile.is_defined_at(next(iter(infoset.members))) @pytest.mark.parametrize( @@ -72,9 +72,11 @@ def test_is_defined_at(game: gbt.Game, rational_flag: bool): ], ) def test_is_defined_at_by_label(game: gbt.Game, label: str, rational_flag: bool): - """is_defined_at resolves a string information-set label, not just an Infoset object.""" + """is_defined_at resolves a string as a node's own label, not an infoset's label.""" + node = next(iter(games.find_infoset_in_game(game, label).members)) + node.label = "target" profile = game.mixed_behavior_profile(rational=rational_flag) - assert profile.is_defined_at(label) + assert profile.is_defined_at(node.label) @pytest.mark.parametrize( @@ -199,7 +201,7 @@ def test_profile_indexing_by_player_infoset_action_reference( rational_flag: bool, ): profile = game.mixed_behavior_profile(rational=rational_flag) - infoset = game.players[player_label].infosets[infoset_label] + infoset = games.find_infoset(game.players[player_label], infoset_label) node = next(iter(infoset.members)) prob = gbt.Rational(prob) if rational_flag else prob assert profile[node][action_label] == prob @@ -264,10 +266,10 @@ 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 = player.infosets[infoset_label] + infoset = games.find_infoset(player, infoset_label) node = next(iter(infoset.members)) probs = [gbt.Rational(prob) for prob in probs] if rational_flag else probs - expected = dict(zip((a.label for a in infoset.actions), probs, strict=True)) + expected = dict(zip(infoset.actions, probs, strict=True)) assert profile[player_label][node] == expected assert profile[node] == expected @@ -286,7 +288,7 @@ def test_behavior_indexing_rejects_node_from_different_player( different player than the one being indexed. """ profile = game.mixed_behavior_profile() - other_infoset = next(iter(game.players[other_player_label].infosets)) + other_infoset = games.player_infosets(game.players[other_player_label])[0] other_node = next(iter(other_infoset.members)) with pytest.raises(gbt.MismatchError): profile[player_label][other_node] @@ -315,8 +317,8 @@ def test_profile_indexing_by_player_label_reference( behav_data = [[gbt.Rational(prob) for prob in probs] for probs in behav_data] player = game.players[player_label] expected = [ - dict(zip((a.label for a in infoset.actions), probs, strict=True)) - for infoset, probs in zip(player.infosets, behav_data, strict=True) + dict(zip(infoset.actions, probs, strict=True)) + for infoset, probs in zip(games.player_infosets(player), behav_data, strict=True) ] assert profile[player_label] == expected @@ -356,7 +358,7 @@ def test_set_probabilities_action( """A sparse one-action distribution leaves the infoset's other actions at weight zero.""" profile = game.mixed_behavior_profile(rational=rational_flag) prob = gbt.Rational(prob) if rational_flag else prob - node = next(iter(game.infosets[infoset_label].members)) + node = next(iter(games.find_infoset_in_game(game, infoset_label).members)) profile[node] = {action_label: prob} assert profile[node][action_label] == prob @@ -432,9 +434,9 @@ 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 = game.players[player_label].infosets[infoset_label] + infoset = games.find_infoset(game.players[player_label], infoset_label) node = next(iter(infoset.members)) - expected = dict(zip((a.label for a in infoset.actions), probs, strict=True)) + expected = dict(zip(infoset.actions, probs, strict=True)) profile[node] = expected assert profile[node] == expected @@ -466,16 +468,16 @@ def test_set_probabilities_player_by_label( behav_data = [[gbt.Rational(prob) for prob in probs] for probs in behav_data] player = game.players[player_label] expected = [ - dict(zip((a.label for a in infoset.actions), probs, strict=True)) - for infoset, probs in zip(player.infosets, behav_data, strict=True) + dict(zip(infoset.actions, probs, strict=True)) + for infoset, probs in zip(games.player_infosets(player), behav_data, strict=True) ] - for infoset, distribution in zip(player.infosets, expected, strict=True): + for infoset, distribution in zip(games.player_infosets(player), expected, strict=True): profile[next(iter(infoset.members))] = distribution assert profile[player_label] == expected def _p1_node(game: gbt.Game): - return next(iter(next(iter(game.players["Player 1"].infosets)).members)) + return next(iter(games.player_infosets(game.players["Player 1"])[0].members)) def test_behavior_setitem_allows_sparse_distribution(): @@ -560,7 +562,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 = next(iter(game.players["Player 1"].infosets)) + infoset = games.player_infosets(game.players["Player 1"])[0] with pytest.raises(TypeError): profile[infoset] with pytest.raises(TypeError): @@ -716,7 +718,7 @@ def test_realiz_prob_nodes_reference( ) def test_infoset_probs_reference(game: gbt.Game, rational_flag: bool, infoset_probs: tuple): profile = game.mixed_behavior_profile(rational=rational_flag) - for prob, infoset in zip(infoset_probs, game.infosets, strict=True): + for prob, infoset in zip(infoset_probs, games.all_infosets(game), strict=True): prob = gbt.Rational(prob) if rational_flag else prob assert profile.infoset_probs[next(iter(infoset.members))] == prob @@ -756,7 +758,7 @@ def test_absent_minded_infoset_prob( game: gbt.Game, infoset_label: str, prob: str | float, rational_flag: bool ): profile = game.mixed_behavior_profile(rational=rational_flag) - node = next(iter(game.infosets[infoset_label].members)) + node = next(iter(games.find_infoset_in_game(game, infoset_label).members)) ip = profile.infoset_probs[node] assert ip == (gbt.Rational(prob) if rational_flag else prob) @@ -782,7 +784,7 @@ def test_nature_rooted_game_root_reached_with_certainty(rational_flag: bool): ) def test_infoset_values_reference(game: gbt.Game, rational_flag: bool, infoset_values: tuple): profile = game.mixed_behavior_profile(rational=rational_flag) - for payoff, infoset in zip(infoset_values, game.infosets, strict=True): + for payoff, infoset in zip(infoset_values, games.all_infosets(game), strict=True): payoff = gbt.Rational(payoff) if rational_flag else payoff assert profile.infoset_values[next(iter(infoset.members))] == payoff @@ -806,11 +808,11 @@ def test_infoset_values_reference(game: gbt.Game, rational_flag: bool, infoset_v ) def test_action_values_reference(game: gbt.Game, rational_flag: bool, action_values: tuple): profile = game.mixed_behavior_profile(rational=rational_flag) - for values_for_infoset, infoset in zip(action_values, game.infosets, strict=True): + for values_for_infoset, infoset in zip(action_values, games.all_infosets(game), strict=True): infoset_action_values = profile.action_values[next(iter(infoset.members))] for value, action in zip(values_for_infoset, infoset.actions, strict=True): value = gbt.Rational(value) if rational_flag else value - assert infoset_action_values[action.label] == value + assert infoset_action_values[action] == value @pytest.mark.parametrize( @@ -827,12 +829,12 @@ 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 player.infosets: + for infoset in games.player_infosets(player): node = next(iter(infoset.members)) for action in infoset.actions: - assert profile.action_regrets[node][action.label] == max( - profile.action_values[node][a.label] for a in infoset.actions - ) - profile.action_values[node][action.label] + assert profile.action_regrets[node][action] == max( + profile.action_values[node][a] for a in infoset.actions + ) - profile.action_values[node][action] @pytest.mark.parametrize( @@ -849,10 +851,10 @@ 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 player.infosets: + for infoset in games.player_infosets(player): node = next(iter(infoset.members)) assert profile.infoset_regrets[node] == max( - profile.action_values[node][a.label] for a in infoset.actions + profile.action_values[node][a] for a in infoset.actions ) - profile.infoset_values[node] @@ -889,7 +891,7 @@ def test_agent_max_regret_consistency(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) infoset_regrets = profile.infoset_regrets assert profile.agent_max_regret() == max( - infoset_regrets[next(iter(infoset.members))] for infoset in game.infosets + infoset_regrets[next(iter(infoset.members))] for infoset in games.all_infosets(game) ) @@ -936,19 +938,19 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): assert isinstance(player_node_values, gbt.NodeValueVector) assert player_node_values[game.root] == payoffs[player.label] - for infoset in player.infosets: + for infoset in games.player_infosets(player): node = next(iter(infoset.members)) infoset_action_values = action_values[node] infoset_action_regrets = action_regrets[node] assert isinstance(infoset_action_values, gbt.ActionValueVector) assert isinstance(infoset_action_regrets, gbt.ActionRegretVector) - best_response_value = max(infoset_action_values[a.label] for a in infoset.actions) + best_response_value = max(infoset_action_values[a] for a in infoset.actions) assert infoset_regrets[node] == best_response_value - infoset_values[node] for action in infoset.actions: assert ( - infoset_action_regrets[action.label] - == best_response_value - infoset_action_values[action.label] + infoset_action_regrets[action] + == best_response_value - infoset_action_values[action] ) for node in game.nodes: @@ -1033,11 +1035,11 @@ def test_action_regrets_reference( profile = game.mixed_behavior_profile(rational=rational_flag) if action_probs: _set_action_probs(profile, action_probs, rational_flag) - for regrets_for_infoset, infoset in zip(action_regrets, game.infosets, strict=True): + for regrets_for_infoset, infoset in zip(action_regrets, games.all_infosets(game), strict=True): infoset_action_regrets = profile.action_regrets[next(iter(infoset.members))] for regret, action in zip(regrets_for_infoset, infoset.actions, strict=True): regret = gbt.Rational(regret) if rational_flag else regret - assert abs(infoset_action_regrets[action.label] - regret) <= tol + assert abs(infoset_action_regrets[action] - regret) <= tol @pytest.mark.parametrize( @@ -1418,8 +1420,7 @@ def test_infoset_value_error_with_chance_player_infoset(game: gbt.Game, rational """The chance player's infosets are excluded from infoset_values, so looking one up is a KeyError. """ - chance_infoset = next(iter(game.players.chance.infosets)) - chance_node = next(iter(chance_infoset.members)) + chance_node = game.get_events()[0] with pytest.raises(KeyError): game.mixed_behavior_profile(rational=rational_flag).infoset_values[chance_node] @@ -1435,12 +1436,21 @@ def test_action_value_error_with_chance_player_action(game: gbt.Game, rational_f """The chance player's infosets are excluded from action_values, so looking up an action there is a KeyError. """ - chance_infoset = next(iter(game.players.chance.infosets)) - chance_node = next(iter(chance_infoset.members)) + chance_node = game.get_events()[0] with pytest.raises(KeyError): game.mixed_behavior_profile(rational=rational_flag).action_values[chance_node] +def _all_node_actions(game: gbt.Game) -> list[tuple[gbt.Node, str]]: + """All (node, action label) pairs across every personal player's information sets.""" + return [ + (node, action) + for player in game.players + for node in game.get_infosets(player.label) + for action in node.actions + ] + + def _get_answers_one_order( game: gbt.Game, action_probs_1st: tuple, @@ -1570,7 +1580,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.infoset_probs[next(iter(y.members))], - lambda x: x.infosets, + lambda x: games.all_infosets(x), ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1578,7 +1588,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.infoset_probs[next(iter(y.members))], - lambda x: x.infosets, + lambda x: games.all_infosets(x), ), ( games.create_stripped_down_poker_efg(), @@ -1586,7 +1596,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.infoset_probs[next(iter(y.members))], - lambda x: x.infosets, + lambda x: games.all_infosets(x), ), ( games.create_stripped_down_poker_efg(), @@ -1594,7 +1604,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.infoset_probs[next(iter(y.members))], - lambda x: x.infosets, + lambda x: games.all_infosets(x), ), ###################################################################################### # infoset_value @@ -1604,7 +1614,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.infoset_values[next(iter(y.members))], - lambda x: x.infosets, + lambda x: games.all_infosets(x), ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1612,7 +1622,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.infoset_values[next(iter(y.members))], - lambda x: x.infosets, + lambda x: games.all_infosets(x), ), ( games.create_stripped_down_poker_efg(), @@ -1620,7 +1630,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.infoset_values[next(iter(y.members))], - lambda x: x.infosets, + lambda x: games.all_infosets(x), ), ( games.create_stripped_down_poker_efg(), @@ -1628,7 +1638,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.infoset_values[next(iter(y.members))], - lambda x: x.infosets, + lambda x: games.all_infosets(x), ), ###################################################################################### # action_value @@ -1637,32 +1647,32 @@ def _get_and_check_answers( PROBS_1A_doub, PROBS_2A_doub, False, - lambda x, y: x.action_values[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x, ny: x.action_values[ny[0]][ny[1]], + lambda x: _all_node_actions(x), ), ( games.read_from_file("mixed_behavior_game.efg"), PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.action_values[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x, ny: x.action_values[ny[0]][ny[1]], + lambda x: _all_node_actions(x), ), ( games.create_stripped_down_poker_efg(), PROBS_1B_doub, PROBS_2B_doub, False, - lambda x, y: x.action_values[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x, ny: x.action_values[ny[0]][ny[1]], + lambda x: _all_node_actions(x), ), ( games.create_stripped_down_poker_efg(), PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.action_values[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x, ny: x.action_values[ny[0]][ny[1]], + lambda x: _all_node_actions(x), ), ###################################################################################### # regret (for actions) @@ -1671,32 +1681,32 @@ def _get_and_check_answers( PROBS_1A_doub, PROBS_2A_doub, False, - lambda x, y: x.action_regrets[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x, ny: x.action_regrets[ny[0]][ny[1]], + lambda x: _all_node_actions(x), ), ( games.read_from_file("mixed_behavior_game.efg"), PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.action_regrets[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x, ny: x.action_regrets[ny[0]][ny[1]], + lambda x: _all_node_actions(x), ), ( games.create_stripped_down_poker_efg(), PROBS_1B_doub, PROBS_2B_doub, False, - lambda x, y: x.action_regrets[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x, ny: x.action_regrets[ny[0]][ny[1]], + lambda x: _all_node_actions(x), ), ( games.create_stripped_down_poker_efg(), PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.action_regrets[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x, ny: x.action_regrets[ny[0]][ny[1]], + lambda x: _all_node_actions(x), ), ###################################################################################### # node_value @@ -1920,11 +1930,11 @@ def test_specific_profile(game: gbt.Game, rational_flag: bool, data: list): """ profile = game.mixed_behavior_profile(rational=rational_flag, data=data) flattened = iter([k for i in data for j in i for k in j]) - for infoset in game.infosets: + for infoset in games.all_infosets(game): node = next(iter(infoset.members)) for action in infoset.actions: prob = next(flattened) - assert profile[node][action.label] == (gbt.Rational(prob) if rational_flag else prob) + assert profile[node][action] == (gbt.Rational(prob) if rational_flag else prob) @pytest.mark.parametrize( @@ -2010,19 +2020,19 @@ def test_undefined_action_value(): """Test that undefined action values return `None`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") *_, p3 = game.players - infoset = next(iter(p3.infosets)) + infoset = games.player_infosets(p3)[0] node = next(iter(infoset.members)) action = next(iter(infoset.actions)) for rat in [False, True]: profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) - assert profile.action_values[node][action.label] is None + assert profile.action_values[node][action] is None def test_undefined_belief(): """Test that undefined beliefs return `None`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") *_, p3 = game.players - node = next(iter(next(iter(p3.infosets)).members)) + node = next(iter(games.player_infosets(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 @@ -2032,7 +2042,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(next(iter(p3.infosets)).members)) + node = next(iter(games.player_infosets(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 3abb705b1..b15b712ad 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -5,6 +5,15 @@ from . import games +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): + if node.infoset.label == label: + return node.infoset + raise KeyError(label) + + def _branching_game(): """A small tree where P1 chooses L/R, each leading to a separate P2 decision, so that removing an action can make a whole subtree's information set unreachable. @@ -25,7 +34,7 @@ def _branching_game(): def test_getitem_by_infoset(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = game.infosets["Infoset 1:1"] + infoset = _find_infoset(game, "Infoset 1:1") support = profile[infoset] assert set(support) == {"U1", "D1"} assert support.infoset == infoset @@ -38,7 +47,7 @@ def test_getitem_by_player_label(): profile = game.behavior_support_profile() support = profile["Player 1"] assert support.player == game.players["Player 1"] - infoset = game.infosets["Infoset 1:1"] + infoset = _find_infoset(game, "Infoset 1:1") assert set(support[infoset]) == {"U1", "D1"} @@ -61,20 +70,20 @@ def test_getitem_infoset_wrong_game(): other = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() with pytest.raises(gbt.MismatchError): - profile[other.infosets["Infoset 1:1"]] + profile[_find_infoset(other, "Infoset 1:1")] def test_predicate_construction(): game = games.read_from_file("mixed_behavior_game.efg") - profile = game.behavior_support_profile(lambda a: a.label != "D1") - infoset = game.infosets["Infoset 1:1"] + profile = game.behavior_support_profile(lambda node, a: a != "D1") + infoset = _find_infoset(game, "Infoset 1:1") assert set(profile[infoset]) == {"U1"} def test_predicate_construction_error(): game = games.read_from_file("mixed_behavior_game.efg") with pytest.raises(ValueError): - game.behavior_support_profile(lambda a: a.infoset.label != "Infoset 1:1") + game.behavior_support_profile(lambda node, a: node.infoset.label != "Infoset 1:1") def test_iter_yields_one_support_per_player(): @@ -90,14 +99,14 @@ def test_behaviorsupport_iter(): profile = game.behavior_support_profile() support = profile["Player 1"] action_supports = list(support) - assert len(action_supports) == len(game.players["Player 1"].infosets) + assert len(action_supports) == len(game.get_infosets("Player 1")) assert all(isinstance(s, gbt.ActionSupport) for s in action_supports) def test_setitem_replaces_support(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = game.infosets["Infoset 1:1"] + infoset = _find_infoset(game, "Infoset 1:1") profile[infoset] = ["U1"] assert set(profile[infoset]) == {"U1"} @@ -105,7 +114,7 @@ def test_setitem_replaces_support(): def test_setitem_unknown_label(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = game.infosets["Infoset 1:1"] + infoset = _find_infoset(game, "Infoset 1:1") with pytest.raises(ValueError): profile[infoset] = ["not-a-label"] @@ -113,7 +122,7 @@ def test_setitem_unknown_label(): def test_setitem_empty(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = game.infosets["Infoset 1:1"] + infoset = _find_infoset(game, "Infoset 1:1") with pytest.raises(ValueError): profile[infoset] = [] @@ -130,14 +139,14 @@ def test_setitem_infoset_wrong_game(): other = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() with pytest.raises(gbt.MismatchError): - profile[other.infosets["Infoset 1:1"]] = ["U1"] + profile[_find_infoset(other, "Infoset 1:1")] = ["U1"] def test_copy_is_independent(): game = games.read_from_file("mixed_behavior_game.efg") original = game.behavior_support_profile() copy = original.copy() - infoset = game.infosets["Infoset 1:1"] + infoset = _find_infoset(game, "Infoset 1:1") copy[infoset] = ["U1"] assert set(copy[infoset]) == {"U1"} assert set(original[infoset]) == {"U1", "D1"} @@ -146,7 +155,7 @@ def test_copy_is_independent(): def test_actionsupport_is_snapshot(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = game.infosets["Infoset 1:1"] + infoset = _find_infoset(game, "Infoset 1:1") snapshot = profile[infoset] profile[infoset] = ["U1"] assert set(snapshot) == {"U1", "D1"} @@ -155,7 +164,7 @@ def test_actionsupport_is_snapshot(): def test_getitem_setitem_accept_node_infoset(): game, root_infoset, left_infoset, right_infoset = _branching_game() profile = game.behavior_support_profile() - # game.root.infoset is a NodeInfoset, not a bare Infoset -- both __getitem__ and + # game.root.infoset is a live, node-anchored Infoset view -- both __getitem__ and # __setitem__ must resolve it the same way Node.infoset is used everywhere else. assert set(profile[game.root.infoset]) == {"L", "R"} profile[game.root.infoset] = ["R"] @@ -177,9 +186,12 @@ def test_is_reachable(): def test_is_reachable_by_label(): + """`is_reachable` resolves a string as a node's own label, not an infoset's label.""" game, root_infoset, left_infoset, right_infoset = _branching_game() + left = next(iter(left_infoset.members)) + left.label = "left" profile = game.behavior_support_profile() - assert profile.is_reachable(left_infoset.label) + assert profile.is_reachable(left.label) def test_is_reachable_wrong_game(): diff --git a/tests/test_catalog.py b/tests/test_catalog.py index d77413d97..c8eb50ed4 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -55,13 +55,19 @@ def test_catalog_games(game_slugs, all_games): def test_catalog_games_filter_n_actions(all_games): - """Test games() function can filter on length of gbt.Game attribute 'actions'""" + """Test games() function can filter on the total number of actions across + the game's personal players' information sets""" filtered_games = gbt.catalog.games(n_actions=2) assert isinstance(filtered_games, pd.DataFrame) assert len(filtered_games) < len(all_games) if len(filtered_games) > 0: g = gbt.catalog.load(filtered_games.Game.iloc[0]) - assert len(g.actions) == 2 + n_game_actions = sum( + len(node.infoset.actions) + for player in g.players + for node in g.get_infosets(player.label) + ) + assert n_game_actions == 2 def test_catalog_games_filter_n_contingencies(all_games): @@ -75,13 +81,13 @@ def test_catalog_games_filter_n_contingencies(all_games): def test_catalog_games_filter_n_infosets(all_games): - """Test games() function can filter on length of gbt.Game attribute 'infosets'""" + """Test games() function can filter on the number of information sets in the game""" filtered_games = gbt.catalog.games(n_infosets=2) assert isinstance(filtered_games, pd.DataFrame) assert len(filtered_games) < len(all_games) if len(filtered_games) > 0: g = gbt.catalog.load(filtered_games.Game.iloc[0]) - assert len(g.infosets) == 2 + assert sum(len(g.get_infosets(p.label)) for p in g.players) == 2 def test_catalog_games_filter_is_const_sum(all_games): diff --git a/tests/test_extensive.py b/tests/test_extensive.py index 2369aff24..587d932c2 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -507,8 +507,9 @@ def test_reduced_strategy_maps(game: gbt.Game, strategy_maps: list): for strategy, expected in zip(player.strategies, expected_maps, strict=True): behavior = game.get_behavior(player.label, strategy) assert tuple( - "*" if (action := behavior.get(infoset)) is None else str(action.number + 1) - for infoset in player.infosets + "*" if (action := behavior.get(infoset)) is None + else str(infoset.actions.index(action) + 1) + for infoset in games.player_infosets(player) ) == expected diff --git a/tests/test_file.py b/tests/test_file.py index 13782f363..9c96ea96a 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -31,13 +31,13 @@ def test_read_efg_repeated_outcome_id_consistent(): def test_read_efg_empty_action_labels_are_normalized(): g = _parse_efg('EFG 2 R "t" { "A" "B" }\n""\np "" 1 1 "" { "" "" } 0\n' 't "" 1 "" { 1, -1 }\nt "" 2 "" { 2, -2 }\n') - assert [a.label for a in g.root.infoset.actions] == ["_1", "_2"] + assert list(g.root.infoset.actions) == ["_1", "_2"] def test_read_efg_duplicate_action_labels_are_normalized(): g = _parse_efg('EFG 2 R "t" { "A" "B" }\n""\np "" 1 1 "" { "l" "l" } 0\n' 't "" 1 "" { 1, -1 }\nt "" 2 "" { 2, -2 }\n') - assert [a.label for a in g.root.infoset.actions] == ["l_1", "l_2"] + assert list(g.root.infoset.actions) == ["l_1", "l_2"] def test_read_efg_repeated_infoset_duplicate_labels_consistent(): @@ -53,7 +53,7 @@ def test_read_efg_repeated_infoset_duplicate_labels_consistent(): 't "" 2 "" { 2, -2 }\n' 't "" 3 "" { 3, -3 }\n' ) - assert [a.label for a in g.root.infoset.actions] == ["l_1", "l_2"] + assert list(g.root.infoset.actions) == ["l_1", "l_2"] _NFG_PAYOFF_BODY = '\n{\n{ "" 1, 1 }\n{ "" 0, 0 }\n{ "" 0, 0 }\n{ "" 1, 1 }\n}\n1 2 3 4\n' diff --git a/tests/test_game.py b/tests/test_game.py index 4b579a669..f7cc6cd93 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -180,7 +180,7 @@ def test_game_get_payoffs_tree(): alice = game.players["Alice"] infoset = game.root.infoset strategy = next( - s for s in alice.strategies if game.get_behavior("Alice", s).get(infoset).label == "a" + s for s in alice.strategies 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}) @@ -225,7 +225,7 @@ def test_mixed_strategy_profile_game_structure_changed_tree(): game = games.read_from_file("basic_extensive_game.efg") profiles = [game.mixed_strategy_profile(rational=b) for b in [False, True]] player = next(iter(game.players)) - game.set_move_actions(game.root.infoset, ["D1"], drop=True) + game.set_move_actions(game.root, ["D1"], drop=True) distribution = {s: 0 for s in player.strategies} for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): @@ -260,8 +260,8 @@ def test_mixed_strategy_profile_game_structure_changed_tree(): def test_mixed_behavior_profile_game_structure_changed(): game = games.read_from_file("basic_extensive_game.efg") profiles = [game.mixed_behavior_profile(rational=b) for b in [False, True]] - game.set_move_actions(game.root.infoset, ["D1"], drop=True) - infoset = next(iter(game.infosets)) + game.set_move_actions(game.root, ["D1"], drop=True) + infoset = game.root for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): _ = profile.action_regrets @@ -311,18 +311,17 @@ def test_mixed_behavior_profile_game_structure_changed(): profile.__getitem__(game.root) +def _bob_response_infoset(g): + return next( + n.infoset for n in g.get_infosets("Bob") if n.infoset.label == "Bob's response" + ) + + COLLECTION_GETTERS = [ pytest.param(lambda g: g.players, id="GamePlayers"), pytest.param(lambda g: g.outcomes, id="GameOutcomes"), - pytest.param(lambda g: g.infosets, id="GameInfosets"), - pytest.param(lambda g: g.actions, id="GameActions"), pytest.param(lambda g: g.players["Alice"].strategies, id="PlayerStrategies"), - pytest.param(lambda g: g.players["Alice"].infosets, id="PlayerInfosets"), - pytest.param(lambda g: g.players["Alice"].actions, id="PlayerActions"), - pytest.param(lambda g: g.players["Bob"].infosets["Bob's response"].actions, - id="InfosetActions"), - pytest.param(lambda g: g.players["Bob"].infosets["Bob's response"].members, - id="InfosetMembers"), + 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 fe9d8ddeb..19bab5c29 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -104,8 +104,16 @@ def test_resolve_node_invalid(game: gbt.Game, node: str, exception: BaseExceptio ] ) def test_resolve_infoset(game: gbt.Game) -> None: - _test_valid_resolutions(game.infosets, - lambda label, fn: game._resolve_infoset(label, fn)) + """`_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): + resolved = game._resolve_infoset(node, "test") + assert resolved == node.infoset + if node.label: + assert game._resolve_infoset(node.label, "test") == node.infoset + for member in node.infoset.members: + assert game._resolve_infoset(member, "test") == node.infoset @pytest.mark.parametrize( @@ -119,27 +127,3 @@ def test_resolve_infoset(game: gbt.Game) -> None: def test_resolve_infoset_invalid(game: gbt.Game, infoset: str, exception: BaseException) -> None: with pytest.raises(exception): game._resolve_infoset(infoset, "test_resolve_infoset_invalid") - - -@pytest.mark.parametrize( - "game", - [ - games.read_from_file("sample_extensive_game.efg"), - ] -) -def test_resolve_action(game: gbt.Game) -> None: - _test_valid_resolutions(game.actions, - lambda label, fn: game._resolve_action(label, fn)) - - -@pytest.mark.parametrize( - "game,action,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"), "inaction", KeyError), - ] -) -def test_resolve_action_invalid(game: gbt.Game, action: str, exception: BaseException) -> None: - with pytest.raises(exception): - game._resolve_action(action, "test_resolve_action_invalid") diff --git a/tests/test_infosets.py b/tests/test_infosets.py index c39ff5ef0..3deb51c68 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 sum(1 for _ in p.infosets) >= 2) - first, second = itertools.islice(player.infosets, 2) + 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)) first.label = "shared" with pytest.raises(ValueError): second.label = "shared" @@ -85,9 +85,10 @@ def test_make_infoset_converts_chance_node(): """A chance node becomes a personal decision node, discarding its probabilities.""" 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 not n.infoset.is_chance) + 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) - assert not chance_node.infoset.is_chance + assert not chance_node.event + assert chance_node.infoset assert chance_node.infoset.player == personal.infoset.player @@ -123,32 +124,15 @@ def test_make_infoset_strategic_game_raises(): game.make_infoset([], "1") -def test_set_move_actions_add_preserves_existing_action_handles(): - """New actions may be declared at any position; the existing Action objects - survive in declared order.""" +def test_set_move_actions_add_preserves_existing_action_order(): + """New actions may be declared at any position; the existing actions' relative + order is preserved.""" game = games.read_from_file("basic_extensive_game.efg") - actions = list(game.root.infoset.actions) - labels = [action.label for action in actions] - game.set_move_actions(game.root.infoset, labels + ["end"]) - assert list(game.root.infoset.actions)[:-1] == actions - game.set_move_actions(game.root.infoset, ["front"] + labels + ["end"]) - assert list(game.root.infoset.actions)[1:-1] == actions - - -def test_infoset_plays(): - """Verify `infoset.plays` returns plays reachable from a given infoset. - """ - game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - list_nodes = list(game.nodes) - list_infosets = list(game.infosets) - - test_infoset = list_infosets[2] # members' paths=[1, 0], [1] - - expected_set_of_plays = { - list_nodes[4], list_nodes[5], list_nodes[7], list_nodes[8] - } # paths=[0, 1, 0], [1, 1, 0], [0, 1], [1, 1] - - assert set(test_infoset.plays) == expected_set_of_plays + labels = list(game.root.actions) + game.set_move_actions(game.root, labels + ["end"]) + assert list(game.root.actions)[:-1] == labels + game.set_move_actions(game.root, ["front"] + labels + ["end"]) + assert list(game.root.actions)[1:-1] == labels @pytest.mark.parametrize( @@ -165,8 +149,9 @@ def test_make_event_sets_probabilities(inprobs, outprobs): """ game = games.read_from_file("stripped_down_poker.efg") game.make_event([game.root], inprobs, "Deal") - for action, prob in zip(game.root.infoset.actions, outprobs, strict=True): - assert action.prob == prob + probs = game.root.action_probs + for action, prob in zip(game.root.actions, outprobs, strict=True): + assert probs[action] == prob def test_make_event_pools_nodes_from_different_infosets(): @@ -174,11 +159,10 @@ def test_make_event_pools_nodes_from_different_infosets(): game = games.read_from_file("stripped_down_poker.efg") nodes = [game.root.children["King"], game.root.children["Queen"]] game.make_event(nodes, ["1/4", "3/4"], "Coin") - assert nodes[0].infoset == nodes[1].infoset - assert nodes[0].infoset.is_chance - assert [a.prob for a in nodes[0].infoset.actions] == [gbt.Rational("1/4"), - gbt.Rational("3/4")] - assert not list(game.players["Alice"].infosets) + assert nodes[0].event == nodes[1].event + assert nodes[0].event + assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] + assert not game.get_infosets("Alice") @pytest.mark.parametrize("probs", [["1/2", "1/2"], {"Call": 1}]) @@ -197,11 +181,12 @@ def test_make_event_requires_matching_action_labels(probs): def test_make_event_converts_personal_node(): """A personal decision node becomes a chance node carrying the probabilities given.""" game = games.read_from_file("stripped_down_poker.efg") - node = next(iter(game.players["Alice"].infosets["Alice has King"].members)) + node = next( + n for n in game.get_infosets("Alice") if n.infoset.label == "Alice has King" + ) game.make_event([node], ["1/4", "3/4"]) - assert node.infoset.is_chance - assert [a.prob for a in node.infoset.actions] == [gbt.Rational("1/4"), - gbt.Rational("3/4")] + assert node.event + assert list(node.action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] def test_make_event_terminal_node_raises(): @@ -255,11 +240,12 @@ def test_make_event_label_reused_when_fully_absorbed(): nodes = [game.root.children["King"], game.root.children["Queen"]] game.make_event(nodes, ["1/2", "1/2"], "Coin") game.make_event(nodes, ["1/4", "3/4"], "Coin") - assert nodes[0].infoset == nodes[1].infoset - assert nodes[0].infoset.label == "Coin" - assert [a.prob for a in nodes[0].infoset.actions] == [gbt.Rational("1/4"), - gbt.Rational("3/4")] - assert [infoset.label for infoset in game.players.chance.infosets].count("Coin") == 1 + assert nodes[0].event == nodes[1].event + assert nodes[0].event.label == "Coin" + assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] + assert [ + n.event.label for n in game.get_events() + ].count("Coin") == 1 @pytest.mark.parametrize("probs", [["3/4", "-1/2"], [0.75, 0.40], ["foo", "bar"]]) @@ -280,14 +266,6 @@ def test_make_event_malformed_probs_raises(probs, error): game.make_event([game.root], probs) -@dataclasses.dataclass -class PriorActionsTestCase: - """TestCase for testing own_prior_actions.""" - factory: typing.Callable[[], gbt.Game] - # each tuple is (action_path_to_a_member_node, expected_prior_actions_set) - expected_results: list[tuple[list[str], set]] - - @dataclasses.dataclass class AbsentMindednessTestCase: """TestCase for testing is_absent_minded.""" @@ -295,63 +273,6 @@ class AbsentMindednessTestCase: expected_am_paths: list[list[str]] -PRIOR_ACTIONS_CASES = [ - pytest.param( - PriorActionsTestCase( - factory=functools.partial(games.read_from_file, "binary_3_levels_generic_payoffs.efg"), - expected_results=[ - ([], {None}), - (["Left", "Left"], {("Player 1", 0, "Left")}), - (["Right", "Left"], {("Player 1", 0, "Right")}), - (["Left"], {None}), - ] - ), - id="perfect_recall" - ), - pytest.param( - PriorActionsTestCase( - factory=functools.partial(gbt.catalog.load, "journals/geb/wichardt2008"), - expected_results=[ - ([], {None}), - (["R"], {("Player 1", 0, "L"), ("Player 1", 0, "R")}), - (["R", "r"], {None}), - ] - ), - id="wichardt_forgetting_action" - ), - pytest.param( - PriorActionsTestCase( - factory=functools.partial(games.read_from_file, "subgames.efg"), - expected_results=[ - (["1"], {None}), - (["2"], {None}), - (["2", "1", "2"], {("Player 1", 1, "1")}), - (["2", "2", "1", "1"], {("Player 1", 5, "1"), ("Player 1", 1, "2")}), - (["2", "2", "1", "2"], {("Player 1", 1, "2")}), - (["2", "2", "1", "2", "2", "1"], {("Player 1", 4, "2")}), - (["2", "2", "2"], {("Player 1", 1, "2")}), - ([], {None}), - (["2", "1"], {("Player 2", 0, "2")}), - (["2", "2", "1"], {("Player 2", 1, "1")}), - (["2", "2", "1", "1", "1"], {("Player 2", 2, "1")}), - (["2", "2", "1", "2", "1"], {("Player 2", 2, "2")}), - (["2", "2", "1", "2", "2", "1", "1"], {("Player 2", 4, "1")}), - ] - ), - id="four_subgames" - ), - pytest.param( - PriorActionsTestCase( - factory=functools.partial(games.read_from_file, "AM-driver-subgame.efg"), - expected_results=[ - ([], {None, ("Player 1", 0, "S")}), - (["S", "T"], {None}), - ] - ), - id="AM_driver" - ), -] - ABSENT_MINDEDNESS_CASES = [ # Games without absent-mindedness pytest.param( @@ -424,33 +345,6 @@ def _get_node_by_path(game, path: list[str]) -> gbt.Node: return node -@pytest.mark.parametrize("test_case", PRIOR_ACTIONS_CASES) -def test_infoset_own_prior_actions(test_case: PriorActionsTestCase): - """ - Test `infoset.own_prior_actions`. - - Verifies that the set of prior actions (as player-infoset-label tuples) - matches the expected results. Each infoset is identified by an action - path to one of its member nodes. - """ - game = test_case.factory() - - for path, expected_set in test_case.expected_results: - node = game.root - for action_label in path: - node = node.children[action_label] - infoset = node.infoset - - actual_actions = infoset.own_prior_actions - - actual_details = { - (a.infoset.player.label, a.infoset.number, a.label) if a is not None else None - for a in actual_actions - } - - assert actual_details == expected_set - - @pytest.mark.parametrize("test_case", ABSENT_MINDEDNESS_CASES) def test_infoset_is_absent_minded(test_case: AbsentMindednessTestCase): """ @@ -465,7 +359,10 @@ def test_infoset_is_absent_minded(test_case: AbsentMindednessTestCase): _get_node_by_path(game, path).infoset for path in test_case.expected_am_paths } - actual_infosets = {infoset for infoset in game.infosets if infoset.is_absent_minded} + actual_infosets = { + n.infoset for p in game.players for n in game.get_infosets(p.label) + if n.infoset.is_absent_minded + } assert actual_infosets == expected_infosets @@ -571,20 +468,20 @@ def test_reveal_splits_infoset_by_action(): """Revealing the deal to Bob separates his single infoset into per-card singletons; the other player's structure is untouched.""" game = games.create_stripped_down_poker_efg(nonterm_outcomes=True) - n_alice = len(list(game.players["Alice"].infosets)) - assert len(list(game.players["Bob"].infosets)) == 1 - game.reveal(game.root.infoset, "Bob") - bob = list(game.players["Bob"].infosets) + n_alice = len(game.get_infosets("Alice")) + assert len(game.get_infosets("Bob")) == 1 + game.reveal(game.root, "Bob") + bob = game.get_infosets("Bob") assert len(bob) == 2 - assert all(len(list(i.members)) == 1 for i in bob) - assert len(list(game.players["Alice"].infosets)) == n_alice + assert all(len(list(n.infoset.members)) == 1 for n in bob) + 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.infoset, game.players.chance) + game.reveal(game.root, game.players.chance) def test_reveal_absent_minded_infoset_raises(): @@ -596,7 +493,7 @@ def test_reveal_absent_minded_infoset_raises(): game.make_infoset([game.root, mid], "Driver") game.append_move(mid.children["Continue"], "2", ["l", "r"]) with pytest.raises(gbt.UndefinedOperationError): - game.reveal(game.root.infoset, "2") + game.reveal(game.root, "2") def test_reveal_mismatch_raises(): @@ -604,4 +501,4 @@ def test_reveal_mismatch_raises(): 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.infoset, next(iter(game2.players))) + game1.reveal(game1.root, next(iter(game2.players))) diff --git a/tests/test_nash.py b/tests/test_nash.py index a365abcac..33eefe2dc 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -23,12 +23,10 @@ def d(*probs) -> tuple: return tuple(probs) -def _action_prob(profile: gbt.MixedBehaviorProfile, action: gbt.Action): - """The probability profile assigns to action, addressed via a representative node - of its information set (MixedBehaviorProfile no longer indexes by Action directly). - """ - node = next(iter(action.infoset.members)) - return profile[node][action.label] +def _action_prob(profile: gbt.MixedBehaviorProfile, node: gbt.Node, label: str): + """The probability profile assigns to the action labeled `label` at `node`'s + information set.""" + return profile[node][label] @dataclasses.dataclass @@ -3209,10 +3207,12 @@ 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 action in player.actions: - assert abs( - _action_prob(eq, action) - _action_prob(expected, action) - ) <= test_case.prob_tol + for node in game.get_infosets(player.label): + for action in node.actions: + assert abs( + _action_prob(eq, node, action) + - _action_prob(expected, node, action) + ) <= test_case.prob_tol ################################################################################################## @@ -3259,9 +3259,12 @@ def test_nash_behavior_solver_unordered(test_case: EquilibriumTestCase, subtests def are_the_same(game, found, candidate): for p in game.players: - for a in p.actions: - if not abs(_action_prob(found, a) - _action_prob(candidate, a)) <= TOL: - return False + for node in game.get_infosets(p.label): + for a in node.actions: + if not abs( + _action_prob(found, node, a) - _action_prob(candidate, node, a) + ) <= TOL: + return False return True game = test_case.factory() @@ -3423,10 +3426,12 @@ 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 action in player.actions: - assert abs( - _action_prob(eq, action) - _action_prob(expected, action) - ) <= test_case.prob_tol + for node in game.get_infosets(player.label): + for action in node.actions: + assert abs( + _action_prob(eq, node, action) + - _action_prob(expected, node, action) + ) <= test_case.prob_tol ################################################################################################## @@ -3489,10 +3494,12 @@ 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 action in player.actions: - assert abs( - _action_prob(eq, action) - _action_prob(expected, action) - ) <= test_case.prob_tol + for node in game.get_infosets(player.label): + for action in node.actions: + assert abs( + _action_prob(eq, node, action) + - _action_prob(expected, node, action) + ) <= test_case.prob_tol ################################################################################################## diff --git a/tests/test_node.py b/tests/test_node.py index 8dc84a361..2d401141d 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -19,10 +19,11 @@ def test_get_infoset(): def test_infoset_equality_is_symmetric(): - """A node-anchored infoset proxy and the resolved Infoset compare equal from either side.""" + """A node-anchored infoset proxy and a separately-constructed Infoset compare + equal from either side.""" game = games.read_from_file("basic_extensive_game.efg") proxy = game.root.infoset - infoset = next(iter(game.infosets)) + infoset = game.get_infosets(game.root.player.label)[0].infoset assert proxy == infoset assert infoset == proxy @@ -155,7 +156,7 @@ def test_get_parent(): def test_get_prior_action(): """Test to ensure that we can retrieve the prior action for a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].prior_action == game.root.infoset.actions["U1"] + assert game.root.children["U1"].prior_action == gbt.Branch(game.root, "U1") assert game.root.prior_action is None @@ -469,10 +470,11 @@ def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): for path, keys in test_case.differences.items() for key in keys } - for infoset in game.infosets: - key = (infoset.player.label, infoset.number) - actual_path = tuple(_get_path_of_action_labels(game.minimal_subgame(infoset).root)) - assert actual_path == expected_path_for_key[key] + for player in game.players: + for node in game.get_infosets(player.label): + key = (node.infoset.player.label, node.infoset.number) + actual_path = tuple(_get_path_of_action_labels(game.minimal_subgame(node).root)) + assert actual_path == expected_path_for_key[key] @pytest.mark.parametrize("game_file, expected_node_data", [ @@ -550,10 +552,10 @@ def test_node_own_prior_action_non_terminal(game_file, expected_node_data): else: # Only collect data for non-terminal nodes opa = node.own_prior_action - details = ( - (opa.infoset.player.label, opa.infoset.number, opa.label) - if opa is not None else None - ) + if opa is not None: + details = (opa.node.infoset.player.label, opa.node.infoset.number, opa.label) + else: + details = None actual_node_data.append((_get_path_of_action_labels(node), details)) assert actual_node_data == expected_node_data @@ -619,7 +621,7 @@ def test_append_move_error_infoset_mismatch(): game1 = gbt.Game.new_tree() game2 = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.MismatchError): - game1.append_infoset(game1.root, game2.root.infoset) + game1.append_infoset(game1.root, game2.root) def test_append_move_error_empty_label(): @@ -801,7 +803,7 @@ def test_append_move_creates_single_infoset_list_of_nodes(): game.root.children["1"].children["1"], game.root.children["1"].children["2"]] game.append_move(nodes, "Player 3", ["B", "F"]) - assert len(game.players["Player 3"].infosets) == 1 + assert len(game.get_infosets("Player 3")) == 1 def test_append_move_same_infoset_list_of_nodes(): @@ -870,8 +872,7 @@ def test_append_move_labels_list_of_nodes(): node2 = game.root.children["1"].children["1"] game.append_move([node1, node2], "Player 3", ["B", "F", "S"]) - for action1, action2 in zip(node1.infoset.actions, node2.infoset.actions, strict=True): - assert action1.label == action2.label + assert node1.infoset.actions == node2.infoset.actions def test_append_move_node_list_with_non_terminal_node(): @@ -925,7 +926,7 @@ def test_append_infoset_node_list_with_non_terminal_node(): with pytest.raises(gbt.UndefinedOperationError): game.append_infoset( [game.root.children["2"], game.root.children["1"].children["2"]], - seed_node.infoset + seed_node ) @@ -942,7 +943,7 @@ def test_append_infoset_node_list_with_duplicate_node(): [game.root.children["1"].children["2"], game.root.children["2"].children["1"], game.root.children["1"].children["2"]], - seed_node.infoset + seed_node ) @@ -955,7 +956,7 @@ def test_append_infoset_node_list_is_empty(): seed_node = game.root.children["1"].children["1"] game.append_move(seed_node, "Player 3", ["B", "F"]) with pytest.raises(ValueError): - game.append_infoset([], seed_node.infoset) + game.append_infoset([], seed_node) def test_append_event_creates_single_event_list_of_nodes(): @@ -964,8 +965,8 @@ def test_append_event_creates_single_event_list_of_nodes(): node1 = game.root.children["2"].children["1"] node2 = game.root.children["1"].children["1"] game.append_event([node1, node2], ["a", "b"], [gbt.Rational(1, 2)] * 2) - assert node1.infoset == node2.infoset - assert node1.infoset.is_chance + assert node1.event == node2.event + assert node1.event def test_append_event_sets_distribution(): @@ -973,7 +974,7 @@ def test_append_event_sets_distribution(): game = games.read_from_file("sample_extensive_game.efg") node = game.root.children["1"].children["1"] game.append_event(node, ["a", "b"], [gbt.Rational(1, 4), gbt.Rational(3, 4)]) - assert [a.prob for a in node.infoset.actions] == [gbt.Rational(1, 4), gbt.Rational(3, 4)] + assert list(node.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] def test_append_event_error_actions_empty(): @@ -1049,8 +1050,8 @@ def test_insert_event_actions_labeled(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") node = game.root.children["L"].children["R"] game.insert_event(node, ["Up", "Down"], [gbt.Rational(1, 2)] * 2) - assert [a.label for a in node.parent.infoset.actions] == ["Up", "Down"] - assert node.parent.infoset.is_chance + assert list(node.parent.actions) == ["Up", "Down"] + assert node.parent.event def test_insert_event_sets_distribution(): @@ -1058,9 +1059,7 @@ def test_insert_event_sets_distribution(): game = games.read_from_file("basic_extensive_game.efg") node = game.root game.insert_event(node, ["a", "b"], [gbt.Rational(1, 4), gbt.Rational(3, 4)]) - assert [a.prob for a in node.parent.infoset.actions] == [ - gbt.Rational(1, 4), gbt.Rational(3, 4) - ] + assert list(node.parent.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] def test_insert_event_error_actions_empty(): @@ -1184,7 +1183,7 @@ def test_len_after_append_infoset(): number_of_infoset_actions = len(infoset_to_modify.actions) terminal_node_to_add = game.root.children["L"].children["L"].children["l"] - game.append_infoset(terminal_node_to_add, infoset_to_modify) + game.append_infoset(terminal_node_to_add, member_node) assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions @@ -1195,8 +1194,8 @@ def test_len_after_set_move_actions_add(): initial_number_of_nodes = len(game.nodes) infoset_to_modify = game.root.children["L"].infoset # Player 2's infoset num_nodes_in_infoset = len(infoset_to_modify.members) - labels = [action.label for action in infoset_to_modify.actions] - game.set_move_actions(infoset_to_modify, labels + ["new"]) + labels = list(infoset_to_modify.actions) + game.set_move_actions(game.root.children["L"], labels + ["new"]) assert len(game.nodes) == initial_number_of_nodes + num_nodes_in_infoset @@ -1204,13 +1203,13 @@ def test_len_after_set_move_actions_drop(): """Verify `len(game.nodes)` is correct after `set_move_actions` deletes an action.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig2") initial_number_of_nodes = len(game.nodes) - action_to_drop = game.root.infoset.actions["L"] + action_to_drop = "L" nodes_to_delete = sum( - _count_subtree_nodes(member.children[action_to_drop.label], True) - for member in action_to_drop.infoset.members + _count_subtree_nodes(member.children[action_to_drop], True) + for member in game.root.infoset.members ) - remaining = [a.label for a in game.root.infoset.actions if a.label != "L"] - game.set_move_actions(game.root.infoset, remaining, drop=True) + remaining = [a for a in game.root.infoset.actions if a != "L"] + game.set_move_actions(game.root, remaining, drop=True) assert len(game.nodes) == initial_number_of_nodes - nodes_to_delete @@ -1233,7 +1232,7 @@ def test_insert_move_actions_labeled(): 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"]) - assert [a.label for a in node.parent.infoset.actions] == ["Up", "Down"] + assert list(node.parent.infoset.actions) == ["Up", "Down"] def test_len_after_insert_infoset(): @@ -1246,7 +1245,7 @@ def test_len_after_insert_infoset(): node_to_insert_above = game.root.children["L"].children["R"] number_of_infoset_actions = len(infoset_to_modify.actions) - game.insert_infoset(node_to_insert_above, infoset_to_modify) + game.insert_infoset(node_to_insert_above, game.root.children["L"]) assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions @@ -1293,15 +1292,6 @@ def test_node_children_action_label(): assert game.root.children["Queen"].children["Fold"] == list(root_children[1].children)[1] -def test_node_children_action(): - """Action lookup returns the correct child. - - The RHS reaches the child positionally -- cf. `test_node_children_action_label()`. - """ - game = games.read_from_file("stripped_down_poker.efg") - assert game.root.children[game.root.infoset.actions["King"]] == list(game.root.children)[0] - - def test_node_children_empty_label(): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(ValueError, match="empty or all whitespace"): @@ -1327,12 +1317,6 @@ def test_node_children_rejects_int(): _ = game.root.children[0] -def test_node_children_other_infoset_action(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(ValueError): - _ = game.root.children[game.root.children["King"].infoset.actions["Bet"]] - - @pytest.mark.parametrize("label", games.VALID_LABELS) def test_node_label_valid(label): game = games.read_from_file("basic_extensive_game.efg") diff --git a/tests/test_players.py b/tests/test_players.py index 29e4bc7f0..bd8c37818 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -185,7 +185,7 @@ def test_extensive_game_set_players_add(): game.set_players(["Alice"]) pl1 = next(iter(game.players)) assert len(game.players) == 1 - assert len(pl1.infosets) == 0 + assert len(game.get_infosets(pl1.label)) == 0 assert len(pl1.strategies) == 1 @@ -322,7 +322,9 @@ def test_player_sequence_count(): """Test the identity that the number of sequences is the number of actions plus one.""" game = gbt.catalog.load("books/myerson1991/fig2_1") for player in game.players: - action_count = sum(len(infoset.actions) for infoset in player.infosets) + action_count = sum( + len(node.infoset.actions) for node in game.get_infosets(player.label) + ) assert len(player.sequences) == action_count + 1 @@ -331,7 +333,11 @@ def test_player_sequence_actions(): player = game.players["Alice"] sequences = set(tuple(seq.actions) for seq in player.sequences) reference = ( - set((action, ) for infoset in player.infosets for action in infoset.actions) | + set( + (action, ) + for node in game.get_infosets(player.label) + for action in node.infoset.actions + ) | {tuple()} ) assert sequences == reference diff --git a/tests/test_qre.py b/tests/test_qre.py index decb97797..e048f5368 100644 --- a/tests/test_qre.py +++ b/tests/test_qre.py @@ -13,9 +13,9 @@ 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 player.infosets: + for infoset in games.player_infosets(player): node = next(iter(infoset.members)) - data[node] = {a.label: float(i + 2) for i, a in enumerate(infoset.actions)} + data[node] = {a: float(i + 2) for i, a in enumerate(infoset.actions)} return data @@ -73,8 +73,8 @@ 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 player.infosets: + for infoset in games.player_infosets(player): node = next(iter(infoset.members)) probs = dict(result.profile[node]) - assert probs.keys() == {a.label for a in infoset.actions} + assert probs.keys() == set(infoset.actions) assert sum(probs.values()) == pytest.approx(1.0) diff --git a/tests/test_strategic.py b/tests/test_strategic.py index 57c700e90..a7a7dc37a 100644 --- a/tests/test_strategic.py +++ b/tests/test_strategic.py @@ -5,30 +5,11 @@ from . import games -def test_strategic_game_actions(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.actions - - -def test_strategic_game_player_actions(): - game = gbt.Game.new_table([2, 2]) - player, _ = game.players - with pytest.raises(gbt.UndefinedOperationError): - _ = player.actions - - -def test_strategic_game_infosets(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.infosets - - -def test_strategic_game_player_infosets(): +def test_strategic_game_get_infosets(): game = gbt.Game.new_table([2, 2]) player, _ = game.players with pytest.raises(gbt.UndefinedOperationError): - _ = player.infosets + _ = game.get_infosets(player.label) def test_strategic_game_root():