diff --git a/ChangeLog b/ChangeLog index 13b4e88ad..06fba6a2c 100644 --- a/ChangeLog +++ b/ChangeLog @@ -7,6 +7,12 @@ to detect if an information is absent-minded. ### 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) - Terminology for agent-form calculations on extensive games has been clarified. Mixed behavior profiles distinguish "agent" regret and liap values from their strategy-based analogs. Methods which compute using the agent-form - specifically `enumpure_solve` and `liap_solve`, now clarify this by being named 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 f6ba3634f..0e884a898 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); } } @@ -709,7 +802,48 @@ Game GameXMLSavefile::GetGame() const throw InvalidFileException("No game representation found in document"); } -Game ReadEfgFile(std::istream &p_stream) +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()] += 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 + // convert it to "_1" + if (counts[label] == 1 && label != "") { + continue; + } + const auto index = ++visited[label]; + element->SetLabel(label + "_" + std::to_string(index)); + } +} + +void NormalizeGameLabels(const Game &p_game) +{ + 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()) { + NormalizeLabels(infoset->GetActions()); + } + } + } + else { + for (const auto &player : p_game->GetPlayers()) { + NormalizeLabels(player->GetStrategies()); + } + } +} + +Game ReadEfgFile(std::istream &p_stream, bool p_normalizeLabels /* = false */) { GameFileLexer parser(p_stream); @@ -737,25 +871,64 @@ Game ReadEfgFile(std::istream &p_stream) parser.GetNextToken(); } ParseNode(parser, game, game->GetRoot(), treeData); + if (p_normalizeLabels) { + NormalizeGameLabels(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); - return BuildNfg(parser, data); + auto game = BuildNfg(parser, data); + if (p_normalizeLabels) { + 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 ReadGame(std::istream &p_file) +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 */) { std::stringstream buffer; buffer << p_file.rdbuf(); @@ -776,20 +949,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 800230231..6736e7da0 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -1150,32 +1150,38 @@ 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 +/// @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. /// @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/action.pxi b/src/pygambit/action.pxi index cd21b7a5a..b8b87c0a0 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 == 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) self.action.deref().SetLabel(value.encode("ascii")) @property diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 664d3eb3e..0cb4b0ffa 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -426,10 +426,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 c8337d0e1..f5043433a 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -31,11 +31,12 @@ 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 def read_game(filepath_or_buffer: str | pathlib.Path | io.IOBase, + normalize_labels: bool, parser: GameParser): g = cython.declare(Game) @@ -47,19 +48,23 @@ 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, normalize_labels)) except Exception as exc: raise ValueError(f"Parse error in game file: {exc}") from None 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 ------- @@ -77,16 +82,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, normalize_labels, 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 +113,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,16 +144,20 @@ 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: +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 ------- @@ -158,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, parser=ParseAggGame) + return read_game(filepath_or_buffer, normalize_labels, parser=ParseAggGame) @cython.cclass diff --git a/src/pygambit/outcome.pxi b/src/pygambit/outcome.pxi index 9c2953a5c..012f48edf 100644 --- a/src/pygambit/outcome.pxi +++ b/src/pygambit/outcome.pxi @@ -68,8 +68,11 @@ 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") + 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/player.pxi b/src/pygambit/player.pxi index 0e7a289d2..4d87d2444 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/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/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); diff --git a/tests/games.py b/tests/games.py index 5fccf5e38..deb1eb580 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 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(), 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 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()