Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/pygambit/behavmixed.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 37 additions & 3 deletions src/pygambit/game.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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()
]

Expand Down Expand Up @@ -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))
Expand Down
27 changes: 24 additions & 3 deletions src/pygambit/hsel.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -240,20 +240,34 @@ 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:
branch = current._prior_action()
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:
Expand All @@ -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:
Expand Down
Loading