From b31be340dc4196a1c7d6426b9950977eb38cc2b9 Mon Sep 17 00:00:00 2001 From: Ted Turocy Date: Tue, 28 Jan 2025 16:03:43 +0000 Subject: [PATCH] Expose access to action map for a strategy This provides an interface to the mapping from strategy to actions in an extensive game: * `GameStrategyRep::GetAction(GameInfoset)` in C++ * `Strategy.action(Infoset)` in Python` --- ChangeLog | 6 ++- doc/pygambit.api.rst | 1 + src/games/game.cc | 13 +++++ src/games/game.h | 3 ++ src/pygambit/gambit.pxd | 2 + src/pygambit/strategy.pxi | 43 ++++++++++++++++ tests/test_actions.py | 100 +++++++++++++++++++++++++++++++++++++- 7 files changed, 164 insertions(+), 4 deletions(-) diff --git a/ChangeLog b/ChangeLog index f49d13a9f..f7bf21387 100644 --- a/ChangeLog +++ b/ChangeLog @@ -10,8 +10,10 @@ been removed as planned. (#357) ### Added -- Implement `GetPlays()` (C++) and `get_plays` (Python) to compute the set of terminal nodes consistent - with a node, information set, or action (#517) +- Implement `GetPlays()` (C++) and `get_plays` (Python) to compute the set of terminal nodes + consistent with a node, information set, or action (#517) +- Implement `GameStrategyRep::GetAction` (C++) and `Strategy.action` (Python) retrieving the action + prescribed by a strategy at an information set ## [16.3.1] - unreleased diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 50052a873..0b9de26e9 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -178,6 +178,7 @@ Information about the game Strategy.game Strategy.player Strategy.number + Strategy.action Player behavior diff --git a/src/games/game.cc b/src/games/game.cc index c25130bb1..8a2f9be0a 100644 --- a/src/games/game.cc +++ b/src/games/game.cc @@ -46,6 +46,19 @@ GameOutcomeRep::GameOutcomeRep(GameRep *p_game, int p_number) : m_game(p_game), } } +//======================================================================== +// class GameStrategyRep +//======================================================================== + +GameAction GameStrategyRep::GetAction(const GameInfoset &p_infoset) const +{ + if (p_infoset->GetPlayer() != m_player) { + throw MismatchException(); + } + const int action = m_behav[p_infoset->GetNumber()]; + return (action) ? *std::next(p_infoset->GetActions().cbegin(), action - 1) : nullptr; +} + //======================================================================== // class GamePlayerRep //======================================================================== diff --git a/src/games/game.h b/src/games/game.h index cd17b2789..7dd1d680c 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -358,6 +358,9 @@ class GameStrategyRep : public GameObject { GamePlayer GetPlayer() const; /// Returns the index of the strategy for its player int GetNumber() const { return m_number; } + + /// Returns the action specified by the strategy at the information set + GameAction GetAction(const GameInfoset &) const; //@} }; diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 4f518f3ad..55e573680 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -73,6 +73,7 @@ cdef extern from "games/game.h": c_GameNodeRep *deref "operator->"() except +RuntimeError cdef cppclass c_GameAction "GameObjectPtr": + bool operator !() except + bool operator !=(c_GameAction) except + c_GameActionRep *deref "operator->"() except +RuntimeError @@ -97,6 +98,7 @@ cdef extern from "games/game.h": c_GamePlayer GetPlayer() except + string GetLabel() except + void SetLabel(string) except + + c_GameAction GetAction(c_GameInfoset) except + cdef cppclass c_GameActionRep "GameActionRep": int GetNumber() except + diff --git a/src/pygambit/strategy.pxi b/src/pygambit/strategy.pxi index 2cef0aaaa..7e0611572 100644 --- a/src/pygambit/strategy.pxi +++ b/src/pygambit/strategy.pxi @@ -73,3 +73,46 @@ class Strategy: def number(self) -> int: """The number of the strategy.""" return self.strategy.deref().GetNumber() - 1 + + def action(self, infoset: typing.Union[Infoset, str]) -> typing.Optional[Action]: + """Get the action prescribed by a strategy for a given information set. + + .. versionadded:: 16.4.0 + + Parameters + ---------- + infoset + The information set for which to find the prescribed action. + Can be an Infoset object or its string label. + + Returns + ------- + Action or None + The prescribed action or None if the strategy is not defined for this + information set, that is, the information set is unreachable under this strategy. + + Raises + ------ + UndefinedOperationError + If the game is not an extensive-form (tree) game. + ValueError + If the information set belongs to a different player than the strategy. + """ + if not self.game.is_tree: + raise UndefinedOperationError( + "Strategy.action is only defined for strategies in extensive-form games." + ) + + resolved_infoset: Infoset = self.game._resolve_infoset(infoset, "Strategy.action") + + if resolved_infoset.player != self.player: + raise ValueError( + f"Information set {resolved_infoset} belongs to player " + f"'{resolved_infoset.player.label}', but this strategy " + f"belongs to player '{self.player.label}'." + ) + + action: c_GameAction = self.strategy.deref().GetAction(resolved_infoset.infoset) + if not action: + return None + return Action.wrap(action) diff --git a/tests/test_actions.py b/tests/test_actions.py index 9b46e7d3d..971a65cbb 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -139,7 +139,103 @@ def test_action_plays(): test_action = list_infosets[2].actions[0] # members' paths=[0, 1, 0], [0, 1] expected_set_of_plays = { - list_nodes[4], list_nodes[7] - } # paths=[0, 1, 0], [0, 1] + list_nodes[4], list_nodes[7] # paths=[0, 1, 0], [0, 1] + } assert set(test_action.plays) == expected_set_of_plays + + +@pytest.mark.parametrize( + "game, player_ind, str_ind, infoset_ind, expected_action_ind", + [ + (games.read_from_file("e01.efg"), 0, 0, 0, 0), + (games.read_from_file("e01.efg"), 0, 1, 0, 1), + (games.read_from_file("e01.efg"), 1, 0, 1, 0), + (games.read_from_file("e01.efg"), 1, 1, 1, 1), + (games.read_from_file("e01.efg"), 2, 0, 2, 0), + (games.read_from_file("e01.efg"), 2, 1, 2, 1), + (games.read_from_file("e02.efg"), 0, 0, 0, 0), + (games.read_from_file("e02.efg"), 0, 1, 0, 1), + (games.read_from_file("e02.efg"), 1, 0, 2, 0), + (games.read_from_file("e02.efg"), 1, 1, 2, 1), + (games.read_from_file("basic_extensive_game.efg"), 0, 0, 0, 0), + (games.read_from_file("basic_extensive_game.efg"), 0, 1, 0, 1), + (games.read_from_file("basic_extensive_game.efg"), 1, 0, 1, 0), + (games.read_from_file("basic_extensive_game.efg"), 1, 1, 1, 1), + (games.read_from_file("basic_extensive_game.efg"), 2, 0, 2, 0), + (games.read_from_file("basic_extensive_game.efg"), 2, 1, 2, 1), + ], +) +def test_strategy_action_defined(game, player_ind, str_ind, infoset_ind, expected_action_ind): + """Verify `Strategy.action` retrieves the correct action for defined actions. + """ + player = game.players[player_ind] + strategy = player.strategies[str_ind] + infoset = game.infosets[infoset_ind] + expected_action = infoset.actions[expected_action_ind] + + prescribed_action = strategy.action(infoset) + + assert prescribed_action == expected_action + + +@pytest.mark.parametrize( + "game, player_ind, str_ind, infoset_ind", + [ + (games.read_from_file("e02.efg"), 0, 0, 1), + (games.read_from_file("cent3.efg"), 0, 0, 1), + (games.read_from_file("cent3.efg"), 0, 0, 2), + (games.read_from_file("cent3.efg"), 0, 1, 2), + (games.read_from_file("cent3.efg"), 1, 0, 7), + (games.read_from_file("cent3.efg"), 1, 0, 7), + (games.read_from_file("cent3.efg"), 1, 1, 8), + ], +) +def test_strategy_action_undefined_returns_none(game, player_ind, str_ind, infoset_ind): + """Verify `Strategy.action` returns None when called on an unreached player's infoset + """ + player = game.players[player_ind] + strategy = player.strategies[str_ind] + infoset = game.infosets[infoset_ind] + + prescribed_action = strategy.action(infoset) + + assert prescribed_action is None + + +@pytest.mark.parametrize( + "game, player_ind, infoset_ind", + [ + (games.read_from_file("e01.efg"), 0, 1), + (games.read_from_file("e01.efg"), 1, 0), + (games.read_from_file("e02.efg"), 0, 2), + (games.read_from_file("e02.efg"), 1, 0), + (games.read_from_file("basic_extensive_game.efg"), 0, 1), + (games.read_from_file("basic_extensive_game.efg"), 1, 2), + (games.read_from_file("basic_extensive_game.efg"), 2, 0), + ], +) +def test_strategy_action_raises_value_error_for_wrong_player(game, player_ind, infoset_ind): + """ + Verify `Strategy.action` raises ValueError when the infoset belongs + to a different player than the strategy. + """ + player = game.players[player_ind] + strategy = player.strategies[0] + other_players_infoset = game.infosets[infoset_ind] + + with pytest.raises(ValueError): + strategy.action(other_players_infoset) + + +def test_strategy_action_raises_error_for_strategic_game(): + """Verify `Strategy.action` retrieves the action prescribed by the strategy + """ + game_efg = games.read_from_file("e02.efg") + game_nfg = game_efg.from_arrays(game_efg.to_arrays()[0], game_efg.to_arrays()[1]) + alice = game_nfg.players[0] + strategy = alice.strategies[0] + test_infoset = game_efg.infosets[0] + + with pytest.raises(gbt.UndefinedOperationError): + strategy.action(test_infoset)