Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
- Added `BehaviorSupportProfile` to `pygambit`.
- Added callback functions to Nash solvers, allowing calling code to take actions when an equilibrium
is found or another solver event is emitted.
- Added `Game.make_outcome`, which creates an outcome with the given payoffs and attaches it to a
set of nodes (extensive) or contingencies (strategic) in a single operation. (#1061)
- Added `MixedStrategyProfile.as_float` and `MixedBehaviorProfile.as_float`, converting a
rational-precision profile to floating-point precision. `liap_solve`, `liap_agent_solve`,
`logit_estimate`, `ipa_solve`, and `gnm_solve` now also accept a rational-precision profile
Expand All @@ -17,6 +19,12 @@
- Improved installers for Windows, and disk-image "installer" for MacOS
- Revised `StrategySupportProfile` to expose a slimmer set of operations, and
implement `StrategySupport` as an immutable snapshot.
- Absent outcomes are now represented by a per-game null outcome rather than by a null reference.
Two null outcomes always compare unequal (cf. missing values in `pandas`). Null outcomes do not appear when
iterating over a game's outcomes, and assigning a payoff to one raises `UndefinedOperationError`. (#1061)
- `Game.new_table` no longer creates an outcome for every contingency; a new strategic game has no outcomes,
and every contingency is initially null. Outcomes are created as they are needed;
games built by `Game.from_arrays` and `Game.from_dict` are unaffected. (#1061)

## [17.0.0-alpha.2] - 2026-08-21

Expand Down
61 changes: 61 additions & 0 deletions doc/gui.nfg.building.rst
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,64 @@ inputting the payoff for the first (row) player, pressing the :kbd:`Tab`
key, inputting the payoff for the second (column) player, pressing the
:kbd:`Tab` key, and so forth, until all the payoff entries in the table
have been filled.

Shared outcomes
^^^^^^^^^^^^^^^^

The payoffs shown at a contingency are stored as an *outcome*, and the
same outcome can be attached to more than one contingency -- most
commonly because several strategy profiles induce the same terminal
node in the reduced strategic game of an extensive game, but a
strategic game can also be built or loaded with outcomes shared this
way directly. Editing a payoff at any one of these contingencies
changes it at every other contingency sharing that outcome, since they
are, in fact, the same outcome.

To see this grouping before editing, hover the mouse pointer over any
payoff cell: every other cell sharing its outcome is shown with a
shaded background for as long as the pointer remains over one of them.
The highlight does not change while a payoff is being edited, even if
the pointer moves elsewhere in the meantime.

Merging and splitting outcomes
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Which contingencies share an outcome can itself be changed from the
table. Click and drag, or shift-click, across several payoff cells to
select them; the selection automatically expands to cover every
payoff column of any contingency it touches, since an outcome is
shared at the level of a whole contingency, not a single payoff cell.
Right-clicking the selection -- or, with nothing selected,
right-clicking a single cell -- opens a menu with three commands:

+ :guilabel:`Set outcome...` (labelled :guilabel:`Merge into one
outcome...` when more than one contingency is selected) opens a
dialog to give a label and a payoff for each player, then attaches
the resulting outcome to every selected contingency. Selecting
several contingencies this way merges them into one shared outcome;
selecting a single contingency that is currently part of a larger
shared outcome splits it off into its own outcome, leaving the rest
of the group unchanged. If the selection already matches one
outcome's full membership exactly, this instead edits that outcome's
own label and payoffs in place, rather than creating a new one.
The dialog starts from a label and payoffs to edit -- the shared
outcome's own, if editing one in place, or the first selected
contingency's current payoffs with a freshly generated label
otherwise -- and disables :guilabel:`OK` until the label is
nonempty and unique among the game's outcomes and every payoff is a
valid number.
+ :guilabel:`Edit outcome...` is the direct route to that same
in-place case: it is enabled whenever the selection matches one
existing outcome's full membership exactly (a single contingency is
enough, if that contingency's outcome has no other members), and
opens the same label-and-payoffs dialog pre-filled with that
outcome's own values, changing them in place without touching which
contingencies share it. Editing a payoff here is no different from
typing directly into a payoff cell -- it is offered as a second way
to reach the same result, alongside also being able to change the
label, matching how outcomes are edited in an extensive game.
+ :guilabel:`Remove outcome` clears every selected contingency back to
having no outcome. It is disabled if none of the selection currently
has one.

Clicking any cell afterward clears the selection.
1 change: 1 addition & 0 deletions doc/pygambit.api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Transforming game information structure
:toctree: api/

Game.make_infoset
Game.make_outcome
Game.make_event
Game.relabel_actions
Game.set_move_actions
Expand Down
30 changes: 14 additions & 16 deletions doc/tutorials/01_quickstart.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,11 @@
"metadata": {},
"outputs": [],
"source": [
"# Both cooperate\n",
"g[\"Cooperate\", \"Cooperate\"][\"Tom\"] = -1\n",
"g[\"Cooperate\", \"Cooperate\"][\"Jerry\"] = -1\n",
"\n",
"# Tom cooperates, Jerry defects\n",
"g[\"Cooperate\", \"Defect\"][\"Tom\"] = -3\n",
"g[\"Cooperate\", \"Defect\"][\"Jerry\"] = 0\n",
"\n",
"# Tom defects, Jerry cooperates\n",
"g[\"Defect\", \"Cooperate\"][\"Tom\"] = 0\n",
"g[\"Defect\", \"Cooperate\"][\"Jerry\"] = -3\n",
"\n",
"# Both defect\n",
"g[\"Defect\", \"Defect\"][\"Tom\"] = -2\n",
"g[\"Defect\", \"Defect\"][\"Jerry\"] = -2"
"# Each contingency gets an outcome, created and attached in one step\n",
"g.make_outcome((\"Cooperate\", \"Cooperate\"), {\"Tom\": -1, \"Jerry\": -1}, \"Both cooperate\")\n",
"g.make_outcome((\"Cooperate\", \"Defect\"), {\"Tom\": -3, \"Jerry\": 0}, \"Tom cooperates, Jerry defects\")\n",
"g.make_outcome((\"Defect\", \"Cooperate\"), {\"Tom\": 0, \"Jerry\": -3}, \"Tom defects, Jerry cooperates\")\n",
"g.make_outcome((\"Defect\", \"Defect\"), {\"Tom\": -2, \"Jerry\": -2}, \"Both defect\")"
]
},
{
Expand Down Expand Up @@ -291,7 +281,15 @@
"id": "980bf6b1",
"metadata": {},
"outputs": [],
"source": "payoffs = msp.payoffs\nfor player in g.players:\n print(f\"{player.label} plays the equilibrium strategy:\")\n print(f\"Probability of cooperating: {msp[player.label]['Cooperate']}\")\n print(f\"Probability of defecting: {msp[player.label]['Defect']}\")\n print(f\"Payoff: {payoffs[player.label]}\")\n print()"
"source": [
"payoffs = msp.payoffs\n",
"for player in g.players:\n",
" print(f\"{player.label} plays the equilibrium strategy:\")\n",
" print(f\"Probability of cooperating: {msp[player.label]['Cooperate']}\")\n",
" print(f\"Probability of defecting: {msp[player.label]['Defect']}\")\n",
" print(f\"Payoff: {payoffs[player.label]}\")\n",
" print()"
]
},
{
"cell_type": "markdown",
Expand Down
8 changes: 3 additions & 5 deletions src/games/behavmixed.cc
Original file line number Diff line number Diff line change
Expand Up @@ -552,11 +552,9 @@ template <class T> void MixedBehaviorProfile<T>::ComputeNodeValues() const
for (const auto &player : game->GetPlayers()) {
vals[player] = static_cast<T>(0);
}
if (node->GetOutcome()) {
const GameOutcome &outcome = node->GetOutcome();
for (const auto &player : game->GetPlayers()) {
vals[player] += outcome->GetPayoff<T>(player);
}
const GameOutcome &outcome = node->GetOutcome();
for (const auto &player : game->GetPlayers()) {
vals[player] += outcome->GetPayoff<T>(player);
}
for (auto [action, child] : node->GetActions()) {
const T p = GetActionProb(action);
Expand Down
8 changes: 3 additions & 5 deletions src/games/behavpure.cc
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,7 @@ T PureBehaviorProfile::GetPayoff(const GameNode &p_node, const GamePlayer &p_pla
{
T payoff(0);

if (p_node->GetOutcome()) {
payoff += p_node->GetOutcome()->GetPayoff<T>(p_player);
}
payoff += p_node->GetOutcome()->GetPayoff<T>(p_player);

if (!p_node->IsTerminal()) {
if (p_node->GetInfoset()->IsChanceInfoset()) {
Expand All @@ -78,7 +76,7 @@ GameOutcome PureBehaviorProfile::GetOutcome(const GameNode &p_start) const
GameOutcome outcome;
GameNode node = p_start;
while (true) {
if (node->GetOutcome()) {
if (!node->GetOutcome()->IsNull()) {
if (outcome) {
// More than one outcome-bearing node along this path; the combined
// payoff cannot be attributed to a single outcome's preserved text.
Expand All @@ -87,7 +85,7 @@ GameOutcome PureBehaviorProfile::GetOutcome(const GameNode &p_start) const
outcome = node->GetOutcome();
}
if (node->IsTerminal()) {
return outcome;
return outcome ? outcome : node->GetOutcome();
}
if (node->GetInfoset()->IsChanceInfoset()) {
throw UndefinedException();
Expand Down
63 changes: 43 additions & 20 deletions src/games/file.cc
Original file line number Diff line number Diff line change
Expand Up @@ -441,19 +441,22 @@ void ParseNfgHeader(GameFileLexer &p_state, TableFileGame &p_data)
}
}

void ReadOutcomeList(GameFileLexer &p_parser, Game &p_nfg)
struct NfgOutcomeRecords {
std::vector<std::string> m_labels;
std::vector<std::vector<Number>> m_payoffs;
};

NfgOutcomeRecords ReadOutcomeList(GameFileLexer &p_parser, const Game &p_nfg)
{
auto players = p_nfg->GetPlayers();
p_parser.GetNextToken();

// Buffer raw labels + payoffs so labels can be normalized (unique, nonempty)
// before the outcome objects are created (NewOutcome now rejects empty/duplicate).
std::vector<std::string> labels;
std::vector<std::vector<Number>> payoff_lists;
// Buffer raw labels + payoffs; which records become outcomes depends on the index table
NfgOutcomeRecords records;

while (p_parser.GetCurrentToken() == TOKEN_LBRACE) {
p_parser.ExpectNextToken(TOKEN_TEXT, "outcome name");
labels.push_back(p_parser.GetLastText());
records.m_labels.push_back(p_parser.GetLastText());
p_parser.GetNextToken();

std::vector<Number> payoffs;
Expand All @@ -462,34 +465,54 @@ void ReadOutcomeList(GameFileLexer &p_parser, Game &p_nfg)
payoffs.emplace_back(p_parser.GetLastText());
p_parser.AcceptNextToken(TOKEN_COMMA);
}
payoff_lists.push_back(payoffs);
records.m_payoffs.push_back(payoffs);

p_parser.ExpectCurrentToken(TOKEN_RBRACE, "'}'");
p_parser.GetNextToken();
}
p_parser.ExpectCurrentToken(TOKEN_RBRACE, "'}'");
p_parser.GetNextToken();
return records;
}

void ParseOutcomeBody(GameFileLexer &p_parser, Game &p_nfg)
{
const auto records = ReadOutcomeList(p_parser, p_nfg);
// First pass: the outcome id at each contingency, in iteration order.
std::vector<int> ids;
for ([[maybe_unused]] const auto &profile : StrategyContingencies(p_nfg)) {
p_parser.ExpectCurrentToken(TOKEN_NUMBER, "outcome index");
ids.push_back(std::stoi(p_parser.GetLastText()));
p_parser.GetNextToken();
}
const std::set<int> referenced(ids.begin(), ids.end());
std::vector<std::string> labels;
for (size_t i = 0; i < records.m_labels.size(); ++i) {
if (referenced.contains(static_cast<int>(i) + 1)) {
labels.push_back(records.m_labels[i]);
}
}
NormalizeLabelStrings(labels);
for (size_t i = 0; i < labels.size(); ++i) {
auto outcome = p_nfg->NewOutcome(labels[i]);
auto player_it = players.begin();
for (const auto &payoff : payoff_lists[i]) {
std::map<int, GameOutcome> created;
auto label_it = labels.begin();
for (size_t i = 0; i < records.m_labels.size(); ++i) {
if (!referenced.contains(static_cast<int>(i) + 1)) {
continue;
}
auto outcome = p_nfg->NewOutcome(*label_it++);
auto player_it = p_nfg->GetPlayers().begin();
for (const auto &payoff : records.m_payoffs[i]) {
outcome->SetPayoff(*player_it, payoff);
++player_it;
}
created.emplace(static_cast<int>(i) + 1, outcome);
}
}

void ParseOutcomeBody(GameFileLexer &p_parser, Game &p_nfg)
{
ReadOutcomeList(p_parser, p_nfg);
// Second pass: attach.
auto id_it = ids.begin();
for (const auto &profile : StrategyContingencies(p_nfg)) {
p_parser.ExpectCurrentToken(TOKEN_NUMBER, "outcome index");
if (const int outcomeId = std::stoi(p_parser.GetLastText())) {
profile->SetOutcome(p_nfg->GetOutcome(outcomeId));
if (const int outcomeId = *id_it++) {
profile->SetOutcome(created.at(outcomeId));
}
p_parser.GetNextToken();
}
}

Expand Down
6 changes: 4 additions & 2 deletions src/games/game.cc
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ namespace Gambit {
// class GameOutcomeRep
//========================================================================

GameOutcomeRep::GameOutcomeRep(GameRep *p_game, int p_number, const std::string &p_label)
: m_game(p_game), m_number(p_number), m_label(p_label)
GameOutcomeRep::GameOutcomeRep(GameRep *p_game, int p_number, const std::string &p_label,
bool p_isNull /* = false */)
: m_isNull(p_isNull), m_game(p_game), m_number(p_number), m_label(p_label)
{
CheckLabel(p_label);
for (const auto &player : m_game->m_players) {
Expand Down Expand Up @@ -170,6 +171,7 @@ GameRep::~GameRep()
for (auto outcome : m_outcomes) {
outcome->Invalidate();
}
m_nullOutcome->Invalidate();
}

void GameRep::IndexStrategies() const
Expand Down
Loading
Loading