From dc810b76db8eff5078e4b9394ce37a5486f06976 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 15:01:11 +0100 Subject: [PATCH 01/24] Added `Game.get_histories` --- src/pygambit/game.pxi | 28 ++++++++++++++++++++++++++++ tests/test_hsel.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 5a8f508321..755f183b04 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -590,6 +590,34 @@ class Game: """ return [_history_of(node) for node in self._get_nodes(selector)] + def get_histories(self, selector: Selector) -> list[tuple]: + """Returns the Histories of the nodes that `selector` resolves to. + + Parameters + ---------- + selector : Selector + An `H`-built expression, evaluated against this game. + + Returns + ------- + list of tuple + The Histories -- plain tuples of action labels from the root -- + of the matching nodes, in the order `selector` produces them. + + .. versionadded:: 17.0.0 + + Raises + ------ + TypeError + If `selector` is not a `Selector`. + """ + if not isinstance(selector, Selector): + raise TypeError( + f"get_histories(): selector must be a Selector, not " + f"{selector.__class__.__name__}" + ) + return self._get_histories(selector) + def _group_nodes(self, grouped: GroupedSelector) -> dict: """Internal: like `_get_groups`, but keeps `Node` objects rather than materializing each into a `History` -- used by mutation methods that diff --git a/tests/test_hsel.py b/tests/test_hsel.py index 30da6d01fe..f2adf3a10c 100644 --- a/tests/test_hsel.py +++ b/tests/test_hsel.py @@ -63,3 +63,38 @@ def key(h): return None game._get_groups(gbt.H.plays.by(key)) + + +def test_get_histories_root(): + game = gbt.Game.new_tree(players=["A"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + + assert game.get_histories(gbt.H.path()) == [()] + + +def test_get_histories_multiple(): + game = gbt.Game.new_tree(players=["A", "B"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + game.append_move(gbt.H.plays, "B", ["x", "y"]) + + assert game.get_histories(gbt.H.path(...)) == [("U",), ("D",)] + assert game.get_histories(gbt.H.plays) == [ + ("U", "x"), ("U", "y"), ("D", "x"), ("D", "y"), + ] + + +def test_get_histories_empty(): + game = gbt.Game.new_tree(players=["A"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + + assert game.get_histories(gbt.H.after("nonexistent")) == [] + + +def test_get_histories_requires_selector(): + game = gbt.Game.new_tree(players=["A"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + + with pytest.raises(TypeError): + game.get_histories(()) + with pytest.raises(TypeError): + game.get_histories("U") From d296f603ae5f4e12cd4eacb9e3733d0dde710739 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 15:14:43 +0100 Subject: [PATCH 02/24] Remove prior_sibling/next_sibling --- doc/pygambit.api.rst | 2 -- src/pygambit/node.pxi | 22 ---------------------- tests/test_node.py | 14 -------------- 3 files changed, 38 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 510b5a40ea..394edde66c 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -137,8 +137,6 @@ Information about the game Node.is_terminal Node.is_strategy_reachable Node.prior_action - Node.prior_sibling - Node.next_sibling Node.members Node.actions Node.action_probs diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 79bf56501e..3626df53fa 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -383,28 +383,6 @@ class Node: cur = cur.deref().GetParent() return Branch(Node.wrap(cur.deref().GetParent()), label) - @property - def prior_sibling(self) -> Node | None: - """The node which is immediately before this one in its parent's children. - - If this is the root node or the first child of its parent, - None is returned. - """ - if self.node.deref().GetPriorSibling() != cython.cast(c_GameNode, NULL): - return Node.wrap(self.node.deref().GetPriorSibling()) - return None - - @property - def next_sibling(self) -> Node | None: - """The node which is immediately after this one in its parent's children. - - If this is the root node or the last child of its parent, - None is returned. - """ - if self.node.deref().GetNextSibling() != cython.cast(c_GameNode, NULL): - return Node.wrap(self.node.deref().GetNextSibling()) - return None - @property def is_terminal(self) -> bool: """Returns whether this is a terminal node of the game.""" diff --git a/tests/test_node.py b/tests/test_node.py index 1b8cc6ff66..2462a74b9f 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -130,20 +130,6 @@ def test_get_prior_action(): assert game.root.prior_action is None -def test_get_prior_sibling(): - """Test to ensure that we can retrieve a prior sibling of a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["D1"].prior_sibling == game.root.children["U1"] - assert game.root.children["U1"].prior_sibling is None - - -def test_get_next_sibling(): - """Test to ensure that we can retrieve a next sibling of a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].next_sibling == game.root.children["D1"] - assert game.root.children["D1"].next_sibling is None - - def test_is_terminal(): """Test to ensure that we can check if a given node is a terminal node""" game = games.read_from_file("basic_extensive_game.efg") From 4b8b6107c086eef4169a6e667fc03186eca4953d Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 15:23:06 +0100 Subject: [PATCH 03/24] Simplify tests so as not to need Node --- tests/test_behavspt_profiles.py | 12 ++++++------ tests/test_node.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index 1fe73cc4ad..20955aa1c7 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -154,24 +154,24 @@ def test_actionsupport_is_snapshot(): def test_getitem_setitem_use_selector(): - game, root, _, _ = _branching_game() + game, _, _, _ = _branching_game() profile = game.behavior_support_profile() - root_selector = games.selector_for_node(root) + root_selector = gbt.H.path() assert set(profile[root_selector]) == {"L", "R"} profile[root_selector] = ["R"] assert set(profile[root_selector]) == {"R"} def test_is_infoset_reachable(): - game, root, left, right = _branching_game() + game, _, _, _ = _branching_game() profile = game.behavior_support_profile() - left_selector = games.selector_for_node(left) - right_selector = games.selector_for_node(right) + left_selector = gbt.H.path("L") + right_selector = gbt.H.path("R") assert profile.is_infoset_reachable(left_selector) assert profile.is_infoset_reachable(right_selector) copy = profile.copy() - copy[games.selector_for_node(root)] = ["R"] + copy[gbt.H.path()] = ["R"] assert not copy.is_infoset_reachable(left_selector) assert copy.is_infoset_reachable(right_selector) # the original, un-mutated profile is unaffected diff --git a/tests/test_node.py b/tests/test_node.py index 2462a74b9f..182fe9fea4 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -800,7 +800,7 @@ def test_append_infoset_error_infoset_not_a_selector(): game.set_players(list(game.players) + ["Player 3"]) game.append_move(gbt.H.path("1", "1"), "Player 3", ["B", "F"]) with pytest.raises(TypeError): - game.append_infoset(gbt.H.path("1", "2"), game.root.children["1"].children["1"]) + game.append_infoset(gbt.H.path("1", "2"), 42) def test_append_infoset_error_infoset_terminal(): From e8041ef3c3fdbd6e1e7a8381a6724b35fbec8574 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 16:10:48 +0100 Subject: [PATCH 04/24] Reorganise test suite around function --- tests/test_actions.py | 357 ------ tests/test_game.py | 204 --- tests/test_infosets.py | 283 ---- ..._outcomes.py => test_outcome_mutations.py} | 69 - tests/test_outcome_queries.py | 149 +++ tests/test_profile_invalidation.py | 126 ++ ...rategic.py => test_strategic_mutations.py} | 45 - tests/test_strategic_queries.py | 133 ++ .../{test_node.py => test_tree_mutations.py} | 1134 ++++++++--------- tests/test_tree_queries.py | 658 ++++++++++ 10 files changed, 1583 insertions(+), 1575 deletions(-) delete mode 100644 tests/test_actions.py delete mode 100644 tests/test_infosets.py rename tests/{test_outcomes.py => test_outcome_mutations.py} (78%) create mode 100644 tests/test_outcome_queries.py create mode 100644 tests/test_profile_invalidation.py rename tests/{test_strategic.py => test_strategic_mutations.py} (79%) create mode 100644 tests/test_strategic_queries.py rename tests/{test_node.py => test_tree_mutations.py} (50%) create mode 100644 tests/test_tree_queries.py diff --git a/tests/test_actions.py b/tests/test_actions.py deleted file mode 100644 index 5333bad57a..0000000000 --- a/tests/test_actions.py +++ /dev/null @@ -1,357 +0,0 @@ -import pytest - -import pygambit as gbt - -from . import games - - -@pytest.mark.parametrize("label", games.VALID_LABELS) -def test_action_label(label: str): - game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.actions)) - game.relabel_actions(gbt.H.path(), {action: label}) - assert label in game.root.actions - - -@pytest.mark.parametrize("label", games.INVALID_LABELS) -def test_action_label_invalid_raises_valueerror(label: str): - game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.actions)) - with pytest.raises(ValueError): - game.relabel_actions(gbt.H.path(), {action: label}) - - -def test_relabel_action_empty_raises_valueerror(): - game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.actions)) - with pytest.raises(ValueError): - game.relabel_actions(gbt.H.path(), {action: ""}) - - -def test_relabel_actions_duplicate_raises_valueerror(): - game = games.create_stripped_down_poker_efg() - with pytest.raises(ValueError): - game.relabel_actions(gbt.H.path(), {"King": "Queen"}) - - -def test_relabel_actions_simultaneous_swap(): - """Reassignment is simultaneous, so a swap is well-defined; applying the entries one - at a time would collide on the intermediate state. - """ - game = games.create_stripped_down_poker_efg() - game.relabel_actions(gbt.H.path(), {"King": "Queen", "Queen": "King"}) - assert list(game.root.actions) == ["Queen", "King"] - - -def test_relabel_actions_duplicate_targets_raises_valueerror(): - """Both replacements are free of the actions left untouched but collide with each - other, so checking each against the untouched actions alone would let this through. - """ - game = games.create_stripped_down_poker_efg() - with pytest.raises(ValueError): - game.relabel_actions(gbt.H.path(), {"King": "Ace", "Queen": "Ace"}) - - -def test_relabel_actions_unknown_label_raises_keyerror(): - game = games.create_stripped_down_poker_efg() - with pytest.raises(KeyError): - game.relabel_actions(gbt.H.path(), {"Jack": "Ace"}) - - -def test_relabel_actions_unknown_label_not_strict_is_ignored(): - game = games.create_stripped_down_poker_efg() - game.relabel_actions(gbt.H.path(), {"Jack": "Ace", "King": "Ace"}, strict=False) - assert list(game.root.actions) == ["Ace", "Queen"] - - -def test_relabel_actions_failure_leaves_game_unchanged(): - """The whole mapping is validated before any label is written, so a mapping that - fails part way through leaves no partial reassignment behind. - """ - game = games.create_stripped_down_poker_efg() - with pytest.raises(ValueError): - game.relabel_actions(gbt.H.path(), {"King": "Ace", "Queen": ""}) - assert list(game.root.actions) == ["King", "Queen"] - - -def test_relabel_actions_scope_is_the_information_set(): - """Action labels are unique within an information set, not within a player: Alice's - two information sets both offer "Bet", and relabelling one leaves the other untouched - and free to take the same new label. - """ - game = games.create_stripped_down_poker_efg() - king = games.find_infoset(game, "Alice", "Alice has King") - queen = games.find_infoset(game, "Alice", "Alice has Queen") - game.relabel_actions(games.selector_for_nodes([next(iter(king.members))]), {"Bet": "Raise"}) - assert list(king.actions) == ["Raise", "Fold"] - assert list(queen.actions) == ["Bet", "Fold"] - game.relabel_actions(games.selector_for_nodes([next(iter(queen.members))]), {"Bet": "Raise"}) - assert list(queen.actions) == ["Raise", "Fold"] - - -def test_relabel_actions_not_a_mapping_raises_typeerror(): - game = games.create_stripped_down_poker_efg() - with pytest.raises(TypeError): - game.relabel_actions(gbt.H.path(), [("King", "Queen")]) - - -@pytest.mark.parametrize("labels", [{1: "Queen"}, {"King": 1}]) -def test_relabel_actions_non_str_label_raises_typeerror(labels: dict): - game = games.create_stripped_down_poker_efg() - with pytest.raises(TypeError): - game.relabel_actions(gbt.H.path(), labels) - - -def test_set_move_actions_drop_shrinks_actions_and_children(): - game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) - action_count = len(infoset.actions) - remaining = list(infoset.actions)[1:] - game.set_move_actions(games.selector_for_nodes([node]), remaining, drop=True) - assert len(infoset.actions) == action_count - 1 - assert len(node.children) == action_count - 1 - - -def test_set_move_actions_cannot_remove_the_only_action(): - game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) - last = next(iter(infoset.actions)) - selector = games.selector_for_nodes([node]) - game.set_move_actions(selector, [last], drop=True) - assert list(infoset.actions) == [last] - with pytest.raises(gbt.UndefinedOperationError): - game.set_move_actions(selector, [], drop=True) - - -def test_set_move_actions_reorder_carries_subtrees(): - """Reordering three actions as a cycle moves every action to a new position. - Each action carries its whole subtree with it, at every member of the information set.""" - game = gbt.Game.new_tree(players=["Alice", "Bob"]) - game.append_move(gbt.H.path(), "Bob", ["x", "y"]) - game.append_move(gbt.H.path(...), "Alice", ["a", "b", "c"]) - game.append_move( - gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in (("x", "a"), ("y", "b"))), - "Bob", ["l", "r"] - ) - infoset = game.root.children["x"] - members = list(infoset.members) - children_before = [{label: member.children[label] for label in ("a", "b", "c")} - for member in members] - game.set_move_actions(gbt.H.path("x"), ["c", "a", "b"]) - assert list(infoset.actions) == ["c", "a", "b"] - for member, children in zip(members, children_before, strict=True): - assert list(member.children) == [children["c"], children["a"], children["b"]] - - -def test_set_move_actions_add_drop_and_reorder_together(): - game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) - nodes_before = len(game.nodes) - game.set_move_actions(games.selector_for_nodes([node]), ["Raise", "Fold"], drop=True) - assert list(infoset.actions) == ["Raise", "Fold"] - # "Bet" and its subtree (Bob's node and its two terminals) go; "Raise" adds one. - assert len(game.nodes) == nodes_before - 3 + 1 - # 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(): - game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) - before = game.to_efg() - selector = games.selector_for_nodes([node]) - with pytest.raises(ValueError): - game.set_move_actions(selector, ["Bet"]) - with pytest.raises(ValueError): - game.set_move_actions(selector, ["Bet", "Fold", "Raise"], add=False) - assert game.to_efg() == before - - -def test_set_move_actions_raises_at_an_event(): - """`set_move_actions` is only for a personal player's move; `set_event_actions` is the - corresponding operation for an event.""" - game = games.create_stripped_down_poker_efg() - with pytest.raises(ValueError): - game.set_move_actions(gbt.H.path(), ["King", "Queen"]) - - -@pytest.mark.parametrize("bad_labels", [["Bet", "Bet"], ["Bet", ""], ["Bet", " x"]]) -def test_set_move_actions_bad_labels_raise_and_leave_game_unchanged(bad_labels): - """Duplicate, empty, and invalid labels in `actions` are rejected in C++, - after the Python guards pass; the game must be unmodified by the failure.""" - game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) - before = game.to_efg() - with pytest.raises(ValueError): - game.set_move_actions(games.selector_for_nodes([node]), bad_labels, drop=True) - assert game.to_efg() == before - - -def test_set_move_actions_absent_minded_drop_and_add(): - """Dropping an action whose subtree contains another member of the same information - set deletes that member with the subtree.""" - game = gbt.Game.new_tree(players=["Alice"]) - game.append_move(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.actions) == ["b", "c"] - assert len(game.root.members) == 1 - assert len(game.nodes) == 3 - - -def test_set_event_actions_reorder_carries_probabilities(): - game = games.create_stripped_down_poker_efg() - game.set_event_actions(gbt.H.path(), {"King": "3/4", "Queen": "1/4"}) - game.set_event_actions(gbt.H.path(), {"Queen": "1/4", "King": "3/4"}) - assert list(game.root.actions) == ["Queen", "King"] - assert game.root.action_probs == {"Queen": gbt.Rational(1, 4), "King": gbt.Rational(3, 4)} - - -def test_set_event_actions_add_with_probs_mapping(): - game = games.create_stripped_down_poker_efg() - nodes_before = len(game.nodes) - game.set_event_actions(gbt.H.path(), {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) - assert list(game.root.actions) == ["Jack", "King", "Queen"] - assert game.root.action_probs == { - "Jack": gbt.Rational(1, 2), "King": gbt.Rational(1, 4), "Queen": gbt.Rational(1, 4) - } - assert len(game.nodes) == nodes_before + 1 - - -def test_set_event_actions_drop_with_probs_mapping(): - game = games.create_stripped_down_poker_efg() - game.set_event_actions(gbt.H.path(), {"King": 1}, drop=True) - assert list(game.root.actions) == ["King"] - assert game.root.action_probs == {"King": 1} - - -def test_set_event_actions_unconfirmed_drop_and_disabled_add_raise(): - game = games.create_stripped_down_poker_efg() - before = game.to_efg() - with pytest.raises(ValueError): - game.set_event_actions(gbt.H.path(), {"King": 1}) - with pytest.raises(ValueError): - game.set_event_actions( - gbt.H.path(), {"King": "1/2", "Queen": "1/4", "Jack": "1/4"}, add=False - ) - assert game.to_efg() == before - - -def test_set_event_actions_raises_at_a_move(): - """`set_event_actions` is only for an event; `set_move_actions` is the corresponding - operation for a personal player's move.""" - game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - with pytest.raises(ValueError): - game.set_event_actions( - games.selector_for_nodes([next(iter(infoset.members))]), {"Bet": 1} - ) - - -def test_set_event_actions_rejects_non_mapping_probs(): - """`probs` must be a mapping: with no separate list of actions, there's nothing for a - plain sequence of probabilities to be paired with positionally.""" - game = games.create_stripped_down_poker_efg() - before = game.to_efg() - with pytest.raises(TypeError): - game.set_event_actions(gbt.H.path(), ["3/4", "1/4"]) - assert game.to_efg() == before - - -def test_set_event_actions_bad_distribution_raises_valueerror(): - game = games.create_stripped_down_poker_efg() - before = game.to_efg() - with pytest.raises(ValueError): - game.set_event_actions(gbt.H.path(), {"King": "3/4", "Queen": "3/4"}) - assert game.to_efg() == before - - -@pytest.mark.parametrize( - "game, player_label, strategy_label, infoset_path, expected_action_label", - [ - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 1", "1", [], "R"), - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 1", "2", [], "L"), - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 2", "1", ["R"], "R"), - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 2", "2", ["R"], "L"), - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 3", "1", ["R", "L"], "R"), - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 3", "2", ["R", "L"], "L"), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "1", [], "R"), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "2", [], "L"), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 2", "1", ["L"], "R"), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 2", "2", ["L"], "L"), - (games.read_from_file("basic_extensive_game.efg"), "Player 1", "1", [], "U1"), - (games.read_from_file("basic_extensive_game.efg"), "Player 1", "2", [], "D1"), - (games.read_from_file("basic_extensive_game.efg"), "Player 2", "1", ["U1"], "U2"), - (games.read_from_file("basic_extensive_game.efg"), "Player 2", "2", ["U1"], "D2"), - (games.read_from_file("basic_extensive_game.efg"), "Player 3", "1", ["U1", "U2"], "U3"), - (games.read_from_file("basic_extensive_game.efg"), "Player 3", "2", ["U1", "U2"], "D3"), - ], -) -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.""" - selector = gbt.H.path(*infoset_path) - - prescribed_action = game.get_behavior(player_label, strategy_label).get(selector) - - assert prescribed_action == expected_action_label - - -@pytest.mark.parametrize( - "game, player_label, strategy_label, infoset_label, infoset_path", - [ - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "1", None, ["L", "L"]), - (games.read_from_file("cent3.efg"), "Player 1", "1", "(1,3)", None), - (games.read_from_file("cent3.efg"), "Player 1", "1", "(1,5)", None), - (games.read_from_file("cent3.efg"), "Player 1", "2", "(1,5)", None), - (games.read_from_file("cent3.efg"), "Player 2", "1", "(2,4)", None), - (games.read_from_file("cent3.efg"), "Player 2", "1", "(2,4)", None), - (games.read_from_file("cent3.efg"), "Player 2", "2", "(2,5)", None), - ], -) -def test_get_behavior_prescribed_action_undefined_returns_none( - game, player_label, strategy_label, infoset_label, infoset_path -): - """Verify `Game.get_behavior` returns None when called on an unreached player's infoset""" - if infoset_label is not None: - node = next(iter(games.find_infoset_in_game(game, infoset_label).members)) - selector = games.selector_for_node(node) - else: - selector = gbt.H.path(*infoset_path) - - prescribed_action = game.get_behavior(player_label, strategy_label).get(selector) - - assert prescribed_action is None - - -@pytest.mark.parametrize( - "game, player_label, other_infoset_path", - [ - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 1", ["R"]), - (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 2", []), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", ["L"]), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 2", []), - (games.read_from_file("basic_extensive_game.efg"), "Player 1", ["U1"]), - (games.read_from_file("basic_extensive_game.efg"), "Player 2", ["U1", "U2"]), - (games.read_from_file("basic_extensive_game.efg"), "Player 3", []), - ], -) -def test_get_behavior_raises_value_error_for_wrong_player( - game, player_label, other_infoset_path -): - """ - Verify `Game.get_behavior`'s result raises ValueError when the infoset belongs - to a different player than the strategy. - """ - behavior = game.get_behavior(player_label, next(iter(game.get_strategies(player_label)))) - other_selector = gbt.H.path(*other_infoset_path) - - with pytest.raises(ValueError): - behavior.get(other_selector) diff --git a/tests/test_game.py b/tests/test_game.py index 548fed15c1..c61fa4862d 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -97,210 +97,6 @@ def test_from_dict(): assert pl2 == "b" -def test_game_get_outcome(): - game = gbt.Game.new_table([2, 2]) - game.make_outcome({"1": "1", "2": "1"}, {"1": 0, "2": 0}, "top left") - assert game.get_outcome({"1": "1", "2": "1"}) == next(iter(game.outcomes)) - - -def test_game_get_outcome_by_relabeled_strategies(): - game = gbt.Game.new_table([2, 2]) - pl1, pl2 = game.players - game.relabel_strategies(pl1, {next(iter(game.get_strategies(pl1))): "defect"}) - game.relabel_strategies(pl2, {next(iter(game.get_strategies(pl2))): "cooperate"}) - game.make_outcome({pl1: "defect", pl2: "cooperate"}, {"1": 0, "2": 0}, "corner") - assert game.get_outcome({pl1: "defect", pl2: "cooperate"}) == \ - next(iter(game.outcomes)) - - -def test_game_get_outcome_incomplete_contingency_raises(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(ValueError): - _ = game.get_outcome({"1": "1"}) - - -def test_game_get_outcome_unknown_player_raises(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(KeyError): - _ = game.get_outcome({"1": "1", "2": "1", "3": "1"}) - - -def test_game_get_outcome_non_mapping_raises(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(TypeError): - _ = game.get_outcome(42) - - -def test_game_get_outcome_non_str_value_raises(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(TypeError): - _ = game.get_outcome({"1": 1.23, "2": "1"}) - - -def test_game_get_outcome_unknown_strategy_label_raises(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(KeyError): - _ = game.get_outcome({"1": "1", "2": "99"}) - - -def test_game_get_outcome_unmatched_label_after_relabel_raises(): - game = gbt.Game.new_table([2, 2]) - pl1, pl2 = game.players - game.relabel_strategies(pl1, {next(iter(game.get_strategies(pl1))): "defect"}) - game.relabel_strategies(pl2, {next(iter(game.get_strategies(pl2))): "cooperate"}) - with pytest.raises(KeyError): - _ = game.get_outcome({pl1: "defect", pl2: "defect"}) - - -def test_game_get_outcome_tree_raises(): - game = gbt.Game.new_tree(["Alice"]) - game.append_move(gbt.H.path(), "Alice", ["a", "b"]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.get_outcome({"Alice": "a"}) - - -def test_game_get_payoffs(): - game = gbt.Game.new_table([2, 2]) - game.make_outcome({"1": "1", "2": "1"}, {"1": 3, "2": -3}, "top left") - payoffs = game.get_payoffs({"1": "1", "2": "1"}) - assert payoffs["1"] == 3 - assert payoffs["2"] == -3 - - -def test_game_get_payoffs_tree(): - game = gbt.Game.new_tree(["Alice"]) - game.append_move(gbt.H.path(), "Alice", ["a", "b"]) - selector = gbt.H.path() - strategy = next( - s for s in game.get_strategies("Alice") - 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}) - assert payoffs["Alice"] == 1 - - -def test_mixed_strategy_profile_game_structure_changed_no_tree(): - game = gbt.Game.from_arrays([[2, 2], [0, 0]], [[0, 0], [1, 1]]) - profiles = [game.mixed_strategy_profile(rational=b) for b in [False, True]] - player = next(iter(game.players)) - distribution = {s: 0 for s in game.get_strategies(player)} - next(iter(game.outcomes))[player] = 3 - for profile in profiles: - with pytest.raises(gbt.GameStructureChangedError): - profile.copy() - with pytest.raises(gbt.GameStructureChangedError): - profile.liap_value() - with pytest.raises(gbt.GameStructureChangedError): - profile.max_regret() - with pytest.raises(gbt.GameStructureChangedError): - profile.normalize() - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.payoffs - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.player_regrets - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.strategy_regrets - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.strategy_values - with pytest.raises(gbt.GameStructureChangedError): - # triggers error via __getitem__ - next(profile.__iter__()) - with pytest.raises(gbt.GameStructureChangedError): - profile.__setitem__(player, distribution) - with pytest.raises(gbt.GameStructureChangedError): - profile.set_mixed_strategy(player, distribution) - with pytest.raises(gbt.GameStructureChangedError): - profile.__getitem__(player) - - -def test_mixed_strategy_profile_game_structure_changed_tree(): - game = games.read_from_file("basic_extensive_game.efg") - profiles = [game.mixed_strategy_profile(rational=b) for b in [False, True]] - player = next(iter(game.players)) - game.set_move_actions(gbt.H.path(), ["D1"], drop=True) - distribution = {s: 0 for s in game.get_strategies(player)} - for profile in profiles: - with pytest.raises(gbt.GameStructureChangedError): - profile.as_behavior() - with pytest.raises(gbt.GameStructureChangedError): - profile.copy() - with pytest.raises(gbt.GameStructureChangedError): - profile.liap_value() - with pytest.raises(gbt.GameStructureChangedError): - profile.max_regret() - with pytest.raises(gbt.GameStructureChangedError): - profile.normalize() - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.payoffs - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.player_regrets - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.strategy_regrets - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.strategy_values - with pytest.raises(gbt.GameStructureChangedError): - # triggers error via __getitem__ - next(profile.__iter__()) - with pytest.raises(gbt.GameStructureChangedError): - profile.__setitem__(player, distribution) - with pytest.raises(gbt.GameStructureChangedError): - profile.set_mixed_strategy(player, distribution) - with pytest.raises(gbt.GameStructureChangedError): - profile.__getitem__(player) - - -def test_mixed_behavior_profile_game_structure_changed(): - game = games.read_from_file("basic_extensive_game.efg") - profiles = [game.mixed_behavior_profile(rational=b) for b in [False, True]] - game.set_move_actions(gbt.H.path(), ["D1"], drop=True) - for profile in profiles: - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.action_regrets - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.action_values - with pytest.raises(gbt.GameStructureChangedError): - profile.as_strategy() - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.beliefs - with pytest.raises(gbt.GameStructureChangedError): - profile.copy() - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.infoset_probs - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.infoset_regrets - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.infoset_values - with pytest.raises(gbt.GameStructureChangedError): - profile.agent_liap_value() - with pytest.raises(gbt.GameStructureChangedError): - profile.liap_value() - with pytest.raises(gbt.GameStructureChangedError): - profile.agent_max_regret() - with pytest.raises(gbt.GameStructureChangedError): - profile.max_regret() - with pytest.raises(gbt.GameStructureChangedError): - # triggers error via __getitem__ - next(profile.__iter__()) - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.node_values - with pytest.raises(gbt.GameStructureChangedError): - profile.normalize() - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.payoffs - with pytest.raises(gbt.GameStructureChangedError): - _ = profile.realiz_probs - with pytest.raises(gbt.GameStructureChangedError): - # triggers error via __getitem__ - next(profile.__iter__()) - with pytest.raises(gbt.GameStructureChangedError): - profile.__setitem__(game.root, {}) - with pytest.raises(gbt.GameStructureChangedError): - profile.set_mixed_action(game.root, {}) - with pytest.raises(gbt.GameStructureChangedError): - profile.__getitem__(game.root) - - COLLECTION_GETTERS = [ pytest.param(lambda g: g.players, id="GamePlayers"), pytest.param(lambda g: g.outcomes, id="GameOutcomes"), diff --git a/tests/test_infosets.py b/tests/test_infosets.py deleted file mode 100644 index a03333e4c3..0000000000 --- a/tests/test_infosets.py +++ /dev/null @@ -1,283 +0,0 @@ -import pytest - -import pygambit as gbt - -from . import games - - -def test_infoset_player_retrieval(): - game = games.read_from_file("basic_extensive_game.efg") - p1, *_ = game.players - assert p1 == game.root.player - - -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.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(): - """All nodes must be decision nodes.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.UndefinedOperationError): - game.make_infoset(gbt.H.path("U1", "U2", "U3"), game.root.player) - - -def test_make_infoset_converts_chance_node(): - """A chance node becomes a personal decision node, discarding its probabilities.""" - game = games.read_from_file("stripped_down_poker.efg") - chance_node = game.root # the deal is a chance move - personal = next(n for n in game.nodes if not n.is_terminal and n.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"]]) -def test_make_infoset_requires_matching_action_labels(node_actions): - """Nodes must have the same actions, with the same labels in the same order; - a matching count is not sufficient.""" - game = gbt.Game.new_tree(players=["1"]) - game.append_move(gbt.H.path(), "1", ["a", "b"]) - game.append_move(gbt.H.path("a"), "1", node_actions) - with pytest.raises(ValueError): - game.make_infoset(gbt.H.after().filter(lambda h: h[:] in ((), ("a",))), "1") - - -def test_make_infoset_empty_nodes_raises(): - """`nodes` must be nonempty.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.make_infoset(gbt.H.path(...).filter(lambda h: False), game.root.player) - - -def test_make_infoset_strategic_game_raises(): - """`make_infoset` is only defined for games with a tree representation.""" - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - game.make_infoset(gbt.H.path(), "1") - - -def test_set_move_actions_add_preserves_existing_action_order(): - """New actions may be declared at any position; the existing actions' relative - order is preserved.""" - game = games.read_from_file("basic_extensive_game.efg") - labels = list(game.root.actions) - game.set_move_actions(gbt.H.path(), labels + ["end"]) - assert list(game.root.actions)[:-1] == labels - game.set_move_actions(gbt.H.path(), ["front"] + labels + ["end"]) - assert list(game.root.actions)[1:-1] == labels - - -@pytest.mark.parametrize( - "inprobs,outprobs", - [ - ({"King": "1/4", "Queen": "3/4"}, [gbt.Rational("1/4"), gbt.Rational("3/4")]), - ({"King": 0.75, "Queen": 0.25}, [0.75, 0.25]), - ({"King": 1}, [1, 0]), - ], -) -def test_make_event_sets_probabilities(inprobs, outprobs): - """Probabilities are given as a mapping from action label to probability, - which may be sparse: an omitted action is assigned probability zero. - """ - game = games.read_from_file("stripped_down_poker.efg") - game.make_event(gbt.H.path(), inprobs, "Deal") - probs = game.root.action_probs - for action, prob in zip(game.root.actions, outprobs, strict=True): - assert probs[action] == prob - - -@pytest.mark.parametrize("probs", [["1/4", "3/4"], [0.75, 0.25]]) -def test_make_event_probs_not_a_mapping_raises_typeerror(probs): - """A positional sequence of probabilities is no longer accepted.""" - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(TypeError): - game.make_event(gbt.H.path(), probs, "Deal") - - -def test_make_event_pools_nodes_from_different_infosets(): - """Nodes in distinct information sets are formed into a single event.""" - game = games.read_from_file("stripped_down_poker.efg") - nodes = [game.root.children["King"], game.root.children["Queen"]] - game.make_event(gbt.H.path(...), {"Bet": "1/4", "Fold": "3/4"}, "Coin") - 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") - - -def test_make_event_requires_matching_action_labels(): - """Nodes must have the same actions, with the same labels in the same order.""" - game = games.read_from_file("stripped_down_poker.efg") - # King node has actions Bet, Fold; its own Bet-child has actions Call, Fold. - with pytest.raises(ValueError): - game.make_event( - gbt.H.after().filter(lambda h: h[:] in (("King",), ("King", "Bet"))), - {"Bet": "1/2", "Fold": "1/2"} - ) - - -def test_make_event_converts_personal_node(): - """A personal decision node becomes a chance node carrying the probabilities given.""" - game = games.read_from_file("stripped_down_poker.efg") - node = game.root.children["King"] - game.make_event(gbt.H.path("King"), {"Bet": "1/4", "Fold": "3/4"}) - assert node.player == "Chance" - assert list(node.action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] - - -def test_make_event_terminal_node_raises(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(gbt.UndefinedOperationError): - game.make_event(gbt.H.path("King", "Fold"), {"a": "1/2", "b": "1/2"}) - - -def test_make_event_strategic_game_raises(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - game.make_event(gbt.H.path(), {"a": 1}) - - -def test_make_event_empty_nodes_raises(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(ValueError): - game.make_event(gbt.H.path(...).filter(lambda h: False), {"a": "1/2", "b": "1/2"}) - - -def test_make_event_label_held_by_rump_raises(): - """A label may be reused only if all members of the event holding it are absorbed.""" - game = games.read_from_file("stripped_down_poker.efg") - game.make_event(gbt.H.path(...), {"Bet": "1/2", "Fold": "1/2"}, "Coin") - before = game.to_efg() - with pytest.raises(ValueError): - game.make_event(gbt.H.path("King"), {"Bet": "1/2", "Fold": "1/2"}, "Coin") - assert game.to_efg() == before - - -def test_make_event_label_reused_when_fully_absorbed(): - """A label held by an existing event may be reused once all of that - event's members are absorbed into the new one; the old event is not left behind. - """ - game = games.read_from_file("stripped_down_poker.efg") - nodes = [game.root.children["King"], game.root.children["Queen"]] - 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] in nodes[1].members - assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] - - -@pytest.mark.parametrize( - "probs", [{"King": "3/4", "Queen": "-1/2"}, {"King": 0.75, "Queen": 0.40}, - {"King": "foo", "Queen": "bar"}] -) -def test_make_event_invalid_probs_raises(probs): - """Values must be numbers, non-negative, and sum to exactly one.""" - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(ValueError): - game.make_event(gbt.H.path(), probs) - - -def test_make_event_malformed_probs_raises(): - """An unknown action label as a mapping key raises KeyError.""" - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(KeyError): - game.make_event(gbt.H.path(), {"Jack": 1}) - - -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). - - Player 2 has two information sets -- one for each signal the chance move - can produce -- each with two members and actions ("S", "C"). Returns - (A, B, C, D) with {A, B} the members of one and {C, D} of the other. - """ - return (game.root.children["S"].children["s"], - game.root.children["C"].children["s"], - game.root.children["S"].children["c"], - game.root.children["C"].children["c"]) - - -def test_make_infoset_cherry_pick_leaves_rumps(): - """Partial consumption leaves the remainders behind.""" - game = gbt.catalog.load("journals/geb/bagwell1995") - A, B, C, D = _bagwell_p2_nodes(game) - game.make_infoset(games.selector_for_nodes([B, C]), "Player 2") - 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) - 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" - - -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) - 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 in B.members - assert C in D.members - - -def test_make_infoset_idempotent(): - """Repeating a call is a no-op: label reuse permits equality of membership.""" - game = gbt.catalog.load("journals/geb/bagwell1995") - A, B, C, D = _bagwell_p2_nodes(game) - game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "Z") - game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "Z") - assert B in C.members - - -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) - game.make_infoset(gbt.H.path("S", "s"), "Player 2") - assert list(A.members) == [A] - assert B not in A.members - - -def test_make_infoset_across_different_source_players(): - """Nodes drawn from different players all land under the target player.""" - game = gbt.Game.new_tree(players=["1", "2", "3"]) - game.append_move(gbt.H.path(), "1", ["a", "b"]) - game.append_move(gbt.H.path("a"), "2", ["a", "b"]) # player 2 - game.append_move(gbt.H.path("b"), "3", ["a", "b"]) # player 3 - n2 = game.root.children["a"] - n3 = game.root.children["b"] - assert n2.player == "2" - assert n3.player == "3" - game.make_infoset(gbt.H.path(...), "1") - assert n2 in n3.members - assert n2.player == "1" - assert n3.player == "1" - - -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.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.members) == [node] diff --git a/tests/test_outcomes.py b/tests/test_outcome_mutations.py similarity index 78% rename from tests/test_outcomes.py rename to tests/test_outcome_mutations.py index 3246feb2d1..191a3b17b3 100644 --- a/tests/test_outcomes.py +++ b/tests/test_outcome_mutations.py @@ -2,8 +2,6 @@ import pygambit as gbt -from . import games - def test_make_outcome_attaches_to_all_given_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) @@ -195,73 +193,6 @@ def test_make_outcome_null_on_already_null_node_is_a_no_op(): assert not up.outcome -@pytest.mark.parametrize("label", games.VALID_LABELS) -def test_outcome_label(label: str): - game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) - outcome = next(iter(game.outcomes)) - outcome.label = label - assert outcome.label == label - - -@pytest.mark.parametrize("label", games.INVALID_LABELS) -def test_outcome_label_invalid_raises_valueerror(label: str): - game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) - outcome = next(iter(game.outcomes)) - with pytest.raises(ValueError): - outcome.label = label - - -@pytest.mark.parametrize("label", games.UNICODE_LABELS) -def test_outcome_label_unicode_accepted(label: str): - """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" - game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) - outcome = next(iter(game.outcomes)) - outcome.label = label - assert outcome.label == label - - -@pytest.mark.parametrize( - "game,label", - [(gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]), "outcome label")] -) -def test_outcome_index_label(game: gbt.Game, label: str): - outcome = next(iter(game.outcomes)) - outcome.label = label - assert outcome == game.outcomes[label] - assert game.outcomes[label].label == label - - -@pytest.mark.parametrize( - "game", [gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]])] -) -def test_outcome_index_unmatched_label(game: gbt.Game): - with pytest.raises(KeyError): - _ = game.outcomes["not an outcome"] - - -@pytest.mark.parametrize( - "game", [gbt.Game.new_table([2, 2])] -) -def test_outcome_index_invalid_type(game: gbt.Game): - with pytest.raises(TypeError): - _ = game.outcomes[1.3] - - -def test_outcome_payoff_by_player_label(): - game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) - pl1, pl2 = list(game.players) - game.relabel_players({pl1: "joe", pl2: "dan"}) - out1, out2, *_ = list(game.outcomes) - out1["joe"] = 1 - out1["dan"] = 2 - out2["joe"] = 3 - out2["dan"] = 4 - assert out1["joe"] == 1 - assert out1["dan"] == 2 - assert out2["joe"] == 3 - assert out2["dan"] == 4 - - def test_outcome_relabel_duplicate_rejected_and_label_unchanged(): game = gbt.Game.new_tree(players=["A", "B"]) game.append_move(gbt.H.path(), "A", ["win", "lose"]) diff --git a/tests/test_outcome_queries.py b/tests/test_outcome_queries.py new file mode 100644 index 0000000000..863b793dc8 --- /dev/null +++ b/tests/test_outcome_queries.py @@ -0,0 +1,149 @@ +import pytest + +import pygambit as gbt + +from . import games + + +def test_get_outcome(): + """Test to ensure that we can retrieve an outcome for a given node""" + game = games.read_from_file("basic_extensive_game.efg") + assert ( + game.root.children["U1"].children["D2"].children["U3"].outcome + == game.outcomes["Outcome 1"] + ) + assert not game.root.outcome + + +def test_make_outcome_null(): + """Resetting a node's outcome to null leaves the node's outcome view falsy.""" + game = games.read_from_file("basic_extensive_game.efg") + node = game.root.children["U1"].children["U2"].children["U3"] + game.make_outcome_null(gbt.H.path("U1", "U2", "U3")) + assert not node.outcome + + +def test_node_outcome_subscript_tracks_mutation(): + """Indexing the outcome view reads/writes the outcome's payoffs, reflecting mutation.""" + game = games.read_from_file("basic_extensive_game.efg") + node = game.root.children["U1"].children["D2"].children["U3"] + proxy = node.outcome + player = "Player 1" + proxy[player] = 7 + assert node.outcome[player] == 7 + + +def test_outcome_equality_is_symmetric(): + """A node-anchored outcome view and the resolved Outcome compare equal from either side.""" + game = games.read_from_file("basic_extensive_game.efg") + node = game.root.children["U1"].children["D2"].children["U3"] + proxy = node.outcome + outcome = game.outcomes["Outcome 1"] + assert proxy == outcome + assert outcome == proxy + + +def test_null_outcome_label_is_none(): + """The blessed nullity idiom: a node with no outcome has `outcome.label is None`.""" + game = games.read_from_file("basic_extensive_game.efg") + assert game.root.outcome.label is None + + +def test_null_outcome_compares_unequal_to_itself(): + """Null outcomes are unequal to everything, including another view of the same + node's outcome; equality must not short-circuit on node identity.""" + game = games.read_from_file("basic_extensive_game.efg") + assert (game.root.outcome == game.root.outcome) is False + + +def test_null_outcome_reads_zero_payoffs(): + """Reading a payoff through an unset node reports zero to every player of the game.""" + game = games.read_from_file("basic_extensive_game.efg") + for player in game.players: + assert game.root.outcome[player] == 0 + + +def test_null_outcome_payoff_write_raises(): + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(gbt.UndefinedOperationError): + game.root.outcome["Player 1"] = 1 + + +def test_null_outcome_label_write_raises(): + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.root.outcome.label = "Outcome 4" + + +def test_null_outcome_number_is_none(): + """The null outcome is not a member of the game's outcomes, so it has no number. + -1 would be a valid index and would silently resolve to the last real outcome.""" + game = games.read_from_file("basic_extensive_game.efg") + assert game.root.outcome.number is None + + +@pytest.mark.parametrize("label", games.VALID_LABELS) +def test_outcome_label(label: str): + game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) + outcome = next(iter(game.outcomes)) + outcome.label = label + assert outcome.label == label + + +@pytest.mark.parametrize("label", games.INVALID_LABELS) +def test_outcome_label_invalid_raises_valueerror(label: str): + game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) + outcome = next(iter(game.outcomes)) + with pytest.raises(ValueError): + outcome.label = label + + +@pytest.mark.parametrize("label", games.UNICODE_LABELS) +def test_outcome_label_unicode_accepted(label: str): + """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" + game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) + outcome = next(iter(game.outcomes)) + outcome.label = label + assert outcome.label == label + + +@pytest.mark.parametrize( + "game,label", + [(gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]), "outcome label")] +) +def test_outcome_index_label(game: gbt.Game, label: str): + outcome = next(iter(game.outcomes)) + outcome.label = label + assert outcome == game.outcomes[label] + assert game.outcomes[label].label == label + + +@pytest.mark.parametrize( + "game", [gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]])] +) +def test_outcome_index_unmatched_label(game: gbt.Game): + with pytest.raises(KeyError): + _ = game.outcomes["not an outcome"] + + +@pytest.mark.parametrize( + "game", [gbt.Game.new_table([2, 2])] +) +def test_outcome_index_invalid_type(game: gbt.Game): + with pytest.raises(TypeError): + _ = game.outcomes[1.3] + + +def test_outcome_payoff_by_player_label(): + game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) + pl1, pl2 = list(game.players) + game.relabel_players({pl1: "joe", pl2: "dan"}) + out1, out2, *_ = list(game.outcomes) + out1["joe"] = 1 + out1["dan"] = 2 + out2["joe"] = 3 + out2["dan"] = 4 + assert out1["joe"] == 1 + assert out1["dan"] == 2 + assert out2["joe"] == 3 + assert out2["dan"] == 4 diff --git a/tests/test_profile_invalidation.py b/tests/test_profile_invalidation.py new file mode 100644 index 0000000000..2aeef6e377 --- /dev/null +++ b/tests/test_profile_invalidation.py @@ -0,0 +1,126 @@ +import pytest + +import pygambit as gbt + +from . import games + + +def test_mixed_strategy_profile_game_structure_changed_no_tree(): + game = gbt.Game.from_arrays([[2, 2], [0, 0]], [[0, 0], [1, 1]]) + profiles = [game.mixed_strategy_profile(rational=b) for b in [False, True]] + player = next(iter(game.players)) + distribution = {s: 0 for s in game.get_strategies(player)} + next(iter(game.outcomes))[player] = 3 + for profile in profiles: + with pytest.raises(gbt.GameStructureChangedError): + profile.copy() + with pytest.raises(gbt.GameStructureChangedError): + profile.liap_value() + with pytest.raises(gbt.GameStructureChangedError): + profile.max_regret() + with pytest.raises(gbt.GameStructureChangedError): + profile.normalize() + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.payoffs + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.player_regrets + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.strategy_regrets + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.strategy_values + with pytest.raises(gbt.GameStructureChangedError): + # triggers error via __getitem__ + next(profile.__iter__()) + with pytest.raises(gbt.GameStructureChangedError): + profile.__setitem__(player, distribution) + with pytest.raises(gbt.GameStructureChangedError): + profile.set_mixed_strategy(player, distribution) + with pytest.raises(gbt.GameStructureChangedError): + profile.__getitem__(player) + + +def test_mixed_strategy_profile_game_structure_changed_tree(): + game = games.read_from_file("basic_extensive_game.efg") + profiles = [game.mixed_strategy_profile(rational=b) for b in [False, True]] + player = next(iter(game.players)) + game.set_move_actions(gbt.H.path(), ["D1"], drop=True) + distribution = {s: 0 for s in game.get_strategies(player)} + for profile in profiles: + with pytest.raises(gbt.GameStructureChangedError): + profile.as_behavior() + with pytest.raises(gbt.GameStructureChangedError): + profile.copy() + with pytest.raises(gbt.GameStructureChangedError): + profile.liap_value() + with pytest.raises(gbt.GameStructureChangedError): + profile.max_regret() + with pytest.raises(gbt.GameStructureChangedError): + profile.normalize() + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.payoffs + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.player_regrets + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.strategy_regrets + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.strategy_values + with pytest.raises(gbt.GameStructureChangedError): + # triggers error via __getitem__ + next(profile.__iter__()) + with pytest.raises(gbt.GameStructureChangedError): + profile.__setitem__(player, distribution) + with pytest.raises(gbt.GameStructureChangedError): + profile.set_mixed_strategy(player, distribution) + with pytest.raises(gbt.GameStructureChangedError): + profile.__getitem__(player) + + +def test_mixed_behavior_profile_game_structure_changed(): + game = games.read_from_file("basic_extensive_game.efg") + profiles = [game.mixed_behavior_profile(rational=b) for b in [False, True]] + game.set_move_actions(gbt.H.path(), ["D1"], drop=True) + for profile in profiles: + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.action_regrets + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.action_values + with pytest.raises(gbt.GameStructureChangedError): + profile.as_strategy() + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.beliefs + with pytest.raises(gbt.GameStructureChangedError): + profile.copy() + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.infoset_probs + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.infoset_regrets + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.infoset_values + with pytest.raises(gbt.GameStructureChangedError): + profile.agent_liap_value() + with pytest.raises(gbt.GameStructureChangedError): + profile.liap_value() + with pytest.raises(gbt.GameStructureChangedError): + profile.agent_max_regret() + with pytest.raises(gbt.GameStructureChangedError): + profile.max_regret() + with pytest.raises(gbt.GameStructureChangedError): + # triggers error via __getitem__ + next(profile.__iter__()) + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.node_values + with pytest.raises(gbt.GameStructureChangedError): + profile.normalize() + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.payoffs + with pytest.raises(gbt.GameStructureChangedError): + _ = profile.realiz_probs + with pytest.raises(gbt.GameStructureChangedError): + # triggers error via __getitem__ + next(profile.__iter__()) + with pytest.raises(gbt.GameStructureChangedError): + profile.__setitem__(game.root, {}) + with pytest.raises(gbt.GameStructureChangedError): + profile.set_mixed_action(game.root, {}) + with pytest.raises(gbt.GameStructureChangedError): + profile.__getitem__(game.root) diff --git a/tests/test_strategic.py b/tests/test_strategic_mutations.py similarity index 79% rename from tests/test_strategic.py rename to tests/test_strategic_mutations.py index cffb854978..2f7e711dad 100644 --- a/tests/test_strategic.py +++ b/tests/test_strategic_mutations.py @@ -5,51 +5,6 @@ from . import games -def test_strategic_game_get_infosets(): - game = gbt.Game.new_table([2, 2]) - player, _ = game.players - with pytest.raises(gbt.UndefinedOperationError): - _ = game.get_infosets(player) - - -def test_strategic_game_root(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.root - - -def test_strategic_game_nodes(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.nodes - - -def test_game_behav_profile_error(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.mixed_behavior_profile() - - -def test_game_is_const_sum(): - game = games.read_from_file("const_sum_game.nfg") - assert game.is_const_sum - - -def test_game_is_not_const_sum(): - game = games.read_from_file("non_const_sum_game.nfg") - assert not game.is_const_sum - - -def test_game_get_min_payoff(): - game = games.read_from_file("mixed_strategy.nfg") - assert game.min_payoff == 0 - - -def test_game_get_max_payoff(): - game = games.read_from_file("mixed_strategy.nfg") - assert game.max_payoff == 3 - - def test_relabel_strategies_swap(): """Swap is well-defined; strategies keep their positions.""" game = gbt.Game.new_table([2, 2]) diff --git a/tests/test_strategic_queries.py b/tests/test_strategic_queries.py new file mode 100644 index 0000000000..693c80dc66 --- /dev/null +++ b/tests/test_strategic_queries.py @@ -0,0 +1,133 @@ +import pytest + +import pygambit as gbt + +from . import games + + +def test_strategic_game_get_infosets(): + game = gbt.Game.new_table([2, 2]) + player, _ = game.players + with pytest.raises(gbt.UndefinedOperationError): + _ = game.get_infosets(player) + + +def test_strategic_game_root(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(gbt.UndefinedOperationError): + _ = game.root + + +def test_strategic_game_nodes(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(gbt.UndefinedOperationError): + _ = game.nodes + + +def test_game_behav_profile_error(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(gbt.UndefinedOperationError): + _ = game.mixed_behavior_profile() + + +def test_game_is_const_sum(): + game = games.read_from_file("const_sum_game.nfg") + assert game.is_const_sum + + +def test_game_is_not_const_sum(): + game = games.read_from_file("non_const_sum_game.nfg") + assert not game.is_const_sum + + +def test_game_get_min_payoff(): + game = games.read_from_file("mixed_strategy.nfg") + assert game.min_payoff == 0 + + +def test_game_get_max_payoff(): + game = games.read_from_file("mixed_strategy.nfg") + assert game.max_payoff == 3 + + +def test_game_get_outcome(): + game = gbt.Game.new_table([2, 2]) + game.make_outcome({"1": "1", "2": "1"}, {"1": 0, "2": 0}, "top left") + assert game.get_outcome({"1": "1", "2": "1"}) == next(iter(game.outcomes)) + + +def test_game_get_outcome_by_relabeled_strategies(): + game = gbt.Game.new_table([2, 2]) + pl1, pl2 = game.players + game.relabel_strategies(pl1, {next(iter(game.get_strategies(pl1))): "defect"}) + game.relabel_strategies(pl2, {next(iter(game.get_strategies(pl2))): "cooperate"}) + game.make_outcome({pl1: "defect", pl2: "cooperate"}, {"1": 0, "2": 0}, "corner") + assert game.get_outcome({pl1: "defect", pl2: "cooperate"}) == \ + next(iter(game.outcomes)) + + +def test_game_get_outcome_incomplete_contingency_raises(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(ValueError): + _ = game.get_outcome({"1": "1"}) + + +def test_game_get_outcome_unknown_player_raises(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(KeyError): + _ = game.get_outcome({"1": "1", "2": "1", "3": "1"}) + + +def test_game_get_outcome_non_mapping_raises(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(TypeError): + _ = game.get_outcome(42) + + +def test_game_get_outcome_non_str_value_raises(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(TypeError): + _ = game.get_outcome({"1": 1.23, "2": "1"}) + + +def test_game_get_outcome_unknown_strategy_label_raises(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(KeyError): + _ = game.get_outcome({"1": "1", "2": "99"}) + + +def test_game_get_outcome_unmatched_label_after_relabel_raises(): + game = gbt.Game.new_table([2, 2]) + pl1, pl2 = game.players + game.relabel_strategies(pl1, {next(iter(game.get_strategies(pl1))): "defect"}) + game.relabel_strategies(pl2, {next(iter(game.get_strategies(pl2))): "cooperate"}) + with pytest.raises(KeyError): + _ = game.get_outcome({pl1: "defect", pl2: "defect"}) + + +def test_game_get_outcome_tree_raises(): + game = gbt.Game.new_tree(["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["a", "b"]) + with pytest.raises(gbt.UndefinedOperationError): + _ = game.get_outcome({"Alice": "a"}) + + +def test_game_get_payoffs(): + game = gbt.Game.new_table([2, 2]) + game.make_outcome({"1": "1", "2": "1"}, {"1": 3, "2": -3}, "top left") + payoffs = game.get_payoffs({"1": "1", "2": "1"}) + assert payoffs["1"] == 3 + assert payoffs["2"] == -3 + + +def test_game_get_payoffs_tree(): + game = gbt.Game.new_tree(["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["a", "b"]) + selector = gbt.H.path() + strategy = next( + s for s in game.get_strategies("Alice") + 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}) + assert payoffs["Alice"] == 1 diff --git a/tests/test_node.py b/tests/test_tree_mutations.py similarity index 50% rename from tests/test_node.py rename to tests/test_tree_mutations.py index 182fe9fea4..9aa34721fb 100644 --- a/tests/test_node.py +++ b/tests/test_tree_mutations.py @@ -1,8 +1,3 @@ -import dataclasses -import functools -import itertools -import typing - import pytest import pygambit as gbt @@ -10,550 +5,6 @@ from . import games -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.actions - assert game.root.children["U1"].actions - with pytest.raises(AttributeError): - _ = game.root.children["U1"].children["D2"].children["U3"].actions - - -def test_get_outcome(): - """Test to ensure that we can retrieve an outcome for a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert ( - game.root.children["U1"].children["D2"].children["U3"].outcome - == game.outcomes["Outcome 1"] - ) - assert not game.root.outcome - - -def test_make_outcome_null(): - """Resetting a node's outcome to null leaves the node's outcome view falsy.""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"].children["U2"].children["U3"] - game.make_outcome_null(gbt.H.path("U1", "U2", "U3")) - assert not node.outcome - - -def test_node_outcome_subscript_tracks_mutation(): - """Indexing the outcome view reads/writes the outcome's payoffs, reflecting mutation.""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"].children["D2"].children["U3"] - proxy = node.outcome - player = "Player 1" - proxy[player] = 7 - assert node.outcome[player] == 7 - - -def test_outcome_equality_is_symmetric(): - """A node-anchored outcome view and the resolved Outcome compare equal from either side.""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"].children["D2"].children["U3"] - proxy = node.outcome - outcome = game.outcomes["Outcome 1"] - assert proxy == outcome - assert outcome == proxy - - -def test_null_outcome_label_is_none(): - """The blessed nullity idiom: a node with no outcome has `outcome.label is None`.""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.outcome.label is None - - -def test_null_outcome_compares_unequal_to_itself(): - """Null outcomes are unequal to everything, including another view of the same - node's outcome; equality must not short-circuit on node identity.""" - game = games.read_from_file("basic_extensive_game.efg") - assert (game.root.outcome == game.root.outcome) is False - - -def test_null_outcome_reads_zero_payoffs(): - """Reading a payoff through an unset node reports zero to every player of the game.""" - game = games.read_from_file("basic_extensive_game.efg") - for player in game.players: - assert game.root.outcome[player] == 0 - - -def test_null_outcome_payoff_write_raises(): - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.UndefinedOperationError): - game.root.outcome["Player 1"] = 1 - - -def test_null_outcome_label_write_raises(): - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.root.outcome.label = "Outcome 4" - - -def test_null_outcome_number_is_none(): - """The null outcome is not a member of the game's outcomes, so it has no number. - -1 would be a valid index and would silently resolve to the last real outcome.""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.outcome.number is None - - -def test_get_player(): - """Test to ensure that we can retrieve a player for a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.player == "Player 1" - assert not game.root.children["U1"].children["D2"].children["U3"].player - - -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.player == "Chance" - - -def test_get_game(): - """Test to ensure that we can retrieve the game object from a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game == game.root.game - - -def test_get_parent(): - """Test to ensure that we can retrieve a parent node for a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].parent == game.root - assert game.root.parent is None - - -def test_get_prior_action(): - """Test to ensure that we can retrieve the prior action for a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].prior_action == gbt.Branch(game.root, "U1") - assert game.root.prior_action is None - - -def test_is_terminal(): - """Test to ensure that we can check if a given node is a terminal node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.is_terminal is False - assert game.root.children["U1"].children["U2"].children["U3"].is_terminal is True - - -def test_is_successor_of(): - """Test to ensure that we can check if a given node is a - successor of a supplied node - """ - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].is_successor_of(game.root) - assert not game.root.is_successor_of(game.root.children["U1"]) - with pytest.raises(TypeError): - game.root.is_successor_of(9) - with pytest.raises(TypeError): - game.root.is_successor_of("Test") - with pytest.raises(TypeError): - game.root.is_successor_of("Player 1") - - -def _get_path_of_action_labels(node: gbt.Node) -> list[str]: - """ - Computes the path of action labels from a given node to the root. - Returns a list of strings. - """ - if not isinstance(node, gbt.Node): - raise TypeError(f"Input must be a pygambit.Node, but got {type(node).__name__}") - - path = [] - current_node = node - while current_node.parent: - path.append(current_node.prior_action.label) - current_node = current_node.parent - - return path - - -@dataclasses.dataclass -class SubgameRootsTestCase: - """TestCase for testing subgame root detection.""" - factory: typing.Callable[[], gbt.Game] - expected_paths: list[list[str]] - - -SUBGAME_ROOTS_CASES = [ - # ------------------------------------------------------------------------ - # Empty Game - # ------------------------------------------------------------------------ - pytest.param( - SubgameRootsTestCase(factory=gbt.Game.new_tree, expected_paths=[[]]), - id="empty_tree" - ), - # ------------------------------------------------------------------------ - # Perfect Information Games - # ------------------------------------------------------------------------ - pytest.param( - SubgameRootsTestCase( - factory=functools.partial(gbt.catalog.load, "journals/ijgt/selten1975/fig2"), - expected_paths=[[], ["L"], ["L", "L"]] - ), - id="centipede_3_rounds" - ), - pytest.param( - SubgameRootsTestCase( - factory=lambda: games.Centipede.get_test_data(N=5, m0=2, m1=7)[0], - expected_paths=[[], ["Push"], ["Push", "Push"], ["Push", "Push", "Push"], - ["Push", "Push", "Push", "Push"]] - ), - id="centipede_5_rounds" - ), - # ------------------------------------------------------------------------ - # Imperfect Information (No Absent-Mindedness) - # ------------------------------------------------------------------------ - pytest.param( - SubgameRootsTestCase( - factory=functools.partial(gbt.catalog.load, "journals/geb/wichardt2008"), - expected_paths=[[]] - ), - id="wichardt_no_nontrivial_subgames" - ), - pytest.param( - SubgameRootsTestCase( - factory=functools.partial(games.read_from_file, "binary_3_levels_generic_payoffs.efg"), - expected_paths=[[]] - ), - id="binary_3_levels_no_nontrivial_subgames" - ), - pytest.param( - SubgameRootsTestCase( - factory=functools.partial( - games.read_from_file, - "subgame_roots_finder_small_subgames_and_overplapping_infosets.efg"), - expected_paths=[[], ["1"], ["2"], ["1", "2", "2"], ["2", "1", "2"], - ["1", "1", "1", "2", "2"], ["2", "2", "2"]] - ), - id="small_subgames_and_overlapping_infosets_inside_subgames_no_Nature_moves" - ), - pytest.param( - SubgameRootsTestCase( - factory=functools.partial( - games.read_from_file, - "subgame_roots_finder_overplapping_infosets_with_Nature.efg"), - expected_paths=[[], ["1_2"], ["1_2", "1_3", "1_2"], ["1_3", "1_2"]] - ), - id="overlapping_infosets_inside_subgames_and_Nature_move" - ), - # ------------------------------------------------------------------------ - # Absent-Minded Games - # ------------------------------------------------------------------------ - pytest.param( - SubgameRootsTestCase( - factory=functools.partial(games.read_from_file, "AM-subgames.efg"), - expected_paths=[[], ["2"], ["1", "1"], ["2", "1"]] - ), - id="Absent-minded-game-with-paths-intersecting-infoset-two-times" - ), - pytest.param( - SubgameRootsTestCase( - factory=functools.partial(games.read_from_file, "noPR-action-AM-two-hops.efg"), - expected_paths=[[], ["2", "1", "1"]] - ), - id="Absent-minded-game-with-paths-intersecting-infoset-three-times" - ), - pytest.param( - SubgameRootsTestCase( - factory=functools.partial(games.read_from_file, "AM-unary-hops.efg"), - expected_paths=[[], ["1", "1"], ["T", "1", "1", "1", "1", "1"]] - ), - id="Absent-minded-game-with-paths-intersecting-infoset-two-times" - ), - pytest.param( - SubgameRootsTestCase( - factory=functools.partial(games.read_from_file, "AM-unary-branches.efg"), - expected_paths=[[], ["1", "1", "1", "T"]] - ), - id="Absent-minded-game-with-paths-intersecting-infoset-two-times" - ), -] - - -@pytest.mark.parametrize("test_case", SUBGAME_ROOTS_CASES) -def test_subgame_roots(test_case: SubgameRootsTestCase): - """ - Tests that the set of nodes marked as subgame roots matches the expected - set of paths (Action Labels from Root -> Node). - """ - game = test_case.factory() - - actual_roots = [node for node in game.nodes if node.is_subgame_root] - actual_paths = [_get_path_of_action_labels(node) for node in actual_roots] - - assert sorted(actual_paths) == sorted(test_case.expected_paths) - - -# ============================================================================ -# Subgame tree / GameSubgame -# ============================================================================ -@dataclasses.dataclass -class SubgameStructureTestCase: - """Expected subgame structure of a game. - - `roots` lists each subgame root as a node->root action-label path, in the - postorder `game.subgames` is expected to produce (children before parents). - - `parents` maps each subgame-root path to its expected parent path - (or None for the root subgame). - - `children` maps each subgame-root path to the set of its child subgame paths. - - `differences` maps each subgame-root path to the set of - (player_label, infoset_number) keys in that subgame's difference --- - the information sets belonging to the subgame but not to any child subgame. - """ - factory: typing.Callable[[], gbt.Game] - roots: list[list[str]] - parents: dict[tuple[str, ...], tuple[str, ...] | None] - children: dict[tuple[str, ...], set[tuple[str, ...]]] - differences: dict[tuple[str, ...], set[tuple[str, int]]] - - -SUBGAME_STRUCTURE_CASES = [ - # ------------------------------------------------------------------------ - # EF game with the only subgame - # ------------------------------------------------------------------------ - pytest.param( - SubgameStructureTestCase( - factory=functools.partial(gbt.catalog.load, "journals/geb/wichardt2008"), - roots=[[]], - parents={(): None}, - children={(): set()}, - differences={(): {("Player 1", 0), ("Player 1", 1), ("Player 2", 0)}}, - ), - id="wichardt_no_nontrivial_subgames", - ), - # ------------------------------------------------------------------------ - # Tree with eight subgames - # ------------------------------------------------------------------------ - pytest.param( - SubgameStructureTestCase( - factory=functools.partial(games.read_from_file, "subgame-8-roots.efg"), - roots=[ - ["L", "L", "L", "L", "L"], - ["R", "L", "L", "L", "L"], - ["L", "L", "L", "L"], - ["L", "L"], - ["R", "L"], - ["L"], - ["R"], - [], - ], - parents={ - ("L", "L", "L", "L", "L"): ("L", "L", "L", "L"), - ("R", "L", "L", "L", "L"): ("L", "L", "L", "L"), - ("L", "L", "L", "L"): ("L", "L"), - ("L", "L"): ("L",), - ("R", "L"): ("L",), - ("L",): (), - ("R",): (), - (): None, - }, - children={ - ("L", "L", "L", "L", "L"): set(), - ("R", "L", "L", "L", "L"): set(), - ("L", "L", "L", "L"): {("L", "L", "L", "L", "L"), - ("R", "L", "L", "L", "L")}, - ("L", "L"): {("L", "L", "L", "L")}, - ("R", "L"): set(), - ("L",): {("L", "L"), ("R", "L")}, - ("R",): set(), - (): {("L",), ("R",)}, - }, - differences={ - ("L", "L", "L", "L", "L"): { - ("Player 1", 3), ("Player 2", 2), ("Player 2", 3), - }, - ("R", "L", "L", "L", "L"): {("Player 1", 4), ("Player 1", 5)}, - ("L", "L", "L", "L"): {("Player 2", 1)}, - ("L", "L"): {("Player 1", 1), ("Player 1", 2)}, - ("R", "L"): {("Player 1", 6)}, - ("L",): {("Player 2", 0)}, - ("R",): { - ("Player 1", 7), ("Player 1", 8), ("Player 1", 9), - ("Player 2", 4), ("Player 2", 5), ("Player 2", 6), - }, - (): {("Player 1", 0)}, - }, - ), - id="eight_subgames", - ), -] - - -@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) -def test_subgames_postorder_sequence(test_case: SubgameStructureTestCase): - """`game.subgames` produces the expected postorder sequence of roots.""" - game = test_case.factory() - actual = [_get_path_of_action_labels(sg.root) for sg in game.subgames] - assert actual == test_case.roots - - -@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) -def test_subgame_parent_links(test_case: SubgameStructureTestCase): - """Each subgame's `parent` matches the expected parent path.""" - game = test_case.factory() - for sg in game.subgames: - path = tuple(_get_path_of_action_labels(sg.root)) - parent_path = ( - None if sg.parent is None - else tuple(_get_path_of_action_labels(sg.parent.root)) - ) - assert parent_path == test_case.parents[path] - - -@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) -def test_subgame_children(test_case: SubgameStructureTestCase): - """Each subgame's `children` match the expected set of child paths.""" - game = test_case.factory() - actual = { - tuple(_get_path_of_action_labels(sg.root)): - {tuple(_get_path_of_action_labels(c.root)) for c in sg.children} - for sg in game.subgames - } - assert actual == test_case.children - - -@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) -def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): - """`game.get_minimal_subgame(node)` returns the smallest subgame containing - the information set `node` belongs to.""" - game = test_case.factory() - expected_path_for_key = { - key: path - for path, keys in test_case.differences.items() - for key in keys - } - for player in game.players: - for node in game.get_infosets(player): - 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) - ) - assert actual_path == expected_path_for_key[key] - - -@pytest.mark.parametrize("game_file, expected_node_data", [ - ( - "binary_3_levels_generic_payoffs.efg", - [ - # Format: (Path in Node->Root order, (Player Label, Infoset Num, Action Label) or None) - ([], None), - (["Left"], None), - (["Left", "Left"], ("Player 1", 0, "Left")), - (["Right", "Left"], ("Player 1", 0, "Left")), - (["Right"], None), - (["Left", "Right"], ("Player 1", 0, "Right")), - (["Right", "Right"], ("Player 1", 0, "Right")), - ] - ), - ( - gbt.catalog.load("journals/geb/wichardt2008"), - [ - ([], None), - (["R"], ("Player 1", 0, "R")), - (["r", "R"], None), - (["l", "R"], None), - (["L"], ("Player 1", 0, "L")), - (["r", "L"], None), - (["l", "L"], None), - ] - ), - ( - "subgames.efg", - [ - ([], None), - (["1"], None), - (["2"], None), - (["1", "2"], ("Player 2", 0, "2")), - (["2", "1", "2"], ("Player 1", 1, "1")), - (["2", "2"], ("Player 2", 0, "2")), - (["1", "2", "2"], ("Player 2", 1, "1")), - (["1", "1", "2", "2"], ("Player 1", 1, "2")), - (["1", "1", "1", "2", "2"], ("Player 2", 2, "1")), - (["2", "1", "2", "2"], ("Player 1", 1, "2")), - (["1", "2", "1", "2", "2"], ("Player 2", 2, "2")), - (["2", "2", "1", "2", "2"], ("Player 2", 2, "2")), - (["1", "2", "2", "1", "2", "2"], ("Player 1", 4, "2")), - (["1", "1", "2", "2", "1", "2", "2"], ("Player 2", 4, "1")), - (["1", "1", "1", "2", "2", "1", "2", "2"], ("Player 1", 5, "1")), - (["2", "1", "1", "2", "2", "1", "2", "2"], ("Player 1", 5, "1")), - (["2", "2", "2", "1", "2", "2"], ("Player 1", 4, "2")), - (["2", "2", "2"], ("Player 1", 1, "2")), - ] - ), - ( - "AM-driver-subgame.efg", - [ - ([], None), - (["S"], ("Player 1", 0, "S")), - (["T", "S"], None), - ] - ), -]) -def test_node_own_prior_action_non_terminal(game_file, expected_node_data): - """ - Tests `node.own_prior_action` for non-terminal nodes. - Also verifies that all terminal nodes return None. - """ - game = game_file if isinstance(game_file, gbt.Game) else games.read_from_file(game_file) - - actual_node_data = [] - - for node in game.nodes: - if node.is_terminal: - assert node.own_prior_action is None, ( - f"Terminal node at {_get_path_of_action_labels(node)} must be None" - ) - else: - # Only collect data for non-terminal nodes - opa = node.own_prior_action - if opa is not None: - 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)) - - assert actual_node_data == expected_node_data - - -@pytest.mark.parametrize("game_file, expected_unreachable_paths", [ - # Games without absent-mindedness, where all nodes are reachable - (gbt.catalog.load("journals/geb/wichardt2008"), []), - ("subgames.efg", []), - - # An absent-minded driver game with an unreachable terminal node - ( - "AM-driver-one-infoset.efg", - [["T", "S"]] - ), - - # An absent-minded driver game with an unreachable subtree - ( - "AM-driver-subgame.efg", - [["T", "S"], ["r", "T", "S"], ["l", "T", "S"]] - ), -]) -def test_is_strategy_reachable(game_file: str, expected_unreachable_paths: list[list[str]]): - """ - Tests `node.is_strategy_reachable` by collecting all unreachable nodes, - converting them to their action-label paths, and comparing the resulting - list of paths against a known-correct list. - """ - game = game_file if isinstance(game_file, gbt.Game) else games.read_from_file(game_file) - nodes = game.nodes - - actual_unreachable_paths = [ - _get_path_of_action_labels(node) for node in nodes if not node.is_strategy_reachable - ] - - assert actual_unreachable_paths == expected_unreachable_paths - - def test_append_move_error_player_actions(): """Test to ensure there are actions when appending with a player""" game = games.read_from_file("basic_extensive_game.efg") @@ -1087,59 +538,6 @@ def test_len_after_copy_tree(): assert len(game.nodes) == initial_number_of_nodes + number_of_src_ancestors - 1 -def test_node_plays(): - """Verify `node.plays` returns plays reachable from a given node. - """ - game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - - test_node = game.root.children["L"] - - expected_set_of_plays = { - game.root.children["L"].children["R"], - game.root.children["L"].children["L"].children["r"], - game.root.children["L"].children["L"].children["l"], - } - - assert set(test_node.plays) == expected_set_of_plays - - -def test_node_children_action_label(): - """Label lookup returns the correct child. - - The RHS reaches the child positionally (independent of ``__getitem__``); a label - on both sides would make the assertion circular. - """ - game = games.read_from_file("stripped_down_poker.efg") - root_children = list(game.root.children) - assert game.root.children["King"] == root_children[0] - assert game.root.children["Queen"].children["Fold"] == list(root_children[1].children)[1] - - -def test_node_children_empty_label(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(ValueError, match="empty or all whitespace"): - _ = game.root.children[" "] - - -def test_node_children_terminal_node(): - game = games.read_from_file("stripped_down_poker.efg") - terminal = next(n for n in game.nodes if n.is_terminal) - with pytest.raises(KeyError, match="No action with label"): - _ = terminal.children["Bet"] - - -def test_node_children_nonexistent_action(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(KeyError, match="No action with label 'Jack'"): - _ = game.root.children["Jack"] - - -def test_node_children_rejects_int(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(TypeError, match="16.7.0"): - _ = game.root.children[0] - - @pytest.mark.parametrize("label", games.VALID_LABELS) def test_node_label_valid(label): game = games.read_from_file("basic_extensive_game.efg") @@ -1178,24 +576,526 @@ def test_node_label_unicode_accepted(label): assert game.root.label == label +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.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(): + """All nodes must be decision nodes.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(gbt.UndefinedOperationError): + game.make_infoset(gbt.H.path("U1", "U2", "U3"), game.root.player) + + +def test_make_infoset_converts_chance_node(): + """A chance node becomes a personal decision node, discarding its probabilities.""" + game = games.read_from_file("stripped_down_poker.efg") + chance_node = game.root # the deal is a chance move + personal = next(n for n in game.nodes if not n.is_terminal and n.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"]]) +def test_make_infoset_requires_matching_action_labels(node_actions): + """Nodes must have the same actions, with the same labels in the same order; + a matching count is not sufficient.""" + game = gbt.Game.new_tree(players=["1"]) + game.append_move(gbt.H.path(), "1", ["a", "b"]) + game.append_move(gbt.H.path("a"), "1", node_actions) + with pytest.raises(ValueError): + game.make_infoset(gbt.H.after().filter(lambda h: h[:] in ((), ("a",))), "1") + + +def test_make_infoset_empty_nodes_raises(): + """`nodes` must be nonempty.""" + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.make_infoset(gbt.H.path(...).filter(lambda h: False), game.root.player) + + +def test_make_infoset_strategic_game_raises(): + """`make_infoset` is only defined for games with a tree representation.""" + game = gbt.Game.new_table([2, 2]) + with pytest.raises(gbt.UndefinedOperationError): + game.make_infoset(gbt.H.path(), "1") + + +def test_set_move_actions_add_preserves_existing_action_order(): + """New actions may be declared at any position; the existing actions' relative + order is preserved.""" + game = games.read_from_file("basic_extensive_game.efg") + labels = list(game.root.actions) + game.set_move_actions(gbt.H.path(), labels + ["end"]) + assert list(game.root.actions)[:-1] == labels + game.set_move_actions(gbt.H.path(), ["front"] + labels + ["end"]) + assert list(game.root.actions)[1:-1] == labels + + @pytest.mark.parametrize( - "game_obj", + "inprobs,outprobs", [ - pytest.param(games.read_from_file("basic_extensive_game.efg")), - pytest.param(games.read_from_file("binary_3_levels_generic_payoffs.efg")), - pytest.param(games.read_from_file("cent3.efg")), - pytest.param(gbt.catalog.load("journals/ijgt/selten1975/fig1")), - pytest.param(gbt.catalog.load("journals/ijgt/selten1975/fig2")), - pytest.param(games.read_from_file("stripped_down_poker.efg")), - pytest.param(gbt.Game.new_tree()), + ({"King": "1/4", "Queen": "3/4"}, [gbt.Rational("1/4"), gbt.Rational("3/4")]), + ({"King": 0.75, "Queen": 0.25}, [0.75, 0.25]), + ({"King": 1}, [1, 0]), ], ) -def test_nodes_iteration_order(game_obj: gbt.Game): - """Verify that the C++ `game.nodes` iterator produces the DFS traversal. +def test_make_event_sets_probabilities(inprobs, outprobs): + """Probabilities are given as a mapping from action label to probability, + which may be sparse: an omitted action is assigned probability zero. + """ + game = games.read_from_file("stripped_down_poker.efg") + game.make_event(gbt.H.path(), inprobs, "Deal") + probs = game.root.action_probs + for action, prob in zip(game.root.actions, outprobs, strict=True): + assert probs[action] == prob + + +@pytest.mark.parametrize("probs", [["1/4", "3/4"], [0.75, 0.25]]) +def test_make_event_probs_not_a_mapping_raises_typeerror(probs): + """A positional sequence of probabilities is no longer accepted.""" + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(TypeError): + game.make_event(gbt.H.path(), probs, "Deal") + + +def test_make_event_pools_nodes_from_different_infosets(): + """Nodes in distinct information sets are formed into a single event.""" + game = games.read_from_file("stripped_down_poker.efg") + nodes = [game.root.children["King"], game.root.children["Queen"]] + game.make_event(gbt.H.path(...), {"Bet": "1/4", "Fold": "3/4"}, "Coin") + 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") + + +def test_make_event_requires_matching_action_labels(): + """Nodes must have the same actions, with the same labels in the same order.""" + game = games.read_from_file("stripped_down_poker.efg") + # King node has actions Bet, Fold; its own Bet-child has actions Call, Fold. + with pytest.raises(ValueError): + game.make_event( + gbt.H.after().filter(lambda h: h[:] in (("King",), ("King", "Bet"))), + {"Bet": "1/2", "Fold": "1/2"} + ) + + +def test_make_event_converts_personal_node(): + """A personal decision node becomes a chance node carrying the probabilities given.""" + game = games.read_from_file("stripped_down_poker.efg") + node = game.root.children["King"] + game.make_event(gbt.H.path("King"), {"Bet": "1/4", "Fold": "3/4"}) + assert node.player == "Chance" + assert list(node.action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] + + +def test_make_event_terminal_node_raises(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(gbt.UndefinedOperationError): + game.make_event(gbt.H.path("King", "Fold"), {"a": "1/2", "b": "1/2"}) + + +def test_make_event_strategic_game_raises(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(gbt.UndefinedOperationError): + game.make_event(gbt.H.path(), {"a": 1}) + + +def test_make_event_empty_nodes_raises(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(ValueError): + game.make_event(gbt.H.path(...).filter(lambda h: False), {"a": "1/2", "b": "1/2"}) + + +def test_make_event_label_held_by_rump_raises(): + """A label may be reused only if all members of the event holding it are absorbed.""" + game = games.read_from_file("stripped_down_poker.efg") + game.make_event(gbt.H.path(...), {"Bet": "1/2", "Fold": "1/2"}, "Coin") + before = game.to_efg() + with pytest.raises(ValueError): + game.make_event(gbt.H.path("King"), {"Bet": "1/2", "Fold": "1/2"}, "Coin") + assert game.to_efg() == before + + +def test_make_event_label_reused_when_fully_absorbed(): + """A label held by an existing event may be reused once all of that + event's members are absorbed into the new one; the old event is not left behind. + """ + game = games.read_from_file("stripped_down_poker.efg") + nodes = [game.root.children["King"], game.root.children["Queen"]] + 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] in nodes[1].members + assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] + + +@pytest.mark.parametrize( + "probs", [{"King": "3/4", "Queen": "-1/2"}, {"King": 0.75, "Queen": 0.40}, + {"King": "foo", "Queen": "bar"}] +) +def test_make_event_invalid_probs_raises(probs): + """Values must be numbers, non-negative, and sum to exactly one.""" + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(ValueError): + game.make_event(gbt.H.path(), probs) + + +def test_make_event_malformed_probs_raises(): + """An unknown action label as a mapping key raises KeyError.""" + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(KeyError): + game.make_event(gbt.H.path(), {"Jack": 1}) + + +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). + + Player 2 has two information sets -- one for each signal the chance move + can produce -- each with two members and actions ("S", "C"). Returns + (A, B, C, D) with {A, B} the members of one and {C, D} of the other. + """ + return (game.root.children["S"].children["s"], + game.root.children["C"].children["s"], + game.root.children["S"].children["c"], + game.root.children["C"].children["c"]) + + +def test_make_infoset_cherry_pick_leaves_rumps(): + """Partial consumption leaves the remainders behind.""" + game = gbt.catalog.load("journals/geb/bagwell1995") + A, B, C, D = _bagwell_p2_nodes(game) + game.make_infoset(games.selector_for_nodes([B, C]), "Player 2") + 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) + 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" + + +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) + 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 in B.members + assert C in D.members + + +def test_make_infoset_idempotent(): + """Repeating a call is a no-op: label reuse permits equality of membership.""" + game = gbt.catalog.load("journals/geb/bagwell1995") + A, B, C, D = _bagwell_p2_nodes(game) + game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "Z") + game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "Z") + assert B in C.members + + +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) + game.make_infoset(gbt.H.path("S", "s"), "Player 2") + assert list(A.members) == [A] + assert B not in A.members + + +def test_make_infoset_across_different_source_players(): + """Nodes drawn from different players all land under the target player.""" + game = gbt.Game.new_tree(players=["1", "2", "3"]) + game.append_move(gbt.H.path(), "1", ["a", "b"]) + game.append_move(gbt.H.path("a"), "2", ["a", "b"]) # player 2 + game.append_move(gbt.H.path("b"), "3", ["a", "b"]) # player 3 + n2 = game.root.children["a"] + n3 = game.root.children["b"] + assert n2.player == "2" + assert n3.player == "3" + game.make_infoset(gbt.H.path(...), "1") + assert n2 in n3.members + assert n2.player == "1" + assert n3.player == "1" + + +@pytest.mark.parametrize("label", games.VALID_LABELS) +def test_action_label(label: str): + game = games.create_stripped_down_poker_efg() + action = next(iter(game.root.actions)) + game.relabel_actions(gbt.H.path(), {action: label}) + assert label in game.root.actions + + +@pytest.mark.parametrize("label", games.INVALID_LABELS) +def test_action_label_invalid_raises_valueerror(label: str): + game = games.create_stripped_down_poker_efg() + action = next(iter(game.root.actions)) + with pytest.raises(ValueError): + game.relabel_actions(gbt.H.path(), {action: label}) + + +def test_relabel_action_empty_raises_valueerror(): + game = games.create_stripped_down_poker_efg() + action = next(iter(game.root.actions)) + with pytest.raises(ValueError): + game.relabel_actions(gbt.H.path(), {action: ""}) + + +def test_relabel_actions_duplicate_raises_valueerror(): + game = games.create_stripped_down_poker_efg() + with pytest.raises(ValueError): + game.relabel_actions(gbt.H.path(), {"King": "Queen"}) + + +def test_relabel_actions_simultaneous_swap(): + """Reassignment is simultaneous, so a swap is well-defined; applying the entries one + at a time would collide on the intermediate state. + """ + game = games.create_stripped_down_poker_efg() + game.relabel_actions(gbt.H.path(), {"King": "Queen", "Queen": "King"}) + assert list(game.root.actions) == ["Queen", "King"] + + +def test_relabel_actions_duplicate_targets_raises_valueerror(): + """Both replacements are free of the actions left untouched but collide with each + other, so checking each against the untouched actions alone would let this through. + """ + game = games.create_stripped_down_poker_efg() + with pytest.raises(ValueError): + game.relabel_actions(gbt.H.path(), {"King": "Ace", "Queen": "Ace"}) + + +def test_relabel_actions_unknown_label_raises_keyerror(): + game = games.create_stripped_down_poker_efg() + with pytest.raises(KeyError): + game.relabel_actions(gbt.H.path(), {"Jack": "Ace"}) + + +def test_relabel_actions_unknown_label_not_strict_is_ignored(): + game = games.create_stripped_down_poker_efg() + game.relabel_actions(gbt.H.path(), {"Jack": "Ace", "King": "Ace"}, strict=False) + assert list(game.root.actions) == ["Ace", "Queen"] + + +def test_relabel_actions_failure_leaves_game_unchanged(): + """The whole mapping is validated before any label is written, so a mapping that + fails part way through leaves no partial reassignment behind. + """ + game = games.create_stripped_down_poker_efg() + with pytest.raises(ValueError): + game.relabel_actions(gbt.H.path(), {"King": "Ace", "Queen": ""}) + assert list(game.root.actions) == ["King", "Queen"] + + +def test_relabel_actions_scope_is_the_information_set(): + """Action labels are unique within an information set, not within a player: Alice's + two information sets both offer "Bet", and relabelling one leaves the other untouched + and free to take the same new label. """ - def dfs(node: gbt.Node) -> typing.Iterator[gbt.Node]: - yield node - for child in node.children: - yield from dfs(child) + game = games.create_stripped_down_poker_efg() + king = games.find_infoset(game, "Alice", "Alice has King") + queen = games.find_infoset(game, "Alice", "Alice has Queen") + game.relabel_actions(games.selector_for_nodes([next(iter(king.members))]), {"Bet": "Raise"}) + assert list(king.actions) == ["Raise", "Fold"] + assert list(queen.actions) == ["Bet", "Fold"] + game.relabel_actions(games.selector_for_nodes([next(iter(queen.members))]), {"Bet": "Raise"}) + assert list(queen.actions) == ["Raise", "Fold"] + + +def test_relabel_actions_not_a_mapping_raises_typeerror(): + game = games.create_stripped_down_poker_efg() + with pytest.raises(TypeError): + game.relabel_actions(gbt.H.path(), [("King", "Queen")]) + + +@pytest.mark.parametrize("labels", [{1: "Queen"}, {"King": 1}]) +def test_relabel_actions_non_str_label_raises_typeerror(labels: dict): + game = games.create_stripped_down_poker_efg() + with pytest.raises(TypeError): + game.relabel_actions(gbt.H.path(), labels) + + +def test_set_move_actions_drop_shrinks_actions_and_children(): + game = games.create_stripped_down_poker_efg() + infoset = games.find_infoset(game, "Alice", "Alice has King") + node = next(iter(infoset.members)) + action_count = len(infoset.actions) + remaining = list(infoset.actions)[1:] + game.set_move_actions(games.selector_for_nodes([node]), remaining, drop=True) + assert len(infoset.actions) == action_count - 1 + assert len(node.children) == action_count - 1 + + +def test_set_move_actions_cannot_remove_the_only_action(): + game = games.create_stripped_down_poker_efg() + infoset = games.find_infoset(game, "Alice", "Alice has King") + node = next(iter(infoset.members)) + last = next(iter(infoset.actions)) + selector = games.selector_for_nodes([node]) + game.set_move_actions(selector, [last], drop=True) + assert list(infoset.actions) == [last] + with pytest.raises(gbt.UndefinedOperationError): + game.set_move_actions(selector, [], drop=True) + + +def test_set_move_actions_reorder_carries_subtrees(): + """Reordering three actions as a cycle moves every action to a new position. + Each action carries its whole subtree with it, at every member of the information set.""" + game = gbt.Game.new_tree(players=["Alice", "Bob"]) + game.append_move(gbt.H.path(), "Bob", ["x", "y"]) + game.append_move(gbt.H.path(...), "Alice", ["a", "b", "c"]) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in (("x", "a"), ("y", "b"))), + "Bob", ["l", "r"] + ) + infoset = game.root.children["x"] + members = list(infoset.members) + children_before = [{label: member.children[label] for label in ("a", "b", "c")} + for member in members] + game.set_move_actions(gbt.H.path("x"), ["c", "a", "b"]) + assert list(infoset.actions) == ["c", "a", "b"] + for member, children in zip(members, children_before, strict=True): + assert list(member.children) == [children["c"], children["a"], children["b"]] + + +def test_set_move_actions_add_drop_and_reorder_together(): + game = games.create_stripped_down_poker_efg() + infoset = games.find_infoset(game, "Alice", "Alice has King") + node = next(iter(infoset.members)) + nodes_before = len(game.nodes) + game.set_move_actions(games.selector_for_nodes([node]), ["Raise", "Fold"], drop=True) + assert list(infoset.actions) == ["Raise", "Fold"] + # "Bet" and its subtree (Bob's node and its two terminals) go; "Raise" adds one. + assert len(game.nodes) == nodes_before - 3 + 1 + # 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(): + game = games.create_stripped_down_poker_efg() + infoset = games.find_infoset(game, "Alice", "Alice has King") + node = next(iter(infoset.members)) + before = game.to_efg() + selector = games.selector_for_nodes([node]) + with pytest.raises(ValueError): + game.set_move_actions(selector, ["Bet"]) + with pytest.raises(ValueError): + game.set_move_actions(selector, ["Bet", "Fold", "Raise"], add=False) + assert game.to_efg() == before + - assert all(a == b for a, b in itertools.zip_longest(game_obj.nodes, dfs(game_obj.root))) +def test_set_move_actions_raises_at_an_event(): + """`set_move_actions` is only for a personal player's move; `set_event_actions` is the + corresponding operation for an event.""" + game = games.create_stripped_down_poker_efg() + with pytest.raises(ValueError): + game.set_move_actions(gbt.H.path(), ["King", "Queen"]) + + +@pytest.mark.parametrize("bad_labels", [["Bet", "Bet"], ["Bet", ""], ["Bet", " x"]]) +def test_set_move_actions_bad_labels_raise_and_leave_game_unchanged(bad_labels): + """Duplicate, empty, and invalid labels in `actions` are rejected in C++, + after the Python guards pass; the game must be unmodified by the failure.""" + game = games.create_stripped_down_poker_efg() + infoset = games.find_infoset(game, "Alice", "Alice has King") + node = next(iter(infoset.members)) + before = game.to_efg() + with pytest.raises(ValueError): + game.set_move_actions(games.selector_for_nodes([node]), bad_labels, drop=True) + assert game.to_efg() == before + + +def test_set_move_actions_absent_minded_drop_and_add(): + """Dropping an action whose subtree contains another member of the same information + set deletes that member with the subtree.""" + game = gbt.Game.new_tree(players=["Alice"]) + game.append_move(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.actions) == ["b", "c"] + assert len(game.root.members) == 1 + assert len(game.nodes) == 3 + + +def test_set_event_actions_reorder_carries_probabilities(): + game = games.create_stripped_down_poker_efg() + game.set_event_actions(gbt.H.path(), {"King": "3/4", "Queen": "1/4"}) + game.set_event_actions(gbt.H.path(), {"Queen": "1/4", "King": "3/4"}) + assert list(game.root.actions) == ["Queen", "King"] + assert game.root.action_probs == {"Queen": gbt.Rational(1, 4), "King": gbt.Rational(3, 4)} + + +def test_set_event_actions_add_with_probs_mapping(): + game = games.create_stripped_down_poker_efg() + nodes_before = len(game.nodes) + game.set_event_actions(gbt.H.path(), {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) + assert list(game.root.actions) == ["Jack", "King", "Queen"] + assert game.root.action_probs == { + "Jack": gbt.Rational(1, 2), "King": gbt.Rational(1, 4), "Queen": gbt.Rational(1, 4) + } + assert len(game.nodes) == nodes_before + 1 + + +def test_set_event_actions_drop_with_probs_mapping(): + game = games.create_stripped_down_poker_efg() + game.set_event_actions(gbt.H.path(), {"King": 1}, drop=True) + assert list(game.root.actions) == ["King"] + assert game.root.action_probs == {"King": 1} + + +def test_set_event_actions_unconfirmed_drop_and_disabled_add_raise(): + game = games.create_stripped_down_poker_efg() + before = game.to_efg() + with pytest.raises(ValueError): + game.set_event_actions(gbt.H.path(), {"King": 1}) + with pytest.raises(ValueError): + game.set_event_actions( + gbt.H.path(), {"King": "1/2", "Queen": "1/4", "Jack": "1/4"}, add=False + ) + assert game.to_efg() == before + + +def test_set_event_actions_raises_at_a_move(): + """`set_event_actions` is only for an event; `set_move_actions` is the corresponding + operation for a personal player's move.""" + game = games.create_stripped_down_poker_efg() + infoset = games.find_infoset(game, "Alice", "Alice has King") + with pytest.raises(ValueError): + game.set_event_actions( + games.selector_for_nodes([next(iter(infoset.members))]), {"Bet": 1} + ) + + +def test_set_event_actions_rejects_non_mapping_probs(): + """`probs` must be a mapping: with no separate list of actions, there's nothing for a + plain sequence of probabilities to be paired with positionally.""" + game = games.create_stripped_down_poker_efg() + before = game.to_efg() + with pytest.raises(TypeError): + game.set_event_actions(gbt.H.path(), ["3/4", "1/4"]) + assert game.to_efg() == before + + +def test_set_event_actions_bad_distribution_raises_valueerror(): + game = games.create_stripped_down_poker_efg() + before = game.to_efg() + with pytest.raises(ValueError): + game.set_event_actions(gbt.H.path(), {"King": "3/4", "Queen": "3/4"}) + assert game.to_efg() == before diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py new file mode 100644 index 0000000000..c3ad0d06bc --- /dev/null +++ b/tests/test_tree_queries.py @@ -0,0 +1,658 @@ +import dataclasses +import functools +import itertools +import typing + +import pytest + +import pygambit as gbt + +from . import games + + +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.actions + assert game.root.children["U1"].actions + with pytest.raises(AttributeError): + _ = game.root.children["U1"].children["D2"].children["U3"].actions + + +def test_get_player(): + """Test to ensure that we can retrieve a player for a given node""" + game = games.read_from_file("basic_extensive_game.efg") + assert game.root.player == "Player 1" + assert not game.root.children["U1"].children["D2"].children["U3"].player + + +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.player == "Chance" + + +def test_get_game(): + """Test to ensure that we can retrieve the game object from a given node""" + game = games.read_from_file("basic_extensive_game.efg") + assert game == game.root.game + + +def test_get_parent(): + """Test to ensure that we can retrieve a parent node for a given node""" + game = games.read_from_file("basic_extensive_game.efg") + assert game.root.children["U1"].parent == game.root + assert game.root.parent is None + + +def test_get_prior_action(): + """Test to ensure that we can retrieve the prior action for a given node""" + game = games.read_from_file("basic_extensive_game.efg") + assert game.root.children["U1"].prior_action == gbt.Branch(game.root, "U1") + assert game.root.prior_action is None + + +def test_is_terminal(): + """Test to ensure that we can check if a given node is a terminal node""" + game = games.read_from_file("basic_extensive_game.efg") + assert game.root.is_terminal is False + assert game.root.children["U1"].children["U2"].children["U3"].is_terminal is True + + +def test_is_successor_of(): + """Test to ensure that we can check if a given node is a + successor of a supplied node + """ + game = games.read_from_file("basic_extensive_game.efg") + assert game.root.children["U1"].is_successor_of(game.root) + assert not game.root.is_successor_of(game.root.children["U1"]) + with pytest.raises(TypeError): + game.root.is_successor_of(9) + with pytest.raises(TypeError): + game.root.is_successor_of("Test") + with pytest.raises(TypeError): + game.root.is_successor_of("Player 1") + + +def _get_path_of_action_labels(node: gbt.Node) -> list[str]: + """ + Computes the path of action labels from a given node to the root. + Returns a list of strings. + """ + if not isinstance(node, gbt.Node): + raise TypeError(f"Input must be a pygambit.Node, but got {type(node).__name__}") + + path = [] + current_node = node + while current_node.parent: + path.append(current_node.prior_action.label) + current_node = current_node.parent + + return path + + +@dataclasses.dataclass +class SubgameRootsTestCase: + """TestCase for testing subgame root detection.""" + factory: typing.Callable[[], gbt.Game] + expected_paths: list[list[str]] + + +SUBGAME_ROOTS_CASES = [ + # ------------------------------------------------------------------------ + # Empty Game + # ------------------------------------------------------------------------ + pytest.param( + SubgameRootsTestCase(factory=gbt.Game.new_tree, expected_paths=[[]]), + id="empty_tree" + ), + # ------------------------------------------------------------------------ + # Perfect Information Games + # ------------------------------------------------------------------------ + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(gbt.catalog.load, "journals/ijgt/selten1975/fig2"), + expected_paths=[[], ["L"], ["L", "L"]] + ), + id="centipede_3_rounds" + ), + pytest.param( + SubgameRootsTestCase( + factory=lambda: games.Centipede.get_test_data(N=5, m0=2, m1=7)[0], + expected_paths=[[], ["Push"], ["Push", "Push"], ["Push", "Push", "Push"], + ["Push", "Push", "Push", "Push"]] + ), + id="centipede_5_rounds" + ), + # ------------------------------------------------------------------------ + # Imperfect Information (No Absent-Mindedness) + # ------------------------------------------------------------------------ + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(gbt.catalog.load, "journals/geb/wichardt2008"), + expected_paths=[[]] + ), + id="wichardt_no_nontrivial_subgames" + ), + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(games.read_from_file, "binary_3_levels_generic_payoffs.efg"), + expected_paths=[[]] + ), + id="binary_3_levels_no_nontrivial_subgames" + ), + pytest.param( + SubgameRootsTestCase( + factory=functools.partial( + games.read_from_file, + "subgame_roots_finder_small_subgames_and_overplapping_infosets.efg"), + expected_paths=[[], ["1"], ["2"], ["1", "2", "2"], ["2", "1", "2"], + ["1", "1", "1", "2", "2"], ["2", "2", "2"]] + ), + id="small_subgames_and_overlapping_infosets_inside_subgames_no_Nature_moves" + ), + pytest.param( + SubgameRootsTestCase( + factory=functools.partial( + games.read_from_file, + "subgame_roots_finder_overplapping_infosets_with_Nature.efg"), + expected_paths=[[], ["1_2"], ["1_2", "1_3", "1_2"], ["1_3", "1_2"]] + ), + id="overlapping_infosets_inside_subgames_and_Nature_move" + ), + # ------------------------------------------------------------------------ + # Absent-Minded Games + # ------------------------------------------------------------------------ + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(games.read_from_file, "AM-subgames.efg"), + expected_paths=[[], ["2"], ["1", "1"], ["2", "1"]] + ), + id="Absent-minded-game-with-paths-intersecting-infoset-two-times" + ), + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(games.read_from_file, "noPR-action-AM-two-hops.efg"), + expected_paths=[[], ["2", "1", "1"]] + ), + id="Absent-minded-game-with-paths-intersecting-infoset-three-times" + ), + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(games.read_from_file, "AM-unary-hops.efg"), + expected_paths=[[], ["1", "1"], ["T", "1", "1", "1", "1", "1"]] + ), + id="Absent-minded-game-with-paths-intersecting-infoset-two-times" + ), + pytest.param( + SubgameRootsTestCase( + factory=functools.partial(games.read_from_file, "AM-unary-branches.efg"), + expected_paths=[[], ["1", "1", "1", "T"]] + ), + id="Absent-minded-game-with-paths-intersecting-infoset-two-times" + ), +] + + +@pytest.mark.parametrize("test_case", SUBGAME_ROOTS_CASES) +def test_subgame_roots(test_case: SubgameRootsTestCase): + """ + Tests that the set of nodes marked as subgame roots matches the expected + set of paths (Action Labels from Root -> Node). + """ + game = test_case.factory() + + actual_roots = [node for node in game.nodes if node.is_subgame_root] + actual_paths = [_get_path_of_action_labels(node) for node in actual_roots] + + assert sorted(actual_paths) == sorted(test_case.expected_paths) + + +# ============================================================================ +# Subgame tree / GameSubgame +# ============================================================================ +@dataclasses.dataclass +class SubgameStructureTestCase: + """Expected subgame structure of a game. + + `roots` lists each subgame root as a node->root action-label path, in the + postorder `game.subgames` is expected to produce (children before parents). + + `parents` maps each subgame-root path to its expected parent path + (or None for the root subgame). + + `children` maps each subgame-root path to the set of its child subgame paths. + + `differences` maps each subgame-root path to the set of + (player_label, infoset_number) keys in that subgame's difference --- + the information sets belonging to the subgame but not to any child subgame. + """ + factory: typing.Callable[[], gbt.Game] + roots: list[list[str]] + parents: dict[tuple[str, ...], tuple[str, ...] | None] + children: dict[tuple[str, ...], set[tuple[str, ...]]] + differences: dict[tuple[str, ...], set[tuple[str, int]]] + + +SUBGAME_STRUCTURE_CASES = [ + # ------------------------------------------------------------------------ + # EF game with the only subgame + # ------------------------------------------------------------------------ + pytest.param( + SubgameStructureTestCase( + factory=functools.partial(gbt.catalog.load, "journals/geb/wichardt2008"), + roots=[[]], + parents={(): None}, + children={(): set()}, + differences={(): {("Player 1", 0), ("Player 1", 1), ("Player 2", 0)}}, + ), + id="wichardt_no_nontrivial_subgames", + ), + # ------------------------------------------------------------------------ + # Tree with eight subgames + # ------------------------------------------------------------------------ + pytest.param( + SubgameStructureTestCase( + factory=functools.partial(games.read_from_file, "subgame-8-roots.efg"), + roots=[ + ["L", "L", "L", "L", "L"], + ["R", "L", "L", "L", "L"], + ["L", "L", "L", "L"], + ["L", "L"], + ["R", "L"], + ["L"], + ["R"], + [], + ], + parents={ + ("L", "L", "L", "L", "L"): ("L", "L", "L", "L"), + ("R", "L", "L", "L", "L"): ("L", "L", "L", "L"), + ("L", "L", "L", "L"): ("L", "L"), + ("L", "L"): ("L",), + ("R", "L"): ("L",), + ("L",): (), + ("R",): (), + (): None, + }, + children={ + ("L", "L", "L", "L", "L"): set(), + ("R", "L", "L", "L", "L"): set(), + ("L", "L", "L", "L"): {("L", "L", "L", "L", "L"), + ("R", "L", "L", "L", "L")}, + ("L", "L"): {("L", "L", "L", "L")}, + ("R", "L"): set(), + ("L",): {("L", "L"), ("R", "L")}, + ("R",): set(), + (): {("L",), ("R",)}, + }, + differences={ + ("L", "L", "L", "L", "L"): { + ("Player 1", 3), ("Player 2", 2), ("Player 2", 3), + }, + ("R", "L", "L", "L", "L"): {("Player 1", 4), ("Player 1", 5)}, + ("L", "L", "L", "L"): {("Player 2", 1)}, + ("L", "L"): {("Player 1", 1), ("Player 1", 2)}, + ("R", "L"): {("Player 1", 6)}, + ("L",): {("Player 2", 0)}, + ("R",): { + ("Player 1", 7), ("Player 1", 8), ("Player 1", 9), + ("Player 2", 4), ("Player 2", 5), ("Player 2", 6), + }, + (): {("Player 1", 0)}, + }, + ), + id="eight_subgames", + ), +] + + +@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) +def test_subgames_postorder_sequence(test_case: SubgameStructureTestCase): + """`game.subgames` produces the expected postorder sequence of roots.""" + game = test_case.factory() + actual = [_get_path_of_action_labels(sg.root) for sg in game.subgames] + assert actual == test_case.roots + + +@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) +def test_subgame_parent_links(test_case: SubgameStructureTestCase): + """Each subgame's `parent` matches the expected parent path.""" + game = test_case.factory() + for sg in game.subgames: + path = tuple(_get_path_of_action_labels(sg.root)) + parent_path = ( + None if sg.parent is None + else tuple(_get_path_of_action_labels(sg.parent.root)) + ) + assert parent_path == test_case.parents[path] + + +@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) +def test_subgame_children(test_case: SubgameStructureTestCase): + """Each subgame's `children` match the expected set of child paths.""" + game = test_case.factory() + actual = { + tuple(_get_path_of_action_labels(sg.root)): + {tuple(_get_path_of_action_labels(c.root)) for c in sg.children} + for sg in game.subgames + } + assert actual == test_case.children + + +@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) +def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): + """`game.get_minimal_subgame(node)` returns the smallest subgame containing + the information set `node` belongs to.""" + game = test_case.factory() + expected_path_for_key = { + key: path + for path, keys in test_case.differences.items() + for key in keys + } + for player in game.players: + for node in game.get_infosets(player): + 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) + ) + assert actual_path == expected_path_for_key[key] + + +@pytest.mark.parametrize("game_file, expected_node_data", [ + ( + "binary_3_levels_generic_payoffs.efg", + [ + # Format: (Path in Node->Root order, (Player Label, Infoset Num, Action Label) or None) + ([], None), + (["Left"], None), + (["Left", "Left"], ("Player 1", 0, "Left")), + (["Right", "Left"], ("Player 1", 0, "Left")), + (["Right"], None), + (["Left", "Right"], ("Player 1", 0, "Right")), + (["Right", "Right"], ("Player 1", 0, "Right")), + ] + ), + ( + gbt.catalog.load("journals/geb/wichardt2008"), + [ + ([], None), + (["R"], ("Player 1", 0, "R")), + (["r", "R"], None), + (["l", "R"], None), + (["L"], ("Player 1", 0, "L")), + (["r", "L"], None), + (["l", "L"], None), + ] + ), + ( + "subgames.efg", + [ + ([], None), + (["1"], None), + (["2"], None), + (["1", "2"], ("Player 2", 0, "2")), + (["2", "1", "2"], ("Player 1", 1, "1")), + (["2", "2"], ("Player 2", 0, "2")), + (["1", "2", "2"], ("Player 2", 1, "1")), + (["1", "1", "2", "2"], ("Player 1", 1, "2")), + (["1", "1", "1", "2", "2"], ("Player 2", 2, "1")), + (["2", "1", "2", "2"], ("Player 1", 1, "2")), + (["1", "2", "1", "2", "2"], ("Player 2", 2, "2")), + (["2", "2", "1", "2", "2"], ("Player 2", 2, "2")), + (["1", "2", "2", "1", "2", "2"], ("Player 1", 4, "2")), + (["1", "1", "2", "2", "1", "2", "2"], ("Player 2", 4, "1")), + (["1", "1", "1", "2", "2", "1", "2", "2"], ("Player 1", 5, "1")), + (["2", "1", "1", "2", "2", "1", "2", "2"], ("Player 1", 5, "1")), + (["2", "2", "2", "1", "2", "2"], ("Player 1", 4, "2")), + (["2", "2", "2"], ("Player 1", 1, "2")), + ] + ), + ( + "AM-driver-subgame.efg", + [ + ([], None), + (["S"], ("Player 1", 0, "S")), + (["T", "S"], None), + ] + ), +]) +def test_node_own_prior_action_non_terminal(game_file, expected_node_data): + """ + Tests `node.own_prior_action` for non-terminal nodes. + Also verifies that all terminal nodes return None. + """ + game = game_file if isinstance(game_file, gbt.Game) else games.read_from_file(game_file) + + actual_node_data = [] + + for node in game.nodes: + if node.is_terminal: + assert node.own_prior_action is None, ( + f"Terminal node at {_get_path_of_action_labels(node)} must be None" + ) + else: + # Only collect data for non-terminal nodes + opa = node.own_prior_action + if opa is not None: + 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)) + + assert actual_node_data == expected_node_data + + +@pytest.mark.parametrize("game_file, expected_unreachable_paths", [ + # Games without absent-mindedness, where all nodes are reachable + (gbt.catalog.load("journals/geb/wichardt2008"), []), + ("subgames.efg", []), + + # An absent-minded driver game with an unreachable terminal node + ( + "AM-driver-one-infoset.efg", + [["T", "S"]] + ), + + # An absent-minded driver game with an unreachable subtree + ( + "AM-driver-subgame.efg", + [["T", "S"], ["r", "T", "S"], ["l", "T", "S"]] + ), +]) +def test_is_strategy_reachable(game_file: str, expected_unreachable_paths: list[list[str]]): + """ + Tests `node.is_strategy_reachable` by collecting all unreachable nodes, + converting them to their action-label paths, and comparing the resulting + list of paths against a known-correct list. + """ + game = game_file if isinstance(game_file, gbt.Game) else games.read_from_file(game_file) + nodes = game.nodes + + actual_unreachable_paths = [ + _get_path_of_action_labels(node) for node in nodes if not node.is_strategy_reachable + ] + + assert actual_unreachable_paths == expected_unreachable_paths + + +def test_node_plays(): + """Verify `node.plays` returns plays reachable from a given node. + """ + game = gbt.catalog.load("journals/ijgt/selten1975/fig2") + + test_node = game.root.children["L"] + + expected_set_of_plays = { + game.root.children["L"].children["R"], + game.root.children["L"].children["L"].children["r"], + game.root.children["L"].children["L"].children["l"], + } + + assert set(test_node.plays) == expected_set_of_plays + + +def test_node_children_action_label(): + """Label lookup returns the correct child. + + The RHS reaches the child positionally (independent of ``__getitem__``); a label + on both sides would make the assertion circular. + """ + game = games.read_from_file("stripped_down_poker.efg") + root_children = list(game.root.children) + assert game.root.children["King"] == root_children[0] + assert game.root.children["Queen"].children["Fold"] == list(root_children[1].children)[1] + + +def test_node_children_empty_label(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(ValueError, match="empty or all whitespace"): + _ = game.root.children[" "] + + +def test_node_children_terminal_node(): + game = games.read_from_file("stripped_down_poker.efg") + terminal = next(n for n in game.nodes if n.is_terminal) + with pytest.raises(KeyError, match="No action with label"): + _ = terminal.children["Bet"] + + +def test_node_children_nonexistent_action(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(KeyError, match="No action with label 'Jack'"): + _ = game.root.children["Jack"] + + +def test_node_children_rejects_int(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(TypeError, match="16.7.0"): + _ = game.root.children[0] + + +def test_infoset_player_retrieval(): + game = games.read_from_file("basic_extensive_game.efg") + p1, *_ = game.players + assert p1 == game.root.player + + +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.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.members) == [node] + + +@pytest.mark.parametrize( + "game, player_label, strategy_label, infoset_path, expected_action_label", + [ + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 1", "1", [], "R"), + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 1", "2", [], "L"), + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 2", "1", ["R"], "R"), + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 2", "2", ["R"], "L"), + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 3", "1", ["R", "L"], "R"), + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 3", "2", ["R", "L"], "L"), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "1", [], "R"), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "2", [], "L"), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 2", "1", ["L"], "R"), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 2", "2", ["L"], "L"), + (games.read_from_file("basic_extensive_game.efg"), "Player 1", "1", [], "U1"), + (games.read_from_file("basic_extensive_game.efg"), "Player 1", "2", [], "D1"), + (games.read_from_file("basic_extensive_game.efg"), "Player 2", "1", ["U1"], "U2"), + (games.read_from_file("basic_extensive_game.efg"), "Player 2", "2", ["U1"], "D2"), + (games.read_from_file("basic_extensive_game.efg"), "Player 3", "1", ["U1", "U2"], "U3"), + (games.read_from_file("basic_extensive_game.efg"), "Player 3", "2", ["U1", "U2"], "D3"), + ], +) +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.""" + selector = gbt.H.path(*infoset_path) + + prescribed_action = game.get_behavior(player_label, strategy_label).get(selector) + + assert prescribed_action == expected_action_label + + +@pytest.mark.parametrize( + "game, player_label, strategy_label, infoset_label, infoset_path", + [ + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "1", None, ["L", "L"]), + (games.read_from_file("cent3.efg"), "Player 1", "1", "(1,3)", None), + (games.read_from_file("cent3.efg"), "Player 1", "1", "(1,5)", None), + (games.read_from_file("cent3.efg"), "Player 1", "2", "(1,5)", None), + (games.read_from_file("cent3.efg"), "Player 2", "1", "(2,4)", None), + (games.read_from_file("cent3.efg"), "Player 2", "1", "(2,4)", None), + (games.read_from_file("cent3.efg"), "Player 2", "2", "(2,5)", None), + ], +) +def test_get_behavior_prescribed_action_undefined_returns_none( + game, player_label, strategy_label, infoset_label, infoset_path +): + """Verify `Game.get_behavior` returns None when called on an unreached player's infoset""" + if infoset_label is not None: + node = next(iter(games.find_infoset_in_game(game, infoset_label).members)) + selector = games.selector_for_node(node) + else: + selector = gbt.H.path(*infoset_path) + + prescribed_action = game.get_behavior(player_label, strategy_label).get(selector) + + assert prescribed_action is None + + +@pytest.mark.parametrize( + "game, player_label, other_infoset_path", + [ + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 1", ["R"]), + (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 2", []), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", ["L"]), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 2", []), + (games.read_from_file("basic_extensive_game.efg"), "Player 1", ["U1"]), + (games.read_from_file("basic_extensive_game.efg"), "Player 2", ["U1", "U2"]), + (games.read_from_file("basic_extensive_game.efg"), "Player 3", []), + ], +) +def test_get_behavior_raises_value_error_for_wrong_player( + game, player_label, other_infoset_path +): + """ + Verify `Game.get_behavior`'s result raises ValueError when the infoset belongs + to a different player than the strategy. + """ + behavior = game.get_behavior(player_label, next(iter(game.get_strategies(player_label)))) + other_selector = gbt.H.path(*other_infoset_path) + + with pytest.raises(ValueError): + behavior.get(other_selector) + + +@pytest.mark.parametrize( + "game_obj", + [ + pytest.param(games.read_from_file("basic_extensive_game.efg")), + pytest.param(games.read_from_file("binary_3_levels_generic_payoffs.efg")), + pytest.param(games.read_from_file("cent3.efg")), + pytest.param(gbt.catalog.load("journals/ijgt/selten1975/fig1")), + pytest.param(gbt.catalog.load("journals/ijgt/selten1975/fig2")), + pytest.param(games.read_from_file("stripped_down_poker.efg")), + pytest.param(gbt.Game.new_tree()), + ], +) +def test_nodes_iteration_order(game_obj: gbt.Game): + """Verify that the C++ `game.nodes` iterator produces the DFS traversal. + """ + def dfs(node: gbt.Node) -> typing.Iterator[gbt.Node]: + yield node + for child in node.children: + yield from dfs(child) + + assert all(a == b for a, b in itertools.zip_longest(game_obj.nodes, dfs(game_obj.root))) From 58f43faf0ad97c34f1514a48d5b13213806702ee Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 16:17:49 +0100 Subject: [PATCH 05/24] Remove obsolete Node tests --- tests/test_tree_queries.py | 57 -------------------------------------- 1 file changed, 57 deletions(-) diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index c3ad0d06bc..5f07bf8aa5 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -33,12 +33,6 @@ def test_node_player_resolves_chance(): assert chance_node.player == "Chance" -def test_get_game(): - """Test to ensure that we can retrieve the game object from a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game == game.root.game - - def test_get_parent(): """Test to ensure that we can retrieve a parent node for a given node""" game = games.read_from_file("basic_extensive_game.efg") @@ -493,63 +487,12 @@ def test_node_plays(): assert set(test_node.plays) == expected_set_of_plays -def test_node_children_action_label(): - """Label lookup returns the correct child. - - The RHS reaches the child positionally (independent of ``__getitem__``); a label - on both sides would make the assertion circular. - """ - game = games.read_from_file("stripped_down_poker.efg") - root_children = list(game.root.children) - assert game.root.children["King"] == root_children[0] - assert game.root.children["Queen"].children["Fold"] == list(root_children[1].children)[1] - - -def test_node_children_empty_label(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(ValueError, match="empty or all whitespace"): - _ = game.root.children[" "] - - -def test_node_children_terminal_node(): - game = games.read_from_file("stripped_down_poker.efg") - terminal = next(n for n in game.nodes if n.is_terminal) - with pytest.raises(KeyError, match="No action with label"): - _ = terminal.children["Bet"] - - -def test_node_children_nonexistent_action(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(KeyError, match="No action with label 'Jack'"): - _ = game.root.children["Jack"] - - -def test_node_children_rejects_int(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(TypeError, match="16.7.0"): - _ = game.root.children[0] - - def test_infoset_player_retrieval(): game = games.read_from_file("basic_extensive_game.efg") p1, *_ = game.players assert p1 == game.root.player -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.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.members) == [node] - - @pytest.mark.parametrize( "game, player_label, strategy_label, infoset_path, expected_action_label", [ From 12aa69785ba0929ca5f78a0b5fadf484af083e97 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 16:29:16 +0100 Subject: [PATCH 06/24] Implement `Game.get_player` --- src/pygambit/game.pxi | 34 ++++++++++++++++++++++++++++++++++ tests/test_tree_queries.py | 33 +++++++++++++++++++++------------ 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 755f183b04..d9292ab40a 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -618,6 +618,40 @@ class Game: ) return self._get_histories(selector) + def get_player(self, history: Selector) -> str | None: + """Returns the label of the player associated with the node that + `history` resolves to: the one who makes the decision, if this is a + personal node, or the chance player, if this is an event. + + Parameters + ---------- + history : Selector + An `H`-built expression, evaluated against this game, that must + resolve to exactly one node. + + Returns + ------- + str or None + The label of the player who owns the node, or `None` if the node + is terminal, which has no player. + + .. versionadded:: 17.0.0 + + Raises + ------ + TypeError + If `history` is not a `Selector`. + ValueError + If `history` does not resolve to exactly one node. + """ + if not isinstance(history, Selector): + raise TypeError( + f"get_player(): history must be a Selector, not " + f"{history.__class__.__name__}" + ) + resolved_node = self._resolve_node(history, "get_player") + return resolved_node.player + def _group_nodes(self, grouped: GroupedSelector) -> dict: """Internal: like `_get_groups`, but keeps `Node` objects rather than materializing each into a `History` -- used by mutation methods that diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index 5f07bf8aa5..37c560e1ca 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -20,17 +20,32 @@ def test_node_actions_reflects_information_set(): def test_get_player(): - """Test to ensure that we can retrieve a player for a given node""" + """A personal node's player; a terminal node has none.""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.player == "Player 1" - assert not game.root.children["U1"].children["D2"].children["U3"].player + assert game.get_player(gbt.H.path()) == "Player 1" + assert game.get_player(gbt.H.path("U1", "D2", "U3")) is None -def test_node_player_resolves_chance(): +def test_get_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.player == "Chance" + assert game.get_player(gbt.H.path()) == "Chance" + + +def test_get_player_requires_selector(): + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(TypeError): + game.get_player(()) + with pytest.raises(TypeError): + game.get_player("U1") + + +def test_get_player_requires_single_match(): + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.get_player(gbt.H.path(...)) + with pytest.raises(ValueError): + game.get_player(gbt.H.path(...).filter(lambda h: False)) def test_get_parent(): @@ -487,12 +502,6 @@ def test_node_plays(): assert set(test_node.plays) == expected_set_of_plays -def test_infoset_player_retrieval(): - game = games.read_from_file("basic_extensive_game.efg") - p1, *_ = game.players - assert p1 == game.root.player - - @pytest.mark.parametrize( "game, player_label, strategy_label, infoset_path, expected_action_label", [ From 6b1ab8aa35965e1ee1964fdaecedb3069489337e Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 16:52:59 +0100 Subject: [PATCH 07/24] Remove redundant `Node.is_terminal` --- doc/pygambit.api.rst | 1 - src/pygambit/game.pxi | 47 +++++++++++++++++++++++++++++++++--- src/pygambit/node.pxi | 8 +++--- tests/games.py | 6 ++--- tests/test_behav.py | 4 +-- tests/test_tree_mutations.py | 12 ++++----- tests/test_tree_queries.py | 37 +++++++++++++++++----------- 7 files changed, 82 insertions(+), 33 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 394edde66c..c0c21fd956 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -134,7 +134,6 @@ Information about the game Node.children Node.parent Node.is_subgame_root - Node.is_terminal Node.is_strategy_reachable Node.prior_action Node.members diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index d9292ab40a..c66e0407c0 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -652,6 +652,45 @@ class Game: resolved_node = self._resolve_node(history, "get_player") return resolved_node.player + def get_actions(self, history: Selector) -> list[str]: + """Returns the labels of the actions available at the node that + `history` resolves to, in the order they are defined. + + Parameters + ---------- + history : Selector + An `H`-built expression, evaluated against this game, that must + resolve to exactly one node. + + Returns + ------- + list of str + The labels of the actions at the node's current information set + or event, or an empty list if the node is terminal -- a node is + terminal exactly when this is empty. + + .. versionadded:: 17.0.0 + + Raises + ------ + TypeError + If `history` is not a `Selector`. + ValueError + If `history` does not resolve to exactly one node. + """ + if not isinstance(history, Selector): + raise TypeError( + f"get_actions(): history must be a Selector, not " + f"{history.__class__.__name__}" + ) + resolved_node = self._resolve_node(history, "get_actions") + infoset_handle: c_GameInfoset = cython.cast(Node, resolved_node)._infoset_handle() + if infoset_handle == cython.cast(c_GameInfoset, NULL): + return [] + return [ + a.deref().GetLabel().decode("utf-8") for a in infoset_handle.deref().GetActions() + ] + def _group_nodes(self, grouped: GroupedSelector) -> dict: """Internal: like `_get_groups`, but keeps `Node` objects rather than materializing each into a `History` -- used by mutation methods that @@ -2085,7 +2124,7 @@ class Game: ) resolved_src = cython.cast(Node, self._resolve_node(src, "copy_tree", "src")) resolved_dest = cython.cast(Node, self._resolve_node(dest, "copy_tree", "dest")) - if not resolved_dest.is_terminal: + if not cython.cast(Node, resolved_dest)._is_terminal(): raise UndefinedOperationError("copy_tree(): `dest` must be a terminal node.") self.game.deref().CopyTree(resolved_dest.node, resolved_src.node) @@ -2125,7 +2164,7 @@ class Game: ) resolved_src = cython.cast(Node, self._resolve_node(src, "move_tree", "src")) resolved_dest = cython.cast(Node, self._resolve_node(dest, "move_tree", "dest")) - if not resolved_dest.is_terminal: + if not cython.cast(Node, resolved_dest)._is_terminal(): raise UndefinedOperationError("move_tree(): `dest` must be a terminal node.") if resolved_dest.is_successor_of(resolved_src): raise UndefinedOperationError("move_tree(): `dest` cannot be a successor of `src`.") @@ -2438,7 +2477,7 @@ class Game: f"make_event(): probs must be a mapping, not {probs.__class__.__name__}" ) resolved_nodes = self._resolve_nodes(nodes, "make_event") - if any(n.is_terminal for n in resolved_nodes): + if any(cython.cast(Node, n)._is_terminal() for n in resolved_nodes): raise UndefinedOperationError( "make_event(): all nodes must be nonterminal" ) @@ -2598,7 +2637,7 @@ class Game: resolved_nodes = self._resolve_nodes(nodes, "make_infoset") resolved_player = self._resolve_player(player, "make_infoset") for n in resolved_nodes: - if n.is_terminal: + if cython.cast(Node, n)._is_terminal(): raise UndefinedOperationError( "make_infoset(): all nodes must be decision nodes" ) diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 3626df53fa..39f79335b2 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -383,9 +383,11 @@ class Node: cur = cur.deref().GetParent() return Branch(Node.wrap(cur.deref().GetParent()), label) - @property - def is_terminal(self) -> bool: - """Returns whether this is a terminal node of the game.""" + @cython.cfunc + def _is_terminal(self) -> cython.bint: + """Whether this is a terminal node of the game. Not part of the public + API; a node is terminal exactly when `Game.get_actions` is empty for it. + """ return self.node.deref().IsTerminal() @property diff --git a/tests/games.py b/tests/games.py index a9d3c4233f..372006e5e0 100644 --- a/tests/games.py +++ b/tests/games.py @@ -386,7 +386,7 @@ def bet(player, payoffs, pot): (-2, 2): "BOb wins 2", } nodes_by_payoff = {payoffs: [] for payoffs in payoff_labels} - for term_node in [n for n in g.nodes if n.is_terminal]: + for term_node in [n for n in g.nodes if not n.children]: nodes_by_payoff[calculate_payoffs(term_node)].append(term_node) for payoffs, nodes in nodes_by_payoff.items(): @@ -453,7 +453,7 @@ def get_path(node): tmp = "wins" if winner == "Bob" else "loses" nodes_by_key[f"Bob calls and {tmp}"].append(n) - for term_node in [n for n in g.nodes if n.is_terminal]: + for term_node in [n for n in g.nodes if not n.children]: collect_nodes(term_node) for key, nodes in nodes_by_key.items(): @@ -816,7 +816,7 @@ def gbt_game(self): ) self.create_binary_tree(g, g.root, (), 0, 0, self.level) for n in g.nodes: - if not n.is_terminal and not n.children["L"].is_terminal: + if n.children and n.children["L"].children: left = n.children["L"] g.make_infoset( selector_for_nodes(list(left.members) + [n.children["R"]]), diff --git a/tests/test_behav.py b/tests/test_behav.py index 892fbdf480..a61001396f 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -931,7 +931,7 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): ) for node in game.nodes: - if node.is_terminal: + if not node.children: continue if infoset_probs[node] == 0: assert beliefs[node] is None @@ -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 node.player == "Chance": + if not node.children or node.player == "Chance": continue expected_val = 0 node_prob = realiz_probs[node] diff --git a/tests/test_tree_mutations.py b/tests/test_tree_mutations.py index 9aa34721fb..778b7d3145 100644 --- a/tests/test_tree_mutations.py +++ b/tests/test_tree_mutations.py @@ -86,12 +86,12 @@ def _subtrees_equal( recursion_stop_node: gbt.Node | None = None ) -> bool: if n1 == recursion_stop_node: - return n2.is_terminal - if n1.is_terminal and n2.is_terminal: + return not n2.children + if not n1.children and not n2.children: if not n1.outcome and not n2.outcome: return True return n1.outcome == n2.outcome - if n1.is_terminal is not n2.is_terminal: + if bool(n1.children) != bool(n2.children): return False # now, both n1 and n2 are non-terminal # check that they are in the same infosets @@ -283,7 +283,7 @@ def test_append_event_creates_single_event_list_of_nodes(): {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 2)} ) assert node1 in node2.members - assert not node1.is_terminal + assert node1.children def test_append_event_sets_distribution(): @@ -388,7 +388,7 @@ def _count_subtree_nodes(start_node: gbt.Node, count_terminal: bool) -> int: count_terminal: bool Include or exclude terminal nodes from count """ - count = 1 if count_terminal or not start_node.is_terminal else 0 + count = 1 if count_terminal or start_node.children else 0 for child in start_node.children: count += _count_subtree_nodes(child, count_terminal) @@ -598,7 +598,7 @@ 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.player != "Chance") + personal = next(n for n in game.nodes if n.children and n.player != "Chance") game.make_infoset(gbt.H.path(), personal.player) assert chance_node.player != "Chance" assert chance_node.player == personal.player diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index 37c560e1ca..bf71d6ef04 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -10,13 +10,29 @@ from . import games -def test_node_actions_reflects_information_set(): - """A node has actions iff it currently belongs to an information set or event.""" +def test_get_actions(): + """A personal or chance node's actions, in order; a terminal node has none -- + a node is terminal exactly when this is empty.""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.actions - assert game.root.children["U1"].actions - with pytest.raises(AttributeError): - _ = game.root.children["U1"].children["D2"].children["U3"].actions + assert game.get_actions(gbt.H.path()) == ["U1", "D1"] + assert game.get_actions(gbt.H.path("U1")) == ["U2", "D2"] + assert game.get_actions(gbt.H.path("U1", "D2", "U3")) == [] + + +def test_get_actions_requires_selector(): + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(TypeError): + game.get_actions(()) + with pytest.raises(TypeError): + game.get_actions("U1") + + +def test_get_actions_requires_single_match(): + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.get_actions(gbt.H.path(...)) + with pytest.raises(ValueError): + game.get_actions(gbt.H.path(...).filter(lambda h: False)) def test_get_player(): @@ -62,13 +78,6 @@ def test_get_prior_action(): assert game.root.prior_action is None -def test_is_terminal(): - """Test to ensure that we can check if a given node is a terminal node""" - game = games.read_from_file("basic_extensive_game.efg") - assert game.root.is_terminal is False - assert game.root.children["U1"].children["U2"].children["U3"].is_terminal is True - - def test_is_successor_of(): """Test to ensure that we can check if a given node is a successor of a supplied node @@ -437,7 +446,7 @@ def test_node_own_prior_action_non_terminal(game_file, expected_node_data): actual_node_data = [] for node in game.nodes: - if node.is_terminal: + if not node.children: assert node.own_prior_action is None, ( f"Terminal node at {_get_path_of_action_labels(node)} must be None" ) From b52baaa6ca80f5838bac82c12157716497c777e3 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 17:14:27 +0100 Subject: [PATCH 08/24] Remove plays from public node interface --- doc/pygambit.api.rst | 1 - src/pygambit/game.pxi | 8 ++++++-- src/pygambit/node.pxi | 8 +++++--- tests/test_hsel.py | 10 ++++++++++ tests/test_tree_queries.py | 16 ---------------- 5 files changed, 21 insertions(+), 22 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index c0c21fd956..4eaa5f699d 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -141,7 +141,6 @@ Information about the game Node.action_probs Node.player Node.is_successor_of - Node.plays Node.own_prior_action .. autosummary:: diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index c66e0407c0..11f0004d16 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -568,7 +568,9 @@ class Game: else [node.children[step] for node in current] ) elif isinstance(op, _PlaysStep): - current = [play for node in current for play in node.plays] + current = [ + play for node in current for play in cython.cast(Node, node)._plays() + ] elif isinstance(op, _FilterStep): current = [ node for node in current @@ -713,7 +715,9 @@ class Game: next_result: dict = {} for key, nodes in result.items(): if isinstance(op, _PlaysStep): - expanded = [play for node in nodes for play in node.plays] + expanded = [ + play for node in nodes for play in cython.cast(Node, node)._plays() + ] if grouped.recall_player is None: next_result[key] = expanded else: diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 39f79335b2..b9f487f9d5 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -432,9 +432,11 @@ class Node: """ return NodeOutcome.wrap(self.node) - @property - def plays(self) -> list[Node]: - """Returns a list of all terminal `Node` objects consistent with it. + @cython.cfunc + def _plays(self) -> list: + """The terminal nodes consistent with this node. Not part of the public + API; the public equivalent is a `Selector`'s `.plays` step, e.g. + `game.get_histories(H.path(...).plays)`. """ return [Node.wrap(n) for n in self.node.deref().GetGame().deref().GetPlays(self.node)] diff --git a/tests/test_hsel.py b/tests/test_hsel.py index f2adf3a10c..fb07e81836 100644 --- a/tests/test_hsel.py +++ b/tests/test_hsel.py @@ -83,6 +83,16 @@ def test_get_histories_multiple(): ] +def test_get_histories_plays_from_non_root(): + """`.plays` chained after a path prefix is the terminal frontier reachable + from that point, not from the whole game.""" + game = gbt.catalog.load("journals/ijgt/selten1975/fig2") + + assert set(game.get_histories(gbt.H.path("L").plays)) == { + ("L", "R"), ("L", "L", "r"), ("L", "L", "l"), + } + + def test_get_histories_empty(): game = gbt.Game.new_tree(players=["A"]) game.append_move(gbt.H.path(), "A", ["U", "D"]) diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index bf71d6ef04..2d02d56598 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -495,22 +495,6 @@ def test_is_strategy_reachable(game_file: str, expected_unreachable_paths: list[ assert actual_unreachable_paths == expected_unreachable_paths -def test_node_plays(): - """Verify `node.plays` returns plays reachable from a given node. - """ - game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - - test_node = game.root.children["L"] - - expected_set_of_plays = { - game.root.children["L"].children["R"], - game.root.children["L"].children["L"].children["r"], - game.root.children["L"].children["L"].children["l"], - } - - assert set(test_node.plays) == expected_set_of_plays - - @pytest.mark.parametrize( "game, player_label, strategy_label, infoset_path, expected_action_label", [ From 81fcd479d305b0ce3b9c840a96071c581ead6795 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 18:52:30 +0100 Subject: [PATCH 09/24] Remove plays from node --- doc/pygambit.api.rst | 1 - src/pygambit/behavmixed.pxi | 21 ++++++-- src/pygambit/catalog.py | 2 +- src/pygambit/gambit.pyx | 11 +++-- src/pygambit/game.pxi | 37 +++++--------- src/pygambit/gamecollections.pxi | 33 ------------- tests/games.py | 17 +++++-- tests/test_behav.py | 68 ++++++++++++-------------- tests/test_catalog.py | 2 +- tests/test_game_resolve.py | 2 +- tests/test_hsel.py | 8 ++++ tests/test_strategic_queries.py | 6 --- tests/test_tree_mutations.py | 82 ++++++++++++++++---------------- tests/test_tree_queries.py | 15 +++--- 14 files changed, 144 insertions(+), 161 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 4eaa5f699d..e0e10d9370 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -111,7 +111,6 @@ Information about the game Game.get_events Game.get_strategies Game.get_sequences - Game.nodes Game.contingencies Game.get_outcome Game.get_payoffs diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index ec05dda958..51f5e98ea8 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -717,10 +717,15 @@ class MixedBehaviorProfile: def node_values(self) -> NodeValuesVector: """Returns the expected payoff to each player conditional on play reaching each node, if all players play according to the profile, grouped by player. + + .. versionchanged:: 17.0.0 + Keyed by each node's History rather than a ``Node`` object. """ self._check_validity() return NodeValuesVector({ - p: NodeValueVector({n: self._node_value(p, n) for n in self.game.nodes}) + p: NodeValueVector({ + _history_of(n): self._node_value(p, n) for n in self.game._all_nodes() + }) for p in self.game.players }) @@ -768,9 +773,14 @@ class MixedBehaviorProfile: def realiz_probs(self) -> RealizProbVector: """Returns the probability with which each node is reached, if all players play according to the profile. + + .. versionchanged:: 17.0.0 + Keyed by each node's History rather than a ``Node`` object. """ self._check_validity() - return RealizProbVector({n: self._realiz_prob(n) for n in self.game.nodes}) + return RealizProbVector({ + _history_of(n): self._realiz_prob(n) for n in self.game._all_nodes() + }) @property def infoset_probs(self) -> InfosetProbVector: @@ -816,9 +826,14 @@ class MixedBehaviorProfile: See Also -------- MixedBehaviorProfile.infoset_probs + + .. versionchanged:: 17.0.0 + Keyed by each node's History rather than a ``Node`` object. """ self._check_validity() - return BeliefVector({n: self._belief(n) for n in self.game.nodes}) + return BeliefVector({ + _history_of(n): self._belief(n) for n in self.game._all_nodes() + }) @property def action_regrets(self) -> ActionRegretsVector: diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 714503d4d6..36163763cc 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -447,7 +447,7 @@ def check_filters(game: gbt.Game) -> bool: if n_nodes is not None: if not game.is_tree: return False - if len(game.nodes) != n_nodes: + if len(game.get_histories(gbt.H.after())) != n_nodes: return False if n_outcomes is not None and len(game.outcomes) != n_outcomes: return False diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index aa1862bb73..248c8cfa68 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -172,11 +172,16 @@ class StrategyIndexedVector(_LabeledVector): @cython.cclass class NodeIndexedVector(_LabeledVector): - """A read-only mapping from a ``Node`` to a computed value, one entry per node. + """A read-only mapping from a node's History to a computed value, one entry + per node. Unlike ``PlayerIndexedVector``/``StrategyIndexedVector``, which are keyed by a stable - label, this is keyed by node identity: the value can genuinely differ between two - nodes, even nodes belonging to the same information set. + label, this is keyed by the node's own History -- a plain tuple of action labels + from the root, unique to that node -- since the value can genuinely differ between + two nodes, even nodes belonging to the same information set. + + .. versionchanged:: 17.0.0 + Keyed by a node's History rather than a ``Node`` object. """ _label_kind = "node" diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 11f0004d16..53fa466408 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -501,27 +501,6 @@ class Game: """The set of outcomes in the game.""" return GameOutcomes.wrap(self.game) - @property - def nodes(self) -> GameNodes: - """The set of nodes in the game. - - Iteration over this property yields the nodes in the order of depth-first search. - - .. versionchanged:: 16.4 - Changed from a method ``nodes()`` to a property. - - Raises - ------ - UndefinedOperationError - If the game does not have a tree representation. - """ - if not self.is_tree: - raise UndefinedOperationError( - "Operation only defined for games with a tree representation" - ) - - return GameNodes.wrap(self.game) - @property def contingencies(self) -> pygambit.gameiter.Contingencies: """An iterator over the contingencies in the game.""" @@ -542,6 +521,16 @@ class Game: ) return Node.wrap(self.game.deref().GetRoot()) + def _all_nodes(self) -> list: + """All nodes in the game, in depth-first traversal order. Not part of + the public API; the public equivalent is `Game.get_histories(H.after())`. + """ + if not self.is_tree: + raise UndefinedOperationError( + "Operation only defined for games with a tree representation" + ) + return [Node.wrap(node) for node in self.game.deref().GetNodes()] + def _get_nodes(self, selector: Selector) -> list[Node]: """Evaluate `selector` (an `H`-built expression) against this game. @@ -555,7 +544,7 @@ class Game: current: list = None for op in selector._ops: if isinstance(op, _AfterStep): - candidates = list(self.nodes) if current is None else current + candidates = self._all_nodes() if current is None else current current = [n for n in candidates if _matches_suffix(n, op.labels)] continue if current is None: @@ -1559,7 +1548,7 @@ class Game: raise ValueError( f"{funcname}(): {argname} cannot be an empty string or all spaces" ) - for n in self.nodes: + for n in self._all_nodes(): if n.label == node: return n raise KeyError(f"{funcname}(): no node with label '{node}'") @@ -3142,7 +3131,7 @@ class NodeCoordinates: def _layout_tree(game: Game) -> dict[Node, NodeCoordinates]: layout = CreateLayout(game.game) data = {} - for node in game.nodes: + for node in game._all_nodes(): data[node] = NodeCoordinates(deref(layout).GetNodeLevel(cython.cast(Node, node).node), deref(layout).GetNodeSublevel(cython.cast(Node, node).node), deref(layout).GetNodeOffset(cython.cast(Node, node).node)) diff --git a/src/pygambit/gamecollections.pxi b/src/pygambit/gamecollections.pxi index e4363f3d61..76f0f399b3 100644 --- a/src/pygambit/gamecollections.pxi +++ b/src/pygambit/gamecollections.pxi @@ -21,39 +21,6 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # -@cython.cclass -class GameNodes: - """Represents the set of nodes in a game.""" - game = cython.declare(c_Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameNodes outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: c_Game) -> GameNodes: - obj: GameNodes = GameNodes.__new__(GameNodes) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameNodes(game={Game.wrap(self.game)})" - - def __len__(self) -> int: - """The number of nodes in the game.""" - if not self.game.deref().IsTree(): - return 0 - return self.game.deref().NumNodes() - - def __iter__(self) -> typing.Iterator[Node]: - """Iterate over the game nodes in the depth-first traversal order.""" - if not self.game.deref().IsTree(): - return - - for node in self.game.deref().GetNodes(): - yield Node.wrap(node) - - @cython.cclass class GameSubgames: """Represents the set of subgames in a game.""" diff --git a/tests/games.py b/tests/games.py index 372006e5e0..74fa6b9dda 100644 --- a/tests/games.py +++ b/tests/games.py @@ -18,6 +18,17 @@ def all_infosets(game: gbt.Game) -> list[gbt.Node]: return [n for p in game.players for n in game.get_infosets(p)] +def all_nodes(game: gbt.Game) -> list[gbt.Node]: + """Every node in the game, in depth-first traversal order, matching + `Game.nodes` before its removal (17.0.0). Built from `.root`/`.children`, + the surviving Node-navigation primitives.""" + def _walk(node: gbt.Node): + yield node + for child in node.children: + yield from _walk(child) + return list(_walk(game.root)) + + 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).""" @@ -386,7 +397,7 @@ def bet(player, payoffs, pot): (-2, 2): "BOb wins 2", } nodes_by_payoff = {payoffs: [] for payoffs in payoff_labels} - for term_node in [n for n in g.nodes if not n.children]: + for term_node in [n for n in all_nodes(g) if not n.children]: nodes_by_payoff[calculate_payoffs(term_node)].append(term_node) for payoffs, nodes in nodes_by_payoff.items(): @@ -453,7 +464,7 @@ def get_path(node): tmp = "wins" if winner == "Bob" else "loses" nodes_by_key[f"Bob calls and {tmp}"].append(n) - for term_node in [n for n in g.nodes if not n.children]: + for term_node in [n for n in all_nodes(g) if not n.children]: collect_nodes(term_node) for key, nodes in nodes_by_key.items(): @@ -815,7 +826,7 @@ def gbt_game(self): title=f"Binary Tree Game (L={self.level})", ) self.create_binary_tree(g, g.root, (), 0, 0, self.level) - for n in g.nodes: + for n in all_nodes(g): if n.children and n.children["L"].children: left = n.children["L"] g.make_infoset( diff --git a/tests/test_behav.py b/tests/test_behav.py index a61001396f..392355a44d 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -674,14 +674,10 @@ def test_as_float_is_independent_copy(): def test_realiz_prob_nodes_reference( game: gbt.Game, path: list[str], realiz_prob: str | float, rational_flag: bool ): - # nodes have no labels, so each node is reached by walking the action-label - # path from the root (an empty path is the root itself) + # a node's History is the tuple of action labels from the root (empty for the root) profile = game.mixed_behavior_profile(rational=rational_flag) realiz_prob = gbt.Rational(realiz_prob) if rational_flag else realiz_prob - node = game.root - for action_label in path: - node = node.children[action_label] - assert profile.realiz_probs[node] == realiz_prob + assert profile.realiz_probs[tuple(path)] == realiz_prob @pytest.mark.parametrize( @@ -746,7 +742,7 @@ def test_nature_rooted_game_root_reached_with_certainty(rational_flag: bool): game = gbt.catalog.load("journals/geb/gilboa1997/fig2") profile = game.mixed_behavior_profile(rational=rational_flag) one = gbt.Rational(1) if rational_flag else 1.0 - assert profile.realiz_probs[game.root] == one + assert profile.realiz_probs[()] == one assert profile.infoset_probs[game.root] == one @@ -913,7 +909,7 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): for player in game.players: player_node_values = node_values[player] assert isinstance(player_node_values, gbt.NodeValueVector) - assert player_node_values[game.root] == payoffs[player] + assert player_node_values[()] == payoffs[player] for infoset in games.player_infosets(game, player): node = next(iter(infoset.members)) @@ -930,17 +926,18 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): == best_response_value - infoset_action_values[action] ) - for node in game.nodes: + for node in games.all_nodes(game): if not node.children: continue + history = games._node_history(node) if infoset_probs[node] == 0: - assert beliefs[node] is None + assert beliefs[history] is None else: - assert beliefs[node] is not None + assert beliefs[history] is not None # equal to an equivalent plain dict or same-type vector, but never to a vector of a # different quantity, even where the underlying numbers happen to coincide - expected = {n: realiz_probs[n] for n in game.nodes} + expected = dict(realiz_probs) assert realiz_probs == expected assert realiz_probs == gbt.RealizProbVector(expected) assert realiz_probs != beliefs @@ -1036,16 +1033,16 @@ def test_martingale_property_of_node_value(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) realiz_probs = profile.realiz_probs node_values = profile.node_values - for node in game.nodes: + for node in games.all_nodes(game): if not node.children or node.player == "Chance": continue expected_val = 0 - node_prob = realiz_probs[node] + node_prob = realiz_probs[games._node_history(node)] player_node_values = node_values[node.player] for child in node.children: - prob = realiz_probs[child] / node_prob - expected_val += prob * player_node_values[child] - assert player_node_values[node] == expected_val + prob = realiz_probs[games._node_history(child)] / node_prob + expected_val += prob * player_node_values[games._node_history(child)] + assert player_node_values[games._node_history(node)] == expected_val @pytest.mark.parametrize( @@ -1064,7 +1061,7 @@ def test_node_value_consistency(game: gbt.Game, rational_flag: bool): node_values = profile.node_values payoffs = profile.payoffs for player in game.players: - assert node_values[player][game.root] == payoffs[player] + assert node_values[player][()] == payoffs[player] @pytest.mark.parametrize( @@ -1361,15 +1358,11 @@ def test_node_belief_reference( value: str | float, rational_flag: bool, ): - # nodes have no labels, so each belief node is reached by walking the - # action-label path from the root (an empty path is the root itself) + # a node's History is the tuple of action labels from the root (empty for the root) profile = game.mixed_behavior_profile(rational=rational_flag) _set_action_probs(profile, probs, rational_flag) - node = game.root - for action_label in path: - node = node.children[action_label] value = gbt.Rational(value) if rational_flag else value - assert abs(profile.beliefs[node] - value) <= tol + assert abs(profile.beliefs[tuple(path)] - value) <= tol @pytest.mark.parametrize( @@ -1475,7 +1468,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1483,7 +1476,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ( games.create_stripped_down_poker_efg(), @@ -1491,7 +1484,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ( games.create_stripped_down_poker_efg(), @@ -1499,7 +1492,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ###################################################################################### # realiz_prob (at nodes) @@ -1509,7 +1502,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1517,7 +1510,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ( games.create_stripped_down_poker_efg(), @@ -1525,7 +1518,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ( games.create_stripped_down_poker_efg(), @@ -1533,7 +1526,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: x.get_histories(gbt.H.after()), ), ###################################################################################### # infoset_prob @@ -1679,7 +1672,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, x.get_histories(gbt.H.after()))), ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1687,7 +1680,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, x.get_histories(gbt.H.after()))), ), ( games.create_stripped_down_poker_efg(), @@ -1695,7 +1688,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, x.get_histories(gbt.H.after()))), ), ( games.create_stripped_down_poker_efg(), @@ -1703,7 +1696,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, x.get_histories(gbt.H.after()))), ), ###################################################################################### # agent_liap_value (of profile, hence [1] for objects_to_test, @@ -1996,9 +1989,10 @@ def test_undefined_belief(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") *_, p3 = game.players node = next(iter(games.player_infosets(game, p3)[0].members)) + history = games._node_history(node) for rat in [False, True]: profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) - assert profile.beliefs[node] is None + assert profile.beliefs[history] is None def test_undefined_infoset_value(): diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 662d7090b5..2169830ed9 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -138,7 +138,7 @@ def test_catalog_games_filter_n_nodes(all_games): assert len(filtered_games) < len(all_games) if len(filtered_games) > 0: g = gbt.catalog.load(filtered_games.Game.iloc[0]) - assert len(g.nodes) == 5 + assert len(g.get_histories(gbt.H.after())) == 5 def test_catalog_games_filter_n_outcomes(all_games): diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index b59a47f1dd..17559932a4 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -32,7 +32,7 @@ def _test_valid_resolutions(collection: list, resolver: typing.Callable) -> None ] ) def test_resolve_node(game: gbt.Game) -> None: - _test_valid_resolutions(game.nodes, + _test_valid_resolutions(games.all_nodes(game), lambda label, fn: game._resolve_node(label, fn)) diff --git a/tests/test_hsel.py b/tests/test_hsel.py index fb07e81836..c8e82975e9 100644 --- a/tests/test_hsel.py +++ b/tests/test_hsel.py @@ -100,6 +100,14 @@ def test_get_histories_empty(): assert game.get_histories(gbt.H.after("nonexistent")) == [] +def test_get_histories_after_strategic_game_raises(): + """`H.after()`, used bare, enumerates every node -- the replacement for the + removed `Game.nodes` -- so it inherits the same tree-only restriction.""" + game = gbt.Game.new_table([2, 2]) + with pytest.raises(gbt.UndefinedOperationError): + game.get_histories(gbt.H.after()) + + def test_get_histories_requires_selector(): game = gbt.Game.new_tree(players=["A"]) game.append_move(gbt.H.path(), "A", ["U", "D"]) diff --git a/tests/test_strategic_queries.py b/tests/test_strategic_queries.py index 693c80dc66..7767489814 100644 --- a/tests/test_strategic_queries.py +++ b/tests/test_strategic_queries.py @@ -18,12 +18,6 @@ def test_strategic_game_root(): _ = game.root -def test_strategic_game_nodes(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.nodes - - def test_game_behav_profile_error(): game = gbt.Game.new_table([2, 2]) with pytest.raises(gbt.UndefinedOperationError): diff --git a/tests/test_tree_mutations.py b/tests/test_tree_mutations.py index 778b7d3145..db8a481b93 100644 --- a/tests/test_tree_mutations.py +++ b/tests/test_tree_mutations.py @@ -395,37 +395,40 @@ def _count_subtree_nodes(start_node: gbt.Node, count_terminal: bool) -> int: return count +def _n_nodes(game: gbt.Game) -> int: + """The number of nodes in the game -- a stand-in for `len(Game.nodes)`, + removed since `Game.nodes` was.""" + return len(game.get_histories(gbt.H.after())) + + def test_len_matches_expected_node_count(): - """Verify `len(game.nodes)` matches expected node count - """ + """Verify the node count matches expectations.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") expected_node_count = 9 - direct_len = len(game.nodes) + direct_len = _n_nodes(game) assert direct_len == expected_node_count assert direct_len == _count_subtree_nodes(game.root, True) def test_len_after_delete_tree(): - """Verify `len(game.nodes)` is correct after `delete_tree`. - """ + """Verify the node count is correct after `delete_tree`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = _n_nodes(game) root_of_the_deleted_subtree = game.root.children["R"].children["L"] number_of_deleted_nodes = _count_subtree_nodes(root_of_the_deleted_subtree, True) - 1 game.delete_tree(gbt.H.path("R", "L")) - assert len(game.nodes) == initial_number_of_nodes - number_of_deleted_nodes + assert _n_nodes(game) == initial_number_of_nodes - number_of_deleted_nodes def test_len_after_delete_parent(): - """Verify `len(game.nodes)` is correct after `delete_parent`. - """ + """Verify the node count is correct after `delete_parent`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = _n_nodes(game) node_parent_to_delete = game.root.children["L"].children["L"] @@ -435,51 +438,50 @@ def test_len_after_delete_parent(): game.delete_parent(gbt.H.path("L", "L")) - assert len(game.nodes) == initial_number_of_nodes - diff + assert _n_nodes(game) == initial_number_of_nodes - diff def test_len_after_append_move(): - """Verify `len(game.nodes)` is correct after `append_move`.""" + """Verify the node count is correct after `append_move`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = _n_nodes(game) player = "Player 1" actions_to_add = ["T", "M", "B"] game.append_move(gbt.H.path("R", "L", "L"), player, actions_to_add) # the [1,1,0] terminal - assert len(game.nodes) == initial_number_of_nodes + len(actions_to_add) + assert _n_nodes(game) == initial_number_of_nodes + len(actions_to_add) def test_len_after_append_infoset(): - """Verify `len(game.nodes)` is correct after `append_infoset`. - """ + """Verify the node count is correct after `append_infoset`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = _n_nodes(game) 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")) - assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions + assert _n_nodes(game) == initial_number_of_nodes + number_of_infoset_actions def test_len_after_set_move_actions_add(): - """Verify `len(game.nodes)` is correct after `set_move_actions` creates an action.""" + """Verify the node count is correct after `set_move_actions` creates an action.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = _n_nodes(game) 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"]) - assert len(game.nodes) == initial_number_of_nodes + num_nodes_in_infoset + assert _n_nodes(game) == initial_number_of_nodes + num_nodes_in_infoset def test_len_after_set_move_actions_drop(): - """Verify `len(game.nodes)` is correct after `set_move_actions` deletes an action.""" + """Verify the node count is correct after `set_move_actions` deletes an action.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = _n_nodes(game) action_to_drop = "L" nodes_to_delete = sum( _count_subtree_nodes(member.children[action_to_drop], True) @@ -487,20 +489,20 @@ def test_len_after_set_move_actions_drop(): ) 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 + assert _n_nodes(game) == initial_number_of_nodes - nodes_to_delete def test_len_after_insert_move(): - """Verify `len(game.nodes)` is correct after `insert_move`.""" + """Verify the node count is correct after `insert_move`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = _n_nodes(game) player = "Player 2" actions_to_add = ["a", "b", "c"] game.insert_move(gbt.H.path("L", "R"), player, actions_to_add) # the [1, 0] node - assert len(game.nodes) == initial_number_of_nodes + len(actions_to_add) + assert _n_nodes(game) == initial_number_of_nodes + len(actions_to_add) def test_insert_move_actions_labeled(): @@ -512,30 +514,28 @@ def test_insert_move_actions_labeled(): def test_len_after_insert_infoset(): - """Verify `len(game.nodes)` is correct after `insert_infoset`. - """ + """Verify the node count is correct after `insert_infoset`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = _n_nodes(game) 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")) - assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions + assert _n_nodes(game) == initial_number_of_nodes + number_of_infoset_actions def test_len_after_copy_tree(): - """Verify `len(game.nodes)` is correct after `copy_tree`. - """ + """Verify the node count is correct after `copy_tree`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = _n_nodes(game) src_node = game.root.children["R"].children["L"] number_of_src_ancestors = _count_subtree_nodes(src_node, True) game.copy_tree(gbt.H.path("R", "L"), gbt.H.path("R", "R")) - assert len(game.nodes) == initial_number_of_nodes + number_of_src_ancestors - 1 + assert _n_nodes(game) == initial_number_of_nodes + number_of_src_ancestors - 1 @pytest.mark.parametrize("label", games.VALID_LABELS) @@ -598,7 +598,7 @@ 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 n.children and n.player != "Chance") + personal = next(n for n in games.all_nodes(game) if n.children and n.player != "Chance") game.make_infoset(gbt.H.path(), personal.player) assert chance_node.player != "Chance" assert chance_node.player == personal.player @@ -978,11 +978,11 @@ def test_set_move_actions_add_drop_and_reorder_together(): game = games.create_stripped_down_poker_efg() infoset = games.find_infoset(game, "Alice", "Alice has King") node = next(iter(infoset.members)) - nodes_before = len(game.nodes) + nodes_before = _n_nodes(game) game.set_move_actions(games.selector_for_nodes([node]), ["Raise", "Fold"], drop=True) assert list(infoset.actions) == ["Raise", "Fold"] # "Bet" and its subtree (Bob's node and its two terminals) go; "Raise" adds one. - assert len(game.nodes) == nodes_before - 3 + 1 + assert _n_nodes(game) == nodes_before - 3 + 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 @@ -1031,7 +1031,7 @@ def test_set_move_actions_absent_minded_drop_and_add(): game.set_move_actions(gbt.H.path(), ["b", "c"], drop=True) assert list(game.root.actions) == ["b", "c"] assert len(game.root.members) == 1 - assert len(game.nodes) == 3 + assert _n_nodes(game) == 3 def test_set_event_actions_reorder_carries_probabilities(): @@ -1044,13 +1044,13 @@ def test_set_event_actions_reorder_carries_probabilities(): def test_set_event_actions_add_with_probs_mapping(): game = games.create_stripped_down_poker_efg() - nodes_before = len(game.nodes) + nodes_before = _n_nodes(game) game.set_event_actions(gbt.H.path(), {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) assert list(game.root.actions) == ["Jack", "King", "Queen"] assert game.root.action_probs == { "Jack": gbt.Rational(1, 2), "King": gbt.Rational(1, 4), "Queen": gbt.Rational(1, 4) } - assert len(game.nodes) == nodes_before + 1 + assert _n_nodes(game) == nodes_before + 1 def test_set_event_actions_drop_with_probs_mapping(): diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index 2d02d56598..9f9946a04e 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -1,6 +1,5 @@ import dataclasses import functools -import itertools import typing import pytest @@ -221,7 +220,7 @@ def test_subgame_roots(test_case: SubgameRootsTestCase): """ game = test_case.factory() - actual_roots = [node for node in game.nodes if node.is_subgame_root] + actual_roots = [node for node in games.all_nodes(game) if node.is_subgame_root] actual_paths = [_get_path_of_action_labels(node) for node in actual_roots] assert sorted(actual_paths) == sorted(test_case.expected_paths) @@ -445,7 +444,7 @@ def test_node_own_prior_action_non_terminal(game_file, expected_node_data): actual_node_data = [] - for node in game.nodes: + for node in games.all_nodes(game): if not node.children: assert node.own_prior_action is None, ( f"Terminal node at {_get_path_of_action_labels(node)} must be None" @@ -486,7 +485,7 @@ def test_is_strategy_reachable(game_file: str, expected_unreachable_paths: list[ list of paths against a known-correct list. """ game = game_file if isinstance(game_file, gbt.Game) else games.read_from_file(game_file) - nodes = game.nodes + nodes = games.all_nodes(game) actual_unreachable_paths = [ _get_path_of_action_labels(node) for node in nodes if not node.is_strategy_reachable @@ -592,12 +591,14 @@ def test_get_behavior_raises_value_error_for_wrong_player( pytest.param(gbt.Game.new_tree()), ], ) -def test_nodes_iteration_order(game_obj: gbt.Game): - """Verify that the C++ `game.nodes` iterator produces the DFS traversal. +def test_get_histories_after_iteration_order(game_obj: gbt.Game): + """`Game.get_histories(H.after())` -- the public replacement for the removed + `Game.nodes` -- produces nodes in depth-first traversal order. """ def dfs(node: gbt.Node) -> typing.Iterator[gbt.Node]: yield node for child in node.children: yield from dfs(child) - assert all(a == b for a, b in itertools.zip_longest(game_obj.nodes, dfs(game_obj.root))) + expected = [games._node_history(node) for node in dfs(game_obj.root)] + assert game_obj.get_histories(gbt.H.after()) == expected From 3cccb794d5bc2f5abc2b693b84a17a61679447a7 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 19:01:38 +0100 Subject: [PATCH 10/24] Implement .get_action_probs() and .get_members() --- src/pygambit/game.pxi | 82 ++++++++++++++++++++++++++++++++++++++ tests/test_tree_queries.py | 54 +++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 53fa466408..1c152961c5 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -682,6 +682,88 @@ class Game: a.deref().GetLabel().decode("utf-8") for a in infoset_handle.deref().GetActions() ] + def get_action_probs(self, history: Selector) -> dict[str, decimal.Decimal | Rational]: + """Returns the probability of each action at the node that `history` + resolves to, keyed by label, if it currently belongs to a chance event. + + Parameters + ---------- + history : Selector + An `H`-built expression, evaluated against this game, that must + resolve to exactly one node. + + Returns + ------- + dict of str to Decimal or Rational + The probability of each action, keyed by label, or an empty dict + if the node does not currently belong to a chance event -- + including a terminal node, or a personal player's node. + + .. versionadded:: 17.0.0 + + Raises + ------ + TypeError + If `history` is not a `Selector`. + ValueError + If `history` does not resolve to exactly one node. + """ + if not isinstance(history, Selector): + raise TypeError( + f"get_action_probs(): history must be a Selector, not " + f"{history.__class__.__name__}" + ) + resolved_node = self._resolve_node(history, "get_action_probs") + infoset_handle: c_GameInfoset = cython.cast(Node, resolved_node)._infoset_handle() + if ( + infoset_handle == cython.cast(c_GameInfoset, NULL) + or not infoset_handle.deref().IsChanceInfoset() + ): + return {} + result: dict = {} + for a in infoset_handle.deref().GetActions(): + result[a.deref().GetLabel().decode("utf-8")] = _decode_prob( + cython.cast(string, infoset_handle.deref().GetActionProb(a)) + ) + return result + + def get_members(self, history: Selector) -> list[tuple]: + """Returns the Histories of the nodes which are members of the + information set or event that the node identified by `history` + currently belongs to. + + Parameters + ---------- + history : Selector + An `H`-built expression, evaluated against this game, that must + resolve to exactly one node. + + Returns + ------- + list of tuple + The Histories of the member nodes, or an empty list if the node + is currently terminal (belongs to no information set or event). + + .. versionadded:: 17.0.0 + + Raises + ------ + TypeError + If `history` is not a `Selector`. + ValueError + If `history` does not resolve to exactly one node. + """ + if not isinstance(history, Selector): + raise TypeError( + f"get_members(): history must be a Selector, not " + f"{history.__class__.__name__}" + ) + resolved_node = self._resolve_node(history, "get_members") + infoset_handle: c_GameInfoset = cython.cast(Node, resolved_node)._infoset_handle() + if infoset_handle == cython.cast(c_GameInfoset, NULL): + return [] + return [_history_of(Node.wrap(member)) for member in infoset_handle.deref().GetMembers()] + def _group_nodes(self, grouped: GroupedSelector) -> dict: """Internal: like `_get_groups`, but keeps `Node` objects rather than materializing each into a `History` -- used by mutation methods that diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index 9f9946a04e..a1eb4a1a64 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -34,6 +34,60 @@ def test_get_actions_requires_single_match(): game.get_actions(gbt.H.path(...).filter(lambda h: False)) +def test_get_action_probs(): + """A chance node's action probabilities, keyed by label; empty for a + personal node or a terminal node -- a node currently belongs to a chance + event exactly when this is nonempty.""" + game = games.read_from_file("stripped_down_poker.efg") + assert game.get_action_probs(gbt.H.path()) == { + "King": gbt.Rational(1, 2), "Queen": gbt.Rational(1, 2) + } + assert game.get_action_probs(gbt.H.path("King")) == {} + assert game.get_action_probs(gbt.H.path("King", "Fold")) == {} + + +def test_get_action_probs_requires_selector(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(TypeError): + game.get_action_probs(()) + with pytest.raises(TypeError): + game.get_action_probs("King") + + +def test_get_action_probs_requires_single_match(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(ValueError): + game.get_action_probs(gbt.H.path(...)) + with pytest.raises(ValueError): + game.get_action_probs(gbt.H.path(...).filter(lambda h: False)) + + +def test_get_members(): + """The Histories of the nodes sharing a node's current information set or + event, including a shared infoset across the members of different chance + outcomes; empty for a terminal node.""" + game = games.read_from_file("stripped_down_poker.efg") + assert game.get_members(gbt.H.path()) == [()] + assert game.get_members(gbt.H.path("King", "Bet")) == [("King", "Bet"), ("Queen", "Bet")] + assert game.get_members(gbt.H.path("King", "Fold")) == [] + + +def test_get_members_requires_selector(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(TypeError): + game.get_members(()) + with pytest.raises(TypeError): + game.get_members("King") + + +def test_get_members_requires_single_match(): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(ValueError): + game.get_members(gbt.H.path(...)) + with pytest.raises(ValueError): + game.get_members(gbt.H.path(...).filter(lambda h: False)) + + def test_get_player(): """A personal node's player; a terminal node has none.""" game = games.read_from_file("basic_extensive_game.efg") From 6f3298e02506233c0db02322d4f773f60a365375 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 19:23:18 +0100 Subject: [PATCH 11/24] Implement `Game.get_outcome` on selector --- src/pygambit/game.pxi | 68 +++++++++++++++++++++++---------- tests/test_strategic_queries.py | 6 ++- tests/test_tree_queries.py | 24 ++++++++++++ 3 files changed, 76 insertions(+), 22 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 1c152961c5..ab57fe4d67 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1044,45 +1044,73 @@ class Game: deref(deref(psp).deref()).SetStrategy(handle) return psp - def get_outcome(self, contingency: typing.Mapping) -> Outcome: - """Returns the `Outcome` attached to a pure-strategy contingency. + def get_outcome(self, location) -> Outcome | str | None: + """Returns the outcome attached at `location`. - Only defined for games in strategic (table) representation; for extensive-form - and action-graph games, a pure-strategy contingency has no single stored outcome - to return (see `get_payoffs`). + For a tree game, `location` is a `Selector` (an `H`-built expression, + evaluated against this game) that must resolve to exactly one node; + the label of the outcome currently attached there is returned. + + For a strategic (table) game, `location` is a pure-strategy + contingency -- a complete mapping from the game's players' labels to + the label of the strategy played by that player; the `Outcome` + attached to it is returned. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + For a tree game, `location` may now be a `Selector`, returning + the outcome's label (or `None`) directly, rather than raising + `UndefinedOperationError`. + Parameters ---------- - contingency : Mapping - A complete mapping from the game's players' labels to the label of the - strategy played by that player. + location : Selector or Mapping + A `Selector` resolving to a single node (tree game), or a + pure-strategy contingency (strategic game). Returns ------- - Outcome - The outcome attached to `contingency` (possibly the null outcome). + Outcome, str, or None + For a strategic game, the `Outcome` attached to `location` + (possibly the null outcome). For a tree game, the label of the + outcome currently attached to the resolved node, or `None` if it + has none. Raises ------ - UndefinedOperationError - If the game is not in strategic (table) representation. + TypeError + If `location` is not a `Selector` (tree game); or is not a + mapping, or a key or value is not a `str` (strategic game). ValueError - If `contingency` does not specify exactly one strategy for each player - of the game, or a key is an empty or all-whitespace string. + For a tree game, if `location` does not resolve to exactly one + node. For a strategic game, if `location` does not specify + exactly one strategy for each player of the game, or a key is an + empty or all-whitespace string. KeyError - If a player label, or a player's strategy label, does not match any - player, or that player's strategies, in the game. - TypeError - If `contingency` is not a mapping, or a key or value is not a `str`. + For a strategic game, if a player label, or a player's strategy + label, does not match any player, or that player's strategies, + in the game. + UndefinedOperationError + If the game is in neither a tree nor a strategic (table) + representation. """ - if self.is_tree or self.game.deref().IsAgg(): + if self.is_tree: + if not isinstance(location, Selector): + raise TypeError( + f"get_outcome(): location must be a Selector, not " + f"{location.__class__.__name__}" + ) + resolved_node = self._resolve_node(location, "get_outcome") + return Outcome.wrap( + cython.cast(Node, resolved_node).node.deref().GetOutcome() + ).label + if self.game.deref().IsAgg(): raise UndefinedOperationError( "get_outcome(): operation not defined for games not in " "strategic (table) representation" ) - resolved = self._resolve_contingency(contingency, "get_outcome") + resolved = self._resolve_contingency(location, "get_outcome") psp = self._make_pure_strategy_profile(resolved) return Outcome.wrap(deref(deref(psp).deref()).GetOutcome()) diff --git a/tests/test_strategic_queries.py b/tests/test_strategic_queries.py index 7767489814..861c5eba03 100644 --- a/tests/test_strategic_queries.py +++ b/tests/test_strategic_queries.py @@ -99,10 +99,12 @@ def test_game_get_outcome_unmatched_label_after_relabel_raises(): _ = game.get_outcome({pl1: "defect", pl2: "defect"}) -def test_game_get_outcome_tree_raises(): +def test_game_get_outcome_tree_rejects_contingency(): + """A pure-strategy contingency (a Mapping) is only meaningful for a + strategic game; for a tree game, `location` must be a `Selector`.""" game = gbt.Game.new_tree(["Alice"]) game.append_move(gbt.H.path(), "Alice", ["a", "b"]) - with pytest.raises(gbt.UndefinedOperationError): + with pytest.raises(TypeError): _ = game.get_outcome({"Alice": "a"}) diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index a1eb4a1a64..cf43cce61e 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -9,6 +9,30 @@ from . import games +def test_get_outcome(): + """A tree game's `get_outcome` resolves a `Selector` to a single node and + returns the label of the outcome attached there, or `None` if it has none.""" + game = games.read_from_file("basic_extensive_game.efg") + assert game.get_outcome(gbt.H.path("U1", "D2", "U3")) == "Outcome 1" + assert game.get_outcome(gbt.H.path()) is None + + +def test_get_outcome_requires_selector(): + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(TypeError): + game.get_outcome(()) + with pytest.raises(TypeError): + game.get_outcome("U1") + + +def test_get_outcome_requires_single_match(): + game = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(ValueError): + game.get_outcome(gbt.H.path(...)) + with pytest.raises(ValueError): + game.get_outcome(gbt.H.path(...).filter(lambda h: False)) + + def test_get_actions(): """A personal or chance node's actions, in order; a terminal node has none -- a node is terminal exactly when this is empty.""" From ac1360499899678fb1423882c856aab1415d4764 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 4 Sep 2026 20:04:13 +0100 Subject: [PATCH 12/24] `get_infosets` and `get_events` returns histories not nodes --- src/pygambit/behavmixed.pxi | 57 +++++++++---- src/pygambit/behavspt.pxi | 2 +- src/pygambit/catalog.py | 4 +- src/pygambit/cli/common.py | 55 +++++++----- src/pygambit/game.pxi | 95 ++++++++++++++------- src/pygambit/qre.py | 31 +++---- src/pygambit/strategy.pxi | 2 +- tests/games.py | 26 +++--- tests/test_behav.py | 166 +++++++++++++++++++----------------- tests/test_catalog.py | 4 +- tests/test_extensive.py | 6 +- tests/test_game_resolve.py | 20 +++-- tests/test_nash.py | 38 ++++----- tests/test_players.py | 6 +- tests/test_qre.py | 16 ++-- tests/test_tree_queries.py | 6 +- 16 files changed, 303 insertions(+), 231 deletions(-) diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 51f5e98ea8..a0a7be3567 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -29,20 +29,41 @@ 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 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``. + by a `Selector` (an `H`-built expression) resolving to a single node belonging to the + information set -- resolved to the History of the information set's canonical member + before lookup, so any member node is an equally valid key -- rather than by a label, + unlike `NodeIndexedVector`. + + .. versionchanged:: 17.0.0 + Indexed by a `Selector` rather than a `Node` object. """ _label_kind = "information set" + _game = cython.declare(Game) + + def __init__(self, game: Game, values: collections.abc.Mapping) -> None: + self._game = game + _LabeledVector.__init__(self, values) - def __getitem__(self, node: Node) -> typing.Any: - resolved_node = cython.cast(Node, node) - if resolved_node._infoset_handle() == cython.cast(c_GameInfoset, NULL): - raise ValueError("node is terminal, has no information set") + def __getitem__(self, selector: Selector) -> typing.Any: + if not isinstance(selector, Selector): + raise TypeError( + f"{type(self).__name__} index must be a Selector, not " + f"{selector.__class__.__name__}" + ) + # Resolves via _resolve_infoset_or_event, not _resolve_infoset: infoset_probs's + # values span both personal information sets and chance events, so resolution + # must succeed for either kind here, leaving "wrong kind for this vector" (e.g. + # a chance node in infoset_values, which only holds personal ones) to surface + # as the KeyError below, from _values simply not having that entry -- not as a + # resolution-time error that would also wrongly block a valid chance lookup in + # infoset_probs. + resolved_node = self._game._resolve_infoset_or_event( + selector, f"{type(self).__name__}.__getitem__" + ) try: return self._values[_canonical_history(resolved_node)] except KeyError: - raise KeyError(f"no {self._label_kind} for this node") from None + raise KeyError(f"no {self._label_kind} for this selector") from None @cython.cclass @@ -428,7 +449,7 @@ class MixedBehaviorProfile: if isinstance(index, str): values = { _canonical_history(node): self._mixed_action_at(node) - for node in self.game.get_infosets(index) + for node in self.game._get_infosets(index) } return MixedBehavior.wrap(self.game, index, values) if isinstance(index, Selector): @@ -444,15 +465,15 @@ class MixedBehaviorProfile: """Iterates over a representative node of every information set and event in the game.""" for player in self.game.players: - yield from self.game.get_infosets(player) - yield from self.game.get_events() + yield from self.game._get_infosets(player) + yield from self.game._get_events() 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: - yield from self.game.get_infosets(player) + 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 @@ -742,7 +763,7 @@ class MixedBehaviorProfile: MixedBehaviorProfile.infoset_probs """ self._check_validity() - return InfosetValueVector({ + return InfosetValueVector(self.game, { _canonical_history(node): self._infoset_value(node) for node in self._personal_infosets() }) @@ -761,7 +782,7 @@ class MixedBehaviorProfile: MixedBehaviorProfile.infoset_probs """ self._check_validity() - return ActionValuesVector({ + return ActionValuesVector(self.game, { _canonical_history(node): ActionValueVector({ a.deref().GetLabel().decode("utf-8"): self._action_value(a) for a in cython.cast(Node, node)._infoset_handle().deref().GetActions() @@ -800,7 +821,7 @@ class MixedBehaviorProfile: MixedBehaviorProfile.beliefs """ self._check_validity() - return InfosetProbVector({ + return InfosetProbVector(self.game, { _canonical_history(node): self._infoset_prob(node) for node in self._all_infosets() }) @@ -854,7 +875,7 @@ class MixedBehaviorProfile: agent_max_regret """ self._check_validity() - return ActionRegretsVector({ + return ActionRegretsVector(self.game, { _canonical_history(node): ActionRegretVector({ a.deref().GetLabel().decode("utf-8"): self._action_regret(a) for a in cython.cast(Node, node)._infoset_handle().deref().GetActions() @@ -881,7 +902,7 @@ class MixedBehaviorProfile: agent_max_regret """ self._check_validity() - return InfosetRegretVector({ + return InfosetRegretVector(self.game, { _canonical_history(node): self._infoset_regret(node) for node in self._personal_infosets() }) @@ -1253,7 +1274,7 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _as_float(self) -> MixedBehaviorProfileDouble: profile: MixedBehaviorProfileDouble = self.game.mixed_behavior_profile() for player in self.game.players: - for node in self.game.get_infosets(player): + for node in self.game._get_infosets(player): profile._setprob_infoset( node, { diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi index 88f34b37e4..38da83e1d4 100644 --- a/src/pygambit/behavspt.pxi +++ b/src/pygambit/behavspt.pxi @@ -211,7 +211,7 @@ class BehaviorSupportProfile: if isinstance(index, str): values = { _canonical_history(node): self._action_support_at(node) - for node in self.game.get_infosets(index) + for node in self.game._get_infosets(index) } return BehaviorSupport.wrap(self.game, index, values) if isinstance(index, Selector): diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 36163763cc..aae361a7ba 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -421,9 +421,9 @@ def check_filters(game: gbt.Game) -> bool: if not game.is_tree: return False n_game_actions = sum( - len(node.actions) + len(game.get_actions(gbt.H.path(*history))) for player in game.players - for node in game.get_infosets(player) + for history in game.get_infosets(player) ) if n_game_actions != n_actions: return False diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index c6b5bcdb6a..3392a19d90 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -244,10 +244,10 @@ def render_support_csv( fields = [ "".join( "1" if action in action_support else "0" - for action in node.actions + for action in support.game.get_actions(gbt.H.path(*history)) ) for player in support.game.players - for node, action_support in zip( + for history, action_support in zip( support.game.get_infosets(player), support[player], strict=True ) ] @@ -296,6 +296,17 @@ def _render_strategy_detail(profile: gbt.MixedStrategyProfile, decimals: int) -> return "\n".join(lines) +def _history_of(node: gbt.Node) -> tuple: + """The plain-tuple History of `node`, walked via `.parent`/`.prior_action`.""" + labels = [] + current = node + while current.parent is not None: + labels.append(current.prior_action.label) + current = current.parent + labels.reverse() + return tuple(labels) + + def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> str: lines = [] action_values = profile.action_values @@ -308,11 +319,12 @@ def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> # 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( + for infoset_number, (history, (_, mixed_action)) in enumerate( zip(profile.game.get_infosets(player), profile[player], strict=True), start=1 ): - values = action_values[node] - for action in node.actions: + selector = gbt.H.path(*history) + values = action_values[selector] + for action in profile.game.get_actions(selector): prob = mixed_action[action] value = values[action] value_text = format_value(value, decimals) if value is not None else "" @@ -323,12 +335,19 @@ def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> lines.append("") lines.append("Infoset Node Belief Prob") lines.append("------- ------- ----------- -----------") - for infoset_number, node in enumerate(profile.game.get_infosets(player), start=1): + # Uses the private, Node-returning `_get_infosets` (rather than the public + # History-returning `get_infosets`) because rendering needs each member's own + # `.label`/`.number` for display, which a History alone cannot provide; + # `Node.members` (still public) then gives the other members directly, and + # `_history_of` recovers the History each one needs to index + # `beliefs`/`realiz_probs` with. + 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] + member_history = _history_of(member) + belief = beliefs[member_history] belief_text = format_value(belief, decimals) if belief is not None else "" - realiz_text = format_value(realiz_probs[member], decimals) + realiz_text = format_value(realiz_probs[member_history], decimals) lines.append( f"{infoset_number:>7} {node_name:>7} " f"{belief_text:>11} {realiz_text:>11}" @@ -367,17 +386,6 @@ 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,9 +396,9 @@ def read_behavior_profiles_csv( the result via `~MixedBehaviorProfile.as_float`. """ count = sum( - len(node.actions) + len(game.get_actions(gbt.H.path(*history))) for player in game.players - for node in game.get_infosets(player) + for history in game.get_infosets(player) ) profiles = [] for line in pathlib.Path(path).read_text().splitlines(): @@ -404,8 +412,9 @@ def read_behavior_profiles_csv( raise ValueError(f"Error reading behavior profile from '{path}': {exc}") from None 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.actions} + for history in game.get_infosets(player): + selector = gbt.H.path(*history) + profile[selector] = {a: next(values) for a in game.get_actions(selector)} profiles.append(profile) return profiles diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index ab57fe4d67..0de6714d5e 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -308,15 +308,30 @@ class Game: def description(self, value: str) -> None: self.game.deref().SetDescription(value.encode("utf-8")) - def get_infosets(self, player: str) -> list[Node]: + def _get_infosets(self, player: str) -> list[Node]: + """Internal: like `get_infosets`, but keeps `Node` objects rather than + materializing each into a History -- used internally where the actual node + (not just its identifying History) is needed. + """ + if not self.is_tree: + raise UndefinedOperationError( + "Operation only defined for games with a tree representation" + ) + resolved_player = self._resolve_player(player, "get_infosets") + return [ + Node.wrap(infoset.deref().GetMember(1)) + for infoset in resolved_player.deref().GetInfosets() + ] + + def get_infosets(self, player: str) -> list[tuple]: """Returns a snapshot of the information sets belonging to the personal player `player`: the decisions at which that player chooses an action. - One representative member node is returned per information set, in the order - the information sets are encountered in the pre-order depth first traversal of - the game tree. This is a materialized snapshot, not a live view: it reflects - the game's state at the moment of the call, and does not change if the game is - subsequently mutated. + One representative member's History is returned per information set, in the + order the information sets are encountered in the pre-order depth first + traversal of the game tree. This is a materialized snapshot, not a live view: + it reflects the game's state at the moment of the call, and does not change if + the game is subsequently mutated. Parameters ---------- @@ -325,11 +340,16 @@ class Game: Returns ------- - list of Node - One representative member node per information set belonging to `player`. + list of tuple + The History of one representative member per information set belonging to + `player`. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + Returns each information set's representative as a History rather than a + ``Node`` object. + Raises ------ UndefinedOperationError @@ -340,46 +360,49 @@ class Game: ValueError If `player` is an empty string or all whitespace. """ + return [_history_of(node) for node in self._get_infosets(player)] + + def _get_events(self) -> list[Node]: + """Internal: like `get_events`, but keeps `Node` objects rather than + materializing each into a History -- used internally where the actual node + (not just its identifying History) is needed. + """ if not self.is_tree: raise UndefinedOperationError( "Operation only defined for games with a tree representation" ) - resolved_player = self._resolve_player(player, "get_infosets") return [ - Node.wrap(infoset.deref().GetMember(1)) - for infoset in resolved_player.deref().GetInfosets() + Node.wrap(event.deref().GetMember(1)) + for event in self.game.deref().GetChance().deref().GetInfosets() ] - def get_events(self) -> list[Node]: + def get_events(self) -> list[tuple]: """Returns a snapshot of the chance player's events: the points of exogenous randomness, each with a probability distribution over its actions. - One representative member node is returned per event, in the order the events - are encountered in the pre-order depth first traversal of the game tree. This - is a materialized snapshot, not a live view: it reflects the game's state at - the moment of the call, and does not change if the game is subsequently - mutated. + One representative member's History is returned per event, in the order the + events are encountered in the pre-order depth first traversal of the game + tree. This is a materialized snapshot, not a live view: it reflects the + game's state at the moment of the call, and does not change if the game is + subsequently mutated. Returns ------- - list of Node - One representative member node per event. + list of tuple + The History of one representative member per event. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + Returns each event's representative as a History rather than a ``Node`` + object. + Raises ------ UndefinedOperationError If the game does not have a tree representation. """ - if not self.is_tree: - raise UndefinedOperationError( - "Operation only defined for games with a tree representation" - ) - return [ - Node.wrap(event.deref().GetMember(1)) - for event in self.game.deref().GetChance().deref().GetInfosets() - ] + return [_history_of(node) for node in self._get_events()] def get_strategies(self, player: str) -> list[str]: """Returns a snapshot of the labels of the strategies belonging to `player`. @@ -758,11 +781,19 @@ class Game: f"get_members(): history must be a Selector, not " f"{history.__class__.__name__}" ) + return [_history_of(member) for member in self._get_members(history)] + + def _get_members(self, history: Selector) -> list[Node]: + """Internal: like `get_members`, but keeps `Node` objects rather than + materializing each into a History -- used internally where the actual node + (not just its identifying History) is needed. `history` is assumed already + validated as a `Selector` by the caller. + """ resolved_node = self._resolve_node(history, "get_members") infoset_handle: c_GameInfoset = cython.cast(Node, resolved_node)._infoset_handle() if infoset_handle == cython.cast(c_GameInfoset, NULL): return [] - return [_history_of(Node.wrap(member)) for member in infoset_handle.deref().GetMembers()] + return [Node.wrap(member) for member in infoset_handle.deref().GetMembers()] def _group_nodes(self, grouped: GroupedSelector) -> dict: """Internal: like `_get_groups`, but keeps `Node` objects rather than @@ -1267,7 +1298,7 @@ class Game: if len(data) != len(self.players): raise ValueError("Number of elements does not match number of players") for (p, d) in zip(self.players, data): - p_infosets = self.get_infosets(p) + p_infosets = self._get_infosets(p) 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): @@ -1359,7 +1390,7 @@ class Game: if denom is None: profile = self.mixed_behavior_profile() for player in self.players: - for node in self.get_infosets(player): + for node in self._get_infosets(player): profile._setprob_infoset( node, _dirichlet_distribution(node.actions, gen), sparse=True ) @@ -1369,7 +1400,7 @@ class Game: else: profile = self.mixed_behavior_profile(rational=True) for player in self.players: - for node in self.get_infosets(player): + for node in self._get_infosets(player): profile._setprob_infoset( node, _grid_distribution(node.actions, denom, gen), sparse=True ) @@ -1423,7 +1454,7 @@ class Game: profile = BehaviorSupportProfile.wrap(make_shared[c_BehaviorSupportProfile](self.game)) if actions is not None: for player in self.players: - for node in self.get_infosets(player): + for node in self._get_infosets(player): 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")): diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index 7d32e80b21..72a36d944e 100644 --- a/src/pygambit/qre.py +++ b/src/pygambit/qre.py @@ -253,31 +253,23 @@ 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[_selector_for_node(node)][a] + data[libgbt.H.path(*history)][a] for p in data.game.players - for node in data.game.get_infosets(p) - for a in node.actions + for history in data.game.get_infosets(p) + for a in data.game.get_actions(libgbt.H.path(*history)) ] normalized = data.normalize() regrets = [ - [-normalized.action_regrets[node][a] for a in node.actions] + [ + -normalized.action_regrets[libgbt.H.path(*history)][a] + for a in data.game.get_actions(libgbt.H.path(*history)) + ] for player in data.game.players - for node in data.game.get_infosets(player) + for history in data.game.get_infosets(player) ] res = scipy.optimize.minimize( lambda x: -_empirical_log_like(x[0], regrets, flattened_data), @@ -287,9 +279,10 @@ def _estimate_behavior_empirical( profile = data.game.mixed_behavior_profile() 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[_selector_for_node(node)] = { - a: math.exp(next(log_probs)) for a in node.actions + for history in data.game.get_infosets(player): + selector = libgbt.H.path(*history) + profile[selector] = { + a: math.exp(next(log_probs)) for a in data.game.get_actions(selector) } return LogitQREMixedBehaviorFitResult( data, "empirical", res.x[0], profile, -res.fun diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index e7154ac2e0..c2641cf981 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -134,7 +134,7 @@ class StrategyBehavior: 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): + for node in self._game._get_infosets(self._player_label): if self._action_at(node) is not None: yield node diff --git a/tests/games.py b/tests/games.py index 74fa6b9dda..68e1a48534 100644 --- a/tests/games.py +++ b/tests/games.py @@ -9,13 +9,12 @@ import pygambit as gbt -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 all_infosets(game: gbt.Game) -> list[tuple]: + """The History of a representative member 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 History per + information set, matching `Game.get_infosets`.""" + return [h for p in game.players for h in game.get_infosets(p)] def all_nodes(game: gbt.Game) -> list[gbt.Node]: @@ -29,17 +28,20 @@ def _walk(node: gbt.Node): return list(_walk(game.root)) -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).""" +def player_infosets(game: gbt.Game, player: str) -> list[tuple]: + """The History of a representative member 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: + game = node.game + members = set(game.get_members(selector_for_node(node))) + for i, history in enumerate(game.get_infosets(node.player)): + if history in members: return i raise ValueError("node is terminal, has no information set") diff --git a/tests/test_behav.py b/tests/test_behav.py index 392355a44d..e10c70c7aa 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -18,10 +18,11 @@ def _set_action_probs(profile: gbt.MixedBehaviorProfile, probs: list, rational_f """ convert = (lambda p: gbt.Rational(p)) if rational_flag else (lambda p: p) probs_iter = iter(probs) - for infoset in games.all_infosets(profile.game): - node = next(iter(infoset.members)) - profile[games.selector_for_node(node)] = { - a: convert(next(probs_iter)) for a in infoset.actions + game = profile.game + for infoset in games.all_infosets(game): + selector = gbt.H.path(*infoset) + profile[selector] = { + a: convert(next(probs_iter)) for a in game.get_actions(selector) } @@ -251,7 +252,7 @@ def test_behavior_indexing_rejects_selector_from_different_player( """ profile = game.mixed_behavior_profile() other_infoset = games.player_infosets(game, other_player_label)[0] - other_selector = games.selector_for_node(next(iter(other_infoset.members))) + other_selector = gbt.H.path(*other_infoset) with pytest.raises(gbt.MismatchError): profile[player_label][other_selector] @@ -278,7 +279,7 @@ def test_profile_indexing_by_player_label_reference( if rational_flag: behav_data = [[gbt.Rational(prob) for prob in probs] for probs in behav_data] expected = [ - dict(zip(infoset.actions, probs, strict=True)) + dict(zip(game.get_actions(gbt.H.path(*infoset)), probs, strict=True)) for infoset, probs in zip( games.player_infosets(game, player_label), behav_data, strict=True ) @@ -432,7 +433,7 @@ def test_set_probabilities_player_by_label( if rational_flag: behav_data = [[gbt.Rational(prob) for prob in probs] for probs in behav_data] expected = [ - dict(zip(infoset.actions, probs, strict=True)) + dict(zip(game.get_actions(gbt.H.path(*infoset)), probs, strict=True)) for infoset, probs in zip( games.player_infosets(game, player_label), behav_data, strict=True ) @@ -440,25 +441,26 @@ def test_set_probabilities_player_by_label( for infoset, distribution in zip( games.player_infosets(game, player_label), expected, strict=True ): - profile[games.selector_for_node(next(iter(infoset.members)))] = distribution + profile[gbt.H.path(*infoset)] = distribution assert profile[player_label] == expected -def _p1_node(game: gbt.Game): - return next(iter(games.player_infosets(game, "Player 1")[0].members)) +def _p1_selector(game: gbt.Game) -> gbt.Selector: + """A Selector for Player 1's first information set.""" + return gbt.H.path(*games.player_infosets(game, "Player 1")[0]) def test_behavior_setitem_allows_sparse_distribution(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - selector = games.selector_for_node(_p1_node(game)) + selector = _p1_selector(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") - selector = games.selector_for_node(_p1_node(game)) + selector = _p1_selector(game) sparse_profile = game.mixed_behavior_profile() sparse_profile.set_mixed_action(selector, {"U1": 1}, sparse=True) setitem_profile = game.mixed_behavior_profile() @@ -469,7 +471,7 @@ def test_set_mixed_action_sparse_matches_setitem(): def test_set_mixed_action_defaults_to_requiring_every_label(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - selector = games.selector_for_node(_p1_node(game)) + selector = _p1_selector(game) with pytest.raises(ValueError, match="exactly one weight"): profile.set_mixed_action(selector, {"U1": 1}) @@ -478,7 +480,7 @@ def test_set_mixed_action_defaults_to_requiring_every_label(): 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() - selector = games.selector_for_node(_p1_node(game)) + selector = _p1_selector(game) with pytest.raises(ValueError, match="not an action label"): profile.set_mixed_action(selector, {"not-an-action": 1}, sparse=sparse) with pytest.raises(ValueError, match="not an action label"): @@ -488,7 +490,7 @@ def test_setitem_and_set_mixed_action_reject_unknown_action_label(sparse: bool): def test_behavior_setitem_empty_distribution_is_all_zero_error(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - selector = games.selector_for_node(_p1_node(game)) + selector = _p1_selector(game) with pytest.raises(ValueError, match="zero"): profile[selector] = {} @@ -497,7 +499,7 @@ def test_behavior_setitem_empty_distribution_is_all_zero_error(): 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() - selector = games.selector_for_node(_p1_node(game)) + selector = _p1_selector(game) with pytest.raises(TypeError, match="Mapping"): profile.set_mixed_action(selector, [1, 0], sparse=sparse) with pytest.raises(TypeError, match="Mapping"): @@ -508,7 +510,7 @@ def test_setitem_and_set_mixed_action_reject_non_mapping(sparse: bool): 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() - selector = games.selector_for_node(_p1_node(game)) + selector = _p1_selector(game) full_distribution = {"U1": "abc", "D1": 0} with pytest.raises(ValueError, match="convert"): profile.set_mixed_action(selector, full_distribution, sparse=sparse) @@ -520,20 +522,21 @@ 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() - selector = games.selector_for_node(_p1_node(game)) + selector = _p1_selector(game) with pytest.raises(ValueError, match="negative"): profile[selector] = {"U1": -1} @pytest.mark.parametrize("sparse", [False, True]) 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`. + """MixedBehaviorProfile's indexing is Selector-only; neither a bare History nor a + bare `Node` object is accepted, 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)) + node = games.all_nodes(game)[0] with pytest.raises(TypeError): profile[infoset] with pytest.raises(TypeError): @@ -555,7 +558,7 @@ 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) - selector = games.selector_for_node(_p1_node(game)) + selector = _p1_selector(game) action_before = profile[selector] behavior_before = profile["Player 1"] profile[selector] = {"U1": 1, "D1": 0} @@ -568,7 +571,7 @@ def test_mixed_action_and_behavior_are_frozen_snapshots(rational_flag: bool): 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) - selector = games.selector_for_node(_p1_node(game)) + selector = _p1_selector(game) original_before = dict(original[selector]) copy = original.copy() copy[selector] = {"U1": 1, "D1": 0} @@ -580,7 +583,7 @@ def test_behavior_copy_mutating_copy_does_not_affect_original(rational_flag: boo 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) - selector = games.selector_for_node(_p1_node(game)) + selector = _p1_selector(game) copy = original.copy() copy_before = dict(copy[selector]) original[selector] = {"U1": 1, "D1": 0} @@ -598,7 +601,7 @@ 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) - selector = games.selector_for_node(_p1_node(game)) + selector = _p1_selector(game) profile[selector] = {"U1": "1/3", "D1": "2/3"} result = profile.as_float() assert dict(result[selector]) == {"U1": pytest.approx(1 / 3), "D1": pytest.approx(2 / 3)} @@ -607,7 +610,7 @@ def test_as_float_converts_rational_probabilities(): def test_as_float_is_independent_copy(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile(rational=False) - selector = games.selector_for_node(_p1_node(game)) + selector = _p1_selector(game) result = profile.as_float() assert result == profile result[selector] = {"U1": 1.0, "D1": 0.0} @@ -693,7 +696,7 @@ def test_infoset_probs_reference(game: gbt.Game, rational_flag: bool, infoset_pr profile = game.mixed_behavior_profile(rational=rational_flag) for prob, infoset in zip(infoset_probs, games.all_infosets(game), strict=True): prob = gbt.Rational(prob) if rational_flag else prob - assert profile.infoset_probs[next(iter(infoset.members))] == prob + assert profile.infoset_probs[gbt.H.path(*infoset)] == prob @pytest.mark.parametrize( @@ -732,7 +735,7 @@ def test_absent_minded_infoset_prob( ): profile = game.mixed_behavior_profile(rational=rational_flag) node = next(iter(games.find_infoset_in_game(game, infoset_label).members)) - ip = profile.infoset_probs[node] + ip = profile.infoset_probs[games.selector_for_node(node)] assert ip == (gbt.Rational(prob) if rational_flag else prob) @@ -743,7 +746,7 @@ def test_nature_rooted_game_root_reached_with_certainty(rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) one = gbt.Rational(1) if rational_flag else 1.0 assert profile.realiz_probs[()] == one - assert profile.infoset_probs[game.root] == one + assert profile.infoset_probs[gbt.H.path()] == one @pytest.mark.parametrize( @@ -759,7 +762,7 @@ def test_infoset_values_reference(game: gbt.Game, rational_flag: bool, infoset_v profile = game.mixed_behavior_profile(rational=rational_flag) for payoff, infoset in zip(infoset_values, games.all_infosets(game), strict=True): payoff = gbt.Rational(payoff) if rational_flag else payoff - assert profile.infoset_values[next(iter(infoset.members))] == payoff + assert profile.infoset_values[gbt.H.path(*infoset)] == payoff @pytest.mark.parametrize( @@ -782,8 +785,11 @@ def test_infoset_values_reference(game: gbt.Game, rational_flag: bool, infoset_v def test_action_values_reference(game: gbt.Game, rational_flag: bool, action_values: tuple): profile = game.mixed_behavior_profile(rational=rational_flag) for values_for_infoset, infoset in zip(action_values, games.all_infosets(game), strict=True): - infoset_action_values = profile.action_values[next(iter(infoset.members))] - for value, action in zip(values_for_infoset, infoset.actions, strict=True): + selector = gbt.H.path(*infoset) + infoset_action_values = profile.action_values[selector] + for value, action in zip( + values_for_infoset, game.get_actions(selector), strict=True + ): value = gbt.Rational(value) if rational_flag else value assert infoset_action_values[action] == value @@ -803,11 +809,12 @@ def test_action_regret_consistency(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) for player in game.players: for infoset in games.player_infosets(game, player): - node = next(iter(infoset.members)) - for action in infoset.actions: - assert profile.action_regrets[node][action] == max( - profile.action_values[node][a] for a in infoset.actions - ) - profile.action_values[node][action] + selector = gbt.H.path(*infoset) + actions = game.get_actions(selector) + for action in actions: + assert profile.action_regrets[selector][action] == max( + profile.action_values[selector][a] for a in actions + ) - profile.action_values[selector][action] @pytest.mark.parametrize( @@ -825,10 +832,10 @@ def test_infoset_regret_consistency(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) for player in game.players: for infoset in games.player_infosets(game, player): - node = next(iter(infoset.members)) - assert profile.infoset_regrets[node] == max( - profile.action_values[node][a] for a in infoset.actions - ) - profile.infoset_values[node] + selector = gbt.H.path(*infoset) + assert profile.infoset_regrets[selector] == max( + profile.action_values[selector][a] for a in game.get_actions(selector) + ) - profile.infoset_values[selector] @pytest.mark.parametrize( @@ -864,7 +871,7 @@ def test_agent_max_regret_consistency(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) infoset_regrets = profile.infoset_regrets assert profile.agent_max_regret() == max( - infoset_regrets[next(iter(infoset.members))] for infoset in games.all_infosets(game) + infoset_regrets[gbt.H.path(*infoset)] for infoset in games.all_infosets(game) ) @@ -912,15 +919,16 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): assert player_node_values[()] == payoffs[player] for infoset in games.player_infosets(game, player): - node = next(iter(infoset.members)) - infoset_action_values = action_values[node] - infoset_action_regrets = action_regrets[node] + selector = gbt.H.path(*infoset) + actions = game.get_actions(selector) + infoset_action_values = action_values[selector] + infoset_action_regrets = action_regrets[selector] assert isinstance(infoset_action_values, gbt.ActionValueVector) assert isinstance(infoset_action_regrets, gbt.ActionRegretVector) - best_response_value = max(infoset_action_values[a] for a in infoset.actions) - assert infoset_regrets[node] == best_response_value - infoset_values[node] - for action in infoset.actions: + best_response_value = max(infoset_action_values[a] for a in actions) + assert infoset_regrets[selector] == best_response_value - infoset_values[selector] + for action in actions: assert ( infoset_action_regrets[action] == best_response_value - infoset_action_values[action] @@ -930,7 +938,7 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): if not node.children: continue history = games._node_history(node) - if infoset_probs[node] == 0: + if infoset_probs[gbt.H.path(*history)] == 0: assert beliefs[history] is None else: assert beliefs[history] is not None @@ -1010,8 +1018,11 @@ def test_action_regrets_reference( if action_probs: _set_action_probs(profile, action_probs, rational_flag) for regrets_for_infoset, infoset in zip(action_regrets, games.all_infosets(game), strict=True): - infoset_action_regrets = profile.action_regrets[next(iter(infoset.members))] - for regret, action in zip(regrets_for_infoset, infoset.actions, strict=True): + selector = gbt.H.path(*infoset) + infoset_action_regrets = profile.action_regrets[selector] + for regret, action in zip( + regrets_for_infoset, game.get_actions(selector), strict=True + ): regret = gbt.Rational(regret) if rational_flag else regret assert abs(infoset_action_regrets[action] - regret) <= tol @@ -1378,7 +1389,9 @@ def test_infoset_value_error_with_chance_player_infoset(game: gbt.Game, rational """ chance_node = game.get_events()[0] with pytest.raises(KeyError): - game.mixed_behavior_profile(rational=rational_flag).infoset_values[chance_node] + game.mixed_behavior_profile(rational=rational_flag).infoset_values[ + gbt.H.path(*chance_node) + ] @pytest.mark.parametrize( @@ -1394,16 +1407,19 @@ def test_action_value_error_with_chance_player_action(game: gbt.Game, rational_f """ chance_node = game.get_events()[0] with pytest.raises(KeyError): - game.mixed_behavior_profile(rational=rational_flag).action_values[chance_node] + game.mixed_behavior_profile(rational=rational_flag).action_values[ + gbt.H.path(*chance_node) + ] -def _all_node_actions(game: gbt.Game) -> list[tuple[gbt.Node, str]]: - """All (node, action label) pairs across every personal player's information sets.""" +def _all_node_actions(game: gbt.Game) -> list[tuple[gbt.Selector, str]]: + """All (selector, action label) pairs across every personal player's information sets.""" return [ - (node, action) + (selector, action) for player in game.players - for node in game.get_infosets(player) - for action in node.actions + for history in game.get_infosets(player) + for selector in [gbt.H.path(*history)] + for action in game.get_actions(selector) ] @@ -1535,7 +1551,7 @@ def _get_and_check_answers( PROBS_1A_doub, PROBS_2A_doub, False, - lambda x, y: x.infoset_probs[next(iter(y.members))], + lambda x, y: x.infoset_probs[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ( @@ -1543,7 +1559,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.infoset_probs[next(iter(y.members))], + lambda x, y: x.infoset_probs[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ( @@ -1551,7 +1567,7 @@ def _get_and_check_answers( PROBS_1B_doub, PROBS_2B_doub, False, - lambda x, y: x.infoset_probs[next(iter(y.members))], + lambda x, y: x.infoset_probs[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ( @@ -1559,7 +1575,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.infoset_probs[next(iter(y.members))], + lambda x, y: x.infoset_probs[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ###################################################################################### @@ -1569,7 +1585,7 @@ def _get_and_check_answers( PROBS_1A_doub, PROBS_2A_doub, False, - lambda x, y: x.infoset_values[next(iter(y.members))], + lambda x, y: x.infoset_values[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ( @@ -1577,7 +1593,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.infoset_values[next(iter(y.members))], + lambda x, y: x.infoset_values[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ( @@ -1585,7 +1601,7 @@ def _get_and_check_answers( PROBS_1B_doub, PROBS_2B_doub, False, - lambda x, y: x.infoset_values[next(iter(y.members))], + lambda x, y: x.infoset_values[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ( @@ -1593,7 +1609,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.infoset_values[next(iter(y.members))], + lambda x, y: x.infoset_values[gbt.H.path(*y)], lambda x: games.all_infosets(x), ), ###################################################################################### @@ -1887,8 +1903,8 @@ 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): - selector = games.selector_for_node(next(iter(infoset.members))) - for action in infoset.actions: + selector = gbt.H.path(*infoset) + for action in game.get_actions(selector): prob = next(flattened) assert profile[selector][action] == (gbt.Rational(prob) if rational_flag else prob) @@ -1976,20 +1992,18 @@ def test_undefined_action_value(): """Test that undefined action values return `None`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") *_, p3 = game.players - infoset = games.player_infosets(game, p3)[0] - node = next(iter(infoset.members)) - action = next(iter(infoset.actions)) + selector = gbt.H.path(*games.player_infosets(game, p3)[0]) + action = game.get_actions(selector)[0] for rat in [False, True]: profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) - assert profile.action_values[node][action] is None + assert profile.action_values[selector][action] is None def test_undefined_belief(): """Test that undefined beliefs return `None`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") *_, p3 = game.players - node = next(iter(games.player_infosets(game, p3)[0].members)) - history = games._node_history(node) + history = games.player_infosets(game, p3)[0] for rat in [False, True]: profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) assert profile.beliefs[history] is None @@ -1999,7 +2013,7 @@ def test_undefined_infoset_value(): """Test that undefined infoset values return `None`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") *_, p3 = game.players - node = next(iter(games.player_infosets(game, p3)[0].members)) + selector = gbt.H.path(*games.player_infosets(game, p3)[0]) for rat in [False, True]: profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) - assert profile.infoset_values[node] is None + assert profile.infoset_values[selector] is None diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 2169830ed9..869a1a2652 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -63,9 +63,9 @@ 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.actions) + len(g.get_actions(gbt.H.path(*history))) for player in g.players - for node in g.get_infosets(player) + for history in g.get_infosets(player) ) assert n_game_actions == 2 diff --git a/tests/test_extensive.py b/tests/test_extensive.py index bc1f7ca251..352f6cedc7 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -533,10 +533,10 @@ def test_reduced_strategy_maps(game: gbt.Game, strategy_maps: list): behavior = game.get_behavior(player, strategy) assert tuple( "*" if ( - action := behavior.get(games.selector_for_node(next(iter(infoset.members)))) + action := behavior.get(gbt.H.path(*history)) ) is None - else str(infoset.actions.index(action) + 1) - for infoset in games.player_infosets(game, player) + else str(game.get_actions(gbt.H.path(*history)).index(action) + 1) + for history in games.player_infosets(game, player) ) == expected diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index 17559932a4..cee0317ce4 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -64,16 +64,18 @@ def test_resolve_node_mismatch(): ] ) def test_resolve_infoset(game: gbt.Game) -> None: - """`_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.""" + """`_resolve_infoset` resolves a Selector to the node it identifies, or a + node's label to that node, 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): - for member in node.members: - assert game._resolve_infoset(member, "test") == member - if member.label: - assert game._resolve_infoset(member.label, "test") == member + for history in game.get_infosets(player): + for member in game.get_members(gbt.H.path(*history)): + resolved = game._resolve_infoset(gbt.H.path(*member), "test") + assert games._node_history(resolved) == member + if resolved.label: + resolved_by_label = game._resolve_infoset(resolved.label, "test") + assert games._node_history(resolved_by_label) == member @pytest.mark.parametrize( diff --git a/tests/test_nash.py b/tests/test_nash.py index 5cc90e9792..f8be934309 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -23,10 +23,10 @@ def d(*probs) -> tuple: return tuple(probs) -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[games.selector_for_node(node)][label] +def _action_prob(profile: gbt.MixedBehaviorProfile, history: tuple, label: str): + """The probability profile assigns to the action labeled `label` at the + information set identified by `history`.""" + return profile[gbt.H.path(*history)][label] @dataclasses.dataclass @@ -3207,11 +3207,11 @@ def test_nash_behavior_solver(test_case: EquilibriumTestCase, subtests) -> None: with subtests.test(eq=i, check="strategy_profile"): expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: - for node in game.get_infosets(player): - for action in node.actions: + for history in game.get_infosets(player): + for action in game.get_actions(gbt.H.path(*history)): assert abs( - _action_prob(eq, node, action) - - _action_prob(expected, node, action) + _action_prob(eq, history, action) + - _action_prob(expected, history, action) ) <= test_case.prob_tol @@ -3259,10 +3259,10 @@ def test_nash_behavior_solver_unordered(test_case: EquilibriumTestCase, subtests def are_the_same(game, found, candidate): for p in game.players: - for node in game.get_infosets(p): - for a in node.actions: + for history in game.get_infosets(p): + for a in game.get_actions(gbt.H.path(*history)): if not abs( - _action_prob(found, node, a) - _action_prob(candidate, node, a) + _action_prob(found, history, a) - _action_prob(candidate, history, a) ) <= TOL: return False return True @@ -3426,11 +3426,11 @@ def test_nash_agent_solver(test_case: EquilibriumTestCase, subtests) -> None: with subtests.test(eq=i, check="strategy_profile"): expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: - for node in game.get_infosets(player): - for action in node.actions: + for history in game.get_infosets(player): + for action in game.get_actions(gbt.H.path(*history)): assert abs( - _action_prob(eq, node, action) - - _action_prob(expected, node, action) + _action_prob(eq, history, action) + - _action_prob(expected, history, action) ) <= test_case.prob_tol @@ -3494,11 +3494,11 @@ def test_nash_agent_w_start_solver(test_case: EquilibriumTestCase, subtests) -> with subtests.test(eq=i, check="strategy_profile"): expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: - for node in game.get_infosets(player): - for action in node.actions: + for history in game.get_infosets(player): + for action in game.get_actions(gbt.H.path(*history)): assert abs( - _action_prob(eq, node, action) - - _action_prob(expected, node, action) + _action_prob(eq, history, action) + - _action_prob(expected, history, action) ) <= test_case.prob_tol diff --git a/tests/test_players.py b/tests/test_players.py index 0612818285..a4fc648e31 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.actions) for node in game.get_infosets(player) + len(game.get_actions(gbt.H.path(*history))) for history in game.get_infosets(player) ) assert len(game.get_sequences(player)) == action_count + 1 @@ -317,8 +317,8 @@ def test_player_sequence_actions(): reference = ( set( (action, ) - for node in game.get_infosets(player) - for action in node.actions + for history in game.get_infosets(player) + for action in game.get_actions(gbt.H.path(*history)) ) | {tuple()} ) diff --git a/tests/test_qre.py b/tests/test_qre.py index c25195d490..81ce559e64 100644 --- a/tests/test_qre.py +++ b/tests/test_qre.py @@ -13,10 +13,10 @@ def _asymmetric_poker_behavior_data() -> gbt.MixedBehaviorProfile: game = games.create_stripped_down_poker_efg() data = game.mixed_behavior_profile(rational=False) for player in game.players: - for infoset in games.player_infosets(game, player): - node = next(iter(infoset.members)) - data[games.selector_for_node(node)] = { - a: float(i + 2) for i, a in enumerate(infoset.actions) + for history in games.player_infosets(game, player): + selector = gbt.H.path(*history) + data[selector] = { + a: float(i + 2) for i, a in enumerate(game.get_actions(selector)) } return data @@ -75,8 +75,8 @@ def test_logit_estimate_behavior_completes(use_empirical: bool, local_max: bool) result = gbt.qre.logit_estimate(data, use_empirical=use_empirical, local_max=local_max) assert isinstance(result.profile, gbt.MixedBehaviorProfileDouble) for player in data.game.players: - for infoset in games.player_infosets(data.game, player): - node = next(iter(infoset.members)) - probs = dict(result.profile[games.selector_for_node(node)]) - assert probs.keys() == set(infoset.actions) + for history in games.player_infosets(data.game, player): + selector = gbt.H.path(*history) + probs = dict(result.profile[selector]) + assert probs.keys() == set(data.game.get_actions(selector)) assert sum(probs.values()) == pytest.approx(1.0) diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index cf43cce61e..fb3e19ee21 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -446,9 +446,9 @@ def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): for key in keys } for player in game.players: - for node in game.get_infosets(player): - key = (node.player, games.infoset_number(node)) - selector = games.selector_for_nodes([node]) + for i, history in enumerate(game.get_infosets(player)): + key = (player, i) + selector = gbt.H.path(*history) actual_path = tuple( _get_path_of_action_labels(game.get_minimal_subgame(selector).root) ) From b648a47bc4e27ec4a4cc8598a04672d153925c0a Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Sat, 5 Sep 2026 07:10:11 +0100 Subject: [PATCH 13/24] Simplify subgames API --- doc/pygambit.api.rst | 11 +--- src/pygambit/game.pxi | 55 ++++++++-------- src/pygambit/gamecollections.pxi | 32 --------- src/pygambit/node.pxi | 66 ------------------- tests/test_tree_queries.py | 110 ++++++++----------------------- 5 files changed, 56 insertions(+), 218 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index e0e10d9370..e3cc646c3e 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -17,7 +17,6 @@ Representation of games Outcome Node Branch - Subgame Creating, reading, and writing games @@ -114,7 +113,7 @@ Information about the game Game.contingencies Game.get_outcome Game.get_payoffs - Game.subgames + Game.get_subgame_roots Game.get_minimal_subgame .. autosummary:: @@ -142,14 +141,6 @@ Information about the game Node.is_successor_of Node.own_prior_action -.. autosummary:: - :toctree: api/ - - Subgame.game - Subgame.root - Subgame.parent - Subgame.children - .. autosummary:: :toctree: api/ diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 0de6714d5e..51086e9be3 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -927,14 +927,11 @@ class Game: """ return rat_to_py(self.game.deref().GetMaxPayoff()) - @property - def subgames(self) -> GameSubgames: - """The set of subgames in the game. - - Iteration over this property yields the subgames in postorder - (children before parents). + def get_subgame_roots(self) -> list[tuple]: + """Returns the Histories of the roots of the subgames of the game, in + postorder (children before parents). - .. versionadded:: 16.7.0 + .. versionadded:: 17.0.0 Raises ------ @@ -943,15 +940,19 @@ class Game: """ if not self.is_tree: raise UndefinedOperationError( - "Operation only defined for games with a tree representation" + "get_subgame_roots(): operation only defined for games " + "with a tree representation" ) - return GameSubgames.wrap(self.game) + return [ + _history_of(Node.wrap(subgame.deref().GetRoot())) + for subgame in self.game.deref().GetSubgames() + ] - def get_minimal_subgame(self, node: Selector) -> Subgame: - """Returns the smallest subgame containing the information set or event that - the node identified by `node` belongs to. + def get_minimal_subgame(self, history: Selector) -> tuple: + """Returns the History of the root of the smallest subgame containing the + information set or event that the node identified by `history` belongs to. - `node` is a `Selector` (an `H`-built expression, evaluated against this + `history` is a `Selector` (an `H`-built expression, evaluated against this game) that must resolve to exactly one node. .. versionadded:: 16.7.0 @@ -959,27 +960,29 @@ class Game: Renamed from `minimal_subgame`. `node` (formerly `infoset`) is now a `Selector`; a `Node` or `str` is no longer accepted directly -- build one with `H`. + .. versionchanged:: 17.0.0 + Returns the History of the subgame's root, instead of a `Subgame` object. Parameters ---------- - node : Selector + history : Selector A `Selector` resolving to a single node belonging to the information set or event to query. Returns ------- - Subgame - The smallest subgame containing the information set or event that - `node` belongs to. + tuple + The History of the root of the smallest subgame containing the + information set or event that `history` belongs to. Raises ------ TypeError - If `node` is not a `Selector`. + If `history` is not a `Selector`. UndefinedOperationError If the game does not have a tree representation. ValueError - If `node` does not resolve to exactly one node, or belongs to no + If `history` does not resolve to exactly one node, or belongs to no information set or event (it is terminal). """ if not self.is_tree: @@ -987,16 +990,16 @@ class Game: "get_minimal_subgame(): operation only defined for games " "with a tree representation" ) - if not isinstance(node, Selector): + if not isinstance(history, Selector): raise TypeError( - f"get_minimal_subgame(): node must be a Selector, not {node.__class__.__name__}" - ) - resolved_node = self._resolve_infoset_or_event(node, "get_minimal_subgame") - return Subgame.wrap( - self.game.deref().GetMinimalSubgame( - cython.cast(Node, resolved_node)._infoset_handle() + "get_minimal_subgame(): history must be a Selector, " + f"not {history.__class__.__name__}" ) + resolved_node = self._resolve_infoset_or_event(history, "get_minimal_subgame") + subgame: c_GameSubgame = self.game.deref().GetMinimalSubgame( + cython.cast(Node, resolved_node)._infoset_handle() ) + return _history_of(Node.wrap(subgame.deref().GetRoot())) def get_behavior(self, player: str, diff --git a/src/pygambit/gamecollections.pxi b/src/pygambit/gamecollections.pxi index 76f0f399b3..95037821a6 100644 --- a/src/pygambit/gamecollections.pxi +++ b/src/pygambit/gamecollections.pxi @@ -21,38 +21,6 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # -@cython.cclass -class GameSubgames: - """Represents the set of subgames in a game.""" - game = cython.declare(c_Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameSubgames outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: c_Game) -> GameSubgames: - obj: GameSubgames = GameSubgames.__new__(GameSubgames) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameSubgames(game={Game.wrap(self.game)})" - - def __len__(self) -> int: - """The number of subgames in the game.""" - if not self.game.deref().IsTree(): - return 0 - return self.game.deref().GetSubgames().size() - - def __iter__(self) -> typing.Iterator[Subgame]: - """Iterate over the game subgames in postorder.""" - if not self.game.deref().IsTree(): - return - for subgame in self.game.deref().GetSubgames(): - yield Subgame.wrap(subgame) - - @cython.cclass class GameOutcomes: """Represents the set of outcomes in a game.""" diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index b9f487f9d5..7ece65bc82 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -439,69 +439,3 @@ class Node: `game.get_histories(H.path(...).plays)`. """ return [Node.wrap(n) for n in self.node.deref().GetGame().deref().GetPlays(self.node)] - - -@cython.cclass -class Subgame: - """A subgame in a ``Game``. - - .. versionadded:: 16.7.0 - """ - subgame = cython.declare(c_GameSubgame) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create a Subgame outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(subgame: c_GameSubgame) -> Subgame: - obj: Subgame = Subgame.__new__(Subgame) - obj.subgame = subgame - return obj - - def __repr__(self) -> str: - return f"Subgame(root={self.root})" - - def __eq__(self, other: typing.Any) -> bool: - return ( - isinstance(other, Subgame) and - self.subgame.deref() == cython.cast(Subgame, other).subgame.deref() - ) - - def __hash__(self) -> int: - return cython.cast(cython.long, self.subgame.deref()) - - @property - def game(self) -> Game: - """Gets the ``Game`` to which the subgame belongs. - - .. versionadded:: 16.7.0 - """ - return Game.wrap(self.subgame.deref().GetGame()) - - @property - def root(self) -> Node: - """Returns the root node of the subgame. - - .. versionadded:: 16.7.0 - """ - return Node.wrap(self.subgame.deref().GetRoot()) - - @property - def parent(self) -> typing.Optional[Subgame]: - """Returns the parent subgame, or None if this is the root subgame. - - .. versionadded:: 16.7.0 - """ - parent: c_GameSubgame = self.subgame.deref().GetParent() - if parent != cython.cast(c_GameSubgame, NULL): - return Subgame.wrap(parent) - return None - - @property - def children(self) -> list[Subgame]: - """Returns the immediate child subgames of this subgame. - - .. versionadded:: 16.7.0 - """ - return [Subgame.wrap(child) for child in self.subgame.deref().GetChildren()] diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index fb3e19ee21..e38c94b52b 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -305,28 +305,21 @@ def test_subgame_roots(test_case: SubgameRootsTestCase): # ============================================================================ -# Subgame tree / GameSubgame +# Subgames # ============================================================================ @dataclasses.dataclass class SubgameStructureTestCase: """Expected subgame structure of a game. - `roots` lists each subgame root as a node->root action-label path, in the - postorder `game.subgames` is expected to produce (children before parents). + `roots` lists the History of each subgame root, in the postorder + `Game.get_subgame_roots` is expected to produce (children before parents). - `parents` maps each subgame-root path to its expected parent path - (or None for the root subgame). - - `children` maps each subgame-root path to the set of its child subgame paths. - - `differences` maps each subgame-root path to the set of + `differences` maps each subgame-root History to the set of (player_label, infoset_number) keys in that subgame's difference --- the information sets belonging to the subgame but not to any child subgame. """ factory: typing.Callable[[], gbt.Game] - roots: list[list[str]] - parents: dict[tuple[str, ...], tuple[str, ...] | None] - children: dict[tuple[str, ...], set[tuple[str, ...]]] + roots: list[tuple[str, ...]] differences: dict[tuple[str, ...], set[tuple[str, int]]] @@ -337,9 +330,7 @@ class SubgameStructureTestCase: pytest.param( SubgameStructureTestCase( factory=functools.partial(gbt.catalog.load, "journals/geb/wichardt2008"), - roots=[[]], - parents={(): None}, - children={(): set()}, + roots=[()], differences={(): {("Player 1", 0), ("Player 1", 1), ("Player 2", 0)}}, ), id="wichardt_no_nontrivial_subgames", @@ -351,44 +342,23 @@ class SubgameStructureTestCase: SubgameStructureTestCase( factory=functools.partial(games.read_from_file, "subgame-8-roots.efg"), roots=[ - ["L", "L", "L", "L", "L"], - ["R", "L", "L", "L", "L"], - ["L", "L", "L", "L"], - ["L", "L"], - ["R", "L"], - ["L"], - ["R"], - [], + ("L", "L", "L", "L", "L"), + ("L", "L", "L", "L", "R"), + ("L", "L", "L", "L"), + ("L", "L"), + ("L", "R"), + ("L",), + ("R",), + (), ], - parents={ - ("L", "L", "L", "L", "L"): ("L", "L", "L", "L"), - ("R", "L", "L", "L", "L"): ("L", "L", "L", "L"), - ("L", "L", "L", "L"): ("L", "L"), - ("L", "L"): ("L",), - ("R", "L"): ("L",), - ("L",): (), - ("R",): (), - (): None, - }, - children={ - ("L", "L", "L", "L", "L"): set(), - ("R", "L", "L", "L", "L"): set(), - ("L", "L", "L", "L"): {("L", "L", "L", "L", "L"), - ("R", "L", "L", "L", "L")}, - ("L", "L"): {("L", "L", "L", "L")}, - ("R", "L"): set(), - ("L",): {("L", "L"), ("R", "L")}, - ("R",): set(), - (): {("L",), ("R",)}, - }, differences={ ("L", "L", "L", "L", "L"): { ("Player 1", 3), ("Player 2", 2), ("Player 2", 3), }, - ("R", "L", "L", "L", "L"): {("Player 1", 4), ("Player 1", 5)}, + ("L", "L", "L", "L", "R"): {("Player 1", 4), ("Player 1", 5)}, ("L", "L", "L", "L"): {("Player 2", 1)}, ("L", "L"): {("Player 1", 1), ("Player 1", 2)}, - ("R", "L"): {("Player 1", 6)}, + ("L", "R"): {("Player 1", 6)}, ("L",): {("Player 2", 0)}, ("R",): { ("Player 1", 7), ("Player 1", 8), ("Player 1", 9), @@ -403,56 +373,28 @@ class SubgameStructureTestCase: @pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) -def test_subgames_postorder_sequence(test_case: SubgameStructureTestCase): - """`game.subgames` produces the expected postorder sequence of roots.""" - game = test_case.factory() - actual = [_get_path_of_action_labels(sg.root) for sg in game.subgames] - assert actual == test_case.roots - - -@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) -def test_subgame_parent_links(test_case: SubgameStructureTestCase): - """Each subgame's `parent` matches the expected parent path.""" +def test_get_subgame_roots_postorder_sequence(test_case: SubgameStructureTestCase): + """`Game.get_subgame_roots` produces the expected postorder sequence of + subgame-root Histories (children before parents).""" game = test_case.factory() - for sg in game.subgames: - path = tuple(_get_path_of_action_labels(sg.root)) - parent_path = ( - None if sg.parent is None - else tuple(_get_path_of_action_labels(sg.parent.root)) - ) - assert parent_path == test_case.parents[path] - - -@pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) -def test_subgame_children(test_case: SubgameStructureTestCase): - """Each subgame's `children` match the expected set of child paths.""" - game = test_case.factory() - actual = { - tuple(_get_path_of_action_labels(sg.root)): - {tuple(_get_path_of_action_labels(c.root)) for c in sg.children} - for sg in game.subgames - } - assert actual == test_case.children + assert game.get_subgame_roots() == test_case.roots @pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): - """`game.get_minimal_subgame(node)` returns the smallest subgame containing - the information set `node` belongs to.""" + """`game.get_minimal_subgame(history)` returns the History of the root of the + smallest subgame containing the information set `history` belongs to.""" game = test_case.factory() - expected_path_for_key = { - key: path - for path, keys in test_case.differences.items() + expected_root_for_key = { + key: root + for root, keys in test_case.differences.items() for key in keys } for player in game.players: for i, history in enumerate(game.get_infosets(player)): key = (player, i) selector = gbt.H.path(*history) - actual_path = tuple( - _get_path_of_action_labels(game.get_minimal_subgame(selector).root) - ) - assert actual_path == expected_path_for_key[key] + assert game.get_minimal_subgame(selector) == expected_root_for_key[key] @pytest.mark.parametrize("game_file, expected_node_data", [ From 22d75cffa37840d55c971f6351321fa226eb6326 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Sat, 5 Sep 2026 07:22:42 +0100 Subject: [PATCH 14/24] Migrate `layout_tree` to use histories --- doc/pygambit.api.rst | 11 +++++++ src/pygambit/game.pxi | 67 ++++++++++++++++++++++++++++++++++---- tests/test_tree_queries.py | 18 ++++++++++ 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index e3cc646c3e..d724fbf02e 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -17,6 +17,8 @@ Representation of games Outcome Node Branch + TreeLayout + TreeLayoutCoordinates Creating, reading, and writing games @@ -42,6 +44,15 @@ Creating, reading, and writing games Game.to_latex +Computing a tree layout for graphical display +.............................................. + +.. autosummary:: + :toctree: api/ + + layout_tree + + Transforming game trees ....................... diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 51086e9be3..3eabddc567 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -3265,22 +3265,75 @@ class Game: @dataclasses.dataclass -class NodeCoordinates: +class TreeLayoutCoordinates: + """The layout coordinates of a single node in a game tree, computed for + graphical display. + + .. versionchanged:: 17.0.0 + Renamed from `NodeCoordinates`. + """ level: int sublevel: int offset: float +class TreeLayout: + """The layout of a game's tree, computed for graphical display. + + Maps each node's History to its `TreeLayoutCoordinates`. + + .. versionadded:: 17.0.0 + """ + + def __init__(self, data: dict[tuple, TreeLayoutCoordinates]) -> None: + self._data = data + + def __repr__(self) -> str: + return f"TreeLayout({self._data!r})" + + def __len__(self) -> int: + return len(self._data) + + def __iter__(self) -> typing.Iterator[tuple]: + return iter(self._data) + + def __contains__(self, history: tuple) -> bool: + return history in self._data + + def __getitem__(self, history: tuple) -> TreeLayoutCoordinates: + return self._data[history] + + def items(self) -> typing.ItemsView[tuple, TreeLayoutCoordinates]: + return self._data.items() + + @cython.cfunc -def _layout_tree(game: Game) -> dict[Node, NodeCoordinates]: +def _layout_tree(game: Game) -> object: layout = CreateLayout(game.game) data = {} for node in game._all_nodes(): - data[node] = NodeCoordinates(deref(layout).GetNodeLevel(cython.cast(Node, node).node), - deref(layout).GetNodeSublevel(cython.cast(Node, node).node), - deref(layout).GetNodeOffset(cython.cast(Node, node).node)) - return data + data[_history_of(node)] = TreeLayoutCoordinates( + deref(layout).GetNodeLevel(cython.cast(Node, node).node), + deref(layout).GetNodeSublevel(cython.cast(Node, node).node), + deref(layout).GetNodeOffset(cython.cast(Node, node).node)) + return TreeLayout(data) -def layout_tree(game: Game) -> dict[Node, NodeCoordinates]: +def layout_tree(game: Game) -> TreeLayout: + """Computes the layout of `game`'s tree for graphical display. + + .. versionchanged:: 17.0.0 + Returns a `TreeLayout` (History-keyed) instead of a + `dict[Node, NodeCoordinates]`. + + Parameters + ---------- + game : Game + The game whose tree layout to compute. + + Returns + ------- + TreeLayout + The layout of `game`'s tree. + """ return _layout_tree(game) diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index e38c94b52b..ce3269df3f 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -622,3 +622,21 @@ def dfs(node: gbt.Node) -> typing.Iterator[gbt.Node]: expected = [games._node_history(node) for node in dfs(game_obj.root)] assert game_obj.get_histories(gbt.H.after()) == expected + + +def test_layout_tree(): + """`layout_tree` returns a `TreeLayout`, keyed by History, with one + `TreeLayoutCoordinates` entry per node of the game.""" + game = games.read_from_file("basic_extensive_game.efg") + layout = gbt.layout_tree(game) + + assert len(layout) == len(game.get_histories(gbt.H.after())) + for history in game.get_histories(gbt.H.after()): + assert history in layout + coordinates = layout[history] + assert isinstance(coordinates, gbt.TreeLayoutCoordinates) + assert isinstance(coordinates.level, int) + assert isinstance(coordinates.sublevel, int) + assert isinstance(coordinates.offset, float) + + assert set(layout) == set(game.get_histories(gbt.H.after())) From 5823ae868cd8ca0b20d8b5d23135bf3f5ceb12ad Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Sat, 5 Sep 2026 07:41:06 +0100 Subject: [PATCH 15/24] Remove `Game.root` from public API --- doc/pygambit.api.rst | 1 - src/pygambit/game.pxi | 15 +-- tests/games.py | 25 ++--- tests/test_behavspt_profiles.py | 2 +- tests/test_file.py | 6 +- tests/test_game_resolve.py | 2 +- tests/test_outcome_mutations.py | 20 ++-- tests/test_outcome_queries.py | 24 +++-- tests/test_profile_invalidation.py | 6 +- tests/test_strategic_queries.py | 6 +- tests/test_tree_mutations.py | 158 +++++++++++++++-------------- tests/test_tree_queries.py | 26 +++-- 12 files changed, 151 insertions(+), 140 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index d724fbf02e..c8f2ef0650 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -116,7 +116,6 @@ Information about the game Game.max_payoff Game.get_min_payoff Game.get_max_payoff - Game.root Game.get_infosets Game.get_events Game.get_strategies diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 3eabddc567..b74131b936 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -529,14 +529,9 @@ class Game: """An iterator over the contingencies in the game.""" return pygambit.gameiter.Contingencies(self) - @property - def root(self) -> Node: - """The root node of the game. - - Raises - ------ - UndefinedOperationError - If the game does not hae a tree representation. + def _root(self) -> Node: + """The root node of the game. Not part of the public API; the public + equivalent is the trivial empty History, `()`, or `H.path()` as a Selector. """ if not self.is_tree: raise UndefinedOperationError( @@ -571,7 +566,7 @@ class Game: current = [n for n in candidates if _matches_suffix(n, op.labels)] continue if current is None: - current = [self.root] + current = [self._root()] if isinstance(op, _PathStep): for step in op.steps: current = ( @@ -591,7 +586,7 @@ class Game: else: raise TypeError(f"_get_nodes(): unknown selector op {op!r}") if current is None: - current = [self.root] + current = [self._root()] return current def _get_histories(self, selector: Selector) -> list[tuple]: diff --git a/tests/games.py b/tests/games.py index 68e1a48534..77a78045a3 100644 --- a/tests/games.py +++ b/tests/games.py @@ -19,13 +19,14 @@ def all_infosets(game: gbt.Game) -> list[tuple]: def all_nodes(game: gbt.Game) -> list[gbt.Node]: """Every node in the game, in depth-first traversal order, matching - `Game.nodes` before its removal (17.0.0). Built from `.root`/`.children`, - the surviving Node-navigation primitives.""" + `Game.nodes` before its removal (17.0.0). Built from `.children`, the + surviving Node-navigation primitive, starting from `node_at_history(game, ())` + (the root) since `Game.root` was itself removed (17.0.0).""" def _walk(node: gbt.Node): yield node for child in node.children: yield from _walk(child) - return list(_walk(game.root)) + return list(_walk(node_at_history(game, ()))) def player_infosets(game: gbt.Game, player: str) -> list[tuple]: @@ -96,17 +97,17 @@ def infoset_number(node: gbt.Node) -> int: } -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 node_at_history(game: gbt.Game, history: tuple) -> gbt.Node: + """The Node reached by following `history` (a tuple of action labels) from + the root -- the standard way to obtain a `Node` now that `Game.root` is + removed (17.0.0): `H.path(*history)` always resolves to exactly one node.""" + return game._get_nodes(gbt.H.path(*history))[0] 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)]) + node = node_at_history(game, _INFOSET_LABEL_HISTORIES[(game.title, label)]) assert node.player == player return node @@ -115,7 +116,7 @@ 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)]) + return node_at_history(game, _INFOSET_LABEL_HISTORIES[(game.title, label)]) def _node_history(node: gbt.Node) -> tuple: @@ -430,7 +431,7 @@ def _create_kuhn_poker_efg_nonterm_outcomes() -> gbt.Game: payoffs_by_key[f"{player} calls and loses"] = (-1, 4) if player == "Alice" else (4, -1) nodes_by_key = {key: [] for key in payoffs_by_key} - nodes_by_key["Ante"].append(g.root) + nodes_by_key["Ante"].append(node_at_history(g, ())) def collect_nodes(term_node): def get_path(node): @@ -827,7 +828,7 @@ def gbt_game(self): players=[str(p) for p in self.players], title=f"Binary Tree Game (L={self.level})", ) - self.create_binary_tree(g, g.root, (), 0, 0, self.level) + self.create_binary_tree(g, node_at_history(g, ()), (), 0, 0, self.level) for n in all_nodes(g): if n.children and n.children["L"].children: left = n.children["L"] diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index 20955aa1c7..6433f9280b 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -20,7 +20,7 @@ def _branching_game(): that removing an action can make a whole subtree's information set unreachable. """ game = gbt.Game.new_tree(players=["P1", "P2"]) - root = game.root + root = games.node_at_history(game, ()) game.append_move(gbt.H.path(), "P1", ["L", "R"]) left = root.children["L"] right = root.children["R"] diff --git a/tests/test_file.py b/tests/test_file.py index 95e71ee2bd..f79d7db367 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.actions) == ["_1", "_2"] + assert g.get_actions(gbt.H.path()) == ["_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.actions) == ["l_1", "l_2"] + assert g.get_actions(gbt.H.path()) == ["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.actions) == ["l_1", "l_2"] + assert g.get_actions(gbt.H.path()) == ["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 cee0317ce4..3b62fc684e 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -54,7 +54,7 @@ def test_resolve_node_mismatch(): game1 = gbt.Game.new_tree() game2 = games.read_from_file("sample_extensive_game.efg") with pytest.raises(gbt.MismatchError): - game1._resolve_node(game2.root, "test_resolve_node_mismatch") + game1._resolve_node(games.node_at_history(game2, ()), "test_resolve_node_mismatch") @pytest.mark.parametrize( diff --git a/tests/test_outcome_mutations.py b/tests/test_outcome_mutations.py index 191a3b17b3..63944fb24d 100644 --- a/tests/test_outcome_mutations.py +++ b/tests/test_outcome_mutations.py @@ -2,11 +2,13 @@ import pygambit as gbt +from . import games + def test_make_outcome_attaches_to_all_given_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + up, middle, down = games.node_at_history(game, ()).children outcome = game.make_outcome( gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1, "Bob": -1}, "shared" ) @@ -20,7 +22,7 @@ def test_make_outcome_attaches_to_all_given_nodes(): def test_make_outcome_accepts_selector(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + up, middle, down = games.node_at_history(game, ()).children outcome = game.make_outcome(gbt.H.path("U"), {"Alice": 1, "Bob": -1}, "shared") assert up.outcome == outcome assert not middle.outcome @@ -30,7 +32,7 @@ def test_make_outcome_accepts_selector(): def test_make_outcome_accepts_selector_matching_several_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + up, middle, down = games.node_at_history(game, ()).children outcome = game.make_outcome(gbt.H.plays, {"Alice": 1, "Bob": -1}, "shared") assert up.outcome == outcome assert middle.outcome == outcome @@ -42,7 +44,7 @@ def test_make_outcome_accepts_grouped_selector_pooled(): receives the same outcome, regardless of grouping.""" game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + up, middle, down = games.node_at_history(game, ()).children outcome = game.make_outcome( gbt.H.path(...).by(lambda h: h[0]), {"Alice": 1, "Bob": -1}, "shared" ) @@ -56,7 +58,7 @@ def test_make_outcome_error_location_not_a_selector(): game = gbt.Game.new_tree(["Alice"]) game.append_move(gbt.H.path(), "Alice", ["U", "D"]) with pytest.raises(TypeError): - game.make_outcome(game.root.children["U"], {"Alice": 1}, "w") + game.make_outcome(games.node_at_history(game, ("U",)), {"Alice": 1}, "w") with pytest.raises(TypeError): game.make_outcome(("U",), {"Alice": 1}, "w") @@ -132,7 +134,7 @@ def test_make_outcome_payoffs_naming_player_twice_raises(): def test_make_outcome_null_accepts_selector(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + up, middle, down = games.node_at_history(game, ()).children game.make_outcome( gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1, "Bob": -1}, "shared" ) @@ -147,7 +149,7 @@ def test_make_outcome_null_error_location_not_a_selector(): game = gbt.Game.new_tree(["Alice"]) game.append_move(gbt.H.path(), "Alice", ["U", "D"]) with pytest.raises(TypeError): - game.make_outcome_null(game.root.children["U"]) + game.make_outcome_null(games.node_at_history(game, ("U",))) with pytest.raises(TypeError): game.make_outcome_null(("U",)) @@ -175,7 +177,7 @@ def test_make_outcome_null_removes_fully_orphaned_outcome(): def test_make_outcome_null_keeps_partially_referenced_outcome(): game = gbt.Game.new_tree(["Alice"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - middle = game.root.children["M"] + middle = games.node_at_history(game, ("M",)) game.make_outcome(gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1}, "shared") outcome_count = len(game.outcomes) game.make_outcome_null(gbt.H.path("U")) @@ -186,7 +188,7 @@ def test_make_outcome_null_keeps_partially_referenced_outcome(): def test_make_outcome_null_on_already_null_node_is_a_no_op(): game = gbt.Game.new_tree(["Alice"]) game.append_move(gbt.H.path(), "Alice", ["U", "D"]) - up = game.root.children["U"] + up = games.node_at_history(game, ("U",)) outcome_count = len(game.outcomes) game.make_outcome_null(gbt.H.path("U")) assert outcome_count == len(game.outcomes) diff --git a/tests/test_outcome_queries.py b/tests/test_outcome_queries.py index 863b793dc8..e731296953 100644 --- a/tests/test_outcome_queries.py +++ b/tests/test_outcome_queries.py @@ -9,16 +9,16 @@ def test_get_outcome(): """Test to ensure that we can retrieve an outcome for a given node""" game = games.read_from_file("basic_extensive_game.efg") assert ( - game.root.children["U1"].children["D2"].children["U3"].outcome + games.node_at_history(game, ("U1", "D2", "U3")).outcome == game.outcomes["Outcome 1"] ) - assert not game.root.outcome + assert not games.node_at_history(game, ()).outcome def test_make_outcome_null(): """Resetting a node's outcome to null leaves the node's outcome view falsy.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"].children["U2"].children["U3"] + node = games.node_at_history(game, ("U1", "U2", "U3")) game.make_outcome_null(gbt.H.path("U1", "U2", "U3")) assert not node.outcome @@ -26,7 +26,7 @@ def test_make_outcome_null(): def test_node_outcome_subscript_tracks_mutation(): """Indexing the outcome view reads/writes the outcome's payoffs, reflecting mutation.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"].children["D2"].children["U3"] + node = games.node_at_history(game, ("U1", "D2", "U3")) proxy = node.outcome player = "Player 1" proxy[player] = 7 @@ -36,7 +36,7 @@ def test_node_outcome_subscript_tracks_mutation(): def test_outcome_equality_is_symmetric(): """A node-anchored outcome view and the resolved Outcome compare equal from either side.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"].children["D2"].children["U3"] + node = games.node_at_history(game, ("U1", "D2", "U3")) proxy = node.outcome outcome = game.outcomes["Outcome 1"] assert proxy == outcome @@ -46,40 +46,42 @@ def test_outcome_equality_is_symmetric(): def test_null_outcome_label_is_none(): """The blessed nullity idiom: a node with no outcome has `outcome.label is None`.""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.outcome.label is None + assert games.node_at_history(game, ()).outcome.label is None def test_null_outcome_compares_unequal_to_itself(): """Null outcomes are unequal to everything, including another view of the same node's outcome; equality must not short-circuit on node identity.""" game = games.read_from_file("basic_extensive_game.efg") - assert (game.root.outcome == game.root.outcome) is False + root = games.node_at_history(game, ()) + assert (root.outcome == root.outcome) is False def test_null_outcome_reads_zero_payoffs(): """Reading a payoff through an unset node reports zero to every player of the game.""" game = games.read_from_file("basic_extensive_game.efg") + root = games.node_at_history(game, ()) for player in game.players: - assert game.root.outcome[player] == 0 + assert root.outcome[player] == 0 def test_null_outcome_payoff_write_raises(): game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.root.outcome["Player 1"] = 1 + games.node_at_history(game, ()).outcome["Player 1"] = 1 def test_null_outcome_label_write_raises(): game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.root.outcome.label = "Outcome 4" + games.node_at_history(game, ()).outcome.label = "Outcome 4" def test_null_outcome_number_is_none(): """The null outcome is not a member of the game's outcomes, so it has no number. -1 would be a valid index and would silently resolve to the last real outcome.""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.outcome.number is None + assert games.node_at_history(game, ()).outcome.number is None @pytest.mark.parametrize("label", games.VALID_LABELS) diff --git a/tests/test_profile_invalidation.py b/tests/test_profile_invalidation.py index 2aeef6e377..a0ffb77351 100644 --- a/tests/test_profile_invalidation.py +++ b/tests/test_profile_invalidation.py @@ -119,8 +119,8 @@ def test_mixed_behavior_profile_game_structure_changed(): # triggers error via __getitem__ next(profile.__iter__()) with pytest.raises(gbt.GameStructureChangedError): - profile.__setitem__(game.root, {}) + profile.__setitem__(gbt.H.path(), {}) with pytest.raises(gbt.GameStructureChangedError): - profile.set_mixed_action(game.root, {}) + profile.set_mixed_action(gbt.H.path(), {}) with pytest.raises(gbt.GameStructureChangedError): - profile.__getitem__(game.root) + profile.__getitem__(gbt.H.path()) diff --git a/tests/test_strategic_queries.py b/tests/test_strategic_queries.py index 861c5eba03..e6223dcc3d 100644 --- a/tests/test_strategic_queries.py +++ b/tests/test_strategic_queries.py @@ -12,10 +12,12 @@ def test_strategic_game_get_infosets(): _ = game.get_infosets(player) -def test_strategic_game_root(): +def test_strategic_game_get_histories_root_raises(): + """A bare `H.path()` (the root) still resolves through the same + tree-only guard as `H.after()`, just via a different internal path.""" game = gbt.Game.new_table([2, 2]) with pytest.raises(gbt.UndefinedOperationError): - _ = game.root + _ = game.get_histories(gbt.H.path()) def test_game_behav_profile_error(): diff --git a/tests/test_tree_mutations.py b/tests/test_tree_mutations.py index db8a481b93..be3e1807bd 100644 --- a/tests/test_tree_mutations.py +++ b/tests/test_tree_mutations.py @@ -50,7 +50,7 @@ def test_insert_move_error_duplicate_label(): 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"] + node = games.node_at_history(game, ("U1",)) assert node.actions game.delete_tree(gbt.H.path("U1")) with pytest.raises(AttributeError): @@ -60,15 +60,15 @@ def test_node_actions_becomes_undefined_when_truncated(): def test_node_delete_parent(): """Test to ensure deleting a parent node works""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] + node = games.node_at_history(game, ("U1",)) game.delete_parent(gbt.H.path("U1")) - assert game.root == node + assert games.node_at_history(game, ()) == node def test_node_delete_tree(): """Test to ensure deleting every child of a node works""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] + node = games.node_at_history(game, ("U1",)) game.delete_tree(gbt.H.path("U1")) assert len(node.children) == 0 @@ -111,8 +111,8 @@ def _subtrees_equal( def test_copy_tree_onto_nondescendent_terminal_node(): """Test copying a subtree to a non-descendent node.""" g = gbt.catalog.load("journals/ijgt/selten1975/fig1") - src_node = g.root.children["R"].children["L"] - dest_node = g.root.children["R"].children["R"] + src_node = games.node_at_history(g, ("R", "L")) + dest_node = games.node_at_history(g, ("R", "R")) g.copy_tree(gbt.H.path("R", "L"), gbt.H.path("R", "R")) @@ -122,8 +122,8 @@ def test_copy_tree_onto_nondescendent_terminal_node(): def test_copy_tree_onto_descendent_terminal_node(): """Test copying a subtree to a node that's a descendent of the original.""" g = gbt.catalog.load("journals/ijgt/selten1975/fig1") - src_node = g.root.children["R"] - dest_node = g.root.children["R"].children["L"].children["R"] + src_node = games.node_at_history(g, ("R",)) + dest_node = games.node_at_history(g, ("R", "L", "R")) g.copy_tree(gbt.H.path("R"), gbt.H.path("R", "L", "R")) @@ -161,8 +161,8 @@ def test_append_move_same_infoset_list_of_nodes(): """Test that nodes matched by a Selector are resolved in the same infoset.""" game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] + node1 = games.node_at_history(game, ("2", "1")) + node2 = games.node_at_history(game, ("1", "1")) matches = (("2", "1"), ("1", "1")) game.append_move( gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), "Player 3", ["B", "F"] @@ -176,8 +176,8 @@ def test_append_move_actions_list_of_nodes(): """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] + node1 = games.node_at_history(game, ("2", "1")) + node2 = games.node_at_history(game, ("1", "1")) matches = (("2", "1"), ("1", "1")) game.append_move( gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), @@ -192,8 +192,8 @@ def test_append_move_labels_list_of_nodes(): """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] + node1 = games.node_at_history(game, ("2", "1")) + node2 = games.node_at_history(game, ("1", "1")) matches = (("2", "1"), ("1", "1")) game.append_move( gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), @@ -275,8 +275,8 @@ def test_append_event_creates_single_event_list_of_nodes(): """Test that appending a Selector matching several nodes creates a single chance event.""" game = games.read_from_file("sample_extensive_game.efg") - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] + node1 = games.node_at_history(game, ("2", "1")) + node2 = games.node_at_history(game, ("1", "1")) matches = (("2", "1"), ("1", "1")) game.append_event( gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), @@ -289,7 +289,7 @@ def test_append_event_creates_single_event_list_of_nodes(): def test_append_event_sets_distribution(): """Test that the new event's actions carry the given probabilities.""" game = games.read_from_file("sample_extensive_game.efg") - node = game.root.children["1"].children["1"] + node = games.node_at_history(game, ("1", "1")) game.append_event(gbt.H.path("1", "1"), {"a": gbt.Rational(1, 4), "b": gbt.Rational(3, 4)}) assert list(node.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] @@ -343,7 +343,7 @@ def test_append_event_error_invalid_distribution(): def test_insert_event_actions_labeled(): """Test that the inserted event's actions are labeled according to `actions`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - node = game.root.children["L"].children["R"] + node = games.node_at_history(game, ("L", "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.player == "Chance" @@ -352,7 +352,7 @@ def test_insert_event_actions_labeled(): def test_insert_event_sets_distribution(): """Test that the inserted event's actions carry the given probabilities.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root + node = games.node_at_history(game, ()) game.insert_event(gbt.H.path(), {"a": gbt.Rational(1, 4), "b": gbt.Rational(3, 4)}) assert list(node.parent.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] @@ -409,7 +409,7 @@ def test_len_matches_expected_node_count(): direct_len = _n_nodes(game) assert direct_len == expected_node_count - assert direct_len == _count_subtree_nodes(game.root, True) + assert direct_len == _count_subtree_nodes(games.node_at_history(game, ()), True) def test_len_after_delete_tree(): @@ -417,7 +417,7 @@ def test_len_after_delete_tree(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = _n_nodes(game) - root_of_the_deleted_subtree = game.root.children["R"].children["L"] + root_of_the_deleted_subtree = games.node_at_history(game, ("R", "L")) number_of_deleted_nodes = _count_subtree_nodes(root_of_the_deleted_subtree, True) - 1 game.delete_tree(gbt.H.path("R", "L")) @@ -430,7 +430,7 @@ def test_len_after_delete_parent(): game = gbt.catalog.load("journals/ijgt/selten1975/fig2") initial_number_of_nodes = _n_nodes(game) - node_parent_to_delete = game.root.children["L"].children["L"] + node_parent_to_delete = games.node_at_history(game, ("L", "L")) number_of_node_ancestors = _count_subtree_nodes(node_parent_to_delete, True) number_of_parent_ancestors = _count_subtree_nodes(node_parent_to_delete.parent, True) @@ -459,7 +459,7 @@ def test_len_after_append_infoset(): game = gbt.catalog.load("journals/ijgt/selten1975/fig2") initial_number_of_nodes = _n_nodes(game) - infoset_to_modify = game.root.children["L"] + infoset_to_modify = games.node_at_history(game, ("L",)) number_of_infoset_actions = len(infoset_to_modify.actions) game.append_infoset(gbt.H.path("L", "L", "l"), gbt.H.path("L")) @@ -471,7 +471,7 @@ def test_len_after_set_move_actions_add(): """Verify the node count is correct after `set_move_actions` creates an action.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = _n_nodes(game) - infoset_to_modify = game.root.children["L"] # Player 2's infoset + infoset_to_modify = games.node_at_history(game, ("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"]) @@ -485,9 +485,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.members + for member in games.node_at_history(game, ()).members ) - remaining = [a for a in game.root.actions if a != "L"] + remaining = [a for a in games.node_at_history(game, ()).actions if a != "L"] game.set_move_actions(gbt.H.path(), remaining, drop=True) assert _n_nodes(game) == initial_number_of_nodes - nodes_to_delete @@ -508,7 +508,7 @@ def test_len_after_insert_move(): def test_insert_move_actions_labeled(): """Test that the inserted move's actions are labeled according to `actions`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - node = game.root.children["L"].children["R"] + node = games.node_at_history(game, ("L", "R")) game.insert_move(gbt.H.path("L", "R"), "Player 2", ["Up", "Down"]) assert list(node.parent.actions) == ["Up", "Down"] @@ -518,7 +518,7 @@ def test_len_after_insert_infoset(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = _n_nodes(game) - infoset_to_modify = game.root.children["L"] + infoset_to_modify = games.node_at_history(game, ("L",)) number_of_infoset_actions = len(infoset_to_modify.actions) game.insert_infoset(gbt.H.path("L", "R"), gbt.H.path("L")) @@ -530,7 +530,7 @@ def test_len_after_copy_tree(): """Verify the node count is correct after `copy_tree`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = _n_nodes(game) - src_node = game.root.children["R"].children["L"] + src_node = games.node_at_history(game, ("R", "L")) number_of_src_ancestors = _count_subtree_nodes(src_node, True) game.copy_tree(gbt.H.path("R", "L"), gbt.H.path("R", "R")) @@ -541,39 +541,39 @@ def test_len_after_copy_tree(): @pytest.mark.parametrize("label", games.VALID_LABELS) def test_node_label_valid(label): game = games.read_from_file("basic_extensive_game.efg") - game.root.label = label - assert game.root.label == label + games.node_at_history(game, ()).label = label + assert games.node_at_history(game, ()).label == label def test_node_label_duplicate_raises_valueerror(): game = games.read_from_file("basic_extensive_game.efg") - game.root.label = "shared" + games.node_at_history(game, ()).label = "shared" with pytest.raises(ValueError): - game.root.children["U1"].label = "shared" + games.node_at_history(game, ("U1",)).label = "shared" def test_node_label_empty_is_allowed(): """Node labels may be empty (unlike outcomes/players); multiple empties coexist.""" game = games.read_from_file("basic_extensive_game.efg") - game.root.label = "" - game.root.children["U1"].label = "" - assert game.root.label == "" - assert game.root.children["U1"].label == "" + games.node_at_history(game, ()).label = "" + games.node_at_history(game, ("U1",)).label = "" + assert games.node_at_history(game, ()).label == "" + assert games.node_at_history(game, ("U1",)).label == "" @pytest.mark.parametrize("label", games.INVALID_LABELS) def test_node_label_invalid_raises_valueerror(label): game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.root.label = label + games.node_at_history(game, ()).label = label @pytest.mark.parametrize("label", games.UNICODE_LABELS) def test_node_label_unicode_accepted(label): """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" game = games.read_from_file("basic_extensive_game.efg") - game.root.label = label - assert game.root.label == label + games.node_at_history(game, ()).label = label + assert games.node_at_history(game, ()).label == label def test_make_infoset_change_player_keeps_membership(): @@ -581,23 +581,23 @@ def test_make_infoset_change_player_keeps_membership(): membership.""" game = games.read_from_file("basic_extensive_game.efg") _, p2, *_ = game.players - members = list(game.root.members) + members = list(games.node_at_history(game, ()).members) game.make_infoset(games.selector_for_nodes(members), p2) - assert game.root.player == p2 - assert list(game.root.members) == members + assert games.node_at_history(game, ()).player == p2 + assert list(games.node_at_history(game, ()).members) == members def test_make_infoset_terminal_node_raises(): """All nodes must be decision nodes.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.make_infoset(gbt.H.path("U1", "U2", "U3"), game.root.player) + game.make_infoset(gbt.H.path("U1", "U2", "U3"), games.node_at_history(game, ()).player) def test_make_infoset_converts_chance_node(): """A chance node becomes a personal decision node, discarding its probabilities.""" game = games.read_from_file("stripped_down_poker.efg") - chance_node = game.root # the deal is a chance move + chance_node = games.node_at_history(game, ()) # the deal is a chance move personal = next(n for n in games.all_nodes(game) if n.children and n.player != "Chance") game.make_infoset(gbt.H.path(), personal.player) assert chance_node.player != "Chance" @@ -619,7 +619,9 @@ def test_make_infoset_empty_nodes_raises(): """`nodes` must be nonempty.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.make_infoset(gbt.H.path(...).filter(lambda h: False), game.root.player) + game.make_infoset( + gbt.H.path(...).filter(lambda h: False), games.node_at_history(game, ()).player + ) def test_make_infoset_strategic_game_raises(): @@ -633,11 +635,11 @@ def test_set_move_actions_add_preserves_existing_action_order(): """New actions may be declared at any position; the existing actions' relative order is preserved.""" game = games.read_from_file("basic_extensive_game.efg") - labels = list(game.root.actions) + labels = list(games.node_at_history(game, ()).actions) game.set_move_actions(gbt.H.path(), labels + ["end"]) - assert list(game.root.actions)[:-1] == labels + assert list(games.node_at_history(game, ()).actions)[:-1] == labels game.set_move_actions(gbt.H.path(), ["front"] + labels + ["end"]) - assert list(game.root.actions)[1:-1] == labels + assert list(games.node_at_history(game, ()).actions)[1:-1] == labels @pytest.mark.parametrize( @@ -654,8 +656,8 @@ def test_make_event_sets_probabilities(inprobs, outprobs): """ game = games.read_from_file("stripped_down_poker.efg") game.make_event(gbt.H.path(), inprobs, "Deal") - probs = game.root.action_probs - for action, prob in zip(game.root.actions, outprobs, strict=True): + probs = games.node_at_history(game, ()).action_probs + for action, prob in zip(games.node_at_history(game, ()).actions, outprobs, strict=True): assert probs[action] == prob @@ -670,7 +672,7 @@ def test_make_event_probs_not_a_mapping_raises_typeerror(probs): def test_make_event_pools_nodes_from_different_infosets(): """Nodes in distinct information sets are formed into a single event.""" game = games.read_from_file("stripped_down_poker.efg") - nodes = [game.root.children["King"], game.root.children["Queen"]] + nodes = [games.node_at_history(game, ("King",)), games.node_at_history(game, ("Queen",))] game.make_event(gbt.H.path(...), {"Bet": "1/4", "Fold": "3/4"}, "Coin") assert nodes[0] in nodes[1].members assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] @@ -691,7 +693,7 @@ 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 = game.root.children["King"] + node = games.node_at_history(game, ("King",)) game.make_event(gbt.H.path("King"), {"Bet": "1/4", "Fold": "3/4"}) assert node.player == "Chance" assert list(node.action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] @@ -730,7 +732,7 @@ def test_make_event_label_reused_when_fully_absorbed(): event's members are absorbed into the new one; the old event is not left behind. """ game = games.read_from_file("stripped_down_poker.efg") - nodes = [game.root.children["King"], game.root.children["Queen"]] + nodes = [games.node_at_history(game, ("King",)), games.node_at_history(game, ("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] in nodes[1].members @@ -762,10 +764,10 @@ def _bagwell_p2_nodes(game: gbt.Game) -> tuple[gbt.Node, gbt.Node, gbt.Node, gbt can produce -- each with two members and actions ("S", "C"). Returns (A, B, C, D) with {A, B} the members of one and {C, D} of the other. """ - return (game.root.children["S"].children["s"], - game.root.children["C"].children["s"], - game.root.children["S"].children["c"], - game.root.children["C"].children["c"]) + return (games.node_at_history(game, ("S", "s")), + games.node_at_history(game, ("C", "s")), + games.node_at_history(game, ("S", "c")), + games.node_at_history(game, ("C", "c"))) def test_make_infoset_cherry_pick_leaves_rumps(): @@ -824,8 +826,8 @@ def test_make_infoset_across_different_source_players(): game.append_move(gbt.H.path(), "1", ["a", "b"]) game.append_move(gbt.H.path("a"), "2", ["a", "b"]) # player 2 game.append_move(gbt.H.path("b"), "3", ["a", "b"]) # player 3 - n2 = game.root.children["a"] - n3 = game.root.children["b"] + n2 = games.node_at_history(game, ("a",)) + n3 = games.node_at_history(game, ("b",)) assert n2.player == "2" assert n3.player == "3" game.make_infoset(gbt.H.path(...), "1") @@ -837,22 +839,22 @@ def test_make_infoset_across_different_source_players(): @pytest.mark.parametrize("label", games.VALID_LABELS) def test_action_label(label: str): game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.actions)) + action = next(iter(games.node_at_history(game, ()).actions)) game.relabel_actions(gbt.H.path(), {action: label}) - assert label in game.root.actions + assert label in games.node_at_history(game, ()).actions @pytest.mark.parametrize("label", games.INVALID_LABELS) def test_action_label_invalid_raises_valueerror(label: str): game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.actions)) + action = next(iter(games.node_at_history(game, ()).actions)) with pytest.raises(ValueError): game.relabel_actions(gbt.H.path(), {action: label}) def test_relabel_action_empty_raises_valueerror(): game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.actions)) + action = next(iter(games.node_at_history(game, ()).actions)) with pytest.raises(ValueError): game.relabel_actions(gbt.H.path(), {action: ""}) @@ -869,7 +871,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.actions) == ["Queen", "King"] + assert list(games.node_at_history(game, ()).actions) == ["Queen", "King"] def test_relabel_actions_duplicate_targets_raises_valueerror(): @@ -890,7 +892,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.actions) == ["Ace", "Queen"] + assert list(games.node_at_history(game, ()).actions) == ["Ace", "Queen"] def test_relabel_actions_failure_leaves_game_unchanged(): @@ -900,7 +902,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.actions) == ["King", "Queen"] + assert list(games.node_at_history(game, ()).actions) == ["King", "Queen"] def test_relabel_actions_scope_is_the_information_set(): @@ -964,7 +966,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 = games.node_at_history(game, ("x",)) members = list(infoset.members) children_before = [{label: member.children[label] for label in ("a", "b", "c")} for member in members] @@ -985,7 +987,7 @@ def test_set_move_actions_add_drop_and_reorder_together(): assert _n_nodes(game) == nodes_before - 3 + 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 + assert len(games.node_at_history(game, ("Queen", "Bet")).members) == 1 def test_set_move_actions_unconfirmed_drop_and_disabled_add_raise(): @@ -1029,8 +1031,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.actions) == ["b", "c"] - assert len(game.root.members) == 1 + assert list(games.node_at_history(game, ()).actions) == ["b", "c"] + assert len(games.node_at_history(game, ()).members) == 1 assert _n_nodes(game) == 3 @@ -1038,16 +1040,18 @@ def test_set_event_actions_reorder_carries_probabilities(): game = games.create_stripped_down_poker_efg() game.set_event_actions(gbt.H.path(), {"King": "3/4", "Queen": "1/4"}) game.set_event_actions(gbt.H.path(), {"Queen": "1/4", "King": "3/4"}) - assert list(game.root.actions) == ["Queen", "King"] - assert game.root.action_probs == {"Queen": gbt.Rational(1, 4), "King": gbt.Rational(3, 4)} + assert list(games.node_at_history(game, ()).actions) == ["Queen", "King"] + assert games.node_at_history(game, ()).action_probs == { + "Queen": gbt.Rational(1, 4), "King": gbt.Rational(3, 4) + } def test_set_event_actions_add_with_probs_mapping(): game = games.create_stripped_down_poker_efg() nodes_before = _n_nodes(game) game.set_event_actions(gbt.H.path(), {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) - assert list(game.root.actions) == ["Jack", "King", "Queen"] - assert game.root.action_probs == { + assert list(games.node_at_history(game, ()).actions) == ["Jack", "King", "Queen"] + assert games.node_at_history(game, ()).action_probs == { "Jack": gbt.Rational(1, 2), "King": gbt.Rational(1, 4), "Queen": gbt.Rational(1, 4) } assert _n_nodes(game) == nodes_before + 1 @@ -1056,8 +1060,8 @@ def test_set_event_actions_add_with_probs_mapping(): def test_set_event_actions_drop_with_probs_mapping(): game = games.create_stripped_down_poker_efg() game.set_event_actions(gbt.H.path(), {"King": 1}, drop=True) - assert list(game.root.actions) == ["King"] - assert game.root.action_probs == {"King": 1} + assert list(games.node_at_history(game, ()).actions) == ["King"] + assert games.node_at_history(game, ()).action_probs == {"King": 1} def test_set_event_actions_unconfirmed_drop_and_disabled_add_raise(): diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index ce3269df3f..3c9476a691 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -144,15 +144,17 @@ def test_get_player_requires_single_match(): def test_get_parent(): """Test to ensure that we can retrieve a parent node for a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].parent == game.root - assert game.root.parent is None + root = games.node_at_history(game, ()) + assert games.node_at_history(game, ("U1",)).parent == root + assert root.parent is None def test_get_prior_action(): """Test to ensure that we can retrieve the prior action for a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].prior_action == gbt.Branch(game.root, "U1") - assert game.root.prior_action is None + root = games.node_at_history(game, ()) + assert games.node_at_history(game, ("U1",)).prior_action == gbt.Branch(root, "U1") + assert root.prior_action is None def test_is_successor_of(): @@ -160,14 +162,16 @@ def test_is_successor_of(): successor of a supplied node """ game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].is_successor_of(game.root) - assert not game.root.is_successor_of(game.root.children["U1"]) + root = games.node_at_history(game, ()) + child = games.node_at_history(game, ("U1",)) + assert child.is_successor_of(root) + assert not root.is_successor_of(child) with pytest.raises(TypeError): - game.root.is_successor_of(9) + root.is_successor_of(9) with pytest.raises(TypeError): - game.root.is_successor_of("Test") + root.is_successor_of("Test") with pytest.raises(TypeError): - game.root.is_successor_of("Player 1") + root.is_successor_of("Player 1") def _get_path_of_action_labels(node: gbt.Node) -> list[str]: @@ -620,7 +624,9 @@ def dfs(node: gbt.Node) -> typing.Iterator[gbt.Node]: for child in node.children: yield from dfs(child) - expected = [games._node_history(node) for node in dfs(game_obj.root)] + expected = [ + games._node_history(node) for node in dfs(games.node_at_history(game_obj, ())) + ] assert game_obj.get_histories(gbt.H.after()) == expected From 1d3e2dcb0726037eb98505db1ae8609c48d7b530 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Sat, 5 Sep 2026 08:07:45 +0100 Subject: [PATCH 16/24] Remove unneeded members from Node --- doc/pygambit.api.rst | 3 --- src/pygambit/cli/common.py | 11 ++++++--- src/pygambit/game.pxi | 20 +++++++++------ src/pygambit/node.pxi | 35 +++++++++++---------------- tests/games.py | 43 +++++++++++++++++++-------------- tests/test_behav.py | 17 +++++++------ tests/test_behavspt_profiles.py | 4 +-- tests/test_outcome_mutations.py | 10 ++++---- tests/test_tree_mutations.py | 28 ++++++++++----------- tests/test_tree_queries.py | 28 +++++++++++---------- 10 files changed, 103 insertions(+), 96 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index c8f2ef0650..6e22f5a967 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -137,11 +137,8 @@ Information about the game :toctree: api/ Node.label - Node.game Node.outcome - Node.children Node.parent - Node.is_subgame_root Node.is_strategy_reachable Node.prior_action Node.members diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index 3392a19d90..8fe1b5e34e 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -275,10 +275,13 @@ def render_profile_detail( return _render_strategy_detail(profile, decimals) -def _name_or_number(obj) -> str: - # Gambit's Python API numbers players/strategies/infosets/actions from 0; - # the C++ tools display the underlying (1-based) engine numbering. - return obj.label if obj.label else str(obj.number + 1) +def _name_or_number(node: gbt.Node) -> str: + # Gambit's Python API numbers nodes from 0; the C++ tools display the + # underlying (1-based) engine numbering. `Node._number` is private (not part + # of the public API) but still Python-accessible, same as `Game._get_infosets` + # just below -- rendering needs the node's own engine number, which a History + # alone cannot provide. + return node.label if node.label else str(node._number() + 1) def _render_strategy_detail(profile: gbt.MixedStrategyProfile, decimals: int) -> str: diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index b74131b936..91b89d3f90 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -554,8 +554,8 @@ class Game: Internal: the `H` selector algebra's evaluator, interpreting the selector's ops in order, starting from the root, reusing `Node`'s - existing navigation (`.children`, `.plays`) rather than walking the - C++ tree directly. Not part of the public API yet -- used to resolve + existing (private) navigation (`_children()`, `_plays()`) rather than + walking the C++ tree directly. Not part of the public API yet -- used to resolve a `Selector`/`GroupedSelector` argument to `append_move`, `append_event`, `append_infoset`, and `make_outcome`. """ @@ -570,9 +570,13 @@ class Game: if isinstance(op, _PathStep): for step in op.steps: current = ( - [child for node in current for child in node.children] + [ + child + for node in current + for child in cython.cast(Node, node)._children() + ] if step is Ellipsis - else [node.children[step] for node in current] + else [cython.cast(Node, node)._children()[step] for node in current] ) elif isinstance(op, _PlaysStep): current = [ @@ -1668,7 +1672,7 @@ class Game: If `node` is an empty `str` or all spaces """ if isinstance(node, Node): - if node.game != self: + if cython.cast(Node, node)._game() != self: raise MismatchError(f"{funcname}(): {argname} must be part of the same game") return node elif isinstance(node, Selector): @@ -1918,7 +1922,7 @@ class Game: if len(set(actions)) != len(actions): raise ValueError("append_move(): action labels must be unique") resolved_nodes = self._resolve_nodes(nodes, "append_move", "nodes") - if any(len(n.children) > 0 for n in resolved_nodes): + if any(not cython.cast(Node, n)._is_terminal() for n in resolved_nodes): raise UndefinedOperationError("append_move(): `nodes` must be terminal nodes") resolved_node = cython.cast(Node, resolved_nodes[0]) @@ -1989,7 +1993,7 @@ class Game: ) 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): + if any(not cython.cast(Node, n)._is_terminal() 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, infoset_handle) @@ -2056,7 +2060,7 @@ class Game: if any(not label for label in action_labels): raise ValueError("append_event(): action labels must not be empty") resolved_nodes = self._resolve_nodes(nodes, "append_event", "nodes") - if any(len(n.children) > 0 for n in resolved_nodes): + if any(not cython.cast(Node, n)._is_terminal() for n in resolved_nodes): raise UndefinedOperationError("append_event(): `nodes` must be terminal nodes") resolved_node = cython.cast(Node, resolved_nodes[0]) diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 7ece65bc82..021b2def32 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -189,7 +189,7 @@ class Node: def __repr__(self) -> str: if self.label: - return f"Node(game={self.game}, label='{self.label}')" + return f"Node(game={self._game()}, label='{self.label}')" path = [] node = self while node.parent: @@ -197,7 +197,7 @@ class Node: cython.cast(Node, node).node.deref().GetPriorAction().deref().GetNumber() - 1 ) node = node.parent - return f"Node(game={self.game}, path={path})" + return f"Node(game={self._game()}, path={path})" def __eq__(self, other: typing.Any) -> bool: return ( @@ -228,21 +228,23 @@ class Node: def label(self, value: str) -> None: self.node.deref().SetLabel(value.encode("utf-8")) - @property - def number(self) -> int: - """Returns the number of the node in its game. - Nodes are numbered starting with 0. + def _number(self) -> int: + """The number of the node in its game, numbered starting with 0. Not part + of the public API; a node's position is otherwise only exposed via History. """ return self.node.deref().GetNumber() - 1 - @property - def children(self) -> NodeChildren: - """The set of children of this node.""" + def _children(self) -> NodeChildren: + """The set of children of this node. Not part of the public API; the public + equivalent is a `Selector`'s `.path(..., ...)` wildcard step, e.g. + `game.get_histories(H.path(*history, ...))`. + """ return NodeChildren.wrap(self.node) - @property - def game(self) -> Game: - """Gets the ``Game`` to which the node belongs.""" + def _game(self) -> Game: + """The `Game` to which the node belongs. Not part of the public API; a + `Node` is otherwise only obtained already scoped to a particular `Game`. + """ return Game.wrap(self.node.deref().GetGame()) @cython.cfunc @@ -390,15 +392,6 @@ class Node: """ return self.node.deref().IsTerminal() - @property - def is_subgame_root(self) -> bool: - """Returns whether the node is the root of a proper subgame. - - .. versionchanged:: 16.1.0 - Changed to being a property instead of a member function. - """ - return self.node.deref().IsSubgameRoot() - @property def is_strategy_reachable(self) -> bool: """Returns whether this node is reachable by any pure strategy profile. diff --git a/tests/games.py b/tests/games.py index 77a78045a3..a1d7ae274b 100644 --- a/tests/games.py +++ b/tests/games.py @@ -19,14 +19,20 @@ def all_infosets(game: gbt.Game) -> list[tuple]: def all_nodes(game: gbt.Game) -> list[gbt.Node]: """Every node in the game, in depth-first traversal order, matching - `Game.nodes` before its removal (17.0.0). Built from `.children`, the - surviving Node-navigation primitive, starting from `node_at_history(game, ())` - (the root) since `Game.root` was itself removed (17.0.0).""" - def _walk(node: gbt.Node): - yield node - for child in node.children: - yield from _walk(child) - return list(_walk(node_at_history(game, ()))) + `Game.nodes` before its removal (17.0.0).""" + return game._all_nodes() + + +def children_histories(game: gbt.Game, history: tuple) -> list[tuple]: + """The Histories of the children of the node at `history` -- the standard way + to enumerate a node's children now that `Node.children` is removed (17.0.0).""" + return [(*history, action) for action in game.get_actions(gbt.H.path(*history))] + + +def children_of(game: gbt.Game, history: tuple) -> list[gbt.Node]: + """The children of the node at `history`, as real Nodes -- for callers that + need actual `Node` objects (e.g. `.members`/`.player`), not just Histories.""" + return [node_at_history(game, h) for h in children_histories(game, history)] def player_infosets(game: gbt.Game, player: str) -> list[tuple]: @@ -39,7 +45,7 @@ def player_infosets(game: gbt.Game, player: str) -> list[tuple]: 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).""" - game = node.game + game = node._game() members = set(game.get_members(selector_for_node(node))) for i, history in enumerate(game.get_infosets(node.player)): if history in members: @@ -400,7 +406,7 @@ def bet(player, payoffs, pot): (-2, 2): "BOb wins 2", } nodes_by_payoff = {payoffs: [] for payoffs in payoff_labels} - for term_node in [n for n in all_nodes(g) if not n.children]: + for term_node in [n for n in all_nodes(g) if not g.get_actions(selector_for_node(n))]: nodes_by_payoff[calculate_payoffs(term_node)].append(term_node) for payoffs, nodes in nodes_by_payoff.items(): @@ -467,7 +473,7 @@ def get_path(node): tmp = "wins" if winner == "Bob" else "loses" nodes_by_key[f"Bob calls and {tmp}"].append(n) - for term_node in [n for n in all_nodes(g) if not n.children]: + for term_node in [n for n in all_nodes(g) if not g.get_actions(selector_for_node(n))]: collect_nodes(term_node) for key, nodes in nodes_by_key.items(): @@ -808,7 +814,7 @@ def reduced_strategies(self): self.set_size_of_rsf(rs) return rs - def create_binary_tree(self, g, node, path, whose_turn, depth, max_depth): + def create_binary_tree(self, g, path, whose_turn, depth, max_depth): # whose_turn cycles through 0,1,n_players-1; current player is str(whose_turn + 1) if depth == max_depth: g.make_outcome( @@ -820,20 +826,21 @@ def create_binary_tree(self, g, node, path, whose_turn, depth, max_depth): g.append_move(gbt.H.path(*path), current_player, ["L", "R"]) whose_turn = (whose_turn + 1) % self.n_players - for label, child in zip(["L", "R"], node.children, strict=True): - self.create_binary_tree(g, child, (*path, label), whose_turn, depth + 1, max_depth) + for label in ["L", "R"]: + self.create_binary_tree(g, (*path, label), whose_turn, depth + 1, max_depth) def gbt_game(self): g = gbt.Game.new_tree( players=[str(p) for p in self.players], title=f"Binary Tree Game (L={self.level})", ) - self.create_binary_tree(g, node_at_history(g, ()), (), 0, 0, self.level) + self.create_binary_tree(g, (), 0, 0, self.level) for n in all_nodes(g): - if n.children and n.children["L"].children: - left = n.children["L"] + history = _node_history(n) + if g.get_actions(gbt.H.path(*history)) and g.get_actions(gbt.H.path(*history, "L")): + left, right = children_of(g, history) g.make_infoset( - selector_for_nodes(list(left.members) + [n.children["R"]]), + selector_for_nodes(list(left.members) + [right]), left.player, ) return g diff --git a/tests/test_behav.py b/tests/test_behav.py index e10c70c7aa..9377a20fe9 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -935,9 +935,9 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): ) for node in games.all_nodes(game): - if not node.children: - continue history = games._node_history(node) + if not game.get_actions(gbt.H.path(*history)): + continue if infoset_probs[gbt.H.path(*history)] == 0: assert beliefs[history] is None else: @@ -1045,15 +1045,16 @@ 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 games.all_nodes(game): - if not node.children or node.player == "Chance": + history = games._node_history(node) + if not game.get_actions(gbt.H.path(*history)) or node.player == "Chance": continue expected_val = 0 - node_prob = realiz_probs[games._node_history(node)] + node_prob = realiz_probs[history] player_node_values = node_values[node.player] - for child in node.children: - prob = realiz_probs[games._node_history(child)] / node_prob - expected_val += prob * player_node_values[games._node_history(child)] - assert player_node_values[games._node_history(node)] == expected_val + for child_history in games.children_histories(game, history): + prob = realiz_probs[child_history] / node_prob + expected_val += prob * player_node_values[child_history] + assert player_node_values[history] == expected_val @pytest.mark.parametrize( diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index 6433f9280b..8f0f1b97b8 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -22,8 +22,8 @@ def _branching_game(): game = gbt.Game.new_tree(players=["P1", "P2"]) root = games.node_at_history(game, ()) game.append_move(gbt.H.path(), "P1", ["L", "R"]) - left = root.children["L"] - right = root.children["R"] + left = games.node_at_history(game, ("L",)) + right = games.node_at_history(game, ("R",)) game.append_move(gbt.H.path("L"), "P2", ["A", "B"]) game.append_move(gbt.H.path("R"), "P2", ["A", "B"]) return game, root, left, right diff --git a/tests/test_outcome_mutations.py b/tests/test_outcome_mutations.py index 63944fb24d..b96e187a84 100644 --- a/tests/test_outcome_mutations.py +++ b/tests/test_outcome_mutations.py @@ -8,7 +8,7 @@ def test_make_outcome_attaches_to_all_given_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = games.node_at_history(game, ()).children + up, middle, down = games.children_of(game, ()) outcome = game.make_outcome( gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1, "Bob": -1}, "shared" ) @@ -22,7 +22,7 @@ def test_make_outcome_attaches_to_all_given_nodes(): def test_make_outcome_accepts_selector(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = games.node_at_history(game, ()).children + up, middle, down = games.children_of(game, ()) outcome = game.make_outcome(gbt.H.path("U"), {"Alice": 1, "Bob": -1}, "shared") assert up.outcome == outcome assert not middle.outcome @@ -32,7 +32,7 @@ def test_make_outcome_accepts_selector(): def test_make_outcome_accepts_selector_matching_several_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = games.node_at_history(game, ()).children + up, middle, down = games.children_of(game, ()) outcome = game.make_outcome(gbt.H.plays, {"Alice": 1, "Bob": -1}, "shared") assert up.outcome == outcome assert middle.outcome == outcome @@ -44,7 +44,7 @@ def test_make_outcome_accepts_grouped_selector_pooled(): receives the same outcome, regardless of grouping.""" game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = games.node_at_history(game, ()).children + up, middle, down = games.children_of(game, ()) outcome = game.make_outcome( gbt.H.path(...).by(lambda h: h[0]), {"Alice": 1, "Bob": -1}, "shared" ) @@ -134,7 +134,7 @@ def test_make_outcome_payoffs_naming_player_twice_raises(): def test_make_outcome_null_accepts_selector(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = games.node_at_history(game, ()).children + up, middle, down = games.children_of(game, ()) game.make_outcome( gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1, "Bob": -1}, "shared" ) diff --git a/tests/test_tree_mutations.py b/tests/test_tree_mutations.py index be3e1807bd..db58a77b6d 100644 --- a/tests/test_tree_mutations.py +++ b/tests/test_tree_mutations.py @@ -70,7 +70,7 @@ def test_node_delete_tree(): game = games.read_from_file("basic_extensive_game.efg") node = games.node_at_history(game, ("U1",)) game.delete_tree(gbt.H.path("U1")) - assert len(node.children) == 0 + assert len(node._children()) == 0 def test_node_copy_nonterminal(): @@ -86,24 +86,24 @@ def _subtrees_equal( recursion_stop_node: gbt.Node | None = None ) -> bool: if n1 == recursion_stop_node: - return not n2.children - if not n1.children and not n2.children: + return not n2._children() + if not n1._children() and not n2._children(): if not n1.outcome and not n2.outcome: return True return n1.outcome == n2.outcome - if bool(n1.children) != bool(n2.children): + if bool(n1._children()) != bool(n2._children()): return False # now, both n1 and n2 are non-terminal # check that they are in the same infosets if n1 not in n2.members: return False # check that they have the same number of children - if len(n1.children) != len(n2.children): + if len(n1._children()) != len(n2._children()): return False return all( _subtrees_equal(c1, c2, recursion_stop_node) for (c1, c2) in zip( - n1.children, n2.children, strict=True + n1._children(), n2._children(), strict=True ) ) @@ -283,7 +283,7 @@ def test_append_event_creates_single_event_list_of_nodes(): {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 2)} ) assert node1 in node2.members - assert node1.children + assert node1._children() def test_append_event_sets_distribution(): @@ -388,9 +388,9 @@ def _count_subtree_nodes(start_node: gbt.Node, count_terminal: bool) -> int: count_terminal: bool Include or exclude terminal nodes from count """ - count = 1 if count_terminal or start_node.children else 0 + count = 1 if count_terminal or start_node._children() else 0 - for child in start_node.children: + for child in start_node._children(): count += _count_subtree_nodes(child, count_terminal) return count @@ -484,7 +484,7 @@ def test_len_after_set_move_actions_drop(): initial_number_of_nodes = _n_nodes(game) action_to_drop = "L" nodes_to_delete = sum( - _count_subtree_nodes(member.children[action_to_drop], True) + _count_subtree_nodes(member._children()[action_to_drop], True) for member in games.node_at_history(game, ()).members ) remaining = [a for a in games.node_at_history(game, ()).actions if a != "L"] @@ -598,7 +598,7 @@ 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 = games.node_at_history(game, ()) # the deal is a chance move - personal = next(n for n in games.all_nodes(game) if n.children and n.player != "Chance") + personal = next(n for n in games.all_nodes(game) if n._children() and n.player != "Chance") game.make_infoset(gbt.H.path(), personal.player) assert chance_node.player != "Chance" assert chance_node.player == personal.player @@ -941,7 +941,7 @@ def test_set_move_actions_drop_shrinks_actions_and_children(): remaining = list(infoset.actions)[1:] game.set_move_actions(games.selector_for_nodes([node]), remaining, drop=True) assert len(infoset.actions) == action_count - 1 - assert len(node.children) == action_count - 1 + assert len(node._children()) == action_count - 1 def test_set_move_actions_cannot_remove_the_only_action(): @@ -968,12 +968,12 @@ def test_set_move_actions_reorder_carries_subtrees(): ) infoset = games.node_at_history(game, ("x",)) members = list(infoset.members) - children_before = [{label: member.children[label] for label in ("a", "b", "c")} + children_before = [{label: member._children()[label] for label in ("a", "b", "c")} for member in members] game.set_move_actions(gbt.H.path("x"), ["c", "a", "b"]) assert list(infoset.actions) == ["c", "a", "b"] for member, children in zip(members, children_before, strict=True): - assert list(member.children) == [children["c"], children["a"], children["b"]] + assert list(member._children()) == [children["c"], children["a"], children["b"]] def test_set_move_actions_add_drop_and_reorder_together(): diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index 3c9476a691..089a36b849 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -203,7 +203,11 @@ class SubgameRootsTestCase: # Empty Game # ------------------------------------------------------------------------ pytest.param( - SubgameRootsTestCase(factory=gbt.Game.new_tree, expected_paths=[[]]), + # `GetSubgames()` returns no roots at all for a single-node (terminal-root) game, + # unlike `IsSubgameRoot()` (which special-cases it as trivially its own subgame) -- + # a known, narrow C++-core discrepancy (`GameTreeRep::GetSubgameData()`'s early + # return for `m_root->IsTerminal()`), not something to paper over here. + SubgameRootsTestCase(factory=gbt.Game.new_tree, expected_paths=[]), id="empty_tree" ), # ------------------------------------------------------------------------ @@ -297,13 +301,13 @@ class SubgameRootsTestCase: @pytest.mark.parametrize("test_case", SUBGAME_ROOTS_CASES) def test_subgame_roots(test_case: SubgameRootsTestCase): """ - Tests that the set of nodes marked as subgame roots matches the expected - set of paths (Action Labels from Root -> Node). + Tests that `Game.get_subgame_roots` matches the expected set of paths + (Action Labels from Node -> Root, matching `_get_path_of_action_labels`'s + convention -- `get_subgame_roots` itself returns Histories, Root -> Node). """ game = test_case.factory() - actual_roots = [node for node in games.all_nodes(game) if node.is_subgame_root] - actual_paths = [_get_path_of_action_labels(node) for node in actual_roots] + actual_paths = [list(reversed(history)) for history in game.get_subgame_roots()] assert sorted(actual_paths) == sorted(test_case.expected_paths) @@ -469,7 +473,7 @@ def test_node_own_prior_action_non_terminal(game_file, expected_node_data): actual_node_data = [] for node in games.all_nodes(game): - if not node.children: + if not game.get_actions(games.selector_for_node(node)): assert node.own_prior_action is None, ( f"Terminal node at {_get_path_of_action_labels(node)} must be None" ) @@ -619,14 +623,12 @@ def test_get_histories_after_iteration_order(game_obj: gbt.Game): """`Game.get_histories(H.after())` -- the public replacement for the removed `Game.nodes` -- produces nodes in depth-first traversal order. """ - def dfs(node: gbt.Node) -> typing.Iterator[gbt.Node]: - yield node - for child in node.children: - yield from dfs(child) + def dfs(history: tuple) -> typing.Iterator[tuple]: + yield history + for action in game_obj.get_actions(gbt.H.path(*history)): + yield from dfs((*history, action)) - expected = [ - games._node_history(node) for node in dfs(games.node_at_history(game_obj, ())) - ] + expected = list(dfs(())) assert game_obj.get_histories(gbt.H.after()) == expected From 3ec960d663d695093002e0cfbfedcdec883b446b Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Sat, 5 Sep 2026 08:39:22 +0100 Subject: [PATCH 17/24] Remove tests that unnecessarily manipulate Node --- tests/games.py | 8 + tests/test_behav.py | 53 ++++-- tests/test_behavspt_profiles.py | 12 +- tests/test_outcome_mutations.py | 41 ++-- tests/test_tree_mutations.py | 324 ++++++++++++++++---------------- 5 files changed, 225 insertions(+), 213 deletions(-) diff --git a/tests/games.py b/tests/games.py index a1d7ae274b..5e5dca19b0 100644 --- a/tests/games.py +++ b/tests/games.py @@ -144,6 +144,14 @@ def selector_for_nodes(nodes: list[gbt.Node]) -> gbt.Selector: return gbt.H.after().filter(lambda h: h[:] in histories) +def selector_for_histories(histories: list[tuple]) -> gbt.Selector: + """A `Selector` matching exactly the given (possibly scattered, mixed-depth) + Histories -- the History-only counterpart to `selector_for_nodes`, for + fixtures that already work in terms of `History` rather than `Node`.""" + histories = frozenset(histories) + 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`) diff --git a/tests/test_behav.py b/tests/test_behav.py index 9377a20fe9..56914ea274 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -26,6 +26,21 @@ def _set_action_probs(profile: gbt.MixedBehaviorProfile, probs: list, rational_f } +def _infoset_history(game: gbt.Game, label: str) -> tuple: + """The History of the representative member of the infoset historically + identified by `label`, matching the removed `Infoset`'s by-label lookup.""" + return games._INFOSET_LABEL_HISTORIES[(game.title, label)] + + +def _member_selector(game: gbt.Game, label: str) -> gbt.Selector: + """A `Selector` for some member of the infoset historically identified by + `label` -- not necessarily the representative one, so profile indexing is + verified to work from any member, not just the canonical one.""" + history = _infoset_history(game, label) + member = next(iter(game.get_members(gbt.H.path(*history)))) + return gbt.H.path(*member) + + @pytest.mark.parametrize( "game,rational_flag,payoffs", [ @@ -164,10 +179,10 @@ def test_profile_indexing_by_player_infoset_action_reference( rational_flag: bool, ): profile = game.mixed_behavior_profile(rational=rational_flag) - infoset = games.find_infoset(game, player_label, infoset_label) - node = next(iter(infoset.members)) + history = _infoset_history(game, infoset_label) + assert game.get_player(gbt.H.path(*history)) == player_label prob = gbt.Rational(prob) if rational_flag else prob - assert profile[games.selector_for_node(node)][action_label] == prob + assert profile[_member_selector(game, infoset_label)][action_label] == prob @pytest.mark.parametrize( @@ -229,10 +244,11 @@ def test_profile_indexing_by_selector_reference( """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) - selector = games.selector_for_node(next(iter(infoset.members))) + history = _infoset_history(game, infoset_label) + assert game.get_player(gbt.H.path(*history)) == player_label + selector = _member_selector(game, infoset_label) probs = [gbt.Rational(prob) for prob in probs] if rational_flag else probs - expected = dict(zip(infoset.actions, probs, strict=True)) + expected = dict(zip(game.get_actions(gbt.H.path(*history)), probs, strict=True)) assert profile[player_label][selector] == expected assert profile[selector] == expected @@ -322,8 +338,7 @@ def test_set_probabilities_action( """A sparse one-action distribution leaves the infoset's other actions at weight zero.""" profile = game.mixed_behavior_profile(rational=rational_flag) prob = gbt.Rational(prob) if rational_flag else prob - node = next(iter(games.find_infoset_in_game(game, infoset_label).members)) - selector = games.selector_for_node(node) + selector = _member_selector(game, infoset_label) profile[selector] = {action_label: prob} assert profile[selector][action_label] == prob @@ -399,10 +414,10 @@ def test_set_probabilities_infoset( profile = game.mixed_behavior_profile(rational=rational_flag) if rational_flag: probs = [gbt.Rational(p) for p in probs] - infoset = games.find_infoset(game, player_label, infoset_label) - node = next(iter(infoset.members)) - selector = games.selector_for_node(node) - expected = dict(zip(infoset.actions, probs, strict=True)) + history = _infoset_history(game, infoset_label) + assert game.get_player(gbt.H.path(*history)) == player_label + selector = _member_selector(game, infoset_label) + expected = dict(zip(game.get_actions(gbt.H.path(*history)), probs, strict=True)) profile[selector] = expected assert profile[selector] == expected @@ -734,8 +749,7 @@ def test_absent_minded_infoset_prob( game: gbt.Game, infoset_label: str, prob: str | float, rational_flag: bool ): profile = game.mixed_behavior_profile(rational=rational_flag) - node = next(iter(games.find_infoset_in_game(game, infoset_label).members)) - ip = profile.infoset_probs[games.selector_for_node(node)] + ip = profile.infoset_probs[_member_selector(game, infoset_label)] assert ip == (gbt.Rational(prob) if rational_flag else prob) @@ -934,8 +948,7 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): == best_response_value - infoset_action_values[action] ) - for node in games.all_nodes(game): - history = games._node_history(node) + for history in game.get_histories(gbt.H.after()): if not game.get_actions(gbt.H.path(*history)): continue if infoset_probs[gbt.H.path(*history)] == 0: @@ -1044,13 +1057,13 @@ def test_martingale_property_of_node_value(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) realiz_probs = profile.realiz_probs node_values = profile.node_values - for node in games.all_nodes(game): - history = games._node_history(node) - if not game.get_actions(gbt.H.path(*history)) or node.player == "Chance": + for history in game.get_histories(gbt.H.after()): + player = game.get_player(gbt.H.path(*history)) + if player is None or player == "Chance": continue expected_val = 0 node_prob = realiz_probs[history] - player_node_values = node_values[node.player] + player_node_values = node_values[player] for child_history in games.children_histories(game, history): prob = realiz_probs[child_history] / node_prob expected_val += prob * player_node_values[child_history] diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index 8f0f1b97b8..1181b09d1d 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -5,14 +5,14 @@ from . import games -def _find_selector(game, label): - """A `Selector` resolving to the infoset with the given 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(games.find_infoset_in_game(game, label)) + return games._INFOSET_LABEL_HISTORIES[(game.title, label)] + + +def _find_selector(game, label): + """A `Selector` resolving to the infoset with the given label.""" + return gbt.H.path(*_find_history(game, label)) def _branching_game(): diff --git a/tests/test_outcome_mutations.py b/tests/test_outcome_mutations.py index b96e187a84..4461e12d23 100644 --- a/tests/test_outcome_mutations.py +++ b/tests/test_outcome_mutations.py @@ -8,13 +8,12 @@ def test_make_outcome_attaches_to_all_given_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = games.children_of(game, ()) outcome = game.make_outcome( gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1, "Bob": -1}, "shared" ) - assert up.outcome == outcome - assert middle.outcome == outcome - assert not down.outcome + assert game.get_outcome(gbt.H.path("U")) == outcome.label + assert game.get_outcome(gbt.H.path("M")) == outcome.label + assert game.get_outcome(gbt.H.path("D")) is None assert outcome["Alice"] == 1 assert outcome["Bob"] == -1 @@ -22,21 +21,19 @@ def test_make_outcome_attaches_to_all_given_nodes(): def test_make_outcome_accepts_selector(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = games.children_of(game, ()) outcome = game.make_outcome(gbt.H.path("U"), {"Alice": 1, "Bob": -1}, "shared") - assert up.outcome == outcome - assert not middle.outcome - assert not down.outcome + assert game.get_outcome(gbt.H.path("U")) == outcome.label + assert game.get_outcome(gbt.H.path("M")) is None + assert game.get_outcome(gbt.H.path("D")) is None def test_make_outcome_accepts_selector_matching_several_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = games.children_of(game, ()) outcome = game.make_outcome(gbt.H.plays, {"Alice": 1, "Bob": -1}, "shared") - assert up.outcome == outcome - assert middle.outcome == outcome - assert down.outcome == outcome + assert game.get_outcome(gbt.H.path("U")) == outcome.label + assert game.get_outcome(gbt.H.path("M")) == outcome.label + assert game.get_outcome(gbt.H.path("D")) == outcome.label def test_make_outcome_accepts_grouped_selector_pooled(): @@ -44,13 +41,12 @@ def test_make_outcome_accepts_grouped_selector_pooled(): receives the same outcome, regardless of grouping.""" game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = games.children_of(game, ()) outcome = game.make_outcome( gbt.H.path(...).by(lambda h: h[0]), {"Alice": 1, "Bob": -1}, "shared" ) - assert up.outcome == outcome - assert middle.outcome == outcome - assert down.outcome == outcome + assert game.get_outcome(gbt.H.path("U")) == outcome.label + assert game.get_outcome(gbt.H.path("M")) == outcome.label + assert game.get_outcome(gbt.H.path("D")) == outcome.label def test_make_outcome_error_location_not_a_selector(): @@ -134,14 +130,13 @@ def test_make_outcome_payoffs_naming_player_twice_raises(): def test_make_outcome_null_accepts_selector(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - up, middle, down = games.children_of(game, ()) game.make_outcome( gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1, "Bob": -1}, "shared" ) game.make_outcome_null(gbt.H.path("U")) - assert not up.outcome - assert middle.outcome - assert not down.outcome + assert game.get_outcome(gbt.H.path("U")) is None + assert game.get_outcome(gbt.H.path("M")) is not None + assert game.get_outcome(gbt.H.path("D")) is None def test_make_outcome_null_error_location_not_a_selector(): @@ -177,22 +172,20 @@ def test_make_outcome_null_removes_fully_orphaned_outcome(): def test_make_outcome_null_keeps_partially_referenced_outcome(): game = gbt.Game.new_tree(["Alice"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) - middle = games.node_at_history(game, ("M",)) game.make_outcome(gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1}, "shared") outcome_count = len(game.outcomes) game.make_outcome_null(gbt.H.path("U")) assert len(game.outcomes) == outcome_count - assert middle.outcome + assert game.get_outcome(gbt.H.path("M")) is not None def test_make_outcome_null_on_already_null_node_is_a_no_op(): game = gbt.Game.new_tree(["Alice"]) game.append_move(gbt.H.path(), "Alice", ["U", "D"]) - up = games.node_at_history(game, ("U",)) outcome_count = len(game.outcomes) game.make_outcome_null(gbt.H.path("U")) assert outcome_count == len(game.outcomes) - assert not up.outcome + assert game.get_outcome(gbt.H.path("U")) is None def test_outcome_relabel_duplicate_rejected_and_label_unchanged(): diff --git a/tests/test_tree_mutations.py b/tests/test_tree_mutations.py index db58a77b6d..9b4f51d3dc 100644 --- a/tests/test_tree_mutations.py +++ b/tests/test_tree_mutations.py @@ -68,9 +68,8 @@ def test_node_delete_parent(): def test_node_delete_tree(): """Test to ensure deleting every child of a node works""" game = games.read_from_file("basic_extensive_game.efg") - node = games.node_at_history(game, ("U1",)) game.delete_tree(gbt.H.path("U1")) - assert len(node._children()) == 0 + assert not game.get_actions(gbt.H.path("U1")) def test_node_copy_nonterminal(): @@ -161,13 +160,11 @@ def test_append_move_same_infoset_list_of_nodes(): """Test that nodes matched by a Selector are resolved in the same infoset.""" game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - node1 = games.node_at_history(game, ("2", "1")) - node2 = games.node_at_history(game, ("1", "1")) matches = (("2", "1"), ("1", "1")) game.append_move( gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), "Player 3", ["B", "F"] ) - assert node1 in node2.members + assert ("2", "1") in game.get_members(gbt.H.path("1", "1")) def test_append_move_actions_list_of_nodes(): @@ -176,14 +173,12 @@ def test_append_move_actions_list_of_nodes(): """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - node1 = games.node_at_history(game, ("2", "1")) - node2 = games.node_at_history(game, ("1", "1")) matches = (("2", "1"), ("1", "1")) game.append_move( gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), "Player 3", ["B", "F", "S"] ) - assert list(node1.actions) == list(node2.actions) + assert game.get_actions(gbt.H.path("2", "1")) == game.get_actions(gbt.H.path("1", "1")) def test_append_move_labels_list_of_nodes(): @@ -192,15 +187,13 @@ def test_append_move_labels_list_of_nodes(): """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - node1 = games.node_at_history(game, ("2", "1")) - node2 = games.node_at_history(game, ("1", "1")) matches = (("2", "1"), ("1", "1")) game.append_move( gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), "Player 3", ["B", "F", "S"] ) - assert node1.actions == node2.actions + assert game.get_actions(gbt.H.path("2", "1")) == game.get_actions(gbt.H.path("1", "1")) def test_append_move_node_list_with_non_terminal_node(): @@ -275,23 +268,22 @@ def test_append_event_creates_single_event_list_of_nodes(): """Test that appending a Selector matching several nodes creates a single chance event.""" game = games.read_from_file("sample_extensive_game.efg") - node1 = games.node_at_history(game, ("2", "1")) - node2 = games.node_at_history(game, ("1", "1")) matches = (("2", "1"), ("1", "1")) game.append_event( gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 2)} ) - assert node1 in node2.members - assert node1._children() + assert ("2", "1") in game.get_members(gbt.H.path("1", "1")) + assert game.get_actions(gbt.H.path("2", "1")) def test_append_event_sets_distribution(): """Test that the new event's actions carry the given probabilities.""" game = games.read_from_file("sample_extensive_game.efg") - node = games.node_at_history(game, ("1", "1")) game.append_event(gbt.H.path("1", "1"), {"a": gbt.Rational(1, 4), "b": gbt.Rational(3, 4)}) - assert list(node.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] + assert list(game.get_action_probs(gbt.H.path("1", "1")).values()) == [ + gbt.Rational(1, 4), gbt.Rational(3, 4) + ] def test_append_event_error_actions_empty(): @@ -343,18 +335,18 @@ def test_append_event_error_invalid_distribution(): def test_insert_event_actions_labeled(): """Test that the inserted event's actions are labeled according to `actions`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - node = games.node_at_history(game, ("L", "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.player == "Chance" + assert game.get_actions(gbt.H.path("L", "R")) == ["Up", "Down"] + assert game.get_player(gbt.H.path("L", "R")) == "Chance" def test_insert_event_sets_distribution(): """Test that the inserted event's actions carry the given probabilities.""" game = games.read_from_file("basic_extensive_game.efg") - node = games.node_at_history(game, ()) game.insert_event(gbt.H.path(), {"a": gbt.Rational(1, 4), "b": gbt.Rational(3, 4)}) - assert list(node.parent.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] + assert list(game.get_action_probs(gbt.H.path()).values()) == [ + gbt.Rational(1, 4), gbt.Rational(3, 4) + ] def test_insert_event_error_actions_empty(): @@ -378,20 +370,23 @@ def test_insert_event_error_invalid_distribution(): game.insert_event(gbt.H.path(), {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 3)}) -def _count_subtree_nodes(start_node: gbt.Node, count_terminal: bool) -> int: - """Counts nodes in the subtree rooted at `start_node` (including `start_node`). +def _count_subtree_nodes(game: gbt.Game, history: tuple, count_terminal: bool) -> int: + """Counts nodes in the subtree rooted at the node with the given History + (including that node itself). Parameters ---------- - start_node: Node - The root of the subtree + game: Game + history: tuple + The History of the root of the subtree count_terminal: bool Include or exclude terminal nodes from count """ - count = 1 if count_terminal or start_node._children() else 0 + children = games.children_histories(game, history) + count = 1 if count_terminal or children else 0 - for child in start_node._children(): - count += _count_subtree_nodes(child, count_terminal) + for child in children: + count += _count_subtree_nodes(game, child, count_terminal) return count @@ -409,7 +404,7 @@ def test_len_matches_expected_node_count(): direct_len = _n_nodes(game) assert direct_len == expected_node_count - assert direct_len == _count_subtree_nodes(games.node_at_history(game, ()), True) + assert direct_len == _count_subtree_nodes(game, (), True) def test_len_after_delete_tree(): @@ -417,8 +412,7 @@ def test_len_after_delete_tree(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = _n_nodes(game) - root_of_the_deleted_subtree = games.node_at_history(game, ("R", "L")) - number_of_deleted_nodes = _count_subtree_nodes(root_of_the_deleted_subtree, True) - 1 + number_of_deleted_nodes = _count_subtree_nodes(game, ("R", "L"), True) - 1 game.delete_tree(gbt.H.path("R", "L")) @@ -430,10 +424,10 @@ def test_len_after_delete_parent(): game = gbt.catalog.load("journals/ijgt/selten1975/fig2") initial_number_of_nodes = _n_nodes(game) - node_parent_to_delete = games.node_at_history(game, ("L", "L")) + node_history = ("L", "L") - number_of_node_ancestors = _count_subtree_nodes(node_parent_to_delete, True) - number_of_parent_ancestors = _count_subtree_nodes(node_parent_to_delete.parent, True) + number_of_node_ancestors = _count_subtree_nodes(game, node_history, True) + number_of_parent_ancestors = _count_subtree_nodes(game, node_history[:-1], True) diff = number_of_parent_ancestors - number_of_node_ancestors game.delete_parent(gbt.H.path("L", "L")) @@ -459,8 +453,7 @@ def test_len_after_append_infoset(): game = gbt.catalog.load("journals/ijgt/selten1975/fig2") initial_number_of_nodes = _n_nodes(game) - infoset_to_modify = games.node_at_history(game, ("L",)) - number_of_infoset_actions = len(infoset_to_modify.actions) + number_of_infoset_actions = len(game.get_actions(gbt.H.path("L"))) game.append_infoset(gbt.H.path("L", "L", "l"), gbt.H.path("L")) @@ -471,9 +464,9 @@ def test_len_after_set_move_actions_add(): """Verify the node count is correct after `set_move_actions` creates an action.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = _n_nodes(game) - infoset_to_modify = games.node_at_history(game, ("L",)) # Player 2's infoset - num_nodes_in_infoset = len(infoset_to_modify.members) - labels = list(infoset_to_modify.actions) + # "L" is Player 2's infoset + num_nodes_in_infoset = len(game.get_members(gbt.H.path("L"))) + labels = list(game.get_actions(gbt.H.path("L"))) game.set_move_actions(gbt.H.path("L"), labels + ["new"]) assert _n_nodes(game) == initial_number_of_nodes + num_nodes_in_infoset @@ -484,10 +477,10 @@ def test_len_after_set_move_actions_drop(): initial_number_of_nodes = _n_nodes(game) action_to_drop = "L" nodes_to_delete = sum( - _count_subtree_nodes(member._children()[action_to_drop], True) - for member in games.node_at_history(game, ()).members + _count_subtree_nodes(game, (*member, action_to_drop), True) + for member in game.get_members(gbt.H.path()) ) - remaining = [a for a in games.node_at_history(game, ()).actions if a != "L"] + remaining = [a for a in game.get_actions(gbt.H.path()) if a != "L"] game.set_move_actions(gbt.H.path(), remaining, drop=True) assert _n_nodes(game) == initial_number_of_nodes - nodes_to_delete @@ -508,9 +501,8 @@ def test_len_after_insert_move(): def test_insert_move_actions_labeled(): """Test that the inserted move's actions are labeled according to `actions`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - node = games.node_at_history(game, ("L", "R")) game.insert_move(gbt.H.path("L", "R"), "Player 2", ["Up", "Down"]) - assert list(node.parent.actions) == ["Up", "Down"] + assert game.get_actions(gbt.H.path("L", "R")) == ["Up", "Down"] def test_len_after_insert_infoset(): @@ -518,8 +510,7 @@ def test_len_after_insert_infoset(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = _n_nodes(game) - infoset_to_modify = games.node_at_history(game, ("L",)) - number_of_infoset_actions = len(infoset_to_modify.actions) + number_of_infoset_actions = len(game.get_actions(gbt.H.path("L"))) game.insert_infoset(gbt.H.path("L", "R"), gbt.H.path("L")) @@ -530,8 +521,7 @@ def test_len_after_copy_tree(): """Verify the node count is correct after `copy_tree`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = _n_nodes(game) - src_node = games.node_at_history(game, ("R", "L")) - number_of_src_ancestors = _count_subtree_nodes(src_node, True) + number_of_src_ancestors = _count_subtree_nodes(game, ("R", "L"), True) game.copy_tree(gbt.H.path("R", "L"), gbt.H.path("R", "R")) @@ -581,27 +571,30 @@ def test_make_infoset_change_player_keeps_membership(): membership.""" game = games.read_from_file("basic_extensive_game.efg") _, p2, *_ = game.players - members = list(games.node_at_history(game, ()).members) - game.make_infoset(games.selector_for_nodes(members), p2) - assert games.node_at_history(game, ()).player == p2 - assert list(games.node_at_history(game, ()).members) == members + members = game.get_members(gbt.H.path()) + game.make_infoset(games.selector_for_histories(members), p2) + assert game.get_player(gbt.H.path()) == p2 + assert game.get_members(gbt.H.path()) == members def test_make_infoset_terminal_node_raises(): """All nodes must be decision nodes.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.make_infoset(gbt.H.path("U1", "U2", "U3"), games.node_at_history(game, ()).player) + game.make_infoset(gbt.H.path("U1", "U2", "U3"), game.get_player(gbt.H.path())) 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 = games.node_at_history(game, ()) # the deal is a chance move - personal = next(n for n in games.all_nodes(game) if n._children() and n.player != "Chance") - game.make_infoset(gbt.H.path(), personal.player) - assert chance_node.player != "Chance" - assert chance_node.player == personal.player + game = games.read_from_file("stripped_down_poker.efg") # the deal is a chance move + personal_history = next( + h for h in game.get_histories(gbt.H.after()) + if game.get_actions(gbt.H.path(*h)) and game.get_player(gbt.H.path(*h)) != "Chance" + ) + personal_player = game.get_player(gbt.H.path(*personal_history)) + game.make_infoset(gbt.H.path(), personal_player) + assert game.get_player(gbt.H.path()) != "Chance" + assert game.get_player(gbt.H.path()) == personal_player @pytest.mark.parametrize("node_actions", [["c", "d"], ["b", "a"]]) @@ -620,7 +613,7 @@ def test_make_infoset_empty_nodes_raises(): game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): game.make_infoset( - gbt.H.path(...).filter(lambda h: False), games.node_at_history(game, ()).player + gbt.H.path(...).filter(lambda h: False), game.get_player(gbt.H.path()) ) @@ -635,11 +628,11 @@ def test_set_move_actions_add_preserves_existing_action_order(): """New actions may be declared at any position; the existing actions' relative order is preserved.""" game = games.read_from_file("basic_extensive_game.efg") - labels = list(games.node_at_history(game, ()).actions) + labels = list(game.get_actions(gbt.H.path())) game.set_move_actions(gbt.H.path(), labels + ["end"]) - assert list(games.node_at_history(game, ()).actions)[:-1] == labels + assert game.get_actions(gbt.H.path())[:-1] == labels game.set_move_actions(gbt.H.path(), ["front"] + labels + ["end"]) - assert list(games.node_at_history(game, ()).actions)[1:-1] == labels + assert game.get_actions(gbt.H.path())[1:-1] == labels @pytest.mark.parametrize( @@ -656,8 +649,8 @@ def test_make_event_sets_probabilities(inprobs, outprobs): """ game = games.read_from_file("stripped_down_poker.efg") game.make_event(gbt.H.path(), inprobs, "Deal") - probs = games.node_at_history(game, ()).action_probs - for action, prob in zip(games.node_at_history(game, ()).actions, outprobs, strict=True): + probs = game.get_action_probs(gbt.H.path()) + for action, prob in zip(game.get_actions(gbt.H.path()), outprobs, strict=True): assert probs[action] == prob @@ -672,10 +665,12 @@ def test_make_event_probs_not_a_mapping_raises_typeerror(probs): def test_make_event_pools_nodes_from_different_infosets(): """Nodes in distinct information sets are formed into a single event.""" game = games.read_from_file("stripped_down_poker.efg") - nodes = [games.node_at_history(game, ("King",)), games.node_at_history(game, ("Queen",))] + king, queen = ("King",), ("Queen",) game.make_event(gbt.H.path(...), {"Bet": "1/4", "Fold": "3/4"}, "Coin") - assert nodes[0] in nodes[1].members - assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] + assert king in game.get_members(gbt.H.path(*queen)) + assert list(game.get_action_probs(gbt.H.path(*king)).values()) == [ + gbt.Rational("1/4"), gbt.Rational("3/4") + ] assert not game.get_infosets("Alice") @@ -693,10 +688,11 @@ 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 = games.node_at_history(game, ("King",)) game.make_event(gbt.H.path("King"), {"Bet": "1/4", "Fold": "3/4"}) - assert node.player == "Chance" - assert list(node.action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] + assert game.get_player(gbt.H.path("King")) == "Chance" + assert list(game.get_action_probs(gbt.H.path("King")).values()) == [ + gbt.Rational("1/4"), gbt.Rational("3/4") + ] def test_make_event_terminal_node_raises(): @@ -732,11 +728,13 @@ def test_make_event_label_reused_when_fully_absorbed(): event's members are absorbed into the new one; the old event is not left behind. """ game = games.read_from_file("stripped_down_poker.efg") - nodes = [games.node_at_history(game, ("King",)), games.node_at_history(game, ("Queen",))] + king, queen = ("King",), ("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] in nodes[1].members - assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] + assert king in game.get_members(gbt.H.path(*queen)) + assert list(game.get_action_probs(gbt.H.path(*king)).values()) == [ + gbt.Rational("1/4"), gbt.Rational("3/4") + ] @pytest.mark.parametrize( @@ -757,67 +755,65 @@ def test_make_event_malformed_probs_raises(): game.make_event(gbt.H.path(), {"Jack": 1}) -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). +def _bagwell_p2_histories(game: gbt.Game) -> tuple[tuple, tuple, tuple, tuple]: + """Player 2's four decision node Histories in Bagwell (1995). Player 2 has two information sets -- one for each signal the chance move can produce -- each with two members and actions ("S", "C"). Returns (A, B, C, D) with {A, B} the members of one and {C, D} of the other. """ - return (games.node_at_history(game, ("S", "s")), - games.node_at_history(game, ("C", "s")), - games.node_at_history(game, ("S", "c")), - games.node_at_history(game, ("C", "c"))) + return (("S", "s"), ("C", "s"), ("S", "c"), ("C", "c")) def test_make_infoset_cherry_pick_leaves_rumps(): """Partial consumption leaves the remainders behind.""" game = gbt.catalog.load("journals/geb/bagwell1995") - A, B, C, D = _bagwell_p2_nodes(game) - game.make_infoset(games.selector_for_nodes([B, C]), "Player 2") - assert B in C.members - assert list(A.members) == [A] - assert list(D.members) == [D] + A, B, C, D = _bagwell_p2_histories(game) + game.make_infoset(games.selector_for_histories([B, C]), "Player 2") + assert B in game.get_members(gbt.H.path(*C)) + assert game.get_members(gbt.H.path(*A)) == [A] + assert game.get_members(gbt.H.path(*D)) == [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) - game.make_infoset(games.selector_for_nodes([A]), "Player 2", "X") + A, B, C, D = _bagwell_p2_histories(game) + game.make_infoset(games.selector_for_histories([A]), "Player 2", "X") + # A remains in "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_histories([B, C]), "Player 2", "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) - game.make_infoset(games.selector_for_nodes([A, B]), "Player 2", "X") - game.make_infoset(games.selector_for_nodes([C, D]), "Player 2", "Y") + A, B, C, D = _bagwell_p2_histories(game) + game.make_infoset(games.selector_for_histories([A, B]), "Player 2", "X") + game.make_infoset(games.selector_for_histories([C, D]), "Player 2", "Y") with pytest.raises(ValueError): - game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "X") - assert A in B.members - assert C in D.members + game.make_infoset(games.selector_for_histories([B, C]), "Player 2", "X") + assert A in game.get_members(gbt.H.path(*B)) + assert C in game.get_members(gbt.H.path(*D)) def test_make_infoset_idempotent(): """Repeating a call is a no-op: label reuse permits equality of membership.""" game = gbt.catalog.load("journals/geb/bagwell1995") - A, B, C, D = _bagwell_p2_nodes(game) - game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "Z") - game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "Z") - assert B in C.members + A, B, C, D = _bagwell_p2_histories(game) + game.make_infoset(games.selector_for_histories([B, C]), "Player 2", "Z") + game.make_infoset(games.selector_for_histories([B, C]), "Player 2", "Z") + assert B in game.get_members(gbt.H.path(*C)) 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, B, C, D = _bagwell_p2_histories(game) game.make_infoset(gbt.H.path("S", "s"), "Player 2") - assert list(A.members) == [A] - assert B not in A.members + assert game.get_members(gbt.H.path(*A)) == [A] + assert B not in game.get_members(gbt.H.path(*A)) def test_make_infoset_across_different_source_players(): @@ -826,35 +822,34 @@ def test_make_infoset_across_different_source_players(): game.append_move(gbt.H.path(), "1", ["a", "b"]) game.append_move(gbt.H.path("a"), "2", ["a", "b"]) # player 2 game.append_move(gbt.H.path("b"), "3", ["a", "b"]) # player 3 - n2 = games.node_at_history(game, ("a",)) - n3 = games.node_at_history(game, ("b",)) - assert n2.player == "2" - assert n3.player == "3" + n2, n3 = ("a",), ("b",) + assert game.get_player(gbt.H.path(*n2)) == "2" + assert game.get_player(gbt.H.path(*n3)) == "3" game.make_infoset(gbt.H.path(...), "1") - assert n2 in n3.members - assert n2.player == "1" - assert n3.player == "1" + assert n2 in game.get_members(gbt.H.path(*n3)) + assert game.get_player(gbt.H.path(*n2)) == "1" + assert game.get_player(gbt.H.path(*n3)) == "1" @pytest.mark.parametrize("label", games.VALID_LABELS) def test_action_label(label: str): game = games.create_stripped_down_poker_efg() - action = next(iter(games.node_at_history(game, ()).actions)) + action = next(iter(game.get_actions(gbt.H.path()))) game.relabel_actions(gbt.H.path(), {action: label}) - assert label in games.node_at_history(game, ()).actions + assert label in game.get_actions(gbt.H.path()) @pytest.mark.parametrize("label", games.INVALID_LABELS) def test_action_label_invalid_raises_valueerror(label: str): game = games.create_stripped_down_poker_efg() - action = next(iter(games.node_at_history(game, ()).actions)) + action = next(iter(game.get_actions(gbt.H.path()))) with pytest.raises(ValueError): game.relabel_actions(gbt.H.path(), {action: label}) def test_relabel_action_empty_raises_valueerror(): game = games.create_stripped_down_poker_efg() - action = next(iter(games.node_at_history(game, ()).actions)) + action = next(iter(game.get_actions(gbt.H.path()))) with pytest.raises(ValueError): game.relabel_actions(gbt.H.path(), {action: ""}) @@ -871,7 +866,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(games.node_at_history(game, ()).actions) == ["Queen", "King"] + assert game.get_actions(gbt.H.path()) == ["Queen", "King"] def test_relabel_actions_duplicate_targets_raises_valueerror(): @@ -892,7 +887,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(games.node_at_history(game, ()).actions) == ["Ace", "Queen"] + assert game.get_actions(gbt.H.path()) == ["Ace", "Queen"] def test_relabel_actions_failure_leaves_game_unchanged(): @@ -902,7 +897,16 @@ 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(games.node_at_history(game, ()).actions) == ["King", "Queen"] + assert game.get_actions(gbt.H.path()) == ["King", "Queen"] + + +def _infoset_history(game: gbt.Game, player: str, label: str) -> tuple: + """The History of the representative member of `player`'s information set + historically identified by `label`, matching the removed `Player.infosets` + by-label lookup.""" + history = games._INFOSET_LABEL_HISTORIES[(game.title, label)] + assert game.get_player(gbt.H.path(*history)) == player + return history def test_relabel_actions_scope_is_the_information_set(): @@ -911,13 +915,13 @@ def test_relabel_actions_scope_is_the_information_set(): and free to take the same new label. """ game = games.create_stripped_down_poker_efg() - king = games.find_infoset(game, "Alice", "Alice has King") - queen = games.find_infoset(game, "Alice", "Alice has Queen") - game.relabel_actions(games.selector_for_nodes([next(iter(king.members))]), {"Bet": "Raise"}) - assert list(king.actions) == ["Raise", "Fold"] - assert list(queen.actions) == ["Bet", "Fold"] - game.relabel_actions(games.selector_for_nodes([next(iter(queen.members))]), {"Bet": "Raise"}) - assert list(queen.actions) == ["Raise", "Fold"] + king = _infoset_history(game, "Alice", "Alice has King") + queen = _infoset_history(game, "Alice", "Alice has Queen") + game.relabel_actions(gbt.H.path(*king), {"Bet": "Raise"}) + assert game.get_actions(gbt.H.path(*king)) == ["Raise", "Fold"] + assert game.get_actions(gbt.H.path(*queen)) == ["Bet", "Fold"] + game.relabel_actions(gbt.H.path(*queen), {"Bet": "Raise"}) + assert game.get_actions(gbt.H.path(*queen)) == ["Raise", "Fold"] def test_relabel_actions_not_a_mapping_raises_typeerror(): @@ -935,23 +939,21 @@ def test_relabel_actions_non_str_label_raises_typeerror(labels: dict): def test_set_move_actions_drop_shrinks_actions_and_children(): game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) - action_count = len(infoset.actions) - remaining = list(infoset.actions)[1:] - game.set_move_actions(games.selector_for_nodes([node]), remaining, drop=True) - assert len(infoset.actions) == action_count - 1 - assert len(node._children()) == action_count - 1 + king = _infoset_history(game, "Alice", "Alice has King") + action_count = len(game.get_actions(gbt.H.path(*king))) + remaining = game.get_actions(gbt.H.path(*king))[1:] + game.set_move_actions(gbt.H.path(*king), remaining, drop=True) + assert len(game.get_actions(gbt.H.path(*king))) == action_count - 1 + assert len(games.children_histories(game, king)) == action_count - 1 def test_set_move_actions_cannot_remove_the_only_action(): game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) - last = next(iter(infoset.actions)) - selector = games.selector_for_nodes([node]) + king = _infoset_history(game, "Alice", "Alice has King") + last = next(iter(game.get_actions(gbt.H.path(*king)))) + selector = gbt.H.path(*king) game.set_move_actions(selector, [last], drop=True) - assert list(infoset.actions) == [last] + assert game.get_actions(gbt.H.path(*king)) == [last] with pytest.raises(gbt.UndefinedOperationError): game.set_move_actions(selector, [], drop=True) @@ -966,36 +968,35 @@ 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 = games.node_at_history(game, ("x",)) - members = list(infoset.members) - children_before = [{label: member._children()[label] for label in ("a", "b", "c")} + members = game.get_members(gbt.H.path("x")) + children_before = [{label: (*member, label) for label in ("a", "b", "c")} for member in members] game.set_move_actions(gbt.H.path("x"), ["c", "a", "b"]) - assert list(infoset.actions) == ["c", "a", "b"] + assert game.get_actions(gbt.H.path("x")) == ["c", "a", "b"] for member, children in zip(members, children_before, strict=True): - assert list(member._children()) == [children["c"], children["a"], children["b"]] + assert games.children_histories(game, member) == [ + children["c"], children["a"], children["b"] + ] def test_set_move_actions_add_drop_and_reorder_together(): game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) + king = _infoset_history(game, "Alice", "Alice has King") nodes_before = _n_nodes(game) - game.set_move_actions(games.selector_for_nodes([node]), ["Raise", "Fold"], drop=True) - assert list(infoset.actions) == ["Raise", "Fold"] + game.set_move_actions(gbt.H.path(*king), ["Raise", "Fold"], drop=True) + assert game.get_actions(gbt.H.path(*king)) == ["Raise", "Fold"] # "Bet" and its subtree (Bob's node and its two terminals) go; "Raise" adds one. assert _n_nodes(game) == nodes_before - 3 + 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(games.node_at_history(game, ("Queen", "Bet")).members) == 1 + assert len(game.get_members(gbt.H.path("Queen", "Bet"))) == 1 def test_set_move_actions_unconfirmed_drop_and_disabled_add_raise(): game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) + king = _infoset_history(game, "Alice", "Alice has King") before = game.to_efg() - selector = games.selector_for_nodes([node]) + selector = gbt.H.path(*king) with pytest.raises(ValueError): game.set_move_actions(selector, ["Bet"]) with pytest.raises(ValueError): @@ -1016,11 +1017,10 @@ def test_set_move_actions_bad_labels_raise_and_leave_game_unchanged(bad_labels): """Duplicate, empty, and invalid labels in `actions` are rejected in C++, after the Python guards pass; the game must be unmodified by the failure.""" game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") - node = next(iter(infoset.members)) + king = _infoset_history(game, "Alice", "Alice has King") before = game.to_efg() with pytest.raises(ValueError): - game.set_move_actions(games.selector_for_nodes([node]), bad_labels, drop=True) + game.set_move_actions(gbt.H.path(*king), bad_labels, drop=True) assert game.to_efg() == before @@ -1031,8 +1031,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(games.node_at_history(game, ()).actions) == ["b", "c"] - assert len(games.node_at_history(game, ()).members) == 1 + assert game.get_actions(gbt.H.path()) == ["b", "c"] + assert len(game.get_members(gbt.H.path())) == 1 assert _n_nodes(game) == 3 @@ -1040,8 +1040,8 @@ def test_set_event_actions_reorder_carries_probabilities(): game = games.create_stripped_down_poker_efg() game.set_event_actions(gbt.H.path(), {"King": "3/4", "Queen": "1/4"}) game.set_event_actions(gbt.H.path(), {"Queen": "1/4", "King": "3/4"}) - assert list(games.node_at_history(game, ()).actions) == ["Queen", "King"] - assert games.node_at_history(game, ()).action_probs == { + assert game.get_actions(gbt.H.path()) == ["Queen", "King"] + assert game.get_action_probs(gbt.H.path()) == { "Queen": gbt.Rational(1, 4), "King": gbt.Rational(3, 4) } @@ -1050,8 +1050,8 @@ def test_set_event_actions_add_with_probs_mapping(): game = games.create_stripped_down_poker_efg() nodes_before = _n_nodes(game) game.set_event_actions(gbt.H.path(), {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) - assert list(games.node_at_history(game, ()).actions) == ["Jack", "King", "Queen"] - assert games.node_at_history(game, ()).action_probs == { + assert game.get_actions(gbt.H.path()) == ["Jack", "King", "Queen"] + assert game.get_action_probs(gbt.H.path()) == { "Jack": gbt.Rational(1, 2), "King": gbt.Rational(1, 4), "Queen": gbt.Rational(1, 4) } assert _n_nodes(game) == nodes_before + 1 @@ -1060,8 +1060,8 @@ def test_set_event_actions_add_with_probs_mapping(): def test_set_event_actions_drop_with_probs_mapping(): game = games.create_stripped_down_poker_efg() game.set_event_actions(gbt.H.path(), {"King": 1}, drop=True) - assert list(games.node_at_history(game, ()).actions) == ["King"] - assert games.node_at_history(game, ()).action_probs == {"King": 1} + assert game.get_actions(gbt.H.path()) == ["King"] + assert game.get_action_probs(gbt.H.path()) == {"King": 1} def test_set_event_actions_unconfirmed_drop_and_disabled_add_raise(): @@ -1080,11 +1080,9 @@ def test_set_event_actions_raises_at_a_move(): """`set_event_actions` is only for an event; `set_move_actions` is the corresponding operation for a personal player's move.""" game = games.create_stripped_down_poker_efg() - infoset = games.find_infoset(game, "Alice", "Alice has King") + king = _infoset_history(game, "Alice", "Alice has King") with pytest.raises(ValueError): - game.set_event_actions( - games.selector_for_nodes([next(iter(infoset.members))]), {"Bet": 1} - ) + game.set_event_actions(gbt.H.path(*king), {"Bet": 1}) def test_set_event_actions_rejects_non_mapping_probs(): From 8b736cc07de29c5e7dbf1d74657cb7dd8f1b9043 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Sat, 5 Sep 2026 09:43:19 +0100 Subject: [PATCH 18/24] Add `Game.get_strategy_unreachable` --- doc/pygambit.api.rst | 2 +- src/pygambit/game.pxi | 27 +++++++++++++++++++++++++++ src/pygambit/node.pxi | 14 ++++---------- tests/test_tree_queries.py | 12 ++++++------ 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 6e22f5a967..0650039c3a 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -125,6 +125,7 @@ Information about the game Game.get_payoffs Game.get_subgame_roots Game.get_minimal_subgame + Game.get_strategy_unreachable .. autosummary:: :toctree: api/ @@ -139,7 +140,6 @@ Information about the game Node.label Node.outcome Node.parent - Node.is_strategy_reachable Node.prior_action Node.members Node.actions diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 91b89d3f90..b1be05313f 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1000,6 +1000,33 @@ class Game: ) return _history_of(Node.wrap(subgame.deref().GetRoot())) + def get_strategy_unreachable(self) -> list[tuple]: + """Returns the Histories of the nodes that are not reachable by any pure + strategy profile. + + A node is considered reachable if there exists at least one pure + strategy profile where the resulting path of play passes through it. + In games with absent-mindedness, some nodes may be unreachable because + any path to them requires conflicting choices at the same information + set. + + .. versionadded:: 17.0.0 + + Raises + ------ + UndefinedOperationError + If the game does not have a tree representation. + """ + if not self.is_tree: + raise UndefinedOperationError( + "get_strategy_unreachable(): operation only defined for games " + "with a tree representation" + ) + return [ + _history_of(node) for node in self._all_nodes() + if not cython.cast(Node, node)._is_strategy_reachable() + ] + def get_behavior(self, player: str, strategy: str) -> StrategyBehavior: diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 021b2def32..dbfe155713 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -392,16 +392,10 @@ class Node: """ return self.node.deref().IsTerminal() - @property - def is_strategy_reachable(self) -> bool: - """Returns whether this node is reachable by any pure strategy profile. - - A node is considered reachable if there exists at least one pure - strategy profile where the resulting path of play passes - through that node. - - In games with absent-mindedness, some nodes may be unreachable because - any path to them requires conflicting choices at the same information set. + @cython.cfunc + def _is_strategy_reachable(self) -> cython.bint: + """Whether this node is reachable by any pure strategy profile. Not part + of the public API; the public equivalent is `Game.get_strategy_unreachable`. """ return self.node.deref().IsStrategyReachable() diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index 089a36b849..89efd4cbcb 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -506,17 +506,17 @@ def test_node_own_prior_action_non_terminal(game_file, expected_node_data): [["T", "S"], ["r", "T", "S"], ["l", "T", "S"]] ), ]) -def test_is_strategy_reachable(game_file: str, expected_unreachable_paths: list[list[str]]): +def test_get_strategy_unreachable(game_file: str, expected_unreachable_paths: list[list[str]]): """ - Tests `node.is_strategy_reachable` by collecting all unreachable nodes, - converting them to their action-label paths, and comparing the resulting - list of paths against a known-correct list. + Tests `Game.get_strategy_unreachable` against a known-correct list of + unreachable-node paths (Action Labels from Node -> Root, matching + `_get_path_of_action_labels`'s convention -- `get_strategy_unreachable` + itself returns Histories, Root -> Node). """ game = game_file if isinstance(game_file, gbt.Game) else games.read_from_file(game_file) - nodes = games.all_nodes(game) actual_unreachable_paths = [ - _get_path_of_action_labels(node) for node in nodes if not node.is_strategy_reachable + list(reversed(history)) for history in game.get_strategy_unreachable() ] assert actual_unreachable_paths == expected_unreachable_paths From 4f28e95e599a4ee0ff1031c4bf31cef6a226fec5 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Sat, 5 Sep 2026 10:10:29 +0100 Subject: [PATCH 19/24] Remove obsolete `Node` accessors --- doc/pygambit.api.rst | 6 -- src/pygambit/cli/common.py | 19 ++--- src/pygambit/game.pxi | 4 +- src/pygambit/hsel.pxi | 21 +++--- src/pygambit/node.pxi | 80 ++++++++------------- tests/games.py | 32 +++------ tests/test_behavspt_profiles.py | 2 +- tests/test_game_resolve.py | 6 +- tests/test_tree_mutations.py | 38 ---------- tests/test_tree_queries.py | 123 +------------------------------- 10 files changed, 69 insertions(+), 262 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 0650039c3a..f3341aa7b4 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -16,7 +16,6 @@ Representation of games Game Outcome Node - Branch TreeLayout TreeLayoutCoordinates @@ -137,16 +136,11 @@ Information about the game .. autosummary:: :toctree: api/ - Node.label Node.outcome - Node.parent - Node.prior_action Node.members Node.actions Node.action_probs Node.player - Node.is_successor_of - Node.own_prior_action .. autosummary:: diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index 8fe1b5e34e..dfc16192dd 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -277,11 +277,11 @@ def render_profile_detail( def _name_or_number(node: gbt.Node) -> str: # Gambit's Python API numbers nodes from 0; the C++ tools display the - # underlying (1-based) engine numbering. `Node._number` is private (not part - # of the public API) but still Python-accessible, same as `Game._get_infosets` - # just below -- rendering needs the node's own engine number, which a History - # alone cannot provide. - return node.label if node.label else str(node._number() + 1) + # underlying (1-based) engine numbering. `Node._label`/`._number` are private + # (not part of the public API) but still Python-accessible, same as + # `Game._get_infosets` just below -- rendering needs the node's own label/engine + # number, which a History alone cannot provide. + return node._label if node._label else str(node._number() + 1) def _render_strategy_detail(profile: gbt.MixedStrategyProfile, decimals: int) -> str: @@ -300,12 +300,13 @@ def _render_strategy_detail(profile: gbt.MixedStrategyProfile, decimals: int) -> def _history_of(node: gbt.Node) -> tuple: - """The plain-tuple History of `node`, walked via `.parent`/`.prior_action`.""" + """The plain-tuple History of `node`, walked via the private + `Node._parent`/`._prior_action`.""" labels = [] current = node - while current.parent is not None: - labels.append(current.prior_action.label) - current = current.parent + while current._parent() is not None: + labels.append(current._prior_action().label) + current = current._parent() labels.reverse() return tuple(labels) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index b1be05313f..6fc8235b60 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1719,7 +1719,7 @@ class Game: f"{funcname}(): {argname} cannot be an empty string or all spaces" ) for n in self._all_nodes(): - if n.label == node: + if cython.cast(Node, n)._label == node: return n raise KeyError(f"{funcname}(): no node with label '{node}'") raise TypeError( @@ -2329,7 +2329,7 @@ class Game: resolved_dest = cython.cast(Node, self._resolve_node(dest, "move_tree", "dest")) if not cython.cast(Node, resolved_dest)._is_terminal(): raise UndefinedOperationError("move_tree(): `dest` must be a terminal node.") - if resolved_dest.is_successor_of(resolved_src): + if resolved_dest._is_successor_of(resolved_src): raise UndefinedOperationError("move_tree(): `dest` cannot be a successor of `src`.") self.game.deref().MoveTree(resolved_dest.node, resolved_src.node) diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi index 1e502a5911..bfc14ce097 100644 --- a/src/pygambit/hsel.pxi +++ b/src/pygambit/hsel.pxi @@ -63,9 +63,10 @@ def _matches_suffix(node: Node, labels: tuple) -> bool: """Whether `node`'s own history ends with exactly `labels`.""" current: Node = node for label in reversed(labels): - if current.parent is None or current.prior_action.label != label: + parent = current._parent() + if parent is None or current._prior_action().label != label: return False - current = current.parent + current = parent return True @@ -198,12 +199,12 @@ class GroupedSelector: def _history_of(node: Node) -> tuple: """The plain-tuple History for `node` -- walks back to the root via the - existing `Node.parent`/`.prior_action` navigation.""" + private `Node._parent`/`._prior_action` navigation.""" labels: list = [] current: Node = node - while current.parent is not None: - labels.append(current.prior_action.label) - current = current.parent + while current._parent() is not None: + labels.append(current._prior_action().label) + current = current._parent() labels.reverse() return tuple(labels) @@ -279,10 +280,10 @@ def _last_action(node: Node, player: str) -> str | None: wherever it fell -- `None` if `player` hasn't acted yet. Shared between `HistoryView.last_action` and `.with_recall(player)`'s evaluation.""" current: Node = node - while current.parent is not None: - if current.parent.player == player: - return current.prior_action.label - current = current.parent + while current._parent() is not None: + if current._parent().player == player: + return current._prior_action().label + current = current._parent() return None diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index dbfe155713..b9e30d9b6a 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -21,14 +21,12 @@ # Branch = collections.namedtuple("Branch", ["node", "label"]) -Branch.__doc__ = """The action labeled `label`, taken at `node`. - -Returned by `Node.prior_action` and `Node.own_prior_action`; `node` is the node at -which the action was taken (not the node it leads to), so ``branch.node.actions`` +Branch.__doc__ = """The action labeled `label`, taken at `node`. Not part of the +public API; internal return type of the private `Node._prior_action`/ +`._own_prior_action`, pending a `Game`/`History`-based design. `node` is the node +at which the action was taken (not the node it leads to), so ``branch.node.actions`` and, for a chance event, ``branch.node.action_probs[branch.label]`` are always well-defined. - -.. versionadded:: 17.0.0 """ @@ -188,15 +186,15 @@ class Node: return obj def __repr__(self) -> str: - if self.label: - return f"Node(game={self._game()}, label='{self.label}')" + if self._label: + return f"Node(game={self._game()}, label='{self._label}')" path = [] node = self - while node.parent: + while node._parent(): path.append( cython.cast(Node, node).node.deref().GetPriorAction().deref().GetNumber() - 1 ) - node = node.parent + node = node._parent() return f"Node(game={self._game()}, path={path})" def __eq__(self, other: typing.Any) -> bool: @@ -208,24 +206,21 @@ class Node: def __hash__(self) -> long: return cython.cast(long, self.node.deref()) - def is_successor_of(self, node: Node) -> bool: - """Returns whether this node is a successor of `node`.""" + def _is_successor_of(self, node: Node) -> bool: + """Whether this node is a successor of `node`. Not part of the public + API, pending a `Game`/`History`-based design. + """ return self.node.deref().IsSuccessorOf((node).node) @property - def label(self) -> str: - """The text label associated with the node. - - .. 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. + def _label(self) -> str: + """The text label associated with the node. Not part of the public API, + pending a `Game`/`Selector`-based design. """ return self.node.deref().GetLabel().decode("utf-8") - @label.setter - def label(self, value: str) -> None: + @_label.setter + def _label(self, value: str) -> None: self.node.deref().SetLabel(value.encode("utf-8")) def _number(self) -> int: @@ -333,48 +328,29 @@ class Node: return None return player.deref().GetLabel().decode("utf-8") - @property - def parent(self) -> Node | None: - """The parent of this node. - - If this is the root node, None is returned. + def _parent(self) -> Node | None: + """The parent of this node, or None if this is the root node. Not part + of the public API, pending a `Game`/`History`-based design. """ if self.node.deref().GetParent() != cython.cast(c_GameNode, NULL): return Node.wrap(self.node.deref().GetParent()) return None - @property - def prior_action(self) -> Branch | None: + def _prior_action(self) -> Branch | None: """The branch -- the parent node and the label of the action taken from it -- - which leads to this node. - - If this is the root node, None is returned. - - .. versionchanged:: 17.0.0 - Returns a `Branch` (the parent node and the action's label) rather than - an `Action` object, following its removal. + which leads to this node, or None if this is the root node. Not part of the + public API, pending a `Game`/`History`-based design. """ prior: c_GameAction = self.node.deref().GetPriorAction() if prior != cython.cast(c_GameAction, NULL): - return Branch(self.parent, prior.deref().GetLabel().decode("utf-8")) + return Branch(self._parent(), prior.deref().GetLabel().decode("utf-8")) return None - @property - def own_prior_action(self) -> Branch | None: + def _own_prior_action(self) -> Branch | None: """The last branch -- the node and the label of the action taken there -- at - which the node's owner acted before reaching this node. - - Returns - ------- - Branch or None - The node at which the node's owner last acted, paired with the label of - the action taken there, or None if the player has not moved previously - on the path to this node. - - .. versionadded:: 16.5.0 - .. versionchanged:: 17.0.0 - Returns a `Branch` (the node and the action's label) rather than an - `Action` object, following its removal. + which the node's owner acted before reaching this node, or None if the player + has not moved previously on the path to this node. Not part of the public + API, pending a `Game`/`History`-based design. """ prior: c_GameAction = self.node.deref().GetOwnPriorAction() if not (prior != cython.cast(c_GameAction, NULL)): diff --git a/tests/games.py b/tests/games.py index 5e5dca19b0..ffdbe4565b 100644 --- a/tests/games.py +++ b/tests/games.py @@ -42,17 +42,6 @@ def player_infosets(game: gbt.Game, player: str) -> list[tuple]: 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).""" - game = node._game() - members = set(game.get_members(selector_for_node(node))) - for i, history in enumerate(game.get_infosets(node.player)): - if history in 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 @@ -126,12 +115,13 @@ def find_infoset_in_game(game: gbt.Game, label: str) -> gbt.Node: def _node_history(node: gbt.Node) -> tuple: - """The plain-tuple history of `node`, walked via `.parent`/`.prior_action`.""" + """The plain-tuple history of `node`, walked via the private + `Node._parent`/`._prior_action`.""" labels = [] current = node - while current.parent is not None: - labels.append(current.prior_action.label) - current = current.parent + while current._parent() is not None: + labels.append(current._prior_action().label) + current = current._parent() labels.reverse() return tuple(labels) @@ -368,9 +358,9 @@ def _create_kuhn_poker_efg_only_term_outcomes() -> gbt.Game: def calculate_payoffs(term_node): def get_path(node): path = [] - while node.parent: - path.append(node.prior_action.label) - node = node.parent + while node._parent(): + path.append(node._prior_action().label) + node = node._parent() return path def showdown(deal, payoffs, pot): @@ -450,9 +440,9 @@ def _create_kuhn_poker_efg_nonterm_outcomes() -> gbt.Game: def collect_nodes(term_node): def get_path(node): path = [] - while node.parent: - path.append((node, node.prior_action.label)) - node = node.parent + while node._parent(): + path.append((node, node._prior_action().label)) + node = node._parent() return path path = get_path(term_node) diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index 1181b09d1d..49f11aaefd 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -85,7 +85,7 @@ def test_predicate_construction_error(): 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.parent is not None) + game.behavior_support_profile(lambda node, a: node._parent() is not None) def test_iter_yields_one_support_per_player(): diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index 3b62fc684e..e6293c6714 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -11,7 +11,7 @@ def _test_valid_resolutions(collection: list, resolver: typing.Callable) -> None: """Generic function to exercise resolving objects as themselves or via existing labels.""" for label, objects in itertools.groupby( - sorted(collection, key=lambda x: x.label), lambda x: x.label + sorted(collection, key=lambda x: x._label), lambda x: x._label ): objects = list(objects) # Objects resolve to themselves @@ -73,8 +73,8 @@ def test_resolve_infoset(game: gbt.Game) -> None: for member in game.get_members(gbt.H.path(*history)): resolved = game._resolve_infoset(gbt.H.path(*member), "test") assert games._node_history(resolved) == member - if resolved.label: - resolved_by_label = game._resolve_infoset(resolved.label, "test") + if resolved._label: + resolved_by_label = game._resolve_infoset(resolved._label, "test") assert games._node_history(resolved_by_label) == member diff --git a/tests/test_tree_mutations.py b/tests/test_tree_mutations.py index 9b4f51d3dc..788bc6772f 100644 --- a/tests/test_tree_mutations.py +++ b/tests/test_tree_mutations.py @@ -528,44 +528,6 @@ def test_len_after_copy_tree(): assert _n_nodes(game) == initial_number_of_nodes + number_of_src_ancestors - 1 -@pytest.mark.parametrize("label", games.VALID_LABELS) -def test_node_label_valid(label): - game = games.read_from_file("basic_extensive_game.efg") - games.node_at_history(game, ()).label = label - assert games.node_at_history(game, ()).label == label - - -def test_node_label_duplicate_raises_valueerror(): - game = games.read_from_file("basic_extensive_game.efg") - games.node_at_history(game, ()).label = "shared" - with pytest.raises(ValueError): - games.node_at_history(game, ("U1",)).label = "shared" - - -def test_node_label_empty_is_allowed(): - """Node labels may be empty (unlike outcomes/players); multiple empties coexist.""" - game = games.read_from_file("basic_extensive_game.efg") - games.node_at_history(game, ()).label = "" - games.node_at_history(game, ("U1",)).label = "" - assert games.node_at_history(game, ()).label == "" - assert games.node_at_history(game, ("U1",)).label == "" - - -@pytest.mark.parametrize("label", games.INVALID_LABELS) -def test_node_label_invalid_raises_valueerror(label): - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - games.node_at_history(game, ()).label = label - - -@pytest.mark.parametrize("label", games.UNICODE_LABELS) -def test_node_label_unicode_accepted(label): - """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" - game = games.read_from_file("basic_extensive_game.efg") - games.node_at_history(game, ()).label = label - assert games.node_at_history(game, ()).label == label - - def test_make_infoset_change_player_keeps_membership(): """Re-forming an information set under a different player retains its membership.""" diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index 89efd4cbcb..f160988c28 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -141,39 +141,6 @@ def test_get_player_requires_single_match(): game.get_player(gbt.H.path(...).filter(lambda h: False)) -def test_get_parent(): - """Test to ensure that we can retrieve a parent node for a given node""" - game = games.read_from_file("basic_extensive_game.efg") - root = games.node_at_history(game, ()) - assert games.node_at_history(game, ("U1",)).parent == root - assert root.parent is None - - -def test_get_prior_action(): - """Test to ensure that we can retrieve the prior action for a given node""" - game = games.read_from_file("basic_extensive_game.efg") - root = games.node_at_history(game, ()) - assert games.node_at_history(game, ("U1",)).prior_action == gbt.Branch(root, "U1") - assert root.prior_action is None - - -def test_is_successor_of(): - """Test to ensure that we can check if a given node is a - successor of a supplied node - """ - game = games.read_from_file("basic_extensive_game.efg") - root = games.node_at_history(game, ()) - child = games.node_at_history(game, ("U1",)) - assert child.is_successor_of(root) - assert not root.is_successor_of(child) - with pytest.raises(TypeError): - root.is_successor_of(9) - with pytest.raises(TypeError): - root.is_successor_of("Test") - with pytest.raises(TypeError): - root.is_successor_of("Player 1") - - def _get_path_of_action_labels(node: gbt.Node) -> list[str]: """ Computes the path of action labels from a given node to the root. @@ -184,9 +151,9 @@ def _get_path_of_action_labels(node: gbt.Node) -> list[str]: path = [] current_node = node - while current_node.parent: - path.append(current_node.prior_action.label) - current_node = current_node.parent + while current_node._parent(): + path.append(current_node._prior_action().label) + current_node = current_node._parent() return path @@ -405,90 +372,6 @@ def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): assert game.get_minimal_subgame(selector) == expected_root_for_key[key] -@pytest.mark.parametrize("game_file, expected_node_data", [ - ( - "binary_3_levels_generic_payoffs.efg", - [ - # Format: (Path in Node->Root order, (Player Label, Infoset Num, Action Label) or None) - ([], None), - (["Left"], None), - (["Left", "Left"], ("Player 1", 0, "Left")), - (["Right", "Left"], ("Player 1", 0, "Left")), - (["Right"], None), - (["Left", "Right"], ("Player 1", 0, "Right")), - (["Right", "Right"], ("Player 1", 0, "Right")), - ] - ), - ( - gbt.catalog.load("journals/geb/wichardt2008"), - [ - ([], None), - (["R"], ("Player 1", 0, "R")), - (["r", "R"], None), - (["l", "R"], None), - (["L"], ("Player 1", 0, "L")), - (["r", "L"], None), - (["l", "L"], None), - ] - ), - ( - "subgames.efg", - [ - ([], None), - (["1"], None), - (["2"], None), - (["1", "2"], ("Player 2", 0, "2")), - (["2", "1", "2"], ("Player 1", 1, "1")), - (["2", "2"], ("Player 2", 0, "2")), - (["1", "2", "2"], ("Player 2", 1, "1")), - (["1", "1", "2", "2"], ("Player 1", 1, "2")), - (["1", "1", "1", "2", "2"], ("Player 2", 2, "1")), - (["2", "1", "2", "2"], ("Player 1", 1, "2")), - (["1", "2", "1", "2", "2"], ("Player 2", 2, "2")), - (["2", "2", "1", "2", "2"], ("Player 2", 2, "2")), - (["1", "2", "2", "1", "2", "2"], ("Player 1", 4, "2")), - (["1", "1", "2", "2", "1", "2", "2"], ("Player 2", 4, "1")), - (["1", "1", "1", "2", "2", "1", "2", "2"], ("Player 1", 5, "1")), - (["2", "1", "1", "2", "2", "1", "2", "2"], ("Player 1", 5, "1")), - (["2", "2", "2", "1", "2", "2"], ("Player 1", 4, "2")), - (["2", "2", "2"], ("Player 1", 1, "2")), - ] - ), - ( - "AM-driver-subgame.efg", - [ - ([], None), - (["S"], ("Player 1", 0, "S")), - (["T", "S"], None), - ] - ), -]) -def test_node_own_prior_action_non_terminal(game_file, expected_node_data): - """ - Tests `node.own_prior_action` for non-terminal nodes. - Also verifies that all terminal nodes return None. - """ - game = game_file if isinstance(game_file, gbt.Game) else games.read_from_file(game_file) - - actual_node_data = [] - - for node in games.all_nodes(game): - if not game.get_actions(games.selector_for_node(node)): - assert node.own_prior_action is None, ( - f"Terminal node at {_get_path_of_action_labels(node)} must be None" - ) - else: - # Only collect data for non-terminal nodes - opa = node.own_prior_action - if opa is not None: - 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)) - - assert actual_node_data == expected_node_data - - @pytest.mark.parametrize("game_file, expected_unreachable_paths", [ # Games without absent-mindedness, where all nodes are reachable (gbt.catalog.load("journals/geb/wichardt2008"), []), From df82d0a7f3476c49ae6f1ba734a11197b310be97 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Sat, 5 Sep 2026 15:34:11 +0100 Subject: [PATCH 20/24] Remove GameOutcome and Node.outcome, standardise get_outcome return --- doc/pygambit.api.rst | 1 - src/pygambit/game.pxi | 37 +++++++++------ src/pygambit/node.pxi | 82 --------------------------------- tests/test_outcome_mutations.py | 4 +- tests/test_outcome_queries.py | 79 ------------------------------- tests/test_strategic_queries.py | 5 +- tests/test_tree_mutations.py | 8 ++-- 7 files changed, 32 insertions(+), 184 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index f3341aa7b4..06ff96e368 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -136,7 +136,6 @@ Information about the game .. autosummary:: :toctree: api/ - Node.outcome Node.members Node.actions Node.action_probs diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 6fc8235b60..25688d8eb1 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -150,7 +150,7 @@ class Game: players = list(g.players) for profile in itertools.product(*(range(s) for s in shape)): contingency = {p: str(i + 1) for p, i in zip(players, profile, strict=True)} - outcome = g.get_outcome(contingency) + outcome = g._get_outcome_object(contingency) for array, player in zip(arrays, players, strict=True): outcome[player] = array[profile] g.title = title @@ -239,7 +239,7 @@ class Game: players = list(g.players) for profile in itertools.product(*(range(s) for s in shape)): contingency = {p: str(i + 1) for p, i in zip(players, profile, strict=True)} - outcome = g.get_outcome(contingency) + outcome = g._get_outcome_object(contingency) for array, player in zip(arrays, players, strict=True): outcome[player] = array[profile] g.title = title @@ -1104,17 +1104,15 @@ class Game: deref(deref(psp).deref()).SetStrategy(handle) return psp - def get_outcome(self, location) -> Outcome | str | None: - """Returns the outcome attached at `location`. + def get_outcome(self, location) -> str | None: + """Returns the label of the outcome attached at `location`. For a tree game, `location` is a `Selector` (an `H`-built expression, - evaluated against this game) that must resolve to exactly one node; - the label of the outcome currently attached there is returned. + evaluated against this game) that must resolve to exactly one node. For a strategic (table) game, `location` is a pure-strategy contingency -- a complete mapping from the game's players' labels to - the label of the strategy played by that player; the `Outcome` - attached to it is returned. + the label of the strategy played by that player. .. versionadded:: 17.0.0 @@ -1123,6 +1121,10 @@ class Game: the outcome's label (or `None`) directly, rather than raising `UndefinedOperationError`. + .. versionchanged:: 17.0.0 + Always returns the outcome's label (or `None`); previously + returned the `Outcome` object itself for a strategic game. + Parameters ---------- location : Selector or Mapping @@ -1131,11 +1133,9 @@ class Game: Returns ------- - Outcome, str, or None - For a strategic game, the `Outcome` attached to `location` - (possibly the null outcome). For a tree game, the label of the - outcome currently attached to the resolved node, or `None` if it - has none. + str or None + The label of the outcome attached at `location`, or `None` if it + is the null outcome. Raises ------ @@ -1165,12 +1165,21 @@ class Game: return Outcome.wrap( cython.cast(Node, resolved_node).node.deref().GetOutcome() ).label + return self._get_outcome_object(location).label + + def _get_outcome_object(self, contingency: typing.Mapping) -> Outcome: + """Returns the `Outcome` object attached at a pure-strategy + `contingency` in a strategic (table) game. Not part of the public + API; `get_outcome` returns only the label. Used internally by + `from_arrays`/`from_dict`, which need to mutate the outcome's + payoffs directly. + """ if self.game.deref().IsAgg(): raise UndefinedOperationError( "get_outcome(): operation not defined for games not in " "strategic (table) representation" ) - resolved = self._resolve_contingency(location, "get_outcome") + resolved = self._resolve_contingency(contingency, "get_outcome") psp = self._make_pure_strategy_profile(resolved) return Outcome.wrap(deref(deref(psp).deref()).GetOutcome()) diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index b9e30d9b6a..d88a7bd701 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -108,68 +108,6 @@ class NodeChildren: raise TypeError(f"Index must be a str label, not {action.__class__.__name__}") -@cython.cclass -class NodeOutcome: - """The outcome attached to a node. - - A lazy, node-anchored view: holds the node and resolves its outcome on each access, - so the value reflects the current state of the game even after the game is mutated. - - .. versionadded:: 16.7.0 - - .. versionchanged:: 17.0.0 - A node with no outcome attached resolves to the game's null outcome: the view is - falsy, its ``label`` is ``None``, its payoffs read as zero, and it compares unequal - to every outcome — including another null and itself — and to ``None``. - """ - node = cython.declare(c_GameNode) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create a NodeOutcome outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(node: c_GameNode) -> NodeOutcome: - obj: NodeOutcome = NodeOutcome.__new__(NodeOutcome) - obj.node = node - return obj - - @cython.cfunc - def _resolve(self) -> Outcome: - return Outcome.wrap(self.node.deref().GetOutcome()) - - def __getattr__(self, name): - if name.startswith("_"): - raise AttributeError(f"'NodeOutcome' object has no attribute '{name}'") - return getattr(self._resolve(), name) - - def __getitem__(self, player): - return self._resolve()[player] - - def __setitem__(self, player, value): - self._resolve()[player] = value - - @property - def label(self): - return self._resolve().label - - @label.setter - def label(self, value): - self._resolve().label = value - - def __repr__(self) -> str: - return repr(self._resolve()) - - def __eq__(self, other: typing.Any) -> bool: - return self._resolve() == other - - def __bool__(self) -> bool: - return not self.node.deref().GetOutcome().deref().IsNull() - - def __hash__(self) -> int: - return hash(self._resolve()) - - @cython.cclass class Node: """A node in a ``Game``.""" @@ -375,26 +313,6 @@ class Node: """ return self.node.deref().IsStrategyReachable() - @property - def outcome(self) -> NodeOutcome: - """The outcome currently attached to this node. - - Returns a lazy, node-anchored view resolved on each access, so the value reflects - the current state of the game even if the game is mutated after this property is read. - When no outcome is attached, the view resolves to the game's null outcome: - its ``label`` is ``None``, its payoffs read as zero, and it compares unequal - to every outcome, including another null. - - .. versionchanged:: 16.7.0 - Now returns a lazily-evaluated, node-anchored view rather than capturing the - outcome at the time of access. - - .. versionchanged:: 17.0.0 - Resolves to the null outcome rather than ``None`` when no outcome is attached; - two null outcomes compare unequal. - """ - return NodeOutcome.wrap(self.node) - @cython.cfunc def _plays(self) -> list: """The terminal nodes consistent with this node. Not part of the public diff --git a/tests/test_outcome_mutations.py b/tests/test_outcome_mutations.py index 4461e12d23..a5d5e950d9 100644 --- a/tests/test_outcome_mutations.py +++ b/tests/test_outcome_mutations.py @@ -64,8 +64,8 @@ def test_make_outcome_attaches_at_contingencies(): outcome = game.make_outcome( [{"1": "1", "2": "1"}, {"1": "2", "2": "2"}], {"1": 2, "2": -2}, "diagonal" ) - assert game.get_outcome({"1": "1", "2": "1"}) == outcome - assert game.get_outcome({"1": "2", "2": "2"}) == outcome + assert game.get_outcome({"1": "1", "2": "1"}) == outcome.label + assert game.get_outcome({"1": "2", "2": "2"}) == outcome.label assert not game.get_outcome({"1": "1", "2": "2"}) assert outcome["1"] == 2 diff --git a/tests/test_outcome_queries.py b/tests/test_outcome_queries.py index e731296953..0760a33e45 100644 --- a/tests/test_outcome_queries.py +++ b/tests/test_outcome_queries.py @@ -5,85 +5,6 @@ from . import games -def test_get_outcome(): - """Test to ensure that we can retrieve an outcome for a given node""" - game = games.read_from_file("basic_extensive_game.efg") - assert ( - games.node_at_history(game, ("U1", "D2", "U3")).outcome - == game.outcomes["Outcome 1"] - ) - assert not games.node_at_history(game, ()).outcome - - -def test_make_outcome_null(): - """Resetting a node's outcome to null leaves the node's outcome view falsy.""" - game = games.read_from_file("basic_extensive_game.efg") - node = games.node_at_history(game, ("U1", "U2", "U3")) - game.make_outcome_null(gbt.H.path("U1", "U2", "U3")) - assert not node.outcome - - -def test_node_outcome_subscript_tracks_mutation(): - """Indexing the outcome view reads/writes the outcome's payoffs, reflecting mutation.""" - game = games.read_from_file("basic_extensive_game.efg") - node = games.node_at_history(game, ("U1", "D2", "U3")) - proxy = node.outcome - player = "Player 1" - proxy[player] = 7 - assert node.outcome[player] == 7 - - -def test_outcome_equality_is_symmetric(): - """A node-anchored outcome view and the resolved Outcome compare equal from either side.""" - game = games.read_from_file("basic_extensive_game.efg") - node = games.node_at_history(game, ("U1", "D2", "U3")) - proxy = node.outcome - outcome = game.outcomes["Outcome 1"] - assert proxy == outcome - assert outcome == proxy - - -def test_null_outcome_label_is_none(): - """The blessed nullity idiom: a node with no outcome has `outcome.label is None`.""" - game = games.read_from_file("basic_extensive_game.efg") - assert games.node_at_history(game, ()).outcome.label is None - - -def test_null_outcome_compares_unequal_to_itself(): - """Null outcomes are unequal to everything, including another view of the same - node's outcome; equality must not short-circuit on node identity.""" - game = games.read_from_file("basic_extensive_game.efg") - root = games.node_at_history(game, ()) - assert (root.outcome == root.outcome) is False - - -def test_null_outcome_reads_zero_payoffs(): - """Reading a payoff through an unset node reports zero to every player of the game.""" - game = games.read_from_file("basic_extensive_game.efg") - root = games.node_at_history(game, ()) - for player in game.players: - assert root.outcome[player] == 0 - - -def test_null_outcome_payoff_write_raises(): - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.UndefinedOperationError): - games.node_at_history(game, ()).outcome["Player 1"] = 1 - - -def test_null_outcome_label_write_raises(): - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - games.node_at_history(game, ()).outcome.label = "Outcome 4" - - -def test_null_outcome_number_is_none(): - """The null outcome is not a member of the game's outcomes, so it has no number. - -1 would be a valid index and would silently resolve to the last real outcome.""" - game = games.read_from_file("basic_extensive_game.efg") - assert games.node_at_history(game, ()).outcome.number is None - - @pytest.mark.parametrize("label", games.VALID_LABELS) def test_outcome_label(label: str): game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) diff --git a/tests/test_strategic_queries.py b/tests/test_strategic_queries.py index e6223dcc3d..e6361e650a 100644 --- a/tests/test_strategic_queries.py +++ b/tests/test_strategic_queries.py @@ -49,7 +49,7 @@ def test_game_get_max_payoff(): def test_game_get_outcome(): game = gbt.Game.new_table([2, 2]) game.make_outcome({"1": "1", "2": "1"}, {"1": 0, "2": 0}, "top left") - assert game.get_outcome({"1": "1", "2": "1"}) == next(iter(game.outcomes)) + assert game.get_outcome({"1": "1", "2": "1"}) == "top left" def test_game_get_outcome_by_relabeled_strategies(): @@ -58,8 +58,7 @@ def test_game_get_outcome_by_relabeled_strategies(): game.relabel_strategies(pl1, {next(iter(game.get_strategies(pl1))): "defect"}) game.relabel_strategies(pl2, {next(iter(game.get_strategies(pl2))): "cooperate"}) game.make_outcome({pl1: "defect", pl2: "cooperate"}, {"1": 0, "2": 0}, "corner") - assert game.get_outcome({pl1: "defect", pl2: "cooperate"}) == \ - next(iter(game.outcomes)) + assert game.get_outcome({pl1: "defect", pl2: "cooperate"}) == "corner" def test_game_get_outcome_incomplete_contingency_raises(): diff --git a/tests/test_tree_mutations.py b/tests/test_tree_mutations.py index 788bc6772f..ba00b38f59 100644 --- a/tests/test_tree_mutations.py +++ b/tests/test_tree_mutations.py @@ -87,9 +87,11 @@ def _subtrees_equal( if n1 == recursion_stop_node: return not n2._children() if not n1._children() and not n2._children(): - if not n1.outcome and not n2.outcome: - return True - return n1.outcome == n2.outcome + game = n1._game() + return ( + game.get_outcome(gbt.H.path(*games._node_history(n1))) + == game.get_outcome(gbt.H.path(*games._node_history(n2))) + ) if bool(n1._children()) != bool(n2._children()): return False # now, both n1 and n2 are non-terminal From 210cc4fb91325f231a376a71fa479df116b943f2 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Sat, 5 Sep 2026 15:48:26 +0100 Subject: [PATCH 21/24] Remove obsolete node tests --- tests/test_behav.py | 22 ++++------- tests/test_behavspt_profiles.py | 24 +++++------ tests/test_outcome_mutations.py | 10 +---- tests/test_tree_mutations.py | 70 --------------------------------- 4 files changed, 19 insertions(+), 107 deletions(-) diff --git a/tests/test_behav.py b/tests/test_behav.py index 56914ea274..5513e212b5 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -543,27 +543,19 @@ def test_behavior_setitem_sparse_rejects_negative_weight(): @pytest.mark.parametrize("sparse", [False, True]) -def test_behavior_indexing_rejects_node_and_infoset(sparse: bool): - """MixedBehaviorProfile's indexing is Selector-only; neither a bare History nor a - bare `Node` object is accepted, following the same pattern as - `Game.get_minimal_subgame`. +def test_behavior_indexing_rejects_history(sparse: bool): + """MixedBehaviorProfile's indexing is Selector-only; a bare `History` tuple is + not accepted, 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 = games.all_nodes(game)[0] + history = games.player_infosets(game, "Player 1")[0] with pytest.raises(TypeError): - profile[infoset] + profile[history] with pytest.raises(TypeError): - profile[infoset] = {"U1": 1} + profile[history] = {"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) + profile.set_mixed_action(history, {"U1": 1}, sparse=sparse) @pytest.mark.parametrize("rational_flag", [False, True]) diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index 49f11aaefd..a885b6f3f9 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -15,18 +15,15 @@ def _find_selector(game, label): return gbt.H.path(*_find_history(game, label)) -def _branching_game(): +def _branching_game() -> gbt.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. """ game = gbt.Game.new_tree(players=["P1", "P2"]) - root = games.node_at_history(game, ()) game.append_move(gbt.H.path(), "P1", ["L", "R"]) - left = games.node_at_history(game, ("L",)) - right = games.node_at_history(game, ("R",)) game.append_move(gbt.H.path("L"), "P2", ["A", "B"]) game.append_move(gbt.H.path("R"), "P2", ["A", "B"]) - return game, root, left, right + return game def test_getitem_by_selector(): @@ -61,17 +58,16 @@ def test_getitem_rejects_other_types(): profile[0] -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`. +def test_getitem_setitem_reject_history(): + """Profile indexing is `Selector`-only: a bare `History` tuple is no longer + accepted, following the same pattern as `Game.get_minimal_subgame`. """ - game, root, _, _ = _branching_game() + game = _branching_game() profile = game.behavior_support_profile() with pytest.raises(TypeError): - profile[root] + profile[("L",)] with pytest.raises(TypeError): - profile[root] = ["R"] + profile[("L",)] = ["A"] def test_predicate_construction(): @@ -154,7 +150,7 @@ def test_actionsupport_is_snapshot(): def test_getitem_setitem_use_selector(): - game, _, _, _ = _branching_game() + game = _branching_game() profile = game.behavior_support_profile() root_selector = gbt.H.path() assert set(profile[root_selector]) == {"L", "R"} @@ -163,7 +159,7 @@ def test_getitem_setitem_use_selector(): def test_is_infoset_reachable(): - game, _, _, _ = _branching_game() + game = _branching_game() profile = game.behavior_support_profile() left_selector = gbt.H.path("L") right_selector = gbt.H.path("R") diff --git a/tests/test_outcome_mutations.py b/tests/test_outcome_mutations.py index a5d5e950d9..07e4e11b45 100644 --- a/tests/test_outcome_mutations.py +++ b/tests/test_outcome_mutations.py @@ -2,8 +2,6 @@ import pygambit as gbt -from . import games - def test_make_outcome_attaches_to_all_given_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) @@ -50,11 +48,9 @@ def test_make_outcome_accepts_grouped_selector_pooled(): def test_make_outcome_error_location_not_a_selector(): - """A bare `Node` or `History` tuple is no longer accepted for an extensive game.""" + """A bare `History` tuple is no longer accepted for an extensive game.""" game = gbt.Game.new_tree(["Alice"]) game.append_move(gbt.H.path(), "Alice", ["U", "D"]) - with pytest.raises(TypeError): - game.make_outcome(games.node_at_history(game, ("U",)), {"Alice": 1}, "w") with pytest.raises(TypeError): game.make_outcome(("U",), {"Alice": 1}, "w") @@ -140,11 +136,9 @@ def test_make_outcome_null_accepts_selector(): def test_make_outcome_null_error_location_not_a_selector(): - """A bare `Node` or `History` tuple is no longer accepted for an extensive game.""" + """A bare `History` tuple is no longer accepted for an extensive game.""" game = gbt.Game.new_tree(["Alice"]) game.append_move(gbt.H.path(), "Alice", ["U", "D"]) - with pytest.raises(TypeError): - game.make_outcome_null(games.node_at_history(game, ("U",))) with pytest.raises(TypeError): game.make_outcome_null(("U",)) diff --git a/tests/test_tree_mutations.py b/tests/test_tree_mutations.py index ba00b38f59..96881f4fd2 100644 --- a/tests/test_tree_mutations.py +++ b/tests/test_tree_mutations.py @@ -47,24 +47,6 @@ def test_insert_move_error_duplicate_label(): game.insert_move(gbt.H.path(), "Player 1", ["a", "a"]) -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 = games.node_at_history(game, ("U1",)) - assert node.actions - game.delete_tree(gbt.H.path("U1")) - with pytest.raises(AttributeError): - _ = node.actions - - -def test_node_delete_parent(): - """Test to ensure deleting a parent node works""" - game = games.read_from_file("basic_extensive_game.efg") - node = games.node_at_history(game, ("U1",)) - game.delete_parent(gbt.H.path("U1")) - assert games.node_at_history(game, ()) == node - - def test_node_delete_tree(): """Test to ensure deleting every child of a node works""" game = games.read_from_file("basic_extensive_game.efg") @@ -79,58 +61,6 @@ def test_node_copy_nonterminal(): game.copy_tree(gbt.H.path(), gbt.H.path()) -def _subtrees_equal( - n1: gbt.Node, - n2: gbt.Node, - recursion_stop_node: gbt.Node | None = None -) -> bool: - if n1 == recursion_stop_node: - return not n2._children() - if not n1._children() and not n2._children(): - game = n1._game() - return ( - game.get_outcome(gbt.H.path(*games._node_history(n1))) - == game.get_outcome(gbt.H.path(*games._node_history(n2))) - ) - if bool(n1._children()) != bool(n2._children()): - return False - # now, both n1 and n2 are non-terminal - # check that they are in the same infosets - if n1 not in n2.members: - return False - # check that they have the same number of children - if len(n1._children()) != len(n2._children()): - return False - - return all( - _subtrees_equal(c1, c2, recursion_stop_node) for (c1, c2) in zip( - n1._children(), n2._children(), strict=True - ) - ) - - -def test_copy_tree_onto_nondescendent_terminal_node(): - """Test copying a subtree to a non-descendent node.""" - g = gbt.catalog.load("journals/ijgt/selten1975/fig1") - src_node = games.node_at_history(g, ("R", "L")) - dest_node = games.node_at_history(g, ("R", "R")) - - g.copy_tree(gbt.H.path("R", "L"), gbt.H.path("R", "R")) - - assert _subtrees_equal(src_node, dest_node) - - -def test_copy_tree_onto_descendent_terminal_node(): - """Test copying a subtree to a node that's a descendent of the original.""" - g = gbt.catalog.load("journals/ijgt/selten1975/fig1") - src_node = games.node_at_history(g, ("R",)) - dest_node = games.node_at_history(g, ("R", "L", "R")) - - g.copy_tree(gbt.H.path("R"), gbt.H.path("R", "L", "R")) - - assert _subtrees_equal(src_node, dest_node, dest_node) - - def test_node_move_nonterminal(): """Test on moving to a nonterminal node.""" game = games.read_from_file("basic_extensive_game.efg") From 509ffee8dadfd9d97d6cbc12871cf3bd22e5c2e2 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Sat, 5 Sep 2026 15:59:17 +0100 Subject: [PATCH 22/24] Remove Node from behaviour support profile --- src/pygambit/game.pxi | 14 ++++++++++---- src/pygambit/hsel.pxi | 11 ++++++----- tests/test_behavspt_profiles.py | 4 ++-- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 25688d8eb1..a5f0ce2fb6 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1481,9 +1481,13 @@ class Game: ---------- actions : function, optional By default the support profile contains all actions at all information - sets. If specified, called as ``actions(node, action)`` for each action at - each information set, where ``node`` is a representative node of the - information set; only actions for which it returns `True` are included. + sets. If specified, called as ``actions(history, action)`` for each action + at each information set, where ``history`` is a read-only `HistoryView` (see + `Selector.filter`) of a representative member of the information set; only + actions for which it returns `True` are included. + + .. versionchanged:: 17.0.0 + ``actions`` is now called with a `HistoryView` rather than a `Node`. Returns ------- @@ -1493,9 +1497,11 @@ class Game: if actions is not None: for player in self.players: for node in self._get_infosets(player): + history_view = HistoryView._wrap(node, _history_of(node)) 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")): + label = action.deref().GetLabel().decode("utf-8") + if not actions(history_view, label): if not deref(profile.profile).RemoveAction(action): raise ValueError( "attempted to remove the last action at an information set" diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi index bfc14ce097..bc660471a3 100644 --- a/src/pygambit/hsel.pxi +++ b/src/pygambit/hsel.pxi @@ -228,11 +228,12 @@ def _canonical_history(node: Node) -> tuple: class HistoryView: - """The object a `.by(callable)` key function actually receives. Supports - plain sequence indexing/slicing like a `History` tuple, plus limited - game-aware navigation (`.last_action(player)`) -- but never exposes the - `Node`/game it's privately backed by. Never returned to calling code - outside a `.by(callable)` call; not constructible directly. + """The object a `.filter(callable)`/`.by(callable)` predicate, or + `Game.behavior_support_profile`'s `actions` callback, actually receives. + Supports plain sequence indexing/slicing like a `History` tuple, plus + limited game-aware navigation (`.last_action(player)`) -- but never + exposes the `Node`/game it's privately backed by. Not constructible + directly. .. versionadded:: 17.0.0 """ diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index a885b6f3f9..a8e8323b69 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -72,7 +72,7 @@ def test_getitem_setitem_reject_history(): def test_predicate_construction(): game = games.read_from_file("mixed_behavior_game.efg") - profile = game.behavior_support_profile(lambda node, a: a != "D1") + profile = game.behavior_support_profile(lambda history, a: a != "D1") assert set(profile[_find_selector(game, "Infoset 1:1")]) == {"U1"} @@ -81,7 +81,7 @@ def test_predicate_construction_error(): 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._parent() is not None) + game.behavior_support_profile(lambda history, a: len(history) > 0) def test_iter_yields_one_support_per_player(): From 381a792781c15da17e116ba32340ccdc4a74035c Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Sat, 5 Sep 2026 16:09:32 +0100 Subject: [PATCH 23/24] Final closure of Node public interface --- src/pygambit/game.pxi | 76 ++++++++++++++++++-------------------- tests/test_game_resolve.py | 17 ++++----- 2 files changed, 42 insertions(+), 51 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index a5f0ce2fb6..43670aac67 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1691,7 +1691,10 @@ class Game: ) def _resolve_node(self, node: typing.Any, funcname: str, argname: str = "node") -> Node: - """Resolve an attempt to reference a node of the game. + """Resolve an attempt to reference a node of the game. A bare `Node` is not + accepted -- every public method that reaches this already requires a + `Selector` (or, for internal callers, an already-resolved `Node`, never + routed back through here). Parameters ---------- @@ -1704,20 +1707,14 @@ class Game: Raises ------ - MismatchError - If `node` is a `Node` from a different game. KeyError If `node` is a string and no node in the game has that label. TypeError - If `node` is not a `Node` or a `str` + If `node` is not a `Selector`, `tuple`, or `str` ValueError If `node` is an empty `str` or all spaces """ - if isinstance(node, Node): - if cython.cast(Node, node)._game() != self: - raise MismatchError(f"{funcname}(): {argname} must be part of the same game") - return node - elif isinstance(node, Selector): + if isinstance(node, Selector): resolved = self._get_nodes(node) if len(resolved) != 1: raise ValueError( @@ -1738,27 +1735,26 @@ class Game: return n raise KeyError(f"{funcname}(): no node with label '{node}'") raise TypeError( - f"{funcname}(): {argname} must be Node or str, not {node.__class__.__name__}" + f"{funcname}(): {argname} must be Selector, tuple, or str, " + f"not {node.__class__.__name__}" ) def _resolve_nodes(self, nodes: typing.Any, funcname: str, argname: str = "nodes") -> list[Node]: - """Resolve an attempt to reference a subset of the nodes of the game of the game. + """Resolve an attempt to reference a subset of the nodes of the game. - See `_resolve_node` for details on functionality. - - `nodes` may also be a `Selector` (an `H`-built expression), evaluated - against this game via `_get_nodes` before the usual resolution. + `nodes` is a `Selector` (an `H`-built expression), evaluated against this + game via `_get_nodes`; or an already-resolved list of `Node`, dispatched + internally one group at a time from a `GroupedSelector` -- never a bare + `Node`/History/label from the caller directly, so no further per-element + resolution is needed. """ if isinstance(nodes, Selector): - nodes = self._get_nodes(nodes) - resolved_nodes = [ - self._resolve_node(n, funcname, argname) - for n in (nodes if hasattr(nodes, "__iter__") and not isinstance(nodes, (str, tuple)) - else [nodes]) - ] + resolved_nodes = self._get_nodes(nodes) + else: + resolved_nodes = list(nodes) if not resolved_nodes: raise ValueError(f"{funcname}(): `{argname}` must not be empty") if len(resolved_nodes) != len(set(resolved_nodes)): @@ -1768,12 +1764,13 @@ class Game: def _resolve_infoset(self, 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. + game, via a `Selector` resolving to a member node, or such a node's label. Parameters ---------- - infoset : Node or str - A node belonging to the information set, or such a node's label. + infoset : Selector, tuple, or str + A `Selector`/History resolving to a node belonging to the information + set, or such a node's label. funcname : str The name of the function to raise any exception on behalf of. argname : str, default 'infoset' @@ -1787,12 +1784,10 @@ class Game: Raises ------ - MismatchError - If `infoset` is a `Node` from a different game. KeyError If `infoset` is a string and no node in the game has that label. TypeError - If `infoset` is not a `Node` or a `str` + If `infoset` is not a `Selector`, `tuple`, or `str` ValueError If `infoset` resolves to a chance event rather than a personal player's information set, or to no information set at all (the node is terminal). @@ -1807,13 +1802,14 @@ class Game: def _resolve_event(self, 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. + """Resolve an attempt to reference a chance event of the game, via a + `Selector` resolving to a member node, or such a node's label. Parameters ---------- - event : Node or str - A node belonging to the event, or such a node's label. + event : Selector, tuple, or str + A `Selector`/History resolving to a node belonging to the event, or + such a node's label. funcname : str The name of the function to raise any exception on behalf of. argname : str, default 'event' @@ -1827,12 +1823,10 @@ class Game: Raises ------ - MismatchError - If `event` is a `Node` from a different game. KeyError If `event` is a string and no node in the game has that label. TypeError - If `event` is not a `Node` or a `str` + If `event` is not a `Selector`, `tuple`, or `str` ValueError If `event` resolves to a personal player's information set rather than a chance event, or to no event at all (the node is terminal). @@ -1850,13 +1844,15 @@ class Game: funcname: str, 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. + (whichever applies), via a `Selector` resolving to a member node, or such a + node's label. For operations that apply uniformly to either, such as + attaching to an existing one. Parameters ---------- - infoset : Node or str - A node belonging to the information set or event, or such a node's label. + infoset : Selector, tuple, or str + A `Selector`/History resolving to a node belonging to the information + set or event, or such a node's label. funcname : str The name of the function to raise any exception on behalf of. argname : str, default 'infoset' @@ -1870,12 +1866,10 @@ class Game: Raises ------ - MismatchError - If `infoset` is a `Node` from a different game. KeyError If `infoset` is a string and no node in the game has that label. TypeError - If `infoset` is not a `Node` or a `str` + If `infoset` is not a `Selector`, `tuple`, or `str` ValueError If `infoset` resolves to no information set or event (the node is terminal). diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index e6293c6714..6bd7cc1a03 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -9,14 +9,11 @@ def _test_valid_resolutions(collection: list, resolver: typing.Callable) -> None: - """Generic function to exercise resolving objects as themselves or via existing labels.""" + """Generic function to exercise resolving objects via their existing labels.""" for label, objects in itertools.groupby( sorted(collection, key=lambda x: x._label), lambda x: x._label ): objects = list(objects) - # Objects resolve to themselves - for obj in objects: - assert obj == resolver(obj, "test") # Ambiguous labels raise ValueError if len(objects) > 1: with pytest.raises(ValueError): @@ -49,12 +46,12 @@ def test_resolve_node_invalid(game: gbt.Game, node: str, exception: BaseExceptio game._resolve_node(node, "test_resolve_node_invalid") -def test_resolve_node_mismatch(): - """A `Node` from a different game raises `MismatchError`.""" - game1 = gbt.Game.new_tree() - game2 = games.read_from_file("sample_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1._resolve_node(games.node_at_history(game2, ()), "test_resolve_node_mismatch") +def test_resolve_node_rejects_node(): + """A bare `Node` -- even one belonging to this same game -- is not accepted; + `_resolve_node` only resolves a `Selector`, `History` tuple, or label `str`.""" + game = games.read_from_file("sample_extensive_game.efg") + with pytest.raises(TypeError): + game._resolve_node(games.node_at_history(game, ()), "test_resolve_node_rejects_node") @pytest.mark.parametrize( From 801ac79a3f48c3b5b5e26bd8cdd97a3ee89083a7 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Sat, 5 Sep 2026 17:39:56 +0100 Subject: [PATCH 24/24] Final closure of Node public interface --- doc/tutorials/03_stripped_down_poker.ipynb | 80 +++++++++++----------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index fd766a7813..5198d980ce 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -371,26 +371,7 @@ "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)" + "`Game.get_infosets` returns each information set's canonical member as a `History` -- a plain tuple of action labels from the root. Indexing the profile directly by that tuple requires turning it into a `Selector` first, since `MixedBehaviorProfile.__getitem__` accepts only a player label or a `Selector`:" ] }, { @@ -401,10 +382,10 @@ "outputs": [], "source": [ "for number, node in enumerate(g.get_infosets(\"Alice\"), start=1):\n", - " for action in node.actions:\n", + " for action in g.get_actions(gbt.H.path(*node)):\n", " print(\n", " f\"At information set {number}, \"\n", - " f\"Alice plays {action} with probability: {eqm[selector_for_node(node)][action]}\"\n", + " f\"Alice plays {action} with probability: {eqm[gbt.H.path(*node)][action]}\"\n", " )" ] }, @@ -443,7 +424,10 @@ "id": "2966e700", "metadata": {}, "outputs": [], - "source": "(bob_node,) = g.get_infosets(\"Bob\")\neqm[selector_for_node(bob_node)][\"Call\"]" + "source": [ + "(bob_node,) = g.get_infosets(\"Bob\")\n", + "eqm[gbt.H.path(*bob_node)][\"Call\"]" + ] }, { "cell_type": "markdown", @@ -462,8 +446,8 @@ "metadata": {}, "outputs": [], "source": [ - "bob_action_values = eqm.action_values[bob_node]\n", - "for action in bob_node.actions:\n", + "bob_action_values = eqm.action_values[gbt.H.path(*bob_node)]\n", + "for action in g.get_actions(gbt.H.path(*bob_node)):\n", " print(\n", " f\"When Bob plays {action} his expected payoff is {bob_action_values[action]}\"\n", " )" @@ -478,7 +462,7 @@ "\n", "`MixedBehaviorProfile.beliefs` returns the probability of reaching each node, conditional on its information set being reached.\n", "\n", - "Recall that the two nodes in Bob's only information set are `g.root.children[\"King\"].children[\"Bet\"]` and `g.root.children[\"Queen\"].children[\"Bet\"]`):" + "Recall that the two nodes in Bob's only information set are `(\"King\", \"Bet\")` and `(\"Queen\", \"Bet\")` (as Histories):" ] }, { @@ -488,10 +472,10 @@ "metadata": {}, "outputs": [], "source": [ - "for node in bob_node.members:\n", + "for node in g.get_members(gbt.H.path(*bob_node)):\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", + " f\"Bob's belief in reaching the {node[-2]} -> \"\n", + " f\"{node[-1]} node is: {eqm.beliefs[node]}\"\n", " )" ] }, @@ -512,7 +496,7 @@ "metadata": {}, "outputs": [], "source": [ - "eqm.infoset_probs[bob_node]" + "eqm.infoset_probs[gbt.H.path(*bob_node)]" ] }, { @@ -531,10 +515,10 @@ "outputs": [], "source": [ "bob_node_values = eqm.node_values[\"Bob\"]\n", - "for node in bob_node.members:\n", + "for node in g.get_members(gbt.H.path(*bob_node)):\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", + " f\"The probability that the node {node[-2]} -> \"\n", + " f\"{node[-1]} is reached is: {eqm.realiz_probs[node]}. \",\n", " f\"Bob's expected payoff conditional on reaching this node is {bob_node_values[node]}\"\n", " )" ] @@ -669,7 +653,23 @@ "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 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()" + "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 g.get_actions(gbt.H.path(*node)):\n", + " print(\n", + " f\"At information set {number}, \"\n", + " f\"when playing {action} - \"\n", + " f\"gnm: {gnm_action_values[gbt.H.path(*node)][action]:.4f}\"\n", + " f\", lcp: {str(lcp_action_values[gbt.H.path(*node)][action])}\"\n", + " )\n", + " print()" + ] }, { "cell_type": "markdown", @@ -877,7 +877,7 @@ "source": [ "small_game = gbt.Game.new_tree()\n", "small_game.append_event(gbt.H.path(), dict.fromkeys([\"a\", \"b\", \"c\"], gbt.Rational(1, 3)))\n", - "list(small_game.root.action_probs.values())" + "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, { @@ -897,7 +897,7 @@ " gbt.H.path(),\n", " {\"a\": gbt.Rational(1, 4), \"b\": gbt.Rational(1, 2), \"c\": gbt.Rational(1, 4)}\n", ")\n", - "list(small_game.root.action_probs.values())" + "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, { @@ -919,7 +919,7 @@ " gbt.H.path(),\n", " {\"a\": gbt.Decimal(\".25\"), \"b\": gbt.Decimal(\".50\"), \"c\": gbt.Decimal(\".25\")}\n", ")\n", - "list(small_game.root.action_probs.values())" + "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, { @@ -942,7 +942,7 @@ "outputs": [], "source": [ "small_game.make_event(gbt.H.path(), {\"a\": \"1/4\", \"b\": \"1/2\", \"c\": \"1/4\"})\n", - "list(small_game.root.action_probs.values())" + "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, { @@ -953,7 +953,7 @@ "outputs": [], "source": [ "small_game.make_event(gbt.H.path(), {\"a\": \".25\", \"b\": \".50\", \"c\": \".25\"})\n", - "list(small_game.root.action_probs.values())" + "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, { @@ -977,7 +977,7 @@ "outputs": [], "source": [ "small_game.make_event(gbt.H.path(), {\"a\": .25, \"b\": .50, \"c\": .25})\n", - "list(small_game.root.action_probs.values())" + "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, {