From a66993a0188c075aec816ca4c646c2653e4aeea8 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 18:05:20 +0100 Subject: [PATCH 01/25] 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 f5bfac7aa..b9c12b68a 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 03ff0bf7387e7a0d2fcf312603ccd1f240a7c407 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 18:35:21 +0100 Subject: [PATCH 02/25] 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 b9c12b68a..849e36e5d 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 a6e724afaa6083c7740571c4c54092cb7d97b489 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 18:39:43 +0100 Subject: [PATCH 03/25] 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 849e36e5d..b068ef51b 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 f4dcbb4fc7853b4bf5ce72bea3e77ae591089dac Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 18:43:52 +0100 Subject: [PATCH 04/25] 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 b068ef51b..2e7fb4438 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 9d82d615ecb55ee116632859a3b059d0ba5041f4 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 18:48:34 +0100 Subject: [PATCH 05/25] 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 2e7fb4438..9f49b8d2d 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: @@ -1405,7 +1414,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) @@ -1548,7 +1562,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 @@ -1556,6 +1570,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 @@ -1568,6 +1590,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 73b4537ea1553be32fc7a3dd22212383f7dc08f5 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 18:52:58 +0100 Subject: [PATCH 06/25] 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 9f49b8d2d..8a7bf95f1 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1394,6 +1394,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( @@ -1422,7 +1433,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 f55e1a04c61f800c732782d3cbb30a745c1baf1d Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 18:59:42 +0100 Subject: [PATCH 07/25] 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 8a7bf95f1..099f8df1c 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: @@ -1603,6 +1628,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 ab58c6facee129643c0bc79810cc33164fca096e Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 19:19:19 +0100 Subject: [PATCH 08/25] 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 47de94766f70de297f6aa84d65f6da8fdbb8ff4f Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 2 Sep 2026 19:47:13 +0100 Subject: [PATCH 09/25] 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 6e75493822cefdfa8246af4b6524d1307351d924 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 12:02:43 +0100 Subject: [PATCH 10/25] 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 099f8df1c..5743b2d6b 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -2419,8 +2419,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). @@ -2457,10 +2459,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. @@ -2469,7 +2472,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 @@ -2541,10 +2544,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. @@ -2552,7 +2556,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 2a5c303479177374a261a536d80ffe7b97ecd5d9 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 12:20:34 +0100 Subject: [PATCH 11/25] 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 966ae02b717eada36702ca5fb67151d969fa22dd Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 17:34:31 +0100 Subject: [PATCH 12/25] Make H-selector machinery private --- .../h_selector_prototype.ipynb | 14 +++---- src/pygambit/game.pxi | 40 +++++++++---------- src/pygambit/hsel.pxi | 9 +++-- tests/test_hsel.py | 8 ++-- 4 files changed, 34 insertions(+), 37 deletions(-) diff --git a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb index fa02233b6..05e091d84 100644 --- a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb +++ b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb @@ -112,7 +112,7 @@ "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", + " sorted(g._get_histories(H.path(\"L\")) + g._get_histories(H.path(\"R\", \"L\"))),\n", ")" ] }, @@ -223,7 +223,7 @@ " 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", + "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", @@ -292,11 +292,11 @@ " (\"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", + "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)))" + "print(\"terminal histories:\", len(g._get_histories(H.plays)))" ] }, { @@ -360,7 +360,7 @@ "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", + "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)}\")" ] @@ -420,7 +420,7 @@ " 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", + "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", @@ -484,7 +484,7 @@ "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", + " sorted(g._get_histories(H.path()) + g._get_histories(H.path(\"S\"))),\n", ")" ] } diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 5743b2d6b..2ade6a4ed 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -542,17 +542,15 @@ class Game: ) return Node.wrap(self.game.deref().GetRoot()) - def get_nodes(self, selector: Selector) -> list[Node]: + 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 + Internal: the `H` selector algebra's evaluator, interpreting 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 + C++ tree directly. Not part of the public API yet -- used to resolve + a `Selector`/`GroupedSelector` argument to `append_move`, + `append_event`, `append_infoset`, and `make_outcome`. """ current: list = None for op in selector._ops: @@ -577,26 +575,23 @@ class Game: if op.predicate(HistoryView._wrap(node, _history_of(node))) ] else: - raise TypeError(f"get_nodes(): unknown selector op {op!r}") + 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]: + 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 + Internal: the History-materializing counterpart to `_get_nodes`, kept + for use by `_get_groups` and tests. Not part of the public API yet. """ - return [_history_of(node) for node in self.get_nodes(selector)] + 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 + """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. @@ -609,7 +604,7 @@ class Game: docstring for why). """ result: dict = {} - for node in self.get_nodes(grouped.base): + 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) @@ -632,12 +627,13 @@ class Game: result = next_result return result - def get_groups(self, grouped: GroupedSelector) -> dict: + 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 + Internal: the History-materializing counterpart to `_group_nodes`, + kept for use by tests. Not part of the public API yet. """ return { key: [_history_of(node) for node in nodes] @@ -1420,7 +1416,7 @@ class Game: raise MismatchError(f"{funcname}(): {argname} must be part of the same game") return node elif isinstance(node, Selector): - resolved = self.get_nodes(node) + resolved = self._get_nodes(node) if len(resolved) != 1: raise ValueError( f"{funcname}(): {argname} selector must resolve to exactly one " @@ -1452,10 +1448,10 @@ class 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. + against this game via `_get_nodes` before the usual resolution. """ if isinstance(nodes, Selector): - nodes = self.get_nodes(nodes) + 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, tuple)) diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi index f3f30f552..a3c9d2610 100644 --- a/src/pygambit/hsel.pxi +++ b/src/pygambit/hsel.pxi @@ -85,8 +85,8 @@ class _FilterStep: 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`. + any game -- it's just a recipe, evaluated only when handed to a `Game` + method that accepts one, such as `append_move` or `make_outcome`. .. versionadded:: 17.0.0 """ @@ -135,8 +135,9 @@ class Selector: class GroupedSelector: - """Result of `.by(callable)`. Game-neutral until evaluated -- call - `Game.get_groups` and iterate its result as `(key, group)` pairs. + """Result of `.by(callable)`. Game-neutral until evaluated -- pass to a + `Game` method that accepts a `GroupedSelector`, such as `append_move`, + which dispatches one call per group. `.plays`/`.after(...)` chain onto a `GroupedSelector` the same way they chain onto a plain `Selector`, but apply per-group: each group's own diff --git a/tests/test_hsel.py b/tests/test_hsel.py index f7a732e4d..0a13a9ffa 100644 --- a/tests/test_hsel.py +++ b/tests/test_hsel.py @@ -14,7 +14,7 @@ def key(h): captured[h[:]] = h.members return frozenset(h.members) - groups = game.get_groups(gbt.H.path(...).by(key)) + groups = game._get_groups(gbt.H.path(...).by(key)) assert captured == { ("U",): [("U",), ("D",)], ("D",): [("U",), ("D",)], @@ -34,7 +34,7 @@ def key(h): captured[h[:]] = h.members return None - game.get_groups(gbt.H.path(...).by(key)) + game._get_groups(gbt.H.path(...).by(key)) assert captured == {("U",): [("U",)], ("D",): [("D",)]} @@ -49,7 +49,7 @@ def key(h): captured[h[:]] = h.members return None - game.get_groups(gbt.H.path(...).by(key)) + game._get_groups(gbt.H.path(...).by(key)) assert captured == {("L",): [("L",), ("R",)], ("R",): [("L",), ("R",)]} @@ -62,4 +62,4 @@ def key(h): _ = h.members return None - game.get_groups(gbt.H.plays.by(key)) + game._get_groups(gbt.H.plays.by(key)) From ba89f625544d1fa8c8109a0d132d49109f670bb7 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 18:05:23 +0100 Subject: [PATCH 13/25] `append_move` only takes a selector --- doc/tutorials/02_extensive_form.ipynb | 6 +- doc/tutorials/03_stripped_down_poker.ipynb | 20 ++-- .../openspiel.ipynb | 2 +- src/pygambit/game.pxi | 31 ++++-- tests/cli/conftest.py | 6 +- tests/games.py | 64 +++++------ tests/test_actions.py | 12 +- tests/test_behavspt_profiles.py | 6 +- tests/test_game.py | 4 +- tests/test_infosets.py | 16 +-- tests/test_node.py | 103 +++++++++--------- tests/test_outcomes.py | 32 +++--- 12 files changed, 154 insertions(+), 148 deletions(-) diff --git a/doc/tutorials/02_extensive_form.ipynb b/doc/tutorials/02_extensive_form.ipynb index 97ae7c99a..b8640b89b 100644 --- a/doc/tutorials/02_extensive_form.ipynb +++ b/doc/tutorials/02_extensive_form.ipynb @@ -93,7 +93,7 @@ "id": "962b4e52", "metadata": {}, "source": [ - "To extend a game from an existing terminal node, use `Game.append_move`. To begin with, the sole root node is the terminal node.\n", + "To extend a game from an existing terminal node, use `Game.append_move`. `append_move` takes an `H`-built selector identifying the node(s) to add the move at, rather than a `Node` object directly; `gbt.H.path()` (with no arguments) selects the root itself, which to begin with is the sole terminal node.\n", "\n", "Here we extend the game from the root node by adding the first move for the \"Buyer\" player, creating two child nodes (one for each possible action)." ] @@ -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(), # Selects the root node\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", ")" diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 0c3b3c81f..a7430d2e6 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -126,8 +126,8 @@ "In this game, information structure is important.\n", "Alice knows her card, so the two nodes at which she has the move are part of different **information sets**.\n", "\n", - "We'll therefore need to append Alice's move separately for each of the root node's children, i.e. the scenarios where she has a King or a Queen.\n", - "Let's now add both of these possible moves:" + "We'll therefore need to append Alice's move separately for each possible card, i.e. the scenarios where she has a King or a Queen.\n", + "`append_move` takes an `H`-built selector describing which node(s) to add the move at; `gbt.H.path(label)` describes the node reached by taking the action labeled `label` from the root:" ] }, { @@ -137,12 +137,8 @@ "metadata": {}, "outputs": [], "source": [ - "for node in g.root.children:\n", - " g.append_move(\n", - " node,\n", - " player=\"Alice\",\n", - " actions=[\"Bet\", \"Fold\"]\n", - " )" + "for card in [\"King\", \"Queen\"]:\n", + " g.append_move(gbt.H.path(card), player=\"Alice\", actions=[\"Bet\", \"Fold\"])" ] }, { @@ -164,13 +160,13 @@ "\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", "\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 with a single selector: `gbt.H.path(..., \"Bet\")` describes the node reached by *any* single action from the root (either card), followed by \"Bet\" -- so it matches both of Bob's decision nodes at once, joining them into one information set:" ] }, { @@ -181,7 +177,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", ")" diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index 0b8e7ea64..6877f8f72 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -666,7 +666,7 @@ "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\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 card in [\"King\", \"Queen\"]:\n gbt_one_card_poker.append_move(\n gbt.H.path(card),\n player=\"Alice\",\n actions=[\"Bet\", \"Fold\"]\n )\n\ngbt_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\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)" }, { "cell_type": "code", diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 2ade6a4ed..11e95263d 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1594,7 +1594,7 @@ class Game: raise IndexError(f"{funcname}(): must specify exactly one probability per action") return probs - def append_move(self, nodes: Node | NodeReferenceSet | Selector | GroupedSelector, + def append_move(self, nodes: Selector | GroupedSelector, player: str, actions: list[str]) -> None: """Add a move for `player` at terminal `nodes`. All elements of `nodes` become part of @@ -1602,20 +1602,21 @@ 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. + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) 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`. + `nodes` is now a `Selector` or `GroupedSelector`; a `Node` or + `NodeReferenceSet` is no longer accepted directly -- build one with `H`. Raises ------ + TypeError + If `nodes` is not a `Selector` or `GroupedSelector`. UndefinedOperationError If `nodes` are not all terminal, or `actions` is empty. - MismatchError - If an element from `nodes` is a `Node` from a different game. KeyError If no player in the game has label `player`. ValueError @@ -1626,8 +1627,20 @@ class Game: for group in self._group_nodes(nodes).values(): if not group: continue - self.append_move(group, player, actions) + self._append_move_at(group, player, actions) return + if not isinstance(nodes, Selector): + raise TypeError( + f"append_move(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) + self._append_move_at(nodes, player, actions) + + def _append_move_at(self, nodes: Selector | list[Node], player: str, + actions: list[str]) -> None: + """Internal: shared body of `append_move`, taking either a `Selector` or an + already-resolved list of `Node` (the latter used for one group at a time, + dispatched from a `GroupedSelector`).""" resolved_player = self._resolve_player(player, "append_move") if not actions: raise UndefinedOperationError("append_move(): `actions` must be a nonempty list") diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 9d913b2ef..0fe246b79 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -97,10 +97,10 @@ 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"]) + game.append_move(gbt.H.path(), "1", ["L", "R"]) left, right = game.root.children - game.append_move(left, "2", ["x", "y", "z"]) - game.append_move(right, "2", ["p", "q"]) + game.append_move(gbt.H.path("L"), "2", ["x", "y", "z"]) + game.append_move(gbt.H.path("R"), "2", ["p", "q"]) for node in left.children: payoff = [1, 1] if node.prior_action.label == "x" else [0, 0] game.make_outcome(node, {"1": payoff[0], "2": payoff[1]}, node.prior_action.label) diff --git a/tests/games.py b/tests/games.py index 07fc521c7..8b6b61879 100644 --- a/tests/games.py +++ b/tests/games.py @@ -93,8 +93,8 @@ 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(gbt.H.path(), "1", actions1) + g.append_move(gbt.H.path(...), "2", actions2) for i, j in itertools.product(range(m), range(n)): node = g.root.children[str(i)].children[str(j)] g.make_outcome(node, {"1": A[i, j], "2": B[i, j]}, f"({i},{j})") @@ -165,14 +165,17 @@ def create_stripped_down_poker_efg(nonterm_outcomes: bool = False) -> gbt.Game: deals = ["King", "Queen"] g.append_event(g.root, deals, [gbt.Rational(1, 2)] * 2) - for node in g.root.children: - g.append_move(node, player="Alice", actions=["Bet", "Fold"]) + for card in deals: + g.append_move( + gbt.H.path(...).filter(lambda h, card=card: h[0] == card), + player="Alice", actions=["Bet", "Fold"] + ) alice_bets_nodes = [ g.root.children["King"].children["Bet"], g.root.children["Queen"].children["Bet"], ] - g.append_move(alice_bets_nodes, player="Bob", actions=["Call", "Fold"]) + g.append_move(gbt.H.path(..., "Bet"), player="Bob", actions=["Call", "Fold"]) g.make_outcome(g.root, {"Alice": -1, "Bob": -1}, "Ante") g.make_outcome( @@ -199,34 +202,31 @@ def _create_kuhn_poker_efg_without_outcomes(): cards = ["J", "Q", "K"] deals = ["JQ", "JK", "QJ", "QK", "KJ", "KQ"] - 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) for alice_card in cards: # Alice's first move - term_nodes = [g.root.children[d] for d in deals_by_infoset("Alice", alice_card)] - g.append_move(term_nodes, "Alice", ["Check", "Bet"]) + g.append_move( + gbt.H.path(...).filter(lambda h, card=alice_card: h[0][0] == card), + "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) - ] - g.append_move(term_nodes, "Bob", ["Check", "Bet"]) + g.append_move( + gbt.H.path(..., "Check").filter(lambda h, card=bob_card: h[0][1] == card), + "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"] - for d in deals_by_infoset("Alice", alice_card) - ] - g.append_move(term_nodes, "Alice", ["Fold", "Call"]) + g.append_move( + gbt.H.path(..., "Check", "Bet").filter(lambda h, card=alice_card: h[0][0] == card), + "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) - ] - g.append_move(term_nodes, "Bob", ["Fold", "Call"]) + g.append_move( + gbt.H.path(..., "Bet").filter(lambda h, card=bob_card: h[0][1] == card), + "Bob", ["Fold", "Call"] + ) return g @@ -439,8 +439,8 @@ 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(gbt.H.path(), "Buyer", ["Trust", "Not trust"]) + g.append_move(gbt.H.path("Trust"), "Seller", ["Honor", "Abuse"]) g.make_outcome( g.root.children["Trust"].children["Honor"], {"Buyer": 1, "Seller": 1}, "Trustworthy" ) @@ -569,7 +569,7 @@ def gbt_game(self): current_node = g.root current_player = "1" for t in range(self.N): - g.append_move(current_node, current_player, ["Take", "Push"]) + g.append_move(gbt.H.path(*(["Push"] * t)), current_player, ["Take", "Push"]) payoffs = [2**t * self.m0, 2**t * self.m1] # take payoffs if current_player == "2": payoffs.reverse() @@ -700,7 +700,7 @@ def reduced_strategies(self): self.set_size_of_rsf(rs) return rs - def create_binary_tree(self, g, node, whose_turn, depth, max_depth): + def create_binary_tree(self, g, node, path, whose_turn, depth, max_depth): # whose_turn cycles through 0,1,n_players-1; current player is str(whose_turn + 1) if depth == max_depth: g.make_outcome( @@ -708,18 +708,18 @@ def create_binary_tree(self, g, node, whose_turn, depth, max_depth): ) else: current_player = str(whose_turn + 1) - g.append_move(node, current_player, ["L", "R"]) + g.append_move(gbt.H.path(*path), current_player, ["L", "R"]) whose_turn = (whose_turn + 1) % self.n_players - for child in node.children: - self.create_binary_tree(g, child, whose_turn, depth + 1, max_depth) + for label, child in zip(["L", "R"], node.children, strict=True): + self.create_binary_tree(g, child, (*path, label), whose_turn, depth + 1, max_depth) def gbt_game(self): g = gbt.Game.new_tree( 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) + self.create_binary_tree(g, g.root, (), 0, 0, self.level) for n in g.nodes: if not n.is_terminal and not n.children["L"].is_terminal: left = n.children["L"] diff --git a/tests/test_actions.py b/tests/test_actions.py index 970f21b08..9a29a00a1 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -128,10 +128,12 @@ 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"]) + game.append_move(gbt.H.path(), "Bob", ["x", "y"]) + game.append_move(gbt.H.path(...), "Alice", ["a", "b", "c"]) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in (("x", "a"), ("y", "b"))), + "Bob", ["l", "r"] + ) infoset = game.root.children["x"].infoset members = list(infoset.members) children_before = [{label: member.children[label] for label in ("a", "b", "c")} @@ -191,7 +193,7 @@ 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_move(gbt.H.path(), "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"] diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index cfb210d9e..a0c1a1c9a 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -20,11 +20,11 @@ def _branching_game(): """ game = gbt.Game.new_tree(players=["P1", "P2"]) root = game.root - game.append_move(root, "P1", ["L", "R"]) + game.append_move(gbt.H.path(), "P1", ["L", "R"]) left = root.children["L"] right = root.children["R"] - game.append_move(left, "P2", ["A", "B"]) - game.append_move(right, "P2", ["A", "B"]) + game.append_move(gbt.H.path("L"), "P2", ["A", "B"]) + game.append_move(gbt.H.path("R"), "P2", ["A", "B"]) root.infoset.label = "P1 infoset" left.infoset.label = "P2 left infoset" right.infoset.label = "P2 right infoset" diff --git a/tests/test_game.py b/tests/test_game.py index d3a9cfb9b..5687509f4 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(gbt.H.path(), "Alice", ["a", "b"]) with pytest.raises(gbt.UndefinedOperationError): _ = game.get_outcome({"Alice": "a"}) @@ -169,7 +169,7 @@ 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"]) + game.append_move(gbt.H.path(), "Alice", ["a", "b"]) infoset = game.root.infoset strategy = next( s for s in game.get_strategies("Alice") diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 3ad6b3ba1..0c96e4c68 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -97,8 +97,8 @@ 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(gbt.H.path(), "1", ["a", "b"]) + game.append_move(gbt.H.path("a"), "1", node_actions) with pytest.raises(ValueError): game.make_infoset([game.root, game.root.children["a"]], "1") @@ -440,9 +440,9 @@ 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 + game.append_move(gbt.H.path(), "1", ["a", "b"]) + game.append_move(gbt.H.path("a"), "2", ["a", "b"]) # player 2 + game.append_move(gbt.H.path("b"), "3", ["a", "b"]) # player 3 n2 = game.root.children["a"] n3 = game.root.children["b"] assert n2.infoset.player == "2" @@ -494,10 +494,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"]) + game.append_move(gbt.H.path(), "Driver", ["Continue", "Exit"]) mid = game.root.children["Continue"] - game.append_move(mid, "Driver", ["Continue", "Exit"]) + game.append_move(gbt.H.path("Continue"), "Driver", ["Continue", "Exit"]) game.make_infoset([game.root, mid], "Driver") - game.append_move(mid.children["Continue"], "2", ["l", "r"]) + game.append_move(gbt.H.path("Continue", "Continue"), "2", ["l", "r"]) with pytest.raises(gbt.UndefinedOperationError): game.reveal(game.root, "2") diff --git a/tests/test_node.py b/tests/test_node.py index f6e98692f..cc90008e4 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -35,7 +35,7 @@ def test_node_infoset_truthiness(): terminal = game.root.children["U1"].children["D2"].children["U3"] proxy = terminal.infoset assert not proxy - game.append_move(terminal, "Player 1", ["a", "b"]) + game.append_move(gbt.H.path("U1", "D2", "U3"), "Player 1", ["a", "b"]) assert proxy @@ -589,7 +589,7 @@ def test_append_move_error_player_actions(): """Test to ensure there are actions when appending with a player""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.append_move(game.root, "Player 1", []) + game.append_move(gbt.H.path(), "Player 1", []) def test_append_move_error_infoset_mismatch(): @@ -604,14 +604,14 @@ 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(gbt.H.path(), "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(gbt.H.path(), "Player 1", ["a", "a"]) def test_insert_move_error_player_actions(): @@ -757,47 +757,58 @@ def test_node_move_across_games(): def test_append_move_creates_single_infoset_list_of_nodes(): - """Test that appending a list of nodes creates a single infoset.""" + """Test that appending a Selector matching several 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"]] - game.append_move(nodes, "Player 3", ["B", "F"]) + matches = (("2", "1"), ("1", "1"), ("1", "2")) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + "Player 3", ["B", "F"] + ) assert len(game.get_infosets("Player 3")) == 1 def test_append_move_same_infoset_list_of_nodes(): - """Test that nodes from a list of nodes are resolved in the same infoset.""" + """Test that nodes matched by a Selector 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"] - game.append_move([node1, node2], "Player 3", ["B", "F"]) + matches = (("2", "1"), ("1", "1")) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), "Player 3", ["B", "F"] + ) assert node1.infoset == node2.infoset def test_append_move_actions_list_of_nodes(): - """Test that nodes from a list of nodes that resolved in the same infoset + """Test that nodes matched by a Selector that resolved in the same infoset have the same actions. """ 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"] - game.append_move([node1, node2], "Player 3", ["B", "F", "S"]) + matches = (("2", "1"), ("1", "1")) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + "Player 3", ["B", "F", "S"] + ) assert list(node1.infoset.actions) == list(node2.infoset.actions) -def test_append_move_actions_list_of_node_labels(): - """Test that nodes from a list of node labels are resolved correctly.""" +def test_append_infoset_actions_list_of_node_labels(): + """Test that nodes referenced by label are resolved correctly when joining + an existing infoset via `append_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.label = "0" node2.label = "00" - game.append_move(["0", "00"], "Player 3", ["B", "F", "S"]) + game.append_move(gbt.H.path("2", "1"), "Player 3", ["B", "F", "S"]) + game.append_infoset(["00"], "0") assert node1.children["B"].parent.label == "0" assert node2.children["B"].parent.label == "00" @@ -805,18 +816,19 @@ def test_append_move_actions_list_of_node_labels(): assert len(node2.children) == 3 -def test_append_move_actions_list_of_mixed_node_references(): - """Test that nodes from a list of nodes with either 'node' or str references - are resolved correctly. +def test_append_infoset_actions_list_of_mixed_node_references(): + """Test that nodes from a list with either 'Node' or str references are + resolved correctly when joining an existing infoset via `append_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"] + seed = game.root.children["1"].children["2"] node1.label = "000" - node_references = ["000", node2] - game.append_move(node_references, "Player 3", ["B", "F", "S"]) + game.append_move(gbt.H.path("1", "2"), "Player 3", ["B", "F", "S"]) + game.append_infoset(["000", node2], seed) assert node1.children["B"].parent.label == "000" assert len(node1.children) == 3 @@ -824,56 +836,40 @@ def test_append_move_actions_list_of_mixed_node_references(): def test_append_move_labels_list_of_nodes(): - """Test that nodes from a list of nodes that resolved in the same infoset + """Test that nodes matched by a Selector that resolved in the same infoset have the same labels per action. """ 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"] - game.append_move([node1, node2], "Player 3", ["B", "F", "S"]) + matches = (("2", "1"), ("1", "1")) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + "Player 3", ["B", "F", "S"] + ) assert node1.infoset.actions == node2.infoset.actions def test_append_move_node_list_with_non_terminal_node(): - """Test that we get an UndefinedOperationError when we import in append_move a list - of nodes that has a non-terminal node. + """Test that we get an UndefinedOperationError when a Selector passed to + append_move matches a non-terminal node. """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) with pytest.raises(gbt.UndefinedOperationError): - game.append_move( - [game.root.children["2"], game.root.children["1"].children["2"]], - "Player 3", - ["B", "F"] - ) - - -def test_append_move_node_list_with_duplicate_node_references(): - """Test that we get a ValueError when we import in append_move a list - nodes with non-unique 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.label = "00" - with pytest.raises(ValueError): - game.append_move( - ["00", game.root.children["2"].children["1"], node], - "Player 3", - ["B", "F"] - ) + game.append_move(gbt.H.path(...), "Player 3", ["B", "F"]) def test_append_move_node_list_is_empty(): - """Test that we get a ValueError when we import in append_move an - empty list of nodes. + """Test that we get a ValueError when a Selector passed to append_move + matches no nodes. """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) with pytest.raises(ValueError): - game.append_move([], "Player 3", ["B", "F"]) + game.append_move(gbt.H.path(...).filter(lambda h: False), "Player 3", ["B", "F"]) def test_append_infoset_node_list_with_non_terminal_node(): @@ -883,7 +879,7 @@ 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"] - game.append_move(seed_node, "Player 3", ["B", "F"]) + game.append_move(gbt.H.path("1", "1"), "Player 3", ["B", "F"]) with pytest.raises(gbt.UndefinedOperationError): game.append_infoset( [game.root.children["2"], game.root.children["1"].children["2"]], @@ -898,7 +894,7 @@ 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"] - game.append_move(seed_node, "Player 3", ["B", "F"]) + game.append_move(gbt.H.path("1", "1"), "Player 3", ["B", "F"]) with pytest.raises(ValueError): game.append_infoset( [game.root.children["1"].children["2"], @@ -915,7 +911,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"] - game.append_move(seed_node, "Player 3", ["B", "F"]) + game.append_move(gbt.H.path("1", "1"), "Player 3", ["B", "F"]) with pytest.raises(ValueError): game.append_infoset([], seed_node) @@ -1124,11 +1120,10 @@ def test_len_after_append_move(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = len(game.nodes) - terminal_node = game.root.children["R"].children["L"].children["L"] # the [1,1,0] terminal player = "Player 1" actions_to_add = ["T", "M", "B"] - game.append_move(terminal_node, player, actions_to_add) + game.append_move(gbt.H.path("R", "L", "L"), player, actions_to_add) # the [1,1,0] terminal assert len(game.nodes) == initial_number_of_nodes + len(actions_to_add) diff --git a/tests/test_outcomes.py b/tests/test_outcomes.py index bae455da1..159ed69a7 100644 --- a/tests/test_outcomes.py +++ b/tests/test_outcomes.py @@ -7,7 +7,7 @@ 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"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) up, middle, down = game.root.children outcome = game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") assert up.outcome == outcome @@ -19,7 +19,7 @@ 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"]) + game.append_move(gbt.H.path(), "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 @@ -29,7 +29,7 @@ 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"]) + game.append_move(gbt.H.path(), "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 @@ -39,7 +39,7 @@ 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"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) up, middle, down = game.root.children outcome = game.make_outcome(("U",), {"Alice": 1, "Bob": -1}, "shared") assert up.outcome == outcome @@ -49,7 +49,7 @@ 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"]) + game.append_move(gbt.H.path(), "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 @@ -70,7 +70,7 @@ 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"]) + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) up, down = game.root.children game.make_outcome(up, {"Alice": 1}, "w") game.make_outcome([up, down], {"Alice": 2}, "w") @@ -79,7 +79,7 @@ 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"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) up, middle, down = game.root.children game.make_outcome([up, middle], {"Alice": 1}, "w") with pytest.raises(ValueError): @@ -90,7 +90,7 @@ 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"]) + game.append_move(gbt.H.path(), "A", ["win", "lose"]) win_node, lose_node = game.root.children game.make_outcome(win_node, {"A": 1, "B": 2}, "win") with pytest.raises(ValueError): @@ -100,7 +100,7 @@ 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(gbt.H.path(), "Alice", ["U", "D"]) with pytest.raises(ValueError): game.make_outcome(next(iter(game.root.children)), {"Alice": 1}, "w") @@ -122,7 +122,7 @@ 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(gbt.H.path(), "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") @@ -130,7 +130,7 @@ def test_make_outcome_payoffs_naming_player_twice_raises(): 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"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) up, middle, down = game.root.children game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") game.make_outcome_null(up) @@ -141,7 +141,7 @@ 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"]) + game.append_move(gbt.H.path(), "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")) @@ -152,7 +152,7 @@ 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"]) + game.append_move(gbt.H.path(), "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",)) @@ -183,7 +183,7 @@ 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"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) up, middle, down = game.root.children game.make_outcome([up, middle], {"Alice": 1}, "shared") outcome_count = len(game.outcomes) @@ -194,7 +194,7 @@ 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"]) + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) up, _ = game.root.children outcome_count = len(game.outcomes) game.make_outcome_null(up) @@ -271,7 +271,7 @@ 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"]) + game.append_move(gbt.H.path(), "A", ["win", "lose"]) win_node, lose_node = game.root.children game.make_outcome(win_node, {"A": 1, "B": 2}, "win") outcome = game.make_outcome(lose_node, {"A": 0, "B": 0}, "lose") From 367930a4bb8c47658c1bf45d0eddcc3ce53f8d0e Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 19:11:01 +0100 Subject: [PATCH 14/25] Update `append_event` signature --- doc/tutorials/03_stripped_down_poker.ipynb | 12 ++- .../h_selector_prototype.ipynb | 65 ++++------------ .../openspiel.ipynb | 2 +- src/pygambit/game.pxi | 78 ++++++++++++------- tests/games.py | 4 +- tests/test_hsel.py | 4 +- tests/test_node.py | 67 ++++++---------- 7 files changed, 101 insertions(+), 131 deletions(-) diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index a7430d2e6..6a7061874 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -96,7 +96,13 @@ "cell_type": "markdown", "id": "0d4c7f5b", "metadata": {}, - "source": "A move belonging to the chance player is called an **event**, and is created with `append_event` rather than `append_move`, since it requires the probability distribution over its actions to be specified explicitly.\n\nThe first step in this game is that Alice is dealt a card which could be a King or Queen, each with probability 1/2.\n\nTo simulate this in Gambit, we create a chance event at the root node of the game:" + "source": [ + "A move belonging to the chance player is called an **event**, and is created with `append_event` rather than `append_move`, since it requires the probability distribution over its actions to be specified explicitly. Like `append_move`, `append_event` takes an `H`-built selector identifying the node(s) to add the event at.\n", + "\n", + "The first step in this game is that Alice is dealt a card which could be a King or Queen, each with probability 1/2.\n", + "\n", + "To simulate this in Gambit, we create a chance event at the root node of the game, using `gbt.H.path()` to select it:" + ] }, { "cell_type": "code", @@ -104,7 +110,7 @@ "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\": gbt.Rational(1, 2), \"Queen\": gbt.Rational(1, 2)}\n)" }, { "cell_type": "code", @@ -815,7 +821,7 @@ "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", + "small_game.append_event(gbt.H.path(), dict.fromkeys([\"a\", \"b\", \"c\"], gbt.Rational(1, 3)))\n", "list(small_game.root.action_probs.values())" ] }, diff --git a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb index 05e091d84..15fecbb72 100644 --- a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb +++ b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb @@ -143,7 +143,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "923feb0b", "metadata": { "execution": { @@ -153,24 +153,16 @@ "shell.execute_reply": "2026-09-02T18:45:38.086660Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "is_perfect_recall: True\n" - ] - } - ], + "outputs": [], "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", + "g.append_event(H.path(), dict.fromkeys(cards, gbt.Rational(1, 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", + " g.append_event(H.path(c), dict.fromkeys(remaining, gbt.Rational(1, 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", @@ -250,7 +242,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "03767b4c", "metadata": { "execution": { @@ -260,23 +252,14 @@ "shell.execute_reply": "2026-09-02T18:45:38.096572Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "is_perfect_recall: True\n", - "terminal histories: 64\n" - ] - } - ], + "outputs": [], "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", + "g.append_event(H.path(), {\"1G\": half, \"1B\": half})\n", "for t1 in [\"1G\", \"1B\"]:\n", - " g.append_event(H.path(t1), [\"2g\", \"2b\"], [half, half])\n", + " g.append_event(H.path(t1), {\"2g\": half, \"2b\": 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", @@ -321,7 +304,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "c1fc0b54", "metadata": { "execution": { @@ -331,24 +314,12 @@ "shell.execute_reply": "2026-09-02T18:45:38.593205Z" } }, - "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" - ] - } - ], + "outputs": [], "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_event(H.path(), {\"H\": half, \"L\": 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", @@ -385,7 +356,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "d8ac1b78", "metadata": { "execution": { @@ -395,19 +366,11 @@ "shell.execute_reply": "2026-09-02T18:45:39.026590Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "is_perfect_recall: True\n" - ] - } - ], + "outputs": [], "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", + "g.append_event(H.path(), dict.fromkeys([\"1\", \"2\"], gbt.Rational(1, 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", diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index 6877f8f72..6899d1a53 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -666,7 +666,7 @@ "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 card in [\"King\", \"Queen\"]:\n gbt_one_card_poker.append_move(\n gbt.H.path(card),\n player=\"Alice\",\n actions=[\"Bet\", \"Fold\"]\n )\n\ngbt_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\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\ngbt_one_card_poker.append_event(\n gbt.H.path(),\n actions={\"King\": gbt.Rational(1, 2), \"Queen\": gbt.Rational(1, 2)}\n)\n\nfor card in [\"King\", \"Queen\"]:\n gbt_one_card_poker.append_move(\n gbt.H.path(card),\n player=\"Alice\",\n actions=[\"Bet\", \"Fold\"]\n )\n\ngbt_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\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)" }, { "cell_type": "code", diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 11e95263d..480c3b5e5 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1692,60 +1692,78 @@ class Game: for n in resolved_nodes: self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_infoset._resolve()) - def append_event(self, nodes: Node | NodeReferenceSet, - actions: list[str], - probs: typing.Sequence | typing.Mapping) -> None: - """Add a chance move at terminal `nodes`, with distribution `probs`. All elements - of `nodes` become part of a new event, with actions labeled according to `actions`. + def append_event(self, nodes: Selector | GroupedSelector, + actions: typing.Mapping) -> None: + """Add a chance move at terminal `nodes`, with actions and their probabilities + given by `actions`. All elements of `nodes` become part of a new event. + + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) or a `GroupedSelector` (an `H`-built + `.by(...)` expression) -- in the latter case, one new event is created per + distinct group, rather than one spanning every match. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `nodes` is now a `Selector` or `GroupedSelector`; a `Node` or + `NodeReferenceSet` is no longer accepted directly -- build one with `H`. + .. versionchanged:: 17.0.0 + `actions` and `probs` are combined into a single mapping from action + label to probability, rather than a list of labels plus a separate + probability sequence or mapping. Parameters ---------- - nodes : Node or NodeReferenceSet + nodes : Selector or GroupedSelector The nonempty set of terminal nodes at which to add the move. - actions : list of str - The labels of the actions of the new event. Nonempty, with no empty or - duplicated label. - probs : sequence or mapping - The probability distribution over `actions`. A sequence must specify one - probability per action, in the order given in `actions`. A mapping from - action labels to probabilities may be sparse; omitted actions are assigned - probability zero. Probabilities are non-negative and sum to exactly one. + actions : Mapping + A mapping from each new action's label to its probability. Nonempty, + with no empty label. Probabilities are non-negative and sum to exactly + one. Raises ------ + TypeError + If `nodes` is not a `Selector` or `GroupedSelector`. UndefinedOperationError If `nodes` are not all terminal, or `actions` is empty. - MismatchError - If an element from `nodes` is a `Node` from a different game. - KeyError - If a key of `probs` matches no label in `actions`. - IndexError - If a sequence `probs` does not have exactly one entry per action. ValueError If `nodes` has duplicated elements, or is empty; if `actions` contains - an empty or a duplicated label; or if `probs` are not non-negative numbers + an empty label; or if the probabilities are not non-negative numbers summing to exactly one. """ - if not actions: - raise UndefinedOperationError("append_event(): `actions` must be a nonempty list") - if any(not label for label in actions): + if isinstance(nodes, GroupedSelector): + for group in self._group_nodes(nodes).values(): + if not group: + continue + self._append_event_at(group, actions) + return + if not isinstance(nodes, Selector): + raise TypeError( + f"append_event(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) + self._append_event_at(nodes, actions) + + def _append_event_at(self, nodes: Selector | list[Node], actions: typing.Mapping) -> None: + """Internal: shared body of `append_event`, taking either a `Selector` or an + already-resolved list of `Node` (the latter used for one group at a time, + dispatched from a `GroupedSelector`).""" + action_labels = list(actions) + if not action_labels: + raise UndefinedOperationError("append_event(): `actions` must be a nonempty mapping") + if any(not label for label in action_labels): raise ValueError("append_event(): action labels must not be empty") - if len(set(actions)) != len(actions): - raise ValueError("append_event(): action labels must be unique") resolved_nodes = self._resolve_nodes(nodes, "append_event", "nodes") if any(len(n.children) > 0 for n in resolved_nodes): raise UndefinedOperationError("append_event(): `nodes` must be terminal nodes") - resolved_probs = self._resolve_probs(probs, actions, "append_event") resolved_node = cython.cast(Node, resolved_nodes[0]) c_actions = stdvector[string]() - for label in actions: + for label in action_labels: c_actions.push_back(label.encode("utf-8")) c_probs = stdvector[c_Number]() - for p in resolved_probs: - c_probs.push_back(_to_number(p)) + for label in action_labels: + c_probs.push_back(_to_number(actions[label])) self.game.deref().AppendEvent(resolved_node.node, c_actions, c_probs) resolved_event = cython.cast(Event, resolved_node.event) for n in resolved_nodes[1:]: diff --git a/tests/games.py b/tests/games.py index 8b6b61879..7aba877df 100644 --- a/tests/games.py +++ b/tests/games.py @@ -163,7 +163,7 @@ 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(gbt.H.path(), dict.fromkeys(deals, gbt.Rational(1, 2))) for card in deals: g.append_move( @@ -202,7 +202,7 @@ def _create_kuhn_poker_efg_without_outcomes(): cards = ["J", "Q", "K"] deals = ["JQ", "JK", "QJ", "QK", "KJ", "KQ"] - g.append_event(g.root, deals, [gbt.Rational(1, 6)] * 6) + g.append_event(gbt.H.path(), dict.fromkeys(deals, gbt.Rational(1, 6))) for alice_card in cards: # Alice's first move g.append_move( diff --git a/tests/test_hsel.py b/tests/test_hsel.py index 0a13a9ffa..30da6d01f 100644 --- a/tests/test_hsel.py +++ b/tests/test_hsel.py @@ -40,8 +40,8 @@ def key(h): 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]) + game.append_event(gbt.H.path(), {"L": 0.5, "R": 0.5}) + game.append_event(gbt.H.plays, {"p": 0.5, "q": 0.5}) captured = {} diff --git a/tests/test_node.py b/tests/test_node.py index cc90008e4..7fc744593 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -917,11 +917,16 @@ 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.""" + """Test that appending a Selector matching several 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"] - game.append_event([node1, node2], ["a", "b"], [gbt.Rational(1, 2)] * 2) + matches = (("2", "1"), ("1", "1")) + game.append_event( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 2)} + ) assert node1.event == node2.event assert node1.event @@ -930,76 +935,54 @@ 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"] - game.append_event(node, ["a", "b"], [gbt.Rational(1, 4), gbt.Rational(3, 4)]) + game.append_event(gbt.H.path("1", "1"), {"a": gbt.Rational(1, 4), "b": gbt.Rational(3, 4)}) assert list(node.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] def test_append_event_error_actions_empty(): """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"] with pytest.raises(gbt.UndefinedOperationError): - game.append_event(terminal, [], []) - - -def test_append_event_error_node_mismatch(): - """Test to ensure the node is from this game.""" - 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) + game.append_event(gbt.H.path("U1", "U2", "U3"), {}) 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"] - with pytest.raises(ValueError): - game.append_event(terminal, ["a", ""], [gbt.Rational(1, 2)] * 2) - - -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"] with pytest.raises(ValueError): - game.append_event(terminal, ["a", "a"], [gbt.Rational(1, 2)] * 2) + game.append_event( + gbt.H.path("U1", "U2", "U3"), {"a": gbt.Rational(1, 2), "": gbt.Rational(1, 2)} + ) def test_append_event_error_node_list_with_non_terminal_node(): - """Test that we get an UndefinedOperationError when the node list has a - non-terminal node. + """Test that we get an UndefinedOperationError when a Selector passed to + append_event matches a non-terminal node. """ game = games.read_from_file("sample_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.append_event( - [game.root.children["2"], game.root.children["1"].children["2"]], - ["a", "b"], - [gbt.Rational(1, 2)] * 2 - ) - - -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"] - with pytest.raises(ValueError): - game.append_event([node, node], ["a", "b"], [gbt.Rational(1, 2)] * 2) + game.append_event(gbt.H.path(...), {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 2)}) def test_append_event_error_node_list_is_empty(): - """Test that we get a ValueError when the node list is empty.""" + """Test that we get a ValueError when a Selector passed to append_event + matches no nodes. + """ game = games.read_from_file("sample_extensive_game.efg") with pytest.raises(ValueError): - game.append_event([], ["a", "b"], [gbt.Rational(1, 2)] * 2) + game.append_event( + gbt.H.path(...).filter(lambda h: False), + {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 2)} + ) 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"] with pytest.raises(ValueError): - game.append_event(terminal, ["a", "b"], [gbt.Rational(1, 2), gbt.Rational(1, 3)]) + game.append_event( + gbt.H.path("U1", "U2", "U3"), {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 3)} + ) def test_insert_event_actions_labeled(): From 6505dfd300a3b349490801a466f67507987ed64e Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 19:25:32 +0100 Subject: [PATCH 15/25] Update `insert_move` signature --- src/pygambit/game.pxi | 25 +++++++++++++++++++------ tests/test_node.py | 11 +++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 480c3b5e5..145327ae6 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1769,24 +1769,37 @@ class Game: for n in resolved_nodes[1:]: self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_event._resolve()) - def insert_move(self, node: Node | str, + def insert_move(self, node: Selector, player: str, actions: list[str]) -> None: - """Insert a move for `player` prior to the node `node`, with actions labeled - according to `actions`. `node` becomes the first child of the newly-inserted node. + """Insert a move for `player` prior to the node identified by `node`, with + actions labeled according to `actions`. The node becomes the first child of + the newly-inserted node. `player` must be a personal player; use `insert_event` to insert a chance move. + `node` is a `Selector` (an `H`-built expression, evaluated against this game) + that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. + Raises ------ + TypeError + If `node` is not a `Selector`. UndefinedOperationError If `actions` is empty. - MismatchError - If `node` is a `Node` from a different game. KeyError If no player in the game has label `player`. ValueError - If `actions` contains an empty or a duplicated label. + If `node` does not resolve to exactly one node, or `actions` contains an + empty or a duplicated label. """ + if not isinstance(node, Selector): + raise TypeError( + f"insert_move(): node must be a Selector, not {node.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "insert_move")) resolved_player = self._resolve_player(player, "insert_move") if not actions: diff --git a/tests/test_node.py b/tests/test_node.py index 7fc744593..13ed694cc 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -618,21 +618,21 @@ 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(gbt.H.path(), "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(gbt.H.path(), "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(gbt.H.path(), "Player 1", ["a", "a"]) def test_node_infoset_becomes_null_when_truncated(): @@ -1157,11 +1157,10 @@ def test_len_after_insert_move(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = len(game.nodes) - node_to_insert_above = game.root.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) + game.insert_move(gbt.H.path("L", "R"), player, actions_to_add) # the [1, 0] node assert len(game.nodes) == initial_number_of_nodes + len(actions_to_add) @@ -1170,7 +1169,7 @@ def test_insert_move_actions_labeled(): """Test that the inserted move's actions are labeled according to `actions`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") node = game.root.children["L"].children["R"] - game.insert_move(node, "Player 2", ["Up", "Down"]) + game.insert_move(gbt.H.path("L", "R"), "Player 2", ["Up", "Down"]) assert list(node.parent.infoset.actions) == ["Up", "Down"] From 6ba06d9da671c5ced8352e3fc28c4ed360dd7fd9 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 19:41:46 +0100 Subject: [PATCH 16/25] Update `append_infoset` signature --- src/pygambit/game.pxi | 62 +++++++++++++++++++------- tests/test_actions.py | 2 +- tests/test_node.py | 101 ++++++++++++++---------------------------- 3 files changed, 81 insertions(+), 84 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 145327ae6..ad7e321c5 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1661,31 +1661,63 @@ class Game: for n in resolved_nodes[1:]: self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_infoset._resolve()) - def append_infoset(self, nodes: Node | NodeReferenceSet, - infoset: NodeReference) -> None: - """Add a move in the information set or event `infoset` at terminal `nodes`. + def append_infoset(self, nodes: Selector | GroupedSelector, + infoset: Selector) -> None: + """Add a move at terminal `nodes`, joining the information set that the node + identified by `infoset` belongs to. + + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) or a `GroupedSelector` (an `H`-built + `.by(...)` expression, whose groups are pooled together -- every resolved + node joins the same `infoset` regardless of grouping). + + `infoset` is a `Selector` that must resolve to exactly one node; that node + must belong to a personal player and must not be terminal -- the information + set it currently belongs to is the one joined. + + .. versionchanged:: 17.0.0 + `nodes` is now a `Selector` or `GroupedSelector`, and `infoset` is now a + `Selector` identifying a node by the information set it belongs to, + rather than a `Node` or `str` reference to an `Infoset`/`Event` directly. + Joining an existing chance event is no longer supported here. Parameters ---------- - nodes : Node or NodeReferenceSet + nodes : Selector or GroupedSelector The nonempty set of terminal nodes at which to add the move. - infoset : Node or str - A node belonging to the information set or event to join, or such a - node's label. + infoset : Selector + A `Selector` resolving to a single node of the personal player's + information set to join. Raises ------ + TypeError + If `nodes` is not a `Selector` or `GroupedSelector`, or `infoset` is not + a `Selector`. UndefinedOperationError - If any element in `nodes` is not a terminal node. - MismatchError - If an element in `nodes` is a `Node` from a different game, - or `infoset` is a `Node` from a different game. + If any element in `nodes` is not a terminal node, or `infoset` resolves + to a terminal node or to a chance node. ValueError - If `nodes` has duplicated elements, or is empty. + If `nodes` has duplicated elements, or is empty; or if `infoset` does not + resolve to exactly one node. """ - resolved_infoset = cython.cast( - _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "append_infoset") - ) + if isinstance(nodes, GroupedSelector): + nodes = [n for group in self._group_nodes(nodes).values() for n in group] + elif not isinstance(nodes, Selector): + raise TypeError( + f"append_infoset(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) + if not isinstance(infoset, Selector): + raise TypeError( + f"append_infoset(): infoset must be a Selector, not {infoset.__class__.__name__}" + ) + infoset_node = cython.cast(Node, self._resolve_node(infoset, "append_infoset", "infoset")) + if not infoset_node.infoset: + raise UndefinedOperationError( + "append_infoset(): infoset must resolve to a personal player's node" + ) + resolved_infoset = cython.cast(Infoset, infoset_node.infoset) resolved_nodes = self._resolve_nodes(nodes, "append_infoset", "nodes") if any(len(n.children) > 0 for n in resolved_nodes): raise UndefinedOperationError("append_infoset(): `nodes` must be terminal nodes") diff --git a/tests/test_actions.py b/tests/test_actions.py index 9a29a00a1..a693403ff 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -194,7 +194,7 @@ def test_set_move_actions_absent_minded_drop_and_add(): set deletes that member with the subtree.""" game = gbt.Game.new_tree(players=["Alice"]) game.append_move(gbt.H.path(), "Alice", ["a", "b"]) - game.append_infoset(game.root.children["a"], game.root) + game.append_infoset(gbt.H.path("a"), gbt.H.path()) 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 diff --git a/tests/test_node.py b/tests/test_node.py index 13ed694cc..0c0453fe3 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -592,12 +592,12 @@ def test_append_move_error_player_actions(): game.append_move(gbt.H.path(), "Player 1", []) -def test_append_move_error_infoset_mismatch(): - """Test to ensure the node and the player are from the same game""" +def test_insert_infoset_error_mismatch(): + """Test to ensure the infoset is from the same game.""" game1 = gbt.Game.new_tree() game2 = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.MismatchError): - game1.append_infoset(game1.root, game2.root) + game1.insert_infoset(game1.root, game2.root) def test_append_move_error_empty_label(): @@ -798,43 +798,6 @@ def test_append_move_actions_list_of_nodes(): assert list(node1.infoset.actions) == list(node2.infoset.actions) -def test_append_infoset_actions_list_of_node_labels(): - """Test that nodes referenced by label are resolved correctly when joining - an existing infoset via `append_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.label = "0" - node2.label = "00" - game.append_move(gbt.H.path("2", "1"), "Player 3", ["B", "F", "S"]) - game.append_infoset(["00"], "0") - - assert node1.children["B"].parent.label == "0" - assert node2.children["B"].parent.label == "00" - assert len(node1.children) == 3 - assert len(node2.children) == 3 - - -def test_append_infoset_actions_list_of_mixed_node_references(): - """Test that nodes from a list with either 'Node' or str references are - resolved correctly when joining an existing infoset via `append_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"] - seed = game.root.children["1"].children["2"] - node1.label = "000" - game.append_move(gbt.H.path("1", "2"), "Player 3", ["B", "F", "S"]) - game.append_infoset(["000", node2], seed) - - assert node1.children["B"].parent.label == "000" - assert len(node1.children) == 3 - assert len(node2.children) == 3 - - def test_append_move_labels_list_of_nodes(): """Test that nodes matched by a Selector that resolved in the same infoset have the same labels per action. @@ -873,47 +836,51 @@ def test_append_move_node_list_is_empty(): def test_append_infoset_node_list_with_non_terminal_node(): - """Test that we get an UndefinedOperationError when we import in append_infoset - a list of nodes that has a non-terminal node. + """Test that we get an UndefinedOperationError when a Selector passed to + append_infoset matches a 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"] game.append_move(gbt.H.path("1", "1"), "Player 3", ["B", "F"]) with pytest.raises(gbt.UndefinedOperationError): - game.append_infoset( - [game.root.children["2"], game.root.children["1"].children["2"]], - seed_node - ) + game.append_infoset(gbt.H.path(...), gbt.H.path("1", "1")) -def test_append_infoset_node_list_with_duplicate_node(): - """Test that we get a ValueError when we import in append_infoset a list - with non-unique elements. +def test_append_infoset_node_list_is_empty(): + """Test that we get a ValueError when a Selector passed to append_infoset + matches no nodes. """ 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"] game.append_move(gbt.H.path("1", "1"), "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"]], - seed_node - ) + game.append_infoset(gbt.H.path(...).filter(lambda h: False), gbt.H.path("1", "1")) -def test_append_infoset_node_list_is_empty(): - """Test that we get a ValueError when we import in append_infoset an - empty list of nodes. - """ +def test_append_infoset_error_infoset_not_a_selector(): + """Test that we get a TypeError when `infoset` is not a Selector.""" 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"] game.append_move(gbt.H.path("1", "1"), "Player 3", ["B", "F"]) - with pytest.raises(ValueError): - game.append_infoset([], seed_node) + with pytest.raises(TypeError): + game.append_infoset(gbt.H.path("1", "2"), game.root.children["1"].children["1"]) + + +def test_append_infoset_error_infoset_terminal(): + """Test that we get an UndefinedOperationError when `infoset` resolves to a + terminal node.""" + game = games.read_from_file("sample_extensive_game.efg") + game.set_players(list(game.players) + ["Player 3"]) + with pytest.raises(gbt.UndefinedOperationError): + game.append_infoset(gbt.H.path("1", "2"), gbt.H.path("1", "1")) + + +def test_append_infoset_error_infoset_chance(): + """Test that we get an UndefinedOperationError when `infoset` resolves to a + chance node.""" + game = games.create_stripped_down_poker_efg() + with pytest.raises(gbt.UndefinedOperationError): + game.append_infoset(gbt.H.path("King", "Bet"), gbt.H.path()) def test_append_event_creates_single_event_list_of_nodes(): @@ -1117,12 +1084,10 @@ def test_len_after_append_infoset(): game = gbt.catalog.load("journals/ijgt/selten1975/fig2") initial_number_of_nodes = len(game.nodes) - member_node = game.root.children["L"] - infoset_to_modify = member_node.infoset + infoset_to_modify = game.root.children["L"].infoset number_of_infoset_actions = len(infoset_to_modify.actions) - terminal_node_to_add = game.root.children["L"].children["L"].children["l"] - game.append_infoset(terminal_node_to_add, member_node) + game.append_infoset(gbt.H.path("L", "L", "l"), gbt.H.path("L")) assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions From 2fa665d59f3db73c9199ae6ec88b985ddc7239df Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 19:57:59 +0100 Subject: [PATCH 17/25] Update `insert_infoset` signature --- src/pygambit/game.pxi | 43 +++++++++++++++++++++++++++++-------------- tests/test_actions.py | 8 ++++++++ tests/test_node.py | 11 +---------- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index ad7e321c5..5367761cc 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1845,25 +1845,40 @@ class Game: c_actions.push_back(label.encode("utf-8")) self.game.deref().InsertMove(resolved_node.node, resolved_player, c_actions) - def insert_infoset(self, node: Node | str, - infoset: NodeReference) -> None: - """Insert a move in the information set or event `infoset` prior to the node - `node`. `node` becomes the first child of the newly-inserted node. + def insert_infoset(self, node: Selector, + infoset: Selector) -> None: + """Insert a move in the information set or event that the node identified by + `infoset` belongs to, prior to the node identified by `node`. The node + becomes the first child of the newly-inserted node. - Parameters - ---------- - node : Node or str - The node before which to insert the move. - infoset : Node or str - A node belonging to the information set or event to join, or such a - node's label. + `node` and `infoset` are each a `Selector` (an `H`-built expression, + evaluated against this game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. + .. versionchanged:: 17.0.0 + `infoset` is now a `Selector` identifying a node by the information set + or event it belongs to, rather than a `Node` or `str` reference to an + `Infoset`/`Event` directly. Raises ------ - MismatchError - If `node` is a `Node` from a different game, or `infoset` is a `Node` from a - different game. + TypeError + If `node` or `infoset` is not a `Selector`. + ValueError + If `node` or `infoset` does not resolve to exactly one node, or if the + node identified by `infoset` belongs to no information set or event (it + is terminal). """ + if not isinstance(node, Selector): + raise TypeError( + f"insert_infoset(): node must be a Selector, not {node.__class__.__name__}" + ) + if not isinstance(infoset, Selector): + raise TypeError( + f"insert_infoset(): infoset must be a Selector, not {infoset.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "insert_infoset")) resolved_infoset = cython.cast( _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "insert_infoset") diff --git a/tests/test_actions.py b/tests/test_actions.py index a693403ff..6afbebf2d 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -34,6 +34,14 @@ def test_relabel_actions_duplicate_raises_valueerror(): game.relabel_actions(game.root, {"King": "Queen"}) +def test_relabel_actions_error_mismatch(): + """Test to ensure `infoset` is from the same game.""" + game1 = gbt.Game.new_tree() + game2 = games.create_stripped_down_poker_efg() + with pytest.raises(gbt.MismatchError): + game1.relabel_actions(game2.root, {"King": "Queen"}) + + def test_relabel_actions_simultaneous_swap(): """Reassignment is simultaneous, so a swap is well-defined; applying the entries one at a time would collide on the intermediate state. diff --git a/tests/test_node.py b/tests/test_node.py index 0c0453fe3..24e7e3c21 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -592,14 +592,6 @@ def test_append_move_error_player_actions(): game.append_move(gbt.H.path(), "Player 1", []) -def test_insert_infoset_error_mismatch(): - """Test to ensure the infoset is from the same game.""" - game1 = gbt.Game.new_tree() - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.insert_infoset(game1.root, game2.root) - - 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") @@ -1145,10 +1137,9 @@ def test_len_after_insert_infoset(): initial_number_of_nodes = len(game.nodes) infoset_to_modify = game.root.children["L"].infoset - node_to_insert_above = game.root.children["L"].children["R"] number_of_infoset_actions = len(infoset_to_modify.actions) - game.insert_infoset(node_to_insert_above, game.root.children["L"]) + game.insert_infoset(gbt.H.path("L", "R"), gbt.H.path("L")) assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions From b1c889607f538d9fcd312e6b37a21a82f0b164f8 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 20:08:46 +0100 Subject: [PATCH 18/25] Update `insert_move` signature --- src/pygambit/game.pxi | 70 +++++++++++++++++++++++-------------------- tests/test_actions.py | 8 +++++ tests/test_node.py | 25 ++++------------ 3 files changed, 50 insertions(+), 53 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 5367761cc..b473cbb78 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1885,56 +1885,60 @@ class Game: ) self.game.deref().InsertMove(resolved_node.node, resolved_infoset._resolve()) - def insert_event(self, node: Node | str, - actions: list[str], - probs: typing.Sequence | typing.Mapping) -> None: - """Insert a chance move prior to the node `node`, with actions labeled according - to `actions` and distribution `probs`. `node` becomes the first child of the - newly-inserted node. + def insert_event(self, node: Selector, actions: typing.Mapping) -> None: + """Insert a chance move prior to the node identified by `node`, with actions + and their probabilities given by `actions`. The node becomes the first + child of the newly-inserted node. + + `node` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. + .. versionchanged:: 17.0.0 + `actions` and `probs` are combined into a single mapping from action + label to probability, rather than a list of labels plus a separate + probability sequence or mapping. Parameters ---------- - node : Node or str - The node before which to insert the move. - actions : list of str - The labels of the actions of the new event. Nonempty, with no empty or - duplicated label. - probs : sequence or mapping - The probability distribution over `actions`. A sequence must specify one - probability per action, in the order given in `actions`. A mapping from - action labels to probabilities may be sparse; omitted actions are assigned - probability zero. Probabilities are non-negative and sum to exactly one. + node : Selector + A `Selector` resolving to the single node before which to insert the + move. + actions : Mapping + A mapping from each new action's label to its probability. Nonempty, + with no empty label. Probabilities are non-negative and sum to exactly + one. Raises ------ + TypeError + If `node` is not a `Selector`. UndefinedOperationError If `actions` is empty. - MismatchError - If `node` is a `Node` from a different game. - KeyError - If a key of `probs` matches no label in `actions`. - IndexError - If a sequence `probs` does not have exactly one entry per action. ValueError - If `actions` contains an empty or a duplicated label, or if `probs` are not - non-negative numbers summing to exactly one. + If `node` does not resolve to exactly one node; if `actions` contains + an empty label; or if the probabilities are not non-negative numbers + summing to exactly one. """ + if not isinstance(node, Selector): + raise TypeError( + f"insert_event(): node must be a Selector, not {node.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "insert_event")) - if not actions: - raise UndefinedOperationError("insert_event(): `actions` must be a nonempty list") - if any(not label for label in actions): + action_labels = list(actions) + if not action_labels: + raise UndefinedOperationError("insert_event(): `actions` must be a nonempty mapping") + if any(not label for label in action_labels): raise ValueError("insert_event(): action labels must not be empty") - if len(set(actions)) != len(actions): - raise ValueError("insert_event(): action labels must be unique") - resolved_probs = self._resolve_probs(probs, actions, "insert_event") c_actions = stdvector[string]() - for label in actions: + for label in action_labels: c_actions.push_back(label.encode("utf-8")) c_probs = stdvector[c_Number]() - for p in resolved_probs: - c_probs.push_back(_to_number(p)) + for label in action_labels: + c_probs.push_back(_to_number(actions[label])) self.game.deref().InsertEvent(resolved_node.node, c_actions, c_probs) def copy_tree(self, src: Node | str, dest: Node | str) -> None: diff --git a/tests/test_actions.py b/tests/test_actions.py index 6afbebf2d..075be0d37 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -209,6 +209,14 @@ def test_set_move_actions_absent_minded_drop_and_add(): assert len(game.nodes) == 3 +def test_set_event_actions_error_mismatch(): + """Test to ensure `event` is from the same game.""" + game1 = gbt.Game.new_tree() + game2 = games.create_stripped_down_poker_efg() + with pytest.raises(gbt.MismatchError): + game1.set_event_actions(game2.root, {"King": "1/2", "Queen": "1/2"}) + + 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"}) diff --git a/tests/test_node.py b/tests/test_node.py index 24e7e3c21..3dfa43e1a 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -948,7 +948,7 @@ 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"] - game.insert_event(node, ["Up", "Down"], [gbt.Rational(1, 2)] * 2) + game.insert_event(gbt.H.path("L", "R"), {"Up": gbt.Rational(1, 2), "Down": gbt.Rational(1, 2)}) assert list(node.parent.actions) == ["Up", "Down"] assert node.parent.event @@ -957,7 +957,7 @@ 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 - game.insert_event(node, ["a", "b"], [gbt.Rational(1, 4), gbt.Rational(3, 4)]) + game.insert_event(gbt.H.path(), {"a": gbt.Rational(1, 4), "b": gbt.Rational(3, 4)}) assert list(node.parent.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] @@ -965,36 +965,21 @@ 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, [], []) - - -def test_insert_event_error_node_mismatch(): - """Test to ensure the node is from this game.""" - 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) + game.insert_event(gbt.H.path(), {}) 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) - - -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(gbt.H.path(), {"a": gbt.Rational(1, 2), "": gbt.Rational(1, 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(gbt.H.path(), {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 3)}) def _count_subtree_nodes(start_node: gbt.Node, count_terminal: bool) -> int: From 3238b68283de3e9713721e703ca76da3297ed048 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 20:41:18 +0100 Subject: [PATCH 19/25] Update copy/mode/delete signatures, make_event/make_infoset --- doc/tutorials/03_stripped_down_poker.ipynb | 21 ++- src/pygambit/game.pxi | 203 +++++++++++++++------ tests/games.py | 21 ++- tests/test_game_resolve.py | 8 + tests/test_infosets.py | 134 ++++++-------- tests/test_node.py | 47 ++--- 6 files changed, 254 insertions(+), 180 deletions(-) diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 6a7061874..524a7e867 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -839,8 +839,8 @@ "outputs": [], "source": [ "small_game.make_event(\n", - " [small_game.root],\n", - " [gbt.Rational(1, 4), gbt.Rational(1, 2), gbt.Rational(1, 4)]\n", + " gbt.H.path(),\n", + " {\"a\": gbt.Rational(1, 4), \"b\": gbt.Rational(1, 2), \"c\": gbt.Rational(1, 4)}\n", ")\n", "list(small_game.root.action_probs.values())" ] @@ -861,8 +861,8 @@ "outputs": [], "source": [ "small_game.make_event(\n", - " [small_game.root],\n", - " [gbt.Decimal(\".25\"), gbt.Decimal(\".50\"), gbt.Decimal(\".25\")]\n", + " gbt.H.path(),\n", + " {\"a\": gbt.Decimal(\".25\"), \"b\": gbt.Decimal(\".50\"), \"c\": gbt.Decimal(\".25\")}\n", ")\n", "list(small_game.root.action_probs.values())" ] @@ -886,7 +886,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [\"1/4\", \"1/2\", \"1/4\"])\n", + "small_game.make_event(gbt.H.path(), {\"a\": \"1/4\", \"b\": \"1/2\", \"c\": \"1/4\"})\n", "list(small_game.root.action_probs.values())" ] }, @@ -897,7 +897,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [\".25\", \".50\", \".25\"])\n", + "small_game.make_event(gbt.H.path(), {\"a\": \".25\", \"b\": \".50\", \"c\": \".25\"})\n", "list(small_game.root.action_probs.values())" ] }, @@ -921,7 +921,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [.25, .50, .25])\n", + "small_game.make_event(gbt.H.path(), {\"a\": .25, \"b\": .50, \"c\": .25})\n", "list(small_game.root.action_probs.values())" ] }, @@ -939,7 +939,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(gbt.H.path(), {\"a\": 1/3, \"b\": 1/3, \"c\": 1/3})\n", + "except ValueError as e:\n", + " print(\"ValueError:\", e)" + ] }, { "cell_type": "markdown", diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index b473cbb78..c8ab05e70 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1941,8 +1941,9 @@ class Game: c_probs.push_back(_to_number(actions[label])) self.game.deref().InsertEvent(resolved_node.node, c_actions, c_probs) - def copy_tree(self, src: Node | str, dest: Node | str) -> None: - """Copy the subtree rooted at the node `src` to the node `dest`. + def copy_tree(self, src: Selector, dest: Selector) -> None: + """Copy the subtree rooted at the node identified by `src` to the node + identified by `dest`. Each node in the subtree copied to follow `dest` is placed in the same information set as the corresponding node in the original subtree under `src`. @@ -1953,43 +1954,76 @@ class Game: The outcome associated with `dest` is not changed by this operation. + `src` and `dest` are each a `Selector` (an `H`-built expression, evaluated + against this game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `src` and `dest` are now `Selector`s; a `Node` or `str` is no longer + accepted directly -- build one with `H`. + Parameters ---------- - src : Node or str - The root of the source subtree to copy - dest : Node or str - The destination subtree to copy to. `dest` must be a terminal node. + src : Selector + A `Selector` resolving to the root of the source subtree to copy. + dest : Selector + A `Selector` resolving to the destination subtree to copy to. Must + resolve to a terminal node. Raises ------ - MismatchError - If `src` or `dest` is not a member of the same game as this node. + TypeError + If `src` or `dest` is not a `Selector`. UndefinedOperationError If `dest` is not a terminal node. + ValueError + If `src` or `dest` does not resolve to exactly one node. """ + if not isinstance(src, Selector): + raise TypeError(f"copy_tree(): src must be a Selector, not {src.__class__.__name__}") + if not isinstance(dest, Selector): + raise TypeError( + f"copy_tree(): dest must be a Selector, not {dest.__class__.__name__}" + ) resolved_src = cython.cast(Node, self._resolve_node(src, "copy_tree", "src")) resolved_dest = cython.cast(Node, self._resolve_node(dest, "copy_tree", "dest")) if not resolved_dest.is_terminal: raise UndefinedOperationError("copy_tree(): `dest` must be a terminal node.") self.game.deref().CopyTree(resolved_dest.node, resolved_src.node) - def move_tree(self, src: Node | str, dest: Node | str) -> None: - """Move the subtree rooted at 'src' to 'dest'. + def move_tree(self, src: Selector, dest: Selector) -> None: + """Move the subtree rooted at the node identified by `src` to the node + identified by `dest`. + + `src` and `dest` are each a `Selector` (an `H`-built expression, evaluated + against this game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `src` and `dest` are now `Selector`s; a `Node` or `str` is no longer + accepted directly -- build one with `H`. Parameters ---------- - src : Node or str - The root of the source subtree to move - dest : Node or str - The destination subtree to move to. `dest` must be a terminal node. + src : Selector + A `Selector` resolving to the root of the source subtree to move. + dest : Selector + A `Selector` resolving to the destination subtree to move to. Must + resolve to a terminal node. Raises ------ - MismatchError - If `src` or `dest` is not a member of the same game as this node. + TypeError + If `src` or `dest` is not a `Selector`. UndefinedOperationError If `dest` is not a terminal node, or `dest` is a successor of `src`. + ValueError + If `src` or `dest` does not resolve to exactly one node. """ + if not isinstance(src, Selector): + raise TypeError(f"move_tree(): src must be a Selector, not {src.__class__.__name__}") + if not isinstance(dest, Selector): + raise TypeError( + f"move_tree(): dest must be a Selector, not {dest.__class__.__name__}" + ) resolved_src = cython.cast(Node, self._resolve_node(src, "move_tree", "src")) resolved_dest = cython.cast(Node, self._resolve_node(dest, "move_tree", "dest")) if not resolved_dest.is_terminal: @@ -1998,39 +2032,64 @@ class Game: raise UndefinedOperationError("move_tree(): `dest` cannot be a successor of `src`.") self.game.deref().MoveTree(resolved_dest.node, resolved_src.node) - def delete_parent(self, node: Node | str) -> None: - """Delete the parent node of `node`. `node` replaces its parent in the tree. All other - subtrees rooted at `node`'s parent are deleted. + def delete_parent(self, node: Selector) -> None: + """Delete the parent of the node identified by `node`. That node replaces + its parent in the tree. All other subtrees rooted at the parent are deleted. + + `node` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - node : Node or str - The node to retain after deleting its parent. - If a string is passed, the node is determined by finding the node with that label, - if any. + node : Selector + A `Selector` resolving to the single node to retain after deleting its + parent. Raises ------ - MismatchError - If `node` is a `Node` from a different game. + TypeError + If `node` is not a `Selector`. + ValueError + If `node` does not resolve to exactly one node. """ + if not isinstance(node, Selector): + raise TypeError( + f"delete_parent(): node must be a Selector, not {node.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "delete_parent")) self.game.deref().DeleteParent(resolved_node.node) - def delete_tree(self, node: Node | str) -> None: - """Truncate the game tree at `node`, deleting the subtree beneath it. + def delete_tree(self, node: Selector) -> None: + """Truncate the game tree at the node identified by `node`, deleting the + subtree beneath it. + + `node` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - node : Node or str - The node to truncate the game at. If a string is passed, the node is determined by - finding the node with that label, if any. + node : Selector + A `Selector` resolving to the single node to truncate the game at. Raises ------ - MismatchError - If `node` is a `Node` from a different game. + TypeError + If `node` is not a `Selector`. + ValueError + If `node` does not resolve to exactly one node. """ + if not isinstance(node, Selector): + raise TypeError( + f"delete_tree(): node must be a Selector, not {node.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "delete_tree")) self.game.deref().DeleteTree(resolved_node.node) @@ -2188,8 +2247,8 @@ class Game: self.game.deref().SetEventActions(resolved_event._resolve(), c_labels, c_probs) def make_event(self, - nodes: Node | NodeReferenceSet, - probs: typing.Sequence | typing.Mapping, + nodes: Selector | GroupedSelector, + probs: typing.Mapping, label: str | None = None) -> None: """Form `nodes` into a single event with distribution `probs`. @@ -2202,20 +2261,31 @@ class Game: raises ``RuntimeError``. The resulting event is accessible as ``node.event`` for any node in `nodes`. - The first node in `nodes` determines the action order of the event, - and is the frame against which mapping keys in `probs` are resolved. + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) or a `GroupedSelector` (an `H`-built + `.by(...)` expression, whose groups are pooled together into the one event). + + Which resolved node is treated as "first", determining the action order of + the event and the frame against which keys of `probs` are resolved, follows + `nodes`' own resolution order. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `nodes` is now a `Selector` or `GroupedSelector`; a `Node` or + `NodeReferenceSet` is no longer accepted directly -- build one with `H`. + .. versionchanged:: 17.0.0 + `probs` is now always a mapping from action label to probability; a + positional sequence is no longer accepted. Parameters ---------- - nodes : Node or NodeReferenceSet + nodes : Selector or GroupedSelector The nonempty set of nonterminal nodes to place in the event. - probs : sequence or mapping - The probability distribution over the actions of the event. A sequence must specify - one probability per action, in action order. A mapping from action labels - to probabilities may be sparse; omitted actions are assigned probability zero. - Probabilities are non-negative and sum to exactly one. + probs : Mapping + The probability distribution over the actions of the event, as a mapping + from action label to probability. May be sparse; omitted actions are + assigned probability zero. Probabilities are non-negative and sum to + exactly one. label : str, optional The label of the new event. If specified, must be unique among the events of the game after the operation. A label currently held by another event @@ -2223,13 +2293,11 @@ class Game: Raises ------ - MismatchError - If any of `nodes` is from a different game. + TypeError + If `nodes` is not a `Selector` or `GroupedSelector`, or `probs` is not a + mapping. KeyError - If a node reference matches no node, or a key of `probs` matches no - action label of the event. - IndexError - If a sequence `probs` does not have exactly one entry per action. + If a key of `probs` matches no action label of the event. UndefinedOperationError If any of `nodes` is a terminal node, or the game is not a tree. ValueError @@ -2242,6 +2310,17 @@ class Game: raise UndefinedOperationError( "make_event(): operation only defined for games with a tree representation" ) + if isinstance(nodes, GroupedSelector): + nodes = [n for group in self._group_nodes(nodes).values() for n in group] + elif not isinstance(nodes, Selector): + raise TypeError( + f"make_event(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) + if not isinstance(probs, typing.Mapping): + raise TypeError( + f"make_event(): probs must be a mapping, not {probs.__class__.__name__}" + ) resolved_nodes = self._resolve_nodes(nodes, "make_event") if any(n.is_terminal for n in resolved_nodes): raise UndefinedOperationError( @@ -2326,7 +2405,7 @@ class Game: self.game.deref().RelabelActions(resolved_infoset._resolve(), c_labels) def make_infoset(self, - nodes: Node | NodeReferenceSet, + nodes: Selector | GroupedSelector, player: str, label: str | None = None) -> None: """Form `nodes` into a single information set belonging to `player`. @@ -2341,11 +2420,19 @@ class Game: The structure of the tree is unchanged: no nodes are created or removed. This operation may introduce imperfect recall or absent-mindedness. + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) or a `GroupedSelector` (an `H`-built + `.by(...)` expression, whose groups are pooled together into the one + information set). + .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `nodes` is now a `Selector` or `GroupedSelector`; a `Node` or + `NodeReferenceSet` is no longer accepted directly -- build one with `H`. Parameters ---------- - nodes : Node or NodeReferenceSet + nodes : Selector or GroupedSelector The nodes to place in the information set. Nonempty; each node may be referenced only once. player : str @@ -2358,12 +2445,11 @@ class Game: Raises ------ - MismatchError - If any of `nodes` is from a different game. - KeyError - If any of `nodes`, or `player`, is a label matching no such object in the game. TypeError - If any of `nodes`, or `player`, is not of an accepted type. + If `nodes` is not a `Selector` or `GroupedSelector`, or `player` is not + of an accepted type. + KeyError + If `player` is a label matching no such object in the game. UndefinedOperationError If any of `nodes` is a terminal node, or if the game is not a tree. ValueError @@ -2375,6 +2461,13 @@ class Game: raise UndefinedOperationError( "make_infoset(): operation only defined for games with a tree representation" ) + if isinstance(nodes, GroupedSelector): + nodes = [n for group in self._group_nodes(nodes).values() for n in group] + elif not isinstance(nodes, Selector): + raise TypeError( + f"make_infoset(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) resolved_nodes = self._resolve_nodes(nodes, "make_infoset") resolved_player = self._resolve_player(player, "make_infoset") for n in resolved_nodes: diff --git a/tests/games.py b/tests/games.py index 7aba877df..c9bd51bf4 100644 --- a/tests/games.py +++ b/tests/games.py @@ -33,6 +33,25 @@ 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 _node_history(node: gbt.Node) -> tuple: + """The plain-tuple history of `node`, walked via `.parent`/`.prior_action`.""" + labels = [] + current = node + while current.parent is not None: + labels.append(current.prior_action.label) + current = current.parent + labels.reverse() + return tuple(labels) + + +def selector_for_nodes(nodes: list[gbt.Node]) -> gbt.Selector: + """A `Selector` matching exactly the given (possibly scattered, mixed-depth) + nodes -- for adapting fixtures that compute a `Node` list dynamically to the + `H`-only mutation methods.""" + histories = frozenset(_node_history(n) for n in nodes) + return gbt.H.after().filter(lambda h: h[:] in histories) + + # 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 @@ -724,7 +743,7 @@ def gbt_game(self): if not n.is_terminal and not n.children["L"].is_terminal: left = n.children["L"] g.make_infoset( - list(left.infoset.members) + [n.children["R"]], + selector_for_nodes(list(left.infoset.members) + [n.children["R"]]), left.infoset.player, left.infoset.label or None, ) diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index 7795dd7b8..1ca595987 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -49,6 +49,14 @@ def test_resolve_node_invalid(game: gbt.Game, node: str, exception: BaseExceptio game._resolve_node(node, "test_resolve_node_invalid") +def test_resolve_node_mismatch(): + """A `Node` from a different game raises `MismatchError`.""" + game1 = gbt.Game.new_tree() + game2 = games.read_from_file("sample_extensive_game.efg") + with pytest.raises(gbt.MismatchError): + game1._resolve_node(game2.root, "test_resolve_node_mismatch") + + @pytest.mark.parametrize( "game", [ diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 0c96e4c68..6f7b9cce3 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -59,26 +59,17 @@ def test_make_infoset_change_player_keeps_label(): game = games.read_from_file("basic_extensive_game.efg") _, p2, *_ = game.players members = list(game.root.infoset.members) - game.make_infoset(members, p2, "moved") + game.make_infoset(games.selector_for_nodes(members), p2, "moved") assert game.root.infoset.player == p2 assert game.root.infoset.label == "moved" assert list(game.root.infoset.members) == members -def test_make_infoset_mismatch_raises(): - """Nodes must belong to this game.""" - 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") - - 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"] with pytest.raises(gbt.UndefinedOperationError): - game.make_infoset([terminal], game.root.player) + game.make_infoset(gbt.H.path("U1", "U2", "U3"), game.root.player) def test_make_infoset_converts_chance_node(): @@ -86,7 +77,7 @@ def test_make_infoset_converts_chance_node(): game = games.read_from_file("stripped_down_poker.efg") chance_node = game.root # the deal is a chance move personal = next(n for n in game.nodes if not n.is_terminal and n.infoset) - game.make_infoset([chance_node], personal.infoset.player) + game.make_infoset(gbt.H.path(), personal.infoset.player) assert not chance_node.event assert chance_node.infoset assert chance_node.infoset.player == personal.infoset.player @@ -100,28 +91,21 @@ def test_make_infoset_requires_matching_action_labels(node_actions): game.append_move(gbt.H.path(), "1", ["a", "b"]) game.append_move(gbt.H.path("a"), "1", node_actions) with pytest.raises(ValueError): - game.make_infoset([game.root, game.root.children["a"]], "1") + game.make_infoset(gbt.H.after().filter(lambda h: h[:] in ((), ("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) - - -def test_make_infoset_repeated_node_raises(): - """Each node may be referenced only once.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.make_infoset([game.root, game.root], game.root.player) + game.make_infoset(gbt.H.path(...).filter(lambda h: False), game.root.player) def test_make_infoset_strategic_game_raises(): """`make_infoset` is only defined for games with a tree representation.""" game = gbt.Game.new_table([2, 2]) with pytest.raises(gbt.UndefinedOperationError): - game.make_infoset([], "1") + game.make_infoset(gbt.H.path(), "1") def test_set_move_actions_add_preserves_existing_action_order(): @@ -138,44 +122,50 @@ def test_set_move_actions_add_preserves_existing_action_order(): @pytest.mark.parametrize( "inprobs,outprobs", [ - (["1/4", "3/4"], [gbt.Rational("1/4"), gbt.Rational("3/4")]), - ([0.75, 0.25], [0.75, 0.25]), + ({"King": "1/4", "Queen": "3/4"}, [gbt.Rational("1/4"), gbt.Rational("3/4")]), + ({"King": 0.75, "Queen": 0.25}, [0.75, 0.25]), ({"King": 1}, [1, 0]), ], ) def test_make_event_sets_probabilities(inprobs, outprobs): - """Probabilities may be given positionally, or as a mapping in which omitted - actions are assigned zero. + """Probabilities are given as a mapping from action label to probability, + which may be sparse: an omitted action is assigned probability zero. """ game = games.read_from_file("stripped_down_poker.efg") - game.make_event([game.root], inprobs, "Deal") + game.make_event(gbt.H.path(), inprobs, "Deal") probs = game.root.action_probs for action, prob in zip(game.root.actions, outprobs, strict=True): assert probs[action] == prob +@pytest.mark.parametrize("probs", [["1/4", "3/4"], [0.75, 0.25]]) +def test_make_event_probs_not_a_mapping_raises_typeerror(probs): + """A positional sequence of probabilities is no longer accepted.""" + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(TypeError): + game.make_event(gbt.H.path(), probs, "Deal") + + 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"]] - game.make_event(nodes, ["1/4", "3/4"], "Coin") + game.make_event(gbt.H.path(...), {"Bet": "1/4", "Fold": "3/4"}, "Coin") assert nodes[0].event == nodes[1].event assert nodes[0].event assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] assert not game.get_infosets("Alice") -@pytest.mark.parametrize("probs", [["1/2", "1/2"], {"Call": 1}]) -def test_make_event_requires_matching_action_labels(probs): - """Nodes must have the same actions, with the same labels in the same order. - - The mapping case was previously reported as an unknown action label. - """ +def test_make_event_requires_matching_action_labels(): + """Nodes must have the same actions, with the same labels in the same order.""" game = games.read_from_file("stripped_down_poker.efg") - alice_node = game.root.children["King"] # actions Bet, Fold - bob_node = alice_node.children["Bet"] # actions Call, Fold + # King node has actions Bet, Fold; its own Bet-child has actions Call, Fold. with pytest.raises(ValueError): - game.make_event([alice_node, bob_node], probs) + game.make_event( + gbt.H.after().filter(lambda h: h[:] in (("King",), ("King", "Bet"))), + {"Bet": "1/2", "Fold": "1/2"} + ) def test_make_event_converts_personal_node(): @@ -184,51 +174,36 @@ def test_make_event_converts_personal_node(): node = next( n for n in game.get_infosets("Alice") if n.infoset.label == "Alice has King" ) - game.make_event([node], ["1/4", "3/4"]) + game.make_event(gbt.H.path("King"), {"Bet": "1/4", "Fold": "3/4"}) assert node.event assert list(node.action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] def test_make_event_terminal_node_raises(): game = games.read_from_file("stripped_down_poker.efg") - terminal = game.root.children["King"].children["Fold"] with pytest.raises(gbt.UndefinedOperationError): - game.make_event([terminal], ["1/2", "1/2"]) - - -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"]) - - -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(gbt.H.path("King", "Fold"), {"a": "1/2", "b": "1/2"}) def test_make_event_strategic_game_raises(): game = gbt.Game.new_table([2, 2]) with pytest.raises(gbt.UndefinedOperationError): - game.make_event([], [1]) + game.make_event(gbt.H.path(), {"a": 1}) def test_make_event_empty_nodes_raises(): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(ValueError): - game.make_event([], ["1/2", "1/2"]) + game.make_event(gbt.H.path(...).filter(lambda h: False), {"a": "1/2", "b": "1/2"}) 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"]] - game.make_event(nodes, ["1/2", "1/2"], "Coin") + game.make_event(gbt.H.path(...), {"Bet": "1/2", "Fold": "1/2"}, "Coin") before = game.to_efg() with pytest.raises(ValueError): - game.make_event([nodes[0]], ["1/2", "1/2"], "Coin") + game.make_event(gbt.H.path("King"), {"Bet": "1/2", "Fold": "1/2"}, "Coin") assert game.to_efg() == before @@ -238,8 +213,8 @@ def test_make_event_label_reused_when_fully_absorbed(): """ game = games.read_from_file("stripped_down_poker.efg") nodes = [game.root.children["King"], game.root.children["Queen"]] - game.make_event(nodes, ["1/2", "1/2"], "Coin") - game.make_event(nodes, ["1/4", "3/4"], "Coin") + game.make_event(gbt.H.path(...), {"Bet": "1/2", "Fold": "1/2"}, "Coin") + game.make_event(gbt.H.path(...), {"Bet": "1/4", "Fold": "3/4"}, "Coin") assert nodes[0].event == nodes[1].event assert nodes[0].event.label == "Coin" assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] @@ -248,22 +223,22 @@ def test_make_event_label_reused_when_fully_absorbed(): ].count("Coin") == 1 -@pytest.mark.parametrize("probs", [["3/4", "-1/2"], [0.75, 0.40], ["foo", "bar"]]) +@pytest.mark.parametrize( + "probs", [{"King": "3/4", "Queen": "-1/2"}, {"King": 0.75, "Queen": 0.40}, + {"King": "foo", "Queen": "bar"}] +) 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(gbt.H.path(), probs) -@pytest.mark.parametrize( - "probs,error", - [(["1/2"], IndexError), (["1/3", "1/3", "1/3"], IndexError), ({"Jack": 1}, KeyError)], -) -def test_make_event_malformed_probs_raises(probs, error): +def test_make_event_malformed_probs_raises(): + """An unknown action label as a mapping key raises KeyError.""" game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(error): - game.make_event([game.root], probs) + with pytest.raises(KeyError): + game.make_event(gbt.H.path(), {"Jack": 1}) @dataclasses.dataclass @@ -386,7 +361,7 @@ def test_make_infoset_cherry_pick_leaves_rumps(): A, B, C, D = _bagwell_p2_nodes(game) A.infoset.label = "X" C.infoset.label = "Y" - game.make_infoset([B, C], "Player 2") + game.make_infoset(games.selector_for_nodes([B, C]), "Player 2") assert B.infoset == C.infoset assert list(A.infoset.members) == [A] assert list(D.infoset.members) == [D] @@ -400,7 +375,7 @@ def test_make_infoset_label_held_by_rump_raises(): A, B, C, D = _bagwell_p2_nodes(game) A.infoset.label = "X" with pytest.raises(ValueError): - game.make_infoset([B, C], "Player 2", "X") # A remains in "X" + game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "X") # A remains in "X" def test_make_infoset_failure_leaves_game_unchanged(): @@ -410,7 +385,7 @@ def test_make_infoset_failure_leaves_game_unchanged(): A.infoset.label = "X" C.infoset.label = "Y" with pytest.raises(ValueError): - game.make_infoset([B, C], "Player 2", "X") + game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "X") assert A.infoset == B.infoset assert C.infoset == D.infoset assert A.infoset.label == "X" @@ -421,8 +396,8 @@ def test_make_infoset_idempotent(): """Repeating a call is a no-op: label reuse permits equality of membership.""" game = gbt.catalog.load("journals/geb/bagwell1995") A, B, C, D = _bagwell_p2_nodes(game) - game.make_infoset([B, C], "Player 2", "Z") - game.make_infoset([B, C], "Player 2", "Z") + game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "Z") + game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "Z") assert B.infoset == C.infoset assert B.infoset.label == "Z" @@ -432,7 +407,7 @@ def test_make_infoset_split_leaves_new_infoset_unlabeled(): game = gbt.catalog.load("journals/geb/bagwell1995") A, B, C, D = _bagwell_p2_nodes(game) A.infoset.label = "X" - game.make_infoset([A], "Player 2") + game.make_infoset(gbt.H.path("S", "s"), "Player 2") assert A.infoset.label == "" assert B.infoset.label == "X" @@ -447,7 +422,7 @@ def test_make_infoset_across_different_source_players(): n3 = game.root.children["b"] assert n2.infoset.player == "2" assert n3.infoset.player == "3" - game.make_infoset([n2, n3], "1") + game.make_infoset(gbt.H.path(...), "1") assert n2.infoset == n3.infoset assert n2.infoset.player == "1" assert n3.infoset.player == "1" @@ -460,7 +435,7 @@ def test_infoset_proxy_reresolves_after_split(): node = game.root.children["U1"] proxy = node.infoset assert len(proxy.members) == 2 - game.make_infoset(node, node.player) + game.make_infoset(gbt.H.path("U1"), node.player) assert list(proxy.members) == [node] @@ -473,7 +448,7 @@ def test_infoset_members_is_a_plain_snapshot_list(): members = node.infoset.members assert isinstance(members, list) assert node in (members[0], members[1]) - game.make_infoset(node, node.player) + game.make_infoset(gbt.H.path("U1"), node.player) assert len(members) == 2 assert list(node.infoset.members) == [node] @@ -495,9 +470,8 @@ 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(gbt.H.path(), "Driver", ["Continue", "Exit"]) - mid = game.root.children["Continue"] game.append_move(gbt.H.path("Continue"), "Driver", ["Continue", "Exit"]) - game.make_infoset([game.root, mid], "Driver") + game.make_infoset(gbt.H.after().filter(lambda h: h[:] in ((), ("Continue",))), "Driver") game.append_move(gbt.H.path("Continue", "Continue"), "2", ["l", "r"]) with pytest.raises(gbt.UndefinedOperationError): game.reveal(game.root, "2") diff --git a/tests/test_node.py b/tests/test_node.py index 3dfa43e1a..68bfc7e78 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -633,7 +633,7 @@ def test_node_infoset_becomes_null_when_truncated(): node = game.root.children["U1"] proxy = node.infoset assert proxy - game.delete_tree(node) + game.delete_tree(gbt.H.path("U1")) assert not proxy @@ -641,7 +641,7 @@ 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"] - game.delete_parent(node) + game.delete_parent(gbt.H.path("U1")) assert game.root == node @@ -649,7 +649,7 @@ 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"] - game.delete_tree(node) + game.delete_tree(gbt.H.path("U1")) assert len(node.children) == 0 @@ -657,19 +657,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) - - -def test_node_copy_across_games(): - """Test to ensure a gbt.MismatchError is raised when trying to copy a tree - from a different game. - """ - 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) - with pytest.raises(gbt.MismatchError): - game1.copy_tree(game2.root, game1.root) + game.copy_tree(gbt.H.path(), gbt.H.path()) def _subtrees_equal( @@ -706,7 +694,7 @@ def test_copy_tree_onto_nondescendent_terminal_node(): src_node = g.root.children["R"].children["L"] dest_node = g.root.children["R"].children["R"] - g.copy_tree(src_node, dest_node) + g.copy_tree(gbt.H.path("R", "L"), gbt.H.path("R", "R")) assert _subtrees_equal(src_node, dest_node) @@ -717,7 +705,7 @@ def test_copy_tree_onto_descendent_terminal_node(): src_node = g.root.children["R"] dest_node = g.root.children["R"].children["L"].children["R"] - g.copy_tree(src_node, dest_node) + g.copy_tree(gbt.H.path("R"), gbt.H.path("R", "L", "R")) assert _subtrees_equal(src_node, dest_node, dest_node) @@ -726,26 +714,14 @@ 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(gbt.H.path(), gbt.H.path()) def test_node_move_successor(): """Test on moving a node to one of its successors.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.move_tree(game.root, game.root.children["U1"].children["U2"].children["U3"]) - - -def test_node_move_across_games(): - """Test to ensure a gbt.MismatchError is raised when trying to move a tree - between different 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) - with pytest.raises(gbt.MismatchError): - game1.move_tree(game2.root, game1.root) + game.move_tree(gbt.H.path(), gbt.H.path("U1", "U2", "U3")) def test_append_move_creates_single_infoset_list_of_nodes(): @@ -1020,7 +996,7 @@ def test_len_after_delete_tree(): root_of_the_deleted_subtree = game.root.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) + game.delete_tree(gbt.H.path("R", "L")) assert len(game.nodes) == initial_number_of_nodes - number_of_deleted_nodes @@ -1037,7 +1013,7 @@ def test_len_after_delete_parent(): number_of_parent_ancestors = _count_subtree_nodes(node_parent_to_delete.parent, True) diff = number_of_parent_ancestors - number_of_node_ancestors - game.delete_parent(node_parent_to_delete) + game.delete_parent(gbt.H.path("L", "L")) assert len(game.nodes) == initial_number_of_nodes - diff @@ -1135,10 +1111,9 @@ def test_len_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"] number_of_src_ancestors = _count_subtree_nodes(src_node, True) - game.copy_tree(src_node, dest_node) + game.copy_tree(gbt.H.path("R", "L"), gbt.H.path("R", "R")) assert len(game.nodes) == initial_number_of_nodes + number_of_src_ancestors - 1 From 1051bb6e68c4dd524fb158f8a0d3fea6d88c8c61 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 20:51:21 +0100 Subject: [PATCH 20/25] Remove reveal from pygambit --- doc/pygambit.api.rst | 1 - src/pygambit/gambit.pxd | 1 - src/pygambit/game.pxi | 43 ----------------------------------------- tests/test_infosets.py | 24 ----------------------- 4 files changed, 69 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 48c90043f..fb3bae221 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -74,7 +74,6 @@ Transforming game information structure Game.relabel_actions Game.set_move_actions Game.set_event_actions - Game.reveal Transforming game components diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 68bfe36ab..046cc5091 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -346,7 +346,6 @@ cdef extern from "games/game.h": string) except +ValueError void MakeOutcomeNull(stdvector[c_GameNode]) except +ValueError void MakeOutcomeNull(stdvector[stdvector[c_GameStrategy]]) except +ValueError - void Reveal(c_GameInfoset, c_GamePlayer) except + void RelabelActions(c_GameInfoset, stdmap[string, string]) except +ValueError void SetMoveActions(c_GameInfoset, stdvector[string]) except +ValueError void SetEventActions(c_GameInfoset, stdvector[string], diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index c8ab05e70..0a82a81cd 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -2480,49 +2480,6 @@ class Game: c_nodes.push_back(cython.cast(Node, n).node) self.game.deref().MakeInfoset(c_nodes, resolved_player, (label or "").encode()) - def reveal(self, - infoset: NodeReference, - player: str) -> None: - """Reveals the move made at the information set or event `infoset` to `player`. - - Revealing the move modifies all subsequent information sets for `player` such - that any two nodes which are successors of two different actions at this - information set are placed in different information sets for `player`. - - Revelation is a one-shot operation; it is not enforced with respect to any - revisions made to the game tree subsequently. - - .. versionchanged:: 17.0.0 - Revealing the move at an absent-minded information set is not permitted. - - Parameters - ---------- - infoset : Node or str - A node belonging to the information set or event of the move to reveal - to the player, or such a node's label. - player : str - The label of the player to which to reveal the move at this information set. - - Raises - ------ - MismatchError - If `infoset` is a `Node` from a different game. - KeyError - If no player in the game has label `player`. - UndefinedOperationError - If `infoset` is absent-minded. - """ - resolved_infoset = cython.cast( - _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "reveal") - ) - resolved_player = self._resolve_player(player, "reveal") - if resolved_infoset.is_absent_minded: - raise UndefinedOperationError( - "reveal(): revealing the move at an absent-minded information set " - "is not well-defined" - ) - self.game.deref().Reveal(resolved_infoset._resolve(), resolved_player) - def set_players(self, players: list[str], drop: bool = False, diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 6f7b9cce3..303fc9ac0 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -451,27 +451,3 @@ def test_infoset_members_is_a_plain_snapshot_list(): game.make_infoset(gbt.H.path("U1"), node.player) assert len(members) == 2 assert list(node.infoset.members) == [node] - - -def test_reveal_splits_infoset_by_action(): - """Revealing the deal to Bob separates his single infoset into per-card - singletons; the other player's structure is untouched.""" - game = games.create_stripped_down_poker_efg(nonterm_outcomes=True) - n_alice = len(game.get_infosets("Alice")) - assert len(game.get_infosets("Bob")) == 1 - game.reveal(game.root, "Bob") - bob = game.get_infosets("Bob") - assert len(bob) == 2 - assert all(len(list(n.infoset.members)) == 1 for n in bob) - assert len(game.get_infosets("Alice")) == n_alice - - -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(gbt.H.path(), "Driver", ["Continue", "Exit"]) - game.append_move(gbt.H.path("Continue"), "Driver", ["Continue", "Exit"]) - game.make_infoset(gbt.H.after().filter(lambda h: h[:] in ((), ("Continue",))), "Driver") - game.append_move(gbt.H.path("Continue", "Continue"), "2", ["l", "r"]) - with pytest.raises(gbt.UndefinedOperationError): - game.reveal(game.root, "2") From efdd0f7e53368f4e436716d1d74ece5a793064fc Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 3 Sep 2026 21:01:34 +0100 Subject: [PATCH 21/25] Migrate `get_minimal_subgame` to use selector. --- doc/pygambit.api.rst | 2 +- src/pygambit/game.pxi | 40 +++++++++++++++++++++++++++++----------- tests/test_node.py | 8 ++++++-- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index fb3bae221..bed5d51aa 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -118,7 +118,7 @@ Information about the game Game.get_outcome Game.get_payoffs Game.subgames - Game.minimal_subgame + Game.get_minimal_subgame .. autosummary:: :toctree: api/ diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 0a82a81cd..e393bbe23 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -740,33 +740,51 @@ class Game: ) return GameSubgames.wrap(self.game) - def minimal_subgame(self, infoset: NodeReference) -> Subgame: - """Returns the smallest subgame containing `infoset`. + def get_minimal_subgame(self, node: Selector) -> Subgame: + """Returns the smallest subgame containing the information set or event that + the node identified by `node` belongs to. + + `node` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + + .. versionadded:: 16.7.0 + .. versionchanged:: 17.0.0 + Renamed from `minimal_subgame`. `node` (formerly `infoset`) is now a + `Selector`; a `Node` or `str` is no longer accepted directly -- build + one with `H`. Parameters ---------- - infoset : Node or str - A node belonging to the information set to query, or such a node's label. + node : Selector + A `Selector` resolving to a single node belonging to the information + set or event to query. Returns ------- Subgame - The smallest subgame containing `infoset`. - - .. versionadded:: 16.7.0 + The smallest subgame containing the information set or event that + `node` belongs to. Raises ------ + TypeError + If `node` is not a `Selector`. UndefinedOperationError If the game does not have a tree representation. - MismatchError - If `infoset` is from a different game. + ValueError + If `node` does not resolve to exactly one node, or belongs to no + information set or event (it is terminal). """ if not self.is_tree: raise UndefinedOperationError( - "Operation only defined for games with a tree representation" + "get_minimal_subgame(): operation only defined for games " + "with a tree representation" + ) + if not isinstance(node, Selector): + raise TypeError( + f"get_minimal_subgame(): node must be a Selector, not {node.__class__.__name__}" ) - resolved_infoset = self._resolve_infoset_or_event(infoset, "minimal_subgame") + resolved_infoset = self._resolve_infoset_or_event(node, "get_minimal_subgame") return Subgame.wrap( self.game.deref().GetMinimalSubgame( cython.cast(_InfosetOrEvent, resolved_infoset)._resolve() diff --git a/tests/test_node.py b/tests/test_node.py index 68bfc7e78..b381abaac 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -454,7 +454,8 @@ def test_subgame_children(test_case: SubgameStructureTestCase): @pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): - """`game.minimal_subgame(infoset)` returns the smallest subgame containing the infoset.""" + """`game.get_minimal_subgame(node)` returns the smallest subgame containing + the information set `node` belongs to.""" game = test_case.factory() expected_path_for_key = { key: path @@ -464,7 +465,10 @@ def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): for player in game.players: for node in game.get_infosets(player): key = (node.infoset.player, node.infoset.number) - actual_path = tuple(_get_path_of_action_labels(game.minimal_subgame(node).root)) + selector = games.selector_for_nodes([node]) + actual_path = tuple( + _get_path_of_action_labels(game.get_minimal_subgame(selector).root) + ) assert actual_path == expected_path_for_key[key] From 0d589f4edd9e157a498b02a9cc40b958b8c7a910 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 07:11:21 +0100 Subject: [PATCH 22/25] Migrate `set_move_actions`/`set_event_actions` --- src/pygambit/game.pxi | 74 ++++++++++++++++++++++++++---------------- tests/test_actions.py | 50 +++++++++++++--------------- tests/test_game.py | 4 +-- tests/test_infosets.py | 4 +-- tests/test_node.py | 4 +-- 5 files changed, 75 insertions(+), 61 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index e393bbe23..af7f56db0 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -2112,11 +2112,12 @@ class Game: self.game.deref().DeleteTree(resolved_node.node) def set_move_actions(self, - infoset: NodeReference, + infoset: Selector, actions: list[str], drop: bool = False, add: bool = True) -> None: - """Set the actions at the move `infoset` to be `actions`, matching by label. + """Set the actions at the move that the node identified by `infoset` + belongs to, to be `actions`, matching by label. An entry of `actions` matching the label of a current action refers to that action, which keeps its subtrees; an entry matching no current action creates a new action there, @@ -2124,13 +2125,19 @@ class Game: in `actions` is deleted, along with the subtrees its branches lead to. Listing the current labels in a new order reorders the actions as well as the children. + `infoset` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `infoset` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - infoset : Node or str - A node belonging to the (personal player's) move at which to set the - actions, or such a node's label. + infoset : Selector + A `Selector` resolving to a single node belonging to the (personal + player's) move at which to set the actions. actions : list of str The labels of the actions the move is to have, in order. Must be nonempty and without duplicates; each label must be a valid, nonempty label. @@ -2143,25 +2150,27 @@ class Game: Raises ------ - MismatchError - If `infoset` is a `Node` from a different game. - KeyError - If `infoset` is a string matching no node. TypeError - If `actions` is a string, or not an iterable of strings. + If `infoset` is not a `Selector`; or if `actions` is a string, or not + an iterable of strings. UndefinedOperationError If `actions` is empty. ValueError - If `infoset` resolves to an event rather than a personal player's move - (use `set_event_actions` for an event); or if a label in `actions` is - repeated, empty, or invalid; or if adding or deleting actions is not - confirmed by `add`/`drop`. + If `infoset` does not resolve to exactly one node, or resolves to an + event rather than a personal player's move (use `set_event_actions` + for an event); or if a label in `actions` is repeated, empty, or + invalid; or if adding or deleting actions is not confirmed by + `add`/`drop`. See Also -------- set_event_actions : The corresponding operation for the actions of an event. relabel_actions : Change the labels of actions, leaving the tree unchanged. """ + if not isinstance(infoset, Selector): + raise TypeError( + f"set_move_actions(): infoset must be a Selector, not {infoset.__class__.__name__}" + ) resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, "set_move_actions")) if isinstance(actions, str) or not hasattr(actions, "__iter__"): raise TypeError("set_move_actions(): actions must be an iterable of str") @@ -2181,12 +2190,13 @@ class Game: self.game.deref().SetMoveActions(resolved_infoset._resolve(), c_labels) def set_event_actions(self, - event: NodeReference, + event: Selector, probs: typing.Mapping, drop: bool = False, add: bool = True) -> None: - """Set the actions at the event `event` to be the keys of `probs`, in order, - with the given probability distribution. + """Set the actions at the event that the node identified by `event` + belongs to, to be the keys of `probs`, in order, with the given + probability distribution. A key of `probs` matching the label of a current action refers to that action, which keeps its subtrees; a key matching no current action creates a new action @@ -2199,13 +2209,19 @@ class Game: of the operation, rather than inferred from the actions which remain: there is no way to reorder an event's actions without also restating their probabilities. + `event` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `event` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - event : Node or str - A node belonging to the event at which to set the actions, or such a - node's label. + event : Selector + A `Selector` resolving to a single node belonging to the event at + which to set the actions. probs : dict-like A mapping from the label of each action the event is to have, in order, to its probability. Must be nonempty, with valid, nonempty keys. Values must be @@ -2219,20 +2235,18 @@ class Game: Raises ------ - MismatchError - If `event` is a `Node` from a different game. - KeyError - If `event` is a string matching no node. TypeError - If `probs` is not a mapping, or a key of `probs` is not a string. + If `event` is not a `Selector`; or if `probs` is not a mapping, or a + key of `probs` is not a string. UndefinedOperationError If `probs` is empty, or if `event` resolves to a personal player's information set rather than an event; use `set_move_actions` for a personal player's move. ValueError - If a key of `probs` is empty or invalid; if adding or deleting actions is not - confirmed by `add`/`drop`; or if the values of `probs` are not non-negative - numbers summing to exactly one. + If `event` does not resolve to exactly one node; if a key of `probs` + is empty or invalid; if adding or deleting actions is not confirmed by + `add`/`drop`; or if the values of `probs` are not non-negative numbers + summing to exactly one. See Also -------- @@ -2240,6 +2254,10 @@ class Game: player's move. relabel_actions : Change the labels of actions, leaving the tree unchanged. """ + if not isinstance(event, Selector): + raise TypeError( + f"set_event_actions(): event must be a Selector, not {event.__class__.__name__}" + ) resolved_event = cython.cast(Event, self._resolve_event(event, "set_event_actions")) if not isinstance(probs, typing.Mapping): raise TypeError( diff --git a/tests/test_actions.py b/tests/test_actions.py index 075be0d37..5ea123dfe 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -116,7 +116,7 @@ def test_set_move_actions_drop_shrinks_actions_and_children(): node = next(iter(infoset.members)) action_count = len(infoset.actions) remaining = list(infoset.actions)[1:] - game.set_move_actions(node, remaining, drop=True) + game.set_move_actions(games.selector_for_nodes([node]), remaining, drop=True) assert len(infoset.actions) == action_count - 1 assert len(node.children) == action_count - 1 @@ -126,10 +126,11 @@ def test_set_move_actions_cannot_remove_the_only_action(): infoset = games.find_infoset(game, "Alice", "Alice has King") node = next(iter(infoset.members)) last = next(iter(infoset.actions)) - game.set_move_actions(node, [last], drop=True) + selector = games.selector_for_nodes([node]) + game.set_move_actions(selector, [last], drop=True) assert list(infoset.actions) == [last] with pytest.raises(gbt.UndefinedOperationError): - game.set_move_actions(node, [], drop=True) + game.set_move_actions(selector, [], drop=True) def test_set_move_actions_reorder_carries_subtrees(): @@ -146,7 +147,7 @@ def test_set_move_actions_reorder_carries_subtrees(): members = list(infoset.members) children_before = [{label: member.children[label] for label in ("a", "b", "c")} for member in members] - game.set_move_actions(game.root.children["x"], ["c", "a", "b"]) + game.set_move_actions(gbt.H.path("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"]] @@ -157,7 +158,7 @@ def test_set_move_actions_add_drop_and_reorder_together(): infoset = games.find_infoset(game, "Alice", "Alice has King") node = next(iter(infoset.members)) nodes_before = len(game.nodes) - game.set_move_actions(node, ["Raise", "Fold"], drop=True) + game.set_move_actions(games.selector_for_nodes([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 @@ -169,10 +170,11 @@ def test_set_move_actions_unconfirmed_drop_and_disabled_add_raise(): infoset = games.find_infoset(game, "Alice", "Alice has King") node = next(iter(infoset.members)) before = game.to_efg() + selector = games.selector_for_nodes([node]) with pytest.raises(ValueError): - game.set_move_actions(node, ["Bet"]) + game.set_move_actions(selector, ["Bet"]) with pytest.raises(ValueError): - game.set_move_actions(node, ["Bet", "Fold", "Raise"], add=False) + game.set_move_actions(selector, ["Bet", "Fold", "Raise"], add=False) assert game.to_efg() == before @@ -181,7 +183,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(gbt.H.path(), ["King", "Queen"]) @pytest.mark.parametrize("bad_labels", [["Bet", "Bet"], ["Bet", ""], ["Bet", " x"]]) @@ -193,7 +195,7 @@ def test_set_move_actions_bad_labels_raise_and_leave_game_unchanged(bad_labels): node = next(iter(infoset.members)) before = game.to_efg() with pytest.raises(ValueError): - game.set_move_actions(node, bad_labels, drop=True) + game.set_move_actions(games.selector_for_nodes([node]), bad_labels, drop=True) assert game.to_efg() == before @@ -203,24 +205,16 @@ def test_set_move_actions_absent_minded_drop_and_add(): game = gbt.Game.new_tree(players=["Alice"]) game.append_move(gbt.H.path(), "Alice", ["a", "b"]) game.append_infoset(gbt.H.path("a"), gbt.H.path()) - game.set_move_actions(game.root, ["b", "c"], drop=True) + game.set_move_actions(gbt.H.path(), ["b", "c"], drop=True) assert list(game.root.infoset.actions) == ["b", "c"] assert len(game.root.infoset.members) == 1 assert len(game.nodes) == 3 -def test_set_event_actions_error_mismatch(): - """Test to ensure `event` is from the same game.""" - game1 = gbt.Game.new_tree() - game2 = games.create_stripped_down_poker_efg() - with pytest.raises(gbt.MismatchError): - game1.set_event_actions(game2.root, {"King": "1/2", "Queen": "1/2"}) - - 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"}) + game.set_event_actions(gbt.H.path(), {"King": "3/4", "Queen": "1/4"}) + game.set_event_actions(gbt.H.path(), {"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)} @@ -228,7 +222,7 @@ def test_set_event_actions_reorder_carries_probabilities(): 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"}) + game.set_event_actions(gbt.H.path(), {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) assert list(game.root.actions) == ["Jack", "King", "Queen"] assert game.root.action_probs == { "Jack": gbt.Rational(1, 2), "King": gbt.Rational(1, 4), "Queen": gbt.Rational(1, 4) @@ -238,7 +232,7 @@ def test_set_event_actions_add_with_probs_mapping(): 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) + game.set_event_actions(gbt.H.path(), {"King": 1}, drop=True) assert list(game.root.actions) == ["King"] assert game.root.action_probs == {"King": 1} @@ -248,10 +242,10 @@ def test_set_event_actions_unconfirmed_drop_and_disabled_add_raise(): _ = game.root.event before = game.to_efg() with pytest.raises(ValueError): - game.set_event_actions(game.root, {"King": 1}) + game.set_event_actions(gbt.H.path(), {"King": 1}) with pytest.raises(ValueError): game.set_event_actions( - game.root, {"King": "1/2", "Queen": "1/4", "Jack": "1/4"}, add=False + gbt.H.path(), {"King": "1/2", "Queen": "1/4", "Jack": "1/4"}, add=False ) assert game.to_efg() == before @@ -262,7 +256,9 @@ def test_set_event_actions_raises_at_a_move(): game = games.create_stripped_down_poker_efg() infoset = games.find_infoset(game, "Alice", "Alice has King") with pytest.raises(ValueError): - game.set_event_actions(next(iter(infoset.members)), {"Bet": 1}) + game.set_event_actions( + games.selector_for_nodes([next(iter(infoset.members))]), {"Bet": 1} + ) def test_set_event_actions_rejects_non_mapping_probs(): @@ -271,7 +267,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(gbt.H.path(), ["3/4", "1/4"]) assert game.to_efg() == before @@ -279,7 +275,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(gbt.H.path(), {"King": "3/4", "Queen": "3/4"}) assert game.to_efg() == before diff --git a/tests/test_game.py b/tests/test_game.py index 5687509f4..88af1dbfa 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -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(gbt.H.path(), ["D1"], drop=True) distribution = {s: 0 for s in game.get_strategies(player)} for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): @@ -253,7 +253,7 @@ 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) + game.set_move_actions(gbt.H.path(), ["D1"], drop=True) infoset = game.root for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 303fc9ac0..b5ba0d35a 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -113,9 +113,9 @@ def test_set_move_actions_add_preserves_existing_action_order(): 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"]) + game.set_move_actions(gbt.H.path(), labels + ["end"]) assert list(game.root.actions)[:-1] == labels - game.set_move_actions(game.root, ["front"] + labels + ["end"]) + game.set_move_actions(gbt.H.path(), ["front"] + labels + ["end"]) assert list(game.root.actions)[1:-1] == labels diff --git a/tests/test_node.py b/tests/test_node.py index b381abaac..02ccc753a 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1056,7 +1056,7 @@ def test_len_after_set_move_actions_add(): infoset_to_modify = game.root.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"]) + game.set_move_actions(gbt.H.path("L"), labels + ["new"]) assert len(game.nodes) == initial_number_of_nodes + num_nodes_in_infoset @@ -1070,7 +1070,7 @@ def test_len_after_set_move_actions_drop(): for member in game.root.infoset.members ) remaining = [a for a in game.root.infoset.actions if a != "L"] - game.set_move_actions(game.root, remaining, drop=True) + game.set_move_actions(gbt.H.path(), remaining, drop=True) assert len(game.nodes) == initial_number_of_nodes - nodes_to_delete From 35408769bfd619fc2f2730c37873b8ec13fb149e Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 07:19:43 +0100 Subject: [PATCH 23/25] Migrate `relabel_actions` --- src/pygambit/game.pxi | 37 ++++++++++++++++++++++++------------- tests/test_actions.py | 34 +++++++++++++--------------------- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index af7f56db0..af3c23a00 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -2380,23 +2380,30 @@ class Game: self.game.deref().MakeEvent(c_nodes, c_probs, (label or "").encode("utf-8")) def relabel_actions(self, - infoset: NodeReference, + infoset: Selector, labels: typing.Mapping[str, str], strict: bool = True) -> None: - """Simultaneously reassign the labels of actions at `infoset`. + """Simultaneously reassign the labels of actions at the information set or + event that the node identified by `infoset` belongs to. `labels` maps current action labels to their replacements. The reassignment is simultaneous, so labels can be swapped directly, e.g. ``{"a": "b", "b": "a"}``. Actions are not re-ordered: each relabelled action keeps its position and, at an event, its probability. After the operation, the labels must be nonempty and unique. + `infoset` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `infoset` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - infoset : Node or str - A node belonging to the information set at which to relabel actions, or - such a node's label. + infoset : Selector + A `Selector` resolving to a single node belonging to the information + set or event at which to relabel actions. labels : Mapping[str, str] A mapping from current action labels to replacement labels. Entries whose key equals their value are ignored. @@ -2407,19 +2414,23 @@ class Game: Raises ------ - MismatchError - If `infoset` is a `Node` from a different game. - KeyError - If `infoset` is a string matching no node; or, when `strict` - is `True`, if a key of `labels` matches no action at `infoset`. TypeError - If `labels` is not a mapping, or any key or value is not a string. + If `infoset` is not a `Selector`; or if `labels` is not a mapping, or + any key or value is not a string. + KeyError + If, when `strict` is `True`, a key of `labels` matches no action at + `infoset`. ValueError - If a key of `labels` matches more than one action at `infoset` (possible - in games read from files predating unique-label enforcement); or if any + If `infoset` does not resolve to exactly one node; if a key of + `labels` matches more than one action at `infoset` (possible in games + read from files predating unique-label enforcement); or if any replacement label is empty, is not a valid label, or would result in a duplicate label at the information set. """ + if not isinstance(infoset, Selector): + raise TypeError( + f"relabel_actions(): infoset must be a Selector, not {infoset.__class__.__name__}" + ) resolved_infoset = cython.cast( _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "relabel_actions") ) diff --git a/tests/test_actions.py b/tests/test_actions.py index 5ea123dfe..7e51063be 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -9,7 +9,7 @@ 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}) + game.relabel_actions(gbt.H.path(), {action: label}) assert label in game.root.actions @@ -18,28 +18,20 @@ def test_action_label_invalid_raises_valueerror(label: str): game = games.create_stripped_down_poker_efg() action = next(iter(game.root.actions)) with pytest.raises(ValueError): - game.relabel_actions(game.root, {action: label}) + game.relabel_actions(gbt.H.path(), {action: label}) def test_relabel_action_empty_raises_valueerror(): game = games.create_stripped_down_poker_efg() action = next(iter(game.root.actions)) with pytest.raises(ValueError): - game.relabel_actions(game.root, {action: ""}) + game.relabel_actions(gbt.H.path(), {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"}) - - -def test_relabel_actions_error_mismatch(): - """Test to ensure `infoset` is from the same game.""" - game1 = gbt.Game.new_tree() - game2 = games.create_stripped_down_poker_efg() - with pytest.raises(gbt.MismatchError): - game1.relabel_actions(game2.root, {"King": "Queen"}) + game.relabel_actions(gbt.H.path(), {"King": "Queen"}) def test_relabel_actions_simultaneous_swap(): @@ -47,7 +39,7 @@ 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"}) + game.relabel_actions(gbt.H.path(), {"King": "Queen", "Queen": "King"}) assert list(game.root.event.actions) == ["Queen", "King"] @@ -57,18 +49,18 @@ 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(gbt.H.path(), {"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(gbt.H.path(), {"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) + game.relabel_actions(gbt.H.path(), {"Jack": "Ace", "King": "Ace"}, strict=False) assert list(game.root.event.actions) == ["Ace", "Queen"] @@ -78,7 +70,7 @@ 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": ""}) + game.relabel_actions(gbt.H.path(), {"King": "Ace", "Queen": ""}) assert list(game.root.event.actions) == ["King", "Queen"] @@ -90,24 +82,24 @@ def test_relabel_actions_scope_is_the_information_set(): game = games.create_stripped_down_poker_efg() king = games.find_infoset(game, "Alice", "Alice has King") queen = games.find_infoset(game, "Alice", "Alice has Queen") - game.relabel_actions(next(iter(king.members)), {"Bet": "Raise"}) + game.relabel_actions(games.selector_for_nodes([next(iter(king.members))]), {"Bet": "Raise"}) assert list(king.actions) == ["Raise", "Fold"] assert list(queen.actions) == ["Bet", "Fold"] - game.relabel_actions(next(iter(queen.members)), {"Bet": "Raise"}) + game.relabel_actions(games.selector_for_nodes([next(iter(queen.members))]), {"Bet": "Raise"}) assert list(queen.actions) == ["Raise", "Fold"] def test_relabel_actions_not_a_mapping_raises_typeerror(): game = games.create_stripped_down_poker_efg() with pytest.raises(TypeError): - game.relabel_actions(game.root, [("King", "Queen")]) + game.relabel_actions(gbt.H.path(), [("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(gbt.H.path(), labels) def test_set_move_actions_drop_shrinks_actions_and_children(): From 34eef762c70f7015b6c3502c802a8dfb98a645d9 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 07:46:41 +0100 Subject: [PATCH 24/25] Migrate `make_outcome`/`make_outcome_null` --- doc/tutorials/02_extensive_form.ipynb | 24 ++++- doc/tutorials/03_stripped_down_poker.ipynb | 30 +++++- .../h_selector_prototype.ipynb | 32 +++--- .../openspiel.ipynb | 2 +- src/pygambit/game.pxi | 63 +++++++----- tests/cli/conftest.py | 11 +-- tests/games.py | 55 +++++------ tests/test_game.py | 2 +- tests/test_node.py | 2 +- tests/test_outcomes.py | 98 +++++++++---------- tests/test_players.py | 4 +- 11 files changed, 189 insertions(+), 134 deletions(-) diff --git a/doc/tutorials/02_extensive_form.ipynb b/doc/tutorials/02_extensive_form.ipynb index b8640b89b..9b5767969 100644 --- a/doc/tutorials/02_extensive_form.ipynb +++ b/doc/tutorials/02_extensive_form.ipynb @@ -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 524a7e867..cede9646b 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -211,7 +211,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", diff --git a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb index 15fecbb72..ba282c149 100644 --- a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb +++ b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb @@ -29,7 +29,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "2e80dd03", "metadata": { "execution": { @@ -49,7 +49,15 @@ "\n", "from pygambit.gambit import H\n", "\n", - "import pygambit as gbt" + "import pygambit as gbt\n", + "\n", + "\n", + "def selector_for_histories(histories):\n", + " \"\"\"A Selector matching exactly the given (already-materialized) Histories --\n", + " for passing a `_get_groups` group to a mutation method, which only accepts a\n", + " Selector/GroupedSelector, not a bare iterable of History tuples.\"\"\"\n", + " keys = frozenset(histories)\n", + " return H.after().filter(lambda h: h[:] in keys)" ] }, { @@ -179,7 +187,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "b11e3f16", "metadata": { "execution": { @@ -189,15 +197,7 @@ "shell.execute_reply": "2026-09-02T18:45:38.091662Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Total outcomes created: 4\n" - ] - } - ], + "outputs": [], "source": [ "def winner(h):\n", " match h[2:]:\n", @@ -217,7 +217,9 @@ "\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", + " g.make_outcome(\n", + " selector_for_histories(group), {win: amount, lose: -amount}, f\"{win} wins {amount}\"\n", + " )\n", "\n", "print(\"Total outcomes created:\", len(list(g.outcomes)))" ] @@ -276,7 +278,7 @@ " (\"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", + " g.make_outcome(selector_for_histories(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)))" @@ -385,7 +387,7 @@ "\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", + " selector_for_histories(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", diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index 6899d1a53..30954e117 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -666,7 +666,7 @@ "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.H.path(),\n actions={\"King\": gbt.Rational(1, 2), \"Queen\": gbt.Rational(1, 2)}\n)\n\nfor card in [\"King\", \"Queen\"]:\n gbt_one_card_poker.append_move(\n gbt.H.path(card),\n player=\"Alice\",\n actions=[\"Bet\", \"Fold\"]\n )\n\ngbt_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\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\ngbt_one_card_poker.append_event(\n gbt.H.path(),\n actions={\"King\": gbt.Rational(1, 2), \"Queen\": gbt.Rational(1, 2)}\n)\n\nfor card in [\"King\", \"Queen\"]:\n gbt_one_card_poker.append_move(\n gbt.H.path(card),\n player=\"Alice\",\n actions=[\"Bet\", \"Fold\"]\n )\n\ngbt_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\ngbt_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\ngbt_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\ngbt_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\ngbt_one_card_poker.make_outcome(\n gbt.H.path(..., \"Bet\", \"Fold\"),\n {\"Alice\": 1, \"Bob\": -1},\n \"Win\"\n)" }, { "cell_type": "code", diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index af3c23a00..274ccd4aa 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -2607,25 +2607,31 @@ 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` (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). + into a list of `Node` (via `_resolve_nodes`, so `location` must be a + `Selector` or `GroupedSelector`); for a strategic game, into a list of + pure-strategy contingencies (each a mapping from player label to strategy + label). Returns (is_tree, resolved). Raises ------ - MismatchError - If any node is from a different game. TypeError - If `location` is not a contingency or an iterable of contingencies + If `location` is not a `Selector` or `GroupedSelector` (tree game + only); or is not a contingency or an iterable of contingencies (strategic game only). ValueError If `location` is empty or contains a repeat, or (strategic game only) if a contingency does not specify exactly one strategy for each player. """ if self.is_tree: + if isinstance(location, GroupedSelector): + location = [n for group in self._group_nodes(location).values() for n in group] + elif not isinstance(location, Selector): + raise TypeError( + f"{funcname}(): location must be a Selector or GroupedSelector, " + f"not {location.__class__.__name__}" + ) return True, self._resolve_nodes(location, funcname) if isinstance(location, collections.abc.Mapping): entries = [location] @@ -2647,20 +2653,26 @@ 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``, 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 + For an extensive game, `location` is a `Selector` (an `H`-built + expression, evaluated against this game and treated as a flat set of + nodes) or a `GroupedSelector` (an `H`-built `.by(...)` expression, whose + groups are pooled together, all receiving the same outcome). 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. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + For an extensive game, `location` is now a `Selector` or + `GroupedSelector`; a `Node`, `History`, or iterable of these is no + longer accepted directly -- build one with `H`. Parameters ---------- - location : Node, History, Selector, contingency, or iterable of these + location : Selector, GroupedSelector, contingency, or iterable of contingencies Where to attach the new outcome. Nonempty; each node or contingency may be referenced only once. payoffs : Mapping @@ -2677,8 +2689,9 @@ class Game: Raises ------ - MismatchError - If any node is from a different game. + TypeError + If, for an extensive game, `location` is not a `Selector` or + `GroupedSelector`. ValueError If `location` is empty or contains a repeat; if `payoffs` is not a complete mapping over exactly the game's players; if a contingency does not specify @@ -2732,26 +2745,32 @@ 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``, 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. + For an extensive game, `location` is a `Selector` (an `H`-built + expression, evaluated against this game and treated as a flat set of + nodes) or a `GroupedSelector` (an `H`-built `.by(...)` expression, whose + groups are pooled together). 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. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + For an extensive game, `location` is now a `Selector` or + `GroupedSelector`; a `Node`, `History`, or iterable of these is no + longer accepted directly -- build one with `H`. Parameters ---------- - location : Node, History, Selector, contingency, or iterable of these + location : Selector, GroupedSelector, contingency, or iterable of contingencies The nodes or contingencies to reset to the null outcome. Nonempty; each node or contingency may be referenced only once. Raises ------ - MismatchError - If any node is from a different game. + TypeError + If, for an extensive game, `location` is not a `Selector` or + `GroupedSelector`. ValueError If `location` is empty or contains a repeat, or if a contingency does not specify exactly one strategy for each player. diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 0fe246b79..05c328e7c 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -98,12 +98,11 @@ def efg_asymmetric_tree_text() -> str: """ game = gbt.Game.new_tree(players=["1", "2"], title="Asymmetric multi-infoset game") game.append_move(gbt.H.path(), "1", ["L", "R"]) - left, right = game.root.children game.append_move(gbt.H.path("L"), "2", ["x", "y", "z"]) game.append_move(gbt.H.path("R"), "2", ["p", "q"]) - for node in left.children: - payoff = [1, 1] if node.prior_action.label == "x" else [0, 0] - game.make_outcome(node, {"1": payoff[0], "2": payoff[1]}, node.prior_action.label) - for node in right.children: - game.make_outcome(node, {"1": 0, "2": 0}, node.prior_action.label) + for label in ["x", "y", "z"]: + payoff = [1, 1] if label == "x" else [0, 0] + game.make_outcome(gbt.H.path("L", label), {"1": payoff[0], "2": payoff[1]}, label) + for label in ["p", "q"]: + game.make_outcome(gbt.H.path("R", label), {"1": 0, "2": 0}, label) return game.to_efg() diff --git a/tests/games.py b/tests/games.py index c9bd51bf4..071956ea8 100644 --- a/tests/games.py +++ b/tests/games.py @@ -115,8 +115,7 @@ def create_efg_corresponding_to_bimatrix_game_arrays( g.append_move(gbt.H.path(), "1", actions1) g.append_move(gbt.H.path(...), "2", actions2) for i, j in itertools.product(range(m), range(n)): - node = g.root.children[str(i)].children[str(j)] - g.make_outcome(node, {"1": A[i, j], "2": B[i, j]}, f"({i},{j})") + g.make_outcome(gbt.H.path(str(i), str(j)), {"1": A[i, j], "2": B[i, j]}, f"({i},{j})") return g @@ -153,9 +152,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(gbt.H.path("0", "1")) elif variant == "with neutral outcome": - g.make_outcome(g.root.children["0"], {"1": 0, "2": 0}, "neutral") + g.make_outcome(gbt.H.path("0"), {"1": 0, "2": 0}, "neutral") return g @@ -190,26 +189,18 @@ def create_stripped_down_poker_efg(nonterm_outcomes: bool = False) -> gbt.Game: player="Alice", actions=["Bet", "Fold"] ) - alice_bets_nodes = [ - g.root.children["King"].children["Bet"], - g.root.children["Queen"].children["Bet"], - ] g.append_move(gbt.H.path(..., "Bet"), player="Bob", actions=["Call", "Fold"]) - g.make_outcome(g.root, {"Alice": -1, "Bob": -1}, "Ante") - g.make_outcome( - [node.children["Fold"] for node in g.root.children], {"Alice": 0, "Bob": 2}, "Alice Folds" - ) + g.make_outcome(gbt.H.path(), {"Alice": -1, "Bob": -1}, "Ante") + g.make_outcome(gbt.H.path(..., "Fold"), {"Alice": 0, "Bob": 2}, "Alice Folds") + g.make_outcome(gbt.H.path(..., "Bet"), {"Alice": -1, "Bob": 0}, "Alice Bets") + g.make_outcome(gbt.H.path(..., "Bet", "Fold"), {"Alice": 3, "Bob": 0}, "Bob Folds") g.make_outcome( - [node.children["Bet"] for node in g.root.children], {"Alice": -1, "Bob": 0}, "Alice Bets" + gbt.H.path("King", "Bet", "Call"), {"Alice": 4, "Bob": -1}, "Bob Calls and Loses" ) g.make_outcome( - [node.children["Fold"] for node in alice_bets_nodes], {"Alice": 3, "Bob": 0}, "Bob Folds" + gbt.H.path("Queen", "Bet", "Call"), {"Alice": 0, "Bob": 3}, "Bob Calls and Wins" ) - bob_calls_and_loses_node = g.root.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"] - g.make_outcome(bob_calls_and_wins_node, {"Alice": 0, "Bob": 3}, "Bob Calls and Wins") return g @@ -320,7 +311,10 @@ def bet(player, payoffs, pot): nodes_by_payoff[calculate_payoffs(term_node)].append(term_node) for payoffs, nodes in nodes_by_payoff.items(): - g.make_outcome(nodes, {"Alice": payoffs[0], "Bob": payoffs[1]}, payoff_labels[payoffs]) + g.make_outcome( + selector_for_nodes(nodes), {"Alice": payoffs[0], "Bob": payoffs[1]}, + payoff_labels[payoffs] + ) return g @@ -387,7 +381,9 @@ def get_path(node): # the same non-terminal node is revisited once per terminal descendant walked above deduped_nodes = list(dict.fromkeys(nodes)) alice_payoff, bob_payoff = payoffs_by_key[key] - g.make_outcome(deduped_nodes, {"Alice": alice_payoff, "Bob": bob_payoff}, key) + g.make_outcome( + selector_for_nodes(deduped_nodes), {"Alice": alice_payoff, "Bob": bob_payoff}, key + ) return g @@ -461,21 +457,21 @@ def create_one_shot_trust_efg(unique_NE_variant: bool = False) -> gbt.Game: g.append_move(gbt.H.path(), "Buyer", ["Trust", "Not trust"]) g.append_move(gbt.H.path("Trust"), "Seller", ["Honor", "Abuse"]) g.make_outcome( - g.root.children["Trust"].children["Honor"], {"Buyer": 1, "Seller": 1}, "Trustworthy" + gbt.H.path("Trust", "Honor"), {"Buyer": 1, "Seller": 1}, "Trustworthy" ) if unique_NE_variant: g.make_outcome( - g.root.children["Trust"].children["Abuse"], + gbt.H.path("Trust", "Abuse"), {"Buyer": "1/2", "Seller": 2}, "Untrustworthy", ) else: g.make_outcome( - g.root.children["Trust"].children["Abuse"], + gbt.H.path("Trust", "Abuse"), {"Buyer": -1, "Seller": 2}, "Untrustworthy", ) - g.make_outcome(g.root.children["Not trust"], {"Buyer": 0, "Seller": 0}, "Opt-out") + g.make_outcome(gbt.H.path("Not trust"), {"Buyer": 0, "Seller": 0}, "Opt-out") return g @@ -585,7 +581,6 @@ 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_player = "1" for t in range(self.N): g.append_move(gbt.H.path(*(["Push"] * t)), current_player, ["Take", "Push"]) @@ -593,16 +588,17 @@ def gbt_game(self): if current_player == "2": payoffs.reverse() g.make_outcome( - current_node.children["Take"], {"1": payoffs[0], "2": payoffs[1]}, f"take_{t}" + gbt.H.path(*(["Push"] * t), "Take"), {"1": payoffs[0], "2": payoffs[1]}, + f"take_{t}" ) if t == self.N - 1: # for last round, push payoffs payoffs = [2 ** (t + 1) * self.m1, 2 ** (t + 1) * self.m0] if current_player == "2": payoffs.reverse() g.make_outcome( - current_node.children["Push"], {"1": payoffs[0], "2": payoffs[1]}, f"push_{t}" + gbt.H.path(*(["Push"] * (t + 1))), {"1": payoffs[0], "2": payoffs[1]}, + f"push_{t}" ) - current_node = current_node.children["Push"] current_player = "2" if current_player == "1" else "1" return g @@ -723,7 +719,8 @@ def create_binary_tree(self, g, node, path, whose_turn, depth, max_depth): # whose_turn cycles through 0,1,n_players-1; current player is str(whose_turn + 1) if depth == max_depth: g.make_outcome( - node, {str(p): 0 for p in self.players}, f"leaf_{len(list(g.outcomes))}" + gbt.H.path(*path), {str(p): 0 for p in self.players}, + f"leaf_{len(list(g.outcomes))}" ) else: current_player = str(whose_turn + 1) diff --git a/tests/test_game.py b/tests/test_game.py index 88af1dbfa..85476fded 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -175,7 +175,7 @@ def test_game_get_payoffs_tree(): 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(gbt.H.path("a"), {"Alice": 1}, "a-outcome") payoffs = game.get_payoffs({"Alice": strategy}) assert payoffs["Alice"] == 1 diff --git a/tests/test_node.py b/tests/test_node.py index 02ccc753a..d38178d2b 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -53,7 +53,7 @@ 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"] - game.make_outcome_null(node) + game.make_outcome_null(gbt.H.path("U1", "U2", "U3")) assert not node.outcome diff --git a/tests/test_outcomes.py b/tests/test_outcomes.py index 159ed69a7..3246feb2d 100644 --- a/tests/test_outcomes.py +++ b/tests/test_outcomes.py @@ -9,7 +9,9 @@ def test_make_outcome_attaches_to_all_given_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) up, middle, down = game.root.children - outcome = game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") + outcome = game.make_outcome( + gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1, "Bob": -1}, "shared" + ) assert up.outcome == outcome assert middle.outcome == outcome assert not down.outcome @@ -37,24 +39,28 @@ def test_make_outcome_accepts_selector_matching_several_nodes(): assert down.outcome == outcome -def test_make_outcome_accepts_history_tuple(): +def test_make_outcome_accepts_grouped_selector_pooled(): + """A `GroupedSelector`'s groups are pooled together: every matched node + receives the same outcome, regardless of grouping.""" game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) up, middle, down = game.root.children - outcome = game.make_outcome(("U",), {"Alice": 1, "Bob": -1}, "shared") + outcome = game.make_outcome( + gbt.H.path(...).by(lambda h: h[0]), {"Alice": 1, "Bob": -1}, "shared" + ) assert up.outcome == outcome - assert not middle.outcome - assert not down.outcome + assert middle.outcome == outcome + assert down.outcome == outcome -def test_make_outcome_accepts_iterable_of_history_tuples(): - game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(gbt.H.path(), "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_error_location_not_a_selector(): + """A bare `Node` or `History` tuple is no longer accepted for an extensive game.""" + game = gbt.Game.new_tree(["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) + with pytest.raises(TypeError): + game.make_outcome(game.root.children["U"], {"Alice": 1}, "w") + with pytest.raises(TypeError): + game.make_outcome(("U",), {"Alice": 1}, "w") def test_make_outcome_attaches_at_contingencies(): @@ -71,19 +77,17 @@ 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(gbt.H.path(), "Alice", ["U", "D"]) - up, down = game.root.children - game.make_outcome(up, {"Alice": 1}, "w") - game.make_outcome([up, down], {"Alice": 2}, "w") + game.make_outcome(gbt.H.path("U"), {"Alice": 1}, "w") + game.make_outcome(gbt.H.path(...), {"Alice": 2}, "w") assert [(o.label, o["Alice"]) for o in game.outcomes] == [("w", 2)] def test_make_outcome_label_of_partially_covered_outcome_refused(): game = gbt.Game.new_tree(["Alice"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children - game.make_outcome([up, middle], {"Alice": 1}, "w") + game.make_outcome(gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1}, "w") with pytest.raises(ValueError): - game.make_outcome(down, {"Alice": 2}, "w") + game.make_outcome(gbt.H.path("D"), {"Alice": 2}, "w") assert len(game.outcomes) == 1 @@ -91,10 +95,9 @@ def test_make_outcome_label_of_partially_covered_outcome_refused(): 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(gbt.H.path(), "A", ["win", "lose"]) - win_node, lose_node = game.root.children - game.make_outcome(win_node, {"A": 1, "B": 2}, "win") + game.make_outcome(gbt.H.path("win"), {"A": 1, "B": 2}, "win") with pytest.raises(ValueError): - game.make_outcome(lose_node, {"A": 3, "B": 4}, bad_label) + game.make_outcome(gbt.H.path("lose"), {"A": 3, "B": 4}, bad_label) assert [o.label for o in game.outcomes] == ["win"] @@ -102,7 +105,7 @@ def test_make_outcome_incomplete_payoffs_raises(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "D"]) with pytest.raises(ValueError): - game.make_outcome(next(iter(game.root.children)), {"Alice": 1}, "w") + game.make_outcome(gbt.H.path("U"), {"Alice": 1}, "w") class _RepeatedEntryPayoffs: @@ -125,40 +128,30 @@ def test_make_outcome_payoffs_naming_player_twice_raises(): game.append_move(gbt.H.path(), "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") - - -def test_make_outcome_null_resets_given_nodes_to_null(): - game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children - game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") - game.make_outcome_null(up) - assert not up.outcome - assert middle.outcome - assert not down.outcome + game.make_outcome(gbt.H.path("U"), payoffs, "w") def test_make_outcome_null_accepts_selector(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) up, middle, down = game.root.children - game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") + game.make_outcome( + gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"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(gbt.H.path(), "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_error_location_not_a_selector(): + """A bare `Node` or `History` tuple is no longer accepted for an extensive game.""" + game = gbt.Game.new_tree(["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) + with pytest.raises(TypeError): + game.make_outcome_null(game.root.children["U"]) + with pytest.raises(TypeError): + game.make_outcome_null(("U",)) def test_make_outcome_null_resets_given_contingencies_to_null(): @@ -184,10 +177,10 @@ 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(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children - game.make_outcome([up, middle], {"Alice": 1}, "shared") + middle = game.root.children["M"] + game.make_outcome(gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1}, "shared") outcome_count = len(game.outcomes) - game.make_outcome_null(up) + game.make_outcome_null(gbt.H.path("U")) assert len(game.outcomes) == outcome_count assert middle.outcome @@ -195,9 +188,9 @@ 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(gbt.H.path(), "Alice", ["U", "D"]) - up, _ = game.root.children + up = game.root.children["U"] outcome_count = len(game.outcomes) - game.make_outcome_null(up) + game.make_outcome_null(gbt.H.path("U")) assert outcome_count == len(game.outcomes) assert not up.outcome @@ -272,9 +265,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(gbt.H.path(), "A", ["win", "lose"]) - win_node, lose_node = game.root.children - game.make_outcome(win_node, {"A": 1, "B": 2}, "win") - outcome = game.make_outcome(lose_node, {"A": 0, "B": 0}, "lose") + game.make_outcome(gbt.H.path("win"), {"A": 1, "B": 2}, "win") + outcome = game.make_outcome(gbt.H.path("lose"), {"A": 0, "B": 0}, "lose") with pytest.raises(ValueError): outcome.label = "win" assert outcome.label == "lose" diff --git a/tests/test_players.py b/tests/test_players.py index 76708e2fd..73e89c008 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(gbt.H.path(), {"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(gbt.H.path(), {"Alice": -1, "Bob": -1}, "outcome") assert game.get_max_payoff("Alice") == 1 assert game.get_max_payoff("Bob") == 1 From 58808f4f326e6ccf9082399b2964d907c1e11561 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 07:58:26 +0100 Subject: [PATCH 25/25] Remove `is_defined_at` and `NodeReference`/`NodeReferenceSet` --- doc/pygambit.api.rst | 1 - src/pygambit/behavmixed.pxi | 30 ---------------------------- src/pygambit/gambit.pxd | 1 - src/pygambit/gambit.pyx | 3 --- tests/test_behav.py | 40 ------------------------------------- tests/test_game.py | 3 --- 6 files changed, 78 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index bed5d51aa..fe9d657eb 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -290,7 +290,6 @@ Probability distributions over behavior MixedBehaviorProfile.realiz_probs MixedBehaviorProfile.infoset_probs MixedBehaviorProfile.beliefs - MixedBehaviorProfile.is_defined_at MixedBehaviorProfile.agent_max_regret MixedBehaviorProfile.agent_liap_value MixedBehaviorProfile.max_regret diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 94538d0bf..60691687f 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -491,10 +491,6 @@ class MixedBehaviorProfile: """ raise NotImplementedError - def _is_defined_at(self, infoset: Infoset) -> bool: - """Returns whether the profile specifies a probability distribution at infoset.""" - raise NotImplementedError - def _payoff(self, player: str) -> ProfileDType: """Returns the expected payoff to player.""" raise NotImplementedError @@ -710,26 +706,6 @@ class MixedBehaviorProfile: infoset = self._resolve_infoset_for_node(index) self._setprob_infoset(infoset, distribution, sparse=sparse) - def is_defined_at(self, infoset: NodeReference) -> bool: - """Returns whether the profile has probabilities defined at the information set. - A profile can be well-defined if probabilities are not specified at some information sets, - as long as those information sets are reached with zero probability. - - Parameters - ---------- - infoset : Node or str - A node belonging to the information set to check, or such a node's label. - - Raises - ------ - MismatchError - If `infoset` is a ``Node`` from a different game. - KeyError - If `infoset` is a string and no node in the game has that label. - """ - self._check_validity() - return self._is_defined_at(self.game._resolve_infoset(infoset, "is_defined_at")) - @property def payoffs(self) -> PayoffVector: """Returns the expected payoff to each player, if all players play according to @@ -1036,9 +1012,6 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): def __len__(self) -> int: return deref(self.profile).BehaviorProfileLength() - def _is_defined_at(self, infoset: Infoset) -> bool: - return deref(self.profile).IsDefinedAt(infoset._resolve()) - @cython.cfunc def _getprob_action(self, index: c_GameAction) -> object: return deref(self.profile).getaction(index) @@ -1168,9 +1141,6 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def __len__(self) -> int: return deref(self.profile).BehaviorProfileLength() - def _is_defined_at(self, infoset: Infoset) -> bool: - return deref(self.profile).IsDefinedAt(infoset._resolve()) - @cython.cfunc def _getprob_action(self, index: c_GameAction) -> object: return rat_to_py(deref(self.profile).getaction(index)) diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 046cc5091..6db710453 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -398,7 +398,6 @@ cdef extern from "games/behavmixed.h" namespace "Gambit": c_Game GetGame() except + bool IsInvalidated() int BehaviorProfileLength() except + - bool IsDefinedAt(c_GameInfoset) except + c_MixedBehaviorProfile[T] Normalize() # except + doesn't compile T getitem "operator[]"(int) except +IndexError T getaction "operator[]"(c_GameAction) except +IndexError diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index d26a082b9..17618a30d 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -100,9 +100,6 @@ def _resolve_by_label(collection, label: str, scope: str, kind: str, kind_plural return matches[0] -NodeReference = Node | str -NodeReferenceSet = typing.Iterable[NodeReference] - ProfileDType = float | Rational diff --git a/tests/test_behav.py b/tests/test_behav.py index 6beeb78aa..4fd6d3c65 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -39,46 +39,6 @@ def test_payoffs_reference(game: gbt.Game, rational_flag: bool, payoffs: tuple): assert profile.payoffs[player] == payoff -@pytest.mark.parametrize( - "game,rational_flag", - [ - (games.read_from_file("mixed_behavior_game.efg"), False), - (games.read_from_file("mixed_behavior_game.efg"), True), - (games.create_stripped_down_poker_efg(), False), - (games.create_stripped_down_poker_efg(), True), - ], -) -def test_is_defined_at(game: gbt.Game, rational_flag: bool): - profile = game.mixed_behavior_profile(rational=rational_flag) - for infoset in games.all_infosets(game): - assert profile.is_defined_at(next(iter(infoset.members))) - - -@pytest.mark.parametrize( - "game,label,rational_flag", - [ - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 1:1", False), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 2:1", False), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 3:1", False), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 1:1", True), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 2:1", True), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 3:1", True), - (games.create_stripped_down_poker_efg(), "Alice has King", False), - (games.create_stripped_down_poker_efg(), "Alice has Queen", False), - (games.create_stripped_down_poker_efg(), "Bob's response", False), - (games.create_stripped_down_poker_efg(), "Alice has King", True), - (games.create_stripped_down_poker_efg(), "Alice has Queen", True), - (games.create_stripped_down_poker_efg(), "Bob's response", True), - ], -) -def test_is_defined_at_by_label(game: gbt.Game, label: str, rational_flag: bool): - """is_defined_at resolves a string as a node's own label, not an infoset's label.""" - node = next(iter(games.find_infoset_in_game(game, label).members)) - node.label = "target" - profile = game.mixed_behavior_profile(rational=rational_flag) - assert profile.is_defined_at(node.label) - - @pytest.mark.parametrize( "game,player_label,infoset_label,action_label,prob,rational_flag", [ diff --git a/tests/test_game.py b/tests/test_game.py index 85476fded..deb16d602 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -254,7 +254,6 @@ 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(gbt.H.path(), ["D1"], drop=True) - infoset = game.root for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): _ = profile.action_regrets @@ -272,8 +271,6 @@ def test_mixed_behavior_profile_game_structure_changed(): _ = profile.infoset_regrets with pytest.raises(gbt.GameStructureChangedError): _ = profile.infoset_values - with pytest.raises(gbt.GameStructureChangedError): - profile.is_defined_at(infoset) with pytest.raises(gbt.GameStructureChangedError): profile.agent_liap_value() with pytest.raises(gbt.GameStructureChangedError):