Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
5 changes: 5 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## [16.5.0] - unreleased

### Added
- Implement `GameTreeRep::GetOwnPriorActions` (C++) and `Game.get_own_prior_actions` (Python)
to compute, for a given information set, the set of last actions taken by the player acting
in the information set before reaching it

### Changed
- 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.
Expand Down
1 change: 1 addition & 0 deletions doc/pygambit.api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ Information about the game
Game.infosets
Game.nodes
Game.contingencies
Game.get_own_prior_actions

.. autosummary::
:toctree: api/
Expand Down
2 changes: 2 additions & 0 deletions src/games/game.h
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,8 @@ class GameRep : public std::enable_shared_from_this<GameRep> {
/// Returns the set of terminal nodes which are descendants of members of an action
virtual std::vector<GameNode> GetPlays(GameAction action) const { throw UndefinedException(); }

/// Returns, for a given infoset, the set of the most recent action(s) of the player active in it
virtual std::vector<GameAction> GetOwnPriorActions(GameInfoset infoset) const = 0;
/// Returns true if the game is perfect recall
virtual bool IsPerfectRecall() const = 0;
//@}
Expand Down
4 changes: 4 additions & 0 deletions src/games/gameagg.h
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ class GameAGGRep : public GameRep {
//@{
bool IsTree() const override { return false; }
bool IsAgg() const override { return true; }
std::vector<GameAction> GetOwnPriorActions(GameInfoset infoset) const override
{
throw UndefinedException();
}
bool IsPerfectRecall() const override { return true; }
bool IsConstSum() const override;
/// Returns the smallest payoff to any player in any outcome of the game
Expand Down
4 changes: 4 additions & 0 deletions src/games/gamebagg.h
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ class GameBAGGRep : public GameRep {
//@{
bool IsTree() const override { return false; }
virtual bool IsBagg() const { return true; }
std::vector<GameAction> GetOwnPriorActions(GameInfoset infoset) const override
{
throw UndefinedException();
}
bool IsPerfectRecall() const override { return true; }
bool IsConstSum() const override { throw UndefinedException(); }
/// Returns the smallest payoff to any player in any outcome of the game
Expand Down
5 changes: 5 additions & 0 deletions src/games/gametable.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ class GameTableRep : public GameExplicitRep {
//@{
bool IsTree() const override { return false; }
bool IsConstSum() const override;
std::vector<GameAction> GetOwnPriorActions(GameInfoset infoset) const override
{
throw UndefinedException();
}

bool IsPerfectRecall() const override { return true; }
//@}

Expand Down
21 changes: 21 additions & 0 deletions src/games/gametree.cc
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,27 @@ bool GameTreeRep::IsConstSum() const
}
}

std::vector<GameAction> GameTreeRep::GetOwnPriorActions(GameInfoset infoset) const
{
if (m_infosetParents.empty() && !m_root->IsTerminal()) {
const_cast<GameTreeRep *>(this)->BuildInfosetParents();
}

auto it = m_infosetParents.find(infoset.get());
if (it == m_infosetParents.end()) {
throw UndefinedException("Cannot get prior actions for an unreached information set.");
}

std::vector<GameAction> own_prior_actions;
for (auto action : it->second) {
if (action) {
own_prior_actions.emplace_back(action->shared_from_this());
}
}

return own_prior_actions;
}

bool GameTreeRep::IsPerfectRecall() const
{
if (m_infosetParents.empty() && !m_root->IsTerminal()) {
Expand Down
1 change: 1 addition & 0 deletions src/games/gametree.h
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class GameTreeRep : public GameExplicitRep {
//@{
bool IsTree() const override { return true; }
bool IsConstSum() const override;
std::vector<GameAction> GetOwnPriorActions(GameInfoset infoset) const override;
bool IsPerfectRecall() const override;
//@}

Expand Down
1 change: 1 addition & 0 deletions src/pygambit/gambit.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ cdef extern from "games/game.h":
stdvector[c_GameNode] GetPlays(c_GameNode) except +
stdvector[c_GameNode] GetPlays(c_GameInfoset) except +
stdvector[c_GameNode] GetPlays(c_GameAction) except +
stdvector[c_GameAction] GetOwnPriorActions(c_GameInfoset) except +
bool IsPerfectRecall() except +

c_GameInfoset AppendMove(c_GameNode, c_GamePlayer, int) except +ValueError
Expand Down
17 changes: 17 additions & 0 deletions src/pygambit/game.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,23 @@ class Game:
"""Whether the game is constant sum."""
return self.game.deref().IsConstSum()

def get_own_prior_actions(self, infoset: typing.Union[Infoset, str]):
""" For a given information set, find the most recent action(s)
of the player active in it which precede this information set.

Raises
------
RuntimeError
If the information set is not reachable from the root of the game.
"""
infoset = self._resolve_infoset(infoset, "get_own_prior_actions")

own_prior_actions = []
for action in self.game.deref().GetOwnPriorActions(cython.cast(Infoset, infoset).infoset):
own_prior_actions.append(Action.wrap(action))

return own_prior_actions

@property
def is_perfect_recall(self) -> bool:
"""Whether the game is perfect recall.
Expand Down
98 changes: 98 additions & 0 deletions tests/test_extensive.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,104 @@ def test_game_add_players_nolabel():
game.add_player()


@pytest.mark.parametrize(
"game_file, infoset_specification, expected_actions",
[
# ======================================================================
# Game 1: binary_3_levels_generic_payoffs.efg (Perfect Recall)
# ======================================================================
# Case: Player 1's root infoset. No prior actions.
("binary_3_levels_generic_payoffs.efg", ("Player 1", 0), []),
# Case: Player 1's second infoset (L branch). Reached after P1's own
# action 'Left' (0) from their root infoset.
(
"binary_3_levels_generic_payoffs.efg",
("Player 1", 1),
[("Player 1", 0, 0)],
),
# Case: Player 1's third infoset (R branch). Reached after P1's own
# action 'Right' (1) from their root infoset.
(
"binary_3_levels_generic_payoffs.efg",
("Player 1", 2),
[("Player 1", 0, 1)],
),
# Case: Player 2's only infoset. Reached after P1's actions, so it has
# no *own* prior actions for Player 2.
("binary_3_levels_generic_payoffs.efg", ("Player 2", 0), []),
# ======================================================================
# GAME 2: wichardt.efg (Imperfect Recall, all infosets reachable)
# ======================================================================
# Case: The root infoset for Player 1. Should have no prior actions.
("wichardt.efg", ("Player 1", 0), []),
# Case: Player 1's second infoset. This is reached after P1's action 'L'
# from infoset 0, or P1's action 'R' from infoset 0. A key test case.
(
"wichardt.efg",
("Player 1", 1),
[("Player 1", 0, 0), ("Player 1", 0, 1)],
),
# Case: Player 2's only infoset. Has no *own* prior actions.
("wichardt.efg", ("Player 2", 0), []),
# ======================================================================
# GAME 3: noPR-action-AM-two-hops.efg (Absent-Mindedness, one unreached)
# ======================================================================
# Case: Player 1's infoset 0. Reached via its own action '1' and
# P1's action '2' from infoset 1.
(
"noPR-action-AM-two-hops.efg",
("Player 1", 0),
[("Player 1", 0, 0), ("Player 1", 1, 1)],
),
# Case: Player 1's infoset 1. Reached via P1's action '1' from infoset 0.
("noPR-action-AM-two-hops.efg", ("Player 1", 1), [("Player 1", 0, 0)]),
# Case: Player 2's infoset 0. Reached via P2's own action '1'.
("noPR-action-AM-two-hops.efg", ("Player 2", 0), [("Player 2", 0, 0)]),
],
)
def test_get_own_prior_actions_return_values(
game_file: str,
infoset_specification: tuple[str, int],
expected_actions: list[tuple[str, int, str]],
):
"""
Verifies the get_own_prior_actions method returns correct actions for
a variety of reachable information sets across different games.
"""
game = games.read_from_file(game_file)
player_label, infoset_num = infoset_specification

player = game.players[player_label]
infoset = player.infosets[infoset_num]

result_actions = game.get_own_prior_actions(infoset)

results = [
(
action.infoset.player.label,
action.infoset.number,
action.number,
)
for action in result_actions
]

assert set(results) == set(expected_actions)


def test_get_own_prior_actions_on_unreached_infoset_raises_error():
"""
Verifies that calling get_own_prior_actions on an infoset that is
unreachable from the game's root correctly raises a RuntimeError.
"""
game = games.read_from_file("noPR-action-AM-two-hops.efg")
unreached_infoset = game.players["Player 2"].infosets[1]

with pytest.raises(
RuntimeError, match="Cannot get prior actions for an unreached information set."
):
game.get_own_prior_actions(unreached_infoset)


@pytest.mark.parametrize("game_input,expected_result", [
# Games with perfect recall from files (game_input is a string)
("e01.efg", True),
Expand Down
Loading