diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 48c90043f..fe9d657eb 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -74,7 +74,6 @@ Transforming game information structure Game.relabel_actions Game.set_move_actions Game.set_event_actions - Game.reveal Transforming game components @@ -119,7 +118,7 @@ Information about the game Game.get_outcome Game.get_payoffs Game.subgames - Game.minimal_subgame + Game.get_minimal_subgame .. autosummary:: :toctree: api/ @@ -291,7 +290,6 @@ Probability distributions over behavior MixedBehaviorProfile.realiz_probs MixedBehaviorProfile.infoset_probs MixedBehaviorProfile.beliefs - MixedBehaviorProfile.is_defined_at MixedBehaviorProfile.agent_max_regret MixedBehaviorProfile.agent_liap_value MixedBehaviorProfile.max_regret diff --git a/doc/tutorials/02_extensive_form.ipynb b/doc/tutorials/02_extensive_form.ipynb index 97ae7c99a..9b5767969 100644 --- a/doc/tutorials/02_extensive_form.ipynb +++ b/doc/tutorials/02_extensive_form.ipynb @@ -93,7 +93,7 @@ "id": "962b4e52", "metadata": {}, "source": [ - "To extend a game from an existing terminal node, use `Game.append_move`. To begin with, the sole root node is the terminal node.\n", + "To extend a game from an existing terminal node, use `Game.append_move`. `append_move` takes an `H`-built selector identifying the node(s) to add the move at, rather than a `Node` object directly; `gbt.H.path()` (with no arguments) selects the root itself, which to begin with is the sole terminal node.\n", "\n", "Here we extend the game from the root node by adding the first move for the \"Buyer\" player, creating two child nodes (one for each possible action)." ] @@ -106,7 +106,7 @@ "outputs": [], "source": [ "g.append_move(\n", - " g.root, # This is the node to append the move to\n", + " gbt.H.path(), # Selects the root node\n", " player=\"Buyer\",\n", " actions=[\"Trust\", \"Not trust\"]\n", ")" @@ -138,7 +138,7 @@ "outputs": [], "source": [ "g.append_move(\n", - " g.root.children[\"Trust\"],\n", + " gbt.H.path(\"Trust\"),\n", " player=\"Seller\",\n", " actions=[\"Honor\", \"Abuse\"]\n", ")" @@ -172,7 +172,13 @@ "id": "716e9b9a", "metadata": {}, "outputs": [], - "source": "g.make_outcome(\n g.root.children[\"Trust\"].children[\"Honor\"],\n {\"Buyer\": 1, \"Seller\": 1},\n \"Trustworthy\"\n)" + "source": [ + "g.make_outcome(\n", + " gbt.H.path(\"Trust\", \"Honor\"),\n", + " {\"Buyer\": 1, \"Seller\": 1},\n", + " \"Trustworthy\"\n", + ")" + ] }, { "cell_type": "code", @@ -198,7 +204,13 @@ "id": "695b1aad", "metadata": {}, "outputs": [], - "source": "g.make_outcome(\n g.root.children[\"Trust\"].children[\"Abuse\"],\n {\"Buyer\": -1, \"Seller\": 2},\n \"Untrustworthy\"\n)" + "source": [ + "g.make_outcome(\n", + " gbt.H.path(\"Trust\", \"Abuse\"),\n", + " {\"Buyer\": -1, \"Seller\": 2},\n", + " \"Untrustworthy\"\n", + ")" + ] }, { "cell_type": "code", @@ -224,7 +236,13 @@ "id": "0704ef86", "metadata": {}, "outputs": [], - "source": "g.make_outcome(\n g.root.children[\"Not trust\"],\n {\"Buyer\": 0, \"Seller\": 0},\n \"Opt-out\"\n)" + "source": [ + "g.make_outcome(\n", + " gbt.H.path(\"Not trust\"),\n", + " {\"Buyer\": 0, \"Seller\": 0},\n", + " \"Opt-out\"\n", + ")" + ] }, { "cell_type": "code", diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 0c3b3c81f..cede9646b 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -96,7 +96,13 @@ "cell_type": "markdown", "id": "0d4c7f5b", "metadata": {}, - "source": "A move belonging to the chance player is called an **event**, and is created with `append_event` rather than `append_move`, since it requires the probability distribution over its actions to be specified explicitly.\n\nThe first step in this game is that Alice is dealt a card which could be a King or Queen, each with probability 1/2.\n\nTo simulate this in Gambit, we create a chance event at the root node of the game:" + "source": [ + "A move belonging to the chance player is called an **event**, and is created with `append_event` rather than `append_move`, since it requires the probability distribution over its actions to be specified explicitly. Like `append_move`, `append_event` takes an `H`-built selector identifying the node(s) to add the event at.\n", + "\n", + "The first step in this game is that Alice is dealt a card which could be a King or Queen, each with probability 1/2.\n", + "\n", + "To simulate this in Gambit, we create a chance event at the root node of the game, using `gbt.H.path()` to select it:" + ] }, { "cell_type": "code", @@ -104,7 +110,7 @@ "id": "fe80c64c", "metadata": {}, "outputs": [], - "source": "g.append_event(\n g.root,\n actions=[\"King\", \"Queen\"],\n probs=[gbt.Rational(1, 2), gbt.Rational(1, 2)]\n)" + "source": "g.append_event(\n gbt.H.path(),\n actions={\"King\": gbt.Rational(1, 2), \"Queen\": gbt.Rational(1, 2)}\n)" }, { "cell_type": "code", @@ -126,8 +132,8 @@ "In this game, information structure is important.\n", "Alice knows her card, so the two nodes at which she has the move are part of different **information sets**.\n", "\n", - "We'll therefore need to append Alice's move separately for each of the root node's children, i.e. the scenarios where she has a King or a Queen.\n", - "Let's now add both of these possible moves:" + "We'll therefore need to append Alice's move separately for each possible card, i.e. the scenarios where she has a King or a Queen.\n", + "`append_move` takes an `H`-built selector describing which node(s) to add the move at; `gbt.H.path(label)` describes the node reached by taking the action labeled `label` from the root:" ] }, { @@ -137,12 +143,8 @@ "metadata": {}, "outputs": [], "source": [ - "for node in g.root.children:\n", - " g.append_move(\n", - " node,\n", - " player=\"Alice\",\n", - " actions=[\"Bet\", \"Fold\"]\n", - " )" + "for card in [\"King\", \"Queen\"]:\n", + " g.append_move(gbt.H.path(card), player=\"Alice\", actions=[\"Bet\", \"Fold\"])" ] }, { @@ -164,13 +166,13 @@ "\n", "In contrast, Bob does not know Alice’s card, and therefore cannot distinguish between the two nodes at which he has to make his decision:\n", "\n", - " - Chance player chooses King, then Alice Bets: `g.root.children[\"King\"].children[\"Bet\"]`\n", - " - Chance player chooses Queen, then Alice Bets: `g.root.children[\"Queen\"].children[\"Bet\"]`\n", + " - Chance player chooses King, then Alice Bets: `gbt.H.path(\"King\", \"Bet\")`\n", + " - Chance player chooses Queen, then Alice Bets: `gbt.H.path(\"Queen\", \"Bet\")`\n", "\n", "In other words, Bob's decision when Alice Bets with a Queen should be part of the same information set as Bob's decision when Alice Bets with a King.\n", "\n", "To set this scenario up in Gambit, we'll need to add both possible moves as part of the same information set (represented in Gambit as an `Infoset`).\n", - "This can be done by passing a list of nodes to the `append_move` method:" + "This can be done with a single selector: `gbt.H.path(..., \"Bet\")` describes the node reached by *any* single action from the root (either card), followed by \"Bet\" -- so it matches both of Bob's decision nodes at once, joining them into one information set:" ] }, { @@ -181,7 +183,7 @@ "outputs": [], "source": [ "g.append_move(\n", - " [g.root.children[\"King\"].children[\"Bet\"], g.root.children[\"Queen\"].children[\"Bet\"]],\n", + " gbt.H.path(..., \"Bet\"),\n", " player=\"Bob\",\n", " actions=[\"Call\", \"Fold\"]\n", ")" @@ -209,7 +211,35 @@ "id": "29aa60a0", "metadata": {}, "outputs": [], - "source": "# Alice folds, Bob wins small\ng.make_outcome(\n [g.root.children[\"King\"].children[\"Fold\"], g.root.children[\"Queen\"].children[\"Fold\"]],\n {\"Alice\": -1, \"Bob\": 1},\n \"Lose\"\n)\n\n# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\ng.make_outcome(\n g.root.children[\"Queen\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": -2, \"Bob\": 2},\n \"Lose Big\"\n)\n\n# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\ng.make_outcome(\n g.root.children[\"King\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": 2, \"Bob\": -2},\n \"Win Big\"\n)\n\n# Bob does not call Alice's Bet, Alice wins small\ng.make_outcome(\n [g.root.children[\"King\"].children[\"Bet\"].children[\"Fold\"],\n g.root.children[\"Queen\"].children[\"Bet\"].children[\"Fold\"]],\n {\"Alice\": 1, \"Bob\": -1},\n \"Win\"\n)" + "source": [ + "# Alice folds, Bob wins small\n", + "g.make_outcome(\n", + " gbt.H.path(..., \"Fold\"),\n", + " {\"Alice\": -1, \"Bob\": 1},\n", + " \"Lose\"\n", + ")\n", + "\n", + "# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\n", + "g.make_outcome(\n", + " gbt.H.path(\"Queen\", \"Bet\", \"Call\"),\n", + " {\"Alice\": -2, \"Bob\": 2},\n", + " \"Lose Big\"\n", + ")\n", + "\n", + "# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\n", + "g.make_outcome(\n", + " gbt.H.path(\"King\", \"Bet\", \"Call\"),\n", + " {\"Alice\": 2, \"Bob\": -2},\n", + " \"Win Big\"\n", + ")\n", + "\n", + "# Bob does not call Alice's Bet, Alice wins small\n", + "g.make_outcome(\n", + " gbt.H.path(..., \"Bet\", \"Fold\"),\n", + " {\"Alice\": 1, \"Bob\": -1},\n", + " \"Win\"\n", + ")" + ] }, { "cell_type": "code", @@ -819,7 +849,7 @@ "outputs": [], "source": [ "small_game = gbt.Game.new_tree()\n", - "small_game.append_event(small_game.root, [\"a\", \"b\", \"c\"], [gbt.Rational(1, 3)] * 3)\n", + "small_game.append_event(gbt.H.path(), dict.fromkeys([\"a\", \"b\", \"c\"], gbt.Rational(1, 3)))\n", "list(small_game.root.action_probs.values())" ] }, @@ -837,8 +867,8 @@ "outputs": [], "source": [ "small_game.make_event(\n", - " [small_game.root],\n", - " [gbt.Rational(1, 4), gbt.Rational(1, 2), gbt.Rational(1, 4)]\n", + " gbt.H.path(),\n", + " {\"a\": gbt.Rational(1, 4), \"b\": gbt.Rational(1, 2), \"c\": gbt.Rational(1, 4)}\n", ")\n", "list(small_game.root.action_probs.values())" ] @@ -859,8 +889,8 @@ "outputs": [], "source": [ "small_game.make_event(\n", - " [small_game.root],\n", - " [gbt.Decimal(\".25\"), gbt.Decimal(\".50\"), gbt.Decimal(\".25\")]\n", + " gbt.H.path(),\n", + " {\"a\": gbt.Decimal(\".25\"), \"b\": gbt.Decimal(\".50\"), \"c\": gbt.Decimal(\".25\")}\n", ")\n", "list(small_game.root.action_probs.values())" ] @@ -884,7 +914,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [\"1/4\", \"1/2\", \"1/4\"])\n", + "small_game.make_event(gbt.H.path(), {\"a\": \"1/4\", \"b\": \"1/2\", \"c\": \"1/4\"})\n", "list(small_game.root.action_probs.values())" ] }, @@ -895,7 +925,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [\".25\", \".50\", \".25\"])\n", + "small_game.make_event(gbt.H.path(), {\"a\": \".25\", \"b\": \".50\", \"c\": \".25\"})\n", "list(small_game.root.action_probs.values())" ] }, @@ -919,7 +949,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [.25, .50, .25])\n", + "small_game.make_event(gbt.H.path(), {\"a\": .25, \"b\": .50, \"c\": .25})\n", "list(small_game.root.action_probs.values())" ] }, @@ -937,7 +967,12 @@ "id": "1991d288", "metadata": {}, "outputs": [], - "source": "try:\n small_game.make_event([small_game.root], [1/3, 1/3, 1/3])\nexcept ValueError as e:\n print(\"ValueError:\", e)\n" + "source": [ + "try:\n", + " small_game.make_event(gbt.H.path(), {\"a\": 1/3, \"b\": 1/3, \"c\": 1/3})\n", + "except ValueError as e:\n", + " print(\"ValueError:\", e)" + ] }, { "cell_type": "markdown", 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..ba282c149 --- /dev/null +++ b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb @@ -0,0 +1,478 @@ +{ + "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": null, + "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\n", + "\n", + "\n", + "def selector_for_histories(histories):\n", + " \"\"\"A Selector matching exactly the given (already-materialized) Histories --\n", + " for passing a `_get_groups` group to a mutation method, which only accepts a\n", + " Selector/GroupedSelector, not a bare iterable of History tuples.\"\"\"\n", + " keys = frozenset(histories)\n", + " return H.after().filter(lambda h: h[:] in keys)" + ] + }, + { + "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": null, + "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": [], + "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(), dict.fromkeys(cards, gbt.Rational(1, 3)))\n", + "for c in cards:\n", + " remaining = [x for x in cards if x != c]\n", + " g.append_event(H.path(c), dict.fromkeys(remaining, gbt.Rational(1, 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": null, + "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": [], + "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(\n", + " selector_for_histories(group), {win: amount, lose: -amount}, f\"{win} wins {amount}\"\n", + " )\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": null, + "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": [], + "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\": half, \"1B\": half})\n", + "for t1 in [\"1G\", \"1B\"]:\n", + " g.append_event(H.path(t1), {\"2g\": half, \"2b\": 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(selector_for_histories(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": null, + "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": [], + "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\": half, \"L\": 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": null, + "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": [], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"Untimeable (Jakobsen et al. 2016)\")\n", + "\n", + "g.append_event(H.path(), dict.fromkeys([\"1\", \"2\"], gbt.Rational(1, 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", + " selector_for_histories(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/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index 0b8e7ea64..30954e117 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -666,7 +666,7 @@ "id": "77dc34c8", "metadata": {}, "outputs": [], - "source": "gbt_one_card_poker = gbt.Game.new_tree(\n players=[\"Alice\", \"Bob\"],\n title=\"Stripped-Down Poker: a simple game of one-card poker from Reiley et al (2008).\"\n)\n\ngbt_one_card_poker.append_event(\n gbt_one_card_poker.root,\n actions=[\"King\", \"Queen\"],\n probs=[gbt.Rational(1, 2), gbt.Rational(1, 2)]\n)\n\nfor node in gbt_one_card_poker.root.children:\n gbt_one_card_poker.append_move(\n node,\n player=\"Alice\",\n actions=[\"Bet\", \"Fold\"]\n )\n\ngbt_one_card_poker.append_move(\n [\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"],\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"]\n ],\n player=\"Bob\",\n actions=[\"Call\", \"Fold\"]\n)\n\n# Alice folds, Bob wins small\ngbt_one_card_poker.make_outcome(\n [\n gbt_one_card_poker.root.children[\"King\"].children[\"Fold\"],\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Fold\"]\n ],\n {\"Alice\": -1, \"Bob\": 1},\n \"Lose\"\n)\n\n# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\ngbt_one_card_poker.make_outcome(\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": -2, \"Bob\": 2},\n \"Lose Big\"\n)\n\n# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\ngbt_one_card_poker.make_outcome(\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": 2, \"Bob\": -2},\n \"Win Big\"\n)\n\n# Bob does not call Alice's Bet, Alice wins small\ngbt_one_card_poker.make_outcome(\n [\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"].children[\"Fold\"],\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"].children[\"Fold\"]\n ],\n {\"Alice\": 1, \"Bob\": -1},\n \"Win\"\n)" + "source": "gbt_one_card_poker = gbt.Game.new_tree(\n players=[\"Alice\", \"Bob\"],\n title=\"Stripped-Down Poker: a simple game of one-card poker from Reiley et al (2008).\"\n)\n\ngbt_one_card_poker.append_event(\n gbt.H.path(),\n actions={\"King\": gbt.Rational(1, 2), \"Queen\": gbt.Rational(1, 2)}\n)\n\nfor card in [\"King\", \"Queen\"]:\n gbt_one_card_poker.append_move(\n gbt.H.path(card),\n player=\"Alice\",\n actions=[\"Bet\", \"Fold\"]\n )\n\ngbt_one_card_poker.append_move(\n gbt.H.path(..., \"Bet\"),\n player=\"Bob\",\n actions=[\"Call\", \"Fold\"]\n)\n\n# Alice folds, Bob wins small\ngbt_one_card_poker.make_outcome(\n gbt.H.path(..., \"Fold\"),\n {\"Alice\": -1, \"Bob\": 1},\n \"Lose\"\n)\n\n# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\ngbt_one_card_poker.make_outcome(\n gbt.H.path(\"Queen\", \"Bet\", \"Call\"),\n {\"Alice\": -2, \"Bob\": 2},\n \"Lose Big\"\n)\n\n# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\ngbt_one_card_poker.make_outcome(\n gbt.H.path(\"King\", \"Bet\", \"Call\"),\n {\"Alice\": 2, \"Bob\": -2},\n \"Win Big\"\n)\n\n# Bob does not call Alice's Bet, Alice wins small\ngbt_one_card_poker.make_outcome(\n gbt.H.path(..., \"Bet\", \"Fold\"),\n {\"Alice\": 1, \"Bob\": -1},\n \"Win\"\n)" }, { "cell_type": "code", diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 94538d0bf..60691687f 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -491,10 +491,6 @@ class MixedBehaviorProfile: """ raise NotImplementedError - def _is_defined_at(self, infoset: Infoset) -> bool: - """Returns whether the profile specifies a probability distribution at infoset.""" - raise NotImplementedError - def _payoff(self, player: str) -> ProfileDType: """Returns the expected payoff to player.""" raise NotImplementedError @@ -710,26 +706,6 @@ class MixedBehaviorProfile: infoset = self._resolve_infoset_for_node(index) self._setprob_infoset(infoset, distribution, sparse=sparse) - def is_defined_at(self, infoset: NodeReference) -> bool: - """Returns whether the profile has probabilities defined at the information set. - A profile can be well-defined if probabilities are not specified at some information sets, - as long as those information sets are reached with zero probability. - - Parameters - ---------- - infoset : Node or str - A node belonging to the information set to check, or such a node's label. - - Raises - ------ - MismatchError - If `infoset` is a ``Node`` from a different game. - KeyError - If `infoset` is a string and no node in the game has that label. - """ - self._check_validity() - return self._is_defined_at(self.game._resolve_infoset(infoset, "is_defined_at")) - @property def payoffs(self) -> PayoffVector: """Returns the expected payoff to each player, if all players play according to @@ -1036,9 +1012,6 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile): def __len__(self) -> int: return deref(self.profile).BehaviorProfileLength() - def _is_defined_at(self, infoset: Infoset) -> bool: - return deref(self.profile).IsDefinedAt(infoset._resolve()) - @cython.cfunc def _getprob_action(self, index: c_GameAction) -> object: return deref(self.profile).getaction(index) @@ -1168,9 +1141,6 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile): def __len__(self) -> int: return deref(self.profile).BehaviorProfileLength() - def _is_defined_at(self, infoset: Infoset) -> bool: - return deref(self.profile).IsDefinedAt(infoset._resolve()) - @cython.cfunc def _getprob_action(self, index: c_GameAction) -> object: return rat_to_py(deref(self.profile).getaction(index)) diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 68bfe36ab..6db710453 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -346,7 +346,6 @@ cdef extern from "games/game.h": string) except +ValueError void MakeOutcomeNull(stdvector[c_GameNode]) except +ValueError void MakeOutcomeNull(stdvector[stdvector[c_GameStrategy]]) except +ValueError - void Reveal(c_GameInfoset, c_GamePlayer) except + void RelabelActions(c_GameInfoset, stdmap[string, string]) except +ValueError void SetMoveActions(c_GameInfoset, stdvector[string]) except +ValueError void SetEventActions(c_GameInfoset, stdvector[string], @@ -399,7 +398,6 @@ cdef extern from "games/behavmixed.h" namespace "Gambit": c_Game GetGame() except + bool IsInvalidated() int BehaviorProfileLength() except + - bool IsDefinedAt(c_GameInfoset) except + c_MixedBehaviorProfile[T] Normalize() # except + doesn't compile T getitem "operator[]"(int) except +IndexError T getaction "operator[]"(c_GameAction) except +IndexError diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index 2d73be109..17618a30d 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -100,9 +100,6 @@ def _resolve_by_label(collection, label: str, scope: str, kind: str, kind_plural return matches[0] -NodeReference = Node | str -NodeReferenceSet = typing.Iterable[NodeReference] - ProfileDType = float | Rational @@ -192,6 +189,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 f5bfac7aa..274ccd4aa 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -542,6 +542,104 @@ 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. + + Internal: the `H` selector algebra's evaluator, interpreting the + selector's ops in order, starting from the root, reusing `Node`'s + existing navigation (`.children`, `.plays`) rather than walking the + C++ tree directly. Not part of the public API yet -- used to resolve + a `Selector`/`GroupedSelector` argument to `append_move`, + `append_event`, `append_infoset`, and `make_outcome`. + """ + 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. + + Internal: the History-materializing counterpart to `_get_nodes`, kept + for use by `_get_groups` and tests. Not part of the public API yet. + """ + 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. + + Internal: the History-materializing counterpart to `_group_nodes`, + kept for use by tests. Not part of the public API yet. + """ + 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.""" @@ -642,33 +740,51 @@ class Game: ) return GameSubgames.wrap(self.game) - def minimal_subgame(self, infoset: NodeReference) -> Subgame: - """Returns the smallest subgame containing `infoset`. + def get_minimal_subgame(self, node: Selector) -> Subgame: + """Returns the smallest subgame containing the information set or event that + the node identified by `node` belongs to. + + `node` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + + .. versionadded:: 16.7.0 + .. versionchanged:: 17.0.0 + Renamed from `minimal_subgame`. `node` (formerly `infoset`) is now a + `Selector`; a `Node` or `str` is no longer accepted directly -- build + one with `H`. Parameters ---------- - infoset : Node or str - A node belonging to the information set to query, or such a node's label. + node : Selector + A `Selector` resolving to a single node belonging to the information + set or event to query. Returns ------- Subgame - The smallest subgame containing `infoset`. - - .. versionadded:: 16.7.0 + The smallest subgame containing the information set or event that + `node` belongs to. Raises ------ + TypeError + If `node` is not a `Selector`. UndefinedOperationError If the game does not have a tree representation. - MismatchError - If `infoset` is from a different game. + ValueError + If `node` does not resolve to exactly one node, or belongs to no + information set or event (it is terminal). """ if not self.is_tree: raise UndefinedOperationError( - "Operation only defined for games with a tree representation" + "get_minimal_subgame(): operation only defined for games " + "with a tree representation" ) - resolved_infoset = self._resolve_infoset_or_event(infoset, "minimal_subgame") + if not isinstance(node, Selector): + raise TypeError( + f"get_minimal_subgame(): node must be a Selector, not {node.__class__.__name__}" + ) + resolved_infoset = self._resolve_infoset_or_event(node, "get_minimal_subgame") return Subgame.wrap( self.game.deref().GetMinimalSubgame( cython.cast(_InfosetOrEvent, resolved_infoset)._resolve() @@ -1317,6 +1433,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( @@ -1337,10 +1464,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: @@ -1480,7 +1612,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: Selector | GroupedSelector, player: str, actions: list[str]) -> None: """Add a move for `player` at terminal `nodes`. All elements of `nodes` become part of @@ -1488,18 +1620,45 @@ class Game: `player` must be a personal player; use `append_event` to add a chance move. + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) 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` is now a `Selector` or `GroupedSelector`; a `Node` or + `NodeReferenceSet` is no longer accepted directly -- build one with `H`. + Raises ------ + TypeError + If `nodes` is not a `Selector` or `GroupedSelector`. UndefinedOperationError If `nodes` are not all terminal, or `actions` is empty. - MismatchError - If an element from `nodes` is a `Node` from a different game. KeyError If no player in the game has label `player`. ValueError 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_at(group, player, actions) + return + if not isinstance(nodes, Selector): + raise TypeError( + f"append_move(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) + self._append_move_at(nodes, player, actions) + + def _append_move_at(self, nodes: Selector | list[Node], player: str, + actions: list[str]) -> None: + """Internal: shared body of `append_move`, taking either a `Selector` or an + already-resolved list of `Node` (the latter used for one group at a time, + dispatched from a `GroupedSelector`).""" resolved_player = self._resolve_player(player, "append_move") if not actions: raise UndefinedOperationError("append_move(): `actions` must be a nonempty list") @@ -1520,114 +1679,177 @@ class Game: for n in resolved_nodes[1:]: self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_infoset._resolve()) - def append_infoset(self, nodes: Node | NodeReferenceSet, - infoset: NodeReference) -> None: - """Add a move in the information set or event `infoset` at terminal `nodes`. + def append_infoset(self, nodes: Selector | GroupedSelector, + infoset: Selector) -> None: + """Add a move at terminal `nodes`, joining the information set that the node + identified by `infoset` belongs to. + + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) or a `GroupedSelector` (an `H`-built + `.by(...)` expression, whose groups are pooled together -- every resolved + node joins the same `infoset` regardless of grouping). + + `infoset` is a `Selector` that must resolve to exactly one node; that node + must belong to a personal player and must not be terminal -- the information + set it currently belongs to is the one joined. + + .. versionchanged:: 17.0.0 + `nodes` is now a `Selector` or `GroupedSelector`, and `infoset` is now a + `Selector` identifying a node by the information set it belongs to, + rather than a `Node` or `str` reference to an `Infoset`/`Event` directly. + Joining an existing chance event is no longer supported here. Parameters ---------- - nodes : Node or NodeReferenceSet + nodes : Selector or GroupedSelector The nonempty set of terminal nodes at which to add the move. - infoset : Node or str - A node belonging to the information set or event to join, or such a - node's label. + infoset : Selector + A `Selector` resolving to a single node of the personal player's + information set to join. Raises ------ + TypeError + If `nodes` is not a `Selector` or `GroupedSelector`, or `infoset` is not + a `Selector`. UndefinedOperationError - If any element in `nodes` is not a terminal node. - MismatchError - If an element in `nodes` is a `Node` from a different game, - or `infoset` is a `Node` from a different game. + If any element in `nodes` is not a terminal node, or `infoset` resolves + to a terminal node or to a chance node. ValueError - If `nodes` has duplicated elements, or is empty. + If `nodes` has duplicated elements, or is empty; or if `infoset` does not + resolve to exactly one node. """ - resolved_infoset = cython.cast( - _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "append_infoset") - ) + if isinstance(nodes, GroupedSelector): + nodes = [n for group in self._group_nodes(nodes).values() for n in group] + elif not isinstance(nodes, Selector): + raise TypeError( + f"append_infoset(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) + if not isinstance(infoset, Selector): + raise TypeError( + f"append_infoset(): infoset must be a Selector, not {infoset.__class__.__name__}" + ) + infoset_node = cython.cast(Node, self._resolve_node(infoset, "append_infoset", "infoset")) + if not infoset_node.infoset: + raise UndefinedOperationError( + "append_infoset(): infoset must resolve to a personal player's node" + ) + resolved_infoset = cython.cast(Infoset, infoset_node.infoset) resolved_nodes = self._resolve_nodes(nodes, "append_infoset", "nodes") if any(len(n.children) > 0 for n in resolved_nodes): raise UndefinedOperationError("append_infoset(): `nodes` must be terminal nodes") for n in resolved_nodes: self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_infoset._resolve()) - def append_event(self, nodes: Node | NodeReferenceSet, - actions: list[str], - probs: typing.Sequence | typing.Mapping) -> None: - """Add a chance move at terminal `nodes`, with distribution `probs`. All elements - of `nodes` become part of a new event, with actions labeled according to `actions`. + def append_event(self, nodes: Selector | GroupedSelector, + actions: typing.Mapping) -> None: + """Add a chance move at terminal `nodes`, with actions and their probabilities + given by `actions`. All elements of `nodes` become part of a new event. + + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) or a `GroupedSelector` (an `H`-built + `.by(...)` expression) -- in the latter case, one new event is created per + distinct group, rather than one spanning every match. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `nodes` is now a `Selector` or `GroupedSelector`; a `Node` or + `NodeReferenceSet` is no longer accepted directly -- build one with `H`. + .. versionchanged:: 17.0.0 + `actions` and `probs` are combined into a single mapping from action + label to probability, rather than a list of labels plus a separate + probability sequence or mapping. Parameters ---------- - nodes : Node or NodeReferenceSet + nodes : Selector or GroupedSelector The nonempty set of terminal nodes at which to add the move. - actions : list of str - The labels of the actions of the new event. Nonempty, with no empty or - duplicated label. - probs : sequence or mapping - The probability distribution over `actions`. A sequence must specify one - probability per action, in the order given in `actions`. A mapping from - action labels to probabilities may be sparse; omitted actions are assigned - probability zero. Probabilities are non-negative and sum to exactly one. + actions : Mapping + A mapping from each new action's label to its probability. Nonempty, + with no empty label. Probabilities are non-negative and sum to exactly + one. Raises ------ + TypeError + If `nodes` is not a `Selector` or `GroupedSelector`. UndefinedOperationError If `nodes` are not all terminal, or `actions` is empty. - MismatchError - If an element from `nodes` is a `Node` from a different game. - KeyError - If a key of `probs` matches no label in `actions`. - IndexError - If a sequence `probs` does not have exactly one entry per action. ValueError If `nodes` has duplicated elements, or is empty; if `actions` contains - an empty or a duplicated label; or if `probs` are not non-negative numbers + an empty label; or if the probabilities are not non-negative numbers summing to exactly one. """ - if not actions: - raise UndefinedOperationError("append_event(): `actions` must be a nonempty list") - if any(not label for label in actions): + if isinstance(nodes, GroupedSelector): + for group in self._group_nodes(nodes).values(): + if not group: + continue + self._append_event_at(group, actions) + return + if not isinstance(nodes, Selector): + raise TypeError( + f"append_event(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) + self._append_event_at(nodes, actions) + + def _append_event_at(self, nodes: Selector | list[Node], actions: typing.Mapping) -> None: + """Internal: shared body of `append_event`, taking either a `Selector` or an + already-resolved list of `Node` (the latter used for one group at a time, + dispatched from a `GroupedSelector`).""" + action_labels = list(actions) + if not action_labels: + raise UndefinedOperationError("append_event(): `actions` must be a nonempty mapping") + if any(not label for label in action_labels): raise ValueError("append_event(): action labels must not be empty") - if len(set(actions)) != len(actions): - raise ValueError("append_event(): action labels must be unique") resolved_nodes = self._resolve_nodes(nodes, "append_event", "nodes") if any(len(n.children) > 0 for n in resolved_nodes): raise UndefinedOperationError("append_event(): `nodes` must be terminal nodes") - resolved_probs = self._resolve_probs(probs, actions, "append_event") resolved_node = cython.cast(Node, resolved_nodes[0]) c_actions = stdvector[string]() - for label in actions: + for label in action_labels: c_actions.push_back(label.encode("utf-8")) c_probs = stdvector[c_Number]() - for p in resolved_probs: - c_probs.push_back(_to_number(p)) + for label in action_labels: + c_probs.push_back(_to_number(actions[label])) self.game.deref().AppendEvent(resolved_node.node, c_actions, c_probs) resolved_event = cython.cast(Event, resolved_node.event) for n in resolved_nodes[1:]: self.game.deref().AppendMove(cython.cast(Node, n).node, resolved_event._resolve()) - def insert_move(self, node: Node | str, + def insert_move(self, node: Selector, player: str, actions: list[str]) -> None: - """Insert a move for `player` prior to the node `node`, with actions labeled - according to `actions`. `node` becomes the first child of the newly-inserted node. + """Insert a move for `player` prior to the node identified by `node`, with + actions labeled according to `actions`. The node becomes the first child of + the newly-inserted node. `player` must be a personal player; use `insert_event` to insert a chance move. + `node` is a `Selector` (an `H`-built expression, evaluated against this game) + that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. + Raises ------ + TypeError + If `node` is not a `Selector`. UndefinedOperationError If `actions` is empty. - MismatchError - If `node` is a `Node` from a different game. KeyError If no player in the game has label `player`. ValueError - If `actions` contains an empty or a duplicated label. + If `node` does not resolve to exactly one node, or `actions` contains an + empty or a duplicated label. """ + if not isinstance(node, Selector): + raise TypeError( + f"insert_move(): node must be a Selector, not {node.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "insert_move")) resolved_player = self._resolve_player(player, "insert_move") if not actions: @@ -1641,85 +1863,105 @@ class Game: c_actions.push_back(label.encode("utf-8")) self.game.deref().InsertMove(resolved_node.node, resolved_player, c_actions) - def insert_infoset(self, node: Node | str, - infoset: NodeReference) -> None: - """Insert a move in the information set or event `infoset` prior to the node - `node`. `node` becomes the first child of the newly-inserted node. + def insert_infoset(self, node: Selector, + infoset: Selector) -> None: + """Insert a move in the information set or event that the node identified by + `infoset` belongs to, prior to the node identified by `node`. The node + becomes the first child of the newly-inserted node. - Parameters - ---------- - node : Node or str - The node before which to insert the move. - infoset : Node or str - A node belonging to the information set or event to join, or such a - node's label. + `node` and `infoset` are each a `Selector` (an `H`-built expression, + evaluated against this game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. + .. versionchanged:: 17.0.0 + `infoset` is now a `Selector` identifying a node by the information set + or event it belongs to, rather than a `Node` or `str` reference to an + `Infoset`/`Event` directly. Raises ------ - MismatchError - If `node` is a `Node` from a different game, or `infoset` is a `Node` from a - different game. + TypeError + If `node` or `infoset` is not a `Selector`. + ValueError + If `node` or `infoset` does not resolve to exactly one node, or if the + node identified by `infoset` belongs to no information set or event (it + is terminal). """ + if not isinstance(node, Selector): + raise TypeError( + f"insert_infoset(): node must be a Selector, not {node.__class__.__name__}" + ) + if not isinstance(infoset, Selector): + raise TypeError( + f"insert_infoset(): infoset must be a Selector, not {infoset.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "insert_infoset")) resolved_infoset = cython.cast( _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "insert_infoset") ) self.game.deref().InsertMove(resolved_node.node, resolved_infoset._resolve()) - def insert_event(self, node: Node | str, - actions: list[str], - probs: typing.Sequence | typing.Mapping) -> None: - """Insert a chance move prior to the node `node`, with actions labeled according - to `actions` and distribution `probs`. `node` becomes the first child of the - newly-inserted node. + def insert_event(self, node: Selector, actions: typing.Mapping) -> None: + """Insert a chance move prior to the node identified by `node`, with actions + and their probabilities given by `actions`. The node becomes the first + child of the newly-inserted node. + + `node` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. + .. versionchanged:: 17.0.0 + `actions` and `probs` are combined into a single mapping from action + label to probability, rather than a list of labels plus a separate + probability sequence or mapping. Parameters ---------- - node : Node or str - The node before which to insert the move. - actions : list of str - The labels of the actions of the new event. Nonempty, with no empty or - duplicated label. - probs : sequence or mapping - The probability distribution over `actions`. A sequence must specify one - probability per action, in the order given in `actions`. A mapping from - action labels to probabilities may be sparse; omitted actions are assigned - probability zero. Probabilities are non-negative and sum to exactly one. + node : Selector + A `Selector` resolving to the single node before which to insert the + move. + actions : Mapping + A mapping from each new action's label to its probability. Nonempty, + with no empty label. Probabilities are non-negative and sum to exactly + one. Raises ------ + TypeError + If `node` is not a `Selector`. UndefinedOperationError If `actions` is empty. - MismatchError - If `node` is a `Node` from a different game. - KeyError - If a key of `probs` matches no label in `actions`. - IndexError - If a sequence `probs` does not have exactly one entry per action. ValueError - If `actions` contains an empty or a duplicated label, or if `probs` are not - non-negative numbers summing to exactly one. + If `node` does not resolve to exactly one node; if `actions` contains + an empty label; or if the probabilities are not non-negative numbers + summing to exactly one. """ + if not isinstance(node, Selector): + raise TypeError( + f"insert_event(): node must be a Selector, not {node.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "insert_event")) - if not actions: - raise UndefinedOperationError("insert_event(): `actions` must be a nonempty list") - if any(not label for label in actions): + action_labels = list(actions) + if not action_labels: + raise UndefinedOperationError("insert_event(): `actions` must be a nonempty mapping") + if any(not label for label in action_labels): raise ValueError("insert_event(): action labels must not be empty") - if len(set(actions)) != len(actions): - raise ValueError("insert_event(): action labels must be unique") - resolved_probs = self._resolve_probs(probs, actions, "insert_event") c_actions = stdvector[string]() - for label in actions: + for label in action_labels: c_actions.push_back(label.encode("utf-8")) c_probs = stdvector[c_Number]() - for p in resolved_probs: - c_probs.push_back(_to_number(p)) + for label in action_labels: + c_probs.push_back(_to_number(actions[label])) self.game.deref().InsertEvent(resolved_node.node, c_actions, c_probs) - def copy_tree(self, src: Node | str, dest: Node | str) -> None: - """Copy the subtree rooted at the node `src` to the node `dest`. + def copy_tree(self, src: Selector, dest: Selector) -> None: + """Copy the subtree rooted at the node identified by `src` to the node + identified by `dest`. Each node in the subtree copied to follow `dest` is placed in the same information set as the corresponding node in the original subtree under `src`. @@ -1730,43 +1972,76 @@ class Game: The outcome associated with `dest` is not changed by this operation. + `src` and `dest` are each a `Selector` (an `H`-built expression, evaluated + against this game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `src` and `dest` are now `Selector`s; a `Node` or `str` is no longer + accepted directly -- build one with `H`. + Parameters ---------- - src : Node or str - The root of the source subtree to copy - dest : Node or str - The destination subtree to copy to. `dest` must be a terminal node. + src : Selector + A `Selector` resolving to the root of the source subtree to copy. + dest : Selector + A `Selector` resolving to the destination subtree to copy to. Must + resolve to a terminal node. Raises ------ - MismatchError - If `src` or `dest` is not a member of the same game as this node. + TypeError + If `src` or `dest` is not a `Selector`. UndefinedOperationError If `dest` is not a terminal node. + ValueError + If `src` or `dest` does not resolve to exactly one node. """ + if not isinstance(src, Selector): + raise TypeError(f"copy_tree(): src must be a Selector, not {src.__class__.__name__}") + if not isinstance(dest, Selector): + raise TypeError( + f"copy_tree(): dest must be a Selector, not {dest.__class__.__name__}" + ) resolved_src = cython.cast(Node, self._resolve_node(src, "copy_tree", "src")) resolved_dest = cython.cast(Node, self._resolve_node(dest, "copy_tree", "dest")) if not resolved_dest.is_terminal: raise UndefinedOperationError("copy_tree(): `dest` must be a terminal node.") self.game.deref().CopyTree(resolved_dest.node, resolved_src.node) - def move_tree(self, src: Node | str, dest: Node | str) -> None: - """Move the subtree rooted at 'src' to 'dest'. + def move_tree(self, src: Selector, dest: Selector) -> None: + """Move the subtree rooted at the node identified by `src` to the node + identified by `dest`. + + `src` and `dest` are each a `Selector` (an `H`-built expression, evaluated + against this game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `src` and `dest` are now `Selector`s; a `Node` or `str` is no longer + accepted directly -- build one with `H`. Parameters ---------- - src : Node or str - The root of the source subtree to move - dest : Node or str - The destination subtree to move to. `dest` must be a terminal node. + src : Selector + A `Selector` resolving to the root of the source subtree to move. + dest : Selector + A `Selector` resolving to the destination subtree to move to. Must + resolve to a terminal node. Raises ------ - MismatchError - If `src` or `dest` is not a member of the same game as this node. + TypeError + If `src` or `dest` is not a `Selector`. UndefinedOperationError If `dest` is not a terminal node, or `dest` is a successor of `src`. + ValueError + If `src` or `dest` does not resolve to exactly one node. """ + if not isinstance(src, Selector): + raise TypeError(f"move_tree(): src must be a Selector, not {src.__class__.__name__}") + if not isinstance(dest, Selector): + raise TypeError( + f"move_tree(): dest must be a Selector, not {dest.__class__.__name__}" + ) resolved_src = cython.cast(Node, self._resolve_node(src, "move_tree", "src")) resolved_dest = cython.cast(Node, self._resolve_node(dest, "move_tree", "dest")) if not resolved_dest.is_terminal: @@ -1775,48 +2050,74 @@ class Game: raise UndefinedOperationError("move_tree(): `dest` cannot be a successor of `src`.") self.game.deref().MoveTree(resolved_dest.node, resolved_src.node) - def delete_parent(self, node: Node | str) -> None: - """Delete the parent node of `node`. `node` replaces its parent in the tree. All other - subtrees rooted at `node`'s parent are deleted. + def delete_parent(self, node: Selector) -> None: + """Delete the parent of the node identified by `node`. That node replaces + its parent in the tree. All other subtrees rooted at the parent are deleted. + + `node` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - node : Node or str - The node to retain after deleting its parent. - If a string is passed, the node is determined by finding the node with that label, - if any. + node : Selector + A `Selector` resolving to the single node to retain after deleting its + parent. Raises ------ - MismatchError - If `node` is a `Node` from a different game. + TypeError + If `node` is not a `Selector`. + ValueError + If `node` does not resolve to exactly one node. """ + if not isinstance(node, Selector): + raise TypeError( + f"delete_parent(): node must be a Selector, not {node.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "delete_parent")) self.game.deref().DeleteParent(resolved_node.node) - def delete_tree(self, node: Node | str) -> None: - """Truncate the game tree at `node`, deleting the subtree beneath it. + def delete_tree(self, node: Selector) -> None: + """Truncate the game tree at the node identified by `node`, deleting the + subtree beneath it. + + `node` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + + .. versionchanged:: 17.0.0 + `node` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - node : Node or str - The node to truncate the game at. If a string is passed, the node is determined by - finding the node with that label, if any. + node : Selector + A `Selector` resolving to the single node to truncate the game at. Raises ------ - MismatchError - If `node` is a `Node` from a different game. + TypeError + If `node` is not a `Selector`. + ValueError + If `node` does not resolve to exactly one node. """ + if not isinstance(node, Selector): + raise TypeError( + f"delete_tree(): node must be a Selector, not {node.__class__.__name__}" + ) resolved_node = cython.cast(Node, self._resolve_node(node, "delete_tree")) self.game.deref().DeleteTree(resolved_node.node) def set_move_actions(self, - infoset: NodeReference, + infoset: Selector, actions: list[str], drop: bool = False, add: bool = True) -> None: - """Set the actions at the move `infoset` to be `actions`, matching by label. + """Set the actions at the move that the node identified by `infoset` + belongs to, to be `actions`, matching by label. An entry of `actions` matching the label of a current action refers to that action, which keeps its subtrees; an entry matching no current action creates a new action there, @@ -1824,13 +2125,19 @@ class Game: in `actions` is deleted, along with the subtrees its branches lead to. Listing the current labels in a new order reorders the actions as well as the children. + `infoset` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `infoset` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - infoset : Node or str - A node belonging to the (personal player's) move at which to set the - actions, or such a node's label. + infoset : Selector + A `Selector` resolving to a single node belonging to the (personal + player's) move at which to set the actions. actions : list of str The labels of the actions the move is to have, in order. Must be nonempty and without duplicates; each label must be a valid, nonempty label. @@ -1843,25 +2150,27 @@ class Game: Raises ------ - MismatchError - If `infoset` is a `Node` from a different game. - KeyError - If `infoset` is a string matching no node. TypeError - If `actions` is a string, or not an iterable of strings. + If `infoset` is not a `Selector`; or if `actions` is a string, or not + an iterable of strings. UndefinedOperationError If `actions` is empty. ValueError - If `infoset` resolves to an event rather than a personal player's move - (use `set_event_actions` for an event); or if a label in `actions` is - repeated, empty, or invalid; or if adding or deleting actions is not - confirmed by `add`/`drop`. + If `infoset` does not resolve to exactly one node, or resolves to an + event rather than a personal player's move (use `set_event_actions` + for an event); or if a label in `actions` is repeated, empty, or + invalid; or if adding or deleting actions is not confirmed by + `add`/`drop`. See Also -------- set_event_actions : The corresponding operation for the actions of an event. relabel_actions : Change the labels of actions, leaving the tree unchanged. """ + if not isinstance(infoset, Selector): + raise TypeError( + f"set_move_actions(): infoset must be a Selector, not {infoset.__class__.__name__}" + ) resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, "set_move_actions")) if isinstance(actions, str) or not hasattr(actions, "__iter__"): raise TypeError("set_move_actions(): actions must be an iterable of str") @@ -1881,12 +2190,13 @@ class Game: self.game.deref().SetMoveActions(resolved_infoset._resolve(), c_labels) def set_event_actions(self, - event: NodeReference, + event: Selector, probs: typing.Mapping, drop: bool = False, add: bool = True) -> None: - """Set the actions at the event `event` to be the keys of `probs`, in order, - with the given probability distribution. + """Set the actions at the event that the node identified by `event` + belongs to, to be the keys of `probs`, in order, with the given + probability distribution. A key of `probs` matching the label of a current action refers to that action, which keeps its subtrees; a key matching no current action creates a new action @@ -1899,13 +2209,19 @@ class Game: of the operation, rather than inferred from the actions which remain: there is no way to reorder an event's actions without also restating their probabilities. + `event` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `event` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - event : Node or str - A node belonging to the event at which to set the actions, or such a - node's label. + event : Selector + A `Selector` resolving to a single node belonging to the event at + which to set the actions. probs : dict-like A mapping from the label of each action the event is to have, in order, to its probability. Must be nonempty, with valid, nonempty keys. Values must be @@ -1919,20 +2235,18 @@ class Game: Raises ------ - MismatchError - If `event` is a `Node` from a different game. - KeyError - If `event` is a string matching no node. TypeError - If `probs` is not a mapping, or a key of `probs` is not a string. + If `event` is not a `Selector`; or if `probs` is not a mapping, or a + key of `probs` is not a string. UndefinedOperationError If `probs` is empty, or if `event` resolves to a personal player's information set rather than an event; use `set_move_actions` for a personal player's move. ValueError - If a key of `probs` is empty or invalid; if adding or deleting actions is not - confirmed by `add`/`drop`; or if the values of `probs` are not non-negative - numbers summing to exactly one. + If `event` does not resolve to exactly one node; if a key of `probs` + is empty or invalid; if adding or deleting actions is not confirmed by + `add`/`drop`; or if the values of `probs` are not non-negative numbers + summing to exactly one. See Also -------- @@ -1940,6 +2254,10 @@ class Game: player's move. relabel_actions : Change the labels of actions, leaving the tree unchanged. """ + if not isinstance(event, Selector): + raise TypeError( + f"set_event_actions(): event must be a Selector, not {event.__class__.__name__}" + ) resolved_event = cython.cast(Event, self._resolve_event(event, "set_event_actions")) if not isinstance(probs, typing.Mapping): raise TypeError( @@ -1965,8 +2283,8 @@ class Game: self.game.deref().SetEventActions(resolved_event._resolve(), c_labels, c_probs) def make_event(self, - nodes: Node | NodeReferenceSet, - probs: typing.Sequence | typing.Mapping, + nodes: Selector | GroupedSelector, + probs: typing.Mapping, label: str | None = None) -> None: """Form `nodes` into a single event with distribution `probs`. @@ -1979,20 +2297,31 @@ class Game: raises ``RuntimeError``. The resulting event is accessible as ``node.event`` for any node in `nodes`. - The first node in `nodes` determines the action order of the event, - and is the frame against which mapping keys in `probs` are resolved. + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) or a `GroupedSelector` (an `H`-built + `.by(...)` expression, whose groups are pooled together into the one event). + + Which resolved node is treated as "first", determining the action order of + the event and the frame against which keys of `probs` are resolved, follows + `nodes`' own resolution order. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `nodes` is now a `Selector` or `GroupedSelector`; a `Node` or + `NodeReferenceSet` is no longer accepted directly -- build one with `H`. + .. versionchanged:: 17.0.0 + `probs` is now always a mapping from action label to probability; a + positional sequence is no longer accepted. Parameters ---------- - nodes : Node or NodeReferenceSet + nodes : Selector or GroupedSelector The nonempty set of nonterminal nodes to place in the event. - probs : sequence or mapping - The probability distribution over the actions of the event. A sequence must specify - one probability per action, in action order. A mapping from action labels - to probabilities may be sparse; omitted actions are assigned probability zero. - Probabilities are non-negative and sum to exactly one. + probs : Mapping + The probability distribution over the actions of the event, as a mapping + from action label to probability. May be sparse; omitted actions are + assigned probability zero. Probabilities are non-negative and sum to + exactly one. label : str, optional The label of the new event. If specified, must be unique among the events of the game after the operation. A label currently held by another event @@ -2000,13 +2329,11 @@ class Game: Raises ------ - MismatchError - If any of `nodes` is from a different game. + TypeError + If `nodes` is not a `Selector` or `GroupedSelector`, or `probs` is not a + mapping. KeyError - If a node reference matches no node, or a key of `probs` matches no - action label of the event. - IndexError - If a sequence `probs` does not have exactly one entry per action. + If a key of `probs` matches no action label of the event. UndefinedOperationError If any of `nodes` is a terminal node, or the game is not a tree. ValueError @@ -2019,6 +2346,17 @@ class Game: raise UndefinedOperationError( "make_event(): operation only defined for games with a tree representation" ) + if isinstance(nodes, GroupedSelector): + nodes = [n for group in self._group_nodes(nodes).values() for n in group] + elif not isinstance(nodes, Selector): + raise TypeError( + f"make_event(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) + if not isinstance(probs, typing.Mapping): + raise TypeError( + f"make_event(): probs must be a mapping, not {probs.__class__.__name__}" + ) resolved_nodes = self._resolve_nodes(nodes, "make_event") if any(n.is_terminal for n in resolved_nodes): raise UndefinedOperationError( @@ -2042,23 +2380,30 @@ class Game: self.game.deref().MakeEvent(c_nodes, c_probs, (label or "").encode("utf-8")) def relabel_actions(self, - infoset: NodeReference, + infoset: Selector, labels: typing.Mapping[str, str], strict: bool = True) -> None: - """Simultaneously reassign the labels of actions at `infoset`. + """Simultaneously reassign the labels of actions at the information set or + event that the node identified by `infoset` belongs to. `labels` maps current action labels to their replacements. The reassignment is simultaneous, so labels can be swapped directly, e.g. ``{"a": "b", "b": "a"}``. Actions are not re-ordered: each relabelled action keeps its position and, at an event, its probability. After the operation, the labels must be nonempty and unique. + `infoset` is a `Selector` (an `H`-built expression, evaluated against this + game) that must resolve to exactly one node. + .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `infoset` is now a `Selector`; a `Node` or `str` is no longer accepted + directly -- build one with `H`. Parameters ---------- - infoset : Node or str - A node belonging to the information set at which to relabel actions, or - such a node's label. + infoset : Selector + A `Selector` resolving to a single node belonging to the information + set or event at which to relabel actions. labels : Mapping[str, str] A mapping from current action labels to replacement labels. Entries whose key equals their value are ignored. @@ -2069,19 +2414,23 @@ class Game: Raises ------ - MismatchError - If `infoset` is a `Node` from a different game. - KeyError - If `infoset` is a string matching no node; or, when `strict` - is `True`, if a key of `labels` matches no action at `infoset`. TypeError - If `labels` is not a mapping, or any key or value is not a string. + If `infoset` is not a `Selector`; or if `labels` is not a mapping, or + any key or value is not a string. + KeyError + If, when `strict` is `True`, a key of `labels` matches no action at + `infoset`. ValueError - If a key of `labels` matches more than one action at `infoset` (possible - in games read from files predating unique-label enforcement); or if any + If `infoset` does not resolve to exactly one node; if a key of + `labels` matches more than one action at `infoset` (possible in games + read from files predating unique-label enforcement); or if any replacement label is empty, is not a valid label, or would result in a duplicate label at the information set. """ + if not isinstance(infoset, Selector): + raise TypeError( + f"relabel_actions(): infoset must be a Selector, not {infoset.__class__.__name__}" + ) resolved_infoset = cython.cast( _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "relabel_actions") ) @@ -2103,7 +2452,7 @@ class Game: self.game.deref().RelabelActions(resolved_infoset._resolve(), c_labels) def make_infoset(self, - nodes: Node | NodeReferenceSet, + nodes: Selector | GroupedSelector, player: str, label: str | None = None) -> None: """Form `nodes` into a single information set belonging to `player`. @@ -2118,11 +2467,19 @@ class Game: The structure of the tree is unchanged: no nodes are created or removed. This operation may introduce imperfect recall or absent-mindedness. + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game + and treated as a flat set of nodes) or a `GroupedSelector` (an `H`-built + `.by(...)` expression, whose groups are pooled together into the one + information set). + .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + `nodes` is now a `Selector` or `GroupedSelector`; a `Node` or + `NodeReferenceSet` is no longer accepted directly -- build one with `H`. Parameters ---------- - nodes : Node or NodeReferenceSet + nodes : Selector or GroupedSelector The nodes to place in the information set. Nonempty; each node may be referenced only once. player : str @@ -2135,12 +2492,11 @@ class Game: Raises ------ - MismatchError - If any of `nodes` is from a different game. - KeyError - If any of `nodes`, or `player`, is a label matching no such object in the game. TypeError - If any of `nodes`, or `player`, is not of an accepted type. + If `nodes` is not a `Selector` or `GroupedSelector`, or `player` is not + of an accepted type. + KeyError + If `player` is a label matching no such object in the game. UndefinedOperationError If any of `nodes` is a terminal node, or if the game is not a tree. ValueError @@ -2152,6 +2508,13 @@ class Game: raise UndefinedOperationError( "make_infoset(): operation only defined for games with a tree representation" ) + if isinstance(nodes, GroupedSelector): + nodes = [n for group in self._group_nodes(nodes).values() for n in group] + elif not isinstance(nodes, Selector): + raise TypeError( + f"make_infoset(): nodes must be a Selector or GroupedSelector, " + f"not {nodes.__class__.__name__}" + ) resolved_nodes = self._resolve_nodes(nodes, "make_infoset") resolved_player = self._resolve_player(player, "make_infoset") for n in resolved_nodes: @@ -2164,49 +2527,6 @@ class Game: c_nodes.push_back(cython.cast(Node, n).node) self.game.deref().MakeInfoset(c_nodes, resolved_player, (label or "").encode()) - def reveal(self, - infoset: NodeReference, - player: str) -> None: - """Reveals the move made at the information set or event `infoset` to `player`. - - Revealing the move modifies all subsequent information sets for `player` such - that any two nodes which are successors of two different actions at this - information set are placed in different information sets for `player`. - - Revelation is a one-shot operation; it is not enforced with respect to any - revisions made to the game tree subsequently. - - .. versionchanged:: 17.0.0 - Revealing the move at an absent-minded information set is not permitted. - - Parameters - ---------- - infoset : Node or str - A node belonging to the information set or event of the move to reveal - to the player, or such a node's label. - player : str - The label of the player to which to reveal the move at this information set. - - Raises - ------ - MismatchError - If `infoset` is a `Node` from a different game. - KeyError - If no player in the game has label `player`. - UndefinedOperationError - If `infoset` is absent-minded. - """ - resolved_infoset = cython.cast( - _InfosetOrEvent, self._resolve_infoset_or_event(infoset, "reveal") - ) - resolved_player = self._resolve_player(player, "reveal") - if resolved_infoset.is_absent_minded: - raise UndefinedOperationError( - "reveal(): revealing the move at an absent-minded information set " - "is not well-defined" - ) - self.game.deref().Reveal(resolved_infoset._resolve(), resolved_player) - def set_players(self, players: list[str], drop: bool = False, @@ -2287,23 +2607,31 @@ class Game: def _resolve_outcome_location(self, location, funcname: str) -> tuple: """Resolve `location` for `make_outcome`/`make_outcome_null`: for a tree game, - into a list of `Node`; for a strategic game, into a list of pure-strategy - contingencies (each a mapping from player label to strategy label). + into a list of `Node` (via `_resolve_nodes`, so `location` must be a + `Selector` or `GroupedSelector`); for a strategic game, into a list of + pure-strategy contingencies (each a mapping from player label to strategy + label). Returns (is_tree, resolved). Raises ------ - MismatchError - If any node is from a different game. TypeError - If `location` is not a contingency or an iterable of contingencies + If `location` is not a `Selector` or `GroupedSelector` (tree game + only); or is not a contingency or an iterable of contingencies (strategic game only). ValueError If `location` is empty or contains a repeat, or (strategic game only) if a contingency does not specify exactly one strategy for each player. """ if self.is_tree: + if isinstance(location, GroupedSelector): + location = [n for group in self._group_nodes(location).values() for n in group] + elif not isinstance(location, Selector): + raise TypeError( + f"{funcname}(): location must be a Selector or GroupedSelector, " + f"not {location.__class__.__name__}" + ) return True, self._resolve_nodes(location, funcname) if isinstance(location, collections.abc.Mapping): entries = [location] @@ -2325,19 +2653,26 @@ class Game: label: str) -> Outcome: """Create an outcome with `payoffs` and `label` and attach it at `location`. - For an extensive game, `location` is a ``Node`` or an iterable of nodes. For a - strategic game, `location` is a pure-strategy contingency — a complete mapping - from the game's players' labels to strategy labels — or an iterable of such - contingencies. + For an extensive game, `location` is a `Selector` (an `H`-built + expression, evaluated against this game and treated as a flat set of + nodes) or a `GroupedSelector` (an `H`-built `.by(...)` expression, whose + groups are pooled together, all receiving the same outcome). For a + strategic game, `location` is a pure-strategy contingency — a complete + mapping from the game's players' labels to strategy labels — or an + iterable of such contingencies. Any outcome all of whose references are among `location` is absorbed by the operation: it is removed from the game, and `label` may reuse its label. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + For an extensive game, `location` is now a `Selector` or + `GroupedSelector`; a `Node`, `History`, or iterable of these is no + longer accepted directly -- build one with `H`. Parameters ---------- - location : Node, contingency, or iterable of these + location : Selector, GroupedSelector, contingency, or iterable of contingencies Where to attach the new outcome. Nonempty; each node or contingency may be referenced only once. payoffs : Mapping @@ -2354,8 +2689,9 @@ class Game: Raises ------ - MismatchError - If any node is from a different game. + TypeError + If, for an extensive game, `location` is not a `Selector` or + `GroupedSelector`. ValueError If `location` is empty or contains a repeat; if `payoffs` is not a complete mapping over exactly the game's players; if a contingency does not specify @@ -2409,25 +2745,32 @@ class Game: def make_outcome_null(self, location) -> None: """Reset the outcome at `location` to the null outcome. - For an extensive game, `location` is a ``Node`` or an iterable of nodes. For a - strategic game, `location` is a pure-strategy contingency — a complete mapping - from the game's players' labels to strategy labels — or an iterable of such - contingencies. + For an extensive game, `location` is a `Selector` (an `H`-built + expression, evaluated against this game and treated as a flat set of + nodes) or a `GroupedSelector` (an `H`-built `.by(...)` expression, whose + groups are pooled together). For a strategic game, `location` is a + pure-strategy contingency — a complete mapping from the game's players' + labels to strategy labels — or an iterable of such contingencies. Any outcome all of whose references are among `location` is removed from the game. .. versionadded:: 17.0.0 + .. versionchanged:: 17.0.0 + For an extensive game, `location` is now a `Selector` or + `GroupedSelector`; a `Node`, `History`, or iterable of these is no + longer accepted directly -- build one with `H`. Parameters ---------- - location : Node, contingency, or iterable of these + location : Selector, GroupedSelector, contingency, or iterable of contingencies The nodes or contingencies to reset to the null outcome. Nonempty; each node or contingency may be referenced only once. Raises ------ - MismatchError - If any node is from a different game. + TypeError + If, for an extensive game, `location` is not a `Selector` or + `GroupedSelector`. ValueError If `location` is empty or contains a repeat, or if a contingency does not specify exactly one strategy for each player. diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi new file mode 100644 index 000000000..a3c9d2610 --- /dev/null +++ b/src/pygambit/hsel.pxi @@ -0,0 +1,299 @@ +# +# 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 that accepts one, such as `append_move` or `make_outcome`. + + .. 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 -- pass to a + `Game` method that accepts a `GroupedSelector`, such as `append_move`, + which dispatches one call per group. + + `.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) + + @property + def members(self) -> list[tuple]: + """The Histories of the nodes which are members of the information set or + event to which this history currently belongs -- whichever applies. + + Raises + ------ + AttributeError + If this history currently belongs to no information set or event (a + terminal node). + """ + return [_history_of(member) for member in self._node.members] + + +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.""" diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 9d913b2ef..05c328e7c 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -97,13 +97,12 @@ def efg_asymmetric_tree_text() -> str: payoff-irrelevant and so free to vary across equilibria). """ game = gbt.Game.new_tree(players=["1", "2"], title="Asymmetric multi-infoset game") - game.append_move(game.root, "1", ["L", "R"]) - left, right = game.root.children - game.append_move(left, "2", ["x", "y", "z"]) - game.append_move(right, "2", ["p", "q"]) - for node in left.children: - payoff = [1, 1] if node.prior_action.label == "x" else [0, 0] - game.make_outcome(node, {"1": payoff[0], "2": payoff[1]}, node.prior_action.label) - for node in right.children: - game.make_outcome(node, {"1": 0, "2": 0}, node.prior_action.label) + game.append_move(gbt.H.path(), "1", ["L", "R"]) + game.append_move(gbt.H.path("L"), "2", ["x", "y", "z"]) + game.append_move(gbt.H.path("R"), "2", ["p", "q"]) + for label in ["x", "y", "z"]: + payoff = [1, 1] if label == "x" else [0, 0] + game.make_outcome(gbt.H.path("L", label), {"1": payoff[0], "2": payoff[1]}, label) + for label in ["p", "q"]: + game.make_outcome(gbt.H.path("R", label), {"1": 0, "2": 0}, label) return game.to_efg() diff --git a/tests/games.py b/tests/games.py index 07fc521c7..071956ea8 100644 --- a/tests/games.py +++ b/tests/games.py @@ -33,6 +33,25 @@ def find_infoset_in_game(game: gbt.Game, label: str) -> gbt.Infoset: return next(i for i in all_infosets(game) if i.label == label) +def _node_history(node: gbt.Node) -> tuple: + """The plain-tuple history of `node`, walked via `.parent`/`.prior_action`.""" + labels = [] + current = node + while current.parent is not None: + labels.append(current.prior_action.label) + current = current.parent + labels.reverse() + return tuple(labels) + + +def selector_for_nodes(nodes: list[gbt.Node]) -> gbt.Selector: + """A `Selector` matching exactly the given (possibly scattered, mixed-depth) + nodes -- for adapting fixtures that compute a `Node` list dynamically to the + `H`-only mutation methods.""" + histories = frozenset(_node_history(n) for n in nodes) + return gbt.H.after().filter(lambda h: h[:] in histories) + + # Label-validation fixtures. # VALID: accepted by the C++ validator (IsValidLabel in src/games/game.h), including # well-formed UTF-8 text (#862, 17.0.0). A single Unicode whitespace character @@ -93,11 +112,10 @@ def create_efg_corresponding_to_bimatrix_game_arrays( g = gbt.Game.new_tree(players=["1", "2"], title=title) actions1 = [str(i) for i in range(m)] actions2 = [str(i) for i in range(n)] - g.append_move(g.root, "1", actions1) - g.append_move(g.root.children, "2", actions2) + g.append_move(gbt.H.path(), "1", actions1) + g.append_move(gbt.H.path(...), "2", actions2) for i, j in itertools.product(range(m), range(n)): - node = g.root.children[str(i)].children[str(j)] - g.make_outcome(node, {"1": A[i, j], "2": B[i, j]}, f"({i},{j})") + g.make_outcome(gbt.H.path(str(i), str(j)), {"1": A[i, j], "2": B[i, j]}, f"({i},{j})") return g @@ -134,9 +152,9 @@ def create_2x2_zero_sum_efg(variant: None | str = None) -> gbt.Game: g = create_efg_corresponding_to_bimatrix_game_arrays(A, B, title) if variant == "missing term outcome": - g.make_outcome_null(g.root.children["0"].children["1"]) + g.make_outcome_null(gbt.H.path("0", "1")) elif variant == "with neutral outcome": - g.make_outcome(g.root.children["0"], {"1": 0, "2": 0}, "neutral") + g.make_outcome(gbt.H.path("0"), {"1": 0, "2": 0}, "neutral") return g @@ -163,31 +181,26 @@ def create_stripped_down_poker_efg(nonterm_outcomes: bool = False) -> gbt.Game: poker from Reiley et al (2008).", ) deals = ["King", "Queen"] - g.append_event(g.root, deals, [gbt.Rational(1, 2)] * 2) + g.append_event(gbt.H.path(), dict.fromkeys(deals, gbt.Rational(1, 2))) - for node in g.root.children: - g.append_move(node, player="Alice", actions=["Bet", "Fold"]) + for card in deals: + g.append_move( + gbt.H.path(...).filter(lambda h, card=card: h[0] == card), + player="Alice", actions=["Bet", "Fold"] + ) - alice_bets_nodes = [ - g.root.children["King"].children["Bet"], - g.root.children["Queen"].children["Bet"], - ] - g.append_move(alice_bets_nodes, player="Bob", actions=["Call", "Fold"]) + g.append_move(gbt.H.path(..., "Bet"), player="Bob", actions=["Call", "Fold"]) - g.make_outcome(g.root, {"Alice": -1, "Bob": -1}, "Ante") - g.make_outcome( - [node.children["Fold"] for node in g.root.children], {"Alice": 0, "Bob": 2}, "Alice Folds" - ) + g.make_outcome(gbt.H.path(), {"Alice": -1, "Bob": -1}, "Ante") + g.make_outcome(gbt.H.path(..., "Fold"), {"Alice": 0, "Bob": 2}, "Alice Folds") + g.make_outcome(gbt.H.path(..., "Bet"), {"Alice": -1, "Bob": 0}, "Alice Bets") + g.make_outcome(gbt.H.path(..., "Bet", "Fold"), {"Alice": 3, "Bob": 0}, "Bob Folds") g.make_outcome( - [node.children["Bet"] for node in g.root.children], {"Alice": -1, "Bob": 0}, "Alice Bets" + gbt.H.path("King", "Bet", "Call"), {"Alice": 4, "Bob": -1}, "Bob Calls and Loses" ) g.make_outcome( - [node.children["Fold"] for node in alice_bets_nodes], {"Alice": 3, "Bob": 0}, "Bob Folds" + gbt.H.path("Queen", "Bet", "Call"), {"Alice": 0, "Bob": 3}, "Bob Calls and Wins" ) - bob_calls_and_loses_node = g.root.children["King"].children["Bet"].children["Call"] - g.make_outcome(bob_calls_and_loses_node, {"Alice": 4, "Bob": -1}, "Bob Calls and Loses") - bob_calls_and_wins_node = g.root.children["Queen"].children["Bet"].children["Call"] - g.make_outcome(bob_calls_and_wins_node, {"Alice": 0, "Bob": 3}, "Bob Calls and Wins") return g @@ -199,34 +212,31 @@ def _create_kuhn_poker_efg_without_outcomes(): cards = ["J", "Q", "K"] deals = ["JQ", "JK", "QJ", "QK", "KJ", "KQ"] - def deals_by_infoset(player, card): - player_idx = 0 if player == "Alice" else 1 - return [d for d in deals if d[player_idx] == card] - - g.append_event(g.root, deals, [gbt.Rational(1, 6)] * 6) + g.append_event(gbt.H.path(), dict.fromkeys(deals, gbt.Rational(1, 6))) for alice_card in cards: # Alice's first move - term_nodes = [g.root.children[d] for d in deals_by_infoset("Alice", alice_card)] - g.append_move(term_nodes, "Alice", ["Check", "Bet"]) + g.append_move( + gbt.H.path(...).filter(lambda h, card=alice_card: h[0][0] == card), + "Alice", ["Check", "Bet"] + ) for bob_card in cards: # Bob's move after Alice checks - term_nodes = [ - g.root.children[d].children["Check"] for d in deals_by_infoset("Bob", bob_card) - ] - g.append_move(term_nodes, "Bob", ["Check", "Bet"]) + g.append_move( + gbt.H.path(..., "Check").filter(lambda h, card=bob_card: h[0][1] == card), + "Bob", ["Check", "Bet"] + ) for alice_card in cards: # Alice's move if Bob's second action is bet - term_nodes = [ - g.root.children[d].children["Check"].children["Bet"] - for d in deals_by_infoset("Alice", alice_card) - ] - g.append_move(term_nodes, "Alice", ["Fold", "Call"]) + g.append_move( + gbt.H.path(..., "Check", "Bet").filter(lambda h, card=alice_card: h[0][0] == card), + "Alice", ["Fold", "Call"] + ) for bob_card in cards: # Bob's move after Alice bets initially - term_nodes = [ - g.root.children[d].children["Bet"] for d in deals_by_infoset("Bob", bob_card) - ] - g.append_move(term_nodes, "Bob", ["Fold", "Call"]) + g.append_move( + gbt.H.path(..., "Bet").filter(lambda h, card=bob_card: h[0][1] == card), + "Bob", ["Fold", "Call"] + ) return g @@ -301,7 +311,10 @@ def bet(player, payoffs, pot): nodes_by_payoff[calculate_payoffs(term_node)].append(term_node) for payoffs, nodes in nodes_by_payoff.items(): - g.make_outcome(nodes, {"Alice": payoffs[0], "Bob": payoffs[1]}, payoff_labels[payoffs]) + g.make_outcome( + selector_for_nodes(nodes), {"Alice": payoffs[0], "Bob": payoffs[1]}, + payoff_labels[payoffs] + ) return g @@ -368,7 +381,9 @@ def get_path(node): # the same non-terminal node is revisited once per terminal descendant walked above deduped_nodes = list(dict.fromkeys(nodes)) alice_payoff, bob_payoff = payoffs_by_key[key] - g.make_outcome(deduped_nodes, {"Alice": alice_payoff, "Bob": bob_payoff}, key) + g.make_outcome( + selector_for_nodes(deduped_nodes), {"Alice": alice_payoff, "Bob": bob_payoff}, key + ) return g @@ -439,24 +454,24 @@ def create_one_shot_trust_efg(unique_NE_variant: bool = False) -> gbt.Game: g = gbt.Game.new_tree( players=["Buyer", "Seller"], title="One-shot trust game, after Kreps (1990)" ) - g.append_move(g.root, "Buyer", ["Trust", "Not trust"]) - g.append_move(g.root.children["Trust"], "Seller", ["Honor", "Abuse"]) + g.append_move(gbt.H.path(), "Buyer", ["Trust", "Not trust"]) + g.append_move(gbt.H.path("Trust"), "Seller", ["Honor", "Abuse"]) g.make_outcome( - g.root.children["Trust"].children["Honor"], {"Buyer": 1, "Seller": 1}, "Trustworthy" + gbt.H.path("Trust", "Honor"), {"Buyer": 1, "Seller": 1}, "Trustworthy" ) if unique_NE_variant: g.make_outcome( - g.root.children["Trust"].children["Abuse"], + gbt.H.path("Trust", "Abuse"), {"Buyer": "1/2", "Seller": 2}, "Untrustworthy", ) else: g.make_outcome( - g.root.children["Trust"].children["Abuse"], + gbt.H.path("Trust", "Abuse"), {"Buyer": -1, "Seller": 2}, "Untrustworthy", ) - g.make_outcome(g.root.children["Not trust"], {"Buyer": 0, "Seller": 0}, "Opt-out") + g.make_outcome(gbt.H.path("Not trust"), {"Buyer": 0, "Seller": 0}, "Opt-out") return g @@ -566,24 +581,24 @@ def __init__(self, params): def gbt_game(self): g = gbt.Game.new_tree(players=["1", "2"], title=f"Centipede Game with {self.N} rounds") - current_node = g.root current_player = "1" for t in range(self.N): - g.append_move(current_node, current_player, ["Take", "Push"]) + g.append_move(gbt.H.path(*(["Push"] * t)), current_player, ["Take", "Push"]) payoffs = [2**t * self.m0, 2**t * self.m1] # take payoffs if current_player == "2": payoffs.reverse() g.make_outcome( - current_node.children["Take"], {"1": payoffs[0], "2": payoffs[1]}, f"take_{t}" + gbt.H.path(*(["Push"] * t), "Take"), {"1": payoffs[0], "2": payoffs[1]}, + f"take_{t}" ) if t == self.N - 1: # for last round, push payoffs payoffs = [2 ** (t + 1) * self.m1, 2 ** (t + 1) * self.m0] if current_player == "2": payoffs.reverse() g.make_outcome( - current_node.children["Push"], {"1": payoffs[0], "2": payoffs[1]}, f"push_{t}" + gbt.H.path(*(["Push"] * (t + 1))), {"1": payoffs[0], "2": payoffs[1]}, + f"push_{t}" ) - current_node = current_node.children["Push"] current_player = "2" if current_player == "1" else "1" return g @@ -700,31 +715,32 @@ def reduced_strategies(self): self.set_size_of_rsf(rs) return rs - def create_binary_tree(self, g, node, whose_turn, depth, max_depth): + def create_binary_tree(self, g, node, path, whose_turn, depth, max_depth): # whose_turn cycles through 0,1,n_players-1; current player is str(whose_turn + 1) if depth == max_depth: g.make_outcome( - node, {str(p): 0 for p in self.players}, f"leaf_{len(list(g.outcomes))}" + gbt.H.path(*path), {str(p): 0 for p in self.players}, + f"leaf_{len(list(g.outcomes))}" ) else: current_player = str(whose_turn + 1) - g.append_move(node, current_player, ["L", "R"]) + g.append_move(gbt.H.path(*path), current_player, ["L", "R"]) whose_turn = (whose_turn + 1) % self.n_players - for child in node.children: - self.create_binary_tree(g, child, whose_turn, depth + 1, max_depth) + for label, child in zip(["L", "R"], node.children, strict=True): + self.create_binary_tree(g, child, (*path, label), whose_turn, depth + 1, max_depth) def gbt_game(self): g = gbt.Game.new_tree( players=[str(p) for p in self.players], title=f"Binary Tree Game (L={self.level})", ) - self.create_binary_tree(g, g.root, 0, 0, self.level) + self.create_binary_tree(g, g.root, (), 0, 0, self.level) for n in g.nodes: if not n.is_terminal and not n.children["L"].is_terminal: left = n.children["L"] g.make_infoset( - list(left.infoset.members) + [n.children["R"]], + selector_for_nodes(list(left.infoset.members) + [n.children["R"]]), left.infoset.player, left.infoset.label or None, ) diff --git a/tests/test_actions.py b/tests/test_actions.py index 970f21b08..7e51063be 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -9,7 +9,7 @@ def test_action_label(label: str): game = games.create_stripped_down_poker_efg() action = next(iter(game.root.actions)) - game.relabel_actions(game.root, {action: label}) + game.relabel_actions(gbt.H.path(), {action: label}) assert label in game.root.actions @@ -18,20 +18,20 @@ def test_action_label_invalid_raises_valueerror(label: str): game = games.create_stripped_down_poker_efg() action = next(iter(game.root.actions)) with pytest.raises(ValueError): - game.relabel_actions(game.root, {action: label}) + game.relabel_actions(gbt.H.path(), {action: label}) def test_relabel_action_empty_raises_valueerror(): game = games.create_stripped_down_poker_efg() action = next(iter(game.root.actions)) with pytest.raises(ValueError): - game.relabel_actions(game.root, {action: ""}) + game.relabel_actions(gbt.H.path(), {action: ""}) def test_relabel_actions_duplicate_raises_valueerror(): game = games.create_stripped_down_poker_efg() with pytest.raises(ValueError): - game.relabel_actions(game.root, {"King": "Queen"}) + game.relabel_actions(gbt.H.path(), {"King": "Queen"}) def test_relabel_actions_simultaneous_swap(): @@ -39,7 +39,7 @@ def test_relabel_actions_simultaneous_swap(): at a time would collide on the intermediate state. """ game = games.create_stripped_down_poker_efg() - game.relabel_actions(game.root, {"King": "Queen", "Queen": "King"}) + game.relabel_actions(gbt.H.path(), {"King": "Queen", "Queen": "King"}) assert list(game.root.event.actions) == ["Queen", "King"] @@ -49,18 +49,18 @@ def test_relabel_actions_duplicate_targets_raises_valueerror(): """ game = games.create_stripped_down_poker_efg() with pytest.raises(ValueError): - game.relabel_actions(game.root, {"King": "Ace", "Queen": "Ace"}) + game.relabel_actions(gbt.H.path(), {"King": "Ace", "Queen": "Ace"}) def test_relabel_actions_unknown_label_raises_keyerror(): game = games.create_stripped_down_poker_efg() with pytest.raises(KeyError): - game.relabel_actions(game.root, {"Jack": "Ace"}) + game.relabel_actions(gbt.H.path(), {"Jack": "Ace"}) def test_relabel_actions_unknown_label_not_strict_is_ignored(): game = games.create_stripped_down_poker_efg() - game.relabel_actions(game.root, {"Jack": "Ace", "King": "Ace"}, strict=False) + game.relabel_actions(gbt.H.path(), {"Jack": "Ace", "King": "Ace"}, strict=False) assert list(game.root.event.actions) == ["Ace", "Queen"] @@ -70,7 +70,7 @@ def test_relabel_actions_failure_leaves_game_unchanged(): """ game = games.create_stripped_down_poker_efg() with pytest.raises(ValueError): - game.relabel_actions(game.root, {"King": "Ace", "Queen": ""}) + game.relabel_actions(gbt.H.path(), {"King": "Ace", "Queen": ""}) assert list(game.root.event.actions) == ["King", "Queen"] @@ -82,24 +82,24 @@ def test_relabel_actions_scope_is_the_information_set(): game = games.create_stripped_down_poker_efg() king = games.find_infoset(game, "Alice", "Alice has King") queen = games.find_infoset(game, "Alice", "Alice has Queen") - game.relabel_actions(next(iter(king.members)), {"Bet": "Raise"}) + game.relabel_actions(games.selector_for_nodes([next(iter(king.members))]), {"Bet": "Raise"}) assert list(king.actions) == ["Raise", "Fold"] assert list(queen.actions) == ["Bet", "Fold"] - game.relabel_actions(next(iter(queen.members)), {"Bet": "Raise"}) + game.relabel_actions(games.selector_for_nodes([next(iter(queen.members))]), {"Bet": "Raise"}) assert list(queen.actions) == ["Raise", "Fold"] def test_relabel_actions_not_a_mapping_raises_typeerror(): game = games.create_stripped_down_poker_efg() with pytest.raises(TypeError): - game.relabel_actions(game.root, [("King", "Queen")]) + game.relabel_actions(gbt.H.path(), [("King", "Queen")]) @pytest.mark.parametrize("labels", [{1: "Queen"}, {"King": 1}]) def test_relabel_actions_non_str_label_raises_typeerror(labels: dict): game = games.create_stripped_down_poker_efg() with pytest.raises(TypeError): - game.relabel_actions(game.root, labels) + game.relabel_actions(gbt.H.path(), labels) def test_set_move_actions_drop_shrinks_actions_and_children(): @@ -108,7 +108,7 @@ def test_set_move_actions_drop_shrinks_actions_and_children(): node = next(iter(infoset.members)) action_count = len(infoset.actions) remaining = list(infoset.actions)[1:] - game.set_move_actions(node, remaining, drop=True) + game.set_move_actions(games.selector_for_nodes([node]), remaining, drop=True) assert len(infoset.actions) == action_count - 1 assert len(node.children) == action_count - 1 @@ -118,25 +118,28 @@ def test_set_move_actions_cannot_remove_the_only_action(): infoset = games.find_infoset(game, "Alice", "Alice has King") node = next(iter(infoset.members)) last = next(iter(infoset.actions)) - game.set_move_actions(node, [last], drop=True) + selector = games.selector_for_nodes([node]) + game.set_move_actions(selector, [last], drop=True) assert list(infoset.actions) == [last] with pytest.raises(gbt.UndefinedOperationError): - game.set_move_actions(node, [], drop=True) + game.set_move_actions(selector, [], drop=True) def test_set_move_actions_reorder_carries_subtrees(): """Reordering three actions as a cycle moves every action to a new position. Each action carries its whole subtree with it, at every member of the information set.""" game = gbt.Game.new_tree(players=["Alice", "Bob"]) - game.append_move(game.root, "Bob", ["x", "y"]) - game.append_move(list(game.root.children), "Alice", ["a", "b", "c"]) - game.append_move([game.root.children["x"].children["a"], - game.root.children["y"].children["b"]], "Bob", ["l", "r"]) + game.append_move(gbt.H.path(), "Bob", ["x", "y"]) + game.append_move(gbt.H.path(...), "Alice", ["a", "b", "c"]) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in (("x", "a"), ("y", "b"))), + "Bob", ["l", "r"] + ) infoset = game.root.children["x"].infoset members = list(infoset.members) children_before = [{label: member.children[label] for label in ("a", "b", "c")} for member in members] - game.set_move_actions(game.root.children["x"], ["c", "a", "b"]) + game.set_move_actions(gbt.H.path("x"), ["c", "a", "b"]) assert list(infoset.actions) == ["c", "a", "b"] for member, children in zip(members, children_before, strict=True): assert list(member.children) == [children["c"], children["a"], children["b"]] @@ -147,7 +150,7 @@ def test_set_move_actions_add_drop_and_reorder_together(): infoset = games.find_infoset(game, "Alice", "Alice has King") node = next(iter(infoset.members)) nodes_before = len(game.nodes) - game.set_move_actions(node, ["Raise", "Fold"], drop=True) + game.set_move_actions(games.selector_for_nodes([node]), ["Raise", "Fold"], drop=True) assert list(infoset.actions) == ["Raise", "Fold"] # "Bet" and its subtree (Bob's node and its two terminals) go; "Raise" adds one. assert len(game.nodes) == nodes_before - 3 + 1 @@ -159,10 +162,11 @@ def test_set_move_actions_unconfirmed_drop_and_disabled_add_raise(): infoset = games.find_infoset(game, "Alice", "Alice has King") node = next(iter(infoset.members)) before = game.to_efg() + selector = games.selector_for_nodes([node]) with pytest.raises(ValueError): - game.set_move_actions(node, ["Bet"]) + game.set_move_actions(selector, ["Bet"]) with pytest.raises(ValueError): - game.set_move_actions(node, ["Bet", "Fold", "Raise"], add=False) + game.set_move_actions(selector, ["Bet", "Fold", "Raise"], add=False) assert game.to_efg() == before @@ -171,7 +175,7 @@ def test_set_move_actions_raises_at_an_event(): corresponding operation for an event.""" game = games.create_stripped_down_poker_efg() with pytest.raises(ValueError): - game.set_move_actions(game.root, ["King", "Queen"]) + game.set_move_actions(gbt.H.path(), ["King", "Queen"]) @pytest.mark.parametrize("bad_labels", [["Bet", "Bet"], ["Bet", ""], ["Bet", " x"]]) @@ -183,7 +187,7 @@ def test_set_move_actions_bad_labels_raise_and_leave_game_unchanged(bad_labels): node = next(iter(infoset.members)) before = game.to_efg() with pytest.raises(ValueError): - game.set_move_actions(node, bad_labels, drop=True) + game.set_move_actions(games.selector_for_nodes([node]), bad_labels, drop=True) assert game.to_efg() == before @@ -191,9 +195,9 @@ def test_set_move_actions_absent_minded_drop_and_add(): """Dropping an action whose subtree contains another member of the same information set deletes that member with the subtree.""" game = gbt.Game.new_tree(players=["Alice"]) - game.append_move(game.root, "Alice", ["a", "b"]) - game.append_infoset(game.root.children["a"], game.root) - game.set_move_actions(game.root, ["b", "c"], drop=True) + game.append_move(gbt.H.path(), "Alice", ["a", "b"]) + game.append_infoset(gbt.H.path("a"), gbt.H.path()) + game.set_move_actions(gbt.H.path(), ["b", "c"], drop=True) assert list(game.root.infoset.actions) == ["b", "c"] assert len(game.root.infoset.members) == 1 assert len(game.nodes) == 3 @@ -201,8 +205,8 @@ def test_set_move_actions_absent_minded_drop_and_add(): def test_set_event_actions_reorder_carries_probabilities(): game = games.create_stripped_down_poker_efg() - game.set_event_actions(game.root, {"King": "3/4", "Queen": "1/4"}) - game.set_event_actions(game.root, {"Queen": "1/4", "King": "3/4"}) + game.set_event_actions(gbt.H.path(), {"King": "3/4", "Queen": "1/4"}) + game.set_event_actions(gbt.H.path(), {"Queen": "1/4", "King": "3/4"}) assert list(game.root.actions) == ["Queen", "King"] assert game.root.action_probs == {"Queen": gbt.Rational(1, 4), "King": gbt.Rational(3, 4)} @@ -210,7 +214,7 @@ def test_set_event_actions_reorder_carries_probabilities(): def test_set_event_actions_add_with_probs_mapping(): game = games.create_stripped_down_poker_efg() nodes_before = len(game.nodes) - game.set_event_actions(game.root, {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) + game.set_event_actions(gbt.H.path(), {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) assert list(game.root.actions) == ["Jack", "King", "Queen"] assert game.root.action_probs == { "Jack": gbt.Rational(1, 2), "King": gbt.Rational(1, 4), "Queen": gbt.Rational(1, 4) @@ -220,7 +224,7 @@ def test_set_event_actions_add_with_probs_mapping(): def test_set_event_actions_drop_with_probs_mapping(): game = games.create_stripped_down_poker_efg() - game.set_event_actions(game.root, {"King": 1}, drop=True) + game.set_event_actions(gbt.H.path(), {"King": 1}, drop=True) assert list(game.root.actions) == ["King"] assert game.root.action_probs == {"King": 1} @@ -230,10 +234,10 @@ def test_set_event_actions_unconfirmed_drop_and_disabled_add_raise(): _ = game.root.event before = game.to_efg() with pytest.raises(ValueError): - game.set_event_actions(game.root, {"King": 1}) + game.set_event_actions(gbt.H.path(), {"King": 1}) with pytest.raises(ValueError): game.set_event_actions( - game.root, {"King": "1/2", "Queen": "1/4", "Jack": "1/4"}, add=False + gbt.H.path(), {"King": "1/2", "Queen": "1/4", "Jack": "1/4"}, add=False ) assert game.to_efg() == before @@ -244,7 +248,9 @@ def test_set_event_actions_raises_at_a_move(): game = games.create_stripped_down_poker_efg() infoset = games.find_infoset(game, "Alice", "Alice has King") with pytest.raises(ValueError): - game.set_event_actions(next(iter(infoset.members)), {"Bet": 1}) + game.set_event_actions( + games.selector_for_nodes([next(iter(infoset.members))]), {"Bet": 1} + ) def test_set_event_actions_rejects_non_mapping_probs(): @@ -253,7 +259,7 @@ def test_set_event_actions_rejects_non_mapping_probs(): game = games.create_stripped_down_poker_efg() before = game.to_efg() with pytest.raises(TypeError): - game.set_event_actions(game.root, ["3/4", "1/4"]) + game.set_event_actions(gbt.H.path(), ["3/4", "1/4"]) assert game.to_efg() == before @@ -261,7 +267,7 @@ def test_set_event_actions_bad_distribution_raises_valueerror(): game = games.create_stripped_down_poker_efg() before = game.to_efg() with pytest.raises(ValueError): - game.set_event_actions(game.root, {"King": "3/4", "Queen": "3/4"}) + game.set_event_actions(gbt.H.path(), {"King": "3/4", "Queen": "3/4"}) assert game.to_efg() == before diff --git a/tests/test_behav.py b/tests/test_behav.py index 6beeb78aa..4fd6d3c65 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -39,46 +39,6 @@ def test_payoffs_reference(game: gbt.Game, rational_flag: bool, payoffs: tuple): assert profile.payoffs[player] == payoff -@pytest.mark.parametrize( - "game,rational_flag", - [ - (games.read_from_file("mixed_behavior_game.efg"), False), - (games.read_from_file("mixed_behavior_game.efg"), True), - (games.create_stripped_down_poker_efg(), False), - (games.create_stripped_down_poker_efg(), True), - ], -) -def test_is_defined_at(game: gbt.Game, rational_flag: bool): - profile = game.mixed_behavior_profile(rational=rational_flag) - for infoset in games.all_infosets(game): - assert profile.is_defined_at(next(iter(infoset.members))) - - -@pytest.mark.parametrize( - "game,label,rational_flag", - [ - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 1:1", False), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 2:1", False), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 3:1", False), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 1:1", True), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 2:1", True), - (games.read_from_file("mixed_behavior_game.efg"), "Infoset 3:1", True), - (games.create_stripped_down_poker_efg(), "Alice has King", False), - (games.create_stripped_down_poker_efg(), "Alice has Queen", False), - (games.create_stripped_down_poker_efg(), "Bob's response", False), - (games.create_stripped_down_poker_efg(), "Alice has King", True), - (games.create_stripped_down_poker_efg(), "Alice has Queen", True), - (games.create_stripped_down_poker_efg(), "Bob's response", True), - ], -) -def test_is_defined_at_by_label(game: gbt.Game, label: str, rational_flag: bool): - """is_defined_at resolves a string as a node's own label, not an infoset's label.""" - node = next(iter(games.find_infoset_in_game(game, label).members)) - node.label = "target" - profile = game.mixed_behavior_profile(rational=rational_flag) - assert profile.is_defined_at(node.label) - - @pytest.mark.parametrize( "game,player_label,infoset_label,action_label,prob,rational_flag", [ diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index cfb210d9e..a0c1a1c9a 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -20,11 +20,11 @@ def _branching_game(): """ game = gbt.Game.new_tree(players=["P1", "P2"]) root = game.root - game.append_move(root, "P1", ["L", "R"]) + game.append_move(gbt.H.path(), "P1", ["L", "R"]) left = root.children["L"] right = root.children["R"] - game.append_move(left, "P2", ["A", "B"]) - game.append_move(right, "P2", ["A", "B"]) + game.append_move(gbt.H.path("L"), "P2", ["A", "B"]) + game.append_move(gbt.H.path("R"), "P2", ["A", "B"]) root.infoset.label = "P1 infoset" left.infoset.label = "P2 left infoset" right.infoset.label = "P2 right infoset" diff --git a/tests/test_game.py b/tests/test_game.py index d3a9cfb9b..deb16d602 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -154,7 +154,7 @@ def test_game_get_outcome_unmatched_label_after_relabel_raises(): def test_game_get_outcome_tree_raises(): game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["a", "b"]) + game.append_move(gbt.H.path(), "Alice", ["a", "b"]) with pytest.raises(gbt.UndefinedOperationError): _ = game.get_outcome({"Alice": "a"}) @@ -169,13 +169,13 @@ def test_game_get_payoffs(): def test_game_get_payoffs_tree(): game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["a", "b"]) + game.append_move(gbt.H.path(), "Alice", ["a", "b"]) infoset = game.root.infoset strategy = next( s for s in game.get_strategies("Alice") if game.get_behavior("Alice", s).get(infoset) == "a" ) - game.make_outcome(game.root.children["a"], {"Alice": 1}, "a-outcome") + game.make_outcome(gbt.H.path("a"), {"Alice": 1}, "a-outcome") payoffs = game.get_payoffs({"Alice": strategy}) assert payoffs["Alice"] == 1 @@ -218,7 +218,7 @@ def test_mixed_strategy_profile_game_structure_changed_tree(): game = games.read_from_file("basic_extensive_game.efg") profiles = [game.mixed_strategy_profile(rational=b) for b in [False, True]] player = next(iter(game.players)) - game.set_move_actions(game.root, ["D1"], drop=True) + game.set_move_actions(gbt.H.path(), ["D1"], drop=True) distribution = {s: 0 for s in game.get_strategies(player)} for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): @@ -253,8 +253,7 @@ def test_mixed_strategy_profile_game_structure_changed_tree(): def test_mixed_behavior_profile_game_structure_changed(): game = games.read_from_file("basic_extensive_game.efg") profiles = [game.mixed_behavior_profile(rational=b) for b in [False, True]] - game.set_move_actions(game.root, ["D1"], drop=True) - infoset = game.root + game.set_move_actions(gbt.H.path(), ["D1"], drop=True) for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): _ = profile.action_regrets @@ -272,8 +271,6 @@ def test_mixed_behavior_profile_game_structure_changed(): _ = profile.infoset_regrets with pytest.raises(gbt.GameStructureChangedError): _ = profile.infoset_values - with pytest.raises(gbt.GameStructureChangedError): - profile.is_defined_at(infoset) with pytest.raises(gbt.GameStructureChangedError): profile.agent_liap_value() with pytest.raises(gbt.GameStructureChangedError): diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index 7795dd7b8..1ca595987 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -49,6 +49,14 @@ def test_resolve_node_invalid(game: gbt.Game, node: str, exception: BaseExceptio game._resolve_node(node, "test_resolve_node_invalid") +def test_resolve_node_mismatch(): + """A `Node` from a different game raises `MismatchError`.""" + game1 = gbt.Game.new_tree() + game2 = games.read_from_file("sample_extensive_game.efg") + with pytest.raises(gbt.MismatchError): + game1._resolve_node(game2.root, "test_resolve_node_mismatch") + + @pytest.mark.parametrize( "game", [ diff --git a/tests/test_hsel.py b/tests/test_hsel.py new file mode 100644 index 000000000..30da6d01f --- /dev/null +++ b/tests/test_hsel.py @@ -0,0 +1,65 @@ +import pytest + +import pygambit as gbt + + +def test_history_view_members_on_shared_infoset(): + game = gbt.Game.new_tree(players=["A", "B"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + game.append_move(gbt.H.plays, "B", ["x", "y"]) + + captured = {} + + def key(h): + captured[h[:]] = h.members + return frozenset(h.members) + + groups = game._get_groups(gbt.H.path(...).by(key)) + assert captured == { + ("U",): [("U",), ("D",)], + ("D",): [("U",), ("D",)], + } + assert list(groups.values()) == [[("U",), ("D",)]] + + +def test_history_view_members_singleton_infoset(): + game = gbt.Game.new_tree(players=["A", "B"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + game.append_move(gbt.H.path("U"), "B", ["x", "y"]) + game.append_move(gbt.H.path("D"), "B", ["x", "y"]) + + captured = {} + + def key(h): + captured[h[:]] = h.members + return None + + game._get_groups(gbt.H.path(...).by(key)) + assert captured == {("U",): [("U",)], ("D",): [("D",)]} + + +def test_history_view_members_on_event(): + game = gbt.Game.new_tree(players=["A"]) + game.append_event(gbt.H.path(), {"L": 0.5, "R": 0.5}) + game.append_event(gbt.H.plays, {"p": 0.5, "q": 0.5}) + + captured = {} + + def key(h): + captured[h[:]] = h.members + return None + + game._get_groups(gbt.H.path(...).by(key)) + assert captured == {("L",): [("L",), ("R",)], ("R",): [("L",), ("R",)]} + + +def test_history_view_members_raises_on_terminal(): + game = gbt.Game.new_tree(players=["A"]) + game.append_move(gbt.H.path(), "A", ["U", "D"]) + + def key(h): + with pytest.raises(AttributeError): + _ = h.members + return None + + game._get_groups(gbt.H.plays.by(key)) diff --git a/tests/test_infosets.py b/tests/test_infosets.py index 3ad6b3ba1..b5ba0d35a 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -59,26 +59,17 @@ def test_make_infoset_change_player_keeps_label(): game = games.read_from_file("basic_extensive_game.efg") _, p2, *_ = game.players members = list(game.root.infoset.members) - game.make_infoset(members, p2, "moved") + game.make_infoset(games.selector_for_nodes(members), p2, "moved") assert game.root.infoset.player == p2 assert game.root.infoset.label == "moved" assert list(game.root.infoset.members) == members -def test_make_infoset_mismatch_raises(): - """Nodes must belong to this game.""" - game1 = games.read_from_file("basic_extensive_game.efg") - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.make_infoset(game2.root, "Player 1") - - def test_make_infoset_terminal_node_raises(): """All nodes must be decision nodes.""" game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] with pytest.raises(gbt.UndefinedOperationError): - game.make_infoset([terminal], game.root.player) + game.make_infoset(gbt.H.path("U1", "U2", "U3"), game.root.player) def test_make_infoset_converts_chance_node(): @@ -86,7 +77,7 @@ def test_make_infoset_converts_chance_node(): game = games.read_from_file("stripped_down_poker.efg") chance_node = game.root # the deal is a chance move personal = next(n for n in game.nodes if not n.is_terminal and n.infoset) - game.make_infoset([chance_node], personal.infoset.player) + game.make_infoset(gbt.H.path(), personal.infoset.player) assert not chance_node.event assert chance_node.infoset assert chance_node.infoset.player == personal.infoset.player @@ -97,31 +88,24 @@ def test_make_infoset_requires_matching_action_labels(node_actions): """Nodes must have the same actions, with the same labels in the same order; a matching count is not sufficient.""" game = gbt.Game.new_tree(players=["1"]) - game.append_move(game.root, "1", ["a", "b"]) - game.append_move(game.root.children["a"], "1", node_actions) + game.append_move(gbt.H.path(), "1", ["a", "b"]) + game.append_move(gbt.H.path("a"), "1", node_actions) with pytest.raises(ValueError): - game.make_infoset([game.root, game.root.children["a"]], "1") + game.make_infoset(gbt.H.after().filter(lambda h: h[:] in ((), ("a",))), "1") def test_make_infoset_empty_nodes_raises(): """`nodes` must be nonempty.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.make_infoset([], game.root.player) - - -def test_make_infoset_repeated_node_raises(): - """Each node may be referenced only once.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.make_infoset([game.root, game.root], game.root.player) + game.make_infoset(gbt.H.path(...).filter(lambda h: False), game.root.player) def test_make_infoset_strategic_game_raises(): """`make_infoset` is only defined for games with a tree representation.""" game = gbt.Game.new_table([2, 2]) with pytest.raises(gbt.UndefinedOperationError): - game.make_infoset([], "1") + game.make_infoset(gbt.H.path(), "1") def test_set_move_actions_add_preserves_existing_action_order(): @@ -129,53 +113,59 @@ def test_set_move_actions_add_preserves_existing_action_order(): order is preserved.""" game = games.read_from_file("basic_extensive_game.efg") labels = list(game.root.actions) - game.set_move_actions(game.root, labels + ["end"]) + game.set_move_actions(gbt.H.path(), labels + ["end"]) assert list(game.root.actions)[:-1] == labels - game.set_move_actions(game.root, ["front"] + labels + ["end"]) + game.set_move_actions(gbt.H.path(), ["front"] + labels + ["end"]) assert list(game.root.actions)[1:-1] == labels @pytest.mark.parametrize( "inprobs,outprobs", [ - (["1/4", "3/4"], [gbt.Rational("1/4"), gbt.Rational("3/4")]), - ([0.75, 0.25], [0.75, 0.25]), + ({"King": "1/4", "Queen": "3/4"}, [gbt.Rational("1/4"), gbt.Rational("3/4")]), + ({"King": 0.75, "Queen": 0.25}, [0.75, 0.25]), ({"King": 1}, [1, 0]), ], ) def test_make_event_sets_probabilities(inprobs, outprobs): - """Probabilities may be given positionally, or as a mapping in which omitted - actions are assigned zero. + """Probabilities are given as a mapping from action label to probability, + which may be sparse: an omitted action is assigned probability zero. """ game = games.read_from_file("stripped_down_poker.efg") - game.make_event([game.root], inprobs, "Deal") + game.make_event(gbt.H.path(), inprobs, "Deal") probs = game.root.action_probs for action, prob in zip(game.root.actions, outprobs, strict=True): assert probs[action] == prob +@pytest.mark.parametrize("probs", [["1/4", "3/4"], [0.75, 0.25]]) +def test_make_event_probs_not_a_mapping_raises_typeerror(probs): + """A positional sequence of probabilities is no longer accepted.""" + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(TypeError): + game.make_event(gbt.H.path(), probs, "Deal") + + def test_make_event_pools_nodes_from_different_infosets(): """Nodes in distinct information sets are formed into a single event.""" game = games.read_from_file("stripped_down_poker.efg") nodes = [game.root.children["King"], game.root.children["Queen"]] - game.make_event(nodes, ["1/4", "3/4"], "Coin") + game.make_event(gbt.H.path(...), {"Bet": "1/4", "Fold": "3/4"}, "Coin") assert nodes[0].event == nodes[1].event assert nodes[0].event assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] assert not game.get_infosets("Alice") -@pytest.mark.parametrize("probs", [["1/2", "1/2"], {"Call": 1}]) -def test_make_event_requires_matching_action_labels(probs): - """Nodes must have the same actions, with the same labels in the same order. - - The mapping case was previously reported as an unknown action label. - """ +def test_make_event_requires_matching_action_labels(): + """Nodes must have the same actions, with the same labels in the same order.""" game = games.read_from_file("stripped_down_poker.efg") - alice_node = game.root.children["King"] # actions Bet, Fold - bob_node = alice_node.children["Bet"] # actions Call, Fold + # King node has actions Bet, Fold; its own Bet-child has actions Call, Fold. with pytest.raises(ValueError): - game.make_event([alice_node, bob_node], probs) + game.make_event( + gbt.H.after().filter(lambda h: h[:] in (("King",), ("King", "Bet"))), + {"Bet": "1/2", "Fold": "1/2"} + ) def test_make_event_converts_personal_node(): @@ -184,51 +174,36 @@ def test_make_event_converts_personal_node(): node = next( n for n in game.get_infosets("Alice") if n.infoset.label == "Alice has King" ) - game.make_event([node], ["1/4", "3/4"]) + game.make_event(gbt.H.path("King"), {"Bet": "1/4", "Fold": "3/4"}) assert node.event assert list(node.action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] def test_make_event_terminal_node_raises(): game = games.read_from_file("stripped_down_poker.efg") - terminal = game.root.children["King"].children["Fold"] with pytest.raises(gbt.UndefinedOperationError): - game.make_event([terminal], ["1/2", "1/2"]) - - -def test_make_event_repeated_node_raises(): - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(ValueError): - game.make_event([game.root, game.root], ["1/2", "1/2"]) - - -def test_make_event_mismatch_raises(): - game = games.read_from_file("stripped_down_poker.efg") - other = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(gbt.MismatchError): - game.make_event([other.root], ["1/2", "1/2"]) + game.make_event(gbt.H.path("King", "Fold"), {"a": "1/2", "b": "1/2"}) def test_make_event_strategic_game_raises(): game = gbt.Game.new_table([2, 2]) with pytest.raises(gbt.UndefinedOperationError): - game.make_event([], [1]) + game.make_event(gbt.H.path(), {"a": 1}) def test_make_event_empty_nodes_raises(): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(ValueError): - game.make_event([], ["1/2", "1/2"]) + game.make_event(gbt.H.path(...).filter(lambda h: False), {"a": "1/2", "b": "1/2"}) def test_make_event_label_held_by_rump_raises(): """A label may be reused only if all members of the event holding it are absorbed.""" game = games.read_from_file("stripped_down_poker.efg") - nodes = [game.root.children["King"], game.root.children["Queen"]] - game.make_event(nodes, ["1/2", "1/2"], "Coin") + game.make_event(gbt.H.path(...), {"Bet": "1/2", "Fold": "1/2"}, "Coin") before = game.to_efg() with pytest.raises(ValueError): - game.make_event([nodes[0]], ["1/2", "1/2"], "Coin") + game.make_event(gbt.H.path("King"), {"Bet": "1/2", "Fold": "1/2"}, "Coin") assert game.to_efg() == before @@ -238,8 +213,8 @@ def test_make_event_label_reused_when_fully_absorbed(): """ game = games.read_from_file("stripped_down_poker.efg") nodes = [game.root.children["King"], game.root.children["Queen"]] - game.make_event(nodes, ["1/2", "1/2"], "Coin") - game.make_event(nodes, ["1/4", "3/4"], "Coin") + game.make_event(gbt.H.path(...), {"Bet": "1/2", "Fold": "1/2"}, "Coin") + game.make_event(gbt.H.path(...), {"Bet": "1/4", "Fold": "3/4"}, "Coin") assert nodes[0].event == nodes[1].event assert nodes[0].event.label == "Coin" assert list(nodes[0].action_probs.values()) == [gbt.Rational("1/4"), gbt.Rational("3/4")] @@ -248,22 +223,22 @@ def test_make_event_label_reused_when_fully_absorbed(): ].count("Coin") == 1 -@pytest.mark.parametrize("probs", [["3/4", "-1/2"], [0.75, 0.40], ["foo", "bar"]]) +@pytest.mark.parametrize( + "probs", [{"King": "3/4", "Queen": "-1/2"}, {"King": 0.75, "Queen": 0.40}, + {"King": "foo", "Queen": "bar"}] +) def test_make_event_invalid_probs_raises(probs): """Values must be numbers, non-negative, and sum to exactly one.""" game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(ValueError): - game.make_event([game.root], probs) + game.make_event(gbt.H.path(), probs) -@pytest.mark.parametrize( - "probs,error", - [(["1/2"], IndexError), (["1/3", "1/3", "1/3"], IndexError), ({"Jack": 1}, KeyError)], -) -def test_make_event_malformed_probs_raises(probs, error): +def test_make_event_malformed_probs_raises(): + """An unknown action label as a mapping key raises KeyError.""" game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(error): - game.make_event([game.root], probs) + with pytest.raises(KeyError): + game.make_event(gbt.H.path(), {"Jack": 1}) @dataclasses.dataclass @@ -386,7 +361,7 @@ def test_make_infoset_cherry_pick_leaves_rumps(): A, B, C, D = _bagwell_p2_nodes(game) A.infoset.label = "X" C.infoset.label = "Y" - game.make_infoset([B, C], "Player 2") + game.make_infoset(games.selector_for_nodes([B, C]), "Player 2") assert B.infoset == C.infoset assert list(A.infoset.members) == [A] assert list(D.infoset.members) == [D] @@ -400,7 +375,7 @@ def test_make_infoset_label_held_by_rump_raises(): A, B, C, D = _bagwell_p2_nodes(game) A.infoset.label = "X" with pytest.raises(ValueError): - game.make_infoset([B, C], "Player 2", "X") # A remains in "X" + game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "X") # A remains in "X" def test_make_infoset_failure_leaves_game_unchanged(): @@ -410,7 +385,7 @@ def test_make_infoset_failure_leaves_game_unchanged(): A.infoset.label = "X" C.infoset.label = "Y" with pytest.raises(ValueError): - game.make_infoset([B, C], "Player 2", "X") + game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "X") assert A.infoset == B.infoset assert C.infoset == D.infoset assert A.infoset.label == "X" @@ -421,8 +396,8 @@ def test_make_infoset_idempotent(): """Repeating a call is a no-op: label reuse permits equality of membership.""" game = gbt.catalog.load("journals/geb/bagwell1995") A, B, C, D = _bagwell_p2_nodes(game) - game.make_infoset([B, C], "Player 2", "Z") - game.make_infoset([B, C], "Player 2", "Z") + game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "Z") + game.make_infoset(games.selector_for_nodes([B, C]), "Player 2", "Z") assert B.infoset == C.infoset assert B.infoset.label == "Z" @@ -432,7 +407,7 @@ def test_make_infoset_split_leaves_new_infoset_unlabeled(): game = gbt.catalog.load("journals/geb/bagwell1995") A, B, C, D = _bagwell_p2_nodes(game) A.infoset.label = "X" - game.make_infoset([A], "Player 2") + game.make_infoset(gbt.H.path("S", "s"), "Player 2") assert A.infoset.label == "" assert B.infoset.label == "X" @@ -440,14 +415,14 @@ def test_make_infoset_split_leaves_new_infoset_unlabeled(): def test_make_infoset_across_different_source_players(): """Nodes drawn from different players all land under the target player.""" game = gbt.Game.new_tree(players=["1", "2", "3"]) - game.append_move(game.root, "1", ["a", "b"]) - game.append_move(game.root.children["a"], "2", ["a", "b"]) # player 2 - game.append_move(game.root.children["b"], "3", ["a", "b"]) # player 3 + game.append_move(gbt.H.path(), "1", ["a", "b"]) + game.append_move(gbt.H.path("a"), "2", ["a", "b"]) # player 2 + game.append_move(gbt.H.path("b"), "3", ["a", "b"]) # player 3 n2 = game.root.children["a"] n3 = game.root.children["b"] assert n2.infoset.player == "2" assert n3.infoset.player == "3" - game.make_infoset([n2, n3], "1") + game.make_infoset(gbt.H.path(...), "1") assert n2.infoset == n3.infoset assert n2.infoset.player == "1" assert n3.infoset.player == "1" @@ -460,7 +435,7 @@ def test_infoset_proxy_reresolves_after_split(): node = game.root.children["U1"] proxy = node.infoset assert len(proxy.members) == 2 - game.make_infoset(node, node.player) + game.make_infoset(gbt.H.path("U1"), node.player) assert list(proxy.members) == [node] @@ -473,31 +448,6 @@ def test_infoset_members_is_a_plain_snapshot_list(): members = node.infoset.members assert isinstance(members, list) assert node in (members[0], members[1]) - game.make_infoset(node, node.player) + game.make_infoset(gbt.H.path("U1"), node.player) assert len(members) == 2 assert list(node.infoset.members) == [node] - - -def test_reveal_splits_infoset_by_action(): - """Revealing the deal to Bob separates his single infoset into per-card - singletons; the other player's structure is untouched.""" - game = games.create_stripped_down_poker_efg(nonterm_outcomes=True) - n_alice = len(game.get_infosets("Alice")) - assert len(game.get_infosets("Bob")) == 1 - game.reveal(game.root, "Bob") - bob = game.get_infosets("Bob") - assert len(bob) == 2 - assert all(len(list(n.infoset.members)) == 1 for n in bob) - assert len(game.get_infosets("Alice")) == n_alice - - -def test_reveal_absent_minded_infoset_raises(): - """Revealing the move at an absent-minded infoset is rejected (17.0).""" - game = gbt.Game.new_tree(players=["Driver", "2"]) - game.append_move(game.root, "Driver", ["Continue", "Exit"]) - mid = game.root.children["Continue"] - game.append_move(mid, "Driver", ["Continue", "Exit"]) - game.make_infoset([game.root, mid], "Driver") - game.append_move(mid.children["Continue"], "2", ["l", "r"]) - with pytest.raises(gbt.UndefinedOperationError): - game.reveal(game.root, "2") diff --git a/tests/test_node.py b/tests/test_node.py index f6e98692f..d38178d2b 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -35,7 +35,7 @@ def test_node_infoset_truthiness(): terminal = game.root.children["U1"].children["D2"].children["U3"] proxy = terminal.infoset assert not proxy - game.append_move(terminal, "Player 1", ["a", "b"]) + game.append_move(gbt.H.path("U1", "D2", "U3"), "Player 1", ["a", "b"]) assert proxy @@ -53,7 +53,7 @@ def test_make_outcome_null(): """Resetting a node's outcome to null leaves the node's outcome view falsy.""" game = games.read_from_file("basic_extensive_game.efg") node = game.root.children["U1"].children["U2"].children["U3"] - game.make_outcome_null(node) + game.make_outcome_null(gbt.H.path("U1", "U2", "U3")) assert not node.outcome @@ -454,7 +454,8 @@ def test_subgame_children(test_case: SubgameStructureTestCase): @pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): - """`game.minimal_subgame(infoset)` returns the smallest subgame containing the infoset.""" + """`game.get_minimal_subgame(node)` returns the smallest subgame containing + the information set `node` belongs to.""" game = test_case.factory() expected_path_for_key = { key: path @@ -464,7 +465,10 @@ def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): for player in game.players: for node in game.get_infosets(player): key = (node.infoset.player, node.infoset.number) - actual_path = tuple(_get_path_of_action_labels(game.minimal_subgame(node).root)) + selector = games.selector_for_nodes([node]) + actual_path = tuple( + _get_path_of_action_labels(game.get_minimal_subgame(selector).root) + ) assert actual_path == expected_path_for_key[key] @@ -589,50 +593,42 @@ def test_append_move_error_player_actions(): """Test to ensure there are actions when appending with a player""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.append_move(game.root, "Player 1", []) - - -def test_append_move_error_infoset_mismatch(): - """Test to ensure the node and the player are from the same game""" - game1 = gbt.Game.new_tree() - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.append_infoset(game1.root, game2.root) + game.append_move(gbt.H.path(), "Player 1", []) def test_append_move_error_empty_label(): """Test that an empty label in `actions` is rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.append_move(game.root, "Player 1", ["a", ""]) + game.append_move(gbt.H.path(), "Player 1", ["a", ""]) def test_append_move_error_duplicate_label(): """Test that duplicated labels in `actions` are rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.append_move(game.root, "Player 1", ["a", "a"]) + game.append_move(gbt.H.path(), "Player 1", ["a", "a"]) def test_insert_move_error_player_actions(): """Test to ensure there are actions when inserting with a player""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.insert_move(game.root, "Player 1", []) + game.insert_move(gbt.H.path(), "Player 1", []) def test_insert_move_error_empty_label(): """Test that an empty label in `actions` is rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.insert_move(game.root, "Player 1", ["a", ""]) + game.insert_move(gbt.H.path(), "Player 1", ["a", ""]) def test_insert_move_error_duplicate_label(): """Test that duplicated labels in `actions` are rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.insert_move(game.root, "Player 1", ["a", "a"]) + game.insert_move(gbt.H.path(), "Player 1", ["a", "a"]) def test_node_infoset_becomes_null_when_truncated(): @@ -641,7 +637,7 @@ def test_node_infoset_becomes_null_when_truncated(): node = game.root.children["U1"] proxy = node.infoset assert proxy - game.delete_tree(node) + game.delete_tree(gbt.H.path("U1")) assert not proxy @@ -649,7 +645,7 @@ def test_node_delete_parent(): """Test to ensure deleting a parent node works""" game = games.read_from_file("basic_extensive_game.efg") node = game.root.children["U1"] - game.delete_parent(node) + game.delete_parent(gbt.H.path("U1")) assert game.root == node @@ -657,7 +653,7 @@ def test_node_delete_tree(): """Test to ensure deleting every child of a node works""" game = games.read_from_file("basic_extensive_game.efg") node = game.root.children["U1"] - game.delete_tree(node) + game.delete_tree(gbt.H.path("U1")) assert len(node.children) == 0 @@ -665,19 +661,7 @@ def test_node_copy_nonterminal(): """Test on copying to a nonterminal node.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.copy_tree(game.root, game.root) - - -def test_node_copy_across_games(): - """Test to ensure a gbt.MismatchError is raised when trying to copy a tree - from a different game. - """ - game1 = gbt.Game.new_tree() - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.copy_tree(game1.root, game2.root) - with pytest.raises(gbt.MismatchError): - game1.copy_tree(game2.root, game1.root) + game.copy_tree(gbt.H.path(), gbt.H.path()) def _subtrees_equal( @@ -714,7 +698,7 @@ def test_copy_tree_onto_nondescendent_terminal_node(): src_node = g.root.children["R"].children["L"] dest_node = g.root.children["R"].children["R"] - g.copy_tree(src_node, dest_node) + g.copy_tree(gbt.H.path("R", "L"), gbt.H.path("R", "R")) assert _subtrees_equal(src_node, dest_node) @@ -725,7 +709,7 @@ def test_copy_tree_onto_descendent_terminal_node(): src_node = g.root.children["R"] dest_node = g.root.children["R"].children["L"].children["R"] - g.copy_tree(src_node, dest_node) + g.copy_tree(gbt.H.path("R"), gbt.H.path("R", "L", "R")) assert _subtrees_equal(src_node, dest_node, dest_node) @@ -734,198 +718,154 @@ def test_node_move_nonterminal(): """Test on moving to a nonterminal node.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.move_tree(game.root, game.root) + game.move_tree(gbt.H.path(), gbt.H.path()) def test_node_move_successor(): """Test on moving a node to one of its successors.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.move_tree(game.root, game.root.children["U1"].children["U2"].children["U3"]) - - -def test_node_move_across_games(): - """Test to ensure a gbt.MismatchError is raised when trying to move a tree - between different games. - """ - game1 = gbt.Game.new_tree() - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.move_tree(game1.root, game2.root) - with pytest.raises(gbt.MismatchError): - game1.move_tree(game2.root, game1.root) + game.move_tree(gbt.H.path(), gbt.H.path("U1", "U2", "U3")) def test_append_move_creates_single_infoset_list_of_nodes(): - """Test that appending a list of nodes creates a single infoset.""" + """Test that appending a Selector matching several nodes creates a single + infoset.""" game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - nodes = [game.root.children["2"].children["1"], - game.root.children["1"].children["1"], - game.root.children["1"].children["2"]] - game.append_move(nodes, "Player 3", ["B", "F"]) + matches = (("2", "1"), ("1", "1"), ("1", "2")) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + "Player 3", ["B", "F"] + ) assert len(game.get_infosets("Player 3")) == 1 def test_append_move_same_infoset_list_of_nodes(): - """Test that nodes from a list of nodes are resolved in the same infoset.""" + """Test that nodes matched by a Selector are resolved in the same infoset.""" game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) node1 = game.root.children["2"].children["1"] node2 = game.root.children["1"].children["1"] - game.append_move([node1, node2], "Player 3", ["B", "F"]) + matches = (("2", "1"), ("1", "1")) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), "Player 3", ["B", "F"] + ) assert node1.infoset == node2.infoset def test_append_move_actions_list_of_nodes(): - """Test that nodes from a list of nodes that resolved in the same infoset + """Test that nodes matched by a Selector that resolved in the same infoset have the same actions. """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) node1 = game.root.children["2"].children["1"] node2 = game.root.children["1"].children["1"] - game.append_move([node1, node2], "Player 3", ["B", "F", "S"]) + matches = (("2", "1"), ("1", "1")) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + "Player 3", ["B", "F", "S"] + ) assert list(node1.infoset.actions) == list(node2.infoset.actions) -def test_append_move_actions_list_of_node_labels(): - """Test that nodes from a list of node labels are resolved correctly.""" - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] - node1.label = "0" - node2.label = "00" - game.append_move(["0", "00"], "Player 3", ["B", "F", "S"]) - - assert node1.children["B"].parent.label == "0" - assert node2.children["B"].parent.label == "00" - assert len(node1.children) == 3 - assert len(node2.children) == 3 - - -def test_append_move_actions_list_of_mixed_node_references(): - """Test that nodes from a list of nodes with either 'node' or str references - are resolved correctly. - """ - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - - node1 = game.root.children["2"].children["1"] - node2 = game.root.children["1"].children["1"] - node1.label = "000" - node_references = ["000", node2] - game.append_move(node_references, "Player 3", ["B", "F", "S"]) - - assert node1.children["B"].parent.label == "000" - assert len(node1.children) == 3 - assert len(node2.children) == 3 - - def test_append_move_labels_list_of_nodes(): - """Test that nodes from a list of nodes that resolved in the same infoset + """Test that nodes matched by a Selector that resolved in the same infoset have the same labels per action. """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) node1 = game.root.children["2"].children["1"] node2 = game.root.children["1"].children["1"] - game.append_move([node1, node2], "Player 3", ["B", "F", "S"]) + matches = (("2", "1"), ("1", "1")) + game.append_move( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + "Player 3", ["B", "F", "S"] + ) assert node1.infoset.actions == node2.infoset.actions def test_append_move_node_list_with_non_terminal_node(): - """Test that we get an UndefinedOperationError when we import in append_move a list - of nodes that has a non-terminal node. + """Test that we get an UndefinedOperationError when a Selector passed to + append_move matches a non-terminal node. """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) with pytest.raises(gbt.UndefinedOperationError): - game.append_move( - [game.root.children["2"], game.root.children["1"].children["2"]], - "Player 3", - ["B", "F"] - ) - - -def test_append_move_node_list_with_duplicate_node_references(): - """Test that we get a ValueError when we import in append_move a list - nodes with non-unique node references. - """ - game = games.read_from_file("sample_extensive_game.efg") - game.set_players(list(game.players) + ["Player 3"]) - node = game.root.children["1"].children["2"] - node.label = "00" - with pytest.raises(ValueError): - game.append_move( - ["00", game.root.children["2"].children["1"], node], - "Player 3", - ["B", "F"] - ) + game.append_move(gbt.H.path(...), "Player 3", ["B", "F"]) def test_append_move_node_list_is_empty(): - """Test that we get a ValueError when we import in append_move an - empty list of nodes. + """Test that we get a ValueError when a Selector passed to append_move + matches no nodes. """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) with pytest.raises(ValueError): - game.append_move([], "Player 3", ["B", "F"]) + game.append_move(gbt.H.path(...).filter(lambda h: False), "Player 3", ["B", "F"]) def test_append_infoset_node_list_with_non_terminal_node(): - """Test that we get an UndefinedOperationError when we import in append_infoset - a list of nodes that has a non-terminal node. + """Test that we get an UndefinedOperationError when a Selector passed to + append_infoset matches a non-terminal node. """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - seed_node = game.root.children["1"].children["1"] - game.append_move(seed_node, "Player 3", ["B", "F"]) + game.append_move(gbt.H.path("1", "1"), "Player 3", ["B", "F"]) with pytest.raises(gbt.UndefinedOperationError): - game.append_infoset( - [game.root.children["2"], game.root.children["1"].children["2"]], - seed_node - ) + game.append_infoset(gbt.H.path(...), gbt.H.path("1", "1")) -def test_append_infoset_node_list_with_duplicate_node(): - """Test that we get a ValueError when we import in append_infoset a list - with non-unique elements. +def test_append_infoset_node_list_is_empty(): + """Test that we get a ValueError when a Selector passed to append_infoset + matches no nodes. """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - seed_node = game.root.children["1"].children["1"] - game.append_move(seed_node, "Player 3", ["B", "F"]) + game.append_move(gbt.H.path("1", "1"), "Player 3", ["B", "F"]) with pytest.raises(ValueError): - game.append_infoset( - [game.root.children["1"].children["2"], - game.root.children["2"].children["1"], - game.root.children["1"].children["2"]], - seed_node - ) + game.append_infoset(gbt.H.path(...).filter(lambda h: False), gbt.H.path("1", "1")) -def test_append_infoset_node_list_is_empty(): - """Test that we get a ValueError when we import in append_infoset an - empty list of nodes. - """ +def test_append_infoset_error_infoset_not_a_selector(): + """Test that we get a TypeError when `infoset` is not a Selector.""" game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) - seed_node = game.root.children["1"].children["1"] - game.append_move(seed_node, "Player 3", ["B", "F"]) - with pytest.raises(ValueError): - game.append_infoset([], seed_node) + game.append_move(gbt.H.path("1", "1"), "Player 3", ["B", "F"]) + with pytest.raises(TypeError): + game.append_infoset(gbt.H.path("1", "2"), game.root.children["1"].children["1"]) + + +def test_append_infoset_error_infoset_terminal(): + """Test that we get an UndefinedOperationError when `infoset` resolves to a + terminal node.""" + game = games.read_from_file("sample_extensive_game.efg") + game.set_players(list(game.players) + ["Player 3"]) + with pytest.raises(gbt.UndefinedOperationError): + game.append_infoset(gbt.H.path("1", "2"), gbt.H.path("1", "1")) + + +def test_append_infoset_error_infoset_chance(): + """Test that we get an UndefinedOperationError when `infoset` resolves to a + chance node.""" + game = games.create_stripped_down_poker_efg() + with pytest.raises(gbt.UndefinedOperationError): + game.append_infoset(gbt.H.path("King", "Bet"), gbt.H.path()) def test_append_event_creates_single_event_list_of_nodes(): - """Test that appending a list of nodes creates a single chance event.""" + """Test that appending a Selector matching several nodes creates a single + chance event.""" game = games.read_from_file("sample_extensive_game.efg") node1 = game.root.children["2"].children["1"] node2 = game.root.children["1"].children["1"] - game.append_event([node1, node2], ["a", "b"], [gbt.Rational(1, 2)] * 2) + matches = (("2", "1"), ("1", "1")) + game.append_event( + gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 2)} + ) assert node1.event == node2.event assert node1.event @@ -934,83 +874,61 @@ def test_append_event_sets_distribution(): """Test that the new event's actions carry the given probabilities.""" game = games.read_from_file("sample_extensive_game.efg") node = game.root.children["1"].children["1"] - game.append_event(node, ["a", "b"], [gbt.Rational(1, 4), gbt.Rational(3, 4)]) + game.append_event(gbt.H.path("1", "1"), {"a": gbt.Rational(1, 4), "b": gbt.Rational(3, 4)}) assert list(node.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] def test_append_event_error_actions_empty(): """Test to ensure there are actions when appending an event.""" game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] with pytest.raises(gbt.UndefinedOperationError): - game.append_event(terminal, [], []) - - -def test_append_event_error_node_mismatch(): - """Test to ensure the node is from this game.""" - game1 = gbt.Game.new_tree() - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.append_event(game2.root, ["a", "b"], [gbt.Rational(1, 2)] * 2) + game.append_event(gbt.H.path("U1", "U2", "U3"), {}) def test_append_event_error_empty_label(): """Test that an empty label in `actions` is rejected.""" game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] - with pytest.raises(ValueError): - game.append_event(terminal, ["a", ""], [gbt.Rational(1, 2)] * 2) - - -def test_append_event_error_duplicate_label(): - """Test that duplicated labels in `actions` are rejected.""" - game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] with pytest.raises(ValueError): - game.append_event(terminal, ["a", "a"], [gbt.Rational(1, 2)] * 2) + game.append_event( + gbt.H.path("U1", "U2", "U3"), {"a": gbt.Rational(1, 2), "": gbt.Rational(1, 2)} + ) def test_append_event_error_node_list_with_non_terminal_node(): - """Test that we get an UndefinedOperationError when the node list has a - non-terminal node. + """Test that we get an UndefinedOperationError when a Selector passed to + append_event matches a non-terminal node. """ game = games.read_from_file("sample_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.append_event( - [game.root.children["2"], game.root.children["1"].children["2"]], - ["a", "b"], - [gbt.Rational(1, 2)] * 2 - ) - - -def test_append_event_error_node_list_with_duplicate_node_references(): - """Test that we get a ValueError when the node list has non-unique node references.""" - game = games.read_from_file("sample_extensive_game.efg") - node = game.root.children["1"].children["2"] - with pytest.raises(ValueError): - game.append_event([node, node], ["a", "b"], [gbt.Rational(1, 2)] * 2) + game.append_event(gbt.H.path(...), {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 2)}) def test_append_event_error_node_list_is_empty(): - """Test that we get a ValueError when the node list is empty.""" + """Test that we get a ValueError when a Selector passed to append_event + matches no nodes. + """ game = games.read_from_file("sample_extensive_game.efg") with pytest.raises(ValueError): - game.append_event([], ["a", "b"], [gbt.Rational(1, 2)] * 2) + game.append_event( + gbt.H.path(...).filter(lambda h: False), + {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 2)} + ) def test_append_event_error_invalid_distribution(): """Test that a distribution which does not sum to one is rejected.""" game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["U2"].children["U3"] with pytest.raises(ValueError): - game.append_event(terminal, ["a", "b"], [gbt.Rational(1, 2), gbt.Rational(1, 3)]) + game.append_event( + gbt.H.path("U1", "U2", "U3"), {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 3)} + ) def test_insert_event_actions_labeled(): """Test that the inserted event's actions are labeled according to `actions`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") node = game.root.children["L"].children["R"] - game.insert_event(node, ["Up", "Down"], [gbt.Rational(1, 2)] * 2) + game.insert_event(gbt.H.path("L", "R"), {"Up": gbt.Rational(1, 2), "Down": gbt.Rational(1, 2)}) assert list(node.parent.actions) == ["Up", "Down"] assert node.parent.event @@ -1019,7 +937,7 @@ def test_insert_event_sets_distribution(): """Test that the inserted event's actions carry the given probabilities.""" game = games.read_from_file("basic_extensive_game.efg") node = game.root - game.insert_event(node, ["a", "b"], [gbt.Rational(1, 4), gbt.Rational(3, 4)]) + game.insert_event(gbt.H.path(), {"a": gbt.Rational(1, 4), "b": gbt.Rational(3, 4)}) assert list(node.parent.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] @@ -1027,36 +945,21 @@ def test_insert_event_error_actions_empty(): """Test to ensure there are actions when inserting an event.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.insert_event(game.root, [], []) - - -def test_insert_event_error_node_mismatch(): - """Test to ensure the node is from this game.""" - game1 = gbt.Game.new_tree() - game2 = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(gbt.MismatchError): - game1.insert_event(game2.root, ["a", "b"], [gbt.Rational(1, 2)] * 2) + game.insert_event(gbt.H.path(), {}) def test_insert_event_error_empty_label(): """Test that an empty label in `actions` is rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.insert_event(game.root, ["a", ""], [gbt.Rational(1, 2)] * 2) - - -def test_insert_event_error_duplicate_label(): - """Test that duplicated labels in `actions` are rejected.""" - game = games.read_from_file("basic_extensive_game.efg") - with pytest.raises(ValueError): - game.insert_event(game.root, ["a", "a"], [gbt.Rational(1, 2)] * 2) + game.insert_event(gbt.H.path(), {"a": gbt.Rational(1, 2), "": gbt.Rational(1, 2)}) def test_insert_event_error_invalid_distribution(): """Test that a distribution which does not sum to one is rejected.""" game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.insert_event(game.root, ["a", "b"], [gbt.Rational(1, 2), gbt.Rational(1, 3)]) + game.insert_event(gbt.H.path(), {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 3)}) def _count_subtree_nodes(start_node: gbt.Node, count_terminal: bool) -> int: @@ -1097,7 +1000,7 @@ def test_len_after_delete_tree(): root_of_the_deleted_subtree = game.root.children["R"].children["L"] number_of_deleted_nodes = _count_subtree_nodes(root_of_the_deleted_subtree, True) - 1 - game.delete_tree(root_of_the_deleted_subtree) + game.delete_tree(gbt.H.path("R", "L")) assert len(game.nodes) == initial_number_of_nodes - number_of_deleted_nodes @@ -1114,7 +1017,7 @@ def test_len_after_delete_parent(): number_of_parent_ancestors = _count_subtree_nodes(node_parent_to_delete.parent, True) diff = number_of_parent_ancestors - number_of_node_ancestors - game.delete_parent(node_parent_to_delete) + game.delete_parent(gbt.H.path("L", "L")) assert len(game.nodes) == initial_number_of_nodes - diff @@ -1124,11 +1027,10 @@ def test_len_after_append_move(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = len(game.nodes) - terminal_node = game.root.children["R"].children["L"].children["L"] # the [1,1,0] terminal player = "Player 1" actions_to_add = ["T", "M", "B"] - game.append_move(terminal_node, player, actions_to_add) + game.append_move(gbt.H.path("R", "L", "L"), player, actions_to_add) # the [1,1,0] terminal assert len(game.nodes) == initial_number_of_nodes + len(actions_to_add) @@ -1139,12 +1041,10 @@ def test_len_after_append_infoset(): game = gbt.catalog.load("journals/ijgt/selten1975/fig2") initial_number_of_nodes = len(game.nodes) - member_node = game.root.children["L"] - infoset_to_modify = member_node.infoset + infoset_to_modify = game.root.children["L"].infoset number_of_infoset_actions = len(infoset_to_modify.actions) - terminal_node_to_add = game.root.children["L"].children["L"].children["l"] - game.append_infoset(terminal_node_to_add, member_node) + game.append_infoset(gbt.H.path("L", "L", "l"), gbt.H.path("L")) assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions @@ -1156,7 +1056,7 @@ def test_len_after_set_move_actions_add(): infoset_to_modify = game.root.children["L"].infoset # Player 2's infoset num_nodes_in_infoset = len(infoset_to_modify.members) labels = list(infoset_to_modify.actions) - game.set_move_actions(game.root.children["L"], labels + ["new"]) + game.set_move_actions(gbt.H.path("L"), labels + ["new"]) assert len(game.nodes) == initial_number_of_nodes + num_nodes_in_infoset @@ -1170,7 +1070,7 @@ def test_len_after_set_move_actions_drop(): for member in game.root.infoset.members ) remaining = [a for a in game.root.infoset.actions if a != "L"] - game.set_move_actions(game.root, remaining, drop=True) + game.set_move_actions(gbt.H.path(), remaining, drop=True) assert len(game.nodes) == initial_number_of_nodes - nodes_to_delete @@ -1179,11 +1079,10 @@ def test_len_after_insert_move(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = len(game.nodes) - node_to_insert_above = game.root.children["L"].children["R"] # the [1, 0] node player = "Player 2" actions_to_add = ["a", "b", "c"] - game.insert_move(node_to_insert_above, player, actions_to_add) + game.insert_move(gbt.H.path("L", "R"), player, actions_to_add) # the [1, 0] node assert len(game.nodes) == initial_number_of_nodes + len(actions_to_add) @@ -1192,7 +1091,7 @@ def test_insert_move_actions_labeled(): """Test that the inserted move's actions are labeled according to `actions`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") node = game.root.children["L"].children["R"] - game.insert_move(node, "Player 2", ["Up", "Down"]) + game.insert_move(gbt.H.path("L", "R"), "Player 2", ["Up", "Down"]) assert list(node.parent.infoset.actions) == ["Up", "Down"] @@ -1203,10 +1102,9 @@ def test_len_after_insert_infoset(): initial_number_of_nodes = len(game.nodes) infoset_to_modify = game.root.children["L"].infoset - node_to_insert_above = game.root.children["L"].children["R"] number_of_infoset_actions = len(infoset_to_modify.actions) - game.insert_infoset(node_to_insert_above, game.root.children["L"]) + game.insert_infoset(gbt.H.path("L", "R"), gbt.H.path("L")) assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions @@ -1217,10 +1115,9 @@ def test_len_after_copy_tree(): game = gbt.catalog.load("journals/ijgt/selten1975/fig1") initial_number_of_nodes = len(game.nodes) src_node = game.root.children["R"].children["L"] - dest_node = game.root.children["R"].children["R"] number_of_src_ancestors = _count_subtree_nodes(src_node, True) - game.copy_tree(src_node, dest_node) + game.copy_tree(gbt.H.path("R", "L"), gbt.H.path("R", "R")) assert len(game.nodes) == initial_number_of_nodes + number_of_src_ancestors - 1 diff --git a/tests/test_outcomes.py b/tests/test_outcomes.py index b34dc1ac9..3246feb2d 100644 --- a/tests/test_outcomes.py +++ b/tests/test_outcomes.py @@ -7,9 +7,11 @@ def test_make_outcome_attaches_to_all_given_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) up, middle, down = game.root.children - outcome = game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") + outcome = game.make_outcome( + gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1, "Bob": -1}, "shared" + ) assert up.outcome == outcome assert middle.outcome == outcome assert not down.outcome @@ -17,6 +19,50 @@ def test_make_outcome_attaches_to_all_given_nodes(): assert outcome["Bob"] == -1 +def test_make_outcome_accepts_selector(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) + up, middle, down = game.root.children + outcome = game.make_outcome(gbt.H.path("U"), {"Alice": 1, "Bob": -1}, "shared") + assert up.outcome == outcome + assert not middle.outcome + assert not down.outcome + + +def test_make_outcome_accepts_selector_matching_several_nodes(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) + up, middle, down = game.root.children + outcome = game.make_outcome(gbt.H.plays, {"Alice": 1, "Bob": -1}, "shared") + assert up.outcome == outcome + assert middle.outcome == outcome + assert down.outcome == outcome + + +def test_make_outcome_accepts_grouped_selector_pooled(): + """A `GroupedSelector`'s groups are pooled together: every matched node + receives the same outcome, regardless of grouping.""" + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) + up, middle, down = game.root.children + outcome = game.make_outcome( + gbt.H.path(...).by(lambda h: h[0]), {"Alice": 1, "Bob": -1}, "shared" + ) + assert up.outcome == outcome + assert middle.outcome == outcome + assert down.outcome == outcome + + +def test_make_outcome_error_location_not_a_selector(): + """A bare `Node` or `History` tuple is no longer accepted for an extensive game.""" + game = gbt.Game.new_tree(["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) + with pytest.raises(TypeError): + game.make_outcome(game.root.children["U"], {"Alice": 1}, "w") + with pytest.raises(TypeError): + game.make_outcome(("U",), {"Alice": 1}, "w") + + def test_make_outcome_attaches_at_contingencies(): game = gbt.Game.new_table([2, 2]) outcome = game.make_outcome( @@ -30,39 +76,36 @@ def test_make_outcome_attaches_at_contingencies(): def test_make_outcome_absorbs_fully_covered_outcome_and_reuses_label(): game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["U", "D"]) - up, down = game.root.children - game.make_outcome(up, {"Alice": 1}, "w") - game.make_outcome([up, down], {"Alice": 2}, "w") + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) + game.make_outcome(gbt.H.path("U"), {"Alice": 1}, "w") + game.make_outcome(gbt.H.path(...), {"Alice": 2}, "w") assert [(o.label, o["Alice"]) for o in game.outcomes] == [("w", 2)] def test_make_outcome_label_of_partially_covered_outcome_refused(): game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children - game.make_outcome([up, middle], {"Alice": 1}, "w") + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) + game.make_outcome(gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1}, "w") with pytest.raises(ValueError): - game.make_outcome(down, {"Alice": 2}, "w") + game.make_outcome(gbt.H.path("D"), {"Alice": 2}, "w") assert len(game.outcomes) == 1 @pytest.mark.parametrize("bad_label", ["", "win"]) def test_make_outcome_bad_label_raises_and_leaves_game_unchanged(bad_label: str): game = gbt.Game.new_tree(players=["A", "B"]) - game.append_move(game.root, "A", ["win", "lose"]) - win_node, lose_node = game.root.children - game.make_outcome(win_node, {"A": 1, "B": 2}, "win") + game.append_move(gbt.H.path(), "A", ["win", "lose"]) + game.make_outcome(gbt.H.path("win"), {"A": 1, "B": 2}, "win") with pytest.raises(ValueError): - game.make_outcome(lose_node, {"A": 3, "B": 4}, bad_label) + game.make_outcome(gbt.H.path("lose"), {"A": 3, "B": 4}, bad_label) assert [o.label for o in game.outcomes] == ["win"] def test_make_outcome_incomplete_payoffs_raises(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "D"]) + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) with pytest.raises(ValueError): - game.make_outcome(next(iter(game.root.children)), {"Alice": 1}, "w") + game.make_outcome(gbt.H.path("U"), {"Alice": 1}, "w") class _RepeatedEntryPayoffs: @@ -82,23 +125,35 @@ def items(self): def test_make_outcome_payoffs_naming_player_twice_raises(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "D"]) + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) payoffs = _RepeatedEntryPayoffs([("Alice", 1), ("Alice", 2), ("Bob", 0)]) with pytest.raises(ValueError): - game.make_outcome(next(iter(game.root.children)), payoffs, "w") + game.make_outcome(gbt.H.path("U"), payoffs, "w") -def test_make_outcome_null_resets_given_nodes_to_null(): +def test_make_outcome_null_accepts_selector(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) up, middle, down = game.root.children - game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") - game.make_outcome_null(up) + game.make_outcome( + gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1, "Bob": -1}, "shared" + ) + game.make_outcome_null(gbt.H.path("U")) assert not up.outcome assert middle.outcome assert not down.outcome +def test_make_outcome_null_error_location_not_a_selector(): + """A bare `Node` or `History` tuple is no longer accepted for an extensive game.""" + game = gbt.Game.new_tree(["Alice"]) + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) + with pytest.raises(TypeError): + game.make_outcome_null(game.root.children["U"]) + with pytest.raises(TypeError): + game.make_outcome_null(("U",)) + + def test_make_outcome_null_resets_given_contingencies_to_null(): game = gbt.Game.new_table([2, 2]) game.make_outcome( @@ -121,21 +176,21 @@ def test_make_outcome_null_removes_fully_orphaned_outcome(): def test_make_outcome_null_keeps_partially_referenced_outcome(): game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children - game.make_outcome([up, middle], {"Alice": 1}, "shared") + game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) + middle = game.root.children["M"] + game.make_outcome(gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1}, "shared") outcome_count = len(game.outcomes) - game.make_outcome_null(up) + game.make_outcome_null(gbt.H.path("U")) assert len(game.outcomes) == outcome_count assert middle.outcome def test_make_outcome_null_on_already_null_node_is_a_no_op(): game = gbt.Game.new_tree(["Alice"]) - game.append_move(game.root, "Alice", ["U", "D"]) - up, _ = game.root.children + game.append_move(gbt.H.path(), "Alice", ["U", "D"]) + up = game.root.children["U"] outcome_count = len(game.outcomes) - game.make_outcome_null(up) + game.make_outcome_null(gbt.H.path("U")) assert outcome_count == len(game.outcomes) assert not up.outcome @@ -209,10 +264,9 @@ def test_outcome_payoff_by_player_label(): def test_outcome_relabel_duplicate_rejected_and_label_unchanged(): game = gbt.Game.new_tree(players=["A", "B"]) - game.append_move(game.root, "A", ["win", "lose"]) - win_node, lose_node = game.root.children - game.make_outcome(win_node, {"A": 1, "B": 2}, "win") - outcome = game.make_outcome(lose_node, {"A": 0, "B": 0}, "lose") + game.append_move(gbt.H.path(), "A", ["win", "lose"]) + game.make_outcome(gbt.H.path("win"), {"A": 1, "B": 2}, "win") + outcome = game.make_outcome(gbt.H.path("lose"), {"A": 0, "B": 0}, "lose") with pytest.raises(ValueError): outcome.label = "win" assert outcome.label == "lose" diff --git a/tests/test_players.py b/tests/test_players.py index 76708e2fd..73e89c008 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -375,7 +375,7 @@ def test_player_get_min_payoff_nonterminal_outcomes(): game = games.read_from_file("stripped_down_poker.efg") assert game.get_min_payoff("Alice") == -2 assert game.get_min_payoff("Bob") == -2 - game.make_outcome(game.root, {"Alice": -1, "Bob": -1}, "outcome") + game.make_outcome(gbt.H.path(), {"Alice": -1, "Bob": -1}, "outcome") assert game.get_min_payoff("Alice") == -3 assert game.get_min_payoff("Bob") == -3 @@ -401,7 +401,7 @@ def test_player_get_max_payoff_nonterminal_outcomes(): game = games.read_from_file("stripped_down_poker.efg") assert game.get_max_payoff("Alice") == 2 assert game.get_max_payoff("Bob") == 2 - game.make_outcome(game.root, {"Alice": -1, "Bob": -1}, "outcome") + game.make_outcome(gbt.H.path(), {"Alice": -1, "Bob": -1}, "outcome") assert game.get_max_payoff("Alice") == 1 assert game.get_max_payoff("Bob") == 1