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
8 changes: 8 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## [17.0.0] - unreleased

### Added
- Added `Game.make_chance_event`, which forms a collection of nodes into a single chance event with a
specified probability distribution over its actions. (#1009)

### 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)
Expand All @@ -12,6 +16,10 @@
- Converting a behavior profile to a strategy profile, and computing pure-strategy payoffs
no longer inserts spurious entries into a strategy's internal action map.

### Removed
- `Game.set_chance_probs` has been removed; use `Game.make_chance_event`, which expresses a change of
probabilities as forming the nodes of the event with the required distribution. (#1009)

## [16.7.0] - 2026-07-11

### Added
Expand Down
2 changes: 1 addition & 1 deletion doc/pygambit.api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Transforming game information structure
Game.set_player
Game.set_infoset
Game.leave_infoset
Game.set_chance_probs
Game.make_chance_event
Game.reveal
Game.sort_infosets

Expand Down
22 changes: 12 additions & 10 deletions doc/tutorials/03_stripped_down_poker.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -933,7 +933,7 @@
"source": [
"## Representation of numerical data of a game\n",
"\n",
"Payoffs to players and probabilities of actions at chance information sets are specified as numbers.\n",
"Payoffs to players and probabilities of actions at chance events are specified as numbers.\n",
"Gambit represents the numerical values in a game in exact precision, using either decimal or rational representations.\n",
"\n",
"To illustrate, consider a trivial game which just has one move for the chance player:"
Expand All @@ -959,7 +959,9 @@
"The default when creating a new move for chance is that all actions are chosen with equal probability.\n",
"These probabilities are represented as rational numbers, using `pygambit`'s `Rational` class, which is derived from Python's `fractions.Fraction`.\n",
"\n",
"Numerical data can be set as rational numbers. Here we update the chance action probabilities with `Rational` numbers:"
"Numerical data can be set as rational numbers.\n",
"`make_chance_event` forms a collection of nodes into a chance event with a given distribution; applied to a single node which is already a chance move, it simply sets the probabilities.\n",
"Here we set them with `Rational` numbers:"
]
},
{
Expand All @@ -969,8 +971,8 @@
"metadata": {},
"outputs": [],
"source": [
"small_game.set_chance_probs(\n",
" small_game.root.infoset,\n",
"small_game.make_chance_event(\n",
" [small_game.root],\n",
" [gbt.Rational(1, 4), gbt.Rational(1, 2), gbt.Rational(1, 4)]\n",
")\n",
"[act.prob for act in small_game.root.infoset.actions]"
Expand All @@ -991,8 +993,8 @@
"metadata": {},
"outputs": [],
"source": [
"small_game.set_chance_probs(\n",
" small_game.root.infoset,\n",
"small_game.make_chance_event(\n",
" [small_game.root],\n",
" [gbt.Decimal(\".25\"), gbt.Decimal(\".50\"), gbt.Decimal(\".25\")]\n",
")\n",
"[act.prob for act in small_game.root.infoset.actions]"
Expand All @@ -1017,7 +1019,7 @@
"metadata": {},
"outputs": [],
"source": [
"small_game.set_chance_probs(small_game.root.infoset, [\"1/4\", \"1/2\", \"1/4\"])\n",
"small_game.make_chance_event([small_game.root], [\"1/4\", \"1/2\", \"1/4\"])\n",
"[act.prob for act in small_game.root.infoset.actions]"
]
},
Expand All @@ -1028,7 +1030,7 @@
"metadata": {},
"outputs": [],
"source": [
"small_game.set_chance_probs(small_game.root.infoset, [\".25\", \".50\", \".25\"])\n",
"small_game.make_chance_event([small_game.root], [\".25\", \".50\", \".25\"])\n",
"[act.prob for act in small_game.root.infoset.actions]"
]
},
Expand All @@ -1052,7 +1054,7 @@
"metadata": {},
"outputs": [],
"source": [
"small_game.set_chance_probs(small_game.root.infoset, [.25, .50, .25])\n",
"small_game.make_chance_event([small_game.root], [.25, .50, .25])\n",
"[act.prob for act in small_game.root.infoset.actions]"
]
},
Expand All @@ -1072,7 +1074,7 @@
"outputs": [],
"source": [
"try:\n",
" small_game.set_chance_probs(small_game.root.infoset, [1/3, 1/3, 1/3])\n",
" small_game.make_chance_event([small_game.root], [1/3, 1/3, 1/3])\n",
"except ValueError as e:\n",
" print(\"ValueError:\", e)\n"
]
Expand Down
42 changes: 31 additions & 11 deletions src/games/game.h
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,11 @@ class GamePlayerRep : public std::enable_shared_from_this<GamePlayerRep> {
GameInfoset GetInfoset(int p_index) const;
/// Returns the information sets for the player
Infosets GetInfosets() const;
/// Validate that p_label is a valid label for an information set of this player,
/// disregarding any information sets in p_ignore.
void CheckInfosetLabel(const std::string &p_label,
const std::set<const GameInfosetRep *> &p_ignore) const;
//@}

/// @name Strategies
//@{
Expand Down Expand Up @@ -666,7 +671,7 @@ inline GameNodeRep::Actions::iterator::iterator(GameInfosetRep::Actions::iterato

inline GameNode GameNodeRep::Actions::iterator::GetOwner() const { return m_child_it.GetOwner(); }

inline void ValidateDistribution(const Array<Number> &p_probs, const bool p_normalized = true)
template <class C> void ValidateDistribution(const C &p_probs, const bool p_normalized = true)
{
if (std::any_of(p_probs.begin(), p_probs.end(),
[](const Number &x) { return static_cast<Rational>(x) < Rational(0); })) {
Expand Down Expand Up @@ -1260,6 +1265,14 @@ class GameRep : public std::enable_shared_from_this<GameRep> {
//@{
/// Set the probability distribution of actions at a chance node
virtual Game SetChanceProbs(const GameInfoset &, const Array<Number> &) = 0;
/// Form the collection of nodes into a single chance event carrying the given
/// probability distribution over its actions. The nodes need not currently be
/// chance nodes; personal decision nodes are converted.
virtual GameInfoset MakeChanceEvent(const std::vector<GameNode> &, const std::vector<Number> &,
const std::string &)
{
throw UndefinedException();
}
//@}

/// Build any computed values anew
Expand Down Expand Up @@ -1338,6 +1351,22 @@ inline void GamePlayerRep::CheckStrategyLabel(const std::string &p_label) const
}
}

inline void
GamePlayerRep::CheckInfosetLabel(const std::string &p_label,
const std::set<const GameInfosetRep *> &p_ignore) const
{
CheckLabel(p_label);
// Infoset labels may be empty; a nonempty label must be unique among the infosets of the player.
if (p_label.empty()) {
return;
}
for (const auto &infoset : m_infosets) {
if (p_ignore.count(infoset.get()) == 0 && infoset->GetLabel() == p_label) {
throw ValueException("Infoset label must be unique for the player");
}
}
}

inline Game GameSequenceRep::GetGame() const { return m_player->GetGame(); }
inline GamePlayer GameSequenceRep::GetPlayer() const { return m_player->shared_from_this(); }

Expand All @@ -1350,16 +1379,7 @@ inline void GameInfosetRep::SetLabel(const std::string &p_label)
if (p_label == m_label) {
return;
}
CheckLabel(p_label);
// Infoset labels may be empty, but a non-empty label must be unique among
// the infosets of the same player.
if (!p_label.empty()) {
for (const auto &infoset : GetPlayer()->GetInfosets()) {
if (infoset.get() != this && infoset->GetLabel() == p_label) {
throw ValueException("Infoset label must be unique for the player");
}
}
}
m_player->CheckInfosetLabel(p_label, {this});
m_label = p_label;
}
inline void GameRep::CheckPlayerLabel(const std::string &p_label) const
Expand Down
73 changes: 73 additions & 0 deletions src/games/gametree.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1631,6 +1631,79 @@ Game GameTreeRep::SetChanceProbs(const GameInfoset &p_infoset, const Array<Numbe
return shared_from_this();
}

GameInfoset GameTreeRep::MakeChanceEvent(const std::vector<GameNode> &p_nodes,
const std::vector<Number> &p_probs,
const std::string &p_label)
{
if (p_nodes.empty()) {
throw ValueException("At least one node must be specified");
}
std::set<GameNodeRep *> selected;
for (const auto &node : p_nodes) {
if (node->m_game != this) {
throw MismatchException();
}
if (node->IsTerminal()) {
throw UndefinedException("All nodes must be decision nodes");
}
if (!selected.insert(node.get()).second) {
throw ValueException("Each node may be referenced only once");
}
}
// The first member defines the action labels and their order of the new chance event.
const auto &reference = p_nodes.front()->m_infoset->m_actions;
for (const auto &node : p_nodes) {
const auto &actions = node->m_infoset->m_actions;
if (!std::equal(
actions.begin(), actions.end(), reference.begin(), reference.end(),
[](const std::shared_ptr<GameActionRep> &a, const std::shared_ptr<GameActionRep> &b) {
return a->GetLabel() == b->GetLabel();
})) {
throw ValueException(
"All nodes must have the same actions, with the same labels in the same order");
}
}
if (p_probs.size() != reference.size()) {
throw DimensionException("The number of probabilities given must match the number of actions");
}
ValidateDistribution(p_probs);
const GamePlayer chance = GetChance();
// Destroy a chance event all of whose members are absorbed; its label can then be reused.
std::set<const GameInfosetRep *> absorbed;
if (!p_label.empty()) {
for (const auto &infoset : chance->m_infosets) {
if (infoset->GetLabel() == p_label &&
std::all_of(infoset->m_members.begin(), infoset->m_members.end(),
[&selected](const std::shared_ptr<GameNodeRep> &m) {
return contains(selected, m.get());
})) {
absorbed.insert(infoset.get());
}
}
}
chance->CheckInfosetLabel(p_label, absorbed);

IncrementVersion();
auto newEvent = std::make_shared<GameInfosetRep>(this, chance->m_infosets.size() + 1,
chance.get(), reference.size());
auto dest = newEvent->m_actions.begin();
for (const auto &action : reference) {
(*dest)->SetLabel(action->GetLabel());
++dest;
}
std::copy(p_probs.begin(), p_probs.end(), newEvent->m_probs.begin());
chance->m_infosets.push_back(newEvent);
for (const auto &node : p_nodes) {
RemoveMember(node->m_infoset, node.get());
newEvent->m_members.push_back(node);
node->m_infoset = newEvent.get();
}
newEvent->SetLabel(p_label);
ClearComputedValues();
InvalidateInfosetOrdering();
return newEvent;
}

Game GameTreeRep::NormalizeChanceProbs(GameInfosetRep *p_infoset)
{
if (p_infoset->m_game != this) {
Expand Down
2 changes: 2 additions & 0 deletions src/games/gametree.h
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ class GameTreeRep final : public GameExplicitRep {
void SetInfoset(GameNode, GameInfoset) override;
GameInfoset LeaveInfoset(GameNode) override;
Game SetChanceProbs(const GameInfoset &, const Array<Number> &) override;
GameInfoset MakeChanceEvent(const std::vector<GameNode> &, const std::vector<Number> &,
const std::string &) override;
GameAction InsertAction(GameInfoset, GameAction p_where = nullptr) override;
void DeleteAction(GameAction) override;
void SetOutcome(const GameNode &p_node, const GameOutcome &p_outcome) override;
Expand Down
2 changes: 2 additions & 0 deletions src/pygambit/gambit.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,8 @@ cdef extern from "games/game.h":
void DeleteAction(c_GameAction) except +ValueError
void SetOutcome(c_GameNode, c_GameOutcome) except +
c_Game SetChanceProbs(c_GameInfoset, Array[c_Number]) except +
c_GameInfoset MakeChanceEvent(stdvector[c_GameNode], stdvector[c_Number],
string) except +ValueError

c_PureStrategyProfile NewPureStrategyProfile() # except + doesn't compile
c_MixedStrategyProfile[T] NewMixedStrategyProfile[T](T) # except + doesn't compile
Expand Down
Loading
Loading