Skip to content

Draft of histories operator concept - #1111

Draft
tturocy wants to merge 9 commits into
masterfrom
dev_histories
Draft

Draft of histories operator concept#1111
tturocy wants to merge 9 commits into
masterfrom
dev_histories

Conversation

@tturocy

@tturocy tturocy commented Sep 2, 2026

Copy link
Copy Markdown
Member

No description provided.

tturocy and others added 8 commits September 2, 2026 18:05
First working slice of the H expression-engine design: a game-neutral
Selector built by H.path(*steps)/H.plays, evaluated only when handed to
Game.get_nodes (internal, returns Node) or Game.get_histories (public,
materializes plain History tuples). Reuses Node's existing navigation
(.children, .plays) rather than new C++ traversal code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Seed form searches the whole game (via game.nodes) for any node whose
own trailing labels match; chained form is a pure filter over whatever
is already selected. Evaluator now tracks whether a selection has been
seeded yet, since .after's seed candidates differ from .path/.plays's
(root-anchored) default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GroupedSelector wraps a base Selector plus a key function, game-neutral
until Game.get_groups evaluates it into dict[key, list[History]].
HistoryView is what the key callable actually receives: plain sequence
indexing/slicing like a History tuple, plus .last_action(player) --
built by walking Node.parent/.player/.prior_action -- but never exposes
the Node or game it's privately backed by (verified via hasattr checks
inside a probing callback). get_histories refactored to share the new
_history_of helper with get_groups instead of duplicating the walk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Chained-only (no bare H.filter(...) seed -- unlike .after(...), a
predicate has no natural whole-game starting domain). Keeps elements
where predicate(HistoryView) is truthy; complements .after(...)'s
label-pattern matching for anything needing richer navigation like
.last_action(player).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_resolve_nodes now resolves a Selector via get_nodes before its usual
node resolution, so both append_move and append_event accept an H
expression directly wherever they took Node/NodeReferenceSet before --
no changes needed to either method's own body for the flat case.
append_move additionally accepts a GroupedSelector (from .by(...)),
dispatching to one append_move call per group via the new
Game._group_nodes helper (shares get_groups's logic, keeping Node
objects instead of materializing Histories, avoiding a round trip).

Verified end-to-end: building Kuhn poker's deal via append_event(H.path(...))
and Alice's three per-card infosets via one
append_move(H.path(...).plays.by(lambda h: h[0]), ...) call, confirming
is_perfect_recall and each infoset's membership.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_resolve_node now also accepts a Selector (must resolve to exactly one
node) and a bare History tuple (resolved via Selector().path(*history),
the always-available manual fallback), and _resolve_nodes now excludes
tuple from its "treat as a collection" check the same way it already
excluded str -- so a single History isn't misread as several
single-label node references. append_infoset gets Selector support in
both its nodes and infoset arguments for free through this; make_outcome
gets it through location's existing _resolve_nodes path.

Verified end-to-end: the Absent-Minded Driver (append_move(H.path(),...),
append_infoset(H.path("S"), H.path()), make_outcome at H.path("S","S")
etc.) reproduces is_perfect_recall == False and the expected two-member
root infoset; and passing a get_groups(...) group (a list[tuple]) straight
to make_outcome works, matching the checkpointed Kuhn poker outcomes
pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GroupedSelector gains .plays/.after(...) (per-group expand/filter,
key untouched) and .with_recall(player), which -- from that point on
-- makes every subsequent .plays also refine each group's key by
folding in player's last action at that point (via the new shared
_last_action helper). Scoped to .plays specifically for now, not every
expand-style op. Game._group_nodes applies a GroupedSelector's post_ops
in order, per-group, doing the recall refinement when with_recall set a
player.

Surfaced and fixed a real edge case along the way: filtering after a
recall-refined .plays can produce empty groups (e.g. a card's "bet
first" branch has no "...,Check,Bet" suffix) -- append_move's
per-group dispatch now skips empty groups rather than erroring,
settling an earlier open design question in favor of "dropped".

Verified end-to-end: the full Kuhn poker betting tree, including
Alice's second decision reusing a with_recall-tagged partition,
reproduces is_perfect_recall == True and the correct 3-infosets-of-2
structure -- the same result the tuple-pivot phase needed a manual
get_last_action+get_infoset dance to achieve, now automatic on reuse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb -- a design
prototype, not a released feature, demonstrating the H selector algebra
sketched on this branch: Selten's Horse, Kuhn poker (construction +
outcomes), bayes2a (a regular two-stage Bayesian game needing neither
with_recall nor append_infoset), a new minimal example of imperfect
recall via forgetting a past observation (distinct in shape from
absent-mindedness and untimeability), the Jakobsen et al. (2016)
untimeable game, and an extended Absent-Minded Driver showing
append_infoset composes normally with further construction.

Every cell actually executed (via jupyter nbconvert --execute) against
the real built module, not hand-traced -- outputs are real, including
cross-checks against the original .efg/catalog files for Selten's
Horse, bayes2a, and the AM-driver-subgame fixture (infoset partitions
and outcome maps verified to match exactly before being folded into the
notebook's own diagnostics).

Also: pip install -e . to make src/pygambit importable directly by the
notebook's Jupyter kernel, which doesn't inherit PYTHONPATH the way a
shell subprocess does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

test_execute_notebook builds nbclient.NotebookClient with
kernel_name=nb.metadata["kernelspec"]["name"] -- our notebook never
had that key set (built via raw nbformat.v4.new_notebook(), which
doesn't populate it the way Jupyter's own UI or an already-kernelspec'd
source notebook would), so kernel_name came through as None, which
traitlets rejects outright before any cell runs.

Verified against the actual CI entry point, not just nbconvert
--execute succeeding: ran pytest tests/test_tutorials.py -k
h_selector_prototype -m tutorials directly, matching CI's own
NotebookClient construction. Passes, and the rest of the tutorials
suite (all 10 notebooks) still passes too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant