From abbe5d6e921bfa6ccf4877e194b18676883268c3 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 25 Jun 2026 15:12:50 +0100 Subject: [PATCH 01/11] Make Node.infoset a lazy, node-anchored view --- src/pygambit/game.pxi | 15 ++++-- src/pygambit/node.pxi | 106 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 111 insertions(+), 10 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 9baaa6ff3..f7dedb75d 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1677,10 +1677,19 @@ class Game: KeyError If `infoset` is a string and no information set in the game has that label. TypeError - If `infoset` is not an `Infoset` or a `str` + If `infoset` is not an `Infoset`, ``NodeInfoset``, or a `str` ValueError - If `infoset` is an empty `str` or all spaces + If `infoset` is an empty `str` or all spaces, or is a ``NodeInfoset`` that + resolves to no information set (its node is terminal). """ + if isinstance(infoset, NodeInfoset): + resolved = cython.cast(NodeInfoset, infoset)._resolve() + if resolved is None: + raise ValueError( + f"{funcname}(): {argname} resolves to no information set " + f"(the node is terminal)" + ) + infoset = resolved if isinstance(infoset, Infoset): if infoset.game != self: raise MismatchError(f"{funcname}(): {argname} must be part of the same game") @@ -1766,7 +1775,7 @@ class Game: self.game.deref().AppendMove(resolved_node.node, resolved_player.player, len(actions)) for label, action in zip(actions, resolved_node.infoset.actions, strict=True): action.label = label - resolved_infoset = cython.cast(Infoset, resolved_node.infoset) + resolved_infoset = cython.cast(NodeInfoset, resolved_node.infoset)._resolve() for n in resolved_nodes[1:]: self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_infoset.infoset) diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index ec30b40bf..34128b524 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -95,6 +95,94 @@ class NodeChildren: raise TypeError(f"Index must be a str label or an Action, not {action.__class__.__name__}") +@cython.cclass +class NodeInfoset: + """The information set to which a node currently belongs. + + A lazy, node-anchored view: holds the node and resolves its information set on each access, + so the value reflects the current state of the game even after the game is mutated. + + .. versionadded:: 16.7.0 + """ + node = cython.declare(c_GameNode) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create a NodeInfoset outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(node: c_GameNode) -> NodeInfoset: + obj: NodeInfoset = NodeInfoset.__new__(NodeInfoset) + obj.node = node + return obj + + @cython.cfunc + def _resolve(self) -> Infoset: + if self.node.deref().GetInfoset() == cython.cast(c_GameInfoset, NULL): + return None + return Infoset.wrap(self.node.deref().GetInfoset()) + + def resolve(self) -> Infoset: + """Return the information set this node currently belongs to. + + Returns ``None`` if the node is terminal (belongs to no information set). + Unlike accessing this object's attributes, which proxy through to the + current information set lazily, this returns the resolved ``Infoset`` + object (or ``None``) at the moment of the call. + + .. versionadded:: 16.7.0 + """ + return self._resolve() + + def __getattr__(self, name): + if name.startswith("_"): + raise AttributeError(f"'NodeInfoset' object has no attribute '{name}'") + resolved = self._resolve() + if resolved is None: + raise AttributeError( + f"node's information set is currently None (terminal node); " + f"cannot access '{name}'" + ) + return getattr(resolved, name) + + @property + def label(self): + resolved = self._resolve() + if resolved is None: + raise AttributeError( + "node's information set is currently None (terminal node); " + "cannot access 'label'" + ) + return resolved.label + + @label.setter + def label(self, value): + resolved = self._resolve() + if resolved is None: + raise AttributeError( + "node's information set is currently None (terminal node); " + "cannot set 'label'" + ) + resolved.label = value + + def __repr__(self) -> str: + resolved = self._resolve() + return repr(resolved) if resolved is not None else "None" + + def __eq__(self, other: typing.Any) -> bool: + mine = self._resolve() + if isinstance(other, NodeInfoset): + other = cython.cast(NodeInfoset, other)._resolve() + if mine is None or other is None: + return mine is None and other is None + return mine == other + + def __bool__(self) -> bool: + return self._resolve() is not None + + __hash__ = None + + @cython.cclass class Node: """A node in a ``Game``.""" @@ -153,15 +241,19 @@ class Node: return Game.wrap(self.node.deref().GetGame()) @property - def infoset(self) -> Infoset | None: - """The information set to which this node belongs. + def infoset(self) -> NodeInfoset: + """The infoset to which this node currently belongs. - If this is a terminal node, which belongs to no information set, - None is returned. + Returns a lazy, node-anchored view resolved on each access, so the value reflects + the current state of the game even if the game is mutated after this property is read. + For a terminal node, which belongs to no infoset, the view is falsy and + ``resolve()`` returns ``None``. + + .. versionchanged:: 16.7.0 + Returns a lazily-evaluated, node-anchored view rather than capturing + the infoset at the time of access. """ - if self.node.deref().GetInfoset() != cython.cast(c_GameInfoset, NULL): - return Infoset.wrap(self.node.deref().GetInfoset()) - return None + return NodeInfoset.wrap(self.node) @property def player(self) -> Player | None: From 8595d0b3e6b3a859187ba179c60df819bb5e5a9b Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 25 Jun 2026 15:15:07 +0100 Subject: [PATCH 02/11] Make Infoset equality symmetric with the lazy infoset view --- src/pygambit/infoset.pxi | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index 82a0f7098..7bd86bf5b 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -145,11 +145,10 @@ class Infoset: else: return f"Infoset(player={self.player}, number={self.number})" - def __eq__(self, other: typing.Any) -> bool: - return ( - isinstance(other, Infoset) and - self.infoset.deref() == cython.cast(Infoset, other).infoset.deref() - ) + def __eq__(self, other: typing.Any): + if not isinstance(other, Infoset): + return NotImplemented + return self.infoset.deref() == cython.cast(Infoset, other).infoset.deref() def __hash__(self) -> int: return cython.cast(cython.long, self.infoset.deref()) From 0075ea8e1404e346aaa36ca00e71e8fce1894aad Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 25 Jun 2026 15:43:28 +0100 Subject: [PATCH 03/11] Update node tests for the lazy infoset view --- tests/test_infosets.py | 3 ++- tests/test_node.py | 37 +++++++++++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/tests/test_infosets.py b/tests/test_infosets.py index ae57d9c87..47188edcb 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -260,7 +260,8 @@ def test_infoset_is_absent_minded(test_case: AbsentMindednessTestCase): game = test_case.factory() expected_infosets = { - _get_node_by_path(game, path).infoset for path in test_case.expected_am_paths + _get_node_by_path(game, path).infoset.resolve() + for path in test_case.expected_am_paths } actual_infosets = {infoset for infoset in game.infosets if infoset.is_absent_minded} diff --git a/tests/test_node.py b/tests/test_node.py index ea148839a..9bbb2e05e 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -13,9 +13,29 @@ def test_get_infoset(): """Test to ensure that we can retrieve an infoset for a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.infoset is not None - assert game.root.children["U1"].infoset is not None - assert game.root.children["U1"].children["D2"].children["U3"].infoset is None + assert game.root.infoset + assert game.root.children["U1"].infoset + assert not game.root.children["U1"].children["D2"].children["U3"].infoset + + +def test_infoset_equality_is_symmetric(): + """A node-anchored infoset proxy and the resolved Infoset compare equal from either side.""" + game = games.read_from_file("basic_extensive_game.efg") + proxy = game.root.infoset + infoset = proxy.resolve() + assert proxy == infoset + assert infoset == proxy + + +def test_node_infoset_truthiness(): + """A node-anchored infoset view is truthy iff the node currently has an + infoset, and tracks mutation across the terminal boundary.""" + game = games.read_from_file("basic_extensive_game.efg") + terminal = game.root.children["U1"].children["D2"].children["U3"] + proxy = terminal.infoset + assert not proxy + game.append_move(terminal, game.players["Player 1"], ["a", "b"]) + assert proxy def test_get_outcome(): @@ -534,12 +554,13 @@ def test_insert_move_error_player_mismatch(): def test_node_leave_infoset(): - """Test to ensure it's possible to remove a node from an infoset""" + """A node-anchored infoset proxy is lazy: it re-resolves after the node leaves its infoset.""" game = games.read_from_file("basic_extensive_game.efg") - p2_infoset = game.root.children["U1"].infoset - assert len(p2_infoset.members) == 2 - game.leave_infoset(game.root.children["U1"]) - assert len(p2_infoset.members) == 1 + node = game.root.children["U1"] + proxy = node.infoset + assert len(proxy.members) == 2 + game.leave_infoset(node) + assert list(proxy.members) == [node] def test_node_delete_parent(): From cd227f780d8cc2412670a84cfd78e4470aa3cde7 Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 26 Jun 2026 13:22:02 +0100 Subject: [PATCH 04/11] Remove public resolve(), make NodeInfoset hashable --- src/pygambit/node.pxi | 40 ++++++++++++++-------------------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 34128b524..f49dd1aa9 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -48,16 +48,16 @@ class NodeChildren: def __getitem__(self, action: str | Action) -> Node: """Returns the successor node which is reached after `action` is played. - `action` may be an ``Action`` at this node's information set, or its label. + `action` may be an ``Action`` at this node's infoset, or its label. Raises ------ KeyError If `action` is a string and no action with that label exists at the node's - information set, or if the node is terminal. + infoset, or if the node is terminal. ValueError If `action` is an empty or all-whitespace string, or is an ``Action`` - from a different information set. + from a different infoset. TypeError If `action` is not a ``str`` or an ``Action``. @@ -85,7 +85,7 @@ class NodeChildren: try: return Node.wrap(self.parent.deref().GetChild(cython.cast(Action, action).action)) except IndexError: - raise ValueError("Action is from a different information set than node") from None + raise ValueError("Action is from a different infoset than node") from None if isinstance(action, int): raise TypeError( "node children cannot be indexed by position; index by the action taken " @@ -97,9 +97,9 @@ class NodeChildren: @cython.cclass class NodeInfoset: - """The information set to which a node currently belongs. + """The infoset to which a node currently belongs. - A lazy, node-anchored view: holds the node and resolves its information set on each access, + A lazy, node-anchored view: holds the node and resolves its infoset on each access, so the value reflects the current state of the game even after the game is mutated. .. versionadded:: 16.7.0 @@ -122,25 +122,13 @@ class NodeInfoset: return None return Infoset.wrap(self.node.deref().GetInfoset()) - def resolve(self) -> Infoset: - """Return the information set this node currently belongs to. - - Returns ``None`` if the node is terminal (belongs to no information set). - Unlike accessing this object's attributes, which proxy through to the - current information set lazily, this returns the resolved ``Infoset`` - object (or ``None``) at the moment of the call. - - .. versionadded:: 16.7.0 - """ - return self._resolve() - def __getattr__(self, name): if name.startswith("_"): raise AttributeError(f"'NodeInfoset' object has no attribute '{name}'") resolved = self._resolve() if resolved is None: raise AttributeError( - f"node's information set is currently None (terminal node); " + f"node's infoset is currently None (terminal node); " f"cannot access '{name}'" ) return getattr(resolved, name) @@ -150,7 +138,7 @@ class NodeInfoset: resolved = self._resolve() if resolved is None: raise AttributeError( - "node's information set is currently None (terminal node); " + "node's infoset is currently None (terminal node); " "cannot access 'label'" ) return resolved.label @@ -160,7 +148,7 @@ class NodeInfoset: resolved = self._resolve() if resolved is None: raise AttributeError( - "node's information set is currently None (terminal node); " + "node's infoset is currently None (terminal node); " "cannot set 'label'" ) resolved.label = value @@ -180,7 +168,10 @@ class NodeInfoset: def __bool__(self) -> bool: return self._resolve() is not None - __hash__ = None + def __hash__(self) -> int: + # Hash by the resolved infoset (transitional). + resolved = self._resolve() + return hash(resolved) if resolved is not None else 0 @cython.cclass @@ -246,12 +237,9 @@ class Node: Returns a lazy, node-anchored view resolved on each access, so the value reflects the current state of the game even if the game is mutated after this property is read. - For a terminal node, which belongs to no infoset, the view is falsy and - ``resolve()`` returns ``None``. + For a terminal node, which belongs to no infoset, the view is falsy and equals ``None``. .. versionchanged:: 16.7.0 - Returns a lazily-evaluated, node-anchored view rather than capturing - the infoset at the time of access. """ return NodeInfoset.wrap(self.node) From 1cb3927146f0ee179933ec1533d8682bc0627aae Mon Sep 17 00:00:00 2001 From: drdkad Date: Fri, 26 Jun 2026 13:22:13 +0100 Subject: [PATCH 05/11] update tests --- tests/test_infosets.py | 2 +- tests/test_node.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 47188edcb..bdb8da0e8 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -260,7 +260,7 @@ def test_infoset_is_absent_minded(test_case: AbsentMindednessTestCase): game = test_case.factory() expected_infosets = { - _get_node_by_path(game, path).infoset.resolve() + _get_node_by_path(game, path).infoset for path in test_case.expected_am_paths } actual_infosets = {infoset for infoset in game.infosets if infoset.is_absent_minded} diff --git a/tests/test_node.py b/tests/test_node.py index 9bbb2e05e..90335be74 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -22,7 +22,7 @@ def test_infoset_equality_is_symmetric(): """A node-anchored infoset proxy and the resolved Infoset compare equal from either side.""" game = games.read_from_file("basic_extensive_game.efg") proxy = game.root.infoset - infoset = proxy.resolve() + infoset = next(iter(game.infosets)) assert proxy == infoset assert infoset == proxy @@ -563,6 +563,16 @@ def test_node_leave_infoset(): assert list(proxy.members) == [node] +def test_node_infoset_becomes_null_when_truncated(): + """A captured infoset proxy re-resolves to null after the node is truncated to a leaf.""" + game = games.read_from_file("basic_extensive_game.efg") + node = game.root.children["U1"] + proxy = node.infoset + assert proxy + game.delete_tree(node) + assert not proxy + + def test_node_delete_parent(): """Test to ensure deleting a parent node works""" game = games.read_from_file("basic_extensive_game.efg") From ff3d16e752e8b4634ff8cea44db2dff1a25e5b76 Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 30 Jun 2026 13:37:53 +0100 Subject: [PATCH 06/11] Make Node.outcome a lazy, node-anchored view --- src/pygambit/game.pxi | 15 ++++-- src/pygambit/node.pxi | 100 ++++++++++++++++++++++++++++++++++++--- src/pygambit/outcome.pxi | 9 ++-- 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index f7dedb75d..a09045a45 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1534,10 +1534,19 @@ class Game: KeyError If `outcome` is a string and no outcome in the game has that label. TypeError - If `outcome` is not an `Outcome` or a `str` + If `outcome` is not an `Outcome`, ``NodeOutcome``, or a `str` ValueError - If `outcome` is an empty `str` or all spaces + If `outcome` is an empty `str` or all spaces, or is a ``NodeOutcome`` that + resolves to no outcome (no outcome is attached to its node). """ + if isinstance(outcome, NodeOutcome): + resolved = cython.cast(NodeOutcome, outcome)._resolve() + if resolved is None: + raise ValueError( + f"{funcname}(): {argname} resolves to no outcome " + f"(no outcome is attached to the node)" + ) + outcome = resolved if isinstance(outcome, Outcome): if outcome.game != self: raise MismatchError(f"{funcname}(): {argname} must be part of the same game") @@ -1550,7 +1559,7 @@ class Game: try: return self.outcomes[outcome] except KeyError: - raise KeyError(f"{funcname}(): no node with label '{outcome}'") + raise KeyError(f"{funcname}(): no outcome with label '{outcome}'") raise TypeError( f"{funcname}(): {argname} must be Outcome or str, not {outcome.__class__.__name__}" ) diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index f49dd1aa9..9715b5b2e 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -174,6 +174,90 @@ class NodeInfoset: return hash(resolved) if resolved is not None else 0 +@cython.cclass +class NodeOutcome: + """The outcome attached to a node. + + A lazy, node-anchored view: holds the node and resolves its outcome on each access, + so the value reflects the current state of the game even after the game is mutated. + + .. versionadded:: 16.7.0 + """ + node = cython.declare(c_GameNode) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create a NodeOutcome outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(node: c_GameNode) -> NodeOutcome: + obj: NodeOutcome = NodeOutcome.__new__(NodeOutcome) + obj.node = node + return obj + + @cython.cfunc + def _resolve(self) -> Outcome: + if self.node.deref().GetOutcome() == cython.cast(c_GameOutcome, NULL): + return None + return Outcome.wrap(self.node.deref().GetOutcome()) + + def __getattr__(self, name): + if name.startswith("_"): + raise AttributeError(f"'NodeOutcome' object has no attribute '{name}'") + resolved = self._resolve() + if resolved is None: + raise AttributeError( + f"node has no outcome attached; cannot access '{name}'" + ) + return getattr(resolved, name) + + def __getitem__(self, player): + resolved = self._resolve() + if resolved is None: + raise KeyError("node has no outcome attached") + return resolved[player] + + def __setitem__(self, player, value): + resolved = self._resolve() + if resolved is None: + raise KeyError("node has no outcome attached") + resolved[player] = value + + @property + def label(self): + resolved = self._resolve() + if resolved is None: + raise AttributeError("node has no outcome attached; cannot access 'label'") + return resolved.label + + @label.setter + def label(self, value): + resolved = self._resolve() + if resolved is None: + raise AttributeError("node has no outcome attached; cannot set 'label'") + resolved.label = value + + def __repr__(self) -> str: + resolved = self._resolve() + return repr(resolved) if resolved is not None else "None" + + def __eq__(self, other: typing.Any) -> bool: + mine = self._resolve() + if isinstance(other, NodeOutcome): + other = cython.cast(NodeOutcome, other)._resolve() + if mine is None or other is None: + return mine is None and other is None + return mine == other + + def __bool__(self) -> bool: + return self._resolve() is not None + + def __hash__(self) -> int: + # Hash by the resolved outcome (transitional). + resolved = self._resolve() + return hash(resolved) if resolved is not None else 0 + + @cython.cclass class Node: """A node in a ``Game``.""" @@ -342,14 +426,18 @@ class Node: return self.node.deref().IsStrategyReachable() @property - def outcome(self) -> Outcome | None: - """Returns the outcome attached to the node. + def outcome(self) -> NodeOutcome: + """The outcome currently attached to this node. + + Returns a lazy, node-anchored view resolved on each access, so the value reflects + the current state of the game even if the game is mutated after this property is read. + When no outcome is attached, the view is falsy and equals ``None``. - If no outcome is attached to the node, None is returned. + .. versionchanged:: 16.7.0 + Now returns a lazily-evaluated, node-anchored view rather than capturing the + outcome at the time of access. """ - if self.node.deref().GetOutcome() == cython.cast(c_GameOutcome, NULL): - return None - return Outcome.wrap(self.node.deref().GetOutcome()) + return NodeOutcome.wrap(self.node) @property def plays(self) -> list[Node]: diff --git a/src/pygambit/outcome.pxi b/src/pygambit/outcome.pxi index 6f3e62f23..4bd4e7950 100644 --- a/src/pygambit/outcome.pxi +++ b/src/pygambit/outcome.pxi @@ -47,11 +47,10 @@ class Outcome: else: return f"Outcome(game={self.game}, number={self.number})" - def __eq__(self, other: typing.Any) -> bool: - return ( - isinstance(other, Outcome) and - self.outcome.deref() == cython.cast(Outcome, other).outcome.deref() - ) + def __eq__(self, other: typing.Any): + if not isinstance(other, Outcome): + return NotImplemented + return self.outcome.deref() == cython.cast(Outcome, other).outcome.deref() def __hash__(self) -> int: return cython.cast(cython.long, self.outcome.deref()) From ac03cfeb2c3d0da5607daf2d01ae2f1288a056f1 Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 30 Jun 2026 13:38:24 +0100 Subject: [PATCH 07/11] update tests for the outcomes refactoring --- tests/test_node.py | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/tests/test_node.py b/tests/test_node.py index 90335be74..b36ae1871 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -42,17 +42,38 @@ def test_get_outcome(): """Test to ensure that we can retrieve an outcome for a given node""" game = games.read_from_file("basic_extensive_game.efg") assert ( - game.root.children["U1"].children["D2"].children["U3"].outcome - == game.outcomes["Outcome 1"] - ) - assert game.root.outcome is None + game.root.children["U1"].children["D2"].children["U3"].outcome + == game.outcomes["Outcome 1"] + ) + assert not game.root.outcome def test_set_outcome_null(): - """Test to set an outcome to the null outcome.""" + """Setting an 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.set_outcome(node, None) + assert not node.outcome + + +def test_node_outcome_subscript_tracks_mutation(): + """Indexing the outcome view reads/writes the outcome's payoffs, reflecting mutation.""" + game = games.read_from_file("basic_extensive_game.efg") + node = game.root.children["U1"].children["D2"].children["U3"] + proxy = node.outcome + player = game.players["Player 1"] + proxy[player] = 7 + assert node.outcome[player] == 7 + + +def test_outcome_equality_is_symmetric(): + """A node-anchored outcome view and the resolved Outcome compare equal from either side.""" game = games.read_from_file("basic_extensive_game.efg") - game.set_outcome(game.root.children["U1"].children["U2"].children["U3"], None) - assert game.root.children["U1"].children["U2"].children["U3"].outcome is None + node = game.root.children["U1"].children["D2"].children["U3"] + proxy = node.outcome + outcome = game.outcomes["Outcome 1"] + assert proxy == outcome + assert outcome == proxy def test_get_player(): From 730c8e5b10042b07488bd54ada6e4512154263fe Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 30 Jun 2026 14:07:55 +0100 Subject: [PATCH 08/11] Make Node.player a lazy, node-anchored view --- src/pygambit/game.pxi | 30 ++++++-------- src/pygambit/node.pxi | 87 ++++++++++++++++++++++++++++++++++++++--- src/pygambit/player.pxi | 9 ++--- 3 files changed, 98 insertions(+), 28 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index a09045a45..17ac43c62 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1476,27 +1476,21 @@ class Game: def _resolve_player(self, player: typing.Any, funcname: str, argname: str = "player") -> Player: """Resolve an attempt to reference a player of the game. - - Parameters - ---------- - player : Any - An object to resolve as a reference to a player. - funcname : str - The name of the function to raise any exception on behalf of. - argname : str, default 'player' - The name of the argument being checked - - Raises - ------ - MismatchError - If `player` is a `Player` from a different game. - KeyError - If `player` is a string and no player in the game has that label. + ... TypeError - If `player` is not a `Player` or a `str` + If `player` is not a `Player`, ``NodePlayer``, or a `str` ValueError - If `player` is an empty `str` or all spaces + If `player` is an empty `str` or is a ``NodePlayer`` that resolves to no player + (terminal node). """ + if isinstance(player, NodePlayer): + resolved = cython.cast(NodePlayer, player)._resolve() + if resolved is None: + raise ValueError( + f"{funcname}(): {argname} resolves to no player " + f"(the node is terminal)" + ) + player = resolved if isinstance(player, Player): if player.game != self: raise MismatchError(f"{funcname}(): {argname} must be part of the same game") diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 9715b5b2e..cb7829809 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -258,6 +258,78 @@ class NodeOutcome: return hash(resolved) if resolved is not None else 0 +@cython.cclass +class NodePlayer: + """The player who makes the decision at a node. + + A lazy, node-anchored view: holds the node and resolves its player on each access, + so the value reflects the current state of the game even after the game is mutated. + + .. versionadded:: 16.7.0 + """ + node = cython.declare(c_GameNode) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create a NodePlayer outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(node: c_GameNode) -> NodePlayer: + obj: NodePlayer = NodePlayer.__new__(NodePlayer) + obj.node = node + return obj + + @cython.cfunc + def _resolve(self) -> Player: + if self.node.deref().GetPlayer() != cython.cast(c_GamePlayer, NULL): + return Player.wrap(self.node.deref().GetPlayer()) + return None + + def __getattr__(self, name): + if name.startswith("_"): + raise AttributeError(f"'NodePlayer' object has no attribute '{name}'") + resolved = self._resolve() + if resolved is None: + raise AttributeError( + f"node has no player (terminal node); cannot access '{name}'" + ) + return getattr(resolved, name) + + @property + def label(self): + resolved = self._resolve() + if resolved is None: + raise AttributeError("node has no player (terminal node); cannot access 'label'") + return resolved.label + + @label.setter + def label(self, value): + resolved = self._resolve() + if resolved is None: + raise AttributeError("node has no player (terminal node); cannot set 'label'") + resolved.label = value + + def __repr__(self) -> str: + resolved = self._resolve() + return repr(resolved) if resolved is not None else "None" + + def __eq__(self, other: typing.Any) -> bool: + mine = self._resolve() + if isinstance(other, NodePlayer): + other = cython.cast(NodePlayer, other)._resolve() + if mine is None or other is None: + return mine is None and other is None + return mine == other + + def __bool__(self) -> bool: + return self._resolve() is not None + + def __hash__(self) -> int: + # Hash by the resolved player (transitional). + resolved = self._resolve() + return hash(resolved) if resolved is not None else 0 + + @cython.cclass class Node: """A node in a ``Game``.""" @@ -328,14 +400,19 @@ class Node: return NodeInfoset.wrap(self.node) @property - def player(self) -> Player | None: + def player(self) -> NodePlayer: """The player who makes the decision at this node. - If this is a terminal node, None is returned. + Returns a lazy, node-anchored view resolved on each access, so the value reflects + the current state of the game even if the game is mutated after this property is read. + For a terminal node, which has no player, the view is falsy and equals ``None``. + At a chance node the view resolves to the chance player (``is_chance`` is ``True``). + + .. versionchanged:: 16.7.0 + Now returns a lazily-evaluated, node-anchored view rather than capturing the + player at the time of access. """ - if self.node.deref().GetPlayer() != cython.cast(c_GamePlayer, NULL): - return Player.wrap(self.node.deref().GetPlayer()) - return None + return NodePlayer.wrap(self.node) @property def parent(self) -> Node | None: diff --git a/src/pygambit/player.pxi b/src/pygambit/player.pxi index 6c54545f4..d826fdb82 100644 --- a/src/pygambit/player.pxi +++ b/src/pygambit/player.pxi @@ -231,11 +231,10 @@ class Player: else: return f"Player(game={self.game}, number={self.number})" - def __eq__(self, other: typing.Any) -> bool: - return ( - isinstance(other, Player) and - self.player.deref() == cython.cast(Player, other).player.deref() - ) + def __eq__(self, other: typing.Any): + if not isinstance(other, Player): + return NotImplemented + return self.player.deref() == cython.cast(Player, other).player.deref() def __hash__(self) -> int: return cython.cast(cython.long, self.player.deref()) From 2c1218c06a6a2f3102896f92bcb7c0dd61ab0726 Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 30 Jun 2026 14:08:24 +0100 Subject: [PATCH 09/11] update tests for the players refactoring --- tests/test_node.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/test_node.py b/tests/test_node.py index b36ae1871..a5244a62d 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -80,7 +80,24 @@ def test_get_player(): """Test to ensure that we can retrieve a player for a given node""" game = games.read_from_file("basic_extensive_game.efg") assert game.root.player == game.players["Player 1"] - assert game.root.children["U1"].children["D2"].children["U3"].player is None + assert not game.root.children["U1"].children["D2"].children["U3"].player + + +def test_player_equality_is_symmetric(): + """A node-anchored player view and the resolved Player compare equal from either side.""" + game = games.read_from_file("basic_extensive_game.efg") + proxy = game.root.player + player = game.players["Player 1"] + assert proxy == player + assert player == proxy + + +def test_node_player_resolves_chance(): + """At a chance node the player view resolves to the chance player.""" + game = games.read_from_file("stripped_down_poker.efg") + chance_node = game.root + assert chance_node.player.is_chance + assert chance_node.player == game.players.chance def test_get_game(): From f97cc296e39e94b8ba14918e7998441eeef72231 Mon Sep 17 00:00:00 2001 From: Ted Turocy Date: Wed, 1 Jul 2026 10:56:13 +0100 Subject: [PATCH 10/11] Update game.pxi --- src/pygambit/game.pxi | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 17ac43c62..22f716fe8 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1478,9 +1478,9 @@ class Game: """Resolve an attempt to reference a player of the game. ... TypeError - If `player` is not a `Player`, ``NodePlayer``, or a `str` + If `player` is not a `Player`, `NodePlayer`, or a `str` ValueError - If `player` is an empty `str` or is a ``NodePlayer`` that resolves to no player + If `player` is an empty `str` or is a `NodePlayer` that resolves to no player (terminal node). """ if isinstance(player, NodePlayer): @@ -1528,9 +1528,9 @@ class Game: KeyError If `outcome` is a string and no outcome in the game has that label. TypeError - If `outcome` is not an `Outcome`, ``NodeOutcome``, or a `str` + If `outcome` is not an `Outcome`, `NodeOutcome`, or a `str` ValueError - If `outcome` is an empty `str` or all spaces, or is a ``NodeOutcome`` that + If `outcome` is an empty `str` or all spaces, or is a `NodeOutcome` that resolves to no outcome (no outcome is attached to its node). """ if isinstance(outcome, NodeOutcome): @@ -1680,9 +1680,9 @@ class Game: KeyError If `infoset` is a string and no information set in the game has that label. TypeError - If `infoset` is not an `Infoset`, ``NodeInfoset``, or a `str` + If `infoset` is not an `Infoset`, `NodeInfoset`, or a `str` ValueError - If `infoset` is an empty `str` or all spaces, or is a ``NodeInfoset`` that + If `infoset` is an empty `str` or all spaces, or is a `NodeInfoset` that resolves to no information set (its node is terminal). """ if isinstance(infoset, NodeInfoset): From 83745d7009957c92431bf3c80daba19cbcd42b42 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 1 Jul 2026 11:06:48 +0100 Subject: [PATCH 11/11] Updated ChangeLog --- ChangeLog | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ChangeLog b/ChangeLog index cc0b309de..bca1fd127 100644 --- a/ChangeLog +++ b/ChangeLog @@ -41,6 +41,8 @@ double spaces); invalid labels raise `ValueError` in `pygambit`. (#944) - Refined and clarified the graphical interface's handling (and persisting) of "workspaces", and improved warning messages on closing windows for games or workspaces with unsaved changes. +- In `pygambit`, the `.outcome`, `.player`, and `.infoset` attributes are now treated as predicates + that are evaluated on-demand, to align with the behaviour of collections. (#946) ### Removed - Built-in plotting of logit QRE for strategic games has been removed in the GUI (#809)