diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index cd7da0de3c..eb679d381e 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -118,7 +118,6 @@ Information about the game Game.get_infosets Game.get_events Game.get_strategies - Game.get_sequences Game.contingencies Game.get_outcome Game.get_outcomes @@ -136,13 +135,6 @@ Information about the game Node.action_probs Node.player -.. autosummary:: - - :toctree: api/ - - Sequence.player - Sequence.parent - Sequence.actions Player behavior ............... diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index cc890eec5a..5941ae1f40 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -370,9 +370,7 @@ "cell_type": "markdown", "id": "9c1f6a2e", "metadata": {}, - "source": [ - "`Game.get_infosets` returns each information set's canonical member as a `History` -- a plain tuple of action labels from the root. Indexing the profile directly by that tuple requires turning it into a `Selector` first, since `MixedBehaviorProfile.__getitem__` accepts only a player label or a `Selector`:" - ] + "source": "`Game.get_infosets` returns each information set's canonical member as a `History`. Indexing the profile directly by it requires turning it into a `Selector` first, since `MixedBehaviorProfile.__getitem__` accepts only a player label or a `Selector`; `History.actions` projects it down to the plain tuple of action labels `H.path` expects:" }, { "cell_type": "code", @@ -380,14 +378,7 @@ "id": "83bbd3e5", "metadata": {}, "outputs": [], - "source": [ - "for number, node in enumerate(g.get_infosets(\"Alice\"), start=1):\n", - " for action in g.get_actions(gbt.H.path(*node)):\n", - " print(\n", - " f\"At information set {number}, \"\n", - " f\"Alice plays {action} with probability: {eqm[gbt.H.path(*node)][action]}\"\n", - " )" - ] + "source": "for number, node in enumerate(g.get_infosets(\"Alice\"), start=1):\n for action in g.get_actions(gbt.H.path(*node.actions)):\n print(\n f\"At information set {number}, \"\n f\"Alice plays {action} with probability: {eqm[gbt.H.path(*node.actions)][action]}\"\n )" }, { "cell_type": "markdown", @@ -424,10 +415,7 @@ "id": "2966e700", "metadata": {}, "outputs": [], - "source": [ - "(bob_node,) = g.get_infosets(\"Bob\")\n", - "eqm[gbt.H.path(*bob_node)][\"Call\"]" - ] + "source": "(bob_node,) = g.get_infosets(\"Bob\")\neqm[gbt.H.path(*bob_node.actions)][\"Call\"]" }, { "cell_type": "markdown", @@ -445,25 +433,13 @@ "id": "a7d3816d", "metadata": {}, "outputs": [], - "source": [ - "bob_action_values = eqm.action_values[gbt.H.path(*bob_node)]\n", - "for action in g.get_actions(gbt.H.path(*bob_node)):\n", - " print(\n", - " f\"When Bob plays {action} his expected payoff is {bob_action_values[action]}\"\n", - " )" - ] + "source": "bob_action_values = eqm.action_values[gbt.H.path(*bob_node.actions)]\nfor action in g.get_actions(gbt.H.path(*bob_node.actions)):\n print(\n f\"When Bob plays {action} his expected payoff is {bob_action_values[action]}\"\n )" }, { "cell_type": "markdown", "id": "6491fdda", "metadata": {}, - "source": [ - "Bob's indifference between his actions arises because of his beliefs given Alice's strategy.\n", - "\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 `(\"King\", \"Bet\")` and `(\"Queen\", \"Bet\")` (as Histories):" - ] + "source": "Bob's indifference between his actions arises because of his beliefs given Alice's strategy.\n\n`MixedBehaviorProfile.beliefs` returns the probability of reaching each node, conditional on its information set being reached.\n\nRecall that the two nodes in Bob's only information set are identified by the action-label sequences `(\"King\", \"Bet\")` and `(\"Queen\", \"Bet\")`:" }, { "cell_type": "code", @@ -471,13 +447,7 @@ "id": "4a54b20c", "metadata": {}, "outputs": [], - "source": [ - "for node in g.get_members(gbt.H.path(*bob_node)):\n", - " print(\n", - " f\"Bob's belief in reaching the {node[-2]} -> \"\n", - " f\"{node[-1]} node is: {eqm.beliefs[node]}\"\n", - " )" - ] + "source": "for node in g.get_members(gbt.H.path(*bob_node.actions)):\n print(\n f\"Bob's belief in reaching the {node[-2].action} -> \"\n f\"{node[-1].action} node is: {eqm.beliefs[node]}\"\n )" }, { "cell_type": "markdown", @@ -495,9 +465,7 @@ "id": "b250c1cd", "metadata": {}, "outputs": [], - "source": [ - "eqm.infoset_probs[gbt.H.path(*bob_node)]" - ] + "source": "eqm.infoset_probs[gbt.H.path(*bob_node.actions)]" }, { "cell_type": "markdown", @@ -513,15 +481,7 @@ "id": "6f01846b", "metadata": {}, "outputs": [], - "source": [ - "bob_node_values = eqm.node_values[\"Bob\"]\n", - "for node in g.get_members(gbt.H.path(*bob_node)):\n", - " print(\n", - " f\"The probability that the node {node[-2]} -> \"\n", - " f\"{node[-1]} is reached is: {eqm.realiz_probs[node]}. \",\n", - " f\"Bob's expected payoff conditional on reaching this node is {bob_node_values[node]}\"\n", - " )" - ] + "source": "bob_node_values = eqm.node_values[\"Bob\"]\nfor node in g.get_members(gbt.H.path(*bob_node.actions)):\n print(\n f\"The probability that the node {node[-2].action} -> \"\n f\"{node[-1].action} is reached is: {eqm.realiz_probs[node]}. \",\n f\"Bob's expected payoff conditional on reaching this node is {bob_node_values[node]}\"\n )" }, { "cell_type": "markdown", @@ -653,23 +613,7 @@ "id": "d18a91f0", "metadata": {}, "outputs": [], - "source": [ - "for player in g.players:\n", - " print(\n", - " f\"{player}'s expected payoffs:\"\n", - " )\n", - " gnm_action_values = gnm_eqm.as_behavior().action_values\n", - " lcp_action_values = eqm.action_values\n", - " for number, node in enumerate(g.get_infosets(player), start=1):\n", - " for action in g.get_actions(gbt.H.path(*node)):\n", - " print(\n", - " f\"At information set {number}, \"\n", - " f\"when playing {action} - \"\n", - " f\"gnm: {gnm_action_values[gbt.H.path(*node)][action]:.4f}\"\n", - " f\", lcp: {str(lcp_action_values[gbt.H.path(*node)][action])}\"\n", - " )\n", - " print()" - ] + "source": "for player in g.players:\n print(\n f\"{player}'s expected payoffs:\"\n )\n gnm_action_values = gnm_eqm.as_behavior().action_values\n lcp_action_values = eqm.action_values\n for number, node in enumerate(g.get_infosets(player), start=1):\n for action in g.get_actions(gbt.H.path(*node.actions)):\n print(\n f\"At information set {number}, \"\n f\"when playing {action} - \"\n f\"gnm: {gnm_action_values[gbt.H.path(*node.actions)][action]:.4f}\"\n f\", lcp: {str(lcp_action_values[gbt.H.path(*node.actions)][action])}\"\n )\n print()" }, { "cell_type": "markdown", diff --git a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb index 1303f0dad6..eb34abfe4b 100644 --- a/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb +++ b/doc/tutorials/advanced_tutorials/h_selector_prototype.ipynb @@ -81,7 +81,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "25387762", "metadata": { "execution": { @@ -91,38 +91,8 @@ "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", - ")" - ] + "outputs": [], + "source": "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\", \"Player 3\"], title=\"Selten's Horse\")\n\ng.append_move(H.path(), \"Player 1\", [\"R\", \"L\"])\ng.append_move(H.path(\"L\"), \"Player 3\", [\"R\", \"L\"])\ng.append_move(H.path(\"R\"), \"Player 2\", [\"R\", \"L\"])\ng.append_infoset(H.path(\"R\", \"L\"), H.path(\"L\"))\n\ng.make_outcome(H.path(\"R\", \"R\"), {\"Player 1\": 1, \"Player 2\": 1, \"Player 3\": 1}, \"RR\")\ng.make_outcome(H.path(\"R\", \"L\", \"R\"), {\"Player 1\": 4, \"Player 2\": 4, \"Player 3\": 0}, \"RLR\")\ng.make_outcome(H.path(\"R\", \"L\", \"L\"), {\"Player 1\": 0, \"Player 2\": 0, \"Player 3\": 1}, \"RLL\")\ng.make_outcome(H.path(\"L\", \"R\"), {\"Player 1\": 3, \"Player 2\": 2, \"Player 3\": 2}, \"LR\")\ng.make_outcome(H.path(\"L\", \"L\"), {\"Player 1\": 0, \"Player 2\": 0, \"Player 3\": 0}, \"LL\")\n\ndraw(g)\nprint(\"is_perfect_recall:\", g.is_perfect_recall)\nprint(\n \"Player 3's infoset members (shown as their action-label paths -- History elements\"\n \" are HistoryTransitions, so .actions projects down to plain labels):\",\n sorted(\n h.actions\n for h in g._get_histories(H.path(\"L\")) + g._get_histories(H.path(\"R\", \"L\"))\n ),\n)" }, { "cell_type": "markdown", @@ -162,28 +132,7 @@ } }, "outputs": [], - "source": [ - "CARD_VALUE = {\"J\": 0, \"Q\": 1, \"K\": 2}\n", - "cards = list(CARD_VALUE)\n", - "\n", - "g = gbt.Game.new_tree(players=[\"Alice\", \"Bob\"], title=\"Kuhn poker\")\n", - "g.append_event(H.path(), dict.fromkeys(cards, gbt.Rational(1, 3)))\n", - "for c in cards:\n", - " remaining = [x for x in cards if x != c]\n", - " g.append_event(H.path(c), dict.fromkeys(remaining, gbt.Rational(1, 2)))\n", - "\n", - "alice_partition = H.path(...).by(lambda h: h[0]).with_recall(\"Alice\")\n", - "g.append_move(alice_partition.plays, \"Alice\", [\"Check\", \"Bet\"])\n", - "\n", - "bob_partition = H.path(..., ...).by(lambda h: h[1])\n", - "g.append_move(bob_partition.plays.after(\"Check\"), \"Bob\", [\"Check\", \"Bet\"])\n", - "\n", - "g.append_move(alice_partition.plays.after(\"Check\", \"Bet\"), \"Alice\", [\"Fold\", \"Call\"])\n", - "g.append_move(bob_partition.plays.after(\"Bet\"), \"Bob\", [\"Fold\", \"Call\"])\n", - "\n", - "draw(g)\n", - "print(\"is_perfect_recall:\", g.is_perfect_recall)" - ] + "source": "CARD_VALUE = {\"J\": 0, \"Q\": 1, \"K\": 2}\ncards = list(CARD_VALUE)\n\ng = gbt.Game.new_tree(players=[\"Alice\", \"Bob\"], title=\"Kuhn poker\")\ng.append_event(H.path(), dict.fromkeys(cards, gbt.Rational(1, 3)))\nfor c in cards:\n remaining = [x for x in cards if x != c]\n g.append_event(H.path(c), dict.fromkeys(remaining, gbt.Rational(1, 2)))\n\nalice_partition = H.path(...).by(lambda h: h[0].action).with_recall(\"Alice\")\ng.append_move(alice_partition.plays, \"Alice\", [\"Check\", \"Bet\"])\n\nbob_partition = H.path(..., ...).by(lambda h: h[1].action)\ng.append_move(bob_partition.plays.after(\"Check\"), \"Bob\", [\"Check\", \"Bet\"])\n\ng.append_move(alice_partition.plays.after(\"Check\", \"Bet\"), \"Alice\", [\"Fold\", \"Call\"])\ng.append_move(bob_partition.plays.after(\"Bet\"), \"Bob\", [\"Fold\", \"Call\"])\n\ndraw(g)\nprint(\"is_perfect_recall:\", g.is_perfect_recall)" }, { "cell_type": "code", @@ -198,7 +147,7 @@ } }, "outputs": [], - "source": "def winner(h):\n match h[2:]:\n case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n return \"Alice\" if CARD_VALUE[h[0]] > CARD_VALUE[h[1]] else \"Bob\"\n case (\"Check\", \"Bet\", \"Fold\"):\n return \"Bob\"\n case (\"Bet\", \"Fold\"):\n return \"Alice\"\n\ndef 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\nfor (win, amount), group in g._get_groups(H.plays.by(lambda h: (winner(h), pot_size(h)))).items():\n lose = \"Bob\" if win == \"Alice\" else \"Alice\"\n g.make_outcome(\n selector_for_histories(group), {win: amount, lose: -amount}, f\"{win} wins {amount}\"\n )\n\nprint(\"Total outcomes created:\", len(g.get_outcomes()))" + "source": "def winner(h):\n match h.actions[2:]:\n case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n return \"Alice\" if CARD_VALUE[h.actions[0]] > CARD_VALUE[h.actions[1]] else \"Bob\"\n case (\"Check\", \"Bet\", \"Fold\"):\n return \"Bob\"\n case (\"Bet\", \"Fold\"):\n return \"Alice\"\n\ndef pot_size(h):\n match h.actions[2:]:\n case (\"Check\", \"Check\") | (\"Check\", \"Bet\", \"Fold\") | (\"Bet\", \"Fold\"):\n return 1\n case (\"Check\", \"Bet\", \"Call\") | (\"Bet\", \"Call\"):\n return 2\n\nfor (win, amount), group in g._get_groups(H.plays.by(lambda h: (winner(h), pot_size(h)))).items():\n lose = \"Bob\" if win == \"Alice\" else \"Alice\"\n g.make_outcome(\n selector_for_histories(group), {win: amount, lose: -amount}, f\"{win} wins {amount}\"\n )\n\nprint(\"Total outcomes created:\", len(g.get_outcomes()))" }, { "cell_type": "markdown", @@ -231,34 +180,7 @@ } }, "outputs": [], - "source": [ - "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"bayes2a\")\n", - "half = gbt.Rational(1, 2)\n", - "\n", - "g.append_event(H.path(), {\"1G\": half, \"1B\": half})\n", - "for t1 in [\"1G\", \"1B\"]:\n", - " g.append_event(H.path(t1), {\"2g\": half, \"2b\": half})\n", - "\n", - "# Round 1: each player's move depends only on their own type.\n", - "g.append_move(H.path(...).plays.by(lambda h: h[0]), \"Player 1\", [\"H\", \"L\"])\n", - "g.append_move(H.path(..., ...).plays.by(lambda h: h[1]), \"Player 2\", [\"h\", \"l\"])\n", - "\n", - "# Round 2: both round-1 actions are now public; each player also still knows their own type.\n", - "g.append_move(H.plays.by(lambda h: (h[0], h[2], h[3])), \"Player 1\", [\"H\", \"L\"])\n", - "g.append_move(H.plays.by(lambda h: (h[1], h[2], h[3])), \"Player 2\", [\"h\", \"l\"])\n", - "\n", - "PAYOFFS = {\n", - " (\"1G\", \"H\", \"h\"): (10, 2), (\"1G\", \"H\", \"l\"): (0, 10),\n", - " (\"1G\", \"L\", \"h\"): (2, 4), (\"1G\", \"L\", \"l\"): (4, 0),\n", - " (\"1B\", \"H\", \"h\"): (4, 2), (\"1B\", \"H\", \"l\"): (2, 10),\n", - " (\"1B\", \"L\", \"h\"): (0, 4), (\"1B\", \"L\", \"l\"): (10, 0),\n", - "}\n", - "for (p1, p2), group in g._get_groups(H.plays.by(lambda h: PAYOFFS[(h[0], h[4], h[5])])).items():\n", - " g.make_outcome(selector_for_histories(group), {\"Player 1\": p1, \"Player 2\": p2}, f\"({p1},{p2})\")\n", - "\n", - "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", - "print(\"terminal histories:\", len(g._get_histories(H.plays)))" - ] + "source": "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"bayes2a\")\nhalf = gbt.Rational(1, 2)\n\ng.append_event(H.path(), {\"1G\": half, \"1B\": half})\nfor t1 in [\"1G\", \"1B\"]:\n g.append_event(H.path(t1), {\"2g\": half, \"2b\": half})\n\n# Round 1: each player's move depends only on their own type.\ng.append_move(H.path(...).plays.by(lambda h: h[0].action), \"Player 1\", [\"H\", \"L\"])\ng.append_move(H.path(..., ...).plays.by(lambda h: h[1].action), \"Player 2\", [\"h\", \"l\"])\n\n# Round 2: both round-1 actions are now public; each player also still knows their own type.\ng.append_move(\n H.plays.by(lambda h: (h[0].action, h[2].action, h[3].action)), \"Player 1\", [\"H\", \"L\"]\n)\ng.append_move(\n H.plays.by(lambda h: (h[1].action, h[2].action, h[3].action)), \"Player 2\", [\"h\", \"l\"]\n)\n\nPAYOFFS = {\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}\nfor (p1, p2), group in g._get_groups(\n H.plays.by(lambda h: PAYOFFS[(h.actions[0], h.actions[4], h.actions[5])])\n).items():\n g.make_outcome(selector_for_histories(group), {\"Player 1\": p1, \"Player 2\": p2}, f\"({p1},{p2})\")\n\nprint(\"is_perfect_recall:\", g.is_perfect_recall)\nprint(\"terminal histories:\", len(g._get_histories(H.plays)))" }, { "cell_type": "markdown", @@ -293,26 +215,7 @@ } }, "outputs": [], - "source": [ - "g = gbt.Game.new_tree(players=[\"Alice\", \"Bob\"], title=\"Forgetting a past observation\")\n", - "half = gbt.Rational(1, 2)\n", - "\n", - "g.append_event(H.path(), {\"H\": half, \"L\": half})\n", - "g.append_move(H.path(\"H\"), \"Alice\", [\"Up\", \"Down\"])\n", - "g.append_move(H.path(\"L\"), \"Alice\", [\"Up\", \"Down\"])\n", - "g.append_move(H.path(..., ...), \"Bob\", [\"x\", \"y\"])\n", - "\n", - "# Keyed by (Alice's own first action, Bob's action) only -- h[0], the signal, is dropped.\n", - "g.append_move(H.plays.by(lambda h: (h[1], h[2])), \"Alice\", [\"Fold\", \"Call\"])\n", - "\n", - "draw(g)\n", - "print(\"is_perfect_recall:\", g.is_perfect_recall)\n", - "# Depth 3, explicitly -- these are the same histories the construction above\n", - "# grouped by (h[1], h[2]) to create Alice's second decision.\n", - "groups = g._get_groups(H.path(..., ..., ...).by(lambda h: (h[1], h[2])))\n", - "for key, members in sorted(groups.items()):\n", - " print(f\" Alice's 2nd decision, key={key}: {sorted(members)}\")" - ] + "source": "g = gbt.Game.new_tree(players=[\"Alice\", \"Bob\"], title=\"Forgetting a past observation\")\nhalf = gbt.Rational(1, 2)\n\ng.append_event(H.path(), {\"H\": half, \"L\": half})\ng.append_move(H.path(\"H\"), \"Alice\", [\"Up\", \"Down\"])\ng.append_move(H.path(\"L\"), \"Alice\", [\"Up\", \"Down\"])\ng.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.\ng.append_move(H.plays.by(lambda h: (h[1].action, h[2].action)), \"Alice\", [\"Fold\", \"Call\"])\n\ndraw(g)\nprint(\"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.\ngroups = g._get_groups(H.path(..., ..., ...).by(lambda h: (h[1].action, h[2].action)))\nfor key, members in sorted(groups.items()):\n print(f\" Alice's 2nd decision, key={key}: {sorted(h.actions for h in members)}\")" }, { "cell_type": "markdown", @@ -345,31 +248,7 @@ } }, "outputs": [], - "source": [ - "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"Untimeable (Jakobsen et al. 2016)\")\n", - "\n", - "g.append_event(H.path(), dict.fromkeys([\"1\", \"2\"], gbt.Rational(1, 2)))\n", - "\n", - "g.append_move(H.path(\"1\"), \"Player 2\", [\"1\", \"2\"])\n", - "g.append_move(H.path(\"2\"), \"Player 1\", [\"1\", \"2\"])\n", - "\n", - "g.append_infoset(H.path(\"1\", ...), H.path(\"2\"))\n", - "g.append_infoset(H.path(\"2\", ...), H.path(\"1\"))\n", - "\n", - "def outcome_key(h):\n", - " p1_guess = h.last_action(\"Player 1\")\n", - " p2_guess = h.last_action(\"Player 2\")\n", - " return (p1_guess == h[0], p2_guess != h[0])\n", - "\n", - "for (p1_ok, p2_ok), group in g._get_groups(H.plays.by(outcome_key)).items():\n", - " g.make_outcome(\n", - " selector_for_histories(group), {\"Player 1\": int(p1_ok), \"Player 2\": int(p2_ok)},\n", - " f\"P1 {'correct' if p1_ok else 'wrong'}, P2 {'correct' if p2_ok else 'wrong'}\",\n", - " )\n", - "\n", - "draw(g)\n", - "print(\"is_perfect_recall:\", g.is_perfect_recall)" - ] + "source": "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"Untimeable (Jakobsen et al. 2016)\")\n\ng.append_event(H.path(), dict.fromkeys([\"1\", \"2\"], gbt.Rational(1, 2)))\n\ng.append_move(H.path(\"1\"), \"Player 2\", [\"1\", \"2\"])\ng.append_move(H.path(\"2\"), \"Player 1\", [\"1\", \"2\"])\n\ng.append_infoset(H.path(\"1\", ...), H.path(\"2\"))\ng.append_infoset(H.path(\"2\", ...), H.path(\"1\"))\n\ndef 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].action, p2_guess != h[0].action)\n\nfor (p1_ok, p2_ok), group in g._get_groups(H.plays.by(outcome_key)).items():\n g.make_outcome(\n selector_for_histories(group), {\"Player 1\": int(p1_ok), \"Player 2\": int(p2_ok)},\n f\"P1 {'correct' if p1_ok else 'wrong'}, P2 {'correct' if p2_ok else 'wrong'}\",\n )\n\ndraw(g)\nprint(\"is_perfect_recall:\", g.is_perfect_recall)" }, { "cell_type": "markdown", @@ -389,7 +268,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "26156c14", "metadata": { "execution": { @@ -399,35 +278,8 @@ "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", - ")" - ] + "outputs": [], + "source": "g = gbt.Game.new_tree(players=[\"Player 1\", \"Player 2\"], title=\"Absent-Minded Driver, extended\")\n\ng.append_move(H.path(), \"Player 1\", [\"S\", \"T\"])\ng.append_infoset(H.path(\"S\"), H.path())\ng.append_move(H.path(\"S\", \"T\"), \"Player 2\", [\"r\", \"l\"])\n\ng.make_outcome(H.path(\"S\", \"S\"), {\"Player 1\": 1, \"Player 2\": -1}, \"SS\")\ng.make_outcome(H.path(\"S\", \"T\", \"r\"), {\"Player 1\": 2, \"Player 2\": -2}, \"STr\")\ng.make_outcome(H.path(\"S\", \"T\", \"l\"), {\"Player 1\": 3, \"Player 2\": -3}, \"STl\")\ng.make_outcome(H.path(\"T\"), {\"Player 1\": 4, \"Player 2\": -4}, \"T\")\n\ndraw(g)\nprint(\"is_perfect_recall:\", g.is_perfect_recall)\nprint(\n \"Player 1's (first) infoset members:\",\n sorted(\n h.actions\n for h in g._get_histories(H.path()) + g._get_histories(H.path(\"S\"))\n ),\n)" } ], "metadata": { diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index a0a7be3567..6379facabb 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -154,10 +154,10 @@ class MixedAction: No longer a live view onto the profile: holds its own copy of the probabilities, and can no longer be assigned into. Set a distribution via ``MixedBehaviorProfile.__setitem__`` instead. `infoset` (an ``Infoset``) replaced - by `history` (the ``History`` -- a plain tuple of action labels -- of the node - that was resolved to identify the information set). + by `history` (the ``History`` of the node that was resolved to identify the + information set). """ - _history = cython.declare(tuple) + _history = cython.declare(object) _values = cython.declare(dict) def __init__(self, *args, **kwargs) -> None: @@ -165,14 +165,14 @@ class MixedAction: @staticmethod @cython.cfunc - def wrap(history: tuple, values: dict) -> MixedAction: + def wrap(history: History, values: dict) -> MixedAction: obj: MixedAction = MixedAction.__new__(MixedAction) obj._history = history obj._values = values return obj @property - def history(self) -> tuple: + def history(self) -> History: """The History of the node that was resolved to identify this information set.""" return self._history @@ -301,7 +301,7 @@ class MixedBehavior: def __len__(self) -> int: return len(self._values) - def __iter__(self) -> typing.Iterator[tuple[tuple, MixedAction], None, None]: + def __iter__(self) -> typing.Iterator[tuple[History, MixedAction], None, None]: """Iterate over the mixed actions specified by the mixed behavior. A ``MixedBehavior`` is a collection of ``MixedAction``\\ s, one per information @@ -320,7 +320,7 @@ class MixedBehavior: Yields ------ - history : tuple + history : History The History identifying an information set belonging to the player action : MixedAction The player's mixed action specified at the information set diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi index 38da83e1d4..3e9e1ab793 100644 --- a/src/pygambit/behavspt.pxi +++ b/src/pygambit/behavspt.pxi @@ -32,20 +32,19 @@ class ActionSupport(_LabelSet): the history that was resolved to reach it, accessible via `history`. .. versionchanged:: 17.0.0 - `infoset` (an ``Infoset``) replaced by `history` (the ``History`` -- a plain - tuple of action labels -- of the node that was resolved to identify the - information set). + `infoset` (an ``Infoset``) replaced by `history` (the ``History`` of the + node that was resolved to identify the information set). """ @staticmethod @cython.cfunc - def wrap(history: tuple, actions: tuple) -> ActionSupport: + def wrap(history: History, actions: tuple) -> ActionSupport: obj: ActionSupport = ActionSupport.__new__(ActionSupport) obj._owner = history obj._labels = actions return obj @property - def history(self) -> tuple: + def history(self) -> History: """The History of the node that was resolved to identify this information set.""" return self._owner diff --git a/src/pygambit/catalog.py b/src/pygambit/catalog.py index bfe8f40ff1..afdb0e3576 100644 --- a/src/pygambit/catalog.py +++ b/src/pygambit/catalog.py @@ -421,7 +421,7 @@ def check_filters(game: gbt.Game) -> bool: if not game.is_tree: return False n_game_actions = sum( - len(game.get_actions(gbt.H.path(*history))) + len(game.get_actions(gbt.H.path(*history.actions))) for player in game.players for history in game.get_infosets(player) ) diff --git a/src/pygambit/cli/common.py b/src/pygambit/cli/common.py index dfc16192dd..e667e56de2 100644 --- a/src/pygambit/cli/common.py +++ b/src/pygambit/cli/common.py @@ -32,6 +32,7 @@ import numpy as np import pygambit as gbt +from pygambit.gambit import _history_of _GAME_FORMATS = ( ("NFG", gbt.read_nfg), @@ -244,7 +245,7 @@ def render_support_csv( fields = [ "".join( "1" if action in action_support else "0" - for action in support.game.get_actions(gbt.H.path(*history)) + for action in support.game.get_actions(gbt.H.path(*history.actions)) ) for player in support.game.players for history, action_support in zip( @@ -299,18 +300,6 @@ def _render_strategy_detail(profile: gbt.MixedStrategyProfile, decimals: int) -> return "\n".join(lines) -def _history_of(node: gbt.Node) -> tuple: - """The plain-tuple History of `node`, walked via the private - `Node._parent`/`._prior_action`.""" - labels = [] - current = node - while current._parent() is not None: - labels.append(current._prior_action().label) - current = current._parent() - labels.reverse() - return tuple(labels) - - def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> str: lines = [] action_values = profile.action_values @@ -326,7 +315,7 @@ def _render_behavior_detail(profile: gbt.MixedBehaviorProfile, decimals: int) -> for infoset_number, (history, (_, mixed_action)) in enumerate( zip(profile.game.get_infosets(player), profile[player], strict=True), start=1 ): - selector = gbt.H.path(*history) + selector = gbt.H.path(*history.actions) values = action_values[selector] for action in profile.game.get_actions(selector): prob = mixed_action[action] @@ -400,7 +389,7 @@ def read_behavior_profiles_csv( the result via `~MixedBehaviorProfile.as_float`. """ count = sum( - len(game.get_actions(gbt.H.path(*history))) + len(game.get_actions(gbt.H.path(*history.actions))) for player in game.players for history in game.get_infosets(player) ) @@ -417,7 +406,7 @@ def read_behavior_profiles_csv( profile = game.mixed_behavior_profile(rational=True) for player in game.players: for history in game.get_infosets(player): - selector = gbt.H.path(*history) + selector = gbt.H.path(*history.actions) profile[selector] = {a: next(values) for a in game.get_actions(selector)} profiles.append(profile) return profiles diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 1c8c16f73f..bbee7d3154 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -47,7 +47,6 @@ cdef extern from "core/array.h": cdef extern from "games/game.h": cdef cppclass c_GameRep "GameRep" cdef cppclass c_GameStrategyRep "GameStrategyRep" - cdef cppclass c_GameSequenceRep "GameSequenceRep" cdef cppclass c_GameActionRep "GameActionRep" cdef cppclass c_GameInfosetRep "GameInfosetRep" cdef cppclass c_GamePlayerRep "GamePlayerRep" @@ -85,11 +84,6 @@ cdef extern from "games/game.h": bool operator !=(c_GameStrategy) except + c_GameStrategyRep *deref "get"() except +RuntimeError - cdef cppclass c_GameSequence "GameObjectPtr": - bool operator ==(c_GameSequence) except + - bool operator !=(c_GameSequence) except + - c_GameSequenceRep *deref "get"() except +RuntimeError - cdef cppclass c_GameSubgame "GameObjectPtr": bool operator ==(c_GameSubgame) except + bool operator !=(c_GameSubgame) except + @@ -104,11 +98,6 @@ cdef extern from "games/game.h": string GetLabel() except + c_GameAction GetAction(c_GameInfoset) except + - cdef cppclass c_GameSequenceRep "GameSequenceRep": - c_GamePlayer GetPlayer() except + - c_GameAction GetAction() except + - c_GameSequence GetParent() except + - cdef cppclass c_GameActionRep "GameActionRep": int GetNumber() except + c_GameInfoset GetInfoset() except + @@ -174,16 +163,6 @@ cdef extern from "games/game.h": iterator begin() except + iterator end() except + - cppclass Sequences: - cppclass iterator: - c_GameSequence operator *() - iterator operator++() - bint operator ==(iterator) - bint operator !=(iterator) - int size() except + - iterator begin() except + - iterator end() except + - c_Game GetGame() except + int GetNumber() except + int IsChance() except + @@ -192,8 +171,6 @@ cdef extern from "games/game.h": Strategies GetStrategies() except + - Sequences GetSequences() except + - Infosets GetInfosets() except + cdef cppclass c_GameOutcomeRep "GameOutcomeRep": diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index 7a61fc3836..7e0fd1c024 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -155,9 +155,9 @@ class NodeIndexedVector(_LabeledVector): per node. Unlike ``PlayerIndexedVector``/``StrategyIndexedVector``, which are keyed by a stable - label, this is keyed by the node's own History -- a plain tuple of action labels - from the root, unique to that node -- since the value can genuinely differ between - two nodes, even nodes belonging to the same information set. + label, this is keyed by the node's own History -- unique to that node -- since the + value can genuinely differ between two nodes, even nodes belonging to the same + information set. .. versionchanged:: 17.0.0 Keyed by a node's History rather than a ``Node`` object. diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 49586ea47b..bea1c63bde 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -327,7 +327,7 @@ class Game: for infoset in resolved_player.deref().GetInfosets() ] - def get_infosets(self, player: str) -> list[tuple]: + def get_infosets(self, player: str) -> list[History]: """Returns a snapshot of the information sets belonging to the personal player `player`: the decisions at which that player chooses an action. @@ -344,7 +344,7 @@ class Game: Returns ------- - list of tuple + list of History The History of one representative member per information set belonging to `player`. @@ -380,7 +380,7 @@ class Game: for event in self.game.deref().GetChance().deref().GetInfosets() ] - def get_events(self) -> list[tuple]: + def get_events(self) -> list[History]: """Returns a snapshot of the chance player's events: the points of exogenous randomness, each with a probability distribution over its actions. @@ -392,7 +392,7 @@ class Game: Returns ------- - list of tuple + list of History The History of one representative member per event. .. versionadded:: 17.0.0 @@ -439,35 +439,6 @@ class Game: s.deref().GetLabel().decode("utf-8") for s in resolved_player.deref().GetStrategies() ] - def get_sequences(self, player: str) -> list[Sequence]: - """Returns a snapshot of the sequences belonging to `player`. - - This is a materialized snapshot, not a live view: it reflects the game's - state at the moment of the call, and does not change if the game is - subsequently mutated. - - Parameters - ---------- - player : str - The label of the player whose sequences to return. - - Returns - ------- - list of Sequence - `player`'s sequences. - - .. versionadded:: 17.0.0 - - Raises - ------ - KeyError - If no player in the game has label `player`. - ValueError - If `player` is an empty string or all whitespace. - """ - resolved_player = self._resolve_player(player, "get_sequences") - return [Sequence.wrap(s) for s in resolved_player.deref().GetSequences()] - def get_min_payoff(self, player: str) -> Rational: """Returns the smallest payoff for `player` in any play of the game. @@ -601,17 +572,17 @@ class Game: current = [self._root()] return current - def _get_histories(self, selector: Selector) -> list[tuple]: + def _get_histories(self, selector: Selector) -> list[History]: """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. + materializing each result as a `History` -- carrying no reference to + this game. Internal: the History-materializing counterpart to `_get_nodes`, kept for use by `_get_groups` and tests. Not part of the public API yet. """ return [_history_of(node) for node in self._get_nodes(selector)] - def get_histories(self, selector: Selector) -> list[tuple]: + def get_histories(self, selector: Selector) -> list[History]: """Returns the Histories of the nodes that `selector` resolves to. Parameters @@ -621,9 +592,9 @@ class Game: Returns ------- - list of tuple - The Histories -- plain tuples of action labels from the root -- - of the matching nodes, in the order `selector` produces them. + list of History + The Histories of the matching nodes, in the order `selector` + produces them. .. versionadded:: 17.0.0 @@ -757,7 +728,7 @@ class Game: ) return result - def get_members(self, history: Selector) -> list[tuple]: + def get_members(self, history: Selector) -> list[History]: """Returns the Histories of the nodes which are members of the information set or event that the node identified by `history` currently belongs to. @@ -770,7 +741,7 @@ class Game: Returns ------- - list of tuple + list of History The Histories of the member nodes, or an empty list if the node is currently terminal (belongs to no information set or event). @@ -934,7 +905,7 @@ class Game: """ return rat_to_py(self.game.deref().GetMaxPayoff()) - def get_subgame_roots(self) -> list[tuple]: + def get_subgame_roots(self) -> list[History]: """Returns the Histories of the roots of the subgames of the game, in postorder (children before parents). @@ -955,7 +926,7 @@ class Game: for subgame in self.game.deref().GetSubgames() ] - def get_minimal_subgame(self, history: Selector) -> tuple: + def get_minimal_subgame(self, history: Selector) -> History: """Returns the History of the root of the smallest subgame containing the information set or event that the node identified by `history` belongs to. @@ -978,7 +949,7 @@ class Game: Returns ------- - tuple + History The History of the root of the smallest subgame containing the information set or event that `history` belongs to. @@ -1008,7 +979,7 @@ class Game: ) return _history_of(Node.wrap(subgame.deref().GetRoot())) - def get_strategy_unreachable(self) -> list[tuple]: + def get_strategy_unreachable(self) -> list[History]: """Returns the Histories of the nodes that are not reachable by any pure strategy profile. @@ -3511,7 +3482,7 @@ class TreeLayout: .. versionadded:: 17.0.0 """ - def __init__(self, data: dict[tuple, TreeLayoutCoordinates]) -> None: + def __init__(self, data: dict[History, TreeLayoutCoordinates]) -> None: self._data = data def __repr__(self) -> str: @@ -3520,16 +3491,16 @@ class TreeLayout: def __len__(self) -> int: return len(self._data) - def __iter__(self) -> typing.Iterator[tuple]: + def __iter__(self) -> typing.Iterator[History]: return iter(self._data) - def __contains__(self, history: tuple) -> bool: + def __contains__(self, history: History) -> bool: return history in self._data - def __getitem__(self, history: tuple) -> TreeLayoutCoordinates: + def __getitem__(self, history: History) -> TreeLayoutCoordinates: return self._data[history] - def items(self) -> typing.ItemsView[tuple, TreeLayoutCoordinates]: + def items(self) -> typing.ItemsView[History, TreeLayoutCoordinates]: return self._data.items() diff --git a/src/pygambit/hsel.pxi b/src/pygambit/hsel.pxi index bc660471a3..e56dc55e57 100644 --- a/src/pygambit/hsel.pxi +++ b/src/pygambit/hsel.pxi @@ -22,6 +22,45 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. # +import dataclasses + + +@dataclasses.dataclass(frozen=True) +class HistoryTransition: + """One step of a `History`: the player who acted, the `History` identifying + the information set or event at which they acted, and the label of the + action taken. + + .. versionadded:: 17.0.0 + """ + player: str + state: "History" + action: str + + +class History(tuple): + """An immutable sequence of `HistoryTransition`s identifying a node (or an + information set/event, via its canonical member) by the path of actions + taken to reach it from the root. + + .. versionadded:: 17.0.0 + """ + + @property + def actions(self) -> tuple[str, ...]: + """The plain tuple of action labels, dropping the player/state context + each step also carries.""" + return tuple(t.action for t in self) + + def __getitem__(self, index): + result = tuple.__getitem__(self, index) + if isinstance(index, slice): + return History(result) + return result + + def __repr__(self) -> str: + return f"History({tuple.__repr__(self)})" + class _PathStep: """One `H.path(*steps)` operation: each step is an exact action label or @@ -197,19 +236,27 @@ class GroupedSelector: 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 - private `Node._parent`/`._prior_action` navigation.""" - labels: list = [] +def _history_of(node: Node) -> History: + """The History for `node` -- walks back to the root via the private + `Node._parent`/`._prior_action` navigation, recording a `HistoryTransition` + per step.""" + transitions: list = [] current: Node = node while current._parent() is not None: - labels.append(current._prior_action().label) - current = current._parent() - labels.reverse() - return tuple(labels) + branch = current._prior_action() + transitions.append( + HistoryTransition( + player=branch.node.player, + state=_canonical_history(branch.node), + action=branch.label, + ) + ) + current = branch.node + transitions.reverse() + return History(transitions) -def _canonical_history(node: Node) -> tuple: +def _canonical_history(node: Node) -> History: """The History of the canonical member of `node`'s current information set or event: the first member encountered in the pre-order depth-first traversal of the game tree (``GetMember(1)``), matching the order `Node.members` itself uses. Used @@ -230,10 +277,11 @@ def _canonical_history(node: Node) -> tuple: class HistoryView: """The object a `.filter(callable)`/`.by(callable)` predicate, or `Game.behavior_support_profile`'s `actions` callback, 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. Not constructible - directly. + Supports indexing/slicing like a `History` -- so each element is a + `HistoryTransition`, not a bare action label -- plus `.actions` (the + plain label-tuple projection) and limited game-aware navigation + (`.last_action(player)`) -- but never exposes the `Node`/game it's + privately backed by. Not constructible directly. .. versionadded:: 17.0.0 """ @@ -242,7 +290,7 @@ class HistoryView: raise ValueError("Cannot create a HistoryView directly.") @staticmethod - def _wrap(node: Node, history: tuple) -> HistoryView: + def _wrap(node: Node, history: History) -> HistoryView: obj: HistoryView = HistoryView.__new__(HistoryView) obj._node = node obj._history = history @@ -257,13 +305,19 @@ class HistoryView: def __getitem__(self, index: typing.Any) -> typing.Any: return self._history[index] + @property + def actions(self) -> tuple[str, ...]: + """The plain tuple of action labels, dropping the player/state context + each step also carries.""" + return self._history.actions + 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]: + def members(self) -> list[History]: """The Histories of the nodes which are members of the information set or event to which this history currently belongs -- whichever applies. diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 7689cfdaf2..846151a22c 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -170,7 +170,7 @@ class Node: def _children(self) -> NodeChildren: """The set of children of this node. Not part of the public API; the public equivalent is a `Selector`'s `.path(..., ...)` wildcard step, e.g. - `game.get_histories(H.path(*history, ...))`. + `game.get_histories(H.path(*history.actions, ...))`. """ return NodeChildren.wrap(self.node) diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index 72a36d944e..ff8e73202d 100644 --- a/src/pygambit/qre.py +++ b/src/pygambit/qre.py @@ -257,16 +257,16 @@ def _estimate_behavior_empirical( data: libgbt.MixedBehaviorProfile, ) -> LogitQREMixedBehaviorFitResult: flattened_data = [ - data[libgbt.H.path(*history)][a] + data[libgbt.H.path(*history.actions)][a] for p in data.game.players for history in data.game.get_infosets(p) - for a in data.game.get_actions(libgbt.H.path(*history)) + for a in data.game.get_actions(libgbt.H.path(*history.actions)) ] normalized = data.normalize() regrets = [ [ - -normalized.action_regrets[libgbt.H.path(*history)][a] - for a in data.game.get_actions(libgbt.H.path(*history)) + -normalized.action_regrets[libgbt.H.path(*history.actions)][a] + for a in data.game.get_actions(libgbt.H.path(*history.actions)) ] for player in data.game.players for history in data.game.get_infosets(player) @@ -280,7 +280,7 @@ def _estimate_behavior_empirical( log_probs = iter(_empirical_log_logit_probs(res.x[0], regrets)) for player in data.game.players: for history in data.game.get_infosets(player): - selector = libgbt.H.path(*history) + selector = libgbt.H.path(*history.actions) profile[selector] = { a: math.exp(next(log_probs)) for a in data.game.get_actions(selector) } diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index c2641cf981..3557c8171b 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -160,76 +160,3 @@ class StrategyBehavior: (_canonical_history(node), self._action_at(node)) for node in self._reachable_nodes() ] - - -@cython.cclass -class Sequence: - """A sequence for a player in a ``Game``. - - .. versionadded:: 16.7.0 - """ - sequence = cython.declare(c_GameSequence) - - def __init__(self, *args, **kwargs) -> None: - raise ValueError("Cannot create a Sequence outside a Game.") - - @staticmethod - @cython.cfunc - def wrap(sequence: c_GameSequence) -> Sequence: - obj: Sequence = Sequence.__new__(Sequence) - obj.sequence = sequence - return obj - - def __repr__(self) -> str: - return f"Sequence(player='{self.player}', actions={self.actions})" - - def __eq__(self, other: typing.Any) -> bool: - return ( - isinstance(other, Sequence) and - self.sequence.deref() == cython.cast(Sequence, other).sequence.deref() - ) - - def __hash__(self) -> int: - return cython.cast(cython.long, self.sequence.deref()) - - @property - def game(self) -> Game: - """The game to which the sequence belongs.""" - return Game.wrap(self.sequence.deref().GetPlayer().deref().GetGame()) - - @property - def player(self) -> str: - """The label of the player to which the sequence belongs.""" - return self.sequence.deref().GetPlayer().deref().GetLabel().decode("utf-8") - - @property - def parent(self) -> Sequence | None: - """The parent (predecessor) of the sequence.""" - if self.sequence.deref().GetParent() == cython.cast(c_GameSequence, NULL): - return None - return Sequence.wrap(self.sequence.deref().GetParent()) - - @property - def children(self) -> list[Sequence]: - """The immediate children (successors) of the sequence.""" - ret: list[Sequence] = [] - for seq in self.game.get_sequences(self.player): - if seq.parent == self: - ret.append(seq) - return ret - - @property - def actions(self) -> tuple[str, ...]: - """The labels of the actions defining this sequence, in order. - - Returns the empty tuple for the root sequence of the player. - - .. versionchanged:: 17.0.0 - Returns bare labels rather than ``Action`` objects. - """ - labels: list[str] = [] - seq = self.sequence - while seq.deref().GetAction() != cython.cast(c_GameAction, NULL): - labels.insert(0, seq.deref().GetAction().deref().GetLabel().decode("utf-8")) - seq = seq.deref().GetParent() - return tuple(labels) diff --git a/tests/games.py b/tests/games.py index 657deae3bd..0e0a9fba6a 100644 --- a/tests/games.py +++ b/tests/games.py @@ -131,7 +131,7 @@ def selector_for_nodes(nodes: list[gbt.Node]) -> gbt.Selector: nodes -- for adapting fixtures that compute a `Node` list dynamically to the `H`-only mutation methods.""" histories = frozenset(_node_history(n) for n in nodes) - return gbt.H.after().filter(lambda h: h[:] in histories) + return gbt.H.after().filter(lambda h: h[:].actions in histories) def selector_for_histories(histories: list[tuple]) -> gbt.Selector: @@ -139,7 +139,7 @@ def selector_for_histories(histories: list[tuple]) -> gbt.Selector: Histories -- the History-only counterpart to `selector_for_nodes`, for fixtures that already work in terms of `History` rather than `Node`.""" histories = frozenset(histories) - return gbt.H.after().filter(lambda h: h[:] in histories) + return gbt.H.after().filter(lambda h: h[:].actions in histories) def selector_for_node(node: gbt.Node) -> gbt.Selector: @@ -291,7 +291,7 @@ def create_stripped_down_poker_efg(nonterm_outcomes: bool = False) -> gbt.Game: for card in deals: g.append_move( - gbt.H.path(...).filter(lambda h, card=card: h[0] == card), + gbt.H.path(...).filter(lambda h, card=card: h[0].action == card), player="Alice", actions=["Bet", "Fold"] ) @@ -322,25 +322,27 @@ def _create_kuhn_poker_efg_without_outcomes(): for alice_card in cards: # Alice's first move g.append_move( - gbt.H.path(...).filter(lambda h, card=alice_card: h[0][0] == card), + gbt.H.path(...).filter(lambda h, card=alice_card: h[0].action[0] == card), "Alice", ["Check", "Bet"] ) for bob_card in cards: # Bob's move after Alice checks g.append_move( - gbt.H.path(..., "Check").filter(lambda h, card=bob_card: h[0][1] == card), + gbt.H.path(..., "Check").filter(lambda h, card=bob_card: h[0].action[1] == card), "Bob", ["Check", "Bet"] ) for alice_card in cards: # Alice's move if Bob's second action is bet g.append_move( - gbt.H.path(..., "Check", "Bet").filter(lambda h, card=alice_card: h[0][0] == card), + gbt.H.path(..., "Check", "Bet").filter( + lambda h, card=alice_card: h[0].action[0] == card + ), "Alice", ["Fold", "Call"] ) for bob_card in cards: # Bob's move after Alice bets initially g.append_move( - gbt.H.path(..., "Bet").filter(lambda h, card=bob_card: h[0][1] == card), + gbt.H.path(..., "Bet").filter(lambda h, card=bob_card: h[0].action[1] == card), "Bob", ["Fold", "Call"] ) return g diff --git a/tests/test_behav.py b/tests/test_behav.py index 5513e212b5..a93311bda1 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -20,7 +20,7 @@ def _set_action_probs(profile: gbt.MixedBehaviorProfile, probs: list, rational_f probs_iter = iter(probs) game = profile.game for infoset in games.all_infosets(game): - selector = gbt.H.path(*infoset) + selector = gbt.H.path(*infoset.actions) profile[selector] = { a: convert(next(probs_iter)) for a in game.get_actions(selector) } @@ -38,7 +38,7 @@ def _member_selector(game: gbt.Game, label: str) -> gbt.Selector: verified to work from any member, not just the canonical one.""" history = _infoset_history(game, label) member = next(iter(game.get_members(gbt.H.path(*history)))) - return gbt.H.path(*member) + return gbt.H.path(*member.actions) @pytest.mark.parametrize( @@ -268,7 +268,7 @@ def test_behavior_indexing_rejects_selector_from_different_player( """ profile = game.mixed_behavior_profile() other_infoset = games.player_infosets(game, other_player_label)[0] - other_selector = gbt.H.path(*other_infoset) + other_selector = gbt.H.path(*other_infoset.actions) with pytest.raises(gbt.MismatchError): profile[player_label][other_selector] @@ -295,7 +295,7 @@ def test_profile_indexing_by_player_label_reference( if rational_flag: behav_data = [[gbt.Rational(prob) for prob in probs] for probs in behav_data] expected = [ - dict(zip(game.get_actions(gbt.H.path(*infoset)), probs, strict=True)) + dict(zip(game.get_actions(gbt.H.path(*infoset.actions)), probs, strict=True)) for infoset, probs in zip( games.player_infosets(game, player_label), behav_data, strict=True ) @@ -448,7 +448,7 @@ def test_set_probabilities_player_by_label( if rational_flag: behav_data = [[gbt.Rational(prob) for prob in probs] for probs in behav_data] expected = [ - dict(zip(game.get_actions(gbt.H.path(*infoset)), probs, strict=True)) + dict(zip(game.get_actions(gbt.H.path(*infoset.actions)), probs, strict=True)) for infoset, probs in zip( games.player_infosets(game, player_label), behav_data, strict=True ) @@ -456,13 +456,13 @@ def test_set_probabilities_player_by_label( for infoset, distribution in zip( games.player_infosets(game, player_label), expected, strict=True ): - profile[gbt.H.path(*infoset)] = distribution + profile[gbt.H.path(*infoset.actions)] = distribution assert profile[player_label] == expected def _p1_selector(game: gbt.Game) -> gbt.Selector: """A Selector for Player 1's first information set.""" - return gbt.H.path(*games.player_infosets(game, "Player 1")[0]) + return gbt.H.path(*games.player_infosets(game, "Player 1")[0].actions) def test_behavior_setitem_allows_sparse_distribution(): @@ -684,10 +684,10 @@ def test_as_float_is_independent_copy(): def test_realiz_prob_nodes_reference( game: gbt.Game, path: list[str], realiz_prob: str | float, rational_flag: bool ): - # a node's History is the tuple of action labels from the root (empty for the root) profile = game.mixed_behavior_profile(rational=rational_flag) realiz_prob = gbt.Rational(realiz_prob) if rational_flag else realiz_prob - assert profile.realiz_probs[tuple(path)] == realiz_prob + history = game.get_histories(gbt.H.path(*path))[0] + assert profile.realiz_probs[history] == realiz_prob @pytest.mark.parametrize( @@ -703,7 +703,7 @@ def test_infoset_probs_reference(game: gbt.Game, rational_flag: bool, infoset_pr profile = game.mixed_behavior_profile(rational=rational_flag) for prob, infoset in zip(infoset_probs, games.all_infosets(game), strict=True): prob = gbt.Rational(prob) if rational_flag else prob - assert profile.infoset_probs[gbt.H.path(*infoset)] == prob + assert profile.infoset_probs[gbt.H.path(*infoset.actions)] == prob @pytest.mark.parametrize( @@ -768,7 +768,7 @@ def test_infoset_values_reference(game: gbt.Game, rational_flag: bool, infoset_v profile = game.mixed_behavior_profile(rational=rational_flag) for payoff, infoset in zip(infoset_values, games.all_infosets(game), strict=True): payoff = gbt.Rational(payoff) if rational_flag else payoff - assert profile.infoset_values[gbt.H.path(*infoset)] == payoff + assert profile.infoset_values[gbt.H.path(*infoset.actions)] == payoff @pytest.mark.parametrize( @@ -791,7 +791,7 @@ def test_infoset_values_reference(game: gbt.Game, rational_flag: bool, infoset_v def test_action_values_reference(game: gbt.Game, rational_flag: bool, action_values: tuple): profile = game.mixed_behavior_profile(rational=rational_flag) for values_for_infoset, infoset in zip(action_values, games.all_infosets(game), strict=True): - selector = gbt.H.path(*infoset) + selector = gbt.H.path(*infoset.actions) infoset_action_values = profile.action_values[selector] for value, action in zip( values_for_infoset, game.get_actions(selector), strict=True @@ -815,7 +815,7 @@ def test_action_regret_consistency(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) for player in game.players: for infoset in games.player_infosets(game, player): - selector = gbt.H.path(*infoset) + selector = gbt.H.path(*infoset.actions) actions = game.get_actions(selector) for action in actions: assert profile.action_regrets[selector][action] == max( @@ -838,7 +838,7 @@ def test_infoset_regret_consistency(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) for player in game.players: for infoset in games.player_infosets(game, player): - selector = gbt.H.path(*infoset) + selector = gbt.H.path(*infoset.actions) assert profile.infoset_regrets[selector] == max( profile.action_values[selector][a] for a in game.get_actions(selector) ) - profile.infoset_values[selector] @@ -877,7 +877,7 @@ def test_agent_max_regret_consistency(game: gbt.Game, rational_flag: bool): profile = game.mixed_behavior_profile(rational=rational_flag) infoset_regrets = profile.infoset_regrets assert profile.agent_max_regret() == max( - infoset_regrets[gbt.H.path(*infoset)] for infoset in games.all_infosets(game) + infoset_regrets[gbt.H.path(*infoset.actions)] for infoset in games.all_infosets(game) ) @@ -925,7 +925,7 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): assert player_node_values[()] == payoffs[player] for infoset in games.player_infosets(game, player): - selector = gbt.H.path(*infoset) + selector = gbt.H.path(*infoset.actions) actions = game.get_actions(selector) infoset_action_values = action_values[selector] infoset_action_regrets = action_regrets[selector] @@ -941,9 +941,9 @@ def test_vectorized_quantities_consistency(game: gbt.Game, rational_flag: bool): ) for history in game.get_histories(gbt.H.after()): - if not game.get_actions(gbt.H.path(*history)): + if not game.get_actions(gbt.H.path(*history.actions)): continue - if infoset_probs[gbt.H.path(*history)] == 0: + if infoset_probs[gbt.H.path(*history.actions)] == 0: assert beliefs[history] is None else: assert beliefs[history] is not None @@ -1022,8 +1022,10 @@ def test_action_regrets_reference( profile = game.mixed_behavior_profile(rational=rational_flag) if action_probs: _set_action_probs(profile, action_probs, rational_flag) - for regrets_for_infoset, infoset in zip(action_regrets, games.all_infosets(game), strict=True): - selector = gbt.H.path(*infoset) + for regrets_for_infoset, infoset in zip( + action_regrets, games.all_infosets(game), strict=True + ): + selector = gbt.H.path(*infoset.actions) infoset_action_regrets = profile.action_regrets[selector] for regret, action in zip( regrets_for_infoset, game.get_actions(selector), strict=True @@ -1050,13 +1052,13 @@ def test_martingale_property_of_node_value(game: gbt.Game, rational_flag: bool): realiz_probs = profile.realiz_probs node_values = profile.node_values for history in game.get_histories(gbt.H.after()): - player = game.get_player(gbt.H.path(*history)) + player = game.get_player(gbt.H.path(*history.actions)) if player is None or player == "Chance": continue expected_val = 0 node_prob = realiz_probs[history] player_node_values = node_values[player] - for child_history in games.children_histories(game, history): + for child_history in game.get_histories(gbt.H.path(*history.actions, ...)): prob = realiz_probs[child_history] / node_prob expected_val += prob * player_node_values[child_history] assert player_node_values[history] == expected_val @@ -1375,11 +1377,11 @@ def test_node_belief_reference( value: str | float, rational_flag: bool, ): - # a node's History is the tuple of action labels from the root (empty for the root) profile = game.mixed_behavior_profile(rational=rational_flag) _set_action_probs(profile, probs, rational_flag) value = gbt.Rational(value) if rational_flag else value - assert abs(profile.beliefs[tuple(path)] - value) <= tol + history = game.get_histories(gbt.H.path(*path))[0] + assert abs(profile.beliefs[history] - value) <= tol @pytest.mark.parametrize( @@ -1396,7 +1398,7 @@ def test_infoset_value_error_with_chance_player_infoset(game: gbt.Game, rational chance_node = game.get_events()[0] with pytest.raises(KeyError): game.mixed_behavior_profile(rational=rational_flag).infoset_values[ - gbt.H.path(*chance_node) + gbt.H.path(*chance_node.actions) ] @@ -1414,7 +1416,7 @@ def test_action_value_error_with_chance_player_action(game: gbt.Game, rational_f chance_node = game.get_events()[0] with pytest.raises(KeyError): game.mixed_behavior_profile(rational=rational_flag).action_values[ - gbt.H.path(*chance_node) + gbt.H.path(*chance_node.actions) ] @@ -1424,7 +1426,7 @@ def _all_node_actions(game: gbt.Game) -> list[tuple[gbt.Selector, str]]: (selector, action) for player in game.players for history in game.get_infosets(player) - for selector in [gbt.H.path(*history)] + for selector in [gbt.H.path(*history.actions)] for action in game.get_actions(selector) ] @@ -1557,7 +1559,7 @@ def _get_and_check_answers( PROBS_1A_doub, PROBS_2A_doub, False, - lambda x, y: x.infoset_probs[gbt.H.path(*y)], + lambda x, y: x.infoset_probs[gbt.H.path(*y.actions)], lambda x: games.all_infosets(x), ), ( @@ -1565,7 +1567,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.infoset_probs[gbt.H.path(*y)], + lambda x, y: x.infoset_probs[gbt.H.path(*y.actions)], lambda x: games.all_infosets(x), ), ( @@ -1573,7 +1575,7 @@ def _get_and_check_answers( PROBS_1B_doub, PROBS_2B_doub, False, - lambda x, y: x.infoset_probs[gbt.H.path(*y)], + lambda x, y: x.infoset_probs[gbt.H.path(*y.actions)], lambda x: games.all_infosets(x), ), ( @@ -1581,7 +1583,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.infoset_probs[gbt.H.path(*y)], + lambda x, y: x.infoset_probs[gbt.H.path(*y.actions)], lambda x: games.all_infosets(x), ), ###################################################################################### @@ -1591,7 +1593,7 @@ def _get_and_check_answers( PROBS_1A_doub, PROBS_2A_doub, False, - lambda x, y: x.infoset_values[gbt.H.path(*y)], + lambda x, y: x.infoset_values[gbt.H.path(*y.actions)], lambda x: games.all_infosets(x), ), ( @@ -1599,7 +1601,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.infoset_values[gbt.H.path(*y)], + lambda x, y: x.infoset_values[gbt.H.path(*y.actions)], lambda x: games.all_infosets(x), ), ( @@ -1607,7 +1609,7 @@ def _get_and_check_answers( PROBS_1B_doub, PROBS_2B_doub, False, - lambda x, y: x.infoset_values[gbt.H.path(*y)], + lambda x, y: x.infoset_values[gbt.H.path(*y.actions)], lambda x: games.all_infosets(x), ), ( @@ -1615,7 +1617,7 @@ def _get_and_check_answers( PROBS_1A_rat, PROBS_2A_rat, True, - lambda x, y: x.infoset_values[gbt.H.path(*y)], + lambda x, y: x.infoset_values[gbt.H.path(*y.actions)], lambda x: games.all_infosets(x), ), ###################################################################################### @@ -1909,7 +1911,7 @@ 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): - selector = gbt.H.path(*infoset) + selector = gbt.H.path(*infoset.actions) for action in game.get_actions(selector): prob = next(flattened) assert profile[selector][action] == (gbt.Rational(prob) if rational_flag else prob) @@ -1998,7 +2000,7 @@ def test_undefined_action_value(): """Test that undefined action values return `None`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") *_, p3 = game.players - selector = gbt.H.path(*games.player_infosets(game, p3)[0]) + selector = gbt.H.path(*games.player_infosets(game, p3)[0].actions) action = game.get_actions(selector)[0] for rat in [False, True]: profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) @@ -2019,7 +2021,7 @@ def test_undefined_infoset_value(): """Test that undefined infoset values return `None`.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig1") *_, p3 = game.players - selector = gbt.H.path(*games.player_infosets(game, p3)[0]) + selector = gbt.H.path(*games.player_infosets(game, p3)[0].actions) for rat in [False, True]: profile = game.mixed_behavior_profile([[[1, 0]], [[1, 0]], [[1, 0]]], rational=rat) assert profile.infoset_values[selector] is None diff --git a/tests/test_behavspt_profiles.py b/tests/test_behavspt_profiles.py index a8e8323b69..c9728b7da7 100644 --- a/tests/test_behavspt_profiles.py +++ b/tests/test_behavspt_profiles.py @@ -31,7 +31,7 @@ def test_getitem_by_selector(): profile = game.behavior_support_profile() support = profile[_find_selector(game, "Infoset 1:1")] assert set(support) == {"U1", "D1"} - assert support.history == _find_history(game, "Infoset 1:1") + assert support.history.actions == _find_history(game, "Infoset 1:1") assert "U1" in support assert "not-a-label" not in support diff --git a/tests/test_extensive.py b/tests/test_extensive.py index 352f6cedc7..83826bf144 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -533,9 +533,9 @@ def test_reduced_strategy_maps(game: gbt.Game, strategy_maps: list): behavior = game.get_behavior(player, strategy) assert tuple( "*" if ( - action := behavior.get(gbt.H.path(*history)) + action := behavior.get(gbt.H.path(*history.actions)) ) is None - else str(game.get_actions(gbt.H.path(*history)).index(action) + 1) + else str(game.get_actions(gbt.H.path(*history.actions)).index(action) + 1) for history in games.player_infosets(game, player) ) == expected diff --git a/tests/test_game_resolve.py b/tests/test_game_resolve.py index 6bd7cc1a03..b5e05b5f27 100644 --- a/tests/test_game_resolve.py +++ b/tests/test_game_resolve.py @@ -67,12 +67,12 @@ def test_resolve_infoset(game: gbt.Game) -> None: just the representative `Game.get_infosets` returns.""" for player in game.players: for history in game.get_infosets(player): - for member in game.get_members(gbt.H.path(*history)): - resolved = game._resolve_infoset(gbt.H.path(*member), "test") - assert games._node_history(resolved) == member + for member in game.get_members(gbt.H.path(*history.actions)): + resolved = game._resolve_infoset(gbt.H.path(*member.actions), "test") + assert games._node_history(resolved) == member.actions if resolved._label: resolved_by_label = game._resolve_infoset(resolved._label, "test") - assert games._node_history(resolved_by_label) == member + assert games._node_history(resolved_by_label) == member.actions @pytest.mark.parametrize( diff --git a/tests/test_hsel.py b/tests/test_hsel.py index c8e82975e9..716b908a58 100644 --- a/tests/test_hsel.py +++ b/tests/test_hsel.py @@ -11,15 +11,15 @@ def test_history_view_members_on_shared_infoset(): captured = {} def key(h): - captured[h[:]] = h.members - return frozenset(h.members) + captured[h[:].actions] = [m.actions for m in h.members] + return frozenset(m.actions for m in 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",)]] + assert [[m.actions for m in g] for g in groups.values()] == [[("U",), ("D",)]] def test_history_view_members_singleton_infoset(): @@ -31,7 +31,7 @@ def test_history_view_members_singleton_infoset(): captured = {} def key(h): - captured[h[:]] = h.members + captured[h[:].actions] = [m.actions for m in h.members] return None game._get_groups(gbt.H.path(...).by(key)) @@ -46,7 +46,7 @@ def test_history_view_members_on_event(): captured = {} def key(h): - captured[h[:]] = h.members + captured[h[:].actions] = [m.actions for m in h.members] return None game._get_groups(gbt.H.path(...).by(key)) @@ -69,7 +69,7 @@ def test_get_histories_root(): game = gbt.Game.new_tree(players=["A"]) game.append_move(gbt.H.path(), "A", ["U", "D"]) - assert game.get_histories(gbt.H.path()) == [()] + assert [h.actions for h in game.get_histories(gbt.H.path())] == [()] def test_get_histories_multiple(): @@ -77,8 +77,8 @@ def test_get_histories_multiple(): game.append_move(gbt.H.path(), "A", ["U", "D"]) game.append_move(gbt.H.plays, "B", ["x", "y"]) - assert game.get_histories(gbt.H.path(...)) == [("U",), ("D",)] - assert game.get_histories(gbt.H.plays) == [ + assert [h.actions for h in game.get_histories(gbt.H.path(...))] == [("U",), ("D",)] + assert [h.actions for h in game.get_histories(gbt.H.plays)] == [ ("U", "x"), ("U", "y"), ("D", "x"), ("D", "y"), ] @@ -88,7 +88,7 @@ def test_get_histories_plays_from_non_root(): from that point, not from the whole game.""" game = gbt.catalog.load("journals/ijgt/selten1975/fig2") - assert set(game.get_histories(gbt.H.path("L").plays)) == { + assert {h.actions for h in game.get_histories(gbt.H.path("L").plays)} == { ("L", "R"), ("L", "L", "r"), ("L", "L", "l"), } diff --git a/tests/test_nash.py b/tests/test_nash.py index 265affebff..7aa09fee70 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -23,10 +23,10 @@ def d(*probs) -> tuple: return tuple(probs) -def _action_prob(profile: gbt.MixedBehaviorProfile, history: tuple, label: str): +def _action_prob(profile: gbt.MixedBehaviorProfile, history: gbt.History, label: str): """The probability profile assigns to the action labeled `label` at the information set identified by `history`.""" - return profile[gbt.H.path(*history)][label] + return profile[gbt.H.path(*history.actions)][label] @dataclasses.dataclass @@ -3256,7 +3256,7 @@ def test_nash_behavior_solver(test_case: EquilibriumTestCase, subtests) -> None: expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: for history in game.get_infosets(player): - for action in game.get_actions(gbt.H.path(*history)): + for action in game.get_actions(gbt.H.path(*history.actions)): assert abs( _action_prob(eq, history, action) - _action_prob(expected, history, action) @@ -3308,7 +3308,7 @@ def test_nash_behavior_solver_unordered(test_case: EquilibriumTestCase, subtests def are_the_same(game, found, candidate): for p in game.players: for history in game.get_infosets(p): - for a in game.get_actions(gbt.H.path(*history)): + for a in game.get_actions(gbt.H.path(*history.actions)): if not abs( _action_prob(found, history, a) - _action_prob(candidate, history, a) ) <= TOL: @@ -3475,7 +3475,7 @@ def test_nash_agent_solver(test_case: EquilibriumTestCase, subtests) -> None: expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: for history in game.get_infosets(player): - for action in game.get_actions(gbt.H.path(*history)): + for action in game.get_actions(gbt.H.path(*history.actions)): assert abs( _action_prob(eq, history, action) - _action_prob(expected, history, action) @@ -3543,7 +3543,7 @@ def test_nash_agent_w_start_solver(test_case: EquilibriumTestCase, subtests) -> expected = game.mixed_behavior_profile(rational=True, data=exp) for player in game.players: for history in game.get_infosets(player): - for action in game.get_actions(gbt.H.path(*history)): + for action in game.get_actions(gbt.H.path(*history.actions)): assert abs( _action_prob(eq, history, action) - _action_prob(expected, history, action) diff --git a/tests/test_outcome_mutations.py b/tests/test_outcome_mutations.py index d89417d540..639be54801 100644 --- a/tests/test_outcome_mutations.py +++ b/tests/test_outcome_mutations.py @@ -7,7 +7,8 @@ def test_make_outcome_attaches_to_all_given_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) game.make_outcome( - gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1, "Bob": -1}, "shared" + gbt.H.path(...).filter(lambda h: h[0].action in ("U", "M")), + {"Alice": 1, "Bob": -1}, "shared" ) assert game.get_outcome(gbt.H.path("U")) == "shared" assert game.get_outcome(gbt.H.path("M")) == "shared" @@ -41,7 +42,7 @@ def test_make_outcome_accepts_grouped_selector_pooled(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) game.make_outcome( - gbt.H.path(...).by(lambda h: h[0]), {"Alice": 1, "Bob": -1}, "shared" + gbt.H.path(...).by(lambda h: h[0].action), {"Alice": 1, "Bob": -1}, "shared" ) assert game.get_outcome(gbt.H.path("U")) == "shared" assert game.get_outcome(gbt.H.path("M")) == "shared" @@ -79,7 +80,9 @@ 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(gbt.H.path(), "Alice", ["U", "M", "D"]) - game.make_outcome(gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1}, "w") + game.make_outcome( + gbt.H.path(...).filter(lambda h: h[0].action in ("U", "M")), {"Alice": 1}, "w" + ) with pytest.raises(ValueError): game.make_outcome(gbt.H.path("D"), {"Alice": 2}, "w") assert len(game.get_outcomes()) == 1 @@ -129,7 +132,8 @@ def test_make_outcome_null_accepts_selector(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(gbt.H.path(), "Alice", ["U", "M", "D"]) game.make_outcome( - gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1, "Bob": -1}, "shared" + gbt.H.path(...).filter(lambda h: h[0].action in ("U", "M")), + {"Alice": 1, "Bob": -1}, "shared" ) game.make_outcome_null(gbt.H.path("U")) assert game.get_outcome(gbt.H.path("U")) is None @@ -168,7 +172,9 @@ 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(gbt.H.path(), "Alice", ["U", "M", "D"]) - game.make_outcome(gbt.H.path(...).filter(lambda h: h[0] in ("U", "M")), {"Alice": 1}, "shared") + game.make_outcome( + gbt.H.path(...).filter(lambda h: h[0].action in ("U", "M")), {"Alice": 1}, "shared" + ) outcome_count = len(game.get_outcomes()) game.make_outcome_null(gbt.H.path("U")) assert len(game.get_outcomes()) == outcome_count diff --git a/tests/test_players.py b/tests/test_players.py index b678d46844..19704d3753 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -300,40 +300,6 @@ def test_strategy_label_duplicate_within_player_raises_valueerror(): game.relabel_strategies(pl1, {s2: s1}) -def test_player_sequence_count(): - """Test the identity that the number of sequences is the number of actions plus one.""" - game = gbt.catalog.load("books/myerson1991/fig2_1") - for player in game.players: - action_count = sum( - len(game.get_actions(gbt.H.path(*history))) for history in game.get_infosets(player) - ) - assert len(game.get_sequences(player)) == action_count + 1 - - -def test_player_sequence_actions(): - game = gbt.catalog.load("books/myerson1991/fig2_1") - player = "Alice" - sequences = set(tuple(seq.actions) for seq in game.get_sequences(player)) - reference = ( - set( - (action, ) - for history in game.get_infosets(player) - for action in game.get_actions(gbt.H.path(*history)) - ) | - {tuple()} - ) - assert sequences == reference - - -def test_player_sequence_tree(): - game = gbt.catalog.load("books/myerson1991/fig2_1") - player = "Alice" - for seq in game.get_sequences(player): - if not seq.parent: - continue - assert seq in seq.parent.children - - @pytest.mark.parametrize( "game,exp_min_payoffs,exp_max_payoffs", [ diff --git a/tests/test_qre.py b/tests/test_qre.py index 81ce559e64..4377ef0688 100644 --- a/tests/test_qre.py +++ b/tests/test_qre.py @@ -14,7 +14,7 @@ def _asymmetric_poker_behavior_data() -> gbt.MixedBehaviorProfile: data = game.mixed_behavior_profile(rational=False) for player in game.players: for history in games.player_infosets(game, player): - selector = gbt.H.path(*history) + selector = gbt.H.path(*history.actions) data[selector] = { a: float(i + 2) for i, a in enumerate(game.get_actions(selector)) } @@ -76,7 +76,7 @@ def test_logit_estimate_behavior_completes(use_empirical: bool, local_max: bool) assert isinstance(result.profile, gbt.MixedBehaviorProfileDouble) for player in data.game.players: for history in games.player_infosets(data.game, player): - selector = gbt.H.path(*history) + selector = gbt.H.path(*history.actions) probs = dict(result.profile[selector]) assert probs.keys() == set(data.game.get_actions(selector)) assert sum(probs.values()) == pytest.approx(1.0) diff --git a/tests/test_tree_mutations.py b/tests/test_tree_mutations.py index 96881f4fd2..812b62fe96 100644 --- a/tests/test_tree_mutations.py +++ b/tests/test_tree_mutations.py @@ -82,7 +82,7 @@ def test_append_move_creates_single_infoset_list_of_nodes(): game.set_players(list(game.players) + ["Player 3"]) matches = (("2", "1"), ("1", "1"), ("1", "2")) game.append_move( - gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + gbt.H.path(..., ...).filter(lambda h: (h[0].action, h[1].action) in matches), "Player 3", ["B", "F"] ) assert len(game.get_infosets("Player 3")) == 1 @@ -94,9 +94,10 @@ def test_append_move_same_infoset_list_of_nodes(): game.set_players(list(game.players) + ["Player 3"]) matches = (("2", "1"), ("1", "1")) game.append_move( - gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), "Player 3", ["B", "F"] + gbt.H.path(..., ...).filter(lambda h: (h[0].action, h[1].action) in matches), + "Player 3", ["B", "F"] ) - assert ("2", "1") in game.get_members(gbt.H.path("1", "1")) + assert ("2", "1") in [m.actions for m in game.get_members(gbt.H.path("1", "1"))] def test_append_move_actions_list_of_nodes(): @@ -107,7 +108,7 @@ def test_append_move_actions_list_of_nodes(): game.set_players(list(game.players) + ["Player 3"]) matches = (("2", "1"), ("1", "1")) game.append_move( - gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + gbt.H.path(..., ...).filter(lambda h: (h[0].action, h[1].action) in matches), "Player 3", ["B", "F", "S"] ) assert game.get_actions(gbt.H.path("2", "1")) == game.get_actions(gbt.H.path("1", "1")) @@ -121,7 +122,7 @@ def test_append_move_labels_list_of_nodes(): game.set_players(list(game.players) + ["Player 3"]) matches = (("2", "1"), ("1", "1")) game.append_move( - gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + gbt.H.path(..., ...).filter(lambda h: (h[0].action, h[1].action) in matches), "Player 3", ["B", "F", "S"] ) @@ -202,10 +203,10 @@ def test_append_event_creates_single_event_list_of_nodes(): game = games.read_from_file("sample_extensive_game.efg") matches = (("2", "1"), ("1", "1")) game.append_event( - gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in matches), + gbt.H.path(..., ...).filter(lambda h: (h[0].action, h[1].action) in matches), {"a": gbt.Rational(1, 2), "b": gbt.Rational(1, 2)} ) - assert ("2", "1") in game.get_members(gbt.H.path("1", "1")) + assert ("2", "1") in [m.actions for m in game.get_members(gbt.H.path("1", "1"))] assert game.get_actions(gbt.H.path("2", "1")) @@ -409,7 +410,7 @@ def test_len_after_set_move_actions_drop(): initial_number_of_nodes = _n_nodes(game) action_to_drop = "L" nodes_to_delete = sum( - _count_subtree_nodes(game, (*member, action_to_drop), True) + _count_subtree_nodes(game, (*member.actions, action_to_drop), True) for member in game.get_members(gbt.H.path()) ) remaining = [a for a in game.get_actions(gbt.H.path()) if a != "L"] @@ -483,9 +484,10 @@ def test_make_infoset_converts_chance_node(): game = games.read_from_file("stripped_down_poker.efg") # the deal is a chance move personal_history = next( h for h in game.get_histories(gbt.H.after()) - if game.get_actions(gbt.H.path(*h)) and game.get_player(gbt.H.path(*h)) != "Chance" + if game.get_actions(gbt.H.path(*h.actions)) + and game.get_player(gbt.H.path(*h.actions)) != "Chance" ) - personal_player = game.get_player(gbt.H.path(*personal_history)) + personal_player = game.get_player(gbt.H.path(*personal_history.actions)) game.make_infoset(gbt.H.path(), personal_player) assert game.get_player(gbt.H.path()) != "Chance" assert game.get_player(gbt.H.path()) == personal_player @@ -499,7 +501,7 @@ def test_make_infoset_requires_matching_action_labels(node_actions): game.append_move(gbt.H.path(), "1", ["a", "b"]) game.append_move(gbt.H.path("a"), "1", node_actions) with pytest.raises(ValueError): - game.make_infoset(gbt.H.after().filter(lambda h: h[:] in ((), ("a",))), "1") + game.make_infoset(gbt.H.after().filter(lambda h: h[:].actions in ((), ("a",))), "1") def test_make_infoset_empty_nodes_raises(): @@ -561,7 +563,7 @@ def test_make_event_pools_nodes_from_different_infosets(): game = games.read_from_file("stripped_down_poker.efg") king, queen = ("King",), ("Queen",) game.make_event(gbt.H.path(...), {"Bet": "1/4", "Fold": "3/4"}, "Coin") - assert king in game.get_members(gbt.H.path(*queen)) + assert king in [m.actions for m in game.get_members(gbt.H.path(*queen))] assert list(game.get_action_probs(gbt.H.path(*king)).values()) == [ gbt.Rational("1/4"), gbt.Rational("3/4") ] @@ -574,7 +576,7 @@ def test_make_event_requires_matching_action_labels(): # King node has actions Bet, Fold; its own Bet-child has actions Call, Fold. with pytest.raises(ValueError): game.make_event( - gbt.H.after().filter(lambda h: h[:] in (("King",), ("King", "Bet"))), + gbt.H.after().filter(lambda h: h[:].actions in (("King",), ("King", "Bet"))), {"Bet": "1/2", "Fold": "1/2"} ) @@ -625,7 +627,7 @@ def test_make_event_label_reused_when_fully_absorbed(): king, queen = ("King",), ("Queen",) game.make_event(gbt.H.path(...), {"Bet": "1/2", "Fold": "1/2"}, "Coin") game.make_event(gbt.H.path(...), {"Bet": "1/4", "Fold": "3/4"}, "Coin") - assert king in game.get_members(gbt.H.path(*queen)) + assert king in [m.actions for m in game.get_members(gbt.H.path(*queen))] assert list(game.get_action_probs(gbt.H.path(*king)).values()) == [ gbt.Rational("1/4"), gbt.Rational("3/4") ] @@ -664,9 +666,9 @@ def test_make_infoset_cherry_pick_leaves_rumps(): game = gbt.catalog.load("journals/geb/bagwell1995") A, B, C, D = _bagwell_p2_histories(game) game.make_infoset(games.selector_for_histories([B, C]), "Player 2") - assert B in game.get_members(gbt.H.path(*C)) - assert game.get_members(gbt.H.path(*A)) == [A] - assert game.get_members(gbt.H.path(*D)) == [D] + assert B in [m.actions for m in game.get_members(gbt.H.path(*C))] + assert [m.actions for m in game.get_members(gbt.H.path(*A))] == [A] + assert [m.actions for m in game.get_members(gbt.H.path(*D))] == [D] def test_make_infoset_label_held_by_rump_raises(): @@ -687,8 +689,8 @@ def test_make_infoset_failure_leaves_game_unchanged(): game.make_infoset(games.selector_for_histories([C, D]), "Player 2", "Y") with pytest.raises(ValueError): game.make_infoset(games.selector_for_histories([B, C]), "Player 2", "X") - assert A in game.get_members(gbt.H.path(*B)) - assert C in game.get_members(gbt.H.path(*D)) + assert A in [m.actions for m in game.get_members(gbt.H.path(*B))] + assert C in [m.actions for m in game.get_members(gbt.H.path(*D))] def test_make_infoset_idempotent(): @@ -697,7 +699,7 @@ def test_make_infoset_idempotent(): A, B, C, D = _bagwell_p2_histories(game) game.make_infoset(games.selector_for_histories([B, C]), "Player 2", "Z") game.make_infoset(games.selector_for_histories([B, C]), "Player 2", "Z") - assert B in game.get_members(gbt.H.path(*C)) + assert B in [m.actions for m in game.get_members(gbt.H.path(*C))] def test_make_infoset_split_creates_new_infoset(): @@ -706,8 +708,8 @@ def test_make_infoset_split_creates_new_infoset(): game = gbt.catalog.load("journals/geb/bagwell1995") A, B, C, D = _bagwell_p2_histories(game) game.make_infoset(gbt.H.path("S", "s"), "Player 2") - assert game.get_members(gbt.H.path(*A)) == [A] - assert B not in game.get_members(gbt.H.path(*A)) + assert [m.actions for m in game.get_members(gbt.H.path(*A))] == [A] + assert B not in [m.actions for m in game.get_members(gbt.H.path(*A))] def test_make_infoset_across_different_source_players(): @@ -720,7 +722,7 @@ def test_make_infoset_across_different_source_players(): assert game.get_player(gbt.H.path(*n2)) == "2" assert game.get_player(gbt.H.path(*n3)) == "3" game.make_infoset(gbt.H.path(...), "1") - assert n2 in game.get_members(gbt.H.path(*n3)) + assert n2 in [m.actions for m in game.get_members(gbt.H.path(*n3))] assert game.get_player(gbt.H.path(*n2)) == "1" assert game.get_player(gbt.H.path(*n3)) == "1" @@ -859,16 +861,18 @@ def test_set_move_actions_reorder_carries_subtrees(): game.append_move(gbt.H.path(), "Bob", ["x", "y"]) game.append_move(gbt.H.path(...), "Alice", ["a", "b", "c"]) game.append_move( - gbt.H.path(..., ...).filter(lambda h: (h[0], h[1]) in (("x", "a"), ("y", "b"))), + gbt.H.path(..., ...).filter( + lambda h: (h[0].action, h[1].action) in (("x", "a"), ("y", "b")) + ), "Bob", ["l", "r"] ) members = game.get_members(gbt.H.path("x")) - children_before = [{label: (*member, label) for label in ("a", "b", "c")} + children_before = [{label: (*member.actions, label) for label in ("a", "b", "c")} for member in members] game.set_move_actions(gbt.H.path("x"), ["c", "a", "b"]) assert game.get_actions(gbt.H.path("x")) == ["c", "a", "b"] for member, children in zip(members, children_before, strict=True): - assert games.children_histories(game, member) == [ + assert games.children_histories(game, member.actions) == [ children["c"], children["a"], children["b"] ] diff --git a/tests/test_tree_queries.py b/tests/test_tree_queries.py index f160988c28..1b7cd0ecf8 100644 --- a/tests/test_tree_queries.py +++ b/tests/test_tree_queries.py @@ -91,8 +91,10 @@ def test_get_members(): event, including a shared infoset across the members of different chance outcomes; empty for a terminal node.""" game = games.read_from_file("stripped_down_poker.efg") - assert game.get_members(gbt.H.path()) == [()] - assert game.get_members(gbt.H.path("King", "Bet")) == [("King", "Bet"), ("Queen", "Bet")] + assert [h.actions for h in game.get_members(gbt.H.path())] == [()] + assert [h.actions for h in game.get_members(gbt.H.path("King", "Bet"))] == [ + ("King", "Bet"), ("Queen", "Bet") + ] assert game.get_members(gbt.H.path("King", "Fold")) == [] @@ -274,7 +276,7 @@ def test_subgame_roots(test_case: SubgameRootsTestCase): """ game = test_case.factory() - actual_paths = [list(reversed(history)) for history in game.get_subgame_roots()] + actual_paths = [list(reversed(history.actions)) for history in game.get_subgame_roots()] assert sorted(actual_paths) == sorted(test_case.expected_paths) @@ -352,7 +354,7 @@ def test_get_subgame_roots_postorder_sequence(test_case: SubgameStructureTestCas """`Game.get_subgame_roots` produces the expected postorder sequence of subgame-root Histories (children before parents).""" game = test_case.factory() - assert game.get_subgame_roots() == test_case.roots + assert [h.actions for h in game.get_subgame_roots()] == test_case.roots @pytest.mark.parametrize("test_case", SUBGAME_STRUCTURE_CASES) @@ -368,8 +370,8 @@ def test_minimal_subgame_for_each_infoset(test_case: SubgameStructureTestCase): for player in game.players: for i, history in enumerate(game.get_infosets(player)): key = (player, i) - selector = gbt.H.path(*history) - assert game.get_minimal_subgame(selector) == expected_root_for_key[key] + selector = gbt.H.path(*history.actions) + assert game.get_minimal_subgame(selector).actions == expected_root_for_key[key] @pytest.mark.parametrize("game_file, expected_unreachable_paths", [ @@ -399,7 +401,7 @@ def test_get_strategy_unreachable(game_file: str, expected_unreachable_paths: li game = game_file if isinstance(game_file, gbt.Game) else games.read_from_file(game_file) actual_unreachable_paths = [ - list(reversed(history)) for history in game.get_strategy_unreachable() + list(reversed(history.actions)) for history in game.get_strategy_unreachable() ] assert actual_unreachable_paths == expected_unreachable_paths @@ -512,7 +514,7 @@ def dfs(history: tuple) -> typing.Iterator[tuple]: yield from dfs((*history, action)) expected = list(dfs(())) - assert game_obj.get_histories(gbt.H.after()) == expected + assert [h.actions for h in game_obj.get_histories(gbt.H.after())] == expected def test_layout_tree():