From 76d877dc2a0bfdd81120cb92a56f51429a1114d1 Mon Sep 17 00:00:00 2001 From: drdkad Date: Wed, 22 Jul 2026 12:21:22 +0100 Subject: [PATCH 1/5] Number reduced strategies sequentially instead of by action signature --- ChangeLog | 2 ++ src/games/game.cc | 13 +++---- tests/games.py | 9 +++-- tests/test_actions.py | 18 +++++----- tests/test_extensive.py | 49 ++++++++++--------------- tests/test_io.py | 4 +-- tests/test_mixed.py | 79 +++++++++++++++-------------------------- 7 files changed, 69 insertions(+), 105 deletions(-) diff --git a/ChangeLog b/ChangeLog index 19e30ae56..899175e7f 100644 --- a/ChangeLog +++ b/ChangeLog @@ -7,6 +7,8 @@ the run. (#998) - Implemented bespoke XML parser that handles the subset in the de-facto legacy XML workbook .gbt format; removes dependency on tinyxml. (#897) +- Reduced strategies of extensive games are now labelled by sequential per-player integers ("1", "2", ...) + in the deterministic order in which they are generated, matching the labelling of strategies in strategic games. ### Fixed - Converting a behavior profile to a strategy profile, and computing pure-strategy payoffs diff --git a/src/games/game.cc b/src/games/game.cc index 79c5cd7dd..ed6db1d73 100644 --- a/src/games/game.cc +++ b/src/games/game.cc @@ -92,16 +92,11 @@ GamePlayerRep::~GamePlayerRep() void GamePlayerRep::MakeStrategy(const std::map &behav) { - auto strategy = std::make_shared(this, m_strategies.size() + 1, ""); + // Reduced strategies are labelled by their sequence number in their generation order. + // This order is deterministic for a given game (see MakeReducedStrats) + const std::string number = std::to_string(m_strategies.size() + 1); + auto strategy = std::make_shared(this, m_strategies.size() + 1, number); strategy->m_behav = behav; - for (const auto &infoset : m_infosets) { - strategy->m_label += (contains(strategy->m_behav, infoset.get())) - ? std::to_string(strategy->m_behav.at(infoset.get())) - : "*"; - } - if (strategy->m_label.empty()) { - strategy->m_label = "*"; - } m_strategies.push_back(strategy); } diff --git a/tests/games.py b/tests/games.py index 9e73e32d2..4d22ddd9f 100644 --- a/tests/games.py +++ b/tests/games.py @@ -535,7 +535,7 @@ def get_rss(n): rs = [get_rss(n) for n in n_moves] self.set_size_of_rsf(rs) - return rs + return [[str(i) for i in range(1, len(r) + 1)] for r in rs] def reduced_strategic_form(self): m, n = self.size_of_rsf @@ -615,7 +615,7 @@ def get_n_infosets(self, level): return {p: n_isets[p - 1] for p in players} def _redu_strategies_level_1(self, player): - return ["1", "2"] if player == 1 else ["*"] + return ["1", "2"] if player == 1 else ["1"] def player_with_changes(self, level): return ((level - 1) % self.n_players) + 1 @@ -625,12 +625,15 @@ def last_player_with_changes(self, level): @abstractmethod def _redu_strats(self, player, level): + """Returns a list whose length is the player's reduced-strategy count. + The signature labels are no longer used as strategies are now labelled sequentially. + """ pass def reduced_strategies(self): rs = [self._redu_strats(player, self.level) for player in self.players] self.set_size_of_rsf(rs) - return rs + return [[str(i) for i in range(1, len(r) + 1)] for r in rs] 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) diff --git a/tests/test_actions.py b/tests/test_actions.py index 28b61eeee..e53f9f8d1 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -180,8 +180,8 @@ def node_at(path: list[str]) -> gbt.Node: (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 2", "2", ["R"], "L"), (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 3", "1", ["R", "L"], "R"), (gbt.catalog.load("journals/ijgt/selten1975/fig1"), "Player 3", "2", ["R", "L"], "L"), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "1*", [], "R"), - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "21", [], "L"), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "1", [], "R"), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "2", [], "L"), (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 2", "1", ["L"], "R"), (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 2", "2", ["L"], "L"), (games.read_from_file("basic_extensive_game.efg"), "Player 1", "1", [], "U1"), @@ -212,13 +212,13 @@ def test_strategy_action_defined( @pytest.mark.parametrize( "game, player_label, strategy_label, infoset_label, infoset_path", [ - (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "1*", None, ["L", "L"]), - (games.read_from_file("cent3.efg"), "Player 1", "1**111", "(1,3)", None), - (games.read_from_file("cent3.efg"), "Player 1", "1**111", "(1,5)", None), - (games.read_from_file("cent3.efg"), "Player 1", "21*111", "(1,5)", None), - (games.read_from_file("cent3.efg"), "Player 2", "1**111", "(2,4)", None), - (games.read_from_file("cent3.efg"), "Player 2", "1**111", "(2,4)", None), - (games.read_from_file("cent3.efg"), "Player 2", "21*111", "(2,5)", None), + (gbt.catalog.load("journals/ijgt/selten1975/fig2"), "Player 1", "1", None, ["L", "L"]), + (games.read_from_file("cent3.efg"), "Player 1", "1", "(1,3)", None), + (games.read_from_file("cent3.efg"), "Player 1", "1", "(1,5)", None), + (games.read_from_file("cent3.efg"), "Player 1", "2", "(1,5)", None), + (games.read_from_file("cent3.efg"), "Player 2", "1", "(2,4)", None), + (games.read_from_file("cent3.efg"), "Player 2", "1", "(2,4)", None), + (games.read_from_file("cent3.efg"), "Player 2", "2", "(2,5)", None), ], ) def test_strategy_action_undefined_returns_none( diff --git a/tests/test_extensive.py b/tests/test_extensive.py index 5f5e35e1f..a7e16c4b6 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -119,13 +119,13 @@ def test_outcome_index_exception_label(): # 1 player; reduction; generic payoffs ( games.read_from_file("reduction_one_player_generic_payoffs.efg"), - [["11", "12", "2*", "3*", "4*"]], + [["1", "2", "3", "4", "5"]], [np.array(range(1, 6))], ), # 2 players; reduction possible for player 1; payoff ties ( gbt.catalog.load("journals/ijgt/selten1975/fig2"), - [["1*", "21", "22"], ["1", "2"]], + [["1", "2", "3"], ["1", "2"]], [ np.array([[1, 1], [0, 0], [0, 2]]), np.array([[1, 1], [2, 3], [2, 0]]), @@ -134,7 +134,7 @@ def test_outcome_index_exception_label(): # 2 players; 1 move each so no reduction possible ( games.read_from_file("sample_extensive_game.efg"), - [["1", "2"], ["11", "12", "21", "22"]], + [["1", "2"], ["1", "2", "3", "4"]], [ np.array([[2, 2, 2, 2], [4, 6, 4, 6]]), np.array([[3, 3, 3, 3], [5, 7, 5, 7]]), @@ -165,8 +165,8 @@ def test_outcome_index_exception_label(): ( games.read_from_file("reduction_generic_payoffs.efg"), [ - ["1*1", "1*2", "211", "212", "221", "222"], - ["11*", "12*", "2**", "3*1", "3*2", "4**"], + ["1", "2", "3", "4", "5", "6"], + ["1", "2", "3", "4", "5", "6"], ], [ np.array( @@ -195,7 +195,7 @@ def test_outcome_index_exception_label(): ( games.read_from_file("binary_3_levels_generic_payoffs.efg"), [ - ["11*", "12*", "2*1", "2*2"], + ["1", "2", "3", "4"], ["1", "2"], ], [ @@ -207,21 +207,8 @@ def test_outcome_index_exception_label(): ( games.read_from_file("reduction_both_players_payoff_ties_GTE_survey.efg"), [ - ["1*", "2*", "31", "32", "4*"], - [ - "1*11", - "1*12", - "1*21", - "1*22", - "2111", - "2112", - "2121", - "2122", - "2211", - "2212", - "2221", - "2222", - ], + ["1", "2", "3", "4", "5"], + ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"], ], [ np.array( @@ -251,8 +238,8 @@ def test_outcome_index_exception_label(): ( games.read_from_file("cent3.efg"), [ - ["1**111", "21*111", "221111", "222111"], - ["1**111", "21*111", "221111", "222111"], + ["1", "2", "3", "4"], + ["1", "2", "3", "4"], ], [ np.array( @@ -276,7 +263,7 @@ def test_outcome_index_exception_label(): # Stripped-down poker; 2 player zero-sum game with chance at the root ( games.create_stripped_down_poker_efg(), - [["11", "12", "21", "22"], ["1", "2"]], + [["1", "2", "3", "4"], ["1", "2"]], [ np.array([[0, 1], ["1/2", 0], ["-3/2", 0], [-1, -1]]), np.array([[0, -1], ["-1/2", 0], ["3/2", 0], [1, 1]]), @@ -284,7 +271,7 @@ def test_outcome_index_exception_label(): ), ( games.create_stripped_down_poker_efg(nonterm_outcomes=True), - [["11", "12", "21", "22"], ["1", "2"]], + [["1", "2", "3", "4"], ["1", "2"]], [ np.array([[0, 1], ["1/2", 0], ["-3/2", 0], [-1, -1]]), np.array([[0, -1], ["-1/2", 0], ["3/2", 0], [1, 1]]), @@ -293,7 +280,7 @@ def test_outcome_index_exception_label(): # Nature playing at the root, 2 players, no reduction, non-generic payoffs ( games.read_from_file("nature_rooted_nongeneric.efg"), - [["1", "2"], ["11", "12", "21", "22"]], + [["1", "2"], ["1", "2", "3", "4"]], [ np.array([[-1, -1, 2, 2], [0, 0, 0, 0]]), np.array([[-1, -1, 2, 2], [3, 4, 3, 4]]), @@ -302,7 +289,7 @@ def test_outcome_index_exception_label(): # Nature playing at the root, 2 players, no reduction, generic payoffs ( games.read_from_file("nature_rooted_generic.efg"), - [["1", "2"], ["11", "12", "21", "22"]], + [["1", "2"], ["1", "2", "3", "4"]], [ np.array([[3, 3, 4, 4], [5, 6, 5, 6]]), np.array([[-3, -3, -4, -4], [-5, -6, -5, -6]]), @@ -311,7 +298,7 @@ def test_outcome_index_exception_label(): # Nature playing last determining the payoffs, 2 players, no reduction, non-generic payoffs ( games.read_from_file("nature_leaves_nongeneric.efg"), - [["1", "2"], ["11", "12", "21", "22"]], + [["1", "2"], ["1", "2", "3", "4"]], [ np.array([[-1, -1, 2, 2], [0, 0, 0, 0]]), np.array([[-1, -1, 2, 2], [3, 4, 3, 4]]), @@ -320,7 +307,7 @@ def test_outcome_index_exception_label(): # Nature playing last determining the payoffs, 2 players, no reduction, generic payoffs ( games.read_from_file("nature_leaves_generic.efg"), - [["1", "2"], ["11", "12", "21", "22"]], + [["1", "2"], ["1", "2", "3", "4"]], [ np.array( [["3/2", "3/2", "7/2", "7/2"], ["11/2", "15/2", "11/2", "15/2"]] @@ -366,7 +353,7 @@ def test_outcome_index_exception_label(): # Wichardt (2008): binary tree of height 3; 2 players; the root player forgets the action # ( # gbt.catalog.load("journals/geb/wichardt2008"), - # [["11", "12", "21", "22"], ["1", "2"]], + # [["1", "2", "3", "4"], ["1", "2"]], # [ # np.array([[1, -1], [-5, -5], [-5, -5], [-1, 1]]), # np.array([[-1, 1], [5, 5], [5, 5], [1, -1]]), @@ -380,7 +367,7 @@ def test_reduced_strategic_form( """ We test two things: - that the strategy labels are as expected - (these use positive integers and '*'s, rather than labels of moves even if they exist) + (labelled by sequential per-player integers, not by the numbers of prescribed actions) - that the payoff tables are correct, which is done via game.to_arrays() """ arrays = game.to_arrays() diff --git a/tests/test_io.py b/tests/test_io.py index 902580ebd..984814e3e 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -131,8 +131,8 @@ def test_write_efg_as_nfg(): result = """ NFG 1 R "Centipede game. Three inning with probability of altruism. " { "Player 1" "Player 2" } -{ { "1**111" "21*111" "221111" "222111" } -{ "1**111" "21*111" "221111" "222111" } +{ { "1" "2" "3" "4" } +{ "1" "2" "3" "4" } } "" diff --git a/tests/test_mixed.py b/tests/test_mixed.py index 047574c2f..eefbb294d 100644 --- a/tests/test_mixed.py +++ b/tests/test_mixed.py @@ -145,14 +145,6 @@ def test_normalize(game, profile_data, expected_data, rational_flag): # coordination 4x4 nfg outcome version with strategy labels (games.read_from_file("coordination_4x4_outcome.nfg"), "1-1", 0.25, False), (games.read_from_file("coordination_4x4_outcome.nfg"), "1-1", "1/4", True), - ############################################################################### - # stripped-down poker efg - (games.create_stripped_down_poker_efg(), "11", 0.25, False), - (games.create_stripped_down_poker_efg(), "12", 0.15, False), - (games.create_stripped_down_poker_efg(), "21", 0.99, False), - (games.create_stripped_down_poker_efg(), "11", "1/4", True), - (games.create_stripped_down_poker_efg(), "12", "3/4", True), - (games.create_stripped_down_poker_efg(), "21", "7/9", True), ], ) def test_set_and_get_probability_by_strategy_label( @@ -198,14 +190,14 @@ def test_set_and_get_probabilities_by_player_label( ############################################################################## # stripped-down poker efg # Player 1 - (games.create_stripped_down_poker_efg(), "Alice", "11", 0.25, False), - (games.create_stripped_down_poker_efg(), "Alice", "12", 0.25, False), - (games.create_stripped_down_poker_efg(), "Alice", "21", 0.25, False), - (games.create_stripped_down_poker_efg(), "Alice", "22", 0.25, False), - (games.create_stripped_down_poker_efg(), "Alice", "11", "1/4", True), - (games.create_stripped_down_poker_efg(), "Alice", "12", "1/4", True), - (games.create_stripped_down_poker_efg(), "Alice", "21", "1/4", True), - (games.create_stripped_down_poker_efg(), "Alice", "22", "1/4", True), + (games.create_stripped_down_poker_efg(), "Alice", "1", 0.25, False), + (games.create_stripped_down_poker_efg(), "Alice", "2", 0.25, False), + (games.create_stripped_down_poker_efg(), "Alice", "3", 0.25, False), + (games.create_stripped_down_poker_efg(), "Alice", "4", 0.25, False), + (games.create_stripped_down_poker_efg(), "Alice", "1", "1/4", True), + (games.create_stripped_down_poker_efg(), "Alice", "2", "1/4", True), + (games.create_stripped_down_poker_efg(), "Alice", "3", "1/4", True), + (games.create_stripped_down_poker_efg(), "Alice", "4", "1/4", True), # Player 2 (games.create_stripped_down_poker_efg(), "Bob", "1", 0.5, False), (games.create_stripped_down_poker_efg(), "Bob", "2", 0.5, False), @@ -232,10 +224,10 @@ def test_profile_indexing_by_player_and_strategy_label_reference( # stripped-down poker efg (games.create_stripped_down_poker_efg(), "Bob", "11", True), (games.create_stripped_down_poker_efg(), "Bob", "11", False), - (games.create_stripped_down_poker_efg(), "Alice", "1", True), - (games.create_stripped_down_poker_efg(), "Alice", "1", False), - (games.create_stripped_down_poker_efg(), "Alice", "2", True), - (games.create_stripped_down_poker_efg(), "Alice", "2", False), + (games.create_stripped_down_poker_efg(), "Alice", "99", True), + (games.create_stripped_down_poker_efg(), "Alice", "99", False), + (games.create_stripped_down_poker_efg(), "Bob", "99", True), + (games.create_stripped_down_poker_efg(), "Bob", "99", False), ############################################################################## # coordination 4x4 nfg outcome version with strategy labels (games.read_from_file("coordination_4x4_outcome.nfg"), P1, "2-1", True), @@ -312,22 +304,6 @@ def test_profile_indexing_by_invalid_strategy_label( @pytest.mark.parametrize( "game,strategy_label,prob,rational_flag", [ - ########################################################################### - # stripped-down poker efg - # Player 1 - (games.create_stripped_down_poker_efg(), "11", 0.25, False), - (games.create_stripped_down_poker_efg(), "12", 0.25, False), - (games.create_stripped_down_poker_efg(), "21", 0.25, False), - (games.create_stripped_down_poker_efg(), "22", 0.25, False), - (games.create_stripped_down_poker_efg(), "11", "1/4", True), - (games.create_stripped_down_poker_efg(), "12", "1/4", True), - (games.create_stripped_down_poker_efg(), "21", "1/4", True), - (games.create_stripped_down_poker_efg(), "22", "1/4", True), - # Player 2 - (games.create_stripped_down_poker_efg(), "1", 0.5, False), - (games.create_stripped_down_poker_efg(), "2", 0.5, False), - (games.create_stripped_down_poker_efg(), "1", "1/2", True), - (games.create_stripped_down_poker_efg(), "2", "1/2", True), ############################################################################ # coordination 4x4 nfg outcome version with strategy labels # Player 1 @@ -503,33 +479,34 @@ def test_payoff_by_label_reference( @pytest.mark.parametrize( - "game,rational_flag,label,value", + "game,rational_flag,player_label,label,value", [ ############################################################################## # zero matrix nfg - (games.read_from_file("2x2_bimatrix_all_zero_payoffs.nfg"), False, "cooperate", 0), - (games.read_from_file("2x2_bimatrix_all_zero_payoffs.nfg"), True, "cooperate", 0), + (games.read_from_file("2x2_bimatrix_all_zero_payoffs.nfg"), False, "Joe", "cooperate", 0), + (games.read_from_file("2x2_bimatrix_all_zero_payoffs.nfg"), True, "Joe", "cooperate", 0), ############################################################################## # coordination 4x4 nfg - (games.read_from_file("coordination_4x4_outcome.nfg"), False, "1-1", 0.25), - (games.read_from_file("coordination_4x4_outcome.nfg"), True, "1-1", "1/4"), + (games.read_from_file("coordination_4x4_outcome.nfg"), False, P1, "1-1", 0.25), + (games.read_from_file("coordination_4x4_outcome.nfg"), True, P1, "1-1", "1/4"), ############################################################################## # stripped-down poker efg - (games.create_stripped_down_poker_efg(), False, "11", 0.5), # Bet/Bet - (games.create_stripped_down_poker_efg(), False, "12", 0.25), # Bet King/Fold Queen - (games.create_stripped_down_poker_efg(), False, "21", -0.75), # Fold King/Bet Queen - (games.create_stripped_down_poker_efg(), False, "22", -1), # Fold/Fold - (games.create_stripped_down_poker_efg(), True, "11", "1/2"), - (games.create_stripped_down_poker_efg(), True, "12", "1/4"), - (games.create_stripped_down_poker_efg(), True, "21", "-3/4"), - (games.create_stripped_down_poker_efg(), True, "22", -1), + (games.create_stripped_down_poker_efg(), False, "Alice", "1", 0.5), # Bet/Bet + (games.create_stripped_down_poker_efg(), False, "Alice", "2", 0.25), # BetKing/FoldQueen + (games.create_stripped_down_poker_efg(), False, "Alice", "3", -0.75), # FoldKing/BetQueen + (games.create_stripped_down_poker_efg(), False, "Alice", "4", -1), # Fold/Fold + (games.create_stripped_down_poker_efg(), True, "Alice", "1", "1/2"), + (games.create_stripped_down_poker_efg(), True, "Alice", "2", "1/4"), + (games.create_stripped_down_poker_efg(), True, "Alice", "3", "-3/4"), + (games.create_stripped_down_poker_efg(), True, "Alice", "4", -1), ], ) def test_strategy_value_by_label_reference( - game: gbt.Game, rational_flag: bool, label: str, value: float | str + game: gbt.Game, rational_flag: bool, player_label: str, label: str, value: float | str ): value = gbt.Rational(value) if rational_flag else value - assert game.mixed_strategy_profile(rational=rational_flag).strategy_value(label) == value + strategy = game.players[player_label].strategies[label] + assert game.mixed_strategy_profile(rational=rational_flag).strategy_value(strategy) == value @pytest.mark.parametrize( From 1772667f767509f11bd328c3547713788c3ea70f Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Wed, 22 Jul 2026 19:10:45 -0400 Subject: [PATCH 2/5] Develop some simple GUI UX for displaying the reduced strategy map from a given strategy. --- doc/gui.nfg.rst | 6 +++ src/gui/nfgtable.cc | 114 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/doc/gui.nfg.rst b/doc/gui.nfg.rst index 3631bd74c..87d1e0272 100644 --- a/doc/gui.nfg.rst +++ b/doc/gui.nfg.rst @@ -17,6 +17,12 @@ of an extensive game cannot be modified directly. Instead, edit the original extensive game; Gambit automatically recomputes the strategic game after any changes to the extensive game. +Strategies in a reduced strategic game are assigned numeric labels for +identification. These labels are assigned via a deterministic algorithm for +constructing the reduced strategic game from an extensive game. +Click a strategy label to open a popup listing the action selected at each +information set where the reduced strategy specifies an action. + Strategic games may also be input directly. To create a new strategic game, select :menuselection:`File --> New --> Strategic game`, or click the new strategic game icon on the toolbar. diff --git a/src/gui/nfgtable.cc b/src/gui/nfgtable.cc index 1bd8b5aa0..4a48a578f 100644 --- a/src/gui/nfgtable.cc +++ b/src/gui/nfgtable.cc @@ -27,6 +27,7 @@ #include // for drag-and-drop support #include // for printing support #include // for SVG output +#include #include "wx/sheet/sheet.h" @@ -75,7 +76,7 @@ class TableWidgetBase : public wxSheet { void DrawCursorCellHighlight(wxDC &, const wxSheetCellAttr &) override {} /// Overriding wxSheet member to show editor on one click - void OnCellLeftClick(wxSheetEvent &); + virtual void OnCellLeftClick(wxSheetEvent &); public: /// @name Lifecycle @@ -159,6 +160,7 @@ class RowPlayerWidget final : public TableWidgetBase { //@} void OnCellRightClick(wxSheetEvent &); + void OnCellLeftClick(wxSheetEvent &) override; bool ShowPlayerDropMenu(int p_index, int p_player, const wxString &p_label, const wxPoint &p_pos); @@ -199,6 +201,99 @@ RowPlayerWidget::RowPlayerWidget(TableWidget *p_parent) static_cast(&RowPlayerWidget::OnCellRightClick)))); } +namespace { +wxString GetStrategyTooltip(const GameStrategy &p_strategy) +{ + if (!p_strategy->GetGame()->IsTree()) { + return {}; + } + + wxString tooltip; + for (const auto &infoset : p_strategy->GetPlayer()->GetInfosets()) { + const auto action = p_strategy->GetAction(infoset); + if (!action) { + continue; + } + + if (!tooltip.empty()) { + tooltip << wxT("\n"); + } + tooltip << wxString::Format(_("Information set %d"), infoset->GetNumber()); + if (!infoset->GetLabel().empty()) { + tooltip << wxT(" (") << wxString(infoset->GetLabel().c_str(), *wxConvCurrent) << wxT(")"); + } + tooltip << wxT(": "); + if (action->GetLabel().empty()) { + tooltip << wxString::Format(_("action %d"), action->GetNumber()); + } + else { + tooltip << wxString(action->GetLabel().c_str(), *wxConvCurrent); + } + } + return tooltip; +} + +class StrategyDescriptionPopup final : public wxPopupTransientWindow { +public: + StrategyDescriptionPopup(wxWindow *p_parent, const GameStrategy &p_strategy) + : wxPopupTransientWindow(p_parent, wxBORDER_NONE) + { + SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_BTNSHADOW)); + + auto *content = new wxPanel(this); + content->SetBackgroundColour(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); + + auto *contentSizer = new wxBoxSizer(wxVERTICAL); + const wxString label(p_strategy->GetLabel().c_str(), *wxConvCurrent); + auto *heading = new wxStaticText(content, wxID_ANY, wxString::Format(_("Strategy %s"), label)); + wxFont headingFont = heading->GetFont(); + headingFont.SetWeight(wxFONTWEIGHT_BOLD); + headingFont.SetPointSize(headingFont.GetPointSize() + 1); + heading->SetFont(headingFont); + contentSizer->Add(heading, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(12)); + + auto *description = new wxStaticText(content, wxID_ANY, GetStrategyTooltip(p_strategy)); + description->Wrap(FromDIP(420)); + contentSizer->Add(description, 0, wxALL, FromDIP(12)); + content->SetSizer(contentSizer); + + auto *popupSizer = new wxBoxSizer(wxVERTICAL); + popupSizer->Add(content, 1, wxEXPAND | wxALL, FromDIP(1)); + SetSizerAndFit(popupSizer); + } + +protected: + void OnDismiss() override { Destroy(); } +}; + +void ShowStrategyPopup(wxSheet *p_sheet, const wxSheetCoords &p_coords, + const GameStrategy &p_strategy) +{ + auto *popup = new StrategyDescriptionPopup(p_sheet, p_strategy); + const wxRect cellRect = p_sheet->CellToRect(p_coords, true); + const wxPoint anchor = p_sheet->GetGridWindow()->ClientToScreen(cellRect.GetBottomLeft()); + popup->Position(anchor, wxSize(popup->FromDIP(8), popup->FromDIP(8))); + popup->Popup(); +} + +} // namespace + +void RowPlayerWidget::OnCellLeftClick(wxSheetEvent &p_event) +{ + if (!m_table->IsReadOnly() || m_table->GetRowHeaderColCount() == 0) { + TableWidgetBase::OnCellLeftClick(p_event); + return; + } + + const auto coords = p_event.GetCoords(); + const int player = m_table->GetRowHeaderPlayer(coords.GetCol()); + const int strategy = m_table->GetRowHeaderStrategy(coords.GetCol(), coords.GetRow()); + const auto gameStrategy = m_table->GetStrategyByPlayerAndIndex(player, strategy); + if (!GetStrategyTooltip(gameStrategy).empty()) { + ShowStrategyPopup(this, coords, gameStrategy); + } +} + void RowPlayerWidget::OnCellRightClick(wxSheetEvent &p_event) { if (m_table->GetRowHeaderColCount() == 0 || m_table->IsReadOnly()) { @@ -428,6 +523,7 @@ class ColPlayerWidget final : public TableWidgetBase { //@} void OnCellRightClick(wxSheetEvent &); + void OnCellLeftClick(wxSheetEvent &) override; bool ShowPlayerDropMenu(int p_index, int p_player, const wxString &p_label, const wxPoint &p_pos); @@ -468,6 +564,22 @@ ColPlayerWidget::ColPlayerWidget(TableWidget *p_parent) wxSheetEventFunction, wxSheetEventFunction(&ColPlayerWidget::OnCellRightClick)))); } +void ColPlayerWidget::OnCellLeftClick(wxSheetEvent &p_event) +{ + if (!m_table->IsReadOnly() || m_table->GetColHeaderRowCount() == 0) { + TableWidgetBase::OnCellLeftClick(p_event); + return; + } + + const auto coords = p_event.GetCoords(); + const int player = m_table->GetColHeaderPlayer(coords.GetRow()); + const int strategy = m_table->GetColHeaderStrategy(coords.GetRow(), coords.GetCol()); + const auto gameStrategy = m_table->GetStrategyByPlayerAndIndex(player, strategy); + if (!GetStrategyTooltip(gameStrategy).empty()) { + ShowStrategyPopup(this, coords, gameStrategy); + } +} + void ColPlayerWidget::OnCellRightClick(wxSheetEvent &p_event) { if (m_table->GetColHeaderRowCount() == 0 || m_table->IsReadOnly()) { From 1d5055ef7f0da1a4a565f6587ed79c6685c8265d Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 23 Jul 2026 11:42:11 +0100 Subject: [PATCH 3/5] Test the reduced-strategy action map directly --- tests/games.py | 15 +++++++ tests/test_extensive.py | 87 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/tests/games.py b/tests/games.py index 4d22ddd9f..6b5a5b428 100644 --- a/tests/games.py +++ b/tests/games.py @@ -447,6 +447,21 @@ def create_EFG_for_6x6_bimatrix_with_long_LH_paths_and_unique_eq() -> gbt.Game: return create_efg_corresponding_to_bimatrix_game(A, B, title) +def strategy_map(player: gbt.Player, strategy: gbt.Strategy) -> tuple: + """The action prescribed by ``strategy`` at each of ``player``'s information sets, + in the player's information set order: the 1-based position of the prescribed action, or "*" + where the strategy prescribes nothing because the information set is unreachable. + """ + prescriptions = [] + for infoset in player.infosets: + action = strategy.action(infoset) + if action is None: + prescriptions.append("*") + else: + prescriptions.append(str(list(infoset.actions).index(action) + 1)) + return tuple(prescriptions) + + class EfgFamilyForReducedStrategicFormTests(ABC): """ """ diff --git a/tests/test_extensive.py b/tests/test_extensive.py index a7e16c4b6..a1801c62f 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -379,6 +379,93 @@ def test_reduced_strategic_form( assert (arr == games.vectorized_make_rational(exp_raw)).all() +@pytest.mark.parametrize( + "game,strategy_maps", + [ + ############################################################################### + # 1 player; reduction after the first move + ( + games.read_from_file("reduction_one_player_generic_payoffs.efg"), + [[("1", "1"), ("1", "2"), ("2", "*"), ("3", "*"), ("4", "*")]], + ), + # 2 players; reduction for player 1 + ( + gbt.catalog.load("journals/ijgt/selten1975/fig2"), + [[("1", "*"), ("2", "1"), ("2", "2")], [("1",), ("2",)]], + ), + # 2 players; reduction for both players + ( + games.read_from_file("reduction_generic_payoffs.efg"), + [ + [ + ("1", "*", "1"), ("1", "*", "2"), ("2", "1", "1"), + ("2", "1", "2"), ("2", "2", "1"), ("2", "2", "2"), + ], + [ + ("1", "1", "*"), ("1", "2", "*"), ("2", "*", "*"), + ("3", "*", "1"), ("3", "*", "2"), ("4", "*", "*"), + ], + ], + ), + # binary tree; reduction for player 1 + ( + games.read_from_file("binary_3_levels_generic_payoffs.efg"), + [ + [("1", "1", "*"), ("1", "2", "*"), ("2", "*", "1"), ("2", "*", "2")], + [("1",), ("2",)], + ], + ), + # game from GTE survey; reduction for both players + ( + games.read_from_file("reduction_both_players_payoff_ties_GTE_survey.efg"), + [ + [("1", "*"), ("2", "*"), ("3", "1"), ("3", "2"), ("4", "*")], + [ + ("1", "*", "1", "1"), ("1", "*", "1", "2"), + ("1", "*", "2", "1"), ("1", "*", "2", "2"), + ("2", "1", "1", "1"), ("2", "1", "1", "2"), + ("2", "1", "2", "1"), ("2", "1", "2", "2"), + ("2", "2", "1", "1"), ("2", "2", "1", "2"), + ("2", "2", "2", "1"), ("2", "2", "2", "2"), + ], + ], + ), + ########################################################################### + # Games with chance nodes + ########################################################################### + # chance-rooted long centipede game + ( + games.read_from_file("cent3.efg"), + [ + [ + ("1", "*", "*", "1", "1", "1"), ("2", "1", "*", "1", "1", "1"), + ("2", "2", "1", "1", "1", "1"), ("2", "2", "2", "1", "1", "1"), + ], + ] * 2, + ), + # stripped-down poker; chance-rooted, no reduction for either player + ( + games.create_stripped_down_poker_efg(), + [ + [("1", "1"), ("1", "2"), ("2", "1"), ("2", "2")], + [("1",), ("2",)], + ], + ), + ], +) +def test_reduced_strategy_maps(game: gbt.Game, strategy_maps: list): + """ + Verify the action assignments of reduced strategies. + + Strategy labels are sequential integers and so no longer describe which action + a strategy prescribes at each information set; the mapping is reconstructed via + `Strategy.action`. A "*" marks an information set at which the strategy + prescribes no action, being unreachable given the strategy's own earlier actions. + """ + for player, expected_maps in zip(game.players, strategy_maps, strict=True): + assert [games.strategy_map(player, s) for s in player.strategies] == expected_maps + + @pytest.mark.parametrize( "standard,modified", [ From ebde5a04ded6d552c3b68e06f750c69d5b81036c Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 30 Jul 2026 15:03:27 +0100 Subject: [PATCH 4/5] Add Game.get_behavior to expose the reduced-strategy action map --- src/pygambit/game.pxi | 56 ++++++++++++++++++++ src/pygambit/strategy.pxi | 105 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index f95d47aba..fb6d3c5b5 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -951,6 +951,62 @@ class Game: self.game.deref().GetMinimalSubgame(cython.cast(Infoset, resolved_infoset).infoset) ) + def get_behavior(self, + player: Player | str, + strategy: Strategy | str) -> StrategyBehavior: + """Return the mapping from information sets to actions prescribed by a strategy. + + .. versionadded:: 17.0.0 + + Parameters + ---------- + player : Player or str + The player whose strategy to view. + strategy : Strategy or str + The strategy to view. + + Returns + ------- + StrategyBehavior + + Raises + ------ + UndefinedOperationError + If the game does not have a tree representation. + MismatchError + If `player` is from a different game, or `strategy` belongs to a different player. + KeyError + If `strategy` is a string and `player` has no strategy with that label. + + See Also + -------- + Strategy.action : The action prescribed at a single information set. + """ + if not self.is_tree: + raise UndefinedOperationError( + "get_behavior(): only defined for games with a tree representation" + ) + resolved_player = cython.cast(Player, self._resolve_player(player, "get_behavior")) + if isinstance(strategy, Strategy): + if strategy.player != resolved_player: + raise MismatchError( + f"get_behavior(): strategy must belong to player " + f"'{resolved_player.label}'" + ) + resolved_strategy = strategy + elif isinstance(strategy, str): + if not strategy.strip(): + raise ValueError( + "get_behavior(): strategy cannot be an empty string or all spaces" + ) + resolved_strategy = resolved_player.strategies[strategy] + else: + raise TypeError( + f"get_behavior(): strategy must be Strategy or str, " + f"not {strategy.__class__.__name__}" + ) + return StrategyBehavior.wrap(resolved_player, resolved_strategy) + def set_chance_probs(self, infoset: Infoset | str, probs: typing.Sequence): """Set the action probabilities at chance information set `infoset`. diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index 80a605460..f501338bf 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -104,6 +104,11 @@ class Strategy: If the game is not an extensive-form (tree) game. ValueError If the information set belongs to a different player than the strategy. + + See Also + -------- + Game.get_behavior : + A map-like view of the strategy's full mapping from information sets to actions. """ if not self.game.is_tree: raise UndefinedOperationError( @@ -125,6 +130,106 @@ class Strategy: return Action.wrap(action) +@cython.cclass +class StrategyBehavior: + """A read-only, map-like view of the actions prescribed by a reduced strategy. + + The keys of the mapping are the information sets of the strategy's player at + which the strategy prescribes an action; an unreachable information set is not a key. + The corresponding values are the prescribed ``Action`` objects. + Iteration yields the keys in the player's information set order. + + .. versionadded:: 17.0.0 + """ + _player = cython.declare(Player) + _strategy = cython.declare(Strategy) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create a StrategyBehavior outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(player: Player, strategy: Strategy) -> StrategyBehavior: + obj: StrategyBehavior = StrategyBehavior.__new__(StrategyBehavior) + obj._player = player + obj._strategy = strategy + return obj + + def __repr__(self) -> str: + return f"StrategyBehavior(player={self._player}, strategy={self._strategy})" + + @property + def player(self) -> Player: + """The player to which the strategy belongs.""" + return self._player + + @property + def strategy(self) -> Strategy: + """The strategy of which this is the behavior.""" + return self._strategy + + def _resolve_key(self, key: Infoset | str) -> Infoset: + """Resolve `key` to an information set at which the player has the move.""" + infoset = self._player.game._resolve_infoset(key, "StrategyBehavior", "key") + if infoset.player != self._player: + raise ValueError( + f"Player '{self._player.label}' does not have the move at {infoset}." + ) + return infoset + + def __getitem__(self, key: Infoset | str) -> Action: + """Return the action prescribed at the information set referenced by `key`. + + Raises + ------ + KeyError + If the strategy prescribes no action at the information set, + or if `key` is a string and no information set has that label. + ValueError + If the information set belongs to a different player. + """ + infoset = self._resolve_key(key) + action = self._strategy.action(infoset) + if action is None: + raise KeyError( + f"Strategy '{self._strategy.label}' prescribes no action at {infoset}." + ) + return action + + def get(self, key: Infoset | str, default: typing.Any = None) -> Action | None: + """Return the action prescribed at `key`, or `default` if none is prescribed.""" + infoset = self._resolve_key(key) + action = self._strategy.action(infoset) + return default if action is None else action + + def __contains__(self, key: typing.Any) -> bool: + try: + infoset = self._resolve_key(key) + except (KeyError, ValueError, TypeError): + return False + return self._strategy.action(infoset) is not None + + def __iter__(self) -> typing.Iterator[Infoset]: + for infoset in self._player.infosets: + if self._strategy.action(infoset) is not None: + yield infoset + + def __len__(self) -> int: + return sum(1 for _ in self) + + def keys(self) -> list[Infoset]: + """The information sets at which the strategy prescribes an action.""" + return list(self) + + def values(self) -> list[Action]: + """The prescribed actions, in the order of `keys`.""" + return [self._strategy.action(infoset) for infoset in self] + + def items(self) -> list[tuple[Infoset, Action]]: + """(information set, action) pairs, in the order of `keys`.""" + return [(infoset, self._strategy.action(infoset)) for infoset in self] + + @cython.cclass class Sequence: """A sequence ``Player`` in a ``Game``. From 940e07f242606b10fde6d639f7c03c37eaf5f3bc Mon Sep 17 00:00:00 2001 From: drdkad Date: Thu, 30 Jul 2026 15:03:51 +0100 Subject: [PATCH 5/5] Restore the strategy-signature generators as reduced-strategy map fixtures --- ChangeLog | 4 ++++ tests/games.py | 45 +++++++++++++++++++++-------------------- tests/test_extensive.py | 34 +++++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 24 deletions(-) diff --git a/ChangeLog b/ChangeLog index 899175e7f..7e8fa6f33 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,5 +1,9 @@ ## [17.0.0] - unreleased +### Added +- `Game.get_behavior` returns a map-like view of the actions a reduced strategy prescribes at its player's + information sets; the unreachable information sets are absent. (#1002) + ### Changed - `lcp_solve` no longer silently absorbs internal exceptions if they occur, but instead these propagate out to match the behaviour of all other Nash equilibrium solvers. (#996) diff --git a/tests/games.py b/tests/games.py index 6b5a5b428..fbde45882 100644 --- a/tests/games.py +++ b/tests/games.py @@ -447,21 +447,6 @@ def create_EFG_for_6x6_bimatrix_with_long_LH_paths_and_unique_eq() -> gbt.Game: return create_efg_corresponding_to_bimatrix_game(A, B, title) -def strategy_map(player: gbt.Player, strategy: gbt.Strategy) -> tuple: - """The action prescribed by ``strategy`` at each of ``player``'s information sets, - in the player's information set order: the 1-based position of the prescribed action, or "*" - where the strategy prescribes nothing because the information set is unreachable. - """ - prescriptions = [] - for infoset in player.infosets: - action = strategy.action(infoset) - if action is None: - prescriptions.append("*") - else: - prescriptions.append(str(list(infoset.actions).index(action) + 1)) - return tuple(prescriptions) - - class EfgFamilyForReducedStrategicFormTests(ABC): """ """ @@ -498,10 +483,29 @@ def get_test_data(cls, **params): return ( game.gbt_game(), - game.reduced_strategies(), + [[str(i) for i in range(1, len(r) + 1)] for r in game.reduced_strategies()], game.reduced_strategic_form(), ) + @classmethod + def get_map_test_data(cls, **params): + """ + given the provided parameters, return a tuple with: + - the game as a gbt.Game object + - the expected infoset-to-action map of each reduced strategy, per player: + the pre-17.0 signature split per information set ("*" = no action + prescribed), or the empty tuple for the single trivial strategy of a + player who has no information sets + the tuple is used directly in test_reduced_strategy_maps in test_extensive.py + """ + game = cls(params) + gbt_game = game.gbt_game() + maps = [ + [tuple(sig) if len(player.infosets) > 0 else () for sig in sigs] + for player, sigs in zip(gbt_game.players, game.reduced_strategies(), strict=True) + ] + return (gbt_game, maps) + class Centipede(EfgFamilyForReducedStrategicFormTests): """ @@ -550,7 +554,7 @@ def get_rss(n): rs = [get_rss(n) for n in n_moves] self.set_size_of_rsf(rs) - return [[str(i) for i in range(1, len(r) + 1)] for r in rs] + return rs def reduced_strategic_form(self): m, n = self.size_of_rsf @@ -630,7 +634,7 @@ def get_n_infosets(self, level): return {p: n_isets[p - 1] for p in players} def _redu_strategies_level_1(self, player): - return ["1", "2"] if player == 1 else ["1"] + return ["1", "2"] if player == 1 else ["*"] def player_with_changes(self, level): return ((level - 1) % self.n_players) + 1 @@ -640,15 +644,12 @@ def last_player_with_changes(self, level): @abstractmethod def _redu_strats(self, player, level): - """Returns a list whose length is the player's reduced-strategy count. - The signature labels are no longer used as strategies are now labelled sequentially. - """ pass def reduced_strategies(self): rs = [self._redu_strats(player, self.level) for player in self.players] self.set_size_of_rsf(rs) - return [[str(i) for i in range(1, len(r) + 1)] for r in rs] + return rs 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) diff --git a/tests/test_extensive.py b/tests/test_extensive.py index a1801c62f..c7601a81a 100644 --- a/tests/test_extensive.py +++ b/tests/test_extensive.py @@ -451,6 +451,31 @@ def test_reduced_strategic_form( [("1",), ("2",)], ], ), + # Centipede + (games.Centipede.get_map_test_data(N=3, m0=2, m1=7)), + (games.Centipede.get_map_test_data(N=4, m0=2, m1=7)), + (games.Centipede.get_map_test_data(N=5, m0=2, m1=7)), + (games.Centipede.get_map_test_data(N=9, m0=3, m1=11)), + # Two player binary tree + (games.BinEfgTwoPlayer.get_map_test_data(level=1)), + (games.BinEfgTwoPlayer.get_map_test_data(level=2)), + (games.BinEfgTwoPlayer.get_map_test_data(level=3)), + (games.BinEfgTwoPlayer.get_map_test_data(level=4)), + (games.BinEfgTwoPlayer.get_map_test_data(level=5)), + (games.BinEfgTwoPlayer.get_map_test_data(level=6)), + # Three player binary tree + (games.BinEfgThreePlayer.get_map_test_data(level=1)), + (games.BinEfgThreePlayer.get_map_test_data(level=2)), + (games.BinEfgThreePlayer.get_map_test_data(level=3)), + (games.BinEfgThreePlayer.get_map_test_data(level=4)), + (games.BinEfgThreePlayer.get_map_test_data(level=5)), + # One player IR binary tree + (games.BinEfgOnePlayerIR.get_map_test_data(level=1)), + (games.BinEfgOnePlayerIR.get_map_test_data(level=2)), + (games.BinEfgOnePlayerIR.get_map_test_data(level=3)), + (games.BinEfgOnePlayerIR.get_map_test_data(level=4)), + (games.BinEfgOnePlayerIR.get_map_test_data(level=5)), + (games.BinEfgOnePlayerIR.get_map_test_data(level=6)), ], ) def test_reduced_strategy_maps(game: gbt.Game, strategy_maps: list): @@ -459,11 +484,16 @@ def test_reduced_strategy_maps(game: gbt.Game, strategy_maps: list): Strategy labels are sequential integers and so no longer describe which action a strategy prescribes at each information set; the mapping is reconstructed via - `Strategy.action`. A "*" marks an information set at which the strategy + `Game.get_behavior`. A "*" marks an information set at which the strategy prescribes no action, being unreachable given the strategy's own earlier actions. """ for player, expected_maps in zip(game.players, strategy_maps, strict=True): - assert [games.strategy_map(player, s) for s in player.strategies] == expected_maps + for strategy, expected in zip(player.strategies, expected_maps, strict=True): + behavior = game.get_behavior(player, strategy) + assert tuple( + "*" if (action := behavior.get(infoset)) is None else str(action.number + 1) + for infoset in player.infosets + ) == expected @pytest.mark.parametrize(