diff --git a/ChangeLog b/ChangeLog index 106d98e91..eff2de8ec 100644 --- a/ChangeLog +++ b/ChangeLog @@ -23,6 +23,9 @@ is found or another solver event is emitted. - Added `Game.make_outcome`, which creates an outcome with the given payoffs and attaches it to a set of nodes (extensive) or contingencies (strategic) in a single operation. (#1061) +- Added `Game.make_outcome_null`, which resets a set of nodes (extensive) or contingencies + (strategic) to the null outcome, removing the previously-attached outcome if this was its + last reference. (#1061) - Added `MixedStrategyProfile.as_float` and `MixedBehaviorProfile.as_float`, converting a rational-precision profile to floating-point precision. `liap_solve`, `liap_agent_solve`, `logit_estimate`, `ipa_solve`, and `gnm_solve` now also accept a rational-precision profile @@ -68,6 +71,9 @@ LaTeX installation, so this lets the tutorials be run without that heavier local setup. ### Removed +- `Game.add_outcome`, `Game.delete_outcome`, and `Game.set_outcome` have been removed; use + `Game.make_outcome`/`Game.make_outcome_null`, which create-and-attach or reset an outcome in + a single operation and never leave an unattached outcome in the game. (#1061) - `gtdraw` is no longer part of the `doc` optional-dependency group. Install it separately (`pip install gtdraw`) to run tutorials locally or build the documentation. - `Game.contingencies` now yields contingencies as a mapping from player label to strategy diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index f7e1395d0..cc9b45bf5 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -71,7 +71,6 @@ Transforming game information structure :toctree: api/ Game.make_infoset - Game.make_outcome Game.make_event Game.relabel_actions Game.set_move_actions @@ -87,11 +86,10 @@ Transforming game components Game.relabel_players Game.set_players - Game.add_outcome - Game.delete_outcome - Game.set_outcome Game.relabel_strategies Game.set_strategies + Game.make_outcome + Game.make_outcome_null Information about the game diff --git a/doc/tutorials/02_extensive_form.ipynb b/doc/tutorials/02_extensive_form.ipynb index 599924774..97ae7c99a 100644 --- a/doc/tutorials/02_extensive_form.ipynb +++ b/doc/tutorials/02_extensive_form.ipynb @@ -172,12 +172,7 @@ "id": "716e9b9a", "metadata": {}, "outputs": [], - "source": [ - "g.set_outcome(\n", - " g.root.children[\"Trust\"].children[\"Honor\"],\n", - " outcome=g.add_outcome(\"Trustworthy\", [1, 1])\n", - ")" - ] + "source": "g.make_outcome(\n g.root.children[\"Trust\"].children[\"Honor\"],\n {\"Buyer\": 1, \"Seller\": 1},\n \"Trustworthy\"\n)" }, { "cell_type": "code", @@ -203,12 +198,7 @@ "id": "695b1aad", "metadata": {}, "outputs": [], - "source": [ - "g.set_outcome(\n", - " g.root.children[\"Trust\"].children[\"Abuse\"],\n", - " outcome=g.add_outcome(\"Untrustworthy\", [-1, 2])\n", - ")" - ] + "source": "g.make_outcome(\n g.root.children[\"Trust\"].children[\"Abuse\"],\n {\"Buyer\": -1, \"Seller\": 2},\n \"Untrustworthy\"\n)" }, { "cell_type": "code", @@ -234,12 +224,7 @@ "id": "0704ef86", "metadata": {}, "outputs": [], - "source": [ - "g.set_outcome(\n", - " g.root.children[\"Not trust\"],\n", - " g.add_outcome(\"Opt-out\", [0, 0])\n", - ")" - ] + "source": "g.make_outcome(\n g.root.children[\"Not trust\"],\n {\"Buyer\": 0, \"Seller\": 0},\n \"Opt-out\"\n)" }, { "cell_type": "code", diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 7088ad471..58e6ca5cb 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -168,7 +168,7 @@ "source": [ "The loop above causes each of the newly-appended moves to be in new information sets, reflecting the fact that Alice's decision depends on the knowledge of which card she holds.\n", "\n", - "In contrast, Bob does not know Alice\u2019s card, and therefore cannot distinguish between the two nodes at which he has to make his decision:\n", + "In contrast, Bob does not know Alice’s card, and therefore cannot distinguish between the two nodes at which he has to make his decision:\n", "\n", " - Chance player chooses King, then Alice Bets: `g.root.children[\"King\"].children[\"Bet\"]`\n", " - Chance player chooses Queen, then Alice Bets: `g.root.children[\"Queen\"].children[\"Bet\"]`\n", @@ -207,35 +207,7 @@ "cell_type": "markdown", "id": "c4eeb65f", "metadata": {}, - "source": [ - "In game theory terms, this creates \"imperfect information\".\n", - "Bob cannot distinguish between these two nodes in the game tree, so he must use the same same probabilities for Call vs. Fold in both situations.\n", - "\n", - "This is crucial in games where players must make decisions without full knowledge of the state of the game.\n", - "\n", - "Let's now set up the four possible payoff outcomes for the game. We'll label them according to player 1 (Alice):" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "87c988be", - "metadata": {}, - "outputs": [], - "source": [ - "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])" - ] - }, - { - "cell_type": "markdown", - "id": "467a2c39", - "metadata": {}, - "source": [ - "Finally, we should assign an outcome to each of the terminal nodes in the game tree:" - ] + "source": "In game theory terms, this creates \"imperfect information\".\nBob cannot distinguish between these two nodes in the game tree, so he must use the same same probabilities for Call vs. Fold in both situations.\n\nThis is crucial in games where players must make decisions without full knowledge of the state of the game.\n\nLet's now set up the four possible payoff outcomes for the game, assigning each directly to the terminal node(s) it results at.\nWe'll label them according to player 1 (Alice):" }, { "cell_type": "code", @@ -243,21 +215,7 @@ "id": "29aa60a0", "metadata": {}, "outputs": [], - "source": [ - "# Alice folds, Bob wins small\n", - "g.set_outcome(g.root.children[\"King\"].children[\"Fold\"], lose)\n", - "g.set_outcome(g.root.children[\"Queen\"].children[\"Fold\"], lose)\n", - "\n", - "# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\n", - "g.set_outcome(g.root.children[\"Queen\"].children[\"Bet\"].children[\"Call\"], lose_big)\n", - "\n", - "# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\n", - "g.set_outcome(g.root.children[\"King\"].children[\"Bet\"].children[\"Call\"], win_big)\n", - "\n", - "# Bob does not call Alice's Bet, Alice wins small\n", - "g.set_outcome(g.root.children[\"King\"].children[\"Bet\"].children[\"Fold\"], win)\n", - "g.set_outcome(g.root.children[\"Queen\"].children[\"Bet\"].children[\"Fold\"], win)" - ] + "source": "# Alice folds, Bob wins small\ng.make_outcome(\n [g.root.children[\"King\"].children[\"Fold\"], g.root.children[\"Queen\"].children[\"Fold\"]],\n {\"Alice\": -1, \"Bob\": 1},\n \"Lose\"\n)\n\n# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\ng.make_outcome(\n g.root.children[\"Queen\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": -2, \"Bob\": 2},\n \"Lose Big\"\n)\n\n# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\ng.make_outcome(\n g.root.children[\"King\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": 2, \"Bob\": -2},\n \"Win Big\"\n)\n\n# Bob does not call Alice's Bet, Alice wins small\ng.make_outcome(\n [g.root.children[\"King\"].children[\"Bet\"].children[\"Fold\"],\n g.root.children[\"Queen\"].children[\"Bet\"].children[\"Fold\"]],\n {\"Alice\": 1, \"Bob\": -1},\n \"Win\"\n)" }, { "cell_type": "code", @@ -404,7 +362,7 @@ "id": "1f121d48", "metadata": {}, "source": [ - "Now let's look at Bob\u2019s strategy:" + "Now let's look at Bob’s strategy:" ] }, { @@ -422,7 +380,7 @@ "id": "e906c4c4", "metadata": {}, "source": [ - "Bob Calls Alice\u2019s Bet two-thirds of the time.\n", + "Bob Calls Alice’s Bet two-thirds of the time.\n", "\n", "Since Bob has just one information set, we can get its representative node and index\n", "the profile directly by it to read off a single action's probability:" diff --git a/doc/tutorials/interoperability_tutorials/openspiel.ipynb b/doc/tutorials/interoperability_tutorials/openspiel.ipynb index d5c4542a4..a97e86cf2 100644 --- a/doc/tutorials/interoperability_tutorials/openspiel.ipynb +++ b/doc/tutorials/interoperability_tutorials/openspiel.ipynb @@ -666,7 +666,7 @@ "id": "77dc34c8", "metadata": {}, "outputs": [], - "source": "gbt_one_card_poker = gbt.Game.new_tree(\n players=[\"Alice\", \"Bob\"],\n title=\"Stripped-Down Poker: a simple game of one-card poker from Reiley et al (2008).\"\n)\n\ngbt_one_card_poker.append_event(\n gbt_one_card_poker.root,\n actions=[\"King\", \"Queen\"],\n probs=[gbt.Rational(1, 2), gbt.Rational(1, 2)]\n)\n\nfor node in gbt_one_card_poker.root.children:\n gbt_one_card_poker.append_move(\n node,\n player=\"Alice\",\n actions=[\"Bet\", \"Fold\"]\n )\n\ngbt_one_card_poker.append_move(\n [\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"],\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"]\n ],\n player=\"Bob\",\n actions=[\"Call\", \"Fold\"]\n)\n\nwin_big = gbt_one_card_poker.add_outcome(\"Win Big\", [2, -2])\nwin = gbt_one_card_poker.add_outcome(\"Win\", [1, -1])\nlose_big = gbt_one_card_poker.add_outcome(\"Lose Big\", [-2, 2])\nlose = gbt_one_card_poker.add_outcome(\"Lose\", [-1, 1])\n\n# Alice folds, Bob wins small\ngbt_one_card_poker.set_outcome(\n gbt_one_card_poker.root.children[\"King\"].children[\"Fold\"],\n lose\n)\ngbt_one_card_poker.set_outcome(\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Fold\"],\n lose\n)\n\n# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\ngbt_one_card_poker.set_outcome(\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"].children[\"Call\"],\n lose_big\n)\n\n# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\ngbt_one_card_poker.set_outcome(\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"].children[\"Call\"],\n win_big\n)\n\n# Bob does not call Alice's Bet, Alice wins small\ngbt_one_card_poker.set_outcome(\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"].children[\"Fold\"],\n win\n)\ngbt_one_card_poker.set_outcome(\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"].children[\"Fold\"],\n win\n)" + "source": "gbt_one_card_poker = gbt.Game.new_tree(\n players=[\"Alice\", \"Bob\"],\n title=\"Stripped-Down Poker: a simple game of one-card poker from Reiley et al (2008).\"\n)\n\ngbt_one_card_poker.append_event(\n gbt_one_card_poker.root,\n actions=[\"King\", \"Queen\"],\n probs=[gbt.Rational(1, 2), gbt.Rational(1, 2)]\n)\n\nfor node in gbt_one_card_poker.root.children:\n gbt_one_card_poker.append_move(\n node,\n player=\"Alice\",\n actions=[\"Bet\", \"Fold\"]\n )\n\ngbt_one_card_poker.append_move(\n [\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"],\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"]\n ],\n player=\"Bob\",\n actions=[\"Call\", \"Fold\"]\n)\n\n# Alice folds, Bob wins small\ngbt_one_card_poker.make_outcome(\n [\n gbt_one_card_poker.root.children[\"King\"].children[\"Fold\"],\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Fold\"]\n ],\n {\"Alice\": -1, \"Bob\": 1},\n \"Lose\"\n)\n\n# Bob sees Alice Bet and calls, correctly believing she is bluffing, Bob wins big\ngbt_one_card_poker.make_outcome(\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": -2, \"Bob\": 2},\n \"Lose Big\"\n)\n\n# Bob sees Alice Bet and calls, incorrectly believing she is bluffing, Alice wins big\ngbt_one_card_poker.make_outcome(\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"].children[\"Call\"],\n {\"Alice\": 2, \"Bob\": -2},\n \"Win Big\"\n)\n\n# Bob does not call Alice's Bet, Alice wins small\ngbt_one_card_poker.make_outcome(\n [\n gbt_one_card_poker.root.children[\"King\"].children[\"Bet\"].children[\"Fold\"],\n gbt_one_card_poker.root.children[\"Queen\"].children[\"Bet\"].children[\"Fold\"]\n ],\n {\"Alice\": 1, \"Bob\": -1},\n \"Win\"\n)" }, { "cell_type": "code", diff --git a/src/games/file.cc b/src/games/file.cc index b95a2bd9a..764ebd571 100644 --- a/src/games/file.cc +++ b/src/games/file.cc @@ -493,27 +493,31 @@ void ParseOutcomeBody(GameFileLexer &p_parser, Game &p_nfg) } } NormalizeLabelStrings(labels); - std::map created; - auto label_it = labels.begin(); - for (size_t i = 0; i < records.m_labels.size(); ++i) { - if (!referenced.contains(static_cast(i) + 1)) { - continue; - } - auto outcome = p_nfg->NewOutcome(*label_it++); - auto player_it = p_nfg->GetPlayers().begin(); - for (const auto &payoff : records.m_payoffs[i]) { - outcome->SetPayoff(*player_it, payoff); - ++player_it; + std::map labelById; + { + auto label_it = labels.begin(); + for (size_t i = 0; i < records.m_labels.size(); ++i) { + if (referenced.contains(static_cast(i) + 1)) { + labelById.emplace(static_cast(i) + 1, *label_it++); + } } - created.emplace(static_cast(i) + 1, outcome); } - // Second pass: attach. + // Second pass: group each referenced contingency by the outcome id attached to it. + std::map>> contingenciesById; auto id_it = ids.begin(); for (const auto &profile : StrategyContingencies(p_nfg)) { if (const int outcomeId = *id_it++) { - profile->SetOutcome(created.at(outcomeId)); + std::vector strategies; + strategies.reserve(p_nfg->NumPlayers()); + for (const auto &player : p_nfg->GetPlayers()) { + strategies.push_back(profile->GetStrategy(player)); + } + contingenciesById[outcomeId].push_back(strategies); } } + for (const auto &[outcomeId, contingencies] : contingenciesById) { + p_nfg->MakeOutcome(contingencies, records.m_payoffs[outcomeId - 1], labelById.at(outcomeId)); + } } void ParsePayoffBody(GameFileLexer &p_parser, Game &p_nfg) @@ -580,7 +584,7 @@ Game BuildNfg(GameFileLexer &p_parser, TableFileGame &p_data) /// 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 +/// normalized in one pass before creation, as MakeOutcome enforces /// the label requirements at creation time. struct OutcomeRecord { std::string m_label; @@ -688,7 +692,7 @@ void ParseOutcome(GameFileLexer &p_state, Game &p_game, TreeData &p_treeData, Ga /// 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 +/// the label requirements enforced by MakeOutcome (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. @@ -699,20 +703,21 @@ void CreateOutcomes(const Game &p_game, const TreeData &p_treeData) 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; + std::map labelById; + { + auto label_it = labels.begin(); + for (const int id : p_treeData.m_outcomeOrder) { + labelById.emplace(id, *label_it++); } - created.emplace(id, outcome); } + + std::map> nodesById; for (const auto &[node, id] : p_treeData.m_nodeOutcomes) { - p_game->SetOutcome(node, created.at(id)); + nodesById[id].push_back(node); + } + for (const int id : p_treeData.m_outcomeOrder) { + p_game->MakeOutcome(nodesById.at(id), p_treeData.m_outcomeRecords.at(id).m_payoffs, + labelById.at(id)); } } diff --git a/src/games/game.h b/src/games/game.h index 049287608..cffffebb7 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -681,8 +681,6 @@ class GameNodeRep : public std::enable_shared_from_this { GameOutcomeRep *m_outcome; std::vector> m_children; - void DeleteOutcome(GameOutcomeRep *outc); - public: using Children = ElementCollection; @@ -1337,11 +1335,6 @@ class GameRep : public std::enable_shared_from_this { { throw UndefinedException(); } - virtual void SetOutcome(const GameNode &p_node, const GameOutcome &p_outcome) - { - throw UndefinedException(); - } - virtual PureStrategyProfile NewPureStrategyProfile() const = 0; virtual MixedStrategyProfile NewMixedStrategyProfile(double) const = 0; virtual MixedStrategyProfile NewMixedStrategyProfile(const Rational &) const = 0; @@ -1434,8 +1427,6 @@ 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(const std::string &p_label) { throw UndefinedException(); } /// Creates an outcome with the given payoffs and label for the specified nodes. virtual GameOutcome MakeOutcome(const std::vector &, const std::vector &, const std::string &) @@ -1448,8 +1439,13 @@ class GameRep : public std::enable_shared_from_this { { throw UndefinedException(); } - /// Deletes the specified outcome from the game - virtual void DeleteOutcome(const GameOutcome &) { throw UndefinedException(); } + /// Resets the outcome at the specified nodes to the null outcome. + virtual void MakeOutcomeNull(const std::vector &) { throw UndefinedException(); } + /// Resets the outcome at the specified contingencies to the null outcome. + virtual void MakeOutcomeNull(const std::vector> &) + { + throw UndefinedException(); + } //@} /// @name Nodes diff --git a/src/games/gameagg.cc b/src/games/gameagg.cc index e05684845..808097be9 100644 --- a/src/games/gameagg.cc +++ b/src/games/gameagg.cc @@ -40,7 +40,6 @@ class AGGPureStrategyProfileRep : public PureStrategyProfileRep { } GameOutcome GetOutcome() const override { throw UndefinedException(); } - void SetOutcome(GameOutcome p_outcome) override { throw UndefinedException(); } Rational GetPayoff(const GamePlayer &) const override; Rational GetStrategyValue(const GameStrategy &) const override; }; diff --git a/src/games/gamebagg.cc b/src/games/gamebagg.cc index f910a76d9..d202c1156 100644 --- a/src/games/gamebagg.cc +++ b/src/games/gamebagg.cc @@ -39,7 +39,6 @@ class BAGGPureStrategyProfileRep : public PureStrategyProfileRep { return std::make_shared(*this); } GameOutcome GetOutcome() const override { throw UndefinedException(); } - void SetOutcome(GameOutcome p_outcome) override { throw UndefinedException(); } Rational GetPayoff(const GamePlayer &) const override; Rational GetStrategyValue(const GameStrategy &) const override; }; diff --git a/src/games/gameexpl.cc b/src/games/gameexpl.cc index 8ce4950d1..fa270c480 100644 --- a/src/games/gameexpl.cc +++ b/src/games/gameexpl.cc @@ -54,17 +54,6 @@ Rational GameExplicitRep::GetMaxPayoff() const }); } -//------------------------------------------------------------------------ -// GameExplicitRep: Outcomes -//------------------------------------------------------------------------ - -GameOutcome GameExplicitRep::NewOutcome(const std::string &p_label) -{ - CheckOutcomeLabel(p_label); - m_outcomes.push_back(std::make_shared(this, m_outcomes.size() + 1, p_label)); - return m_outcomes.back(); -} - //------------------------------------------------------------------------ // GameExplicitRep: Writing data files //------------------------------------------------------------------------ diff --git a/src/games/gameexpl.h b/src/games/gameexpl.h index 1ae9b0a68..160b1acc4 100644 --- a/src/games/gameexpl.h +++ b/src/games/gameexpl.h @@ -39,11 +39,6 @@ class GameExplicitRep : public GameRep { Rational GetMaxPayoff() const override; //@} - /// @name Outcomes - //@{ - /// Creates a new outcome in the game - GameOutcome NewOutcome(const std::string &p_label) override; - /// @name Writing data files //@{ void Write(std::ostream &p_stream, const std::string &p_format = "native") const override; diff --git a/src/games/gametable.cc b/src/games/gametable.cc index 9a935fb36..ce07e9488 100644 --- a/src/games/gametable.cc +++ b/src/games/gametable.cc @@ -42,7 +42,6 @@ class TablePureStrategyProfileRep : public PureStrategyProfileRep { public: explicit TablePureStrategyProfileRep(const Game &p_game) : PureStrategyProfileRep(p_game) {} GameOutcome GetOutcome() const override; - void SetOutcome(GameOutcome p_outcome) override; Rational GetPayoff(const GamePlayer &) const override; Rational GetStrategyValue(const GameStrategy &) const override; }; @@ -61,12 +60,6 @@ GameOutcome TablePureStrategyProfileRep::GetOutcome() const return dynamic_cast(*m_game).m_results.at(m_index)->shared_from_this(); } -void TablePureStrategyProfileRep::SetOutcome(GameOutcome p_outcome) -{ - dynamic_cast(*m_game).m_results[m_index] = - p_outcome ? p_outcome.get() : m_game->m_nullOutcome.get(); -} - Rational TablePureStrategyProfileRep::GetPayoff(const GamePlayer &p_player) const { return dynamic_cast(*m_game).m_results.at(m_index)->GetPayoff( @@ -481,20 +474,11 @@ void GameTableRep::WriteNfgFile(std::ostream &p_file) const // GameTableRep: Outcomes //------------------------------------------------------------------------ -GameOutcome -GameTableRep::MakeOutcome(const std::vector> &p_contingencies, - const std::vector &p_payoffs, const std::string &p_label) +std::set GameTableRep::ResolveContingencies( + const std::vector> &p_contingencies) const { - if (p_contingencies.empty()) { - throw ValueException("At least one contingency must be specified"); - } - if (p_payoffs.size() != m_players.size()) { - throw ValueException("A payoff must be specified for each player"); - } const auto &strides = m_pureStrategies.m_strides; - // `covered` collects the candidates for absorption. std::set selected; - std::set covered; for (const auto &contingency : p_contingencies) { if (contingency.size() != m_players.size()) { throw ValueException("Each contingency must give one strategy per player"); @@ -512,20 +496,44 @@ GameTableRep::MakeOutcome(const std::vector> &p_contin if (!selected.insert(index).second) { throw ValueException("Each contingency may be referenced only once"); } - if (!m_results[index]->IsNull()) { - covered.insert(m_results[index]); - } } + return selected; +} + +std::set +GameTableRep::ComputeAbsorbedOutcomes(const std::set &p_selected, + const std::set &p_covered) const +{ std::set absorbed; - if (!covered.empty()) { - // A candidate survives absorption if some cell outside the selection still references it. - absorbed = covered; + if (!p_covered.empty()) { + absorbed = p_covered; for (size_t index = 0; index < m_results.size() && !absorbed.empty(); index++) { - if (!selected.contains(static_cast(index))) { + if (!p_selected.contains(static_cast(index))) { absorbed.erase(m_results[index]); } } } + return absorbed; +} + +GameOutcome +GameTableRep::MakeOutcome(const std::vector> &p_contingencies, + const std::vector &p_payoffs, const std::string &p_label) +{ + if (p_contingencies.empty()) { + throw ValueException("At least one contingency must be specified"); + } + if (p_payoffs.size() != m_players.size()) { + throw ValueException("A payoff must be specified for each player"); + } + const auto selected = ResolveContingencies(p_contingencies); + std::set covered; + for (const auto index : selected) { + if (!m_results[index]->IsNull()) { + covered.insert(m_results[index]); + } + } + const auto absorbed = ComputeAbsorbedOutcomes(selected, covered); CheckOutcomeLabel(p_label, absorbed); IncrementVersion(); @@ -543,14 +551,27 @@ GameTableRep::MakeOutcome(const std::vector> &p_contin return outcome; } -void GameTableRep::DeleteOutcome(const GameOutcome &p_outcome) +void GameTableRep::MakeOutcomeNull(const std::vector> &p_contingencies) { - if (p_outcome->IsNull()) { - throw UndefinedException("The null outcome cannot be deleted"); + if (p_contingencies.empty()) { + throw ValueException("At least one contingency must be specified"); } + const auto selected = ResolveContingencies(p_contingencies); + std::set covered; + for (const auto index : selected) { + if (!m_results[index]->IsNull()) { + covered.insert(m_results[index]); + } + } + const auto absorbed = ComputeAbsorbedOutcomes(selected, covered); + IncrementVersion(); - std::replace(m_results.begin(), m_results.end(), p_outcome.get(), m_nullOutcome.get()); - EraseOutcomes({p_outcome.get()}); + for (const auto index : selected) { + m_results[index] = m_nullOutcome.get(); + } + if (!absorbed.empty()) { + EraseOutcomes(absorbed); + } } //------------------------------------------------------------------------ diff --git a/src/games/gametable.h b/src/games/gametable.h index 6aa768415..ddde2968e 100644 --- a/src/games/gametable.h +++ b/src/games/gametable.h @@ -44,6 +44,14 @@ class GameTableRep : public GameExplicitRep { /// p_oldToNew maps old strategy indices to new index, or to -1 if the strategy was removed. void RebuildTable(const std::vector &old_radices, long p_player, const std::vector &p_oldToNew); + /// Resolves p_contingencies to the flat m_results indices they refer to, validating that each + /// contingency gives exactly one strategy per player and is referenced only once. + std::set + ResolveContingencies(const std::vector> &p_contingencies) const; + /// Returns the subset of p_covered no longer referenced by any contingency outside p_selected. + std::set + ComputeAbsorbedOutcomes(const std::set &p_selected, + const std::set &p_covered) const; //@} public: @@ -54,6 +62,7 @@ class GameTableRep : public GameExplicitRep { explicit GameTableRep(const std::vector &p_dim, bool p_sparseOutcomes = false); GameOutcome MakeOutcome(const std::vector> &, const std::vector &, const std::string &) override; + void MakeOutcomeNull(const std::vector> &) override; Game Copy() const override; //@} @@ -93,12 +102,6 @@ class GameTableRep : public GameExplicitRep { size_t NumNonterminalNodes() const override { throw UndefinedException(); } //@} - /// @name Outcomes - //@{ - /// Deletes the specified outcome from the game - void DeleteOutcome(const GameOutcome &) override; - //@} - /// @name Strategies //@{ void RelabelStrategies(const GamePlayer &, const std::map &) override; diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 5f579fcd1..b3c7a424c 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -412,32 +412,6 @@ std::set GameInfosetRep::GetOwnPriorActions() const return m_game->GetOwnPriorActions(std::const_pointer_cast(shared_from_this())); } -void GameNodeRep::DeleteOutcome(GameOutcomeRep *outc) -{ - m_game->IncrementVersion(); - if (outc == m_outcome) { - m_outcome = m_game->m_nullOutcome.get(); - } - for (auto child : m_children) { - child->DeleteOutcome(outc); - } -} - -void GameTreeRep::SetOutcome(const GameNode &p_node, const GameOutcome &p_outcome) -{ - if (p_node->m_game != this) { - throw MismatchException(); - } - if (p_outcome && p_outcome->m_game != this) { - throw MismatchException(); - } - if (const auto newOutcome = p_outcome ? p_outcome.get_shared().get() : m_nullOutcome.get(); - newOutcome != p_node->m_outcome) { - p_node->m_outcome = newOutcome; - IncrementVersion(); - } -} - bool GameNodeRep::IsSuccessorOf(GameNode p_node) const { auto *n = const_cast(this); @@ -1833,6 +1807,25 @@ std::vector GameTreeRep::GetPlays(GameAction action) const return plays; } +std::set +GameTreeRep::ComputeAbsorbedOutcomes(const std::set &p_selected, + const std::set &p_covered) const +{ + std::set absorbed; + if (!p_covered.empty()) { + absorbed = p_covered; + for (const auto &node : GetNodes()) { + if (absorbed.empty()) { + break; + } + if (!p_selected.contains(node.get())) { + absorbed.erase(node->m_outcome); + } + } + } + return absorbed; +} + GameOutcome GameTreeRep::MakeOutcome(const std::vector &p_nodes, const std::vector &p_payoffs, const std::string &p_label) @@ -1856,18 +1849,7 @@ GameOutcome GameTreeRep::MakeOutcome(const std::vector &p_nodes, covered.insert(node->m_outcome); } } - std::set absorbed; - if (!covered.empty()) { - absorbed = covered; - for (const auto &node : GetNodes()) { - if (absorbed.empty()) { - break; - } - if (!selected.contains(node.get())) { - absorbed.erase(node->m_outcome); - } - } - } + const auto absorbed = ComputeAbsorbedOutcomes(selected, covered); CheckOutcomeLabel(p_label, absorbed); IncrementVersion(); @@ -1885,14 +1867,33 @@ GameOutcome GameTreeRep::MakeOutcome(const std::vector &p_nodes, return outcome; } -void GameTreeRep::DeleteOutcome(const GameOutcome &p_outcome) +void GameTreeRep::MakeOutcomeNull(const std::vector &p_nodes) { - if (p_outcome->IsNull()) { - throw UndefinedException("The null outcome cannot be deleted"); + if (p_nodes.empty()) { + throw ValueException("At least one node must be specified"); + } + std::set selected; + std::set covered; + for (const auto &node : p_nodes) { + if (node->m_game != this) { + throw MismatchException(); + } + if (!selected.insert(node.get()).second) { + throw ValueException("Each node may be referenced only once"); + } + if (!node->m_outcome->IsNull()) { + covered.insert(node->m_outcome); + } } + const auto absorbed = ComputeAbsorbedOutcomes(selected, covered); + IncrementVersion(); - m_root->DeleteOutcome(p_outcome.get()); - EraseOutcomes({p_outcome.get()}); + for (auto *node : selected) { + node->m_outcome = m_nullOutcome.get(); + } + if (!absorbed.empty()) { + EraseOutcomes(absorbed); + } } //------------------------------------------------------------------------ @@ -2046,7 +2047,6 @@ class TreePureStrategyProfileRep : public PureStrategyProfileRep { public: TreePureStrategyProfileRep(const Game &p_game) : PureStrategyProfileRep(p_game) {} GameOutcome GetOutcome() const override; - void SetOutcome(GameOutcome p_outcome) override { throw UndefinedException(); } Rational GetPayoff(const GamePlayer &) const override; Rational GetStrategyValue(const GameStrategy &) const override; }; diff --git a/src/games/gametree.h b/src/games/gametree.h index 4aa1fa9f6..c3452345d 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -68,6 +68,10 @@ class GameTreeRep final : public GameExplicitRep { template Rational AggregateSubtreePayoff(const GamePlayer &p_player, Aggregator p_aggregator) const; static void RenumberInfosets(GamePlayerRep *); + /// Returns the subset of p_covered no longer referenced by any node outside p_selected. + std::set + ComputeAbsorbedOutcomes(const std::set &p_selected, + const std::set &p_covered) const; //@} /// @name Managing the representation @@ -138,8 +142,6 @@ class GameTreeRep final : public GameExplicitRep { GameAction GetOwnPriorAction(const GameNode &p_node) const override; //@} - void DeleteOutcome(const GameOutcome &) override; - /// @name Writing data files //@{ void WriteEfgFile(std::ostream &, const GameNode &p_node = nullptr) const override; @@ -179,6 +181,7 @@ class GameTreeRep final : public GameExplicitRep { void DeleteTree(GameNode) override; GameOutcome MakeOutcome(const std::vector &, const std::vector &, const std::string &) override; + void MakeOutcomeNull(const std::vector &) override; GameInfoset MakeInfoset(const std::vector &, const GamePlayer &, const std::string &) override; void Reveal(GameInfoset, GamePlayer) override; @@ -188,8 +191,6 @@ class GameTreeRep final : public GameExplicitRep { void SetMoveActions(const GameInfoset &, const std::vector &) override; void SetEventActions(const GameInfoset &, const std::vector &, const std::vector &) override; - void SetOutcome(const GameNode &p_node, const GameOutcome &p_outcome) override; - std::vector GetPlays(GameNode node) const override; std::vector GetPlays(GameInfoset infoset) const override; std::vector GetPlays(GameAction action) const override; diff --git a/src/games/stratpure.h b/src/games/stratpure.h index d541f22ef..f3db59283 100644 --- a/src/games/stratpure.h +++ b/src/games/stratpure.h @@ -84,9 +84,6 @@ class PureStrategyProfileRep { /// Get the outcome that results from the profile virtual GameOutcome GetOutcome() const = 0; - /// Set the outcome that results from the profile - virtual void SetOutcome(GameOutcome p_outcome) = 0; - /// Get the payoff to the player resulting from the profile virtual Rational GetPayoff(const GamePlayer &p_player) const = 0; diff --git a/src/gui/efgtooltip.cc b/src/gui/efgtooltip.cc index ac6c33957..6a41f9a4e 100644 --- a/src/gui/efgtooltip.cc +++ b/src/gui/efgtooltip.cc @@ -96,7 +96,7 @@ class OutcomeEditorPopup : public wxDialog { void OnOpenTimer(wxTimerEvent &p_event); // Checks the label field for being non-empty and not matching any of the game's other - // outcomes (GameOutcome::NewOutcome() rejects an empty or duplicate label outright, so an + // outcomes (GameRep::MakeOutcome() rejects an empty or duplicate label outright, so an // outcome not yet attached to a node would otherwise fail at commit with no earlier warning), // colouring it like EditMoveDialog's infoset-label field and returning a description of the // problem found, or an empty string if it's valid. @@ -271,7 +271,7 @@ void OutcomeEditorPopup::LoadValues() const GameOutcome outcome = m_node ? m_node->GetOutcome() : nullptr; if (!outcome || outcome->IsNull()) { - // GameOutcome::NewOutcome() (called from Commit(), via DoSetOutcomeData) rejects an empty + // GameRep::MakeOutcome() (called from Commit(), via DoSetOutcomeData) rejects an empty // or duplicate label outright -- pre-filling a fresh, unique one here means the dialog never // opens already showing that as an error the user has to notice and fix before they can // accept anything else. diff --git a/src/gui/gamedoc.cc b/src/gui/gamedoc.cc index a4dac734e..33a7e5127 100644 --- a/src/gui/gamedoc.cc +++ b/src/gui/gamedoc.cc @@ -698,19 +698,42 @@ std::string GenerateOutcomeLabel(const Game &p_game) void GameDocument::DoNewOutcome(GameNode p_node) { - m_game->SetOutcome(p_node, m_game->NewOutcome(GenerateOutcomeLabel(m_game))); + m_game->MakeOutcome({p_node}, std::vector(m_game->NumPlayers(), Number()), + GenerateOutcomeLabel(m_game)); NotifyChanged(GameModificationType::GamePayoffs); } void GameDocument::DoNewOutcome(const PureStrategyProfile &p_profile) { - p_profile->SetOutcome(m_game->NewOutcome(GenerateOutcomeLabel(m_game))); + std::vector strategies; + strategies.reserve(m_game->NumPlayers()); + for (const auto &player : m_game->GetPlayers()) { + strategies.push_back(p_profile->GetStrategy(player)); + } + m_game->MakeOutcome({strategies}, std::vector(m_game->NumPlayers(), Number()), + GenerateOutcomeLabel(m_game)); NotifyChanged(GameModificationType::GamePayoffs); } void GameDocument::DoSetOutcome(GameNode p_node, GameOutcome p_outcome) { - m_game->SetOutcome(p_node, p_outcome); + if (!p_outcome) { + m_game->MakeOutcomeNull({p_node}); + } + else { + std::vector members{p_node}; + for (const auto &node : m_game->GetNodes()) { + if (node != p_node && node->GetOutcome() == p_outcome) { + members.push_back(node); + } + } + std::vector payoffs; + payoffs.reserve(m_game->NumPlayers()); + for (const auto &player : m_game->GetPlayers()) { + payoffs.emplace_back(p_outcome->GetPayoff(player)); + } + m_game->MakeOutcome(members, payoffs, p_outcome->GetLabel()); + } NotifyChanged(GameModificationType::GamePayoffs); } @@ -756,15 +779,19 @@ void GameDocument::DoSetOutcomeData(const GameNode &p_node, const wxString &p_la } if (outcome->IsNull()) { - outcome = m_game->NewOutcome(p_label.ToStdString(wxConvUTF8)); - m_game->SetOutcome(p_node, outcome); + std::vector payoffs; + payoffs.reserve(p_payoffs.size()); + for (const auto &value : p_payoffs) { + payoffs.emplace_back(value.ToStdString()); + } + m_game->MakeOutcome({p_node}, payoffs, label); } else { outcome->SetLabel(label); - } - - for (size_t player = 1; player <= GetGame()->NumPlayers(); ++player) { - outcome->SetPayoff(GetGame()->GetPlayer(player), Number(p_payoffs[player - 1].ToStdString())); + for (size_t player = 1; player <= GetGame()->NumPlayers(); ++player) { + outcome->SetPayoff(GetGame()->GetPlayer(player), + Number(p_payoffs[player - 1].ToStdString())); + } } NotifyChanged(GameModificationType::GamePayoffs); @@ -775,7 +802,7 @@ void GameDocument::DoRemoveOutcome(GameNode p_node) if (!p_node || p_node->GetOutcome()->IsNull()) { return; } - m_game->SetOutcome(p_node, nullptr); + m_game->MakeOutcomeNull({p_node}); NotifyChanged(GameModificationType::GamePayoffs); } @@ -784,7 +811,12 @@ void GameDocument::DoRemoveOutcome(const PureStrategyProfile &p_profile) if (p_profile->GetOutcome()->IsNull()) { return; } - p_profile->SetOutcome(nullptr); + std::vector strategies; + strategies.reserve(m_game->NumPlayers()); + for (const auto &player : m_game->GetPlayers()) { + strategies.push_back(p_profile->GetStrategy(player)); + } + m_game->MakeOutcomeNull({strategies}); NotifyChanged(GameModificationType::GamePayoffs); } diff --git a/src/gui/gamedoc.h b/src/gui/gamedoc.h index c3ac14349..511396343 100644 --- a/src/gui/gamedoc.h +++ b/src/gui/gamedoc.h @@ -206,7 +206,7 @@ class AnalysisWorkspace { }; // Generates a label guaranteed not to collide with any of p_game's current outcome labels, of -// the form "Outcome N" -- GameOutcome::NewOutcome() itself rejects an empty or duplicate label, +// the form "Outcome N" -- GameRep::MakeOutcome() itself rejects an empty or duplicate label, // so callers that need to pre-fill a fresh outcome's label (rather than make the user type one // first) use this. std::string GenerateOutcomeLabel(const Game &p_game); diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 958414ac2..4d52bc002 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -325,8 +325,6 @@ cdef extern from "games/game.h": int NumOutcomes() except + c_GameOutcome GetOutcome(int) except +IndexError Outcomes GetOutcomes() except + - c_GameOutcome NewOutcome(string) except +ValueError - void DeleteOutcome(c_GameOutcome) except + int NumNodes() except + int NumNonterminalNodes() except + @@ -373,12 +371,13 @@ cdef extern from "games/game.h": string) except +ValueError c_GameOutcome MakeOutcome(stdvector[stdvector[c_GameStrategy]], stdvector[c_Number], string) except +ValueError + void MakeOutcomeNull(stdvector[c_GameNode]) except +ValueError + void MakeOutcomeNull(stdvector[stdvector[c_GameStrategy]]) except +ValueError void Reveal(c_GameInfoset, c_GamePlayer) except + void RelabelActions(c_GameInfoset, stdmap[string, string]) except +ValueError void SetMoveActions(c_GameInfoset, stdvector[string]) except +ValueError void SetEventActions(c_GameInfoset, stdvector[string], stdvector[c_Number]) except +ValueError - void SetOutcome(c_GameNode, c_GameOutcome) except + c_GameInfoset MakeEvent(stdvector[c_GameNode], stdvector[c_Number], string) except +ValueError @@ -398,7 +397,6 @@ cdef extern from "games/stratpure.h": void SetStrategy(c_GameStrategy) except + c_GameOutcome GetOutcome() except + - void SetOutcome(c_GameOutcome) except + c_Rational GetPayoff(c_GamePlayer) except + diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 78d34c1bf..4a4ec5dd3 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -2743,89 +2743,69 @@ class Game: self.game.deref().MakeOutcome(c_contingencies, c_payoffs, label.encode("utf-8")) ) - def add_outcome(self, - label: str, - payoffs: list | None = None) -> Outcome: - """Add a new outcome to the game. + def make_outcome_null(self, location) -> None: + """Reset the outcome at `location` to the null outcome. - .. 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. - - Raises - ------ - ValueError - If `payoffs` is specified but is not the same length as the number of players - in the game, or if `label` is empty or already in use by another outcome. - - Returns - ------- - Outcome - A reference to the newly-created outcome. - """ - if payoffs is not None: - if len(payoffs) != len(self.players): - 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(label.encode("utf-8"))) - for player, payoff in zip(self.players, payoffs, strict=True): - c[player] = payoff - return c - - def delete_outcome(self, outcome: Outcome | str) -> None: - """Delete an outcome from the game. - - If this game is an extensive game, any - node at which this outcome is attached has its outcome reset to null. If this game - is a strategic game, any contingency at which this outcome is attached as its outcome - reset to null. - - Parameters - ---------- - outcome : Outcome or str - The outcome to delete from the game + For an extensive game, `location` is a ``Node`` or an iterable of nodes. For a + strategic game, `location` is a pure-strategy contingency — a complete mapping + from the game's players' labels to strategy labels — or an iterable of such + contingencies. - Raises - ------ - MismatchError - If `outcome` is an `Outcome` from another game. - """ - resolved_outcome = cython.cast(Outcome, self._resolve_outcome(outcome, "delete_outcome")) - self.game.deref().DeleteOutcome(resolved_outcome.outcome) + Any outcome all of whose references are among `location` is removed from the game. - def set_outcome(self, node: Node | str, - outcome: Outcome | str | None) -> None: - """Set `outcome` to be the outcome at `node`. If `outcome` is None, the - outcome at `node` is unset. + .. versionadded:: 17.0.0 Parameters ---------- - node : Node or str - The node to set the outcome at - outcome : Outcome or str or None - The outcome to assign to the node + location : Node, contingency, or iterable of these + The nodes or contingencies to reset to the null outcome. Nonempty; each + node or contingency may be referenced only once. Raises ------ MismatchError - If `node` is a `Node` from a different game, or `outcome` is an - `Outcome` from a different game. + If any node is from a different game. + ValueError + If `location` is empty or contains a repeat, or if a contingency does not + specify exactly one strategy for each player. + UndefinedOperationError + If the game is in action-graph representation, where outcomes are not + represented explicitly. """ - resolved_node = cython.cast(Node, self._resolve_node(node, "set_outcome")) - if outcome is None: - self.game.deref().SetOutcome(resolved_node.node, cython.cast(c_GameOutcome, NULL)) + if self.game.deref().IsAgg(): + raise UndefinedOperationError( + "make_outcome_null(): operation not defined for games in " + "action-graph representation" + ) + if self.is_tree: + resolved_nodes = self._resolve_nodes(location, "make_outcome_null") + c_nodes = stdvector[c_GameNode]() + for n in resolved_nodes: + c_nodes.push_back(cython.cast(Node, n).node) + self.game.deref().MakeOutcomeNull(c_nodes) return - resolved_outcome = cython.cast(Outcome, self._resolve_outcome(outcome, "set_outcome")) - self.game.deref().SetOutcome(resolved_node.node, resolved_outcome.outcome) + if isinstance(location, collections.abc.Mapping): + entries = [location] + else: + try: + entries = list(location) + except TypeError: + raise TypeError( + "make_outcome_null(): location must be a contingency or an " + "iterable of contingencies" + ) from None + c_contingencies = stdvector[stdvector[c_GameStrategy]]() + for entry in entries: + resolved = self._resolve_contingency(entry, "make_outcome_null", "location") + c_one = stdvector[c_GameStrategy]() + for player in self.players: + resolved_player: Player = cython.cast(Player, player) + c_one.push_back( + self._resolve_strategy(resolved_player, resolved[resolved_player], + "make_outcome_null") + ) + c_contingencies.push_back(c_one) + self.game.deref().MakeOutcomeNull(c_contingencies) def relabel_strategies(self, player: Player | str, diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 824f7435f..b5b5707dc 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -103,7 +103,7 @@ def efg_asymmetric_tree_text() -> str: game.append_move(right, "2", ["p", "q"]) for node in left.children: payoff = [1, 1] if node.prior_action.label == "x" else [0, 0] - game.set_outcome(node, game.add_outcome(node.prior_action.label, payoff)) + game.make_outcome(node, {"1": payoff[0], "2": payoff[1]}, node.prior_action.label) for node in right.children: - game.set_outcome(node, game.add_outcome(node.prior_action.label, [0, 0])) + game.make_outcome(node, {"1": 0, "2": 0}, node.prior_action.label) return game.to_efg() diff --git a/tests/games.py b/tests/games.py index 67f842ead..1bb6f9382 100644 --- a/tests/games.py +++ b/tests/games.py @@ -96,10 +96,8 @@ def create_efg_corresponding_to_bimatrix_game_arrays( g.append_move(g.root, "1", actions1) g.append_move(g.root.children, "2", actions2) for i, j in itertools.product(range(m), range(n)): - g.set_outcome( - g.root.children[str(i)].children[str(j)], - g.add_outcome(f"({i},{j})", [A[i, j], B[i, j]]), - ) + node = g.root.children[str(i)].children[str(j)] + g.make_outcome(node, {"1": A[i, j], "2": B[i, j]}, f"({i},{j})") return g @@ -136,10 +134,9 @@ def create_2x2_zero_sum_efg(variant: None | str = None) -> gbt.Game: g = create_efg_corresponding_to_bimatrix_game_arrays(A, B, title) if variant == "missing term outcome": - g.delete_outcome(g.root.children["0"].children["1"].outcome) + g.make_outcome_null(g.root.children["0"].children["1"]) elif variant == "with neutral outcome": - neutral = g.add_outcome("neutral", [0, 0]) - g.set_outcome(g.root.children["0"], neutral) + g.make_outcome(g.root.children["0"], {"1": 0, "2": 0}, "neutral") return g @@ -168,32 +165,29 @@ def create_stripped_down_poker_efg(nonterm_outcomes: bool = False) -> gbt.Game: deals = ["King", "Queen"] g.append_event(g.root, deals, [gbt.Rational(1, 2)] * 2) - ante_outcome = g.add_outcome("Ante", [-1, -1]) - g.set_outcome(g.root, ante_outcome) - - 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"]) - g.set_outcome(node.children["Fold"], alice_folds_outcome) - g.set_outcome(node.children["Bet"], alice_bets_outcome) alice_bets_nodes = [ g.root.children["King"].children["Bet"], g.root.children["Queen"].children["Bet"], ] g.append_move(alice_bets_nodes, player="Bob", actions=["Call", "Fold"]) - for node in alice_bets_nodes: - g.set_outcome(node.children["Fold"], bob_folds_outcome) + g.make_outcome(g.root, {"Alice": -1, "Bob": -1}, "Ante") + g.make_outcome( + [node.children["Fold"] for node in g.root.children], {"Alice": 0, "Bob": 2}, "Alice Folds" + ) + g.make_outcome( + [node.children["Bet"] for node in g.root.children], {"Alice": -1, "Bob": 0}, "Alice Bets" + ) + g.make_outcome( + [node.children["Fold"] for node in alice_bets_nodes], {"Alice": 3, "Bob": 0}, "Bob Folds" + ) bob_calls_and_loses_node = g.root.children["King"].children["Bet"].children["Call"] - g.set_outcome(bob_calls_and_loses_node, bob_calls_and_loses_outcome) + g.make_outcome(bob_calls_and_loses_node, {"Alice": 4, "Bob": -1}, "Bob Calls and Loses") bob_calls_and_wins_node = g.root.children["Queen"].children["Bet"].children["Call"] - g.set_outcome(bob_calls_and_wins_node, bob_calls_and_wins_outcome) + g.make_outcome(bob_calls_and_wins_node, {"Alice": 0, "Bob": 3}, "Bob Calls and Wins") return g @@ -295,17 +289,19 @@ def bet(player, payoffs, pot): return tuple(payoffs.values()) - # create 4 possible outcomes just once - payoffs_to_outcomes = { - (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]), + # group terminal nodes by their payoffs, so each of the 4 possible outcomes is created once + payoff_labels = { + (1, -1): "Alice wins 1", + (2, -2): "Alice wins 2", + (-1, 1): "Bob wins 1", + (-2, 2): "BOb wins 2", } - + nodes_by_payoff = {payoffs: [] for payoffs in payoff_labels} for term_node in [n for n in g.nodes if n.is_terminal]: - outcome = payoffs_to_outcomes[calculate_payoffs(term_node)] - g.set_outcome(term_node, outcome) + nodes_by_payoff[calculate_payoffs(term_node)].append(term_node) + + for payoffs, nodes in nodes_by_payoff.items(): + g.make_outcome(nodes, {"Alice": payoffs[0], "Bob": payoffs[1]}, payoff_labels[payoffs]) return g @@ -316,37 +312,22 @@ def _create_kuhn_poker_efg_nonterm_outcomes() -> gbt.Game: """ g = _create_kuhn_poker_efg_without_outcomes() - ante_outcome = g.add_outcome("Ante", [-1, -1]) - g.set_outcome(g.root, ante_outcome) - - outcomes_dict = dict() + # each outcome's payoffs, keyed by the same labels used below; collected up front so each + # outcome can be created once, attached to every node (terminal or not) that shares it. + payoffs_by_key = {"Ante": (-1, -1)} for player in ["Alice", "Bob"]: - # non-terminal outcomes for betting - payoffs = [-1, 0] if player == "Alice" else [0, -1] - tmp = f"{player} bets" - 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(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(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(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(tmp, payoffs) - - def add_outcomes(term_node): + payoffs_by_key[f"{player} bets"] = (-1, 0) if player == "Alice" else (0, -1) + payoffs_by_key[f"{player} wins showdown for pot of 2"] = ( + (2, 0) if player == "Alice" else (0, 2) + ) + payoffs_by_key[f"{player} folds"] = (0, 3) if player == "Alice" else (3, 0) + payoffs_by_key[f"{player} calls and wins"] = (3, 0) if player == "Alice" else (0, 3) + payoffs_by_key[f"{player} calls and loses"] = (-1, 4) if player == "Alice" else (4, -1) + + nodes_by_key = {key: [] for key in payoffs_by_key} + nodes_by_key["Ante"].append(g.root) + + def collect_nodes(term_node): def get_path(node): path = [] while node.parent: @@ -362,26 +343,32 @@ def get_path(node): if label == "Check": # Alice checks n, label = path.pop() if label == "Check": # Bob checks - g.set_outcome(n, outcomes_dict[f"{winner} wins showdown for pot of 2"]) + nodes_by_key[f"{winner} wins showdown for pot of 2"].append(n) else: # Bob bets - g.set_outcome(n, outcomes_dict["Bob bets"]) + nodes_by_key["Bob bets"].append(n) n, label = path.pop() if label == "Fold": # Alice folds - g.set_outcome(n, outcomes_dict["Alice folds"]) + nodes_by_key["Alice folds"].append(n) else: # Alice calls tmp = "wins" if winner == "Alice" else "loses" - g.set_outcome(n, outcomes_dict[f"Alice calls and {tmp}"]) + nodes_by_key[f"Alice calls and {tmp}"].append(n) else: # Alice bets - g.set_outcome(n, outcomes_dict["Alice bets"]) + nodes_by_key["Alice bets"].append(n) n, label = path.pop() if label == "Fold": # Bob - g.set_outcome(n, outcomes_dict["Bob folds"]) + nodes_by_key["Bob folds"].append(n) else: # Bob calls tmp = "wins" if winner == "Bob" else "loses" - g.set_outcome(n, outcomes_dict[f"Bob calls and {tmp}"]) + nodes_by_key[f"Bob calls and {tmp}"].append(n) for term_node in [n for n in g.nodes if n.is_terminal]: - add_outcomes(term_node) + collect_nodes(term_node) + + for key, nodes in nodes_by_key.items(): + # the same non-terminal node is revisited once per terminal descendant walked above + deduped_nodes = list(dict.fromkeys(nodes)) + alice_payoff, bob_payoff = payoffs_by_key[key] + g.make_outcome(deduped_nodes, {"Alice": alice_payoff, "Bob": bob_payoff}, key) return g @@ -454,16 +441,22 @@ def create_one_shot_trust_efg(unique_NE_variant: bool = False) -> gbt.Game: ) g.append_move(g.root, "Buyer", ["Trust", "Not trust"]) g.append_move(g.root.children["Trust"], "Seller", ["Honor", "Abuse"]) - g.set_outcome(g.root.children["Trust"].children["Honor"], g.add_outcome("Trustworthy", [1, 1])) + g.make_outcome( + g.root.children["Trust"].children["Honor"], {"Buyer": 1, "Seller": 1}, "Trustworthy" + ) if unique_NE_variant: - g.set_outcome( - g.root.children["Trust"].children["Abuse"], g.add_outcome("Untrustworthy", ["1/2", 2]) + g.make_outcome( + g.root.children["Trust"].children["Abuse"], + {"Buyer": "1/2", "Seller": 2}, + "Untrustworthy", ) else: - g.set_outcome( - g.root.children["Trust"].children["Abuse"], g.add_outcome("Untrustworthy", [-1, 2]) + g.make_outcome( + g.root.children["Trust"].children["Abuse"], + {"Buyer": -1, "Seller": 2}, + "Untrustworthy", ) - g.set_outcome(g.root.children["Not trust"], g.add_outcome("Opt-out", [0, 0])) + g.make_outcome(g.root.children["Not trust"], {"Buyer": 0, "Seller": 0}, "Opt-out") return g @@ -580,12 +573,16 @@ 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(f"take_{t}", payoffs)) + g.make_outcome( + current_node.children["Take"], {"1": payoffs[0], "2": payoffs[1]}, f"take_{t}" + ) if t == self.N - 1: # for last round, push payoffs payoffs = [2 ** (t + 1) * self.m1, 2 ** (t + 1) * self.m0] if current_player == "2": payoffs.reverse() - g.set_outcome(current_node.children["Push"], g.add_outcome(f"push_{t}", payoffs)) + g.make_outcome( + current_node.children["Push"], {"1": payoffs[0], "2": payoffs[1]}, f"push_{t}" + ) current_node = current_node.children["Push"] current_player = "2" if current_player == "1" else "1" return g @@ -706,8 +703,8 @@ 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(f"leaf_{len(list(g.outcomes))}", [0] * self.n_players) + g.make_outcome( + node, {str(p): 0 for p in self.players}, f"leaf_{len(list(g.outcomes))}" ) else: current_player = str(whose_turn + 1) diff --git a/tests/test_node.py b/tests/test_node.py index 2d401141d..1a71d8a22 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -49,11 +49,11 @@ def test_get_outcome(): assert not game.root.outcome -def test_set_outcome_null(): - """Setting an outcome to null leaves the node's outcome view falsy.""" +def test_make_outcome_null(): + """Resetting a node's outcome to null leaves the node's outcome view falsy.""" game = games.read_from_file("basic_extensive_game.efg") node = game.root.children["U1"].children["U2"].children["U3"] - game.set_outcome(node, None) + game.make_outcome_null(node) assert not node.outcome diff --git a/tests/test_outcomes.py b/tests/test_outcomes.py index 7abca712a..897e74fc7 100644 --- a/tests/test_outcomes.py +++ b/tests/test_outcomes.py @@ -5,15 +5,6 @@ from . import games -@pytest.mark.parametrize( - "game", [gbt.Game.new_table([2, 2]), gbt.Game.new_tree()] -) -def test_outcome_add(game: gbt.Game): - outcome_count = len(game.outcomes) - game.add_outcome(label="new outcome") - assert len(game.outcomes) == outcome_count + 1 - - def test_make_outcome_attaches_to_all_given_nodes(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(game.root, "Alice", ["U", "M", "D"]) @@ -56,6 +47,17 @@ def test_make_outcome_label_of_partially_covered_outcome_refused(): assert len(game.outcomes) == 1 +@pytest.mark.parametrize("bad_label", ["", "win"]) +def test_make_outcome_bad_label_raises_and_leaves_game_unchanged(bad_label: str): + game = gbt.Game.new_tree(players=["A", "B"]) + game.append_move(game.root, "A", ["win", "lose"]) + win_node, lose_node = game.root.children + game.make_outcome(win_node, {"A": 1, "B": 2}, "win") + with pytest.raises(ValueError): + game.make_outcome(lose_node, {"A": 3, "B": 4}, bad_label) + assert [o.label for o in game.outcomes] == ["win"] + + def test_make_outcome_incomplete_payoffs_raises(): game = gbt.Game.new_tree(["Alice", "Bob"]) game.append_move(game.root, "Alice", ["U", "D"]) @@ -72,15 +74,58 @@ def test_make_outcome_payoffs_naming_player_twice_raises(): {"Alice": 1, alice: 2, "Bob": 0}, "w") -@pytest.mark.parametrize( - "game", [gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]])] -) -def test_outcome_delete(game: gbt.Game): +def test_make_outcome_null_resets_given_nodes_to_null(): + game = gbt.Game.new_tree(["Alice", "Bob"]) + game.append_move(game.root, "Alice", ["U", "M", "D"]) + up, middle, down = game.root.children + game.make_outcome([up, middle], {"Alice": 1, "Bob": -1}, "shared") + game.make_outcome_null(up) + assert not up.outcome + assert middle.outcome + assert not down.outcome + + +def test_make_outcome_null_resets_given_contingencies_to_null(): + game = gbt.Game.new_table([2, 2]) + game.make_outcome( + [{"1": "1", "2": "1"}, {"1": "2", "2": "2"}], {"1": 2, "2": -2}, "diagonal" + ) + game.make_outcome_null({"1": "1", "2": "1"}) + assert not game.get_outcome({"1": "1", "2": "1"}) + assert game.get_outcome({"1": "2", "2": "2"}) + + +def test_make_outcome_null_removes_fully_orphaned_outcome(): + game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) outcome_count = len(game.outcomes) - game.delete_outcome(next(iter(game.outcomes))) + p1, p2 = game.players + s1 = next(iter(p1.strategies)) + s2 = next(iter(p2.strategies)) + game.make_outcome_null({p1.label: s1, p2.label: s2}) assert len(game.outcomes) == outcome_count - 1 +def test_make_outcome_null_keeps_partially_referenced_outcome(): + game = gbt.Game.new_tree(["Alice"]) + game.append_move(game.root, "Alice", ["U", "M", "D"]) + up, middle, down = game.root.children + game.make_outcome([up, middle], {"Alice": 1}, "shared") + outcome_count = len(game.outcomes) + game.make_outcome_null(up) + assert len(game.outcomes) == outcome_count + assert middle.outcome + + +def test_make_outcome_null_on_already_null_node_is_a_no_op(): + game = gbt.Game.new_tree(["Alice"]) + game.append_move(game.root, "Alice", ["U", "D"]) + up, _ = game.root.children + outcome_count = len(game.outcomes) + game.make_outcome_null(up) + assert outcome_count == len(game.outcomes) + assert not up.outcome + + @pytest.mark.parametrize("label", games.VALID_LABELS) def test_outcome_label(label: str): game = gbt.Game.from_arrays([[0, 0], [0, 0]], [[0, 0], [0, 0]]) @@ -125,12 +170,6 @@ 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])] ) @@ -154,19 +193,12 @@ def test_outcome_payoff_by_player_label(): 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]) + game.append_move(game.root, "A", ["win", "lose"]) + win_node, lose_node = game.root.children + game.make_outcome(win_node, {"A": 1, "B": 2}, "win") + outcome = game.make_outcome(lose_node, {"A": 0, "B": 0}, "lose") with pytest.raises(ValueError): outcome.label = "win" assert outcome.label == "lose" diff --git a/tests/test_players.py b/tests/test_players.py index bd8c37818..5a9745593 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -393,7 +393,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("outcome", [-1, -1])) + game.make_outcome(game.root, {"Alice": -1, "Bob": -1}, "outcome") assert game.players["Alice"].min_payoff == -3 assert game.players["Bob"].min_payoff == -3 @@ -419,7 +419,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("outcome", [-1, -1])) + game.make_outcome(game.root, {"Alice": -1, "Bob": -1}, "outcome") assert game.players["Alice"].max_payoff == 1 assert game.players["Bob"].max_payoff == 1