From 2a6f8ee75aaf2d784d8bafa9ddba9e8989d681d7 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 24 Aug 2026 11:01:04 +0100 Subject: [PATCH 01/11] Remove direct properties from `Action`. This breaks `gtdraw`; test is currently `xfail` pending us updating it. --- ChangeLog | 8 +++ doc/pygambit.api.rst | 4 +- doc/tutorials/03_stripped_down_poker.ipynb | 18 +++---- src/pygambit/action.pxi | 57 +--------------------- src/pygambit/infoset.pxi | 25 ++++++++++ src/pygambit/node.pxi | 2 +- tests/test_actions.py | 50 +++++++++++-------- tests/test_extensive.py | 3 +- tests/test_infosets.py | 15 +++--- tests/test_node.py | 4 +- tests/test_tutorials.py | 22 ++++++++- 11 files changed, 107 insertions(+), 101 deletions(-) diff --git a/ChangeLog b/ChangeLog index 7c8af4afca..b0926659ee 100644 --- a/ChangeLog +++ b/ChangeLog @@ -12,6 +12,8 @@ - Added `max_rectangles` to `enumpoly_solve` (and `-r` to `gambit-enumpoly`), bounding the number of cells examined when searching for equilibria on a single support. (#1055) - Added `Game.set_players`, which declares the ordered list of the game's players, matching by label. (#1059) +- Added `Infoset.action_probs`, returning the probability of each of the information set's + actions, keyed by label. ### Fixed - `MixedStrategy.__eq__` raised `AttributeError` when comparing two `MixedStrategy` instances @@ -45,6 +47,12 @@ nonempty, unique labels. (#1058) - `Game.add_player` has been removed; use `Game.set_players`, specifying the labels of the players. (#1059) +- `Action.prob`, `Action.plays`, `Action.precedes`, and `Action.number` have been removed. + `Action.prob` is replaced by `Infoset.action_probs`. `Action.plays` and `Action.precedes` + compose from existing primitives (`Infoset.members`, `Node.children`, `Node.plays`, + `Node.is_successor_of`) and have no direct replacement. `Action.number` was always just + the action's position in `Infoset.actions`. This breaks the `gtdraw` package used by some + tutorial notebooks; the affected notebook tests are `xfail`-gated pending a `gtdraw` update. ## [17.0.0-alpha.1] - 2026-08-13 diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index fce9151913..247ff9c50c 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -176,6 +176,7 @@ Information about the game Infoset.is_absent_minded Infoset.player Infoset.actions + Infoset.action_probs Infoset.members Infoset.precedes Infoset.plays @@ -187,9 +188,6 @@ Information about the game Action.label Action.infoset - Action.precedes - Action.prob - Action.plays .. autosummary:: diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 113330199e..0791e5be69 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -160,7 +160,7 @@ "source": [ "The loop above causes each of the newly-appended moves to be in new information sets, reflecting the fact that Alice's decision depends on the knowledge of which card she holds.\n", "\n", - "In contrast, Bob does not know Alice\u2019s card, and therefore cannot distinguish between the two nodes at which he has to make his decision:\n", + "In contrast, Bob does not know Alice’s card, and therefore cannot distinguish between the two nodes at which he has to make his decision:\n", "\n", " - Chance player chooses King, then Alice Bets: `g.root.children[\"King\"].children[\"Bet\"]`\n", " - Chance player chooses Queen, then Alice Bets: `g.root.children[\"Queen\"].children[\"Bet\"]`\n", @@ -396,7 +396,7 @@ "id": "1f121d48", "metadata": {}, "source": [ - "Now let's look at Bob\u2019s strategy:" + "Now let's look at Bob’s strategy:" ] }, { @@ -414,7 +414,7 @@ "id": "e906c4c4", "metadata": {}, "source": [ - "Bob Calls Alice\u2019s Bet two-thirds of the time.\n", + "Bob Calls Alice’s Bet two-thirds of the time.\n", "\n", "Since Bob has just one information set, we can get its representative node and index\n", "the profile directly by it to read off a single action's probability:" @@ -879,7 +879,7 @@ "id": "2f79695a", "metadata": {}, "outputs": [], - "source": "small_game = gbt.Game.new_tree()\nsmall_game.append_event(small_game.root, [\"a\", \"b\", \"c\"], [gbt.Rational(1, 3)] * 3)\n[act.prob for act in small_game.root.infoset.actions]" + "source": "small_game = gbt.Game.new_tree()\nsmall_game.append_event(small_game.root, [\"a\", \"b\", \"c\"], [gbt.Rational(1, 3)] * 3)\nlist(small_game.root.infoset.action_probs.values())" }, { "cell_type": "markdown", @@ -893,7 +893,7 @@ "id": "5de6acb2", "metadata": {}, "outputs": [], - "source": "small_game.make_event(\n [small_game.root],\n [gbt.Rational(1, 4), gbt.Rational(1, 2), gbt.Rational(1, 4)]\n)\n[act.prob for act in small_game.root.infoset.actions]" + "source": "small_game.make_event(\n [small_game.root],\n [gbt.Rational(1, 4), gbt.Rational(1, 2), gbt.Rational(1, 4)]\n)\nlist(small_game.root.infoset.action_probs.values())" }, { "cell_type": "markdown", @@ -909,7 +909,7 @@ "id": "c47d2ab6", "metadata": {}, "outputs": [], - "source": "small_game.make_event(\n [small_game.root],\n [gbt.Decimal(\".25\"), gbt.Decimal(\".50\"), gbt.Decimal(\".25\")]\n)\n[act.prob for act in small_game.root.infoset.actions]" + "source": "small_game.make_event(\n [small_game.root],\n [gbt.Decimal(\".25\"), gbt.Decimal(\".50\"), gbt.Decimal(\".25\")]\n)\nlist(small_game.root.infoset.action_probs.values())" }, { "cell_type": "markdown", @@ -929,7 +929,7 @@ "id": "04329084", "metadata": {}, "outputs": [], - "source": "small_game.make_event([small_game.root], [\"1/4\", \"1/2\", \"1/4\"])\n[act.prob for act in small_game.root.infoset.actions]" + "source": "small_game.make_event([small_game.root], [\"1/4\", \"1/2\", \"1/4\"])\nlist(small_game.root.infoset.action_probs.values())" }, { "cell_type": "code", @@ -937,7 +937,7 @@ "id": "9015e129", "metadata": {}, "outputs": [], - "source": "small_game.make_event([small_game.root], [\".25\", \".50\", \".25\"])\n[act.prob for act in small_game.root.infoset.actions]" + "source": "small_game.make_event([small_game.root], [\".25\", \".50\", \".25\"])\nlist(small_game.root.infoset.action_probs.values())" }, { "cell_type": "markdown", @@ -958,7 +958,7 @@ "id": "0a019aa5", "metadata": {}, "outputs": [], - "source": "small_game.make_event([small_game.root], [.25, .50, .25])\n[act.prob for act in small_game.root.infoset.actions]" + "source": "small_game.make_event([small_game.root], [.25, .50, .25])\nlist(small_game.root.infoset.action_probs.values())" }, { "cell_type": "markdown", diff --git a/src/pygambit/action.pxi b/src/pygambit/action.pxi index f91a83007f..34ac919397 100644 --- a/src/pygambit/action.pxi +++ b/src/pygambit/action.pxi @@ -36,10 +36,7 @@ class Action: return obj def __repr__(self) -> str: - if self.label: - return f"Action(infoset={self.infoset}, label='{self.label}')" - else: - return f"Action(infoset={self.infoset}, number={self.number})" + return f"Action(infoset={self.infoset}, label='{self.label}')" def __eq__(self, other: typing.Any) -> bool: return ( @@ -50,26 +47,6 @@ class Action: def __hash__(self) -> int: return cython.cast(cython.long, self.action.deref()) - @property - def number(self) -> int: - """Returns the number of the action at its information set. - Actions are numbered starting with 0. - """ - return self.action.deref().GetNumber() - 1 - - def precedes(self, node: Node) -> bool: - """Returns whether `node` precedes this action in the - extensive game. - - Raises - ------ - MismatchError - If `node` is not in the same game as the action. - """ - if self.infoset.game != node.game: - raise MismatchError("precedes() requires a node from the same game as the action") - return self.action.deref().Precedes(cython.cast(Node, node).node) - @property def label(self) -> str: """The text label of the action. @@ -89,35 +66,3 @@ class Action: def infoset(self) -> Infoset: """Get the information set to which the action belongs.""" return Infoset.wrap(self.action.deref().GetInfoset()) - - @property - def prob(self) -> decimal.Decimal | Rational: - """ - Get the probability a chance action is played. - - Raises - ------ - UndefinedOperationError - If the action does not belong to the chance player. - """ - if not self.infoset.is_chance: - raise UndefinedOperationError( - "action probabilities are only defined at events" - ) - py_string = cython.cast( - string, - self.action.deref().GetInfoset().deref().GetActionProb(self.action) - ) - if "." in py_string.decode("ascii"): - return decimal.Decimal(py_string.decode("ascii")) - else: - return Rational(py_string.decode("ascii")) - - @property - def plays(self) -> list[Node]: - """Returns a list of all terminal `Node` objects consistent with it. - """ - return [ - Node.wrap(n) for n in - self.action.deref().GetInfoset().deref().GetGame().deref().GetPlays(self.action) - ] diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index 6a6d5d0e3d..f94fd06b27 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -215,6 +215,31 @@ class Infoset: """The set of actions at the information set.""" return InfosetActions.wrap(self.infoset) + @property + def action_probs(self) -> dict[str, decimal.Decimal | Rational]: + """The probability of each action at the information set, keyed by label. + + .. versionadded:: 17.0.0 + + Raises + ------ + UndefinedOperationError + If the information set does not belong to the chance player. + """ + if not self.is_chance: + raise UndefinedOperationError( + "action probabilities are only defined at events" + ) + probs = {} + for action in self.infoset.deref().GetActions(): + py_string = cython.cast(string, self.infoset.deref().GetActionProb(action)) + if "." in py_string.decode("ascii"): + prob = decimal.Decimal(py_string.decode("ascii")) + else: + prob = Rational(py_string.decode("ascii")) + probs[action.deref().GetLabel().decode("utf-8")] = prob + return probs + @property def own_prior_actions(self) -> list[Action | None]: """The set of actions taken by the player immediately preceding the member nodes diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index fc6e1b19af..cb626e336e 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -352,7 +352,7 @@ class Node: path = [] node = self while node.parent: - path.append(node.prior_action.number) + path.append(node.prior_action.label) node = node.parent return f"Node(game={self.game}, path={path})" diff --git a/tests/test_actions.py b/tests/test_actions.py index dadf98b186..c97aadb0c9 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -104,16 +104,12 @@ def test_relabel_actions_non_str_label_raises_typeerror(labels: dict): @pytest.mark.parametrize("game", [games.create_stripped_down_poker_efg()]) def test_action_precedes(game: gbt.Game): + """`Action.precedes` was removed; the equivalent predicate composes from + `Node.is_successor_of` over the information set's members.""" + infoset = game.root.infoset child = game.root.children["King"] - assert game.root.infoset.actions["King"].precedes(child) - assert not game.root.infoset.actions["Queen"].precedes(child) - - -@pytest.mark.parametrize("game", [games.create_stripped_down_poker_efg()]) -def test_action_precedes_nonnode(game: gbt.Game): - action = next(iter(game.root.infoset.actions)) - with pytest.raises(TypeError): - action.precedes(game) + assert any(child.is_successor_of(m.children["King"]) for m in infoset.members) + assert not any(child.is_successor_of(m.children["Queen"]) for m in infoset.members) def test_set_move_actions_drop_shrinks_actions_and_children(): @@ -149,12 +145,21 @@ def test_set_move_actions_reorder_carries_subtrees(): members = list(infoset.members) children_before = [{label: member.children[label] for label in ("a", "b", "c")} for member in members] - plays_before = {action.label: set(action.plays) for action in infoset.actions} + + def action_plays(infoset): + """`Action.plays` was removed; composes from `Infoset.members`, + `Node.children`, and `Node.plays`.""" + return { + action.label: {n for m in infoset.members for n in m.children[action.label].plays} + for action in infoset.actions + } + + plays_before = action_plays(infoset) game.set_move_actions(infoset, ["c", "a", "b"]) assert [action.label for action in 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 {action.label: set(action.plays) for action in infoset.actions} == plays_before + assert action_plays(infoset) == plays_before def test_set_move_actions_add_drop_and_reorder_together(): @@ -216,8 +221,8 @@ def test_set_event_actions_reorder_carries_probabilities(): event = game.root.infoset game.set_event_actions(event, {"King": "3/4", "Queen": "1/4"}) game.set_event_actions(event, {"Queen": "1/4", "King": "3/4"}) - assert [(a.label, a.prob) for a in event.actions] == [("Queen", gbt.Rational(1, 4)), - ("King", gbt.Rational(3, 4))] + assert list(event.action_probs.items()) == [("Queen", gbt.Rational(1, 4)), + ("King", gbt.Rational(3, 4))] def test_set_event_actions_add_with_probs_mapping(): @@ -225,9 +230,9 @@ def test_set_event_actions_add_with_probs_mapping(): event = game.root.infoset nodes_before = len(game.nodes) game.set_event_actions(event, {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) - assert [(a.label, a.prob) for a in event.actions] == [("Jack", gbt.Rational(1, 2)), - ("King", gbt.Rational(1, 4)), - ("Queen", gbt.Rational(1, 4))] + assert list(event.action_probs.items()) == [("Jack", gbt.Rational(1, 2)), + ("King", gbt.Rational(1, 4)), + ("Queen", gbt.Rational(1, 4))] assert len(game.nodes) == nodes_before + 1 @@ -235,7 +240,7 @@ def test_set_event_actions_drop_with_probs_mapping(): game = games.create_stripped_down_poker_efg() event = game.root.infoset game.set_event_actions(event, {"King": 1}, drop=True) - assert [(a.label, a.prob) for a in event.actions] == [("King", 1)] + assert list(event.action_probs.items()) == [("King", 1)] def test_set_event_actions_unconfirmed_drop_and_disabled_add_raise(): @@ -277,7 +282,8 @@ def test_set_event_actions_bad_distribution_raises_valueerror(): def test_action_plays(): - """Verify `action.plays` returns plays reachable from a given action.""" + """Verify the plays reachable from a given action, composed from `Infoset.members`, + `Node.children`, and `Node.plays` (`Action.plays` was removed).""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") def node_at(path: list[str]) -> gbt.Node: @@ -286,11 +292,15 @@ def node_at(path: list[str]) -> gbt.Node: node = node.children[action_label] return node - test_action = node_at(["L"]).infoset.actions["R"] + test_infoset = node_at(["L"]).infoset + test_label = "R" expected_set_of_plays = {node_at(["R", "L", "R"]), node_at(["L", "R"])} - assert set(test_action.plays) == expected_set_of_plays + actual_set_of_plays = { + n for m in test_infoset.members for n in m.children[test_label].plays + } + assert actual_set_of_plays == expected_set_of_plays @pytest.mark.parametrize( diff --git a/tests/test_extensive.py b/tests/test_extensive.py index 3165c0248a..bb5a6e94ff 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -508,7 +508,8 @@ def test_reduced_strategy_maps(game: gbt.Game, strategy_maps: list): for strategy, expected in zip(player.strategies, expected_maps, strict=True): behavior = game.get_behavior(player, strategy) assert tuple( - "*" if (action := behavior.get(infoset)) is None else str(action.number + 1) + "*" if (action := behavior.get(infoset)) is None + else str(list(infoset.actions).index(action) + 1) for infoset in player.infosets ) == expected diff --git a/tests/test_infosets.py b/tests/test_infosets.py index c39ff5ef0c..2e6486b97a 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -165,8 +165,7 @@ def test_make_event_sets_probabilities(inprobs, outprobs): """ game = games.read_from_file("stripped_down_poker.efg") game.make_event([game.root], inprobs, "Deal") - for action, prob in zip(game.root.infoset.actions, outprobs, strict=True): - assert action.prob == prob + assert list(game.root.infoset.action_probs.values()) == outprobs def test_make_event_pools_nodes_from_different_infosets(): @@ -176,8 +175,8 @@ def test_make_event_pools_nodes_from_different_infosets(): game.make_event(nodes, ["1/4", "3/4"], "Coin") assert nodes[0].infoset == nodes[1].infoset assert nodes[0].infoset.is_chance - assert [a.prob for a in nodes[0].infoset.actions] == [gbt.Rational("1/4"), - gbt.Rational("3/4")] + assert list(nodes[0].infoset.action_probs.values()) == [gbt.Rational("1/4"), + gbt.Rational("3/4")] assert not list(game.players["Alice"].infosets) @@ -200,8 +199,8 @@ def test_make_event_converts_personal_node(): node = next(iter(game.players["Alice"].infosets["Alice has King"].members)) game.make_event([node], ["1/4", "3/4"]) assert node.infoset.is_chance - assert [a.prob for a in node.infoset.actions] == [gbt.Rational("1/4"), - gbt.Rational("3/4")] + assert list(node.infoset.action_probs.values()) == [gbt.Rational("1/4"), + gbt.Rational("3/4")] def test_make_event_terminal_node_raises(): @@ -257,8 +256,8 @@ def test_make_event_label_reused_when_fully_absorbed(): game.make_event(nodes, ["1/4", "3/4"], "Coin") assert nodes[0].infoset == nodes[1].infoset assert nodes[0].infoset.label == "Coin" - assert [a.prob for a in nodes[0].infoset.actions] == [gbt.Rational("1/4"), - gbt.Rational("3/4")] + assert list(nodes[0].infoset.action_probs.values()) == [gbt.Rational("1/4"), + gbt.Rational("3/4")] assert [infoset.label for infoset in game.players.chance.infosets].count("Coin") == 1 diff --git a/tests/test_node.py b/tests/test_node.py index 4618f06cc4..cc6346816c 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -932,7 +932,7 @@ def test_append_event_sets_distribution(): game = games.read_from_file("sample_extensive_game.efg") node = game.root.children["1"].children["1"] game.append_event(node, ["a", "b"], [gbt.Rational(1, 4), gbt.Rational(3, 4)]) - assert [a.prob for a in node.infoset.actions] == [gbt.Rational(1, 4), gbt.Rational(3, 4)] + assert list(node.infoset.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] def test_append_event_error_actions_empty(): @@ -1017,7 +1017,7 @@ def test_insert_event_sets_distribution(): game = games.read_from_file("basic_extensive_game.efg") node = game.root game.insert_event(node, ["a", "b"], [gbt.Rational(1, 4), gbt.Rational(3, 4)]) - assert [a.prob for a in node.parent.infoset.actions] == [ + assert list(node.parent.infoset.action_probs.values()) == [ gbt.Rational(1, 4), gbt.Rational(3, 4) ] diff --git a/tests/test_tutorials.py b/tests/test_tutorials.py index 6d6a4bb9e0..ec24e6721a 100644 --- a/tests/test_tutorials.py +++ b/tests/test_tutorials.py @@ -35,12 +35,32 @@ def _find_tutorial_notebooks(): return notebooks +def _notebook_params(notebooks): + """Wrap each notebook path in a `pytest.param`, `xfail`-marking any notebook whose + raw source mentions `gtdraw`. + + `gtdraw` reaches into pygambit's node/action API in ways that pygambit's ongoing + removal of `Action` as a live reference breaks; a `gtdraw` fix is tracked separately + and is expected to land before this merges to master. Content-based, rather than a + hardcoded filename list, so this self-updates once `gtdraw` no longer needs it. + """ + params = [] + for path in notebooks: + marks = [] + if "gtdraw" in path.read_text(): + marks.append(pytest.mark.xfail( + reason="gtdraw not yet updated for pygambit's Action changes", strict=False + )) + params.append(pytest.param(path, marks=marks, id=path.name)) + return params + + # Discover notebooks at import time so pytest can parametrize them. _NOTEBOOKS = _find_tutorial_notebooks() @pytest.mark.tutorials -@pytest.mark.parametrize("nb_path", _NOTEBOOKS, ids=[p.name for p in _NOTEBOOKS]) +@pytest.mark.parametrize("nb_path", _notebook_params(_NOTEBOOKS)) def test_execute_notebook(nb_path): """Execute a single Jupyter notebook and fail if any cell errors occur. From 4a0fe6b4da8f210494c170077c7bafb20b3edbfe Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 24 Aug 2026 11:22:02 +0100 Subject: [PATCH 02/11] Remove Game.actions and Player.actions --- ChangeLog | 5 + doc/pygambit.api.rst | 2 - doc/tutorials/03_stripped_down_poker.ipynb | 27 +----- src/pygambit/catalog.py | 2 +- src/pygambit/game.pxi | 107 --------------------- src/pygambit/player.pxi | 67 ------------- tests/test_actions.py | 6 -- tests/test_behav.py | 20 ++-- tests/test_catalog.py | 2 +- tests/test_game.py | 2 - tests/test_game_resolve.py | 24 ----- tests/test_nash.py | 34 ++++--- tests/test_strategic.py | 13 --- 13 files changed, 38 insertions(+), 273 deletions(-) diff --git a/ChangeLog b/ChangeLog index b0926659ee..621ca0cd59 100644 --- a/ChangeLog +++ b/ChangeLog @@ -53,6 +53,11 @@ `Node.is_successor_of`) and have no direct replacement. `Action.number` was always just the action's position in `Infoset.actions`. This breaks the `gtdraw` package used by some tutorial notebooks; the affected notebook tests are `xfail`-gated pending a `gtdraw` update. +- `Game.actions` and `Player.actions` have been removed; both flattened actions across + several information sets into a single collection, discarding which information set each + action belonged to. Index an information set's own `actions` instead, iterating + `Game.infosets`/`Player.infosets` as needed. `Game._resolve_action`, an internal helper + with no callers besides its own tests, is removed along with it. ## [17.0.0-alpha.1] - 2026-08-13 diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 247ff9c50c..986ae78bad 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -110,7 +110,6 @@ Information about the game Game.max_payoff Game.strategies Game.root - Game.actions Game.infosets Game.nodes Game.contingencies @@ -125,7 +124,6 @@ Information about the game Player.game Player.strategies Player.infosets - Player.actions Player.is_chance Player.min_payoff Player.max_payoff diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 0791e5be69..2d7939acba 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -382,14 +382,7 @@ "id": "83bbd3e5", "metadata": {}, "outputs": [], - "source": [ - "for action in g.players[\"Alice\"].actions:\n", - " node = next(iter(action.infoset.members))\n", - " print(\n", - " f\"At information set {action.infoset.number}, \"\n", - " f\"Alice plays {action.label} with probability: {eqm[node][action.label]}\"\n", - " )" - ] + "source": "for infoset in g.players[\"Alice\"].infosets:\n for action in infoset.actions:\n node = next(iter(action.infoset.members))\n print(\n f\"At information set {action.infoset.number}, \"\n f\"Alice plays {action.label} with probability: {eqm[node][action.label]}\"\n )" }, { "cell_type": "markdown", @@ -658,23 +651,7 @@ "id": "d18a91f0", "metadata": {}, "outputs": [], - "source": [ - "for player in g.players:\n", - " print(\n", - " f\"{player.label}'s expected payoffs:\"\n", - " )\n", - " gnm_action_values = gnm_eqm.as_behavior().action_values\n", - " lcp_action_values = eqm.action_values\n", - " for action in player.actions:\n", - " node = next(iter(action.infoset.members))\n", - " print(\n", - " f\"At information set {action.infoset.number}, \"\n", - " f\"when playing {action.label} - \"\n", - " f\"gnm: {gnm_action_values[node][action.label]:.4f}\"\n", - " f\", lcp: {str(lcp_action_values[node][action.label])}\"\n", - " )\n", - " print()" - ] + "source": "for player in g.players:\n print(\n f\"{player.label}'s expected payoffs:\"\n )\n gnm_action_values = gnm_eqm.as_behavior().action_values\n lcp_action_values = eqm.action_values\n for infoset in player.infosets:\n for action in infoset.actions:\n node = next(iter(action.infoset.members))\n print(\n f\"At information set {action.infoset.number}, \"\n f\"when playing {action.label} - \"\n f\"gnm: {gnm_action_values[node][action.label]:.4f}\"\n f\", lcp: {str(lcp_action_values[node][action.label])}\"\n )\n print()" }, { "cell_type": "markdown", diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index dfbac97750..e33c9fe595 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -420,7 +420,7 @@ def check_filters(game: gbt.Game) -> bool: if n_actions is not None: if not game.is_tree: return False - if len(game.actions) != n_actions: + if sum(len(infoset.actions) for infoset in game.infosets) != n_actions: return False if n_contingencies is not None and len(game.contingencies) != n_contingencies: return False diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 402308b0eb..d9d7b3abd2 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -362,57 +362,6 @@ class GamePlayers: return Player.wrap(self.game.deref().GetChance()) -@cython.cclass -class GameActions: - """Represents the set of all actions in a game.""" - game = cython.declare(Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameActions outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: Game) -> GameActions: - obj: GameActions = GameActions.__new__(GameActions) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameActions(game={self.game})" - - def __len__(self) -> int: - return sum(len(s.actions) for s in self.game.infosets) - - def __iter__(self) -> typing.Iterator[Action]: - for infoset in self.game.infosets: - yield from infoset.actions - - def __getitem__(self, label: str) -> Action: - """Returns the action with text label `label`. - - Parameters - ---------- - label : str - The text label of the action to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. - - Raises - ------ - KeyError - If no action in the game has label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one action has label `label`. - TypeError - If `label` is not a string. - - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference an action by its label, or iterate - over the collection. String lookup now requires an exact match of the label; - previously, leading/trailing whitespace was stripped from `label` before comparison. - """ - return _resolve_by_label(self, label, "Game", "action", "actions") - - @cython.cclass class GameInfosets: """Represents the set of all infosets in a game.""" @@ -780,21 +729,6 @@ class Game: def description(self, value: str) -> None: self.game.deref().SetDescription(value.encode("utf-8")) - @property - def actions(self) -> GameActions: - """The set of actions available in the game. - - 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 GameActions.wrap(self) - @property def infosets(self) -> GameInfosets: """The set of information sets in the game. @@ -1719,47 +1653,6 @@ class Game: f"{funcname}(): {argname} must be Infoset or str, not {infoset.__class__.__name__}" ) - def _resolve_action(self, - action: typing.Any, funcname: str, argname: str = "action") -> Action: - """Resolve an attempt to reference an action of the game. - - Parameters - ---------- - action : Any - An object to resolve as a reference to an action. - funcname : str - The name of the function to raise any exception on behalf of. - argname : str, default 'action' - The name of the argument being checked - - Raises - ------ - MismatchError - If `action` is an `Action` from a different game. - KeyError - If `action` is a string and no action in the game has that label. - TypeError - If `action` is not an `Action` or a `str` - ValueError - If `action` is an empty `str` or all spaces - """ - if isinstance(action, Action): - if action.infoset.game != self: - raise MismatchError(f"{funcname}(): {argname} must be part of the same game") - return action - elif isinstance(action, str): - if not action.strip(): - raise ValueError( - f"{funcname}(): {argname} cannot be an empty string or all spaces" - ) - try: - return self.actions[action] - except KeyError: - raise KeyError(f"{funcname}(): no action with label '{action}'") - raise TypeError( - f"{funcname}(): {argname} must be Action or str, not {action.__class__.__name__}" - ) - def _resolve_probs(self, probs: typing.Sequence | typing.Mapping, action_labels: list[str], diff --git a/src/pygambit/player.pxi b/src/pygambit/player.pxi index 2916db8ed7..3cd6ba7709 100644 --- a/src/pygambit/player.pxi +++ b/src/pygambit/player.pxi @@ -78,58 +78,6 @@ class PlayerInfosets: return _resolve_by_label(self, label, "Player", "infoset", "infosets") -@cython.cclass -class PlayerActions: - """Represents the set of all actions available to a player at some information set.""" - player = cython.declare(Player) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create PlayerActions outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(player: Player) -> PlayerActions: - obj: PlayerActions = PlayerActions.__new__(PlayerActions) - obj.player = player - return obj - - def __repr__(self) -> str: - return f"PlayerActions(player={self.player})" - - def __len__(self) -> int: - return sum(len(s.actions) for s in self.player.infosets) - - def __iter__(self) -> typing.Iterator[Action]: - for infoset in self.player.infosets: - yield from infoset.actions - - def __getitem__(self, label: str) -> Action: - """Returns the player's action with text label `label`. - - Parameters - ---------- - label : str - The text label of the action to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. - - Raises - ------ - KeyError - If the player has no action with label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one of the player's actions - has label `label`. - TypeError - If `label` is not a string. - - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference an action by its label, or iterate - over the collection. String lookup now requires an exact match of the label; - previously, leading/trailing whitespace was stripped from `label` before comparison. - """ - return _resolve_by_label(self, label, "Player", "action", "actions") - - @cython.cclass class PlayerStrategies: """The set of strategies available to a player.""" @@ -302,21 +250,6 @@ class Player: ) return PlayerInfosets.wrap(self.player) - @property - def actions(self) -> PlayerActions: - """Returns the set of actions available to the player at some information set. - - Raises - ------ - UndefinedOperationError - If the game does not have a tree representation. - """ - if not self.game.is_tree: - raise UndefinedOperationError( - "Operation only defined for games with a tree representation" - ) - return PlayerActions.wrap(self) - @property def min_payoff(self) -> Rational: """Returns the smallest payoff for the player in any play of the game. diff --git a/tests/test_actions.py b/tests/test_actions.py index c97aadb0c9..f18365590b 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -412,9 +412,3 @@ def test_strategy_action_raises_error_for_strategic_game(): with pytest.raises(gbt.UndefinedOperationError): strategy.action(test_infoset) - - -def test_player_actions_len(): - game = games.create_stripped_down_poker_efg() - for player in game.players: - assert len(player.actions) == len(list(player.actions)) diff --git a/tests/test_behav.py b/tests/test_behav.py index ffd17f2606..7b0e897566 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -13,8 +13,8 @@ def _set_action_probs(profile: gbt.MixedBehaviorProfile, probs: list, rational_flag: bool): """Set the action probabilities in a behavior profile called ```profile``` according to a - list with probabilities in the order of ```profile.game.actions``` (grouped by information - set, matching how ```game.actions``` itself is ordered). + flat list with probabilities in the order of ```profile.game.infosets```, and then each + information set's ```actions```. """ convert = (lambda p: gbt.Rational(p)) if rational_flag else (lambda p: p) probs_iter = iter(probs) @@ -1612,7 +1612,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.action_values[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x: [a for infoset in x.infosets for a in infoset.actions], ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1620,7 +1620,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.action_values[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x: [a for infoset in x.infosets for a in infoset.actions], ), ( games.create_stripped_down_poker_efg(), @@ -1628,7 +1628,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.action_values[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x: [a for infoset in x.infosets for a in infoset.actions], ), ( games.create_stripped_down_poker_efg(), @@ -1636,7 +1636,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.action_values[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x: [a for infoset in x.infosets for a in infoset.actions], ), ###################################################################################### # regret (for actions) @@ -1646,7 +1646,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.action_regrets[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x: [a for infoset in x.infosets for a in infoset.actions], ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1654,7 +1654,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.action_regrets[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x: [a for infoset in x.infosets for a in infoset.actions], ), ( games.create_stripped_down_poker_efg(), @@ -1662,7 +1662,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.action_regrets[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x: [a for infoset in x.infosets for a in infoset.actions], ), ( games.create_stripped_down_poker_efg(), @@ -1670,7 +1670,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.action_regrets[next(iter(y.infoset.members))][y.label], - lambda x: x.actions, + lambda x: [a for infoset in x.infosets for a in infoset.actions], ), ###################################################################################### # node_value diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 3d854f0e27..0addf47bc3 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -61,7 +61,7 @@ def test_catalog_games_filter_n_actions(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.actions) == 2 + assert sum(len(infoset.actions) for infoset in g.infosets) == 2 def test_catalog_games_filter_n_contingencies(all_games): diff --git a/tests/test_game.py b/tests/test_game.py index 6c01fc42bd..cd6245e751 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -299,10 +299,8 @@ def test_mixed_behavior_profile_game_structure_changed(): pytest.param(lambda g: g.outcomes, id="GameOutcomes"), pytest.param(lambda g: g.strategies, id="GameStrategies"), pytest.param(lambda g: g.infosets, id="GameInfosets"), - pytest.param(lambda g: g.actions, id="GameActions"), pytest.param(lambda g: g.players["Alice"].strategies, id="PlayerStrategies"), pytest.param(lambda g: g.players["Alice"].infosets, id="PlayerInfosets"), - pytest.param(lambda g: g.players["Alice"].actions, id="PlayerActions"), pytest.param(lambda g: g.players["Bob"].infosets["Bob's response"].actions, id="InfosetActions"), pytest.param(lambda g: g.players["Bob"].infosets["Bob's response"].members, diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index 8b78221511..bcfff14478 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -145,27 +145,3 @@ def test_resolve_infoset(game: gbt.Game) -> None: def test_resolve_infoset_invalid(game: gbt.Game, infoset: str, exception: BaseException) -> None: with pytest.raises(exception): game._resolve_infoset(infoset, "test_resolve_infoset_invalid") - - -@pytest.mark.parametrize( - "game", - [ - games.read_from_file("sample_extensive_game.efg"), - ] -) -def test_resolve_action(game: gbt.Game) -> None: - _test_valid_resolutions(game.actions, - lambda label, fn: game._resolve_action(label, fn)) - - -@pytest.mark.parametrize( - "game,action,exception", - [ - (games.read_from_file("sample_extensive_game.efg"), "", ValueError), - (games.read_from_file("sample_extensive_game.efg"), " ", ValueError), - (games.read_from_file("sample_extensive_game.efg"), "inaction", KeyError), - ] -) -def test_resolve_action_invalid(game: gbt.Game, action: str, exception: BaseException) -> None: - with pytest.raises(exception): - game._resolve_action(action, "test_resolve_action_invalid") diff --git a/tests/test_nash.py b/tests/test_nash.py index 62058e9cd1..9cc38be98e 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -3156,10 +3156,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 action in player.actions: - assert abs( - _action_prob(eq, action) - _action_prob(expected, action) - ) <= test_case.prob_tol + for infoset in player.infosets: + for action in infoset.actions: + assert abs( + _action_prob(eq, action) - _action_prob(expected, action) + ) <= test_case.prob_tol ################################################################################################## @@ -3206,9 +3207,10 @@ def test_nash_behavior_solver_unordered(test_case: EquilibriumTestCase, subtests def are_the_same(game, found, candidate): for p in game.players: - for a in p.actions: - if not abs(_action_prob(found, a) - _action_prob(candidate, a)) <= TOL: - return False + for infoset in p.infosets: + for a in infoset.actions: + if not abs(_action_prob(found, a) - _action_prob(candidate, a)) <= TOL: + return False return True game = test_case.factory() @@ -3370,10 +3372,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 action in player.actions: - assert abs( - _action_prob(eq, action) - _action_prob(expected, action) - ) <= test_case.prob_tol + for infoset in player.infosets: + for action in infoset.actions: + assert abs( + _action_prob(eq, action) - _action_prob(expected, action) + ) <= test_case.prob_tol ################################################################################################## @@ -3424,10 +3427,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 action in player.actions: - assert abs( - _action_prob(eq, action) - _action_prob(expected, action) - ) <= test_case.prob_tol + for infoset in player.infosets: + for action in infoset.actions: + assert abs( + _action_prob(eq, action) - _action_prob(expected, action) + ) <= test_case.prob_tol ################################################################################################## diff --git a/tests/test_strategic.py b/tests/test_strategic.py index 2791c9c117..36f94bb771 100644 --- a/tests/test_strategic.py +++ b/tests/test_strategic.py @@ -5,19 +5,6 @@ from . import games -def test_strategic_game_actions(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.actions - - -def test_strategic_game_player_actions(): - game = gbt.Game.new_table([2, 2]) - player, _ = game.players - with pytest.raises(gbt.UndefinedOperationError): - _ = player.actions - - def test_strategic_game_infosets(): game = gbt.Game.new_table([2, 2]) with pytest.raises(gbt.UndefinedOperationError): From 343acd3ed523112a87b917d2b828a9ab685f1830 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 24 Aug 2026 11:32:57 +0100 Subject: [PATCH 03/11] Remove Strategy.action in favour of just returning the behaviour --- ChangeLog | 3 ++ doc/pygambit.api.rst | 14 +++++++- src/pygambit/game.pxi | 4 --- src/pygambit/strategy.pxi | 71 +++++++++++---------------------------- tests/test_actions.py | 19 +++++------ 5 files changed, 44 insertions(+), 67 deletions(-) diff --git a/ChangeLog b/ChangeLog index 621ca0cd59..e584f96777 100644 --- a/ChangeLog +++ b/ChangeLog @@ -58,6 +58,9 @@ action belonged to. Index an information set's own `actions` instead, iterating `Game.infosets`/`Player.infosets` as needed. `Game._resolve_action`, an internal helper with no callers besides its own tests, is removed along with it. +- `Strategy.action` has been removed; it is superseded by `Game.get_behavior`, which returns + a `StrategyBehavior`, a full map-like view of a strategy's information-set-to-action + mapping rather than a one-information-set-at-a-time query. ## [17.0.0-alpha.1] - 2026-08-13 diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 986ae78bad..776137e234 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -195,7 +195,6 @@ Information about the game Strategy.game Strategy.player Strategy.number - Strategy.action .. autosummary:: @@ -216,6 +215,19 @@ Player behavior Game.mixed_behavior_profile Game.random_behavior_profile Game.strategy_support_profile + Game.get_behavior + + StrategyBehavior + StrategyBehavior.player + StrategyBehavior.strategy + StrategyBehavior.__getitem__ + StrategyBehavior.get + StrategyBehavior.__contains__ + StrategyBehavior.__iter__ + StrategyBehavior.__len__ + StrategyBehavior.keys + StrategyBehavior.values + StrategyBehavior.items Representation of strategic behavior diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index d9d7b3abd2..44dbeb20ae 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -921,10 +921,6 @@ class Game: If `player` is from a different game, or `strategy` belongs to a different player. KeyError If `strategy` is a string and `player` has no strategy with that label. - - See Also - -------- - Strategy.action : The action prescribed at a single information set. """ if not self.is_tree: raise UndefinedOperationError( diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index 999da977de..7a8927570f 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -80,50 +80,14 @@ class Strategy: """The number of the strategy.""" return self.strategy.deref().GetNumber() - 1 - def action(self, infoset: Infoset | str) -> Action | None: - """Get the action prescribed by a strategy for a given information set. - - .. versionadded:: 16.4.0 - - Parameters - ---------- - infoset - The information set for which to find the prescribed action. - Can be an Infoset object or its string label. - - Returns - ------- - Action or None - The prescribed action or None if the strategy is not defined for this - information set, that is, the information set is unreachable under this strategy. - - Raises - ------ - UndefinedOperationError - If the game is not an extensive-form (tree) game. - ValueError - If the information set belongs to a different player than the strategy. - - See Also - -------- - Game.get_behavior : - A map-like view of the strategy's full mapping from information sets to actions. + @cython.cfunc + def _get_action(self, infoset: c_GameInfoset) -> Action: + """Returns the action this strategy prescribes at `infoset`, or None if the + strategy does not reach it. Unlike the removed public `action()` method, this + performs no validation of `infoset`: used internally by `StrategyBehavior`, + which has already resolved and validated it. """ - if not self.game.is_tree: - raise UndefinedOperationError( - "Strategy.action is only defined for strategies in extensive-form games." - ) - - resolved_infoset: Infoset = self.game._resolve_infoset(infoset, "Strategy.action") - - if resolved_infoset.player != self.player: - raise ValueError( - f"Information set {resolved_infoset} belongs to player " - f"'{resolved_infoset.player.label}', but this strategy " - f"belongs to player '{self.player.label}'." - ) - - action: c_GameAction = self.strategy.deref().GetAction(resolved_infoset.infoset) + action: c_GameAction = self.strategy.deref().GetAction(infoset) if not action: return None return Action.wrap(action) @@ -187,8 +151,8 @@ class StrategyBehavior: ValueError If the information set belongs to a different player. """ - infoset = self._resolve_key(key) - action = self._strategy.action(infoset) + infoset: Infoset = self._resolve_key(key) + action = self._strategy._get_action(infoset.infoset) if action is None: raise KeyError( f"Strategy '{self._strategy.label}' prescribes no action at {infoset}." @@ -197,20 +161,21 @@ class StrategyBehavior: def get(self, key: Infoset | str, default: typing.Any = None) -> Action | None: """Return the action prescribed at `key`, or `default` if none is prescribed.""" - infoset = self._resolve_key(key) - action = self._strategy.action(infoset) + infoset: Infoset = self._resolve_key(key) + action = self._strategy._get_action(infoset.infoset) return default if action is None else action def __contains__(self, key: typing.Any) -> bool: try: - infoset = self._resolve_key(key) + infoset: Infoset = self._resolve_key(key) except (KeyError, ValueError, TypeError): return False - return self._strategy.action(infoset) is not None + return self._strategy._get_action(infoset.infoset) is not None def __iter__(self) -> typing.Iterator[Infoset]: + infoset: Infoset for infoset in self._player.infosets: - if self._strategy.action(infoset) is not None: + if self._strategy._get_action(infoset.infoset) is not None: yield infoset def __len__(self) -> int: @@ -222,11 +187,13 @@ class StrategyBehavior: def values(self) -> list[Action]: """The prescribed actions, in the order of `keys`.""" - return [self._strategy.action(infoset) for infoset in self] + infoset: Infoset + return [self._strategy._get_action(infoset.infoset) for infoset in self] def items(self) -> list[tuple[Infoset, Action]]: """(information set, action) pairs, in the order of `keys`.""" - return [(infoset, self._strategy.action(infoset)) for infoset in self] + infoset: Infoset + return [(infoset, self._strategy._get_action(infoset.infoset)) for infoset in self] @cython.cclass diff --git a/tests/test_actions.py b/tests/test_actions.py index f18365590b..e590e1e862 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -327,7 +327,7 @@ def node_at(path: list[str]) -> gbt.Node: def test_strategy_action_defined( game, player_label, strategy_label, infoset_path, expected_action_label ): - """Verify `Strategy.action` retrieves the correct action for defined actions.""" + """Verify `StrategyBehavior` retrieves the correct action for defined actions.""" player = game.players[player_label] strategy = player.strategies[strategy_label] node = game.root @@ -336,7 +336,7 @@ def test_strategy_action_defined( infoset = node.infoset expected_action = infoset.actions[expected_action_label] - prescribed_action = strategy.action(infoset) + prescribed_action = game.get_behavior(player, strategy).get(infoset) assert prescribed_action == expected_action @@ -356,7 +356,7 @@ def test_strategy_action_defined( def test_strategy_action_undefined_returns_none( game, player_label, strategy_label, infoset_label, infoset_path ): - """Verify `Strategy.action` returns None when called on an unreached player's infoset""" + """Verify `StrategyBehavior` returns None when queried on an unreached player's infoset""" player = game.players[player_label] strategy = player.strategies[strategy_label] if infoset_label is not None: @@ -367,7 +367,7 @@ def test_strategy_action_undefined_returns_none( node = node.children[action_label] infoset = node.infoset - prescribed_action = strategy.action(infoset) + prescribed_action = game.get_behavior(player, strategy).get(infoset) assert prescribed_action is None @@ -388,7 +388,7 @@ def test_strategy_action_raises_value_error_for_wrong_player( game, player_label, other_infoset_path ): """ - Verify `Strategy.action` raises ValueError when the infoset belongs + Verify `StrategyBehavior` raises ValueError when the infoset belongs to a different player than the strategy. """ player = game.players[player_label] @@ -399,16 +399,15 @@ def test_strategy_action_raises_value_error_for_wrong_player( other_players_infoset = node.infoset with pytest.raises(ValueError): - strategy.action(other_players_infoset) + _ = game.get_behavior(player, strategy)[other_players_infoset] -def test_strategy_action_raises_error_for_strategic_game(): - """Verify `Strategy.action` retrieves the action prescribed by the strategy""" +def test_get_behavior_raises_error_for_strategic_game(): + """Verify `Game.get_behavior` raises UndefinedOperationError for a strategic game.""" game_efg = gbt.catalog.load("journals/ijgt/selten1975/fig2") game_nfg = game_efg.from_arrays(game_efg.to_arrays()[0], game_efg.to_arrays()[1]) alice = next(iter(game_nfg.players)) strategy = next(iter(alice.strategies)) - test_infoset = next(iter(game_efg.infosets)) with pytest.raises(gbt.UndefinedOperationError): - strategy.action(test_infoset) + game_nfg.get_behavior(alice, strategy) From 661ba1454c4de7cfb7b2631259fbc7a7f843715e Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 24 Aug 2026 11:45:11 +0100 Subject: [PATCH 04/11] Change Sequence.actions to return a tuple --- ChangeLog | 2 ++ src/pygambit/strategy.pxi | 13 +++++++++---- tests/test_players.py | 3 ++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/ChangeLog b/ChangeLog index e584f96777..989b8a215a 100644 --- a/ChangeLog +++ b/ChangeLog @@ -61,6 +61,8 @@ - `Strategy.action` has been removed; it is superseded by `Game.get_behavior`, which returns a `StrategyBehavior`, a full map-like view of a strategy's information-set-to-action mapping rather than a one-information-set-at-a-time query. +- `Sequence.actions` now returns `list[tuple[Infoset, str]]` (an information set paired with + its action's label) instead of `list[Action]`. ## [17.0.0-alpha.1] - 2026-08-13 diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index 7a8927570f..05c0432cb1 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -253,14 +253,19 @@ class Sequence: return ret @property - def actions(self) -> list[Action]: - """Get the collection of actions defining this sequence. + def actions(self) -> list[tuple[Infoset, str]]: + """Get the (information set, action label) pairs defining this sequence, in + order from the player's root sequence to this one. Returns the empty list for the root sequence of the player. """ - actions: list[Action] = [] + actions: list[tuple[Infoset, str]] = [] seq = self.sequence while seq.deref().GetAction() != cython.cast(c_GameAction, NULL): - actions.insert(0, Action.wrap(seq.deref().GetAction())) + action: c_GameAction = seq.deref().GetAction() + actions.insert(0, ( + Infoset.wrap(action.deref().GetInfoset()), + action.deref().GetLabel().decode("utf-8") + )) seq = seq.deref().GetParent() return actions diff --git a/tests/test_players.py b/tests/test_players.py index 4339f04fc0..b6c8818bd1 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -348,7 +348,8 @@ def test_player_sequence_actions(): player = game.players["Alice"] sequences = set(tuple(seq.actions) for seq in player.sequences) reference = ( - set((action, ) for infoset in player.infosets for action in infoset.actions) | + set(((infoset, action.label), ) for infoset in player.infosets + for action in infoset.actions) | {tuple()} ) assert sequences == reference From f93fc2ddfcee29abe9f5150b07d7a88b1a237d16 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 24 Aug 2026 11:53:38 +0100 Subject: [PATCH 05/11] mixed actions deal only in action labels, not action objects --- ChangeLog | 2 ++ src/pygambit/strategy.pxi | 53 +++++++++++++++++++++------------------ tests/test_actions.py | 5 ++-- tests/test_extensive.py | 4 +-- 4 files changed, 35 insertions(+), 29 deletions(-) diff --git a/ChangeLog b/ChangeLog index 989b8a215a..1068fff0b9 100644 --- a/ChangeLog +++ b/ChangeLog @@ -63,6 +63,8 @@ mapping rather than a one-information-set-at-a-time query. - `Sequence.actions` now returns `list[tuple[Infoset, str]]` (an information set paired with its action's label) instead of `list[Action]`. +- `StrategyBehavior.__getitem__`/`.get`/`.values`/`.items` now return action labels (`str`) + instead of `Action` objects. ## [17.0.0-alpha.1] - 2026-08-13 diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index 05c0432cb1..07e1b59b4c 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -81,16 +81,16 @@ class Strategy: return self.strategy.deref().GetNumber() - 1 @cython.cfunc - def _get_action(self, infoset: c_GameInfoset) -> Action: - """Returns the action this strategy prescribes at `infoset`, or None if the - strategy does not reach it. Unlike the removed public `action()` method, this - performs no validation of `infoset`: used internally by `StrategyBehavior`, - which has already resolved and validated it. + def _get_action_label(self, infoset: c_GameInfoset) -> str: + """Returns the label of the action this strategy prescribes at `infoset`, or + None if the strategy does not reach it. Unlike the removed public `action()` + method, this performs no validation of `infoset`: used internally by + `StrategyBehavior`, which has already resolved and validated it. """ action: c_GameAction = self.strategy.deref().GetAction(infoset) if not action: return None - return Action.wrap(action) + return action.deref().GetLabel().decode("utf-8") @cython.cclass @@ -99,7 +99,8 @@ class StrategyBehavior: The keys of the mapping are the information sets of the strategy's player at which the strategy prescribes an action; an unreachable information set is not a key. - The corresponding values are the prescribed ``Action`` objects. + The corresponding values are the prescribed actions' text labels, each guaranteed to + be a valid label at its information set. Iteration yields the keys in the player's information set order. .. versionadded:: 17.0.0 @@ -140,8 +141,9 @@ class StrategyBehavior: ) return infoset - def __getitem__(self, key: Infoset | str) -> Action: - """Return the action prescribed at the information set referenced by `key`. + def __getitem__(self, key: Infoset | str) -> str: + """Return the label of the action prescribed at the information set referenced + by `key`. Raises ------ @@ -152,30 +154,31 @@ class StrategyBehavior: If the information set belongs to a different player. """ infoset: Infoset = self._resolve_key(key) - action = self._strategy._get_action(infoset.infoset) - if action is None: + label = self._strategy._get_action_label(infoset.infoset) + if label is None: raise KeyError( f"Strategy '{self._strategy.label}' prescribes no action at {infoset}." ) - return action + return label - def get(self, key: Infoset | str, default: typing.Any = None) -> Action | None: - """Return the action prescribed at `key`, or `default` if none is prescribed.""" + def get(self, key: Infoset | str, default: typing.Any = None) -> str | None: + """Return the label of the action prescribed at `key`, or `default` if none is + prescribed.""" infoset: Infoset = self._resolve_key(key) - action = self._strategy._get_action(infoset.infoset) - return default if action is None else action + label = self._strategy._get_action_label(infoset.infoset) + return default if label is None else label def __contains__(self, key: typing.Any) -> bool: try: infoset: Infoset = self._resolve_key(key) except (KeyError, ValueError, TypeError): return False - return self._strategy._get_action(infoset.infoset) is not None + return self._strategy._get_action_label(infoset.infoset) is not None def __iter__(self) -> typing.Iterator[Infoset]: infoset: Infoset for infoset in self._player.infosets: - if self._strategy._get_action(infoset.infoset) is not None: + if self._strategy._get_action_label(infoset.infoset) is not None: yield infoset def __len__(self) -> int: @@ -185,15 +188,17 @@ class StrategyBehavior: """The information sets at which the strategy prescribes an action.""" return list(self) - def values(self) -> list[Action]: - """The prescribed actions, in the order of `keys`.""" + def values(self) -> list[str]: + """The prescribed actions' labels, in the order of `keys`.""" infoset: Infoset - return [self._strategy._get_action(infoset.infoset) for infoset in self] + return [self._strategy._get_action_label(infoset.infoset) for infoset in self] - def items(self) -> list[tuple[Infoset, Action]]: - """(information set, action) pairs, in the order of `keys`.""" + def items(self) -> list[tuple[Infoset, str]]: + """(information set, action label) pairs, in the order of `keys`.""" infoset: Infoset - return [(infoset, self._strategy._get_action(infoset.infoset)) for infoset in self] + return [ + (infoset, self._strategy._get_action_label(infoset.infoset)) for infoset in self + ] @cython.cclass diff --git a/tests/test_actions.py b/tests/test_actions.py index e590e1e862..bb2a5d7cb7 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -327,18 +327,17 @@ def node_at(path: list[str]) -> gbt.Node: def test_strategy_action_defined( game, player_label, strategy_label, infoset_path, expected_action_label ): - """Verify `StrategyBehavior` retrieves the correct action for defined actions.""" + """Verify `StrategyBehavior` retrieves the correct action label for defined actions.""" player = game.players[player_label] strategy = player.strategies[strategy_label] node = game.root for action_label in infoset_path: node = node.children[action_label] infoset = node.infoset - expected_action = infoset.actions[expected_action_label] prescribed_action = game.get_behavior(player, strategy).get(infoset) - assert prescribed_action == expected_action + assert prescribed_action == expected_action_label @pytest.mark.parametrize( diff --git a/tests/test_extensive.py b/tests/test_extensive.py index bb5a6e94ff..e5d2b2a83e 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -508,8 +508,8 @@ def test_reduced_strategy_maps(game: gbt.Game, strategy_maps: list): for strategy, expected in zip(player.strategies, expected_maps, strict=True): behavior = game.get_behavior(player, strategy) assert tuple( - "*" if (action := behavior.get(infoset)) is None - else str(list(infoset.actions).index(action) + 1) + "*" if (label := behavior.get(infoset)) is None + else str([a.label for a in infoset.actions].index(label) + 1) for infoset in player.infosets ) == expected From 3823a79e099d72638b24aac35cf2a12074033aa6 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 24 Aug 2026 12:03:14 +0100 Subject: [PATCH 06/11] Remove Infoset.own_prior_actions --- ChangeLog | 2 ++ doc/pygambit.api.rst | 1 - src/pygambit/gambit.pxd | 2 -- src/pygambit/infoset.pxi | 24 ------------------------ src/pygambit/node.pxi | 4 ---- tests/test_infosets.py | 6 ++++-- 6 files changed, 6 insertions(+), 33 deletions(-) diff --git a/ChangeLog b/ChangeLog index 1068fff0b9..a64e38f7e0 100644 --- a/ChangeLog +++ b/ChangeLog @@ -65,6 +65,8 @@ its action's label) instead of `list[Action]`. - `StrategyBehavior.__getitem__`/`.get`/`.values`/`.items` now return action labels (`str`) instead of `Action` objects. +- `Infoset.own_prior_actions` has been removed; it composes from `Infoset.members` and + `Node.own_prior_action`, e.g. `{m.own_prior_action for m in infoset.members}`. ## [17.0.0-alpha.1] - 2026-08-13 diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 776137e234..20a41320a8 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -178,7 +178,6 @@ Information about the game Infoset.members Infoset.precedes Infoset.plays - Infoset.own_prior_actions .. autosummary:: diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 4c69e14273..7e4cabfc16 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -3,7 +3,6 @@ from libcpp.string cimport string from libcpp.memory cimport shared_ptr, unique_ptr from libcpp.list cimport list as stdlist from libcpp.vector cimport vector as stdvector -from libcpp.set cimport set as stdset from libcpp.map cimport map as stdmap from libcpp.optional cimport optional @@ -158,7 +157,6 @@ cdef extern from "games/game.h": bint IsChanceInfoset() except + bint Precedes(c_GameNode) except + - stdset[c_GameAction] GetOwnPriorActions() except + cdef cppclass c_GamePlayerRep "GamePlayerRep": cppclass Infosets: diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index f94fd06b27..264d890d5e 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -240,30 +240,6 @@ class Infoset: probs[action.deref().GetLabel().decode("utf-8")] = prob return probs - @property - def own_prior_actions(self) -> list[Action | None]: - """The set of actions taken by the player immediately preceding the member nodes - in the information set. - - Returns - ------- - list of Action or None - A list containing Action objects. If a node in the information set - is reached without the player having moved previously, None will be - included in the list. - .. versionadded:: 16.5.0 - - See Also - -------- - Node.own_prior_action - """ - c_actions: stdset[c_GameAction] = self.infoset.deref().GetOwnPriorActions() - - return [ - Action.wrap(action) if action != cython.cast(c_GameAction, NULL) else None - for action in c_actions - ] - @property def members(self) -> InfosetMembers: """The set of nodes which are members of the information set. diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index cb626e336e..9af6b400db 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -453,10 +453,6 @@ class Node: The action object, or None if the player has not moved previously on the path to this node. .. versionadded:: 16.5.0 - - See Also - -------- - Infoset.own_prior_actions """ if self.node.deref().GetOwnPriorAction() != cython.cast(c_GameAction, NULL): return Action.wrap(self.node.deref().GetOwnPriorAction()) diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 2e6486b97a..498783571b 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -426,7 +426,9 @@ def _get_node_by_path(game, path: list[str]) -> gbt.Node: @pytest.mark.parametrize("test_case", PRIOR_ACTIONS_CASES) def test_infoset_own_prior_actions(test_case: PriorActionsTestCase): """ - Test `infoset.own_prior_actions`. + Test the set of prior actions across an information set's members + (`Infoset.own_prior_actions` was removed; composes from `Infoset.members` and + `Node.own_prior_action`). Verifies that the set of prior actions (as player-infoset-label tuples) matches the expected results. Each infoset is identified by an action @@ -440,7 +442,7 @@ def test_infoset_own_prior_actions(test_case: PriorActionsTestCase): node = node.children[action_label] infoset = node.infoset - actual_actions = infoset.own_prior_actions + actual_actions = {member.own_prior_action for member in infoset.members} actual_details = { (a.infoset.player.label, a.infoset.number, a.label) if a is not None else None From 424c3c1c6039d4446a3f317c8ea13203ce3295fe Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 24 Aug 2026 12:16:37 +0100 Subject: [PATCH 07/11] Change own_prior_action/prior_action for Node to be a node and label tuple --- ChangeLog | 7 ++++ doc/tutorials/03_stripped_down_poker.ipynb | 18 ++-------- src/pygambit/gambit.pxd | 1 - src/pygambit/node.pxi | 38 +++++++++++++++------- tests/games.py | 4 +-- tests/test_infosets.py | 2 +- tests/test_node.py | 6 ++-- 7 files changed, 41 insertions(+), 35 deletions(-) diff --git a/ChangeLog b/ChangeLog index a64e38f7e0..b6464f435a 100644 --- a/ChangeLog +++ b/ChangeLog @@ -67,6 +67,13 @@ instead of `Action` objects. - `Infoset.own_prior_actions` has been removed; it composes from `Infoset.members` and `Node.own_prior_action`, e.g. `{m.own_prior_action for m in infoset.members}`. +- `Node.prior_action` and `Node.own_prior_action` now return `tuple[Node, str] | None` + (the node on the path at which the action was taken, paired with its label) instead of + `Action | None`. `Node.own_prior_action` no longer calls into the C++ core at all; it is + computed by walking up `Node.parent`/`Node.prior_action` to the nearest ancestor sharing + this node's own player. This further breaks `gtdraw`, which reads `.label` off the + previous return value; more tutorial notebooks now report `xfail` under `--run-tutorials` + as a result (already covered by the content-based gating added earlier). ## [17.0.0-alpha.1] - 2026-08-13 diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 2d7939acba..c75984ea05 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -467,13 +467,7 @@ "id": "4a54b20c", "metadata": {}, "outputs": [], - "source": [ - "for node in bob_infoset.members:\n", - " print(\n", - " f\"Bob's belief in reaching the {node.parent.prior_action.label} -> \"\n", - " f\"{node.prior_action.label} node is: {eqm.beliefs[node]}\"\n", - " )" - ] + "source": "for node in bob_infoset.members:\n print(\n f\"Bob's belief in reaching the {node.parent.prior_action[1]} -> \"\n f\"{node.prior_action[1]} node is: {eqm.beliefs[node]}\"\n )" }, { "cell_type": "markdown", @@ -509,15 +503,7 @@ "id": "6f01846b", "metadata": {}, "outputs": [], - "source": [ - "bob_node_values = eqm.node_values[\"Bob\"]\n", - "for node in bob_infoset.members:\n", - " print(\n", - " f\"The probability that the node {node.parent.prior_action.label} -> \"\n", - " f\"{node.prior_action.label} is reached is: {eqm.realiz_probs[node]}. \",\n", - " f\"Bob's expected payoff conditional on reaching this node is {bob_node_values[node]}\"\n", - " )" - ] + "source": "bob_node_values = eqm.node_values[\"Bob\"]\nfor node in bob_infoset.members:\n print(\n f\"The probability that the node {node.parent.prior_action[1]} -> \"\n f\"{node.prior_action[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 )" }, { "cell_type": "markdown", diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 7e4cabfc16..1b73d4db00 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -243,7 +243,6 @@ cdef extern from "games/game.h": bint IsSubgameRoot() except + bint IsStrategyReachable() except + c_GameAction GetPriorAction() except + - c_GameAction GetOwnPriorAction() except + cdef cppclass c_GameSubgameRep "GameSubgameRep": cppclass SubgameCollection: diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 9af6b400db..fb32885a3f 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -352,7 +352,7 @@ class Node: path = [] node = self while node.parent: - path.append(node.prior_action.label) + path.append(node.prior_action[1]) node = node.parent return f"Node(game={self.game}, path={path})" @@ -434,28 +434,42 @@ class Node: return None @property - def prior_action(self) -> Action | None: - """The action which leads to this node. + def prior_action(self) -> tuple[Node, str] | None: + """The parent of this node, paired with the label of the action taken there + which leads to this node. If this is the root node, None is returned. """ - if self.node.deref().GetPriorAction() != cython.cast(c_GameAction, NULL): - return Action.wrap(self.node.deref().GetPriorAction()) + action: c_GameAction = self.node.deref().GetPriorAction() + if action != cython.cast(c_GameAction, NULL): + return (self.parent, action.deref().GetLabel().decode("utf-8")) return None @property - def own_prior_action(self) -> Action | None: - """The last action taken by the node's owner before reaching this node. + def own_prior_action(self) -> tuple[Node, str] | None: + """The most recent node on the path to this one at which this node's own + player had the move, paired with the label of the action taken there. Returns ------- - Action or None - The action object, or None if the player has not moved previously - on the path to this node. + tuple of Node and str, or None + None if the player has not moved previously on the path to this node, + or if this is a terminal node. .. versionadded:: 16.5.0 + + .. versionchanged:: 17.0.0 + Now returns a `(Node, str)` pair -- the node at which the action was + taken, and its label -- instead of an `Action` object. """ - if self.node.deref().GetOwnPriorAction() != cython.cast(c_GameAction, NULL): - return Action.wrap(self.node.deref().GetOwnPriorAction()) + if self.is_terminal: + return None + target_player = self.player + node = self + while node.parent is not None: + prior_node, prior_label = node.prior_action + node = prior_node + if node.player == target_player: + return (node, prior_label) return None @property diff --git a/tests/games.py b/tests/games.py index dbe5602f96..9a964454b9 100644 --- a/tests/games.py +++ b/tests/games.py @@ -233,7 +233,7 @@ def calculate_payoffs(term_node): def get_path(node): path = [] while node.parent: - path.append(node.prior_action.label) + path.append(node.prior_action[1]) node = node.parent return path @@ -325,7 +325,7 @@ def add_outcomes(term_node): def get_path(node): path = [] while node.parent: - path.append((node, node.prior_action.label)) + path.append((node, node.prior_action[1])) node = node.parent return path diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 498783571b..01d7e683ec 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -445,7 +445,7 @@ def test_infoset_own_prior_actions(test_case: PriorActionsTestCase): actual_actions = {member.own_prior_action for member in infoset.members} actual_details = { - (a.infoset.player.label, a.infoset.number, a.label) if a is not None else None + (a[0].infoset.player.label, a[0].infoset.number, a[1]) if a is not None else None for a in actual_actions } diff --git a/tests/test_node.py b/tests/test_node.py index cc6346816c..a1bb77c7cc 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -116,7 +116,7 @@ def test_get_parent(): 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 == game.root.infoset.actions["U1"] + assert game.root.children["U1"].prior_action == (game.root, "U1") assert game.root.prior_action is None @@ -167,7 +167,7 @@ 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) + path.append(current_node.prior_action[1]) current_node = current_node.parent return path @@ -512,7 +512,7 @@ def test_node_own_prior_action_non_terminal(game_file, expected_node_data): # Only collect data for non-terminal nodes opa = node.own_prior_action details = ( - (opa.infoset.player.label, opa.infoset.number, opa.label) + (opa[0].infoset.player.label, opa[0].infoset.number, opa[1]) if opa is not None else None ) actual_node_data.append((_get_path_of_action_labels(node), details)) From 6ed318eae50a5301fc403250c69b3f9ff5128f87 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 24 Aug 2026 13:17:47 +0100 Subject: [PATCH 08/11] Infoset.actions is just a list of labels --- ChangeLog | 4 ++ doc/tutorials/03_stripped_down_poker.ipynb | 12 +--- src/pygambit/behavmixed.pxi | 56 +++++++++------- src/pygambit/gambit.pyx | 13 +++- src/pygambit/game.pxi | 18 ++--- src/pygambit/infoset.pxi | 62 ++---------------- src/pygambit/node.pxi | 41 +++++------- src/pygambit/qre.py | 4 +- tests/test_actions.py | 42 ++++++------ tests/test_behav.py | 76 +++++++++++----------- tests/test_extensive.py | 2 +- tests/test_file.py | 6 +- tests/test_game.py | 2 - tests/test_infosets.py | 13 ++-- tests/test_nash.py | 32 +++++---- tests/test_node.py | 34 +++------- tests/test_players.py | 4 +- 17 files changed, 190 insertions(+), 231 deletions(-) diff --git a/ChangeLog b/ChangeLog index b6464f435a..7661f15675 100644 --- a/ChangeLog +++ b/ChangeLog @@ -67,6 +67,10 @@ instead of `Action` objects. - `Infoset.own_prior_actions` has been removed; it composes from `Infoset.members` and `Node.own_prior_action`, e.g. `{m.own_prior_action for m in infoset.members}`. +- `Infoset.actions` now returns `list[str]` (the actions' labels, in order) instead of a + collection of `Action` objects. `NodeChildren.__getitem__` (`node.children[...]`) no longer + accepts an `Action`; index by label only. `Action` itself is no longer referenced anywhere + in pygambit's public surface. - `Node.prior_action` and `Node.own_prior_action` now return `tuple[Node, str] | None` (the node on the path at which the action was taken, paired with its label) instead of `Action | None`. `Node.own_prior_action` no longer calls into the C++ core at all; it is diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index c75984ea05..f942a69a95 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -382,7 +382,7 @@ "id": "83bbd3e5", "metadata": {}, "outputs": [], - "source": "for infoset in g.players[\"Alice\"].infosets:\n for action in infoset.actions:\n node = next(iter(action.infoset.members))\n print(\n f\"At information set {action.infoset.number}, \"\n f\"Alice plays {action.label} with probability: {eqm[node][action.label]}\"\n )" + "source": "for infoset in g.players[\"Alice\"].infosets:\n for label in infoset.actions:\n node = next(iter(infoset.members))\n print(\n f\"At information set {infoset.number}, \"\n f\"Alice plays {label} with probability: {eqm[node][label]}\"\n )" }, { "cell_type": "markdown", @@ -441,13 +441,7 @@ "id": "a7d3816d", "metadata": {}, "outputs": [], - "source": [ - "bob_action_values = eqm.action_values[bob_node]\n", - "for action in bob_infoset.actions:\n", - " print(\n", - " f\"When Bob plays {action.label} his expected payoff is {bob_action_values[action.label]}\"\n", - " )" - ] + "source": "bob_action_values = eqm.action_values[bob_node]\nfor label in bob_infoset.actions:\n print(\n f\"When Bob plays {label} his expected payoff is {bob_action_values[label]}\"\n )" }, { "cell_type": "markdown", @@ -637,7 +631,7 @@ "id": "d18a91f0", "metadata": {}, "outputs": [], - "source": "for player in g.players:\n print(\n f\"{player.label}'s expected payoffs:\"\n )\n gnm_action_values = gnm_eqm.as_behavior().action_values\n lcp_action_values = eqm.action_values\n for infoset in player.infosets:\n for action in infoset.actions:\n node = next(iter(action.infoset.members))\n print(\n f\"At information set {action.infoset.number}, \"\n f\"when playing {action.label} - \"\n f\"gnm: {gnm_action_values[node][action.label]:.4f}\"\n f\", lcp: {str(lcp_action_values[node][action.label])}\"\n )\n print()" + "source": "for player in g.players:\n print(\n f\"{player.label}'s expected payoffs:\"\n )\n gnm_action_values = gnm_eqm.as_behavior().action_values\n lcp_action_values = eqm.action_values\n for infoset in player.infosets:\n for label in infoset.actions:\n node = next(iter(infoset.members))\n print(\n f\"At information set {infoset.number}, \"\n f\"when playing {label} - \"\n f\"gnm: {gnm_action_values[node][label]:.4f}\"\n f\", lcp: {str(lcp_action_values[node][label])}\"\n )\n print()" }, { "cell_type": "markdown", diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 6330b582b5..a2cdcabc41 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -447,7 +447,7 @@ class MixedBehaviorProfile: def _mixed_action_at(self, infoset: Infoset) -> MixedAction: """Returns a snapshot of the mixed action at infoset, as of now.""" return MixedAction.wrap( - infoset, {a.label: self._getprob_action(a) for a in infoset.actions} + infoset, {label: self._getprob_action(infoset, label) for label in infoset.actions} ) def _setprob_infoset( @@ -465,7 +465,7 @@ class MixedBehaviorProfile: f"a mixed action must be set from a Mapping from action label to " f"weight, not {distribution.__class__.__name__}" ) - labels = {a.label for a in infoset.actions} + labels = set(infoset.actions) given = set(distribution.keys()) unknown = given - labels if unknown: @@ -484,8 +484,8 @@ class MixedBehaviorProfile: raise ValueError("a mixed action's weights must be non-negative") if all(v == 0 for v in values.values()): raise ValueError("a mixed action's weights must not all be zero") - for a in infoset.actions: - self._setprob_action(a, values[a.label]) + for label in infoset.actions: + self._setprob_action(infoset, label, values[label]) def __setitem__(self, index: Node, distribution: collections.abc.Mapping) -> None: """Sets the mixed action at the information set containing `index`. @@ -655,7 +655,9 @@ class MixedBehaviorProfile: """ self._check_validity() return ActionValuesVector({ - infoset: ActionValueVector({a.label: self._action_value(a) for a in infoset.actions}) + infoset: ActionValueVector({ + label: self._action_value(infoset, label) for label in infoset.actions + }) for infoset in self.game.infosets }) @@ -734,7 +736,9 @@ class MixedBehaviorProfile: """ self._check_validity() return ActionRegretsVector({ - infoset: ActionRegretVector({a.label: self._action_regret(a) for a in infoset.actions}) + infoset: ActionRegretVector({ + label: self._action_regret(infoset, label) for label in infoset.actions + }) for infoset in self.game.infosets }) @@ -889,8 +893,9 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): def _is_defined_at(self, infoset: Infoset) -> bool: return deref(self.profile).IsDefinedAt(infoset.infoset) - def _getprob_action(self, index: Action) -> float: - return deref(self.profile).getaction(index.action) + def _getprob_action(self, infoset: Infoset, label: str) -> float: + action = _resolve_action_handle(infoset.infoset, label) + return deref(self.profile).getaction(action) @cython.cfunc def _ensure_unshared(self) -> cython.void: @@ -900,9 +905,10 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): if self.profile.use_count() != 1: self.profile = make_shared[c_MixedBehaviorProfile[double]](deref(self.profile)) - def _setprob_action(self, index: Action, value) -> None: + def _setprob_action(self, infoset: Infoset, label: str, value) -> None: self._ensure_unshared() - setitem_mbpd_action(deref(self.profile), index.action, value) + action = _resolve_action_handle(infoset.infoset, label) + setitem_mbpd_action(deref(self.profile), action, value) def _to_prob(self, value: typing.Any) -> float: normalized = _to_number_string(value) @@ -936,14 +942,16 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): def _node_value(self, player: Player, node: Node) -> float: return deref(self.profile).GetPayoff(player.player, node.node) - def _action_value(self, action: Action) -> float | None: - cdef optional[double] value = deref(self.profile).GetPayoff(action.action) + def _action_value(self, infoset: Infoset, label: str) -> float | None: + action = _resolve_action_handle(infoset.infoset, label) + cdef optional[double] value = deref(self.profile).GetPayoff(action) if value.has_value(): return value.value() return None - def _action_regret(self, action: Action) -> float: - return deref(self.profile).GetRegret(action.action) + def _action_regret(self, infoset: Infoset, label: str) -> float: + action = _resolve_action_handle(infoset.infoset, label) + return deref(self.profile).GetRegret(action) def _infoset_regret(self, infoset: Infoset) -> float: return deref(self.profile).GetRegret(infoset.infoset) @@ -1011,8 +1019,9 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _is_defined_at(self, infoset: Infoset) -> bool: return deref(self.profile).IsDefinedAt(infoset.infoset) - def _getprob_action(self, index: Action) -> Rational: - return rat_to_py(deref(self.profile).getaction(index.action)) + def _getprob_action(self, infoset: Infoset, label: str) -> Rational: + action = _resolve_action_handle(infoset.infoset, label) + return rat_to_py(deref(self.profile).getaction(action)) @cython.cfunc def _ensure_unshared(self) -> cython.void: @@ -1022,14 +1031,15 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): if self.profile.use_count() != 1: self.profile = make_shared[c_MixedBehaviorProfile[c_Rational]](deref(self.profile)) - def _setprob_action(self, index: Action, value: typing.Any) -> None: + def _setprob_action(self, infoset: Infoset, label: str, value: typing.Any) -> None: if not isinstance(value, (int, fractions.Fraction)): raise TypeError( f"rational precision profile requires int or Fraction probability, " f"not {value.__class__.__name__}" ) self._ensure_unshared() - setitem_mbpr_action(deref(self.profile), index.action, + action = _resolve_action_handle(infoset.infoset, label) + setitem_mbpr_action(deref(self.profile), action, to_rational(str(value).encode("ascii"))) def _to_prob(self, value: typing.Any) -> Rational: @@ -1059,14 +1069,16 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def _node_value(self, player: Player, node: Node) -> Rational: return rat_to_py(deref(self.profile).GetPayoff(player.player, node.node)) - def _action_value(self, action: Action) -> Rational | None: - cdef optional[c_Rational] value = deref(self.profile).GetPayoff(action.action) + def _action_value(self, infoset: Infoset, label: str) -> Rational | None: + action = _resolve_action_handle(infoset.infoset, label) + cdef optional[c_Rational] value = deref(self.profile).GetPayoff(action) if value.has_value(): return rat_to_py(value.value()) return None - def _action_regret(self, action: Action) -> Rational: - return rat_to_py(deref(self.profile).GetRegret(action.action)) + def _action_regret(self, infoset: Infoset, label: str) -> Rational: + action = _resolve_action_handle(infoset.infoset, label) + return rat_to_py(deref(self.profile).GetRegret(action)) def _infoset_regret(self, infoset: Infoset) -> Rational: return rat_to_py(deref(self.profile).GetRegret(infoset.infoset)) diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index bdf10934b3..e771bf89d2 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -100,10 +100,21 @@ def _resolve_by_label(collection, label: str, scope: str, kind: str, kind_plural return matches[0] +@cython.cfunc +def _resolve_action_handle(infoset: c_GameInfoset, label: str) -> c_GameAction: + """Finds the action with `label` at `infoset`, as a transient C++ handle -- used + internally where a label must be turned into a handle to call a C++ method, and + discarded immediately afterward. Raises KeyError if no action matches. + """ + for action in infoset.deref().GetActions(): + if action.deref().GetLabel().decode("utf-8") == label: + return action + raise KeyError(f"no action with label '{label}' at this information set") + + PlayerReference = Player | str StrategyReference = Strategy | str InfosetReference = Infoset | str -ActionReference = Action | str NodeReference = Node | str NodeReferenceSet = typing.Iterable[NodeReference] diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 44dbeb20ae..68a6829b70 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1159,7 +1159,7 @@ class Game: f"actions for infoset {i} for {p}" ) profile[next(iter(i.members))] = { - a.label: typefunc(u) for a, u in zip(i.actions, v, strict=True) + a: typefunc(u) for a, u in zip(i.actions, v, strict=True) } return profile @@ -1243,7 +1243,7 @@ class Game: alpha=[1 for action in infoset.actions], seed=gen ).rvs(size=1)[0] profile[next(iter(infoset.members))] = dict( - zip((a.label for a in infoset.actions), weights, strict=True) + zip(infoset.actions, weights, strict=True) ) return profile elif denom < 1: @@ -2051,7 +2051,7 @@ class Game: raise TypeError("set_move_actions(): actions must be an iterable of str") if not labels: raise UndefinedOperationError("set_move_actions(): `actions` must be a nonempty list") - current = [action.label for action in resolved_infoset.actions] + current = list(resolved_infoset.actions) if len(set(current)) != len(current): raise ValueError( "set_move_actions(): the information set has duplicate action labels, " @@ -2150,7 +2150,7 @@ class Game: raise UndefinedOperationError( "set_event_actions(): `probs` must be a nonempty mapping" ) - current = [action.label for action in resolved_infoset.actions] + current = list(resolved_infoset.actions) if len(set(current)) != len(current): raise ValueError( "set_event_actions(): the information set has duplicate action labels, " @@ -2185,8 +2185,8 @@ class Game: converted, and the move is thereafter resolved by chance. Nodes are removed from whatever information sets or events they currently belong to; any of those which retain members survive, keeping their labels, and those left with no members are deleted. - Any ``Infoset`` object, and any of its ``Action`` objects, referring to a deleted one - becomes invalid, and subsequent use raises ``RuntimeError``. + Any ``Infoset`` object referring to a deleted one becomes invalid, and subsequent + use raises ``RuntimeError``. The resulting event is accessible as ``node.infoset`` for any node in `nodes`. The first node in `nodes` determines the action order of the event, @@ -2235,8 +2235,8 @@ class Game: "make_event(): all nodes must be nonterminal" ) resolved_node = cython.cast(Node, resolved_nodes[0]) - action_labels = [a.label for a in resolved_node.infoset.actions] - if any([a.label for a in n.infoset.actions] != action_labels + action_labels = list(resolved_node.infoset.actions) + if any(list(n.infoset.actions) != action_labels for n in resolved_nodes[1:]): raise ValueError( "make_event(): all nodes must have the same actions, " @@ -2299,7 +2299,7 @@ class Game: f"relabel_actions(): labels must be a mapping, " f"not {labels.__class__.__name__}" ) - current = [action.label for action in resolved_infoset.actions] + current = list(resolved_infoset.actions) c_labels = stdmap[string, string]() for old, new in labels.items(): if not isinstance(old, str) or not isinstance(new, str): diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index 264d890d5e..ba0bb005d8 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -71,59 +71,6 @@ class InfosetMembers: return _resolve_by_label(self, label, "Infoset", "member", "members") -@cython.cclass -class InfosetActions: - """The set of actions which are available at an information set.""" - infoset = cython.declare(c_GameInfoset) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create InfosetActions outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(infoset: c_GameInfoset) -> InfosetActions: - obj: InfosetActions = InfosetActions.__new__(InfosetActions) - obj.infoset = infoset - return obj - - def __repr__(self) -> str: - return f"InfosetActions(infoset={Infoset.wrap(self.infoset)})" - - def __len__(self): - """The number of actions at the information set.""" - return self.infoset.deref().GetActions().size() - - def __iter__(self) -> typing.Iterator[Action]: - for action in self.infoset.deref().GetActions(): - yield Action.wrap(action) - - def __getitem__(self, label: str) -> Action: - """Returns the action at the information set with text label `label`. - - Parameters - ---------- - label : str - The text label of the action to return. Lookup is by exact match; - leading/trailing whitespace is stripped from `label`. - - Raises - ------ - KeyError - If the information set has no action with label `label`. - ValueError - If `label` is empty or all whitespace, or if more than one action at the information - set has label `label`. - TypeError - If `label` is not a string. - - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; reference an action by its label, or iterate - over the collection. String lookup now requires an exact match of the label; - previously, leading/trailing whitespace was stripped from `label` before comparison. - """ - return _resolve_by_label(self, label, "Infoset", "action", "actions") - - @cython.cclass class Infoset: """An information set in a ``Game``. @@ -211,9 +158,12 @@ class Infoset: return self.infoset.deref().GetGame().deref().IsAbsentMinded(self.infoset) @property - def actions(self) -> InfosetActions: - """The set of actions at the information set.""" - return InfosetActions.wrap(self.infoset) + def actions(self) -> list[str]: + """The labels of the actions at the information set, in order.""" + return [ + action.deref().GetLabel().decode("utf-8") + for action in self.infoset.deref().GetActions() + ] @property def action_probs(self) -> dict[str, decimal.Decimal | Rational]: diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index fb32885a3f..0caa2d5756 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -45,54 +45,49 @@ class NodeChildren: for child in self.parent.deref().GetChildren(): yield Node.wrap(child) - def __getitem__(self, action: str | Action) -> Node: - """Returns the successor node which is reached after `action` is played. - - `action` may be an ``Action`` at this node's infoset, or its label. + def __getitem__(self, action: typing.Any) -> Node: + """Returns the successor node which is reached after the action with label + `action` is played. Raises ------ KeyError - If `action` is a string and no action with that label exists at the node's - infoset, or if the node is terminal. + If no action with that label exists at the node's infoset, or if the node + is terminal. ValueError - If `action` is an empty or all-whitespace string, or is an ``Action`` - from a different infoset. + If `action` is an empty or all-whitespace string. TypeError - If `action` is not a ``str`` or an ``Action``. + If `action` is not a ``str``. .. versionchanged:: 16.5.0 Previously indexing by string searched the labels of the child nodes, rather than referring to actions. This implements the more natural interpretation that strings refer to action labels. - Relatedly, the collection can now be indexed by an Action object. - .. versionchanged:: 16.7.0 - Integer indexing is no longer supported; index by the ``Action`` taken, or its label. + Integer indexing is no longer supported; index by the action's label, or iterate. A label matching no action now raises ``KeyError``. + + .. versionchanged:: 17.0.0 + No longer accepts an ``Action`` object; index by its label instead. """ if isinstance(action, str): if not action.strip(): raise ValueError("Action label cannot be empty or all whitespace") if self.parent.deref().GetInfoset() == cython.cast(c_GameInfoset, NULL): raise KeyError(f"No action with label '{action}' at node") - for act in self.parent.deref().GetInfoset().deref().GetActions(): - if act.deref().GetLabel().decode("utf-8") == cython.cast(str, action): - return Node.wrap(self.parent.deref().GetChild(act)) - raise KeyError(f"No action with label '{action}' at node") - if isinstance(action, Action): try: - return Node.wrap(self.parent.deref().GetChild(cython.cast(Action, action).action)) - except IndexError: - raise ValueError("Action is from a different infoset than node") from None + act = _resolve_action_handle(self.parent.deref().GetInfoset(), action) + except KeyError: + raise KeyError(f"No action with label '{action}' at node") from None + return Node.wrap(self.parent.deref().GetChild(act)) if isinstance(action, int): raise TypeError( - "node children cannot be indexed by position; index by the action taken " - "(an Action or its label), or iterate. " + "node children cannot be indexed by position; index by the action's " + "label, or iterate. " "(Integer indexing was removed in 16.7.0.)" ) - raise TypeError(f"Index must be a str label or an Action, not {action.__class__.__name__}") + raise TypeError(f"Index must be a str label, not {action.__class__.__name__}") @cython.cclass diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index ced17f49e9..dc50cde686 100644 --- a/src/pygambit/qre.py +++ b/src/pygambit/qre.py @@ -233,12 +233,12 @@ def _estimate_behavior_empirical( data: libgbt.MixedBehaviorProfileDouble, ) -> LogitQREMixedBehaviorFitResult: flattened_data = [ - data[next(iter(s.members))][a.label] + data[next(iter(s.members))][a] for p in data.game.players for s in p.infosets for a in s.actions ] normalized = data.normalize() regrets = [ - [-normalized.action_regrets[next(iter(infoset.members))][a.label] for a in infoset.actions] + [-normalized.action_regrets[next(iter(infoset.members))][a] for a in infoset.actions] for player in data.game.players for infoset in player.infosets ] res = scipy.optimize.minimize( diff --git a/tests/test_actions.py b/tests/test_actions.py index bb2a5d7cb7..07b4c4c450 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -7,10 +7,14 @@ @pytest.mark.parametrize("label", games.VALID_LABELS) def test_action_label(label: str): + """A label captured before relabeling is just a plain string -- it does not update + to reflect the new label, since actions are values, not live references. + """ game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.infoset.actions)) - game.relabel_actions(game.root.infoset, {action.label: label}) - assert action.label == label + original = next(iter(game.root.infoset.actions)) + game.relabel_actions(game.root.infoset, {original: label}) + assert label in game.root.infoset.actions + assert original not in game.root.infoset.actions @pytest.mark.parametrize("label", games.INVALID_LABELS) @@ -18,14 +22,14 @@ def test_action_label_invalid_raises_valueerror(label: str): game = games.create_stripped_down_poker_efg() action = next(iter(game.root.infoset.actions)) with pytest.raises(ValueError): - game.relabel_actions(game.root.infoset, {action.label: label}) + game.relabel_actions(game.root.infoset, {action: label}) def test_relabel_action_empty_raises_valueerror(): game = games.create_stripped_down_poker_efg() action = next(iter(game.root.infoset.actions)) with pytest.raises(ValueError): - game.relabel_actions(game.root.infoset, {action.label: ""}) + game.relabel_actions(game.root.infoset, {action: ""}) def test_relabel_actions_duplicate_raises_valueerror(): @@ -40,7 +44,7 @@ def test_relabel_actions_simultaneous_swap(): """ game = games.create_stripped_down_poker_efg() game.relabel_actions(game.root.infoset, {"King": "Queen", "Queen": "King"}) - assert [action.label for action in game.root.infoset.actions] == ["Queen", "King"] + assert list(game.root.infoset.actions) == ["Queen", "King"] def test_relabel_actions_duplicate_targets_raises_valueerror(): @@ -61,7 +65,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(game.root.infoset, {"Jack": "Ace", "King": "Ace"}, strict=False) - assert [action.label for action in game.root.infoset.actions] == ["Ace", "Queen"] + assert list(game.root.infoset.actions) == ["Ace", "Queen"] def test_relabel_actions_failure_leaves_game_unchanged(): @@ -71,7 +75,7 @@ def test_relabel_actions_failure_leaves_game_unchanged(): game = games.create_stripped_down_poker_efg() with pytest.raises(ValueError): game.relabel_actions(game.root.infoset, {"King": "Ace", "Queen": ""}) - assert [action.label for action in game.root.infoset.actions] == ["King", "Queen"] + assert list(game.root.infoset.actions) == ["King", "Queen"] def test_relabel_actions_scope_is_the_information_set(): @@ -83,10 +87,10 @@ def test_relabel_actions_scope_is_the_information_set(): king = game.players["Alice"].infosets["Alice has King"] queen = game.players["Alice"].infosets["Alice has Queen"] game.relabel_actions(king, {"Bet": "Raise"}) - assert [action.label for action in king.actions] == ["Raise", "Fold"] - assert [action.label for action in queen.actions] == ["Bet", "Fold"] + assert list(king.actions) == ["Raise", "Fold"] + assert list(queen.actions) == ["Bet", "Fold"] game.relabel_actions(queen, {"Bet": "Raise"}) - assert [action.label for action in queen.actions] == ["Raise", "Fold"] + assert list(queen.actions) == ["Raise", "Fold"] def test_relabel_actions_not_a_mapping_raises_typeerror(): @@ -117,7 +121,7 @@ def test_set_move_actions_drop_shrinks_actions_and_children(): infoset = game.players["Alice"].infosets["Alice has King"] node = next(iter(infoset.members)) action_count = len(infoset.actions) - remaining = [action.label for action in infoset.actions][1:] + remaining = list(infoset.actions)[1:] game.set_move_actions(infoset, remaining, drop=True) assert len(infoset.actions) == action_count - 1 assert len(node.children) == action_count - 1 @@ -126,9 +130,9 @@ def test_set_move_actions_drop_shrinks_actions_and_children(): def test_set_move_actions_cannot_remove_the_only_action(): game = games.create_stripped_down_poker_efg() infoset = game.players["Alice"].infosets["Alice has King"] - last = next(iter(infoset.actions)).label + last = next(iter(infoset.actions)) game.set_move_actions(infoset, [last], drop=True) - assert [action.label for action in infoset.actions] == [last] + assert list(infoset.actions) == [last] with pytest.raises(gbt.UndefinedOperationError): game.set_move_actions(infoset, [], drop=True) @@ -150,13 +154,13 @@ def action_plays(infoset): """`Action.plays` was removed; composes from `Infoset.members`, `Node.children`, and `Node.plays`.""" return { - action.label: {n for m in infoset.members for n in m.children[action.label].plays} - for action in infoset.actions + label: {n for m in infoset.members for n in m.children[label].plays} + for label in infoset.actions } plays_before = action_plays(infoset) game.set_move_actions(infoset, ["c", "a", "b"]) - assert [action.label for action in infoset.actions] == ["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 action_plays(infoset) == plays_before @@ -167,7 +171,7 @@ def test_set_move_actions_add_drop_and_reorder_together(): infoset = game.players["Alice"].infosets["Alice has King"] nodes_before = len(game.nodes) game.set_move_actions(infoset, ["Raise", "Fold"], drop=True) - assert [action.label for action in infoset.actions] == ["Raise", "Fold"] + assert list(infoset.actions) == ["Raise", "Fold"] # "Bet" and its subtree (Bob's node and its two terminals) go; "Raise" adds one. assert len(game.nodes) == nodes_before - 3 + 1 assert len(game.players["Bob"].infosets["Bob's response"].members) == 1 @@ -211,7 +215,7 @@ def test_set_move_actions_absent_minded_drop_and_add(): game.append_move(game.root, "Alice", ["a", "b"]) game.append_infoset(game.root.children["a"], game.root.infoset) game.set_move_actions(game.root.infoset, ["b", "c"], drop=True) - assert [action.label for action in game.root.infoset.actions] == ["b", "c"] + assert list(game.root.infoset.actions) == ["b", "c"] assert len(game.root.infoset.members) == 1 assert len(game.nodes) == 3 diff --git a/tests/test_behav.py b/tests/test_behav.py index 7b0e897566..2856fd5645 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -20,7 +20,7 @@ def _set_action_probs(profile: gbt.MixedBehaviorProfile, probs: list, rational_f probs_iter = iter(probs) for infoset in profile.game.infosets: node = next(iter(infoset.members)) - profile[node] = {a.label: convert(next(probs_iter)) for a in infoset.actions} + profile[node] = {a: convert(next(probs_iter)) for a in infoset.actions} @pytest.mark.parametrize( @@ -267,7 +267,7 @@ def test_profile_indexing_by_node_reference( infoset = player.infosets[infoset_label] node = next(iter(infoset.members)) probs = [gbt.Rational(prob) for prob in probs] if rational_flag else probs - expected = dict(zip((a.label for a in infoset.actions), probs, strict=True)) + expected = dict(zip(infoset.actions, probs, strict=True)) assert profile[player_label][node] == expected assert profile[node] == expected @@ -315,7 +315,7 @@ def test_profile_indexing_by_player_label_reference( behav_data = [[gbt.Rational(prob) for prob in probs] for probs in behav_data] player = game.players[player_label] expected = [ - dict(zip((a.label for a in infoset.actions), probs, strict=True)) + dict(zip(infoset.actions, probs, strict=True)) for infoset, probs in zip(player.infosets, behav_data, strict=True) ] assert profile[player_label] == expected @@ -434,7 +434,7 @@ def test_set_probabilities_infoset( probs = [gbt.Rational(p) for p in probs] infoset = game.players[player_label].infosets[infoset_label] node = next(iter(infoset.members)) - expected = dict(zip((a.label for a in infoset.actions), probs, strict=True)) + expected = dict(zip(infoset.actions, probs, strict=True)) profile[node] = expected assert profile[node] == expected @@ -466,7 +466,7 @@ def test_set_probabilities_player_by_label( behav_data = [[gbt.Rational(prob) for prob in probs] for probs in behav_data] player = game.players[player_label] expected = [ - dict(zip((a.label for a in infoset.actions), probs, strict=True)) + dict(zip(infoset.actions, probs, strict=True)) for infoset, probs in zip(player.infosets, behav_data, strict=True) ] for infoset, distribution in zip(player.infosets, expected, strict=True): @@ -782,9 +782,9 @@ def test_action_values_reference(game: gbt.Game, rational_flag: bool, action_val profile = game.mixed_behavior_profile(rational=rational_flag) for values_for_infoset, infoset in zip(action_values, game.infosets, strict=True): infoset_action_values = profile.action_values[next(iter(infoset.members))] - for value, action in zip(values_for_infoset, infoset.actions, strict=True): + for value, label in zip(values_for_infoset, infoset.actions, strict=True): value = gbt.Rational(value) if rational_flag else value - assert infoset_action_values[action.label] == value + assert infoset_action_values[label] == value @pytest.mark.parametrize( @@ -803,10 +803,10 @@ def test_action_regret_consistency(game: gbt.Game, rational_flag: bool): for player in game.players: for infoset in player.infosets: node = next(iter(infoset.members)) - for action in infoset.actions: - assert profile.action_regrets[node][action.label] == max( - profile.action_values[node][a.label] for a in infoset.actions - ) - profile.action_values[node][action.label] + for label in infoset.actions: + assert profile.action_regrets[node][label] == max( + profile.action_values[node][a] for a in infoset.actions + ) - profile.action_values[node][label] @pytest.mark.parametrize( @@ -826,7 +826,7 @@ def test_infoset_regret_consistency(game: gbt.Game, rational_flag: bool): for infoset in player.infosets: node = next(iter(infoset.members)) assert profile.infoset_regrets[node] == max( - profile.action_values[node][a.label] for a in infoset.actions + profile.action_values[node][a] for a in infoset.actions ) - profile.infoset_values[node] @@ -917,12 +917,12 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): assert isinstance(infoset_action_values, gbt.ActionValueVector) assert isinstance(infoset_action_regrets, gbt.ActionRegretVector) - best_response_value = max(infoset_action_values[a.label] for a in infoset.actions) + 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: + for label in infoset.actions: assert ( - infoset_action_regrets[action.label] - == best_response_value - infoset_action_values[action.label] + infoset_action_regrets[label] + == best_response_value - infoset_action_values[label] ) for node in game.nodes: @@ -1009,9 +1009,9 @@ def test_action_regrets_reference( _set_action_probs(profile, action_probs, rational_flag) for regrets_for_infoset, infoset in zip(action_regrets, game.infosets, strict=True): infoset_action_regrets = profile.action_regrets[next(iter(infoset.members))] - for regret, action in zip(regrets_for_infoset, infoset.actions, strict=True): + for regret, label in zip(regrets_for_infoset, infoset.actions, strict=True): regret = gbt.Rational(regret) if rational_flag else regret - assert abs(infoset_action_regrets[action.label] - regret) <= tol + assert abs(infoset_action_regrets[label] - regret) <= tol @pytest.mark.parametrize( @@ -1611,32 +1611,32 @@ def _get_and_check_answers( PROBS_1A_doub, PROBS_2A_doub, False, - lambda x, y: x.action_values[next(iter(y.infoset.members))][y.label], - lambda x: [a for infoset in x.infosets for a in infoset.actions], + lambda x, y: x.action_values[next(iter(y[0].members))][y[1]], + lambda x: [(infoset, a) for infoset in x.infosets for a in infoset.actions], ), ( games.read_from_file("mixed_behavior_game.efg"), PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.action_values[next(iter(y.infoset.members))][y.label], - lambda x: [a for infoset in x.infosets for a in infoset.actions], + lambda x, y: x.action_values[next(iter(y[0].members))][y[1]], + lambda x: [(infoset, a) for infoset in x.infosets for a in infoset.actions], ), ( games.create_stripped_down_poker_efg(), PROBS_1B_doub, PROBS_2B_doub, False, - lambda x, y: x.action_values[next(iter(y.infoset.members))][y.label], - lambda x: [a for infoset in x.infosets for a in infoset.actions], + lambda x, y: x.action_values[next(iter(y[0].members))][y[1]], + lambda x: [(infoset, a) for infoset in x.infosets for a in infoset.actions], ), ( games.create_stripped_down_poker_efg(), PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.action_values[next(iter(y.infoset.members))][y.label], - lambda x: [a for infoset in x.infosets for a in infoset.actions], + lambda x, y: x.action_values[next(iter(y[0].members))][y[1]], + lambda x: [(infoset, a) for infoset in x.infosets for a in infoset.actions], ), ###################################################################################### # regret (for actions) @@ -1645,32 +1645,32 @@ def _get_and_check_answers( PROBS_1A_doub, PROBS_2A_doub, False, - lambda x, y: x.action_regrets[next(iter(y.infoset.members))][y.label], - lambda x: [a for infoset in x.infosets for a in infoset.actions], + lambda x, y: x.action_regrets[next(iter(y[0].members))][y[1]], + lambda x: [(infoset, a) for infoset in x.infosets for a in infoset.actions], ), ( games.read_from_file("mixed_behavior_game.efg"), PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.action_regrets[next(iter(y.infoset.members))][y.label], - lambda x: [a for infoset in x.infosets for a in infoset.actions], + lambda x, y: x.action_regrets[next(iter(y[0].members))][y[1]], + lambda x: [(infoset, a) for infoset in x.infosets for a in infoset.actions], ), ( games.create_stripped_down_poker_efg(), PROBS_1B_doub, PROBS_2B_doub, False, - lambda x, y: x.action_regrets[next(iter(y.infoset.members))][y.label], - lambda x: [a for infoset in x.infosets for a in infoset.actions], + lambda x, y: x.action_regrets[next(iter(y[0].members))][y[1]], + lambda x: [(infoset, a) for infoset in x.infosets for a in infoset.actions], ), ( games.create_stripped_down_poker_efg(), PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.action_regrets[next(iter(y.infoset.members))][y.label], - lambda x: [a for infoset in x.infosets for a in infoset.actions], + lambda x, y: x.action_regrets[next(iter(y[0].members))][y[1]], + lambda x: [(infoset, a) for infoset in x.infosets for a in infoset.actions], ), ###################################################################################### # node_value @@ -1896,9 +1896,9 @@ def test_specific_profile(game: gbt.Game, rational_flag: bool, data: list): flattened = iter([k for i in data for j in i for k in j]) for infoset in game.infosets: node = next(iter(infoset.members)) - for action in infoset.actions: + for label in infoset.actions: prob = next(flattened) - assert profile[node][action.label] == (gbt.Rational(prob) if rational_flag else prob) + assert profile[node][label] == (gbt.Rational(prob) if rational_flag else prob) @pytest.mark.parametrize( @@ -1986,10 +1986,10 @@ def test_undefined_action_value(): *_, p3 = game.players infoset = next(iter(p3.infosets)) node = next(iter(infoset.members)) - action = next(iter(infoset.actions)) + label = next(iter(infoset.actions)) for rat in [False, True]: profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) - assert profile.action_values[node][action.label] is None + assert profile.action_values[node][label] is None def test_undefined_belief(): diff --git a/tests/test_extensive.py b/tests/test_extensive.py index e5d2b2a83e..44835f755d 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -509,7 +509,7 @@ def test_reduced_strategy_maps(game: gbt.Game, strategy_maps: list): behavior = game.get_behavior(player, strategy) assert tuple( "*" if (label := behavior.get(infoset)) is None - else str([a.label for a in infoset.actions].index(label) + 1) + else str(list(infoset.actions).index(label) + 1) for infoset in player.infosets ) == expected diff --git a/tests/test_file.py b/tests/test_file.py index 0ac63b7b06..fb5c841eea 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 [a.label for a in g.root.infoset.actions] == ["_1", "_2"] + assert list(g.root.infoset.actions) == ["_1", "_2"] def test_read_efg_duplicate_action_labels_are_normalized(): g = _parse_efg('EFG 2 R "t" { "A" "B" }\n""\np "" 1 1 "" { "l" "l" } 0\n' 't "" 1 "" { 1, -1 }\nt "" 2 "" { 2, -2 }\n') - assert [a.label for a in g.root.infoset.actions] == ["l_1", "l_2"] + assert list(g.root.infoset.actions) == ["l_1", "l_2"] def test_read_efg_repeated_infoset_duplicate_labels_consistent(): @@ -53,7 +53,7 @@ def test_read_efg_repeated_infoset_duplicate_labels_consistent(): 't "" 2 "" { 2, -2 }\n' 't "" 3 "" { 3, -3 }\n' ) - assert [a.label for a in g.root.infoset.actions] == ["l_1", "l_2"] + assert list(g.root.infoset.actions) == ["l_1", "l_2"] _NFG_PAYOFF_BODY = '\n{\n{ "" 1, 1 }\n{ "" 0, 0 }\n{ "" 0, 0 }\n{ "" 1, 1 }\n}\n1 2 3 4\n' diff --git a/tests/test_game.py b/tests/test_game.py index cd6245e751..759090a10d 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -301,8 +301,6 @@ def test_mixed_behavior_profile_game_structure_changed(): pytest.param(lambda g: g.infosets, id="GameInfosets"), pytest.param(lambda g: g.players["Alice"].strategies, id="PlayerStrategies"), pytest.param(lambda g: g.players["Alice"].infosets, id="PlayerInfosets"), - pytest.param(lambda g: g.players["Bob"].infosets["Bob's response"].actions, - id="InfosetActions"), pytest.param(lambda g: g.players["Bob"].infosets["Bob's response"].members, id="InfosetMembers"), ] diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 01d7e683ec..771d2f5afa 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -123,16 +123,15 @@ def test_make_infoset_strategic_game_raises(): game.make_infoset([], "1") -def test_set_move_actions_add_preserves_existing_action_handles(): - """New actions may be declared at any position; the existing Action objects - survive in declared order.""" +def test_set_move_actions_add_preserves_existing_labels(): + """New actions may be declared at any position; the existing labels survive in + declared order (actions are just labels -- there is no live handle to preserve).""" game = games.read_from_file("basic_extensive_game.efg") - actions = list(game.root.infoset.actions) - labels = [action.label for action in actions] + labels = list(game.root.infoset.actions) game.set_move_actions(game.root.infoset, labels + ["end"]) - assert list(game.root.infoset.actions)[:-1] == actions + assert list(game.root.infoset.actions)[:-1] == labels game.set_move_actions(game.root.infoset, ["front"] + labels + ["end"]) - assert list(game.root.infoset.actions)[1:-1] == actions + assert list(game.root.infoset.actions)[1:-1] == labels def test_infoset_plays(): diff --git a/tests/test_nash.py b/tests/test_nash.py index 9cc38be98e..f49640d49b 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -23,12 +23,12 @@ def d(*probs) -> tuple: return tuple(probs) -def _action_prob(profile: gbt.MixedBehaviorProfile, action: gbt.Action): - """The probability profile assigns to action, addressed via a representative node - of its information set (MixedBehaviorProfile no longer indexes by Action directly). +def _action_prob(profile: gbt.MixedBehaviorProfile, infoset: gbt.Infoset, label: str): + """The probability profile assigns to the action at infoset with label, addressed + via a representative node of the information set. """ - node = next(iter(action.infoset.members)) - return profile[node][action.label] + node = next(iter(infoset.members)) + return profile[node][label] @dataclasses.dataclass @@ -3157,9 +3157,10 @@ def test_nash_behavior_solver(test_case: EquilibriumTestCase, subtests) -> None: expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: for infoset in player.infosets: - for action in infoset.actions: + for label in infoset.actions: assert abs( - _action_prob(eq, action) - _action_prob(expected, action) + _action_prob(eq, infoset, label) + - _action_prob(expected, infoset, label) ) <= test_case.prob_tol @@ -3208,8 +3209,11 @@ def test_nash_behavior_solver_unordered(test_case: EquilibriumTestCase, subtests def are_the_same(game, found, candidate): for p in game.players: for infoset in p.infosets: - for a in infoset.actions: - if not abs(_action_prob(found, a) - _action_prob(candidate, a)) <= TOL: + for label in infoset.actions: + if not abs( + _action_prob(found, infoset, label) + - _action_prob(candidate, infoset, label) + ) <= TOL: return False return True @@ -3373,9 +3377,10 @@ def test_nash_agent_solver(test_case: EquilibriumTestCase, subtests) -> None: expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: for infoset in player.infosets: - for action in infoset.actions: + for label in infoset.actions: assert abs( - _action_prob(eq, action) - _action_prob(expected, action) + _action_prob(eq, infoset, label) + - _action_prob(expected, infoset, label) ) <= test_case.prob_tol @@ -3428,9 +3433,10 @@ def test_nash_agent_w_start_solver(test_case: EquilibriumTestCase, subtests) -> expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: for infoset in player.infosets: - for action in infoset.actions: + for label in infoset.actions: assert abs( - _action_prob(eq, action) - _action_prob(expected, action) + _action_prob(eq, infoset, label) + - _action_prob(expected, infoset, label) ) <= test_case.prob_tol diff --git a/tests/test_node.py b/tests/test_node.py index a1bb77c7cc..763bb324e9 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -830,7 +830,7 @@ def test_append_move_labels_list_of_nodes(): game.append_move([node1, node2], "Player 3", ["B", "F", "S"]) for action1, action2 in zip(node1.infoset.actions, node2.infoset.actions, strict=True): - assert action1.label == action2.label + assert action1 == action2 def test_append_move_node_list_with_non_terminal_node(): @@ -1008,7 +1008,7 @@ def test_insert_event_actions_labeled(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") node = game.root.children["L"].children["R"] game.insert_event(node, ["Up", "Down"], [gbt.Rational(1, 2)] * 2) - assert [a.label for a in node.parent.infoset.actions] == ["Up", "Down"] + assert list(node.parent.infoset.actions) == ["Up", "Down"] assert node.parent.infoset.is_chance @@ -1154,7 +1154,7 @@ def test_len_after_set_move_actions_add(): initial_number_of_nodes = len(game.nodes) infoset_to_modify = game.root.children["L"].infoset # Player 2's infoset num_nodes_in_infoset = len(infoset_to_modify.members) - labels = [action.label for action in infoset_to_modify.actions] + labels = list(infoset_to_modify.actions) game.set_move_actions(infoset_to_modify, labels + ["new"]) assert len(game.nodes) == initial_number_of_nodes + num_nodes_in_infoset @@ -1163,13 +1163,14 @@ def test_len_after_set_move_actions_drop(): """Verify `len(game.nodes)` is correct after `set_move_actions` deletes an action.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig2") initial_number_of_nodes = len(game.nodes) - action_to_drop = game.root.infoset.actions["L"] + infoset_to_modify = game.root.infoset + label_to_drop = "L" nodes_to_delete = sum( - _count_subtree_nodes(member.children[action_to_drop.label], True) - for member in action_to_drop.infoset.members + _count_subtree_nodes(member.children[label_to_drop], True) + for member in infoset_to_modify.members ) - remaining = [a.label for a in game.root.infoset.actions if a.label != "L"] - game.set_move_actions(game.root.infoset, remaining, drop=True) + remaining = [label for label in infoset_to_modify.actions if label != label_to_drop] + game.set_move_actions(infoset_to_modify, remaining, drop=True) assert len(game.nodes) == initial_number_of_nodes - nodes_to_delete @@ -1192,7 +1193,7 @@ def test_insert_move_actions_labeled(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") node = game.root.children["L"].children["R"] game.insert_move(node, game.players["Player 2"], ["Up", "Down"]) - assert [a.label for a in node.parent.infoset.actions] == ["Up", "Down"] + assert list(node.parent.infoset.actions) == ["Up", "Down"] def test_len_after_insert_infoset(): @@ -1252,15 +1253,6 @@ def test_node_children_action_label(): assert game.root.children["Queen"].children["Fold"] == list(root_children[1].children)[1] -def test_node_children_action(): - """Action lookup returns the correct child. - - The RHS reaches the child positionally -- cf. `test_node_children_action_label()`. - """ - game = games.read_from_file("stripped_down_poker.efg") - assert game.root.children[game.root.infoset.actions["King"]] == list(game.root.children)[0] - - def test_node_children_empty_label(): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(ValueError, match="empty or all whitespace"): @@ -1286,12 +1278,6 @@ def test_node_children_rejects_int(): _ = game.root.children[0] -def test_node_children_other_infoset_action(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(ValueError): - _ = game.root.children[game.root.children["King"].infoset.actions["Bet"]] - - @pytest.mark.parametrize("label", games.VALID_LABELS) def test_node_label_valid(label): game = games.read_from_file("basic_extensive_game.efg") diff --git a/tests/test_players.py b/tests/test_players.py index b6c8818bd1..ce5ee94cb7 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -348,8 +348,8 @@ def test_player_sequence_actions(): player = game.players["Alice"] sequences = set(tuple(seq.actions) for seq in player.sequences) reference = ( - set(((infoset, action.label), ) for infoset in player.infosets - for action in infoset.actions) | + set(((infoset, label), ) for infoset in player.infosets + for label in infoset.actions) | {tuple()} ) assert sequences == reference From a50391d155b518285198214cdf90bd848e7219d9 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 24 Aug 2026 13:25:15 +0100 Subject: [PATCH 09/11] Remove `Action` class --- ChangeLog | 4 +++ doc/pygambit.api.rst | 8 ----- src/pygambit/action.pxi | 68 ----------------------------------------- src/pygambit/gambit.pyx | 1 - 4 files changed, 4 insertions(+), 77 deletions(-) delete mode 100644 src/pygambit/action.pxi diff --git a/ChangeLog b/ChangeLog index 7661f15675..2dd48ce32f 100644 --- a/ChangeLog +++ b/ChangeLog @@ -78,6 +78,10 @@ this node's own player. This further breaks `gtdraw`, which reads `.label` off the previous return value; more tutorial notebooks now report `xfail` under `--run-tutorials` as a result (already covered by the content-based gating added earlier). +- The `Action` class has been removed entirely, completing the removals above: an action + is now represented purely as a text label (`str`), optionally paired with the `Infoset` + or `Node` that gives it context, rather than as an object holding a live reference into + the game. ## [17.0.0-alpha.1] - 2026-08-13 diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 20a41320a8..c595be5f67 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -18,7 +18,6 @@ Representation of games Outcome Node Infoset - Action Strategy Subgame @@ -179,13 +178,6 @@ Information about the game Infoset.precedes Infoset.plays -.. autosummary:: - - :toctree: api/ - - Action.label - Action.infoset - .. autosummary:: :toctree: api/ diff --git a/src/pygambit/action.pxi b/src/pygambit/action.pxi deleted file mode 100644 index 34ac919397..0000000000 --- a/src/pygambit/action.pxi +++ /dev/null @@ -1,68 +0,0 @@ -# -# This file is part of Gambit -# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -# -# FILE: src/pygambit/action.pxi -# Cython wrapper for actions -# -# This program is free software; you can redistribute it and/or modify -# It under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -# - -@cython.cclass -class Action: - """A choice available at an ``Infoset`` in a ``Game``.""" - action = cython.declare(c_GameAction) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create an Action outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(action: c_GameAction) -> Action: - obj: Action = Action.__new__(Action) - obj.action = action - return obj - - def __repr__(self) -> str: - return f"Action(infoset={self.infoset}, label='{self.label}')" - - def __eq__(self, other: typing.Any) -> bool: - return ( - isinstance(other, Action) and - self.action.deref() == cython.cast(Action, other).action.deref() - ) - - def __hash__(self) -> int: - return cython.cast(cython.long, self.action.deref()) - - @property - def label(self) -> str: - """The text label of the action. - - .. 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. - - The label is now read-only, and must be nonempty and unique within its - information set; use `Game.relabel_actions` to change it. - """ - return self.action.deref().GetLabel().decode("utf-8") - - @property - def infoset(self) -> Infoset: - """Get the information set to which the action belongs.""" - return Infoset.wrap(self.action.deref().GetInfoset()) diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index e771bf89d2..0dc76589d3 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -203,7 +203,6 @@ class NodeIndexedVector(_LabeledVector): # Includes ###################### -include "action.pxi" include "infoset.pxi" include "strategy.pxi" include "player.pxi" From fd9ae06af696c4c184b3b84b5d1b74139bdb6651 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 24 Aug 2026 14:02:54 +0100 Subject: [PATCH 10/11] lmlDefine Action and Branch as named tuples. --- ChangeLog | 20 +++++++++++++------- doc/pygambit.api.rst | 4 ++++ src/pygambit/node.pxi | 24 ++++++++++++++++-------- src/pygambit/strategy.pxi | 18 +++++++++++++----- 4 files changed, 46 insertions(+), 20 deletions(-) diff --git a/ChangeLog b/ChangeLog index 2dd48ce32f..aefca0b148 100644 --- a/ChangeLog +++ b/ChangeLog @@ -61,18 +61,18 @@ - `Strategy.action` has been removed; it is superseded by `Game.get_behavior`, which returns a `StrategyBehavior`, a full map-like view of a strategy's information-set-to-action mapping rather than a one-information-set-at-a-time query. -- `Sequence.actions` now returns `list[tuple[Infoset, str]]` (an information set paired with - its action's label) instead of `list[Action]`. -- `StrategyBehavior.__getitem__`/`.get`/`.values`/`.items` now return action labels (`str`) - instead of `Action` objects. +- `Sequence.actions` now returns `list[Action]`, a `NamedTuple` pairing an information set + with its action's label -- see the note on `Action`/`Branch` below. +- `StrategyBehavior.__getitem__`/`.get`/`.values` now return action labels (`str`) instead of + `Action` objects (the removed class); `.items` returns `list[Action]` (the new `NamedTuple`). - `Infoset.own_prior_actions` has been removed; it composes from `Infoset.members` and `Node.own_prior_action`, e.g. `{m.own_prior_action for m in infoset.members}`. - `Infoset.actions` now returns `list[str]` (the actions' labels, in order) instead of a collection of `Action` objects. `NodeChildren.__getitem__` (`node.children[...]`) no longer accepts an `Action`; index by label only. `Action` itself is no longer referenced anywhere in pygambit's public surface. -- `Node.prior_action` and `Node.own_prior_action` now return `tuple[Node, str] | None` - (the node on the path at which the action was taken, paired with its label) instead of +- `Node.prior_action` and `Node.own_prior_action` now return `Branch | None`, a `NamedTuple` + pairing the node on the path at which the action was taken with its label, instead of `Action | None`. `Node.own_prior_action` no longer calls into the C++ core at all; it is computed by walking up `Node.parent`/`Node.prior_action` to the nearest ancestor sharing this node's own player. This further breaks `gtdraw`, which reads `.label` off the @@ -81,7 +81,13 @@ - The `Action` class has been removed entirely, completing the removals above: an action is now represented purely as a text label (`str`), optionally paired with the `Infoset` or `Node` that gives it context, rather than as an object holding a live reference into - the game. + the game. Two new lightweight `NamedTuple` types formalize that pairing where it recurs: + `Action` (an `Infoset` and a label -- reusing the removed class's name, but now a plain + immutable value with no live reference, used by `Sequence.actions` and + `StrategyBehavior.items`) and `Branch` (a `Node` and a label -- a specific edge in the game + tree, used by `Node.prior_action` and `Node.own_prior_action`). Both are ordinary tuples + (support positional indexing, unpacking, and equality against a plain 2-tuple) with named + `.infoset`/`.node` and `.label` fields added for clarity. ## [17.0.0-alpha.1] - 2026-08-13 diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index c595be5f67..23fab8867e 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -155,6 +155,8 @@ Information about the game Node.plays Node.own_prior_action + Branch + .. autosummary:: :toctree: api/ @@ -195,6 +197,8 @@ Information about the game Sequence.parent Sequence.actions + Action + Player behavior ............... diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 0caa2d5756..c6c7073def 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -347,7 +347,7 @@ class Node: path = [] node = self while node.parent: - path.append(node.prior_action[1]) + path.append(node.prior_action.label) node = node.parent return f"Node(game={self.game}, path={path})" @@ -429,7 +429,7 @@ class Node: return None @property - def prior_action(self) -> tuple[Node, str] | None: + def prior_action(self) -> Branch | None: """The parent of this node, paired with the label of the action taken there which leads to this node. @@ -437,24 +437,24 @@ class Node: """ action: c_GameAction = self.node.deref().GetPriorAction() if action != cython.cast(c_GameAction, NULL): - return (self.parent, action.deref().GetLabel().decode("utf-8")) + return Branch(self.parent, action.deref().GetLabel().decode("utf-8")) return None @property - def own_prior_action(self) -> tuple[Node, str] | None: + def own_prior_action(self) -> Branch | None: """The most recent node on the path to this one at which this node's own player had the move, paired with the label of the action taken there. Returns ------- - tuple of Node and str, or None + Branch or None None if the player has not moved previously on the path to this node, or if this is a terminal node. .. versionadded:: 16.5.0 .. versionchanged:: 17.0.0 - Now returns a `(Node, str)` pair -- the node at which the action was - taken, and its label -- instead of an `Action` object. + Now returns a `Branch` (the node at which the action was taken, + and its label) instead of an `Action` object. """ if self.is_terminal: return None @@ -464,7 +464,7 @@ class Node: prior_node, prior_label = node.prior_action node = prior_node if node.player == target_player: - return (node, prior_label) + return Branch(node, prior_label) return None @property @@ -537,6 +537,14 @@ class Node: return [Node.wrap(n) for n in self.node.deref().GetGame().deref().GetPlays(self.node)] +class Branch(typing.NamedTuple): + """A specific edge in the game tree: a node, together with the label of the + action taken there. + """ + node: Node + label: str + + @cython.cclass class Subgame: """A subgame in a ``Game``. diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index 07e1b59b4c..3176f579b2 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -20,6 +20,13 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # +class Action(typing.NamedTuple): + """An information set, together with the label of one of its actions. + """ + infoset: Infoset + label: str + + @cython.cclass class Strategy: """A plan of action for a ``Player`` in a ``Game``.""" @@ -193,11 +200,12 @@ class StrategyBehavior: infoset: Infoset return [self._strategy._get_action_label(infoset.infoset) for infoset in self] - def items(self) -> list[tuple[Infoset, str]]: + def items(self) -> list[Action]: """(information set, action label) pairs, in the order of `keys`.""" infoset: Infoset return [ - (infoset, self._strategy._get_action_label(infoset.infoset)) for infoset in self + Action(infoset, self._strategy._get_action_label(infoset.infoset)) + for infoset in self ] @@ -258,17 +266,17 @@ class Sequence: return ret @property - def actions(self) -> list[tuple[Infoset, str]]: + def actions(self) -> list[Action]: """Get the (information set, action label) pairs defining this sequence, in order from the player's root sequence to this one. Returns the empty list for the root sequence of the player. """ - actions: list[tuple[Infoset, str]] = [] + actions: list[Action] = [] seq = self.sequence while seq.deref().GetAction() != cython.cast(c_GameAction, NULL): action: c_GameAction = seq.deref().GetAction() - actions.insert(0, ( + actions.insert(0, Action( Infoset.wrap(action.deref().GetInfoset()), action.deref().GetLabel().decode("utf-8") )) From fc6a20bf7818c3dcab8a382772030a67799c3fac Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 24 Aug 2026 14:06:50 +0100 Subject: [PATCH 11/11] Update ChangeLog --- ChangeLog | 50 +++++++------------------------------------------- 1 file changed, 7 insertions(+), 43 deletions(-) diff --git a/ChangeLog b/ChangeLog index aefca0b148..0f85be19b2 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,10 @@ +## [17.0.0-beta.1] - unreleased + +### Changed +- In `pygambit` actions are referred to by their labels, with node or information set context + only when required. + + ## [17.0.0-alpha.2] - 2026-08-21 ### Added @@ -12,8 +19,6 @@ - Added `max_rectangles` to `enumpoly_solve` (and `-r` to `gambit-enumpoly`), bounding the number of cells examined when searching for equilibria on a single support. (#1055) - Added `Game.set_players`, which declares the ordered list of the game's players, matching by label. (#1059) -- Added `Infoset.action_probs`, returning the probability of each of the information set's - actions, keyed by label. ### Fixed - `MixedStrategy.__eq__` raised `AttributeError` when comparing two `MixedStrategy` instances @@ -47,47 +52,6 @@ nonempty, unique labels. (#1058) - `Game.add_player` has been removed; use `Game.set_players`, specifying the labels of the players. (#1059) -- `Action.prob`, `Action.plays`, `Action.precedes`, and `Action.number` have been removed. - `Action.prob` is replaced by `Infoset.action_probs`. `Action.plays` and `Action.precedes` - compose from existing primitives (`Infoset.members`, `Node.children`, `Node.plays`, - `Node.is_successor_of`) and have no direct replacement. `Action.number` was always just - the action's position in `Infoset.actions`. This breaks the `gtdraw` package used by some - tutorial notebooks; the affected notebook tests are `xfail`-gated pending a `gtdraw` update. -- `Game.actions` and `Player.actions` have been removed; both flattened actions across - several information sets into a single collection, discarding which information set each - action belonged to. Index an information set's own `actions` instead, iterating - `Game.infosets`/`Player.infosets` as needed. `Game._resolve_action`, an internal helper - with no callers besides its own tests, is removed along with it. -- `Strategy.action` has been removed; it is superseded by `Game.get_behavior`, which returns - a `StrategyBehavior`, a full map-like view of a strategy's information-set-to-action - mapping rather than a one-information-set-at-a-time query. -- `Sequence.actions` now returns `list[Action]`, a `NamedTuple` pairing an information set - with its action's label -- see the note on `Action`/`Branch` below. -- `StrategyBehavior.__getitem__`/`.get`/`.values` now return action labels (`str`) instead of - `Action` objects (the removed class); `.items` returns `list[Action]` (the new `NamedTuple`). -- `Infoset.own_prior_actions` has been removed; it composes from `Infoset.members` and - `Node.own_prior_action`, e.g. `{m.own_prior_action for m in infoset.members}`. -- `Infoset.actions` now returns `list[str]` (the actions' labels, in order) instead of a - collection of `Action` objects. `NodeChildren.__getitem__` (`node.children[...]`) no longer - accepts an `Action`; index by label only. `Action` itself is no longer referenced anywhere - in pygambit's public surface. -- `Node.prior_action` and `Node.own_prior_action` now return `Branch | None`, a `NamedTuple` - pairing the node on the path at which the action was taken with its label, instead of - `Action | None`. `Node.own_prior_action` no longer calls into the C++ core at all; it is - computed by walking up `Node.parent`/`Node.prior_action` to the nearest ancestor sharing - this node's own player. This further breaks `gtdraw`, which reads `.label` off the - previous return value; more tutorial notebooks now report `xfail` under `--run-tutorials` - as a result (already covered by the content-based gating added earlier). -- The `Action` class has been removed entirely, completing the removals above: an action - is now represented purely as a text label (`str`), optionally paired with the `Infoset` - or `Node` that gives it context, rather than as an object holding a live reference into - the game. Two new lightweight `NamedTuple` types formalize that pairing where it recurs: - `Action` (an `Infoset` and a label -- reusing the removed class's name, but now a plain - immutable value with no live reference, used by `Sequence.actions` and - `StrategyBehavior.items`) and `Branch` (a `Node` and a label -- a specific edge in the game - tree, used by `Node.prior_action` and `Node.own_prior_action`). Both are ordinary tuples - (support positional indexing, unpacking, and equality against a plain 2-tuple) with named - `.infoset`/`.node` and `.label` fields added for clarity. ## [17.0.0-alpha.1] - 2026-08-13