From dbade9ae37797a985606f27b5f2d9bf4f9f7d0b7 Mon Sep 17 00:00:00 2001 From: drdkad Date: Sat, 18 Jul 2026 21:42:31 +0100 Subject: [PATCH 1/9] update Changelog, api.rst --- ChangeLog | 10 ++++++++++ doc/pygambit.api.rst | 1 + 2 files changed, 11 insertions(+) diff --git a/ChangeLog b/ChangeLog index 19e30ae56..93e8b8be2 100644 --- a/ChangeLog +++ b/ChangeLog @@ -7,11 +7,21 @@ the run. (#998) - Implemented bespoke XML parser that handles the subset in the de-facto legacy XML workbook .gbt format; removes dependency on tinyxml. (#897) +- `Game.leave_infoset`, `Game.set_infoset`, `Game.set_player`, and `Game.reveal` are now + implemented in terms of `Game.make_infoset`. `set_infoset` now requires the node to have + the same actions as the target information set, with the same labels in the same order + (previously only the number of actions was checked). `reveal` now raises at + absent-minded information sets. + +### Added +- Added `Game.make_infoset`, forming a collection of personal decision nodes into a single + information set; the primitive operation for editing information structures. ### Fixed - Converting a behavior profile to a strategy profile, and computing pure-strategy payoffs no longer inserts spurious entries into a strategy's internal action map. + ## [16.7.0] - 2026-07-11 ### Added diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index e475a689a..2c1ac9245 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -68,6 +68,7 @@ Transforming game information structure .. autosummary:: :toctree: api/ + Game.make_infoset Game.set_player Game.set_infoset Game.leave_infoset From 70bb3afa22191793037f34590f67d6e07d1a4835 Mon Sep 17 00:00:00 2001 From: drdkad Date: Sat, 18 Jul 2026 21:43:05 +0100 Subject: [PATCH 2/9] add make_infoset and refactor other referring to it --- src/pygambit/game.pxi | 148 ++++++++++++++++++++++++++++++++++++++--- tests/test_infosets.py | 22 ++++++ 2 files changed, 162 insertions(+), 8 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index f95d47aba..283bfe41b 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1972,17 +1972,126 @@ class Game: ) self.game.deref().DeleteAction(resolved_action.action) + def make_infoset(self, + nodes: Node | NodeReferenceSet, + player: str, + label: str | None = None) -> None: + """Form `nodes` into a single information set belonging to `player`. + + The nodes must all: (i) be personal decision nodes of this game, + (ii) have the same actions, with the same labels in the same order. + Nodes are removed from whatever information sets they currently belong to; any of + those information sets which retain members after removal survive, keeping their labels. + Infosets left with no members are deleted. + + The structure of the tree is unchanged: no nodes are created or removed. + This operation may introduce imperfect recall or absent-mindedness. + + .. versionadded:: 17.0.0 + + Parameters + ---------- + nodes : Node or NodeReferenceSet + The nodes to place in the information set. Nonempty; each + node may be referenced only once. + player : str + The label of the player to whom the information set belongs. + label : str, optional + The label of the new information set. If specified, must be unique + among the information sets of `player` after the operation. A label + currently held by another of `player`'s information sets may be reused + only if all members of that set are among `nodes`. + + Raises + ------ + MismatchError + If any of `nodes` is not a member of this game. + UndefinedOperationError + If any of `nodes` is a terminal node or a chance node, or if `player` + is the chance player, or if the game is not a tree. + ValueError + If `nodes` is empty or contains a repeated node; if `player` is not + the label of a player in this game; if the nodes do not all have the + same actions in the same order; or if `label` is not unique among + `player`'s information sets after the operation. + """ + if not self.is_tree: + raise UndefinedOperationError( + "make_infoset(): operation only defined for games with a tree representation" + ) + resolved_nodes = self._resolve_nodes(nodes, "make_infoset") + resolved_player = cython.cast(Player, self._resolve_player(player, "make_infoset")) + if resolved_player.is_chance: + raise UndefinedOperationError( + "make_infoset(): `player` must not be the chance player" + ) + for n in resolved_nodes: + if n.is_terminal: + raise UndefinedOperationError( + "make_infoset(): all nodes must be decision nodes" + ) + if n.infoset.player.is_chance: + raise UndefinedOperationError( + "make_infoset(): all nodes must be personal player nodes, not chance" + ) + first_node = cython.cast(Node, resolved_nodes[0]) # mint from the first node + reference_action_list = [a.label for a in first_node.infoset.actions] + for n in resolved_nodes[1:]: + if [a.label for a in n.infoset.actions] != reference_action_list: + raise ValueError( + "make_infoset(): all nodes must have the same actions, " + "with the same labels in the same order" + ) + if label is not None: + selected = set(resolved_nodes) + for iset in resolved_player.infosets: + if iset.label == label and not set(iset.members) <= selected: + raise ValueError( + f"make_infoset(): label '{label}' is not unique among the " + "information sets of the player after the operation" + ) + # (1) LeaveInfoset mints a fresh singleton infoset holding first_node; + # a native C++ MakeInfoset (step 3 of the plan) would mint directly. + c_iset: c_GameInfoset = self.game.deref().LeaveInfoset(first_node.node) + + # (2) player is unchanged by LeaveInfoset; reassign if it differs from the target. + if first_node.infoset.player != resolved_player: + self.game.deref().SetPlayer(c_iset, resolved_player.player) + + # (3) join the rest into the minted infoset; departed members leave + # their old infosets, which C++ deletes once emptied. + for n in resolved_nodes[1:]: + self.game.deref().SetInfoset(cython.cast(Node, n).node, c_iset) + + # (4) label last: SetLabel now sees the final player and membership, so + # its per-player uniqueness check agrees with the Python pre-check. + # `label or ""` clears any label inherited via the sole-member LeaveInfoset no-op. + c_iset.deref().SetLabel((label or "").encode("ascii")) + def leave_infoset(self, node: Node | str): """Remove this node from its information set. If this node is the only node in its information set, this operation has no effect. + .. versionchanged:: 17.0.0 + Now implemented via `make_infoset` for personal decision nodes. + Parameters ---------- node : Node or str The node to move to a new singleton information set. """ resolved_node = cython.cast(Node, self._resolve_node(node, "leave_infoset")) - self.game.deref().LeaveInfoset(resolved_node.node) + if ( + resolved_node.is_terminal # C++ silently no-ops + or resolved_node.infoset.player.is_chance # chance: outside make_infoset's + # domain; semantics untested (D5) + or len(resolved_node.infoset.members) == 1 # documented no-op, label retained + ): + self.game.deref().LeaveInfoset(resolved_node.node) + return + self.make_infoset([resolved_node], + resolved_node.infoset.player.label, + None) def set_infoset(self, node: Node | str, @@ -2002,14 +2111,18 @@ class Game: MismatchError If `node` is a `Node` from a different game, or `infoset` is an `Infoset` from a different game. + .. versionchanged:: 17.0.0 + Now implemented via `make_infoset`. The node must have the same + actions as `infoset`, with the same labels in the same order; + previously only the number of actions was checked. """ resolved_node = cython.cast(Node, self._resolve_node(node, "set_infoset")) resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, "set_infoset")) - if len(resolved_node.children) != len(resolved_infoset.actions): - raise ValueError( - "set_infoset(): `infoset` must have same number of actions as `node` has children." - ) - self.game.deref().SetInfoset(resolved_node.node, resolved_infoset.infoset) + if resolved_node.infoset == resolved_infoset: + return # preserve documented no-op + self.make_infoset(list(resolved_infoset.members) + [resolved_node], + resolved_infoset.player.label, + resolved_infoset.label or None) def reveal(self, infoset: Infoset | str, @@ -2035,10 +2148,26 @@ class Game: MismatchError If `infoset` is an `Infoset` from a different game, or `player` is a `Player` from a different game. + + .. versionchanged:: 17.0.0 + Now implemented via `make_infoset`. Revealing the move at an + absent-minded information set is no longer permitted. + + UndefinedOperationError + If `infoset` is absent-minded. """ resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, "reveal")) resolved_player = cython.cast(Player, self._resolve_player(player, "reveal")) - self.game.deref().Reveal(resolved_infoset.infoset, resolved_player.player) + if resolved_infoset.is_absent_minded: + raise UndefinedOperationError( + "reveal(): revealing the move at an absent-minded information set " + "is not well-defined" + ) + for action in resolved_infoset.actions: + for iset in list(resolved_player.infosets): + group = [m for m in iset.members if action.precedes(m)] + if group: + self.make_infoset(group, resolved_player.label) def sort_infosets(self) -> None: """Sort information sets into a standard order. @@ -2106,10 +2235,13 @@ class Game: MismatchError If `infoset` is an `Infoset` from another game, or `player` is a `Player` from another game. + .. versionchanged:: 17.0.0 """ resolved_player = cython.cast(Player, self._resolve_player(player, "set_player")) resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, "set_player")) - self.game.deref().SetPlayer(resolved_infoset.infoset, resolved_player.player) + self.make_infoset(list(resolved_infoset.members), + resolved_player.label, + resolved_infoset.label or None) def add_outcome(self, label: str, diff --git a/tests/test_infosets.py b/tests/test_infosets.py index fb4ff58fe..70c742eae 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -54,10 +54,16 @@ def test_infoset_node_precedes(): def test_infoset_set_player(): + """`set_player` reassigns the player; the label and membership ride along. + """ game = games.read_from_file("basic_extensive_game.efg") _, p2, *_ = game.players + game.root.infoset.label = "moved" + members = list(game.root.infoset.members) game.set_player(game.root.infoset, p2) assert game.root.infoset.player == p2 + assert game.root.infoset.label == "moved" + assert list(game.root.infoset.members) == members def test_infoset_set_player_mismatch(): @@ -292,3 +298,19 @@ def test_infoset_is_absent_minded(test_case: AbsentMindednessTestCase): actual_infosets = {infoset for infoset in game.infosets if infoset.is_absent_minded} assert actual_infosets == expected_infosets + + +def test_make_infoset_across_different_source_players(): + """Nodes drawn from different players all land under the target player.""" + game = gbt.Game.new_tree(players=["1", "2", "3"]) + game.append_move(game.root, "1", ["a", "b"]) + game.append_move(game.root.children["a"], "2", ["a", "b"]) # player 2 + game.append_move(game.root.children["b"], "3", ["a", "b"]) # player 3 + n2 = game.root.children["a"] + n3 = game.root.children["b"] + assert n2.infoset.player == game.players["2"] + assert n3.infoset.player == game.players["3"] + game.make_infoset([n2, n3], "1") + assert n2.infoset == n3.infoset + assert n2.infoset.player == game.players["1"] + assert n3.infoset.player == game.players["1"] From d46e5b15e4560ba6ac82c85289df58118a3ca563 Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 21 Jul 2026 11:01:19 +0100 Subject: [PATCH 3/9] leave_infoset and set_infoset --- src/pygambit/game.pxi | 63 ++++++++++++++++++++++++++++++------------ tests/test_infosets.py | 63 ++++++++++++++++++++++++++++++++++++++++++ tests/test_node.py | 24 ++++++++++++++++ 3 files changed, 132 insertions(+), 18 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 283bfe41b..3344c65c4 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -2069,23 +2069,37 @@ class Game: c_iset.deref().SetLabel((label or "").encode("ascii")) def leave_infoset(self, node: Node | str): - """Remove this node from its information set. If this node is the only node - in its information set, this operation has no effect. + """Remove `node` from its information set, placing it in a new singleton. + + If `node` is the only member of its information set, this is a no-op and + the information set (with its label) is unchanged. Otherwise `node` is + placed in a new, unlabeled singleton information set belonging to the + same player; the label, if any, stays with the members left in the rump. .. versionchanged:: 17.0.0 - Now implemented via `make_infoset` for personal decision nodes. + Now implemented via `make_infoset`. Parameters ---------- node : Node or str The node to move to a new singleton information set. + + Raises + ------ + MismatchError + If `node` is a `Node` from a different game. + KeyError + If `node` is a string and no node in the game has that label. + TypeError + If `node` is neither a `Node` nor a `str`. + ValueError + If `node` is an empty string or all whitespace. """ resolved_node = cython.cast(Node, self._resolve_node(node, "leave_infoset")) if ( - resolved_node.is_terminal # C++ silently no-ops - or resolved_node.infoset.player.is_chance # chance: outside make_infoset's - # domain; semantics untested (D5) - or len(resolved_node.infoset.members) == 1 # documented no-op, label retained + resolved_node.is_terminal + or resolved_node.infoset.player.is_chance + or len(resolved_node.infoset.members) == 1 ): self.game.deref().LeaveInfoset(resolved_node.node) return @@ -2096,30 +2110,43 @@ class Game: def set_infoset(self, node: Node | str, infoset: Infoset | str) -> None: - """Place `node` in the information set `infoset`. `node` must have the same - number of descendants as `infoset` has actions. + """Place `node` in the information set `infoset`. + + `node` must be a decision node with the same action labels as `infoset` in the same order + If `node` already belongs to `infoset`, this is a no-op. + + .. versionchanged:: 17.0.0 + Now implemented via `make_infoset`. Two new requirements are now enforced: + - `node` must have the same actions as `infoset`: the same labels in the same order + (previously only the number of actions was checked); + - `node` must be a personal decision node. + Setting the information set of a terminal node or a chance node now raises. Parameters ---------- node : Node or str - The node to set the information set + The node to place in the information set. infoset : Infoset or str - The information set to join + The information set to join. Raises ------ MismatchError - If `node` is a `Node` from a different game, or `infoset` is an `Infoset` from - a different game. - .. versionchanged:: 17.0.0 - Now implemented via `make_infoset`. The node must have the same - actions as `infoset`, with the same labels in the same order; - previously only the number of actions was checked. + If `node` or `infoset` is from a different game. + KeyError + If `node` or `infoset` is a string matching no node or information + set in the game. + TypeError + If `node` or `infoset` is not an accepted type. + UndefinedOperationError + If `node` is a terminal node or a chance node. + ValueError + If `node`'s actions do not match `infoset`'s, with the same labels in the same order. """ resolved_node = cython.cast(Node, self._resolve_node(node, "set_infoset")) resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, "set_infoset")) if resolved_node.infoset == resolved_infoset: - return # preserve documented no-op + return self.make_infoset(list(resolved_infoset.members) + [resolved_node], resolved_infoset.player.label, resolved_infoset.label or None) diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 70c742eae..453da9de1 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -314,3 +314,66 @@ def test_make_infoset_across_different_source_players(): assert n2.infoset == n3.infoset assert n2.infoset.player == game.players["1"] assert n3.infoset.player == game.players["1"] + + +def test_set_infoset_joins_node_to_infoset(): + """A node joins the target infoset, adopting its player and label.""" + game = games.read_from_file("sample_extensive_game.efg") + game.add_player("Player 3") + # Two same-shape nodes under different players; put one into the other's infoset. + a = game.root.children["1"].children["1"] + game.append_move(a, "Player 3", ["x", "y"]) + b = game.root.children["2"].children["1"] + game.append_move(b, "Player 3", ["x", "y"]) + a.infoset.label = "target" + game.set_infoset(b, a.infoset) + assert b.infoset == a.infoset + assert b.infoset.label == "target" + assert b.infoset.player == game.players["Player 3"] + + +@pytest.mark.parametrize("node_actions", [["c", "d"], ["b", "a"]]) +def test_set_infoset_requires_matching_action_labels(node_actions): + """`node`'s actions must match `infoset`'s in label and order; a matching + count is no longer sufficient (was the only check before 17.0).""" + game = gbt.Game.new_tree(players=["1"]) + game.append_move(game.root, "1", ["a", "b"]) + game.append_move(game.root.children["a"], "1", node_actions) + with pytest.raises(ValueError): + game.set_infoset(game.root.children["a"], game.root.infoset) + + +def test_set_infoset_terminal_node_raises(): + """Setting the infoset of a terminal node now raises (was a silent no-op before 17.0).""" + game = games.read_from_file("basic_extensive_game.efg") + terminal = game.root.children["U1"].children["U2"].children["U3"] + with pytest.raises(gbt.UndefinedOperationError): + game.set_infoset(terminal, game.root.infoset) + + +def test_set_infoset_chance_node_raises(): + """A chance node cannot be placed in a personal infoset (newly enforced in 17.0).""" + game = games.read_from_file("stripped_down_poker.efg") + chance_node = game.root # the deal is a chance move + personal = next(n for n in game.nodes if not n.is_terminal and not n.infoset.is_chance) + with pytest.raises(gbt.UndefinedOperationError): + game.set_infoset(chance_node, personal.infoset) + + +def test_set_infoset_already_member_is_noop(): + """Placing a node in the infoset it already belongs to is a no-op.""" + game = games.read_from_file("sample_extensive_game.efg") + node = game.root.children["1"] + node.infoset.label = "keep" + members = list(node.infoset.members) + game.set_infoset(node, node.infoset) + assert node.infoset.label == "keep" + assert list(node.infoset.members) == members + + +def test_set_infoset_mismatch_raises(): + """`node` and `infoset` must belong to this game.""" + game1 = games.read_from_file("basic_extensive_game.efg") + game2 = games.read_from_file("basic_extensive_game.efg") + with pytest.raises(gbt.MismatchError): + game1.set_infoset(game1.root, game2.root.infoset) diff --git a/tests/test_node.py b/tests/test_node.py index 3b2ec5c61..bc9683bd0 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -601,6 +601,30 @@ def test_node_leave_infoset(): assert list(proxy.members) == [node] +def test_leave_infoset_sole_member_is_noop(): + """Leaving a sinleton infoset is a no-op: infoset identity and label retained.""" + game = games.read_from_file("basic_extensive_game.efg") + game.root.infoset.label = "solo" # root is its infoset's only member + iset = game.root.infoset + game.leave_infoset(game.root) + assert game.root.infoset == iset + assert game.root.infoset.label == "solo" + + +def test_leave_infoset_from_shared_infoset_relabels(): + """Leaving a multi-node infoset places the node in a new unlabeled singleton, + while the original infoset keeps its label with the members left behind.""" + game = games.read_from_file("basic_extensive_game.efg") + node = game.root.children["U1"] + sibling = game.root.children["D1"] + node.infoset.label = "multi" + assert len(node.infoset.members) == 2 # precondition: a multi-node infoset + game.leave_infoset(node) + assert node.infoset.label == "" # departing node: fresh unlabeled singleton + assert sibling.infoset.label == "multi" # label stays with the rump + assert list(sibling.infoset.members) == [sibling] + + def test_node_infoset_becomes_null_when_truncated(): """A captured infoset proxy re-resolves to null after the node is truncated to a leaf.""" game = games.read_from_file("basic_extensive_game.efg") From 195a60fdc9f7d0fc080d7971fcf147dea900562c Mon Sep 17 00:00:00 2001 From: drdkad Date: Tue, 21 Jul 2026 13:59:53 +0100 Subject: [PATCH 4/9] reveal and tests --- src/pygambit/game.pxi | 8 ++-- tests/games.py | 18 +++++++++ tests/test_infosets.py | 91 ++++++++++++++++++++++++++++++++++++++++++ tests/test_node.py | 4 +- 4 files changed, 114 insertions(+), 7 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 3344c65c4..95060e96a 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -2163,6 +2163,9 @@ class Game: Revelation is a one-shot operation; it is not enforced with respect to any revisions made to the game tree subsequently. + .. versionchanged:: 17.0.0 + Now implemented via `make_infoset`. + Parameters ---------- infoset : Infoset or str @@ -2175,11 +2178,6 @@ class Game: MismatchError If `infoset` is an `Infoset` from a different game, or `player` is a `Player` from a different game. - - .. versionchanged:: 17.0.0 - Now implemented via `make_infoset`. Revealing the move at an - absent-minded information set is no longer permitted. - UndefinedOperationError If `infoset` is absent-minded. """ diff --git a/tests/games.py b/tests/games.py index 9e73e32d2..138bae2fd 100644 --- a/tests/games.py +++ b/tests/games.py @@ -415,6 +415,24 @@ def create_one_shot_trust_efg(unique_NE_variant: bool = False) -> gbt.Game: return g +def create_two_pair_infosets_efg() -> gbt.Game: + """Four decision nodes of player "1" in two labeled pair infosets. + + Chance flips L/R; "1" moves at both depth-1 nodes (infoset "X") and again + after action "a" at each (infoset "Y"); all four nodes have actions ["a","b"]. + With A,B the depth-1 nodes and C,D the depth-2 nodes, make_infoset([B,C],...) + leaves A and D as singleton rumps retaining "X" and "Y". + """ + g = gbt.Game.new_tree(players=["1", "2"], title="Two pair infosets") + g.append_move(g.root, g.players.chance, ["L", "R"]) + g.append_move(list(g.root.children), "1", ["a", "b"]) + g.root.children["L"].infoset.label = "X" + g.append_move([g.root.children["L"].children["a"], + g.root.children["R"].children["a"]], "1", ["a", "b"]) + g.root.children["L"].children["a"].infoset.label = "Y" + return g + + def create_EFG_for_nxn_bimatrix_coordination_game(n: int) -> gbt.Game: A = np.eye(n, dtype=int) B = A diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 453da9de1..0e179d437 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -300,6 +300,64 @@ def test_infoset_is_absent_minded(test_case: AbsentMindednessTestCase): assert actual_infosets == expected_infosets +def _two_pairs(game): + return (game.root.children["L"], + game.root.children["R"], + game.root.children["L"].children["a"], + game.root.children["R"].children["a"]) + + +def test_make_infoset_cherry_pick_leaves_rumps(): + """Partial consumption leaves the remainders behind, labels retained.""" + game = games.create_two_pair_infosets_efg() + A, B, C, D = _two_pairs(game) + game.make_infoset([B, C], "1") + assert B.infoset == C.infoset + assert list(A.infoset.members) == [A] + assert list(D.infoset.members) == [D] + assert A.infoset.label == "X" + assert D.infoset.label == "Y" + + +def test_make_infoset_label_held_by_rump_raises(): + """Reusing a label whose infoset is only partly consumed is rejected.""" + game = games.create_two_pair_infosets_efg() + A, B, C, D = _two_pairs(game) + with pytest.raises(ValueError): + game.make_infoset([B, C], "1", "X") # A remains in "X" + + +def test_make_infoset_failure_leaves_game_unchanged(): + """A rejected call must not modify the partition.""" + game = games.create_two_pair_infosets_efg() + A, B, C, D = _two_pairs(game) + with pytest.raises(ValueError): + game.make_infoset([B, C], "1", "X") + assert A.infoset == B.infoset + assert C.infoset == D.infoset + assert A.infoset.label == "X" + assert C.infoset.label == "Y" + + +def test_make_infoset_idempotent(): + """Repeating a call is a no-op: label reuse permits equality of membership.""" + game = games.create_two_pair_infosets_efg() + A, B, C, D = _two_pairs(game) + game.make_infoset([B, C], "1", "Z") + game.make_infoset([B, C], "1", "Z") + assert B.infoset == C.infoset + assert B.infoset.label == "Z" + + +def test_make_infoset_split_leaves_new_infoset_unlabeled(): + """A node split out gets a fresh unlabeled infoset; the rump keeps the label.""" + game = games.create_two_pair_infosets_efg() + A, B, C, D = _two_pairs(game) + game.make_infoset([A], "1") + assert A.infoset.label == "" + assert B.infoset.label == "X" + + def test_make_infoset_across_different_source_players(): """Nodes drawn from different players all land under the target player.""" game = gbt.Game.new_tree(players=["1", "2", "3"]) @@ -377,3 +435,36 @@ def test_set_infoset_mismatch_raises(): game2 = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.MismatchError): game1.set_infoset(game1.root, game2.root.infoset) + + +def test_reveal_splits_infoset_by_action(): + """Revealing the deal to Bob separates his single infoset into per-card + singletons; the other player's structure is untouched.""" + game = games.create_stripped_down_poker_efg(nonterm_outcomes=True) + n_alice = len(list(game.players["Alice"].infosets)) + assert len(list(game.players["Bob"].infosets)) == 1 + game.reveal(game.root.infoset, "Bob") + bob = list(game.players["Bob"].infosets) + assert len(bob) == 2 + assert all(len(list(i.members)) == 1 for i in bob) + assert len(list(game.players["Alice"].infosets)) == n_alice + + +def test_reveal_absent_minded_infoset_raises(): + """Revealing the move at an absent-minded infoset is rejected (17.0).""" + game = gbt.Game.new_tree(players=["Driver", "2"]) + game.append_move(game.root, "Driver", ["Continue", "Exit"]) + mid = game.root.children["Continue"] + game.append_move(mid, "Driver", ["Continue", "Exit"]) + game.make_infoset([game.root, mid], "Driver") + game.append_move(mid.children["Continue"], "2", ["l", "r"]) + with pytest.raises(gbt.UndefinedOperationError): + game.reveal(game.root.infoset, "2") + + +def test_reveal_mismatch_raises(): + """`infoset` and `player` must belong to this game.""" + game1 = games.read_from_file("stripped_down_poker.efg") + game2 = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(gbt.MismatchError): + game1.reveal(game1.root.infoset, next(iter(game2.players))) diff --git a/tests/test_node.py b/tests/test_node.py index bc9683bd0..58612f988 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -602,10 +602,10 @@ def test_node_leave_infoset(): def test_leave_infoset_sole_member_is_noop(): - """Leaving a sinleton infoset is a no-op: infoset identity and label retained.""" + """Leaving a singleton infoset is a no-op: infoset identity and label retained.""" game = games.read_from_file("basic_extensive_game.efg") game.root.infoset.label = "solo" # root is its infoset's only member - iset = game.root.infoset + iset = game.infosets["solo"] # resolved Infoset, not a node-anchored proxy game.leave_infoset(game.root) assert game.root.infoset == iset assert game.root.infoset.label == "solo" From 4fc8f87e7cb2d55f689235b97b7622853f1a9fd2 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 29 Jul 2026 22:31:03 +0100 Subject: [PATCH 5/9] Remove Game.set_player and Game.sort_infosets --- src/pygambit/game.pxi | 53 +++------------------------------------- src/pygambit/infoset.pxi | 5 ---- src/pygambit/player.pxi | 4 --- 3 files changed, 3 insertions(+), 59 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 95060e96a..2a27dde8c 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -2077,7 +2077,6 @@ class Game: same player; the label, if any, stays with the members left in the rump. .. versionchanged:: 17.0.0 - Now implemented via `make_infoset`. Parameters ---------- @@ -2116,7 +2115,8 @@ class Game: If `node` already belongs to `infoset`, this is a no-op. .. versionchanged:: 17.0.0 - Now implemented via `make_infoset`. Two new requirements are now enforced: + Two new requirements are now enforced: + - `node` must have the same actions as `infoset`: the same labels in the same order (previously only the number of actions was checked); - `node` must be a personal decision node. @@ -2164,7 +2164,7 @@ class Game: revisions made to the game tree subsequently. .. versionchanged:: 17.0.0 - Now implemented via `make_infoset`. + Revealing the move at an absent-minded information set is not permitted. Parameters ---------- @@ -2194,29 +2194,6 @@ class Game: if group: self.make_infoset(group, resolved_player.label) - def sort_infosets(self) -> None: - """Sort information sets into a standard order. - - .. deprecated:: 16.5.0 - This operation is deprecated as efficient management of the iteration orders of - information sets and their members is now handled by the representation objects. - - Raises - ------ - UndefinedOperationError - If the game does not have a tree representation. - """ - warnings.warn( - "sort_infosets() is deprecated; This operation is now done automatically when" - " required. " - "This function will be removed in a future release.", - FutureWarning - ) - if not self.is_tree: - raise UndefinedOperationError( - "Operation only defined for games with a tree representation" - ) - def add_player(self, label: str) -> Player: """Add a new player to the game. @@ -2244,30 +2221,6 @@ class Game: """ return Player.wrap(self.game.deref().NewPlayer(label.encode("ascii"))) - def set_player(self, infoset: Infoset | str, - player: Player | str) -> None: - """Set the player at an information set. - - Parameters - ---------- - infoset : Infoset or str - The information set to assign to the player - player : Player or str - The player to have the move at the information set - - Raises - ------ - MismatchError - If `infoset` is an `Infoset` from another game, or `player` is a - `Player` from another game. - .. versionchanged:: 17.0.0 - """ - resolved_player = cython.cast(Player, self._resolve_player(player, "set_player")) - resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, "set_player")) - self.make_infoset(list(resolved_infoset.members), - resolved_player.label, - resolved_infoset.label or None) - def add_outcome(self, label: str, payoffs: list | None = None) -> Outcome: diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index 412cfac22..8a674fb82 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -235,11 +235,6 @@ class Infoset: The iteration order of information set members is the order in which they are encountered in the pre-order depth first traversal of the game tree. - - .. versionchanged:: 16.5.0 - It is no longer necessary to call `Game.sort_infosets` to standardise - iteration order. - """ return InfosetMembers.wrap(self.infoset) diff --git a/src/pygambit/player.pxi b/src/pygambit/player.pxi index 7cfc4f7de..a932c0f0d 100644 --- a/src/pygambit/player.pxi +++ b/src/pygambit/player.pxi @@ -287,10 +287,6 @@ class Player: The iteration order of information sets is the order in which they are encountered in the pre-order depth first traversal of the game tree. - .. versionchanged:: 16.5.0 - It is no longer necessary to call `Game.sort_infosets` to standardise - iteration order. - Raises ------ UndefinedOperationError From 962664aac0901197ece8ae30fdd0e0d4ea5aaa73 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 29 Jul 2026 22:32:03 +0100 Subject: [PATCH 6/9] Move information-structure tests into test_infosets; use Bagwell for make_infoset tests --- tests/games.py | 18 ------- tests/test_infosets.py | 107 +++++++++++++++++++++++++++++----------- tests/test_node.py | 34 ------------- tests/test_strategic.py | 6 --- 4 files changed, 77 insertions(+), 88 deletions(-) diff --git a/tests/games.py b/tests/games.py index 138bae2fd..9e73e32d2 100644 --- a/tests/games.py +++ b/tests/games.py @@ -415,24 +415,6 @@ def create_one_shot_trust_efg(unique_NE_variant: bool = False) -> gbt.Game: return g -def create_two_pair_infosets_efg() -> gbt.Game: - """Four decision nodes of player "1" in two labeled pair infosets. - - Chance flips L/R; "1" moves at both depth-1 nodes (infoset "X") and again - after action "a" at each (infoset "Y"); all four nodes have actions ["a","b"]. - With A,B the depth-1 nodes and C,D the depth-2 nodes, make_infoset([B,C],...) - leaves A and D as singleton rumps retaining "X" and "Y". - """ - g = gbt.Game.new_tree(players=["1", "2"], title="Two pair infosets") - g.append_move(g.root, g.players.chance, ["L", "R"]) - g.append_move(list(g.root.children), "1", ["a", "b"]) - g.root.children["L"].infoset.label = "X" - g.append_move([g.root.children["L"].children["a"], - g.root.children["R"].children["a"]], "1", ["a", "b"]) - g.root.children["L"].children["a"].infoset.label = "Y" - return g - - def create_EFG_for_nxn_bimatrix_coordination_game(n: int) -> gbt.Game: A = np.eye(n, dtype=int) B = A diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 0e179d437..d86e1a546 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -53,24 +53,24 @@ def test_infoset_node_precedes(): assert game.root.children["U1"].infoset.precedes(game.root.children["U1"]) -def test_infoset_set_player(): - """`set_player` reassigns the player; the label and membership ride along. - """ +def test_make_infoset_change_player_keeps_label(): + """Re-forming an information set under a different player retains an + explicitly specified label and its membership.""" game = games.read_from_file("basic_extensive_game.efg") _, p2, *_ = game.players - game.root.infoset.label = "moved" members = list(game.root.infoset.members) - game.set_player(game.root.infoset, p2) + game.make_infoset(members, p2.label, "moved") assert game.root.infoset.player == p2 assert game.root.infoset.label == "moved" assert list(game.root.infoset.members) == members -def test_infoset_set_player_mismatch(): - game = games.read_from_file("basic_extensive_game.efg") - game2 = gbt.Game.new_tree(["Frank"]) +def test_make_infoset_mismatch_raises(): + """Nodes must belong to this game.""" + game1 = games.read_from_file("basic_extensive_game.efg") + game2 = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.MismatchError): - game.set_player(game.root.infoset, next(iter(game2.players))) + game1.make_infoset(game2.root, "Player 1") def test_infoset_add_action_end(): @@ -300,18 +300,26 @@ def test_infoset_is_absent_minded(test_case: AbsentMindednessTestCase): assert actual_infosets == expected_infosets -def _two_pairs(game): - return (game.root.children["L"], - game.root.children["R"], - game.root.children["L"].children["a"], - game.root.children["R"].children["a"]) +def _bagwell_p2_nodes(game: gbt.Game) -> tuple[gbt.Node, gbt.Node, gbt.Node, gbt.Node]: + """Player 2's four decision nodes in Bagwell (1995). + + Player 2 has two information sets -- one for each signal the chance move + can produce -- each with two members and actions ("S", "C"). Returns + (A, B, C, D) with {A, B} the members of one and {C, D} of the other. + """ + return (game.root.children["S"].children["s"], + game.root.children["C"].children["s"], + game.root.children["S"].children["c"], + game.root.children["C"].children["c"]) def test_make_infoset_cherry_pick_leaves_rumps(): """Partial consumption leaves the remainders behind, labels retained.""" - game = games.create_two_pair_infosets_efg() - A, B, C, D = _two_pairs(game) - game.make_infoset([B, C], "1") + game = gbt.catalog.load("journals/geb/bagwell1995") + A, B, C, D = _bagwell_p2_nodes(game) + A.infoset.label = "X" + C.infoset.label = "Y" + game.make_infoset([B, C], "Player 2") assert B.infoset == C.infoset assert list(A.infoset.members) == [A] assert list(D.infoset.members) == [D] @@ -321,18 +329,21 @@ def test_make_infoset_cherry_pick_leaves_rumps(): def test_make_infoset_label_held_by_rump_raises(): """Reusing a label whose infoset is only partly consumed is rejected.""" - game = games.create_two_pair_infosets_efg() - A, B, C, D = _two_pairs(game) + game = gbt.catalog.load("journals/geb/bagwell1995") + A, B, C, D = _bagwell_p2_nodes(game) + A.infoset.label = "X" with pytest.raises(ValueError): - game.make_infoset([B, C], "1", "X") # A remains in "X" + game.make_infoset([B, C], "Player 2", "X") # A remains in "X" def test_make_infoset_failure_leaves_game_unchanged(): """A rejected call must not modify the partition.""" - game = games.create_two_pair_infosets_efg() - A, B, C, D = _two_pairs(game) + game = gbt.catalog.load("journals/geb/bagwell1995") + A, B, C, D = _bagwell_p2_nodes(game) + A.infoset.label = "X" + C.infoset.label = "Y" with pytest.raises(ValueError): - game.make_infoset([B, C], "1", "X") + game.make_infoset([B, C], "Player 2", "X") assert A.infoset == B.infoset assert C.infoset == D.infoset assert A.infoset.label == "X" @@ -341,19 +352,20 @@ def test_make_infoset_failure_leaves_game_unchanged(): def test_make_infoset_idempotent(): """Repeating a call is a no-op: label reuse permits equality of membership.""" - game = games.create_two_pair_infosets_efg() - A, B, C, D = _two_pairs(game) - game.make_infoset([B, C], "1", "Z") - game.make_infoset([B, C], "1", "Z") + game = gbt.catalog.load("journals/geb/bagwell1995") + A, B, C, D = _bagwell_p2_nodes(game) + game.make_infoset([B, C], "Player 2", "Z") + game.make_infoset([B, C], "Player 2", "Z") assert B.infoset == C.infoset assert B.infoset.label == "Z" def test_make_infoset_split_leaves_new_infoset_unlabeled(): """A node split out gets a fresh unlabeled infoset; the rump keeps the label.""" - game = games.create_two_pair_infosets_efg() - A, B, C, D = _two_pairs(game) - game.make_infoset([A], "1") + game = gbt.catalog.load("journals/geb/bagwell1995") + A, B, C, D = _bagwell_p2_nodes(game) + A.infoset.label = "X" + game.make_infoset([A], "Player 2") assert A.infoset.label == "" assert B.infoset.label == "X" @@ -374,6 +386,41 @@ def test_make_infoset_across_different_source_players(): assert n3.infoset.player == game.players["1"] +def test_infoset_proxy_reresolves_after_split(): + """A node-anchored infoset proxy is lazy: it re-resolves after the node is + placed in a new information set.""" + game = games.read_from_file("basic_extensive_game.efg") + node = game.root.children["U1"] + proxy = node.infoset + assert len(proxy.members) == 2 + game.make_infoset(node, node.player.label) + assert list(proxy.members) == [node] + + +def test_leave_infoset_sole_member_is_noop(): + """Leaving a singleton infoset is a no-op: infoset identity and label retained.""" + game = games.read_from_file("basic_extensive_game.efg") + game.root.infoset.label = "solo" # root is its infoset's only member + iset = game.infosets["solo"] # resolved Infoset, not a node-anchored proxy + game.leave_infoset(game.root) + assert game.root.infoset == iset + assert game.root.infoset.label == "solo" + + +def test_leave_infoset_from_shared_infoset_relabels(): + """Leaving a multi-node infoset places the node in a new unlabeled singleton, + while the original infoset keeps its label with the members left behind.""" + game = games.read_from_file("basic_extensive_game.efg") + node = game.root.children["U1"] + sibling = game.root.children["D1"] + node.infoset.label = "multi" + assert len(node.infoset.members) == 2 # precondition: a multi-node infoset + game.leave_infoset(node) + assert node.infoset.label == "" # departing node: fresh unlabeled singleton + assert sibling.infoset.label == "multi" # label stays with the rump + assert list(sibling.infoset.members) == [sibling] + + def test_set_infoset_joins_node_to_infoset(): """A node joins the target infoset, adopting its player and label.""" game = games.read_from_file("sample_extensive_game.efg") diff --git a/tests/test_node.py b/tests/test_node.py index 58612f988..4f776c3c2 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -591,40 +591,6 @@ def test_insert_move_error_player_mismatch(): game1.insert_move(game1.root, game2.players["Player 1"], 1) -def test_node_leave_infoset(): - """A node-anchored infoset proxy is lazy: it re-resolves after the node leaves its infoset.""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] - proxy = node.infoset - assert len(proxy.members) == 2 - game.leave_infoset(node) - assert list(proxy.members) == [node] - - -def test_leave_infoset_sole_member_is_noop(): - """Leaving a singleton infoset is a no-op: infoset identity and label retained.""" - game = games.read_from_file("basic_extensive_game.efg") - game.root.infoset.label = "solo" # root is its infoset's only member - iset = game.infosets["solo"] # resolved Infoset, not a node-anchored proxy - game.leave_infoset(game.root) - assert game.root.infoset == iset - assert game.root.infoset.label == "solo" - - -def test_leave_infoset_from_shared_infoset_relabels(): - """Leaving a multi-node infoset places the node in a new unlabeled singleton, - while the original infoset keeps its label with the members left behind.""" - game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] - sibling = game.root.children["D1"] - node.infoset.label = "multi" - assert len(node.infoset.members) == 2 # precondition: a multi-node infoset - game.leave_infoset(node) - assert node.infoset.label == "" # departing node: fresh unlabeled singleton - assert sibling.infoset.label == "multi" # label stays with the rump - assert list(sibling.infoset.members) == [sibling] - - def test_node_infoset_becomes_null_when_truncated(): """A captured infoset proxy re-resolves to null after the node is truncated to a leaf.""" game = games.read_from_file("basic_extensive_game.efg") diff --git a/tests/test_strategic.py b/tests/test_strategic.py index fedef6097..a23d026cb 100644 --- a/tests/test_strategic.py +++ b/tests/test_strategic.py @@ -43,12 +43,6 @@ def test_strategic_game_nodes(): _ = game.nodes -def test_strategic_game_sort_infosets(): - game = gbt.Game.new_table([2, 2]) - with pytest.warns(FutureWarning), pytest.raises(gbt.UndefinedOperationError): - _ = game.sort_infosets() - - def test_game_behav_profile_error(): game = gbt.Game.new_table([2, 2]) with pytest.raises(gbt.UndefinedOperationError): From cb78fd2c6dbab2a05ea80a92ddfa05d4e0a4f869 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 30 Jul 2026 09:09:49 +0100 Subject: [PATCH 7/9] Implement make_infoset in C++ and route pygambit through it --- doc/pygambit.api.rst | 2 -- src/games/game.h | 5 +++ src/games/gametree.cc | 73 +++++++++++++++++++++++++++++++++++++++++ src/games/gametree.h | 2 ++ src/pygambit/gambit.pxd | 1 + src/pygambit/game.pxi | 51 ++++++++-------------------- 6 files changed, 94 insertions(+), 40 deletions(-) diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 2c1ac9245..43d4e7f04 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -69,12 +69,10 @@ Transforming game information structure :toctree: api/ Game.make_infoset - Game.set_player Game.set_infoset Game.leave_infoset Game.set_chance_probs Game.reveal - Game.sort_infosets Transforming game components diff --git a/src/games/game.h b/src/games/game.h index bd598b723..c0cc9f2ff 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -1103,6 +1103,11 @@ class GameRep : public std::enable_shared_from_this { virtual void Reveal(GameInfoset, GamePlayer) { throw UndefinedException(); } virtual void SetInfoset(GameNode, GameInfoset) { throw UndefinedException(); } virtual GameInfoset LeaveInfoset(GameNode) { throw UndefinedException(); } + virtual GameInfoset MakeInfoset(const std::vector &, const GamePlayer &, + const std::string &) + { + throw UndefinedException(); + } virtual GameAction InsertAction(GameInfoset, GameAction p_where = nullptr) { throw UndefinedException(); diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 84d6ec0fd..2dea27780 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -552,6 +552,79 @@ Game GameTreeRep::CopySubgame(GameNode p_root) const return ReadGame(is); } +GameInfoset GameTreeRep::MakeInfoset(const std::vector &p_nodes, + const GamePlayer &p_player, const std::string &p_label) +{ + if (p_nodes.empty()) { + throw ValueException("At least one node must be specified"); + } + if (p_player->m_game != this) { + throw MismatchException(); + } + if (p_player->IsChance()) { + throw UndefinedException("The information set must belong to a personal player, not chance"); + } + std::set selected; + for (const auto &node : p_nodes) { + if (node->m_game != this) { + throw MismatchException(); + } + if (node->IsTerminal()) { + throw UndefinedException("All nodes must be decision nodes"); + } + if (node->m_infoset->IsChanceInfoset()) { + throw UndefinedException("All nodes must belong to a personal player, not chance"); + } + if (!selected.insert(node.get()).second) { + throw ValueException("Each node may be referenced only once"); + } + } + // The first member defines the action labels and their order of the new information set. + const auto &reference = p_nodes.front()->m_infoset->m_actions; + for (const auto &node : p_nodes) { + const auto &actions = node->m_infoset->m_actions; + if (!std::equal( + actions.begin(), actions.end(), reference.begin(), reference.end(), + [](const std::shared_ptr &a, const std::shared_ptr &b) { + return a->GetLabel() == b->GetLabel(); + })) { + throw ValueException( + "All nodes must have the same actions, with the same labels in the same order"); + } + } + CheckLabel(p_label); + if (!p_label.empty()) { + for (const auto &infoset : p_player->m_infosets) { + if (infoset->GetLabel() == p_label && + !std::all_of(infoset->m_members.begin(), infoset->m_members.end(), + [&selected](const std::shared_ptr &m) { + return contains(selected, m.get()); + })) { + throw ValueException("Infoset label must be unique for the player"); + } + } + } + + IncrementVersion(); + auto newInfoset = std::make_shared(this, p_player->m_infosets.size() + 1, + p_player.get(), reference.size()); + auto dest = newInfoset->m_actions.begin(); + for (const auto &action : reference) { + (*dest)->SetLabel(action->GetLabel()); + ++dest; + } + p_player->m_infosets.push_back(newInfoset); + for (const auto &node : p_nodes) { + RemoveMember(node->m_infoset, node.get()); + newInfoset->m_members.push_back(node); + node->m_infoset = newInfoset.get(); + } + newInfoset->SetLabel(p_label); + ClearComputedValues(); + InvalidateInfosetOrdering(); + return newInfoset; +} + void GameTreeRep::SetInfoset(GameNode p_node, GameInfoset p_infoset) { if (p_node->m_game != this || p_infoset->m_game != this) { diff --git a/src/games/gametree.h b/src/games/gametree.h index 1b2e15e9e..4191b8fa7 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -187,6 +187,8 @@ class GameTreeRep final : public GameExplicitRep { void DeleteTree(GameNode) override; void SetPlayer(GameInfoset, GamePlayer) override; void Reveal(GameInfoset, GamePlayer) override; + GameInfoset MakeInfoset(const std::vector &, const GamePlayer &, + const std::string &) override; void SetInfoset(GameNode, GameInfoset) override; GameInfoset LeaveInfoset(GameNode) override; Game SetChanceProbs(const GameInfoset &, const Array &) override; diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 71538a7fc..5537100a4 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -364,6 +364,7 @@ cdef extern from "games/game.h": void DeleteTree(c_GameNode) except + void SetPlayer(c_GameInfoset, c_GamePlayer) except + void Reveal(c_GameInfoset, c_GamePlayer) except + + c_GameInfoset MakeInfoset(stdvector[c_GameNode], c_GamePlayer, string) except +ValueError void SetInfoset(c_GameNode, c_GameInfoset) except +ValueError c_GameInfoset LeaveInfoset(c_GameNode) except + c_GameAction InsertAction(c_GameInfoset, c_GameAction) except +ValueError diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 2a27dde8c..2e4a635d4 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -2005,14 +2005,17 @@ class Game: Raises ------ MismatchError - If any of `nodes` is not a member of this game. + If any of `nodes`, or `player`, is from a different game. + KeyError + If any of `nodes`, or `player`, is a label matching no such object in the game. + TypeError + If any of `nodes`, or `player`, is not of an accepted type. UndefinedOperationError If any of `nodes` is a terminal node or a chance node, or if `player` is the chance player, or if the game is not a tree. ValueError - If `nodes` is empty or contains a repeated node; if `player` is not - the label of a player in this game; if the nodes do not all have the - same actions in the same order; or if `label` is not unique among + If `nodes` is empty or contains a repeated node; if the nodes do not all + have the same actions in the same order; or if `label` is not unique among `player`'s information sets after the operation. """ if not self.is_tree: @@ -2023,7 +2026,7 @@ class Game: resolved_player = cython.cast(Player, self._resolve_player(player, "make_infoset")) if resolved_player.is_chance: raise UndefinedOperationError( - "make_infoset(): `player` must not be the chance player" + "make_infoset(): `player` must be a personal player" ) for n in resolved_nodes: if n.is_terminal: @@ -2034,39 +2037,11 @@ class Game: raise UndefinedOperationError( "make_infoset(): all nodes must be personal player nodes, not chance" ) - first_node = cython.cast(Node, resolved_nodes[0]) # mint from the first node - reference_action_list = [a.label for a in first_node.infoset.actions] - for n in resolved_nodes[1:]: - if [a.label for a in n.infoset.actions] != reference_action_list: - raise ValueError( - "make_infoset(): all nodes must have the same actions, " - "with the same labels in the same order" - ) - if label is not None: - selected = set(resolved_nodes) - for iset in resolved_player.infosets: - if iset.label == label and not set(iset.members) <= selected: - raise ValueError( - f"make_infoset(): label '{label}' is not unique among the " - "information sets of the player after the operation" - ) - # (1) LeaveInfoset mints a fresh singleton infoset holding first_node; - # a native C++ MakeInfoset (step 3 of the plan) would mint directly. - c_iset: c_GameInfoset = self.game.deref().LeaveInfoset(first_node.node) - - # (2) player is unchanged by LeaveInfoset; reassign if it differs from the target. - if first_node.infoset.player != resolved_player: - self.game.deref().SetPlayer(c_iset, resolved_player.player) - - # (3) join the rest into the minted infoset; departed members leave - # their old infosets, which C++ deletes once emptied. - for n in resolved_nodes[1:]: - self.game.deref().SetInfoset(cython.cast(Node, n).node, c_iset) - - # (4) label last: SetLabel now sees the final player and membership, so - # its per-player uniqueness check agrees with the Python pre-check. - # `label or ""` clears any label inherited via the sole-member LeaveInfoset no-op. - c_iset.deref().SetLabel((label or "").encode("ascii")) + c_nodes = stdvector[c_GameNode]() + for n in resolved_nodes: + c_nodes.push_back(cython.cast(Node, n).node) + self.game.deref().MakeInfoset(c_nodes, resolved_player.player, + (label or "").encode("ascii")) def leave_infoset(self, node: Node | str): """Remove `node` from its information set, placing it in a new singleton. From d0bfc8f645d27fe30bd8b215bc2506efcb37da98 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 30 Jul 2026 09:09:59 +0100 Subject: [PATCH 8/9] Update ChangeLog --- ChangeLog | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/ChangeLog b/ChangeLog index 93e8b8be2..7af1833e5 100644 --- a/ChangeLog +++ b/ChangeLog @@ -7,20 +7,25 @@ the run. (#998) - Implemented bespoke XML parser that handles the subset in the de-facto legacy XML workbook .gbt format; removes dependency on tinyxml. (#897) -- `Game.leave_infoset`, `Game.set_infoset`, `Game.set_player`, and `Game.reveal` are now - implemented in terms of `Game.make_infoset`. `set_infoset` now requires the node to have - the same actions as the target information set, with the same labels in the same order - (previously only the number of actions was checked). `reveal` now raises at - absent-minded information sets. +- `Game.set_infoset` now requires the node to have the same actions as the target information set, with + the same labels in the same order; previously only the number of actions was checked. (#999) +- `Game.set_infoset` now raises when called on a terminal node or a chance node; previously a terminal + node was silently ignored. (#999) +- `Game.reveal` now raises at absent-minded information sets; previously the result was undefined. (#999) ### Added - Added `Game.make_infoset`, forming a collection of personal decision nodes into a single - information set; the primitive operation for editing information structures. + information set; the primitive operation for editing information structures. (#999) ### Fixed - Converting a behavior profile to a strategy profile, and computing pure-strategy payoffs no longer inserts spurious entries into a strategy's internal action map. +### Removed +- `Game.set_player` has been removed; use `Game.make_infoset`, specifying the members of the + information set and the new player. (#999) +- `Game.sort_infosets` has been removed; it was deprecated in 16.5.0, as the iteration order of + information sets and their members is now maintained automatically. (#999) ## [16.7.0] - 2026-07-11 From 844bef8e19c6990d9eabd2aadd4f4b0e43109471 Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 30 Jul 2026 10:39:26 +0100 Subject: [PATCH 9/9] Make SetLabel and MakeInfoset share GamePlayerRep::CheckInfosetLabel, each passing the information sets to disregard --- src/games/game.h | 32 ++++++++++++++++++++++---------- src/games/gametree.cc | 14 ++++++++------ 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/games/game.h b/src/games/game.h index c0cc9f2ff..4789a673c 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -476,6 +476,11 @@ class GamePlayerRep : public std::enable_shared_from_this { GameInfoset GetInfoset(int p_index) const; /// Returns the information sets for the player Infosets GetInfosets() const; + /// Validate that p_label is a valid label for an information set of this player, + /// disregarding any information sets in p_ignore. + void CheckInfosetLabel(const std::string &p_label, + const std::set &p_ignore) const; + //@} /// @name Strategies //@{ @@ -1343,6 +1348,22 @@ inline void GamePlayerRep::CheckStrategyLabel(const std::string &p_label) const } } +inline void +GamePlayerRep::CheckInfosetLabel(const std::string &p_label, + const std::set &p_ignore) const +{ + CheckLabel(p_label); + // Infoset labels may be empty; a nonempty label must be unique among the infosets of the player. + if (p_label.empty()) { + return; + } + for (const auto &infoset : m_infosets) { + if (p_ignore.count(infoset.get()) == 0 && infoset->GetLabel() == p_label) { + throw ValueException("Infoset label must be unique for the player"); + } + } +} + inline Game GameSequenceRep::GetGame() const { return m_player->GetGame(); } inline GamePlayer GameSequenceRep::GetPlayer() const { return m_player->shared_from_this(); } @@ -1355,16 +1376,7 @@ inline void GameInfosetRep::SetLabel(const std::string &p_label) if (p_label == m_label) { return; } - CheckLabel(p_label); - // Infoset labels may be empty, but a non-empty label must be unique among - // the infosets of the same player. - if (!p_label.empty()) { - for (const auto &infoset : GetPlayer()->GetInfosets()) { - if (infoset.get() != this && infoset->GetLabel() == p_label) { - throw ValueException("Infoset label must be unique for the player"); - } - } - } + m_player->CheckInfosetLabel(p_label, {this}); m_label = p_label; } inline void GameRep::CheckPlayerLabel(const std::string &p_label) const diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 2dea27780..9a4a9acd7 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -592,18 +592,20 @@ GameInfoset GameTreeRep::MakeInfoset(const std::vector &p_nodes, "All nodes must have the same actions, with the same labels in the same order"); } } - CheckLabel(p_label); + // Destroy an infoset all of whose members are absorbed; the label it holds can then be reused. + std::set absorbed; if (!p_label.empty()) { for (const auto &infoset : p_player->m_infosets) { if (infoset->GetLabel() == p_label && - !std::all_of(infoset->m_members.begin(), infoset->m_members.end(), - [&selected](const std::shared_ptr &m) { - return contains(selected, m.get()); - })) { - throw ValueException("Infoset label must be unique for the player"); + std::all_of(infoset->m_members.begin(), infoset->m_members.end(), + [&selected](const std::shared_ptr &m) { + return contains(selected, m.get()); + })) { + absorbed.insert(infoset.get()); } } } + p_player->CheckInfosetLabel(p_label, absorbed); IncrementVersion(); auto newInfoset = std::make_shared(this, p_player->m_infosets.size() + 1,