From a7c091b26236493d338d286289d6786b63e3e6a8 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 10:37:52 +0100 Subject: [PATCH 1/6] Indexing of profiles uses nodes in place of Infoset --- doc/pygambit.api.rst | 2 +- doc/tutorials/03_stripped_down_poker.ipynb | 2 +- src/pygambit/behavmixed.pxi | 100 +++++++--------- src/pygambit/behavspt.pxi | 104 +++++++---------- src/pygambit/cli/common.py | 13 ++- src/pygambit/game.pxi | 18 ++- src/pygambit/qre.py | 15 ++- tests/games.py | 7 ++ tests/test_behav.py | 124 +++++++++++--------- tests/test_behavspt_profiles.py | 130 ++++++++++----------- tests/test_nash.py | 2 +- tests/test_qre.py | 6 +- 12 files changed, 264 insertions(+), 259 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index fe9d657eb4..c427fc656f 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -357,7 +357,7 @@ Subsets of actions BehaviorSupportProfile.__getitem__ BehaviorSupportProfile.__setitem__ BehaviorSupportProfile.copy - BehaviorSupportProfile.is_reachable + BehaviorSupportProfile.is_infoset_reachable BehaviorSupport BehaviorSupport.player diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index cede9646b3..89e0d2e14b 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -171,7 +171,7 @@ "\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", + "To set this scenario up in Gambit, we'll need to add both possible moves as part of the same information set.\n", "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:" ] }, diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 60691687f3..9bb67e3bbb 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -388,62 +388,42 @@ class MixedBehaviorProfile: Parameters ---------- - index : str or Node + index : str or Selector The part of the profile to return: * If `index` is a ``str``, returns a ``MixedBehavior`` over the player's information sets. The player is determined by finding the player with that label, if any. - * If `index` is a ``Node``, returns a ``MixedAction`` over the actions at - the node's information set. + * If `index` is a ``Selector`` (an `H`-built expression) resolving to a + single node, returns a ``MixedAction`` over the actions at that + node's information set. Raises ------ TypeError - If `index` is not a ``str`` or a ``Node``. - MismatchError - If `index` is a ``Node`` from a different game. + If `index` is not a ``str`` or a ``Selector``. ValueError - If `index` is a terminal ``Node``, which belongs to no information set. + If `index` is a ``Selector`` resolving to a terminal node, which + belongs to no information set, or to a chance event. KeyError If `index` is a ``str`` and no player in the game has that label. """ self._check_validity() - if isinstance(index, Node): - return self._mixed_action_at(self._resolve_infoset_for_node(index)) if isinstance(index, str): values = { node.infoset: self._mixed_action_at(node.infoset) for node in self.game.get_infosets(index) } return MixedBehavior.wrap(index, values) + if isinstance(index, Selector): + resolved_infoset = self.game._resolve_infoset( + index, "MixedBehaviorProfile.__getitem__" + ) + return self._mixed_action_at(resolved_infoset) raise TypeError( - f"profile index must be str or Node, not {index.__class__.__name__}" + f"profile index must be str or Selector, not {index.__class__.__name__}" ) - def _resolve_infoset_for_node(self, node: Node) -> Infoset: - """Resolves the personal player's information set containing node. - - Raises - ------ - MismatchError - If `node` belongs to a different game. - ValueError - If `node` resolves to a chance event, or is terminal, and so belongs to - no personal player's information set. - """ - if node.game != self.game: - raise MismatchError("node must belong to this game") - infoset = cython.cast(Infoset, node.infoset) - if not infoset: - if node.event: - raise ValueError( - "node belongs to a chance event, not a personal player's " - "information set" - ) - raise ValueError("node is terminal, has no information set") - return infoset - def _all_infosets(self) -> typing.Iterator[Infoset]: """Iterates over every information set and event in the game.""" for player in self.game.players: @@ -613,7 +593,7 @@ class MixedBehaviorProfile: for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions(): self._setprob_action(a, values[a.deref().GetLabel().decode("utf-8")]) - def __setitem__(self, index: Node, distribution: collections.abc.Mapping) -> None: + def __setitem__(self, index: Selector, distribution: collections.abc.Mapping) -> None: """Sets the mixed action at the information set containing `index`. `distribution` need not specify a weight for every one of the information @@ -622,8 +602,9 @@ class MixedBehaviorProfile: Parameters ---------- - index : Node - A node belonging to the information set to set. + index : Selector + An `H`-built expression resolving to a single node belonging to the + information set to set. distribution : Mapping[str, Any] A non-negative weight for some or all of the information set's actions, keyed by action label; actions it omits are treated as having weight @@ -634,14 +615,13 @@ class MixedBehaviorProfile: Raises ------ TypeError - If `index` is not a ``Node``, or `distribution` is not a Mapping. - MismatchError - If `index` is a ``Node`` from a different game. + If `index` is not a ``Selector``, or `distribution` is not a Mapping. ValueError - If `index` is a terminal ``Node``, which belongs to no information set; - if any key of `distribution` is not one of the information set's action - labels; if any weight cannot be interpreted as a number; if any weight - is negative; or if the weights are all zero. + If `index` resolves to a terminal node, which belongs to no + information set, or to a chance event; if any key of `distribution` + is not one of the information set's action labels; if any weight + cannot be interpreted as a number; if any weight is negative; or if + the weights are all zero. See Also -------- @@ -650,13 +630,13 @@ class MixedBehaviorProfile: silently defaulting omitted ones to zero. """ self._check_validity() - if not isinstance(index, Node): - raise TypeError(f"profile index must be Node, not {index.__class__.__name__}") - infoset = self._resolve_infoset_for_node(index) + if not isinstance(index, Selector): + raise TypeError(f"profile index must be Selector, not {index.__class__.__name__}") + infoset = self.game._resolve_infoset(index, "MixedBehaviorProfile.__setitem__") self._setprob_infoset(infoset, distribution, sparse=True) def set_mixed_action( - self, index: Node, distribution: collections.abc.Mapping, sparse: bool = False + self, index: Selector, distribution: collections.abc.Mapping, sparse: bool = False ) -> None: """Sets the mixed action at the information set containing `index`. @@ -670,8 +650,9 @@ class MixedBehaviorProfile: Parameters ---------- - index : Node - A node belonging to the information set to set. + index : Selector + An `H`-built expression resolving to a single node belonging to the + information set to set. distribution : Mapping[str, Any] A non-negative weight for the information set's actions, keyed by action label. A weight may be any value Gambit can interpret as a @@ -686,24 +667,23 @@ class MixedBehaviorProfile: Raises ------ TypeError - If `index` is not a ``Node``, or `distribution` is not a Mapping. - MismatchError - If `index` is a ``Node`` from a different game. + If `index` is not a ``Selector``, or `distribution` is not a Mapping. ValueError - If `index` is a terminal ``Node``, which belongs to no information set; - if any key of `distribution` is not one of the information set's action - labels; if `sparse` is False and `distribution` omits an action; if any - weight cannot be interpreted as a number; if any weight is negative; or - if the weights are all zero. + If `index` resolves to a terminal node, which belongs to no + information set, or to a chance event; if any key of `distribution` + is not one of the information set's action labels; if `sparse` is + False and `distribution` omits an action; if any weight cannot be + interpreted as a number; if any weight is negative; or if the + weights are all zero. See Also -------- __setitem__ """ self._check_validity() - if not isinstance(index, Node): - raise TypeError(f"profile index must be Node, not {index.__class__.__name__}") - infoset = self._resolve_infoset_for_node(index) + if not isinstance(index, Selector): + raise TypeError(f"profile index must be Selector, not {index.__class__.__name__}") + infoset = self.game._resolve_infoset(index, "MixedBehaviorProfile.set_mixed_action") self._setprob_infoset(infoset, distribution, sparse=sparse) @property diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi index 1abee090a1..cd3f727ff0 100644 --- a/src/pygambit/behavspt.pxi +++ b/src/pygambit/behavspt.pxi @@ -167,62 +167,41 @@ class BehaviorSupportProfile: Parameters ---------- - index : str, Node, or Infoset + index : str or Selector The part of the profile to return: * If `index` is a ``str``, returns a ``BehaviorSupport`` over the player's information sets. The player is determined by finding the player with that label, if any. - * If `index` is a ``Node`` or an ``Infoset`` (e.g. one obtained from - iterating a ``BehaviorSupport``), returns an ``ActionSupport`` over the - actions in the support at the information set. + * If `index` is a ``Selector`` (an `H`-built expression) resolving to a + single node, returns an ``ActionSupport`` over the actions in the + support at that node's information set. Raises ------ TypeError - If `index` is not a ``str``, a ``Node``, or an ``Infoset``. - MismatchError - If `index` is a ``Node`` or ``Infoset`` from a different game. + If `index` is not a ``str`` or a ``Selector``. ValueError - If `index` is a terminal ``Node``, which belongs to no information set. + If `index` is a ``Selector`` resolving to a terminal node, which belongs + to no information set, or to a chance event. KeyError If `index` is a ``str`` and no player in the game has that label. """ - resolved_infoset = self._resolve_infoset_arg(index) - if resolved_infoset is not None: - if resolved_infoset.game != self.game: - raise MismatchError("infoset must be part of the same game") - return self._action_support_at(resolved_infoset) if isinstance(index, str): values = { node.infoset: self._action_support_at(node.infoset) for node in self.game.get_infosets(index) } return BehaviorSupport.wrap(index, values) + if isinstance(index, Selector): + resolved_infoset = self.game._resolve_infoset( + index, "BehaviorSupportProfile.__getitem__" + ) + return self._action_support_at(resolved_infoset) raise TypeError( - f"profile index must be str, Node, or Infoset, not {index.__class__.__name__}" + f"profile index must be str or Selector, not {index.__class__.__name__}" ) - @cython.cfunc - def _resolve_infoset_arg(self, index: object) -> object: - """Resolves index to the Infoset it identifies if it is a Node or an Infoset, - or returns None if index is neither (e.g. a player label str). - """ - if isinstance(index, Node): - node = cython.cast(Node, index) - resolved = cython.cast(Infoset, node.infoset) - if not resolved: - if node.event: - raise ValueError( - "index resolves to a chance event; a behavior support is only " - "defined for a personal player's information sets" - ) - raise ValueError("index resolves to no information set (the node is terminal)") - return resolved - if isinstance(index, Infoset): - return index - return None - def _action_support_at(self, infoset: Infoset) -> ActionSupport: """Returns a snapshot of the action support at infoset, as of now.""" infoset_handle = cython.cast(Infoset, infoset)._resolve() @@ -273,10 +252,9 @@ class BehaviorSupportProfile: Parameters ---------- - infoset : Node or Infoset - A node belonging to the information set whose support is to be set, or - the information set itself (e.g. one obtained from iterating a - ``BehaviorSupport``). + infoset : Selector + An `H`-built expression resolving to a single node belonging to the + information set whose support is to be set. actions : Iterable[str] The labels of the actions which should be in the support at the information set. Every other action at the information set is removed @@ -285,21 +263,20 @@ class BehaviorSupportProfile: Raises ------ TypeError - If `infoset` is not a ``Node`` or an ``Infoset``. - MismatchError - If `infoset` is a `Node` or `Infoset` from a different game. + If `infoset` is not a ``Selector``. ValueError If any entry of `actions` is not one of the information set's action - labels, or if `actions` is empty; or if `infoset` is a terminal node, - which belongs to no information set. + labels, or if `actions` is empty; or if `infoset` resolves to a + terminal node, which belongs to no information set, or to a chance + event. """ - resolved_infoset = self._resolve_infoset_arg(infoset) - if resolved_infoset is None: + if not isinstance(infoset, Selector): raise TypeError( - f"profile index must be Node or Infoset, not {infoset.__class__.__name__}" + f"profile index must be Selector, not {infoset.__class__.__name__}" ) - if resolved_infoset.game != self.game: - raise MismatchError("infoset must be part of the same game") + resolved_infoset = self.game._resolve_infoset( + infoset, "BehaviorSupportProfile.__setitem__" + ) self._set_support(resolved_infoset, actions) def copy(self) -> BehaviorSupportProfile: @@ -313,29 +290,28 @@ class BehaviorSupportProfile: """ return BehaviorSupportProfile.wrap(self.profile) - def is_reachable(self, infoset: typing.Any) -> bool: + def is_infoset_reachable(self, infoset: Selector) -> bool: """Returns whether `infoset` can be reached under this support, i.e. whether there is some path of play consistent with the support that reaches it. Parameters ---------- - infoset : Node, str, or Infoset - A node belonging to the information set to check, such a node's label, or - the information set itself (e.g. one obtained from iterating a - ``BehaviorSupport``). + infoset : Selector + An `H`-built expression resolving to a single node belonging to the + information set to check. Raises ------ - MismatchError - If `infoset` is a `Node` or `Infoset` from a different game. - KeyError - If `infoset` is a string and no node in the game has that label. + TypeError + If `infoset` is not a ``Selector``. + ValueError + If `infoset` resolves to a terminal node, which belongs to no + information set, or to a chance event. """ - resolved_infoset: Infoset - if isinstance(infoset, Infoset): - resolved_infoset = infoset - if resolved_infoset.game != self.game: - raise MismatchError("is_reachable(): infoset must be part of the same game") - else: - resolved_infoset = self.game._resolve_infoset(infoset, "is_reachable") + if not isinstance(infoset, Selector): + raise TypeError( + f"is_infoset_reachable(): infoset must be a Selector, " + f"not {infoset.__class__.__name__}" + ) + resolved_infoset: Infoset = self.game._resolve_infoset(infoset, "is_infoset_reachable") return deref(self.profile).IsReachable(resolved_infoset._resolve()) diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index d7863a2c31..5f863c702b 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -361,6 +361,17 @@ def read_strategy_profiles_csv( return profiles +def _selector_for_node(node: gbt.Node) -> gbt.H: + """A root-anchored `Selector` matching exactly `node`'s own path of action labels.""" + labels = [] + current = node + while current.parent is not None: + labels.append(current.prior_action.label) + current = current.parent + labels.reverse() + return gbt.H.path(*labels) + + def read_behavior_profiles_csv( path: str, game: gbt.Game ) -> list[gbt.MixedBehaviorProfileRational]: @@ -388,7 +399,7 @@ def read_behavior_profiles_csv( profile = game.mixed_behavior_profile(rational=True) for player in game.players: for node in game.get_infosets(player): - profile[node] = {a: next(values) for a in node.infoset.actions} + profile[_selector_for_node(node)] = {a: next(values) for a in node.infoset.actions} profiles.append(profile) return profiles diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 274ccd4aa5..c1b4764234 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1073,9 +1073,11 @@ class Game: f"Number of elements does not match number of " f"actions for infoset {infoset} for {p}" ) - profile[node] = { - a: typefunc(u) for a, u in zip(infoset.actions, v, strict=True) - } + profile._setprob_infoset( + infoset, + {a: typefunc(u) for a, u in zip(infoset.actions, v, strict=True)}, + sparse=True, + ) return profile def mixed_behavior_profile(self, data=None, rational=False) -> MixedBehaviorProfile: @@ -1155,7 +1157,10 @@ class Game: profile = self.mixed_behavior_profile() for player in self.players: for node in self.get_infosets(player): - profile[node] = _dirichlet_distribution(node.infoset.actions, gen) + infoset = node.infoset + profile._setprob_infoset( + infoset, _dirichlet_distribution(infoset.actions, gen), sparse=True + ) return profile elif denom < 1: raise ValueError("random_behavior_profile(): denom must be positive") @@ -1163,7 +1168,10 @@ class Game: profile = self.mixed_behavior_profile(rational=True) for player in self.players: for node in self.get_infosets(player): - profile[node] = _grid_distribution(node.infoset.actions, denom, gen) + infoset = node.infoset + profile._setprob_infoset( + infoset, _grid_distribution(infoset.actions, denom, gen), sparse=True + ) return profile def strategy_support_profile( diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index 22de0655e0..efbbf3befb 100644 --- a/src/pygambit/qre.py +++ b/src/pygambit/qre.py @@ -253,11 +253,22 @@ def _estimate_strategy_empirical( ) +def _selector_for_node(node: libgbt.Node) -> libgbt.H: + """A root-anchored `Selector` matching exactly `node`'s own path of action labels.""" + labels = [] + current = node + while current.parent is not None: + labels.append(current.prior_action.label) + current = current.parent + labels.reverse() + return libgbt.H.path(*labels) + + def _estimate_behavior_empirical( data: libgbt.MixedBehaviorProfile, ) -> LogitQREMixedBehaviorFitResult: flattened_data = [ - data[node][a] + data[_selector_for_node(node)][a] for p in data.game.players for node in data.game.get_infosets(p) for a in node.infoset.actions @@ -277,7 +288,7 @@ def _estimate_behavior_empirical( log_probs = iter(_empirical_log_logit_probs(res.x[0], regrets)) for player in data.game.players: for node in data.game.get_infosets(player): - profile[node] = { + profile[_selector_for_node(node)] = { a: math.exp(next(log_probs)) for a in node.infoset.actions } return LogitQREMixedBehaviorFitResult( diff --git a/tests/games.py b/tests/games.py index 071956ea84..8a3420a997 100644 --- a/tests/games.py +++ b/tests/games.py @@ -52,6 +52,13 @@ def selector_for_nodes(nodes: list[gbt.Node]) -> gbt.Selector: return gbt.H.after().filter(lambda h: h[:] in histories) +def selector_for_node(node: gbt.Node) -> gbt.Selector: + """A root-anchored `Selector` matching exactly `node`'s own path of action + labels -- for adapting a fixture's `Node` (e.g. one from `Game.get_infosets`) + to profile indexing, which is `Selector`-only.""" + return gbt.H.path(*_node_history(node)) + + # Label-validation fixtures. # VALID: accepted by the C++ validator (IsValidLabel in src/games/game.h), including # well-formed UTF-8 text (#862, 17.0.0). A single Unicode whitespace character diff --git a/tests/test_behav.py b/tests/test_behav.py index 4fd6d3c657..77ba460ec0 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -20,7 +20,9 @@ def _set_action_probs(profile: gbt.MixedBehaviorProfile, probs: list, rational_f probs_iter = iter(probs) for infoset in games.all_infosets(profile.game): node = next(iter(infoset.members)) - profile[node] = {a: convert(next(probs_iter)) for a in infoset.actions} + profile[games.selector_for_node(node)] = { + a: convert(next(probs_iter)) for a in infoset.actions + } @pytest.mark.parametrize( @@ -164,7 +166,7 @@ def test_profile_indexing_by_player_infoset_action_reference( infoset = games.find_infoset(game, player_label, infoset_label) node = next(iter(infoset.members)) prob = gbt.Rational(prob) if rational_flag else prob - assert profile[node][action_label] == prob + assert profile[games.selector_for_node(node)][action_label] == prob @pytest.mark.parametrize( @@ -220,17 +222,17 @@ def test_profile_indexing_by_player_infoset_action_reference( (games.create_stripped_down_poker_efg(), "Bob", "Bob's response", ["1/2", "1/2"], True), ], ) -def test_profile_indexing_by_node_reference( +def test_profile_indexing_by_selector_reference( game: gbt.Game, player_label: str, infoset_label: str, probs: list, rational_flag: bool ): - """profile[node] and profile[player_label][node] resolve to the same MixedAction.""" + """profile[selector] and profile[player_label][node] resolve to the same MixedAction.""" profile = game.mixed_behavior_profile(rational=rational_flag) infoset = games.find_infoset(game, player_label, infoset_label) node = next(iter(infoset.members)) probs = [gbt.Rational(prob) for prob in probs] if rational_flag else probs expected = dict(zip(infoset.actions, probs, strict=True)) assert profile[player_label][node] == expected - assert profile[node] == expected + assert profile[games.selector_for_node(node)] == expected @pytest.mark.parametrize( @@ -319,8 +321,9 @@ def test_set_probabilities_action( profile = game.mixed_behavior_profile(rational=rational_flag) prob = gbt.Rational(prob) if rational_flag else prob node = next(iter(games.find_infoset_in_game(game, infoset_label).members)) - profile[node] = {action_label: prob} - assert profile[node][action_label] == prob + selector = games.selector_for_node(node) + profile[selector] = {action_label: prob} + assert profile[selector][action_label] == prob @pytest.mark.parametrize( @@ -396,9 +399,10 @@ def test_set_probabilities_infoset( probs = [gbt.Rational(p) for p in probs] infoset = games.find_infoset(game, player_label, infoset_label) node = next(iter(infoset.members)) + selector = games.selector_for_node(node) expected = dict(zip(infoset.actions, probs, strict=True)) - profile[node] = expected - assert profile[node] == expected + profile[selector] = expected + assert profile[selector] == expected @pytest.mark.parametrize( @@ -435,7 +439,7 @@ def test_set_probabilities_player_by_label( for infoset, distribution in zip( games.player_infosets(game, player_label), expected, strict=True ): - profile[next(iter(infoset.members))] = distribution + profile[games.selector_for_node(next(iter(infoset.members)))] = distribution assert profile[player_label] == expected @@ -446,92 +450,101 @@ def _p1_node(game: gbt.Game): def test_behavior_setitem_allows_sparse_distribution(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) - profile[node] = {"U1": 1} - assert profile[node] == {"U1": 1, "D1": 0} + selector = games.selector_for_node(_p1_node(game)) + profile[selector] = {"U1": 1} + assert profile[selector] == {"U1": 1, "D1": 0} def test_set_mixed_action_sparse_matches_setitem(): game = games.read_from_file("mixed_behavior_game.efg") - node = _p1_node(game) + selector = games.selector_for_node(_p1_node(game)) sparse_profile = game.mixed_behavior_profile() - sparse_profile.set_mixed_action(node, {"U1": 1}, sparse=True) + sparse_profile.set_mixed_action(selector, {"U1": 1}, sparse=True) setitem_profile = game.mixed_behavior_profile() - setitem_profile[node] = {"U1": 1} - assert sparse_profile[node] == setitem_profile[node] + setitem_profile[selector] = {"U1": 1} + assert sparse_profile[selector] == setitem_profile[selector] def test_set_mixed_action_defaults_to_requiring_every_label(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + selector = games.selector_for_node(_p1_node(game)) with pytest.raises(ValueError, match="exactly one weight"): - profile.set_mixed_action(node, {"U1": 1}) + profile.set_mixed_action(selector, {"U1": 1}) @pytest.mark.parametrize("sparse", [False, True]) def test_setitem_and_set_mixed_action_reject_unknown_action_label(sparse: bool): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + selector = games.selector_for_node(_p1_node(game)) with pytest.raises(ValueError, match="not an action label"): - profile.set_mixed_action(node, {"not-an-action": 1}, sparse=sparse) + profile.set_mixed_action(selector, {"not-an-action": 1}, sparse=sparse) with pytest.raises(ValueError, match="not an action label"): - profile[node] = {"not-an-action": 1} + profile[selector] = {"not-an-action": 1} def test_behavior_setitem_empty_distribution_is_all_zero_error(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + selector = games.selector_for_node(_p1_node(game)) with pytest.raises(ValueError, match="zero"): - profile[node] = {} + profile[selector] = {} @pytest.mark.parametrize("sparse", [False, True]) def test_setitem_and_set_mixed_action_reject_non_mapping(sparse: bool): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + selector = games.selector_for_node(_p1_node(game)) with pytest.raises(TypeError, match="Mapping"): - profile.set_mixed_action(node, [1, 0], sparse=sparse) + profile.set_mixed_action(selector, [1, 0], sparse=sparse) with pytest.raises(TypeError, match="Mapping"): - profile[node] = [1, 0] + profile[selector] = [1, 0] @pytest.mark.parametrize("sparse", [False, True]) def test_setitem_and_set_mixed_action_reject_uncoercible_weight(sparse: bool): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + selector = games.selector_for_node(_p1_node(game)) full_distribution = {"U1": "abc", "D1": 0} with pytest.raises(ValueError, match="convert"): - profile.set_mixed_action(node, full_distribution, sparse=sparse) + profile.set_mixed_action(selector, full_distribution, sparse=sparse) with pytest.raises(ValueError, match="convert"): - profile[node] = full_distribution + profile[selector] = full_distribution def test_behavior_setitem_sparse_rejects_negative_weight(): """Negativity is checked even for weights given under a sparse distribution.""" game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + selector = games.selector_for_node(_p1_node(game)) with pytest.raises(ValueError, match="negative"): - profile[node] = {"U1": -1} + profile[selector] = {"U1": -1} @pytest.mark.parametrize("sparse", [False, True]) -def test_behavior_indexing_rejects_infoset_object(sparse: bool): - """MixedBehaviorProfile's indexing is Node-only; an Infoset object is rejected.""" +def test_behavior_indexing_rejects_node_and_infoset(sparse: bool): + """MixedBehaviorProfile's indexing is Selector-only; a bare Node or Infoset object + is rejected, following the same pattern as `Game.get_minimal_subgame`. + """ game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() infoset = games.player_infosets(game, "Player 1")[0] + node = next(iter(infoset.members)) with pytest.raises(TypeError): profile[infoset] with pytest.raises(TypeError): profile[infoset] = {"U1": 1} with pytest.raises(TypeError): profile.set_mixed_action(infoset, {"U1": 1}, sparse=sparse) + with pytest.raises(TypeError): + profile[node] + with pytest.raises(TypeError): + profile[node] = {"U1": 1} + with pytest.raises(TypeError): + profile.set_mixed_action(node, {"U1": 1}, sparse=sparse) @pytest.mark.parametrize("rational_flag", [False, True]) @@ -542,36 +555,37 @@ def test_mixed_action_and_behavior_are_frozen_snapshots(rational_flag: bool): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile(rational=rational_flag) node = _p1_node(game) - action_before = profile[node] + selector = games.selector_for_node(node) + action_before = profile[selector] behavior_before = profile["Player 1"] - profile[node] = {"U1": 1, "D1": 0} + profile[selector] = {"U1": 1, "D1": 0} assert dict(action_before) == {"U1": 0.5, "D1": 0.5} assert dict(behavior_before[node]) == {"U1": 0.5, "D1": 0.5} - assert dict(profile[node]) == {"U1": 1, "D1": 0} + assert dict(profile[selector]) == {"U1": 1, "D1": 0} @pytest.mark.parametrize("rational_flag", [False, True]) def test_behavior_copy_mutating_copy_does_not_affect_original(rational_flag: bool): game = games.read_from_file("mixed_behavior_game.efg") original = game.mixed_behavior_profile(rational=rational_flag) - node = _p1_node(game) - original_before = dict(original[node]) + selector = games.selector_for_node(_p1_node(game)) + original_before = dict(original[selector]) copy = original.copy() - copy[node] = {"U1": 1, "D1": 0} - assert dict(original[node]) == original_before - assert dict(copy[node]) == {"U1": 1, "D1": 0} + copy[selector] = {"U1": 1, "D1": 0} + assert dict(original[selector]) == original_before + assert dict(copy[selector]) == {"U1": 1, "D1": 0} @pytest.mark.parametrize("rational_flag", [False, True]) def test_behavior_copy_mutating_original_does_not_affect_copy(rational_flag: bool): game = games.read_from_file("mixed_behavior_game.efg") original = game.mixed_behavior_profile(rational=rational_flag) - node = _p1_node(game) + selector = games.selector_for_node(_p1_node(game)) copy = original.copy() - copy_before = dict(copy[node]) - original[node] = {"U1": 1, "D1": 0} - assert dict(copy[node]) == copy_before - assert dict(original[node]) == {"U1": 1, "D1": 0} + copy_before = dict(copy[selector]) + original[selector] = {"U1": 1, "D1": 0} + assert dict(copy[selector]) == copy_before + assert dict(original[selector]) == {"U1": 1, "D1": 0} @pytest.mark.parametrize("rational_flag", [False, True]) @@ -584,20 +598,20 @@ def test_as_float_returns_double(rational_flag: bool): def test_as_float_converts_rational_probabilities(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile(rational=True) - node = _p1_node(game) - profile[node] = {"U1": "1/3", "D1": "2/3"} + selector = games.selector_for_node(_p1_node(game)) + profile[selector] = {"U1": "1/3", "D1": "2/3"} result = profile.as_float() - assert dict(result[node]) == {"U1": pytest.approx(1 / 3), "D1": pytest.approx(2 / 3)} + assert dict(result[selector]) == {"U1": pytest.approx(1 / 3), "D1": pytest.approx(2 / 3)} def test_as_float_is_independent_copy(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile(rational=False) - node = _p1_node(game) + selector = games.selector_for_node(_p1_node(game)) result = profile.as_float() assert result == profile - result[node] = {"U1": 1.0, "D1": 0.0} - assert dict(profile[node]) != dict(result[node]) + result[selector] = {"U1": 1.0, "D1": 0.0} + assert dict(profile[selector]) != dict(result[selector]) @pytest.mark.parametrize( @@ -1880,10 +1894,10 @@ def test_specific_profile(game: gbt.Game, rational_flag: bool, data: list): profile = game.mixed_behavior_profile(rational=rational_flag, data=data) flattened = iter([k for i in data for j in i for k in j]) for infoset in games.all_infosets(game): - node = next(iter(infoset.members)) + selector = games.selector_for_node(next(iter(infoset.members))) for action in infoset.actions: prob = next(flattened) - assert profile[node][action] == (gbt.Rational(prob) if rational_flag else prob) + assert profile[selector][action] == (gbt.Rational(prob) if rational_flag else prob) @pytest.mark.parametrize( diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index a0c1a1c9a7..c8c1355a01 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -5,15 +5,26 @@ from . import games -def _find_infoset(game, label): - """Find the Infoset with the given label, searching across all players.""" +def _find_node(game, label): + """Find a member node of the infoset with the given label, searching across all + players.""" for player in game.players: for node in game.get_infosets(player): if node.infoset.label == label: - return node.infoset + return node raise KeyError(label) +def _find_infoset(game, label): + """Find the Infoset with the given label, searching across all players.""" + return _find_node(game, label).infoset + + +def _find_selector(game, label): + """A `Selector` resolving to the infoset with the given label.""" + return games.selector_for_node(_find_node(game, label)) + + def _branching_game(): """A small tree where P1 chooses L/R, each leading to a separate P2 decision, so that removing an action can make a whole subtree's information set unreachable. @@ -28,14 +39,14 @@ def _branching_game(): root.infoset.label = "P1 infoset" left.infoset.label = "P2 left infoset" right.infoset.label = "P2 right infoset" - return game, root.infoset, left.infoset, right.infoset + return game, root, left, right -def test_getitem_by_infoset(): +def test_getitem_by_selector(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() infoset = _find_infoset(game, "Infoset 1:1") - support = profile[infoset] + support = profile[_find_selector(game, "Infoset 1:1")] assert set(support) == {"U1", "D1"} assert support.infoset == infoset assert "U1" in support @@ -65,19 +76,27 @@ def test_getitem_rejects_other_types(): profile[0] -def test_getitem_infoset_wrong_game(): - game = games.read_from_file("mixed_behavior_game.efg") - other = games.read_from_file("mixed_behavior_game.efg") +def test_getitem_setitem_reject_node_and_infoset(): + """Profile indexing is `Selector`-only: a bare `Node` or `Infoset` -- even one + obtained straight from the game, e.g. `game.root`/`game.root.infoset` -- is no + longer accepted, following the same pattern as `Game.get_minimal_subgame`. + """ + game, root, _, _ = _branching_game() profile = game.behavior_support_profile() - with pytest.raises(gbt.MismatchError): - profile[_find_infoset(other, "Infoset 1:1")] + with pytest.raises(TypeError): + profile[root] + with pytest.raises(TypeError): + profile[root.infoset] + with pytest.raises(TypeError): + profile[root] = ["R"] + with pytest.raises(TypeError): + profile[root.infoset] = ["R"] def test_predicate_construction(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile(lambda node, a: a != "D1") - infoset = _find_infoset(game, "Infoset 1:1") - assert set(profile[infoset]) == {"U1"} + assert set(profile[_find_selector(game, "Infoset 1:1")]) == {"U1"} def test_predicate_construction_error(): @@ -106,96 +125,73 @@ def test_behaviorsupport_iter(): def test_setitem_replaces_support(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = _find_infoset(game, "Infoset 1:1") - profile[infoset] = ["U1"] - assert set(profile[infoset]) == {"U1"} + selector = _find_selector(game, "Infoset 1:1") + profile[selector] = ["U1"] + assert set(profile[selector]) == {"U1"} def test_setitem_unknown_label(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = _find_infoset(game, "Infoset 1:1") with pytest.raises(ValueError): - profile[infoset] = ["not-a-label"] + profile[_find_selector(game, "Infoset 1:1")] = ["not-a-label"] def test_setitem_empty(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = _find_infoset(game, "Infoset 1:1") with pytest.raises(ValueError): - profile[infoset] = [] + profile[_find_selector(game, "Infoset 1:1")] = [] -def test_setitem_rejects_non_infoset(): +def test_setitem_rejects_non_selector(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() with pytest.raises(TypeError): profile["Infoset 1:1"] = ["U1"] -def test_setitem_infoset_wrong_game(): - game = games.read_from_file("mixed_behavior_game.efg") - other = games.read_from_file("mixed_behavior_game.efg") - profile = game.behavior_support_profile() - with pytest.raises(gbt.MismatchError): - profile[_find_infoset(other, "Infoset 1:1")] = ["U1"] - - def test_copy_is_independent(): game = games.read_from_file("mixed_behavior_game.efg") original = game.behavior_support_profile() copy = original.copy() - infoset = _find_infoset(game, "Infoset 1:1") - copy[infoset] = ["U1"] - assert set(copy[infoset]) == {"U1"} - assert set(original[infoset]) == {"U1", "D1"} + selector = _find_selector(game, "Infoset 1:1") + copy[selector] = ["U1"] + assert set(copy[selector]) == {"U1"} + assert set(original[selector]) == {"U1", "D1"} def test_actionsupport_is_snapshot(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = _find_infoset(game, "Infoset 1:1") - snapshot = profile[infoset] - profile[infoset] = ["U1"] + selector = _find_selector(game, "Infoset 1:1") + snapshot = profile[selector] + profile[selector] = ["U1"] assert set(snapshot) == {"U1", "D1"} -def test_getitem_setitem_accept_node_infoset(): - game, root_infoset, left_infoset, right_infoset = _branching_game() +def test_getitem_setitem_use_selector(): + game, root, _, _ = _branching_game() profile = game.behavior_support_profile() - # game.root.infoset is a live, node-anchored Infoset view -- both __getitem__ and - # __setitem__ must resolve it the same way Node.infoset is used everywhere else. - assert set(profile[game.root.infoset]) == {"L", "R"} - profile[game.root.infoset] = ["R"] - assert set(profile[game.root.infoset]) == {"R"} + root_selector = games.selector_for_node(root) + # game.root's information set is a live, node-anchored view -- the Selector must + # resolve it the same way Node.infoset is used everywhere else. + assert set(profile[root_selector]) == {"L", "R"} + profile[root_selector] = ["R"] + assert set(profile[root_selector]) == {"R"} -def test_is_reachable(): - game, root_infoset, left_infoset, right_infoset = _branching_game() +def test_is_infoset_reachable(): + game, root, left, right = _branching_game() profile = game.behavior_support_profile() - assert profile.is_reachable(left_infoset) - assert profile.is_reachable(right_infoset) + left_selector = games.selector_for_node(left) + right_selector = games.selector_for_node(right) + assert profile.is_infoset_reachable(left_selector) + assert profile.is_infoset_reachable(right_selector) copy = profile.copy() - copy[root_infoset] = ["R"] - assert not copy.is_reachable(left_infoset) - assert copy.is_reachable(right_infoset) + copy[games.selector_for_node(root)] = ["R"] + assert not copy.is_infoset_reachable(left_selector) + assert copy.is_infoset_reachable(right_selector) # the original, un-mutated profile is unaffected - assert profile.is_reachable(left_infoset) - - -def test_is_reachable_by_label(): - """`is_reachable` resolves a string as a node's own label, not an infoset's label.""" - game, root_infoset, left_infoset, right_infoset = _branching_game() - left = next(iter(left_infoset.members)) - left.label = "left" - profile = game.behavior_support_profile() - assert profile.is_reachable(left.label) - - -def test_is_reachable_wrong_game(): - _, _, left_infoset, _ = _branching_game() - other, *_ = _branching_game() - with pytest.raises(gbt.MismatchError): - other.behavior_support_profile().is_reachable(left_infoset) + assert profile.is_infoset_reachable(left_selector) diff --git a/tests/test_nash.py b/tests/test_nash.py index d49b599686..5cc90e9792 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -26,7 +26,7 @@ def d(*probs) -> tuple: def _action_prob(profile: gbt.MixedBehaviorProfile, node: gbt.Node, label: str): """The probability profile assigns to the action labeled `label` at `node`'s information set.""" - return profile[node][label] + return profile[games.selector_for_node(node)][label] @dataclasses.dataclass diff --git a/tests/test_qre.py b/tests/test_qre.py index fd5770edcf..c25195d490 100644 --- a/tests/test_qre.py +++ b/tests/test_qre.py @@ -15,7 +15,9 @@ def _asymmetric_poker_behavior_data() -> gbt.MixedBehaviorProfile: for player in game.players: for infoset in games.player_infosets(game, player): node = next(iter(infoset.members)) - data[node] = {a: float(i + 2) for i, a in enumerate(infoset.actions)} + data[games.selector_for_node(node)] = { + a: float(i + 2) for i, a in enumerate(infoset.actions) + } return data @@ -75,6 +77,6 @@ def test_logit_estimate_behavior_completes(use_empirical: bool, local_max: bool) for player in data.game.players: for infoset in games.player_infosets(data.game, player): node = next(iter(infoset.members)) - probs = dict(result.profile[node]) + probs = dict(result.profile[games.selector_for_node(node)]) assert probs.keys() == set(infoset.actions) assert sum(probs.values()) == pytest.approx(1.0) From cff2a6e689500a6e7e3c5e363566664679588566 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 10:52:33 +0100 Subject: [PATCH 2/6] Index profiles by history selectors rather than Infoset --- src/pygambit/behavmixed.pxi | 37 ++++++++++++++++++++------------- src/pygambit/behavspt.pxi | 31 ++++++++++++++++++++------- src/pygambit/strategy.pxi | 36 +++++++++++++++++++------------- tests/test_actions.py | 24 +++++++-------------- tests/test_behav.py | 22 ++++++++++---------- tests/test_behavspt_profiles.py | 3 +-- tests/test_extensive.py | 4 +++- tests/test_game.py | 4 ++-- 8 files changed, 92 insertions(+), 69 deletions(-) diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 9bb67e3bbb..169028790a 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -235,14 +235,16 @@ class MixedBehavior: """ _player = cython.declare(str) _values = cython.declare(dict) + _game = cython.declare(Game) def __init__(self, *args, **kwargs) -> None: raise ValueError("Cannot create a MixedBehavior outside a Game.") @staticmethod @cython.cfunc - def wrap(player: str, values: dict) -> MixedBehavior: + def wrap(game: Game, player: str, values: dict) -> MixedBehavior: obj: MixedBehavior = MixedBehavior.__new__(MixedBehavior) + obj._game = game obj._player = player obj._values = values return obj @@ -297,27 +299,34 @@ class MixedBehavior: """ yield from self._values.items() - def __getitem__(self, index: Node) -> MixedAction: - """Returns the mixed action at the information set containing `index`. + def __getitem__(self, selector: Selector) -> MixedAction: + """Returns the mixed action at the information set `selector` resolves to. Parameters ---------- - index : Node - A node belonging to the information set to return. + selector : Selector + An `H`-built expression resolving to a single node belonging to the + information set to return. Raises ------ - MismatchError - If `index` is a ``Node`` from a different game, or belongs to an - information set that isn't this player's. + TypeError + If `selector` is not a ``Selector``. ValueError - If `index` is a terminal node, which belongs to no information set. + If `selector` resolves to a terminal node, which belongs to no + information set, or to a chance event. + MismatchError + If the resolved information set does not belong to this player. """ - infoset = cython.cast(Infoset, index.infoset) - if not infoset: - raise ValueError("node is terminal, has no information set") + if not isinstance(selector, Selector): + raise TypeError( + f"MixedBehavior index must be Selector, not {selector.__class__.__name__}" + ) + infoset = self._game._resolve_infoset(selector, "MixedBehavior.__getitem__") if infoset.player != self._player: - raise MismatchError("node must belong to this player") + raise MismatchError( + "selector must resolve to an information set belonging to this player" + ) return self._values[infoset] @@ -414,7 +423,7 @@ class MixedBehaviorProfile: node.infoset: self._mixed_action_at(node.infoset) for node in self.game.get_infosets(index) } - return MixedBehavior.wrap(index, values) + return MixedBehavior.wrap(self.game, index, values) if isinstance(index, Selector): resolved_infoset = self.game._resolve_infoset( index, "MixedBehaviorProfile.__getitem__" diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi index cd3f727ff0..427779baa8 100644 --- a/src/pygambit/behavspt.pxi +++ b/src/pygambit/behavspt.pxi @@ -55,14 +55,16 @@ class BehaviorSupport: """ _player = cython.declare(str) _values = cython.declare(dict) + _game = cython.declare(Game) def __init__(self, *args, **kwargs) -> None: raise ValueError("Cannot create a BehaviorSupport outside a Game.") @staticmethod @cython.cfunc - def wrap(player: str, values: dict) -> BehaviorSupport: + def wrap(game: Game, player: str, values: dict) -> BehaviorSupport: obj: BehaviorSupport = BehaviorSupport.__new__(BehaviorSupport) + obj._game = game obj._player = player obj._values = values return obj @@ -97,21 +99,34 @@ class BehaviorSupport: """ yield from self._values.values() - def __getitem__(self, infoset: Infoset) -> ActionSupport: - """Returns the action support at `infoset`. + def __getitem__(self, selector: Selector) -> ActionSupport: + """Returns the action support at the information set `selector` resolves to. Parameters ---------- - infoset : Infoset - The information set to return the support for. + selector : Selector + An `H`-built expression resolving to a single node belonging to the + information set to return the support for. Raises ------ + TypeError + If `selector` is not a ``Selector``. + ValueError + If `selector` resolves to a terminal node, which belongs to no + information set, or to a chance event. MismatchError - If `infoset` does not belong to this player. + If the resolved information set does not belong to this player. """ + if not isinstance(selector, Selector): + raise TypeError( + f"BehaviorSupport index must be Selector, not {selector.__class__.__name__}" + ) + infoset = self._game._resolve_infoset(selector, "BehaviorSupport.__getitem__") if infoset.player != self._player: - raise MismatchError("infoset must belong to this player") + raise MismatchError( + "selector must resolve to an information set belonging to this player" + ) return self._values[infoset] @@ -192,7 +207,7 @@ class BehaviorSupportProfile: node.infoset: self._action_support_at(node.infoset) for node in self.game.get_infosets(index) } - return BehaviorSupport.wrap(index, values) + return BehaviorSupport.wrap(self.game, index, values) if isinstance(index, Selector): resolved_infoset = self.game._resolve_infoset( index, "BehaviorSupportProfile.__getitem__" diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index 6e506fc4e4..e7b3d97440 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -74,32 +74,38 @@ class StrategyBehavior: return None return action.deref().GetLabel().decode("utf-8") - def _resolve_key(self, key: Infoset | str) -> Infoset: + def _resolve_key(self, key: Selector) -> Infoset: """Resolve `key` to an information set at which the player has the move.""" - infoset: Infoset - if isinstance(key, Infoset): - infoset = key - if infoset.game != self._game: - raise MismatchError("StrategyBehavior: key must be part of the same game") - else: - infoset = self._game._resolve_infoset(key, "StrategyBehavior", "key") + if not isinstance(key, Selector): + raise TypeError( + f"StrategyBehavior key must be Selector, not {key.__class__.__name__}" + ) + infoset = self._game._resolve_infoset(key, "StrategyBehavior", "key") if infoset.player != self._player_label: raise ValueError( f"Player '{self._player_label}' does not have the move at {infoset}." ) return infoset - def __getitem__(self, key: Infoset | str) -> str: - """Return the label of the action prescribed at the information set - referenced by `key`. + def __getitem__(self, key: Selector) -> str: + """Return the label of the action prescribed at the information set `key` + resolves to. + + Parameters + ---------- + key : Selector + An `H`-built expression resolving to a single node belonging to the + information set to look up. Raises ------ + TypeError + If `key` is not a ``Selector``. KeyError - If the strategy prescribes no action at the information set, - or if `key` is a string and no information set has that label. + If the strategy prescribes no action at the information set. ValueError - If the information set belongs to a different player. + If the information set belongs to a different player, or `key` + resolves to a terminal node or a chance event. """ infoset = self._resolve_key(key) action = self._action_at(infoset) @@ -109,7 +115,7 @@ class StrategyBehavior: ) return action - def get(self, key: Infoset | str, default: typing.Any = None) -> str | None: + def get(self, key: Selector, default: typing.Any = None) -> str | None: """Return the label of the action prescribed at `key`, or `default` if none is prescribed.""" infoset = self._resolve_key(key) diff --git a/tests/test_actions.py b/tests/test_actions.py index 7e51063bed..28d0a95bdb 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -296,12 +296,9 @@ def test_get_behavior_prescribed_action_defined( game, player_label, strategy_label, infoset_path, expected_action_label ): """Verify `Game.get_behavior` retrieves the correct action for defined actions.""" - node = game.root - for action_label in infoset_path: - node = node.children[action_label] - infoset = node.infoset + selector = gbt.H.path(*infoset_path) - prescribed_action = game.get_behavior(player_label, strategy_label).get(infoset) + prescribed_action = game.get_behavior(player_label, strategy_label).get(selector) assert prescribed_action == expected_action_label @@ -323,14 +320,12 @@ def test_get_behavior_prescribed_action_undefined_returns_none( ): """Verify `Game.get_behavior` returns None when called on an unreached player's infoset""" if infoset_label is not None: - infoset = games.find_infoset_in_game(game, infoset_label) + node = next(iter(games.find_infoset_in_game(game, infoset_label).members)) + selector = games.selector_for_node(node) else: - node = game.root - for action_label in infoset_path: - node = node.children[action_label] - infoset = node.infoset + selector = gbt.H.path(*infoset_path) - prescribed_action = game.get_behavior(player_label, strategy_label).get(infoset) + prescribed_action = game.get_behavior(player_label, strategy_label).get(selector) assert prescribed_action is None @@ -355,10 +350,7 @@ def test_get_behavior_raises_value_error_for_wrong_player( to a different player than the strategy. """ behavior = game.get_behavior(player_label, next(iter(game.get_strategies(player_label)))) - node = game.root - for action_label in other_infoset_path: - node = node.children[action_label] - other_players_infoset = node.infoset + other_selector = gbt.H.path(*other_infoset_path) with pytest.raises(ValueError): - behavior.get(other_players_infoset) + behavior.get(other_selector) diff --git a/tests/test_behav.py b/tests/test_behav.py index 77ba460ec0..4f87eb4055 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -225,14 +225,15 @@ def test_profile_indexing_by_player_infoset_action_reference( def test_profile_indexing_by_selector_reference( game: gbt.Game, player_label: str, infoset_label: str, probs: list, rational_flag: bool ): - """profile[selector] and profile[player_label][node] resolve to the same MixedAction.""" + """profile[selector] and profile[player_label][selector] resolve to the same + MixedAction.""" profile = game.mixed_behavior_profile(rational=rational_flag) infoset = games.find_infoset(game, player_label, infoset_label) - node = next(iter(infoset.members)) + selector = games.selector_for_node(next(iter(infoset.members))) probs = [gbt.Rational(prob) for prob in probs] if rational_flag else probs expected = dict(zip(infoset.actions, probs, strict=True)) - assert profile[player_label][node] == expected - assert profile[games.selector_for_node(node)] == expected + assert profile[player_label][selector] == expected + assert profile[selector] == expected @pytest.mark.parametrize( @@ -242,17 +243,17 @@ def test_profile_indexing_by_selector_reference( (games.create_stripped_down_poker_efg(), "Alice", "Bob"), ], ) -def test_behavior_indexing_rejects_node_from_different_player( +def test_behavior_indexing_rejects_selector_from_different_player( game: gbt.Game, player_label: str, other_player_label: str ): - """MixedBehavior/MixedBehaviorProfile reject a Node whose information set belongs to a + """MixedBehavior rejects a Selector whose information set belongs to a different player than the one being indexed. """ profile = game.mixed_behavior_profile() other_infoset = games.player_infosets(game, other_player_label)[0] - other_node = next(iter(other_infoset.members)) + other_selector = games.selector_for_node(next(iter(other_infoset.members))) with pytest.raises(gbt.MismatchError): - profile[player_label][other_node] + profile[player_label][other_selector] @pytest.mark.parametrize( @@ -554,13 +555,12 @@ def test_mixed_action_and_behavior_are_frozen_snapshots(rational_flag: bool): """ game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile(rational=rational_flag) - node = _p1_node(game) - selector = games.selector_for_node(node) + selector = games.selector_for_node(_p1_node(game)) action_before = profile[selector] behavior_before = profile["Player 1"] profile[selector] = {"U1": 1, "D1": 0} assert dict(action_before) == {"U1": 0.5, "D1": 0.5} - assert dict(behavior_before[node]) == {"U1": 0.5, "D1": 0.5} + assert dict(behavior_before[selector]) == {"U1": 0.5, "D1": 0.5} assert dict(profile[selector]) == {"U1": 1, "D1": 0} diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index c8c1355a01..dbeb64f438 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -58,8 +58,7 @@ def test_getitem_by_player_label(): profile = game.behavior_support_profile() support = profile["Player 1"] assert support.player == "Player 1" - infoset = _find_infoset(game, "Infoset 1:1") - assert set(support[infoset]) == {"U1", "D1"} + assert set(support[_find_selector(game, "Infoset 1:1")]) == {"U1", "D1"} def test_getitem_unknown_player(): diff --git a/tests/test_extensive.py b/tests/test_extensive.py index b0854cc01e..bc1f7ca251 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -532,7 +532,9 @@ def test_reduced_strategy_maps(game: gbt.Game, strategy_maps: list): for strategy, expected in zip(game.get_strategies(player), expected_maps, strict=True): behavior = game.get_behavior(player, strategy) assert tuple( - "*" if (action := behavior.get(infoset)) is None + "*" if ( + action := behavior.get(games.selector_for_node(next(iter(infoset.members)))) + ) is None else str(infoset.actions.index(action) + 1) for infoset in games.player_infosets(game, player) ) == expected diff --git a/tests/test_game.py b/tests/test_game.py index deb16d6020..548fed15c1 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -170,10 +170,10 @@ def test_game_get_payoffs(): def test_game_get_payoffs_tree(): game = gbt.Game.new_tree(["Alice"]) game.append_move(gbt.H.path(), "Alice", ["a", "b"]) - infoset = game.root.infoset + selector = gbt.H.path() strategy = next( s for s in game.get_strategies("Alice") - if game.get_behavior("Alice", s).get(infoset) == "a" + if game.get_behavior("Alice", s).get(selector) == "a" ) game.make_outcome(gbt.H.path("a"), {"Alice": 1}, "a-outcome") payoffs = game.get_payoffs({"Alice": strategy}) From e53e9fa199015567e07026d7555a05c2aedd6f1c Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 11:05:37 +0100 Subject: [PATCH 3/6] Refer to sub-profiles by history rather than information set --- doc/pygambit.api.rst | 2 +- src/pygambit/behavmixed.pxi | 24 ++++++++++++++---------- src/pygambit/behavspt.pxi | 19 +++++++++++++------ src/pygambit/cli/common.py | 6 ++++-- src/pygambit/stratspt.pxi | 2 +- tests/test_behavspt_profiles.py | 13 ++++++------- 6 files changed, 39 insertions(+), 27 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index c427fc656f..aa10f56a7d 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -365,7 +365,7 @@ Subsets of actions BehaviorSupport.__getitem__ ActionSupport - ActionSupport.infoset + ActionSupport.history ActionSupport.__iter__ ActionSupport.__contains__ diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 169028790a..56c470039a 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -126,15 +126,18 @@ class MixedAction: An immutable snapshot taken from a ``MixedBehaviorProfile`` at retrieval time: it does not reflect later changes to the profile, and cannot itself be modified. The - information set is accessible via `infoset`. + information set is identified by the history that was resolved to reach it, + accessible via `history`. .. versionchanged:: 17.0.0 No longer a live view onto the profile: holds its own copy of the probabilities, and can no longer be assigned into. Set a distribution via - ``MixedBehaviorProfile.__setitem__`` instead. + ``MixedBehaviorProfile.__setitem__`` instead. `infoset` (an ``Infoset``) replaced + by `history` (the ``History`` -- a plain tuple of action labels -- of the node + that was resolved to identify the information set). """ - _infoset = cython.declare(Infoset) + _history = cython.declare(tuple) _values = cython.declare(dict) def __init__(self, *args, **kwargs) -> None: @@ -142,16 +145,16 @@ class MixedAction: @staticmethod @cython.cfunc - def wrap(infoset: Infoset, values: dict) -> MixedAction: + def wrap(history: tuple, values: dict) -> MixedAction: obj: MixedAction = MixedAction.__new__(MixedAction) - obj._infoset = infoset + obj._history = history obj._values = values return obj @property - def infoset(self) -> Infoset: - """The information set over which this mixed action is defined.""" - return self._infoset + def history(self) -> tuple: + """The History of the node that was resolved to identify this information set.""" + return self._history def __repr__(self) -> str: return str(self._values) @@ -172,7 +175,7 @@ class MixedAction: def __eq__(self, other: typing.Any) -> bool: if isinstance(other, collections.abc.Mapping): return self._values == dict(other) - if not isinstance(other, MixedAction) or self.infoset != other.infoset: + if not isinstance(other, MixedAction) or self.history != other.history: return False return self._values == cython.cast(MixedAction, other)._values @@ -563,7 +566,8 @@ class MixedBehaviorProfile: values: dict = {} for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions(): values[a.deref().GetLabel().decode("utf-8")] = self._getprob_action(a) - return MixedAction.wrap(infoset, values) + history = _history_of(Node.wrap(cython.cast(Infoset, infoset).node)) + return MixedAction.wrap(history, values) def _setprob_infoset( self, infoset: Infoset, distribution: collections.abc.Mapping, sparse: bool diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi index 427779baa8..a2474558b5 100644 --- a/src/pygambit/behavspt.pxi +++ b/src/pygambit/behavspt.pxi @@ -28,19 +28,25 @@ class ActionSupport(_LabelSet): """A set of actions at a specified information set in a `BehaviorSupportProfile`. An immutable snapshot taken from a ``BehaviorSupportProfile`` at retrieval time: it - does not reflect later changes to the profile. The information set is accessible - via `infoset`. + does not reflect later changes to the profile. The information set is identified by + the history that was resolved to reach it, accessible via `history`. + + .. versionchanged:: 17.0.0 + `infoset` (an ``Infoset``) replaced by `history` (the ``History`` -- a plain + tuple of action labels -- of the node that was resolved to identify the + information set). """ @staticmethod @cython.cfunc - def wrap(infoset: Infoset, actions: tuple) -> ActionSupport: + def wrap(history: tuple, actions: tuple) -> ActionSupport: obj: ActionSupport = ActionSupport.__new__(ActionSupport) - obj._owner = infoset + obj._owner = history obj._labels = actions return obj @property - def infoset(self) -> Infoset: + def history(self) -> tuple: + """The History of the node that was resolved to identify this information set.""" return self._owner @@ -224,7 +230,8 @@ class BehaviorSupportProfile: a.deref().GetLabel().decode("utf-8") for a in deref(self.profile).GetActions(infoset_handle) ) - return ActionSupport.wrap(infoset, actions) + history = _history_of(Node.wrap(cython.cast(Infoset, infoset).node)) + return ActionSupport.wrap(history, actions) @cython.cfunc def _ensure_unshared(self) -> cython.void: diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index 5f863c702b..a8f011bde5 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -244,10 +244,12 @@ def render_support_csv( fields = [ "".join( "1" if action in action_support else "0" - for action in action_support.infoset.actions + for action in node.infoset.actions ) for player in support.game.players - for action_support in support[player] + for node, action_support in zip( + support.game.get_infosets(player), support[player], strict=True + ) ] else: fields = [ diff --git a/src/pygambit/stratspt.pxi b/src/pygambit/stratspt.pxi index 8a393eafae..a291672814 100644 --- a/src/pygambit/stratspt.pxi +++ b/src/pygambit/stratspt.pxi @@ -30,7 +30,7 @@ class _LabelSet: """Shared implementation for `StrategySupport` and `ActionSupport`: an immutable snapshot of a set of labels (strategies, or actions at an information set) taken from a support profile at retrieval time, together with the owner (a player label, - or an information set) the labels belong to. + or the History identifying an information set) the labels belong to. Not exported; only `StrategySupport` and `ActionSupport` are part of the public API. """ diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index dbeb64f438..43a026d7ad 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -15,16 +15,16 @@ def _find_node(game, label): raise KeyError(label) -def _find_infoset(game, label): - """Find the Infoset with the given label, searching across all players.""" - return _find_node(game, label).infoset - - def _find_selector(game, label): """A `Selector` resolving to the infoset with the given label.""" return games.selector_for_node(_find_node(game, label)) +def _find_history(game, label): + """The History of a member node of the infoset with the given label.""" + return games._node_history(_find_node(game, label)) + + def _branching_game(): """A small tree where P1 chooses L/R, each leading to a separate P2 decision, so that removing an action can make a whole subtree's information set unreachable. @@ -45,10 +45,9 @@ def _branching_game(): def test_getitem_by_selector(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.behavior_support_profile() - infoset = _find_infoset(game, "Infoset 1:1") support = profile[_find_selector(game, "Infoset 1:1")] assert set(support) == {"U1", "D1"} - assert support.infoset == infoset + assert support.history == _find_history(game, "Infoset 1:1") assert "U1" in support assert "not-a-label" not in support From ece35a8f9ce67cac965a32e7c18204e6b4037d55 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 11:20:59 +0100 Subject: [PATCH 4/6] Key by canoncial history tuples instead of Infoset object --- src/pygambit/behavmixed.pxi | 36 ++++++++++++++++++++++-------------- src/pygambit/behavspt.pxi | 4 ++-- src/pygambit/cli/common.py | 18 +++++++++++------- src/pygambit/hsel.pxi | 11 +++++++++++ 4 files changed, 46 insertions(+), 23 deletions(-) diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 56c470039a..78c7cd96af 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -29,9 +29,9 @@ class InfosetIndexedVector(_LabeledVector): information set. Since information sets don't reliably have unique persistent labels, this is indexed - by any ``Node`` belonging to the information set (resolved to the information set - itself before lookup) rather than by a label: any member node is an equally valid - key, unlike ``NodeIndexedVector``. + by any ``Node`` belonging to the information set (resolved to the History of the + information set's canonical member before lookup) rather than by a label: any + member node is an equally valid key, unlike ``NodeIndexedVector``. """ _label_kind = "information set" @@ -41,7 +41,7 @@ class InfosetIndexedVector(_LabeledVector): if not infoset: raise ValueError("node is terminal, has no information set") try: - return self._values[infoset] + return self._values[_canonical_history(infoset)] except KeyError: raise KeyError(f"no {self._label_kind} for this node") from None @@ -281,7 +281,7 @@ class MixedBehavior: def __len__(self) -> int: return len(self._values) - def __iter__(self) -> typing.Iterator[tuple[Infoset, MixedAction], None, None]: + def __iter__(self) -> typing.Iterator[tuple[tuple, MixedAction], None, None]: """Iterate over the mixed actions specified by the mixed behavior. A ``MixedBehavior`` is a collection of ``MixedAction``\\ s, one per information @@ -293,10 +293,15 @@ class MixedBehavior: Previously iterated over individual actions and their probabilities; use ``MixedAction``'s own iteration for that at a specific information set. + .. versionchanged:: 17.0.0 + + Yields the History of the information set's canonical member (its first, + in pre-order depth-first order) instead of an ``Infoset``. + Yields ------ - infoset : Infoset - An information set belonging to the player + history : tuple + The History identifying an information set belonging to the player action : MixedAction The player's mixed action specified at the information set """ @@ -330,7 +335,7 @@ class MixedBehavior: raise MismatchError( "selector must resolve to an information set belonging to this player" ) - return self._values[infoset] + return self._values[_canonical_history(infoset)] @cython.cclass @@ -423,7 +428,7 @@ class MixedBehaviorProfile: self._check_validity() if isinstance(index, str): values = { - node.infoset: self._mixed_action_at(node.infoset) + _canonical_history(node.infoset): self._mixed_action_at(node.infoset) for node in self.game.get_infosets(index) } return MixedBehavior.wrap(self.game, index, values) @@ -735,7 +740,8 @@ class MixedBehaviorProfile: """ self._check_validity() return InfosetValueVector({ - infoset: self._infoset_value(infoset) for infoset in self._personal_infosets() + _canonical_history(infoset): self._infoset_value(infoset) + for infoset in self._personal_infosets() }) @property @@ -753,7 +759,7 @@ class MixedBehaviorProfile: """ self._check_validity() return ActionValuesVector({ - infoset: ActionValueVector({ + _canonical_history(infoset): ActionValueVector({ a.deref().GetLabel().decode("utf-8"): self._action_value(a) for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions() }) @@ -787,7 +793,8 @@ class MixedBehaviorProfile: """ self._check_validity() return InfosetProbVector({ - infoset: self._infoset_prob(infoset) for infoset in self._all_infosets() + _canonical_history(infoset): self._infoset_prob(infoset) + for infoset in self._all_infosets() }) @property @@ -835,7 +842,7 @@ class MixedBehaviorProfile: """ self._check_validity() return ActionRegretsVector({ - infoset: ActionRegretVector({ + _canonical_history(infoset): ActionRegretVector({ a.deref().GetLabel().decode("utf-8"): self._action_regret(a) for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions() }) @@ -862,7 +869,8 @@ class MixedBehaviorProfile: """ self._check_validity() return InfosetRegretVector({ - infoset: self._infoset_regret(infoset) for infoset in self._personal_infosets() + _canonical_history(infoset): self._infoset_regret(infoset) + for infoset in self._personal_infosets() }) def agent_max_regret(self) -> ProfileDType: diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi index a2474558b5..fd55176db1 100644 --- a/src/pygambit/behavspt.pxi +++ b/src/pygambit/behavspt.pxi @@ -133,7 +133,7 @@ class BehaviorSupport: raise MismatchError( "selector must resolve to an information set belonging to this player" ) - return self._values[infoset] + return self._values[_canonical_history(infoset)] @cython.cclass @@ -210,7 +210,7 @@ class BehaviorSupportProfile: """ if isinstance(index, str): values = { - node.infoset: self._action_support_at(node.infoset) + _canonical_history(node.infoset): self._action_support_at(node.infoset) for node in self.game.get_infosets(index) } return BehaviorSupport.wrap(self.game, index, values) diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index a8f011bde5..d6fc86af9a 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -305,9 +305,12 @@ def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> lines.append(f"Behavior profile for player {number}:") lines.append("Infoset Action Prob Value") lines.append("------- ------- ----------- -----------") - for infoset, mixed_action in profile[player]: + for node, (_, mixed_action) in zip( + profile.game.get_infosets(player), profile[player], strict=True + ): + infoset = node.infoset infoset_name = _name_or_number(infoset) - values = action_values[next(iter(infoset.members))] + values = action_values[node] for action in infoset.actions: prob = mixed_action[action] value = values[action] @@ -319,13 +322,14 @@ def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> lines.append("") lines.append("Infoset Node Belief Prob") lines.append("------- ------- ----------- -----------") - for infoset, _mixed_action in profile[player]: + for node in profile.game.get_infosets(player): + infoset = node.infoset infoset_name = _name_or_number(infoset) - for node in infoset.members: - node_name = _name_or_number(node) - belief = beliefs[node] + for member in infoset.members: + node_name = _name_or_number(member) + belief = beliefs[member] belief_text = format_value(belief, decimals) if belief is not None else "" - realiz_text = format_value(realiz_probs[node], decimals) + realiz_text = format_value(realiz_probs[member], decimals) lines.append( f"{infoset_name:>7} {node_name:>7} {belief_text:>11} {realiz_text:>11}" ) diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi index a3c9d2610e..96ad8e0f1d 100644 --- a/src/pygambit/hsel.pxi +++ b/src/pygambit/hsel.pxi @@ -208,6 +208,17 @@ def _history_of(node: Node) -> tuple: return tuple(labels) +def _canonical_history(infoset: _InfosetOrEvent) -> tuple: + """The History of `infoset`'s canonical member: the first one encountered in the + pre-order depth-first traversal of the game tree (``GetMember(1)``), matching the + order ``Infoset.members``/``Event.members`` themselves use. Used as the stable key + identifying an information set or event, in place of the `Infoset`/`Event` object + itself. + """ + resolved: c_GameInfoset = infoset._resolve() + return _history_of(Node.wrap(resolved.deref().GetMember(1))) + + class HistoryView: """The object a `.by(callable)` key function actually receives. Supports plain sequence indexing/slicing like a `History` tuple, plus limited From 3fc35903a5b9e06354091002e1f8ce2bb1f6c523 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 12:36:55 +0100 Subject: [PATCH 5/6] Remove Infoset object, refer to information sets entirely by nodes/histories as planned. --- doc/pygambit.api.rst | 28 ---- src/pygambit/behavmixed.pxi | 119 ++++++++------- src/pygambit/behavspt.pxi | 38 ++--- src/pygambit/catalog.py | 2 +- src/pygambit/cli/common.py | 28 ++-- src/pygambit/gambit.pyx | 1 - src/pygambit/game.pxi | 108 +++++++------ src/pygambit/gamecollections.pxi | 4 +- src/pygambit/gamehelpers.pxi | 32 ++-- src/pygambit/hsel.pxi | 21 ++- src/pygambit/infoset.pxi | 220 --------------------------- src/pygambit/node.pxi | 59 +++----- src/pygambit/qre.py | 6 +- src/pygambit/strategy.pxi | 75 ++++++---- tests/games.py | 121 +++++++++++---- tests/test_actions.py | 17 ++- tests/test_behav.py | 2 +- tests/test_behavspt_profiles.py | 35 ++--- tests/test_catalog.py | 2 +- tests/test_file.py | 6 +- tests/test_game_resolve.py | 16 +- tests/test_infosets.py | 250 +++++-------------------------- tests/test_node.py | 73 ++++----- tests/test_players.py | 4 +- 24 files changed, 452 insertions(+), 815 deletions(-) delete mode 100644 src/pygambit/infoset.pxi diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index aa10f56a7d..510b5a40ea 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -16,8 +16,6 @@ Representation of games Game Outcome Node - Infoset - Event Branch Subgame @@ -141,8 +139,6 @@ Information about the game Node.prior_action Node.prior_sibling Node.next_sibling - Node.infoset - Node.event Node.members Node.actions Node.action_probs @@ -159,30 +155,6 @@ Information about the game Subgame.parent Subgame.children -.. autosummary:: - - :toctree: api/ - - Infoset.label - Infoset.game - Infoset.is_absent_minded - Infoset.player - Infoset.actions - Infoset.members - Infoset.precedes - -.. autosummary:: - - :toctree: api/ - - Event.label - Event.game - Event.is_absent_minded - Event.player - Event.actions - Event.members - Event.precedes - .. autosummary:: :toctree: api/ diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 78c7cd96af..ec05dda958 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -37,11 +37,10 @@ class InfosetIndexedVector(_LabeledVector): def __getitem__(self, node: Node) -> typing.Any: resolved_node = cython.cast(Node, node) - infoset = resolved_node.infoset or resolved_node.event - if not infoset: + if resolved_node._infoset_handle() == cython.cast(c_GameInfoset, NULL): raise ValueError("node is terminal, has no information set") try: - return self._values[_canonical_history(infoset)] + return self._values[_canonical_history(resolved_node)] except KeyError: raise KeyError(f"no {self._label_kind} for this node") from None @@ -428,7 +427,7 @@ class MixedBehaviorProfile: self._check_validity() if isinstance(index, str): values = { - _canonical_history(node.infoset): self._mixed_action_at(node.infoset) + _canonical_history(node): self._mixed_action_at(node) for node in self.game.get_infosets(index) } return MixedBehavior.wrap(self.game, index, values) @@ -441,21 +440,19 @@ class MixedBehaviorProfile: f"profile index must be str or Selector, not {index.__class__.__name__}" ) - def _all_infosets(self) -> typing.Iterator[Infoset]: - """Iterates over every information set and event in the game.""" + def _all_infosets(self) -> typing.Iterator[Node]: + """Iterates over a representative node of every information set and event in + the game.""" for player in self.game.players: - for node in self.game.get_infosets(player): - yield node.infoset - for node in self.game.get_events(): - yield node.event + yield from self.game.get_infosets(player) + yield from self.game.get_events() - def _personal_infosets(self) -> typing.Iterator[Infoset]: - """Iterates over every information set in the game belonging to a personal - player, excluding the chance player's. + def _personal_infosets(self) -> typing.Iterator[Node]: + """Iterates over a representative node of every information set in the game + belonging to a personal player, excluding the chance player's. """ for player in self.game.players: - for node in self.game.get_infosets(player): - yield node.infoset + yield from self.game.get_infosets(player) # The public API above is implemented once here and dispatches to the hooks below, # each of which is implemented by a concrete dtype-specific subclass @@ -502,13 +499,13 @@ class MixedBehaviorProfile: """Returns the probability that play reaches node.""" raise NotImplementedError - def _infoset_prob(self, infoset: _InfosetOrEvent) -> ProfileDType: - """Returns the probability that play reaches infoset.""" + def _infoset_prob(self, node: Node) -> ProfileDType: + """Returns the probability that play reaches node's information set or event.""" raise NotImplementedError - def _infoset_value(self, infoset: Infoset) -> ProfileDType | None: - """Returns the expected payoff to the player owning infoset, conditional on - reaching it; None if it is unreached. + def _infoset_value(self, node: Node) -> ProfileDType | None: + """Returns the expected payoff to the player owning node's information set, + conditional on reaching it; None if it is unreached. """ raise NotImplementedError @@ -528,8 +525,9 @@ class MixedBehaviorProfile: """Returns the regret to playing action.""" raise NotImplementedError - def _infoset_regret(self, infoset: Infoset) -> ProfileDType: - """Returns the regret of the player owning infoset for their behavior at it.""" + def _infoset_regret(self, node: Node) -> ProfileDType: + """Returns the regret of the player owning node's information set for their + behavior at it.""" raise NotImplementedError def _agent_max_regret(self) -> ProfileDType: @@ -566,18 +564,18 @@ class MixedBehaviorProfile: """ raise NotImplementedError - def _mixed_action_at(self, infoset: Infoset) -> MixedAction: - """Returns a snapshot of the mixed action at infoset, as of now.""" + def _mixed_action_at(self, node: Node) -> MixedAction: + """Returns a snapshot of the mixed action at node's information set, as of + now.""" values: dict = {} - for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions(): + for a in cython.cast(Node, node)._infoset_handle().deref().GetActions(): values[a.deref().GetLabel().decode("utf-8")] = self._getprob_action(a) - history = _history_of(Node.wrap(cython.cast(Infoset, infoset).node)) - return MixedAction.wrap(history, values) + return MixedAction.wrap(_history_of(node), values) def _setprob_infoset( - self, infoset: Infoset, distribution: collections.abc.Mapping, sparse: bool + self, node: Node, distribution: collections.abc.Mapping, sparse: bool ) -> None: - """Validates and sets the whole mixed action for infoset. + """Validates and sets the whole mixed action for node's information set. Every key of `distribution` must be one of the information set's action labels. If `sparse` is True, actions `distribution` omits are treated as @@ -589,7 +587,7 @@ class MixedBehaviorProfile: f"a mixed action must be set from a Mapping from action label to " f"weight, not {distribution.__class__.__name__}" ) - labels = set(infoset.actions) + labels = set(node.actions) given = set(distribution.keys()) unknown = given - labels if unknown: @@ -608,7 +606,7 @@ class MixedBehaviorProfile: raise ValueError("a mixed action's weights must be non-negative") if all(v == 0 for v in values.values()): raise ValueError("a mixed action's weights must not all be zero") - for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions(): + for a in cython.cast(Node, node)._infoset_handle().deref().GetActions(): self._setprob_action(a, values[a.deref().GetLabel().decode("utf-8")]) def __setitem__(self, index: Selector, distribution: collections.abc.Mapping) -> None: @@ -740,8 +738,8 @@ class MixedBehaviorProfile: """ self._check_validity() return InfosetValueVector({ - _canonical_history(infoset): self._infoset_value(infoset) - for infoset in self._personal_infosets() + _canonical_history(node): self._infoset_value(node) + for node in self._personal_infosets() }) @property @@ -759,11 +757,11 @@ class MixedBehaviorProfile: """ self._check_validity() return ActionValuesVector({ - _canonical_history(infoset): ActionValueVector({ + _canonical_history(node): ActionValueVector({ a.deref().GetLabel().decode("utf-8"): self._action_value(a) - for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions() + for a in cython.cast(Node, node)._infoset_handle().deref().GetActions() }) - for infoset in self._personal_infosets() + for node in self._personal_infosets() }) @property @@ -793,8 +791,8 @@ class MixedBehaviorProfile: """ self._check_validity() return InfosetProbVector({ - _canonical_history(infoset): self._infoset_prob(infoset) - for infoset in self._all_infosets() + _canonical_history(node): self._infoset_prob(node) + for node in self._all_infosets() }) @property @@ -842,11 +840,11 @@ class MixedBehaviorProfile: """ self._check_validity() return ActionRegretsVector({ - _canonical_history(infoset): ActionRegretVector({ + _canonical_history(node): ActionRegretVector({ a.deref().GetLabel().decode("utf-8"): self._action_regret(a) - for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions() + for a in cython.cast(Node, node)._infoset_handle().deref().GetActions() }) - for infoset in self._personal_infosets() + for node in self._personal_infosets() }) @property @@ -869,8 +867,8 @@ class MixedBehaviorProfile: """ self._check_validity() return InfosetRegretVector({ - _canonical_history(infoset): self._infoset_regret(infoset) - for infoset in self._personal_infosets() + _canonical_history(node): self._infoset_regret(node) + for node in self._personal_infosets() }) def agent_max_regret(self) -> ProfileDType: @@ -1051,11 +1049,13 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): def _realiz_prob(self, node: Node) -> float: return deref(self.profile).GetRealizProb(node.node) - def _infoset_prob(self, infoset: _InfosetOrEvent) -> float: - return deref(self.profile).GetInfosetProb(infoset._resolve()) + def _infoset_prob(self, node: Node) -> float: + return deref(self.profile).GetInfosetProb(cython.cast(Node, node)._infoset_handle()) - def _infoset_value(self, infoset: Infoset) -> float | None: - cdef optional[double] value = deref(self.profile).GetPayoff(infoset._resolve()) + def _infoset_value(self, node: Node) -> float | None: + cdef optional[double] value = deref(self.profile).GetPayoff( + cython.cast(Node, node)._infoset_handle() + ) if value.has_value(): return value.value() return None @@ -1076,8 +1076,8 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): def _action_regret(self, action: c_GameAction) -> object: return deref(self.profile).GetRegret(action) - def _infoset_regret(self, infoset: Infoset) -> float: - return deref(self.profile).GetRegret(infoset._resolve()) + def _infoset_regret(self, node: Node) -> float: + return deref(self.profile).GetRegret(cython.cast(Node, node)._infoset_handle()) def _agent_max_regret(self) -> float: return deref(self.profile).GetAgentMaxRegret() @@ -1181,11 +1181,15 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _realiz_prob(self, node: Node) -> Rational: return rat_to_py(deref(self.profile).GetRealizProb(node.node)) - def _infoset_prob(self, infoset: _InfosetOrEvent) -> Rational: - return rat_to_py(deref(self.profile).GetInfosetProb(infoset._resolve())) + def _infoset_prob(self, node: Node) -> Rational: + return rat_to_py( + deref(self.profile).GetInfosetProb(cython.cast(Node, node)._infoset_handle()) + ) - def _infoset_value(self, infoset: Infoset) -> Rational | None: - cdef optional[c_Rational] value = deref(self.profile).GetPayoff(infoset._resolve()) + def _infoset_value(self, node: Node) -> Rational | None: + cdef optional[c_Rational] value = deref(self.profile).GetPayoff( + cython.cast(Node, node)._infoset_handle() + ) if value.has_value(): return rat_to_py(value.value()) return None @@ -1206,8 +1210,8 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _action_regret(self, action: c_GameAction) -> object: return rat_to_py(deref(self.profile).GetRegret(action)) - def _infoset_regret(self, infoset: Infoset) -> Rational: - return rat_to_py(deref(self.profile).GetRegret(infoset._resolve())) + def _infoset_regret(self, node: Node) -> Rational: + return rat_to_py(deref(self.profile).GetRegret(cython.cast(Node, node)._infoset_handle())) def _agent_max_regret(self) -> Rational: return rat_to_py(deref(self.profile).GetAgentMaxRegret()) @@ -1235,12 +1239,11 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): profile: MixedBehaviorProfileDouble = self.game.mixed_behavior_profile() for player in self.game.players: for node in self.game.get_infosets(player): - infoset = node.infoset profile._setprob_infoset( - infoset, + node, { a.deref().GetLabel().decode("utf-8"): float(self._getprob_action(a)) - for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions() + for a in cython.cast(Node, node)._infoset_handle().deref().GetActions() }, sparse=True, ) diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi index fd55176db1..88f34b37e4 100644 --- a/src/pygambit/behavspt.pxi +++ b/src/pygambit/behavspt.pxi @@ -128,12 +128,12 @@ class BehaviorSupport: raise TypeError( f"BehaviorSupport index must be Selector, not {selector.__class__.__name__}" ) - infoset = self._game._resolve_infoset(selector, "BehaviorSupport.__getitem__") - if infoset.player != self._player: + resolved_node = self._game._resolve_infoset(selector, "BehaviorSupport.__getitem__") + if resolved_node.player != self._player: raise MismatchError( "selector must resolve to an information set belonging to this player" ) - return self._values[_canonical_history(infoset)] + return self._values[_canonical_history(resolved_node)] @cython.cclass @@ -210,28 +210,28 @@ class BehaviorSupportProfile: """ if isinstance(index, str): values = { - _canonical_history(node.infoset): self._action_support_at(node.infoset) + _canonical_history(node): self._action_support_at(node) for node in self.game.get_infosets(index) } return BehaviorSupport.wrap(self.game, index, values) if isinstance(index, Selector): - resolved_infoset = self.game._resolve_infoset( + resolved_node = self.game._resolve_infoset( index, "BehaviorSupportProfile.__getitem__" ) - return self._action_support_at(resolved_infoset) + return self._action_support_at(resolved_node) raise TypeError( f"profile index must be str or Selector, not {index.__class__.__name__}" ) - def _action_support_at(self, infoset: Infoset) -> ActionSupport: - """Returns a snapshot of the action support at infoset, as of now.""" - infoset_handle = cython.cast(Infoset, infoset)._resolve() + def _action_support_at(self, node: Node) -> ActionSupport: + """Returns a snapshot of the action support at node's information set, as of + now.""" + infoset_handle: c_GameInfoset = cython.cast(Node, node)._infoset_handle() actions = tuple( a.deref().GetLabel().decode("utf-8") for a in deref(self.profile).GetActions(infoset_handle) ) - history = _history_of(Node.wrap(cython.cast(Infoset, infoset).node)) - return ActionSupport.wrap(history, actions) + return ActionSupport.wrap(_history_of(node), actions) @cython.cfunc def _ensure_unshared(self) -> cython.void: @@ -242,13 +242,13 @@ class BehaviorSupportProfile: self.profile = make_shared[c_BehaviorSupportProfile](deref(self.profile)) @cython.cfunc - def _set_support(self, infoset: Infoset, actions: object) -> cython.void: - """Validates and sets the whole support at infoset. + def _set_support(self, node: Node, actions: object) -> cython.void: + """Validates and sets the whole support at node's information set. Every entry of `actions` must be one of the information set's action labels, and at least one must be given. """ - labels = set(infoset.actions) + labels = set(node.actions) given = set(actions) unknown = given - labels if unknown: @@ -261,7 +261,7 @@ class BehaviorSupportProfile: # Actions to keep are added first, so that a subsequent removal is never asked # to remove the last remaining action at the information set. (Unlike # RemoveStrategy, RemoveAction does not itself guard against emptying its scope.) - action_handles = cython.cast(Infoset, infoset)._resolve().deref().GetActions() + action_handles = cython.cast(Node, node)._infoset_handle().deref().GetActions() for a in action_handles: if a.deref().GetLabel().decode("utf-8") in given: deref(self.profile).AddAction(a) @@ -296,10 +296,10 @@ class BehaviorSupportProfile: raise TypeError( f"profile index must be Selector, not {infoset.__class__.__name__}" ) - resolved_infoset = self.game._resolve_infoset( + resolved_node = self.game._resolve_infoset( infoset, "BehaviorSupportProfile.__setitem__" ) - self._set_support(resolved_infoset, actions) + self._set_support(resolved_node, actions) def copy(self) -> BehaviorSupportProfile: """Creates a copy of the support profile. @@ -335,5 +335,5 @@ class BehaviorSupportProfile: f"is_infoset_reachable(): infoset must be a Selector, " f"not {infoset.__class__.__name__}" ) - resolved_infoset: Infoset = self.game._resolve_infoset(infoset, "is_infoset_reachable") - return deref(self.profile).IsReachable(resolved_infoset._resolve()) + resolved_node: Node = self.game._resolve_infoset(infoset, "is_infoset_reachable") + return deref(self.profile).IsReachable(resolved_node._infoset_handle()) diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 2f70acdd54..714503d4d6 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -421,7 +421,7 @@ def check_filters(game: gbt.Game) -> bool: if not game.is_tree: return False n_game_actions = sum( - len(node.infoset.actions) + len(node.actions) for player in game.players for node in game.get_infosets(player) ) diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index d6fc86af9a..c6b5bcdb6a 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -244,7 +244,7 @@ def render_support_csv( fields = [ "".join( "1" if action in action_support else "0" - for action in node.infoset.actions + for action in node.actions ) for player in support.game.players for node, action_support in zip( @@ -305,33 +305,33 @@ def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> lines.append(f"Behavior profile for player {number}:") lines.append("Infoset Action Prob Value") lines.append("------- ------- ----------- -----------") - for node, (_, mixed_action) in zip( - profile.game.get_infosets(player), profile[player], strict=True + # Numbered by position among the player's information sets: Infoset no + # longer exists as an object, so there is currently no infoset-level label + # to prefer over this, unlike _name_or_number's use for players/nodes/actions. + for infoset_number, (node, (_, mixed_action)) in enumerate( + zip(profile.game.get_infosets(player), profile[player], strict=True), start=1 ): - infoset = node.infoset - infoset_name = _name_or_number(infoset) values = action_values[node] - for action in infoset.actions: + for action in node.actions: prob = mixed_action[action] value = values[action] value_text = format_value(value, decimals) if value is not None else "" lines.append( - f"{infoset_name:>7} {action:>7} " + f"{infoset_number:>7} {action:>7} " f"{format_value(prob, decimals):>11} {value_text:>11}" ) lines.append("") lines.append("Infoset Node Belief Prob") lines.append("------- ------- ----------- -----------") - for node in profile.game.get_infosets(player): - infoset = node.infoset - infoset_name = _name_or_number(infoset) - for member in infoset.members: + for infoset_number, node in enumerate(profile.game.get_infosets(player), start=1): + for member in node.members: node_name = _name_or_number(member) belief = beliefs[member] belief_text = format_value(belief, decimals) if belief is not None else "" realiz_text = format_value(realiz_probs[member], decimals) lines.append( - f"{infoset_name:>7} {node_name:>7} {belief_text:>11} {realiz_text:>11}" + f"{infoset_number:>7} {node_name:>7} " + f"{belief_text:>11} {realiz_text:>11}" ) lines.append("") return "\n".join(lines) @@ -388,7 +388,7 @@ def read_behavior_profiles_csv( the result via `~MixedBehaviorProfile.as_float`. """ count = sum( - len(node.infoset.actions) + len(node.actions) for player in game.players for node in game.get_infosets(player) ) @@ -405,7 +405,7 @@ def read_behavior_profiles_csv( profile = game.mixed_behavior_profile(rational=True) for player in game.players: for node in game.get_infosets(player): - profile[_selector_for_node(node)] = {a: next(values) for a in node.infoset.actions} + profile[_selector_for_node(node)] = {a: next(values) for a in node.actions} profiles.append(profile) return profiles diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index 17618a30d1..aa1862bb73 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -185,7 +185,6 @@ class NodeIndexedVector(_LabeledVector): # Includes ###################### -include "infoset.pxi" include "strategy.pxi" include "outcome.pxi" include "node.pxi" diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index c1b4764234..5a8f508321 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -784,10 +784,10 @@ class Game: raise TypeError( f"get_minimal_subgame(): node must be a Selector, not {node.__class__.__name__}" ) - resolved_infoset = self._resolve_infoset_or_event(node, "get_minimal_subgame") + resolved_node = self._resolve_infoset_or_event(node, "get_minimal_subgame") return Subgame.wrap( self.game.deref().GetMinimalSubgame( - cython.cast(_InfosetOrEvent, resolved_infoset)._resolve() + cython.cast(Node, resolved_node)._infoset_handle() ) ) @@ -1067,15 +1067,14 @@ class Game: if len(p_infosets) != len(d): raise ValueError(f"Number of elements does not match number of infosets for {p}") for (node, v) in zip(p_infosets, d, strict=True): - infoset = node.infoset - if len(infoset.actions) != len(v): + if len(node.actions) != len(v): raise ValueError( f"Number of elements does not match number of " - f"actions for infoset {infoset} for {p}" + f"actions for infoset {node} for {p}" ) profile._setprob_infoset( - infoset, - {a: typefunc(u) for a, u in zip(infoset.actions, v, strict=True)}, + node, + {a: typefunc(u) for a, u in zip(node.actions, v, strict=True)}, sparse=True, ) return profile @@ -1157,9 +1156,8 @@ class Game: profile = self.mixed_behavior_profile() for player in self.players: for node in self.get_infosets(player): - infoset = node.infoset profile._setprob_infoset( - infoset, _dirichlet_distribution(infoset.actions, gen), sparse=True + node, _dirichlet_distribution(node.actions, gen), sparse=True ) return profile elif denom < 1: @@ -1168,9 +1166,8 @@ class Game: profile = self.mixed_behavior_profile(rational=True) for player in self.players: for node in self.get_infosets(player): - infoset = node.infoset profile._setprob_infoset( - infoset, _grid_distribution(infoset.actions, denom, gen), sparse=True + node, _grid_distribution(node.actions, denom, gen), sparse=True ) return profile @@ -1223,7 +1220,7 @@ class Game: if actions is not None: for player in self.players: for node in self.get_infosets(player): - infoset_handle: c_GameInfoset = cython.cast(Infoset, node.infoset)._resolve() + infoset_handle: c_GameInfoset = cython.cast(Node, node)._infoset_handle() for action in infoset_handle.deref().GetActions(): if not actions(node, action.deref().GetLabel().decode("utf-8")): if not deref(profile.profile).RemoveAction(action): @@ -1490,7 +1487,7 @@ class Game: return resolved_nodes def _resolve_infoset(self, - infoset: typing.Any, funcname: str, argname: str = "infoset") -> Infoset: + infoset: typing.Any, funcname: str, argname: str = "infoset") -> Node: """Resolve an attempt to reference a personal player's information set of the game, via a member node or its label. @@ -1503,6 +1500,12 @@ class Game: argname : str, default 'infoset' The name of the argument being checked + Returns + ------- + Node + The resolved node itself, validated as currently belonging to a personal + player's information set. + Raises ------ MismatchError @@ -1516,14 +1519,15 @@ class Game: information set, or to no information set at all (the node is terminal). """ resolved_node = self._resolve_node(infoset, funcname, argname) - return cython.cast(Infoset, _resolve_infoset_or_event_kind( - resolved_node.infoset, resolved_node.event, + is_personal, is_chance = _node_infoset_kind(resolved_node) + return _resolve_infoset_or_event_kind( + resolved_node, is_personal, is_chance, "information set", "a personal player's information set", "a chance event", funcname, argname - )) + ) def _resolve_event(self, - event: typing.Any, funcname: str, argname: str = "event") -> Event: + event: typing.Any, funcname: str, argname: str = "event") -> Node: """Resolve an attempt to reference a chance event of the game, via a member node or its label. @@ -1536,6 +1540,12 @@ class Game: argname : str, default 'event' The name of the argument being checked + Returns + ------- + Node + The resolved node itself, validated as currently belonging to a chance + event. + Raises ------ MismatchError @@ -1549,16 +1559,17 @@ class Game: chance event, or to no event at all (the node is terminal). """ resolved_node = self._resolve_node(event, funcname, argname) - return cython.cast(Event, _resolve_infoset_or_event_kind( - resolved_node.event, resolved_node.infoset, + is_personal, is_chance = _node_infoset_kind(resolved_node) + return _resolve_infoset_or_event_kind( + resolved_node, is_chance, is_personal, "event", "a chance event", "a personal player's information set", funcname, argname - )) + ) def _resolve_infoset_or_event(self, infoset: typing.Any, funcname: str, - argname: str = "infoset") -> typing.Any: + argname: str = "infoset") -> Node: """Resolve an attempt to reference an information set or event of the game (whichever applies), via a member node or its label. For operations that apply uniformly to either, such as attaching to an existing one. @@ -1574,7 +1585,9 @@ class Game: Returns ------- - Infoset or Event + Node + The resolved node itself, validated as currently belonging to some + information set or event. Raises ------ @@ -1589,12 +1602,9 @@ class Game: terminal). """ resolved_node = self._resolve_node(infoset, funcname, argname) - resolved_infoset = cython.cast(Infoset, resolved_node.infoset) - if resolved_infoset: - return resolved_infoset - resolved_event = cython.cast(Event, resolved_node.event) - if resolved_event: - return resolved_event + is_personal, is_chance = _node_infoset_kind(resolved_node) + if is_personal or is_chance: + return resolved_node raise ValueError( f"{funcname}(): {argname} resolves to no information set " f"(the node is terminal)" @@ -1683,9 +1693,9 @@ class Game: for label in actions: c_actions.push_back(label.encode("utf-8")) self.game.deref().AppendMove(resolved_node.node, resolved_player, c_actions) - resolved_infoset = cython.cast(Infoset, resolved_node.infoset) + infoset_handle: c_GameInfoset = resolved_node._infoset_handle() for n in resolved_nodes[1:]: - self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_infoset._resolve()) + self.game.deref().AppendMove(cython.cast(Node, n).node, infoset_handle) def append_infoset(self, nodes: Selector | GroupedSelector, infoset: Selector) -> None: @@ -1739,16 +1749,17 @@ class Game: 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: + is_personal, _ = _node_infoset_kind(infoset_node) + if not is_personal: raise UndefinedOperationError( "append_infoset(): infoset must resolve to a personal player's node" ) - resolved_infoset = cython.cast(Infoset, infoset_node.infoset) + infoset_handle: c_GameInfoset = infoset_node._infoset_handle() 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") for n in resolved_nodes: - self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_infoset._resolve()) + self.game.deref().AppendMove(cython.cast(Node, n).node, infoset_handle) def append_event(self, nodes: Selector | GroupedSelector, actions: typing.Mapping) -> None: @@ -1823,9 +1834,9 @@ class Game: 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) + event_handle: c_GameInfoset = resolved_node._infoset_handle() for n in resolved_nodes[1:]: - self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_event._resolve()) + self.game.deref().AppendMove(cython.cast(Node, n).node, event_handle) def insert_move(self, node: Selector, player: str, actions: list[str]) -> None: @@ -1906,10 +1917,10 @@ class Game: 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") + resolved_infoset_node = cython.cast( + Node, self._resolve_infoset_or_event(infoset, "insert_infoset") ) - self.game.deref().InsertMove(resolved_node.node, resolved_infoset._resolve()) + self.game.deref().InsertMove(resolved_node.node, resolved_infoset_node._infoset_handle()) def insert_event(self, node: Selector, actions: typing.Mapping) -> None: """Insert a chance move prior to the node identified by `node`, with actions @@ -2179,7 +2190,7 @@ class Game: 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")) + resolved_infoset = cython.cast(Node, 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") labels = list(actions) @@ -2195,7 +2206,7 @@ class Game: c_labels = stdvector[string]() for label in labels: c_labels.push_back(label.encode("utf-8")) - self.game.deref().SetMoveActions(resolved_infoset._resolve(), c_labels) + self.game.deref().SetMoveActions(resolved_infoset._infoset_handle(), c_labels) def set_event_actions(self, event: Selector, @@ -2266,7 +2277,7 @@ class Game: 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")) + resolved_event = cython.cast(Node, self._resolve_event(event, "set_event_actions")) if not isinstance(probs, typing.Mapping): raise TypeError( "set_event_actions(): probs must be a mapping from label to probability" @@ -2288,7 +2299,7 @@ class Game: for label in labels: c_labels.push_back(label.encode("utf-8")) c_probs.push_back(_to_number(probs[label])) - self.game.deref().SetEventActions(resolved_event._resolve(), c_labels, c_probs) + self.game.deref().SetEventActions(resolved_event._infoset_handle(), c_labels, c_probs) def make_event(self, nodes: Selector | GroupedSelector, @@ -2301,9 +2312,8 @@ class Game: converted, and the move is thereafter resolved by chance. Nodes are removed from whatever information sets or events they currently belong to; any of those which retain members survive, keeping their labels, and those left with no members are deleted. - Any ``Infoset`` object referring to a deleted one becomes invalid, and subsequent use - raises ``RuntimeError``. - The resulting event is accessible as ``node.event`` for any node in `nodes`. + The resulting event's members, actions, and player are accessible via + ``Node.members``/``Node.actions``/``Node.player`` for any node in `nodes`. `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 @@ -2371,8 +2381,8 @@ class Game: "make_event(): all nodes must be nonterminal" ) resolved_node = cython.cast(Node, resolved_nodes[0]) - action_labels = list((resolved_node.infoset or resolved_node.event).actions) - if any(list((n.infoset or n.event).actions) != action_labels + action_labels = list(resolved_node.actions) + if any(list(n.actions) != action_labels for n in resolved_nodes[1:]): raise ValueError( "make_event(): all nodes must have the same actions, " @@ -2440,7 +2450,7 @@ class Game: 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") + Node, self._resolve_infoset_or_event(infoset, "relabel_actions") ) if not hasattr(labels, "items"): raise TypeError( @@ -2457,7 +2467,7 @@ class Game: c_labels = stdmap[string, string]() for old, new in remap.items(): c_labels[old.encode("utf-8")] = new.encode("utf-8") - self.game.deref().RelabelActions(resolved_infoset._resolve(), c_labels) + self.game.deref().RelabelActions(resolved_infoset._infoset_handle(), c_labels) def make_infoset(self, nodes: Selector | GroupedSelector, diff --git a/src/pygambit/gamecollections.pxi b/src/pygambit/gamecollections.pxi index ee2928192b..e4363f3d61 100644 --- a/src/pygambit/gamecollections.pxi +++ b/src/pygambit/gamecollections.pxi @@ -146,8 +146,8 @@ class GamePlayers: Iterates over player labels (``str``) rather than ``Player`` objects; indexing by label is no longer supported (a label is already in hand once iterated) -- use ``in`` to test membership. The chance player is no longer - exposed here (it never was included in iteration); the ``Infoset``/``Event`` - split on ``Node`` already distinguishes personal from chance nodes. + exposed here (it never was included in iteration); ``Node.player`` already + distinguishes personal from chance nodes. """ game = cython.declare(c_Game) diff --git a/src/pygambit/gamehelpers.pxi b/src/pygambit/gamehelpers.pxi index 10e755612c..a85c4f9ce2 100644 --- a/src/pygambit/gamehelpers.pxi +++ b/src/pygambit/gamehelpers.pxi @@ -90,22 +90,36 @@ def _compute_relabeling( return remap +def _node_infoset_kind(node: Node) -> tuple: + """Whether `node` currently belongs to a personal player's information set, and + whether it belongs to a chance event -- exactly one of the two, or neither if + `node` is currently terminal. + """ + handle: c_GameInfoset = cython.cast(Node, node)._infoset_handle() + if handle == cython.cast(c_GameInfoset, NULL): + return False, False + is_chance = handle.deref().IsChanceInfoset() + return not is_chance, is_chance + + def _resolve_infoset_or_event_kind( - this: object, other: object, this_bare: str, this_full: str, other_full: str, + resolved_node: Node, this_ok: bool, other_ok: bool, + this_bare: str, this_full: str, other_full: str, funcname: str, argname: str -) -> object: - """Shared error-raising shape for `_resolve_infoset`/`_resolve_event`: `this` is - the already-resolved `Infoset`/`Event` of the desired kind, falsy if the - anchoring node's partition element is not of that kind; `other` is the opposite - kind, consulted only to raise a more specific error when `this` does not apply. +) -> Node: + """Shared error-raising shape for `_resolve_infoset`/`_resolve_event`: `this_ok` is + whether `resolved_node`'s current partition element is of the desired kind; + `other_ok` is whether it's the opposite kind, consulted only to raise a more + specific error when `this_ok` is False. Returns `resolved_node` unchanged when + `this_ok`. """ - if not this: - if other: + if not this_ok: + if other_ok: raise ValueError(f"{funcname}(): {argname} resolves to {other_full}, not {this_full}") raise ValueError( f"{funcname}(): {argname} resolves to no {this_bare} (the node is terminal)" ) - return this + return resolved_node def _dirichlet_distribution(items: list, gen: object) -> dict: diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi index 96ad8e0f1d..1e502a5911 100644 --- a/src/pygambit/hsel.pxi +++ b/src/pygambit/hsel.pxi @@ -208,14 +208,21 @@ def _history_of(node: Node) -> tuple: return tuple(labels) -def _canonical_history(infoset: _InfosetOrEvent) -> tuple: - """The History of `infoset`'s canonical member: the first one encountered in the - pre-order depth-first traversal of the game tree (``GetMember(1)``), matching the - order ``Infoset.members``/``Event.members`` themselves use. Used as the stable key - identifying an information set or event, in place of the `Infoset`/`Event` object - itself. +def _canonical_history(node: Node) -> tuple: + """The History of the canonical member of `node`'s current information set or + event: the first member encountered in the pre-order depth-first traversal of the + game tree (``GetMember(1)``), matching the order `Node.members` itself uses. Used + as the stable key identifying an information set or event, in place of any one + particular member node. + + Raises + ------ + AttributeError + If `node` currently belongs to no information set or event (a terminal node). """ - resolved: c_GameInfoset = infoset._resolve() + resolved: c_GameInfoset = node._infoset_handle() + if resolved == cython.cast(c_GameInfoset, NULL): + raise AttributeError("node currently belongs to no information set or event") return _history_of(Node.wrap(resolved.deref().GetMember(1))) diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi deleted file mode 100644 index 14f6824c24..0000000000 --- a/src/pygambit/infoset.pxi +++ /dev/null @@ -1,220 +0,0 @@ -# -# This file is part of Gambit -# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -# -# FILE: src/pygambit/infoset.pxi -# Cython wrapper for information sets -# -# 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. -# - -@cython.cclass -class _InfosetOrEvent: - """Shared implementation for `Infoset` and `Event`: a lazy, node-anchored view over - an information set, filtered to whichever of the two subclasses' concept currently - applies at the anchoring node (see each subclass's `_try_resolve`). - - Not exported; only `Infoset` and `Event` are part of the public API. - """ - node = cython.declare(c_GameNode) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError(f"Cannot create an {type(self).__name__} outside a Game.") - - @cython.cfunc - def _try_resolve(self) -> c_GameInfoset: - """Returns the resolved handle, filtered to this subclass's applicable case; - null if that case does not currently apply at the anchoring node (including, - but not limited to, a terminal node).""" - raise NotImplementedError - - @cython.cfunc - def _resolve(self) -> c_GameInfoset: - """Returns the resolved handle, raising if this subclass's case does not - currently apply at the anchoring node.""" - resolved: c_GameInfoset = self._try_resolve() - if resolved == cython.cast(c_GameInfoset, NULL): - raise AttributeError( - f"node's {type(self).__name__.lower()} is currently None" - ) - return resolved - - def __repr__(self) -> str: - if self._try_resolve() == cython.cast(c_GameInfoset, NULL): - return "None" - name = type(self).__name__ - if self.label: - return f"{name}(player={self.player}, label='{self.label}')" - else: - return f"{name}(player={self.player}, number={self.number})" - - def __eq__(self, other: typing.Any): - if type(other) is not type(self): - return NotImplemented - mine: c_GameInfoset = self._try_resolve() - theirs: c_GameInfoset = cython.cast(_InfosetOrEvent, other)._try_resolve() - if mine == cython.cast(c_GameInfoset, NULL) or theirs == cython.cast(c_GameInfoset, NULL): - return ( - mine == cython.cast(c_GameInfoset, NULL) and - theirs == cython.cast(c_GameInfoset, NULL) - ) - return mine == theirs - - def __bool__(self) -> bool: - return self._try_resolve() != cython.cast(c_GameInfoset, NULL) - - def __hash__(self) -> int: - resolved: c_GameInfoset = self._try_resolve() - if resolved == cython.cast(c_GameInfoset, NULL): - return 0 - return cython.cast(cython.long, resolved.deref()) - - def precedes(self, node: Node) -> bool: - """Return whether this information set precedes `node` in the game tree.""" - return self._resolve().deref().Precedes(cython.cast(Node, node).node) - - @property - def game(self) -> Game: - """The ``Game`` to which the information set belongs.""" - return Game.wrap(self._resolve().deref().GetGame()) - - @property - def label(self) -> str: - """Get or set the text label of the information set. - - .. versionchanged:: 17.0.0 - A label may now be any well-formed UTF-8 text, not just ASCII; it must still - contain no control characters, and must not begin/end with whitespace or have - two consecutive whitespace characters. "Whitespace" means any Unicode space - separator (e.g. U+00A0 NO-BREAK SPACE), not just the ASCII space. - """ - return self._resolve().deref().GetLabel().decode("utf-8") - - @label.setter - def label(self, value: str) -> None: - self._resolve().deref().SetLabel(value.encode("utf-8")) - - @property - def number(self) -> int: - """Returns the number of the information set for its player. - Information sets are numbered starting with 0. - """ - return self._resolve().deref().GetNumber() - 1 - - @property - def is_absent_minded(self) -> bool: - """ - Whether the information set is absent-minded. - - An information set is absent-minded if there exists a path of play - in the game tree that intersects the information set more than once. - - .. versionadded:: 16.5.0 - """ - resolved: c_GameInfoset = self._resolve() - return resolved.deref().GetGame().deref().IsAbsentMinded(resolved) - - @property - def actions(self) -> list[str]: - """The labels of the actions available at the information set, in order. - - .. versionchanged:: 17.0.0 - Returns bare labels rather than ``Action`` objects, following its removal. - """ - resolved: c_GameInfoset = self._resolve() - return [a.deref().GetLabel().decode("utf-8") for a in resolved.deref().GetActions()] - - @property - def members(self) -> list[Node]: - """The nodes which are members of the information set. - - The order of information set members is the order in which they are - encountered in the pre-order depth first traversal of the game tree. - - .. versionchanged:: 17.0.0 - Returns a plain ``list`` rather than a lazily-resolved collection; a - member is no longer accessible by label, following the removal of - ``Action``/``Strategy`` label-indexed collections elsewhere in the API. - """ - resolved: c_GameInfoset = self._resolve() - return [Node.wrap(member) for member in resolved.deref().GetMembers()] - - @property - def player(self) -> str: - """The label of the player who has the move at this information set.""" - return self._resolve().deref().GetPlayer().deref().GetLabel().decode("utf-8") - - -@cython.cclass -class Infoset(_InfosetOrEvent): - """An information set belonging to a personal player in a ``Game``: the point at - which that player chooses an action, and so the object of potential optimisation. - The corresponding concept for the chance player is an ``Event``. - - A lazy, node-anchored view: holds a member node and resolves the information set - on each access, so the value reflects the current state of the game even after - the game is mutated. For a node currently belonging to no personal player's - information set (a terminal node, or a chance event -- see ``Node.event``), the - view is falsy and equals ``None``. - - .. versionchanged:: 17.0.0 - Now a node-anchored view (see ``Node.infoset``) rather than an object with - identity of its own; equality/hashing are still based on the information set - currently resolved, not on the anchoring node. No longer used for the chance - player's events; see ``Event``. - """ - @staticmethod - @cython.cfunc - def wrap(node: c_GameNode) -> Infoset: - obj: Infoset = Infoset.__new__(Infoset) - obj.node = node - return obj - - @cython.cfunc - def _try_resolve(self) -> c_GameInfoset: - resolved: c_GameInfoset = self.node.deref().GetInfoset() - if resolved != cython.cast(c_GameInfoset, NULL) and resolved.deref().IsChanceInfoset(): - return cython.cast(c_GameInfoset, NULL) - return resolved - - -@cython.cclass -class Event(_InfosetOrEvent): - """An event belonging to the chance player in a ``Game``: a point of exogenous - randomness, with a probability distribution over its actions that is specified - rather than chosen. The corresponding concept for a personal player is an - ``Infoset``. - - A lazy, node-anchored view: holds a member node and resolves the event on each - access, so the value reflects the current state of the game even after the game - is mutated. For a node not currently belonging to a chance event (a terminal - node, or a personal player's information set -- see ``Node.infoset``), the view - is falsy and equals ``None``. - - .. versionadded:: 17.0.0 - """ - @staticmethod - @cython.cfunc - def wrap(node: c_GameNode) -> Event: - obj: Event = Event.__new__(Event) - obj.node = node - return obj - - @cython.cfunc - def _try_resolve(self) -> c_GameInfoset: - resolved: c_GameInfoset = self.node.deref().GetInfoset() - if resolved != cython.cast(c_GameInfoset, NULL) and not resolved.deref().IsChanceInfoset(): - return cython.cast(c_GameInfoset, NULL) - return resolved diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index d8e8fa98a3..79bf56501e 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -245,41 +245,19 @@ class Node: """Gets the ``Game`` to which the node belongs.""" return Game.wrap(self.node.deref().GetGame()) - @property - def infoset(self) -> Infoset: - """The personal player's information set to which this node currently belongs. - - 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 node that does not currently belong to a personal player's information set - (a terminal node, or a chance event -- see `event`), the view is falsy and equals - ``None``. - - .. versionchanged:: 16.7.0 - .. versionchanged:: 17.0.0 - No longer resolves to the chance player's events; see `event`. - """ - return Infoset.wrap(self.node) - - @property - def event(self) -> Event: - """The chance event to which this node currently belongs. - - 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 node that is not currently a chance event (a terminal node, or a personal - player's information set -- see `infoset`), the view is falsy and equals ``None``. - - .. versionadded:: 17.0.0 + @cython.cfunc + def _infoset_handle(self) -> c_GameInfoset: + """The node's current information set or event, as a raw handle -- null if + the node is currently terminal. Not part of the public API; the information + set/event a node belongs to is otherwise only exposed piecemeal, via + `members`/`actions`/`action_probs` and the `Game`/`Selector`-based resolvers. """ - return Event.wrap(self.node) + return self.node.deref().GetInfoset() @property def members(self) -> list[Node]: """The nodes which are members of the information set or event to which - this node currently belongs -- whichever applies. Equivalent to - ``self.infoset.members`` or ``self.event.members``, whichever is not falsy; - unlike those, this is well-defined regardless of which currently applies. + this node currently belongs -- whichever applies. .. versionadded:: 17.0.0 @@ -289,10 +267,10 @@ class Node: If this node currently belongs to no information set or event (a terminal node). """ - infoset: Infoset = self.infoset - if infoset: - return infoset.members - return self.event.members + resolved: c_GameInfoset = self._infoset_handle() + if resolved == cython.cast(c_GameInfoset, NULL): + raise AttributeError("node currently belongs to no information set or event") + return [Node.wrap(member) for member in resolved.deref().GetMembers()] @property def actions(self) -> list[str]: @@ -307,10 +285,10 @@ class Node: If this node currently belongs to no information set or event (a terminal node). """ - infoset: Infoset = self.infoset - if infoset: - return infoset.actions - return self.event.actions + resolved: c_GameInfoset = self._infoset_handle() + if resolved == cython.cast(c_GameInfoset, NULL): + raise AttributeError("node currently belongs to no information set or event") + return [a.deref().GetLabel().decode("utf-8") for a in resolved.deref().GetActions()] @property def action_probs(self) -> dict[str, decimal.Decimal | Rational]: @@ -324,12 +302,11 @@ class Node: UndefinedOperationError If the node does not currently belong to a chance event. """ - event: Event = self.event - if not event: + resolved: c_GameInfoset = self._infoset_handle() + if resolved == cython.cast(c_GameInfoset, NULL) or not resolved.deref().IsChanceInfoset(): raise UndefinedOperationError( "action probabilities are only defined at events" ) - resolved: c_GameInfoset = event._resolve() result: dict = {} for a in resolved.deref().GetActions(): result[a.deref().GetLabel().decode("utf-8")] = _decode_prob( diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index efbbf3befb..7d32e80b21 100644 --- a/src/pygambit/qre.py +++ b/src/pygambit/qre.py @@ -271,11 +271,11 @@ def _estimate_behavior_empirical( data[_selector_for_node(node)][a] for p in data.game.players for node in data.game.get_infosets(p) - for a in node.infoset.actions + for a in node.actions ] normalized = data.normalize() regrets = [ - [-normalized.action_regrets[node][a] for a in node.infoset.actions] + [-normalized.action_regrets[node][a] for a in node.actions] for player in data.game.players for node in data.game.get_infosets(player) ] @@ -289,7 +289,7 @@ def _estimate_behavior_empirical( for player in data.game.players: for node in data.game.get_infosets(player): profile[_selector_for_node(node)] = { - a: math.exp(next(log_probs)) for a in node.infoset.actions + a: math.exp(next(log_probs)) for a in node.actions } return LogitQREMixedBehaviorFitResult( data, "empirical", res.x[0], profile, -res.fun diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index e7b3d97440..e7154ac2e0 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -24,10 +24,11 @@ class StrategyBehavior: """A read-only, map-like view of the actions prescribed by a reduced strategy. - The keys of the mapping are the information sets of the strategy's player at - which the strategy prescribes an action; an unreachable information set is not a key. - The corresponding values are the labels of the prescribed actions. - Iteration yields the keys in the player's information set order. + The keys of the mapping are Histories identifying the information sets of the + strategy's player at which the strategy prescribes an action; an unreachable + information set is not a key. The corresponding values are the labels of the + prescribed actions. Iteration yields the keys in the player's information set + order. .. versionadded:: 17.0.0 """ @@ -63,29 +64,30 @@ class StrategyBehavior: """The label of the strategy of which this is the behavior.""" return self._strategy_label - def _action_at(self, infoset: Infoset) -> str | None: - """The label of the action prescribed by the strategy at `infoset`, or None - if unreachable.""" + def _action_at(self, node: Node) -> str | None: + """The label of the action prescribed by the strategy at node's information + set, or None if unreachable.""" handle = self._game._resolve_strategy( self._player_label, self._strategy_label, "StrategyBehavior" ) - action: c_GameAction = handle.deref().GetAction(cython.cast(Infoset, infoset)._resolve()) + action: c_GameAction = handle.deref().GetAction(cython.cast(Node, node)._infoset_handle()) if not action: return None return action.deref().GetLabel().decode("utf-8") - def _resolve_key(self, key: Selector) -> Infoset: - """Resolve `key` to an information set at which the player has the move.""" + def _resolve_key(self, key: Selector) -> Node: + """Resolve `key` to a node at the information set at which the player has + the move.""" if not isinstance(key, Selector): raise TypeError( f"StrategyBehavior key must be Selector, not {key.__class__.__name__}" ) - infoset = self._game._resolve_infoset(key, "StrategyBehavior", "key") - if infoset.player != self._player_label: + resolved_node = self._game._resolve_infoset(key, "StrategyBehavior", "key") + if resolved_node.player != self._player_label: raise ValueError( - f"Player '{self._player_label}' does not have the move at {infoset}." + f"Player '{self._player_label}' does not have the move at {resolved_node}." ) - return infoset + return resolved_node def __getitem__(self, key: Selector) -> str: """Return the label of the action prescribed at the information set `key` @@ -107,48 +109,57 @@ class StrategyBehavior: If the information set belongs to a different player, or `key` resolves to a terminal node or a chance event. """ - infoset = self._resolve_key(key) - action = self._action_at(infoset) + node = self._resolve_key(key) + action = self._action_at(node) if action is None: raise KeyError( - f"Strategy '{self._strategy_label}' prescribes no action at {infoset}." + f"Strategy '{self._strategy_label}' prescribes no action at {node}." ) return action def get(self, key: Selector, default: typing.Any = None) -> str | None: """Return the label of the action prescribed at `key`, or `default` if none is prescribed.""" - infoset = self._resolve_key(key) - action = self._action_at(infoset) + node = self._resolve_key(key) + action = self._action_at(node) return default if action is None else action def __contains__(self, key: typing.Any) -> bool: try: - infoset = self._resolve_key(key) + node = self._resolve_key(key) except (KeyError, ValueError, TypeError): return False - return self._action_at(infoset) is not None + return self._action_at(node) is not None - def __iter__(self) -> typing.Iterator[Infoset]: + def _reachable_nodes(self) -> typing.Iterator[Node]: + """The representative nodes of the player's information sets at which the + strategy prescribes an action, in the player's information set order.""" for node in self._game.get_infosets(self._player_label): - infoset = node.infoset - if self._action_at(infoset) is not None: - yield infoset + if self._action_at(node) is not None: + yield node + + def __iter__(self) -> typing.Iterator[tuple]: + for node in self._reachable_nodes(): + yield _canonical_history(node) def __len__(self) -> int: return sum(1 for _ in self) - def keys(self) -> list[Infoset]: - """The information sets at which the strategy prescribes an action.""" + def keys(self) -> list[tuple]: + """The Histories identifying the information sets at which the strategy + prescribes an action.""" return list(self) def values(self) -> list[str]: """The labels of the prescribed actions, in the order of `keys`.""" - return [self._action_at(infoset) for infoset in self] - - def items(self) -> list[tuple[Infoset, str]]: - """(information set, action label) pairs, in the order of `keys`.""" - return [(infoset, self._action_at(infoset)) for infoset in self] + return [self._action_at(node) for node in self._reachable_nodes()] + + def items(self) -> list[tuple[tuple, str]]: + """(History, action label) pairs, in the order of `keys`.""" + return [ + (_canonical_history(node), self._action_at(node)) + for node in self._reachable_nodes() + ] @cython.cclass diff --git a/tests/games.py b/tests/games.py index 8a3420a997..a9d3c4233f 100644 --- a/tests/games.py +++ b/tests/games.py @@ -9,28 +9,100 @@ import pygambit as gbt -def all_infosets(game: gbt.Game) -> list[gbt.Infoset]: - """All Infosets belonging to a personal player, across every player, in the - canonical order `Game.infosets` used to yield before its removal (17.0.0).""" - return [n.infoset for p in game.players for n in game.get_infosets(p)] - - -def player_infosets(game: gbt.Game, player: str) -> list[gbt.Infoset]: - """All Infosets belonging to `player`, in canonical order, matching - `Player.infosets` before its removal (17.0.0).""" - return [n.infoset for n in game.get_infosets(player)] - - -def find_infoset(game: gbt.Game, player: str, label: str) -> gbt.Infoset: - """Find the Infoset belonging to `player` with the given label, matching - `Player.infosets[label]` before its removal (17.0.0).""" - return next(i for i in player_infosets(game, player) if i.label == label) - - -def find_infoset_in_game(game: gbt.Game, label: str) -> gbt.Infoset: - """Find the Infoset with the given label, searching across all (personal) - players, matching `Game.infosets[label]` before its removal (17.0.0).""" - return next(i for i in all_infosets(game) if i.label == label) +def all_infosets(game: gbt.Game) -> list[gbt.Node]: + """A representative node of every information set belonging to a personal + player, across every player, in the canonical order `Game.infosets` used to + yield before its removal (17.0.0). One node per information set, matching + `Game.get_infosets`; use `.members`/`.actions`/etc. on the node, which already + proxy to its information set.""" + return [n for p in game.players for n in game.get_infosets(p)] + + +def player_infosets(game: gbt.Game, player: str) -> list[gbt.Node]: + """A representative node of every information set belonging to `player`, in + canonical order, matching `Player.infosets` before its removal (17.0.0).""" + return list(game.get_infosets(player)) + + +def infoset_number(node: gbt.Node) -> int: + """The 0-based position of `node`'s information set among its player's + information sets, matching the removed `Infoset.number` (17.0.0).""" + for i, rep in enumerate(node.game.get_infosets(node.player)): + if rep in node.members: + return i + raise ValueError("node is terminal, has no information set") + + +# (game.title, infoset label) -> the History of a member node, hand-verified against +# each fixture file before `Infoset.label` (and thus by-label lookup) was removed +# (17.0.0). `find_infoset`/`find_infoset_in_game` are a fixed stand-in for that +# removed lookup, recognizing only the fixture games this test suite happens to use +# it with; add an entry here if a new fixture/label combination is needed. +_INFOSET_LABEL_HISTORIES = { + ("Test Extensive Form Game", "Infoset 1:1"): (), + ("Test Extensive Form Game", "Infoset 2:1"): ("U1",), + ("Test Extensive Form Game", "Infoset 3:1"): ("U1", "U2"), + ("A simple Poker game", "Alice has King"): ("King",), + ("A simple Poker game", "Alice has Queen"): ("Queen",), + ("A simple Poker game", "Bob's response"): ("King", "Bet"), + ("Centipede game. Three inning with probability of altruism. ", "(1,1)"): + ("1=rational", "2=rational"), + ("Centipede game. Three inning with probability of altruism. ", "(1,3)"): + ("1=rational", "2=rational", "p", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(1,5)"): + ("1=rational", "2=rational", "p", "p", "p", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(1,2)"): + ("1=altruist", "2=rational"), + ("Centipede game. Three inning with probability of altruism. ", "(1,4)"): + ("1=altruist", "2=rational", "p", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(1,6)"): + ("1=altruist", "2=rational", "p", "p", "p", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(2,1)"): + ("1=rational", "2=rational", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(2,4)"): + ("1=rational", "2=rational", "p", "p", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(2,5)"): + ("1=rational", "2=rational", "p", "p", "p", "p", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(2,2)"): + ("1=rational", "2=altruist", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(2,3)"): + ("1=rational", "2=altruist", "p", "p", "p"), + ("Centipede game. Three inning with probability of altruism. ", "(2,6)"): + ("1=rational", "2=altruist", "p", "p", "p", "p", "p"), + ("AM-driver variation", "Absent-minded"): (), + ("AM-driver variation", "Second"): ("1",), + ("AM-driver variation", "Third"): ("1", "1", "2"), + ("AM-game with two players", "Absent-minded"): (), + ("AM-game with two players", "Response 1"): ("1", "1"), + ("AM-game with two players", "Response 2"): ("1", "2"), + ("AM-game with two players", "Response 3"): ("2", "1"), + ("AM-game with two players", "Response 4"): ("2", "2"), + ("Untitled Extensive Game", "Absent-minded"): (), + ("Untitled Extensive Game", "Second"): ("1",), + ("Untitled Extensive Game", "Player 2"): ("1", "1", "2", "2"), +} + + +def _node_at_history(game: gbt.Game, history: tuple) -> gbt.Node: + node = game.root + for step in history: + node = node.children[step] + return node + + +def find_infoset(game: gbt.Game, player: str, label: str) -> gbt.Node: + """The representative node of `player`'s information set historically identified + by `label`, matching `Player.infosets[label]` before its removal (17.0.0).""" + node = _node_at_history(game, _INFOSET_LABEL_HISTORIES[(game.title, label)]) + assert node.player == player + return node + + +def find_infoset_in_game(game: gbt.Game, label: str) -> gbt.Node: + """The representative node of the information set historically identified by + `label`, searching across all (personal) players, matching + `Game.infosets[label]` before its removal (17.0.0).""" + return _node_at_history(game, _INFOSET_LABEL_HISTORIES[(game.title, label)]) def _node_history(node: gbt.Node) -> tuple: @@ -747,9 +819,8 @@ def gbt_game(self): if not n.is_terminal and not n.children["L"].is_terminal: left = n.children["L"] g.make_infoset( - selector_for_nodes(list(left.infoset.members) + [n.children["R"]]), - left.infoset.player, - left.infoset.label or None, + selector_for_nodes(list(left.members) + [n.children["R"]]), + left.player, ) return g diff --git a/tests/test_actions.py b/tests/test_actions.py index 28d0a95bdb..5333bad57a 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -40,7 +40,7 @@ def test_relabel_actions_simultaneous_swap(): """ game = games.create_stripped_down_poker_efg() game.relabel_actions(gbt.H.path(), {"King": "Queen", "Queen": "King"}) - assert list(game.root.event.actions) == ["Queen", "King"] + assert list(game.root.actions) == ["Queen", "King"] def test_relabel_actions_duplicate_targets_raises_valueerror(): @@ -61,7 +61,7 @@ def test_relabel_actions_unknown_label_raises_keyerror(): def test_relabel_actions_unknown_label_not_strict_is_ignored(): game = games.create_stripped_down_poker_efg() game.relabel_actions(gbt.H.path(), {"Jack": "Ace", "King": "Ace"}, strict=False) - assert list(game.root.event.actions) == ["Ace", "Queen"] + assert list(game.root.actions) == ["Ace", "Queen"] def test_relabel_actions_failure_leaves_game_unchanged(): @@ -71,7 +71,7 @@ def test_relabel_actions_failure_leaves_game_unchanged(): game = games.create_stripped_down_poker_efg() with pytest.raises(ValueError): game.relabel_actions(gbt.H.path(), {"King": "Ace", "Queen": ""}) - assert list(game.root.event.actions) == ["King", "Queen"] + assert list(game.root.actions) == ["King", "Queen"] def test_relabel_actions_scope_is_the_information_set(): @@ -135,7 +135,7 @@ def test_set_move_actions_reorder_carries_subtrees(): gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in (("x", "a"), ("y", "b"))), "Bob", ["l", "r"] ) - infoset = game.root.children["x"].infoset + infoset = game.root.children["x"] members = list(infoset.members) children_before = [{label: member.children[label] for label in ("a", "b", "c")} for member in members] @@ -154,7 +154,9 @@ def test_set_move_actions_add_drop_and_reorder_together(): assert list(infoset.actions) == ["Raise", "Fold"] # "Bet" and its subtree (Bob's node and its two terminals) go; "Raise" adds one. assert len(game.nodes) == nodes_before - 3 + 1 - assert len(games.find_infoset(game, "Bob", "Bob's response").members) == 1 + # Bob's response infoset survives, now with only its Queen-side member: the + # King-side member (found via the removed "Bet" action) is gone with the subtree. + assert len(game.root.children["Queen"].children["Bet"].members) == 1 def test_set_move_actions_unconfirmed_drop_and_disabled_add_raise(): @@ -198,8 +200,8 @@ def test_set_move_actions_absent_minded_drop_and_add(): game.append_move(gbt.H.path(), "Alice", ["a", "b"]) game.append_infoset(gbt.H.path("a"), gbt.H.path()) 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 list(game.root.actions) == ["b", "c"] + assert len(game.root.members) == 1 assert len(game.nodes) == 3 @@ -231,7 +233,6 @@ def test_set_event_actions_drop_with_probs_mapping(): def test_set_event_actions_unconfirmed_drop_and_disabled_add_raise(): game = games.create_stripped_down_poker_efg() - _ = game.root.event before = game.to_efg() with pytest.raises(ValueError): game.set_event_actions(gbt.H.path(), {"King": 1}) diff --git a/tests/test_behav.py b/tests/test_behav.py index 4f87eb4055..892fbdf480 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -1037,7 +1037,7 @@ def test_martingale_property_of_node_value(game: gbt.Game, rational_flag: bool): realiz_probs = profile.realiz_probs node_values = profile.node_values for node in game.nodes: - if node.is_terminal or bool(node.event): + if node.is_terminal or node.player == "Chance": continue expected_val = 0 node_prob = realiz_probs[node] diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index 43a026d7ad..1fe73cc4ad 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -5,24 +5,14 @@ from . import games -def _find_node(game, label): - """Find a member node of the infoset with the given label, searching across all - players.""" - for player in game.players: - for node in game.get_infosets(player): - if node.infoset.label == label: - return node - raise KeyError(label) - - def _find_selector(game, label): """A `Selector` resolving to the infoset with the given label.""" - return games.selector_for_node(_find_node(game, label)) + return games.selector_for_node(games.find_infoset_in_game(game, label)) def _find_history(game, label): """The History of a member node of the infoset with the given label.""" - return games._node_history(_find_node(game, label)) + return games._node_history(games.find_infoset_in_game(game, label)) def _branching_game(): @@ -36,9 +26,6 @@ def _branching_game(): right = root.children["R"] 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" return game, root, left, right @@ -74,21 +61,17 @@ def test_getitem_rejects_other_types(): profile[0] -def test_getitem_setitem_reject_node_and_infoset(): - """Profile indexing is `Selector`-only: a bare `Node` or `Infoset` -- even one - obtained straight from the game, e.g. `game.root`/`game.root.infoset` -- is no - longer accepted, following the same pattern as `Game.get_minimal_subgame`. +def test_getitem_setitem_reject_node(): + """Profile indexing is `Selector`-only: a bare `Node` -- even one obtained + straight from the game, e.g. `game.root` -- is no longer accepted, following + the same pattern as `Game.get_minimal_subgame`. """ game, root, _, _ = _branching_game() profile = game.behavior_support_profile() with pytest.raises(TypeError): profile[root] - with pytest.raises(TypeError): - profile[root.infoset] with pytest.raises(TypeError): profile[root] = ["R"] - with pytest.raises(TypeError): - profile[root.infoset] = ["R"] def test_predicate_construction(): @@ -98,9 +81,11 @@ def test_predicate_construction(): def test_predicate_construction_error(): + """A predicate that excludes every action at some information set (here, the + root's) triggers "attempted to remove the last action".""" game = games.read_from_file("mixed_behavior_game.efg") with pytest.raises(ValueError): - game.behavior_support_profile(lambda node, a: node.infoset.label != "Infoset 1:1") + game.behavior_support_profile(lambda node, a: node.parent is not None) def test_iter_yields_one_support_per_player(): @@ -172,8 +157,6 @@ def test_getitem_setitem_use_selector(): game, root, _, _ = _branching_game() profile = game.behavior_support_profile() root_selector = games.selector_for_node(root) - # game.root's information set is a live, node-anchored view -- the Selector must - # resolve it the same way Node.infoset is used everywhere else. assert set(profile[root_selector]) == {"L", "R"} profile[root_selector] = ["R"] assert set(profile[root_selector]) == {"R"} diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 961f523067..662d7090b5 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -63,7 +63,7 @@ def test_catalog_games_filter_n_actions(all_games): if len(filtered_games) > 0: g = gbt.catalog.load(filtered_games.Game.iloc[0]) n_game_actions = sum( - len(node.infoset.actions) + len(node.actions) for player in g.players for node in g.get_infosets(player) ) diff --git a/tests/test_file.py b/tests/test_file.py index fe4b33bb53..95e71ee2bd 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -31,13 +31,13 @@ def test_read_efg_repeated_outcome_id_consistent(): def test_read_efg_empty_action_labels_are_normalized(): g = _parse_efg('EFG 2 R "t" { "A" "B" }\n""\np "" 1 1 "" { "" "" } 0\n' 't "" 1 "" { 1, -1 }\nt "" 2 "" { 2, -2 }\n') - assert list(g.root.infoset.actions) == ["_1", "_2"] + assert list(g.root.actions) == ["_1", "_2"] def test_read_efg_duplicate_action_labels_are_normalized(): g = _parse_efg('EFG 2 R "t" { "A" "B" }\n""\np "" 1 1 "" { "l" "l" } 0\n' 't "" 1 "" { 1, -1 }\nt "" 2 "" { 2, -2 }\n') - assert list(g.root.infoset.actions) == ["l_1", "l_2"] + assert list(g.root.actions) == ["l_1", "l_2"] def test_read_efg_repeated_infoset_duplicate_labels_consistent(): @@ -53,7 +53,7 @@ def test_read_efg_repeated_infoset_duplicate_labels_consistent(): 't "" 2 "" { 2, -2 }\n' 't "" 3 "" { 3, -3 }\n' ) - assert list(g.root.infoset.actions) == ["l_1", "l_2"] + assert list(g.root.actions) == ["l_1", "l_2"] _NFG_PAYOFF_BODY = '\n{\n{ "" 1, 1 }\n{ "" 0, 0 }\n{ "" 0, 0 }\n{ "" 1, 1 }\n}\n1 2 3 4\n' diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index 1ca595987e..b59a47f1dd 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -64,16 +64,16 @@ def test_resolve_node_mismatch(): ] ) def test_resolve_infoset(game: gbt.Game) -> None: - """`_resolve_infoset` resolves a Node to the Infoset it belongs to, or a node's - label to the same; any member node of an infoset resolves to an equal Infoset.""" + """`_resolve_infoset` resolves a Node to itself, or a node's label to the node + with that label, validating that it currently belongs to a personal player's + information set; this holds for every member node, not just the representative + `Game.get_infosets` returns.""" for player in game.players: for node in game.get_infosets(player): - resolved = game._resolve_infoset(node, "test") - assert resolved == node.infoset - if node.label: - assert game._resolve_infoset(node.label, "test") == node.infoset - for member in node.infoset.members: - assert game._resolve_infoset(member, "test") == node.infoset + for member in node.members: + assert game._resolve_infoset(member, "test") == member + if member.label: + assert game._resolve_infoset(member.label, "test") == member @pytest.mark.parametrize( diff --git a/tests/test_infosets.py b/tests/test_infosets.py index b5ba0d35a4..a03333e4c3 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -1,8 +1,3 @@ -import dataclasses -import functools -import itertools -import typing - import pytest import pygambit as gbt @@ -10,59 +5,21 @@ from . import games -@pytest.mark.parametrize("label", games.VALID_LABELS) -def test_infoset_set_label(label): - game = games.read_from_file("basic_extensive_game.efg") - game.root.infoset.label = label - assert game.root.infoset.label == label - - -@pytest.mark.parametrize("label", games.INVALID_LABELS) -def test_infoset_label_invalid_raises_valueerror(label): - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.root.infoset.label = label - - -@pytest.mark.parametrize("label", games.UNICODE_LABELS) -def test_infoset_label_unicode_accepted(label): - """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" - game = games.read_from_file("basic_extensive_game.efg") - game.root.infoset.label = label - assert game.root.infoset.label == label - - -def test_infoset_label_duplicate_within_player_raises_valueerror(): - game = games.read_from_file("subgames.efg") - player = next(p for p in game.players if len(game.get_infosets(p)) >= 2) - first, second = (n.infoset for n in itertools.islice(game.get_infosets(player), 2)) - first.label = "shared" - with pytest.raises(ValueError): - second.label = "shared" - - def test_infoset_player_retrieval(): game = games.read_from_file("basic_extensive_game.efg") p1, *_ = game.players - assert p1 == game.root.infoset.player - - -def test_infoset_node_precedes(): - game = games.read_from_file("basic_extensive_game.efg") - assert not game.root.infoset.precedes(game.root) - assert game.root.children["U1"].infoset.precedes(game.root.children["U1"]) + assert p1 == game.root.player -def test_make_infoset_change_player_keeps_label(): - """Re-forming an information set under a different player retains an - explicitly specified label and its membership.""" +def test_make_infoset_change_player_keeps_membership(): + """Re-forming an information set under a different player retains its + membership.""" game = games.read_from_file("basic_extensive_game.efg") _, p2, *_ = game.players - members = list(game.root.infoset.members) - 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 + members = list(game.root.members) + game.make_infoset(games.selector_for_nodes(members), p2) + assert game.root.player == p2 + assert list(game.root.members) == members def test_make_infoset_terminal_node_raises(): @@ -76,11 +33,10 @@ def test_make_infoset_converts_chance_node(): """A chance node becomes a personal decision node, discarding its probabilities.""" game = games.read_from_file("stripped_down_poker.efg") chance_node = game.root # the deal is a chance move - personal = next(n for n in game.nodes if not n.is_terminal and n.infoset) - 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 + personal = next(n for n in game.nodes if not n.is_terminal and n.player != "Chance") + game.make_infoset(gbt.H.path(), personal.player) + assert chance_node.player != "Chance" + assert chance_node.player == personal.player @pytest.mark.parametrize("node_actions", [["c", "d"], ["b", "a"]]) @@ -151,8 +107,7 @@ def test_make_event_pools_nodes_from_different_infosets(): game = games.read_from_file("stripped_down_poker.efg") nodes = [game.root.children["King"], game.root.children["Queen"]] 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 nodes[0] in nodes[1].members assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] assert not game.get_infosets("Alice") @@ -171,11 +126,9 @@ def test_make_event_requires_matching_action_labels(): def test_make_event_converts_personal_node(): """A personal decision node becomes a chance node carrying the probabilities given.""" game = games.read_from_file("stripped_down_poker.efg") - node = next( - n for n in game.get_infosets("Alice") if n.infoset.label == "Alice has King" - ) + node = game.root.children["King"] game.make_event(gbt.H.path("King"), {"Bet": "1/4", "Fold": "3/4"}) - assert node.event + assert node.player == "Chance" assert list(node.action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] @@ -215,12 +168,8 @@ def test_make_event_label_reused_when_fully_absorbed(): nodes = [game.root.children["King"], game.root.children["Queen"]] 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 nodes[0] in nodes[1].members assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] - assert [ - n.event.label for n in game.get_events() - ].count("Coin") == 1 @pytest.mark.parametrize( @@ -241,107 +190,6 @@ def test_make_event_malformed_probs_raises(): game.make_event(gbt.H.path(), {"Jack": 1}) -@dataclasses.dataclass -class AbsentMindednessTestCase: - """TestCase for testing is_absent_minded.""" - factory: typing.Callable[[], gbt.Game] - expected_am_paths: list[list[str]] - - -ABSENT_MINDEDNESS_CASES = [ - # Games without absent-mindedness - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(gbt.catalog.load, "journals/ijgt/selten1975/fig2"), - expected_am_paths=[] - ), - id="short_centipede_perfect_info" - ), - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(games.read_from_file, "stripped_down_poker.efg"), - expected_am_paths=[] - ), - id="poker_stripped" - ), - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(games.read_from_file, "basic_extensive_game.efg"), - expected_am_paths=[] - ), - id="basic_extensive" - ), - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(games.read_from_file, "gilboa_two_am_agents.efg"), - expected_am_paths=[] - ), - id="gilboa_forgetting_info" - ), - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(gbt.catalog.load, "journals/geb/wichardt2008"), - expected_am_paths=[] - ), - id="wichardt_forgetting_action" - ), - # Games with absent-mindedness - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(games.read_from_file, "noPR-AM-driver-two-players.efg"), - expected_am_paths=[[]] - ), - id="AM_driver_two_players" - ), - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(games.read_from_file, "noPR-action-AM.efg"), - expected_am_paths=[[]] - ), - id="AM_forgetting_action" - ), - pytest.param( - AbsentMindednessTestCase( - factory=functools.partial(games.read_from_file, "noPR-action-AM-two-hops.efg"), - expected_am_paths=[["2", "1", "1", "1", "1"], ["1", "1", "1"]] - ), - id="AM_infoset_takes_two_hops" - ), -] - - -def _get_node_by_path(game, path: list[str]) -> gbt.Node: - """ - Helper to find a node by following a sequence of action labels. - """ - node = game.root - for action_label in reversed(path): - node = node.children[action_label] - return node - - -@pytest.mark.parametrize("test_case", ABSENT_MINDEDNESS_CASES) -def test_infoset_is_absent_minded(test_case: AbsentMindednessTestCase): - """ - Test `infoset.is_absent_minded`. - - Verifies that the set of infosets marked as absent-minded matches the - expected set derived from action paths. - """ - game = test_case.factory() - - expected_infosets = { - _get_node_by_path(game, path).infoset - for path in test_case.expected_am_paths - } - actual_infosets = { - n.infoset for p in game.players for n in game.get_infosets(p) - if n.infoset.is_absent_minded - } - - assert actual_infosets == expected_infosets - - def _bagwell_p2_nodes(game: gbt.Game) -> tuple[gbt.Node, gbt.Node, gbt.Node, gbt.Node]: """Player 2's four decision nodes in Bagwell (1995). @@ -356,40 +204,34 @@ def _bagwell_p2_nodes(game: gbt.Game) -> tuple[gbt.Node, gbt.Node, gbt.Node, gbt def test_make_infoset_cherry_pick_leaves_rumps(): - """Partial consumption leaves the remainders behind, labels retained.""" + """Partial consumption leaves the remainders behind.""" game = gbt.catalog.load("journals/geb/bagwell1995") A, B, C, D = _bagwell_p2_nodes(game) - A.infoset.label = "X" - C.infoset.label = "Y" 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] - assert A.infoset.label == "X" - assert D.infoset.label == "Y" + assert B in C.members + assert list(A.members) == [A] + assert list(D.members) == [D] def test_make_infoset_label_held_by_rump_raises(): """Reusing a label whose infoset is only partly consumed is rejected.""" game = gbt.catalog.load("journals/geb/bagwell1995") A, B, C, D = _bagwell_p2_nodes(game) - A.infoset.label = "X" + game.make_infoset(games.selector_for_nodes([A]), "Player 2", "X") with pytest.raises(ValueError): - game.make_infoset(games.selector_for_nodes([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(): """A rejected call must not modify the partition.""" game = gbt.catalog.load("journals/geb/bagwell1995") A, B, C, D = _bagwell_p2_nodes(game) - A.infoset.label = "X" - C.infoset.label = "Y" + game.make_infoset(games.selector_for_nodes([A, B]), "Player 2", "X") + game.make_infoset(games.selector_for_nodes([C, D]), "Player 2", "Y") with pytest.raises(ValueError): 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" - assert C.infoset.label == "Y" + assert A in B.members + assert C in D.members def test_make_infoset_idempotent(): @@ -398,18 +240,17 @@ def test_make_infoset_idempotent(): A, B, C, D = _bagwell_p2_nodes(game) 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" + assert B in C.members -def test_make_infoset_split_leaves_new_infoset_unlabeled(): - """A node split out gets a fresh unlabeled infoset; the rump keeps the label.""" +def test_make_infoset_split_creates_new_infoset(): + """A node split out of an infoset lands in a fresh infoset of its own; the rump + keeps the rest of the original membership.""" game = gbt.catalog.load("journals/geb/bagwell1995") A, B, C, D = _bagwell_p2_nodes(game) - A.infoset.label = "X" game.make_infoset(gbt.H.path("S", "s"), "Player 2") - assert A.infoset.label == "" - assert B.infoset.label == "X" + assert list(A.members) == [A] + assert B not in A.members def test_make_infoset_across_different_source_players(): @@ -420,34 +261,23 @@ def test_make_infoset_across_different_source_players(): 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" - assert n3.infoset.player == "3" + assert n2.player == "2" + assert n3.player == "3" game.make_infoset(gbt.H.path(...), "1") - assert n2.infoset == n3.infoset - assert n2.infoset.player == "1" - assert n3.infoset.player == "1" - - -def test_infoset_proxy_reresolves_after_split(): - """A node-anchored infoset proxy is lazy: it re-resolves after the node is - placed in a new information set.""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] - proxy = node.infoset - assert len(proxy.members) == 2 - game.make_infoset(gbt.H.path("U1"), node.player) - assert list(proxy.members) == [node] + assert n2 in n3.members + assert n2.player == "1" + assert n3.player == "1" -def test_infoset_members_is_a_plain_snapshot_list(): +def test_node_members_is_a_plain_snapshot_list(): """`members` returns a plain `list`, not a lazily-resolved view: it supports integer indexing, and a list obtained before a mutation keeps reflecting the information set as it was at the time, rather than tracking its owner.""" game = games.read_from_file("basic_extensive_game.efg") node = game.root.children["U1"] - members = node.infoset.members + members = node.members assert isinstance(members, list) assert node in (members[0], members[1]) game.make_infoset(gbt.H.path("U1"), node.player) assert len(members) == 2 - assert list(node.infoset.members) == [node] + assert list(node.members) == [node] diff --git a/tests/test_node.py b/tests/test_node.py index d38178d2b7..1b8cc6ff66 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -10,33 +10,13 @@ from . import games -def test_get_infoset(): - """Test to ensure that we can retrieve an infoset for a given node""" +def test_node_actions_reflects_information_set(): + """A node has actions iff it currently belongs to an information set or event.""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.infoset - assert game.root.children["U1"].infoset - assert not game.root.children["U1"].children["D2"].children["U3"].infoset - - -def test_infoset_equality_is_symmetric(): - """A node-anchored infoset proxy and a separately-constructed Infoset compare - equal from either side.""" - game = games.read_from_file("basic_extensive_game.efg") - proxy = game.root.infoset - infoset = game.get_infosets(game.root.player)[0].infoset - 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(gbt.H.path("U1", "D2", "U3"), "Player 1", ["a", "b"]) - assert proxy + assert game.root.actions + assert game.root.children["U1"].actions + with pytest.raises(AttributeError): + _ = game.root.children["U1"].children["D2"].children["U3"].actions def test_get_outcome(): @@ -127,7 +107,6 @@ def test_node_player_resolves_chance(): """At a chance node, the player label is the chance player's.""" game = games.read_from_file("stripped_down_poker.efg") chance_node = game.root - assert chance_node.event assert chance_node.player == "Chance" @@ -464,7 +443,7 @@ 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) + key = (node.player, games.infoset_number(node)) selector = games.selector_for_nodes([node]) actual_path = tuple( _get_path_of_action_labels(game.get_minimal_subgame(selector).root) @@ -548,7 +527,7 @@ def test_node_own_prior_action_non_terminal(game_file, expected_node_data): # Only collect data for non-terminal nodes opa = node.own_prior_action if opa is not None: - details = (opa.node.infoset.player, opa.node.infoset.number, opa.label) + details = (opa.node.player, games.infoset_number(opa.node), opa.label) else: details = None actual_node_data.append((_get_path_of_action_labels(node), details)) @@ -631,14 +610,14 @@ def test_insert_move_error_duplicate_label(): game.insert_move(gbt.H.path(), "Player 1", ["a", "a"]) -def test_node_infoset_becomes_null_when_truncated(): - """A captured infoset proxy re-resolves to null after the node is truncated to a leaf.""" +def test_node_actions_becomes_undefined_when_truncated(): + """A node's actions become undefined after it is truncated to a leaf.""" game = games.read_from_file("basic_extensive_game.efg") node = game.root.children["U1"] - proxy = node.infoset - assert proxy + assert node.actions game.delete_tree(gbt.H.path("U1")) - assert not proxy + with pytest.raises(AttributeError): + _ = node.actions def test_node_delete_parent(): @@ -679,7 +658,7 @@ def _subtrees_equal( return False # now, both n1 and n2 are non-terminal # check that they are in the same infosets - if n1.infoset != n2.infoset: + if n1 not in n2.members: return False # check that they have the same number of children if len(n1.children) != len(n2.children): @@ -751,7 +730,7 @@ def test_append_move_same_infoset_list_of_nodes(): game.append_move( gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), "Player 3", ["B", "F"] ) - assert node1.infoset == node2.infoset + assert node1 in node2.members def test_append_move_actions_list_of_nodes(): @@ -767,7 +746,7 @@ def test_append_move_actions_list_of_nodes(): 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) + assert list(node1.actions) == list(node2.actions) def test_append_move_labels_list_of_nodes(): @@ -784,7 +763,7 @@ def test_append_move_labels_list_of_nodes(): "Player 3", ["B", "F", "S"] ) - assert node1.infoset.actions == node2.infoset.actions + assert node1.actions == node2.actions def test_append_move_node_list_with_non_terminal_node(): @@ -866,8 +845,8 @@ def test_append_event_creates_single_event_list_of_nodes(): 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 + assert node1 in node2.members + assert not node1.is_terminal def test_append_event_sets_distribution(): @@ -930,7 +909,7 @@ def test_insert_event_actions_labeled(): node = game.root.children["L"].children["R"] 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 + assert node.parent.player == "Chance" def test_insert_event_sets_distribution(): @@ -1041,7 +1020,7 @@ def test_len_after_append_infoset(): game = gbt.catalog.load("journals/ijgt/selten1975/fig2") initial_number_of_nodes = len(game.nodes) - infoset_to_modify = game.root.children["L"].infoset + infoset_to_modify = game.root.children["L"] number_of_infoset_actions = len(infoset_to_modify.actions) game.append_infoset(gbt.H.path("L", "L", "l"), gbt.H.path("L")) @@ -1053,7 +1032,7 @@ def test_len_after_set_move_actions_add(): """Verify `len(game.nodes)` is correct after `set_move_actions` creates an action.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = len(game.nodes) - infoset_to_modify = game.root.children["L"].infoset # Player 2's infoset + infoset_to_modify = game.root.children["L"] # Player 2's infoset num_nodes_in_infoset = len(infoset_to_modify.members) labels = list(infoset_to_modify.actions) game.set_move_actions(gbt.H.path("L"), labels + ["new"]) @@ -1067,9 +1046,9 @@ def test_len_after_set_move_actions_drop(): action_to_drop = "L" nodes_to_delete = sum( _count_subtree_nodes(member.children[action_to_drop], True) - for member in game.root.infoset.members + for member in game.root.members ) - remaining = [a for a in game.root.infoset.actions if a != "L"] + remaining = [a for a in game.root.actions if a != "L"] game.set_move_actions(gbt.H.path(), remaining, drop=True) assert len(game.nodes) == initial_number_of_nodes - nodes_to_delete @@ -1092,7 +1071,7 @@ def test_insert_move_actions_labeled(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") node = game.root.children["L"].children["R"] game.insert_move(gbt.H.path("L", "R"), "Player 2", ["Up", "Down"]) - assert list(node.parent.infoset.actions) == ["Up", "Down"] + assert list(node.parent.actions) == ["Up", "Down"] def test_len_after_insert_infoset(): @@ -1101,7 +1080,7 @@ def test_len_after_insert_infoset(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = len(game.nodes) - infoset_to_modify = game.root.children["L"].infoset + infoset_to_modify = game.root.children["L"] number_of_infoset_actions = len(infoset_to_modify.actions) game.insert_infoset(gbt.H.path("L", "R"), gbt.H.path("L")) diff --git a/tests/test_players.py b/tests/test_players.py index 73e89c0084..0612818285 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -305,7 +305,7 @@ def test_player_sequence_count(): game = gbt.catalog.load("books/myerson1991/fig2_1") for player in game.players: action_count = sum( - len(node.infoset.actions) for node in game.get_infosets(player) + len(node.actions) for node in game.get_infosets(player) ) assert len(game.get_sequences(player)) == action_count + 1 @@ -318,7 +318,7 @@ def test_player_sequence_actions(): set( (action, ) for node in game.get_infosets(player) - for action in node.infoset.actions + for action in node.actions ) | {tuple()} ) From 2efb274e380b845667c04a61e4086562e6ca6dc5 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 14:04:45 +0100 Subject: [PATCH 6/6] Update tests and tutorials for changes --- doc/tutorials/03_stripped_down_poker.ipynb | 45 +++++++++++++++---- .../agent_versus_non_agent_regret.ipynb | 2 +- .../openspiel.ipynb | 8 ++-- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 89e0d2e14b..fd766a7813 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -356,7 +356,7 @@ "id": "f45a82b6", "metadata": {}, "outputs": [], - "source": "for infoset, mixed_action in eqm[\"Alice\"]:\n print(\n f\"At information set {infoset.number}, \"\n f\"Alice plays Bet with probability: {mixed_action['Bet']}\"\n f\" and Fold with probability: {mixed_action['Fold']}\"\n )" + "source": "for number, (_infoset, mixed_action) in enumerate(eqm[\"Alice\"], start=1):\n print(\n f\"At information set {number}, \"\n f\"Alice plays Bet with probability: {mixed_action['Bet']}\"\n f\" and Fold with probability: {mixed_action['Fold']}\"\n )" }, { "cell_type": "markdown", @@ -366,6 +366,33 @@ "We can alternatively iterate through each of a player's actions like so:" ] }, + { + "cell_type": "markdown", + "id": "9c1f6a2e", + "metadata": {}, + "source": [ + "Indexing the profile directly by a `Node` requires identifying it with a `Selector`, since `MixedBehaviorProfile.__getitem__` accepts only a player label or a `Selector`, not a bare `Node`.\n", + "\n", + "A `Node`'s own path of action labels back to the root builds exactly the `Selector` that matches it:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b7c1e8a4", + "metadata": {}, + "outputs": [], + "source": [ + "def selector_for_node(node):\n", + " labels = []\n", + " current = node\n", + " while current.parent is not None:\n", + " labels.append(current.prior_action.label)\n", + " current = current.parent\n", + " labels.reverse()\n", + " return gbt.H.path(*labels)" + ] + }, { "cell_type": "code", "execution_count": null, @@ -373,11 +400,11 @@ "metadata": {}, "outputs": [], "source": [ - "for node in g.get_infosets(\"Alice\"):\n", + "for number, node in enumerate(g.get_infosets(\"Alice\"), start=1):\n", " for action in node.actions:\n", " print(\n", - " f\"At information set {node.infoset.number}, \"\n", - " f\"Alice plays {action} with probability: {eqm[node][action]}\"\n", + " f\"At information set {number}, \"\n", + " f\"Alice plays {action} with probability: {eqm[selector_for_node(node)][action]}\"\n", " )" ] }, @@ -416,7 +443,7 @@ "id": "2966e700", "metadata": {}, "outputs": [], - "source": "(bob_node,) = g.get_infosets(\"Bob\")\nbob_infoset = bob_node.infoset\neqm[bob_node][\"Call\"]" + "source": "(bob_node,) = g.get_infosets(\"Bob\")\neqm[selector_for_node(bob_node)][\"Call\"]" }, { "cell_type": "markdown", @@ -436,7 +463,7 @@ "outputs": [], "source": [ "bob_action_values = eqm.action_values[bob_node]\n", - "for action in bob_infoset.actions:\n", + "for action in bob_node.actions:\n", " print(\n", " f\"When Bob plays {action} his expected payoff is {bob_action_values[action]}\"\n", " )" @@ -461,7 +488,7 @@ "metadata": {}, "outputs": [], "source": [ - "for node in bob_infoset.members:\n", + "for node in bob_node.members:\n", " print(\n", " f\"Bob's belief in reaching the {node.parent.prior_action.label} -> \"\n", " f\"{node.prior_action.label} node is: {eqm.beliefs[node]}\"\n", @@ -504,7 +531,7 @@ "outputs": [], "source": [ "bob_node_values = eqm.node_values[\"Bob\"]\n", - "for node in bob_infoset.members:\n", + "for node in bob_node.members:\n", " print(\n", " f\"The probability that the node {node.parent.prior_action.label} -> \"\n", " f\"{node.prior_action.label} is reached is: {eqm.realiz_probs[node]}. \",\n", @@ -642,7 +669,7 @@ "id": "d18a91f0", "metadata": {}, "outputs": [], - "source": "for player in g.players:\n print(\n f\"{player}'s expected payoffs:\"\n )\n gnm_action_values = gnm_eqm.as_behavior().action_values\n lcp_action_values = eqm.action_values\n for node in g.get_infosets(player):\n for action in node.actions:\n print(\n f\"At information set {node.infoset.number}, \"\n f\"when playing {action} - \"\n f\"gnm: {gnm_action_values[node][action]:.4f}\"\n f\", lcp: {str(lcp_action_values[node][action])}\"\n )\n print()" + "source": "for player in g.players:\n print(\n f\"{player}'s expected payoffs:\"\n )\n gnm_action_values = gnm_eqm.as_behavior().action_values\n lcp_action_values = eqm.action_values\n for number, node in enumerate(g.get_infosets(player), start=1):\n for action in node.actions:\n print(\n f\"At information set {number}, \"\n f\"when playing {action} - \"\n f\"gnm: {gnm_action_values[node][action]:.4f}\"\n f\", lcp: {str(lcp_action_values[node][action])}\"\n )\n print()" }, { "cell_type": "markdown", diff --git a/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb b/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb index cee1fec173..0785585928 100644 --- a/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb +++ b/doc/tutorials/advanced_tutorials/agent_versus_non_agent_regret.ipynb @@ -84,7 +84,7 @@ "id": "6e3e9303-453a-4bac-a449-fa8fda2ba5ec", "metadata": {}, "outputs": [], - "source": "eq = pure_Nash_equilibria[0]\nfor behavior in eq.as_behavior():\n for infoset, probs in behavior:\n print(infoset.player, \"infoset:\", infoset.number, \"behavior probabilities:\", probs)" + "source": "eq = pure_Nash_equilibria[0]\nfor behavior in eq.as_behavior():\n for number, (_infoset, probs) in enumerate(behavior, start=1):\n print(behavior.player, \"infoset:\", number, \"behavior probabilities:\", probs)" }, { "cell_type": "markdown", diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index 30954e1174..e05408d767 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -500,9 +500,9 @@ "metadata": {}, "outputs": [], "source": [ - "for infoset, mixed_action in eqm[\"Pl0\"]:\n", + "for number, (_infoset, mixed_action) in enumerate(eqm[\"Pl0\"], start=1):\n", " print(\n", - " f\"At information set {infoset.number}, \"\n", + " f\"At information set {number}, \"\n", " f\"Player 0 plays action 0 with probability: {mixed_action['p0a0']}\"\n", " f\" and action 1 with probability: {mixed_action['p0a1']}\"\n", " f\" and action 2 with probability: {mixed_action['p0a2']}\"\n", @@ -534,9 +534,9 @@ "metadata": {}, "outputs": [], "source": [ - "for infoset, mixed_action in eqm[\"Pl1\"]:\n", + "for number, (_infoset, mixed_action) in enumerate(eqm[\"Pl1\"], start=1):\n", " print(\n", - " f\"At information set {infoset.number}, \"\n", + " f\"At information set {number}, \"\n", " f\"Player 1 plays action 0 with probability: {mixed_action['p1a0']}\"\n", " f\" and action 1 with probability: {mixed_action['p1a1']}\"\n", " f\" and action 2 with probability: {mixed_action['p1a2']}\"\n",