From f4b79b8a48f76191d8d7fceff1c64a4f1dc7265d Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 16 Sep 2026 13:53:35 +0100 Subject: [PATCH] Remove implicit holding of internal pointers to nodes in `pygambit` This removes the last avenues by which calling code could manage to access and hold pointers to internal game data structures, specifically, pointers to the objects representing nodes (histories). --- src/pygambit/behavmixed.pxi | 30 +++--- src/pygambit/behavspt.pxi | 14 ++- src/pygambit/game.pxi | 123 +++++++++++-------------- src/pygambit/hsel.pxi | 60 +++++++++--- src/pygambit/node.pxi | 55 +---------- src/pygambit/strategy.pxi | 6 +- tests/games.py | 176 +++++++++++------------------------- tests/test_behav.py | 4 +- tests/test_game_resolve.py | 88 ------------------ tests/test_tree_queries.py | 5 +- 10 files changed, 193 insertions(+), 368 deletions(-) delete mode 100644 tests/test_game_resolve.py diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 5c8778b966..b9c936d1b7 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -59,7 +59,7 @@ class InfosetIndexedVector(_LabeledVector): # as the KeyError below, from _values simply not having that entry -- not as a # resolution-time error that would also wrongly block a valid chance lookup in # infoset_probs. - resolved_node = self._game._resolve_infoset_or_event( + resolved_node = cython.cast(Game, self._game)._resolve_infoset_or_event( selector, f"{type(self).__name__}.__getitem__" ) try: @@ -362,7 +362,9 @@ class MixedBehavior: raise TypeError( f"MixedBehavior index must be Selector, not {selector.__class__.__name__}" ) - infoset = self._game._resolve_infoset(selector, "MixedBehavior.__getitem__") + infoset = cython.cast(Game, self._game)._resolve_infoset( + selector, "MixedBehavior.__getitem__" + ) if infoset.player != self._player: raise MismatchError( "selector must resolve to an information set belonging to this player" @@ -461,11 +463,11 @@ class MixedBehaviorProfile: if isinstance(index, str): values = { _canonical_history(node): self._mixed_action_at(node) - for node in self.game._get_infosets(index) + for node in cython.cast(Game, self.game)._get_infosets(index) } return MixedBehavior.wrap(self.game, index, values) if isinstance(index, Selector): - resolved_infoset = self.game._resolve_infoset( + resolved_infoset = cython.cast(Game, self.game)._resolve_infoset( index, "MixedBehaviorProfile.__getitem__" ) return self._mixed_action_at(resolved_infoset) @@ -478,7 +480,7 @@ class MixedBehaviorProfile: belonging to a personal player, excluding the chance player's. """ for player in self.game.players: - yield from self.game._get_infosets(player) + yield from cython.cast(Game, self.game)._get_infosets(player) def _events(self) -> typing.Iterator[Node]: """Iterates over a representative node of every event of the chance player in @@ -680,7 +682,9 @@ class MixedBehaviorProfile: self._check_validity() if not isinstance(index, Selector): raise TypeError(f"profile index must be Selector, not {index.__class__.__name__}") - infoset = self.game._resolve_infoset(index, "MixedBehaviorProfile.__setitem__") + infoset = cython.cast(Game, self.game)._resolve_infoset( + index, "MixedBehaviorProfile.__setitem__" + ) self._setprob_infoset(infoset, distribution, sparse=True) def set_mixed_action( @@ -731,7 +735,9 @@ class MixedBehaviorProfile: self._check_validity() if not isinstance(index, Selector): raise TypeError(f"profile index must be Selector, not {index.__class__.__name__}") - infoset = self.game._resolve_infoset(index, "MixedBehaviorProfile.set_mixed_action") + infoset = cython.cast(Game, self.game)._resolve_infoset( + index, "MixedBehaviorProfile.set_mixed_action" + ) self._setprob_infoset(infoset, distribution, sparse=sparse) @property @@ -753,7 +759,7 @@ class MixedBehaviorProfile: .. versionadded:: 17.0.0 """ self._check_validity() - histories: dict = self.game._all_histories() + histories: dict = cython.cast(Game, self.game)._all_histories() return HistoryValuesVector({ p: HistoryValueVector({ history: self._node_value(p, n) for n, history in histories.items() @@ -810,7 +816,8 @@ class MixedBehaviorProfile: """ self._check_validity() return RealizProbVector({ - history: self._realiz_prob(n) for n, history in self.game._all_histories().items() + history: self._realiz_prob(n) + for n, history in cython.cast(Game, self.game)._all_histories().items() }) @property @@ -887,7 +894,8 @@ class MixedBehaviorProfile: """ self._check_validity() return BeliefVector({ - history: self._belief(n) for n, history in self.game._all_histories().items() + history: self._belief(n) + for n, history in cython.cast(Game, self.game)._all_histories().items() }) @property @@ -1308,7 +1316,7 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _as_float(self) -> MixedBehaviorProfileDouble: profile: MixedBehaviorProfileDouble = self.game.mixed_behavior_profile() for player in self.game.players: - for node in self.game._get_infosets(player): + for node in cython.cast(Game, self.game)._get_infosets(player): profile._setprob_infoset( node, { diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi index 7f19615a03..51fba15404 100644 --- a/src/pygambit/behavspt.pxi +++ b/src/pygambit/behavspt.pxi @@ -127,7 +127,9 @@ class BehaviorSupport: raise TypeError( f"BehaviorSupport index must be Selector, not {selector.__class__.__name__}" ) - resolved_node = self._game._resolve_infoset(selector, "BehaviorSupport.__getitem__") + resolved_node = cython.cast(Game, self._game)._resolve_infoset( + selector, "BehaviorSupport.__getitem__" + ) if resolved_node.player != self._player: raise MismatchError( "selector must resolve to an information set belonging to this player" @@ -212,11 +214,11 @@ class BehaviorSupportProfile: if isinstance(index, str): values = { _canonical_history(node): self._action_support_at(node) - for node in self.game._get_infosets(index) + for node in cython.cast(Game, self.game)._get_infosets(index) } return BehaviorSupport.wrap(self.game, index, values) if isinstance(index, Selector): - resolved_node = self.game._resolve_infoset( + resolved_node = cython.cast(Game, self.game)._resolve_infoset( index, "BehaviorSupportProfile.__getitem__" ) return self._action_support_at(resolved_node) @@ -297,7 +299,7 @@ class BehaviorSupportProfile: raise TypeError( f"profile index must be Selector, not {infoset.__class__.__name__}" ) - resolved_node = self.game._resolve_infoset( + resolved_node = cython.cast(Game, self.game)._resolve_infoset( infoset, "BehaviorSupportProfile.__setitem__" ) self._set_support(resolved_node, actions) @@ -336,5 +338,7 @@ class BehaviorSupportProfile: f"is_infoset_reachable(): infoset must be a Selector, " f"not {infoset.__class__.__name__}" ) - resolved_node: Node = self.game._resolve_infoset(infoset, "is_infoset_reachable") + resolved_node: Node = cython.cast(Game, self.game)._resolve_infoset( + infoset, "is_infoset_reachable" + ) return deref(self.profile).IsReachable(resolved_node._infoset_handle()) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index cb7b910877..a96e5a20d6 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -312,6 +312,7 @@ class Game: def description(self, value: str) -> None: self.game.deref().SetDescription(value.encode("utf-8")) + @cython.cfunc def _get_infosets(self, player: str) -> list[Node]: """Internal: like `get_infosets`, but keeps `Node` objects rather than materializing each into a History -- used internally where the actual node @@ -500,6 +501,7 @@ class Game: """An iterator over the contingencies in the game.""" return pygambit.gameiter.Contingencies(self) + @cython.cfunc def _root(self) -> Node: """The root node of the game. Not part of the public API; the public equivalent is the trivial empty History, `()`, or `H.path()` as a Selector. @@ -510,6 +512,7 @@ class Game: ) return Node.wrap(self.game.deref().GetRoot()) + @cython.cfunc def _all_nodes(self) -> list: """All nodes in the game, in depth-first traversal order. Not part of the public API; the public equivalent is `Game.get_histories(H.after())`. @@ -520,6 +523,7 @@ class Game: ) return [Node.wrap(node) for node in self.game.deref().GetNodes()] + @cython.cfunc def _all_histories(self) -> dict: """The History of every node in the game, computed in one linear sweep. Not part of the public API; used where most or all of a possibly-large @@ -554,6 +558,7 @@ class Game: )) return histories + @cython.cfunc def _get_nodes(self, selector: Selector) -> list[Node]: """Evaluate `selector` (an `H`-built expression) against this game. @@ -787,6 +792,7 @@ class Game: ) return [_history_of(member) for member in self._get_members(history)] + @cython.cfunc def _get_members(self, history: Selector) -> list[Node]: """Internal: like `get_members`, but keeps `Node` objects rather than materializing each into a History -- used internally where the actual node @@ -799,6 +805,7 @@ class Game: return [] return [Node.wrap(member) for member in infoset_handle.deref().GetMembers()] + @cython.cfunc def _group_nodes(self, grouped: GroupedSelector) -> dict: """Internal: like `_get_groups`, but keeps `Node` objects rather than materializing each into a `History` -- used by mutation methods that @@ -1718,16 +1725,17 @@ class Game: return outcome raise KeyError(f"{funcname}(): no outcome with label '{label}'") - def _resolve_node(self, node: typing.Any, funcname: str, argname: str = "node") -> Node: - """Resolve an attempt to reference a node of the game. A bare `Node` is not - accepted -- every public method that reaches this already requires a - `Selector` (or, for internal callers, an already-resolved `Node`, never - routed back through here). + @cython.cfunc + def _resolve_node(self, node: Selector, funcname: str, argname: str = "node") -> Node: + """Resolve an attempt to reference a node of the game. Every public method + that reaches this already requires a `Selector` (or, for internal callers, + an already-resolved `Node`, never routed back through here). Parameters ---------- - node : Any - An object to resolve as a reference to a node. + node : Selector + An `H`-built expression, evaluated against this game, that must resolve + to exactly one node. funcname : str The name of the function to raise any exception on behalf of. argname : str, default 'node' @@ -1735,38 +1743,18 @@ class Game: Raises ------ - KeyError - If `node` is a string and no node in the game has that label. - TypeError - If `node` is not a `Selector`, `tuple`, or `str` ValueError - If `node` is an empty `str` or all spaces + If `node` does not resolve to exactly one node. """ - if isinstance(node, Selector): - resolved = self._get_nodes(node) - if len(resolved) != 1: - raise ValueError( - f"{funcname}(): {argname} selector must resolve to exactly one " - f"node, resolved to {len(resolved)}" - ) - return resolved[0] - elif isinstance(node, tuple): - # A History -- the manual fallback: root-anchored, every step exact. - return self._resolve_node(Selector().path(*node), funcname, argname) - elif isinstance(node, str): - if not node.strip(): - raise ValueError( - f"{funcname}(): {argname} cannot be an empty string or all spaces" - ) - for n in self._all_nodes(): - if cython.cast(Node, n)._label == node: - return n - raise KeyError(f"{funcname}(): no node with label '{node}'") - raise TypeError( - f"{funcname}(): {argname} must be Selector, tuple, or str, " - f"not {node.__class__.__name__}" - ) + resolved = self._get_nodes(node) + if len(resolved) != 1: + raise ValueError( + f"{funcname}(): {argname} selector must resolve to exactly one " + f"node, resolved to {len(resolved)}" + ) + return resolved[0] + @cython.cfunc def _resolve_nodes(self, nodes: typing.Any, funcname: str, @@ -1789,16 +1777,17 @@ class Game: raise ValueError(f"{funcname}(): Each node must be referenced only once") return resolved_nodes + @cython.cfunc def _resolve_infoset(self, - infoset: typing.Any, funcname: str, argname: str = "infoset") -> Node: + infoset: Selector, funcname: str, argname: str = "infoset") -> Node: """Resolve an attempt to reference a personal player's information set of the - game, via a `Selector` resolving to a member node, or such a node's label. + game, via a `Selector` resolving to a member node. Parameters ---------- - infoset : Selector, tuple, or str - A `Selector`/History resolving to a node belonging to the information - set, or such a node's label. + infoset : Selector + An `H`-built expression, evaluated against this game, that must resolve + to a node belonging to the information set. funcname : str The name of the function to raise any exception on behalf of. argname : str, default 'infoset' @@ -1812,13 +1801,10 @@ class Game: Raises ------ - KeyError - If `infoset` is a string and no node in the game has that label. - TypeError - If `infoset` is not a `Selector`, `tuple`, or `str` ValueError - 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 `infoset` does not resolve to exactly one node, or the node resolves + to a chance event rather than a personal player's information set, or to + no information set at all (the node is terminal). """ resolved_node = self._resolve_node(infoset, funcname, argname) is_personal, is_chance = _node_infoset_kind(resolved_node) @@ -1828,16 +1814,17 @@ class Game: funcname, argname ) + @cython.cfunc def _resolve_event(self, - event: typing.Any, funcname: str, argname: str = "event") -> Node: + event: Selector, funcname: str, argname: str = "event") -> Node: """Resolve an attempt to reference a chance event of the game, via a - `Selector` resolving to a member node, or such a node's label. + `Selector` resolving to a member node. Parameters ---------- - event : Selector, tuple, or str - A `Selector`/History resolving to a node belonging to the event, or - such a node's label. + event : Selector + An `H`-built expression, evaluated against this game, that must resolve + to a node belonging to the event. funcname : str The name of the function to raise any exception on behalf of. argname : str, default 'event' @@ -1851,13 +1838,10 @@ class Game: Raises ------ - KeyError - If `event` is a string and no node in the game has that label. - TypeError - If `event` is not a `Selector`, `tuple`, or `str` ValueError - 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 `event` does not resolve to exactly one node, or the node resolves to + a personal player's information set rather than a chance event, or to no + event at all (the node is terminal). """ resolved_node = self._resolve_node(event, funcname, argname) is_personal, is_chance = _node_infoset_kind(resolved_node) @@ -1867,20 +1851,21 @@ class Game: funcname, argname ) + @cython.cfunc def _resolve_infoset_or_event(self, - infoset: typing.Any, + infoset: Selector, funcname: str, argname: str = "infoset") -> Node: """Resolve an attempt to reference an information set or event of the game - (whichever applies), via a `Selector` resolving to a member node, or such a - node's label. For operations that apply uniformly to either, such as - attaching to an existing one. + (whichever applies), via a `Selector` resolving to a member node. For + operations that apply uniformly to either, such as attaching to an existing + one. Parameters ---------- - infoset : Selector, tuple, or str - A `Selector`/History resolving to a node belonging to the information - set or event, or such a node's label. + infoset : Selector + An `H`-built expression, evaluated against this game, that must resolve + to a node belonging to the information set or event. funcname : str The name of the function to raise any exception on behalf of. argname : str, default 'infoset' @@ -1894,13 +1879,9 @@ class Game: Raises ------ - KeyError - If `infoset` is a string and no node in the game has that label. - TypeError - If `infoset` is not a `Selector`, `tuple`, or `str` ValueError - If `infoset` resolves to no information set or event (the node is - terminal). + If `infoset` does not resolve to exactly one node, or resolves to no + information set or event (the node is terminal). """ resolved_node = self._resolve_node(infoset, funcname, argname) is_personal, is_chance = _node_infoset_kind(resolved_node) diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi index 111b7dcfcb..a931bfe479 100644 --- a/src/pygambit/hsel.pxi +++ b/src/pygambit/hsel.pxi @@ -295,6 +295,34 @@ def _canonical_history_cached(node: Node, cache: dict) -> History: return _history_of_cached(Node.wrap(resolved.deref().GetMember(1)), cache) +@cython.cfunc +def _node_last_action(node: c_GameNode, player: str) -> object: + """The label of the last action `player` took on the path to `node` + (a raw handle, not a `Node`), wherever it fell -- `None` if `player` + hasn't acted yet. The `c_GameNode`-only core of `_last_action`, used + directly by `HistoryView`, which must never hold a `Node`.""" + current: c_GameNode = node + parent: c_GameNode = current.deref().GetParent() + while parent != cython.cast(c_GameNode, NULL): + player_handle: c_GamePlayer = parent.deref().GetPlayer() + if ( + player_handle != cython.cast(c_GamePlayer, NULL) and + player_handle.deref().GetLabel().decode("utf-8") == player + ): + return current.deref().GetPriorAction().deref().GetLabel().decode("utf-8") + current = parent + parent = current.deref().GetParent() + return None + + +def _last_action(node: Node, player: str) -> str | None: + """The label of the last action `player` took on the path to `node`, + wherever it fell -- `None` if `player` hasn't acted yet. Used by + `.with_recall(player)`'s evaluation, which still works in terms of `Node`.""" + return _node_last_action(cython.cast(Node, node).node, player) + + +@cython.cclass class HistoryView: """The object a `.filter(callable)`/`.by(callable)` predicate, or `Game.behavior_support_profile`'s `actions` callback, actually receives. @@ -304,16 +332,27 @@ class HistoryView: (`.last_action(player)`) -- but never exposes the `Node`/game it's privately backed by. Not constructible directly. + A Cython extension type holding a raw `c_GameNode` handle, not a `Node` + object: `_node`/`_history` are C-level fields, not `__dict__` entries, so + a predicate/callback cannot read them back out via `view._node`; and + since `c_GameNode` is a C++ value with no Python identity, there is no + Python object here for `gc.get_referents()` to find either, unlike a + cclass holding a `Node` (or any other Python-object field), which Cython + would still make reachable that way regardless of attribute access. + .. versionadded:: 17.0.0 """ + _node = cython.declare(c_GameNode) + _history = cython.declare(object) def __init__(self, *args, **kwargs) -> None: raise ValueError("Cannot create a HistoryView directly.") @staticmethod + @cython.cfunc def _wrap(node: Node, history: History) -> HistoryView: obj: HistoryView = HistoryView.__new__(HistoryView) - obj._node = node + obj._node = cython.cast(Node, node).node obj._history = history return obj @@ -335,7 +374,7 @@ class HistoryView: def last_action(self, player: str) -> str | None: """The label of the last action `player` took on the path to this history, wherever it fell -- `None` if `player` hasn't acted yet.""" - return _last_action(self._node, player) + return _node_last_action(self._node, player) @property def members(self) -> list[History]: @@ -348,19 +387,10 @@ class HistoryView: If this history currently belongs to no information set or event (a terminal node). """ - return [_history_of(member) for member in self._node.members] - - -def _last_action(node: Node, player: str) -> str | None: - """The label of the last action `player` took on the path to `node`, - wherever it fell -- `None` if `player` hasn't acted yet. Shared between - `HistoryView.last_action` and `.with_recall(player)`'s evaluation.""" - current: Node = node - while current._parent() is not None: - if current._parent().player == player: - return current._prior_action().label - current = current._parent() - return None + infoset_handle: c_GameInfoset = self._node.deref().GetInfoset() + if infoset_handle == cython.cast(c_GameInfoset, NULL): + raise AttributeError("node currently belongs to no information set or event") + return [_history_of(Node.wrap(member)) for member in infoset_handle.deref().GetMembers()] class H: diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 846151a22c..eb9dfd2e49 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -22,11 +22,9 @@ Branch = collections.namedtuple("Branch", ["node", "label"]) Branch.__doc__ = """The action labeled `label`, taken at `node`. Not part of the -public API; internal return type of the private `Node._prior_action`/ -`._own_prior_action`, pending a `Game`/`History`-based design. `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. +public API; internal return type of the private `Node._prior_action`, pending a +`Game`/`History`-based design. `node` is the node at which the action was taken +(not the node it leads to), so ``branch.node.actions`` is always well-defined. """ @@ -161,12 +159,6 @@ class Node: def _label(self, value: str) -> None: self.node.deref().SetLabel(value.encode("utf-8")) - def _number(self) -> int: - """The number of the node in its game, numbered starting with 0. Not part - of the public API; a node's position is otherwise only exposed via History. - """ - return self.node.deref().GetNumber() - 1 - def _children(self) -> NodeChildren: """The set of children of this node. Not part of the public API; the public equivalent is a `Selector`'s `.path(..., ...)` wildcard step, e.g. @@ -185,7 +177,7 @@ class Node: """The node's current information set or event, as a raw handle -- null if the node is currently terminal. Not part of the public API; the information set/event a node belongs to is otherwise only exposed piecemeal, via - `members`/`actions`/`action_probs` and the `Game`/`Selector`-based resolvers. + `members`/`actions` and the `Game`/`Selector`-based resolvers. """ return self.node.deref().GetInfoset() @@ -225,30 +217,6 @@ class Node: raise AttributeError("node currently belongs to no information set or event") return [a.deref().GetLabel().decode("utf-8") for a in resolved.deref().GetActions()] - @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. - """ - resolved: c_GameInfoset = self._infoset_handle() - if resolved == cython.cast(c_GameInfoset, NULL) or not resolved.deref().IsChanceInfoset(): - raise UndefinedOperationError( - "action probabilities are only defined at events" - ) - result: dict = {} - for a in resolved.deref().GetActions(): - result[a.deref().GetLabel().decode("utf-8")] = _decode_number( - cython.cast(string, resolved.deref().GetActionProb(a)) - ) - return result - @property def player(self) -> str | None: """The label of the player associated with this node: the one who makes the @@ -284,21 +252,6 @@ class Node: return Branch(self._parent(), prior.deref().GetLabel().decode("utf-8")) return None - 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, or None if the player - has not moved previously on the path to this node. Not part of the public - API, pending a `Game`/`History`-based design. - """ - 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) - @cython.cfunc def _is_terminal(self) -> cython.bint: """Whether this is a terminal node of the game. Not part of the public diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index 3557c8171b..4c78bdeb48 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -82,7 +82,9 @@ class StrategyBehavior: raise TypeError( f"StrategyBehavior key must be Selector, not {key.__class__.__name__}" ) - resolved_node = self._game._resolve_infoset(key, "StrategyBehavior", "key") + resolved_node = cython.cast(Game, self._game)._resolve_infoset( + key, "StrategyBehavior", "key" + ) if resolved_node.player != self._player_label: raise ValueError( f"Player '{self._player_label}' does not have the move at {resolved_node}." @@ -134,7 +136,7 @@ class StrategyBehavior: def _reachable_nodes(self) -> typing.Iterator[Node]: """The representative nodes of the player's information sets at which the strategy prescribes an action, in the player's information set order.""" - for node in self._game._get_infosets(self._player_label): + for node in cython.cast(Game, self._game)._get_infosets(self._player_label): if self._action_at(node) is not None: yield node diff --git a/tests/games.py b/tests/games.py index ed79916021..f49c66a7f8 100644 --- a/tests/games.py +++ b/tests/games.py @@ -17,24 +17,12 @@ def all_infosets(game: gbt.Game) -> list[tuple]: return [h for p in game.players for h in game.get_infosets(p)] -def all_nodes(game: gbt.Game) -> list[gbt.Node]: - """Every node in the game, in depth-first traversal order, matching - `Game.nodes` before its removal (17.0.0).""" - return game._all_nodes() - - def children_histories(game: gbt.Game, history: tuple) -> list[tuple]: """The Histories of the children of the node at `history` -- the standard way to enumerate a node's children now that `Node.children` is removed (17.0.0).""" return [(*history, action) for action in game.get_actions(gbt.H.path(*history))] -def children_of(game: gbt.Game, history: tuple) -> list[gbt.Node]: - """The children of the node at `history`, as real Nodes -- for callers that - need actual `Node` objects (e.g. `.members`/`.player`), not just Histories.""" - return [node_at_history(game, h) for h in children_histories(game, history)] - - def player_infosets(game: gbt.Game, player: str) -> list[tuple]: """The History of a representative member of every information set belonging to `player`, in canonical order, matching `Player.infosets` before its @@ -44,9 +32,9 @@ def player_infosets(game: gbt.Game, player: str) -> list[tuple]: # (game.title, infoset label) -> the History of a member node, hand-verified against # each fixture file before `Infoset.label` (and thus by-label lookup) was removed -# (17.0.0). `find_infoset`/`find_infoset_in_game` are a fixed stand-in for that -# removed lookup, recognizing only the fixture games this test suite happens to use -# it with; add an entry here if a new fixture/label combination is needed. +# (17.0.0). `infoset_history_in_game` is a fixed stand-in for that removed lookup, +# recognizing only the fixture games this test suite happens to use it with; add an +# entry here if a new fixture/label combination is needed. _INFOSET_LABEL_HISTORIES = { ("Test Extensive Form Game", "Infoset 1:1"): (), ("Test Extensive Form Game", "Infoset 2:1"): ("U1",), @@ -92,63 +80,20 @@ def player_infosets(game: gbt.Game, player: str) -> list[tuple]: } -def node_at_history(game: gbt.Game, history: tuple) -> gbt.Node: - """The Node reached by following `history` (a tuple of action labels) from - the root -- the standard way to obtain a `Node` now that `Game.root` is - removed (17.0.0): `H.path(*history)` always resolves to exactly one node.""" - return game._get_nodes(gbt.H.path(*history))[0] - - -def find_infoset(game: gbt.Game, player: str, label: str) -> gbt.Node: - """The representative node of `player`'s information set historically identified - by `label`, matching `Player.infosets[label]` before its removal (17.0.0).""" - node = node_at_history(game, _INFOSET_LABEL_HISTORIES[(game.title, label)]) - assert node.player == player - return node - - -def find_infoset_in_game(game: gbt.Game, label: str) -> gbt.Node: - """The representative node of the information set historically identified by - `label`, searching across all (personal) players, matching +def infoset_history_in_game(game: gbt.Game, label: str) -> tuple: + """The History of the representative member of the information set historically + identified by `label`, searching across all (personal) players, matching `Game.infosets[label]` before its removal (17.0.0).""" - return node_at_history(game, _INFOSET_LABEL_HISTORIES[(game.title, label)]) - - -def _node_history(node: gbt.Node) -> tuple: - """The plain-tuple history of `node`, walked via the private - `Node._parent`/`._prior_action`.""" - labels = [] - current = node - while current._parent() is not None: - labels.append(current._prior_action().label) - current = current._parent() - labels.reverse() - return tuple(labels) - - -def selector_for_nodes(nodes: list[gbt.Node]) -> gbt.Selector: - """A `Selector` matching exactly the given (possibly scattered, mixed-depth) - nodes -- for adapting fixtures that compute a `Node` list dynamically to the - `H`-only mutation methods.""" - histories = frozenset(_node_history(n) for n in nodes) - return gbt.H.after().filter(lambda h: h[:].actions in histories) + return _INFOSET_LABEL_HISTORIES[(game.title, label)] def selector_for_histories(histories: list[tuple]) -> gbt.Selector: """A `Selector` matching exactly the given (possibly scattered, mixed-depth) - Histories -- the History-only counterpart to `selector_for_nodes`, for - fixtures that already work in terms of `History` rather than `Node`.""" + Histories.""" histories = frozenset(histories) return gbt.H.after().filter(lambda h: h[:].actions in histories) -def selector_for_node(node: gbt.Node) -> gbt.Selector: - """A root-anchored `Selector` matching exactly `node`'s own path of action - labels -- for adapting a fixture's `Node` (e.g. one from `Game.get_infosets`) - to profile indexing, which is `Selector`-only.""" - return gbt.H.path(*_node_history(node)) - - # 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 @@ -383,14 +328,7 @@ def _create_kuhn_poker_efg_only_term_outcomes() -> gbt.Game: """ g = _create_kuhn_poker_efg_without_outcomes() - def calculate_payoffs(term_node): - def get_path(node): - path = [] - while node._parent(): - path.append(node._prior_action().label) - node = node._parent() - return path - + def calculate_payoffs(term_history): def showdown(deal, payoffs, pot): payoffs[_kuhn_showdown_winner(deal)] += pot return payoffs @@ -400,23 +338,23 @@ def bet(player, payoffs, pot): pot += 1 return payoffs, pot - path = get_path(term_node) - deal = path.pop() # needed if there is a showdown + path = list(term_history.actions) + deal = path.pop(0) # needed if there is a showdown payoffs = dict(Alice=-1, Bob=-1) # ante of 1 for both players pot = 2 - if path.pop() == "Check": # Alice checks - if path.pop() == "Check": # Bob checks + if path.pop(0) == "Check": # Alice checks + if path.pop(0) == "Check": # Bob checks payoffs = showdown(deal, payoffs, pot) else: # Bob bets payoffs, pot = bet("Bob", payoffs, pot) - if path.pop() == "Fold": # Alice folds + if path.pop(0) == "Fold": # Alice folds payoffs["Bob"] += pot else: # Alice calls payoffs, pot = bet("Alice", payoffs, pot) payoffs = showdown(deal, payoffs, pot) else: # Alice bets payoffs, pot = bet("Alice", payoffs, pot) - if path.pop() == "Fold": # Bob + if path.pop(0) == "Fold": # Bob payoffs["Alice"] += pot else: # Bob calls payoffs, pot = bet("Bob", payoffs, pot) @@ -424,20 +362,21 @@ def bet(player, payoffs, pot): return tuple(payoffs.values()) - # group terminal nodes by their payoffs, so each of the 4 possible outcomes is created once + # group terminal histories by their payoffs, so each of the 4 possible outcomes is + # created once payoff_labels = { (1, -1): "Alice wins 1", (2, -2): "Alice wins 2", (-1, 1): "Bob wins 1", (-2, 2): "BOb wins 2", } - nodes_by_payoff = {payoffs: [] for payoffs in payoff_labels} - for term_node in [n for n in all_nodes(g) if not g.get_actions(selector_for_node(n))]: - nodes_by_payoff[calculate_payoffs(term_node)].append(term_node) + histories_by_payoff = {payoffs: [] for payoffs in payoff_labels} + for term_history in g.get_histories(gbt.H.plays): + histories_by_payoff[calculate_payoffs(term_history)].append(term_history.actions) - for payoffs, nodes in nodes_by_payoff.items(): + for payoffs, histories in histories_by_payoff.items(): g.make_outcome( - selector_for_nodes(nodes), {"Alice": payoffs[0], "Bob": payoffs[1]}, + selector_for_histories(histories), {"Alice": payoffs[0], "Bob": payoffs[1]}, payoff_labels[payoffs] ) @@ -462,52 +401,45 @@ def _create_kuhn_poker_efg_nonterm_outcomes() -> gbt.Game: payoffs_by_key[f"{player} calls and wins"] = (3, 0) if player == "Alice" else (0, 3) payoffs_by_key[f"{player} calls and loses"] = (-1, 4) if player == "Alice" else (4, -1) - nodes_by_key = {key: [] for key in payoffs_by_key} - nodes_by_key["Ante"].append(node_at_history(g, ())) - - def collect_nodes(term_node): - def get_path(node): - path = [] - while node._parent(): - path.append((node, node._prior_action().label)) - node = node._parent() - return path + histories_by_key = {key: [] for key in payoffs_by_key} + histories_by_key["Ante"].append(()) - path = get_path(term_node) - _, deal = path.pop() + def collect_histories(term_history): + # Each prefix of `actions` identifies one node on the path from the root to + # the terminal history, exactly as the corresponding depth's node would -- + # a node is nothing more than the sequence of actions that reaches it. + actions = term_history.actions + deal = actions[0] winner = _kuhn_showdown_winner(deal) # needed if there is a showdown - n, label = path.pop() - if label == "Check": # Alice checks - n, label = path.pop() - if label == "Check": # Bob checks - nodes_by_key[f"{winner} wins showdown for pot of 2"].append(n) + if actions[1] == "Check": # Alice checks + if actions[2] == "Check": # Bob checks + histories_by_key[f"{winner} wins showdown for pot of 2"].append(actions[:3]) else: # Bob bets - nodes_by_key["Bob bets"].append(n) - n, label = path.pop() - if label == "Fold": # Alice folds - nodes_by_key["Alice folds"].append(n) + histories_by_key["Bob bets"].append(actions[:3]) + if actions[3] == "Fold": # Alice folds + histories_by_key["Alice folds"].append(actions[:4]) else: # Alice calls tmp = "wins" if winner == "Alice" else "loses" - nodes_by_key[f"Alice calls and {tmp}"].append(n) + histories_by_key[f"Alice calls and {tmp}"].append(actions[:4]) else: # Alice bets - nodes_by_key["Alice bets"].append(n) - n, label = path.pop() - if label == "Fold": # Bob - nodes_by_key["Bob folds"].append(n) + histories_by_key["Alice bets"].append(actions[:2]) + if actions[2] == "Fold": # Bob folds + histories_by_key["Bob folds"].append(actions[:3]) else: # Bob calls tmp = "wins" if winner == "Bob" else "loses" - nodes_by_key[f"Bob calls and {tmp}"].append(n) + histories_by_key[f"Bob calls and {tmp}"].append(actions[:3]) - for term_node in [n for n in all_nodes(g) if not g.get_actions(selector_for_node(n))]: - collect_nodes(term_node) + for term_history in g.get_histories(gbt.H.plays): + collect_histories(term_history) - for key, nodes in nodes_by_key.items(): + for key, histories in histories_by_key.items(): # the same non-terminal node is revisited once per terminal descendant walked above - deduped_nodes = list(dict.fromkeys(nodes)) + deduped_histories = list(dict.fromkeys(histories)) alice_payoff, bob_payoff = payoffs_by_key[key] g.make_outcome( - selector_for_nodes(deduped_nodes), {"Alice": alice_payoff, "Bob": bob_payoff}, key + selector_for_histories(deduped_histories), + {"Alice": alice_payoff, "Bob": bob_payoff}, key ) return g @@ -861,13 +793,15 @@ def gbt_game(self): title=f"Binary Tree Game (L={self.level})", ) self.create_binary_tree(g, (), 0, 0, self.level) - for n in all_nodes(g): - history = _node_history(n) - if g.get_actions(gbt.H.path(*history)) and g.get_actions(gbt.H.path(*history, "L")): - left, right = children_of(g, history) + for history in g.get_histories(gbt.H.after()): + actions = history.actions + if g.get_actions(gbt.H.path(*actions)) and g.get_actions(gbt.H.path(*actions, "L")): + left_actions = (*actions, "L") + right_actions = (*actions, "R") + left_members = g.get_members(gbt.H.path(*left_actions)) g.make_infoset( - selector_for_nodes(list(left.members) + [right]), - left.player, + selector_for_histories([m.actions for m in left_members] + [right_actions]), + g.get_player(gbt.H.path(*left_actions)), ) return g diff --git a/tests/test_behav.py b/tests/test_behav.py index 80139a4a4b..4022f40c57 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -965,9 +965,9 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): if not game.get_actions(gbt.H.path(*history.actions)): continue selector = gbt.H.path(*history.actions) - node = games.node_at_history(game, history.actions) + mover = game.get_player(selector) reach_prob = ( - infoset_probs[selector] if node.player in game.players else event_probs[selector] + infoset_probs[selector] if mover in game.players else event_probs[selector] ) if reach_prob == 0: assert beliefs[history] is None diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py deleted file mode 100644 index b5e05b5f27..0000000000 --- a/tests/test_game_resolve.py +++ /dev/null @@ -1,88 +0,0 @@ -import itertools -import typing - -import pytest - -import pygambit as gbt - -from . import games - - -def _test_valid_resolutions(collection: list, resolver: typing.Callable) -> None: - """Generic function to exercise resolving objects via their existing labels.""" - for label, objects in itertools.groupby( - sorted(collection, key=lambda x: x._label), lambda x: x._label - ): - objects = list(objects) - # Ambiguous labels raise ValueError - if len(objects) > 1: - with pytest.raises(ValueError): - _ = resolver(label, "test") - else: - assert objects[0] == resolver(label, "test") - - -@pytest.mark.parametrize( - "game", - [ - games.read_from_file("sample_extensive_game.efg"), - ] -) -def test_resolve_node(game: gbt.Game) -> None: - _test_valid_resolutions(games.all_nodes(game), - lambda label, fn: game._resolve_node(label, fn)) - - -@pytest.mark.parametrize( - "game,node,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"), "fictitious", KeyError), - ] -) -def test_resolve_node_invalid(game: gbt.Game, node: str, exception: BaseException) -> None: - with pytest.raises(exception): - game._resolve_node(node, "test_resolve_node_invalid") - - -def test_resolve_node_rejects_node(): - """A bare `Node` -- even one belonging to this same game -- is not accepted; - `_resolve_node` only resolves a `Selector`, `History` tuple, or label `str`.""" - game = games.read_from_file("sample_extensive_game.efg") - with pytest.raises(TypeError): - game._resolve_node(games.node_at_history(game, ()), "test_resolve_node_rejects_node") - - -@pytest.mark.parametrize( - "game", - [ - games.read_from_file("sample_extensive_game.efg"), - ] -) -def test_resolve_infoset(game: gbt.Game) -> None: - """`_resolve_infoset` resolves a Selector to the node it identifies, or a - node's label to that node, validating that it currently belongs to a - personal player's information set; this holds for every member node, not - just the representative `Game.get_infosets` returns.""" - for player in game.players: - for history in game.get_infosets(player): - for member in game.get_members(gbt.H.path(*history.actions)): - resolved = game._resolve_infoset(gbt.H.path(*member.actions), "test") - assert games._node_history(resolved) == member.actions - if resolved._label: - resolved_by_label = game._resolve_infoset(resolved._label, "test") - assert games._node_history(resolved_by_label) == member.actions - - -@pytest.mark.parametrize( - "game,infoset,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"), "neverhappens", KeyError), - ] -) -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") diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index 1b7cd0ecf8..044a0848a1 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -456,8 +456,9 @@ 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: - node = next(iter(games.find_infoset_in_game(game, infoset_label).members)) - selector = games.selector_for_node(node) + canonical_history = games.infoset_history_in_game(game, infoset_label) + member = next(iter(game.get_members(gbt.H.path(*canonical_history)))) + selector = gbt.H.path(*member.actions) else: selector = gbt.H.path(*infoset_path)