diff --git a/src/games/gametree.cc b/src/games/gametree.cc index dae51d55c..ca8ecb3e4 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -230,6 +230,8 @@ GameAction GameTreeInfosetRep::InsertAction(GameAction p_action /* =0 */) new GameTreeNodeRep(m_efg, member)); } + m_efg->m_numNodes += m_members.size(); + m_efg->ClearComputedValues(); m_efg->Canonicalize(); return action; @@ -444,6 +446,9 @@ void GameTreeNodeRep::DeleteTree() m_children.front()->Invalidate(); erase_atindex(m_children, 1); } + + m_efg->m_numNodes--; + if (m_infoset) { m_infoset->RemoveMember(this); m_infoset = nullptr; @@ -600,9 +605,11 @@ GameInfoset GameTreeNodeRep::AppendMove(GameInfoset p_infoset) m_efg->IncrementVersion(); m_infoset = dynamic_cast(p_infoset.operator->()); m_infoset->AddMember(this); - std::for_each( - m_infoset->m_actions.begin(), m_infoset->m_actions.end(), - [this](const GameActionRep *) { m_children.push_back(new GameTreeNodeRep(m_efg, this)); }); + std::for_each(m_infoset->m_actions.begin(), m_infoset->m_actions.end(), + [this](const GameActionRep *) { + m_children.push_back(new GameTreeNodeRep(m_efg, this)); + m_efg->m_numNodes++; + }); m_efg->ClearComputedValues(); m_efg->Canonicalize(); return m_infoset; @@ -647,6 +654,9 @@ GameInfoset GameTreeNodeRep::InsertMove(GameInfoset p_infoset) newNode->m_children.push_back(new GameTreeNodeRep(m_efg, newNode)); }); + // Total nodes added = 1 (newNode) + (NumActions - 1) (new children of newNode) = NumActions + m_efg->m_numNodes += newNode->m_infoset->m_actions.size(); + m_efg->ClearComputedValues(); m_efg->Canonicalize(); return p_infoset; @@ -1015,23 +1025,6 @@ void GameTreeRep::DeleteOutcome(const GameOutcome &p_outcome) ClearComputedValues(); } -//------------------------------------------------------------------------ -// GameTreeRep: Nodes -//------------------------------------------------------------------------ - -namespace { -size_t CountNodes(GameNode p_node) -{ - size_t num = 1; - for (size_t i = 1; i <= p_node->NumChildren(); num += CountNodes(p_node->GetChild(i++))) - ; - return num; -} - -} // end anonymous namespace - -size_t GameTreeRep::NumNodes() const { return CountNodes(m_root); } - //------------------------------------------------------------------------ // GameTreeRep: Modification //------------------------------------------------------------------------ diff --git a/src/games/gametree.h b/src/games/gametree.h index 9441d5c7d..80e311860 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -213,6 +213,7 @@ class GameTreeRep : public GameExplicitRep { mutable bool m_computedValues{false}, m_doCanon{true}; GameTreeNodeRep *m_root; GamePlayerRep *m_chance; + std::size_t m_numNodes = 1; /// @name Private auxiliary functions //@{ @@ -265,7 +266,7 @@ class GameTreeRep : public GameExplicitRep { /// Returns the root node of the game GameNode GetRoot() const override { return m_root; } /// Returns the number of nodes in the game - size_t NumNodes() const override; + size_t NumNodes() const override { return m_numNodes; } //@} void DeleteOutcome(const GameOutcome &) override; diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 9fe2e1943..166701d83 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -160,6 +160,40 @@ def read_agg(filepath_or_buffer: typing.Union[str, pathlib.Path, io.IOBase]) -> return read_game(filepath_or_buffer, parser=ParseAggGame) +@cython.cclass +class GameNodes: + """Represents the set of nodes in a game.""" + game = cython.declare(c_Game) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create GameNodes outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(game: c_Game) -> GameNodes: + obj: GameNodes = GameNodes.__new__(GameNodes) + obj.game = game + return obj + + def __repr__(self) -> str: + return f"GameNodes(game={Game.wrap(self.game)})" + + def __len__(self) -> int: + """The number of nodes in the game.""" + if not self.game.deref().IsTree(): + return 0 + return self.game.deref().NumNodes() + + def __iter__(self) -> typing.Iterator[Node]: + def dfs(node): + yield node + for child in node.children: + yield from dfs(child) + if not self.game.deref().IsTree(): + return + yield from dfs(Node.wrap(self.game.deref().GetRoot())) + + @cython.cclass class GameOutcomes: """Represents the set of outcomes in a game.""" @@ -667,6 +701,18 @@ class Game: """The set of outcomes in the game.""" return GameOutcomes.wrap(self.game) + @property + def nodes(self) -> GameNodes: + """The set of nodes in the game. + + Iteration over this property yields the nodes in the order of depth-first search. + + .. versionchanged:: 16.4 + Changed from a method ``nodes()`` to a property. Access as + ``game.nodes`` instead of ``game.nodes()``. + """ + return GameNodes.wrap(self.game) + @property def contingencies(self) -> pygambit.gameiter.Contingencies: """An iterator over the contingencies in the game.""" @@ -1062,37 +1108,6 @@ class Game: raise ValueError("attempted to remove the last strategy for player") return profile - def nodes( - self, - subtree: typing.Optional[typing.Union[Node, str]] = None - ) -> typing.List[Node]: - """Return a list of nodes in the game tree. If `subtree` is not None, returns - the nodes in the subtree rooted at that node. - - Nodes are returned in prefix-traversal order: a node appears prior to the list of - nodes in the subtrees rooted at the node's children. - - Parameters - ---------- - subtree : Node or str, optional - If specified, return only the nodes in the subtree rooted at `subtree`. - - Raises - ------ - MismatchError - If `node` is a `Node` from a different game. - """ - if not self.is_tree: - return [] - if subtree: - resolved_node = cython.cast(Node, self._resolve_node(subtree, "nodes", "subtree")) - else: - resolved_node = self.root - return ( - [resolved_node] + - [n for child in resolved_node.children for n in self.nodes(child)] - ) - @cython.cfunc def _to_format( self, @@ -1369,7 +1384,7 @@ class Game: raise ValueError( f"{funcname}(): {argname} cannot be an empty string or all spaces" ) - for n in self.nodes(): + for n in self.nodes: if n.label == node: return n raise KeyError(f"{funcname}(): no node with label '{node}'") diff --git a/tests/test_behav.py b/tests/test_behav.py index 6636cf04c..fd7e93e85 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -641,7 +641,7 @@ def test_realiz_prob_nodes_reference(game: gbt.Game, node_idx: int, realiz_prob: typing.Union[str, float], rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) realiz_prob = (gbt.Rational(realiz_prob) if rational_flag else realiz_prob) - node = game.nodes()[node_idx] + node = list(game.nodes)[node_idx] assert profile.realiz_prob(node) == realiz_prob @@ -890,7 +890,7 @@ def test_martingale_property_of_node_value(game: gbt.Game, rational_flag: bool): realization probabilities of those children """ profile = game.mixed_behavior_profile(rational=rational_flag) - for node in game.nodes(): + for node in game.nodes: if node.is_terminal or node.player.is_chance: continue expected_val = 0 @@ -1080,23 +1080,23 @@ def _get_and_check_answers(game: gbt.Game, action_probs1: tuple, action_probs2: ###################################################################################### # belief (at nodes) (games.create_mixed_behav_game_efg(), PROBS_1A_doub, PROBS_2A_doub, False, - lambda x, y: x.belief(y), lambda x: x.nodes()), + lambda x, y: x.belief(y), lambda x: x.nodes), (games.create_mixed_behav_game_efg(), PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.belief(y), lambda x: x.nodes()), + lambda x, y: x.belief(y), lambda x: x.nodes), (games.create_myerson_2_card_poker_efg(), PROBS_1B_doub, PROBS_2B_doub, False, - lambda x, y: x.belief(y), lambda x: x.nodes()), + lambda x, y: x.belief(y), lambda x: x.nodes), (games.create_myerson_2_card_poker_efg(), PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.belief(y), lambda x: x.nodes()), + lambda x, y: x.belief(y), lambda x: x.nodes), ###################################################################################### # realiz_prob (at nodes) (games.create_mixed_behav_game_efg(), PROBS_1A_doub, PROBS_2A_doub, False, - lambda x, y: x.realiz_prob(y), lambda x: x.nodes()), + lambda x, y: x.realiz_prob(y), lambda x: x.nodes), (games.create_mixed_behav_game_efg(), PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.realiz_prob(y), lambda x: x.nodes()), + lambda x, y: x.realiz_prob(y), lambda x: x.nodes), (games.create_myerson_2_card_poker_efg(), PROBS_1B_doub, PROBS_2B_doub, False, - lambda x, y: x.realiz_prob(y), lambda x: x.nodes()), + lambda x, y: x.realiz_prob(y), lambda x: x.nodes), (games.create_myerson_2_card_poker_efg(), PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.realiz_prob(y), lambda x: x.nodes()), + lambda x, y: x.realiz_prob(y), lambda x: x.nodes), ###################################################################################### # infoset_prob (games.create_mixed_behav_game_efg(), PROBS_1A_doub, PROBS_2A_doub, False, @@ -1141,16 +1141,16 @@ def _get_and_check_answers(game: gbt.Game, action_probs1: tuple, action_probs2: # node_value (games.create_mixed_behav_game_efg(), PROBS_1A_doub, PROBS_2A_doub, False, lambda x, y: x.node_value(player=y[0], node=y[1]), - lambda x: list(product(x.players, x.nodes()))), + lambda x: list(product(x.players, x.nodes))), (games.create_mixed_behav_game_efg(), PROBS_1A_rat, PROBS_2A_rat, True, lambda x, y: x.node_value(player=y[0], node=y[1]), - lambda x: list(product(x.players, x.nodes()))), + lambda x: list(product(x.players, x.nodes))), (games.create_myerson_2_card_poker_efg(), PROBS_1B_doub, PROBS_2B_doub, False, lambda x, y: x.node_value(player=y[0], node=y[1]), - lambda x: list(product(x.players, x.nodes()))), + lambda x: list(product(x.players, x.nodes))), (games.create_myerson_2_card_poker_efg(), PROBS_1A_rat, PROBS_2A_rat, True, lambda x, y: x.node_value(player=y[0], node=y[1]), - lambda x: list(product(x.players, x.nodes()))), + lambda x: list(product(x.players, x.nodes))), ###################################################################################### # liap_value (of profile, hence [1] for objects_to_test, any singleton collection would do) (games.create_mixed_behav_game_efg(), PROBS_1A_doub, PROBS_2A_doub, False, diff --git a/tests/test_extensive.py b/tests/test_extensive.py index 9fe517f8f..acde88e93 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -55,11 +55,6 @@ def test_game_add_players_nolabel(): game.add_player() -def test_game_num_nodes(): - game = games.read_from_file("basic_extensive_game.efg") - assert len(game.nodes()) == 15 - - def test_game_is_perfect_recall(): game = games.read_from_file("perfect_recall.efg") assert game.is_perfect_recall diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index e18c76794..8b7822151 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -106,7 +106,7 @@ def test_resolve_strategy_invalid( ] ) def test_resolve_node(game: gbt.Game) -> None: - _test_valid_resolutions(game.nodes(), + _test_valid_resolutions(game.nodes, lambda label, fn: game._resolve_node(label, fn)) diff --git a/tests/test_node.py b/tests/test_node.py index 6bd1029c7..6d26e9d07 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -200,8 +200,9 @@ def _subtrees_equal( def test_copy_tree_onto_nondescendent_terminal_node(): """Test copying a subtree to a non-descendent node.""" g = games.read_from_file("e01.efg") - src_node = g.nodes()[3] # path=[1, 0] - dest_node = g.nodes()[2] # path=[0, 0] + list_nodes = list(g.nodes) + src_node = list_nodes[3] # path=[1, 0] + dest_node = list_nodes[2] # path=[0, 0] g.copy_tree(src_node, dest_node) @@ -211,8 +212,9 @@ def test_copy_tree_onto_nondescendent_terminal_node(): def test_copy_tree_onto_descendent_terminal_node(): """Test copying a subtree to a node that's a descendent of the original.""" g = games.read_from_file("e01.efg") - src_node = g.nodes()[1] # path=[0] - dest_node = g.nodes()[4] # path=[0, 1, 0] + list_nodes = list(g.nodes) + src_node = list_nodes[1] # path=[0] + dest_node = list_nodes[4] # path=[0, 1, 0] g.copy_tree(src_node, dest_node) @@ -404,3 +406,184 @@ def test_append_infoset_node_list_is_empty(): game.append_move(game.root.children[0].children[0], "Player 3", ["B", "F"]) with pytest.raises(ValueError): game.append_infoset([], game.root.children[0].children[0].infoset) + + +def _get_members(action: gbt.Action) -> set[gbt.Node]: + """Calculates the set of nodes resulting from taking a specific action + at all nodes within its information set. + """ + infoset = action.infoset + action_index = action.number + + return [member_node.children[action_index] for member_node in infoset.members] + + +def _count_subtree_nodes(start_node: gbt.Node) -> int: + """Counts nodes in the subtree rooted at start_node (including start_node).""" + count = 1 + for child in start_node.children: + count += _count_subtree_nodes(child) + return count + + +def test_len_matches_sum_children_plus_one(): + """Verify `len(game.nodes)` matches (sum of children counts + 1) + """ + game = games.read_from_file("e01.efg") + expected_node_count = 9 + + direct_len = len(game.nodes) + assert direct_len == expected_node_count + + assert direct_len == _count_subtree_nodes(game.root) + + +def test_len_after_delete_tree(): + """Verify `len(game.nodes)` is correct after `delete_tree`. + """ + game = games.read_from_file("e01.efg") + initial_number_of_nodes = len(game.nodes) + list_nodes = list(game.nodes) + + root_of_the_deleted_subtree = list_nodes[3] + number_of_deleted_nodes = _count_subtree_nodes(root_of_the_deleted_subtree) + + game.delete_tree(root_of_the_deleted_subtree) + + assert len(game.nodes) == initial_number_of_nodes - number_of_deleted_nodes + + +def test_len_after_delete_parent(): + """Verify `len(game.nodes)` is correct after `delete_parent`. + """ + game = games.read_from_file("e02.efg") + initial_number_of_nodes = len(game.nodes) + list_nodes = list(game.nodes) + + node_parent_to_delete = list_nodes[4] + + number_of_node_ancestors = _count_subtree_nodes(node_parent_to_delete) + number_of_parent_ancestors = _count_subtree_nodes(node_parent_to_delete.parent) + diff = number_of_parent_ancestors - number_of_node_ancestors + + game.delete_parent(node_parent_to_delete) + + assert len(game.nodes) == initial_number_of_nodes - diff + + +def test_len_after_append_move(): + """Verify `len(game.nodes)` is correct after `append_move`. + """ + game = games.read_from_file("e01.efg") + initial_number_of_nodes = len(game.nodes) + list_nodes = list(game.nodes) + + terminal_node = list_nodes[5] # path=[1, 1, 0] + player = game.players[0] + actions_to_add = ["T", "M", "B"] + + game.append_move(terminal_node, player, actions_to_add) + + assert len(game.nodes) == initial_number_of_nodes + len(actions_to_add) + + +def test_len_after_append_infoset(): + """Verify `len(game.nodes)` is correct after `append_infoset`. + """ + game = games.read_from_file("e02.efg") + initial_number_of_nodes = len(game.nodes) + list_nodes = list(game.nodes) + + member_node = list_nodes[2] # path=[1] + infoset_to_modify = member_node.infoset + number_of_infoset_actions = len(infoset_to_modify.actions) + terminal_node_to_add = list_nodes[6] # path=[1, 1, 1] + + game.append_infoset(terminal_node_to_add, infoset_to_modify) + + assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions + + +def test_len_after_add_action(): + """Verify `len(game.nodes)` is correct after `add_action`. + """ + game = games.read_from_file("e01.efg") + initial_number_of_nodes = len(game.nodes) + + infoset_to_modify = game.infosets[1] + + num_nodes_in_infoset = len(infoset_to_modify.members) + + game.add_action(infoset_to_modify) + + assert len(game.nodes) == initial_number_of_nodes + num_nodes_in_infoset + + +def test_len_after_delete_action(): + """Verify `len(game.nodes)` is correct after `delete_action`. + """ + game = games.read_from_file("e02.efg") + initial_number_of_nodes = len(game.nodes) + + action_to_delete = game.infosets[0].actions[1] + + # Calculate the total number of nodes within all subtrees + # that begin immediately after taking the specified action. + nodes_to_delete = 0 + action_nodes = _get_members(action_to_delete) + + for subtree_root in action_nodes: + nodes_to_delete += _count_subtree_nodes(subtree_root) + + game.delete_action(action_to_delete) + + assert len(game.nodes) == initial_number_of_nodes - nodes_to_delete + + +def test_len_after_insert_move(): + """Verify `len(game.nodes)` is correct after `insert_move`. + """ + game = games.read_from_file("e01.efg") + initial_number_of_nodes = len(game.nodes) + list_nodes = list(game.nodes) + + node_to_insert_above = list_nodes[3] + + player = game.players[1] + num_actions_to_add = 3 + + game.insert_move(node_to_insert_above, player, num_actions_to_add) + + assert len(game.nodes) == initial_number_of_nodes + num_actions_to_add + + +def test_len_after_insert_infoset(): + """Verify `len(game.nodes)` is correct after `insert_infoset`. + """ + game = games.read_from_file("e01.efg") + initial_number_of_nodes = len(game.nodes) + list_nodes = list(game.nodes) + + member_node = list_nodes[6] # path=[1] + infoset_to_modify = member_node.infoset + node_to_insert_above = list_nodes[7] # path=[0, 1] + number_of_infoset_actions = len(infoset_to_modify.actions) + + game.insert_infoset(node_to_insert_above, infoset_to_modify) + + assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions + + +def test_len_after_copy_tree(): + """Verify `len(game.nodes)` is correct after `copy_tree`. + """ + game = games.read_from_file("e01.efg") + initial_number_of_nodes = len(game.nodes) + list_nodes = list(game.nodes) + src_node = list_nodes[3] # path=[1, 0] + dest_node = list_nodes[2] # path=[0, 0] + number_of_src_ancestors = _count_subtree_nodes(src_node) + + game.copy_tree(src_node, dest_node) + + assert len(game.nodes) == initial_number_of_nodes + number_of_src_ancestors - 1 diff --git a/tests/test_strategic.py b/tests/test_strategic.py index c8ab47d09..5b453ce78 100644 --- a/tests/test_strategic.py +++ b/tests/test_strategic.py @@ -25,7 +25,7 @@ def test_strategic_game_root(): def test_strategic_game_nodes(): game = gbt.Game.new_table([2, 2]) - assert game.nodes() == [] + assert list(game.nodes) == [] def test_game_behav_profile_error():