From b2c2226c548c28088eaae3fde34beac4d28fa7f4 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 24 Nov 2025 13:07:06 +0000 Subject: [PATCH 01/12] Added `FutureWarning` on setting a duplicate action label at an information set. --- src/pygambit/action.pxi | 6 ++++++ tests/test_actions.py | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/pygambit/action.pxi b/src/pygambit/action.pxi index cd21b7a5a..89f14e714 100644 --- a/src/pygambit/action.pxi +++ b/src/pygambit/action.pxi @@ -77,6 +77,12 @@ class Action: @label.setter def label(self, value: str) -> None: + if (value == "" or value.encode("ascii") in + (action.deref().GetLabel() + for action in self.action.deref().GetInfoset().deref().GetActions())): + warnings.warn("In a future version, actions must have unique labels " + "within their information set", + FutureWarning) self.action.deref().SetLabel(value.encode("ascii")) @property diff --git a/tests/test_actions.py b/tests/test_actions.py index 3942049e8..29c091472 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -14,6 +14,18 @@ def test_set_action_label(game: gbt.Game, label: str): assert game.root.infoset.actions[0].label == label +def test_set_empty_action_futurewarning(): + game = games.create_stripped_down_poker_efg() + with pytest.warns(FutureWarning): + game.root.infoset.actions[0].label = "" + + +def test_set_duplicate_action_futurewarning(): + game = games.create_stripped_down_poker_efg() + with pytest.warns(FutureWarning): + game.root.infoset.actions[0].label = "Queen" + + @pytest.mark.parametrize( "game,inprobs,outprobs", [(games.create_stripped_down_poker_efg(), From 45b3419c0151befdf3851a881a0d322650e14a8b Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Mon, 24 Nov 2025 13:08:54 +0000 Subject: [PATCH 02/12] Make .efg file parsing more strict This adds a number of robustness checks for .efg files. Principally, this enforces that if information is repeated (information set or outcome details), these much match *exactly* the original definition. Documentation of the format has been updated to clarify this and a few other points. --- ChangeLog | 18 +++--- doc/formats.efg.rst | 52 ++++++----------- src/games/file.cc | 117 +++++++++++++++++++++++++++++++++++---- src/pygambit/action.pxi | 6 +- src/pygambit/outcome.pxi | 2 - src/pygambit/player.pxi | 8 ++- tests/test_players.py | 12 ++++ 7 files changed, 150 insertions(+), 65 deletions(-) diff --git a/ChangeLog b/ChangeLog index 5b014fd15..3c24c557c 100644 --- a/ChangeLog +++ b/ChangeLog @@ -5,6 +5,13 @@ ### Added - Implement `IsAbsentMinded()` on information sets (C++) and `Infoset.is_absent_minded` (Python) to detect if an information is absent-minded. +- Tests for EFG Nash solvers -- `enumpoly_solve`, `lp_solve`, `lcp_solve` -- in behavior stratgegies +- In `pygambit`, `Node` objects now have a read-only property `own_prior_action` and `Infoset` objects + have a read-only property `own_prior_actions` to retrieve the last action or the set of last actions + taken by the player before reaching the node or information set, respectively. (#582) +- In `pygambit`, `Node` objects now have a read-only property `is_strategy_reachable` to determine + if the node is reachable by at least one pure strategy profile. This proves useful for identifying + unreachable parts of the game tree in games with absent-mindedness. (#629) ### Changed - In the graphical interface, removed option to configure information set link drawing; information sets @@ -13,15 +20,8 @@ not a label of a child node. In addition, indexing by an action object is now supported. (#587) - In `pygambit`, `min_payoff` and `max_payoff` (for both games and players) now refers to payoffs in any play of the game; previously this referred only to the set of outcomes. (#498) - -### Added -- Tests for EFG Nash solvers -- `enumpoly_solve`, `lp_solve`, `lcp_solve` -- in behavior stratgegies -- In `pygambit`, `Node` objects now have a read-only property `own_prior_action` and `Infoset` objects - have a read-only property `own_prior_actions` to retrieve the last action or the set of last actions - taken by the player before reaching the node or information set, respectively. (#582) -- In `pygambit`, `Node` objects now have a read-only property `is_strategy_reachable` to determine - if the node is reachable by at least one pure strategy profile. This proves useful for identifying - unreachable parts of the game tree in games with absent-mindedness. (#629) +- Internal consistency checking in parsing .efg files has been tightened. If labels, probabilities, + or payoffs are repeated, they must match exactly, otherwise an error is raised. ### Removed - Eliminating dominated actions has been removed from the GUI as it was implementing a non-standard diff --git a/doc/formats.efg.rst b/doc/formats.efg.rst index 87d180079..1bc78c362 100644 --- a/doc/formats.efg.rst +++ b/doc/formats.efg.rst @@ -6,18 +6,13 @@ The extensive game (.efg) file format The extensive game (.efg) file format has been used by Gambit, with minor variations, to represent extensive games since circa 1994. It replaced an earlier format, which had no particular name but which had -the conventional extension .dt1. It is intended that some new formats -will be introduced in the future; however, this format will be -supported by Gambit, possibly through the use of converter programs to -those putative future formats, for the foreseeable future. +the conventional extension .dt1. A sample file ------------- -This is a sample file illustrating the general format of the file. -This file is similar to the one distributed in the Gambit distribution -under the name bayes1a.efg:: +This is a sample file illustrating the general format of the file:: EFG 2 R "General Bayes game, one stage" { "Player 1" "Player 2" } c "ROOT" 1 "(0,1)" { "1G" 0.500000 "1B" 0.500000 } 0 @@ -53,8 +48,6 @@ under the name bayes1a.efg:: t "" 16 "Outcome 16" { 10.000000 0.000000 } - - Structure of the prologue ------------------------- @@ -97,11 +90,9 @@ of one line per node. Each node entry begins with an unquoted character indicating the type of the node. There are three node types: - - -+ c for a chance node -+ p for a personal player node -+ t for a terminal node ++ `c` for a chance node ++ `p` for a personal player node ++ `t` for a terminal node Each node type will be discussed individually below. There are three numbering conventions which are used to identify the information @@ -116,7 +107,8 @@ integer may be used to specify information sets for different players; this is not ambiguous since the player number appears as well. Finally, outcomes are also arbitrarily numbered in the file format in the same way in which information sets are, except for the special -number 0 which indicates the null outcome. +number 0 which is reserved to indicate the null outcome. +Outcome 0 must not have a name or payoffs specified. Information sets and outcomes may (and frequently will) appear multiple times within a game. By convention, the second and subsequent @@ -124,11 +116,12 @@ times an information set or outcome appears, the file may omit the descriptive information for that information set or outcome. Alternatively, the file may specify the descriptive information again; however, it must precisely match the original declaration of the -information set or outcome. If any part of the description is omitted, -the whole description must be omitted. +information set or outcome. Any mismatch in repeated declarations +is an error, and the file is not valid. +If any part of the description is omitted, the whole description must be omitted. Outcomes may appear at nonterminal nodes. In these cases, payoffs are -interepreted as incremental payoffs; the payoff to a player for a +interpreted as incremental payoffs; the payoff to a player for a given path through the tree is interpreted as the sum of the payoffs at the outcomes encountered on that path (including at the terminal node). This is ideal for the representation of games with well- @@ -146,41 +139,28 @@ with the character c . Following this, in order, are + a text string, giving the name of the node + a positive integer specifying the information set number -+ (optional) the name of the information set -+ (optional) a list of actions at the information set with their ++ (optional) the name of the information set and a list of actions at the information set with their corresponding probabilities + a nonnegative integer specifying the outcome -+ (optional)the payoffs to each player for the outcome - - ++ (optional) the name of the outcome and the payoffs to each player for the outcome **Format of personal (player) nodes.** Entries for personal player decision nodes begin with the character p . Following this, in order, are: - - + a text string, giving the name of the node + a positive integer specifying the player who owns the node + a positive integer specifying the information set -+ (optional) the name of the information set -+ (optional) a list of action names for the information set ++ (optional) the name of the information set and a list of action names for the information set + a nonnegative integer specifying the outcome -+ (optional) the name of the outcome -+ the payoffs to each player for the outcome - ++ (optional) the name of the outcome and the payoffs to each player for the outcome **Format of terminal nodes.** Entries for terminal nodes begin with the character t . Following this, in order, are: - - + a text string, giving the name of the node + a nonnegative integer specifying the outcome -+ (optional) the name of the outcome -+ the payoffs to each player for the outcome - - ++ (optional) the name of the outcome and the payoffs to each player for the outcome There is no explicit end-of-file delimiter for the file. diff --git a/src/games/file.cc b/src/games/file.cc index babcf925e..17f72cb5b 100644 --- a/src/games/file.cc +++ b/src/games/file.cc @@ -480,6 +480,36 @@ void ReadPlayers(GameFileLexer &p_state, Game &p_game, TreeData &p_treeData) p_state.ExpectCurrentToken(TOKEN_RBRACE, "'}'"); } +void CheckOutcomeDefinition(const GameFileLexer &p_state, int p_outcomeId, + const GameOutcome &p_outcome, const std::string &p_label, + const GameRep::Players &p_players, + const std::vector &p_payoffs) +{ + if (p_outcome->GetLabel() != p_label) { + p_state.OnParseError("Outcome label does not match previous definition " + "(outcome " + + std::to_string(p_outcomeId) + ")"); + } + + if (p_players.size() != p_payoffs.size()) { + p_state.OnParseError("Outcome payoff count mismatch " + "(outcome " + + std::to_string(p_outcomeId) + ")"); + } + + auto player_it = p_players.begin(); + for (const auto &payoff : p_payoffs) { + if (p_outcome->GetPayoff(*player_it) != + static_cast(payoff)) { + p_state.OnParseError("Outcome payoffs do not match previous definition " + "(outcome " + + std::to_string(p_outcomeId) + ", player " + + std::to_string((*player_it)->GetNumber()) + ")"); + } + ++player_it; + } +} + void ParseOutcome(GameFileLexer &p_state, Game &p_game, TreeData &p_treeData, GameNode &p_node) { p_state.ExpectCurrentToken(TOKEN_NUMBER, "index of outcome"); @@ -488,26 +518,38 @@ void ParseOutcome(GameFileLexer &p_state, Game &p_game, TreeData &p_treeData, Ga if (p_state.GetCurrentToken() == TOKEN_TEXT) { // This node entry contains information about the outcome - GameOutcome outcome; - try { - outcome = p_treeData.m_outcomeMap.at(outcomeId); - } - catch (std::out_of_range &) { - outcome = p_game->NewOutcome(); - p_treeData.m_outcomeMap[outcomeId] = outcome; + if (outcomeId == 0) { + p_state.OnParseError("Cannot specify a label or payoffs for the null outcome"); } - outcome->SetLabel(p_state.GetLastText()); - p_game->SetOutcome(p_node, outcome); + const std::string label = p_state.GetLastText(); + std::vector payoffs; p_state.ExpectNextToken(TOKEN_LBRACE, "'{'"); p_state.GetNextToken(); for (auto player : p_game->GetPlayers()) { p_state.ExpectCurrentToken(TOKEN_NUMBER, "numerical payoff"); - outcome->SetPayoff(player, Number(p_state.GetLastText())); + payoffs.emplace_back(p_state.GetLastText()); p_state.AcceptNextToken(TOKEN_COMMA); } p_state.ExpectCurrentToken(TOKEN_RBRACE, "'}'"); p_state.GetNextToken(); + + GameOutcome outcome; + if (!contains(p_treeData.m_outcomeMap, outcomeId)) { + outcome = p_game->NewOutcome(); + p_treeData.m_outcomeMap[outcomeId] = outcome; + outcome->SetLabel(label); + auto player_it = p_game->GetPlayers().begin(); + for (const auto &payoff : payoffs) { + outcome->SetPayoff(*player_it, payoff); + ++player_it; + } + } + else { + outcome = p_treeData.m_outcomeMap.at(outcomeId); + CheckOutcomeDefinition(p_state, outcomeId, outcome, label, p_game->GetPlayers(), payoffs); + } + p_game->SetOutcome(p_node, outcome); } else if (outcomeId != 0) { // The node entry does not contain information about the outcome. @@ -521,6 +563,56 @@ void ParseOutcome(GameFileLexer &p_state, Game &p_game, TreeData &p_treeData, Ga } } +void CheckInfosetActions(const GameFileLexer &p_state, const int p_playerId, const int p_infosetId, + const GameInfoset &p_infoset, const std::string &p_label, + const std::list &p_labels) +{ + if (p_infoset->GetLabel() != p_label) { + p_state.OnParseError("Infoset labels does not match previous definition " + "(player " + + std::to_string(p_playerId) + ", infoset " + std::to_string(p_infosetId) + + ")"); + } + + const auto &actions = p_infoset->GetActions(); + if (actions.size() != p_labels.size()) { + p_state.OnParseError("Infoset action count mismatch " + "(player " + + std::to_string(p_playerId) + ", infoset " + std::to_string(p_infosetId) + + ")"); + } + auto label_it = p_labels.begin(); + for (auto action : actions) { + if (action->GetLabel() != *label_it) { + p_state.OnParseError("Infoset action labels do not match previous definition " + "(player " + + std::to_string(p_playerId) + ", infoset " + + std::to_string(p_infosetId) + ")"); + } + ++label_it; + } +} + +void CheckChanceProbs(const GameFileLexer &p_state, const int p_infosetId, + const GameInfoset &p_infoset, const Array &p_probs) +{ + if (p_infoset->GetActions().size() != p_probs.size()) { + p_state.OnParseError("Chance infoset probability count mismatch " + "(infoset " + + std::to_string(p_infosetId) + ")"); + } + auto action_it = p_infoset->GetActions().begin(); + for (size_t i = 1; i <= p_probs.size(); ++i) { + if (static_cast(p_infoset->GetActionProb(*action_it)) != + static_cast(p_probs[i])) { + p_state.OnParseError("Chance infoset probabilities do not match previous definition " + "(infoset " + + std::to_string(p_infosetId) + ")"); + } + ++action_it; + } +} + void ParseNode(GameFileLexer &p_state, Game p_game, GameNode p_node, TreeData &p_treeData); void ParseChanceNode(GameFileLexer &p_state, Game &p_game, GameNode &p_node, TreeData &p_treeData) @@ -561,7 +653,8 @@ void ParseChanceNode(GameFileLexer &p_state, Game &p_game, GameNode &p_node, Tre p_game->SetChanceProbs(infoset, probs); } else { - // TODO: Verify actions match up to any previous specifications + CheckInfosetActions(p_state, 0, infosetId, infoset, label, action_labels); + CheckChanceProbs(p_state, infosetId, infoset, probs); p_game->AppendMove(p_node, infoset); } } @@ -616,7 +709,7 @@ void ParsePersonalNode(GameFileLexer &p_state, Game p_game, GameNode p_node, Tre } } else { - // TODO: Verify actions match up to previous specifications + CheckInfosetActions(p_state, player, infosetId, infoset, label, action_labels); p_game->AppendMove(p_node, infoset); } } diff --git a/src/pygambit/action.pxi b/src/pygambit/action.pxi index 89f14e714..b8b87c0a0 100644 --- a/src/pygambit/action.pxi +++ b/src/pygambit/action.pxi @@ -77,9 +77,9 @@ class Action: @label.setter def label(self, value: str) -> None: - if (value == "" or value.encode("ascii") in - (action.deref().GetLabel() - for action in self.action.deref().GetInfoset().deref().GetActions())): + if value == self.label: + return + if value == "" or value in (act.label for act in self.infoset.actions): warnings.warn("In a future version, actions must have unique labels " "within their information set", FutureWarning) diff --git a/src/pygambit/outcome.pxi b/src/pygambit/outcome.pxi index 9c2953a5c..417ceacfe 100644 --- a/src/pygambit/outcome.pxi +++ b/src/pygambit/outcome.pxi @@ -68,8 +68,6 @@ class Outcome: @label.setter def label(self, value: str) -> None: - if value in [i.label for i in self.game.outcomes]: - warnings.warn("Another outcome with an identical label exists") self.outcome.deref().SetLabel(value.encode("ascii")) @property diff --git a/src/pygambit/player.pxi b/src/pygambit/player.pxi index d6f026328..ca1477d20 100644 --- a/src/pygambit/player.pxi +++ b/src/pygambit/player.pxi @@ -192,9 +192,11 @@ class Player: @label.setter def label(self, value: str) -> None: - # check to see if the player's name has been used elsewhere - if value in [i.label for i in self.game.players]: - warnings.warn("Another player with an identical label exists") + if value == self.label: + return + if value == "" or value in (player.label for player in self.game.players): + warnings.warn("In a future version, players must have unique labels", + FutureWarning) self.player.deref().SetLabel(value.encode("ascii")) @property diff --git a/tests/test_players.py b/tests/test_players.py index 9aa01c42e..dd0257f0a 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -61,6 +61,18 @@ def test_player_label_invalid(): _ = game.players["Not a player"] +def test_set_empty_player_futurewarning(): + game = games.create_stripped_down_poker_efg() + with pytest.warns(FutureWarning): + game.players[0].label = "" + + +def test_set_duplicate_player_futurewarning(): + game = games.create_stripped_down_poker_efg() + with pytest.warns(FutureWarning): + game.players[0].label = game.players[1].label + + def test_strategic_game_add_player(): game = gbt.Game.new_table([2, 2]) game.add_player() From 63465a8b9a9076f3588336332dd3992877f5f2a2 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 18 Dec 2025 16:37:56 +0000 Subject: [PATCH 03/12] Write function to standardise labels for players, outcomes, actions, and strategies on load. This is currently turned on (and as a result a test is marked to xfail). In a subsequent commit we will make it an option for 16.5 (it will become compulsory in 16.6) --- src/games/file.cc | 40 +++++++++++++++++++++++++++++++++++++++- tests/test_io.py | 1 + 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/games/file.cc b/src/games/file.cc index 17f72cb5b..0d56bc278 100644 --- a/src/games/file.cc +++ b/src/games/file.cc @@ -802,6 +802,41 @@ Game GameXMLSavefile::GetGame() const throw InvalidFileException("No game representation found in document"); } +template void StandardizeLabels(C &&p_container) +{ + std::map counts; + for (const auto &element : p_container) { + ++counts[element->GetLabel()]; + } + std::map visited; + for (auto element : p_container) { + const auto label = element->GetLabel(); + if (counts[label] == 1) { + continue; + } + const auto index = ++visited[label]; + element->SetLabel(label + "_" + std::to_string(index)); + } +} + +void StandardizeGameLabels(const Game &p_game) +{ + StandardizeLabels(p_game->GetPlayers()); + StandardizeLabels(p_game->GetOutcomes()); + if (p_game->IsTree()) { + for (const auto &player : p_game->GetPlayersWithChance()) { + for (const auto &infoset : player->GetInfosets()) { + StandardizeLabels(infoset->GetActions()); + } + } + } + else { + for (const auto &player : p_game->GetPlayers()) { + StandardizeLabels(player->GetStrategies()); + } + } +} + Game ReadEfgFile(std::istream &p_stream) { GameFileLexer parser(p_stream); @@ -831,6 +866,7 @@ Game ReadEfgFile(std::istream &p_stream) } ParseNode(parser, game, game->GetRoot(), treeData); game->SortInfosets(); + StandardizeGameLabels(game); return game; } @@ -839,7 +875,9 @@ Game ReadNfgFile(std::istream &p_stream) GameFileLexer parser(p_stream); TableFileGame data; ParseNfgHeader(parser, data); - return BuildNfg(parser, data); + auto game = BuildNfg(parser, data); + StandardizeGameLabels(game); + return game; } Game ReadGbtFile(std::istream &p_stream) diff --git a/tests/test_io.py b/tests/test_io.py index cc179a187..ff6f8431d 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -113,6 +113,7 @@ def test_read_write_efg(): assert serialized_efg_game == double_serialized_efg_game +@pytest.mark.xfail def test_read_write_nfg(): nfg_game = create_2x2_zero_nfg() serialized_nfg_game = nfg_game.to_nfg() From a0092826625f3dd5930c843fdf3a3fc27c8a1af1 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 19 Dec 2025 12:47:26 +0000 Subject: [PATCH 04/12] Add FutureWarning on duplicate strategy or outcome labels --- src/pygambit/outcome.pxi | 5 +++++ src/pygambit/strategy.pxi | 5 +++++ tests/games.py | 6 ++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/pygambit/outcome.pxi b/src/pygambit/outcome.pxi index 417ceacfe..012f48edf 100644 --- a/src/pygambit/outcome.pxi +++ b/src/pygambit/outcome.pxi @@ -68,6 +68,11 @@ class Outcome: @label.setter def label(self, value: str) -> None: + if value == self.label: + return + if value == "" or value in (outcome.label for outcome in self.game.outcomes): + warnings.warn("In a future version, outcomes must have unique labels", + FutureWarning) self.outcome.deref().SetLabel(value.encode("ascii")) @property diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index bcc5aef5e..5f2b065bc 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -57,6 +57,11 @@ class Strategy: @label.setter def label(self, value: str) -> None: + if value == self.label: + return + if value == "" or value in (strategy.label for strategy in self.player.strategies): + warnings.warn("In a future version, strategies for a player must have unique labels", + FutureWarning) self.strategy.deref().SetLabel(value.encode("ascii")) @property diff --git a/tests/games.py b/tests/games.py index 2292d30aa..51ec0f218 100644 --- a/tests/games.py +++ b/tests/games.py @@ -5,6 +5,7 @@ from abc import ABC, abstractmethod import numpy as np +import pytest import pygambit as gbt @@ -58,8 +59,9 @@ def create_2x2_zero_nfg() -> gbt.Game: game.players[1].label = "Dan" game.players["Dan"].strategies[0].label = "defect" - # intentional duplicate label for player (generates warning): - game.players["Dan"].strategies[1].label = "defect" + # intentional duplicate label for player + with pytest.warns(FutureWarning): + game.players["Dan"].strategies[1].label = "defect" return game From de205ec41c870e19718926554e8a9f3c705b4e27 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 19 Dec 2025 12:57:32 +0000 Subject: [PATCH 05/12] Make label normalisation a flag; this flips the final IO test back to xpass for now. --- tests/test_io.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_io.py b/tests/test_io.py index ff6f8431d..cc179a187 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -113,7 +113,6 @@ def test_read_write_efg(): assert serialized_efg_game == double_serialized_efg_game -@pytest.mark.xfail def test_read_write_nfg(): nfg_game = create_2x2_zero_nfg() serialized_nfg_game = nfg_game.to_nfg() From 05a132c62d0aab086348b951937c466046a490f2 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 19 Dec 2025 13:09:54 +0000 Subject: [PATCH 06/12] Internal infrastructure for label normalisation in Python. --- src/games/file.cc | 28 +++++++++++++++------------- src/games/game.h | 10 +++++++--- src/pygambit/gambit.pxd | 8 ++++---- src/pygambit/game.pxi | 4 ++-- src/pygambit/util.h | 12 ++++++------ 5 files changed, 34 insertions(+), 28 deletions(-) diff --git a/src/games/file.cc b/src/games/file.cc index 0d56bc278..ed4eb7b50 100644 --- a/src/games/file.cc +++ b/src/games/file.cc @@ -837,7 +837,7 @@ void StandardizeGameLabels(const Game &p_game) } } -Game ReadEfgFile(std::istream &p_stream) +Game ReadEfgFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) { GameFileLexer parser(p_stream); @@ -866,17 +866,21 @@ Game ReadEfgFile(std::istream &p_stream) } ParseNode(parser, game, game->GetRoot(), treeData); game->SortInfosets(); - StandardizeGameLabels(game); + if (p_normalizeLabels) { + StandardizeGameLabels(game); + } return game; } -Game ReadNfgFile(std::istream &p_stream) +Game ReadNfgFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) { GameFileLexer parser(p_stream); TableFileGame data; ParseNfgHeader(parser, data); auto game = BuildNfg(parser, data); - StandardizeGameLabels(game); + if (p_normalizeLabels) { + StandardizeGameLabels(game); + } return game; } @@ -887,7 +891,7 @@ Game ReadGbtFile(std::istream &p_stream) return GameXMLSavefile(buffer.str()).GetGame(); } -Game ReadGame(std::istream &p_file) +Game ReadGame(std::istream &p_file, bool p_normalizeLabels /* = false */) { std::stringstream buffer; buffer << p_file.rdbuf(); @@ -908,20 +912,18 @@ Game ReadGame(std::istream &p_file) } buffer.seekg(0, std::ios::beg); if (parser.GetLastText() == "NFG") { - return ReadNfgFile(buffer); + return ReadNfgFile(buffer, p_normalizeLabels); } - else if (parser.GetLastText() == "EFG") { - return ReadEfgFile(buffer); + if (parser.GetLastText() == "EFG") { + return ReadEfgFile(buffer, p_normalizeLabels); } - else if (parser.GetLastText() == "#AGG") { + if (parser.GetLastText() == "#AGG") { return ReadAggFile(buffer); } - else if (parser.GetLastText() == "#BAGG") { + if (parser.GetLastText() == "#BAGG") { return ReadBaggFile(buffer); } - else { - throw InvalidFileException("Unrecognized file format"); - } + throw InvalidFileException("Unrecognized file format"); } catch (std::exception &ex) { throw InvalidFileException(ex.what()); diff --git a/src/games/game.h b/src/games/game.h index 7cb3a315f..b27478857 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -1122,19 +1122,23 @@ Game NewTable(const std::vector &p_dim, bool p_sparseOutcomes = false); /// @brief Reads a game representation in .efg format /// /// @param[in] p_stream An input stream, positioned at the start of the text in .efg format +/// @param[in] p_normalizeLabels Require element labels to be nonempty and unique within +/// their scope /// @return A handle to the game representation constructed /// @throw InvalidFileException If the stream does not contain a valid serialisation /// of a game in .efg format. /// @sa Game::WriteEfgFile, ReadNfgFile, ReadAggFile, ReadBaggFile -Game ReadEfgFile(std::istream &p_stream); +Game ReadEfgFile(std::istream &p_stream, bool p_normalizeLabels = false); /// @brief Reads a game representation in .nfg format /// @param[in] p_stream An input stream, positioned at the start of the text in .nfg format +/// @param[in] p_normalizeLabels Require element labels to be nonempty and unique within +/// their scope /// @return A handle to the game representation constructed /// @throw InvalidFileException If the stream does not contain a valid serialisation /// of a game in .nfg format. /// @sa Game::WriteNfgFile, ReadEfgFile, ReadAggFile, ReadBaggFile -Game ReadNfgFile(std::istream &p_stream); +Game ReadNfgFile(std::istream &p_stream, bool p_normalizeLabels = false); /// @brief Reads a game representation from a graphical interface XML saveflie /// @param[in] p_stream An input stream, positioned at the start of the text @@ -1147,7 +1151,7 @@ Game ReadGbtFile(std::istream &p_stream); /// @brief Reads a game from the input stream, attempting to autodetect file format /// @deprecated Deprecated in favour of the various ReadXXXGame functions. /// @sa ReadEfgFile, ReadNfgFile, ReadGbtFile, ReadAggFile, ReadBaggFile -Game ReadGame(std::istream &p_stream); +Game ReadGame(std::istream &p_stream, bool p_normalizeLabels = false); /// @brief Generate a distribution over a simplex restricted to rational numbers of given /// denominator diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 43a0b717b..a10075636 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -425,10 +425,10 @@ cdef extern from "games/layout.h": cdef extern from "util.h": - c_Game ParseGbtGame(string) except +IOError - c_Game ParseEfgGame(string) except +IOError - c_Game ParseNfgGame(string) except +IOError - c_Game ParseAggGame(string) except +IOError + c_Game ParseGbtGame(string, bint) except +IOError + c_Game ParseEfgGame(string, bint) except +IOError + c_Game ParseNfgGame(string, bint) except +IOError + c_Game ParseAggGame(string, bint) except +IOError string WriteEfgFile(c_Game) string WriteNfgFile(c_Game) string WriteNfgFileSupport(c_StrategySupportProfile) except +IOError diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 0b1735c0f..543bf261b 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -31,7 +31,7 @@ import scipy.stats import pygambit.gameiter ctypedef string (*GameWriter)(const c_Game &) except +IOError -ctypedef c_Game (*GameParser)(const string &) except +IOError +ctypedef c_Game (*GameParser)(const string &, bool) except +IOError @cython.cfunc @@ -47,7 +47,7 @@ def read_game(filepath_or_buffer: str | pathlib.Path | io.IOBase, with open(filepath_or_buffer, "rb") as f: data = f.read() try: - g = Game.wrap(parser(data)) + g = Game.wrap(parser(data, False)) except Exception as exc: raise ValueError(f"Parse error in game file: {exc}") from None return g diff --git a/src/pygambit/util.h b/src/pygambit/util.h index e40bc59e2..78fade05e 100644 --- a/src/pygambit/util.h +++ b/src/pygambit/util.h @@ -37,25 +37,25 @@ using namespace std; using namespace Gambit; using namespace Gambit::Nash; -Game ParseGbtGame(std::string const &s) +Game ParseGbtGame(std::string const &s, bool p_normalizeLabels) { std::istringstream f(s); return ReadGbtFile(f); } -Game ParseEfgGame(std::string const &s) +Game ParseEfgGame(std::string const &s, bool p_normalizeLabels) { std::istringstream f(s); - return ReadEfgFile(f); + return ReadEfgFile(f, p_normalizeLabels); } -Game ParseNfgGame(std::string const &s) +Game ParseNfgGame(std::string const &s, bool p_normalizeLabels) { std::istringstream f(s); - return ReadNfgFile(f); + return ReadNfgFile(f, p_normalizeLabels); } -Game ParseAggGame(std::string const &s) +Game ParseAggGame(std::string const &s, bool p_normalizeLabels) { std::istringstream f(s); return ReadAggFile(f); From 625a3efda820fac6a5f59b9de68768c053e3adf9 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 19 Dec 2025 13:16:11 +0000 Subject: [PATCH 07/12] Expose label normalization in Python for efg and nfg --- src/pygambit/game.pxi | 23 ++++++++++++++++------- tests/test_io.py | 12 +++++++++++- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 543bf261b..d7be7472d 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -36,6 +36,7 @@ ctypedef c_Game (*GameParser)(const string &, bool) except +IOError @cython.cfunc def read_game(filepath_or_buffer: str | pathlib.Path | io.IOBase, + normalize_labels: bool, parser: GameParser): g = cython.declare(Game) @@ -47,7 +48,7 @@ def read_game(filepath_or_buffer: str | pathlib.Path | io.IOBase, with open(filepath_or_buffer, "rb") as f: data = f.read() try: - g = Game.wrap(parser(data, False)) + g = Game.wrap(parser(data, normalize_labels)) except Exception as exc: raise ValueError(f"Parse error in game file: {exc}") from None return g @@ -77,16 +78,20 @@ def read_gbt(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: -------- read_efg, read_nfg, read_agg """ - return read_game(filepath_or_buffer, parser=ParseGbtGame) + return read_game(filepath_or_buffer, False, parser=ParseGbtGame) -def read_efg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: +def read_efg(filepath_or_buffer: str | pathlib.Path | io.IOBase, + normalize_labels: bool = False) -> Game: """Construct a game from its serialised representation in an EFG file. Parameters ---------- filepath_or_buffer : str, pathlib.Path or io.IOBase The path to the file containing the game representation or file-like object + normalize_labels : bool (default False) + Ensure all labels are nonempty and unique within their scopes. + This will be enforced in a future version of Gambit. Returns ------- @@ -104,16 +109,20 @@ def read_efg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: -------- read_gbt, read_nfg, read_agg """ - return read_game(filepath_or_buffer, parser=ParseEfgGame) + return read_game(filepath_or_buffer, normalize_labels, parser=ParseEfgGame) -def read_nfg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: +def read_nfg(filepath_or_buffer: str | pathlib.Path | io.IOBase, + normalize_labels: bool = False) -> Game: """Construct a game from its serialised representation in a NFG file. Parameters ---------- filepath_or_buffer : str, pathlib.Path or io.IOBase The path to the file containing the game representation or file-like object + normalize_labels : bool (default False) + Ensure all labels are nonempty and unique within their scopes. + This will be enforced in a future version of Gambit. Returns ------- @@ -131,7 +140,7 @@ def read_nfg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: -------- read_gbt, read_efg, read_agg """ - return read_game(filepath_or_buffer, parser=ParseNfgGame) + return read_game(filepath_or_buffer, normalize_labels, parser=ParseNfgGame) def read_agg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: @@ -158,7 +167,7 @@ def read_agg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: -------- read_gbt, read_efg, read_nfg """ - return read_game(filepath_or_buffer, parser=ParseAggGame) + return read_game(filepath_or_buffer, False, parser=ParseAggGame) @cython.cclass diff --git a/tests/test_io.py b/tests/test_io.py index cc179a187..1a6c328de 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -116,6 +116,16 @@ def test_read_write_efg(): def test_read_write_nfg(): nfg_game = create_2x2_zero_nfg() serialized_nfg_game = nfg_game.to_nfg() - deserialized_nfg_game = gbt.read_nfg(io.BytesIO(serialized_nfg_game.encode())) + deserialized_nfg_game = gbt.read_nfg(io.BytesIO(serialized_nfg_game.encode()), + normalize_labels=False) double_serialized_nfg_game = deserialized_nfg_game.to_nfg() assert serialized_nfg_game == double_serialized_nfg_game + + +def test_read_write_nfg_normalize(): + nfg_game = create_2x2_zero_nfg() + serialized_nfg_game = nfg_game.to_nfg() + deserialized_nfg_game = gbt.read_nfg(io.BytesIO(serialized_nfg_game.encode()), + normalize_labels=True) + double_serialized_nfg_game = deserialized_nfg_game.to_nfg() + assert serialized_nfg_game != double_serialized_nfg_game From d7ff6d63f85962224830fbc015aba30f68bac47f Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 19 Dec 2025 13:18:22 +0000 Subject: [PATCH 08/12] Handle special case of a single empty string as a label. --- src/games/file.cc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/games/file.cc b/src/games/file.cc index ed4eb7b50..fb4dc6d3c 100644 --- a/src/games/file.cc +++ b/src/games/file.cc @@ -811,7 +811,9 @@ template void StandardizeLabels(C &&p_container) std::map visited; for (auto element : p_container) { const auto label = element->GetLabel(); - if (counts[label] == 1) { + // A special case: If only one label is the empty string we still want to + // convert it to "_1" + if (counts[label] == 1 && label != "") { continue; } const auto index = ++visited[label]; From c61554d8437ccf293973d3039a28ee451556e59a Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 19 Dec 2025 13:26:07 +0000 Subject: [PATCH 09/12] Add label normalization to reading .gbt --- src/pygambit/game.pxi | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index d7be7472d..362663f33 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -54,13 +54,17 @@ def read_game(filepath_or_buffer: str | pathlib.Path | io.IOBase, return g -def read_gbt(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: +def read_gbt(filepath_or_buffer: str | pathlib.Path | io.IOBase, + normalize_labels: bool = False) -> Game: """Construct a game from its serialised representation in a GBT file. Parameters ---------- filepath_or_buffer : str, pathlib.Path or io.IOBase The path to the file containing the game representation or file-like object + normalize_labels : bool (default False) + Ensure all labels are nonempty and unique within their scopes. + This will be enforced in a future version of Gambit. Returns ------- @@ -78,7 +82,7 @@ def read_gbt(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: -------- read_efg, read_nfg, read_agg """ - return read_game(filepath_or_buffer, False, parser=ParseGbtGame) + return read_game(filepath_or_buffer, normalize_labels, parser=ParseGbtGame) def read_efg(filepath_or_buffer: str | pathlib.Path | io.IOBase, From 4984a80eb0184b4004be76f98c16a97b3970df1d Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 19 Dec 2025 13:40:50 +0000 Subject: [PATCH 10/12] Add label normalization to AGG and BAGG files --- ChangeLog | 6 +++++ src/games/file.cc | 52 ++++++++++++++++++++++++++++++++++--------- src/games/game.h | 4 +++- src/pygambit/game.pxi | 8 +++++-- 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/ChangeLog b/ChangeLog index 3c24c557c..679b1eb7d 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,12 @@ unreachable parts of the game tree in games with absent-mindedness. (#629) ### Changed +- Labels for players, outcomes, strategies, and actions are expected to be non-empty and unique within + the relevant scope (games for players and outcomes, players for strategies, and information sets for + actions). `pygambit` now issues a `FutureWarning` if a label is changed that does not conform to these + expectations. There is now an optional flag `normalize_labels` to `read_*` which will automatically + fill in non-confirming sets of labels. In version 16.6 these will be enforced; invalid label sets will + generate an error and files will be normalized automatically on read. (#614) - In the graphical interface, removed option to configure information set link drawing; information sets are always drawn and indicators are always drawn if an information set spans multiple levels. - In `pygambit`, indexing the children of a node by a string inteprets the string as an action label, diff --git a/src/games/file.cc b/src/games/file.cc index fb4dc6d3c..159a2ac87 100644 --- a/src/games/file.cc +++ b/src/games/file.cc @@ -802,7 +802,7 @@ Game GameXMLSavefile::GetGame() const throw InvalidFileException("No game representation found in document"); } -template void StandardizeLabels(C &&p_container) +template void NormalizeLabels(C &&p_container) { std::map counts; for (const auto &element : p_container) { @@ -821,20 +821,20 @@ template void StandardizeLabels(C &&p_container) } } -void StandardizeGameLabels(const Game &p_game) +void NormalizeGameLabels(const Game &p_game) { - StandardizeLabels(p_game->GetPlayers()); - StandardizeLabels(p_game->GetOutcomes()); + NormalizeLabels(p_game->GetPlayers()); + NormalizeLabels(p_game->GetOutcomes()); if (p_game->IsTree()) { for (const auto &player : p_game->GetPlayersWithChance()) { for (const auto &infoset : player->GetInfosets()) { - StandardizeLabels(infoset->GetActions()); + NormalizeLabels(infoset->GetActions()); } } } else { for (const auto &player : p_game->GetPlayers()) { - StandardizeLabels(player->GetStrategies()); + NormalizeLabels(player->GetStrategies()); } } } @@ -869,7 +869,7 @@ Game ReadEfgFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) ParseNode(parser, game, game->GetRoot(), treeData); game->SortInfosets(); if (p_normalizeLabels) { - StandardizeGameLabels(game); + NormalizeGameLabels(game); } return game; } @@ -881,16 +881,48 @@ Game ReadNfgFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) ParseNfgHeader(parser, data); auto game = BuildNfg(parser, data); if (p_normalizeLabels) { - StandardizeGameLabels(game); + NormalizeGameLabels(game); } return game; } -Game ReadGbtFile(std::istream &p_stream) +Game ReadGbtFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) { std::stringstream buffer; buffer << p_stream.rdbuf(); - return GameXMLSavefile(buffer.str()).GetGame(); + auto game = GameXMLSavefile(buffer.str()).GetGame(); + if (p_normalizeLabels) { + NormalizeGameLabels(game); + } + return game; +} + +Game ReadAggFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) +{ + try { + auto game = std::make_shared(agg::AGG::makeAGG(p_stream)); + if (p_normalizeLabels) { + NormalizeGameLabels(game); + } + return game; + } + catch (std::runtime_error &ex) { + throw InvalidFileException(ex.what()); + } +} + +Game ReadBaggFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) +{ + try { + auto game = std::make_shared(agg::BAGG::makeBAGG(p_stream)); + if (p_normalizeLabels) { + NormalizeGameLabels(game); + } + return game; + } + catch (std::runtime_error &ex) { + throw InvalidFileException(ex.what()); + } } Game ReadGame(std::istream &p_file, bool p_normalizeLabels /* = false */) diff --git a/src/games/game.h b/src/games/game.h index b27478857..9e9031911 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -1142,11 +1142,13 @@ Game ReadNfgFile(std::istream &p_stream, bool p_normalizeLabels = false); /// @brief Reads a game representation from a graphical interface XML saveflie /// @param[in] p_stream An input stream, positioned at the start of the text +/// @param[in] p_normalizeLabels Require element labels to be nonempty and unique within +/// their scope /// @return A handle to the game representation constructed /// @throw InvalidFileException If the stream does not contain a valid serialisation /// of a game in an XML savefile /// @sa ReadEfgFile, ReadNfgFile, ReadAggFile, ReadBaggFile -Game ReadGbtFile(std::istream &p_stream); +Game ReadGbtFile(std::istream &p_stream, bool p_normalizeLabels = false); /// @brief Reads a game from the input stream, attempting to autodetect file format /// @deprecated Deprecated in favour of the various ReadXXXGame functions. diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 362663f33..095577fdb 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -147,13 +147,17 @@ def read_nfg(filepath_or_buffer: str | pathlib.Path | io.IOBase, return read_game(filepath_or_buffer, normalize_labels, parser=ParseNfgGame) -def read_agg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: +def read_agg(filepath_or_buffer: str | pathlib.Path | io.IOBase, + normalize_labels: bool = False) -> Game: """Construct a game from its serialised representation in an AGG file. Parameters ---------- filepath_or_buffer : str, pathlib.Path or io.IOBase The path to the file containing the game representation or file-like object + normalize_labels : bool (default False) + Ensure all labels are nonempty and unique within their scopes. + This will be enforced in a future version of Gambit. Returns ------- @@ -171,7 +175,7 @@ def read_agg(filepath_or_buffer: str | pathlib.Path | io.IOBase) -> Game: -------- read_gbt, read_efg, read_nfg """ - return read_game(filepath_or_buffer, False, parser=ParseAggGame) + return read_game(filepath_or_buffer, normalize_labels, parser=ParseAggGame) @cython.cclass From 1ced4063db2d67bc8367955af6d9feda3ddbc745 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 2 Jan 2026 10:26:58 +0000 Subject: [PATCH 11/12] Comments for bad const-correctness flag in clang-tidy --- src/games/file.cc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/games/file.cc b/src/games/file.cc index 02ace169a..09392fd79 100644 --- a/src/games/file.cc +++ b/src/games/file.cc @@ -804,7 +804,9 @@ Game GameXMLSavefile::GetGame() const template void NormalizeLabels(C &&p_container) { + // NOLINTBEGIN(misc-const-correctness) std::map counts; + // NOLINTEND(misc-const-correctness) for (const auto &element : p_container) { ++counts[element->GetLabel()]; } From 66cc5f0375cbb0faea044493be3c2642b0036b8a Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Fri, 2 Jan 2026 12:29:28 +0000 Subject: [PATCH 12/12] Another const-correctness bamboozle --- src/games/file.cc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/games/file.cc b/src/games/file.cc index 09392fd79..0e884a898 100644 --- a/src/games/file.cc +++ b/src/games/file.cc @@ -808,9 +808,11 @@ template void NormalizeLabels(C &&p_container) std::map counts; // NOLINTEND(misc-const-correctness) for (const auto &element : p_container) { - ++counts[element->GetLabel()]; + counts[element->GetLabel()] += 1; } + // NOLINTBEGIN(misc-const-correctness) std::map visited; + // NOLINTEND(misc-const-correctness) for (auto element : p_container) { const auto label = element->GetLabel(); // A special case: If only one label is the empty string we still want to