diff --git a/ChangeLog b/ChangeLog index 19e30ae56..7af1833e5 100644 --- a/ChangeLog +++ b/ChangeLog @@ -7,11 +7,26 @@ 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.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. (#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 ### Added diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index e475a689a..43d4e7f04 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -68,12 +68,11 @@ Transforming game information structure .. autosummary:: :toctree: api/ - Game.set_player + Game.make_infoset 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..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 //@{ @@ -1103,6 +1108,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(); @@ -1338,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(); } @@ -1350,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 84d6ec0fd..9a4a9acd7 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -552,6 +552,81 @@ 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"); + } + } + // 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()); + })) { + absorbed.insert(infoset.get()); + } + } + } + p_player->CheckInfosetLabel(p_label, absorbed); + + 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 f95d47aba..2e4a635d4 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1972,44 +1972,159 @@ 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`, 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 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 be a personal 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" + ) + 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 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 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")) - self.game.deref().LeaveInfoset(resolved_node.node) + if ( + 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 + self.make_infoset([resolved_node], + resolved_node.infoset.player.label, + None) 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 + 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. + 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 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 + self.make_infoset(list(resolved_infoset.members) + [resolved_node], + resolved_infoset.player.label, + resolved_infoset.label or None) def reveal(self, infoset: Infoset | str, @@ -2023,6 +2138,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 + Revealing the move at an absent-minded information set is not permitted. + Parameters ---------- infoset : Infoset or str @@ -2035,33 +2153,21 @@ class Game: MismatchError If `infoset` is an `Infoset` from a different game, or `player` is a `Player` from a different game. + 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) - - 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: + if resolved_infoset.is_absent_minded: raise UndefinedOperationError( - "Operation only defined for games with a tree representation" + "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 add_player(self, label: str) -> Player: """Add a new player to the game. @@ -2090,27 +2196,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. - """ - 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) - 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 diff --git a/tests/test_infosets.py b/tests/test_infosets.py index fb4ff58fe..d86e1a546 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -53,18 +53,24 @@ def test_infoset_node_precedes(): assert game.root.children["U1"].infoset.precedes(game.root.children["U1"]) -def test_infoset_set_player(): +def test_make_infoset_change_player_keeps_label(): + """Re-forming an information set under a different player retains an + explicitly specified label and its membership.""" game = games.read_from_file("basic_extensive_game.efg") _, p2, *_ = game.players - game.set_player(game.root.infoset, p2) + members = list(game.root.infoset.members) + 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(): @@ -292,3 +298,220 @@ 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 _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 = 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] + 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 = 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], "Player 2", "X") # A remains in "X" + + +def test_make_infoset_failure_leaves_game_unchanged(): + """A rejected call must not modify the partition.""" + game = gbt.catalog.load("journals/geb/bagwell1995") + A, B, C, D = _bagwell_p2_nodes(game) + A.infoset.label = "X" + C.infoset.label = "Y" + with pytest.raises(ValueError): + game.make_infoset([B, C], "Player 2", "X") + assert A.infoset == B.infoset + assert C.infoset == D.infoset + assert A.infoset.label == "X" + assert C.infoset.label == "Y" + + +def test_make_infoset_idempotent(): + """Repeating a call is a no-op: label reuse permits equality of membership.""" + game = gbt.catalog.load("journals/geb/bagwell1995") + A, B, C, D = _bagwell_p2_nodes(game) + game.make_infoset([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 = 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" + + +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"] + + +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") + 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) + + +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 3b2ec5c61..4f776c3c2 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -591,16 +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_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):