diff --git a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb new file mode 100644 index 000000000..fa02233b6 --- /dev/null +++ b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb @@ -0,0 +1,513 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "014efe26", + "metadata": {}, + "source": [ + "# The `H` node-selector algebra: six worked examples\n", + "\n", + "This notebook is a design prototype, not a released feature. `pygambit.gambit.H` is an\n", + "internal, unreleased module -- everything here demonstrates a work-in-progress replacement\n", + "for constructing extensive-form games without ever handling raw `Node` objects.\n", + "\n", + "The core idea: a *selector*, built from `H`, describes a set of histories symbolically --\n", + "it carries no reference to any particular game until you hand it to one. `H.path(*steps)`\n", + "walks a sequence of exact labels and/or `...` wildcards from the root (or from wherever a\n", + "selection currently is, when chained); `H.after(*labels)` matches anywhere by a trailing\n", + "label pattern; `.plays` expands to whatever is currently terminal; `.by(callable)`\n", + "partitions a selection by a key function, and `.filter(callable)` keeps only matching\n", + "elements. `Game.append_move`/`append_event`/`append_infoset`/`make_outcome` all accept these\n", + "selectors directly, in place of `Node`/`NodeReferenceSet`.\n", + "\n", + "Six examples below, each chosen to exercise a different corner of the design: a classic\n", + "imperfect-information game needing `append_infoset`, a game with betting and outcome\n", + "computation, a regular two-stage Bayesian game, three different shapes of imperfect\n", + "*recall*, and a variation showing `append_infoset` composes normally with further\n", + "construction." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "2e80dd03", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:36.060080Z", + "iopub.status.busy": "2026-09-02T18:45:36.059915Z", + "iopub.status.idle": "2026-09-02T18:45:36.970529Z", + "shell.execute_reply": "2026-09-02T18:45:36.970258Z" + } + }, + "outputs": [], + "source": [ + "try:\n", + " from gtdraw import draw\n", + "except ImportError:\n", + " def draw(*args, **kwargs):\n", + " print(\"gtdraw is not installed; game trees won't be drawn, but everything else runs.\")\n", + "\n", + "from pygambit.gambit import H\n", + "\n", + "import pygambit as gbt" + ] + }, + { + "cell_type": "markdown", + "id": "ddd44d44", + "metadata": {}, + "source": [ + "## 1. Selten's Horse\n", + "\n", + "A classic three-player game (Selten, 1975) used to illustrate subtleties of sequential\n", + "equilibrium. Player 1 moves first; if he plays \"R\", Player 2 moves; if Player 2 also plays\n", + "\"L\", or if Player 1 played \"L\" directly, Player 3 faces the same decision either way --\n", + "**Player 3 cannot tell which path led there**.\n", + "\n", + "This needs `append_infoset`, not because of anything exotic about recall or timing (the\n", + "game is perfectly ordinary on both counts), but for a mundane construction-ordering reason:\n", + "Player 3's two infoset members aren't simultaneously available. The node reached via a bare\n", + "\"L\" exists as soon as Player 1 moves; the node reached via \"R\", \"L\" only exists once Player 2\n", + "has *also* moved -- so one `append_move` call can never cover both." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "25387762", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:36.971889Z", + "iopub.status.busy": "2026-09-02T18:45:36.971789Z", + "iopub.status.idle": "2026-09-02T18:45:37.473789Z", + "shell.execute_reply": "2026-09-02T18:45:37.473536Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: True\n", + "Player 3's infoset members (as Histories, not raw Node paths -- the latter display node-to-root, easy to misread): [('L',), ('R', 'L')]\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\", \"Player 3\"], title=\"Selten's Horse\")\n", + "\n", + "g.append_move(H.path(), \"Player 1\", [\"R\", \"L\"])\n", + "g.append_move(H.path(\"L\"), \"Player 3\", [\"R\", \"L\"])\n", + "g.append_move(H.path(\"R\"), \"Player 2\", [\"R\", \"L\"])\n", + "g.append_infoset(H.path(\"R\", \"L\"), H.path(\"L\"))\n", + "\n", + "g.make_outcome(H.path(\"R\", \"R\"), {\"Player 1\": 1, \"Player 2\": 1, \"Player 3\": 1}, \"RR\")\n", + "g.make_outcome(H.path(\"R\", \"L\", \"R\"), {\"Player 1\": 4, \"Player 2\": 4, \"Player 3\": 0}, \"RLR\")\n", + "g.make_outcome(H.path(\"R\", \"L\", \"L\"), {\"Player 1\": 0, \"Player 2\": 0, \"Player 3\": 1}, \"RLL\")\n", + "g.make_outcome(H.path(\"L\", \"R\"), {\"Player 1\": 3, \"Player 2\": 2, \"Player 3\": 2}, \"LR\")\n", + "g.make_outcome(H.path(\"L\", \"L\"), {\"Player 1\": 0, \"Player 2\": 0, \"Player 3\": 0}, \"LL\")\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "print(\n", + " \"Player 3's infoset members (as Histories, not raw Node paths -- the latter\"\n", + " \" display node-to-root, easy to misread):\",\n", + " sorted(g.get_histories(H.path(\"L\")) + g.get_histories(H.path(\"R\", \"L\"))),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "2ca9c9c1", + "metadata": {}, + "source": [ + "## 2. Kuhn poker\n", + "\n", + "Three-card poker, the standard small illustration of imperfect information *and* betting.\n", + "This example exercises the recall-tracking machinery in earnest: Alice's second decision\n", + "(call/fold after checking then facing a bet) must still distinguish her own card, even\n", + "though the tree has grown well past where that distinction was first established.\n", + "\n", + "`alice_partition` is built once, tagged `.with_recall(\"Alice\")`, and reused for both of her\n", + "decisions -- the tag makes `.plays` automatically fold her own last action into the group\n", + "key from her second decision onward, with no separate re-derivation step. `bob_partition`\n", + "never needs the tag: his two uses are his *one* decision instantiated on two mutually\n", + "exclusive branches, not a first-then-second sequence for him.\n", + "\n", + "Outcome computation is a genuinely different kind of selector from the recall-tracking\n", + "above: `winner`/`pot_size` are direct, declarative facts about a completed hand (who took\n", + "the pot, how much), not a player's own partial view of the game -- an outcome deliberately\n", + "throws away *how* a given payoff was reached, which is the opposite spirit from recall\n", + "grouping's insistence on never conflating what a player can actually tell apart." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "923feb0b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:37.474927Z", + "iopub.status.busy": "2026-09-02T18:45:37.474792Z", + "iopub.status.idle": "2026-09-02T18:45:38.086934Z", + "shell.execute_reply": "2026-09-02T18:45:38.086660Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: True\n" + ] + } + ], + "source": [ + "CARD_VALUE = {\"J\": 0, \"Q\": 1, \"K\": 2}\n", + "cards = list(CARD_VALUE)\n", + "\n", + "g = gbt.Game.new_tree(players=[\"Alice\", \"Bob\"], title=\"Kuhn poker\")\n", + "g.append_event(H.path(), cards, [gbt.Rational(1, 3)] * 3)\n", + "for c in cards:\n", + " remaining = [x for x in cards if x != c]\n", + " g.append_event(H.path(c), remaining, [gbt.Rational(1, 2)] * 2)\n", + "\n", + "alice_partition = H.path(...).by(lambda h: h[0]).with_recall(\"Alice\")\n", + "g.append_move(alice_partition.plays, \"Alice\", [\"Check\", \"Bet\"])\n", + "\n", + "bob_partition = H.path(..., ...).by(lambda h: h[1])\n", + "g.append_move(bob_partition.plays.after(\"Check\"), \"Bob\", [\"Check\", \"Bet\"])\n", + "\n", + "g.append_move(alice_partition.plays.after(\"Check\", \"Bet\"), \"Alice\", [\"Fold\", \"Call\"])\n", + "g.append_move(bob_partition.plays.after(\"Bet\"), \"Bob\", [\"Fold\", \"Call\"])\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "b11e3f16", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:38.088103Z", + "iopub.status.busy": "2026-09-02T18:45:38.087990Z", + "iopub.status.idle": "2026-09-02T18:45:38.091896Z", + "shell.execute_reply": "2026-09-02T18:45:38.091662Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Total outcomes created: 4\n" + ] + } + ], + "source": [ + "def winner(h):\n", + " match h[2:]:\n", + " case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n", + " return \"Alice\" if CARD_VALUE[h[0]] > CARD_VALUE[h[1]] else \"Bob\"\n", + " case (\"Check\", \"Bet\", \"Fold\"):\n", + " return \"Bob\"\n", + " case (\"Bet\", \"Fold\"):\n", + " return \"Alice\"\n", + "\n", + "def pot_size(h):\n", + " match h[2:]:\n", + " case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Fold\") | (\"Bet\", \"Fold\"):\n", + " return 1\n", + " case (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n", + " return 2\n", + "\n", + "for (win, amount), group in g.get_groups(H.plays.by(lambda h: (winner(h), pot_size(h)))).items():\n", + " lose = \"Bob\" if win == \"Alice\" else \"Alice\"\n", + " g.make_outcome(group, {win: amount, lose: -amount}, f\"{win} wins {amount}\")\n", + "\n", + "print(\"Total outcomes created:\", len(list(g.outcomes)))" + ] + }, + { + "cell_type": "markdown", + "id": "51ade76e", + "metadata": {}, + "source": [ + "## 3. `bayes2a`: a regular two-stage Bayesian game\n", + "\n", + "A fully \"timeable\" game with private types and two rounds of simultaneous moves --\n", + "`contrib/games/bayes2a.efg` in the repository. Both players privately learn a type, then\n", + "move simultaneously each round; each round's actions become public before the next round.\n", + "\n", + "Unlike Kuhn poker, this game needs neither `.with_recall` nor `append_infoset` -- every\n", + "player's decision falls at a fixed, predictable position in the history across every\n", + "branch, so plain positional indexing on the augmented history object is all the grouping\n", + "needs. This is deliberately included as a contrast case: `H`'s dedicated recall machinery\n", + "exists for games that need it, but a well-behaved regular game doesn't have to pay for it." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "03767b4c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:38.092996Z", + "iopub.status.busy": "2026-09-02T18:45:38.092929Z", + "iopub.status.idle": "2026-09-02T18:45:38.096810Z", + "shell.execute_reply": "2026-09-02T18:45:38.096572Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: True\n", + "terminal histories: 64\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"bayes2a\")\n", + "half = gbt.Rational(1, 2)\n", + "\n", + "g.append_event(H.path(), [\"1G\", \"1B\"], [half, half])\n", + "for t1 in [\"1G\", \"1B\"]:\n", + " g.append_event(H.path(t1), [\"2g\", \"2b\"], [half, half])\n", + "\n", + "# Round 1: each player's move depends only on their own type.\n", + "g.append_move(H.path(...).plays.by(lambda h: h[0]), \"Player 1\", [\"H\", \"L\"])\n", + "g.append_move(H.path(..., ...).plays.by(lambda h: h[1]), \"Player 2\", [\"h\", \"l\"])\n", + "\n", + "# Round 2: both round-1 actions are now public; each player also still knows their own type.\n", + "g.append_move(H.plays.by(lambda h: (h[0], h[2], h[3])), \"Player 1\", [\"H\", \"L\"])\n", + "g.append_move(H.plays.by(lambda h: (h[1], h[2], h[3])), \"Player 2\", [\"h\", \"l\"])\n", + "\n", + "PAYOFFS = {\n", + " (\"1G\", \"H\", \"h\"): (10, 2), (\"1G\", \"H\", \"l\"): (0, 10),\n", + " (\"1G\", \"L\", \"h\"): (2, 4), (\"1G\", \"L\", \"l\"): (4, 0),\n", + " (\"1B\", \"H\", \"h\"): (4, 2), (\"1B\", \"H\", \"l\"): (2, 10),\n", + " (\"1B\", \"L\", \"h\"): (0, 4), (\"1B\", \"L\", \"l\"): (10, 0),\n", + "}\n", + "for (p1, p2), group in g.get_groups(H.plays.by(lambda h: PAYOFFS[(h[0], h[4], h[5])])).items():\n", + " g.make_outcome(group, {\"Player 1\": p1, \"Player 2\": p2}, f\"({p1},{p2})\")\n", + "\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "print(\"terminal histories:\", len(g.get_histories(H.plays)))" + ] + }, + { + "cell_type": "markdown", + "id": "c1af37be", + "metadata": {}, + "source": [ + "## 4. Imperfect recall: forgetting a past observation\n", + "\n", + "A third, distinct shape of imperfect recall, alongside absent-mindedness and\n", + "untimeability below. Alice privately observes a signal (H or L) and acts on it -- her\n", + "first decision is correctly split into two infosets, one per signal. Bob then moves,\n", + "seeing nothing private. Alice's *second* decision is deliberately built to merge across\n", + "both signal values, keyed only by her own first action and Bob's -- she is modeled as\n", + "having forgotten the signal that legitimately informed her own first move.\n", + "\n", + "This is neither absent-mindedness (no single node is ever revisited -- these are two\n", + "separate first-decision infosets being merged, not one node crossed twice) nor\n", + "untimeability (every one of Alice's second-decision nodes sits at exactly the same depth --\n", + "the issue is purely about what she remembers, not about timing)." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "c1fc0b54", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:38.097844Z", + "iopub.status.busy": "2026-09-02T18:45:38.097770Z", + "iopub.status.idle": "2026-09-02T18:45:38.594007Z", + "shell.execute_reply": "2026-09-02T18:45:38.593205Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: False\n", + " Alice's 2nd decision, key=('Down', 'x'): [('H', 'Down', 'x'), ('L', 'Down', 'x')]\n", + " Alice's 2nd decision, key=('Down', 'y'): [('H', 'Down', 'y'), ('L', 'Down', 'y')]\n", + " Alice's 2nd decision, key=('Up', 'x'): [('H', 'Up', 'x'), ('L', 'Up', 'x')]\n", + " Alice's 2nd decision, key=('Up', 'y'): [('H', 'Up', 'y'), ('L', 'Up', 'y')]\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Alice\", \"Bob\"], title=\"Forgetting a past observation\")\n", + "half = gbt.Rational(1, 2)\n", + "\n", + "g.append_event(H.path(), [\"H\", \"L\"], [half, half])\n", + "g.append_move(H.path(\"H\"), \"Alice\", [\"Up\", \"Down\"])\n", + "g.append_move(H.path(\"L\"), \"Alice\", [\"Up\", \"Down\"])\n", + "g.append_move(H.path(..., ...), \"Bob\", [\"x\", \"y\"])\n", + "\n", + "# Keyed by (Alice's own first action, Bob's action) only -- h[0], the signal, is dropped.\n", + "g.append_move(H.plays.by(lambda h: (h[1], h[2])), \"Alice\", [\"Fold\", \"Call\"])\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "# Depth 3, explicitly -- these are the same histories the construction above\n", + "# grouped by (h[1], h[2]) to create Alice's second decision.\n", + "groups = g.get_groups(H.path(..., ..., ...).by(lambda h: (h[1], h[2])))\n", + "for key, members in sorted(groups.items()):\n", + " print(f\" Alice's 2nd decision, key={key}: {sorted(members)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "4d009aae", + "metadata": {}, + "source": [ + "## 5. An untimeable game\n", + "\n", + "Jakobsen, Sørensen & Conitzer (2016), Figure 1(a): a coin toss decides who moves first;\n", + "each player then guesses whether they went first or second, unable to tell which, since\n", + "neither observes the other's move or the coin. Each player's infoset spans both a\n", + "depth-1 node (moving first) and depth-2 nodes (moving second) -- and, unlike Selten's\n", + "Horse above, **no valid timing assignment exists at all**, even allowing a dense\n", + "(non-integer) time scale: each player's second decision would need to come strictly after\n", + "the *other's* first decision, on different branches -- a circular constraint no monotonic\n", + "timing can resolve. Perfect recall holds throughout regardless -- neither player forgets\n", + "anything, each has only one decision to have forgotten at." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "d8ac1b78", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:38.595461Z", + "iopub.status.busy": "2026-09-02T18:45:38.595355Z", + "iopub.status.idle": "2026-09-02T18:45:39.026877Z", + "shell.execute_reply": "2026-09-02T18:45:39.026590Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: True\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"Untimeable (Jakobsen et al. 2016)\")\n", + "\n", + "g.append_event(H.path(), [\"1\", \"2\"], [gbt.Rational(1, 2)] * 2)\n", + "\n", + "g.append_move(H.path(\"1\"), \"Player 2\", [\"1\", \"2\"])\n", + "g.append_move(H.path(\"2\"), \"Player 1\", [\"1\", \"2\"])\n", + "\n", + "g.append_infoset(H.path(\"1\", ...), H.path(\"2\"))\n", + "g.append_infoset(H.path(\"2\", ...), H.path(\"1\"))\n", + "\n", + "def outcome_key(h):\n", + " p1_guess = h.last_action(\"Player 1\")\n", + " p2_guess = h.last_action(\"Player 2\")\n", + " return (p1_guess == h[0], p2_guess != h[0])\n", + "\n", + "for (p1_ok, p2_ok), group in g.get_groups(H.plays.by(outcome_key)).items():\n", + " g.make_outcome(\n", + " group, {\"Player 1\": int(p1_ok), \"Player 2\": int(p2_ok)},\n", + " f\"P1 {'correct' if p1_ok else 'wrong'}, P2 {'correct' if p2_ok else 'wrong'}\",\n", + " )\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)" + ] + }, + { + "cell_type": "markdown", + "id": "02f8b9bb", + "metadata": {}, + "source": [ + "## 6. Absent-Minded Driver, with a further decision appended\n", + "\n", + "The classic Piccione–Rubinstein Absent-Minded Driver: one real binary decision (\"S\"/\"T\"),\n", + "faced *twice* without knowing which time it is, since the driver's own \"S\"-child shares\n", + "her first infoset. This variation goes one step further than the minimal version: after\n", + "her second \"S\", a *second* player gets a genuine, ordinary decision -- showing that\n", + "`append_infoset` composes normally with whatever construction comes after it; nothing\n", + "about the rest of the tree needs special treatment once the absent-minded infoset is set\n", + "up." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "26156c14", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:39.028098Z", + "iopub.status.busy": "2026-09-02T18:45:39.028013Z", + "iopub.status.idle": "2026-09-02T18:45:39.434611Z", + "shell.execute_reply": "2026-09-02T18:45:39.434339Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: False\n", + "Player 1's (first) infoset members: [(), ('S',)]\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"Absent-Minded Driver, extended\")\n", + "\n", + "g.append_move(H.path(), \"Player 1\", [\"S\", \"T\"])\n", + "g.append_infoset(H.path(\"S\"), H.path())\n", + "g.append_move(H.path(\"S\", \"T\"), \"Player 2\", [\"r\", \"l\"])\n", + "\n", + "g.make_outcome(H.path(\"S\", \"S\"), {\"Player 1\": 1, \"Player 2\": -1}, \"SS\")\n", + "g.make_outcome(H.path(\"S\", \"T\", \"r\"), {\"Player 1\": 2, \"Player 2\": -2}, \"STr\")\n", + "g.make_outcome(H.path(\"S\", \"T\", \"l\"), {\"Player 1\": 3, \"Player 2\": -3}, \"STl\")\n", + "g.make_outcome(H.path(\"T\"), {\"Player 1\": 4, \"Player 2\": -4}, \"T\")\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "print(\n", + " \"Player 1's (first) infoset members:\",\n", + " sorted(g.get_histories(H.path()) + g.get_histories(H.path(\"S\"))),\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index 2d73be109..d26a082b9 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -192,6 +192,7 @@ include "infoset.pxi" include "strategy.pxi" include "outcome.pxi" include "node.pxi" +include "hsel.pxi" include "stratspt.pxi" include "behavspt.pxi" include "stratmixed.pxi" diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 84c511663..f6c83f2fc 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -542,6 +542,108 @@ class Game: ) return Node.wrap(self.game.deref().GetRoot()) + def get_nodes(self, selector: Selector) -> list[Node]: + """Evaluate `selector` (an `H`-built expression) against this game. + + First sketch of the `H` selector algebra's evaluator: interprets the + selector's ops in order, starting from the root, reusing `Node`'s + existing navigation (`.children`, `.plays`) rather than walking the + C++ tree directly. Returns raw `Node` objects for now -- a real + public version would wrap each result in a `.game`-free facade + instead, not yet built. + + .. versionadded:: 17.0.0 + """ + current: list = None + for op in selector._ops: + if isinstance(op, _AfterStep): + candidates = list(self.nodes) if current is None else current + current = [n for n in candidates if _matches_suffix(n, op.labels)] + continue + if current is None: + current = [self.root] + if isinstance(op, _PathStep): + for step in op.steps: + current = ( + [child for node in current for child in node.children] + if step is Ellipsis + else [node.children[step] for node in current] + ) + elif isinstance(op, _PlaysStep): + current = [play for node in current for play in node.plays] + elif isinstance(op, _FilterStep): + current = [ + node for node in current + if op.predicate(HistoryView._wrap(node, _history_of(node))) + ] + else: + raise TypeError(f"get_nodes(): unknown selector op {op!r}") + if current is None: + current = [self.root] + return current + + def get_histories(self, selector: Selector) -> list[tuple]: + """Evaluate `selector` (an `H`-built expression) against this game, + materializing each result as a `History` -- a plain tuple of action + labels from the root, carrying no reference to this game. + + This is the public-facing counterpart to `get_nodes`: `get_nodes` + exists only as an internal sketch and is never meant to hand a `Node` + to calling code. + + .. versionadded:: 17.0.0 + """ + return [_history_of(node) for node in self.get_nodes(selector)] + + def _group_nodes(self, grouped: GroupedSelector) -> dict: + """Internal: like `get_groups`, but keeps `Node` objects rather than + materializing each into a `History` -- used by mutation methods that + need to resolve straight back to concrete nodes, avoiding a + Node -> History -> Node round trip. + + Applies `grouped`'s initial partition (`base`/`key`), then its + `post_ops` in order, each one per-group -- expanding/filtering each + group's own members independently, leaving the key untouched, except + that a `.plays` step refines the key by `recall_player`'s last action + at that point, if `with_recall` set one (see `GroupedSelector`'s + docstring for why). + """ + result: dict = {} + for node in self.get_nodes(grouped.base): + view: HistoryView = HistoryView._wrap(node, _history_of(node)) + key = grouped.key(view) + result.setdefault(key, []).append(node) + for op in grouped.post_ops: + next_result: dict = {} + for key, nodes in result.items(): + if isinstance(op, _PlaysStep): + expanded = [play for node in nodes for play in node.plays] + if grouped.recall_player is None: + next_result[key] = expanded + else: + for play in expanded: + refined_key = (key, _last_action(play, grouped.recall_player)) + next_result.setdefault(refined_key, []).append(play) + continue + if isinstance(op, _AfterStep): + next_result[key] = [n for n in nodes if _matches_suffix(n, op.labels)] + continue + raise TypeError(f"_group_nodes(): unknown post-op {op!r}") + result = next_result + return result + + def get_groups(self, grouped: GroupedSelector) -> dict: + """Evaluate a `.by(callable)`-built `GroupedSelector` against this + game, returning a dict from each distinct key to the list of + Histories that produced it. + + .. versionadded:: 17.0.0 + """ + return { + key: [_history_of(node) for node in nodes] + for key, nodes in self._group_nodes(grouped).items() + } + @property def is_const_sum(self) -> bool: """Whether the game is constant sum.""" @@ -1281,6 +1383,17 @@ class Game: if node.game != self: raise MismatchError(f"{funcname}(): {argname} must be part of the same game") return node + elif isinstance(node, Selector): + resolved = self.get_nodes(node) + if len(resolved) != 1: + raise ValueError( + f"{funcname}(): {argname} selector must resolve to exactly one " + f"node, resolved to {len(resolved)}" + ) + return resolved[0] + elif isinstance(node, tuple): + # A History -- the manual fallback: root-anchored, every step exact. + return self._resolve_node(Selector().path(*node), funcname, argname) elif isinstance(node, str): if not node.strip(): raise ValueError( @@ -1301,10 +1414,15 @@ class Game: """Resolve an attempt to reference a subset of the nodes of the game of the game. See `_resolve_node` for details on functionality. + + `nodes` may also be a `Selector` (an `H`-built expression), evaluated + against this game via `get_nodes` before the usual resolution. """ + if isinstance(nodes, Selector): + nodes = self.get_nodes(nodes) resolved_nodes = [ self._resolve_node(n, funcname, argname) - for n in (nodes if hasattr(nodes, "__iter__") and not isinstance(nodes, str) + for n in (nodes if hasattr(nodes, "__iter__") and not isinstance(nodes, (str, tuple)) else [nodes]) ] if not resolved_nodes: @@ -1444,7 +1562,7 @@ class Game: raise IndexError(f"{funcname}(): must specify exactly one probability per action") return probs - def append_move(self, nodes: Node | NodeReferenceSet, + def append_move(self, nodes: Node | NodeReferenceSet | Selector | GroupedSelector, player: str, actions: list[str]) -> None: """Add a move for `player` at terminal `nodes`. All elements of `nodes` become part of @@ -1452,6 +1570,14 @@ class Game: `player` must be a personal player; use `append_event` to add a chance move. + `nodes` may be a `Selector` (an `H`-built expression, evaluated against this + game and treated as a flat `NodeReferenceSet`) or a `GroupedSelector` (an + `H`-built `.by(...)` expression) -- in the latter case, one new information + set is created per distinct group, rather than one spanning every match. + + .. versionchanged:: 17.0.0 + `nodes` may now be a `Selector` or `GroupedSelector`. + Raises ------ UndefinedOperationError @@ -1464,6 +1590,12 @@ class Game: If `nodes` has duplicated elements, or is empty; or if `actions` contains an empty or a duplicated label. """ + if isinstance(nodes, GroupedSelector): + for group in self._group_nodes(nodes).values(): + if not group: + continue + self.append_move(group, player, actions) + return resolved_player = self._resolve_player(player, "append_move") if not actions: raise UndefinedOperationError("append_move(): `actions` must be a nonempty list") diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi new file mode 100644 index 000000000..d91cff266 --- /dev/null +++ b/src/pygambit/hsel.pxi @@ -0,0 +1,285 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/hsel.pxi +# First sketch of the H selector algebra: game-neutral expressions built by +# pygambit.H, evaluated only when handed to a Game. Deliberately minimal -- +# just enough operations to validate the architecture, not the full roster. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# + + +class _PathStep: + """One `H.path(*steps)` operation: each step is an exact action label or + the wildcard `...`. Root-anchored if it is the first op in a Selector, + "this many more steps from here" otherwise -- the evaluator doesn't need + to distinguish the two cases, since they're the same operation applied to + whatever's already been selected (the root, for a bare seed).""" + + def __init__(self, steps: tuple) -> None: + self.steps = steps + + def __repr__(self) -> str: + return f"_PathStep(steps={self.steps!r})" + + +class _PlaysStep: + """One `.plays` operation: expand to the current terminal frontier.""" + + def __repr__(self) -> str: + return "_PlaysStep()" + + +class _AfterStep: + """One `.after(*labels)` operation: an unconstrained (possibly empty) + prefix, then exactly these trailing labels. As the first op in a + Selector, matches anywhere in the whole game, not just root's frontier -- + the natural counterpart to `.path(...)`'s root anchoring. Chained onto an + existing selection, it's a pure filter: no new nodes are considered, just + whichever already-selected ones end in this suffix.""" + + def __init__(self, labels: tuple) -> None: + self.labels = labels + + def __repr__(self) -> str: + return f"_AfterStep(labels={self.labels!r})" + + +def _matches_suffix(node: Node, labels: tuple) -> bool: + """Whether `node`'s own history ends with exactly `labels`.""" + current: Node = node + for label in reversed(labels): + if current.parent is None or current.prior_action.label != label: + return False + current = current.parent + return True + + +class _FilterStep: + """One `.filter(callable)` operation: keep only elements where + `predicate`, given a HistoryView, returns something truthy. Chained-only + -- unlike `.after(...)`, there's no natural "whole game" domain for a + bare predicate to start from, so it's not exposed as an `H.filter(...)` + seed.""" + + def __init__(self, predicate: typing.Callable) -> None: + self.predicate = predicate + + def __repr__(self) -> str: + return f"_FilterStep(predicate={self.predicate!r})" + + +class Selector: + """A game-neutral description of a set of nodes. Carries no reference to + any game -- it's just a recipe, evaluated only when handed to a Game + method such as `get_nodes`. + + .. versionadded:: 17.0.0 + """ + + def __init__(self, ops: tuple = ()) -> None: + self._ops = ops + + def __repr__(self) -> str: + return f"Selector(ops={self._ops!r})" + + def _extend(self, op) -> Selector: + return Selector(self._ops + (op,)) + + def path(self, *steps: str) -> Selector: + """`N` more steps from wherever this selection currently is. Each + step is an exact action label, or `...` to match any single action. + """ + return self._extend(_PathStep(steps)) + + @property + def plays(self) -> Selector: + """The current terminal frontier of this selection -- not + necessarily one step forward, whatever is currently terminal beneath + each already-selected node.""" + return self._extend(_PlaysStep()) + + def after(self, *labels: str) -> Selector: + """Filter this selection to just the elements whose own trailing + labels are exactly `labels`, whatever came before them.""" + return self._extend(_AfterStep(labels)) + + def filter(self, predicate: typing.Callable) -> Selector: + """Keep only the elements of this selection where `predicate`, + called once per element with a read-only `HistoryView` of it, + returns something truthy. The general escape hatch for a filter + `.after(...)`'s label-pattern matching can't express -- e.g. + anything needing `.last_action(player)` rather than a plain + trailing-label match.""" + return self._extend(_FilterStep(predicate)) + + def by(self, key: typing.Callable) -> GroupedSelector: + """Partition this selection by `key`, called once per element with a + read-only `HistoryView` of it. Distinct return values become distinct + groups; game-neutral until evaluated, same as `Selector` itself.""" + return GroupedSelector(self, key) + + +class GroupedSelector: + """Result of `.by(callable)`. Game-neutral until evaluated -- call + `Game.get_groups` and iterate its result as `(key, group)` pairs. + + `.plays`/`.after(...)` chain onto a `GroupedSelector` the same way they + chain onto a plain `Selector`, but apply per-group: each group's own + members are expanded/filtered independently, and the group's key is left + untouched -- expanding past a decision point doesn't retroactively change + what a group was keyed by. `.with_recall(player)` is the one exception: + once set, every subsequent `.plays` on this selector *also* refines each + group's key by folding in `player`'s last action at that point, so a + partition built for one decision stays a valid recall-respecting + partition when reused for a later one, without the caller needing to + re-derive or manually re-key it. Scoped to `.plays` specifically for now + (not every expand-style op) -- narrower than the full "any expand-style + step" idea from the design notes, not yet stress-tested against a shape + that would need more. + + .. versionadded:: 17.0.0 + """ + + def __init__( + self, + base: Selector, + key: typing.Callable, + post_ops: tuple = (), + recall_player: str = None, + ) -> None: + self.base = base + self.key = key + self.post_ops = post_ops + self.recall_player = recall_player + + def __repr__(self) -> str: + return ( + f"GroupedSelector(base={self.base!r}, key={self.key!r}, " + f"post_ops={self.post_ops!r}, recall_player={self.recall_player!r})" + ) + + def _extend(self, op) -> GroupedSelector: + return GroupedSelector(self.base, self.key, self.post_ops + (op,), self.recall_player) + + @property + def plays(self) -> GroupedSelector: + """The current terminal frontier of each group, independently -- + see the class docstring for how this interacts with + `.with_recall(player)`.""" + return self._extend(_PlaysStep()) + + def after(self, *labels: str) -> GroupedSelector: + """Filter each group to just the members whose own trailing labels + are exactly `labels`, whatever came before them.""" + return self._extend(_AfterStep(labels)) + + def with_recall(self, player: str) -> GroupedSelector: + """From here on, every `.plays` on this selector also refines each + group's key by folding in `player`'s last action at that point -- + see the class docstring.""" + return GroupedSelector(self.base, self.key, self.post_ops, player) + + +def _history_of(node: Node) -> tuple: + """The plain-tuple History for `node` -- walks back to the root via the + existing `Node.parent`/`.prior_action` navigation.""" + labels: list = [] + current: Node = node + while current.parent is not None: + labels.append(current.prior_action.label) + current = current.parent + labels.reverse() + return tuple(labels) + + +class HistoryView: + """The object a `.by(callable)` key function actually receives. Supports + plain sequence indexing/slicing like a `History` tuple, plus limited + game-aware navigation (`.last_action(player)`) -- but never exposes the + `Node`/game it's privately backed by. Never returned to calling code + outside a `.by(callable)` call; not constructible directly. + + .. versionadded:: 17.0.0 + """ + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create a HistoryView directly.") + + @staticmethod + def _wrap(node: Node, history: tuple) -> HistoryView: + obj: HistoryView = HistoryView.__new__(HistoryView) + obj._node = node + obj._history = history + return obj + + def __repr__(self) -> str: + return f"HistoryView({self._history!r})" + + def __len__(self) -> int: + return len(self._history) + + def __getitem__(self, index: typing.Any) -> typing.Any: + return self._history[index] + + def last_action(self, player: str) -> str | None: + """The label of the last action `player` took on the path to this + history, wherever it fell -- `None` if `player` hasn't acted yet.""" + return _last_action(self._node, player) + + +def _last_action(node: Node, player: str) -> str | None: + """The label of the last action `player` took on the path to `node`, + wherever it fell -- `None` if `player` hasn't acted yet. Shared between + `HistoryView.last_action` and `.with_recall(player)`'s evaluation.""" + current: Node = node + while current.parent is not None: + if current.parent.player == player: + return current.prior_action.label + current = current.parent + return None + + +class H: + """Namespace of seed constructors for the node-selector algebra. Not + meant to be instantiated -- use as `H.path(...)`, conventionally imported + as `import pygambit.H as H`. + + .. versionadded:: 17.0.0 + """ + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("H is a namespace of selector constructors, not instantiable.") + + @staticmethod + def path(*steps: str) -> Selector: + """A root-anchored selection. Each step is an exact action label, or + `...` to match any single action. `H.path()` with no steps selects + the root itself. + """ + return Selector().path(*steps) + + @staticmethod + def after(*labels: str) -> Selector: + """Anywhere in the whole game whose own trailing labels are exactly + `labels`, whatever came before them -- the suffix-anchored + counterpart to the root-anchored `.path(...)`. + """ + return Selector().after(*labels) + + plays: Selector = Selector((_PlaysStep(),)) + """All currently-terminal nodes in the whole game."""