Skip to content
Open
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
6 changes: 6 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
## [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)
- `gnm_solve` executes callback when equilibrium is found rather than emitting all equilibria at the end of
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
Expand Down
6 changes: 6 additions & 0 deletions doc/gui.nfg.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 4 additions & 9 deletions src/games/game.cc
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,11 @@ GamePlayerRep::~GamePlayerRep()

void GamePlayerRep::MakeStrategy(const std::map<GameInfosetRep *, int> &behav)
{
auto strategy = std::make_shared<GameStrategyRep>(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<GameStrategyRep>(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);
}

Expand Down
114 changes: 113 additions & 1 deletion src/gui/nfgtable.cc
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
#include <wx/dnd.h> // for drag-and-drop support
#include <wx/print.h> // for printing support
#include <wx/dcsvg.h> // for SVG output
#include <wx/popupwin.h>

#include "wx/sheet/sheet.h"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -199,6 +201,99 @@ RowPlayerWidget::RowPlayerWidget(TableWidget *p_parent)
static_cast<wxSheetEventFunction>(&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()) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()) {
Expand Down
56 changes: 56 additions & 0 deletions src/pygambit/game.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
105 changes: 105 additions & 0 deletions src/pygambit/strategy.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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``.
Expand Down
Loading
Loading