diff --git a/doc/tutorials/02_extensive_form.ipynb b/doc/tutorials/02_extensive_form.ipynb index 1f4f6fd0e..bba579e9f 100644 --- a/doc/tutorials/02_extensive_form.ipynb +++ b/doc/tutorials/02_extensive_form.ipynb @@ -4,7 +4,30 @@ "cell_type": "markdown", "id": "96019084", "metadata": {}, - "source": "# 2) Extensive-form games\n\nIn the first tutorial, we used Gambit to set up the Prisoner's Dilemma, an example of a normal (strategic) form game.\n\nGambit can also be used to set up extensive-form games; the game is represented as a tree, where each node represents a decision point for a player, and the branches represent the possible actions they can take.\n\n**Example: One-shot trust game with binary actions**\n\n[Kreps (1990)](#references) introduced a game commonly referred to as the **trust game**.\nWe will build a one-shot version of this game using Gambit's game transformation operations.\n\nThe game can be defined as follows:\n- There are two players, a **Buyer** and a **Seller**.\n- The Buyer moves first and has two actions, **Trust** or **Not trust**.\n- If the Buyer chooses **Not trust**, then the game ends, and both players receive payoffs of `0`.\n- If the Buyer chooses **Trust**, then the Seller has a choice with two actions, **Honor** or **Abuse**.\n- If the Seller chooses **Honor**, both players receive payoffs of `1`;\n- If the Seller chooses **Abuse**, the Buyer receives a payoff of `-1` and the Seller receives a payoff of `2`.\n\nIn addition to `pygambit`, this tutorial introduces the `gtdraw` package, which can be used to draw extensive form games in Python.\nIf you're running this tutorial on your local machine, you'll need to install the requirements for [gtdraw](https://www.gambit-project.org/gtdraw/), which include LaTeX, in order to run the `gtdraw` cells.\nAnother option for visualising extensive form games is to install the Gambit GUI and use it to load the EFG file generated at the end of this tutorial." + "source": [ + "# 2) Extensive-form games\n", + "\n", + "In the first tutorial, we used Gambit to set up the Prisoner's Dilemma, an example of a normal (strategic) form game.\n", + "\n", + "Gambit can also be used to set up extensive-form games; the game is represented as a tree, where each node represents a decision point for a player, and the branches represent the possible actions they can take.\n", + "\n", + "**Example: One-shot trust game with binary actions**\n", + "\n", + "[Kreps (1990)](#references) introduced a game commonly referred to as the **trust game**.\n", + "We will build a one-shot version of this game using Gambit's game transformation operations.\n", + "\n", + "The game can be defined as follows:\n", + "- There are two players, a **Buyer** and a **Seller**.\n", + "- The Buyer moves first and has two actions, **Trust** or **Not trust**.\n", + "- If the Buyer chooses **Not trust**, then the game ends, and both players receive payoffs of `0`.\n", + "- If the Buyer chooses **Trust**, then the Seller has a choice with two actions, **Honor** or **Abuse**.\n", + "- If the Seller chooses **Honor**, both players receive payoffs of `1`;\n", + "- If the Seller chooses **Abuse**, the Buyer receives a payoff of `-1` and the Seller receives a payoff of `2`.\n", + "\n", + "In addition to `pygambit`, this tutorial introduces the `gtdraw` package, which can be used to draw extensive form games in Python.\n", + "If you're running this tutorial on your local machine, you'll need to install the requirements for [gtdraw](https://www.gambit-project.org/gtdraw/), which include LaTeX, in order to run the `gtdraw` cells.\n", + "Another option for visualising extensive form games is to install the Gambit GUI and use it to load the EFG file generated at the end of this tutorial." + ] }, { "cell_type": "code", @@ -12,7 +35,11 @@ "id": "5946289b", "metadata": {}, "outputs": [], - "source": "from gtdraw import draw\n\nimport pygambit as gbt" + "source": [ + "from gtdraw import draw\n", + "\n", + "import pygambit as gbt" + ] }, { "cell_type": "markdown", @@ -49,7 +76,9 @@ "id": "3cd94917", "metadata": {}, "outputs": [], - "source": "draw(g)" + "source": [ + "draw(g)" + ] }, { "cell_type": "markdown", @@ -81,7 +110,9 @@ "id": "45638fda-7e25-4c8e-b709-24b05780581b", "metadata": {}, "outputs": [], - "source": "draw(g)" + "source": [ + "draw(g)" + ] }, { "cell_type": "markdown", @@ -111,7 +142,9 @@ "id": "ce41e9fe-cca4-46fb-8e9d-b2c27342e5ef", "metadata": {}, "outputs": [], - "source": "draw(g)" + "source": [ + "draw(g)" + ] }, { "cell_type": "markdown", @@ -134,10 +167,7 @@ "source": [ "g.set_outcome(\n", " g.root.children[\"Trust\"].children[\"Honor\"],\n", - " outcome=g.add_outcome(\n", - " payoffs=[1, 1],\n", - " label=\"Trustworthy\"\n", - " )\n", + " outcome=g.add_outcome(\"Trustworthy\", [1, 1])\n", ")" ] }, @@ -147,7 +177,9 @@ "id": "b3408c55-714e-4a6f-b598-e338839442e4", "metadata": {}, "outputs": [], - "source": "draw(g)" + "source": [ + "draw(g)" + ] }, { "cell_type": "markdown", @@ -166,10 +198,7 @@ "source": [ "g.set_outcome(\n", " g.root.children[\"Trust\"].children[\"Abuse\"],\n", - " outcome=g.add_outcome(\n", - " payoffs=[-1, 2],\n", - " label=\"Untrustworthy\"\n", - " )\n", + " outcome=g.add_outcome(\"Untrustworthy\", [-1, 2])\n", ")" ] }, @@ -179,7 +208,9 @@ "id": "09bedb3a-aac7-46e6-ae93-c47932c746d4", "metadata": {}, "outputs": [], - "source": "draw(g)" + "source": [ + "draw(g)" + ] }, { "cell_type": "markdown", @@ -198,10 +229,7 @@ "source": [ "g.set_outcome(\n", " g.root.children[\"Not trust\"],\n", - " g.add_outcome(\n", - " payoffs=[0, 0],\n", - " label=\"Opt-out\"\n", - " )\n", + " g.add_outcome(\"Opt-out\", [0, 0])\n", ")" ] }, @@ -211,7 +239,9 @@ "id": "cba0e562-2989-4dae-a0f0-b121635ba032", "metadata": {}, "outputs": [], - "source": "draw(g)" + "source": [ + "draw(g)" + ] }, { "cell_type": "markdown", @@ -258,7 +288,10 @@ "id": "56899a29-cc53-48db-9eb4-ed2295517400", "metadata": {}, "outputs": [], - "source": "g = gbt.catalog.load(\"journals/ijgt/selten1975/fig2\")\ndraw(g)" + "source": [ + "g = gbt.catalog.load(\"journals/ijgt/selten1975/fig2\")\n", + "draw(g)" + ] }, { "cell_type": "markdown", diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 5dc52da3d..7f47f183f 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -4,7 +4,38 @@ "cell_type": "markdown", "id": "98eb65d8", "metadata": {}, - "source": "# 3) Stripped-down poker\n\nIn this tutorial, we'll create an extensive-form representation of a one-card poker game from [Reiley et al (2008)](#references), a classroom game under the name \"stripped-down poker\".\nThis is perhaps the simplest interesting game with imperfect information.\n\nWe'll use \"stripped-down poker\" to demonstrate and explain the following with Gambit:\n\n1. Setting up an extensive-form game with imperfect information using [information sets](#information-sets)\n2. [Computing and interpreting Nash equilibria](#computing-and-interpreting-nash-equilibria) and understanding mixed behaviour and mixed strategy profiles\n3. [Acceptance criteria for Nash equilibria](#acceptance-criteria-for-nash-equilibria)\n\nIn our version of the game, there are two players, **Alice** and **Bob**, and a deck of cards, with equal numbers of **King** and **Queen** cards.\n\n- The game begins with each player putting \\$1 in the pot.\n - A card is dealt at random to Alice.\n - Alice observes her card.\n - Bob does not observe the card.\n- Alice then chooses either to **Bet** or to **Fold**.\n - If she chooses to Fold, Bob wins the pot and the game ends.\n - If she chooses to Bet, she adds another \\$1 to the pot.\n- Bob then chooses either to **Call** or **Fold**.\n - If he chooses to Fold, Alice wins the pot and the game ends.\n - If he chooses to Call, he adds another $1 to the pot.\n- There is then a showdown, in which Alice reveals her card.\n - If she has a King, then she wins the pot.\n - If she has a Queen, then Bob wins the pot.\n\nIn addition to `pygambit`, this tutorial uses the `gtdraw` package, which can be used to draw extensive form games in Python.\nIf you're running this tutorial on your local machine, you'll need to install the requirements for [gtdraw](https://www.gambit-project.org/gtdraw/), which include LaTeX, in order to run the `gtdraw` cells.\nAnother option for visualising extensive form games is to install the Gambit GUI and use it to load a saved EFG file." + "source": [ + "# 3) Stripped-down poker\n", + "\n", + "In this tutorial, we'll create an extensive-form representation of a one-card poker game from [Reiley et al (2008)](#references), a classroom game under the name \"stripped-down poker\".\n", + "This is perhaps the simplest interesting game with imperfect information.\n", + "\n", + "We'll use \"stripped-down poker\" to demonstrate and explain the following with Gambit:\n", + "\n", + "1. Setting up an extensive-form game with imperfect information using [information sets](#information-sets)\n", + "2. [Computing and interpreting Nash equilibria](#computing-and-interpreting-nash-equilibria) and understanding mixed behaviour and mixed strategy profiles\n", + "3. [Acceptance criteria for Nash equilibria](#acceptance-criteria-for-nash-equilibria)\n", + "\n", + "In our version of the game, there are two players, **Alice** and **Bob**, and a deck of cards, with equal numbers of **King** and **Queen** cards.\n", + "\n", + "- The game begins with each player putting \\$1 in the pot.\n", + " - A card is dealt at random to Alice.\n", + " - Alice observes her card.\n", + " - Bob does not observe the card.\n", + "- Alice then chooses either to **Bet** or to **Fold**.\n", + " - If she chooses to Fold, Bob wins the pot and the game ends.\n", + " - If she chooses to Bet, she adds another \\$1 to the pot.\n", + "- Bob then chooses either to **Call** or **Fold**.\n", + " - If he chooses to Fold, Alice wins the pot and the game ends.\n", + " - If he chooses to Call, he adds another $1 to the pot.\n", + "- There is then a showdown, in which Alice reveals her card.\n", + " - If she has a King, then she wins the pot.\n", + " - If she has a Queen, then Bob wins the pot.\n", + "\n", + "In addition to `pygambit`, this tutorial uses the `gtdraw` package, which can be used to draw extensive form games in Python.\n", + "If you're running this tutorial on your local machine, you'll need to install the requirements for [gtdraw](https://www.gambit-project.org/gtdraw/), which include LaTeX, in order to run the `gtdraw` cells.\n", + "Another option for visualising extensive form games is to install the Gambit GUI and use it to load a saved EFG file." + ] }, { "cell_type": "code", @@ -12,7 +43,11 @@ "id": "69cbfe81", "metadata": {}, "outputs": [], - "source": "from gtdraw import draw\n\nimport pygambit as gbt" + "source": [ + "from gtdraw import draw\n", + "\n", + "import pygambit as gbt" + ] }, { "cell_type": "markdown", @@ -89,7 +124,9 @@ "id": "867cb1d8-7a5d-45d1-9349-9bbc2a4e2344", "metadata": {}, "outputs": [], - "source": "draw(g, color_scheme=\"gambit\")" + "source": [ + "draw(g, color_scheme=\"gambit\")" + ] }, { "cell_type": "markdown", @@ -126,7 +163,9 @@ "id": "0c522c2d-992e-48b6-a1f8-0696d33cdbe0", "metadata": {}, "outputs": [], - "source": "draw(g, color_scheme=\"gambit\")" + "source": [ + "draw(g, color_scheme=\"gambit\")" + ] }, { "cell_type": "markdown", @@ -166,7 +205,9 @@ "id": "e85b3346-2fea-4a73-aa72-9efb436c68c1", "metadata": {}, "outputs": [], - "source": "draw(g, color_scheme=\"gambit\")" + "source": [ + "draw(g, color_scheme=\"gambit\")" + ] }, { "cell_type": "markdown", @@ -188,10 +229,10 @@ "metadata": {}, "outputs": [], "source": [ - "win_big = g.add_outcome([2, -2], label=\"Win Big\")\n", - "win = g.add_outcome([1, -1], label=\"Win\")\n", - "lose_big = g.add_outcome([-2, 2], label=\"Lose Big\")\n", - "lose = g.add_outcome([-1, 1], label=\"Lose\")" + "win_big = g.add_outcome(\"Win Big\", [2, -2])\n", + "win = g.add_outcome(\"Win\", [1, -1])\n", + "lose_big = g.add_outcome(\"Lose Big\", [-2, 2])\n", + "lose = g.add_outcome(\"Lose\", [-1, 1])" ] }, { @@ -230,7 +271,9 @@ "id": "fdee7b53-7820-44df-9d17-d15d0b9667aa", "metadata": {}, "outputs": [], - "source": "draw(g, color_scheme=\"gambit\")" + "source": [ + "draw(g, color_scheme=\"gambit\")" + ] }, { "cell_type": "markdown", diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index 21fbf0256..de36447bc 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -468,7 +468,17 @@ "id": "b913fc7a", "metadata": {}, "outputs": [], - "source": "from gtdraw import draw\n\ndraw(\n gbt_hanabi_game,\n color_scheme=\"gambit\",\n edge_thickness=2,\n action_label_position=0.8,\n shared_terminal_depth=True\n)" + "source": [ + "from gtdraw import draw\n", + "\n", + "draw(\n", + " gbt_hanabi_game,\n", + " color_scheme=\"gambit\",\n", + " edge_thickness=2,\n", + " action_label_position=0.8,\n", + " shared_terminal_depth=True\n", + ")" + ] }, { "cell_type": "markdown", @@ -697,10 +707,10 @@ " actions=[\"Call\", \"Fold\"]\n", ")\n", "\n", - "win_big = gbt_one_card_poker.add_outcome([2, -2], label=\"Win Big\")\n", - "win = gbt_one_card_poker.add_outcome([1, -1], label=\"Win\")\n", - "lose_big = gbt_one_card_poker.add_outcome([-2, 2], label=\"Lose Big\")\n", - "lose = gbt_one_card_poker.add_outcome([-1, 1], label=\"Lose\")\n", + "win_big = gbt_one_card_poker.add_outcome(\"Win Big\", [2, -2])\n", + "win = gbt_one_card_poker.add_outcome(\"Win\", [1, -1])\n", + "lose_big = gbt_one_card_poker.add_outcome(\"Lose Big\", [-2, 2])\n", + "lose = gbt_one_card_poker.add_outcome(\"Lose\", [-1, 1])\n", "\n", "# Alice folds, Bob wins small\n", "gbt_one_card_poker.set_outcome(\n", @@ -741,7 +751,9 @@ "id": "ed920d33-b7c6-4cc1-b055-7244a5bf42d8", "metadata": {}, "outputs": [], - "source": "draw(gbt_one_card_poker, color_scheme=\"gambit\")" + "source": [ + "draw(gbt_one_card_poker, color_scheme=\"gambit\")" + ] }, { "cell_type": "markdown", @@ -833,7 +845,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.13" + "version": "3.13.5" } }, "nbformat": 4, diff --git a/src/games/file.cc b/src/games/file.cc index ed36d6a27..0d772d18f 100644 --- a/src/games/file.cc +++ b/src/games/file.cc @@ -437,23 +437,39 @@ void ReadOutcomeList(GameFileLexer &p_parser, Game &p_nfg) auto players = p_nfg->GetPlayers(); p_parser.GetNextToken(); + // Buffer raw labels + payoffs so labels can be normalized (unique, nonempty) + // before the outcome objects are created (NewOutcome now rejects empty/duplicate). + std::vector labels; + std::vector> payoff_lists; + while (p_parser.GetCurrentToken() == TOKEN_LBRACE) { p_parser.ExpectNextToken(TOKEN_TEXT, "outcome name"); - auto outcome = p_nfg->NewOutcome(); - outcome->SetLabel(p_parser.GetLastText()); + labels.push_back(p_parser.GetLastText()); p_parser.GetNextToken(); - for (auto player : players) { + std::vector payoffs; + for (size_t i = 0; i < players.size(); ++i) { p_parser.ExpectCurrentToken(TOKEN_NUMBER, "numerical payoff"); - outcome->SetPayoff(player, Number(p_parser.GetLastText())); + payoffs.emplace_back(p_parser.GetLastText()); p_parser.AcceptNextToken(TOKEN_COMMA); } + payoff_lists.push_back(payoffs); + p_parser.ExpectCurrentToken(TOKEN_RBRACE, "'}'"); p_parser.GetNextToken(); } - p_parser.ExpectCurrentToken(TOKEN_RBRACE, "'}'"); p_parser.GetNextToken(); + + NormalizeLabelStrings(labels); + for (size_t i = 0; i < labels.size(); ++i) { + auto outcome = p_nfg->NewOutcome(labels[i]); + auto player_it = players.begin(); + for (const auto &payoff : payoff_lists[i]) { + outcome->SetPayoff(*player_it, payoff); + ++player_it; + } + } } void ParseOutcomeBody(GameFileLexer &p_parser, Game &p_nfg) @@ -511,9 +527,24 @@ Game BuildNfg(GameFileLexer &p_parser, TableFileGame &p_data) // Temporary representation classes //========================================================================= +/// An outcome definition encountered during the parse. Outcomes are not +/// created until the whole tree has been read, so that their labels can be +/// normalized in one pass before creation, as NewOutcome enforces +/// the label requirements at creation time. +struct OutcomeRecord { + std::string m_label; + std::vector m_payoffs; +}; + class TreeData { public: - std::map m_outcomeMap; + std::map m_outcomeRecords; + /// Outcome ids in order of first occurrence in the file; determines the + /// creation order (and hence numbering) of the outcomes, matching the + /// order in which the previous implementation created them. + std::vector m_outcomeOrder; + /// Deferred node-to-outcome attachments, replayed after outcomes are created. + std::vector> m_nodeOutcomes; std::map> m_infosetMap; }; @@ -536,32 +567,27 @@ void ReadPlayers(GameFileLexer &p_state, Game &p_game, TreeData &p_treeData) } void CheckOutcomeDefinition(const GameFileLexer &p_state, int p_outcomeId, - const GameOutcome &p_outcome, const std::string &p_label, - const GameRep::Players &p_players, + const OutcomeRecord &p_record, const std::string &p_label, const std::vector &p_payoffs) { - if (p_outcome->GetLabel() != p_label) { + if (p_record.m_label != p_label) { p_state.OnParseError("Outcome label does not match previous definition " "(outcome " + std::to_string(p_outcomeId) + ")"); } - - if (p_players.size() != p_payoffs.size()) { + if (p_record.m_payoffs.size() != p_payoffs.size()) { p_state.OnParseError("Outcome payoff count mismatch " "(outcome " + std::to_string(p_outcomeId) + ")"); } - - auto player_it = p_players.begin(); - for (const auto &payoff : p_payoffs) { - if (p_outcome->GetPayoff(*player_it) != - static_cast(payoff)) { + for (size_t i = 0; i < p_payoffs.size(); ++i) { + if (static_cast(p_record.m_payoffs[i]) != + static_cast(p_payoffs[i])) { p_state.OnParseError("Outcome payoffs do not match previous definition " "(outcome " + - std::to_string(p_outcomeId) + ", player " + - std::to_string((*player_it)->GetNumber()) + ")"); + std::to_string(p_outcomeId) + ", player " + std::to_string(i + 1) + + ")"); } - ++player_it; } } @@ -589,32 +615,53 @@ void ParseOutcome(GameFileLexer &p_state, Game &p_game, TreeData &p_treeData, Ga p_state.ExpectCurrentToken(TOKEN_RBRACE, "'}'"); p_state.GetNextToken(); - GameOutcome outcome; - if (!contains(p_treeData.m_outcomeMap, outcomeId)) { - outcome = p_game->NewOutcome(); - p_treeData.m_outcomeMap[outcomeId] = outcome; - outcome->SetLabel(label); - auto player_it = p_game->GetPlayers().begin(); - for (const auto &payoff : payoffs) { - outcome->SetPayoff(*player_it, payoff); - ++player_it; - } + if (!contains(p_treeData.m_outcomeRecords, outcomeId)) { + p_treeData.m_outcomeRecords.emplace(outcomeId, OutcomeRecord{label, payoffs}); + p_treeData.m_outcomeOrder.push_back(outcomeId); } else { - outcome = p_treeData.m_outcomeMap.at(outcomeId); - CheckOutcomeDefinition(p_state, outcomeId, outcome, label, p_game->GetPlayers(), payoffs); + CheckOutcomeDefinition(p_state, outcomeId, p_treeData.m_outcomeRecords.at(outcomeId), label, + payoffs); } - p_game->SetOutcome(p_node, outcome); + p_treeData.m_nodeOutcomes.emplace_back(p_node, outcomeId); } else if (outcomeId != 0) { // The node entry does not contain information about the outcome. // This means the outcome should have been defined already. - try { - p_game->SetOutcome(p_node, p_treeData.m_outcomeMap.at(outcomeId)); - } - catch (std::out_of_range) { + if (!contains(p_treeData.m_outcomeRecords, outcomeId)) { p_state.OnParseError("Outcome not defined"); } + p_treeData.m_nodeOutcomes.emplace_back(p_node, outcomeId); + } +} + +/// Create the game's outcomes from the definitions buffered during the parse. +/// Labels are normalized in first-occurrence order before creation, so that +/// the label requirements enforced by NewOutcome (nonempty, unique) are +/// satisfied; this matches the treatment of outcome labels read from .nfg +/// files, and produces the same labels the previous post-parse normalization +/// pass produced. +void CreateOutcomes(const Game &p_game, const TreeData &p_treeData) +{ + std::vector labels; + for (const int id : p_treeData.m_outcomeOrder) { + labels.push_back(p_treeData.m_outcomeRecords.at(id).m_label); + } + NormalizeLabelStrings(labels); + + std::map created; + auto label_it = labels.begin(); + for (const int id : p_treeData.m_outcomeOrder) { + auto outcome = p_game->NewOutcome(*label_it++); + auto player_it = p_game->GetPlayers().begin(); + for (const auto &payoff : p_treeData.m_outcomeRecords.at(id).m_payoffs) { + outcome->SetPayoff(*player_it, payoff); + ++player_it; + } + created.emplace(id, outcome); + } + for (const auto &[node, id] : p_treeData.m_nodeOutcomes) { + p_game->SetOutcome(node, created.at(id)); } } @@ -905,6 +952,7 @@ Game ReadEfgFile(std::istream &p_stream) parser.GetNextToken(); } ParseNode(parser, game, game->GetRoot(), treeData); + CreateOutcomes(game, treeData); NormalizeGameLabels(game); return game; } diff --git a/src/games/game.cc b/src/games/game.cc index 9a467565b..f912c8179 100644 --- a/src/games/game.cc +++ b/src/games/game.cc @@ -39,8 +39,10 @@ namespace Gambit { // class GameOutcomeRep //======================================================================== -GameOutcomeRep::GameOutcomeRep(GameRep *p_game, int p_number) : m_game(p_game), m_number(p_number) +GameOutcomeRep::GameOutcomeRep(GameRep *p_game, int p_number, const std::string &p_label) + : m_game(p_game), m_number(p_number), m_label(p_label) { + CheckLabel(p_label); for (const auto &player : m_game->m_players) { m_payoffs[player.get()] = Number(); } diff --git a/src/games/game.h b/src/games/game.h index 279fdd90c..bd598b723 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -184,7 +184,7 @@ class GameOutcomeRep : public std::enable_shared_from_this { /// @name Lifecycle //@{ /// Creates a new outcome object, with payoffs set to zero - GameOutcomeRep(GameRep *p_game, int p_number); + GameOutcomeRep(GameRep *p_game, int p_number, const std::string &p_label); ~GameOutcomeRep() = default; //@} @@ -201,11 +201,7 @@ class GameOutcomeRep : public std::enable_shared_from_this { /// Returns the text label associated with the outcome const std::string &GetLabel() const { return m_label; } /// Sets the text label associated with the outcome - void SetLabel(const std::string &p_label) - { - CheckLabel(p_label); - m_label = p_label; - } + void SetLabel(const std::string &p_label); /// Gets the payoff associated with the outcome to the player template const T &GetPayoff(const GamePlayer &p_player) const; @@ -763,6 +759,8 @@ class GameRep : public std::enable_shared_from_this { void IndexStrategies() const; /// Validate that p_label is a nonempty, valid, unique label for a player of this game, void CheckPlayerLabel(const std::string &p_label) const; + /// Validate that p_label is a nonempty, valid, unique label for an outcome of this game. + void CheckOutcomeLabel(const std::string &p_label) const; //@} /// Hooks for derived classes to update lazily-computed orderings if required @@ -1224,7 +1222,7 @@ class GameRep : public std::enable_shared_from_this { return Outcomes(std::const_pointer_cast(shared_from_this()), &m_outcomes); } /// Creates a new outcome in the game - virtual GameOutcome NewOutcome() { throw UndefinedException(); } + virtual GameOutcome NewOutcome(const std::string &p_label) { throw UndefinedException(); } /// Deletes the specified outcome from the game virtual void DeleteOutcome(const GameOutcome &) { throw UndefinedException(); } //@} @@ -1278,6 +1276,14 @@ class GameRep : public std::enable_shared_from_this { // all classes to be defined. inline Game GameOutcomeRep::GetGame() const { return m_game->shared_from_this(); } +inline void GameOutcomeRep::SetLabel(const std::string &p_label) +{ + if (p_label == m_label) { + return; + } + GetGame()->CheckOutcomeLabel(p_label); + m_label = p_label; +} template const T &GameOutcomeRep::GetPayoff(const GamePlayer &p_player) const { @@ -1371,6 +1377,18 @@ inline void GameRep::CheckPlayerLabel(const std::string &p_label) const } } } +inline void GameRep::CheckOutcomeLabel(const std::string &p_label) const +{ + if (p_label.empty()) { + throw ValueException("Outcome label must not be empty"); + } + CheckLabel(p_label); + for (const auto &outcome : m_outcomes) { + if (outcome->GetLabel() == p_label) { + throw ValueException("Outcome label must be unique within the game"); + } + } +} inline bool GameInfosetRep::IsChanceInfoset() const { return m_player->IsChance(); } inline Game GamePlayerRep::GetGame() const { return m_game->shared_from_this(); } diff --git a/src/games/gameexpl.cc b/src/games/gameexpl.cc index 718b48dea..90823eb3b 100644 --- a/src/games/gameexpl.cc +++ b/src/games/gameexpl.cc @@ -58,9 +58,10 @@ Rational GameExplicitRep::GetMaxPayoff() const // GameExplicitRep: Outcomes //------------------------------------------------------------------------ -GameOutcome GameExplicitRep::NewOutcome() +GameOutcome GameExplicitRep::NewOutcome(const std::string &p_label) { - m_outcomes.push_back(std::make_shared(this, m_outcomes.size() + 1)); + CheckOutcomeLabel(p_label); + m_outcomes.push_back(std::make_shared(this, m_outcomes.size() + 1, p_label)); return m_outcomes.back(); } diff --git a/src/games/gameexpl.h b/src/games/gameexpl.h index 06a72308b..092e3b970 100644 --- a/src/games/gameexpl.h +++ b/src/games/gameexpl.h @@ -42,7 +42,7 @@ class GameExplicitRep : public GameRep { /// @name Outcomes //@{ /// Creates a new outcome in the game - GameOutcome NewOutcome() override; + GameOutcome NewOutcome(const std::string &p_label) override; /// @name Writing data files //@{ diff --git a/src/games/gametable.cc b/src/games/gametable.cc index 798c783c7..4bee172d8 100644 --- a/src/games/gametable.cc +++ b/src/games/gametable.cc @@ -392,7 +392,7 @@ GameTableRep::GameTableRep(const std::vector &dim, bool p_sparseOutcomes /* else { m_outcomes = std::vector>(m_results.size()); std::generate(m_outcomes.begin(), m_outcomes.end(), [this, outc = 1]() mutable { - return std::make_shared(this, outc++); + return std::make_shared(this, outc++, ""); }); std::transform(m_outcomes.begin(), m_outcomes.end(), m_results.begin(), [](const std::shared_ptr &c) { return c.get(); }); diff --git a/src/gui/gamedoc.cc b/src/gui/gamedoc.cc index 61d5efd0f..6dc4a2088 100644 --- a/src/gui/gamedoc.cc +++ b/src/gui/gamedoc.cc @@ -647,15 +647,40 @@ void GameDocument::DoSetPlayer(GameNode p_node, GamePlayer p_player) } } +namespace { + +std::string GenerateOutcomeLabel(const Game &p_game) +{ + std::set outcomeLabels; + for (const auto &outcome : p_game->GetOutcomes()) { + outcomeLabels.insert(outcome->GetLabel()); + } + int outc = p_game->GetOutcomes().size() + 1; + while (contains(outcomeLabels, "Outcome " + std::to_string(outc))) { + outc++; + } + return "Outcome " + std::to_string(outc); +} + +} // namespace + void GameDocument::DoNewOutcome(GameNode p_node) { - m_game->SetOutcome(p_node, m_game->NewOutcome()); + std::set outcomeLabels; + for (const auto &outcome : m_game->GetOutcomes()) { + outcomeLabels.insert(outcome->GetLabel()); + } + int outc = m_game->GetOutcomes().size() + 1; + while (contains(outcomeLabels, "Outcome " + std::to_string(outc))) { + outc++; + } + m_game->SetOutcome(p_node, m_game->NewOutcome(GenerateOutcomeLabel(m_game))); NotifyChanged(GameModificationType::GamePayoffs); } void GameDocument::DoNewOutcome(const PureStrategyProfile &p_profile) { - p_profile->SetOutcome(m_game->NewOutcome()); + p_profile->SetOutcome(m_game->NewOutcome(GenerateOutcomeLabel(m_game))); NotifyChanged(GameModificationType::GamePayoffs); } @@ -707,12 +732,10 @@ void GameDocument::DoSetOutcomeData(const GameNode &p_node, const wxString &p_la } if (!outcome) { - outcome = GetGame()->NewOutcome(); - GetGame()->SetOutcome(p_node, outcome); + outcome = m_game->NewOutcome(p_label.ToStdString()); + m_game->SetOutcome(p_node, outcome); } - outcome->SetLabel(label); - for (size_t player = 1; player <= GetGame()->NumPlayers(); ++player) { outcome->SetPayoff(GetGame()->GetPlayer(player), Number(p_payoffs[player - 1].ToStdString())); } @@ -731,7 +754,7 @@ void GameDocument::DoRemoveOutcome(GameNode p_node) void GameDocument::DoCopyOutcome(GameNode p_node, GameOutcome p_outcome) { - const GameOutcome outcome = m_game->NewOutcome(); + const GameOutcome outcome = m_game->NewOutcome(GenerateOutcomeLabel(m_game)); outcome->SetLabel("Outcome" + lexical_cast(outcome->GetNumber())); for (const auto &player : m_game->GetPlayers()) { outcome->SetPayoff(player, p_outcome->GetPayoff(player)); diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index a9e25bfb1..b6be8e913 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -324,7 +324,7 @@ cdef extern from "games/game.h": int NumOutcomes() except + c_GameOutcome GetOutcome(int) except +IndexError Outcomes GetOutcomes() except + - c_GameOutcome NewOutcome() except + + c_GameOutcome NewOutcome(string) except +ValueError void DeleteOutcome(c_GameOutcome) except + int NumNodes() except + diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 6685ff914..f95d47aba 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -2112,22 +2112,27 @@ class Game: self.game.deref().SetPlayer(resolved_infoset.infoset, resolved_player.player) def add_outcome(self, - payoffs: list | None = None, - label: str = "") -> Outcome: + label: str, + payoffs: list | None = None) -> Outcome: """Add a new outcome to the game. + .. versionchanged:: 16.7.0 + A label is now required and must be nonempty and unique among the + game's outcomes. + Parameters ---------- + label : str + The label for the outcome. Must be nonempty and not already in use + by another outcome in the game. payoffs : list, optional The payoffs of the outcome to each player. - label : str, default "" - The label for the outcome Raises ------ ValueError If `payoffs` is specified but is not the same length as the number of players - in the game. + in the game, or if `label` is empty or already in use by another outcome. Returns ------- @@ -2139,9 +2144,7 @@ class Game: raise ValueError("add_outcome(): number of payoffs must equal number of players") else: payoffs = [0 for _ in self.players] - c = Outcome.wrap(self.game.deref().NewOutcome()) - if str(label) != "": - c.label = str(label) + c = Outcome.wrap(self.game.deref().NewOutcome(label.encode("ascii"))) for player, payoff in zip(self.players, payoffs, strict=True): c[player] = payoff return c diff --git a/src/pygambit/outcome.pxi b/src/pygambit/outcome.pxi index 975cf9b34..5c42cfd95 100644 --- a/src/pygambit/outcome.pxi +++ b/src/pygambit/outcome.pxi @@ -65,18 +65,14 @@ class Outcome: """The text label associated with this outcome. .. versionchanged:: 16.7.0 - An invalid label now raises ``ValueError``: a label may contain only printable ASCII - characters and spaces, not begin/end with a space, nor have two consecutive spaces. + An outcome label must be nonempty and unique within the game; an empty or duplicate + label now raises ``ValueError``. A label may contain only printable ASCII characters + and spaces, not begin/end with a space, nor have two consecutive spaces. """ return self.outcome.deref().GetLabel().decode("ascii") @label.setter def label(self, value: str) -> None: - if value == self.label: - return - if value == "" or value in (outcome.label for outcome in self.game.outcomes): - warnings.warn("In a future version, outcomes must have unique labels", - FutureWarning) self.outcome.deref().SetLabel(value.encode("ascii")) @property diff --git a/tests/games.py b/tests/games.py index 872811320..9e73e32d2 100644 --- a/tests/games.py +++ b/tests/games.py @@ -48,7 +48,7 @@ def create_efg_corresponding_to_bimatrix_game( for i, j in itertools.product(range(m), range(n)): g.set_outcome( g.root.children[str(i)].children[str(j)], - g.add_outcome([A[i, j], B[i, j]]) + g.add_outcome(f"({i},{j})", [A[i, j], B[i, j]]) ) return g @@ -77,7 +77,7 @@ def create_2x2_zero_sum_efg(variant: None | str = None) -> gbt.Game: if variant == "missing term outcome": g.delete_outcome(g.root.children["0"].children["1"].outcome) elif variant == "with neutral outcome": - neutral = g.add_outcome([0, 0], label="neutral") + neutral = g.add_outcome("neutral", [0, 0]) g.set_outcome(g.root.children["0"], neutral) return g @@ -107,14 +107,14 @@ def create_stripped_down_poker_efg(nonterm_outcomes: bool = False) -> gbt.Game: deals = ["King", "Queen"] g.append_move(g.root, g.players.chance, deals) - ante_outcome = g.add_outcome([-1, -1], label="Ante") + ante_outcome = g.add_outcome("Ante", [-1, -1]) g.set_outcome(g.root, ante_outcome) - alice_folds_outcome = g.add_outcome([0, 2], label="Alice Folds") - alice_bets_outcome = g.add_outcome([-1, 0], label="Alice Bets") - bob_folds_outcome = g.add_outcome([3, 0], label="Bob Folds") - bob_calls_and_wins_outcome = g.add_outcome([0, 3], label="Bob Calls and Wins") - bob_calls_and_loses_outcome = g.add_outcome([4, -1], label="Bob Calls and Loses") + alice_folds_outcome = g.add_outcome("Alice Folds", [0, 2]) + alice_bets_outcome = g.add_outcome("Alice Bets", [-1, 0]) + bob_folds_outcome = g.add_outcome("Bob Folds", [3, 0]) + bob_calls_and_wins_outcome = g.add_outcome("Bob Calls and Wins", [0, 3]) + bob_calls_and_loses_outcome = g.add_outcome("Bob Calls and Loses", [4, -1]) for node in g.root.children: g.append_move(node, player="Alice", actions=["Bet", "Fold"]) @@ -237,10 +237,10 @@ def bet(player, payoffs, pot): # create 4 possible outcomes just once payoffs_to_outcomes = { - (1, -1): g.add_outcome([1, -1], label="Alice wins 1"), - (2, -2): g.add_outcome([2, -2], label="Alice wins 2"), - (-1, 1): g.add_outcome([-1, 1], label="Bob wins 1"), - (-2, 2): g.add_outcome([-2, 2], label="Bob wins 2"), + (1, -1): g.add_outcome("Alice wins 1", [1, -1]), + (2, -2): g.add_outcome("Alice wins 2", [2, -2]), + (-1, 1): g.add_outcome("Bob wins 1", [-1, 1]), + (-2, 2): g.add_outcome("BOb wins 2", [-2, 2]), } for term_node in [n for n in g.nodes if n.is_terminal]: @@ -256,7 +256,7 @@ def _create_kuhn_poker_efg_nonterm_outcomes() -> gbt.Game: """ g = _create_kuhn_poker_efg_without_outcomes() - ante_outcome = g.add_outcome([-1, -1], label="Ante") + ante_outcome = g.add_outcome("Ante", [-1, -1]) g.set_outcome(g.root, ante_outcome) outcomes_dict = dict() @@ -264,27 +264,27 @@ def _create_kuhn_poker_efg_nonterm_outcomes() -> gbt.Game: # non-terminal outcomes for betting payoffs = [-1, 0] if player == "Alice" else [0, -1] tmp = f"{player} bets" - outcomes_dict[tmp] = g.add_outcome(payoffs, label=tmp) + outcomes_dict[tmp] = g.add_outcome(tmp, payoffs) # terminal outcomes for showdown after both check (pot of 2) payoffs = [2, 0] if player == "Alice" else [0, 2] tmp = f"{player} wins showdown for pot of 2" - outcomes_dict[tmp] = g.add_outcome(payoffs, label=tmp) + outcomes_dict[tmp] = g.add_outcome(tmp, payoffs) # terminal outcomes after a player folds (pot of 3) payoffs = [0, 3] if player == "Alice" else [3, 0] tmp = f"{player} folds" - outcomes_dict[tmp] = g.add_outcome(payoffs, label=tmp) + outcomes_dict[tmp] = g.add_outcome(tmp, payoffs) # terminal outcomes after a player calls and wins: bet first (-1) then win pot (4) payoffs = [3, 0] if player == "Alice" else [0, 3] tmp = f"{player} calls and wins" - outcomes_dict[tmp] = g.add_outcome(payoffs, label=tmp) + outcomes_dict[tmp] = g.add_outcome(tmp, payoffs) # terminal outcomes after a player calls and loses: bet first (-1) then lose pot (4) payoffs = [-1, 4] if player == "Alice" else [4, -1] tmp = f"{player} calls and loses" - outcomes_dict[tmp] = g.add_outcome(payoffs, label=tmp) + outcomes_dict[tmp] = g.add_outcome(tmp, payoffs) def add_outcomes(term_node): def get_path(node): @@ -396,21 +396,21 @@ def create_one_shot_trust_efg(unique_NE_variant: bool = False) -> gbt.Game: g.append_move(g.root.children["Trust"], "Seller", ["Honor", "Abuse"]) g.set_outcome( g.root.children["Trust"].children["Honor"], - g.add_outcome([1, 1], label="Trustworthy") + g.add_outcome("Trustworthy", [1, 1]) ) if unique_NE_variant: g.set_outcome( g.root.children["Trust"].children["Abuse"], - g.add_outcome(["1/2", 2], label="Untrustworthy") + g.add_outcome("Untrustworthy", ["1/2", 2]) ) else: g.set_outcome( g.root.children["Trust"].children["Abuse"], - g.add_outcome([-1, 2], label="Untrustworthy") + g.add_outcome("Untrustworthy", [-1, 2]) ) g.set_outcome( g.root.children["Not trust"], - g.add_outcome([0, 0], label="Opt-out") + g.add_outcome("Opt-out", [0, 0]) ) return g @@ -509,12 +509,12 @@ def gbt_game(self): payoffs = [2**t * self.m0, 2**t * self.m1] # take payoffs if current_player == "2": payoffs.reverse() - g.set_outcome(current_node.children["Take"], g.add_outcome(payoffs)) + g.set_outcome(current_node.children["Take"], g.add_outcome(f"take_{t}", payoffs)) if t == self.N - 1: # for last round, push payoffs payoffs = [2 ** (t + 1) * self.m1, 2 ** (t + 1) * self.m0] if current_player == "2": payoffs.reverse() - g.set_outcome(current_node.children["Push"], g.add_outcome(payoffs)) + g.set_outcome(current_node.children["Push"], g.add_outcome(f"push_{t}", payoffs)) current_node = current_node.children["Push"] current_player = "2" if current_player == "1" else "1" return g @@ -635,7 +635,9 @@ def reduced_strategies(self): def create_binary_tree(self, g, node, whose_turn, depth, max_depth): # whose_turn cycles through 0,1,n_players-1; current player is str(whose_turn + 1) if depth == max_depth: - g.set_outcome(node, g.add_outcome([0] * self.n_players)) + g.set_outcome( + node, g.add_outcome(f"leaf_{len(list(g.outcomes))}", [0] * self.n_players) + ) else: current_player = str(whose_turn + 1) g.append_move(node, current_player, ["L", "R"]) diff --git a/tests/test_file.py b/tests/test_file.py index cd0d0c883..ee7dade88 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -15,6 +15,19 @@ def _parse_nfg(text: str) -> gbt.Game: return gbt.read_nfg(f) +LEGACY_EFG_HEADER = 'EFG 2 R "t" { "A" "B" }\n""\np "" 1 1 "" { "l" "r" } 0\n' + + +def test_read_efg_empty_outcome_labels_are_normalized(): + g = _parse_efg(LEGACY_EFG_HEADER + 't "" 1 "" { 1, -1 }\nt "" 2 "" { 2, -2 }\n') + assert [o.label for o in g.outcomes] == ["_1", "_2"] + + +def test_read_efg_repeated_outcome_id_consistent(): + g = _parse_efg(LEGACY_EFG_HEADER + 't "" 1 "" { 1, -1 }\nt "" 1 "" { 1, -1 }\n') + assert len(g.outcomes) == 1 + + def test_string_empty(): with pytest.raises(ValueError) as excinfo: _parse_efg("") diff --git a/tests/test_outcomes.py b/tests/test_outcomes.py index 6a29d399b..86a273747 100644 --- a/tests/test_outcomes.py +++ b/tests/test_outcomes.py @@ -10,7 +10,7 @@ ) def test_outcome_add(game: gbt.Game): outcome_count = len(game.outcomes) - game.add_outcome() + game.add_outcome(label="new outcome") assert len(game.outcomes) == outcome_count + 1 @@ -67,6 +67,12 @@ def test_outcome_index_unmatched_label(game: gbt.Game): _ = game.outcomes["not an outcome"] +def test_add_outcome_requires_label(): + game = gbt.Game.new_table([2, 2]) + with pytest.raises(TypeError): + game.add_outcome([0, 0]) + + @pytest.mark.parametrize( "game", [gbt.Game.new_table([2, 2])] ) @@ -89,3 +95,21 @@ def test_outcome_payoff_by_player_label(): assert out1["dan"] == 2 assert out2["joe"] == 3 assert out2["dan"] == 4 + + +@pytest.mark.parametrize("bad_label", ["", "win"]) +def test_add_outcome_bad_label_raises_and_leaves_game_unchanged(bad_label: str): + game = gbt.Game.new_tree(players=["A", "B"]) + game.add_outcome("win", [1, 2]) + with pytest.raises(ValueError): + game.add_outcome(bad_label, [3, 4]) + assert [o.label for o in game.outcomes] == ["win"] + + +def test_outcome_relabel_duplicate_rejected_and_label_unchanged(): + game = gbt.Game.new_tree(players=["A", "B"]) + game.add_outcome("win", [1, 2]) + outcome = game.add_outcome("lose", [0, 0]) + with pytest.raises(ValueError): + outcome.label = "win" + assert outcome.label == "lose" diff --git a/tests/test_players.py b/tests/test_players.py index 4dbfa0172..387baeb5b 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -294,7 +294,7 @@ def test_player_get_min_payoff_nonterminal_outcomes(): game = games.read_from_file("stripped_down_poker.efg") assert game.players["Alice"].min_payoff == -2 assert game.players["Bob"].min_payoff == -2 - game.set_outcome(game.root, game.add_outcome([-1, -1])) + game.set_outcome(game.root, game.add_outcome("outcome", [-1, -1])) assert game.players["Alice"].min_payoff == -3 assert game.players["Bob"].min_payoff == -3 @@ -320,7 +320,7 @@ def test_player_get_max_payoff_nonterminal_outcomes(): game = games.read_from_file("stripped_down_poker.efg") assert game.players["Alice"].max_payoff == 2 assert game.players["Bob"].max_payoff == 2 - game.set_outcome(game.root, game.add_outcome([-1, -1])) + game.set_outcome(game.root, game.add_outcome("outcome", [-1, -1])) assert game.players["Alice"].max_payoff == 1 assert game.players["Bob"].max_payoff == 1