From da9cba374041bb72d5d806a30943e6d9387ac76f Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 18:05:20 +0100 Subject: [PATCH 01/13] Sketch H selector algebra: path/plays ops, Game.get_nodes/get_histories First working slice of the H expression-engine design: a game-neutral Selector built by H.path(*steps)/H.plays, evaluated only when handed to Game.get_nodes (internal, returns Node) or Game.get_histories (public, materializes plain History tuples). Reuses Node's existing navigation (.children, .plays) rather than new C++ traversal code. Co-Authored-By: Claude Sonnet 5 --- src/pygambit/gambit.pyx | 1 + src/pygambit/game.pxi | 49 +++++++++++++++++++++ src/pygambit/hsel.pxi | 98 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 src/pygambit/hsel.pxi diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index 2d73be109..d26a082b9 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -192,6 +192,7 @@ include "infoset.pxi" include "strategy.pxi" include "outcome.pxi" include "node.pxi" +include "hsel.pxi" include "stratspt.pxi" include "behavspt.pxi" include "stratmixed.pxi" diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 84c511663..72f03dd18 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -542,6 +542,55 @@ class Game: ) return Node.wrap(self.game.deref().GetRoot()) + def get_nodes(self, selector: Selector) -> list[Node]: + """Evaluate `selector` (an `H`-built expression) against this game. + + First sketch of the `H` selector algebra's evaluator: interprets the + selector's ops in order, starting from the root, reusing `Node`'s + existing navigation (`.children`, `.plays`) rather than walking the + C++ tree directly. Returns raw `Node` objects for now -- a real + public version would wrap each result in a `.game`-free facade + instead, not yet built. + + .. versionadded:: 17.0.0 + """ + current: list = [self.root] + for op in selector._ops: + if isinstance(op, _PathStep): + for step in op.steps: + current = ( + [child for node in current for child in node.children] + if step is Ellipsis + else [node.children[step] for node in current] + ) + elif isinstance(op, _PlaysStep): + current = [play for node in current for play in node.plays] + else: + raise TypeError(f"get_nodes(): unknown selector op {op!r}") + return current + + def get_histories(self, selector: Selector) -> list[tuple]: + """Evaluate `selector` (an `H`-built expression) against this game, + materializing each result as a `History` -- a plain tuple of action + labels from the root, carrying no reference to this game. + + This is the public-facing counterpart to `get_nodes`: `get_nodes` + exists only as an internal sketch and is never meant to hand a `Node` + to calling code. + + .. versionadded:: 17.0.0 + """ + result: list = [] + for node in self.get_nodes(selector): + labels: list = [] + current: Node = node + while current.parent is not None: + labels.append(current.prior_action.label) + current = current.parent + labels.reverse() + result.append(tuple(labels)) + return result + @property def is_const_sum(self) -> bool: """Whether the game is constant sum.""" diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi new file mode 100644 index 000000000..4cbac209c --- /dev/null +++ b/src/pygambit/hsel.pxi @@ -0,0 +1,98 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/hsel.pxi +# First sketch of the H selector algebra: game-neutral expressions built by +# pygambit.H, evaluated only when handed to a Game. Deliberately minimal -- +# just enough operations to validate the architecture, not the full roster. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# + + +class _PathStep: + """One `H.path(*steps)` operation: each step is an exact action label or + the wildcard `...`. Root-anchored if it is the first op in a Selector, + "this many more steps from here" otherwise -- the evaluator doesn't need + to distinguish the two cases, since they're the same operation applied to + whatever's already been selected (the root, for a bare seed).""" + + def __init__(self, steps: tuple) -> None: + self.steps = steps + + def __repr__(self) -> str: + return f"_PathStep(steps={self.steps!r})" + + +class _PlaysStep: + """One `.plays` operation: expand to the current terminal frontier.""" + + def __repr__(self) -> str: + return "_PlaysStep()" + + +class Selector: + """A game-neutral description of a set of nodes. Carries no reference to + any game -- it's just a recipe, evaluated only when handed to a Game + method such as `get_nodes`. + + .. versionadded:: 17.0.0 + """ + + def __init__(self, ops: tuple = ()) -> None: + self._ops = ops + + def __repr__(self) -> str: + return f"Selector(ops={self._ops!r})" + + def _extend(self, op) -> Selector: + return Selector(self._ops + (op,)) + + def path(self, *steps: str) -> Selector: + """`N` more steps from wherever this selection currently is. Each + step is an exact action label, or `...` to match any single action. + """ + return self._extend(_PathStep(steps)) + + @property + def plays(self) -> Selector: + """The current terminal frontier of this selection -- not + necessarily one step forward, whatever is currently terminal beneath + each already-selected node.""" + return self._extend(_PlaysStep()) + + +class H: + """Namespace of seed constructors for the node-selector algebra. Not + meant to be instantiated -- use as `H.path(...)`, conventionally imported + as `import pygambit.H as H`. + + .. versionadded:: 17.0.0 + """ + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("H is a namespace of selector constructors, not instantiable.") + + @staticmethod + def path(*steps: str) -> Selector: + """A root-anchored selection. Each step is an exact action label, or + `...` to match any single action. `H.path()` with no steps selects + the root itself. + """ + return Selector().path(*steps) + + plays: Selector = Selector((_PlaysStep(),)) + """All currently-terminal nodes in the whole game.""" From cf6dbf79909cc02b46b8c6c961d0359b02279a7c Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 18:35:21 +0100 Subject: [PATCH 02/13] Add H.after(*labels), the suffix-anchored counterpart to H.path Seed form searches the whole game (via game.nodes) for any node whose own trailing labels match; chained form is a pure filter over whatever is already selected. Evaluator now tracks whether a selection has been seeded yet, since .after's seed candidates differ from .path/.plays's (root-anchored) default. Co-Authored-By: Claude Sonnet 5 --- src/pygambit/game.pxi | 10 +++++++++- src/pygambit/hsel.pxi | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 72f03dd18..9da7511e5 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -554,8 +554,14 @@ class Game: .. versionadded:: 17.0.0 """ - current: list = [self.root] + current: list = None for op in selector._ops: + if isinstance(op, _AfterStep): + candidates = list(self.nodes) if current is None else current + current = [n for n in candidates if _matches_suffix(n, op.labels)] + continue + if current is None: + current = [self.root] if isinstance(op, _PathStep): for step in op.steps: current = ( @@ -567,6 +573,8 @@ class Game: current = [play for node in current for play in node.plays] else: raise TypeError(f"get_nodes(): unknown selector op {op!r}") + if current is None: + current = [self.root] return current def get_histories(self, selector: Selector) -> list[tuple]: diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi index 4cbac209c..1b121d7e2 100644 --- a/src/pygambit/hsel.pxi +++ b/src/pygambit/hsel.pxi @@ -44,6 +44,31 @@ class _PlaysStep: return "_PlaysStep()" +class _AfterStep: + """One `.after(*labels)` operation: an unconstrained (possibly empty) + prefix, then exactly these trailing labels. As the first op in a + Selector, matches anywhere in the whole game, not just root's frontier -- + the natural counterpart to `.path(...)`'s root anchoring. Chained onto an + existing selection, it's a pure filter: no new nodes are considered, just + whichever already-selected ones end in this suffix.""" + + def __init__(self, labels: tuple) -> None: + self.labels = labels + + def __repr__(self) -> str: + return f"_AfterStep(labels={self.labels!r})" + + +def _matches_suffix(node: Node, labels: tuple) -> bool: + """Whether `node`'s own history ends with exactly `labels`.""" + current: Node = node + for label in reversed(labels): + if current.parent is None or current.prior_action.label != label: + return False + current = current.parent + return True + + class Selector: """A game-neutral description of a set of nodes. Carries no reference to any game -- it's just a recipe, evaluated only when handed to a Game @@ -74,6 +99,11 @@ class Selector: each already-selected node.""" return self._extend(_PlaysStep()) + def after(self, *labels: str) -> Selector: + """Filter this selection to just the elements whose own trailing + labels are exactly `labels`, whatever came before them.""" + return self._extend(_AfterStep(labels)) + class H: """Namespace of seed constructors for the node-selector algebra. Not @@ -94,5 +124,13 @@ class H: """ return Selector().path(*steps) + @staticmethod + def after(*labels: str) -> Selector: + """Anywhere in the whole game whose own trailing labels are exactly + `labels`, whatever came before them -- the suffix-anchored + counterpart to the root-anchored `.path(...)`. + """ + return Selector().after(*labels) + plays: Selector = Selector((_PlaysStep(),)) """All currently-terminal nodes in the whole game.""" From e85140ac7dabefaf27cc3f557a7e42bc75fc2e10 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 18:39:43 +0100 Subject: [PATCH 03/13] Add .by(callable) grouping, HistoryView facade, Game.get_groups GroupedSelector wraps a base Selector plus a key function, game-neutral until Game.get_groups evaluates it into dict[key, list[History]]. HistoryView is what the key callable actually receives: plain sequence indexing/slicing like a History tuple, plus .last_action(player) -- built by walking Node.parent/.player/.prior_action -- but never exposes the Node or game it's privately backed by (verified via hasattr checks inside a probing callback). get_histories refactored to share the new _history_of helper with get_groups instead of duplicating the walk. Co-Authored-By: Claude Sonnet 5 --- src/pygambit/game.pxi | 24 ++++++++------ src/pygambit/hsel.pxi | 75 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 9da7511e5..4c278bd74 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -588,15 +588,21 @@ class Game: .. versionadded:: 17.0.0 """ - result: list = [] - for node in self.get_nodes(selector): - labels: list = [] - current: Node = node - while current.parent is not None: - labels.append(current.prior_action.label) - current = current.parent - labels.reverse() - result.append(tuple(labels)) + return [_history_of(node) for node in self.get_nodes(selector)] + + def get_groups(self, grouped: GroupedSelector) -> dict: + """Evaluate a `.by(callable)`-built `GroupedSelector` against this + game, returning a dict from each distinct key to the list of + Histories that produced it. + + .. versionadded:: 17.0.0 + """ + result: dict = {} + for node in self.get_nodes(grouped.base): + history: tuple = _history_of(node) + view: HistoryView = HistoryView._wrap(node, history) + key = grouped.key(view) + result.setdefault(key, []).append(history) return result @property diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi index 1b121d7e2..129164300 100644 --- a/src/pygambit/hsel.pxi +++ b/src/pygambit/hsel.pxi @@ -104,6 +104,81 @@ class Selector: labels are exactly `labels`, whatever came before them.""" return self._extend(_AfterStep(labels)) + def by(self, key: typing.Callable) -> GroupedSelector: + """Partition this selection by `key`, called once per element with a + read-only `HistoryView` of it. Distinct return values become distinct + groups; game-neutral until evaluated, same as `Selector` itself.""" + return GroupedSelector(self, key) + + +class GroupedSelector: + """Result of `.by(callable)`. Game-neutral until evaluated -- iterate + the evaluated result (`Game.get_groups`) as `(key, group)` pairs. No + further chaining yet (`.plays`/`.after(...)` applied per-group) -- + deferred until a concrete need for it shows up. + + .. versionadded:: 17.0.0 + """ + + def __init__(self, base: Selector, key: typing.Callable) -> None: + self.base = base + self.key = key + + def __repr__(self) -> str: + return f"GroupedSelector(base={self.base!r}, key={self.key!r})" + + +def _history_of(node: Node) -> tuple: + """The plain-tuple History for `node` -- walks back to the root via the + existing `Node.parent`/`.prior_action` navigation.""" + labels: list = [] + current: Node = node + while current.parent is not None: + labels.append(current.prior_action.label) + current = current.parent + labels.reverse() + return tuple(labels) + + +class HistoryView: + """The object a `.by(callable)` key function actually receives. Supports + plain sequence indexing/slicing like a `History` tuple, plus limited + game-aware navigation (`.last_action(player)`) -- but never exposes the + `Node`/game it's privately backed by. Never returned to calling code + outside a `.by(callable)` call; not constructible directly. + + .. versionadded:: 17.0.0 + """ + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create a HistoryView directly.") + + @staticmethod + def _wrap(node: Node, history: tuple) -> HistoryView: + obj: HistoryView = HistoryView.__new__(HistoryView) + obj._node = node + obj._history = history + return obj + + def __repr__(self) -> str: + return f"HistoryView({self._history!r})" + + def __len__(self) -> int: + return len(self._history) + + def __getitem__(self, index: typing.Any) -> typing.Any: + return self._history[index] + + 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.""" + current: Node = self._node + while current.parent is not None: + if current.parent.player == player: + return current.prior_action.label + current = current.parent + return None + class H: """Namespace of seed constructors for the node-selector algebra. Not From 3b691b98626a492bd8daf955336f6228acf7daac Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 18:43:52 +0100 Subject: [PATCH 04/13] Add .filter(callable), the general predicate escape hatch Chained-only (no bare H.filter(...) seed -- unlike .after(...), a predicate has no natural whole-game starting domain). Keeps elements where predicate(HistoryView) is truthy; complements .after(...)'s label-pattern matching for anything needing richer navigation like .last_action(player). Co-Authored-By: Claude Sonnet 5 --- src/pygambit/game.pxi | 5 +++++ src/pygambit/hsel.pxi | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 4c278bd74..c9d92d47a 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -571,6 +571,11 @@ class Game: ) elif isinstance(op, _PlaysStep): current = [play for node in current for play in node.plays] + elif isinstance(op, _FilterStep): + current = [ + node for node in current + if op.predicate(HistoryView._wrap(node, _history_of(node))) + ] else: raise TypeError(f"get_nodes(): unknown selector op {op!r}") if current is None: diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi index 129164300..c2692de31 100644 --- a/src/pygambit/hsel.pxi +++ b/src/pygambit/hsel.pxi @@ -69,6 +69,20 @@ def _matches_suffix(node: Node, labels: tuple) -> bool: return True +class _FilterStep: + """One `.filter(callable)` operation: keep only elements where + `predicate`, given a HistoryView, returns something truthy. Chained-only + -- unlike `.after(...)`, there's no natural "whole game" domain for a + bare predicate to start from, so it's not exposed as an `H.filter(...)` + seed.""" + + def __init__(self, predicate: typing.Callable) -> None: + self.predicate = predicate + + def __repr__(self) -> str: + return f"_FilterStep(predicate={self.predicate!r})" + + class Selector: """A game-neutral description of a set of nodes. Carries no reference to any game -- it's just a recipe, evaluated only when handed to a Game @@ -104,6 +118,15 @@ class Selector: labels are exactly `labels`, whatever came before them.""" return self._extend(_AfterStep(labels)) + def filter(self, predicate: typing.Callable) -> Selector: + """Keep only the elements of this selection where `predicate`, + called once per element with a read-only `HistoryView` of it, + returns something truthy. The general escape hatch for a filter + `.after(...)`'s label-pattern matching can't express -- e.g. + anything needing `.last_action(player)` rather than a plain + trailing-label match.""" + return self._extend(_FilterStep(predicate)) + def by(self, key: typing.Callable) -> GroupedSelector: """Partition this selection by `key`, called once per element with a read-only `HistoryView` of it. Distinct return values become distinct From 72abded69be7872c77c825cdd553a07d1f843e24 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 18:48:34 +0100 Subject: [PATCH 05/13] Wire H selectors into append_move/append_event mutation _resolve_nodes now resolves a Selector via get_nodes before its usual node resolution, so both append_move and append_event accept an H expression directly wherever they took Node/NodeReferenceSet before -- no changes needed to either method's own body for the flat case. append_move additionally accepts a GroupedSelector (from .by(...)), dispatching to one append_move call per group via the new Game._group_nodes helper (shares get_groups's logic, keeping Node objects instead of materializing Histories, avoiding a round trip). Verified end-to-end: building Kuhn poker's deal via append_event(H.path(...)) and Alice's three per-card infosets via one append_move(H.path(...).plays.by(lambda h: h[0]), ...) call, confirming is_perfect_recall and each infoset's membership. Co-Authored-By: Claude Sonnet 5 --- src/pygambit/game.pxi | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index c9d92d47a..0a7ee66b1 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -595,6 +595,18 @@ class Game: """ return [_history_of(node) for node in self.get_nodes(selector)] + 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 + need to resolve straight back to concrete nodes, avoiding a + Node -> History -> Node round trip.""" + result: dict = {} + for node in self.get_nodes(grouped.base): + view: HistoryView = HistoryView._wrap(node, _history_of(node)) + key = grouped.key(view) + result.setdefault(key, []).append(node) + return result + def get_groups(self, grouped: GroupedSelector) -> dict: """Evaluate a `.by(callable)`-built `GroupedSelector` against this game, returning a dict from each distinct key to the list of @@ -602,13 +614,10 @@ class Game: .. versionadded:: 17.0.0 """ - result: dict = {} - for node in self.get_nodes(grouped.base): - history: tuple = _history_of(node) - view: HistoryView = HistoryView._wrap(node, history) - key = grouped.key(view) - result.setdefault(key, []).append(history) - return result + return { + key: [_history_of(node) for node in nodes] + for key, nodes in self._group_nodes(grouped).items() + } @property def is_const_sum(self) -> bool: @@ -1369,7 +1378,12 @@ class Game: """Resolve an attempt to reference a subset of the nodes of the game of the game. See `_resolve_node` for details on functionality. + + `nodes` may also be a `Selector` (an `H`-built expression), evaluated + against this game via `get_nodes` before the usual resolution. """ + if isinstance(nodes, Selector): + nodes = self.get_nodes(nodes) resolved_nodes = [ self._resolve_node(n, funcname, argname) for n in (nodes if hasattr(nodes, "__iter__") and not isinstance(nodes, str) @@ -1512,7 +1526,7 @@ class Game: raise IndexError(f"{funcname}(): must specify exactly one probability per action") return probs - def append_move(self, nodes: Node | NodeReferenceSet, + def append_move(self, nodes: Node | NodeReferenceSet | Selector | GroupedSelector, player: str, actions: list[str]) -> None: """Add a move for `player` at terminal `nodes`. All elements of `nodes` become part of @@ -1520,6 +1534,14 @@ class Game: `player` must be a personal player; use `append_event` to add a chance move. + `nodes` may be a `Selector` (an `H`-built expression, evaluated against this + game and treated as a flat `NodeReferenceSet`) or a `GroupedSelector` (an + `H`-built `.by(...)` expression) -- in the latter case, one new information + set is created per distinct group, rather than one spanning every match. + + .. versionchanged:: 17.0.0 + `nodes` may now be a `Selector` or `GroupedSelector`. + Raises ------ UndefinedOperationError @@ -1532,6 +1554,10 @@ class Game: If `nodes` has duplicated elements, or is empty; or if `actions` contains an empty or a duplicated label. """ + if isinstance(nodes, GroupedSelector): + for group in self._group_nodes(nodes).values(): + self.append_move(group, player, actions) + return resolved_player = self._resolve_player(player, "append_move") if not actions: raise UndefinedOperationError("append_move(): `actions` must be a nonempty list") From ad507ddfdd014e8d11ce0cc7d60e6ce6063aa2b7 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 18:52:58 +0100 Subject: [PATCH 06/13] Wire H selectors into append_infoset/make_outcome; accept bare Histories _resolve_node now also accepts a Selector (must resolve to exactly one node) and a bare History tuple (resolved via Selector().path(*history), the always-available manual fallback), and _resolve_nodes now excludes tuple from its "treat as a collection" check the same way it already excluded str -- so a single History isn't misread as several single-label node references. append_infoset gets Selector support in both its nodes and infoset arguments for free through this; make_outcome gets it through location's existing _resolve_nodes path. Verified end-to-end: the Absent-Minded Driver (append_move(H.path(),...), append_infoset(H.path("S"), H.path()), make_outcome at H.path("S","S") etc.) reproduces is_perfect_recall == False and the expected two-member root infoset; and passing a get_groups(...) group (a list[tuple]) straight to make_outcome works, matching the checkpointed Kuhn poker outcomes pattern. Co-Authored-By: Claude Sonnet 5 --- src/pygambit/game.pxi | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 0a7ee66b1..050859226 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1358,6 +1358,17 @@ class Game: if node.game != self: raise MismatchError(f"{funcname}(): {argname} must be part of the same game") return node + elif 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( @@ -1386,7 +1397,7 @@ class Game: nodes = self.get_nodes(nodes) resolved_nodes = [ self._resolve_node(n, funcname, argname) - for n in (nodes if hasattr(nodes, "__iter__") and not isinstance(nodes, str) + for n in (nodes if hasattr(nodes, "__iter__") and not isinstance(nodes, (str, tuple)) else [nodes]) ] if not resolved_nodes: From d1f7c7ce1148a4aafbb05c836107eec80e27be8c Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 18:59:42 +0100 Subject: [PATCH 07/13] Add .with_recall(player) and GroupedSelector elementwise chaining GroupedSelector gains .plays/.after(...) (per-group expand/filter, key untouched) and .with_recall(player), which -- from that point on -- makes every subsequent .plays also refine each group's key by folding in player's last action at that point (via the new shared _last_action helper). Scoped to .plays specifically for now, not every expand-style op. Game._group_nodes applies a GroupedSelector's post_ops in order, per-group, doing the recall refinement when with_recall set a player. Surfaced and fixed a real edge case along the way: filtering after a recall-refined .plays can produce empty groups (e.g. a card's "bet first" branch has no "...,Check,Bet" suffix) -- append_move's per-group dispatch now skips empty groups rather than erroring, settling an earlier open design question in favor of "dropped". Verified end-to-end: the full Kuhn poker betting tree, including Alice's second decision reusing a with_recall-tagged partition, reproduces is_perfect_recall == True and the correct 3-infosets-of-2 structure -- the same result the tuple-pivot phase needed a manual get_last_action+get_infoset dance to achieve, now automatic on reuse. Co-Authored-By: Claude Sonnet 5 --- src/pygambit/game.pxi | 29 ++++++++++++++++- src/pygambit/hsel.pxi | 75 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 050859226..f6c83f2fc 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -599,12 +599,37 @@ class Game: """Internal: like `get_groups`, but keeps `Node` objects rather than materializing each into a `History` -- used by mutation methods that need to resolve straight back to concrete nodes, avoiding a - Node -> History -> Node round trip.""" + Node -> History -> Node round trip. + + Applies `grouped`'s initial partition (`base`/`key`), then its + `post_ops` in order, each one per-group -- expanding/filtering each + group's own members independently, leaving the key untouched, except + that a `.plays` step refines the key by `recall_player`'s last action + at that point, if `with_recall` set one (see `GroupedSelector`'s + docstring for why). + """ result: dict = {} for node in self.get_nodes(grouped.base): view: HistoryView = HistoryView._wrap(node, _history_of(node)) key = grouped.key(view) result.setdefault(key, []).append(node) + for op in grouped.post_ops: + next_result: dict = {} + for key, nodes in result.items(): + if isinstance(op, _PlaysStep): + expanded = [play for node in nodes for play in node.plays] + if grouped.recall_player is None: + next_result[key] = expanded + else: + for play in expanded: + refined_key = (key, _last_action(play, grouped.recall_player)) + next_result.setdefault(refined_key, []).append(play) + continue + if isinstance(op, _AfterStep): + next_result[key] = [n for n in nodes if _matches_suffix(n, op.labels)] + continue + raise TypeError(f"_group_nodes(): unknown post-op {op!r}") + result = next_result return result def get_groups(self, grouped: GroupedSelector) -> dict: @@ -1567,6 +1592,8 @@ class Game: """ if isinstance(nodes, GroupedSelector): for group in self._group_nodes(nodes).values(): + if not group: + continue self.append_move(group, player, actions) return resolved_player = self._resolve_player(player, "append_move") diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi index c2692de31..d91cff266 100644 --- a/src/pygambit/hsel.pxi +++ b/src/pygambit/hsel.pxi @@ -135,20 +135,64 @@ class Selector: class GroupedSelector: - """Result of `.by(callable)`. Game-neutral until evaluated -- iterate - the evaluated result (`Game.get_groups`) as `(key, group)` pairs. No - further chaining yet (`.plays`/`.after(...)` applied per-group) -- - deferred until a concrete need for it shows up. + """Result of `.by(callable)`. Game-neutral until evaluated -- call + `Game.get_groups` and iterate its result as `(key, group)` pairs. + + `.plays`/`.after(...)` chain onto a `GroupedSelector` the same way they + chain onto a plain `Selector`, but apply per-group: each group's own + members are expanded/filtered independently, and the group's key is left + untouched -- expanding past a decision point doesn't retroactively change + what a group was keyed by. `.with_recall(player)` is the one exception: + once set, every subsequent `.plays` on this selector *also* refines each + group's key by folding in `player`'s last action at that point, so a + partition built for one decision stays a valid recall-respecting + partition when reused for a later one, without the caller needing to + re-derive or manually re-key it. Scoped to `.plays` specifically for now + (not every expand-style op) -- narrower than the full "any expand-style + step" idea from the design notes, not yet stress-tested against a shape + that would need more. .. versionadded:: 17.0.0 """ - def __init__(self, base: Selector, key: typing.Callable) -> None: + def __init__( + self, + base: Selector, + key: typing.Callable, + post_ops: tuple = (), + recall_player: str = None, + ) -> None: self.base = base self.key = key + self.post_ops = post_ops + self.recall_player = recall_player def __repr__(self) -> str: - return f"GroupedSelector(base={self.base!r}, key={self.key!r})" + return ( + f"GroupedSelector(base={self.base!r}, key={self.key!r}, " + f"post_ops={self.post_ops!r}, recall_player={self.recall_player!r})" + ) + + def _extend(self, op) -> GroupedSelector: + return GroupedSelector(self.base, self.key, self.post_ops + (op,), self.recall_player) + + @property + def plays(self) -> GroupedSelector: + """The current terminal frontier of each group, independently -- + see the class docstring for how this interacts with + `.with_recall(player)`.""" + return self._extend(_PlaysStep()) + + def after(self, *labels: str) -> GroupedSelector: + """Filter each group to just the members whose own trailing labels + are exactly `labels`, whatever came before them.""" + return self._extend(_AfterStep(labels)) + + def with_recall(self, player: str) -> GroupedSelector: + """From here on, every `.plays` on this selector also refines each + group's key by folding in `player`'s last action at that point -- + see the class docstring.""" + return GroupedSelector(self.base, self.key, self.post_ops, player) def _history_of(node: Node) -> tuple: @@ -195,12 +239,19 @@ 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.""" - current: Node = self._node - while current.parent is not None: - if current.parent.player == player: - return current.prior_action.label - current = current.parent - return None + return _last_action(self._node, player) + + +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 class H: From aaeada21eebc972d8a3210c841885447f661ceb6 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 19:19:19 +0100 Subject: [PATCH 08/13] Add H selector algebra tutorial notebook, six worked examples doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb -- a design prototype, not a released feature, demonstrating the H selector algebra sketched on this branch: Selten's Horse, Kuhn poker (construction + outcomes), bayes2a (a regular two-stage Bayesian game needing neither with_recall nor append_infoset), a new minimal example of imperfect recall via forgetting a past observation (distinct in shape from absent-mindedness and untimeability), the Jakobsen et al. (2016) untimeable game, and an extended Absent-Minded Driver showing append_infoset composes normally with further construction. Every cell actually executed (via jupyter nbconvert --execute) against the real built module, not hand-traced -- outputs are real, including cross-checks against the original .efg/catalog files for Selten's Horse, bayes2a, and the AM-driver-subgame fixture (infoset partitions and outcome maps verified to match exactly before being folded into the notebook's own diagnostics). Also: pip install -e . to make src/pygambit importable directly by the notebook's Jupyter kernel, which doesn't inherit PYTHONPATH the way a shell subprocess does. Co-Authored-By: Claude Sonnet 5 --- .../h_selector_prototype.ipynb | 508 ++++++++++++++++++ 1 file changed, 508 insertions(+) create mode 100644 doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb diff --git a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb new file mode 100644 index 000000000..1e58e5cc7 --- /dev/null +++ b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb @@ -0,0 +1,508 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "6129f93a", + "metadata": {}, + "source": [ + "# The `H` node-selector algebra: six worked examples\n", + "\n", + "This notebook is a design prototype, not a released feature. `pygambit.gambit.H` is an\n", + "internal, unreleased module -- everything here demonstrates a work-in-progress replacement\n", + "for constructing extensive-form games without ever handling raw `Node` objects.\n", + "\n", + "The core idea: a *selector*, built from `H`, describes a set of histories symbolically --\n", + "it carries no reference to any particular game until you hand it to one. `H.path(*steps)`\n", + "walks a sequence of exact labels and/or `...` wildcards from the root (or from wherever a\n", + "selection currently is, when chained); `H.after(*labels)` matches anywhere by a trailing\n", + "label pattern; `.plays` expands to whatever is currently terminal; `.by(callable)`\n", + "partitions a selection by a key function, and `.filter(callable)` keeps only matching\n", + "elements. `Game.append_move`/`append_event`/`append_infoset`/`make_outcome` all accept these\n", + "selectors directly, in place of `Node`/`NodeReferenceSet`.\n", + "\n", + "Six examples below, each chosen to exercise a different corner of the design: a classic\n", + "imperfect-information game needing `append_infoset`, a game with betting and outcome\n", + "computation, a regular two-stage Bayesian game, three different shapes of imperfect\n", + "*recall*, and a variation showing `append_infoset` composes normally with further\n", + "construction." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "f2cd201c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:15:32.510195Z", + "iopub.status.busy": "2026-09-02T18:15:32.510042Z", + "iopub.status.idle": "2026-09-02T18:15:33.390313Z", + "shell.execute_reply": "2026-09-02T18:15:33.390051Z" + } + }, + "outputs": [], + "source": [ + "try:\n", + " from gtdraw import draw\n", + "except ImportError:\n", + " def draw(*args, **kwargs):\n", + " print(\"gtdraw is not installed; game trees won't be drawn, but everything else runs.\")\n", + "\n", + "from pygambit.gambit import H\n", + "\n", + "import pygambit as gbt" + ] + }, + { + "cell_type": "markdown", + "id": "8eac066e", + "metadata": {}, + "source": [ + "## 1. Selten's Horse\n", + "\n", + "A classic three-player game (Selten, 1975) used to illustrate subtleties of sequential\n", + "equilibrium. Player 1 moves first; if he plays \"R\", Player 2 moves; if Player 2 also plays\n", + "\"L\", or if Player 1 played \"L\" directly, Player 3 faces the same decision either way --\n", + "**Player 3 cannot tell which path led there**.\n", + "\n", + "This needs `append_infoset`, not because of anything exotic about recall or timing (the\n", + "game is perfectly ordinary on both counts), but for a mundane construction-ordering reason:\n", + "Player 3's two infoset members aren't simultaneously available. The node reached via a bare\n", + "\"L\" exists as soon as Player 1 moves; the node reached via \"R\", \"L\" only exists once Player 2\n", + "has *also* moved -- so one `append_move` call can never cover both." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "a1c2e549", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:15:33.391775Z", + "iopub.status.busy": "2026-09-02T18:15:33.391661Z", + "iopub.status.idle": "2026-09-02T18:15:33.871243Z", + "shell.execute_reply": "2026-09-02T18:15:33.870994Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: True\n", + "Player 3's infoset members (as Histories, not raw Node paths -- the latter display node-to-root, easy to misread): [('L',), ('R', 'L')]\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\", \"Player 3\"], title=\"Selten's Horse\")\n", + "\n", + "g.append_move(H.path(), \"Player 1\", [\"R\", \"L\"])\n", + "g.append_move(H.path(\"L\"), \"Player 3\", [\"R\", \"L\"])\n", + "g.append_move(H.path(\"R\"), \"Player 2\", [\"R\", \"L\"])\n", + "g.append_infoset(H.path(\"R\", \"L\"), H.path(\"L\"))\n", + "\n", + "g.make_outcome(H.path(\"R\", \"R\"), {\"Player 1\": 1, \"Player 2\": 1, \"Player 3\": 1}, \"RR\")\n", + "g.make_outcome(H.path(\"R\", \"L\", \"R\"), {\"Player 1\": 4, \"Player 2\": 4, \"Player 3\": 0}, \"RLR\")\n", + "g.make_outcome(H.path(\"R\", \"L\", \"L\"), {\"Player 1\": 0, \"Player 2\": 0, \"Player 3\": 1}, \"RLL\")\n", + "g.make_outcome(H.path(\"L\", \"R\"), {\"Player 1\": 3, \"Player 2\": 2, \"Player 3\": 2}, \"LR\")\n", + "g.make_outcome(H.path(\"L\", \"L\"), {\"Player 1\": 0, \"Player 2\": 0, \"Player 3\": 0}, \"LL\")\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "print(\n", + " \"Player 3's infoset members (as Histories, not raw Node paths -- the latter\"\n", + " \" display node-to-root, easy to misread):\",\n", + " sorted(g.get_histories(H.path(\"L\")) + g.get_histories(H.path(\"R\", \"L\"))),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "562522fe", + "metadata": {}, + "source": [ + "## 2. Kuhn poker\n", + "\n", + "Three-card poker, the standard small illustration of imperfect information *and* betting.\n", + "This example exercises the recall-tracking machinery in earnest: Alice's second decision\n", + "(call/fold after checking then facing a bet) must still distinguish her own card, even\n", + "though the tree has grown well past where that distinction was first established.\n", + "\n", + "`alice_partition` is built once, tagged `.with_recall(\"Alice\")`, and reused for both of her\n", + "decisions -- the tag makes `.plays` automatically fold her own last action into the group\n", + "key from her second decision onward, with no separate re-derivation step. `bob_partition`\n", + "never needs the tag: his two uses are his *one* decision instantiated on two mutually\n", + "exclusive branches, not a first-then-second sequence for him.\n", + "\n", + "Outcome computation is a genuinely different kind of selector from the recall-tracking\n", + "above: `winner`/`pot_size` are direct, declarative facts about a completed hand (who took\n", + "the pot, how much), not a player's own partial view of the game -- an outcome deliberately\n", + "throws away *how* a given payoff was reached, which is the opposite spirit from recall\n", + "grouping's insistence on never conflating what a player can actually tell apart." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cccb5906", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:15:33.872512Z", + "iopub.status.busy": "2026-09-02T18:15:33.872348Z", + "iopub.status.idle": "2026-09-02T18:15:34.497146Z", + "shell.execute_reply": "2026-09-02T18:15:34.496574Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: True\n" + ] + } + ], + "source": [ + "CARD_VALUE = {\"J\": 0, \"Q\": 1, \"K\": 2}\n", + "cards = list(CARD_VALUE)\n", + "\n", + "g = gbt.Game.new_tree(players=[\"Alice\", \"Bob\"], title=\"Kuhn poker\")\n", + "g.append_event(H.path(), cards, [gbt.Rational(1, 3)] * 3)\n", + "for c in cards:\n", + " remaining = [x for x in cards if x != c]\n", + " g.append_event(H.path(c), remaining, [gbt.Rational(1, 2)] * 2)\n", + "\n", + "alice_partition = H.path(...).by(lambda h: h[0]).with_recall(\"Alice\")\n", + "g.append_move(alice_partition.plays, \"Alice\", [\"Check\", \"Bet\"])\n", + "\n", + "bob_partition = H.path(..., ...).by(lambda h: h[1])\n", + "g.append_move(bob_partition.plays.after(\"Check\"), \"Bob\", [\"Check\", \"Bet\"])\n", + "\n", + "g.append_move(alice_partition.plays.after(\"Check\", \"Bet\"), \"Alice\", [\"Fold\", \"Call\"])\n", + "g.append_move(bob_partition.plays.after(\"Bet\"), \"Bob\", [\"Fold\", \"Call\"])\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "d30e4132", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:15:34.499734Z", + "iopub.status.busy": "2026-09-02T18:15:34.499544Z", + "iopub.status.idle": "2026-09-02T18:15:34.505092Z", + "shell.execute_reply": "2026-09-02T18:15:34.504690Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Total outcomes created: 4\n" + ] + } + ], + "source": [ + "def winner(h):\n", + " match h[2:]:\n", + " case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n", + " return \"Alice\" if CARD_VALUE[h[0]] > CARD_VALUE[h[1]] else \"Bob\"\n", + " case (\"Check\", \"Bet\", \"Fold\"):\n", + " return \"Bob\"\n", + " case (\"Bet\", \"Fold\"):\n", + " return \"Alice\"\n", + "\n", + "def pot_size(h):\n", + " match h[2:]:\n", + " case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Fold\") | (\"Bet\", \"Fold\"):\n", + " return 1\n", + " case (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n", + " return 2\n", + "\n", + "for (win, amount), group in g.get_groups(H.plays.by(lambda h: (winner(h), pot_size(h)))).items():\n", + " lose = \"Bob\" if win == \"Alice\" else \"Alice\"\n", + " g.make_outcome(group, {win: amount, lose: -amount}, f\"{win} wins {amount}\")\n", + "\n", + "print(\"Total outcomes created:\", len(list(g.outcomes)))" + ] + }, + { + "cell_type": "markdown", + "id": "96bf2218", + "metadata": {}, + "source": [ + "## 3. `bayes2a`: a regular two-stage Bayesian game\n", + "\n", + "A fully \"timeable\" game with private types and two rounds of simultaneous moves --\n", + "`contrib/games/bayes2a.efg` in the repository. Both players privately learn a type, then\n", + "move simultaneously each round; each round's actions become public before the next round.\n", + "\n", + "Unlike Kuhn poker, this game needs neither `.with_recall` nor `append_infoset` -- every\n", + "player's decision falls at a fixed, predictable position in the history across every\n", + "branch, so plain positional indexing on the augmented history object is all the grouping\n", + "needs. This is deliberately included as a contrast case: `H`'s dedicated recall machinery\n", + "exists for games that need it, but a well-behaved regular game doesn't have to pay for it." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "f80e4d7d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:15:34.506784Z", + "iopub.status.busy": "2026-09-02T18:15:34.506623Z", + "iopub.status.idle": "2026-09-02T18:15:34.513266Z", + "shell.execute_reply": "2026-09-02T18:15:34.512836Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: True\n", + "terminal histories: 64\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"bayes2a\")\n", + "half = gbt.Rational(1, 2)\n", + "\n", + "g.append_event(H.path(), [\"1G\", \"1B\"], [half, half])\n", + "for t1 in [\"1G\", \"1B\"]:\n", + " g.append_event(H.path(t1), [\"2g\", \"2b\"], [half, half])\n", + "\n", + "# Round 1: each player's move depends only on their own type.\n", + "g.append_move(H.path(...).plays.by(lambda h: h[0]), \"Player 1\", [\"H\", \"L\"])\n", + "g.append_move(H.path(..., ...).plays.by(lambda h: h[1]), \"Player 2\", [\"h\", \"l\"])\n", + "\n", + "# Round 2: both round-1 actions are now public; each player also still knows their own type.\n", + "g.append_move(H.plays.by(lambda h: (h[0], h[2], h[3])), \"Player 1\", [\"H\", \"L\"])\n", + "g.append_move(H.plays.by(lambda h: (h[1], h[2], h[3])), \"Player 2\", [\"h\", \"l\"])\n", + "\n", + "PAYOFFS = {\n", + " (\"1G\", \"H\", \"h\"): (10, 2), (\"1G\", \"H\", \"l\"): (0, 10),\n", + " (\"1G\", \"L\", \"h\"): (2, 4), (\"1G\", \"L\", \"l\"): (4, 0),\n", + " (\"1B\", \"H\", \"h\"): (4, 2), (\"1B\", \"H\", \"l\"): (2, 10),\n", + " (\"1B\", \"L\", \"h\"): (0, 4), (\"1B\", \"L\", \"l\"): (10, 0),\n", + "}\n", + "for (p1, p2), group in g.get_groups(H.plays.by(lambda h: PAYOFFS[(h[0], h[4], h[5])])).items():\n", + " g.make_outcome(group, {\"Player 1\": p1, \"Player 2\": p2}, f\"({p1},{p2})\")\n", + "\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "print(\"terminal histories:\", len(g.get_histories(H.plays)))" + ] + }, + { + "cell_type": "markdown", + "id": "47b03c36", + "metadata": {}, + "source": [ + "## 4. Imperfect recall: forgetting a past observation\n", + "\n", + "A third, distinct shape of imperfect recall, alongside absent-mindedness and\n", + "untimeability below. Alice privately observes a signal (H or L) and acts on it -- her\n", + "first decision is correctly split into two infosets, one per signal. Bob then moves,\n", + "seeing nothing private. Alice's *second* decision is deliberately built to merge across\n", + "both signal values, keyed only by her own first action and Bob's -- she is modeled as\n", + "having forgotten the signal that legitimately informed her own first move.\n", + "\n", + "This is neither absent-mindedness (no single node is ever revisited -- these are two\n", + "separate first-decision infosets being merged, not one node crossed twice) nor\n", + "untimeability (every one of Alice's second-decision nodes sits at exactly the same depth --\n", + "the issue is purely about what she remembers, not about timing)." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "2d538188", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:15:34.515054Z", + "iopub.status.busy": "2026-09-02T18:15:34.514862Z", + "iopub.status.idle": "2026-09-02T18:15:35.012227Z", + "shell.execute_reply": "2026-09-02T18:15:35.011959Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: False\n", + " Alice's 2nd decision, key=('Down', 'x'): [('H', 'Down', 'x'), ('L', 'Down', 'x')]\n", + " Alice's 2nd decision, key=('Down', 'y'): [('H', 'Down', 'y'), ('L', 'Down', 'y')]\n", + " Alice's 2nd decision, key=('Up', 'x'): [('H', 'Up', 'x'), ('L', 'Up', 'x')]\n", + " Alice's 2nd decision, key=('Up', 'y'): [('H', 'Up', 'y'), ('L', 'Up', 'y')]\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Alice\", \"Bob\"], title=\"Forgetting a past observation\")\n", + "half = gbt.Rational(1, 2)\n", + "\n", + "g.append_event(H.path(), [\"H\", \"L\"], [half, half])\n", + "g.append_move(H.path(\"H\"), \"Alice\", [\"Up\", \"Down\"])\n", + "g.append_move(H.path(\"L\"), \"Alice\", [\"Up\", \"Down\"])\n", + "g.append_move(H.path(..., ...), \"Bob\", [\"x\", \"y\"])\n", + "\n", + "# Keyed by (Alice's own first action, Bob's action) only -- h[0], the signal, is dropped.\n", + "g.append_move(H.plays.by(lambda h: (h[1], h[2])), \"Alice\", [\"Fold\", \"Call\"])\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "# Depth 3, explicitly -- these are the same histories the construction above\n", + "# grouped by (h[1], h[2]) to create Alice's second decision.\n", + "groups = g.get_groups(H.path(..., ..., ...).by(lambda h: (h[1], h[2])))\n", + "for key, members in sorted(groups.items()):\n", + " print(f\" Alice's 2nd decision, key={key}: {sorted(members)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "ff959c38", + "metadata": {}, + "source": [ + "## 5. An untimeable game\n", + "\n", + "Jakobsen, Sørensen & Conitzer (2016), Figure 1(a): a coin toss decides who moves first;\n", + "each player then guesses whether they went first or second, unable to tell which, since\n", + "neither observes the other's move or the coin. Each player's infoset spans both a\n", + "depth-1 node (moving first) and depth-2 nodes (moving second) -- and, unlike Selten's\n", + "Horse above, **no valid timing assignment exists at all**, even allowing a dense\n", + "(non-integer) time scale: each player's second decision would need to come strictly after\n", + "the *other's* first decision, on different branches -- a circular constraint no monotonic\n", + "timing can resolve. Perfect recall holds throughout regardless -- neither player forgets\n", + "anything, each has only one decision to have forgotten at." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "0b0c25ba", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:15:35.013542Z", + "iopub.status.busy": "2026-09-02T18:15:35.013454Z", + "iopub.status.idle": "2026-09-02T18:15:35.474573Z", + "shell.execute_reply": "2026-09-02T18:15:35.474194Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: True\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"Untimeable (Jakobsen et al. 2016)\")\n", + "\n", + "g.append_event(H.path(), [\"1\", \"2\"], [gbt.Rational(1, 2)] * 2)\n", + "\n", + "g.append_move(H.path(\"1\"), \"Player 2\", [\"1\", \"2\"])\n", + "g.append_move(H.path(\"2\"), \"Player 1\", [\"1\", \"2\"])\n", + "\n", + "g.append_infoset(H.path(\"1\", ...), H.path(\"2\"))\n", + "g.append_infoset(H.path(\"2\", ...), H.path(\"1\"))\n", + "\n", + "def outcome_key(h):\n", + " p1_guess = h.last_action(\"Player 1\")\n", + " p2_guess = h.last_action(\"Player 2\")\n", + " return (p1_guess == h[0], p2_guess != h[0])\n", + "\n", + "for (p1_ok, p2_ok), group in g.get_groups(H.plays.by(outcome_key)).items():\n", + " g.make_outcome(\n", + " group, {\"Player 1\": int(p1_ok), \"Player 2\": int(p2_ok)},\n", + " f\"P1 {'correct' if p1_ok else 'wrong'}, P2 {'correct' if p2_ok else 'wrong'}\",\n", + " )\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)" + ] + }, + { + "cell_type": "markdown", + "id": "721b5f7c", + "metadata": {}, + "source": [ + "## 6. Absent-Minded Driver, with a further decision appended\n", + "\n", + "The classic Piccione–Rubinstein Absent-Minded Driver: one real binary decision (\"S\"/\"T\"),\n", + "faced *twice* without knowing which time it is, since the driver's own \"S\"-child shares\n", + "her first infoset. This variation goes one step further than the minimal version: after\n", + "her second \"S\", a *second* player gets a genuine, ordinary decision -- showing that\n", + "`append_infoset` composes normally with whatever construction comes after it; nothing\n", + "about the rest of the tree needs special treatment once the absent-minded infoset is set\n", + "up." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "bc56a47a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:15:35.475884Z", + "iopub.status.busy": "2026-09-02T18:15:35.475800Z", + "iopub.status.idle": "2026-09-02T18:15:35.867008Z", + "shell.execute_reply": "2026-09-02T18:15:35.866692Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: False\n", + "Player 1's (first) infoset members: [(), ('S',)]\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"Absent-Minded Driver, extended\")\n", + "\n", + "g.append_move(H.path(), \"Player 1\", [\"S\", \"T\"])\n", + "g.append_infoset(H.path(\"S\"), H.path())\n", + "g.append_move(H.path(\"S\", \"T\"), \"Player 2\", [\"r\", \"l\"])\n", + "\n", + "g.make_outcome(H.path(\"S\", \"S\"), {\"Player 1\": 1, \"Player 2\": -1}, \"SS\")\n", + "g.make_outcome(H.path(\"S\", \"T\", \"r\"), {\"Player 1\": 2, \"Player 2\": -2}, \"STr\")\n", + "g.make_outcome(H.path(\"S\", \"T\", \"l\"), {\"Player 1\": 3, \"Player 2\": -3}, \"STl\")\n", + "g.make_outcome(H.path(\"T\"), {\"Player 1\": 4, \"Player 2\": -4}, \"T\")\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "print(\n", + " \"Player 1's (first) infoset members:\",\n", + " sorted(g.get_histories(H.path()) + g.get_histories(H.path(\"S\"))),\n", + ")" + ] + } + ], + "metadata": { + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 15046d69f601df3d14237654079d1d94347014f1 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 19:47:13 +0100 Subject: [PATCH 09/13] Fix CI failure: notebook was missing kernelspec metadata test_execute_notebook builds nbclient.NotebookClient with kernel_name=nb.metadata["kernelspec"]["name"] -- our notebook never had that key set (built via raw nbformat.v4.new_notebook(), which doesn't populate it the way Jupyter's own UI or an already-kernelspec'd source notebook would), so kernel_name came through as None, which traitlets rejects outright before any cell runs. Verified against the actual CI entry point, not just nbconvert --execute succeeding: ran pytest tests/test_tutorials.py -k h_selector_prototype -m tutorials directly, matching CI's own NotebookClient construction. Passes, and the rest of the tutorials suite (all 10 notebooks) still passes too. Co-Authored-By: Claude Sonnet 5 --- .../h_selector_prototype.ipynb | 99 ++++++++++--------- 1 file changed, 52 insertions(+), 47 deletions(-) diff --git a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb index 1e58e5cc7..fa02233b6 100644 --- a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb +++ b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "6129f93a", + "id": "014efe26", "metadata": {}, "source": [ "# The `H` node-selector algebra: six worked examples\n", @@ -30,13 +30,13 @@ { "cell_type": "code", "execution_count": 1, - "id": "f2cd201c", + "id": "2e80dd03", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T18:15:32.510195Z", - "iopub.status.busy": "2026-09-02T18:15:32.510042Z", - "iopub.status.idle": "2026-09-02T18:15:33.390313Z", - "shell.execute_reply": "2026-09-02T18:15:33.390051Z" + "iopub.execute_input": "2026-09-02T18:45:36.060080Z", + "iopub.status.busy": "2026-09-02T18:45:36.059915Z", + "iopub.status.idle": "2026-09-02T18:45:36.970529Z", + "shell.execute_reply": "2026-09-02T18:45:36.970258Z" } }, "outputs": [], @@ -54,7 +54,7 @@ }, { "cell_type": "markdown", - "id": "8eac066e", + "id": "ddd44d44", "metadata": {}, "source": [ "## 1. Selten's Horse\n", @@ -74,13 +74,13 @@ { "cell_type": "code", "execution_count": 2, - "id": "a1c2e549", + "id": "25387762", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T18:15:33.391775Z", - "iopub.status.busy": "2026-09-02T18:15:33.391661Z", - "iopub.status.idle": "2026-09-02T18:15:33.871243Z", - "shell.execute_reply": "2026-09-02T18:15:33.870994Z" + "iopub.execute_input": "2026-09-02T18:45:36.971889Z", + "iopub.status.busy": "2026-09-02T18:45:36.971789Z", + "iopub.status.idle": "2026-09-02T18:45:37.473789Z", + "shell.execute_reply": "2026-09-02T18:45:37.473536Z" } }, "outputs": [ @@ -118,7 +118,7 @@ }, { "cell_type": "markdown", - "id": "562522fe", + "id": "2ca9c9c1", "metadata": {}, "source": [ "## 2. Kuhn poker\n", @@ -144,13 +144,13 @@ { "cell_type": "code", "execution_count": 3, - "id": "cccb5906", + "id": "923feb0b", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T18:15:33.872512Z", - "iopub.status.busy": "2026-09-02T18:15:33.872348Z", - "iopub.status.idle": "2026-09-02T18:15:34.497146Z", - "shell.execute_reply": "2026-09-02T18:15:34.496574Z" + "iopub.execute_input": "2026-09-02T18:45:37.474927Z", + "iopub.status.busy": "2026-09-02T18:45:37.474792Z", + "iopub.status.idle": "2026-09-02T18:45:38.086934Z", + "shell.execute_reply": "2026-09-02T18:45:38.086660Z" } }, "outputs": [ @@ -188,13 +188,13 @@ { "cell_type": "code", "execution_count": 4, - "id": "d30e4132", + "id": "b11e3f16", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T18:15:34.499734Z", - "iopub.status.busy": "2026-09-02T18:15:34.499544Z", - "iopub.status.idle": "2026-09-02T18:15:34.505092Z", - "shell.execute_reply": "2026-09-02T18:15:34.504690Z" + "iopub.execute_input": "2026-09-02T18:45:38.088103Z", + "iopub.status.busy": "2026-09-02T18:45:38.087990Z", + "iopub.status.idle": "2026-09-02T18:45:38.091896Z", + "shell.execute_reply": "2026-09-02T18:45:38.091662Z" } }, "outputs": [ @@ -232,7 +232,7 @@ }, { "cell_type": "markdown", - "id": "96bf2218", + "id": "51ade76e", "metadata": {}, "source": [ "## 3. `bayes2a`: a regular two-stage Bayesian game\n", @@ -251,13 +251,13 @@ { "cell_type": "code", "execution_count": 5, - "id": "f80e4d7d", + "id": "03767b4c", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T18:15:34.506784Z", - "iopub.status.busy": "2026-09-02T18:15:34.506623Z", - "iopub.status.idle": "2026-09-02T18:15:34.513266Z", - "shell.execute_reply": "2026-09-02T18:15:34.512836Z" + "iopub.execute_input": "2026-09-02T18:45:38.092996Z", + "iopub.status.busy": "2026-09-02T18:45:38.092929Z", + "iopub.status.idle": "2026-09-02T18:45:38.096810Z", + "shell.execute_reply": "2026-09-02T18:45:38.096572Z" } }, "outputs": [ @@ -301,7 +301,7 @@ }, { "cell_type": "markdown", - "id": "47b03c36", + "id": "c1af37be", "metadata": {}, "source": [ "## 4. Imperfect recall: forgetting a past observation\n", @@ -322,13 +322,13 @@ { "cell_type": "code", "execution_count": 6, - "id": "2d538188", + "id": "c1fc0b54", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T18:15:34.515054Z", - "iopub.status.busy": "2026-09-02T18:15:34.514862Z", - "iopub.status.idle": "2026-09-02T18:15:35.012227Z", - "shell.execute_reply": "2026-09-02T18:15:35.011959Z" + "iopub.execute_input": "2026-09-02T18:45:38.097844Z", + "iopub.status.busy": "2026-09-02T18:45:38.097770Z", + "iopub.status.idle": "2026-09-02T18:45:38.594007Z", + "shell.execute_reply": "2026-09-02T18:45:38.593205Z" } }, "outputs": [ @@ -367,7 +367,7 @@ }, { "cell_type": "markdown", - "id": "ff959c38", + "id": "4d009aae", "metadata": {}, "source": [ "## 5. An untimeable game\n", @@ -386,13 +386,13 @@ { "cell_type": "code", "execution_count": 7, - "id": "0b0c25ba", + "id": "d8ac1b78", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T18:15:35.013542Z", - "iopub.status.busy": "2026-09-02T18:15:35.013454Z", - "iopub.status.idle": "2026-09-02T18:15:35.474573Z", - "shell.execute_reply": "2026-09-02T18:15:35.474194Z" + "iopub.execute_input": "2026-09-02T18:45:38.595461Z", + "iopub.status.busy": "2026-09-02T18:45:38.595355Z", + "iopub.status.idle": "2026-09-02T18:45:39.026877Z", + "shell.execute_reply": "2026-09-02T18:45:39.026590Z" } }, "outputs": [ @@ -432,7 +432,7 @@ }, { "cell_type": "markdown", - "id": "721b5f7c", + "id": "02f8b9bb", "metadata": {}, "source": [ "## 6. Absent-Minded Driver, with a further decision appended\n", @@ -449,13 +449,13 @@ { "cell_type": "code", "execution_count": 8, - "id": "bc56a47a", + "id": "26156c14", "metadata": { "execution": { - "iopub.execute_input": "2026-09-02T18:15:35.475884Z", - "iopub.status.busy": "2026-09-02T18:15:35.475800Z", - "iopub.status.idle": "2026-09-02T18:15:35.867008Z", - "shell.execute_reply": "2026-09-02T18:15:35.866692Z" + "iopub.execute_input": "2026-09-02T18:45:39.028098Z", + "iopub.status.busy": "2026-09-02T18:45:39.028013Z", + "iopub.status.idle": "2026-09-02T18:45:39.434611Z", + "shell.execute_reply": "2026-09-02T18:45:39.434339Z" } }, "outputs": [ @@ -490,6 +490,11 @@ } ], "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, "language_info": { "codemirror_mode": { "name": "ipython", From a0358695f72d56779d86e8eca6835bea37936a29 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 12:02:43 +0100 Subject: [PATCH 10/13] Document and test Selector/History support in make_outcome/make_outcome_null _resolve_outcome_location already delegated to _resolve_nodes for tree games, which handles Node, History (tuple), Selector, and iterables of these -- so make_outcome/make_outcome_null already worked with H-built selectors and materialized histories, just undocumented and untested. Updates the docstrings and adds coverage for both call sites. Co-Authored-By: Claude Sonnet 5 --- src/pygambit/game.pxi | 28 +++++++++++-------- tests/test_outcomes.py | 62 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index f6c83f2fc..cc72b2294 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -2383,8 +2383,10 @@ class Game: def _resolve_outcome_location(self, location, funcname: str) -> tuple: """Resolve `location` for `make_outcome`/`make_outcome_null`: for a tree game, - into a list of `Node`; for a strategic game, into a list of pure-strategy - contingencies (each a mapping from player label to strategy label). + into a list of `Node` (via `_resolve_nodes`, so `location` may be a `Node`, + `History`, `Selector`, or an iterable of these); for a strategic game, into a + list of pure-strategy contingencies (each a mapping from player label to + strategy label). Returns (is_tree, resolved). @@ -2421,10 +2423,11 @@ class Game: label: str) -> Outcome: """Create an outcome with `payoffs` and `label` and attach it at `location`. - For an extensive game, `location` is a ``Node`` or an iterable of nodes. For a - strategic game, `location` is a pure-strategy contingency — a complete mapping - from the game's players' labels to strategy labels — or an iterable of such - contingencies. + For an extensive game, `location` is a ``Node``, a ``History``, a ``Selector`` + (an `H`-built expression, evaluated against this game), or an iterable of + these. For a strategic game, `location` is a pure-strategy contingency — a + complete mapping from the game's players' labels to strategy labels — or an + iterable of such contingencies. Any outcome all of whose references are among `location` is absorbed by the operation: it is removed from the game, and `label` may reuse its label. @@ -2433,7 +2436,7 @@ class Game: Parameters ---------- - location : Node, contingency, or iterable of these + location : Node, History, Selector, contingency, or iterable of these Where to attach the new outcome. Nonempty; each node or contingency may be referenced only once. payoffs : Mapping @@ -2505,10 +2508,11 @@ class Game: def make_outcome_null(self, location) -> None: """Reset the outcome at `location` to the null outcome. - For an extensive game, `location` is a ``Node`` or an iterable of nodes. For a - strategic game, `location` is a pure-strategy contingency — a complete mapping - from the game's players' labels to strategy labels — or an iterable of such - contingencies. + For an extensive game, `location` is a ``Node``, a ``History``, a ``Selector`` + (an `H`-built expression, evaluated against this game), or an iterable of + these. For a strategic game, `location` is a pure-strategy contingency — a + complete mapping from the game's players' labels to strategy labels — or an + iterable of such contingencies. Any outcome all of whose references are among `location` is removed from the game. @@ -2516,7 +2520,7 @@ class Game: Parameters ---------- - location : Node, contingency, or iterable of these + location : Node, History, Selector, contingency, or iterable of these The nodes or contingencies to reset to the null outcome. Nonempty; each node or contingency may be referenced only once. diff --git a/tests/test_outcomes.py b/tests/test_outcomes.py index b34dc1ac9..bae455da1 100644 --- a/tests/test_outcomes.py +++ b/tests/test_outcomes.py @@ -17,6 +17,46 @@ def test_make_outcome_attaches_to_all_given_nodes(): assert outcome["Bob"] == -1 +def test_make_outcome_accepts_selector(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(game.root, "Alice", ["U", "M", "D"]) + up, middle, down = game.root.children + outcome = game.make_outcome(gbt.H.path("U"), {"Alice": 1, "Bob": -1}, "shared") + assert up.outcome == outcome + assert not middle.outcome + assert not down.outcome + + +def test_make_outcome_accepts_selector_matching_several_nodes(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(game.root, "Alice", ["U", "M", "D"]) + up, middle, down = game.root.children + outcome = game.make_outcome(gbt.H.plays, {"Alice": 1, "Bob": -1}, "shared") + assert up.outcome == outcome + assert middle.outcome == outcome + assert down.outcome == outcome + + +def test_make_outcome_accepts_history_tuple(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(game.root, "Alice", ["U", "M", "D"]) + up, middle, down = game.root.children + outcome = game.make_outcome(("U",), {"Alice": 1, "Bob": -1}, "shared") + assert up.outcome == outcome + assert not middle.outcome + assert not down.outcome + + +def test_make_outcome_accepts_iterable_of_history_tuples(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(game.root, "Alice", ["U", "M", "D"]) + up, middle, down = game.root.children + outcome = game.make_outcome([("U",), ("M",)], {"Alice": 1, "Bob": -1}, "shared") + assert up.outcome == outcome + assert middle.outcome == outcome + assert not down.outcome + + def test_make_outcome_attaches_at_contingencies(): game = gbt.Game.new_table([2, 2]) outcome = game.make_outcome( @@ -99,6 +139,28 @@ def test_make_outcome_null_resets_given_nodes_to_null(): assert not down.outcome +def test_make_outcome_null_accepts_selector(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(game.root, "Alice", ["U", "M", "D"]) + up, middle, down = game.root.children + game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") + game.make_outcome_null(gbt.H.path("U")) + assert not up.outcome + assert middle.outcome + assert not down.outcome + + +def test_make_outcome_null_accepts_history_tuple(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(game.root, "Alice", ["U", "M", "D"]) + up, middle, down = game.root.children + game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") + game.make_outcome_null(("U",)) + assert not up.outcome + assert middle.outcome + assert not down.outcome + + def test_make_outcome_null_resets_given_contingencies_to_null(): game = gbt.Game.new_table([2, 2]) game.make_outcome( From 11e686e372efa82cb0723d07c6b3cd94e970819b Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 12:20:34 +0100 Subject: [PATCH 11/13] Add HistoryView.members, replacing the need for Infoset.members/Event.members Mirrors Node.members but returns each member's History (a plain tuple) rather than a Node, so a .by(...)/.filter(...) callable can reason about infoset/event membership -- e.g. build a grouping key from it -- without ever touching Node, Infoset, or Event. Co-Authored-By: Claude Sonnet 5 --- src/pygambit/hsel.pxi | 13 +++++++++ tests/test_hsel.py | 65 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 tests/test_hsel.py diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi index d91cff266..f3f30f552 100644 --- a/src/pygambit/hsel.pxi +++ b/src/pygambit/hsel.pxi @@ -241,6 +241,19 @@ class HistoryView: history, wherever it fell -- `None` if `player` hasn't acted yet.""" return _last_action(self._node, player) + @property + def members(self) -> list[tuple]: + """The Histories of the nodes which are members of the information set or + event to which this history currently belongs -- whichever applies. + + Raises + ------ + AttributeError + 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`, diff --git a/tests/test_hsel.py b/tests/test_hsel.py new file mode 100644 index 000000000..f7a732e4d --- /dev/null +++ b/tests/test_hsel.py @@ -0,0 +1,65 @@ +import pytest + +import pygambit as gbt + + +def test_history_view_members_on_shared_infoset(): + game = gbt.Game.new_tree(players=["A", "B"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + game.append_move(gbt.H.plays, "B", ["x", "y"]) + + captured = {} + + def key(h): + captured[h[:]] = h.members + return frozenset(h.members) + + groups = game.get_groups(gbt.H.path(...).by(key)) + assert captured == { + ("U",): [("U",), ("D",)], + ("D",): [("U",), ("D",)], + } + assert list(groups.values()) == [[("U",), ("D",)]] + + +def test_history_view_members_singleton_infoset(): + game = gbt.Game.new_tree(players=["A", "B"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + game.append_move(gbt.H.path("U"), "B", ["x", "y"]) + game.append_move(gbt.H.path("D"), "B", ["x", "y"]) + + captured = {} + + def key(h): + captured[h[:]] = h.members + return None + + game.get_groups(gbt.H.path(...).by(key)) + assert captured == {("U",): [("U",)], ("D",): [("D",)]} + + +def test_history_view_members_on_event(): + game = gbt.Game.new_tree(players=["A"]) + game.append_event(gbt.H.path(), ["L", "R"], [0.5, 0.5]) + game.append_event(gbt.H.plays, ["p", "q"], [0.5, 0.5]) + + captured = {} + + def key(h): + captured[h[:]] = h.members + return None + + game.get_groups(gbt.H.path(...).by(key)) + assert captured == {("L",): [("L",), ("R",)], ("R",): [("L",), ("R",)]} + + +def test_history_view_members_raises_on_terminal(): + game = gbt.Game.new_tree(players=["A"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + + def key(h): + with pytest.raises(AttributeError): + _ = h.members + return None + + game.get_groups(gbt.H.plays.by(key)) From 07146c3e6a98299f8e3ef8e029ad3709d6fcd460 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 13:46:26 +0100 Subject: [PATCH 12/13] Retire Game.root/Game.nodes; profile indexing takes History, not Node Game.root and Game.nodes are removed from the public API -- H selectors and materialized History tuples are now the only entry points into a tree. Game.nodes's only real job was letting calling code build ad hoc selections by hand, which H does directly, so it has no replacement; Game.root is superseded by H.path()/(). Internally, get_nodes()'s H.after(...) whole-game seed and _resolve_node()'s label lookup now route through new private Game._root_node()/_all_nodes() helpers instead of the public properties. GameNodes (which only ever backed Game.nodes) is deleted outright. layout_tree()'s own Node-keyed dict return type is untouched, so its known external consumer (gtdraw) is not affected by this specific change -- though see below. MixedBehaviorProfile.__getitem__/__setitem__/set_mixed_action and MixedBehavior.__getitem__ now take a History (tuple of action labels) instead of a Node, like-for-like -- same resolution semantics, routed through Game._resolve_infoset (already History-capable). Node gains a new `.history` property as the bridge for code that still holds a Node (e.g. from Game.get_infosets/get_events) and needs to index a profile with it. BehaviorSupportProfile's indexing already accepted a History tuple-shaped resolution path once _resolve_infoset_arg was widened, so it picked this up the same way. Fixed three production call sites that broke under their own change: Game._fill_behavior_profile, Game.random_behavior_profile, and src/pygambit/cli/common.py's starting-profile reader all still indexed a MixedBehaviorProfile by Node; now use node.history. Added Game._num_nodes() (an O(1) count via the C++ layer) so catalog.py's n_nodes filter didn't regress into materializing every node just to count them. Updated every test file that constructed or indexed via game.root/ game.nodes (games.py gained root_node()/all_nodes()/history_of() test helpers backing this across the suite) or indexed a MixedBehaviorProfile by Node. Deleted test_strategic_game_root/test_strategic_game_nodes (tested the removed properties' error behavior on strategic games, no longer applicable) and test_nodes_iteration_order (tested Game.nodes's own DFS ordering guarantee, which no longer exists). Full suite green (1898 passed). Not yet done, flagged rather than silently skipped: several tutorial notebooks (02_extensive_form, 03_stripped_down_poker, h_selector_prototype, agent_versus_non_agent_regret, openspiel) call game.root/game.nodes directly and now fail to execute; 04_creating_images additionally fails via gtdraw (an external package), which calls game.root directly in its own layout code -- a real break for that consumer, not just a theoretical risk, and needs a decision on how to handle before touching notebooks. Co-Authored-By: Claude Sonnet 5 --- doc/pygambit.api.rst | 3 +- src/pygambit/behavmixed.pxi | 138 ++++++------ src/pygambit/behavspt.pxi | 63 +++--- src/pygambit/catalog.py | 2 +- src/pygambit/cli/common.py | 2 +- src/pygambit/game.pxi | 72 +++--- src/pygambit/gamecollections.pxi | 33 --- src/pygambit/node.pxi | 10 + src/pygambit/qre.py | 4 +- tests/cli/conftest.py | 6 +- tests/games.py | 98 +++++--- tests/test_actions.py | 107 ++++----- tests/test_behav.py | 178 +++++++-------- tests/test_behavspt_profiles.py | 10 +- tests/test_catalog.py | 4 +- tests/test_file.py | 8 +- tests/test_game.py | 20 +- tests/test_game_resolve.py | 2 +- tests/test_infosets.py | 114 +++++----- tests/test_nash.py | 2 +- tests/test_node.py | 375 +++++++++++++++---------------- tests/test_outcomes.py | 64 +++--- tests/test_players.py | 4 +- tests/test_qre.py | 4 +- tests/test_strategic.py | 12 - 25 files changed, 658 insertions(+), 677 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index e8ef2c8a6..dd218b67d 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -108,12 +108,10 @@ Information about the game Game.max_payoff Game.get_min_payoff Game.get_max_payoff - Game.root Game.get_infosets Game.get_events Game.get_strategies Game.get_sequences - Game.nodes Game.contingencies Game.get_outcome Game.get_payoffs @@ -150,6 +148,7 @@ Information about the game Node.is_successor_of Node.plays Node.own_prior_action + Node.history .. autosummary:: :toctree: api/ diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 94538d0bf..18a274789 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -297,27 +297,35 @@ class MixedBehavior: """ yield from self._values.items() - def __getitem__(self, index: Node) -> MixedAction: + def __getitem__(self, index: tuple) -> MixedAction: """Returns the mixed action at the information set containing `index`. Parameters ---------- - index : Node - A node belonging to the information set to return. + index : History + A History (a tuple of action labels) belonging to the information set + to return. Raises ------ + TypeError + If `index` is not a ``History``. + KeyError + If `index` does not match any node of the game. MismatchError - If `index` is a ``Node`` from a different game, or belongs to an - information set that isn't this player's. + If `index` belongs to an information set that isn't this player's. ValueError - If `index` is a terminal node, which belongs to no information set. + If `index` resolves to a terminal node, which belongs to no + information set. """ - infoset = cython.cast(Infoset, index.infoset) - if not infoset: - raise ValueError("node is terminal, has no information set") + if not isinstance(index, tuple): + raise TypeError(f"index must be History, not {index.__class__.__name__}") + if not self._values: + raise MismatchError("history must belong to this player") + game = next(iter(self._values)).game + infoset = game._resolve_infoset(index, "__getitem__") if infoset.player != self._player: - raise MismatchError("node must belong to this player") + raise MismatchError("history must belong to this player") return self._values[infoset] @@ -388,29 +396,30 @@ class MixedBehaviorProfile: Parameters ---------- - index : str or Node + index : str or History The part of the profile to return: * If `index` is a ``str``, returns a ``MixedBehavior`` over the player's information sets. The player is determined by finding the player with that label, if any. - * If `index` is a ``Node``, returns a ``MixedAction`` over the actions at - the node's information set. + * If `index` is a ``History`` (a tuple of action labels, as returned by + e.g. `Game.get_histories`), returns a ``MixedAction`` over the actions + at that history's information set. Raises ------ TypeError - If `index` is not a ``str`` or a ``Node``. - MismatchError - If `index` is a ``Node`` from a different game. - ValueError - If `index` is a terminal ``Node``, which belongs to no information set. + If `index` is not a ``str`` or a ``History``. KeyError - If `index` is a ``str`` and no player in the game has that label. + If `index` is a ``str`` and no player in the game has that label, or a + ``History`` that does not match any node of the game. + ValueError + If `index` is a ``History`` resolving to a terminal node, which belongs + to no information set. """ self._check_validity() - if isinstance(index, Node): - return self._mixed_action_at(self._resolve_infoset_for_node(index)) + if isinstance(index, tuple): + return self._mixed_action_at(self.game._resolve_infoset(index, "__getitem__")) if isinstance(index, str): values = { node.infoset: self._mixed_action_at(node.infoset) @@ -418,32 +427,9 @@ class MixedBehaviorProfile: } return MixedBehavior.wrap(index, values) raise TypeError( - f"profile index must be str or Node, not {index.__class__.__name__}" + f"profile index must be str or History, not {index.__class__.__name__}" ) - def _resolve_infoset_for_node(self, node: Node) -> Infoset: - """Resolves the personal player's information set containing node. - - Raises - ------ - MismatchError - If `node` belongs to a different game. - ValueError - 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(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 and event in the game.""" for player in self.game.players: @@ -617,7 +603,7 @@ class MixedBehaviorProfile: 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: + def __setitem__(self, index: tuple, distribution: collections.abc.Mapping) -> None: """Sets the mixed action at the information set containing `index`. `distribution` need not specify a weight for every one of the information @@ -626,8 +612,9 @@ class MixedBehaviorProfile: Parameters ---------- - index : Node - A node belonging to the information set to set. + index : History + A History (a tuple of action labels) belonging to the information set + to set. distribution : Mapping[str, Any] A non-negative weight for some or all of the information set's actions, keyed by action label; actions it omits are treated as having weight @@ -638,14 +625,14 @@ class MixedBehaviorProfile: Raises ------ TypeError - If `index` is not a ``Node``, or `distribution` is not a Mapping. - MismatchError - If `index` is a ``Node`` from a different game. + If `index` is not a ``History``, or `distribution` is not a Mapping. + KeyError + If `index` does not match any node of the game. ValueError - If `index` is a terminal ``Node``, which belongs to no information set; - if any key of `distribution` is not one of the information set's action - labels; if any weight cannot be interpreted as a number; if any weight - is negative; or if the weights are all zero. + If `index` resolves to a terminal node, which belongs to no information + set; if any key of `distribution` is not one of the information set's + action labels; if any weight cannot be interpreted as a number; if any + weight is negative; or if the weights are all zero. See Also -------- @@ -654,13 +641,13 @@ class MixedBehaviorProfile: silently defaulting omitted ones to zero. """ self._check_validity() - if not isinstance(index, Node): - raise TypeError(f"profile index must be Node, not {index.__class__.__name__}") - infoset = self._resolve_infoset_for_node(index) + if not isinstance(index, tuple): + raise TypeError(f"profile index must be History, not {index.__class__.__name__}") + infoset = self.game._resolve_infoset(index, "__setitem__") self._setprob_infoset(infoset, distribution, sparse=True) def set_mixed_action( - self, index: Node, distribution: collections.abc.Mapping, sparse: bool = False + self, index: tuple, distribution: collections.abc.Mapping, sparse: bool = False ) -> None: """Sets the mixed action at the information set containing `index`. @@ -674,8 +661,9 @@ class MixedBehaviorProfile: Parameters ---------- - index : Node - A node belonging to the information set to set. + index : History + A History (a tuple of action labels) belonging to the information set + to set. distribution : Mapping[str, Any] A non-negative weight for the information set's actions, keyed by action label. A weight may be any value Gambit can interpret as a @@ -690,24 +678,24 @@ class MixedBehaviorProfile: Raises ------ TypeError - If `index` is not a ``Node``, or `distribution` is not a Mapping. - MismatchError - If `index` is a ``Node`` from a different game. + If `index` is not a ``History``, or `distribution` is not a Mapping. + KeyError + If `index` does not match any node of the game. ValueError - If `index` is a terminal ``Node``, which belongs to no information set; - if any key of `distribution` is not one of the information set's action - labels; if `sparse` is False and `distribution` omits an action; if any - weight cannot be interpreted as a number; if any weight is negative; or - if the weights are all zero. + If `index` resolves to a terminal node, which belongs to no information + set; if any key of `distribution` is not one of the information set's + action labels; if `sparse` is False and `distribution` omits an action; + if any weight cannot be interpreted as a number; if any weight is + negative; or if the weights are all zero. See Also -------- __setitem__ """ self._check_validity() - if not isinstance(index, Node): - raise TypeError(f"profile index must be Node, not {index.__class__.__name__}") - infoset = self._resolve_infoset_for_node(index) + if not isinstance(index, tuple): + raise TypeError(f"profile index must be History, not {index.__class__.__name__}") + infoset = self.game._resolve_infoset(index, "set_mixed_action") self._setprob_infoset(infoset, distribution, sparse=sparse) def is_defined_at(self, infoset: NodeReference) -> bool: @@ -748,7 +736,7 @@ class MixedBehaviorProfile: """ self._check_validity() return NodeValuesVector({ - p: NodeValueVector({n: self._node_value(p, n) for n in self.game.nodes}) + p: NodeValueVector({n: self._node_value(p, n) for n in self.game._all_nodes()}) for p in self.game.players }) @@ -797,7 +785,7 @@ class MixedBehaviorProfile: play according to the profile. """ self._check_validity() - return RealizProbVector({n: self._realiz_prob(n) for n in self.game.nodes}) + return RealizProbVector({n: self._realiz_prob(n) for n in self.game._all_nodes()}) @property def infoset_probs(self) -> InfosetProbVector: @@ -844,7 +832,7 @@ class MixedBehaviorProfile: MixedBehaviorProfile.infoset_probs """ self._check_validity() - return BeliefVector({n: self._belief(n) for n in self.game.nodes}) + return BeliefVector({n: self._belief(n) for n in self.game._all_nodes()}) @property def action_regrets(self) -> ActionRegretsVector: diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi index 1abee090a..c7b568006 100644 --- a/src/pygambit/behavspt.pxi +++ b/src/pygambit/behavspt.pxi @@ -167,28 +167,30 @@ class BehaviorSupportProfile: Parameters ---------- - index : str, Node, or Infoset + index : str, History, 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 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. + * If `index` is a ``History`` (a tuple of action labels) 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``, a ``Node``, or an ``Infoset``. + If `index` is not a ``str``, a ``History``, or an ``Infoset``. MismatchError - If `index` is a ``Node`` or ``Infoset`` from a different game. + If `index` is an ``Infoset`` from a different game. ValueError - If `index` is a terminal ``Node``, which belongs to no information set. + If `index` is a ``History`` resolving to a terminal node, which belongs + to no information set. KeyError - If `index` is a ``str`` and no player in the game has that label. + If `index` is a ``str`` and no player in the game has that label, or a + ``History`` that does not match any node of the game. """ - resolved_infoset = self._resolve_infoset_arg(index) + resolved_infoset = self._resolve_infoset_arg(index, "__getitem__") if resolved_infoset is not None: if resolved_infoset.game != self.game: raise MismatchError("infoset must be part of the same game") @@ -200,25 +202,16 @@ class BehaviorSupportProfile: } return BehaviorSupport.wrap(index, values) raise TypeError( - f"profile index must be str, Node, or Infoset, not {index.__class__.__name__}" + f"profile index must be str, History, or Infoset, not {index.__class__.__name__}" ) @cython.cfunc - def _resolve_infoset_arg(self, index: object) -> object: - """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). + def _resolve_infoset_arg(self, index: object, funcname: str) -> object: + """Resolves index to the Infoset it identifies if it is a History or an + Infoset, or returns None if index is neither (e.g. a player label str). """ - 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, tuple): + return self.game._resolve_infoset(index, funcname) if isinstance(index, Infoset): return index return None @@ -273,10 +266,10 @@ class BehaviorSupportProfile: Parameters ---------- - 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``). + infoset : History or Infoset + A History (a tuple of action labels) 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 @@ -285,18 +278,20 @@ class BehaviorSupportProfile: Raises ------ TypeError - If `infoset` is not a ``Node`` or an ``Infoset``. + If `infoset` is not a ``History`` or an ``Infoset``. MismatchError - If `infoset` is a `Node` or `Infoset` from a different game. + If `infoset` is an ``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; or if `infoset` is a terminal node, - which belongs to no information set. + labels, or if `actions` is empty; or if `infoset` is a ``History`` + resolving to a terminal node, which belongs to no information set. + KeyError + If `infoset` is a ``History`` that does not match any node of the game. """ - resolved_infoset = self._resolve_infoset_arg(infoset) + resolved_infoset = self._resolve_infoset_arg(infoset, "__setitem__") if resolved_infoset is None: raise TypeError( - f"profile index must be Node or Infoset, not {infoset.__class__.__name__}" + f"profile index must be History or Infoset, not {infoset.__class__.__name__}" ) if resolved_infoset.game != self.game: raise MismatchError("infoset must be part of the same game") diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 2f70acdd5..07b4aa51a 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -447,7 +447,7 @@ def check_filters(game: gbt.Game) -> bool: if n_nodes is not None: if not game.is_tree: return False - if len(game.nodes) != n_nodes: + if game._num_nodes() != n_nodes: return False if n_outcomes is not None and len(game.outcomes) != n_outcomes: return False diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index d7863a2c3..cf02cd9d8 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -388,7 +388,7 @@ def read_behavior_profiles_csv( profile = game.mixed_behavior_profile(rational=True) for player in game.players: for node in game.get_infosets(player): - profile[node] = {a: next(values) for a in node.infoset.actions} + profile[node.history] = {a: next(values) for a in node.infoset.actions} profiles.append(profile) return profiles diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index cc72b2294..4a557d367 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -502,45 +502,45 @@ class Game: return GameOutcomes.wrap(self.game) @property - def nodes(self) -> GameNodes: - """The set of nodes in the game. - - Iteration over this property yields the nodes in the order of depth-first search. + def contingencies(self) -> pygambit.gameiter.Contingencies: + """An iterator over the contingencies in the game.""" + return pygambit.gameiter.Contingencies(self) - .. versionchanged:: 16.4 - Changed from a method ``nodes()`` to a property. + def _root_node(self) -> Node: + """Internal: the root node of the game. `Game.root` was retired from the + public API in favor of `H` selectors and materialized `History` tuples + (e.g. `H.path()`/`()`); this is the last direct access to it, used only to + seed selector evaluation. + """ + if not self.is_tree: + raise UndefinedOperationError( + "root: only games with a tree representation have a root node" + ) + return Node.wrap(self.game.deref().GetRoot()) - Raises - ------ - UndefinedOperationError - If the game does not have a tree representation. + def _all_nodes(self) -> list: + """Internal: every node of the game, in the C++ tree's depth-first order. + `Game.nodes` was retired from the public API -- its only real job was + letting calling code build ad hoc selections by hand, which `H` now does + directly; this is the last surviving use, backing `H.after(...)`'s + whole-game seed and label-based node lookup. """ if not self.is_tree: raise UndefinedOperationError( "Operation only defined for games with a tree representation" ) + return [Node.wrap(node) for node in self.game.deref().GetNodes()] - return GameNodes.wrap(self.game) - - @property - def contingencies(self) -> pygambit.gameiter.Contingencies: - """An iterator over the contingencies in the game.""" - return pygambit.gameiter.Contingencies(self) - - @property - def root(self) -> Node: - """The root node of the game. - - Raises - ------ - UndefinedOperationError - If the game does not hae a tree representation. + def _num_nodes(self) -> int: + """Internal: the number of nodes in the game -- a cheap count for callers (such + as catalog filtering) that don't need the nodes themselves, so don't need to pay + for materializing `_all_nodes()`'s full list. """ if not self.is_tree: raise UndefinedOperationError( - "root: only games with a tree representation have a root node" + "Operation only defined for games with a tree representation" ) - return Node.wrap(self.game.deref().GetRoot()) + return self.game.deref().NumNodes() def get_nodes(self, selector: Selector) -> list[Node]: """Evaluate `selector` (an `H`-built expression) against this game. @@ -557,11 +557,11 @@ class Game: current: list = None for op in selector._ops: if isinstance(op, _AfterStep): - candidates = list(self.nodes) if current is None else current + candidates = self._all_nodes() if current is None else current current = [n for n in candidates if _matches_suffix(n, op.labels)] continue if current is None: - current = [self.root] + current = [self._root_node()] if isinstance(op, _PathStep): for step in op.steps: current = ( @@ -579,7 +579,7 @@ class Game: else: raise TypeError(f"get_nodes(): unknown selector op {op!r}") if current is None: - current = [self.root] + current = [self._root_node()] return current def get_histories(self, selector: Selector) -> list[tuple]: @@ -1023,7 +1023,7 @@ class Game: f"Number of elements does not match number of " f"actions for infoset {infoset} for {p}" ) - profile[node] = { + profile[node.history] = { a: typefunc(u) for a, u in zip(infoset.actions, v, strict=True) } return profile @@ -1105,7 +1105,7 @@ class Game: profile = self.mixed_behavior_profile() for player in self.players: for node in self.get_infosets(player): - profile[node] = _dirichlet_distribution(node.infoset.actions, gen) + profile[node.history] = _dirichlet_distribution(node.infoset.actions, gen) return profile elif denom < 1: raise ValueError("random_behavior_profile(): denom must be positive") @@ -1113,7 +1113,9 @@ class Game: profile = self.mixed_behavior_profile(rational=True) for player in self.players: for node in self.get_infosets(player): - profile[node] = _grid_distribution(node.infoset.actions, denom, gen) + profile[node.history] = _grid_distribution( + node.infoset.actions, denom, gen + ) return profile def strategy_support_profile( @@ -1399,7 +1401,7 @@ class Game: raise ValueError( f"{funcname}(): {argname} cannot be an empty string or all spaces" ) - for n in self.nodes: + for n in self._all_nodes(): if n.label == node: return n raise KeyError(f"{funcname}(): no node with label '{node}'") @@ -2776,7 +2778,7 @@ class NodeCoordinates: def _layout_tree(game: Game) -> dict[Node, NodeCoordinates]: layout = CreateLayout(game.game) data = {} - for node in game.nodes: + for node in game._all_nodes(): data[node] = NodeCoordinates(deref(layout).GetNodeLevel(cython.cast(Node, node).node), deref(layout).GetNodeSublevel(cython.cast(Node, node).node), deref(layout).GetNodeOffset(cython.cast(Node, node).node)) diff --git a/src/pygambit/gamecollections.pxi b/src/pygambit/gamecollections.pxi index ee2928192..726023832 100644 --- a/src/pygambit/gamecollections.pxi +++ b/src/pygambit/gamecollections.pxi @@ -21,39 +21,6 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # -@cython.cclass -class GameNodes: - """Represents the set of nodes in a game.""" - game = cython.declare(c_Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameNodes outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: c_Game) -> GameNodes: - obj: GameNodes = GameNodes.__new__(GameNodes) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameNodes(game={Game.wrap(self.game)})" - - def __len__(self) -> int: - """The number of nodes in the game.""" - if not self.game.deref().IsTree(): - return 0 - return self.game.deref().NumNodes() - - def __iter__(self) -> typing.Iterator[Node]: - """Iterate over the game nodes in the depth-first traversal order.""" - if not self.game.deref().IsTree(): - return - - for node in self.game.deref().GetNodes(): - yield Node.wrap(node) - - @cython.cclass class GameSubgames: """Represents the set of subgames in a game.""" diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index d8e8fa98a..d67a3d677 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -481,6 +481,16 @@ class Node: """ return [Node.wrap(n) for n in self.node.deref().GetGame().deref().GetPlays(self.node)] + @property + def history(self) -> tuple: + """The History (a tuple of action labels) leading to this node from the root -- + the materialized, game-agnostic form used elsewhere in the public API (profile + indexing, `H`-built selector results, ...). + + .. versionadded:: 17.0.0 + """ + return _history_of(self) + @cython.cclass class Subgame: diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index 22de0655e..71a01884f 100644 --- a/src/pygambit/qre.py +++ b/src/pygambit/qre.py @@ -257,7 +257,7 @@ def _estimate_behavior_empirical( data: libgbt.MixedBehaviorProfile, ) -> LogitQREMixedBehaviorFitResult: flattened_data = [ - data[node][a] + data[node.history][a] for p in data.game.players for node in data.game.get_infosets(p) for a in node.infoset.actions @@ -277,7 +277,7 @@ def _estimate_behavior_empirical( log_probs = iter(_empirical_log_logit_probs(res.x[0], regrets)) for player in data.game.players: for node in data.game.get_infosets(player): - profile[node] = { + profile[node.history] = { a: math.exp(next(log_probs)) for a in node.infoset.actions } return LogitQREMixedBehaviorFitResult( diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 9d913b2ef..ba2556fa3 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -5,7 +5,7 @@ import pygambit as gbt -from ..games import create_2x2_zero_sum_efg +from ..games import create_2x2_zero_sum_efg, root_node @pytest.fixture @@ -97,8 +97,8 @@ def efg_asymmetric_tree_text() -> str: payoff-irrelevant and so free to vary across equilibria). """ game = gbt.Game.new_tree(players=["1", "2"], title="Asymmetric multi-infoset game") - game.append_move(game.root, "1", ["L", "R"]) - left, right = game.root.children + game.append_move(root_node(game), "1", ["L", "R"]) + left, right = root_node(game).children game.append_move(left, "2", ["x", "y", "z"]) game.append_move(right, "2", ["p", "q"]) for node in left.children: diff --git a/tests/games.py b/tests/games.py index 07fc521c7..dce2b8a10 100644 --- a/tests/games.py +++ b/tests/games.py @@ -33,6 +33,41 @@ def find_infoset_in_game(game: gbt.Game, label: str) -> gbt.Infoset: return next(i for i in all_infosets(game) if i.label == label) +def root_node(game: gbt.Game) -> gbt.Node: + """The root Node of `game`, this test suite's own replacement for the old + `Game.root` (retired, 17.0.0, in favor of `H` selectors and materialized + `History` tuples as the public entry points into a tree). `Node` itself is + unaffected by that removal -- fixture code below still freely navigates via + `.children`/`.parent` once it holds one.""" + return game.get_nodes(gbt.H.path())[0] + + +def history_of(node: gbt.Node) -> tuple: + """The History (tuple of action labels) leading to `node` -- this test suite's + own Node-to-History bridge, for call sites (such as `MixedBehaviorProfile` + indexing) that now require a `History` rather than a `Node`.""" + labels = [] + while node.parent is not None: + labels.append(node.prior_action.label) + node = node.parent + labels.reverse() + return tuple(labels) + + +def all_nodes(game: gbt.Game) -> list[gbt.Node]: + """Every node of `game`, in depth-first order -- this test suite's own + replacement for the old `Game.nodes` (retired, 17.0.0: its only real job was + letting calling code build ad hoc selections by hand, which `H` now does + directly; a plain recursive walk from the root covers the rarer case of + needing literally every node at once, e.g. to bucket terminal nodes by + payoff).""" + def walk(node: gbt.Node): + yield node + for child in node.children: + yield from walk(child) + return list(walk(root_node(game))) + + # 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 @@ -93,10 +128,10 @@ def create_efg_corresponding_to_bimatrix_game_arrays( g = gbt.Game.new_tree(players=["1", "2"], title=title) actions1 = [str(i) for i in range(m)] actions2 = [str(i) for i in range(n)] - g.append_move(g.root, "1", actions1) - g.append_move(g.root.children, "2", actions2) + g.append_move(root_node(g), "1", actions1) + g.append_move(root_node(g).children, "2", actions2) for i, j in itertools.product(range(m), range(n)): - node = g.root.children[str(i)].children[str(j)] + node = root_node(g).children[str(i)].children[str(j)] g.make_outcome(node, {"1": A[i, j], "2": B[i, j]}, f"({i},{j})") return g @@ -134,9 +169,9 @@ def create_2x2_zero_sum_efg(variant: None | str = None) -> gbt.Game: g = create_efg_corresponding_to_bimatrix_game_arrays(A, B, title) if variant == "missing term outcome": - g.make_outcome_null(g.root.children["0"].children["1"]) + g.make_outcome_null(root_node(g).children["0"].children["1"]) elif variant == "with neutral outcome": - g.make_outcome(g.root.children["0"], {"1": 0, "2": 0}, "neutral") + g.make_outcome(root_node(g).children["0"], {"1": 0, "2": 0}, "neutral") return g @@ -163,30 +198,31 @@ def create_stripped_down_poker_efg(nonterm_outcomes: bool = False) -> gbt.Game: poker from Reiley et al (2008).", ) deals = ["King", "Queen"] - g.append_event(g.root, deals, [gbt.Rational(1, 2)] * 2) + g.append_event(root_node(g), deals, [gbt.Rational(1, 2)] * 2) - for node in g.root.children: + for node in root_node(g).children: g.append_move(node, player="Alice", actions=["Bet", "Fold"]) alice_bets_nodes = [ - g.root.children["King"].children["Bet"], - g.root.children["Queen"].children["Bet"], + root_node(g).children["King"].children["Bet"], + root_node(g).children["Queen"].children["Bet"], ] g.append_move(alice_bets_nodes, player="Bob", actions=["Call", "Fold"]) - g.make_outcome(g.root, {"Alice": -1, "Bob": -1}, "Ante") + root_children = list(root_node(g).children) + g.make_outcome(root_node(g), {"Alice": -1, "Bob": -1}, "Ante") g.make_outcome( - [node.children["Fold"] for node in g.root.children], {"Alice": 0, "Bob": 2}, "Alice Folds" + [node.children["Fold"] for node in root_children], {"Alice": 0, "Bob": 2}, "Alice Folds" ) g.make_outcome( - [node.children["Bet"] for node in g.root.children], {"Alice": -1, "Bob": 0}, "Alice Bets" + [node.children["Bet"] for node in root_children], {"Alice": -1, "Bob": 0}, "Alice Bets" ) g.make_outcome( [node.children["Fold"] for node in alice_bets_nodes], {"Alice": 3, "Bob": 0}, "Bob Folds" ) - bob_calls_and_loses_node = g.root.children["King"].children["Bet"].children["Call"] + bob_calls_and_loses_node = root_node(g).children["King"].children["Bet"].children["Call"] g.make_outcome(bob_calls_and_loses_node, {"Alice": 4, "Bob": -1}, "Bob Calls and Loses") - bob_calls_and_wins_node = g.root.children["Queen"].children["Bet"].children["Call"] + bob_calls_and_wins_node = root_node(g).children["Queen"].children["Bet"].children["Call"] g.make_outcome(bob_calls_and_wins_node, {"Alice": 0, "Bob": 3}, "Bob Calls and Wins") return g @@ -203,28 +239,28 @@ def deals_by_infoset(player, card): player_idx = 0 if player == "Alice" else 1 return [d for d in deals if d[player_idx] == card] - g.append_event(g.root, deals, [gbt.Rational(1, 6)] * 6) + g.append_event(root_node(g), deals, [gbt.Rational(1, 6)] * 6) for alice_card in cards: # Alice's first move - term_nodes = [g.root.children[d] for d in deals_by_infoset("Alice", alice_card)] + term_nodes = [root_node(g).children[d] for d in deals_by_infoset("Alice", alice_card)] g.append_move(term_nodes, "Alice", ["Check", "Bet"]) for bob_card in cards: # Bob's move after Alice checks term_nodes = [ - g.root.children[d].children["Check"] for d in deals_by_infoset("Bob", bob_card) + root_node(g).children[d].children["Check"] for d in deals_by_infoset("Bob", bob_card) ] g.append_move(term_nodes, "Bob", ["Check", "Bet"]) for alice_card in cards: # Alice's move if Bob's second action is bet term_nodes = [ - g.root.children[d].children["Check"].children["Bet"] + root_node(g).children[d].children["Check"].children["Bet"] for d in deals_by_infoset("Alice", alice_card) ] g.append_move(term_nodes, "Alice", ["Fold", "Call"]) for bob_card in cards: # Bob's move after Alice bets initially term_nodes = [ - g.root.children[d].children["Bet"] for d in deals_by_infoset("Bob", bob_card) + root_node(g).children[d].children["Bet"] for d in deals_by_infoset("Bob", bob_card) ] g.append_move(term_nodes, "Bob", ["Fold", "Call"]) return g @@ -297,7 +333,7 @@ def bet(player, payoffs, pot): (-2, 2): "BOb wins 2", } nodes_by_payoff = {payoffs: [] for payoffs in payoff_labels} - for term_node in [n for n in g.nodes if n.is_terminal]: + for term_node in [n for n in all_nodes(g) if n.is_terminal]: nodes_by_payoff[calculate_payoffs(term_node)].append(term_node) for payoffs, nodes in nodes_by_payoff.items(): @@ -325,7 +361,7 @@ def _create_kuhn_poker_efg_nonterm_outcomes() -> gbt.Game: 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(g.root) + nodes_by_key["Ante"].append(root_node(g)) def collect_nodes(term_node): def get_path(node): @@ -361,7 +397,7 @@ def get_path(node): tmp = "wins" if winner == "Bob" else "loses" nodes_by_key[f"Bob calls and {tmp}"].append(n) - for term_node in [n for n in g.nodes if n.is_terminal]: + for term_node in [n for n in all_nodes(g) if n.is_terminal]: collect_nodes(term_node) for key, nodes in nodes_by_key.items(): @@ -439,24 +475,24 @@ def create_one_shot_trust_efg(unique_NE_variant: bool = False) -> gbt.Game: g = gbt.Game.new_tree( players=["Buyer", "Seller"], title="One-shot trust game, after Kreps (1990)" ) - g.append_move(g.root, "Buyer", ["Trust", "Not trust"]) - g.append_move(g.root.children["Trust"], "Seller", ["Honor", "Abuse"]) + g.append_move(root_node(g), "Buyer", ["Trust", "Not trust"]) + g.append_move(root_node(g).children["Trust"], "Seller", ["Honor", "Abuse"]) g.make_outcome( - g.root.children["Trust"].children["Honor"], {"Buyer": 1, "Seller": 1}, "Trustworthy" + root_node(g).children["Trust"].children["Honor"], {"Buyer": 1, "Seller": 1}, "Trustworthy" ) if unique_NE_variant: g.make_outcome( - g.root.children["Trust"].children["Abuse"], + root_node(g).children["Trust"].children["Abuse"], {"Buyer": "1/2", "Seller": 2}, "Untrustworthy", ) else: g.make_outcome( - g.root.children["Trust"].children["Abuse"], + root_node(g).children["Trust"].children["Abuse"], {"Buyer": -1, "Seller": 2}, "Untrustworthy", ) - g.make_outcome(g.root.children["Not trust"], {"Buyer": 0, "Seller": 0}, "Opt-out") + g.make_outcome(root_node(g).children["Not trust"], {"Buyer": 0, "Seller": 0}, "Opt-out") return g @@ -566,7 +602,7 @@ def __init__(self, params): def gbt_game(self): g = gbt.Game.new_tree(players=["1", "2"], title=f"Centipede Game with {self.N} rounds") - current_node = g.root + current_node = root_node(g) current_player = "1" for t in range(self.N): g.append_move(current_node, current_player, ["Take", "Push"]) @@ -719,8 +755,8 @@ def gbt_game(self): players=[str(p) for p in self.players], title=f"Binary Tree Game (L={self.level})", ) - self.create_binary_tree(g, g.root, 0, 0, self.level) - for n in g.nodes: + self.create_binary_tree(g, root_node(g), 0, 0, self.level) + for n in all_nodes(g): if not n.is_terminal and not n.children["L"].is_terminal: left = n.children["L"] g.make_infoset( diff --git a/tests/test_actions.py b/tests/test_actions.py index 970f21b08..8d45be7a6 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.actions)) - game.relabel_actions(game.root, {action: label}) - assert label in game.root.actions + action = next(iter(games.root_node(game).actions)) + game.relabel_actions(games.root_node(game), {action: label}) + assert label in games.root_node(game).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.actions)) + action = next(iter(games.root_node(game).actions)) with pytest.raises(ValueError): - game.relabel_actions(game.root, {action: label}) + game.relabel_actions(games.root_node(game), {action: label}) def test_relabel_action_empty_raises_valueerror(): game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.actions)) + action = next(iter(games.root_node(game).actions)) with pytest.raises(ValueError): - game.relabel_actions(game.root, {action: ""}) + game.relabel_actions(games.root_node(game), {action: ""}) def test_relabel_actions_duplicate_raises_valueerror(): game = games.create_stripped_down_poker_efg() with pytest.raises(ValueError): - game.relabel_actions(game.root, {"King": "Queen"}) + game.relabel_actions(games.root_node(game), {"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, {"King": "Queen", "Queen": "King"}) - assert list(game.root.event.actions) == ["Queen", "King"] + game.relabel_actions(games.root_node(game), {"King": "Queen", "Queen": "King"}) + assert list(games.root_node(game).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, {"King": "Ace", "Queen": "Ace"}) + game.relabel_actions(games.root_node(game), {"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, {"Jack": "Ace"}) + game.relabel_actions(games.root_node(game), {"Jack": "Ace"}) def test_relabel_actions_unknown_label_not_strict_is_ignored(): game = games.create_stripped_down_poker_efg() - game.relabel_actions(game.root, {"Jack": "Ace", "King": "Ace"}, strict=False) - assert list(game.root.event.actions) == ["Ace", "Queen"] + game.relabel_actions(games.root_node(game), {"Jack": "Ace", "King": "Ace"}, strict=False) + assert list(games.root_node(game).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, {"King": "Ace", "Queen": ""}) - assert list(game.root.event.actions) == ["King", "Queen"] + game.relabel_actions(games.root_node(game), {"King": "Ace", "Queen": ""}) + assert list(games.root_node(game).event.actions) == ["King", "Queen"] def test_relabel_actions_scope_is_the_information_set(): @@ -92,14 +92,14 @@ def test_relabel_actions_scope_is_the_information_set(): 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, [("King", "Queen")]) + game.relabel_actions(games.root_node(game), [("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, labels) + game.relabel_actions(games.root_node(game), labels) def test_set_move_actions_drop_shrinks_actions_and_children(): @@ -128,15 +128,15 @@ def test_set_move_actions_reorder_carries_subtrees(): """Reordering three actions as a cycle moves every action to a new position. Each action carries its whole subtree with it, at every member of the information set.""" game = gbt.Game.new_tree(players=["Alice", "Bob"]) - game.append_move(game.root, "Bob", ["x", "y"]) - game.append_move(list(game.root.children), "Alice", ["a", "b", "c"]) - game.append_move([game.root.children["x"].children["a"], - game.root.children["y"].children["b"]], "Bob", ["l", "r"]) - infoset = game.root.children["x"].infoset + game.append_move(games.root_node(game), "Bob", ["x", "y"]) + game.append_move(list(games.root_node(game).children), "Alice", ["a", "b", "c"]) + game.append_move([games.root_node(game).children["x"].children["a"], + games.root_node(game).children["y"].children["b"]], "Bob", ["l", "r"]) + infoset = games.root_node(game).children["x"].infoset members = list(infoset.members) children_before = [{label: member.children[label] for label in ("a", "b", "c")} for member in members] - game.set_move_actions(game.root.children["x"], ["c", "a", "b"]) + game.set_move_actions(games.root_node(game).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"]] @@ -146,11 +146,11 @@ def test_set_move_actions_add_drop_and_reorder_together(): game = games.create_stripped_down_poker_efg() infoset = games.find_infoset(game, "Alice", "Alice has King") node = next(iter(infoset.members)) - nodes_before = len(game.nodes) + nodes_before = len(games.all_nodes(game)) game.set_move_actions(node, ["Raise", "Fold"], drop=True) assert list(infoset.actions) == ["Raise", "Fold"] # "Bet" and its subtree (Bob's node and its two terminals) go; "Raise" adds one. - assert len(game.nodes) == nodes_before - 3 + 1 + assert len(games.all_nodes(game)) == nodes_before - 3 + 1 assert len(games.find_infoset(game, "Bob", "Bob's response").members) == 1 @@ -171,7 +171,7 @@ def test_set_move_actions_raises_at_an_event(): corresponding operation for an event.""" game = games.create_stripped_down_poker_efg() with pytest.raises(ValueError): - game.set_move_actions(game.root, ["King", "Queen"]) + game.set_move_actions(games.root_node(game), ["King", "Queen"]) @pytest.mark.parametrize("bad_labels", [["Bet", "Bet"], ["Bet", ""], ["Bet", " x"]]) @@ -191,49 +191,50 @@ def test_set_move_actions_absent_minded_drop_and_add(): """Dropping an action whose subtree contains another member of the same information 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) - 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 + game.append_move(games.root_node(game), "Alice", ["a", "b"]) + game.append_infoset(games.root_node(game).children["a"], games.root_node(game)) + game.set_move_actions(games.root_node(game), ["b", "c"], drop=True) + assert list(games.root_node(game).infoset.actions) == ["b", "c"] + assert len(games.root_node(game).infoset.members) == 1 + assert len(games.all_nodes(game)) == 3 def test_set_event_actions_reorder_carries_probabilities(): game = games.create_stripped_down_poker_efg() - 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)} + root = games.root_node(game) + game.set_event_actions(root, {"King": "3/4", "Queen": "1/4"}) + game.set_event_actions(root, {"Queen": "1/4", "King": "3/4"}) + assert list(root.actions) == ["Queen", "King"] + assert 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() - nodes_before = len(game.nodes) - 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 == { + nodes_before = len(games.all_nodes(game)) + game.set_event_actions(games.root_node(game), {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) + assert list(games.root_node(game).actions) == ["Jack", "King", "Queen"] + assert games.root_node(game).action_probs == { "Jack": gbt.Rational(1, 2), "King": gbt.Rational(1, 4), "Queen": gbt.Rational(1, 4) } - assert len(game.nodes) == nodes_before + 1 + assert len(games.all_nodes(game)) == nodes_before + 1 def test_set_event_actions_drop_with_probs_mapping(): game = games.create_stripped_down_poker_efg() - game.set_event_actions(game.root, {"King": 1}, drop=True) - assert list(game.root.actions) == ["King"] - assert game.root.action_probs == {"King": 1} + game.set_event_actions(games.root_node(game), {"King": 1}, drop=True) + assert list(games.root_node(game).actions) == ["King"] + assert games.root_node(game).action_probs == {"King": 1} def test_set_event_actions_unconfirmed_drop_and_disabled_add_raise(): game = games.create_stripped_down_poker_efg() - _ = game.root.event + _ = games.root_node(game).event before = game.to_efg() with pytest.raises(ValueError): - game.set_event_actions(game.root, {"King": 1}) + game.set_event_actions(games.root_node(game), {"King": 1}) with pytest.raises(ValueError): game.set_event_actions( - game.root, {"King": "1/2", "Queen": "1/4", "Jack": "1/4"}, add=False + games.root_node(game), {"King": "1/2", "Queen": "1/4", "Jack": "1/4"}, add=False ) assert game.to_efg() == before @@ -253,7 +254,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, ["3/4", "1/4"]) + game.set_event_actions(games.root_node(game), ["3/4", "1/4"]) assert game.to_efg() == before @@ -261,7 +262,7 @@ 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, {"King": "3/4", "Queen": "3/4"}) + game.set_event_actions(games.root_node(game), {"King": "3/4", "Queen": "3/4"}) assert game.to_efg() == before @@ -290,7 +291,7 @@ def test_get_behavior_prescribed_action_defined( game, player_label, strategy_label, infoset_path, expected_action_label ): """Verify `Game.get_behavior` retrieves the correct action for defined actions.""" - node = game.root + node = games.root_node(game) for action_label in infoset_path: node = node.children[action_label] infoset = node.infoset @@ -319,7 +320,7 @@ def test_get_behavior_prescribed_action_undefined_returns_none( if infoset_label is not None: infoset = games.find_infoset_in_game(game, infoset_label) else: - node = game.root + node = games.root_node(game) for action_label in infoset_path: node = node.children[action_label] infoset = node.infoset @@ -349,7 +350,7 @@ def test_get_behavior_raises_value_error_for_wrong_player( to a different player than the strategy. """ behavior = game.get_behavior(player_label, next(iter(game.get_strategies(player_label)))) - node = game.root + node = games.root_node(game) for action_label in other_infoset_path: node = node.children[action_label] other_players_infoset = node.infoset diff --git a/tests/test_behav.py b/tests/test_behav.py index 6beeb78aa..3b2d42357 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -19,8 +19,8 @@ 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 games.all_infosets(profile.game): - node = next(iter(infoset.members)) - profile[node] = {a: convert(next(probs_iter)) for a in infoset.actions} + history = games.history_of(next(iter(infoset.members))) + profile[history] = {a: convert(next(probs_iter)) for a in infoset.actions} @pytest.mark.parametrize( @@ -202,9 +202,9 @@ def test_profile_indexing_by_player_infoset_action_reference( ): profile = game.mixed_behavior_profile(rational=rational_flag) infoset = games.find_infoset(game, player_label, infoset_label) - node = next(iter(infoset.members)) + history = games.history_of(next(iter(infoset.members))) prob = gbt.Rational(prob) if rational_flag else prob - assert profile[node][action_label] == prob + assert profile[history][action_label] == prob @pytest.mark.parametrize( @@ -263,14 +263,14 @@ def test_profile_indexing_by_player_infoset_action_reference( def test_profile_indexing_by_node_reference( game: gbt.Game, player_label: str, infoset_label: str, probs: list, rational_flag: bool ): - """profile[node] and profile[player_label][node] resolve to the same MixedAction.""" + """profile[history] and profile[player_label][history] resolve to the same MixedAction.""" profile = game.mixed_behavior_profile(rational=rational_flag) infoset = games.find_infoset(game, player_label, infoset_label) - node = next(iter(infoset.members)) + history = games.history_of(next(iter(infoset.members))) probs = [gbt.Rational(prob) for prob in probs] if rational_flag else probs expected = dict(zip(infoset.actions, probs, strict=True)) - assert profile[player_label][node] == expected - assert profile[node] == expected + assert profile[player_label][history] == expected + assert profile[history] == expected @pytest.mark.parametrize( @@ -283,14 +283,14 @@ def test_profile_indexing_by_node_reference( def test_behavior_indexing_rejects_node_from_different_player( game: gbt.Game, player_label: str, other_player_label: str ): - """MixedBehavior/MixedBehaviorProfile reject a Node whose information set belongs to a - different player than the one being indexed. + """MixedBehavior/MixedBehaviorProfile reject a History whose information set belongs to + a different player than the one being indexed. """ profile = game.mixed_behavior_profile() other_infoset = games.player_infosets(game, other_player_label)[0] - other_node = next(iter(other_infoset.members)) + other_history = games.history_of(next(iter(other_infoset.members))) with pytest.raises(gbt.MismatchError): - profile[player_label][other_node] + profile[player_label][other_history] @pytest.mark.parametrize( @@ -358,9 +358,9 @@ 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(games.find_infoset_in_game(game, infoset_label).members)) - profile[node] = {action_label: prob} - assert profile[node][action_label] == prob + history = games.history_of(next(iter(games.find_infoset_in_game(game, infoset_label).members))) + profile[history] = {action_label: prob} + assert profile[history][action_label] == prob @pytest.mark.parametrize( @@ -435,10 +435,10 @@ def test_set_probabilities_infoset( if rational_flag: probs = [gbt.Rational(p) for p in probs] infoset = games.find_infoset(game, player_label, infoset_label) - node = next(iter(infoset.members)) + history = games.history_of(next(iter(infoset.members))) expected = dict(zip(infoset.actions, probs, strict=True)) - profile[node] = expected - assert profile[node] == expected + profile[history] = expected + assert profile[history] == expected @pytest.mark.parametrize( @@ -475,94 +475,94 @@ def test_set_probabilities_player_by_label( for infoset, distribution in zip( games.player_infosets(game, player_label), expected, strict=True ): - profile[next(iter(infoset.members))] = distribution + profile[games.history_of(next(iter(infoset.members)))] = distribution assert profile[player_label] == expected -def _p1_node(game: gbt.Game): - return next(iter(games.player_infosets(game, "Player 1")[0].members)) +def _p1_history(game: gbt.Game) -> tuple: + return games.history_of(next(iter(games.player_infosets(game, "Player 1")[0].members))) def test_behavior_setitem_allows_sparse_distribution(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) - profile[node] = {"U1": 1} - assert profile[node] == {"U1": 1, "D1": 0} + history = _p1_history(game) + profile[history] = {"U1": 1} + assert profile[history] == {"U1": 1, "D1": 0} def test_set_mixed_action_sparse_matches_setitem(): game = games.read_from_file("mixed_behavior_game.efg") - node = _p1_node(game) + history = _p1_history(game) sparse_profile = game.mixed_behavior_profile() - sparse_profile.set_mixed_action(node, {"U1": 1}, sparse=True) + sparse_profile.set_mixed_action(history, {"U1": 1}, sparse=True) setitem_profile = game.mixed_behavior_profile() - setitem_profile[node] = {"U1": 1} - assert sparse_profile[node] == setitem_profile[node] + setitem_profile[history] = {"U1": 1} + assert sparse_profile[history] == setitem_profile[history] def test_set_mixed_action_defaults_to_requiring_every_label(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + history = _p1_history(game) with pytest.raises(ValueError, match="exactly one weight"): - profile.set_mixed_action(node, {"U1": 1}) + profile.set_mixed_action(history, {"U1": 1}) @pytest.mark.parametrize("sparse", [False, True]) def test_setitem_and_set_mixed_action_reject_unknown_action_label(sparse: bool): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + history = _p1_history(game) with pytest.raises(ValueError, match="not an action label"): - profile.set_mixed_action(node, {"not-an-action": 1}, sparse=sparse) + profile.set_mixed_action(history, {"not-an-action": 1}, sparse=sparse) with pytest.raises(ValueError, match="not an action label"): - profile[node] = {"not-an-action": 1} + profile[history] = {"not-an-action": 1} def test_behavior_setitem_empty_distribution_is_all_zero_error(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + history = _p1_history(game) with pytest.raises(ValueError, match="zero"): - profile[node] = {} + profile[history] = {} @pytest.mark.parametrize("sparse", [False, True]) def test_setitem_and_set_mixed_action_reject_non_mapping(sparse: bool): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + history = _p1_history(game) with pytest.raises(TypeError, match="Mapping"): - profile.set_mixed_action(node, [1, 0], sparse=sparse) + profile.set_mixed_action(history, [1, 0], sparse=sparse) with pytest.raises(TypeError, match="Mapping"): - profile[node] = [1, 0] + profile[history] = [1, 0] @pytest.mark.parametrize("sparse", [False, True]) def test_setitem_and_set_mixed_action_reject_uncoercible_weight(sparse: bool): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + history = _p1_history(game) full_distribution = {"U1": "abc", "D1": 0} with pytest.raises(ValueError, match="convert"): - profile.set_mixed_action(node, full_distribution, sparse=sparse) + profile.set_mixed_action(history, full_distribution, sparse=sparse) with pytest.raises(ValueError, match="convert"): - profile[node] = full_distribution + profile[history] = full_distribution def test_behavior_setitem_sparse_rejects_negative_weight(): """Negativity is checked even for weights given under a sparse distribution.""" game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + history = _p1_history(game) with pytest.raises(ValueError, match="negative"): - profile[node] = {"U1": -1} + profile[history] = {"U1": -1} @pytest.mark.parametrize("sparse", [False, True]) def test_behavior_indexing_rejects_infoset_object(sparse: bool): - """MixedBehaviorProfile's indexing is Node-only; an Infoset object is rejected.""" + """MixedBehaviorProfile's indexing is History-only; an Infoset object is rejected.""" game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() infoset = games.player_infosets(game, "Player 1")[0] @@ -581,37 +581,37 @@ def test_mixed_action_and_behavior_are_frozen_snapshots(rational_flag: bool): """ game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile(rational=rational_flag) - node = _p1_node(game) - action_before = profile[node] + history = _p1_history(game) + action_before = profile[history] behavior_before = profile["Player 1"] - profile[node] = {"U1": 1, "D1": 0} + profile[history] = {"U1": 1, "D1": 0} assert dict(action_before) == {"U1": 0.5, "D1": 0.5} - assert dict(behavior_before[node]) == {"U1": 0.5, "D1": 0.5} - assert dict(profile[node]) == {"U1": 1, "D1": 0} + assert dict(behavior_before[history]) == {"U1": 0.5, "D1": 0.5} + assert dict(profile[history]) == {"U1": 1, "D1": 0} @pytest.mark.parametrize("rational_flag", [False, True]) def test_behavior_copy_mutating_copy_does_not_affect_original(rational_flag: bool): game = games.read_from_file("mixed_behavior_game.efg") original = game.mixed_behavior_profile(rational=rational_flag) - node = _p1_node(game) - original_before = dict(original[node]) + history = _p1_history(game) + original_before = dict(original[history]) copy = original.copy() - copy[node] = {"U1": 1, "D1": 0} - assert dict(original[node]) == original_before - assert dict(copy[node]) == {"U1": 1, "D1": 0} + copy[history] = {"U1": 1, "D1": 0} + assert dict(original[history]) == original_before + assert dict(copy[history]) == {"U1": 1, "D1": 0} @pytest.mark.parametrize("rational_flag", [False, True]) def test_behavior_copy_mutating_original_does_not_affect_copy(rational_flag: bool): game = games.read_from_file("mixed_behavior_game.efg") original = game.mixed_behavior_profile(rational=rational_flag) - node = _p1_node(game) + history = _p1_history(game) copy = original.copy() - copy_before = dict(copy[node]) - original[node] = {"U1": 1, "D1": 0} - assert dict(copy[node]) == copy_before - assert dict(original[node]) == {"U1": 1, "D1": 0} + copy_before = dict(copy[history]) + original[history] = {"U1": 1, "D1": 0} + assert dict(copy[history]) == copy_before + assert dict(original[history]) == {"U1": 1, "D1": 0} @pytest.mark.parametrize("rational_flag", [False, True]) @@ -624,20 +624,20 @@ def test_as_float_returns_double(rational_flag: bool): def test_as_float_converts_rational_probabilities(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile(rational=True) - node = _p1_node(game) - profile[node] = {"U1": "1/3", "D1": "2/3"} + history = _p1_history(game) + profile[history] = {"U1": "1/3", "D1": "2/3"} result = profile.as_float() - assert dict(result[node]) == {"U1": pytest.approx(1 / 3), "D1": pytest.approx(2 / 3)} + assert dict(result[history]) == {"U1": pytest.approx(1 / 3), "D1": pytest.approx(2 / 3)} def test_as_float_is_independent_copy(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile(rational=False) - node = _p1_node(game) + history = _p1_history(game) result = profile.as_float() assert result == profile - result[node] = {"U1": 1.0, "D1": 0.0} - assert dict(profile[node]) != dict(result[node]) + result[history] = {"U1": 1.0, "D1": 0.0} + assert dict(profile[history]) != dict(result[history]) @pytest.mark.parametrize( @@ -704,7 +704,7 @@ def test_realiz_prob_nodes_reference( # path from the root (an empty path is the root itself) profile = game.mixed_behavior_profile(rational=rational_flag) realiz_prob = gbt.Rational(realiz_prob) if rational_flag else realiz_prob - node = game.root + node = games.root_node(game) for action_label in path: node = node.children[action_label] assert profile.realiz_probs[node] == realiz_prob @@ -772,8 +772,9 @@ def test_nature_rooted_game_root_reached_with_certainty(rational_flag: bool): game = gbt.catalog.load("journals/geb/gilboa1997/fig2") profile = game.mixed_behavior_profile(rational=rational_flag) one = gbt.Rational(1) if rational_flag else 1.0 - assert profile.realiz_probs[game.root] == one - assert profile.infoset_probs[game.root] == one + root = games.root_node(game) + assert profile.realiz_probs[root] == one + assert profile.infoset_probs[root] == one @pytest.mark.parametrize( @@ -939,7 +940,7 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): for player in game.players: player_node_values = node_values[player] assert isinstance(player_node_values, gbt.NodeValueVector) - assert player_node_values[game.root] == payoffs[player] + assert player_node_values[games.root_node(game)] == payoffs[player] for infoset in games.player_infosets(game, player): node = next(iter(infoset.members)) @@ -956,7 +957,7 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): == best_response_value - infoset_action_values[action] ) - for node in game.nodes: + for node in games.all_nodes(game): if node.is_terminal: continue if infoset_probs[node] == 0: @@ -966,7 +967,7 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): # equal to an equivalent plain dict or same-type vector, but never to a vector of a # different quantity, even where the underlying numbers happen to coincide - expected = {n: realiz_probs[n] for n in game.nodes} + expected = {n: realiz_probs[n] for n in games.all_nodes(game)} assert realiz_probs == expected assert realiz_probs == gbt.RealizProbVector(expected) assert realiz_probs != beliefs @@ -1062,7 +1063,7 @@ def test_martingale_property_of_node_value(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) realiz_probs = profile.realiz_probs node_values = profile.node_values - for node in game.nodes: + for node in games.all_nodes(game): if node.is_terminal or bool(node.event): continue expected_val = 0 @@ -1089,8 +1090,9 @@ def test_node_value_consistency(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) node_values = profile.node_values payoffs = profile.payoffs + root = games.root_node(game) for player in game.players: - assert node_values[player][game.root] == payoffs[player] + assert node_values[player][root] == payoffs[player] @pytest.mark.parametrize( @@ -1391,7 +1393,7 @@ def test_node_belief_reference( # action-label path from the root (an empty path is the root itself) profile = game.mixed_behavior_profile(rational=rational_flag) _set_action_probs(profile, probs, rational_flag) - node = game.root + node = games.root_node(game) for action_label in path: node = node.children[action_label] value = gbt.Rational(value) if rational_flag else value @@ -1501,7 +1503,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1509,7 +1511,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ( games.create_stripped_down_poker_efg(), @@ -1517,7 +1519,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ( games.create_stripped_down_poker_efg(), @@ -1525,7 +1527,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ###################################################################################### # realiz_prob (at nodes) @@ -1535,7 +1537,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1543,7 +1545,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ( games.create_stripped_down_poker_efg(), @@ -1551,7 +1553,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ( games.create_stripped_down_poker_efg(), @@ -1559,7 +1561,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ###################################################################################### # infoset_prob @@ -1705,7 +1707,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, games.all_nodes(x))), ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1713,7 +1715,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, games.all_nodes(x))), ), ( games.create_stripped_down_poker_efg(), @@ -1721,7 +1723,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, games.all_nodes(x))), ), ( games.create_stripped_down_poker_efg(), @@ -1729,7 +1731,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, games.all_nodes(x))), ), ###################################################################################### # agent_liap_value (of profile, hence [1] for objects_to_test, @@ -1920,10 +1922,10 @@ 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 games.all_infosets(game): - node = next(iter(infoset.members)) + history = games.history_of(next(iter(infoset.members))) for action in infoset.actions: prob = next(flattened) - assert profile[node][action] == (gbt.Rational(prob) if rational_flag else prob) + assert profile[history][action] == (gbt.Rational(prob) if rational_flag else prob) @pytest.mark.parametrize( diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index cfb210d9e..840f5adbe 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -19,7 +19,7 @@ def _branching_game(): that removing an action can make a whole subtree's information set unreachable. """ game = gbt.Game.new_tree(players=["P1", "P2"]) - root = game.root + root = games.root_node(game) game.append_move(root, "P1", ["L", "R"]) left = root.children["L"] right = root.children["R"] @@ -164,11 +164,11 @@ 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 live, node-anchored Infoset view -- both __getitem__ and + # games.root_node(game).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"] - assert set(profile[game.root.infoset]) == {"R"} + assert set(profile[games.root_node(game).infoset]) == {"L", "R"} + profile[games.root_node(game).infoset] = ["R"] + assert set(profile[games.root_node(game).infoset]) == {"R"} def test_is_reachable(): diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 961f52306..a6b80d31d 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -7,6 +7,8 @@ import pygambit as gbt +from . import games + @pytest.fixture(scope="module") def all_games(): @@ -138,7 +140,7 @@ def test_catalog_games_filter_n_nodes(all_games): assert len(filtered_games) < len(all_games) if len(filtered_games) > 0: g = gbt.catalog.load(filtered_games.Game.iloc[0]) - assert len(g.nodes) == 5 + assert len(games.all_nodes(g)) == 5 def test_catalog_games_filter_n_outcomes(all_games): diff --git a/tests/test_file.py b/tests/test_file.py index fe4b33bb5..a167f46fa 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -4,6 +4,8 @@ import pygambit as gbt +from . import games + def _parse_efg(text: str) -> gbt.Game: with io.StringIO(text) as f: @@ -31,13 +33,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 list(g.root.infoset.actions) == ["_1", "_2"] + assert list(games.root_node(g).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 list(g.root.infoset.actions) == ["l_1", "l_2"] + assert list(games.root_node(g).infoset.actions) == ["l_1", "l_2"] def test_read_efg_repeated_infoset_duplicate_labels_consistent(): @@ -53,7 +55,7 @@ def test_read_efg_repeated_infoset_duplicate_labels_consistent(): 't "" 2 "" { 2, -2 }\n' 't "" 3 "" { 3, -3 }\n' ) - assert list(g.root.infoset.actions) == ["l_1", "l_2"] + assert list(games.root_node(g).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 d3a9cfb9b..8fe5729a9 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -154,7 +154,7 @@ def test_game_get_outcome_unmatched_label_after_relabel_raises(): def test_game_get_outcome_tree_raises(): game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["a", "b"]) + game.append_move(games.root_node(game), "Alice", ["a", "b"]) with pytest.raises(gbt.UndefinedOperationError): _ = game.get_outcome({"Alice": "a"}) @@ -169,13 +169,13 @@ def test_game_get_payoffs(): def test_game_get_payoffs_tree(): game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["a", "b"]) - infoset = game.root.infoset + game.append_move(games.root_node(game), "Alice", ["a", "b"]) + infoset = games.root_node(game).infoset strategy = next( s for s in game.get_strategies("Alice") if game.get_behavior("Alice", s).get(infoset) == "a" ) - game.make_outcome(game.root.children["a"], {"Alice": 1}, "a-outcome") + game.make_outcome(games.root_node(game).children["a"], {"Alice": 1}, "a-outcome") payoffs = game.get_payoffs({"Alice": strategy}) assert payoffs["Alice"] == 1 @@ -218,7 +218,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, ["D1"], drop=True) + game.set_move_actions(games.root_node(game), ["D1"], drop=True) distribution = {s: 0 for s in game.get_strategies(player)} for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): @@ -253,8 +253,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, ["D1"], drop=True) - infoset = game.root + game.set_move_actions(games.root_node(game), ["D1"], drop=True) + infoset = games.root_node(game) for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): _ = profile.action_regrets @@ -297,11 +297,11 @@ def test_mixed_behavior_profile_game_structure_changed(): # triggers error via __getitem__ next(profile.__iter__()) with pytest.raises(gbt.GameStructureChangedError): - profile.__setitem__(game.root, {}) + profile.__setitem__((), {}) with pytest.raises(gbt.GameStructureChangedError): - profile.set_mixed_action(game.root, {}) + profile.set_mixed_action((), {}) with pytest.raises(gbt.GameStructureChangedError): - profile.__getitem__(game.root) + profile.__getitem__(()) COLLECTION_GETTERS = [ diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index 7795dd7b8..6a1153543 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -32,7 +32,7 @@ def _test_valid_resolutions(collection: list, resolver: typing.Callable) -> None ] ) def test_resolve_node(game: gbt.Game) -> None: - _test_valid_resolutions(game.nodes, + _test_valid_resolutions(games.all_nodes(game), lambda label, fn: game._resolve_node(label, fn)) diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 3ad6b3ba1..bc14fd636 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -13,23 +13,23 @@ @pytest.mark.parametrize("label", games.VALID_LABELS) def test_infoset_set_label(label): game = games.read_from_file("basic_extensive_game.efg") - game.root.infoset.label = label - assert game.root.infoset.label == label + games.root_node(game).infoset.label = label + assert games.root_node(game).infoset.label == label @pytest.mark.parametrize("label", games.INVALID_LABELS) def test_infoset_label_invalid_raises_valueerror(label): game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.root.infoset.label = label + games.root_node(game).infoset.label = label @pytest.mark.parametrize("label", games.UNICODE_LABELS) def test_infoset_label_unicode_accepted(label): """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" game = games.read_from_file("basic_extensive_game.efg") - game.root.infoset.label = label - assert game.root.infoset.label == label + games.root_node(game).infoset.label = label + assert games.root_node(game).infoset.label == label def test_infoset_label_duplicate_within_player_raises_valueerror(): @@ -44,13 +44,14 @@ def test_infoset_label_duplicate_within_player_raises_valueerror(): def test_infoset_player_retrieval(): game = games.read_from_file("basic_extensive_game.efg") p1, *_ = game.players - assert p1 == game.root.infoset.player + assert p1 == games.root_node(game).infoset.player def test_infoset_node_precedes(): game = games.read_from_file("basic_extensive_game.efg") - assert not game.root.infoset.precedes(game.root) - assert game.root.children["U1"].infoset.precedes(game.root.children["U1"]) + root = games.root_node(game) + assert not root.infoset.precedes(root) + assert root.children["U1"].infoset.precedes(root.children["U1"]) def test_make_infoset_change_player_keeps_label(): @@ -58,11 +59,11 @@ def test_make_infoset_change_player_keeps_label(): explicitly specified label and its membership.""" game = games.read_from_file("basic_extensive_game.efg") _, p2, *_ = game.players - members = list(game.root.infoset.members) + members = list(games.root_node(game).infoset.members) game.make_infoset(members, p2, "moved") - assert game.root.infoset.player == p2 - assert game.root.infoset.label == "moved" - assert list(game.root.infoset.members) == members + assert games.root_node(game).infoset.player == p2 + assert games.root_node(game).infoset.label == "moved" + assert list(games.root_node(game).infoset.members) == members def test_make_infoset_mismatch_raises(): @@ -70,22 +71,22 @@ def test_make_infoset_mismatch_raises(): game1 = games.read_from_file("basic_extensive_game.efg") game2 = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.MismatchError): - game1.make_infoset(game2.root, "Player 1") + game1.make_infoset(games.root_node(game2), "Player 1") def test_make_infoset_terminal_node_raises(): """All nodes must be decision nodes.""" game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] + terminal = games.root_node(game).children["U1"].children["U2"].children["U3"] with pytest.raises(gbt.UndefinedOperationError): - game.make_infoset([terminal], game.root.player) + game.make_infoset([terminal], games.root_node(game).player) 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 n.infoset) + chance_node = games.root_node(game) # the deal is a chance move + personal = next(n for n in games.all_nodes(game) if not n.is_terminal and n.infoset) game.make_infoset([chance_node], personal.infoset.player) assert not chance_node.event assert chance_node.infoset @@ -97,24 +98,25 @@ def test_make_infoset_requires_matching_action_labels(node_actions): """Nodes must have the same actions, with the same labels in the same order; a matching count is not sufficient.""" game = gbt.Game.new_tree(players=["1"]) - game.append_move(game.root, "1", ["a", "b"]) - game.append_move(game.root.children["a"], "1", node_actions) + game.append_move(games.root_node(game), "1", ["a", "b"]) + game.append_move(games.root_node(game).children["a"], "1", node_actions) with pytest.raises(ValueError): - game.make_infoset([game.root, game.root.children["a"]], "1") + game.make_infoset([games.root_node(game), games.root_node(game).children["a"]], "1") def test_make_infoset_empty_nodes_raises(): """`nodes` must be nonempty.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.make_infoset([], game.root.player) + game.make_infoset([], games.root_node(game).player) def test_make_infoset_repeated_node_raises(): """Each node may be referenced only once.""" game = games.read_from_file("basic_extensive_game.efg") + root = games.root_node(game) with pytest.raises(ValueError): - game.make_infoset([game.root, game.root], game.root.player) + game.make_infoset([root, root], root.player) def test_make_infoset_strategic_game_raises(): @@ -128,11 +130,11 @@ 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") - 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 + labels = list(games.root_node(game).actions) + game.set_move_actions(games.root_node(game), labels + ["end"]) + assert list(games.root_node(game).actions)[:-1] == labels + game.set_move_actions(games.root_node(game), ["front"] + labels + ["end"]) + assert list(games.root_node(game).actions)[1:-1] == labels @pytest.mark.parametrize( @@ -148,16 +150,16 @@ def test_make_event_sets_probabilities(inprobs, outprobs): actions are assigned zero. """ game = games.read_from_file("stripped_down_poker.efg") - game.make_event([game.root], inprobs, "Deal") - probs = game.root.action_probs - for action, prob in zip(game.root.actions, outprobs, strict=True): + game.make_event([games.root_node(game)], inprobs, "Deal") + probs = games.root_node(game).action_probs + for action, prob in zip(games.root_node(game).actions, outprobs, strict=True): assert probs[action] == prob def test_make_event_pools_nodes_from_different_infosets(): """Nodes in distinct information sets are formed into a single event.""" game = games.read_from_file("stripped_down_poker.efg") - nodes = [game.root.children["King"], game.root.children["Queen"]] + nodes = [games.root_node(game).children["King"], games.root_node(game).children["Queen"]] game.make_event(nodes, ["1/4", "3/4"], "Coin") assert nodes[0].event == nodes[1].event assert nodes[0].event @@ -172,7 +174,7 @@ def test_make_event_requires_matching_action_labels(probs): The mapping case was previously reported as an unknown action label. """ game = games.read_from_file("stripped_down_poker.efg") - alice_node = game.root.children["King"] # actions Bet, Fold + alice_node = games.root_node(game).children["King"] # actions Bet, Fold bob_node = alice_node.children["Bet"] # actions Call, Fold with pytest.raises(ValueError): game.make_event([alice_node, bob_node], probs) @@ -191,7 +193,7 @@ def test_make_event_converts_personal_node(): def test_make_event_terminal_node_raises(): game = games.read_from_file("stripped_down_poker.efg") - terminal = game.root.children["King"].children["Fold"] + terminal = games.root_node(game).children["King"].children["Fold"] with pytest.raises(gbt.UndefinedOperationError): game.make_event([terminal], ["1/2", "1/2"]) @@ -199,14 +201,14 @@ def test_make_event_terminal_node_raises(): def test_make_event_repeated_node_raises(): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(ValueError): - game.make_event([game.root, game.root], ["1/2", "1/2"]) + game.make_event([games.root_node(game), games.root_node(game)], ["1/2", "1/2"]) def test_make_event_mismatch_raises(): game = games.read_from_file("stripped_down_poker.efg") other = games.read_from_file("stripped_down_poker.efg") with pytest.raises(gbt.MismatchError): - game.make_event([other.root], ["1/2", "1/2"]) + game.make_event([games.root_node(other)], ["1/2", "1/2"]) def test_make_event_strategic_game_raises(): @@ -224,7 +226,7 @@ def test_make_event_empty_nodes_raises(): def test_make_event_label_held_by_rump_raises(): """A label may be reused only if all members of the event holding it are absorbed.""" game = games.read_from_file("stripped_down_poker.efg") - nodes = [game.root.children["King"], game.root.children["Queen"]] + nodes = [games.root_node(game).children["King"], games.root_node(game).children["Queen"]] game.make_event(nodes, ["1/2", "1/2"], "Coin") before = game.to_efg() with pytest.raises(ValueError): @@ -237,7 +239,7 @@ def test_make_event_label_reused_when_fully_absorbed(): event's members are absorbed into the new one; the old event is not left behind. """ game = games.read_from_file("stripped_down_poker.efg") - nodes = [game.root.children["King"], game.root.children["Queen"]] + nodes = [games.root_node(game).children["King"], games.root_node(game).children["Queen"]] game.make_event(nodes, ["1/2", "1/2"], "Coin") game.make_event(nodes, ["1/4", "3/4"], "Coin") assert nodes[0].event == nodes[1].event @@ -253,7 +255,7 @@ def test_make_event_invalid_probs_raises(probs): """Values must be numbers, non-negative, and sum to exactly one.""" game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(ValueError): - game.make_event([game.root], probs) + game.make_event([games.root_node(game)], probs) @pytest.mark.parametrize( @@ -263,7 +265,7 @@ def test_make_event_invalid_probs_raises(probs): def test_make_event_malformed_probs_raises(probs, error): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(error): - game.make_event([game.root], probs) + game.make_event([games.root_node(game)], probs) @dataclasses.dataclass @@ -339,7 +341,7 @@ def _get_node_by_path(game, path: list[str]) -> gbt.Node: """ Helper to find a node by following a sequence of action labels. """ - node = game.root + node = games.root_node(game) for action_label in reversed(path): node = node.children[action_label] return node @@ -374,10 +376,10 @@ def _bagwell_p2_nodes(game: gbt.Game) -> tuple[gbt.Node, gbt.Node, gbt.Node, gbt can produce -- each with two members and actions ("S", "C"). Returns (A, B, C, D) with {A, B} the members of one and {C, D} of the other. """ - return (game.root.children["S"].children["s"], - game.root.children["C"].children["s"], - game.root.children["S"].children["c"], - game.root.children["C"].children["c"]) + return (games.root_node(game).children["S"].children["s"], + games.root_node(game).children["C"].children["s"], + games.root_node(game).children["S"].children["c"], + games.root_node(game).children["C"].children["c"]) def test_make_infoset_cherry_pick_leaves_rumps(): @@ -440,11 +442,11 @@ def test_make_infoset_split_leaves_new_infoset_unlabeled(): def test_make_infoset_across_different_source_players(): """Nodes drawn from different players all land under the target player.""" game = gbt.Game.new_tree(players=["1", "2", "3"]) - game.append_move(game.root, "1", ["a", "b"]) - game.append_move(game.root.children["a"], "2", ["a", "b"]) # player 2 - game.append_move(game.root.children["b"], "3", ["a", "b"]) # player 3 - n2 = game.root.children["a"] - n3 = game.root.children["b"] + game.append_move(games.root_node(game), "1", ["a", "b"]) + game.append_move(games.root_node(game).children["a"], "2", ["a", "b"]) # player 2 + game.append_move(games.root_node(game).children["b"], "3", ["a", "b"]) # player 3 + n2 = games.root_node(game).children["a"] + n3 = games.root_node(game).children["b"] assert n2.infoset.player == "2" assert n3.infoset.player == "3" game.make_infoset([n2, n3], "1") @@ -457,7 +459,7 @@ def test_infoset_proxy_reresolves_after_split(): """A node-anchored infoset proxy is lazy: it re-resolves after the node is placed in a new information set.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] + node = games.root_node(game).children["U1"] proxy = node.infoset assert len(proxy.members) == 2 game.make_infoset(node, node.player) @@ -469,7 +471,7 @@ def test_infoset_members_is_a_plain_snapshot_list(): integer indexing, and a list obtained before a mutation keeps reflecting the information set as it was at the time, rather than tracking its owner.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] + node = games.root_node(game).children["U1"] members = node.infoset.members assert isinstance(members, list) assert node in (members[0], members[1]) @@ -484,7 +486,7 @@ def test_reveal_splits_infoset_by_action(): game = games.create_stripped_down_poker_efg(nonterm_outcomes=True) n_alice = len(game.get_infosets("Alice")) assert len(game.get_infosets("Bob")) == 1 - game.reveal(game.root, "Bob") + game.reveal(games.root_node(game), "Bob") bob = game.get_infosets("Bob") assert len(bob) == 2 assert all(len(list(n.infoset.members)) == 1 for n in bob) @@ -494,10 +496,10 @@ def test_reveal_splits_infoset_by_action(): def test_reveal_absent_minded_infoset_raises(): """Revealing the move at an absent-minded infoset is rejected (17.0).""" game = gbt.Game.new_tree(players=["Driver", "2"]) - game.append_move(game.root, "Driver", ["Continue", "Exit"]) - mid = game.root.children["Continue"] + game.append_move(games.root_node(game), "Driver", ["Continue", "Exit"]) + mid = games.root_node(game).children["Continue"] game.append_move(mid, "Driver", ["Continue", "Exit"]) - game.make_infoset([game.root, mid], "Driver") + game.make_infoset([games.root_node(game), mid], "Driver") game.append_move(mid.children["Continue"], "2", ["l", "r"]) with pytest.raises(gbt.UndefinedOperationError): - game.reveal(game.root, "2") + game.reveal(games.root_node(game), "2") diff --git a/tests/test_nash.py b/tests/test_nash.py index d49b59968..3eef4c049 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -26,7 +26,7 @@ def d(*probs) -> tuple: 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] + return profile[node.history][label] @dataclasses.dataclass diff --git a/tests/test_node.py b/tests/test_node.py index f6e98692f..58378745a 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1,6 +1,5 @@ import dataclasses import functools -import itertools import typing import pytest @@ -13,17 +12,17 @@ def test_get_infoset(): """Test to ensure that we can retrieve an infoset for a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.infoset - assert game.root.children["U1"].infoset - assert not game.root.children["U1"].children["D2"].children["U3"].infoset + assert games.root_node(game).infoset + assert games.root_node(game).children["U1"].infoset + assert not games.root_node(game).children["U1"].children["D2"].children["U3"].infoset def test_infoset_equality_is_symmetric(): """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 = game.get_infosets(game.root.player)[0].infoset + proxy = games.root_node(game).infoset + infoset = game.get_infosets(games.root_node(game).player)[0].infoset assert proxy == infoset assert infoset == proxy @@ -32,7 +31,7 @@ def test_node_infoset_truthiness(): """A node-anchored infoset view is truthy iff the node currently has an infoset, and tracks mutation across the terminal boundary.""" game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["D2"].children["U3"] + terminal = games.root_node(game).children["U1"].children["D2"].children["U3"] proxy = terminal.infoset assert not proxy game.append_move(terminal, "Player 1", ["a", "b"]) @@ -43,16 +42,16 @@ def test_get_outcome(): """Test to ensure that we can retrieve an outcome for a given node""" game = games.read_from_file("basic_extensive_game.efg") assert ( - game.root.children["U1"].children["D2"].children["U3"].outcome + games.root_node(game).children["U1"].children["D2"].children["U3"].outcome == game.outcomes["Outcome 1"] ) - assert not game.root.outcome + assert not games.root_node(game).outcome def test_make_outcome_null(): """Resetting a node's outcome to null leaves the node's outcome view falsy.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"].children["U2"].children["U3"] + node = games.root_node(game).children["U1"].children["U2"].children["U3"] game.make_outcome_null(node) assert not node.outcome @@ -60,7 +59,7 @@ def test_make_outcome_null(): def test_node_outcome_subscript_tracks_mutation(): """Indexing the outcome view reads/writes the outcome's payoffs, reflecting mutation.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"].children["D2"].children["U3"] + node = games.root_node(game).children["U1"].children["D2"].children["U3"] proxy = node.outcome player = "Player 1" proxy[player] = 7 @@ -70,7 +69,7 @@ def test_node_outcome_subscript_tracks_mutation(): def test_outcome_equality_is_symmetric(): """A node-anchored outcome view and the resolved Outcome compare equal from either side.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"].children["D2"].children["U3"] + node = games.root_node(game).children["U1"].children["D2"].children["U3"] proxy = node.outcome outcome = game.outcomes["Outcome 1"] assert proxy == outcome @@ -80,53 +79,53 @@ def test_outcome_equality_is_symmetric(): def test_null_outcome_label_is_none(): """The blessed nullity idiom: a node with no outcome has `outcome.label is None`.""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.outcome.label is None + assert games.root_node(game).outcome.label is None def test_null_outcome_compares_unequal_to_itself(): """Null outcomes are unequal to everything, including another view of the same node's outcome; equality must not short-circuit on node identity.""" game = games.read_from_file("basic_extensive_game.efg") - assert (game.root.outcome == game.root.outcome) is False + assert (games.root_node(game).outcome == games.root_node(game).outcome) is False def test_null_outcome_reads_zero_payoffs(): """Reading a payoff through an unset node reports zero to every player of the game.""" game = games.read_from_file("basic_extensive_game.efg") for player in game.players: - assert game.root.outcome[player] == 0 + assert games.root_node(game).outcome[player] == 0 def test_null_outcome_payoff_write_raises(): game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.root.outcome["Player 1"] = 1 + games.root_node(game).outcome["Player 1"] = 1 def test_null_outcome_label_write_raises(): game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.root.outcome.label = "Outcome 4" + games.root_node(game).outcome.label = "Outcome 4" def test_null_outcome_number_is_none(): """The null outcome is not a member of the game's outcomes, so it has no number. -1 would be a valid index and would silently resolve to the last real outcome.""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.outcome.number is None + assert games.root_node(game).outcome.number is None def test_get_player(): """Test to ensure that we can retrieve a player for a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.player == "Player 1" - assert not game.root.children["U1"].children["D2"].children["U3"].player + assert games.root_node(game).player == "Player 1" + assert not games.root_node(game).children["U1"].children["D2"].children["U3"].player def test_node_player_resolves_chance(): """At a chance node, the player label is the chance player's.""" game = games.read_from_file("stripped_down_poker.efg") - chance_node = game.root + chance_node = games.root_node(game) assert chance_node.event assert chance_node.player == "Chance" @@ -134,42 +133,45 @@ def test_node_player_resolves_chance(): def test_get_game(): """Test to ensure that we can retrieve the game object from a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game == game.root.game + assert game == games.root_node(game).game def test_get_parent(): """Test to ensure that we can retrieve a parent node for a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].parent == game.root - assert game.root.parent is None + assert games.root_node(game).children["U1"].parent == games.root_node(game) + assert games.root_node(game).parent is None 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 == gbt.Branch(game.root, "U1") - assert game.root.prior_action is None + root = games.root_node(game) + assert root.children["U1"].prior_action == gbt.Branch(root, "U1") + assert root.prior_action is None def test_get_prior_sibling(): """Test to ensure that we can retrieve a prior sibling of a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["D1"].prior_sibling == game.root.children["U1"] - assert game.root.children["U1"].prior_sibling is None + root = games.root_node(game) + assert root.children["D1"].prior_sibling == root.children["U1"] + assert root.children["U1"].prior_sibling is None def test_get_next_sibling(): """Test to ensure that we can retrieve a next sibling of a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].next_sibling == game.root.children["D1"] - assert game.root.children["D1"].next_sibling is None + root = games.root_node(game) + assert root.children["U1"].next_sibling == root.children["D1"] + assert root.children["D1"].next_sibling is None def test_is_terminal(): """Test to ensure that we can check if a given node is a terminal node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.is_terminal is False - assert game.root.children["U1"].children["U2"].children["U3"].is_terminal is True + assert games.root_node(game).is_terminal is False + assert games.root_node(game).children["U1"].children["U2"].children["U3"].is_terminal is True def test_is_successor_of(): @@ -177,14 +179,14 @@ def test_is_successor_of(): successor of a supplied node """ game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].is_successor_of(game.root) - assert not game.root.is_successor_of(game.root.children["U1"]) + assert games.root_node(game).children["U1"].is_successor_of(games.root_node(game)) + assert not games.root_node(game).is_successor_of(games.root_node(game).children["U1"]) with pytest.raises(TypeError): - game.root.is_successor_of(9) + games.root_node(game).is_successor_of(9) with pytest.raises(TypeError): - game.root.is_successor_of("Test") + games.root_node(game).is_successor_of("Test") with pytest.raises(TypeError): - game.root.is_successor_of("Player 1") + games.root_node(game).is_successor_of("Player 1") def _get_path_of_action_labels(node: gbt.Node) -> list[str]: @@ -315,7 +317,7 @@ def test_subgame_roots(test_case: SubgameRootsTestCase): """ game = test_case.factory() - actual_roots = [node for node in game.nodes if node.is_subgame_root] + actual_roots = [node for node in games.all_nodes(game) if node.is_subgame_root] actual_paths = [_get_path_of_action_labels(node) for node in actual_roots] assert sorted(actual_paths) == sorted(test_case.expected_paths) @@ -535,7 +537,7 @@ def test_node_own_prior_action_non_terminal(game_file, expected_node_data): actual_node_data = [] - for node in game.nodes: + for node in games.all_nodes(game): if node.is_terminal: assert node.own_prior_action is None, ( f"Terminal node at {_get_path_of_action_labels(node)} must be None" @@ -576,7 +578,7 @@ def test_is_strategy_reachable(game_file: str, expected_unreachable_paths: list[ list of paths against a known-correct list. """ game = game_file if isinstance(game_file, gbt.Game) else games.read_from_file(game_file) - nodes = game.nodes + nodes = games.all_nodes(game) actual_unreachable_paths = [ _get_path_of_action_labels(node) for node in nodes if not node.is_strategy_reachable @@ -589,7 +591,7 @@ def test_append_move_error_player_actions(): """Test to ensure there are actions when appending with a player""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.append_move(game.root, "Player 1", []) + game.append_move(games.root_node(game), "Player 1", []) def test_append_move_error_infoset_mismatch(): @@ -597,48 +599,48 @@ 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) + game1.append_infoset(games.root_node(game1), games.root_node(game2)) def test_append_move_error_empty_label(): """Test that an empty label in `actions` is rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.append_move(game.root, "Player 1", ["a", ""]) + game.append_move(games.root_node(game), "Player 1", ["a", ""]) def test_append_move_error_duplicate_label(): """Test that duplicated labels in `actions` are rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.append_move(game.root, "Player 1", ["a", "a"]) + game.append_move(games.root_node(game), "Player 1", ["a", "a"]) def test_insert_move_error_player_actions(): """Test to ensure there are actions when inserting with a player""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.insert_move(game.root, "Player 1", []) + game.insert_move(games.root_node(game), "Player 1", []) def test_insert_move_error_empty_label(): """Test that an empty label in `actions` is rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.insert_move(game.root, "Player 1", ["a", ""]) + game.insert_move(games.root_node(game), "Player 1", ["a", ""]) def test_insert_move_error_duplicate_label(): """Test that duplicated labels in `actions` are rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.insert_move(game.root, "Player 1", ["a", "a"]) + game.insert_move(games.root_node(game), "Player 1", ["a", "a"]) def test_node_infoset_becomes_null_when_truncated(): """A captured infoset proxy re-resolves to null after the node is truncated to a leaf.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] + node = games.root_node(game).children["U1"] proxy = node.infoset assert proxy game.delete_tree(node) @@ -648,15 +650,15 @@ def test_node_infoset_becomes_null_when_truncated(): def test_node_delete_parent(): """Test to ensure deleting a parent node works""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] + node = games.root_node(game).children["U1"] game.delete_parent(node) - assert game.root == node + assert games.root_node(game) == node def test_node_delete_tree(): """Test to ensure deleting every child of a node works""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] + node = games.root_node(game).children["U1"] game.delete_tree(node) assert len(node.children) == 0 @@ -665,7 +667,7 @@ def test_node_copy_nonterminal(): """Test on copying to a nonterminal node.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.copy_tree(game.root, game.root) + game.copy_tree(games.root_node(game), games.root_node(game)) def test_node_copy_across_games(): @@ -675,9 +677,9 @@ def test_node_copy_across_games(): game1 = gbt.Game.new_tree() game2 = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.MismatchError): - game1.copy_tree(game1.root, game2.root) + game1.copy_tree(games.root_node(game1), games.root_node(game2)) with pytest.raises(gbt.MismatchError): - game1.copy_tree(game2.root, game1.root) + game1.copy_tree(games.root_node(game2), games.root_node(game1)) def _subtrees_equal( @@ -711,8 +713,8 @@ def _subtrees_equal( def test_copy_tree_onto_nondescendent_terminal_node(): """Test copying a subtree to a non-descendent node.""" g = gbt.catalog.load("journals/ijgt/selten1975/fig1") - src_node = g.root.children["R"].children["L"] - dest_node = g.root.children["R"].children["R"] + src_node = games.root_node(g).children["R"].children["L"] + dest_node = games.root_node(g).children["R"].children["R"] g.copy_tree(src_node, dest_node) @@ -722,8 +724,8 @@ def test_copy_tree_onto_nondescendent_terminal_node(): def test_copy_tree_onto_descendent_terminal_node(): """Test copying a subtree to a node that's a descendent of the original.""" g = gbt.catalog.load("journals/ijgt/selten1975/fig1") - src_node = g.root.children["R"] - dest_node = g.root.children["R"].children["L"].children["R"] + src_node = games.root_node(g).children["R"] + dest_node = games.root_node(g).children["R"].children["L"].children["R"] g.copy_tree(src_node, dest_node) @@ -734,14 +736,15 @@ def test_node_move_nonterminal(): """Test on moving to a nonterminal node.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.move_tree(game.root, game.root) + game.move_tree(games.root_node(game), games.root_node(game)) def test_node_move_successor(): """Test on moving a node to one of its successors.""" game = games.read_from_file("basic_extensive_game.efg") + root = games.root_node(game) with pytest.raises(gbt.UndefinedOperationError): - game.move_tree(game.root, game.root.children["U1"].children["U2"].children["U3"]) + game.move_tree(root, root.children["U1"].children["U2"].children["U3"]) def test_node_move_across_games(): @@ -751,18 +754,18 @@ def test_node_move_across_games(): game1 = gbt.Game.new_tree() game2 = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.MismatchError): - game1.move_tree(game1.root, game2.root) + game1.move_tree(games.root_node(game1), games.root_node(game2)) with pytest.raises(gbt.MismatchError): - game1.move_tree(game2.root, game1.root) + game1.move_tree(games.root_node(game2), games.root_node(game1)) def test_append_move_creates_single_infoset_list_of_nodes(): """Test that appending a list of nodes creates a single infoset.""" game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - nodes = [game.root.children["2"].children["1"], - game.root.children["1"].children["1"], - game.root.children["1"].children["2"]] + nodes = [games.root_node(game).children["2"].children["1"], + games.root_node(game).children["1"].children["1"], + games.root_node(game).children["1"].children["2"]] game.append_move(nodes, "Player 3", ["B", "F"]) assert len(game.get_infosets("Player 3")) == 1 @@ -771,8 +774,8 @@ def test_append_move_same_infoset_list_of_nodes(): """Test that nodes from a list of nodes are resolved in the same infoset.""" game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] + node1 = games.root_node(game).children["2"].children["1"] + node2 = games.root_node(game).children["1"].children["1"] game.append_move([node1, node2], "Player 3", ["B", "F"]) assert node1.infoset == node2.infoset @@ -783,8 +786,8 @@ def test_append_move_actions_list_of_nodes(): """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] + node1 = games.root_node(game).children["2"].children["1"] + node2 = games.root_node(game).children["1"].children["1"] game.append_move([node1, node2], "Player 3", ["B", "F", "S"]) assert list(node1.infoset.actions) == list(node2.infoset.actions) @@ -793,8 +796,8 @@ def test_append_move_actions_list_of_node_labels(): """Test that nodes from a list of node labels are resolved correctly.""" game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] + node1 = games.root_node(game).children["2"].children["1"] + node2 = games.root_node(game).children["1"].children["1"] node1.label = "0" node2.label = "00" game.append_move(["0", "00"], "Player 3", ["B", "F", "S"]) @@ -812,8 +815,8 @@ def test_append_move_actions_list_of_mixed_node_references(): game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] + node1 = games.root_node(game).children["2"].children["1"] + node2 = games.root_node(game).children["1"].children["1"] node1.label = "000" node_references = ["000", node2] game.append_move(node_references, "Player 3", ["B", "F", "S"]) @@ -829,8 +832,8 @@ def test_append_move_labels_list_of_nodes(): """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] + node1 = games.root_node(game).children["2"].children["1"] + node2 = games.root_node(game).children["1"].children["1"] game.append_move([node1, node2], "Player 3", ["B", "F", "S"]) assert node1.infoset.actions == node2.infoset.actions @@ -842,9 +845,10 @@ def test_append_move_node_list_with_non_terminal_node(): """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) + root = games.root_node(game) with pytest.raises(gbt.UndefinedOperationError): game.append_move( - [game.root.children["2"], game.root.children["1"].children["2"]], + [root.children["2"], root.children["1"].children["2"]], "Player 3", ["B", "F"] ) @@ -856,11 +860,11 @@ def test_append_move_node_list_with_duplicate_node_references(): """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - node = game.root.children["1"].children["2"] + node = games.root_node(game).children["1"].children["2"] node.label = "00" with pytest.raises(ValueError): game.append_move( - ["00", game.root.children["2"].children["1"], node], + ["00", games.root_node(game).children["2"].children["1"], node], "Player 3", ["B", "F"] ) @@ -882,11 +886,12 @@ def test_append_infoset_node_list_with_non_terminal_node(): """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - seed_node = game.root.children["1"].children["1"] + root = games.root_node(game) + seed_node = root.children["1"].children["1"] game.append_move(seed_node, "Player 3", ["B", "F"]) with pytest.raises(gbt.UndefinedOperationError): game.append_infoset( - [game.root.children["2"], game.root.children["1"].children["2"]], + [root.children["2"], root.children["1"].children["2"]], seed_node ) @@ -897,13 +902,13 @@ def test_append_infoset_node_list_with_duplicate_node(): """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - seed_node = game.root.children["1"].children["1"] + seed_node = games.root_node(game).children["1"].children["1"] game.append_move(seed_node, "Player 3", ["B", "F"]) with pytest.raises(ValueError): game.append_infoset( - [game.root.children["1"].children["2"], - game.root.children["2"].children["1"], - game.root.children["1"].children["2"]], + [games.root_node(game).children["1"].children["2"], + games.root_node(game).children["2"].children["1"], + games.root_node(game).children["1"].children["2"]], seed_node ) @@ -914,7 +919,7 @@ def test_append_infoset_node_list_is_empty(): """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - seed_node = game.root.children["1"].children["1"] + seed_node = games.root_node(game).children["1"].children["1"] game.append_move(seed_node, "Player 3", ["B", "F"]) with pytest.raises(ValueError): game.append_infoset([], seed_node) @@ -923,8 +928,8 @@ def test_append_infoset_node_list_is_empty(): def test_append_event_creates_single_event_list_of_nodes(): """Test that appending a list of nodes creates a single chance event.""" game = games.read_from_file("sample_extensive_game.efg") - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] + node1 = games.root_node(game).children["2"].children["1"] + node2 = games.root_node(game).children["1"].children["1"] game.append_event([node1, node2], ["a", "b"], [gbt.Rational(1, 2)] * 2) assert node1.event == node2.event assert node1.event @@ -933,7 +938,7 @@ def test_append_event_creates_single_event_list_of_nodes(): def test_append_event_sets_distribution(): """Test that the new event's actions carry the given probabilities.""" game = games.read_from_file("sample_extensive_game.efg") - node = game.root.children["1"].children["1"] + node = games.root_node(game).children["1"].children["1"] game.append_event(node, ["a", "b"], [gbt.Rational(1, 4), gbt.Rational(3, 4)]) assert list(node.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] @@ -941,7 +946,7 @@ def test_append_event_sets_distribution(): def test_append_event_error_actions_empty(): """Test to ensure there are actions when appending an event.""" game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] + terminal = games.root_node(game).children["U1"].children["U2"].children["U3"] with pytest.raises(gbt.UndefinedOperationError): game.append_event(terminal, [], []) @@ -951,13 +956,13 @@ def test_append_event_error_node_mismatch(): game1 = gbt.Game.new_tree() game2 = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.MismatchError): - game1.append_event(game2.root, ["a", "b"], [gbt.Rational(1, 2)] * 2) + game1.append_event(games.root_node(game2), ["a", "b"], [gbt.Rational(1, 2)] * 2) def test_append_event_error_empty_label(): """Test that an empty label in `actions` is rejected.""" game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] + terminal = games.root_node(game).children["U1"].children["U2"].children["U3"] with pytest.raises(ValueError): game.append_event(terminal, ["a", ""], [gbt.Rational(1, 2)] * 2) @@ -965,7 +970,7 @@ def test_append_event_error_empty_label(): def test_append_event_error_duplicate_label(): """Test that duplicated labels in `actions` are rejected.""" game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] + terminal = games.root_node(game).children["U1"].children["U2"].children["U3"] with pytest.raises(ValueError): game.append_event(terminal, ["a", "a"], [gbt.Rational(1, 2)] * 2) @@ -975,9 +980,10 @@ def test_append_event_error_node_list_with_non_terminal_node(): non-terminal node. """ game = games.read_from_file("sample_extensive_game.efg") + root = games.root_node(game) with pytest.raises(gbt.UndefinedOperationError): game.append_event( - [game.root.children["2"], game.root.children["1"].children["2"]], + [root.children["2"], root.children["1"].children["2"]], ["a", "b"], [gbt.Rational(1, 2)] * 2 ) @@ -986,7 +992,7 @@ def test_append_event_error_node_list_with_non_terminal_node(): def test_append_event_error_node_list_with_duplicate_node_references(): """Test that we get a ValueError when the node list has non-unique node references.""" game = games.read_from_file("sample_extensive_game.efg") - node = game.root.children["1"].children["2"] + node = games.root_node(game).children["1"].children["2"] with pytest.raises(ValueError): game.append_event([node, node], ["a", "b"], [gbt.Rational(1, 2)] * 2) @@ -1001,7 +1007,7 @@ def test_append_event_error_node_list_is_empty(): def test_append_event_error_invalid_distribution(): """Test that a distribution which does not sum to one is rejected.""" game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] + terminal = games.root_node(game).children["U1"].children["U2"].children["U3"] with pytest.raises(ValueError): game.append_event(terminal, ["a", "b"], [gbt.Rational(1, 2), gbt.Rational(1, 3)]) @@ -1009,7 +1015,7 @@ def test_append_event_error_invalid_distribution(): def test_insert_event_actions_labeled(): """Test that the inserted event's actions are labeled according to `actions`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - node = game.root.children["L"].children["R"] + node = games.root_node(game).children["L"].children["R"] game.insert_event(node, ["Up", "Down"], [gbt.Rational(1, 2)] * 2) assert list(node.parent.actions) == ["Up", "Down"] assert node.parent.event @@ -1018,7 +1024,7 @@ def test_insert_event_actions_labeled(): def test_insert_event_sets_distribution(): """Test that the inserted event's actions carry the given probabilities.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root + node = games.root_node(game) game.insert_event(node, ["a", "b"], [gbt.Rational(1, 4), gbt.Rational(3, 4)]) assert list(node.parent.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] @@ -1027,7 +1033,7 @@ def test_insert_event_error_actions_empty(): """Test to ensure there are actions when inserting an event.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.insert_event(game.root, [], []) + game.insert_event(games.root_node(game), [], []) def test_insert_event_error_node_mismatch(): @@ -1035,28 +1041,30 @@ def test_insert_event_error_node_mismatch(): game1 = gbt.Game.new_tree() game2 = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.MismatchError): - game1.insert_event(game2.root, ["a", "b"], [gbt.Rational(1, 2)] * 2) + game1.insert_event(games.root_node(game2), ["a", "b"], [gbt.Rational(1, 2)] * 2) def test_insert_event_error_empty_label(): """Test that an empty label in `actions` is rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.insert_event(game.root, ["a", ""], [gbt.Rational(1, 2)] * 2) + game.insert_event(games.root_node(game), ["a", ""], [gbt.Rational(1, 2)] * 2) def test_insert_event_error_duplicate_label(): """Test that duplicated labels in `actions` are rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.insert_event(game.root, ["a", "a"], [gbt.Rational(1, 2)] * 2) + game.insert_event(games.root_node(game), ["a", "a"], [gbt.Rational(1, 2)] * 2) def test_insert_event_error_invalid_distribution(): """Test that a distribution which does not sum to one is rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.insert_event(game.root, ["a", "b"], [gbt.Rational(1, 2), gbt.Rational(1, 3)]) + game.insert_event( + games.root_node(game), ["a", "b"], [gbt.Rational(1, 2), gbt.Rational(1, 3)] + ) def _count_subtree_nodes(start_node: gbt.Node, count_terminal: bool) -> int: @@ -1077,38 +1085,38 @@ def _count_subtree_nodes(start_node: gbt.Node, count_terminal: bool) -> int: def test_len_matches_expected_node_count(): - """Verify `len(game.nodes)` matches expected node count + """Verify `len(games.all_nodes(game))` matches expected node count """ game = gbt.catalog.load("journals/ijgt/selten1975/fig1") expected_node_count = 9 - direct_len = len(game.nodes) + direct_len = len(games.all_nodes(game)) assert direct_len == expected_node_count - assert direct_len == _count_subtree_nodes(game.root, True) + assert direct_len == _count_subtree_nodes(games.root_node(game), True) def test_len_after_delete_tree(): - """Verify `len(game.nodes)` is correct after `delete_tree`. + """Verify `len(games.all_nodes(game))` is correct after `delete_tree`. """ game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = len(games.all_nodes(game)) - root_of_the_deleted_subtree = game.root.children["R"].children["L"] + root_of_the_deleted_subtree = games.root_node(game).children["R"].children["L"] number_of_deleted_nodes = _count_subtree_nodes(root_of_the_deleted_subtree, True) - 1 game.delete_tree(root_of_the_deleted_subtree) - assert len(game.nodes) == initial_number_of_nodes - number_of_deleted_nodes + assert len(games.all_nodes(game)) == initial_number_of_nodes - number_of_deleted_nodes def test_len_after_delete_parent(): - """Verify `len(game.nodes)` is correct after `delete_parent`. + """Verify `len(games.all_nodes(game))` is correct after `delete_parent`. """ game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = len(games.all_nodes(game)) - node_parent_to_delete = game.root.children["L"].children["L"] + node_parent_to_delete = games.root_node(game).children["L"].children["L"] number_of_node_ancestors = _count_subtree_nodes(node_parent_to_delete, True) number_of_parent_ancestors = _count_subtree_nodes(node_parent_to_delete.parent, True) @@ -1116,113 +1124,114 @@ def test_len_after_delete_parent(): game.delete_parent(node_parent_to_delete) - assert len(game.nodes) == initial_number_of_nodes - diff + assert len(games.all_nodes(game)) == initial_number_of_nodes - diff def test_len_after_append_move(): - """Verify `len(game.nodes)` is correct after `append_move`.""" + """Verify `len(games.all_nodes(game))` is correct after `append_move`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = len(games.all_nodes(game)) - terminal_node = game.root.children["R"].children["L"].children["L"] # the [1,1,0] terminal + # the [1,1,0] terminal + terminal_node = games.root_node(game).children["R"].children["L"].children["L"] player = "Player 1" actions_to_add = ["T", "M", "B"] game.append_move(terminal_node, player, actions_to_add) - assert len(game.nodes) == initial_number_of_nodes + len(actions_to_add) + assert len(games.all_nodes(game)) == initial_number_of_nodes + len(actions_to_add) def test_len_after_append_infoset(): - """Verify `len(game.nodes)` is correct after `append_infoset`. + """Verify `len(games.all_nodes(game))` is correct after `append_infoset`. """ game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = len(games.all_nodes(game)) - member_node = game.root.children["L"] + member_node = games.root_node(game).children["L"] infoset_to_modify = member_node.infoset number_of_infoset_actions = len(infoset_to_modify.actions) - terminal_node_to_add = game.root.children["L"].children["L"].children["l"] + terminal_node_to_add = games.root_node(game).children["L"].children["L"].children["l"] game.append_infoset(terminal_node_to_add, member_node) - assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions + assert len(games.all_nodes(game)) == initial_number_of_nodes + number_of_infoset_actions def test_len_after_set_move_actions_add(): - """Verify `len(game.nodes)` is correct after `set_move_actions` creates an action.""" + """Verify the node count is correct after `set_move_actions` creates an action.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) - infoset_to_modify = game.root.children["L"].infoset # Player 2's infoset + initial_number_of_nodes = len(games.all_nodes(game)) + infoset_to_modify = games.root_node(game).children["L"].infoset # Player 2's infoset num_nodes_in_infoset = len(infoset_to_modify.members) 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 + game.set_move_actions(games.root_node(game).children["L"], labels + ["new"]) + assert len(games.all_nodes(game)) == initial_number_of_nodes + num_nodes_in_infoset def test_len_after_set_move_actions_drop(): - """Verify `len(game.nodes)` is correct after `set_move_actions` deletes an action.""" + """Verify the node count is correct after `set_move_actions` deletes an action.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = len(games.all_nodes(game)) action_to_drop = "L" nodes_to_delete = sum( _count_subtree_nodes(member.children[action_to_drop], True) - for member in game.root.infoset.members + for member in games.root_node(game).infoset.members ) - 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 + remaining = [a for a in games.root_node(game).infoset.actions if a != "L"] + game.set_move_actions(games.root_node(game), remaining, drop=True) + assert len(games.all_nodes(game)) == initial_number_of_nodes - nodes_to_delete def test_len_after_insert_move(): - """Verify `len(game.nodes)` is correct after `insert_move`.""" + """Verify `len(games.all_nodes(game))` is correct after `insert_move`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = len(games.all_nodes(game)) - node_to_insert_above = game.root.children["L"].children["R"] # the [1, 0] node + node_to_insert_above = games.root_node(game).children["L"].children["R"] # the [1, 0] node player = "Player 2" actions_to_add = ["a", "b", "c"] game.insert_move(node_to_insert_above, player, actions_to_add) - assert len(game.nodes) == initial_number_of_nodes + len(actions_to_add) + assert len(games.all_nodes(game)) == initial_number_of_nodes + len(actions_to_add) def test_insert_move_actions_labeled(): """Test that the inserted move's actions are labeled according to `actions`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - node = game.root.children["L"].children["R"] + node = games.root_node(game).children["L"].children["R"] game.insert_move(node, "Player 2", ["Up", "Down"]) assert list(node.parent.infoset.actions) == ["Up", "Down"] def test_len_after_insert_infoset(): - """Verify `len(game.nodes)` is correct after `insert_infoset`. + """Verify `len(games.all_nodes(game))` is correct after `insert_infoset`. """ game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = len(games.all_nodes(game)) - infoset_to_modify = game.root.children["L"].infoset - node_to_insert_above = game.root.children["L"].children["R"] + infoset_to_modify = games.root_node(game).children["L"].infoset + node_to_insert_above = games.root_node(game).children["L"].children["R"] number_of_infoset_actions = len(infoset_to_modify.actions) - game.insert_infoset(node_to_insert_above, game.root.children["L"]) + game.insert_infoset(node_to_insert_above, games.root_node(game).children["L"]) - assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions + assert len(games.all_nodes(game)) == initial_number_of_nodes + number_of_infoset_actions def test_len_after_copy_tree(): - """Verify `len(game.nodes)` is correct after `copy_tree`. + """Verify `len(games.all_nodes(game))` is correct after `copy_tree`. """ game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) - src_node = game.root.children["R"].children["L"] - dest_node = game.root.children["R"].children["R"] + initial_number_of_nodes = len(games.all_nodes(game)) + src_node = games.root_node(game).children["R"].children["L"] + dest_node = games.root_node(game).children["R"].children["R"] number_of_src_ancestors = _count_subtree_nodes(src_node, True) game.copy_tree(src_node, dest_node) - assert len(game.nodes) == initial_number_of_nodes + number_of_src_ancestors - 1 + assert len(games.all_nodes(game)) == initial_number_of_nodes + number_of_src_ancestors - 1 def test_node_plays(): @@ -1230,12 +1239,12 @@ def test_node_plays(): """ game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - test_node = game.root.children["L"] + test_node = games.root_node(game).children["L"] expected_set_of_plays = { - game.root.children["L"].children["R"], - game.root.children["L"].children["L"].children["r"], - game.root.children["L"].children["L"].children["l"], + games.root_node(game).children["L"].children["R"], + games.root_node(game).children["L"].children["L"].children["r"], + games.root_node(game).children["L"].children["L"].children["l"], } assert set(test_node.plays) == expected_set_of_plays @@ -1248,20 +1257,21 @@ def test_node_children_action_label(): on both sides would make the assertion circular. """ game = games.read_from_file("stripped_down_poker.efg") - root_children = list(game.root.children) - assert game.root.children["King"] == root_children[0] - assert game.root.children["Queen"].children["Fold"] == list(root_children[1].children)[1] + root = games.root_node(game) + root_children = list(root.children) + assert root.children["King"] == root_children[0] + assert root.children["Queen"].children["Fold"] == list(root_children[1].children)[1] def test_node_children_empty_label(): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(ValueError, match="empty or all whitespace"): - _ = game.root.children[" "] + _ = games.root_node(game).children[" "] def test_node_children_terminal_node(): game = games.read_from_file("stripped_down_poker.efg") - terminal = next(n for n in game.nodes if n.is_terminal) + terminal = next(n for n in games.all_nodes(game) if n.is_terminal) with pytest.raises(KeyError, match="No action with label"): _ = terminal.children["Bet"] @@ -1269,71 +1279,48 @@ def test_node_children_terminal_node(): def test_node_children_nonexistent_action(): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(KeyError, match="No action with label 'Jack'"): - _ = game.root.children["Jack"] + _ = games.root_node(game).children["Jack"] def test_node_children_rejects_int(): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(TypeError, match="16.7.0"): - _ = game.root.children[0] + _ = games.root_node(game).children[0] @pytest.mark.parametrize("label", games.VALID_LABELS) def test_node_label_valid(label): game = games.read_from_file("basic_extensive_game.efg") - game.root.label = label - assert game.root.label == label + games.root_node(game).label = label + assert games.root_node(game).label == label def test_node_label_duplicate_raises_valueerror(): game = games.read_from_file("basic_extensive_game.efg") - game.root.label = "shared" + games.root_node(game).label = "shared" with pytest.raises(ValueError): - game.root.children["U1"].label = "shared" + games.root_node(game).children["U1"].label = "shared" def test_node_label_empty_is_allowed(): """Node labels may be empty (unlike outcomes/players); multiple empties coexist.""" game = games.read_from_file("basic_extensive_game.efg") - game.root.label = "" - game.root.children["U1"].label = "" - assert game.root.label == "" - assert game.root.children["U1"].label == "" + games.root_node(game).label = "" + games.root_node(game).children["U1"].label = "" + assert games.root_node(game).label == "" + assert games.root_node(game).children["U1"].label == "" @pytest.mark.parametrize("label", games.INVALID_LABELS) def test_node_label_invalid_raises_valueerror(label): game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.root.label = label + games.root_node(game).label = label @pytest.mark.parametrize("label", games.UNICODE_LABELS) def test_node_label_unicode_accepted(label): """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" game = games.read_from_file("basic_extensive_game.efg") - game.root.label = label - assert game.root.label == label - - -@pytest.mark.parametrize( - "game_obj", - [ - pytest.param(games.read_from_file("basic_extensive_game.efg")), - pytest.param(games.read_from_file("binary_3_levels_generic_payoffs.efg")), - pytest.param(games.read_from_file("cent3.efg")), - pytest.param(gbt.catalog.load("journals/ijgt/selten1975/fig1")), - pytest.param(gbt.catalog.load("journals/ijgt/selten1975/fig2")), - pytest.param(games.read_from_file("stripped_down_poker.efg")), - pytest.param(gbt.Game.new_tree()), - ], -) -def test_nodes_iteration_order(game_obj: gbt.Game): - """Verify that the C++ `game.nodes` iterator produces the DFS traversal. - """ - def dfs(node: gbt.Node) -> typing.Iterator[gbt.Node]: - yield node - for child in node.children: - yield from dfs(child) - - assert all(a == b for a, b in itertools.zip_longest(game_obj.nodes, dfs(game_obj.root))) + games.root_node(game).label = label + assert games.root_node(game).label == label diff --git a/tests/test_outcomes.py b/tests/test_outcomes.py index bae455da1..a1bfcb052 100644 --- a/tests/test_outcomes.py +++ b/tests/test_outcomes.py @@ -7,8 +7,8 @@ def test_make_outcome_attaches_to_all_given_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children outcome = game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") assert up.outcome == outcome assert middle.outcome == outcome @@ -19,8 +19,8 @@ def test_make_outcome_attaches_to_all_given_nodes(): def test_make_outcome_accepts_selector(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children outcome = game.make_outcome(gbt.H.path("U"), {"Alice": 1, "Bob": -1}, "shared") assert up.outcome == outcome assert not middle.outcome @@ -29,8 +29,8 @@ def test_make_outcome_accepts_selector(): def test_make_outcome_accepts_selector_matching_several_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children outcome = game.make_outcome(gbt.H.plays, {"Alice": 1, "Bob": -1}, "shared") assert up.outcome == outcome assert middle.outcome == outcome @@ -39,8 +39,8 @@ def test_make_outcome_accepts_selector_matching_several_nodes(): def test_make_outcome_accepts_history_tuple(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children outcome = game.make_outcome(("U",), {"Alice": 1, "Bob": -1}, "shared") assert up.outcome == outcome assert not middle.outcome @@ -49,8 +49,8 @@ def test_make_outcome_accepts_history_tuple(): def test_make_outcome_accepts_iterable_of_history_tuples(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children outcome = game.make_outcome([("U",), ("M",)], {"Alice": 1, "Bob": -1}, "shared") assert up.outcome == outcome assert middle.outcome == outcome @@ -70,8 +70,8 @@ def test_make_outcome_attaches_at_contingencies(): def test_make_outcome_absorbs_fully_covered_outcome_and_reuses_label(): game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["U", "D"]) - up, down = game.root.children + game.append_move(games.root_node(game), "Alice", ["U", "D"]) + up, down = games.root_node(game).children game.make_outcome(up, {"Alice": 1}, "w") game.make_outcome([up, down], {"Alice": 2}, "w") assert [(o.label, o["Alice"]) for o in game.outcomes] == [("w", 2)] @@ -79,8 +79,8 @@ def test_make_outcome_absorbs_fully_covered_outcome_and_reuses_label(): def test_make_outcome_label_of_partially_covered_outcome_refused(): game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children game.make_outcome([up, middle], {"Alice": 1}, "w") with pytest.raises(ValueError): game.make_outcome(down, {"Alice": 2}, "w") @@ -90,8 +90,8 @@ def test_make_outcome_label_of_partially_covered_outcome_refused(): @pytest.mark.parametrize("bad_label", ["", "win"]) def test_make_outcome_bad_label_raises_and_leaves_game_unchanged(bad_label: str): game = gbt.Game.new_tree(players=["A", "B"]) - game.append_move(game.root, "A", ["win", "lose"]) - win_node, lose_node = game.root.children + game.append_move(games.root_node(game), "A", ["win", "lose"]) + win_node, lose_node = games.root_node(game).children game.make_outcome(win_node, {"A": 1, "B": 2}, "win") with pytest.raises(ValueError): game.make_outcome(lose_node, {"A": 3, "B": 4}, bad_label) @@ -100,9 +100,9 @@ def test_make_outcome_bad_label_raises_and_leaves_game_unchanged(bad_label: str) def test_make_outcome_incomplete_payoffs_raises(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "D"]) + game.append_move(games.root_node(game), "Alice", ["U", "D"]) with pytest.raises(ValueError): - game.make_outcome(next(iter(game.root.children)), {"Alice": 1}, "w") + game.make_outcome(next(iter(games.root_node(game).children)), {"Alice": 1}, "w") class _RepeatedEntryPayoffs: @@ -122,16 +122,16 @@ def items(self): def test_make_outcome_payoffs_naming_player_twice_raises(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "D"]) + game.append_move(games.root_node(game), "Alice", ["U", "D"]) payoffs = _RepeatedEntryPayoffs([("Alice", 1), ("Alice", 2), ("Bob", 0)]) with pytest.raises(ValueError): - game.make_outcome(next(iter(game.root.children)), payoffs, "w") + game.make_outcome(next(iter(games.root_node(game).children)), payoffs, "w") def test_make_outcome_null_resets_given_nodes_to_null(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") game.make_outcome_null(up) assert not up.outcome @@ -141,8 +141,8 @@ def test_make_outcome_null_resets_given_nodes_to_null(): def test_make_outcome_null_accepts_selector(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") game.make_outcome_null(gbt.H.path("U")) assert not up.outcome @@ -152,8 +152,8 @@ def test_make_outcome_null_accepts_selector(): def test_make_outcome_null_accepts_history_tuple(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") game.make_outcome_null(("U",)) assert not up.outcome @@ -183,8 +183,8 @@ def test_make_outcome_null_removes_fully_orphaned_outcome(): def test_make_outcome_null_keeps_partially_referenced_outcome(): game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children game.make_outcome([up, middle], {"Alice": 1}, "shared") outcome_count = len(game.outcomes) game.make_outcome_null(up) @@ -194,8 +194,8 @@ def test_make_outcome_null_keeps_partially_referenced_outcome(): def test_make_outcome_null_on_already_null_node_is_a_no_op(): game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["U", "D"]) - up, _ = game.root.children + game.append_move(games.root_node(game), "Alice", ["U", "D"]) + up, _ = games.root_node(game).children outcome_count = len(game.outcomes) game.make_outcome_null(up) assert outcome_count == len(game.outcomes) @@ -271,8 +271,8 @@ def test_outcome_payoff_by_player_label(): def test_outcome_relabel_duplicate_rejected_and_label_unchanged(): game = gbt.Game.new_tree(players=["A", "B"]) - game.append_move(game.root, "A", ["win", "lose"]) - win_node, lose_node = game.root.children + game.append_move(games.root_node(game), "A", ["win", "lose"]) + win_node, lose_node = games.root_node(game).children game.make_outcome(win_node, {"A": 1, "B": 2}, "win") outcome = game.make_outcome(lose_node, {"A": 0, "B": 0}, "lose") with pytest.raises(ValueError): diff --git a/tests/test_players.py b/tests/test_players.py index 76708e2fd..e362bf53f 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -375,7 +375,7 @@ def test_player_get_min_payoff_nonterminal_outcomes(): game = games.read_from_file("stripped_down_poker.efg") assert game.get_min_payoff("Alice") == -2 assert game.get_min_payoff("Bob") == -2 - game.make_outcome(game.root, {"Alice": -1, "Bob": -1}, "outcome") + game.make_outcome(games.root_node(game), {"Alice": -1, "Bob": -1}, "outcome") assert game.get_min_payoff("Alice") == -3 assert game.get_min_payoff("Bob") == -3 @@ -401,7 +401,7 @@ def test_player_get_max_payoff_nonterminal_outcomes(): game = games.read_from_file("stripped_down_poker.efg") assert game.get_max_payoff("Alice") == 2 assert game.get_max_payoff("Bob") == 2 - game.make_outcome(game.root, {"Alice": -1, "Bob": -1}, "outcome") + game.make_outcome(games.root_node(game), {"Alice": -1, "Bob": -1}, "outcome") assert game.get_max_payoff("Alice") == 1 assert game.get_max_payoff("Bob") == 1 diff --git a/tests/test_qre.py b/tests/test_qre.py index fd5770edc..bcfab0800 100644 --- a/tests/test_qre.py +++ b/tests/test_qre.py @@ -15,7 +15,7 @@ def _asymmetric_poker_behavior_data() -> gbt.MixedBehaviorProfile: for player in game.players: for infoset in games.player_infosets(game, player): node = next(iter(infoset.members)) - data[node] = {a: float(i + 2) for i, a in enumerate(infoset.actions)} + data[node.history] = {a: float(i + 2) for i, a in enumerate(infoset.actions)} return data @@ -75,6 +75,6 @@ def test_logit_estimate_behavior_completes(use_empirical: bool, local_max: bool) for player in data.game.players: for infoset in games.player_infosets(data.game, player): node = next(iter(infoset.members)) - probs = dict(result.profile[node]) + probs = dict(result.profile[node.history]) 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 cffb85497..da937893f 100644 --- a/tests/test_strategic.py +++ b/tests/test_strategic.py @@ -12,18 +12,6 @@ def test_strategic_game_get_infosets(): _ = game.get_infosets(player) -def test_strategic_game_root(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.root - - -def test_strategic_game_nodes(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.nodes - - def test_game_behav_profile_error(): game = gbt.Game.new_table([2, 2]) with pytest.raises(gbt.UndefinedOperationError): From 39bc8c9211bcb669851aa9cd190df2f3e4d5cae3 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 14:24:19 +0100 Subject: [PATCH 13/13] Fix tutorial notebooks broken by Game.root/Game.nodes removal 02_extensive_form.ipynb, 03_stripped_down_poker.ipynb, and interoperability_tutorials/openspiel.ipynb all built games via game.root/game.root.children[...] chains, and the poker notebook also indexed a MixedBehaviorProfile by Node (eqm[node]/eqm[bob_node]). Switched construction to H.path(...)/H.path(...)-with-wildcards and profile indexing to node.history, matching the API changes landed in 07146c3e6. Verified two ways: (1) built each fixed game programmatically and diffed its outcome/infoset structure against a reconstruction using the old Node-based navigation (via a get_nodes(H.path())[0] stand-in for the removed game.root) -- identical in every case; (2) executed full copies of the notebooks end-to-end (--allow-errors, outside the repo) and confirmed every remaining error is a `draw(...)` cell failing inside gtdraw itself (an external package -- flagged separately, not fixed here), not a pygambit-API cell. Three other notebooks (h_selector_prototype, agent_versus_non_agent_ regret, 04_creating_images) have no in-repo API usage to fix -- they fail solely through gtdraw's own game.root call in its layout code, so there's nothing to change here; they'll pass once gtdraw is fixed upstream. Co-Authored-By: Claude Sonnet 5 --- doc/tutorials/02_extensive_form.ipynb | 28 +++++- doc/tutorials/03_stripped_down_poker.ipynb | 96 ++++++++++++++----- .../openspiel.ipynb | 54 ++++++++++- 3 files changed, 146 insertions(+), 32 deletions(-) diff --git a/doc/tutorials/02_extensive_form.ipynb b/doc/tutorials/02_extensive_form.ipynb index 97ae7c99a..30ce8828a 100644 --- a/doc/tutorials/02_extensive_form.ipynb +++ b/doc/tutorials/02_extensive_form.ipynb @@ -106,7 +106,7 @@ "outputs": [], "source": [ "g.append_move(\n", - " g.root, # This is the node to append the move to\n", + " gbt.H.path(), # This is the node to append the move to\n", " player=\"Buyer\",\n", " actions=[\"Trust\", \"Not trust\"]\n", ")" @@ -138,7 +138,7 @@ "outputs": [], "source": [ "g.append_move(\n", - " g.root.children[\"Trust\"],\n", + " gbt.H.path(\"Trust\"),\n", " player=\"Seller\",\n", " actions=[\"Honor\", \"Abuse\"]\n", ")" @@ -172,7 +172,13 @@ "id": "716e9b9a", "metadata": {}, "outputs": [], - "source": "g.make_outcome(\n g.root.children[\"Trust\"].children[\"Honor\"],\n {\"Buyer\": 1, \"Seller\": 1},\n \"Trustworthy\"\n)" + "source": [ + "g.make_outcome(\n", + " gbt.H.path(\"Trust\", \"Honor\"),\n", + " {\"Buyer\": 1, \"Seller\": 1},\n", + " \"Trustworthy\"\n", + ")" + ] }, { "cell_type": "code", @@ -198,7 +204,13 @@ "id": "695b1aad", "metadata": {}, "outputs": [], - "source": "g.make_outcome(\n g.root.children[\"Trust\"].children[\"Abuse\"],\n {\"Buyer\": -1, \"Seller\": 2},\n \"Untrustworthy\"\n)" + "source": [ + "g.make_outcome(\n", + " gbt.H.path(\"Trust\", \"Abuse\"),\n", + " {\"Buyer\": -1, \"Seller\": 2},\n", + " \"Untrustworthy\"\n", + ")" + ] }, { "cell_type": "code", @@ -224,7 +236,13 @@ "id": "0704ef86", "metadata": {}, "outputs": [], - "source": "g.make_outcome(\n g.root.children[\"Not trust\"],\n {\"Buyer\": 0, \"Seller\": 0},\n \"Opt-out\"\n)" + "source": [ + "g.make_outcome(\n", + " gbt.H.path(\"Not trust\"),\n", + " {\"Buyer\": 0, \"Seller\": 0},\n", + " \"Opt-out\"\n", + ")" + ] }, { "cell_type": "code", diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 0c3b3c81f..ea02973d2 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -104,7 +104,13 @@ "id": "fe80c64c", "metadata": {}, "outputs": [], - "source": "g.append_event(\n g.root,\n actions=[\"King\", \"Queen\"],\n probs=[gbt.Rational(1, 2), gbt.Rational(1, 2)]\n)" + "source": [ + "g.append_event(\n", + " gbt.H.path(),\n", + " actions=[\"King\", \"Queen\"],\n", + " probs=[gbt.Rational(1, 2), gbt.Rational(1, 2)]\n", + ")" + ] }, { "cell_type": "code", @@ -137,7 +143,7 @@ "metadata": {}, "outputs": [], "source": [ - "for node in g.root.children:\n", + "for node in g.get_nodes(gbt.H.path(...)):\n", " g.append_move(\n", " node,\n", " player=\"Alice\",\n", @@ -162,15 +168,15 @@ "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", + " - Chance player chooses King, then Alice Bets: `gbt.H.path(\"King\", \"Bet\")`\n", + " - Chance player chooses Queen, then Alice Bets: `gbt.H.path(\"Queen\", \"Bet\")`\n", "\n", "In other words, Bob's decision when Alice Bets with a Queen should be part of the same information set as Bob's decision when Alice Bets with a King.\n", "\n", "To set this scenario up in Gambit, we'll need to add both possible moves as part of the same information set (represented in Gambit as an `Infoset`).\n", - "This can be done by passing a list of nodes to the `append_move` method:" + "This can be done by passing a selector matching both nodes to the `append_move` method:" ] }, { @@ -181,7 +187,7 @@ "outputs": [], "source": [ "g.append_move(\n", - " [g.root.children[\"King\"].children[\"Bet\"], g.root.children[\"Queen\"].children[\"Bet\"]],\n", + " gbt.H.path(..., \"Bet\"),\n", " player=\"Bob\",\n", " actions=[\"Call\", \"Fold\"]\n", ")" @@ -209,7 +215,35 @@ "id": "29aa60a0", "metadata": {}, "outputs": [], - "source": "# Alice folds, Bob wins small\ng.make_outcome(\n [g.root.children[\"King\"].children[\"Fold\"], g.root.children[\"Queen\"].children[\"Fold\"]],\n {\"Alice\": -1, \"Bob\": 1},\n \"Lose\"\n)\n\n# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\ng.make_outcome(\n g.root.children[\"Queen\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": -2, \"Bob\": 2},\n \"Lose Big\"\n)\n\n# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\ng.make_outcome(\n g.root.children[\"King\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": 2, \"Bob\": -2},\n \"Win Big\"\n)\n\n# Bob does not call Alice's Bet, Alice wins small\ng.make_outcome(\n [g.root.children[\"King\"].children[\"Bet\"].children[\"Fold\"],\n g.root.children[\"Queen\"].children[\"Bet\"].children[\"Fold\"]],\n {\"Alice\": 1, \"Bob\": -1},\n \"Win\"\n)" + "source": [ + "# Alice folds, Bob wins small\n", + "g.make_outcome(\n", + " gbt.H.path(..., \"Fold\"),\n", + " {\"Alice\": -1, \"Bob\": 1},\n", + " \"Lose\"\n", + ")\n", + "\n", + "# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\n", + "g.make_outcome(\n", + " gbt.H.path(\"Queen\", \"Bet\", \"Call\"),\n", + " {\"Alice\": -2, \"Bob\": 2},\n", + " \"Lose Big\"\n", + ")\n", + "\n", + "# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\n", + "g.make_outcome(\n", + " gbt.H.path(\"King\", \"Bet\", \"Call\"),\n", + " {\"Alice\": 2, \"Bob\": -2},\n", + " \"Win Big\"\n", + ")\n", + "\n", + "# Bob does not call Alice's Bet, Alice wins small\n", + "g.make_outcome(\n", + " gbt.H.path(..., \"Bet\", \"Fold\"),\n", + " {\"Alice\": 1, \"Bob\": -1},\n", + " \"Win\"\n", + ")" + ] }, { "cell_type": "code", @@ -347,7 +381,7 @@ " 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", + " f\"Alice plays {action} with probability: {eqm[node.history][action]}\"\n", " )" ] }, @@ -356,7 +390,7 @@ "id": "1f121d48", "metadata": {}, "source": [ - "Now let's look at Bob’s strategy:" + "Now let's look at Bob\u2019s strategy:" ] }, { @@ -374,7 +408,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:" @@ -386,7 +420,11 @@ "id": "2966e700", "metadata": {}, "outputs": [], - "source": "(bob_node,) = g.get_infosets(\"Bob\")\nbob_infoset = bob_node.infoset\neqm[bob_node][\"Call\"]" + "source": [ + "(bob_node,) = g.get_infosets(\"Bob\")\n", + "bob_infoset = bob_node.infoset\n", + "eqm[bob_node.history][\"Call\"]" + ] }, { "cell_type": "markdown", @@ -421,7 +459,7 @@ "\n", "`MixedBehaviorProfile.beliefs` returns the probability of reaching each node, conditional on its information set being reached.\n", "\n", - "Recall that the two nodes in Bob's only information set are `g.root.children[\"King\"].children[\"Bet\"]` and `g.root.children[\"Queen\"].children[\"Bet\"]`):" + "Recall that the two nodes in Bob's only information set are `gbt.H.path(\"King\", \"Bet\")` and `gbt.H.path(\"Queen\", \"Bet\")`):" ] }, { @@ -819,8 +857,9 @@ "outputs": [], "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())" + "root = small_game.get_nodes(gbt.H.path())[0]\n", + "small_game.append_event(root, [\"a\", \"b\", \"c\"], [gbt.Rational(1, 3)] * 3)\n", + "list(root.action_probs.values())" ] }, { @@ -837,10 +876,10 @@ "outputs": [], "source": [ "small_game.make_event(\n", - " [small_game.root],\n", + " [root],\n", " [gbt.Rational(1, 4), gbt.Rational(1, 2), gbt.Rational(1, 4)]\n", ")\n", - "list(small_game.root.action_probs.values())" + "list(root.action_probs.values())" ] }, { @@ -859,10 +898,10 @@ "outputs": [], "source": [ "small_game.make_event(\n", - " [small_game.root],\n", + " [root],\n", " [gbt.Decimal(\".25\"), gbt.Decimal(\".50\"), gbt.Decimal(\".25\")]\n", ")\n", - "list(small_game.root.action_probs.values())" + "list(root.action_probs.values())" ] }, { @@ -884,8 +923,8 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [\"1/4\", \"1/2\", \"1/4\"])\n", - "list(small_game.root.action_probs.values())" + "small_game.make_event([root], [\"1/4\", \"1/2\", \"1/4\"])\n", + "list(root.action_probs.values())" ] }, { @@ -895,8 +934,8 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [\".25\", \".50\", \".25\"])\n", - "list(small_game.root.action_probs.values())" + "small_game.make_event([root], [\".25\", \".50\", \".25\"])\n", + "list(root.action_probs.values())" ] }, { @@ -919,8 +958,8 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [.25, .50, .25])\n", - "list(small_game.root.action_probs.values())" + "small_game.make_event([root], [.25, .50, .25])\n", + "list(root.action_probs.values())" ] }, { @@ -937,7 +976,12 @@ "id": "1991d288", "metadata": {}, "outputs": [], - "source": "try:\n small_game.make_event([small_game.root], [1/3, 1/3, 1/3])\nexcept ValueError as e:\n print(\"ValueError:\", e)\n" + "source": [ + "try:\n", + " small_game.make_event([root], [1/3, 1/3, 1/3])\n", + "except ValueError as e:\n", + " print(\"ValueError:\", e)\n" + ] }, { "cell_type": "markdown", diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index 0b8e7ea64..a9e251a1a 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -666,7 +666,59 @@ "id": "77dc34c8", "metadata": {}, "outputs": [], - "source": "gbt_one_card_poker = gbt.Game.new_tree(\n players=[\"Alice\", \"Bob\"],\n title=\"Stripped-Down Poker: a simple game of one-card poker from Reiley et al (2008).\"\n)\n\ngbt_one_card_poker.append_event(\n gbt_one_card_poker.root,\n actions=[\"King\", \"Queen\"],\n probs=[gbt.Rational(1, 2), gbt.Rational(1, 2)]\n)\n\nfor node in gbt_one_card_poker.root.children:\n gbt_one_card_poker.append_move(\n node,\n player=\"Alice\",\n actions=[\"Bet\", \"Fold\"]\n )\n\ngbt_one_card_poker.append_move(\n [\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"],\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"]\n ],\n player=\"Bob\",\n actions=[\"Call\", \"Fold\"]\n)\n\n# Alice folds, Bob wins small\ngbt_one_card_poker.make_outcome(\n [\n gbt_one_card_poker.root.children[\"King\"].children[\"Fold\"],\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Fold\"]\n ],\n {\"Alice\": -1, \"Bob\": 1},\n \"Lose\"\n)\n\n# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\ngbt_one_card_poker.make_outcome(\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": -2, \"Bob\": 2},\n \"Lose Big\"\n)\n\n# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\ngbt_one_card_poker.make_outcome(\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": 2, \"Bob\": -2},\n \"Win Big\"\n)\n\n# Bob does not call Alice's Bet, Alice wins small\ngbt_one_card_poker.make_outcome(\n [\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"].children[\"Fold\"],\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"].children[\"Fold\"]\n ],\n {\"Alice\": 1, \"Bob\": -1},\n \"Win\"\n)" + "source": [ + "gbt_one_card_poker = gbt.Game.new_tree(\n", + " players=[\"Alice\", \"Bob\"],\n", + " title=\"Stripped-Down Poker: a simple game of one-card poker from Reiley et al (2008).\"\n", + ")\n", + "\n", + "gbt_one_card_poker.append_event(\n", + " gbt.H.path(),\n", + " actions=[\"King\", \"Queen\"],\n", + " probs=[gbt.Rational(1, 2), gbt.Rational(1, 2)]\n", + ")\n", + "\n", + "for node in gbt_one_card_poker.get_nodes(gbt.H.path(...)):\n", + " gbt_one_card_poker.append_move(\n", + " node,\n", + " player=\"Alice\",\n", + " actions=[\"Bet\", \"Fold\"]\n", + " )\n", + "\n", + "gbt_one_card_poker.append_move(\n", + " gbt.H.path(..., \"Bet\"),\n", + " player=\"Bob\",\n", + " actions=[\"Call\", \"Fold\"]\n", + ")\n", + "\n", + "# Alice folds, Bob wins small\n", + "gbt_one_card_poker.make_outcome(\n", + " gbt.H.path(..., \"Fold\"),\n", + " {\"Alice\": -1, \"Bob\": 1},\n", + " \"Lose\"\n", + ")\n", + "\n", + "# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\n", + "gbt_one_card_poker.make_outcome(\n", + " gbt.H.path(\"Queen\", \"Bet\", \"Call\"),\n", + " {\"Alice\": -2, \"Bob\": 2},\n", + " \"Lose Big\"\n", + ")\n", + "\n", + "# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\n", + "gbt_one_card_poker.make_outcome(\n", + " gbt.H.path(\"King\", \"Bet\", \"Call\"),\n", + " {\"Alice\": 2, \"Bob\": -2},\n", + " \"Win Big\"\n", + ")\n", + "\n", + "# Bob does not call Alice's Bet, Alice wins small\n", + "gbt_one_card_poker.make_outcome(\n", + " gbt.H.path(..., \"Bet\", \"Fold\"),\n", + " {\"Alice\": 1, \"Bob\": -1},\n", + " \"Win\"\n", + ")" + ] }, { "cell_type": "code",