diff --git a/ChangeLog b/ChangeLog index 6bbb198eb..78befb046 100644 --- a/ChangeLog +++ b/ChangeLog @@ -10,7 +10,13 @@ of both methods (#1) - Fixed bug in gambit-lp which would return non-Nash output on extensive games if the game had chance nodes other than the root node (#134) +- In pygambit, fixed indexing in mixed behavior and mixed strategy profiles, which could result + in strategies or actions belonging to other players or information sets being referenced when + indexing by string label +### Changed +- In pygambit, resolving game objects with ambiguous or duplicated labels results in a ValueError, + instead of silently returning the first matching object found. ## [16.1.0] - 2023-11-09 diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index ca883c1d9..cdc3fd218 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -108,7 +108,9 @@ Information about the game Player.label Player.number Player.game + Player.strategies Player.infosets + Player.actions Player.is_chance Player.min_payoff Player.max_payoff diff --git a/doc/pygambit.user.rst b/doc/pygambit.user.rst index cde6d7a35..4383f4e18 100644 --- a/doc/pygambit.user.rst +++ b/doc/pygambit.user.rst @@ -165,7 +165,7 @@ by a collection of payoff tables, one per player. The most direct way to create a strategic game is via :py:meth:`.Game.from_arrays`. This function takes one n-dimensional array per player, where n is the number of players in the game. The arrays can be any object that can be indexed like an n-times-nested Python list; -so, for example, NumPy arrays can be used directly. +so, for example, `numpy` arrays can be used directly. For example, to create a standard prisoner's dilemma game in which the cooperative payoff is 8, the betrayal payoff is 10, the sucker payoff is 2, and the noncooperative @@ -349,25 +349,56 @@ the extensive representation. Assuming that ``g`` refers to the game len(eqa) The result of the calculation is a list of :py:class:`~pygambit.gambit.MixedBehaviorProfile`. -A mixed behavior profile specifies, for each information set, the probability distribution over -actions at that information set. +A mixed behavior profile is a ``dict``-like object which specifies, for each information set, +the probability distribution over actions at that information set, conditional on the +information set being reached. Indexing a :py:class:`.MixedBehaviorProfile` by a player gives the probability distributions over each of that player's information sets: - .. ipython:: python eqa[0]["Alice"] In this case, at Alice's first information set, the one at which she has the King, she always raises. At her second information set, where she has the Queen, she sometimes bluffs, raising with -probability one-third. Looking at Bob's strategy, +probability one-third: + +.. ipython:: python + + [eqa[0]["Alice"][infoset]["Raise"] for infoset in g.players["Alice"].infosets] + +In larger games, labels may not always be the most convenient way to refer to specific +actions. We can also index profiles directly with :py:class:`.Action` objects. +So an alternative way to extract the probabilities of playing "Raise" would be by +iterating Alice's list of actions: + +.. ipython:: python + + [eqa[0][action] for action in g.players["Alice"].actions if action.label == "Raise"] + + +Looking at Bob's strategy, .. ipython:: python eqa[0]["Bob"] -Bob meets Alice's raise two-thirds of the time. +Bob meets Alice's raise two-thirds of the time. The label "Raise" is used in more than one +information set for Alice, so in the above we had to specify information sets when indexing. +When there is no ambiguity, we can specify action labels directly. So for example, because +Bob has only one action named "Meet" in the game, we can extract the probability that Bob plays +"Meet" by: + +.. ipython:: python + + eqa[0]["Bob"]["Meet"] + +Moreover, this is the only action with that label in the game, so we can index the +profile directly using the action label without any ambiguity: + +.. ipython:: python + + eqa[0]["Meet"] Because this is an equilibrium, the fact that Bob randomizes at his information set must mean he is indifferent between the two actions at his information set. :py:meth:`.MixedBehaviorProfile.action_value` diff --git a/src/pygambit/behav.pxi b/src/pygambit/behav.pxi index 4b63f9e01..79af4cf72 100644 --- a/src/pygambit/behav.pxi +++ b/src/pygambit/behav.pxi @@ -53,14 +53,30 @@ class MixedAgentStrategy: return len(self.infoset.actions) def __getitem__(self, action: typing.Union[Action, str]): - if isinstance(action, Action) and action.infoset != self.infoset: - raise MismatchError("action must belong to this infoset") - return self.profile[action] + if isinstance(action, Action): + if action.infoset != self.infoset: + raise MismatchError("action must belong to this infoset") + return self.profile._getprob_action(action) + if isinstance(action, str): + try: + return self.profile._getprob_action(self.infoset.actions[action]) + except KeyError: + raise KeyError(f"no action with label '{index}' at infoset") from None + raise TypeError(f"strategy index must be Action or str, not {index.__class__.__name__}") def __setitem__(self, action: typing.Union[Action, str], value: typing.Any) -> None: - if isinstance(action, Action) and action.infoset != self.infoset: - raise MismatchError("action must belong to this infoset") - self.profile[action] = value + if isinstance(action, Action): + if action.infoset != self.infoset: + raise MismatchError("action must belong to this infoset") + self.profile._setprob_action(action, value) + return + if isinstance(action, str): + try: + self.profile._setprob_action(self.infoset.actions[action], value) + return + except KeyError: + raise KeyError(f"no action with label '{index}' at infoset") from None + raise TypeError(f"strategy index must be Action or str, not {index.__class__.__name__}") class MixedBehaviorStrategy: @@ -92,20 +108,74 @@ class MixedBehaviorStrategy: def __len__(self) -> int: return len(self.player.infosets) - def __getitem__(self, infoset: typing.Union[Infoset, str]): - if isinstance(infoset, Infoset) and infoset.player != self.player: - raise MismatchError("infoset must belong to this player") - return self.profile[infoset] + def __getitem__(self, index: typing.Union[Infoset, Action, str]): + if isinstance(index, Infoset): + if index.player != self.player: + raise MismatchError("infoset must belong to this player") + return self.profile[index] + if isinstance(index, Action): + if index.player != self.player: + raise MismatchError("action must belong to this player") + return self.profile[index] + if isinstance(index, str): + try: + return self.profile[self.player.infosets[index]] + except KeyError: + pass + try: + return self.profile[self.player.actions[index]] + except KeyError: + raise KeyError(f"no infoset or action with label '{index}' for player") from None + raise TypeError(f"strategy index must be Infoset, Action or str, not {index.__class__.__name__}") - def __setitem__(self, infoset: typing.Union[Infoset, str], value: typing.Any) -> None: - if isinstance(infoset, Infoset) and infoset.player != self.player: - raise MismatchError("infoset must belong to this player") - self.profile[infoset] = value + def __setitem__(self, index: typing.Union[Infoset, Action, str], value: typing.Any) -> None: + if isinstance(index, Infoset): + if index.player != self.player: + raise MismatchError("infoset must belong to this player") + self.profile[index] = value + return + if isinstance(index, Action): + if index.player != self.player: + raise MismatchError("action must belong to this player") + self.profile[index] = value + return + if isinstance(index, str): + try: + self.profile[self.player.infosets[index]] = value + return + except KeyError: + pass + try: + self.profile[self.player.actions[index]] = value + except KeyError: + raise KeyError(f"no infoset or action with label '{index}' for player") from None + return + raise TypeError(f"strategy index must be Infoset, Action or str, not {index.__class__.__name__}") @cython.cclass class MixedBehaviorProfile: - """A behavior strategy profile over the actions in a game.""" + """Represents a mixed behavior profile over the actions in a ``Game``. + + A mixed behavior profile is a dict-like object, mapping each action at each information + set in a game to the corresponding probability with which the action is played, conditional + on that information set being reached. + + Mixed behavior profiles may represent probabilities as either exact (rational) + numbers, or floating-point numbers. These may not be combined in the same mixed + behavior profile. + + .. versionchanged:: 16.1.0 + Profiles are accessed as dict-like objects; indexing by integer player, infoset, or + action indices is no longer supported. + + See Also + -------- + Game.mixed_behavior_profile + Creates a new mixed behavior profile on a game. + MixedStrategyProfile + Represents a mixed strategy profile over a ``Game``. + """ def __repr__(self) -> str: return str([self[player] for player in self.game.players]) @@ -118,7 +188,7 @@ class MixedBehaviorProfile: @property def game(self) -> Game: - """The game on which this mixed behaviour profile is defined.""" + """The game on which this mixed behavior profile is defined.""" return self._game def __getitem__(self, index: typing.Union[Player, Infoset, Action, str]): @@ -163,7 +233,10 @@ class MixedBehaviorProfile: return MixedAgentStrategy(self, self.game._resolve_infoset(index, '__getitem__')) except KeyError: pass - return self._getprob_action(self.game._resolve_action(index, '__getitem__')) + try: + return self._getprob_action(self.game._resolve_action(index, '__getitem__')) + except KeyError: + raise KeyError(f"no player, infoset, or action with label '{index}'") raise TypeError( f"profile index must be Player, Infoset, Action, or str, not {index.__class__.__name__}" ) @@ -229,7 +302,10 @@ class MixedBehaviorProfile: return except KeyError: pass - self._setprob_action(self.game._resolve_action(index, '__getitem__'), value) + try: + self._setprob_action(self.game._resolve_action(index, '__getitem__'), value) + except KeyError: + raise KeyError(f"no player, infoset, or action with label '{index}'") return raise TypeError( f"profile index must be Player, Infoset, Action, or str, not {index.__class__.__name__}" diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index e84b25767..2be4c3d9b 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -76,22 +76,6 @@ def _to_number(value: typing.Any) -> c_Number: return c_Number(value.encode('ascii')) -@cython.cclass -class Collection: - """Represents a collection of related objects in a game.""" - def __repr__(self): - return str(list(self)) - - def __getitem__(self, i): - if isinstance(i, str): - try: - return self[[x.label for x in self].index(i)] - except ValueError: - raise IndexError(f"no object with label '{i}'") - else: - raise TypeError(f"collection indexes must be int or str, not {i.__class__.__name__}") - - ###################### # Includes ###################### diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index c53ebf8c8..ac20bd6a6 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -29,20 +29,35 @@ import pygambit.gte import pygambit.gameiter @cython.cclass -class Outcomes(Collection): - """Represents a collection of outcomes in a game.""" +class GameOutcomes: + """Represents the set of outcomes in a game.""" game = cython.declare(c_Game) - def __len__(self): + def __len__(self) -> int: """The number of outcomes in the game.""" return self.game.deref().NumOutcomes() - def __getitem__(self, outc): - if not isinstance(outc, int): - return Collection.__getitem__(self, outc) - c = Outcome() - c.outcome = self.game.deref().GetOutcome(outc+1) - return c + def __iter__(self) -> typing.Iterator[Outcome]: + for i in range(self.game.deref().NumOutcomes()): + c = Outcome() + c.outcome = self.game.deref().GetOutcome(i + 1) + yield c + + def __getitem__(self, index: typing.Union[int, str]) -> Outcome: + if isinstance(index, str): + if not index.strip(): + raise ValueError("Outcome label cannot be empty or all whitespace") + matches = [x for x in self if x.label == index.strip()] + if not matches: + raise KeyError(f"Game has no outcome with label '{index}'") + if len(matches) > 1: + raise ValueError(f"Game has multiple outcomes with label '{index}'") + return matches[0] + if isinstance(index, int): + c = Outcome() + c.outcome = self.game.deref().GetOutcome(index + 1) + return c + raise TypeError(f"Outcome index must be int or str, not {index.__class__.__name__}") @deprecated(version='16.1.0', reason='Use Game.add_outcome() instead of Game.outcomes.add()', @@ -61,20 +76,35 @@ class Outcomes(Collection): @cython.cclass -class Players(Collection): +class GamePlayers: """Represents a collection of players in a game.""" game = cython.declare(c_Game) - def __len__(self): + def __len__(self) -> int: """Returns the number of players in the game.""" return self.game.deref().NumPlayers() - def __getitem__(self, pl): - if not isinstance(pl, int): - return Collection.__getitem__(self, pl) - p = Player() - p.player = self.game.deref().GetPlayer(pl+1) - return p + def __iter__(self) -> typing.Iterator[Player]: + for i in range(self.game.deref().NumPlayers()): + p = Player() + p.player = self.game.deref().GetPlayer(i + 1) + yield p + + def __getitem__(self, index: typing.Union[int, str]) -> Player: + if isinstance(index, str): + if not index.strip(): + raise ValueError("Player label cannot be empty or all whitespace") + matches = [x for x in self if x.label == index.strip()] + if not matches: + raise KeyError(f"Game has no player with label '{index}'") + if len(matches) > 1: + raise ValueError(f"Game has multiple players with label '{index}'") + return matches[0] + if isinstance(index, int): + p = Player() + p.player = self.game.deref().GetPlayer(index + 1) + return p + raise TypeError(f"Player index must be int or str, not {index.__class__.__name__}") @deprecated(version='16.1.0', reason='Use Game.add_player() instead of Game.players.add()', @@ -96,56 +126,105 @@ class Players(Collection): @cython.cclass -class GameActions(Collection): - """Represents a collection of actions in a game.""" - game = cython.declare(c_Game) - - def __len__(self): - return self.game.deref().BehavProfileLength() - - def __getitem__(self, action): - if not isinstance(action, int): - return Collection.__getitem__(self, action) - a = Action() - a.action = self.game.deref().GetAction(action+1) - return a +class GameActions: + """Represents the set of all actions in a game.""" + game = cython.declare(Game) + + def __init__(self, game: Game) -> None: + self.game = game + + def __len__(self) -> int: + return sum(len(s.actions) for s in self.game.infosets) + + def __iter__(self) -> typing.Iterator[Action]: + for infoset in self.game.infosets: + yield from infoset.actions + + def __getitem__(self, index: typing.Union[int, str]) -> Action: + if isinstance(index, str): + if not index.strip(): + raise ValueError("Action label cannot be empty or all whitespace") + matches = [x for x in self if x.label == index.strip()] + if not matches: + raise KeyError(f"Game has no action with label '{index}'") + if len(matches) > 1: + raise ValueError(f"Game has multiple actions with label '{index}'") + return matches[0] + if isinstance(index, int): + for i, action in enumerate(self): + if i == index: + return action + else: + raise IndexError("Index out of range") + raise TypeError(f"Action index must be int or str, not {index.__class__.__name__}") @cython.cclass -class GameInfosets(Collection): - """Represents a collection of infosets in a game.""" - game = cython.declare(c_Game) - - def __len__(self): - num_infosets = self.game.deref().NumInfosets() - size = num_infosets.Length() - n = 0 - for i in range(1, size+1): - n += num_infosets.getitem(i) - return n - - def __getitem__(self, infoset): - if not isinstance(infoset, int): - return Collection.__getitem__(self, infoset) - i = Infoset() - i.infoset = self.game.deref().GetInfoset(infoset+1) - return i +class GameInfosets: + """Represents the set of all infosets in a game.""" + game = cython.declare(Game) + + def __init__(self, game: Game) -> None: + self.game = game + + def __len__(self) -> int: + return sum(len(p.infosets) for p in self.game.players) + + def __iter__(self) -> typing.Iterator[Infoset]: + for player in self.game.players: + yield from player.infosets + + def __getitem__(self, index: typing.Union[int, str]) -> Infoset: + if isinstance(index, str): + if not index.strip(): + raise ValueError("Infoset label cannot be empty or all whitespace") + matches = [x for x in self if x.label == index.strip()] + if not matches: + raise KeyError(f"Game has no infoset with label '{index}'") + if len(matches) > 1: + raise ValueError(f"Game has multiple infosets with label '{index}'") + return matches[0] + if isinstance(index, int): + for i, infoset in enumerate(self): + if i == index: + return infoset + else: + raise IndexError("Index out of range") + raise TypeError(f"Infoset index must be int or str, not {index.__class__.__name__}") @cython.cclass -class GameStrategies(Collection): - """Represents a collection of strategies in a game.""" - game = cython.declare(c_Game) - - def __len__(self): - return self.game.deref().MixedProfileLength() - - def __getitem__(self, st): - if not isinstance(st, int): - return Collection.__getitem__(self, st) - s = Strategy() - s.strategy = self.game.deref().GetStrategy(st+1) - return s +class GameStrategies: + """Represents the set of all strategies in the game.""" + game = cython.declare(Game) + + def __init__(self, game: Game) -> None: + self.game = game + + def __len__(self) -> int: + return sum(len(p.strategies) for p in self.game.players) + + def __iter__(self) -> typing.Iterator[Strategy]: + for player in self.game.players: + yield from player.strategies + + def __getitem__(self, index: typing.Union[int, str]) -> Strategy: + if isinstance(index, str): + if not index.strip(): + raise ValueError("Strategy label cannot be empty or all whitespace") + matches = [x for x in self if x.label == index.strip()] + if not matches: + raise KeyError(f"Game has no strategy with label '{index}'") + if len(matches) > 1: + raise ValueError(f"Game has multiple strategies with label '{index}'") + return matches[0] + if isinstance(index, int): + for i, strat in enumerate(self): + if i == index: + return strat + else: + raise IndexError("Index out of range") + raise TypeError(f"Strategy index must be int or str, not {index.__class__.__name__}") @cython.cclass @@ -431,9 +510,7 @@ class Game: """ if not self.is_tree: raise UndefinedOperationError("Operation only defined for games with a tree representation") - a = GameActions() - a.game = self.game - return a + return GameActions(self) @property def infosets(self) -> GameInfosets: @@ -446,28 +523,24 @@ class Game: """ if not self.is_tree: raise UndefinedOperationError("Operation only defined for games with a tree representation") - i = GameInfosets() - i.game = self.game - return i + return GameInfosets(self) @property - def players(self) -> Players: + def players(self) -> GamePlayers: """The set of players in the game.""" - p = Players() + p = GamePlayers() p.game = self.game return p @property def strategies(self) -> GameStrategies: """The set of strategies in the game.""" - s = GameStrategies() - s.game = self.game - return s + return GameStrategies(self) @property - def outcomes(self) -> Outcomes: + def outcomes(self) -> GameOutcomes: """The set of outcomes in the game.""" - c = Outcomes() + c = GameOutcomes() c.game = self.game return c @@ -807,7 +880,7 @@ class Game: raise ValueError(f"{funcname}(): {argname} cannot be an empty string or all spaces") try: return self.players[player] - except IndexError: + except KeyError: raise KeyError(f"{funcname}(): no player with label '{player}'") raise TypeError(f"{funcname}(): {argname} must be Player or str, not {player.__class__.__name__}") @@ -843,7 +916,7 @@ class Game: raise ValueError(f"{funcname}(): {argname} cannot be an empty string or all spaces") try: return self.outcomes[outcome] - except IndexError: + except KeyError: raise KeyError(f"{funcname}(): no node with label '{outcome}'") raise TypeError(f"{funcname}(): {argname} must be Outcome or str, not {outcome.__class__.__name__}") @@ -879,7 +952,7 @@ class Game: raise ValueError(f"{funcname}(): {argname} cannot be an empty string or all spaces") try: return self.strategies[strategy] - except IndexError: + except KeyError: raise KeyError(f"{funcname}(): no strategy with label '{strategy}'") raise TypeError(f"{funcname}(): {argname} must be Strategy or str, not {strategy.__class__.__name__}") @@ -951,9 +1024,9 @@ class Game: raise ValueError(f"{funcname}(): {argname} cannot be an empty string or all spaces") try: return self.infosets[infoset] - except IndexError: + except KeyError: raise KeyError(f"{funcname}(): no information set with label '{infoset}'") - raise TypeError(f"{funcname}(): {argname} must be Infoset or str, not {infoset.__class__.__name__}") + raise TypeError(f"{funcname}(): {argname} must be Infoset or str, not {node.__class__.__name__}") def _resolve_action(self, action: typing.Any, funcname: str, argname: str = "action") -> Action: """Resolve an attempt to reference an action of the game. @@ -987,7 +1060,7 @@ class Game: raise ValueError(f"{funcname}(): {argname} cannot be an empty string or all spaces") try: return self.actions[action] - except IndexError: + except KeyError: raise KeyError(f"{funcname}(): no action with label '{action}'") raise TypeError(f"{funcname}(): {argname} must be Action or str, not {action.__class__.__name__}") diff --git a/src/pygambit/infoset.pxi b/src/pygambit/infoset.pxi index 3c761ddca..9950664e4 100644 --- a/src/pygambit/infoset.pxi +++ b/src/pygambit/infoset.pxi @@ -22,26 +22,41 @@ from deprecated import deprecated @cython.cclass -class Members(Collection): +class InfosetMembers: """The set of nodes which are members of an information set.""" infoset = cython.declare(c_GameInfoset) def __init__(self, infoset: Infoset): self.infoset = infoset.infoset - def __len__(self): + def __len__(self) -> int: return self.infoset.deref().NumMembers() - def __getitem__(self, i): - if not isinstance(i, int): - return Collection.__getitem__(self, i) - n = Node() - n.node = self.infoset.deref().GetMember(i+1) - return n + def __iter__(self) -> typing.Iterator[Node]: + for i in range(self.infoset.deref().NumMembers()): + m = Node() + m.node = self.infoset.deref().GetMember(i + 1) + yield m + + def __getitem__(self, index: typing.Union[int, str]) -> Node: + if isinstance(index, str): + if not index.strip(): + raise ValueError("Node label cannot be empty or all whitespace") + matches = [x for x in self if x.label == index.strip()] + if not matches: + raise KeyError(f"Infoset has no member with label '{index}'") + if len(matches) > 1: + raise ValueError(f"Infoset has multiple members with label '{index}'") + return matches[0] + if isinstance(index, int): + m = Node() + m.node = self.infoset.deref().GetMember(index + 1) + return m + raise TypeError(f"Member index must be int or str, not {index.__class__.__name__}") @cython.cclass -class Actions(Collection): +class InfosetActions: """The set of actions which are available at an information set.""" infoset = cython.declare(c_GameInfoset) @@ -64,16 +79,31 @@ class Actions(Collection): else: raise TypeError("insert_action takes an Action object as its input") - def __len__(self): + def __len__(self) -> int: """The number of actions at the information set.""" return self.infoset.deref().NumActions() - def __getitem__(self, act): - if not isinstance(act, int): - return Collection.__getitem__(self, act) - a = Action() - a.action = self.infoset.deref().GetAction(act+1) - return a + def __iter__(self) -> typing.Iterator[Action]: + for i in range(self.infoset.deref().NumActions()): + a = Action() + a.action = self.infoset.deref().GetAction(i + 1) + yield a + + def __getitem__(self, index: typing.Union[int, str]) -> Action: + if isinstance(index, str): + if not index.strip(): + raise ValueError("Action label cannot be empty or all whitespace") + matches = [x for x in self if x.label == index.strip()] + if not matches: + raise KeyError(f"Infoset has no action with label '{index}'") + if len(matches) > 1: + raise ValueError(f"Infoset has multiple actions with label '{index}'") + return matches[0] + if isinstance(index, int): + a = Action() + a.action = self.infoset.deref().GetAction(index + 1) + return a + raise TypeError(f"Action index must be int or str, not {index.__class__.__name__}") @cython.cclass @@ -157,16 +187,16 @@ class Infoset: return self.infoset.deref().IsChanceInfoset() @property - def actions(self) -> Actions: + def actions(self) -> InfosetActions: """The set of actions at the information set.""" - a = Actions() + a = InfosetActions() a.infoset = self.infoset return a @property - def members(self) -> Members: + def members(self) -> InfosetMembers: """The set of nodes which are members of the information set.""" - return Members(self) + return InfosetMembers(self) @property def player(self) -> Player: diff --git a/src/pygambit/mixed.pxi b/src/pygambit/mixed.pxi index 1a4bc3098..4e6a2dfc5 100644 --- a/src/pygambit/mixed.pxi +++ b/src/pygambit/mixed.pxi @@ -52,19 +52,53 @@ class MixedStrategy: return len(self.player.strategies) def __getitem__(self, strategy: typing.Union[Strategy, str]): - if isinstance(strategy, Strategy) and strategy.player != self.player: - raise MismatchError("strategy must belong to this player") - return self.profile[strategy] + if isinstance(strategy, Strategy): + if strategy.player != self.player: + raise MismatchError("strategy must belong to this player") + return self.profile._getprob_strategy(strategy) + if isinstance(strategy, str): + try: + return self.profile._getprob_strategy(self.player.strategies[strategy]) + except KeyError: + raise KeyError(f"no strategy with label '{index}' for player") from None + raise TypeError(f"strategy index must be Strategy or str, not {index.__class__.__name__}") def __setitem__(self, strategy: typing.Union[Strategy, str], value: typing.Any) -> None: - if isinstance(strategy, Strategy) and strategy.player != self.player: - raise MismatchError("strategy must belong to this player") - self.profile[strategy] = value - + if isinstance(strategy, Strategy): + if strategy.player != self.player: + raise MismatchError("strategy must belong to this player") + self.profile._setprob_strategy(strategy, value) + return + if isinstance(strategy, str): + try: + self.profile._setprob_strategy(self.player.strategies[strategy], value) + return + except KeyError: + raise KeyError(f"no strategy with label '{index}' for player") from None + raise TypeError(f"strategy index must be Strategy or str, not {index.__class__.__name__}") @cython.cclass class MixedStrategyProfile: - """Represents a mixed strategy profile over the strategies in a Game. + """Represents a mixed strategy profile over the strategies in a ``Game``. + + A mixed strategy profile is a dict-like object, mapping each strategy in a game to + the corresponding probability with which that strategy is played. + + Mixed strategy profiles may represent probabilities as either exact (rational) + numbers, or floating-point numbers. These may not be combined in the same mixed + strategy profile. + + .. versionchanged:: 16.1.0 + Profiles are accessed as dict-like objects; indexing by integer player or strategy + indices is no longer supported. + + See Also + -------- + Game.mixed_strategy_profile + Creates a new mixed strategy profile on a game. + MixedBehaviorProfile + Represents a mixed behavior profile over a ``Game`` with an extensive + representation. """ def __repr__(self): return str([ self[player] for player in self.game.players ]) @@ -109,7 +143,10 @@ class MixedStrategyProfile: return MixedStrategy(self, self.game._resolve_player(index, '__getitem__')) except KeyError: pass - return self._getprob_strategy(self.game._resolve_strategy(index, '__getitem__')) + try: + return self._getprob_strategy(self.game._resolve_strategy(index, '__getitem__')) + except KeyError: + raise KeyError(f"no player or strategy with label '{index}'") raise TypeError(f"profile index must be Player, Strategy, or str, not {index.__class__.__name__}") def _setprob_player(self, player: Player, value: typing.Any) -> None: @@ -154,7 +191,10 @@ class MixedStrategyProfile: return except KeyError: pass - self._setprob_strategy(self.game._resolve_strategy(index, '__setitem__'), value) + try: + self._setprob_strategy(self.game._resolve_strategy(index, '__setitem__'), value) + except KeyError: + raise KeyError(f"no player or strategy with label '{index}'") return raise TypeError(f"profile index must be Player, Strategy, or str, not {index.__class__.__name__}") diff --git a/src/pygambit/node.pxi b/src/pygambit/node.pxi index 2ceedcdc5..6a5da2264 100644 --- a/src/pygambit/node.pxi +++ b/src/pygambit/node.pxi @@ -22,19 +22,34 @@ from deprecated import deprecated @cython.cclass -class Children(Collection): - """Represents the collection of direct children of a node.""" +class NodeChildren: + """The set of nodes which are direct descendants of a node.""" parent = cython.declare(c_GameNode) - def __len__(self): + def __len__(self) -> int: return self.parent.deref().NumChildren() - def __getitem__(self, i): - if not isinstance(i, int): - return Collection.__getitem__(self, i) - n = Node() - n.node = self.parent.deref().GetChild(i+1) - return n + def __iter__(self) -> typing.Iterator[Node]: + for i in range(self.parent.deref().NumChildren()): + c = Node() + c.node = self.parent.deref().GetChild(i + 1) + yield c + + def __getitem__(self, index: typing.Union[int, str]) -> Node: + if isinstance(index, str): + if not index.strip(): + raise ValueError("Node label cannot be empty or all whitespace") + matches = [x for x in self if x.label == index.strip()] + if not matches: + raise KeyError(f"Node has no child with label '{index}'") + if len(matches) > 1: + raise ValueError(f"Node has multiple children with label '{index}'") + return matches[0] + if isinstance(index, int): + c = Node() + c.node = self.parent.deref().GetChild(index + 1) + return c + raise TypeError(f"Child index must be int or str, not {index.__class__.__name__}") @cython.cclass @@ -199,9 +214,9 @@ class Node: self.node.deref().SetLabel(value.encode('ascii')) @property - def children(self) -> Children: + def children(self) -> NodeChildren: """The set of children of this node.""" - c = Children() + c = NodeChildren() c.parent = self.node return c diff --git a/src/pygambit/player.pxi b/src/pygambit/player.pxi index 7ca3065c9..dfb8ebc1c 100644 --- a/src/pygambit/player.pxi +++ b/src/pygambit/player.pxi @@ -22,24 +22,73 @@ from deprecated import deprecated @cython.cclass -class Infosets(Collection): +class PlayerInfosets: """The set of information sets at which a player has the decision.""" player = cython.declare(c_GamePlayer) - def __len__(self): + def __len__(self) -> int: """The number of information sets at which the player has the decision.""" return self.player.deref().NumInfosets() - def __getitem__(self, iset): - if not isinstance(iset, int): - return Collection.__getitem__(self, iset) - s = Infoset() - s.infoset = self.player.deref().GetInfoset(iset+1) - return s + def __iter__(self) -> typing.Iterator[Infoset]: + for i in range(self.player.deref().NumInfosets()): + s = Infoset() + s.infoset = self.player.deref().GetInfoset(i + 1) + yield s + + def __getitem__(self, index: typing.Union[int, str]) -> Infoset: + if isinstance(index, str): + if not index.strip(): + raise ValueError("Infoset label cannot be empty or all whitespace") + matches = [x for x in self if x.label == index.strip()] + if not matches: + raise KeyError(f"Player has no infoset with label '{index}'") + if len(matches) > 1: + raise ValueError(f"Player has multiple infosets with label '{index}'") + return matches[0] + if isinstance(index, int): + s = Infoset() + s.infoset = self.player.deref().GetInfoset(index + 1) + return s + raise TypeError(f"Infoset index must be int or str, not {index.__class__.__name__}") + + +@cython.cclass +class PlayerActions: + """Represents the set of all actions available to a player at some information set.""" + player = cython.declare(Player) + + def __init__(self, player: Player) -> None: + self.player = player + + def __len__(self) -> int: + return sum(len(s.actions) for s in self.player.actions) + + def __iter__(self) -> typing.Iterator[Action]: + for infoset in self.player.infosets: + yield from infoset.actions + + def __getitem__(self, index: typing.Union[int, str]) -> Action: + if isinstance(index, str): + if not index.strip(): + raise ValueError("Action label cannot be empty or all whitespace") + matches = [x for x in self if x.label == index.strip()] + if not matches: + raise KeyError(f"Player has no action with label '{index}'") + if len(matches) > 1: + raise ValueError(f"Player has multiple actions with label '{index}'") + return matches[0] + if isinstance(index, int): + for i, action in enumerate(self): + if i == index: + return action + else: + raise IndexError("Index out of range") + raise TypeError(f"Action index must be int or str, not {index.__class__.__name__}") @cython.cclass -class Strategies(Collection): +class PlayerStrategies: """The set of strategies available to a player.""" player = cython.declare(c_GamePlayer) @@ -67,16 +116,31 @@ class Strategies(Collection): s.label = str(label) return s - def __len__(self): + def __len__(self) -> int: """The number of strategies for the player in the game.""" return self.player.deref().NumStrategies() - def __getitem__(self, st): - if not isinstance(st, int): - return Collection.__getitem__(self, st) - s = Strategy() - s.strategy = self.player.deref().GetStrategy(st+1) - return s + def __iter__(self) -> typing.Iterator[Strategy]: + for i in range(self.player.deref().NumStrategies()): + s = Strategy() + s.strategy = self.player.deref().GetStrategy(i + 1) + yield s + + def __getitem__(self, index: typing.Union[int, str]) -> Strategy: + if isinstance(index, str): + if not index.strip(): + raise ValueError("Strategy label cannot be empty or all whitespace") + matches = [x for x in self if x.label == index.strip()] + if not matches: + raise KeyError(f"Player has no strategy with label '{index}'") + if len(matches) > 1: + raise ValueError(f"Player has multiple strategies with label '{index}'") + return matches[0] + if isinstance(index, int): + s = Strategy() + s.strategy = self.player.deref().GetStrategy(index + 1) + return s + raise TypeError(f"Strategy index must be int or str, not {index.__class__.__name__}") @cython.cclass @@ -131,19 +195,24 @@ class Player: return self.player.deref().IsChance() != 0 @property - def strategies(self) -> Strategies: + def strategies(self) -> PlayerStrategies: """Returns the set of strategies belonging to the player.""" - s = Strategies() + s = PlayerStrategies() s.player = self.player return s @property - def infosets(self) -> Infosets: + def infosets(self) -> PlayerInfosets: """Returns the set of information sets at which the player has the decision.""" - s = Infosets() + s = PlayerInfosets() s.player = self.player return s + @property + def actions(self) -> PlayerActions: + """Returns the set of actions available to the player at some information set.""" + return PlayerActions(self) + @property def min_payoff(self) -> Rational: """Returns the smallest payoff for the player in any outcome of the game.""" diff --git a/src/pygambit/stratspt.pxi b/src/pygambit/stratspt.pxi index 77fac511c..751728172 100644 --- a/src/pygambit/stratspt.pxi +++ b/src/pygambit/stratspt.pxi @@ -26,7 +26,7 @@ from libcpp.memory cimport unique_ptr from deprecated import deprecated @cython.cclass -class StrategySupportProfile(Collection): +class StrategySupportProfile: """A set-like object representing a subset of the strategies in game. A StrategySupportProfile always contains at least one strategy for each player in the game. @@ -92,7 +92,6 @@ class StrategySupportProfile(Collection): return deref(self.support).Contains(strategy.strategy) def __iter__(self) -> typing.Generator[Strategy, None, None]: - cdef Strategy s for pl in range(len(self.game.players)): for st in range(deref(self.support).NumStrategiesPlayer(pl+1)): s = Strategy() diff --git a/src/pygambit/tests/test_game_resolve_functions.py b/src/pygambit/tests/test_game_resolve_functions.py index 3d51dc312..9ec509502 100644 --- a/src/pygambit/tests/test_game_resolve_functions.py +++ b/src/pygambit/tests/test_game_resolve_functions.py @@ -66,8 +66,8 @@ def test_resolve_strategy_nonempty_strings(self): assert self.game1._resolve_strategy(strategy='11', funcname='test') assert self.game1._resolve_strategy(strategy='12', funcname='test') self.assertRaises(KeyError, self.game1._resolve_strategy, strategy='13', funcname='test') - assert self.game2._resolve_strategy(strategy='1', funcname='test') - assert self.game2._resolve_strategy(strategy='2', funcname='test') + self.assertRaises(ValueError, self.game2._resolve_strategy, strategy='1', funcname='test') + self.assertRaises(ValueError, self.game2._resolve_strategy, strategy='2', funcname='test') self.assertRaises(KeyError, self.game2._resolve_strategy, strategy='3', funcname='test') def test_resolve_node_empty_strings(self): @@ -107,8 +107,8 @@ def test_resolve_action_empty_strings(self): def test_resolve_action_nonempty_strings(self): """Test _resolve_action with non-empty strings, some that resolve some that don't""" - assert self.game1._resolve_action(action='1', funcname='test') - assert self.game1._resolve_action(action='2', funcname='test') + self.assertRaises(ValueError, self.game1._resolve_action, action='1', funcname='test') + self.assertRaises(ValueError, self.game1._resolve_action, action='2', funcname='test') self.assertRaises(KeyError, self.game1._resolve_action, action='3', funcname='test') assert self.game2._resolve_action(action='U1', funcname='test') assert self.game2._resolve_action(action='D1', funcname='test') diff --git a/src/pygambit/tests/test_outcomes.py b/src/pygambit/tests/test_outcomes.py index 68a07d9b8..4d0ef5299 100644 --- a/src/pygambit/tests/test_outcomes.py +++ b/src/pygambit/tests/test_outcomes.py @@ -52,7 +52,7 @@ def test_outcome_index_int_range(game: gbt.Game): "game", [gbt.Game.new_table([2, 2])] ) def test_outcome_index_label_range(game: gbt.Game): - with pytest.raises(IndexError): + with pytest.raises(KeyError): _ = game.outcomes["not an outcome"] diff --git a/src/pygambit/tests/test_players.py b/src/pygambit/tests/test_players.py index 68e154de0..94ff7c6a8 100644 --- a/src/pygambit/tests/test_players.py +++ b/src/pygambit/tests/test_players.py @@ -49,7 +49,7 @@ def test_player_index_invalid(): def test_player_label_invalid(): game = gbt.Game.new_table([2, 2]) - with pytest.raises(IndexError): + with pytest.raises(KeyError): _ = game.players["Not a player"] @@ -113,7 +113,7 @@ def test_player_strategy_bad_index(): def test_player_strategy_bad_label(): game = gbt.Game.new_table([2, 2]) - with pytest.raises(IndexError): + with pytest.raises(KeyError): _ = game.players[0].strategies["Cooperate"]