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) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 2d146f781..39cf0acbd 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") @@ -1534,10 +1528,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 +1553,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__}" ) @@ -1677,10 +1680,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 +1778,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/infoset.pxi b/src/pygambit/infoset.pxi index 7ee046a7d..412cfac22 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()) diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 8a4a64278..e1bdd30c1 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 " @@ -95,6 +95,241 @@ class NodeChildren: raise TypeError(f"Index must be a str label or an Action, not {action.__class__.__name__}") +@cython.cclass +class NodeInfoset: + """The infoset to which a node currently belongs. + + A lazy, node-anchored view: holds the node and resolves its infoset on each access, + so the value reflects the current state of the game even after the game is mutated. + + .. versionadded:: 16.7.0 + """ + node = cython.declare(c_GameNode) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create a NodeInfoset outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(node: c_GameNode) -> NodeInfoset: + obj: NodeInfoset = NodeInfoset.__new__(NodeInfoset) + obj.node = node + return obj + + @cython.cfunc + def _resolve(self) -> Infoset: + if self.node.deref().GetInfoset() == cython.cast(c_GameInfoset, NULL): + return None + return Infoset.wrap(self.node.deref().GetInfoset()) + + def __getattr__(self, name): + if name.startswith("_"): + raise AttributeError(f"'NodeInfoset' object has no attribute '{name}'") + resolved = self._resolve() + if resolved is None: + raise AttributeError( + f"node's infoset is currently None (terminal node); " + f"cannot access '{name}'" + ) + return getattr(resolved, name) + + @property + def label(self): + resolved = self._resolve() + if resolved is None: + raise AttributeError( + "node's infoset is currently None (terminal node); " + "cannot access 'label'" + ) + return resolved.label + + @label.setter + def label(self, value): + resolved = self._resolve() + if resolved is None: + raise AttributeError( + "node's infoset is currently None (terminal node); " + "cannot set 'label'" + ) + resolved.label = value + + def __repr__(self) -> str: + resolved = self._resolve() + return repr(resolved) if resolved is not None else "None" + + def __eq__(self, other: typing.Any) -> bool: + mine = self._resolve() + if isinstance(other, NodeInfoset): + other = cython.cast(NodeInfoset, other)._resolve() + if mine is None or other is None: + return mine is None and other is None + return mine == other + + def __bool__(self) -> bool: + return self._resolve() is not None + + def __hash__(self) -> int: + # Hash by the resolved infoset (transitional). + resolved = self._resolve() + return hash(resolved) if resolved is not None else 0 + + +@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 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``.""" @@ -158,25 +393,31 @@ 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 equals ``None``. + + .. versionchanged:: 16.7.0 """ - 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: + 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: @@ -267,14 +508,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. - If no outcome is attached to the 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. + When no outcome is attached, the view is falsy and equals ``None``. + + .. 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 d8cc806e9..975cf9b34 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()) diff --git a/src/pygambit/player.pxi b/src/pygambit/player.pxi index 351339d98..dae64cee8 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()) diff --git a/tests/test_infosets.py b/tests/test_infosets.py index e64d9b70c..a32c53341 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -276,7 +276,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 + 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 8792021c8..ecf002edc 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -13,33 +13,91 @@ 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 = next(iter(game.infosets)) + 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(): """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(): """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(): @@ -534,12 +592,23 @@ 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_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():