From 570b35864d0f2bb2011572910ff6aa4cfac2631b Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 16 Sep 2026 12:14:44 +0100 Subject: [PATCH] Avoid repeated tree traversal in `History` construction The first implementation of tree traversal had a mutually-recursive pair of calls which meant in practice history construction was exponential in the depth of the tree. This instead constructs histories using a linear scan of the tree, taking advantage of the canonical ordering of information set/event members. --- src/pygambit/behavmixed.pxi | 7 ++++--- src/pygambit/game.pxi | 40 ++++++++++++++++++++++++++++++++++--- src/pygambit/hsel.pxi | 27 ++++++++++++++++++++++--- 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index ab2abef519..5c8778b966 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -753,9 +753,10 @@ class MixedBehaviorProfile: .. versionadded:: 17.0.0 """ self._check_validity() + histories: dict = self.game._all_histories() return HistoryValuesVector({ p: HistoryValueVector({ - _history_of(n): self._node_value(p, n) for n in self.game._all_nodes() + history: self._node_value(p, n) for n, history in histories.items() }) for p in self.game.players }) @@ -809,7 +810,7 @@ class MixedBehaviorProfile: """ self._check_validity() return RealizProbVector({ - _history_of(n): self._realiz_prob(n) for n in self.game._all_nodes() + history: self._realiz_prob(n) for n, history in self.game._all_histories().items() }) @property @@ -886,7 +887,7 @@ class MixedBehaviorProfile: """ self._check_validity() return BeliefVector({ - _history_of(n): self._belief(n) for n in self.game._all_nodes() + history: self._belief(n) for n, history in self.game._all_histories().items() }) @property diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 9343d31637..cb7b910877 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -520,6 +520,40 @@ class Game: ) return [Node.wrap(node) for node in self.game.deref().GetNodes()] + def _all_histories(self) -> dict: + """The History of every node in the game, computed in one linear sweep. + Not part of the public API; used where most or all of a possibly-large + game's nodes are wanted at once, in place of one `_history_of`/ + `_canonical_history` call per node. + + `_all_nodes()` yields nodes in the same pre-order the C++ core uses to + number nodes -- and hence to pick each information set's/event's + canonical member (`GetMember(1)`) -- so a node's canonical member is + always already a key in the result by the time the node itself is + reached. Building each node's History as its parent's (already-known) + History plus one more transition, in that order, computes every node's + History exactly once, with none of the repeated backward walking or + redundant canonical-history recomputation that a `_history_of`/ + `_canonical_history` call per node would do. + """ + histories: dict = {} + for node in self._all_nodes(): + parent = cython.cast(Node, node)._parent() + if parent is None: + histories[node] = History(()) + continue + branch = cython.cast(Node, node)._prior_action() + canonical_infoset: c_GameInfoset = cython.cast(Node, branch.node)._infoset_handle() + canonical: Node = Node.wrap(canonical_infoset.deref().GetMember(1)) + histories[node] = History(histories[parent] + ( + HistoryTransition( + player=branch.node.player, + state=histories[canonical], + action=branch.label, + ), + )) + return histories + def _get_nodes(self, selector: Selector) -> list[Node]: """Evaluate `selector` (an `H`-built expression) against this game. @@ -993,7 +1027,7 @@ class Game: "with a tree representation" ) return [ - _history_of(node) for node in self._all_nodes() + history for node, history in self._all_histories().items() if not cython.cast(Node, node)._is_strategy_reachable() ] @@ -3449,8 +3483,8 @@ class TreeLayout: def _layout_tree(game: Game) -> object: layout = CreateLayout(game.game) data = {} - for node in game._all_nodes(): - data[_history_of(node)] = TreeLayoutCoordinates( + for node, history in game._all_histories().items(): + data[history] = TreeLayoutCoordinates( deref(layout).GetNodeLevel(cython.cast(Node, node).node), deref(layout).GetNodeSublevel(cython.cast(Node, node).node), deref(layout).GetNodeOffset(cython.cast(Node, node).node)) diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi index e56dc55e57..111b7dcfcb 100644 --- a/src/pygambit/hsel.pxi +++ b/src/pygambit/hsel.pxi @@ -240,6 +240,18 @@ def _history_of(node: Node) -> History: """The History for `node` -- walks back to the root via the private `Node._parent`/`._prior_action` navigation, recording a `HistoryTransition` per step.""" + return _history_of_cached(node, {}) + + +@cython.cfunc +def _history_of_cached(node: Node, cache: dict) -> History: + """Internal: `_history_of`, memoized on `cache` (keyed by `Node`) for the + duration of one top-level call. Without this, each `HistoryTransition.state` + triggers a fresh `_canonical_history`/`_history_of` walk of its own, and since + that walk repeats for every ancestor, the total cost is exponential in tree + depth rather than linear.""" + if node in cache: + return cache[node] transitions: list = [] current: Node = node while current._parent() is not None: @@ -247,13 +259,15 @@ def _history_of(node: Node) -> History: transitions.append( HistoryTransition( player=branch.node.player, - state=_canonical_history(branch.node), + state=_canonical_history_cached(branch.node, cache), action=branch.label, ) ) current = branch.node transitions.reverse() - return History(transitions) + result: History = History(transitions) + cache[node] = result + return result def _canonical_history(node: Node) -> History: @@ -268,10 +282,17 @@ def _canonical_history(node: Node) -> History: AttributeError If `node` currently belongs to no information set or event (a terminal node). """ + return _canonical_history_cached(node, {}) + + +@cython.cfunc +def _canonical_history_cached(node: Node, cache: dict) -> History: + """Internal: `_canonical_history`, threading the same `cache` used by + `_history_of_cached` through to its own `_history_of_cached` call.""" resolved: c_GameInfoset = node._infoset_handle() if resolved == cython.cast(c_GameInfoset, NULL): raise AttributeError("node currently belongs to no information set or event") - return _history_of(Node.wrap(resolved.deref().GetMember(1))) + return _history_of_cached(Node.wrap(resolved.deref().GetMember(1)), cache) class HistoryView: