diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index e8ef2c8a6..dd218b67d 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -108,12 +108,10 @@ Information about the game Game.max_payoff Game.get_min_payoff Game.get_max_payoff - Game.root Game.get_infosets Game.get_events Game.get_strategies Game.get_sequences - Game.nodes Game.contingencies Game.get_outcome Game.get_payoffs @@ -150,6 +148,7 @@ Information about the game Node.is_successor_of Node.plays Node.own_prior_action + Node.history .. autosummary:: :toctree: api/ diff --git a/doc/tutorials/02_extensive_form.ipynb b/doc/tutorials/02_extensive_form.ipynb index 97ae7c99a..30ce8828a 100644 --- a/doc/tutorials/02_extensive_form.ipynb +++ b/doc/tutorials/02_extensive_form.ipynb @@ -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(), # This is the node to append the move to\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..ea02973d2 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -104,7 +104,13 @@ "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\", \"Queen\"],\n", + " probs=[gbt.Rational(1, 2), gbt.Rational(1, 2)]\n", + ")" + ] }, { "cell_type": "code", @@ -137,7 +143,7 @@ "metadata": {}, "outputs": [], "source": [ - "for node in g.root.children:\n", + "for node in g.get_nodes(gbt.H.path(...)):\n", " g.append_move(\n", " node,\n", " player=\"Alice\",\n", @@ -162,15 +168,15 @@ "source": [ "The loop above causes each of the newly-appended moves to be in new information sets, reflecting the fact that Alice's decision depends on the knowledge of which card she holds.\n", "\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", + "In contrast, Bob does not know Alice\u2019s 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 by passing a selector matching both nodes to the `append_move` method:" ] }, { @@ -181,7 +187,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 +215,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", @@ -347,7 +381,7 @@ " for action in node.actions:\n", " print(\n", " f\"At information set {node.infoset.number}, \"\n", - " f\"Alice plays {action} with probability: {eqm[node][action]}\"\n", + " f\"Alice plays {action} with probability: {eqm[node.history][action]}\"\n", " )" ] }, @@ -356,7 +390,7 @@ "id": "1f121d48", "metadata": {}, "source": [ - "Now let's look at Bob’s strategy:" + "Now let's look at Bob\u2019s strategy:" ] }, { @@ -374,7 +408,7 @@ "id": "e906c4c4", "metadata": {}, "source": [ - "Bob Calls Alice’s Bet two-thirds of the time.\n", + "Bob Calls Alice\u2019s Bet two-thirds of the time.\n", "\n", "Since Bob has just one information set, we can get its representative node and index\n", "the profile directly by it to read off a single action's probability:" @@ -386,7 +420,11 @@ "id": "2966e700", "metadata": {}, "outputs": [], - "source": "(bob_node,) = g.get_infosets(\"Bob\")\nbob_infoset = bob_node.infoset\neqm[bob_node][\"Call\"]" + "source": [ + "(bob_node,) = g.get_infosets(\"Bob\")\n", + "bob_infoset = bob_node.infoset\n", + "eqm[bob_node.history][\"Call\"]" + ] }, { "cell_type": "markdown", @@ -421,7 +459,7 @@ "\n", "`MixedBehaviorProfile.beliefs` returns the probability of reaching each node, conditional on its information set being reached.\n", "\n", - "Recall that the two nodes in Bob's only information set are `g.root.children[\"King\"].children[\"Bet\"]` and `g.root.children[\"Queen\"].children[\"Bet\"]`):" + "Recall that the two nodes in Bob's only information set are `gbt.H.path(\"King\", \"Bet\")` and `gbt.H.path(\"Queen\", \"Bet\")`):" ] }, { @@ -819,8 +857,9 @@ "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", - "list(small_game.root.action_probs.values())" + "root = small_game.get_nodes(gbt.H.path())[0]\n", + "small_game.append_event(root, [\"a\", \"b\", \"c\"], [gbt.Rational(1, 3)] * 3)\n", + "list(root.action_probs.values())" ] }, { @@ -837,10 +876,10 @@ "outputs": [], "source": [ "small_game.make_event(\n", - " [small_game.root],\n", + " [root],\n", " [gbt.Rational(1, 4), gbt.Rational(1, 2), gbt.Rational(1, 4)]\n", ")\n", - "list(small_game.root.action_probs.values())" + "list(root.action_probs.values())" ] }, { @@ -859,10 +898,10 @@ "outputs": [], "source": [ "small_game.make_event(\n", - " [small_game.root],\n", + " [root],\n", " [gbt.Decimal(\".25\"), gbt.Decimal(\".50\"), gbt.Decimal(\".25\")]\n", ")\n", - "list(small_game.root.action_probs.values())" + "list(root.action_probs.values())" ] }, { @@ -884,8 +923,8 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [\"1/4\", \"1/2\", \"1/4\"])\n", - "list(small_game.root.action_probs.values())" + "small_game.make_event([root], [\"1/4\", \"1/2\", \"1/4\"])\n", + "list(root.action_probs.values())" ] }, { @@ -895,8 +934,8 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [\".25\", \".50\", \".25\"])\n", - "list(small_game.root.action_probs.values())" + "small_game.make_event([root], [\".25\", \".50\", \".25\"])\n", + "list(root.action_probs.values())" ] }, { @@ -919,8 +958,8 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event([small_game.root], [.25, .50, .25])\n", - "list(small_game.root.action_probs.values())" + "small_game.make_event([root], [.25, .50, .25])\n", + "list(root.action_probs.values())" ] }, { @@ -937,7 +976,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([root], [1/3, 1/3, 1/3])\n", + "except ValueError as e:\n", + " print(\"ValueError:\", e)\n" + ] }, { "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..fa02233b6 --- /dev/null +++ b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb @@ -0,0 +1,513 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "014efe26", + "metadata": {}, + "source": [ + "# The `H` node-selector algebra: six worked examples\n", + "\n", + "This notebook is a design prototype, not a released feature. `pygambit.gambit.H` is an\n", + "internal, unreleased module -- everything here demonstrates a work-in-progress replacement\n", + "for constructing extensive-form games without ever handling raw `Node` objects.\n", + "\n", + "The core idea: a *selector*, built from `H`, describes a set of histories symbolically --\n", + "it carries no reference to any particular game until you hand it to one. `H.path(*steps)`\n", + "walks a sequence of exact labels and/or `...` wildcards from the root (or from wherever a\n", + "selection currently is, when chained); `H.after(*labels)` matches anywhere by a trailing\n", + "label pattern; `.plays` expands to whatever is currently terminal; `.by(callable)`\n", + "partitions a selection by a key function, and `.filter(callable)` keeps only matching\n", + "elements. `Game.append_move`/`append_event`/`append_infoset`/`make_outcome` all accept these\n", + "selectors directly, in place of `Node`/`NodeReferenceSet`.\n", + "\n", + "Six examples below, each chosen to exercise a different corner of the design: a classic\n", + "imperfect-information game needing `append_infoset`, a game with betting and outcome\n", + "computation, a regular two-stage Bayesian game, three different shapes of imperfect\n", + "*recall*, and a variation showing `append_infoset` composes normally with further\n", + "construction." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "2e80dd03", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:36.060080Z", + "iopub.status.busy": "2026-09-02T18:45:36.059915Z", + "iopub.status.idle": "2026-09-02T18:45:36.970529Z", + "shell.execute_reply": "2026-09-02T18:45:36.970258Z" + } + }, + "outputs": [], + "source": [ + "try:\n", + " from gtdraw import draw\n", + "except ImportError:\n", + " def draw(*args, **kwargs):\n", + " print(\"gtdraw is not installed; game trees won't be drawn, but everything else runs.\")\n", + "\n", + "from pygambit.gambit import H\n", + "\n", + "import pygambit as gbt" + ] + }, + { + "cell_type": "markdown", + "id": "ddd44d44", + "metadata": {}, + "source": [ + "## 1. Selten's Horse\n", + "\n", + "A classic three-player game (Selten, 1975) used to illustrate subtleties of sequential\n", + "equilibrium. Player 1 moves first; if he plays \"R\", Player 2 moves; if Player 2 also plays\n", + "\"L\", or if Player 1 played \"L\" directly, Player 3 faces the same decision either way --\n", + "**Player 3 cannot tell which path led there**.\n", + "\n", + "This needs `append_infoset`, not because of anything exotic about recall or timing (the\n", + "game is perfectly ordinary on both counts), but for a mundane construction-ordering reason:\n", + "Player 3's two infoset members aren't simultaneously available. The node reached via a bare\n", + "\"L\" exists as soon as Player 1 moves; the node reached via \"R\", \"L\" only exists once Player 2\n", + "has *also* moved -- so one `append_move` call can never cover both." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "25387762", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:36.971889Z", + "iopub.status.busy": "2026-09-02T18:45:36.971789Z", + "iopub.status.idle": "2026-09-02T18:45:37.473789Z", + "shell.execute_reply": "2026-09-02T18:45:37.473536Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: True\n", + "Player 3's infoset members (as Histories, not raw Node paths -- the latter display node-to-root, easy to misread): [('L',), ('R', 'L')]\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\", \"Player 3\"], title=\"Selten's Horse\")\n", + "\n", + "g.append_move(H.path(), \"Player 1\", [\"R\", \"L\"])\n", + "g.append_move(H.path(\"L\"), \"Player 3\", [\"R\", \"L\"])\n", + "g.append_move(H.path(\"R\"), \"Player 2\", [\"R\", \"L\"])\n", + "g.append_infoset(H.path(\"R\", \"L\"), H.path(\"L\"))\n", + "\n", + "g.make_outcome(H.path(\"R\", \"R\"), {\"Player 1\": 1, \"Player 2\": 1, \"Player 3\": 1}, \"RR\")\n", + "g.make_outcome(H.path(\"R\", \"L\", \"R\"), {\"Player 1\": 4, \"Player 2\": 4, \"Player 3\": 0}, \"RLR\")\n", + "g.make_outcome(H.path(\"R\", \"L\", \"L\"), {\"Player 1\": 0, \"Player 2\": 0, \"Player 3\": 1}, \"RLL\")\n", + "g.make_outcome(H.path(\"L\", \"R\"), {\"Player 1\": 3, \"Player 2\": 2, \"Player 3\": 2}, \"LR\")\n", + "g.make_outcome(H.path(\"L\", \"L\"), {\"Player 1\": 0, \"Player 2\": 0, \"Player 3\": 0}, \"LL\")\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "print(\n", + " \"Player 3's infoset members (as Histories, not raw Node paths -- the latter\"\n", + " \" display node-to-root, easy to misread):\",\n", + " sorted(g.get_histories(H.path(\"L\")) + g.get_histories(H.path(\"R\", \"L\"))),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "2ca9c9c1", + "metadata": {}, + "source": [ + "## 2. Kuhn poker\n", + "\n", + "Three-card poker, the standard small illustration of imperfect information *and* betting.\n", + "This example exercises the recall-tracking machinery in earnest: Alice's second decision\n", + "(call/fold after checking then facing a bet) must still distinguish her own card, even\n", + "though the tree has grown well past where that distinction was first established.\n", + "\n", + "`alice_partition` is built once, tagged `.with_recall(\"Alice\")`, and reused for both of her\n", + "decisions -- the tag makes `.plays` automatically fold her own last action into the group\n", + "key from her second decision onward, with no separate re-derivation step. `bob_partition`\n", + "never needs the tag: his two uses are his *one* decision instantiated on two mutually\n", + "exclusive branches, not a first-then-second sequence for him.\n", + "\n", + "Outcome computation is a genuinely different kind of selector from the recall-tracking\n", + "above: `winner`/`pot_size` are direct, declarative facts about a completed hand (who took\n", + "the pot, how much), not a player's own partial view of the game -- an outcome deliberately\n", + "throws away *how* a given payoff was reached, which is the opposite spirit from recall\n", + "grouping's insistence on never conflating what a player can actually tell apart." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "923feb0b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:37.474927Z", + "iopub.status.busy": "2026-09-02T18:45:37.474792Z", + "iopub.status.idle": "2026-09-02T18:45:38.086934Z", + "shell.execute_reply": "2026-09-02T18:45:38.086660Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: True\n" + ] + } + ], + "source": [ + "CARD_VALUE = {\"J\": 0, \"Q\": 1, \"K\": 2}\n", + "cards = list(CARD_VALUE)\n", + "\n", + "g = gbt.Game.new_tree(players=[\"Alice\", \"Bob\"], title=\"Kuhn poker\")\n", + "g.append_event(H.path(), cards, [gbt.Rational(1, 3)] * 3)\n", + "for c in cards:\n", + " remaining = [x for x in cards if x != c]\n", + " g.append_event(H.path(c), remaining, [gbt.Rational(1, 2)] * 2)\n", + "\n", + "alice_partition = H.path(...).by(lambda h: h[0]).with_recall(\"Alice\")\n", + "g.append_move(alice_partition.plays, \"Alice\", [\"Check\", \"Bet\"])\n", + "\n", + "bob_partition = H.path(..., ...).by(lambda h: h[1])\n", + "g.append_move(bob_partition.plays.after(\"Check\"), \"Bob\", [\"Check\", \"Bet\"])\n", + "\n", + "g.append_move(alice_partition.plays.after(\"Check\", \"Bet\"), \"Alice\", [\"Fold\", \"Call\"])\n", + "g.append_move(bob_partition.plays.after(\"Bet\"), \"Bob\", [\"Fold\", \"Call\"])\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "b11e3f16", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:38.088103Z", + "iopub.status.busy": "2026-09-02T18:45:38.087990Z", + "iopub.status.idle": "2026-09-02T18:45:38.091896Z", + "shell.execute_reply": "2026-09-02T18:45:38.091662Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Total outcomes created: 4\n" + ] + } + ], + "source": [ + "def winner(h):\n", + " match h[2:]:\n", + " case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n", + " return \"Alice\" if CARD_VALUE[h[0]] > CARD_VALUE[h[1]] else \"Bob\"\n", + " case (\"Check\", \"Bet\", \"Fold\"):\n", + " return \"Bob\"\n", + " case (\"Bet\", \"Fold\"):\n", + " return \"Alice\"\n", + "\n", + "def pot_size(h):\n", + " match h[2:]:\n", + " case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Fold\") | (\"Bet\", \"Fold\"):\n", + " return 1\n", + " case (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n", + " return 2\n", + "\n", + "for (win, amount), group in g.get_groups(H.plays.by(lambda h: (winner(h), pot_size(h)))).items():\n", + " lose = \"Bob\" if win == \"Alice\" else \"Alice\"\n", + " g.make_outcome(group, {win: amount, lose: -amount}, f\"{win} wins {amount}\")\n", + "\n", + "print(\"Total outcomes created:\", len(list(g.outcomes)))" + ] + }, + { + "cell_type": "markdown", + "id": "51ade76e", + "metadata": {}, + "source": [ + "## 3. `bayes2a`: a regular two-stage Bayesian game\n", + "\n", + "A fully \"timeable\" game with private types and two rounds of simultaneous moves --\n", + "`contrib/games/bayes2a.efg` in the repository. Both players privately learn a type, then\n", + "move simultaneously each round; each round's actions become public before the next round.\n", + "\n", + "Unlike Kuhn poker, this game needs neither `.with_recall` nor `append_infoset` -- every\n", + "player's decision falls at a fixed, predictable position in the history across every\n", + "branch, so plain positional indexing on the augmented history object is all the grouping\n", + "needs. This is deliberately included as a contrast case: `H`'s dedicated recall machinery\n", + "exists for games that need it, but a well-behaved regular game doesn't have to pay for it." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "03767b4c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:38.092996Z", + "iopub.status.busy": "2026-09-02T18:45:38.092929Z", + "iopub.status.idle": "2026-09-02T18:45:38.096810Z", + "shell.execute_reply": "2026-09-02T18:45:38.096572Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: True\n", + "terminal histories: 64\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"bayes2a\")\n", + "half = gbt.Rational(1, 2)\n", + "\n", + "g.append_event(H.path(), [\"1G\", \"1B\"], [half, half])\n", + "for t1 in [\"1G\", \"1B\"]:\n", + " g.append_event(H.path(t1), [\"2g\", \"2b\"], [half, half])\n", + "\n", + "# Round 1: each player's move depends only on their own type.\n", + "g.append_move(H.path(...).plays.by(lambda h: h[0]), \"Player 1\", [\"H\", \"L\"])\n", + "g.append_move(H.path(..., ...).plays.by(lambda h: h[1]), \"Player 2\", [\"h\", \"l\"])\n", + "\n", + "# Round 2: both round-1 actions are now public; each player also still knows their own type.\n", + "g.append_move(H.plays.by(lambda h: (h[0], h[2], h[3])), \"Player 1\", [\"H\", \"L\"])\n", + "g.append_move(H.plays.by(lambda h: (h[1], h[2], h[3])), \"Player 2\", [\"h\", \"l\"])\n", + "\n", + "PAYOFFS = {\n", + " (\"1G\", \"H\", \"h\"): (10, 2), (\"1G\", \"H\", \"l\"): (0, 10),\n", + " (\"1G\", \"L\", \"h\"): (2, 4), (\"1G\", \"L\", \"l\"): (4, 0),\n", + " (\"1B\", \"H\", \"h\"): (4, 2), (\"1B\", \"H\", \"l\"): (2, 10),\n", + " (\"1B\", \"L\", \"h\"): (0, 4), (\"1B\", \"L\", \"l\"): (10, 0),\n", + "}\n", + "for (p1, p2), group in g.get_groups(H.plays.by(lambda h: PAYOFFS[(h[0], h[4], h[5])])).items():\n", + " g.make_outcome(group, {\"Player 1\": p1, \"Player 2\": p2}, f\"({p1},{p2})\")\n", + "\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "print(\"terminal histories:\", len(g.get_histories(H.plays)))" + ] + }, + { + "cell_type": "markdown", + "id": "c1af37be", + "metadata": {}, + "source": [ + "## 4. Imperfect recall: forgetting a past observation\n", + "\n", + "A third, distinct shape of imperfect recall, alongside absent-mindedness and\n", + "untimeability below. Alice privately observes a signal (H or L) and acts on it -- her\n", + "first decision is correctly split into two infosets, one per signal. Bob then moves,\n", + "seeing nothing private. Alice's *second* decision is deliberately built to merge across\n", + "both signal values, keyed only by her own first action and Bob's -- she is modeled as\n", + "having forgotten the signal that legitimately informed her own first move.\n", + "\n", + "This is neither absent-mindedness (no single node is ever revisited -- these are two\n", + "separate first-decision infosets being merged, not one node crossed twice) nor\n", + "untimeability (every one of Alice's second-decision nodes sits at exactly the same depth --\n", + "the issue is purely about what she remembers, not about timing)." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "c1fc0b54", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:38.097844Z", + "iopub.status.busy": "2026-09-02T18:45:38.097770Z", + "iopub.status.idle": "2026-09-02T18:45:38.594007Z", + "shell.execute_reply": "2026-09-02T18:45:38.593205Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: False\n", + " Alice's 2nd decision, key=('Down', 'x'): [('H', 'Down', 'x'), ('L', 'Down', 'x')]\n", + " Alice's 2nd decision, key=('Down', 'y'): [('H', 'Down', 'y'), ('L', 'Down', 'y')]\n", + " Alice's 2nd decision, key=('Up', 'x'): [('H', 'Up', 'x'), ('L', 'Up', 'x')]\n", + " Alice's 2nd decision, key=('Up', 'y'): [('H', 'Up', 'y'), ('L', 'Up', 'y')]\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Alice\", \"Bob\"], title=\"Forgetting a past observation\")\n", + "half = gbt.Rational(1, 2)\n", + "\n", + "g.append_event(H.path(), [\"H\", \"L\"], [half, half])\n", + "g.append_move(H.path(\"H\"), \"Alice\", [\"Up\", \"Down\"])\n", + "g.append_move(H.path(\"L\"), \"Alice\", [\"Up\", \"Down\"])\n", + "g.append_move(H.path(..., ...), \"Bob\", [\"x\", \"y\"])\n", + "\n", + "# Keyed by (Alice's own first action, Bob's action) only -- h[0], the signal, is dropped.\n", + "g.append_move(H.plays.by(lambda h: (h[1], h[2])), \"Alice\", [\"Fold\", \"Call\"])\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "# Depth 3, explicitly -- these are the same histories the construction above\n", + "# grouped by (h[1], h[2]) to create Alice's second decision.\n", + "groups = g.get_groups(H.path(..., ..., ...).by(lambda h: (h[1], h[2])))\n", + "for key, members in sorted(groups.items()):\n", + " print(f\" Alice's 2nd decision, key={key}: {sorted(members)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "4d009aae", + "metadata": {}, + "source": [ + "## 5. An untimeable game\n", + "\n", + "Jakobsen, Sørensen & Conitzer (2016), Figure 1(a): a coin toss decides who moves first;\n", + "each player then guesses whether they went first or second, unable to tell which, since\n", + "neither observes the other's move or the coin. Each player's infoset spans both a\n", + "depth-1 node (moving first) and depth-2 nodes (moving second) -- and, unlike Selten's\n", + "Horse above, **no valid timing assignment exists at all**, even allowing a dense\n", + "(non-integer) time scale: each player's second decision would need to come strictly after\n", + "the *other's* first decision, on different branches -- a circular constraint no monotonic\n", + "timing can resolve. Perfect recall holds throughout regardless -- neither player forgets\n", + "anything, each has only one decision to have forgotten at." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "d8ac1b78", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:38.595461Z", + "iopub.status.busy": "2026-09-02T18:45:38.595355Z", + "iopub.status.idle": "2026-09-02T18:45:39.026877Z", + "shell.execute_reply": "2026-09-02T18:45:39.026590Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: True\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"Untimeable (Jakobsen et al. 2016)\")\n", + "\n", + "g.append_event(H.path(), [\"1\", \"2\"], [gbt.Rational(1, 2)] * 2)\n", + "\n", + "g.append_move(H.path(\"1\"), \"Player 2\", [\"1\", \"2\"])\n", + "g.append_move(H.path(\"2\"), \"Player 1\", [\"1\", \"2\"])\n", + "\n", + "g.append_infoset(H.path(\"1\", ...), H.path(\"2\"))\n", + "g.append_infoset(H.path(\"2\", ...), H.path(\"1\"))\n", + "\n", + "def outcome_key(h):\n", + " p1_guess = h.last_action(\"Player 1\")\n", + " p2_guess = h.last_action(\"Player 2\")\n", + " return (p1_guess == h[0], p2_guess != h[0])\n", + "\n", + "for (p1_ok, p2_ok), group in g.get_groups(H.plays.by(outcome_key)).items():\n", + " g.make_outcome(\n", + " group, {\"Player 1\": int(p1_ok), \"Player 2\": int(p2_ok)},\n", + " f\"P1 {'correct' if p1_ok else 'wrong'}, P2 {'correct' if p2_ok else 'wrong'}\",\n", + " )\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)" + ] + }, + { + "cell_type": "markdown", + "id": "02f8b9bb", + "metadata": {}, + "source": [ + "## 6. Absent-Minded Driver, with a further decision appended\n", + "\n", + "The classic Piccione–Rubinstein Absent-Minded Driver: one real binary decision (\"S\"/\"T\"),\n", + "faced *twice* without knowing which time it is, since the driver's own \"S\"-child shares\n", + "her first infoset. This variation goes one step further than the minimal version: after\n", + "her second \"S\", a *second* player gets a genuine, ordinary decision -- showing that\n", + "`append_infoset` composes normally with whatever construction comes after it; nothing\n", + "about the rest of the tree needs special treatment once the absent-minded infoset is set\n", + "up." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "26156c14", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-02T18:45:39.028098Z", + "iopub.status.busy": "2026-09-02T18:45:39.028013Z", + "iopub.status.idle": "2026-09-02T18:45:39.434611Z", + "shell.execute_reply": "2026-09-02T18:45:39.434339Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "is_perfect_recall: False\n", + "Player 1's (first) infoset members: [(), ('S',)]\n" + ] + } + ], + "source": [ + "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"Absent-Minded Driver, extended\")\n", + "\n", + "g.append_move(H.path(), \"Player 1\", [\"S\", \"T\"])\n", + "g.append_infoset(H.path(\"S\"), H.path())\n", + "g.append_move(H.path(\"S\", \"T\"), \"Player 2\", [\"r\", \"l\"])\n", + "\n", + "g.make_outcome(H.path(\"S\", \"S\"), {\"Player 1\": 1, \"Player 2\": -1}, \"SS\")\n", + "g.make_outcome(H.path(\"S\", \"T\", \"r\"), {\"Player 1\": 2, \"Player 2\": -2}, \"STr\")\n", + "g.make_outcome(H.path(\"S\", \"T\", \"l\"), {\"Player 1\": 3, \"Player 2\": -3}, \"STl\")\n", + "g.make_outcome(H.path(\"T\"), {\"Player 1\": 4, \"Player 2\": -4}, \"T\")\n", + "\n", + "draw(g)\n", + "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", + "print(\n", + " \"Player 1's (first) infoset members:\",\n", + " sorted(g.get_histories(H.path()) + g.get_histories(H.path(\"S\"))),\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index 0b8e7ea64..a9e251a1a 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -666,7 +666,59 @@ "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", + "\n", + "gbt_one_card_poker.append_event(\n", + " gbt.H.path(),\n", + " actions=[\"King\", \"Queen\"],\n", + " probs=[gbt.Rational(1, 2), gbt.Rational(1, 2)]\n", + ")\n", + "\n", + "for node in gbt_one_card_poker.get_nodes(gbt.H.path(...)):\n", + " gbt_one_card_poker.append_move(\n", + " node,\n", + " player=\"Alice\",\n", + " actions=[\"Bet\", \"Fold\"]\n", + " )\n", + "\n", + "gbt_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\n", + "gbt_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\n", + "gbt_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\n", + "gbt_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\n", + "gbt_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..18a274789 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -297,27 +297,35 @@ class MixedBehavior: """ yield from self._values.items() - def __getitem__(self, index: Node) -> MixedAction: + def __getitem__(self, index: tuple) -> MixedAction: """Returns the mixed action at the information set containing `index`. Parameters ---------- - index : Node - A node belonging to the information set to return. + index : History + A History (a tuple of action labels) belonging to the information set + to return. Raises ------ + TypeError + If `index` is not a ``History``. + KeyError + If `index` does not match any node of the game. MismatchError - If `index` is a ``Node`` from a different game, or belongs to an - information set that isn't this player's. + If `index` belongs to an information set that isn't this player's. ValueError - If `index` is a terminal node, which belongs to no information set. + If `index` resolves to a terminal node, which belongs to no + information set. """ - infoset = cython.cast(Infoset, index.infoset) - if not infoset: - raise ValueError("node is terminal, has no information set") + if not isinstance(index, tuple): + raise TypeError(f"index must be History, not {index.__class__.__name__}") + if not self._values: + raise MismatchError("history must belong to this player") + game = next(iter(self._values)).game + infoset = game._resolve_infoset(index, "__getitem__") if infoset.player != self._player: - raise MismatchError("node must belong to this player") + raise MismatchError("history must belong to this player") return self._values[infoset] @@ -388,29 +396,30 @@ class MixedBehaviorProfile: Parameters ---------- - index : str or Node + index : str or History The part of the profile to return: * If `index` is a ``str``, returns a ``MixedBehavior`` over the player's information sets. The player is determined by finding the player with that label, if any. - * If `index` is a ``Node``, returns a ``MixedAction`` over the actions at - the node's information set. + * If `index` is a ``History`` (a tuple of action labels, as returned by + e.g. `Game.get_histories`), returns a ``MixedAction`` over the actions + at that history's information set. Raises ------ TypeError - If `index` is not a ``str`` or a ``Node``. - MismatchError - If `index` is a ``Node`` from a different game. - ValueError - If `index` is a terminal ``Node``, which belongs to no information set. + If `index` is not a ``str`` or a ``History``. KeyError - If `index` is a ``str`` and no player in the game has that label. + If `index` is a ``str`` and no player in the game has that label, or a + ``History`` that does not match any node of the game. + ValueError + If `index` is a ``History`` resolving to a terminal node, which belongs + to no information set. """ self._check_validity() - if isinstance(index, Node): - return self._mixed_action_at(self._resolve_infoset_for_node(index)) + if isinstance(index, tuple): + return self._mixed_action_at(self.game._resolve_infoset(index, "__getitem__")) if isinstance(index, str): values = { node.infoset: self._mixed_action_at(node.infoset) @@ -418,32 +427,9 @@ class MixedBehaviorProfile: } return MixedBehavior.wrap(index, values) raise TypeError( - f"profile index must be str or Node, not {index.__class__.__name__}" + f"profile index must be str or History, not {index.__class__.__name__}" ) - def _resolve_infoset_for_node(self, node: Node) -> Infoset: - """Resolves the personal player's information set containing node. - - Raises - ------ - MismatchError - If `node` belongs to a different game. - ValueError - If `node` resolves to a chance event, or is terminal, and so belongs to - no personal player's information set. - """ - if node.game != self.game: - raise MismatchError("node must belong to this game") - infoset = cython.cast(Infoset, node.infoset) - if not infoset: - if node.event: - raise ValueError( - "node belongs to a chance event, not a personal player's " - "information set" - ) - raise ValueError("node is terminal, has no information set") - return infoset - def _all_infosets(self) -> typing.Iterator[Infoset]: """Iterates over every information set and event in the game.""" for player in self.game.players: @@ -617,7 +603,7 @@ class MixedBehaviorProfile: for a in cython.cast(Infoset, infoset)._resolve().deref().GetActions(): self._setprob_action(a, values[a.deref().GetLabel().decode("utf-8")]) - def __setitem__(self, index: Node, distribution: collections.abc.Mapping) -> None: + def __setitem__(self, index: tuple, distribution: collections.abc.Mapping) -> None: """Sets the mixed action at the information set containing `index`. `distribution` need not specify a weight for every one of the information @@ -626,8 +612,9 @@ class MixedBehaviorProfile: Parameters ---------- - index : Node - A node belonging to the information set to set. + index : History + A History (a tuple of action labels) belonging to the information set + to set. distribution : Mapping[str, Any] A non-negative weight for some or all of the information set's actions, keyed by action label; actions it omits are treated as having weight @@ -638,14 +625,14 @@ class MixedBehaviorProfile: Raises ------ TypeError - If `index` is not a ``Node``, or `distribution` is not a Mapping. - MismatchError - If `index` is a ``Node`` from a different game. + If `index` is not a ``History``, or `distribution` is not a Mapping. + KeyError + If `index` does not match any node of the game. ValueError - If `index` is a terminal ``Node``, which belongs to no information set; - if any key of `distribution` is not one of the information set's action - labels; if any weight cannot be interpreted as a number; if any weight - is negative; or if the weights are all zero. + If `index` resolves to a terminal node, which belongs to no information + set; if any key of `distribution` is not one of the information set's + action labels; if any weight cannot be interpreted as a number; if any + weight is negative; or if the weights are all zero. See Also -------- @@ -654,13 +641,13 @@ class MixedBehaviorProfile: silently defaulting omitted ones to zero. """ self._check_validity() - if not isinstance(index, Node): - raise TypeError(f"profile index must be Node, not {index.__class__.__name__}") - infoset = self._resolve_infoset_for_node(index) + if not isinstance(index, tuple): + raise TypeError(f"profile index must be History, not {index.__class__.__name__}") + infoset = self.game._resolve_infoset(index, "__setitem__") self._setprob_infoset(infoset, distribution, sparse=True) def set_mixed_action( - self, index: Node, distribution: collections.abc.Mapping, sparse: bool = False + self, index: tuple, distribution: collections.abc.Mapping, sparse: bool = False ) -> None: """Sets the mixed action at the information set containing `index`. @@ -674,8 +661,9 @@ class MixedBehaviorProfile: Parameters ---------- - index : Node - A node belonging to the information set to set. + index : History + A History (a tuple of action labels) belonging to the information set + to set. distribution : Mapping[str, Any] A non-negative weight for the information set's actions, keyed by action label. A weight may be any value Gambit can interpret as a @@ -690,24 +678,24 @@ class MixedBehaviorProfile: Raises ------ TypeError - If `index` is not a ``Node``, or `distribution` is not a Mapping. - MismatchError - If `index` is a ``Node`` from a different game. + If `index` is not a ``History``, or `distribution` is not a Mapping. + KeyError + If `index` does not match any node of the game. ValueError - If `index` is a terminal ``Node``, which belongs to no information set; - if any key of `distribution` is not one of the information set's action - labels; if `sparse` is False and `distribution` omits an action; if any - weight cannot be interpreted as a number; if any weight is negative; or - if the weights are all zero. + If `index` resolves to a terminal node, which belongs to no information + set; if any key of `distribution` is not one of the information set's + action labels; if `sparse` is False and `distribution` omits an action; + if any weight cannot be interpreted as a number; if any weight is + negative; or if the weights are all zero. See Also -------- __setitem__ """ self._check_validity() - if not isinstance(index, Node): - raise TypeError(f"profile index must be Node, not {index.__class__.__name__}") - infoset = self._resolve_infoset_for_node(index) + if not isinstance(index, tuple): + raise TypeError(f"profile index must be History, not {index.__class__.__name__}") + infoset = self.game._resolve_infoset(index, "set_mixed_action") self._setprob_infoset(infoset, distribution, sparse=sparse) def is_defined_at(self, infoset: NodeReference) -> bool: @@ -748,7 +736,7 @@ class MixedBehaviorProfile: """ self._check_validity() return NodeValuesVector({ - p: NodeValueVector({n: self._node_value(p, n) for n in self.game.nodes}) + p: NodeValueVector({n: self._node_value(p, n) for n in self.game._all_nodes()}) for p in self.game.players }) @@ -797,7 +785,7 @@ class MixedBehaviorProfile: play according to the profile. """ self._check_validity() - return RealizProbVector({n: self._realiz_prob(n) for n in self.game.nodes}) + return RealizProbVector({n: self._realiz_prob(n) for n in self.game._all_nodes()}) @property def infoset_probs(self) -> InfosetProbVector: @@ -844,7 +832,7 @@ class MixedBehaviorProfile: MixedBehaviorProfile.infoset_probs """ self._check_validity() - return BeliefVector({n: self._belief(n) for n in self.game.nodes}) + return BeliefVector({n: self._belief(n) for n in self.game._all_nodes()}) @property def action_regrets(self) -> ActionRegretsVector: diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi index 1abee090a..c7b568006 100644 --- a/src/pygambit/behavspt.pxi +++ b/src/pygambit/behavspt.pxi @@ -167,28 +167,30 @@ class BehaviorSupportProfile: Parameters ---------- - index : str, Node, or Infoset + index : str, History, or Infoset The part of the profile to return: * If `index` is a ``str``, returns a ``BehaviorSupport`` over the player's information sets. The player is determined by finding the player with that label, if any. - * If `index` is a ``Node`` or an ``Infoset`` (e.g. one obtained from - iterating a ``BehaviorSupport``), returns an ``ActionSupport`` over the - actions in the support at the information set. + * If `index` is a ``History`` (a tuple of action labels) or an ``Infoset`` + (e.g. one obtained from iterating a ``BehaviorSupport``), returns an + ``ActionSupport`` over the actions in the support at the information set. Raises ------ TypeError - If `index` is not a ``str``, a ``Node``, or an ``Infoset``. + If `index` is not a ``str``, a ``History``, or an ``Infoset``. MismatchError - If `index` is a ``Node`` or ``Infoset`` from a different game. + If `index` is an ``Infoset`` from a different game. ValueError - If `index` is a terminal ``Node``, which belongs to no information set. + If `index` is a ``History`` resolving to a terminal node, which belongs + to no information set. KeyError - If `index` is a ``str`` and no player in the game has that label. + If `index` is a ``str`` and no player in the game has that label, or a + ``History`` that does not match any node of the game. """ - resolved_infoset = self._resolve_infoset_arg(index) + resolved_infoset = self._resolve_infoset_arg(index, "__getitem__") if resolved_infoset is not None: if resolved_infoset.game != self.game: raise MismatchError("infoset must be part of the same game") @@ -200,25 +202,16 @@ class BehaviorSupportProfile: } return BehaviorSupport.wrap(index, values) raise TypeError( - f"profile index must be str, Node, or Infoset, not {index.__class__.__name__}" + f"profile index must be str, History, or Infoset, not {index.__class__.__name__}" ) @cython.cfunc - def _resolve_infoset_arg(self, index: object) -> object: - """Resolves index to the Infoset it identifies if it is a Node or an Infoset, - or returns None if index is neither (e.g. a player label str). + def _resolve_infoset_arg(self, index: object, funcname: str) -> object: + """Resolves index to the Infoset it identifies if it is a History or an + Infoset, or returns None if index is neither (e.g. a player label str). """ - if isinstance(index, Node): - node = cython.cast(Node, index) - resolved = cython.cast(Infoset, node.infoset) - if not resolved: - if node.event: - raise ValueError( - "index resolves to a chance event; a behavior support is only " - "defined for a personal player's information sets" - ) - raise ValueError("index resolves to no information set (the node is terminal)") - return resolved + if isinstance(index, tuple): + return self.game._resolve_infoset(index, funcname) if isinstance(index, Infoset): return index return None @@ -273,10 +266,10 @@ class BehaviorSupportProfile: Parameters ---------- - infoset : Node or Infoset - A node belonging to the information set whose support is to be set, or - the information set itself (e.g. one obtained from iterating a - ``BehaviorSupport``). + infoset : History or Infoset + A History (a tuple of action labels) belonging to the information set + whose support is to be set, or the information set itself (e.g. one + obtained from iterating a ``BehaviorSupport``). actions : Iterable[str] The labels of the actions which should be in the support at the information set. Every other action at the information set is removed @@ -285,18 +278,20 @@ class BehaviorSupportProfile: Raises ------ TypeError - If `infoset` is not a ``Node`` or an ``Infoset``. + If `infoset` is not a ``History`` or an ``Infoset``. MismatchError - If `infoset` is a `Node` or `Infoset` from a different game. + If `infoset` is an ``Infoset`` from a different game. ValueError If any entry of `actions` is not one of the information set's action - labels, or if `actions` is empty; or if `infoset` is a terminal node, - which belongs to no information set. + labels, or if `actions` is empty; or if `infoset` is a ``History`` + resolving to a terminal node, which belongs to no information set. + KeyError + If `infoset` is a ``History`` that does not match any node of the game. """ - resolved_infoset = self._resolve_infoset_arg(infoset) + resolved_infoset = self._resolve_infoset_arg(infoset, "__setitem__") if resolved_infoset is None: raise TypeError( - f"profile index must be Node or Infoset, not {infoset.__class__.__name__}" + f"profile index must be History or Infoset, not {infoset.__class__.__name__}" ) if resolved_infoset.game != self.game: raise MismatchError("infoset must be part of the same game") diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index 2f70acdd5..07b4aa51a 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -447,7 +447,7 @@ def check_filters(game: gbt.Game) -> bool: if n_nodes is not None: if not game.is_tree: return False - if len(game.nodes) != n_nodes: + if game._num_nodes() != n_nodes: return False if n_outcomes is not None and len(game.outcomes) != n_outcomes: return False diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index d7863a2c3..cf02cd9d8 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -388,7 +388,7 @@ def read_behavior_profiles_csv( profile = game.mixed_behavior_profile(rational=True) for player in game.players: for node in game.get_infosets(player): - profile[node] = {a: next(values) for a in node.infoset.actions} + profile[node.history] = {a: next(values) for a in node.infoset.actions} profiles.append(profile) return profiles diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index 2d73be109..d26a082b9 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -192,6 +192,7 @@ include "infoset.pxi" include "strategy.pxi" include "outcome.pxi" include "node.pxi" +include "hsel.pxi" include "stratspt.pxi" include "behavspt.pxi" include "stratmixed.pxi" diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 84c511663..4a557d367 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -502,45 +502,147 @@ class Game: return GameOutcomes.wrap(self.game) @property - def nodes(self) -> GameNodes: - """The set of nodes in the game. + def contingencies(self) -> pygambit.gameiter.Contingencies: + """An iterator over the contingencies in the game.""" + return pygambit.gameiter.Contingencies(self) - Iteration over this property yields the nodes in the order of depth-first search. + def _root_node(self) -> Node: + """Internal: the root node of the game. `Game.root` was retired from the + public API in favor of `H` selectors and materialized `History` tuples + (e.g. `H.path()`/`()`); this is the last direct access to it, used only to + seed selector evaluation. + """ + if not self.is_tree: + raise UndefinedOperationError( + "root: only games with a tree representation have a root node" + ) + return Node.wrap(self.game.deref().GetRoot()) - .. versionchanged:: 16.4 - Changed from a method ``nodes()`` to a property. + def _all_nodes(self) -> list: + """Internal: every node of the game, in the C++ tree's depth-first order. + `Game.nodes` was retired from the public API -- its only real job was + letting calling code build ad hoc selections by hand, which `H` now does + directly; this is the last surviving use, backing `H.after(...)`'s + whole-game seed and label-based node lookup. + """ + if not self.is_tree: + raise UndefinedOperationError( + "Operation only defined for games with a tree representation" + ) + return [Node.wrap(node) for node in self.game.deref().GetNodes()] - Raises - ------ - UndefinedOperationError - If the game does not have a tree representation. + def _num_nodes(self) -> int: + """Internal: the number of nodes in the game -- a cheap count for callers (such + as catalog filtering) that don't need the nodes themselves, so don't need to pay + for materializing `_all_nodes()`'s full list. """ if not self.is_tree: raise UndefinedOperationError( "Operation only defined for games with a tree representation" ) + return self.game.deref().NumNodes() - return GameNodes.wrap(self.game) + def get_nodes(self, selector: Selector) -> list[Node]: + """Evaluate `selector` (an `H`-built expression) against this game. - @property - def contingencies(self) -> pygambit.gameiter.Contingencies: - """An iterator over the contingencies in the game.""" - return pygambit.gameiter.Contingencies(self) + First sketch of the `H` selector algebra's evaluator: interprets the + selector's ops in order, starting from the root, reusing `Node`'s + existing navigation (`.children`, `.plays`) rather than walking the + C++ tree directly. Returns raw `Node` objects for now -- a real + public version would wrap each result in a `.game`-free facade + instead, not yet built. - @property - def root(self) -> Node: - """The root node of the game. + .. versionadded:: 17.0.0 + """ + current: list = None + for op in selector._ops: + if isinstance(op, _AfterStep): + candidates = self._all_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_node()] + 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_node()] + return current + + def get_histories(self, selector: Selector) -> list[tuple]: + """Evaluate `selector` (an `H`-built expression) against this game, + materializing each result as a `History` -- a plain tuple of action + labels from the root, carrying no reference to this game. + + This is the public-facing counterpart to `get_nodes`: `get_nodes` + exists only as an internal sketch and is never meant to hand a `Node` + to calling code. - Raises - ------ - UndefinedOperationError - If the game does not hae a tree representation. + .. versionadded:: 17.0.0 """ - if not self.is_tree: - raise UndefinedOperationError( - "root: only games with a tree representation have a root node" - ) - return Node.wrap(self.game.deref().GetRoot()) + return [_history_of(node) for node in self.get_nodes(selector)] + + def _group_nodes(self, grouped: GroupedSelector) -> dict: + """Internal: like `get_groups`, but keeps `Node` objects rather than + materializing each into a `History` -- used by mutation methods that + need to resolve straight back to concrete nodes, avoiding a + Node -> History -> Node round trip. + + Applies `grouped`'s initial partition (`base`/`key`), then its + `post_ops` in order, each one per-group -- expanding/filtering each + group's own members independently, leaving the key untouched, except + that a `.plays` step refines the key by `recall_player`'s last action + at that point, if `with_recall` set one (see `GroupedSelector`'s + docstring for why). + """ + result: dict = {} + for node in self.get_nodes(grouped.base): + view: HistoryView = HistoryView._wrap(node, _history_of(node)) + key = grouped.key(view) + result.setdefault(key, []).append(node) + for op in grouped.post_ops: + next_result: dict = {} + for key, nodes in result.items(): + if isinstance(op, _PlaysStep): + expanded = [play for node in nodes for play in node.plays] + if grouped.recall_player is None: + next_result[key] = expanded + else: + for play in expanded: + refined_key = (key, _last_action(play, grouped.recall_player)) + next_result.setdefault(refined_key, []).append(play) + continue + if isinstance(op, _AfterStep): + next_result[key] = [n for n in nodes if _matches_suffix(n, op.labels)] + continue + raise TypeError(f"_group_nodes(): unknown post-op {op!r}") + result = next_result + return result + + def get_groups(self, grouped: GroupedSelector) -> dict: + """Evaluate a `.by(callable)`-built `GroupedSelector` against this + game, returning a dict from each distinct key to the list of + Histories that produced it. + + .. versionadded:: 17.0.0 + """ + return { + key: [_history_of(node) for node in nodes] + for key, nodes in self._group_nodes(grouped).items() + } @property def is_const_sum(self) -> bool: @@ -921,7 +1023,7 @@ class Game: f"Number of elements does not match number of " f"actions for infoset {infoset} for {p}" ) - profile[node] = { + profile[node.history] = { a: typefunc(u) for a, u in zip(infoset.actions, v, strict=True) } return profile @@ -1003,7 +1105,7 @@ class Game: profile = self.mixed_behavior_profile() for player in self.players: for node in self.get_infosets(player): - profile[node] = _dirichlet_distribution(node.infoset.actions, gen) + profile[node.history] = _dirichlet_distribution(node.infoset.actions, gen) return profile elif denom < 1: raise ValueError("random_behavior_profile(): denom must be positive") @@ -1011,7 +1113,9 @@ class Game: profile = self.mixed_behavior_profile(rational=True) for player in self.players: for node in self.get_infosets(player): - profile[node] = _grid_distribution(node.infoset.actions, denom, gen) + profile[node.history] = _grid_distribution( + node.infoset.actions, denom, gen + ) return profile def strategy_support_profile( @@ -1281,12 +1385,23 @@ 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( f"{funcname}(): {argname} cannot be an empty string or all spaces" ) - for n in self.nodes: + for n in self._all_nodes(): if n.label == node: return n raise KeyError(f"{funcname}(): no node with label '{node}'") @@ -1301,10 +1416,15 @@ class Game: """Resolve an attempt to reference a subset of the nodes of the game of the game. See `_resolve_node` for details on functionality. + + `nodes` may also be a `Selector` (an `H`-built expression), evaluated + against this game via `get_nodes` before the usual resolution. """ + if isinstance(nodes, Selector): + nodes = self.get_nodes(nodes) resolved_nodes = [ self._resolve_node(n, funcname, argname) - for n in (nodes if hasattr(nodes, "__iter__") and not isinstance(nodes, str) + for n in (nodes if hasattr(nodes, "__iter__") and not isinstance(nodes, (str, tuple)) else [nodes]) ] if not resolved_nodes: @@ -1444,7 +1564,7 @@ class Game: raise IndexError(f"{funcname}(): must specify exactly one probability per action") return probs - def append_move(self, nodes: Node | NodeReferenceSet, + def append_move(self, nodes: Node | NodeReferenceSet | Selector | GroupedSelector, player: str, actions: list[str]) -> None: """Add a move for `player` at terminal `nodes`. All elements of `nodes` become part of @@ -1452,6 +1572,14 @@ class Game: `player` must be a personal player; use `append_event` to add a chance move. + `nodes` may be a `Selector` (an `H`-built expression, evaluated against this + game and treated as a flat `NodeReferenceSet`) or a `GroupedSelector` (an + `H`-built `.by(...)` expression) -- in the latter case, one new information + set is created per distinct group, rather than one spanning every match. + + .. versionchanged:: 17.0.0 + `nodes` may now be a `Selector` or `GroupedSelector`. + Raises ------ UndefinedOperationError @@ -1464,6 +1592,12 @@ class Game: If `nodes` has duplicated elements, or is empty; or if `actions` contains an empty or a duplicated label. """ + if isinstance(nodes, GroupedSelector): + for group in self._group_nodes(nodes).values(): + if not group: + continue + self.append_move(group, player, actions) + return resolved_player = self._resolve_player(player, "append_move") if not actions: raise UndefinedOperationError("append_move(): `actions` must be a nonempty list") @@ -2251,8 +2385,10 @@ 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` may be a `Node`, + `History`, `Selector`, or an iterable of these); for a strategic game, into a + list of pure-strategy contingencies (each a mapping from player label to + strategy label). Returns (is_tree, resolved). @@ -2289,10 +2425,11 @@ 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 ``Node``, a ``History``, a ``Selector`` + (an `H`-built expression, evaluated against this game), or an iterable of + these. 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. @@ -2301,7 +2438,7 @@ class Game: Parameters ---------- - location : Node, contingency, or iterable of these + location : Node, History, Selector, contingency, or iterable of these Where to attach the new outcome. Nonempty; each node or contingency may be referenced only once. payoffs : Mapping @@ -2373,10 +2510,11 @@ 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 ``Node``, a ``History``, a ``Selector`` + (an `H`-built expression, evaluated against this game), or an iterable of + these. 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. @@ -2384,7 +2522,7 @@ class Game: Parameters ---------- - location : Node, contingency, or iterable of these + location : Node, History, Selector, contingency, or iterable of these The nodes or contingencies to reset to the null outcome. Nonempty; each node or contingency may be referenced only once. @@ -2640,7 +2778,7 @@ class NodeCoordinates: def _layout_tree(game: Game) -> dict[Node, NodeCoordinates]: layout = CreateLayout(game.game) data = {} - for node in game.nodes: + for node in game._all_nodes(): data[node] = NodeCoordinates(deref(layout).GetNodeLevel(cython.cast(Node, node).node), deref(layout).GetNodeSublevel(cython.cast(Node, node).node), deref(layout).GetNodeOffset(cython.cast(Node, node).node)) diff --git a/src/pygambit/gamecollections.pxi b/src/pygambit/gamecollections.pxi index ee2928192..726023832 100644 --- a/src/pygambit/gamecollections.pxi +++ b/src/pygambit/gamecollections.pxi @@ -21,39 +21,6 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # -@cython.cclass -class GameNodes: - """Represents the set of nodes in a game.""" - game = cython.declare(c_Game) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create GameNodes outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(game: c_Game) -> GameNodes: - obj: GameNodes = GameNodes.__new__(GameNodes) - obj.game = game - return obj - - def __repr__(self) -> str: - return f"GameNodes(game={Game.wrap(self.game)})" - - def __len__(self) -> int: - """The number of nodes in the game.""" - if not self.game.deref().IsTree(): - return 0 - return self.game.deref().NumNodes() - - def __iter__(self) -> typing.Iterator[Node]: - """Iterate over the game nodes in the depth-first traversal order.""" - if not self.game.deref().IsTree(): - return - - for node in self.game.deref().GetNodes(): - yield Node.wrap(node) - - @cython.cclass class GameSubgames: """Represents the set of subgames in a game.""" diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi new file mode 100644 index 000000000..f3f30f552 --- /dev/null +++ b/src/pygambit/hsel.pxi @@ -0,0 +1,298 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/hsel.pxi +# First sketch of the H selector algebra: game-neutral expressions built by +# pygambit.H, evaluated only when handed to a Game. Deliberately minimal -- +# just enough operations to validate the architecture, not the full roster. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# + + +class _PathStep: + """One `H.path(*steps)` operation: each step is an exact action label or + the wildcard `...`. Root-anchored if it is the first op in a Selector, + "this many more steps from here" otherwise -- the evaluator doesn't need + to distinguish the two cases, since they're the same operation applied to + whatever's already been selected (the root, for a bare seed).""" + + def __init__(self, steps: tuple) -> None: + self.steps = steps + + def __repr__(self) -> str: + return f"_PathStep(steps={self.steps!r})" + + +class _PlaysStep: + """One `.plays` operation: expand to the current terminal frontier.""" + + def __repr__(self) -> str: + return "_PlaysStep()" + + +class _AfterStep: + """One `.after(*labels)` operation: an unconstrained (possibly empty) + prefix, then exactly these trailing labels. As the first op in a + Selector, matches anywhere in the whole game, not just root's frontier -- + the natural counterpart to `.path(...)`'s root anchoring. Chained onto an + existing selection, it's a pure filter: no new nodes are considered, just + whichever already-selected ones end in this suffix.""" + + def __init__(self, labels: tuple) -> None: + self.labels = labels + + def __repr__(self) -> str: + return f"_AfterStep(labels={self.labels!r})" + + +def _matches_suffix(node: Node, labels: tuple) -> bool: + """Whether `node`'s own history ends with exactly `labels`.""" + current: Node = node + for label in reversed(labels): + if current.parent is None or current.prior_action.label != label: + return False + current = current.parent + return True + + +class _FilterStep: + """One `.filter(callable)` operation: keep only elements where + `predicate`, given a HistoryView, returns something truthy. Chained-only + -- unlike `.after(...)`, there's no natural "whole game" domain for a + bare predicate to start from, so it's not exposed as an `H.filter(...)` + seed.""" + + def __init__(self, predicate: typing.Callable) -> None: + self.predicate = predicate + + def __repr__(self) -> str: + return f"_FilterStep(predicate={self.predicate!r})" + + +class Selector: + """A game-neutral description of a set of nodes. Carries no reference to + any game -- it's just a recipe, evaluated only when handed to a Game + method such as `get_nodes`. + + .. versionadded:: 17.0.0 + """ + + def __init__(self, ops: tuple = ()) -> None: + self._ops = ops + + def __repr__(self) -> str: + return f"Selector(ops={self._ops!r})" + + def _extend(self, op) -> Selector: + return Selector(self._ops + (op,)) + + def path(self, *steps: str) -> Selector: + """`N` more steps from wherever this selection currently is. Each + step is an exact action label, or `...` to match any single action. + """ + return self._extend(_PathStep(steps)) + + @property + def plays(self) -> Selector: + """The current terminal frontier of this selection -- not + necessarily one step forward, whatever is currently terminal beneath + each already-selected node.""" + return self._extend(_PlaysStep()) + + def after(self, *labels: str) -> Selector: + """Filter this selection to just the elements whose own trailing + labels are exactly `labels`, whatever came before them.""" + return self._extend(_AfterStep(labels)) + + def filter(self, predicate: typing.Callable) -> Selector: + """Keep only the elements of this selection where `predicate`, + called once per element with a read-only `HistoryView` of it, + returns something truthy. The general escape hatch for a filter + `.after(...)`'s label-pattern matching can't express -- e.g. + anything needing `.last_action(player)` rather than a plain + trailing-label match.""" + return self._extend(_FilterStep(predicate)) + + def by(self, key: typing.Callable) -> GroupedSelector: + """Partition this selection by `key`, called once per element with a + read-only `HistoryView` of it. Distinct return values become distinct + groups; game-neutral until evaluated, same as `Selector` itself.""" + return GroupedSelector(self, key) + + +class GroupedSelector: + """Result of `.by(callable)`. Game-neutral until evaluated -- call + `Game.get_groups` and iterate its result as `(key, group)` pairs. + + `.plays`/`.after(...)` chain onto a `GroupedSelector` the same way they + chain onto a plain `Selector`, but apply per-group: each group's own + members are expanded/filtered independently, and the group's key is left + untouched -- expanding past a decision point doesn't retroactively change + what a group was keyed by. `.with_recall(player)` is the one exception: + once set, every subsequent `.plays` on this selector *also* refines each + group's key by folding in `player`'s last action at that point, so a + partition built for one decision stays a valid recall-respecting + partition when reused for a later one, without the caller needing to + re-derive or manually re-key it. Scoped to `.plays` specifically for now + (not every expand-style op) -- narrower than the full "any expand-style + step" idea from the design notes, not yet stress-tested against a shape + that would need more. + + .. versionadded:: 17.0.0 + """ + + def __init__( + self, + base: Selector, + key: typing.Callable, + post_ops: tuple = (), + recall_player: str = None, + ) -> None: + self.base = base + self.key = key + self.post_ops = post_ops + self.recall_player = recall_player + + def __repr__(self) -> str: + return ( + f"GroupedSelector(base={self.base!r}, key={self.key!r}, " + f"post_ops={self.post_ops!r}, recall_player={self.recall_player!r})" + ) + + def _extend(self, op) -> GroupedSelector: + return GroupedSelector(self.base, self.key, self.post_ops + (op,), self.recall_player) + + @property + def plays(self) -> GroupedSelector: + """The current terminal frontier of each group, independently -- + see the class docstring for how this interacts with + `.with_recall(player)`.""" + return self._extend(_PlaysStep()) + + def after(self, *labels: str) -> GroupedSelector: + """Filter each group to just the members whose own trailing labels + are exactly `labels`, whatever came before them.""" + return self._extend(_AfterStep(labels)) + + def with_recall(self, player: str) -> GroupedSelector: + """From here on, every `.plays` on this selector also refines each + group's key by folding in `player`'s last action at that point -- + see the class docstring.""" + return GroupedSelector(self.base, self.key, self.post_ops, player) + + +def _history_of(node: Node) -> tuple: + """The plain-tuple History for `node` -- walks back to the root via the + existing `Node.parent`/`.prior_action` navigation.""" + labels: list = [] + current: Node = node + while current.parent is not None: + labels.append(current.prior_action.label) + current = current.parent + labels.reverse() + return tuple(labels) + + +class HistoryView: + """The object a `.by(callable)` key function actually receives. Supports + plain sequence indexing/slicing like a `History` tuple, plus limited + game-aware navigation (`.last_action(player)`) -- but never exposes the + `Node`/game it's privately backed by. Never returned to calling code + outside a `.by(callable)` call; not constructible directly. + + .. versionadded:: 17.0.0 + """ + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create a HistoryView directly.") + + @staticmethod + def _wrap(node: Node, history: tuple) -> HistoryView: + obj: HistoryView = HistoryView.__new__(HistoryView) + obj._node = node + obj._history = history + return obj + + def __repr__(self) -> str: + return f"HistoryView({self._history!r})" + + def __len__(self) -> int: + return len(self._history) + + def __getitem__(self, index: typing.Any) -> typing.Any: + return self._history[index] + + def last_action(self, player: str) -> str | None: + """The label of the last action `player` took on the path to this + history, wherever it fell -- `None` if `player` hasn't acted yet.""" + return _last_action(self._node, player) + + @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/src/pygambit/node.pxi b/src/pygambit/node.pxi index d8e8fa98a..d67a3d677 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -481,6 +481,16 @@ class Node: """ return [Node.wrap(n) for n in self.node.deref().GetGame().deref().GetPlays(self.node)] + @property + def history(self) -> tuple: + """The History (a tuple of action labels) leading to this node from the root -- + the materialized, game-agnostic form used elsewhere in the public API (profile + indexing, `H`-built selector results, ...). + + .. versionadded:: 17.0.0 + """ + return _history_of(self) + @cython.cclass class Subgame: diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index 22de0655e..71a01884f 100644 --- a/src/pygambit/qre.py +++ b/src/pygambit/qre.py @@ -257,7 +257,7 @@ def _estimate_behavior_empirical( data: libgbt.MixedBehaviorProfile, ) -> LogitQREMixedBehaviorFitResult: flattened_data = [ - data[node][a] + data[node.history][a] for p in data.game.players for node in data.game.get_infosets(p) for a in node.infoset.actions @@ -277,7 +277,7 @@ def _estimate_behavior_empirical( log_probs = iter(_empirical_log_logit_probs(res.x[0], regrets)) for player in data.game.players: for node in data.game.get_infosets(player): - profile[node] = { + profile[node.history] = { a: math.exp(next(log_probs)) for a in node.infoset.actions } return LogitQREMixedBehaviorFitResult( diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 9d913b2ef..ba2556fa3 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -5,7 +5,7 @@ import pygambit as gbt -from ..games import create_2x2_zero_sum_efg +from ..games import create_2x2_zero_sum_efg, root_node @pytest.fixture @@ -97,8 +97,8 @@ 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(root_node(game), "1", ["L", "R"]) + left, right = root_node(game).children game.append_move(left, "2", ["x", "y", "z"]) game.append_move(right, "2", ["p", "q"]) for node in left.children: diff --git a/tests/games.py b/tests/games.py index 07fc521c7..dce2b8a10 100644 --- a/tests/games.py +++ b/tests/games.py @@ -33,6 +33,41 @@ 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 root_node(game: gbt.Game) -> gbt.Node: + """The root Node of `game`, this test suite's own replacement for the old + `Game.root` (retired, 17.0.0, in favor of `H` selectors and materialized + `History` tuples as the public entry points into a tree). `Node` itself is + unaffected by that removal -- fixture code below still freely navigates via + `.children`/`.parent` once it holds one.""" + return game.get_nodes(gbt.H.path())[0] + + +def history_of(node: gbt.Node) -> tuple: + """The History (tuple of action labels) leading to `node` -- this test suite's + own Node-to-History bridge, for call sites (such as `MixedBehaviorProfile` + indexing) that now require a `History` rather than a `Node`.""" + labels = [] + while node.parent is not None: + labels.append(node.prior_action.label) + node = node.parent + labels.reverse() + return tuple(labels) + + +def all_nodes(game: gbt.Game) -> list[gbt.Node]: + """Every node of `game`, in depth-first order -- this test suite's own + replacement for the old `Game.nodes` (retired, 17.0.0: its only real job was + letting calling code build ad hoc selections by hand, which `H` now does + directly; a plain recursive walk from the root covers the rarer case of + needing literally every node at once, e.g. to bucket terminal nodes by + payoff).""" + def walk(node: gbt.Node): + yield node + for child in node.children: + yield from walk(child) + return list(walk(root_node(game))) + + # 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,10 +128,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(root_node(g), "1", actions1) + g.append_move(root_node(g).children, "2", actions2) for i, j in itertools.product(range(m), range(n)): - node = g.root.children[str(i)].children[str(j)] + node = root_node(g).children[str(i)].children[str(j)] g.make_outcome(node, {"1": A[i, j], "2": B[i, j]}, f"({i},{j})") return g @@ -134,9 +169,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(root_node(g).children["0"].children["1"]) elif variant == "with neutral outcome": - g.make_outcome(g.root.children["0"], {"1": 0, "2": 0}, "neutral") + g.make_outcome(root_node(g).children["0"], {"1": 0, "2": 0}, "neutral") return g @@ -163,30 +198,31 @@ 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(root_node(g), deals, [gbt.Rational(1, 2)] * 2) - for node in g.root.children: + for node in root_node(g).children: g.append_move(node, player="Alice", actions=["Bet", "Fold"]) alice_bets_nodes = [ - g.root.children["King"].children["Bet"], - g.root.children["Queen"].children["Bet"], + root_node(g).children["King"].children["Bet"], + root_node(g).children["Queen"].children["Bet"], ] g.append_move(alice_bets_nodes, player="Bob", actions=["Call", "Fold"]) - g.make_outcome(g.root, {"Alice": -1, "Bob": -1}, "Ante") + root_children = list(root_node(g).children) + g.make_outcome(root_node(g), {"Alice": -1, "Bob": -1}, "Ante") g.make_outcome( - [node.children["Fold"] for node in g.root.children], {"Alice": 0, "Bob": 2}, "Alice Folds" + [node.children["Fold"] for node in root_children], {"Alice": 0, "Bob": 2}, "Alice Folds" ) g.make_outcome( - [node.children["Bet"] for node in g.root.children], {"Alice": -1, "Bob": 0}, "Alice Bets" + [node.children["Bet"] for node in root_children], {"Alice": -1, "Bob": 0}, "Alice Bets" ) g.make_outcome( [node.children["Fold"] for node in alice_bets_nodes], {"Alice": 3, "Bob": 0}, "Bob Folds" ) - bob_calls_and_loses_node = g.root.children["King"].children["Bet"].children["Call"] + bob_calls_and_loses_node = root_node(g).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"] + bob_calls_and_wins_node = root_node(g).children["Queen"].children["Bet"].children["Call"] g.make_outcome(bob_calls_and_wins_node, {"Alice": 0, "Bob": 3}, "Bob Calls and Wins") return g @@ -203,28 +239,28 @@ 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(root_node(g), deals, [gbt.Rational(1, 6)] * 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)] + term_nodes = [root_node(g).children[d] for d in deals_by_infoset("Alice", alice_card)] g.append_move(term_nodes, "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) + root_node(g).children[d].children["Check"] for d in deals_by_infoset("Bob", bob_card) ] g.append_move(term_nodes, "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"] + root_node(g).children[d].children["Check"].children["Bet"] for d in deals_by_infoset("Alice", alice_card) ] g.append_move(term_nodes, "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) + root_node(g).children[d].children["Bet"] for d in deals_by_infoset("Bob", bob_card) ] g.append_move(term_nodes, "Bob", ["Fold", "Call"]) return g @@ -297,7 +333,7 @@ def bet(player, payoffs, pot): (-2, 2): "BOb wins 2", } nodes_by_payoff = {payoffs: [] for payoffs in payoff_labels} - for term_node in [n for n in g.nodes if n.is_terminal]: + for term_node in [n for n in all_nodes(g) if n.is_terminal]: nodes_by_payoff[calculate_payoffs(term_node)].append(term_node) for payoffs, nodes in nodes_by_payoff.items(): @@ -325,7 +361,7 @@ def _create_kuhn_poker_efg_nonterm_outcomes() -> gbt.Game: payoffs_by_key[f"{player} calls and loses"] = (-1, 4) if player == "Alice" else (4, -1) nodes_by_key = {key: [] for key in payoffs_by_key} - nodes_by_key["Ante"].append(g.root) + nodes_by_key["Ante"].append(root_node(g)) def collect_nodes(term_node): def get_path(node): @@ -361,7 +397,7 @@ def get_path(node): tmp = "wins" if winner == "Bob" else "loses" nodes_by_key[f"Bob calls and {tmp}"].append(n) - for term_node in [n for n in g.nodes if n.is_terminal]: + for term_node in [n for n in all_nodes(g) if n.is_terminal]: collect_nodes(term_node) for key, nodes in nodes_by_key.items(): @@ -439,24 +475,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(root_node(g), "Buyer", ["Trust", "Not trust"]) + g.append_move(root_node(g).children["Trust"], "Seller", ["Honor", "Abuse"]) g.make_outcome( - g.root.children["Trust"].children["Honor"], {"Buyer": 1, "Seller": 1}, "Trustworthy" + root_node(g).children["Trust"].children["Honor"], {"Buyer": 1, "Seller": 1}, "Trustworthy" ) if unique_NE_variant: g.make_outcome( - g.root.children["Trust"].children["Abuse"], + root_node(g).children["Trust"].children["Abuse"], {"Buyer": "1/2", "Seller": 2}, "Untrustworthy", ) else: g.make_outcome( - g.root.children["Trust"].children["Abuse"], + root_node(g).children["Trust"].children["Abuse"], {"Buyer": -1, "Seller": 2}, "Untrustworthy", ) - g.make_outcome(g.root.children["Not trust"], {"Buyer": 0, "Seller": 0}, "Opt-out") + g.make_outcome(root_node(g).children["Not trust"], {"Buyer": 0, "Seller": 0}, "Opt-out") return g @@ -566,7 +602,7 @@ 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_node = root_node(g) current_player = "1" for t in range(self.N): g.append_move(current_node, current_player, ["Take", "Push"]) @@ -719,8 +755,8 @@ def gbt_game(self): 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) - for n in g.nodes: + self.create_binary_tree(g, root_node(g), 0, 0, self.level) + for n in all_nodes(g): if not n.is_terminal and not n.children["L"].is_terminal: left = n.children["L"] g.make_infoset( diff --git a/tests/test_actions.py b/tests/test_actions.py index 970f21b08..8d45be7a6 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -8,30 +8,30 @@ @pytest.mark.parametrize("label", games.VALID_LABELS) 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}) - assert label in game.root.actions + action = next(iter(games.root_node(game).actions)) + game.relabel_actions(games.root_node(game), {action: label}) + assert label in games.root_node(game).actions @pytest.mark.parametrize("label", games.INVALID_LABELS) def test_action_label_invalid_raises_valueerror(label: str): game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.actions)) + action = next(iter(games.root_node(game).actions)) with pytest.raises(ValueError): - game.relabel_actions(game.root, {action: label}) + game.relabel_actions(games.root_node(game), {action: label}) def test_relabel_action_empty_raises_valueerror(): game = games.create_stripped_down_poker_efg() - action = next(iter(game.root.actions)) + action = next(iter(games.root_node(game).actions)) with pytest.raises(ValueError): - game.relabel_actions(game.root, {action: ""}) + game.relabel_actions(games.root_node(game), {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(games.root_node(game), {"King": "Queen"}) def test_relabel_actions_simultaneous_swap(): @@ -39,8 +39,8 @@ 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"}) - assert list(game.root.event.actions) == ["Queen", "King"] + game.relabel_actions(games.root_node(game), {"King": "Queen", "Queen": "King"}) + assert list(games.root_node(game).event.actions) == ["Queen", "King"] def test_relabel_actions_duplicate_targets_raises_valueerror(): @@ -49,19 +49,19 @@ 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(games.root_node(game), {"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(games.root_node(game), {"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) - assert list(game.root.event.actions) == ["Ace", "Queen"] + game.relabel_actions(games.root_node(game), {"Jack": "Ace", "King": "Ace"}, strict=False) + assert list(games.root_node(game).event.actions) == ["Ace", "Queen"] def test_relabel_actions_failure_leaves_game_unchanged(): @@ -70,8 +70,8 @@ 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": ""}) - assert list(game.root.event.actions) == ["King", "Queen"] + game.relabel_actions(games.root_node(game), {"King": "Ace", "Queen": ""}) + assert list(games.root_node(game).event.actions) == ["King", "Queen"] def test_relabel_actions_scope_is_the_information_set(): @@ -92,14 +92,14 @@ def test_relabel_actions_scope_is_the_information_set(): 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(games.root_node(game), [("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(games.root_node(game), labels) def test_set_move_actions_drop_shrinks_actions_and_children(): @@ -128,15 +128,15 @@ 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"]) - infoset = game.root.children["x"].infoset + game.append_move(games.root_node(game), "Bob", ["x", "y"]) + game.append_move(list(games.root_node(game).children), "Alice", ["a", "b", "c"]) + game.append_move([games.root_node(game).children["x"].children["a"], + games.root_node(game).children["y"].children["b"]], "Bob", ["l", "r"]) + infoset = games.root_node(game).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(games.root_node(game).children["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"]] @@ -146,11 +146,11 @@ def test_set_move_actions_add_drop_and_reorder_together(): game = games.create_stripped_down_poker_efg() infoset = games.find_infoset(game, "Alice", "Alice has King") node = next(iter(infoset.members)) - nodes_before = len(game.nodes) + nodes_before = len(games.all_nodes(game)) game.set_move_actions(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 + assert len(games.all_nodes(game)) == nodes_before - 3 + 1 assert len(games.find_infoset(game, "Bob", "Bob's response").members) == 1 @@ -171,7 +171,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(games.root_node(game), ["King", "Queen"]) @pytest.mark.parametrize("bad_labels", [["Bet", "Bet"], ["Bet", ""], ["Bet", " x"]]) @@ -191,49 +191,50 @@ 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) - assert list(game.root.infoset.actions) == ["b", "c"] - assert len(game.root.infoset.members) == 1 - assert len(game.nodes) == 3 + game.append_move(games.root_node(game), "Alice", ["a", "b"]) + game.append_infoset(games.root_node(game).children["a"], games.root_node(game)) + game.set_move_actions(games.root_node(game), ["b", "c"], drop=True) + assert list(games.root_node(game).infoset.actions) == ["b", "c"] + assert len(games.root_node(game).infoset.members) == 1 + assert len(games.all_nodes(game)) == 3 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"}) - assert list(game.root.actions) == ["Queen", "King"] - assert game.root.action_probs == {"Queen": gbt.Rational(1, 4), "King": gbt.Rational(3, 4)} + root = games.root_node(game) + game.set_event_actions(root, {"King": "3/4", "Queen": "1/4"}) + game.set_event_actions(root, {"Queen": "1/4", "King": "3/4"}) + assert list(root.actions) == ["Queen", "King"] + assert root.action_probs == {"Queen": gbt.Rational(1, 4), "King": gbt.Rational(3, 4)} 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"}) - assert list(game.root.actions) == ["Jack", "King", "Queen"] - assert game.root.action_probs == { + nodes_before = len(games.all_nodes(game)) + game.set_event_actions(games.root_node(game), {"Jack": "1/2", "King": "1/4", "Queen": "1/4"}) + assert list(games.root_node(game).actions) == ["Jack", "King", "Queen"] + assert games.root_node(game).action_probs == { "Jack": gbt.Rational(1, 2), "King": gbt.Rational(1, 4), "Queen": gbt.Rational(1, 4) } - assert len(game.nodes) == nodes_before + 1 + assert len(games.all_nodes(game)) == nodes_before + 1 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) - assert list(game.root.actions) == ["King"] - assert game.root.action_probs == {"King": 1} + game.set_event_actions(games.root_node(game), {"King": 1}, drop=True) + assert list(games.root_node(game).actions) == ["King"] + assert games.root_node(game).action_probs == {"King": 1} def test_set_event_actions_unconfirmed_drop_and_disabled_add_raise(): game = games.create_stripped_down_poker_efg() - _ = game.root.event + _ = games.root_node(game).event before = game.to_efg() with pytest.raises(ValueError): - game.set_event_actions(game.root, {"King": 1}) + game.set_event_actions(games.root_node(game), {"King": 1}) with pytest.raises(ValueError): game.set_event_actions( - game.root, {"King": "1/2", "Queen": "1/4", "Jack": "1/4"}, add=False + games.root_node(game), {"King": "1/2", "Queen": "1/4", "Jack": "1/4"}, add=False ) assert game.to_efg() == before @@ -253,7 +254,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(games.root_node(game), ["3/4", "1/4"]) assert game.to_efg() == before @@ -261,7 +262,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(games.root_node(game), {"King": "3/4", "Queen": "3/4"}) assert game.to_efg() == before @@ -290,7 +291,7 @@ def test_get_behavior_prescribed_action_defined( game, player_label, strategy_label, infoset_path, expected_action_label ): """Verify `Game.get_behavior` retrieves the correct action for defined actions.""" - node = game.root + node = games.root_node(game) for action_label in infoset_path: node = node.children[action_label] infoset = node.infoset @@ -319,7 +320,7 @@ def test_get_behavior_prescribed_action_undefined_returns_none( if infoset_label is not None: infoset = games.find_infoset_in_game(game, infoset_label) else: - node = game.root + node = games.root_node(game) for action_label in infoset_path: node = node.children[action_label] infoset = node.infoset @@ -349,7 +350,7 @@ def test_get_behavior_raises_value_error_for_wrong_player( to a different player than the strategy. """ behavior = game.get_behavior(player_label, next(iter(game.get_strategies(player_label)))) - node = game.root + node = games.root_node(game) for action_label in other_infoset_path: node = node.children[action_label] other_players_infoset = node.infoset diff --git a/tests/test_behav.py b/tests/test_behav.py index 6beeb78aa..3b2d42357 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -19,8 +19,8 @@ def _set_action_probs(profile: gbt.MixedBehaviorProfile, probs: list, rational_f convert = (lambda p: gbt.Rational(p)) if rational_flag else (lambda p: p) probs_iter = iter(probs) for infoset in games.all_infosets(profile.game): - node = next(iter(infoset.members)) - profile[node] = {a: convert(next(probs_iter)) for a in infoset.actions} + history = games.history_of(next(iter(infoset.members))) + profile[history] = {a: convert(next(probs_iter)) for a in infoset.actions} @pytest.mark.parametrize( @@ -202,9 +202,9 @@ def test_profile_indexing_by_player_infoset_action_reference( ): profile = game.mixed_behavior_profile(rational=rational_flag) infoset = games.find_infoset(game, player_label, infoset_label) - node = next(iter(infoset.members)) + history = games.history_of(next(iter(infoset.members))) prob = gbt.Rational(prob) if rational_flag else prob - assert profile[node][action_label] == prob + assert profile[history][action_label] == prob @pytest.mark.parametrize( @@ -263,14 +263,14 @@ def test_profile_indexing_by_player_infoset_action_reference( def test_profile_indexing_by_node_reference( game: gbt.Game, player_label: str, infoset_label: str, probs: list, rational_flag: bool ): - """profile[node] and profile[player_label][node] resolve to the same MixedAction.""" + """profile[history] and profile[player_label][history] resolve to the same MixedAction.""" profile = game.mixed_behavior_profile(rational=rational_flag) infoset = games.find_infoset(game, player_label, infoset_label) - node = next(iter(infoset.members)) + history = games.history_of(next(iter(infoset.members))) probs = [gbt.Rational(prob) for prob in probs] if rational_flag else probs expected = dict(zip(infoset.actions, probs, strict=True)) - assert profile[player_label][node] == expected - assert profile[node] == expected + assert profile[player_label][history] == expected + assert profile[history] == expected @pytest.mark.parametrize( @@ -283,14 +283,14 @@ def test_profile_indexing_by_node_reference( def test_behavior_indexing_rejects_node_from_different_player( game: gbt.Game, player_label: str, other_player_label: str ): - """MixedBehavior/MixedBehaviorProfile reject a Node whose information set belongs to a - different player than the one being indexed. + """MixedBehavior/MixedBehaviorProfile reject a History whose information set belongs to + a different player than the one being indexed. """ profile = game.mixed_behavior_profile() other_infoset = games.player_infosets(game, other_player_label)[0] - other_node = next(iter(other_infoset.members)) + other_history = games.history_of(next(iter(other_infoset.members))) with pytest.raises(gbt.MismatchError): - profile[player_label][other_node] + profile[player_label][other_history] @pytest.mark.parametrize( @@ -358,9 +358,9 @@ def test_set_probabilities_action( """A sparse one-action distribution leaves the infoset's other actions at weight zero.""" profile = game.mixed_behavior_profile(rational=rational_flag) prob = gbt.Rational(prob) if rational_flag else prob - node = next(iter(games.find_infoset_in_game(game, infoset_label).members)) - profile[node] = {action_label: prob} - assert profile[node][action_label] == prob + history = games.history_of(next(iter(games.find_infoset_in_game(game, infoset_label).members))) + profile[history] = {action_label: prob} + assert profile[history][action_label] == prob @pytest.mark.parametrize( @@ -435,10 +435,10 @@ def test_set_probabilities_infoset( if rational_flag: probs = [gbt.Rational(p) for p in probs] infoset = games.find_infoset(game, player_label, infoset_label) - node = next(iter(infoset.members)) + history = games.history_of(next(iter(infoset.members))) expected = dict(zip(infoset.actions, probs, strict=True)) - profile[node] = expected - assert profile[node] == expected + profile[history] = expected + assert profile[history] == expected @pytest.mark.parametrize( @@ -475,94 +475,94 @@ def test_set_probabilities_player_by_label( for infoset, distribution in zip( games.player_infosets(game, player_label), expected, strict=True ): - profile[next(iter(infoset.members))] = distribution + profile[games.history_of(next(iter(infoset.members)))] = distribution assert profile[player_label] == expected -def _p1_node(game: gbt.Game): - return next(iter(games.player_infosets(game, "Player 1")[0].members)) +def _p1_history(game: gbt.Game) -> tuple: + return games.history_of(next(iter(games.player_infosets(game, "Player 1")[0].members))) def test_behavior_setitem_allows_sparse_distribution(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) - profile[node] = {"U1": 1} - assert profile[node] == {"U1": 1, "D1": 0} + history = _p1_history(game) + profile[history] = {"U1": 1} + assert profile[history] == {"U1": 1, "D1": 0} def test_set_mixed_action_sparse_matches_setitem(): game = games.read_from_file("mixed_behavior_game.efg") - node = _p1_node(game) + history = _p1_history(game) sparse_profile = game.mixed_behavior_profile() - sparse_profile.set_mixed_action(node, {"U1": 1}, sparse=True) + sparse_profile.set_mixed_action(history, {"U1": 1}, sparse=True) setitem_profile = game.mixed_behavior_profile() - setitem_profile[node] = {"U1": 1} - assert sparse_profile[node] == setitem_profile[node] + setitem_profile[history] = {"U1": 1} + assert sparse_profile[history] == setitem_profile[history] def test_set_mixed_action_defaults_to_requiring_every_label(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + history = _p1_history(game) with pytest.raises(ValueError, match="exactly one weight"): - profile.set_mixed_action(node, {"U1": 1}) + profile.set_mixed_action(history, {"U1": 1}) @pytest.mark.parametrize("sparse", [False, True]) def test_setitem_and_set_mixed_action_reject_unknown_action_label(sparse: bool): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + history = _p1_history(game) with pytest.raises(ValueError, match="not an action label"): - profile.set_mixed_action(node, {"not-an-action": 1}, sparse=sparse) + profile.set_mixed_action(history, {"not-an-action": 1}, sparse=sparse) with pytest.raises(ValueError, match="not an action label"): - profile[node] = {"not-an-action": 1} + profile[history] = {"not-an-action": 1} def test_behavior_setitem_empty_distribution_is_all_zero_error(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + history = _p1_history(game) with pytest.raises(ValueError, match="zero"): - profile[node] = {} + profile[history] = {} @pytest.mark.parametrize("sparse", [False, True]) def test_setitem_and_set_mixed_action_reject_non_mapping(sparse: bool): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + history = _p1_history(game) with pytest.raises(TypeError, match="Mapping"): - profile.set_mixed_action(node, [1, 0], sparse=sparse) + profile.set_mixed_action(history, [1, 0], sparse=sparse) with pytest.raises(TypeError, match="Mapping"): - profile[node] = [1, 0] + profile[history] = [1, 0] @pytest.mark.parametrize("sparse", [False, True]) def test_setitem_and_set_mixed_action_reject_uncoercible_weight(sparse: bool): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + history = _p1_history(game) full_distribution = {"U1": "abc", "D1": 0} with pytest.raises(ValueError, match="convert"): - profile.set_mixed_action(node, full_distribution, sparse=sparse) + profile.set_mixed_action(history, full_distribution, sparse=sparse) with pytest.raises(ValueError, match="convert"): - profile[node] = full_distribution + profile[history] = full_distribution def test_behavior_setitem_sparse_rejects_negative_weight(): """Negativity is checked even for weights given under a sparse distribution.""" game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() - node = _p1_node(game) + history = _p1_history(game) with pytest.raises(ValueError, match="negative"): - profile[node] = {"U1": -1} + profile[history] = {"U1": -1} @pytest.mark.parametrize("sparse", [False, True]) def test_behavior_indexing_rejects_infoset_object(sparse: bool): - """MixedBehaviorProfile's indexing is Node-only; an Infoset object is rejected.""" + """MixedBehaviorProfile's indexing is History-only; an Infoset object is rejected.""" game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile() infoset = games.player_infosets(game, "Player 1")[0] @@ -581,37 +581,37 @@ def test_mixed_action_and_behavior_are_frozen_snapshots(rational_flag: bool): """ game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile(rational=rational_flag) - node = _p1_node(game) - action_before = profile[node] + history = _p1_history(game) + action_before = profile[history] behavior_before = profile["Player 1"] - profile[node] = {"U1": 1, "D1": 0} + profile[history] = {"U1": 1, "D1": 0} assert dict(action_before) == {"U1": 0.5, "D1": 0.5} - assert dict(behavior_before[node]) == {"U1": 0.5, "D1": 0.5} - assert dict(profile[node]) == {"U1": 1, "D1": 0} + assert dict(behavior_before[history]) == {"U1": 0.5, "D1": 0.5} + assert dict(profile[history]) == {"U1": 1, "D1": 0} @pytest.mark.parametrize("rational_flag", [False, True]) def test_behavior_copy_mutating_copy_does_not_affect_original(rational_flag: bool): game = games.read_from_file("mixed_behavior_game.efg") original = game.mixed_behavior_profile(rational=rational_flag) - node = _p1_node(game) - original_before = dict(original[node]) + history = _p1_history(game) + original_before = dict(original[history]) copy = original.copy() - copy[node] = {"U1": 1, "D1": 0} - assert dict(original[node]) == original_before - assert dict(copy[node]) == {"U1": 1, "D1": 0} + copy[history] = {"U1": 1, "D1": 0} + assert dict(original[history]) == original_before + assert dict(copy[history]) == {"U1": 1, "D1": 0} @pytest.mark.parametrize("rational_flag", [False, True]) def test_behavior_copy_mutating_original_does_not_affect_copy(rational_flag: bool): game = games.read_from_file("mixed_behavior_game.efg") original = game.mixed_behavior_profile(rational=rational_flag) - node = _p1_node(game) + history = _p1_history(game) copy = original.copy() - copy_before = dict(copy[node]) - original[node] = {"U1": 1, "D1": 0} - assert dict(copy[node]) == copy_before - assert dict(original[node]) == {"U1": 1, "D1": 0} + copy_before = dict(copy[history]) + original[history] = {"U1": 1, "D1": 0} + assert dict(copy[history]) == copy_before + assert dict(original[history]) == {"U1": 1, "D1": 0} @pytest.mark.parametrize("rational_flag", [False, True]) @@ -624,20 +624,20 @@ def test_as_float_returns_double(rational_flag: bool): def test_as_float_converts_rational_probabilities(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile(rational=True) - node = _p1_node(game) - profile[node] = {"U1": "1/3", "D1": "2/3"} + history = _p1_history(game) + profile[history] = {"U1": "1/3", "D1": "2/3"} result = profile.as_float() - assert dict(result[node]) == {"U1": pytest.approx(1 / 3), "D1": pytest.approx(2 / 3)} + assert dict(result[history]) == {"U1": pytest.approx(1 / 3), "D1": pytest.approx(2 / 3)} def test_as_float_is_independent_copy(): game = games.read_from_file("mixed_behavior_game.efg") profile = game.mixed_behavior_profile(rational=False) - node = _p1_node(game) + history = _p1_history(game) result = profile.as_float() assert result == profile - result[node] = {"U1": 1.0, "D1": 0.0} - assert dict(profile[node]) != dict(result[node]) + result[history] = {"U1": 1.0, "D1": 0.0} + assert dict(profile[history]) != dict(result[history]) @pytest.mark.parametrize( @@ -704,7 +704,7 @@ def test_realiz_prob_nodes_reference( # path from the root (an empty path is the root itself) profile = game.mixed_behavior_profile(rational=rational_flag) realiz_prob = gbt.Rational(realiz_prob) if rational_flag else realiz_prob - node = game.root + node = games.root_node(game) for action_label in path: node = node.children[action_label] assert profile.realiz_probs[node] == realiz_prob @@ -772,8 +772,9 @@ def test_nature_rooted_game_root_reached_with_certainty(rational_flag: bool): game = gbt.catalog.load("journals/geb/gilboa1997/fig2") profile = game.mixed_behavior_profile(rational=rational_flag) one = gbt.Rational(1) if rational_flag else 1.0 - assert profile.realiz_probs[game.root] == one - assert profile.infoset_probs[game.root] == one + root = games.root_node(game) + assert profile.realiz_probs[root] == one + assert profile.infoset_probs[root] == one @pytest.mark.parametrize( @@ -939,7 +940,7 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): for player in game.players: player_node_values = node_values[player] assert isinstance(player_node_values, gbt.NodeValueVector) - assert player_node_values[game.root] == payoffs[player] + assert player_node_values[games.root_node(game)] == payoffs[player] for infoset in games.player_infosets(game, player): node = next(iter(infoset.members)) @@ -956,7 +957,7 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): == best_response_value - infoset_action_values[action] ) - for node in game.nodes: + for node in games.all_nodes(game): if node.is_terminal: continue if infoset_probs[node] == 0: @@ -966,7 +967,7 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): # equal to an equivalent plain dict or same-type vector, but never to a vector of a # different quantity, even where the underlying numbers happen to coincide - expected = {n: realiz_probs[n] for n in game.nodes} + expected = {n: realiz_probs[n] for n in games.all_nodes(game)} assert realiz_probs == expected assert realiz_probs == gbt.RealizProbVector(expected) assert realiz_probs != beliefs @@ -1062,7 +1063,7 @@ def test_martingale_property_of_node_value(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) realiz_probs = profile.realiz_probs node_values = profile.node_values - for node in game.nodes: + for node in games.all_nodes(game): if node.is_terminal or bool(node.event): continue expected_val = 0 @@ -1089,8 +1090,9 @@ def test_node_value_consistency(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) node_values = profile.node_values payoffs = profile.payoffs + root = games.root_node(game) for player in game.players: - assert node_values[player][game.root] == payoffs[player] + assert node_values[player][root] == payoffs[player] @pytest.mark.parametrize( @@ -1391,7 +1393,7 @@ def test_node_belief_reference( # action-label path from the root (an empty path is the root itself) profile = game.mixed_behavior_profile(rational=rational_flag) _set_action_probs(profile, probs, rational_flag) - node = game.root + node = games.root_node(game) for action_label in path: node = node.children[action_label] value = gbt.Rational(value) if rational_flag else value @@ -1501,7 +1503,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1509,7 +1511,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ( games.create_stripped_down_poker_efg(), @@ -1517,7 +1519,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ( games.create_stripped_down_poker_efg(), @@ -1525,7 +1527,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.beliefs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ###################################################################################### # realiz_prob (at nodes) @@ -1535,7 +1537,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1543,7 +1545,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ( games.create_stripped_down_poker_efg(), @@ -1551,7 +1553,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ( games.create_stripped_down_poker_efg(), @@ -1559,7 +1561,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.realiz_probs[y], - lambda x: x.nodes, + lambda x: games.all_nodes(x), ), ###################################################################################### # infoset_prob @@ -1705,7 +1707,7 @@ def _get_and_check_answers( PROBS_2A_doub, False, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, games.all_nodes(x))), ), ( games.read_from_file("mixed_behavior_game.efg"), @@ -1713,7 +1715,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, games.all_nodes(x))), ), ( games.create_stripped_down_poker_efg(), @@ -1721,7 +1723,7 @@ def _get_and_check_answers( PROBS_2B_doub, False, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, games.all_nodes(x))), ), ( games.create_stripped_down_poker_efg(), @@ -1729,7 +1731,7 @@ def _get_and_check_answers( PROBS_2A_rat, True, lambda x, y: x.node_values[y[0]][y[1]], - lambda x: list(product(x.players, x.nodes)), + lambda x: list(product(x.players, games.all_nodes(x))), ), ###################################################################################### # agent_liap_value (of profile, hence [1] for objects_to_test, @@ -1920,10 +1922,10 @@ def test_specific_profile(game: gbt.Game, rational_flag: bool, data: list): profile = game.mixed_behavior_profile(rational=rational_flag, data=data) flattened = iter([k for i in data for j in i for k in j]) for infoset in games.all_infosets(game): - node = next(iter(infoset.members)) + history = games.history_of(next(iter(infoset.members))) for action in infoset.actions: prob = next(flattened) - assert profile[node][action] == (gbt.Rational(prob) if rational_flag else prob) + assert profile[history][action] == (gbt.Rational(prob) if rational_flag else prob) @pytest.mark.parametrize( diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index cfb210d9e..840f5adbe 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -19,7 +19,7 @@ def _branching_game(): that removing an action can make a whole subtree's information set unreachable. """ game = gbt.Game.new_tree(players=["P1", "P2"]) - root = game.root + root = games.root_node(game) game.append_move(root, "P1", ["L", "R"]) left = root.children["L"] right = root.children["R"] @@ -164,11 +164,11 @@ def test_actionsupport_is_snapshot(): def test_getitem_setitem_accept_node_infoset(): game, root_infoset, left_infoset, right_infoset = _branching_game() profile = game.behavior_support_profile() - # game.root.infoset is a live, node-anchored Infoset view -- both __getitem__ and + # games.root_node(game).infoset is a live, node-anchored Infoset view -- both __getitem__ and # __setitem__ must resolve it the same way Node.infoset is used everywhere else. - assert set(profile[game.root.infoset]) == {"L", "R"} - profile[game.root.infoset] = ["R"] - assert set(profile[game.root.infoset]) == {"R"} + assert set(profile[games.root_node(game).infoset]) == {"L", "R"} + profile[games.root_node(game).infoset] = ["R"] + assert set(profile[games.root_node(game).infoset]) == {"R"} def test_is_reachable(): diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 961f52306..a6b80d31d 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -7,6 +7,8 @@ import pygambit as gbt +from . import games + @pytest.fixture(scope="module") def all_games(): @@ -138,7 +140,7 @@ def test_catalog_games_filter_n_nodes(all_games): assert len(filtered_games) < len(all_games) if len(filtered_games) > 0: g = gbt.catalog.load(filtered_games.Game.iloc[0]) - assert len(g.nodes) == 5 + assert len(games.all_nodes(g)) == 5 def test_catalog_games_filter_n_outcomes(all_games): diff --git a/tests/test_file.py b/tests/test_file.py index fe4b33bb5..a167f46fa 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -4,6 +4,8 @@ import pygambit as gbt +from . import games + def _parse_efg(text: str) -> gbt.Game: with io.StringIO(text) as f: @@ -31,13 +33,13 @@ def test_read_efg_repeated_outcome_id_consistent(): def test_read_efg_empty_action_labels_are_normalized(): g = _parse_efg('EFG 2 R "t" { "A" "B" }\n""\np "" 1 1 "" { "" "" } 0\n' 't "" 1 "" { 1, -1 }\nt "" 2 "" { 2, -2 }\n') - assert list(g.root.infoset.actions) == ["_1", "_2"] + assert list(games.root_node(g).infoset.actions) == ["_1", "_2"] def test_read_efg_duplicate_action_labels_are_normalized(): g = _parse_efg('EFG 2 R "t" { "A" "B" }\n""\np "" 1 1 "" { "l" "l" } 0\n' 't "" 1 "" { 1, -1 }\nt "" 2 "" { 2, -2 }\n') - assert list(g.root.infoset.actions) == ["l_1", "l_2"] + assert list(games.root_node(g).infoset.actions) == ["l_1", "l_2"] def test_read_efg_repeated_infoset_duplicate_labels_consistent(): @@ -53,7 +55,7 @@ def test_read_efg_repeated_infoset_duplicate_labels_consistent(): 't "" 2 "" { 2, -2 }\n' 't "" 3 "" { 3, -3 }\n' ) - assert list(g.root.infoset.actions) == ["l_1", "l_2"] + assert list(games.root_node(g).infoset.actions) == ["l_1", "l_2"] _NFG_PAYOFF_BODY = '\n{\n{ "" 1, 1 }\n{ "" 0, 0 }\n{ "" 0, 0 }\n{ "" 1, 1 }\n}\n1 2 3 4\n' diff --git a/tests/test_game.py b/tests/test_game.py index d3a9cfb9b..8fe5729a9 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(games.root_node(game), "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"]) - infoset = game.root.infoset + game.append_move(games.root_node(game), "Alice", ["a", "b"]) + infoset = games.root_node(game).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(games.root_node(game).children["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(games.root_node(game), ["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,8 @@ 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(games.root_node(game), ["D1"], drop=True) + infoset = games.root_node(game) for profile in profiles: with pytest.raises(gbt.GameStructureChangedError): _ = profile.action_regrets @@ -297,11 +297,11 @@ def test_mixed_behavior_profile_game_structure_changed(): # triggers error via __getitem__ next(profile.__iter__()) with pytest.raises(gbt.GameStructureChangedError): - profile.__setitem__(game.root, {}) + profile.__setitem__((), {}) with pytest.raises(gbt.GameStructureChangedError): - profile.set_mixed_action(game.root, {}) + profile.set_mixed_action((), {}) with pytest.raises(gbt.GameStructureChangedError): - profile.__getitem__(game.root) + profile.__getitem__(()) COLLECTION_GETTERS = [ diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index 7795dd7b8..6a1153543 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -32,7 +32,7 @@ def _test_valid_resolutions(collection: list, resolver: typing.Callable) -> None ] ) def test_resolve_node(game: gbt.Game) -> None: - _test_valid_resolutions(game.nodes, + _test_valid_resolutions(games.all_nodes(game), lambda label, fn: game._resolve_node(label, fn)) diff --git a/tests/test_hsel.py b/tests/test_hsel.py new file mode 100644 index 000000000..f7a732e4d --- /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", "R"], [0.5, 0.5]) + game.append_event(gbt.H.plays, ["p", "q"], [0.5, 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..bc14fd636 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -13,23 +13,23 @@ @pytest.mark.parametrize("label", games.VALID_LABELS) def test_infoset_set_label(label): game = games.read_from_file("basic_extensive_game.efg") - game.root.infoset.label = label - assert game.root.infoset.label == label + games.root_node(game).infoset.label = label + assert games.root_node(game).infoset.label == label @pytest.mark.parametrize("label", games.INVALID_LABELS) def test_infoset_label_invalid_raises_valueerror(label): game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.root.infoset.label = label + games.root_node(game).infoset.label = label @pytest.mark.parametrize("label", games.UNICODE_LABELS) def test_infoset_label_unicode_accepted(label): """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" game = games.read_from_file("basic_extensive_game.efg") - game.root.infoset.label = label - assert game.root.infoset.label == label + games.root_node(game).infoset.label = label + assert games.root_node(game).infoset.label == label def test_infoset_label_duplicate_within_player_raises_valueerror(): @@ -44,13 +44,14 @@ def test_infoset_label_duplicate_within_player_raises_valueerror(): def test_infoset_player_retrieval(): game = games.read_from_file("basic_extensive_game.efg") p1, *_ = game.players - assert p1 == game.root.infoset.player + assert p1 == games.root_node(game).infoset.player def test_infoset_node_precedes(): game = games.read_from_file("basic_extensive_game.efg") - assert not game.root.infoset.precedes(game.root) - assert game.root.children["U1"].infoset.precedes(game.root.children["U1"]) + root = games.root_node(game) + assert not root.infoset.precedes(root) + assert root.children["U1"].infoset.precedes(root.children["U1"]) def test_make_infoset_change_player_keeps_label(): @@ -58,11 +59,11 @@ def test_make_infoset_change_player_keeps_label(): explicitly specified label and its membership.""" game = games.read_from_file("basic_extensive_game.efg") _, p2, *_ = game.players - members = list(game.root.infoset.members) + members = list(games.root_node(game).infoset.members) game.make_infoset(members, p2, "moved") - assert game.root.infoset.player == p2 - assert game.root.infoset.label == "moved" - assert list(game.root.infoset.members) == members + assert games.root_node(game).infoset.player == p2 + assert games.root_node(game).infoset.label == "moved" + assert list(games.root_node(game).infoset.members) == members def test_make_infoset_mismatch_raises(): @@ -70,22 +71,22 @@ def test_make_infoset_mismatch_raises(): 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") + game1.make_infoset(games.root_node(game2), "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"] + terminal = games.root_node(game).children["U1"].children["U2"].children["U3"] with pytest.raises(gbt.UndefinedOperationError): - game.make_infoset([terminal], game.root.player) + game.make_infoset([terminal], games.root_node(game).player) def test_make_infoset_converts_chance_node(): """A chance node becomes a personal decision node, discarding its probabilities.""" 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) + chance_node = games.root_node(game) # the deal is a chance move + personal = next(n for n in games.all_nodes(game) if not n.is_terminal and n.infoset) game.make_infoset([chance_node], personal.infoset.player) assert not chance_node.event assert chance_node.infoset @@ -97,24 +98,25 @@ 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(games.root_node(game), "1", ["a", "b"]) + game.append_move(games.root_node(game).children["a"], "1", node_actions) with pytest.raises(ValueError): - game.make_infoset([game.root, game.root.children["a"]], "1") + game.make_infoset([games.root_node(game), games.root_node(game).children["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) + game.make_infoset([], games.root_node(game).player) def test_make_infoset_repeated_node_raises(): """Each node may be referenced only once.""" game = games.read_from_file("basic_extensive_game.efg") + root = games.root_node(game) with pytest.raises(ValueError): - game.make_infoset([game.root, game.root], game.root.player) + game.make_infoset([root, root], root.player) def test_make_infoset_strategic_game_raises(): @@ -128,11 +130,11 @@ def test_set_move_actions_add_preserves_existing_action_order(): """New actions may be declared at any position; the existing actions' relative 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"]) - assert list(game.root.actions)[:-1] == labels - game.set_move_actions(game.root, ["front"] + labels + ["end"]) - assert list(game.root.actions)[1:-1] == labels + labels = list(games.root_node(game).actions) + game.set_move_actions(games.root_node(game), labels + ["end"]) + assert list(games.root_node(game).actions)[:-1] == labels + game.set_move_actions(games.root_node(game), ["front"] + labels + ["end"]) + assert list(games.root_node(game).actions)[1:-1] == labels @pytest.mark.parametrize( @@ -148,16 +150,16 @@ def test_make_event_sets_probabilities(inprobs, outprobs): actions are assigned zero. """ game = games.read_from_file("stripped_down_poker.efg") - game.make_event([game.root], inprobs, "Deal") - probs = game.root.action_probs - for action, prob in zip(game.root.actions, outprobs, strict=True): + game.make_event([games.root_node(game)], inprobs, "Deal") + probs = games.root_node(game).action_probs + for action, prob in zip(games.root_node(game).actions, outprobs, strict=True): assert probs[action] == prob 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"]] + nodes = [games.root_node(game).children["King"], games.root_node(game).children["Queen"]] game.make_event(nodes, ["1/4", "3/4"], "Coin") assert nodes[0].event == nodes[1].event assert nodes[0].event @@ -172,7 +174,7 @@ def test_make_event_requires_matching_action_labels(probs): The mapping case was previously reported as an unknown action label. """ game = games.read_from_file("stripped_down_poker.efg") - alice_node = game.root.children["King"] # actions Bet, Fold + alice_node = games.root_node(game).children["King"] # actions Bet, Fold bob_node = alice_node.children["Bet"] # actions Call, Fold with pytest.raises(ValueError): game.make_event([alice_node, bob_node], probs) @@ -191,7 +193,7 @@ def test_make_event_converts_personal_node(): def test_make_event_terminal_node_raises(): game = games.read_from_file("stripped_down_poker.efg") - terminal = game.root.children["King"].children["Fold"] + terminal = games.root_node(game).children["King"].children["Fold"] with pytest.raises(gbt.UndefinedOperationError): game.make_event([terminal], ["1/2", "1/2"]) @@ -199,14 +201,14 @@ def test_make_event_terminal_node_raises(): 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"]) + game.make_event([games.root_node(game), games.root_node(game)], ["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([games.root_node(other)], ["1/2", "1/2"]) def test_make_event_strategic_game_raises(): @@ -224,7 +226,7 @@ def test_make_event_empty_nodes_raises(): 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"]] + nodes = [games.root_node(game).children["King"], games.root_node(game).children["Queen"]] game.make_event(nodes, ["1/2", "1/2"], "Coin") before = game.to_efg() with pytest.raises(ValueError): @@ -237,7 +239,7 @@ def test_make_event_label_reused_when_fully_absorbed(): event's members are absorbed into the new one; the old event is not left behind. """ game = games.read_from_file("stripped_down_poker.efg") - nodes = [game.root.children["King"], game.root.children["Queen"]] + nodes = [games.root_node(game).children["King"], games.root_node(game).children["Queen"]] game.make_event(nodes, ["1/2", "1/2"], "Coin") game.make_event(nodes, ["1/4", "3/4"], "Coin") assert nodes[0].event == nodes[1].event @@ -253,7 +255,7 @@ 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([games.root_node(game)], probs) @pytest.mark.parametrize( @@ -263,7 +265,7 @@ def test_make_event_invalid_probs_raises(probs): def test_make_event_malformed_probs_raises(probs, error): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(error): - game.make_event([game.root], probs) + game.make_event([games.root_node(game)], probs) @dataclasses.dataclass @@ -339,7 +341,7 @@ def _get_node_by_path(game, path: list[str]) -> gbt.Node: """ Helper to find a node by following a sequence of action labels. """ - node = game.root + node = games.root_node(game) for action_label in reversed(path): node = node.children[action_label] return node @@ -374,10 +376,10 @@ def _bagwell_p2_nodes(game: gbt.Game) -> tuple[gbt.Node, gbt.Node, gbt.Node, gbt can produce -- each with two members and actions ("S", "C"). Returns (A, B, C, D) with {A, B} the members of one and {C, D} of the other. """ - return (game.root.children["S"].children["s"], - game.root.children["C"].children["s"], - game.root.children["S"].children["c"], - game.root.children["C"].children["c"]) + return (games.root_node(game).children["S"].children["s"], + games.root_node(game).children["C"].children["s"], + games.root_node(game).children["S"].children["c"], + games.root_node(game).children["C"].children["c"]) def test_make_infoset_cherry_pick_leaves_rumps(): @@ -440,11 +442,11 @@ 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 - n2 = game.root.children["a"] - n3 = game.root.children["b"] + game.append_move(games.root_node(game), "1", ["a", "b"]) + game.append_move(games.root_node(game).children["a"], "2", ["a", "b"]) # player 2 + game.append_move(games.root_node(game).children["b"], "3", ["a", "b"]) # player 3 + n2 = games.root_node(game).children["a"] + n3 = games.root_node(game).children["b"] assert n2.infoset.player == "2" assert n3.infoset.player == "3" game.make_infoset([n2, n3], "1") @@ -457,7 +459,7 @@ def test_infoset_proxy_reresolves_after_split(): """A node-anchored infoset proxy is lazy: it re-resolves after the node is placed in a new information set.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] + node = games.root_node(game).children["U1"] proxy = node.infoset assert len(proxy.members) == 2 game.make_infoset(node, node.player) @@ -469,7 +471,7 @@ def test_infoset_members_is_a_plain_snapshot_list(): integer indexing, and a list obtained before a mutation keeps reflecting the information set as it was at the time, rather than tracking its owner.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] + node = games.root_node(game).children["U1"] members = node.infoset.members assert isinstance(members, list) assert node in (members[0], members[1]) @@ -484,7 +486,7 @@ def test_reveal_splits_infoset_by_action(): 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") + game.reveal(games.root_node(game), "Bob") bob = game.get_infosets("Bob") assert len(bob) == 2 assert all(len(list(n.infoset.members)) == 1 for n in bob) @@ -494,10 +496,10 @@ def test_reveal_splits_infoset_by_action(): 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(games.root_node(game), "Driver", ["Continue", "Exit"]) + mid = games.root_node(game).children["Continue"] game.append_move(mid, "Driver", ["Continue", "Exit"]) - game.make_infoset([game.root, mid], "Driver") + game.make_infoset([games.root_node(game), mid], "Driver") game.append_move(mid.children["Continue"], "2", ["l", "r"]) with pytest.raises(gbt.UndefinedOperationError): - game.reveal(game.root, "2") + game.reveal(games.root_node(game), "2") diff --git a/tests/test_nash.py b/tests/test_nash.py index d49b59968..3eef4c049 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -26,7 +26,7 @@ def d(*probs) -> tuple: def _action_prob(profile: gbt.MixedBehaviorProfile, node: gbt.Node, label: str): """The probability profile assigns to the action labeled `label` at `node`'s information set.""" - return profile[node][label] + return profile[node.history][label] @dataclasses.dataclass diff --git a/tests/test_node.py b/tests/test_node.py index f6e98692f..58378745a 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -1,6 +1,5 @@ import dataclasses import functools -import itertools import typing import pytest @@ -13,17 +12,17 @@ def test_get_infoset(): """Test to ensure that we can retrieve an infoset for a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.infoset - assert game.root.children["U1"].infoset - assert not game.root.children["U1"].children["D2"].children["U3"].infoset + assert games.root_node(game).infoset + assert games.root_node(game).children["U1"].infoset + assert not games.root_node(game).children["U1"].children["D2"].children["U3"].infoset def test_infoset_equality_is_symmetric(): """A node-anchored infoset proxy and a separately-constructed Infoset compare equal from either side.""" game = games.read_from_file("basic_extensive_game.efg") - proxy = game.root.infoset - infoset = game.get_infosets(game.root.player)[0].infoset + proxy = games.root_node(game).infoset + infoset = game.get_infosets(games.root_node(game).player)[0].infoset assert proxy == infoset assert infoset == proxy @@ -32,7 +31,7 @@ def test_node_infoset_truthiness(): """A node-anchored infoset view is truthy iff the node currently has an infoset, and tracks mutation across the terminal boundary.""" game = games.read_from_file("basic_extensive_game.efg") - terminal = game.root.children["U1"].children["D2"].children["U3"] + terminal = games.root_node(game).children["U1"].children["D2"].children["U3"] proxy = terminal.infoset assert not proxy game.append_move(terminal, "Player 1", ["a", "b"]) @@ -43,16 +42,16 @@ def test_get_outcome(): """Test to ensure that we can retrieve an outcome for a given node""" game = games.read_from_file("basic_extensive_game.efg") assert ( - game.root.children["U1"].children["D2"].children["U3"].outcome + games.root_node(game).children["U1"].children["D2"].children["U3"].outcome == game.outcomes["Outcome 1"] ) - assert not game.root.outcome + assert not games.root_node(game).outcome 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"] + node = games.root_node(game).children["U1"].children["U2"].children["U3"] game.make_outcome_null(node) assert not node.outcome @@ -60,7 +59,7 @@ def test_make_outcome_null(): def test_node_outcome_subscript_tracks_mutation(): """Indexing the outcome view reads/writes the outcome's payoffs, reflecting mutation.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"].children["D2"].children["U3"] + node = games.root_node(game).children["U1"].children["D2"].children["U3"] proxy = node.outcome player = "Player 1" proxy[player] = 7 @@ -70,7 +69,7 @@ def test_node_outcome_subscript_tracks_mutation(): def test_outcome_equality_is_symmetric(): """A node-anchored outcome view and the resolved Outcome compare equal from either side.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"].children["D2"].children["U3"] + node = games.root_node(game).children["U1"].children["D2"].children["U3"] proxy = node.outcome outcome = game.outcomes["Outcome 1"] assert proxy == outcome @@ -80,53 +79,53 @@ def test_outcome_equality_is_symmetric(): def test_null_outcome_label_is_none(): """The blessed nullity idiom: a node with no outcome has `outcome.label is None`.""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.outcome.label is None + assert games.root_node(game).outcome.label is None def test_null_outcome_compares_unequal_to_itself(): """Null outcomes are unequal to everything, including another view of the same node's outcome; equality must not short-circuit on node identity.""" game = games.read_from_file("basic_extensive_game.efg") - assert (game.root.outcome == game.root.outcome) is False + assert (games.root_node(game).outcome == games.root_node(game).outcome) is False def test_null_outcome_reads_zero_payoffs(): """Reading a payoff through an unset node reports zero to every player of the game.""" game = games.read_from_file("basic_extensive_game.efg") for player in game.players: - assert game.root.outcome[player] == 0 + assert games.root_node(game).outcome[player] == 0 def test_null_outcome_payoff_write_raises(): game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(gbt.UndefinedOperationError): - game.root.outcome["Player 1"] = 1 + games.root_node(game).outcome["Player 1"] = 1 def test_null_outcome_label_write_raises(): game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.root.outcome.label = "Outcome 4" + games.root_node(game).outcome.label = "Outcome 4" def test_null_outcome_number_is_none(): """The null outcome is not a member of the game's outcomes, so it has no number. -1 would be a valid index and would silently resolve to the last real outcome.""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.outcome.number is None + assert games.root_node(game).outcome.number is None def test_get_player(): """Test to ensure that we can retrieve a player for a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.player == "Player 1" - assert not game.root.children["U1"].children["D2"].children["U3"].player + assert games.root_node(game).player == "Player 1" + assert not games.root_node(game).children["U1"].children["D2"].children["U3"].player def test_node_player_resolves_chance(): """At a chance node, the player label is the chance player's.""" game = games.read_from_file("stripped_down_poker.efg") - chance_node = game.root + chance_node = games.root_node(game) assert chance_node.event assert chance_node.player == "Chance" @@ -134,42 +133,45 @@ def test_node_player_resolves_chance(): def test_get_game(): """Test to ensure that we can retrieve the game object from a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game == game.root.game + assert game == games.root_node(game).game def test_get_parent(): """Test to ensure that we can retrieve a parent node for a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].parent == game.root - assert game.root.parent is None + assert games.root_node(game).children["U1"].parent == games.root_node(game) + assert games.root_node(game).parent is None def test_get_prior_action(): """Test to ensure that we can retrieve the prior action for a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].prior_action == gbt.Branch(game.root, "U1") - assert game.root.prior_action is None + root = games.root_node(game) + assert root.children["U1"].prior_action == gbt.Branch(root, "U1") + assert root.prior_action is None def test_get_prior_sibling(): """Test to ensure that we can retrieve a prior sibling of a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["D1"].prior_sibling == game.root.children["U1"] - assert game.root.children["U1"].prior_sibling is None + root = games.root_node(game) + assert root.children["D1"].prior_sibling == root.children["U1"] + assert root.children["U1"].prior_sibling is None def test_get_next_sibling(): """Test to ensure that we can retrieve a next sibling of a given node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].next_sibling == game.root.children["D1"] - assert game.root.children["D1"].next_sibling is None + root = games.root_node(game) + assert root.children["U1"].next_sibling == root.children["D1"] + assert root.children["D1"].next_sibling is None def test_is_terminal(): """Test to ensure that we can check if a given node is a terminal node""" game = games.read_from_file("basic_extensive_game.efg") - assert game.root.is_terminal is False - assert game.root.children["U1"].children["U2"].children["U3"].is_terminal is True + assert games.root_node(game).is_terminal is False + assert games.root_node(game).children["U1"].children["U2"].children["U3"].is_terminal is True def test_is_successor_of(): @@ -177,14 +179,14 @@ def test_is_successor_of(): successor of a supplied node """ game = games.read_from_file("basic_extensive_game.efg") - assert game.root.children["U1"].is_successor_of(game.root) - assert not game.root.is_successor_of(game.root.children["U1"]) + assert games.root_node(game).children["U1"].is_successor_of(games.root_node(game)) + assert not games.root_node(game).is_successor_of(games.root_node(game).children["U1"]) with pytest.raises(TypeError): - game.root.is_successor_of(9) + games.root_node(game).is_successor_of(9) with pytest.raises(TypeError): - game.root.is_successor_of("Test") + games.root_node(game).is_successor_of("Test") with pytest.raises(TypeError): - game.root.is_successor_of("Player 1") + games.root_node(game).is_successor_of("Player 1") def _get_path_of_action_labels(node: gbt.Node) -> list[str]: @@ -315,7 +317,7 @@ def test_subgame_roots(test_case: SubgameRootsTestCase): """ game = test_case.factory() - actual_roots = [node for node in game.nodes if node.is_subgame_root] + actual_roots = [node for node in games.all_nodes(game) if node.is_subgame_root] actual_paths = [_get_path_of_action_labels(node) for node in actual_roots] assert sorted(actual_paths) == sorted(test_case.expected_paths) @@ -535,7 +537,7 @@ def test_node_own_prior_action_non_terminal(game_file, expected_node_data): actual_node_data = [] - for node in game.nodes: + for node in games.all_nodes(game): if node.is_terminal: assert node.own_prior_action is None, ( f"Terminal node at {_get_path_of_action_labels(node)} must be None" @@ -576,7 +578,7 @@ def test_is_strategy_reachable(game_file: str, expected_unreachable_paths: list[ list of paths against a known-correct list. """ game = game_file if isinstance(game_file, gbt.Game) else games.read_from_file(game_file) - nodes = game.nodes + nodes = games.all_nodes(game) actual_unreachable_paths = [ _get_path_of_action_labels(node) for node in nodes if not node.is_strategy_reachable @@ -589,7 +591,7 @@ 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", []) + game.append_move(games.root_node(game), "Player 1", []) def test_append_move_error_infoset_mismatch(): @@ -597,48 +599,48 @@ def test_append_move_error_infoset_mismatch(): 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) + game1.append_infoset(games.root_node(game1), games.root_node(game2)) 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(games.root_node(game), "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(games.root_node(game), "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(games.root_node(game), "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(games.root_node(game), "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(games.root_node(game), "Player 1", ["a", "a"]) def test_node_infoset_becomes_null_when_truncated(): """A captured infoset proxy re-resolves to null after the node is truncated to a leaf.""" game = games.read_from_file("basic_extensive_game.efg") - node = game.root.children["U1"] + node = games.root_node(game).children["U1"] proxy = node.infoset assert proxy game.delete_tree(node) @@ -648,15 +650,15 @@ def test_node_infoset_becomes_null_when_truncated(): 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"] + node = games.root_node(game).children["U1"] game.delete_parent(node) - assert game.root == node + assert games.root_node(game) == node 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"] + node = games.root_node(game).children["U1"] game.delete_tree(node) assert len(node.children) == 0 @@ -665,7 +667,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) + game.copy_tree(games.root_node(game), games.root_node(game)) def test_node_copy_across_games(): @@ -675,9 +677,9 @@ def test_node_copy_across_games(): 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) + game1.copy_tree(games.root_node(game1), games.root_node(game2)) with pytest.raises(gbt.MismatchError): - game1.copy_tree(game2.root, game1.root) + game1.copy_tree(games.root_node(game2), games.root_node(game1)) def _subtrees_equal( @@ -711,8 +713,8 @@ def _subtrees_equal( def test_copy_tree_onto_nondescendent_terminal_node(): """Test copying a subtree to a non-descendent node.""" g = gbt.catalog.load("journals/ijgt/selten1975/fig1") - src_node = g.root.children["R"].children["L"] - dest_node = g.root.children["R"].children["R"] + src_node = games.root_node(g).children["R"].children["L"] + dest_node = games.root_node(g).children["R"].children["R"] g.copy_tree(src_node, dest_node) @@ -722,8 +724,8 @@ def test_copy_tree_onto_nondescendent_terminal_node(): def test_copy_tree_onto_descendent_terminal_node(): """Test copying a subtree to a node that's a descendent of the original.""" g = gbt.catalog.load("journals/ijgt/selten1975/fig1") - src_node = g.root.children["R"] - dest_node = g.root.children["R"].children["L"].children["R"] + src_node = games.root_node(g).children["R"] + dest_node = games.root_node(g).children["R"].children["L"].children["R"] g.copy_tree(src_node, dest_node) @@ -734,14 +736,15 @@ 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(games.root_node(game), games.root_node(game)) def test_node_move_successor(): """Test on moving a node to one of its successors.""" game = games.read_from_file("basic_extensive_game.efg") + root = games.root_node(game) with pytest.raises(gbt.UndefinedOperationError): - game.move_tree(game.root, game.root.children["U1"].children["U2"].children["U3"]) + game.move_tree(root, root.children["U1"].children["U2"].children["U3"]) def test_node_move_across_games(): @@ -751,18 +754,18 @@ def test_node_move_across_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) + game1.move_tree(games.root_node(game1), games.root_node(game2)) with pytest.raises(gbt.MismatchError): - game1.move_tree(game2.root, game1.root) + game1.move_tree(games.root_node(game2), games.root_node(game1)) def test_append_move_creates_single_infoset_list_of_nodes(): """Test that appending a list of 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"]] + nodes = [games.root_node(game).children["2"].children["1"], + games.root_node(game).children["1"].children["1"], + games.root_node(game).children["1"].children["2"]] game.append_move(nodes, "Player 3", ["B", "F"]) assert len(game.get_infosets("Player 3")) == 1 @@ -771,8 +774,8 @@ def test_append_move_same_infoset_list_of_nodes(): """Test that nodes from a list of nodes 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"] + node1 = games.root_node(game).children["2"].children["1"] + node2 = games.root_node(game).children["1"].children["1"] game.append_move([node1, node2], "Player 3", ["B", "F"]) assert node1.infoset == node2.infoset @@ -783,8 +786,8 @@ def test_append_move_actions_list_of_nodes(): """ 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 = games.root_node(game).children["2"].children["1"] + node2 = games.root_node(game).children["1"].children["1"] game.append_move([node1, node2], "Player 3", ["B", "F", "S"]) assert list(node1.infoset.actions) == list(node2.infoset.actions) @@ -793,8 +796,8 @@ 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 = games.root_node(game).children["2"].children["1"] + node2 = games.root_node(game).children["1"].children["1"] node1.label = "0" node2.label = "00" game.append_move(["0", "00"], "Player 3", ["B", "F", "S"]) @@ -812,8 +815,8 @@ def test_append_move_actions_list_of_mixed_node_references(): 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 = games.root_node(game).children["2"].children["1"] + node2 = games.root_node(game).children["1"].children["1"] node1.label = "000" node_references = ["000", node2] game.append_move(node_references, "Player 3", ["B", "F", "S"]) @@ -829,8 +832,8 @@ def test_append_move_labels_list_of_nodes(): """ 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 = games.root_node(game).children["2"].children["1"] + node2 = games.root_node(game).children["1"].children["1"] game.append_move([node1, node2], "Player 3", ["B", "F", "S"]) assert node1.infoset.actions == node2.infoset.actions @@ -842,9 +845,10 @@ def test_append_move_node_list_with_non_terminal_node(): """ game = games.read_from_file("sample_extensive_game.efg") game.set_players(list(game.players) + ["Player 3"]) + root = games.root_node(game) with pytest.raises(gbt.UndefinedOperationError): game.append_move( - [game.root.children["2"], game.root.children["1"].children["2"]], + [root.children["2"], root.children["1"].children["2"]], "Player 3", ["B", "F"] ) @@ -856,11 +860,11 @@ def test_append_move_node_list_with_duplicate_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 = games.root_node(game).children["1"].children["2"] node.label = "00" with pytest.raises(ValueError): game.append_move( - ["00", game.root.children["2"].children["1"], node], + ["00", games.root_node(game).children["2"].children["1"], node], "Player 3", ["B", "F"] ) @@ -882,11 +886,12 @@ def test_append_infoset_node_list_with_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"] + root = games.root_node(game) + seed_node = root.children["1"].children["1"] game.append_move(seed_node, "Player 3", ["B", "F"]) with pytest.raises(gbt.UndefinedOperationError): game.append_infoset( - [game.root.children["2"], game.root.children["1"].children["2"]], + [root.children["2"], root.children["1"].children["2"]], seed_node ) @@ -897,13 +902,13 @@ def test_append_infoset_node_list_with_duplicate_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"] + seed_node = games.root_node(game).children["1"].children["1"] game.append_move(seed_node, "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"]], + [games.root_node(game).children["1"].children["2"], + games.root_node(game).children["2"].children["1"], + games.root_node(game).children["1"].children["2"]], seed_node ) @@ -914,7 +919,7 @@ def test_append_infoset_node_list_is_empty(): """ 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"] + seed_node = games.root_node(game).children["1"].children["1"] game.append_move(seed_node, "Player 3", ["B", "F"]) with pytest.raises(ValueError): game.append_infoset([], seed_node) @@ -923,8 +928,8 @@ def test_append_infoset_node_list_is_empty(): def test_append_event_creates_single_event_list_of_nodes(): """Test that appending a list of 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"] + node1 = games.root_node(game).children["2"].children["1"] + node2 = games.root_node(game).children["1"].children["1"] game.append_event([node1, node2], ["a", "b"], [gbt.Rational(1, 2)] * 2) assert node1.event == node2.event assert node1.event @@ -933,7 +938,7 @@ def test_append_event_creates_single_event_list_of_nodes(): 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"] + node = games.root_node(game).children["1"].children["1"] game.append_event(node, ["a", "b"], [gbt.Rational(1, 4), gbt.Rational(3, 4)]) assert list(node.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] @@ -941,7 +946,7 @@ def test_append_event_sets_distribution(): 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"] + terminal = games.root_node(game).children["U1"].children["U2"].children["U3"] with pytest.raises(gbt.UndefinedOperationError): game.append_event(terminal, [], []) @@ -951,13 +956,13 @@ def test_append_event_error_node_mismatch(): 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) + game1.append_event(games.root_node(game2), ["a", "b"], [gbt.Rational(1, 2)] * 2) 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"] + terminal = games.root_node(game).children["U1"].children["U2"].children["U3"] with pytest.raises(ValueError): game.append_event(terminal, ["a", ""], [gbt.Rational(1, 2)] * 2) @@ -965,7 +970,7 @@ def test_append_event_error_empty_label(): 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"] + terminal = games.root_node(game).children["U1"].children["U2"].children["U3"] with pytest.raises(ValueError): game.append_event(terminal, ["a", "a"], [gbt.Rational(1, 2)] * 2) @@ -975,9 +980,10 @@ def test_append_event_error_node_list_with_non_terminal_node(): non-terminal node. """ game = games.read_from_file("sample_extensive_game.efg") + root = games.root_node(game) with pytest.raises(gbt.UndefinedOperationError): game.append_event( - [game.root.children["2"], game.root.children["1"].children["2"]], + [root.children["2"], root.children["1"].children["2"]], ["a", "b"], [gbt.Rational(1, 2)] * 2 ) @@ -986,7 +992,7 @@ def test_append_event_error_node_list_with_non_terminal_node(): 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"] + node = games.root_node(game).children["1"].children["2"] with pytest.raises(ValueError): game.append_event([node, node], ["a", "b"], [gbt.Rational(1, 2)] * 2) @@ -1001,7 +1007,7 @@ def test_append_event_error_node_list_is_empty(): 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"] + terminal = games.root_node(game).children["U1"].children["U2"].children["U3"] with pytest.raises(ValueError): game.append_event(terminal, ["a", "b"], [gbt.Rational(1, 2), gbt.Rational(1, 3)]) @@ -1009,7 +1015,7 @@ def test_append_event_error_invalid_distribution(): 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"] + node = games.root_node(game).children["L"].children["R"] game.insert_event(node, ["Up", "Down"], [gbt.Rational(1, 2)] * 2) assert list(node.parent.actions) == ["Up", "Down"] assert node.parent.event @@ -1018,7 +1024,7 @@ def test_insert_event_actions_labeled(): 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 + node = games.root_node(game) game.insert_event(node, ["a", "b"], [gbt.Rational(1, 4), gbt.Rational(3, 4)]) assert list(node.parent.action_probs.values()) == [gbt.Rational(1, 4), gbt.Rational(3, 4)] @@ -1027,7 +1033,7 @@ 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, [], []) + game.insert_event(games.root_node(game), [], []) def test_insert_event_error_node_mismatch(): @@ -1035,28 +1041,30 @@ def test_insert_event_error_node_mismatch(): 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) + game1.insert_event(games.root_node(game2), ["a", "b"], [gbt.Rational(1, 2)] * 2) 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) + game.insert_event(games.root_node(game), ["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(games.root_node(game), ["a", "a"], [gbt.Rational(1, 2)] * 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( + games.root_node(game), ["a", "b"], [gbt.Rational(1, 2), gbt.Rational(1, 3)] + ) def _count_subtree_nodes(start_node: gbt.Node, count_terminal: bool) -> int: @@ -1077,38 +1085,38 @@ def _count_subtree_nodes(start_node: gbt.Node, count_terminal: bool) -> int: def test_len_matches_expected_node_count(): - """Verify `len(game.nodes)` matches expected node count + """Verify `len(games.all_nodes(game))` matches expected node count """ game = gbt.catalog.load("journals/ijgt/selten1975/fig1") expected_node_count = 9 - direct_len = len(game.nodes) + direct_len = len(games.all_nodes(game)) assert direct_len == expected_node_count - assert direct_len == _count_subtree_nodes(game.root, True) + assert direct_len == _count_subtree_nodes(games.root_node(game), True) def test_len_after_delete_tree(): - """Verify `len(game.nodes)` is correct after `delete_tree`. + """Verify `len(games.all_nodes(game))` is correct after `delete_tree`. """ game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = len(games.all_nodes(game)) - root_of_the_deleted_subtree = game.root.children["R"].children["L"] + root_of_the_deleted_subtree = games.root_node(game).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) - assert len(game.nodes) == initial_number_of_nodes - number_of_deleted_nodes + assert len(games.all_nodes(game)) == initial_number_of_nodes - number_of_deleted_nodes def test_len_after_delete_parent(): - """Verify `len(game.nodes)` is correct after `delete_parent`. + """Verify `len(games.all_nodes(game))` is correct after `delete_parent`. """ game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = len(games.all_nodes(game)) - node_parent_to_delete = game.root.children["L"].children["L"] + node_parent_to_delete = games.root_node(game).children["L"].children["L"] number_of_node_ancestors = _count_subtree_nodes(node_parent_to_delete, True) number_of_parent_ancestors = _count_subtree_nodes(node_parent_to_delete.parent, True) @@ -1116,113 +1124,114 @@ def test_len_after_delete_parent(): game.delete_parent(node_parent_to_delete) - assert len(game.nodes) == initial_number_of_nodes - diff + assert len(games.all_nodes(game)) == initial_number_of_nodes - diff def test_len_after_append_move(): - """Verify `len(game.nodes)` is correct after `append_move`.""" + """Verify `len(games.all_nodes(game))` is correct after `append_move`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = len(games.all_nodes(game)) - terminal_node = game.root.children["R"].children["L"].children["L"] # the [1,1,0] terminal + # the [1,1,0] terminal + terminal_node = games.root_node(game).children["R"].children["L"].children["L"] player = "Player 1" actions_to_add = ["T", "M", "B"] game.append_move(terminal_node, player, actions_to_add) - assert len(game.nodes) == initial_number_of_nodes + len(actions_to_add) + assert len(games.all_nodes(game)) == initial_number_of_nodes + len(actions_to_add) def test_len_after_append_infoset(): - """Verify `len(game.nodes)` is correct after `append_infoset`. + """Verify `len(games.all_nodes(game))` is correct after `append_infoset`. """ game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = len(games.all_nodes(game)) - member_node = game.root.children["L"] + member_node = games.root_node(game).children["L"] infoset_to_modify = member_node.infoset number_of_infoset_actions = len(infoset_to_modify.actions) - terminal_node_to_add = game.root.children["L"].children["L"].children["l"] + terminal_node_to_add = games.root_node(game).children["L"].children["L"].children["l"] game.append_infoset(terminal_node_to_add, member_node) - assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions + assert len(games.all_nodes(game)) == initial_number_of_nodes + number_of_infoset_actions def test_len_after_set_move_actions_add(): - """Verify `len(game.nodes)` is correct after `set_move_actions` creates an action.""" + """Verify the node count is correct after `set_move_actions` creates an action.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) - infoset_to_modify = game.root.children["L"].infoset # Player 2's infoset + initial_number_of_nodes = len(games.all_nodes(game)) + infoset_to_modify = games.root_node(game).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"]) - assert len(game.nodes) == initial_number_of_nodes + num_nodes_in_infoset + game.set_move_actions(games.root_node(game).children["L"], labels + ["new"]) + assert len(games.all_nodes(game)) == initial_number_of_nodes + num_nodes_in_infoset def test_len_after_set_move_actions_drop(): - """Verify `len(game.nodes)` is correct after `set_move_actions` deletes an action.""" + """Verify the node count is correct after `set_move_actions` deletes an action.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = len(games.all_nodes(game)) action_to_drop = "L" nodes_to_delete = sum( _count_subtree_nodes(member.children[action_to_drop], True) - for member in game.root.infoset.members + for member in games.root_node(game).infoset.members ) - remaining = [a for a in game.root.infoset.actions if a != "L"] - game.set_move_actions(game.root, remaining, drop=True) - assert len(game.nodes) == initial_number_of_nodes - nodes_to_delete + remaining = [a for a in games.root_node(game).infoset.actions if a != "L"] + game.set_move_actions(games.root_node(game), remaining, drop=True) + assert len(games.all_nodes(game)) == initial_number_of_nodes - nodes_to_delete def test_len_after_insert_move(): - """Verify `len(game.nodes)` is correct after `insert_move`.""" + """Verify `len(games.all_nodes(game))` is correct after `insert_move`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = len(games.all_nodes(game)) - node_to_insert_above = game.root.children["L"].children["R"] # the [1, 0] node + node_to_insert_above = games.root_node(game).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) - assert len(game.nodes) == initial_number_of_nodes + len(actions_to_add) + assert len(games.all_nodes(game)) == initial_number_of_nodes + len(actions_to_add) 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"] + node = games.root_node(game).children["L"].children["R"] game.insert_move(node, "Player 2", ["Up", "Down"]) assert list(node.parent.infoset.actions) == ["Up", "Down"] def test_len_after_insert_infoset(): - """Verify `len(game.nodes)` is correct after `insert_infoset`. + """Verify `len(games.all_nodes(game))` is correct after `insert_infoset`. """ game = gbt.catalog.load("journals/ijgt/selten1975/fig1") - initial_number_of_nodes = len(game.nodes) + initial_number_of_nodes = len(games.all_nodes(game)) - infoset_to_modify = game.root.children["L"].infoset - node_to_insert_above = game.root.children["L"].children["R"] + infoset_to_modify = games.root_node(game).children["L"].infoset + node_to_insert_above = games.root_node(game).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(node_to_insert_above, games.root_node(game).children["L"]) - assert len(game.nodes) == initial_number_of_nodes + number_of_infoset_actions + assert len(games.all_nodes(game)) == initial_number_of_nodes + number_of_infoset_actions def test_len_after_copy_tree(): - """Verify `len(game.nodes)` is correct after `copy_tree`. + """Verify `len(games.all_nodes(game))` is correct 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"] + initial_number_of_nodes = len(games.all_nodes(game)) + src_node = games.root_node(game).children["R"].children["L"] + dest_node = games.root_node(game).children["R"].children["R"] number_of_src_ancestors = _count_subtree_nodes(src_node, True) game.copy_tree(src_node, dest_node) - assert len(game.nodes) == initial_number_of_nodes + number_of_src_ancestors - 1 + assert len(games.all_nodes(game)) == initial_number_of_nodes + number_of_src_ancestors - 1 def test_node_plays(): @@ -1230,12 +1239,12 @@ def test_node_plays(): """ game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - test_node = game.root.children["L"] + test_node = games.root_node(game).children["L"] expected_set_of_plays = { - game.root.children["L"].children["R"], - game.root.children["L"].children["L"].children["r"], - game.root.children["L"].children["L"].children["l"], + games.root_node(game).children["L"].children["R"], + games.root_node(game).children["L"].children["L"].children["r"], + games.root_node(game).children["L"].children["L"].children["l"], } assert set(test_node.plays) == expected_set_of_plays @@ -1248,20 +1257,21 @@ def test_node_children_action_label(): on both sides would make the assertion circular. """ game = games.read_from_file("stripped_down_poker.efg") - root_children = list(game.root.children) - assert game.root.children["King"] == root_children[0] - assert game.root.children["Queen"].children["Fold"] == list(root_children[1].children)[1] + root = games.root_node(game) + root_children = list(root.children) + assert root.children["King"] == root_children[0] + assert root.children["Queen"].children["Fold"] == list(root_children[1].children)[1] def test_node_children_empty_label(): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(ValueError, match="empty or all whitespace"): - _ = game.root.children[" "] + _ = games.root_node(game).children[" "] def test_node_children_terminal_node(): game = games.read_from_file("stripped_down_poker.efg") - terminal = next(n for n in game.nodes if n.is_terminal) + terminal = next(n for n in games.all_nodes(game) if n.is_terminal) with pytest.raises(KeyError, match="No action with label"): _ = terminal.children["Bet"] @@ -1269,71 +1279,48 @@ def test_node_children_terminal_node(): def test_node_children_nonexistent_action(): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(KeyError, match="No action with label 'Jack'"): - _ = game.root.children["Jack"] + _ = games.root_node(game).children["Jack"] def test_node_children_rejects_int(): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(TypeError, match="16.7.0"): - _ = game.root.children[0] + _ = games.root_node(game).children[0] @pytest.mark.parametrize("label", games.VALID_LABELS) def test_node_label_valid(label): game = games.read_from_file("basic_extensive_game.efg") - game.root.label = label - assert game.root.label == label + games.root_node(game).label = label + assert games.root_node(game).label == label def test_node_label_duplicate_raises_valueerror(): game = games.read_from_file("basic_extensive_game.efg") - game.root.label = "shared" + games.root_node(game).label = "shared" with pytest.raises(ValueError): - game.root.children["U1"].label = "shared" + games.root_node(game).children["U1"].label = "shared" def test_node_label_empty_is_allowed(): """Node labels may be empty (unlike outcomes/players); multiple empties coexist.""" game = games.read_from_file("basic_extensive_game.efg") - game.root.label = "" - game.root.children["U1"].label = "" - assert game.root.label == "" - assert game.root.children["U1"].label == "" + games.root_node(game).label = "" + games.root_node(game).children["U1"].label = "" + assert games.root_node(game).label == "" + assert games.root_node(game).children["U1"].label == "" @pytest.mark.parametrize("label", games.INVALID_LABELS) def test_node_label_invalid_raises_valueerror(label): game = games.read_from_file("basic_extensive_game.efg") with pytest.raises(ValueError): - game.root.label = label + games.root_node(game).label = label @pytest.mark.parametrize("label", games.UNICODE_LABELS) def test_node_label_unicode_accepted(label): """Non-ASCII UTF-8 labels are accepted as of #862 (17.0).""" game = games.read_from_file("basic_extensive_game.efg") - game.root.label = label - assert game.root.label == label - - -@pytest.mark.parametrize( - "game_obj", - [ - pytest.param(games.read_from_file("basic_extensive_game.efg")), - pytest.param(games.read_from_file("binary_3_levels_generic_payoffs.efg")), - pytest.param(games.read_from_file("cent3.efg")), - pytest.param(gbt.catalog.load("journals/ijgt/selten1975/fig1")), - pytest.param(gbt.catalog.load("journals/ijgt/selten1975/fig2")), - pytest.param(games.read_from_file("stripped_down_poker.efg")), - pytest.param(gbt.Game.new_tree()), - ], -) -def test_nodes_iteration_order(game_obj: gbt.Game): - """Verify that the C++ `game.nodes` iterator produces the DFS traversal. - """ - def dfs(node: gbt.Node) -> typing.Iterator[gbt.Node]: - yield node - for child in node.children: - yield from dfs(child) - - assert all(a == b for a, b in itertools.zip_longest(game_obj.nodes, dfs(game_obj.root))) + games.root_node(game).label = label + assert games.root_node(game).label == label diff --git a/tests/test_outcomes.py b/tests/test_outcomes.py index b34dc1ac9..a1bfcb052 100644 --- a/tests/test_outcomes.py +++ b/tests/test_outcomes.py @@ -7,8 +7,8 @@ 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"]) - up, middle, down = game.root.children + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children outcome = game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") assert up.outcome == outcome assert middle.outcome == outcome @@ -17,6 +17,46 @@ 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(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).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(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).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_history_tuple(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children + outcome = game.make_outcome(("U",), {"Alice": 1, "Bob": -1}, "shared") + assert up.outcome == outcome + assert not middle.outcome + assert not down.outcome + + +def test_make_outcome_accepts_iterable_of_history_tuples(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children + outcome = game.make_outcome([("U",), ("M",)], {"Alice": 1, "Bob": -1}, "shared") + assert up.outcome == outcome + assert middle.outcome == outcome + assert not down.outcome + + def test_make_outcome_attaches_at_contingencies(): game = gbt.Game.new_table([2, 2]) outcome = game.make_outcome( @@ -30,8 +70,8 @@ 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.append_move(games.root_node(game), "Alice", ["U", "D"]) + up, down = games.root_node(game).children game.make_outcome(up, {"Alice": 1}, "w") game.make_outcome([up, down], {"Alice": 2}, "w") assert [(o.label, o["Alice"]) for o in game.outcomes] == [("w", 2)] @@ -39,8 +79,8 @@ def test_make_outcome_absorbs_fully_covered_outcome_and_reuses_label(): 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.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children game.make_outcome([up, middle], {"Alice": 1}, "w") with pytest.raises(ValueError): game.make_outcome(down, {"Alice": 2}, "w") @@ -50,8 +90,8 @@ def test_make_outcome_label_of_partially_covered_outcome_refused(): @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.append_move(games.root_node(game), "A", ["win", "lose"]) + win_node, lose_node = games.root_node(game).children game.make_outcome(win_node, {"A": 1, "B": 2}, "win") with pytest.raises(ValueError): game.make_outcome(lose_node, {"A": 3, "B": 4}, bad_label) @@ -60,9 +100,9 @@ def test_make_outcome_bad_label_raises_and_leaves_game_unchanged(bad_label: str) 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(games.root_node(game), "Alice", ["U", "D"]) with pytest.raises(ValueError): - game.make_outcome(next(iter(game.root.children)), {"Alice": 1}, "w") + game.make_outcome(next(iter(games.root_node(game).children)), {"Alice": 1}, "w") class _RepeatedEntryPayoffs: @@ -82,16 +122,16 @@ 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(games.root_node(game), "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(next(iter(games.root_node(game).children)), payoffs, "w") def test_make_outcome_null_resets_given_nodes_to_null(): game = gbt.Game.new_tree(["Alice", "Bob"]) - game.append_move(game.root, "Alice", ["U", "M", "D"]) - up, middle, down = game.root.children + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") game.make_outcome_null(up) assert not up.outcome @@ -99,6 +139,28 @@ def test_make_outcome_null_resets_given_nodes_to_null(): assert not down.outcome +def test_make_outcome_null_accepts_selector(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children + game.make_outcome([up, middle], {"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_accepts_history_tuple(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children + game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") + game.make_outcome_null(("U",)) + assert not up.outcome + assert middle.outcome + assert not down.outcome + + def test_make_outcome_null_resets_given_contingencies_to_null(): game = gbt.Game.new_table([2, 2]) game.make_outcome( @@ -121,8 +183,8 @@ 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.append_move(games.root_node(game), "Alice", ["U", "M", "D"]) + up, middle, down = games.root_node(game).children game.make_outcome([up, middle], {"Alice": 1}, "shared") outcome_count = len(game.outcomes) game.make_outcome_null(up) @@ -132,8 +194,8 @@ def test_make_outcome_null_keeps_partially_referenced_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(games.root_node(game), "Alice", ["U", "D"]) + up, _ = games.root_node(game).children outcome_count = len(game.outcomes) game.make_outcome_null(up) assert outcome_count == len(game.outcomes) @@ -209,8 +271,8 @@ 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.append_move(games.root_node(game), "A", ["win", "lose"]) + win_node, lose_node = games.root_node(game).children game.make_outcome(win_node, {"A": 1, "B": 2}, "win") outcome = game.make_outcome(lose_node, {"A": 0, "B": 0}, "lose") with pytest.raises(ValueError): diff --git a/tests/test_players.py b/tests/test_players.py index 76708e2fd..e362bf53f 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(games.root_node(game), {"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(games.root_node(game), {"Alice": -1, "Bob": -1}, "outcome") assert game.get_max_payoff("Alice") == 1 assert game.get_max_payoff("Bob") == 1 diff --git a/tests/test_qre.py b/tests/test_qre.py index fd5770edc..bcfab0800 100644 --- a/tests/test_qre.py +++ b/tests/test_qre.py @@ -15,7 +15,7 @@ def _asymmetric_poker_behavior_data() -> gbt.MixedBehaviorProfile: for player in game.players: for infoset in games.player_infosets(game, player): node = next(iter(infoset.members)) - data[node] = {a: float(i + 2) for i, a in enumerate(infoset.actions)} + data[node.history] = {a: float(i + 2) for i, a in enumerate(infoset.actions)} return data @@ -75,6 +75,6 @@ def test_logit_estimate_behavior_completes(use_empirical: bool, local_max: bool) for player in data.game.players: for infoset in games.player_infosets(data.game, player): node = next(iter(infoset.members)) - probs = dict(result.profile[node]) + probs = dict(result.profile[node.history]) assert probs.keys() == set(infoset.actions) assert sum(probs.values()) == pytest.approx(1.0) diff --git a/tests/test_strategic.py b/tests/test_strategic.py index cffb85497..da937893f 100644 --- a/tests/test_strategic.py +++ b/tests/test_strategic.py @@ -12,18 +12,6 @@ def test_strategic_game_get_infosets(): _ = game.get_infosets(player) -def test_strategic_game_root(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.root - - -def test_strategic_game_nodes(): - game = gbt.Game.new_table([2, 2]) - with pytest.raises(gbt.UndefinedOperationError): - _ = game.nodes - - def test_game_behav_profile_error(): game = gbt.Game.new_table([2, 2]) with pytest.raises(gbt.UndefinedOperationError):