diff --git a/ChangeLog b/ChangeLog index 7c8af4afca..7e234c25f3 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,12 @@ +## [17.0.0-beta.1] - unreleased + +### Added +- Added `BehaviorSupportProfile` to `pygambit`. + +### Changed +- Revised `StrategySupportProfile` to expose a slimmer set of operations, and + implement `StrategySupport` as an immutable snapshot. + ## [17.0.0-alpha.2] - 2026-08-21 ### Added diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index fce9151913..8c827754e3 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -220,6 +220,7 @@ Player behavior Game.mixed_behavior_profile Game.random_behavior_profile Game.strategy_support_profile + Game.behavior_support_profile Representation of strategic behavior @@ -336,6 +337,56 @@ Probability distributions over behavior NodeValuesVector +Representation of supports +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. currentmodule:: pygambit.gambit + +Subsets of strategies +...................... + +.. autosummary:: + :toctree: api/ + + StrategySupportProfile + StrategySupportProfile.game + StrategySupportProfile.__iter__ + StrategySupportProfile.__getitem__ + StrategySupportProfile.__setitem__ + StrategySupportProfile.copy + StrategySupportProfile.restrict + StrategySupportProfile.is_dominated + + StrategySupport + StrategySupport.player + StrategySupport.__iter__ + StrategySupport.__contains__ + +Subsets of actions +................... + +.. autosummary:: + :toctree: api/ + + BehaviorSupportProfile + BehaviorSupportProfile.game + BehaviorSupportProfile.__iter__ + BehaviorSupportProfile.__getitem__ + BehaviorSupportProfile.__setitem__ + BehaviorSupportProfile.copy + BehaviorSupportProfile.is_reachable + + BehaviorSupport + BehaviorSupport.player + BehaviorSupport.__iter__ + BehaviorSupport.__getitem__ + + ActionSupport + ActionSupport.infoset + ActionSupport.__iter__ + ActionSupport.__contains__ + + Computation on supports ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/pygambit/behavspt.pxi b/src/pygambit/behavspt.pxi new file mode 100644 index 0000000000..bcf3967f2a --- /dev/null +++ b/src/pygambit/behavspt.pxi @@ -0,0 +1,346 @@ +# +# This file is part of Gambit +# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) +# +# FILE: src/pygambit/behavspt.pxi +# Cython wrapper for behavior support profiles +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +import cython +from cython.operator cimport dereference as deref + + +@cython.cclass +class ActionSupport: + """A set of actions at a specified information set in a `BehaviorSupportProfile`. + + An immutable snapshot taken from a ``BehaviorSupportProfile`` at retrieval time: it + does not reflect later changes to the profile. The information set is accessible + via `infoset`. + """ + _infoset = cython.declare(Infoset) + _actions = cython.declare(tuple) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create an ActionSupport outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(infoset: Infoset, actions: tuple) -> ActionSupport: + obj: ActionSupport = ActionSupport.__new__(ActionSupport) + obj._infoset = infoset + obj._actions = actions + return obj + + @property + def infoset(self) -> Infoset: + return self._infoset + + def __repr__(self) -> str: + return str(list(self._actions)) + + def __eq__(self, other: typing.Any) -> bool: + if isinstance(other, (set, frozenset, list, tuple)): + return set(self._actions) == set(other) + if not isinstance(other, ActionSupport) or self.infoset != other.infoset: + return False + return set(self._actions) == set(cython.cast(ActionSupport, other)._actions) + + def __len__(self) -> int: + return len(self._actions) + + def __iter__(self) -> typing.Generator[str, None, None]: + yield from self._actions + + def __contains__(self, label: str) -> bool: + return label in self._actions + + +@cython.cclass +class BehaviorSupport: + """The action supports at the information sets belonging to a specified player in + a `BehaviorSupportProfile`. + + An immutable snapshot taken from a ``BehaviorSupportProfile`` at retrieval time: it + does not reflect later changes to the profile. The player is accessible via + `player`. + """ + _player = cython.declare(Player) + _values = cython.declare(dict) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create a BehaviorSupport outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(player: Player, values: dict) -> BehaviorSupport: + obj: BehaviorSupport = BehaviorSupport.__new__(BehaviorSupport) + obj._player = player + obj._values = values + return obj + + @property + def player(self) -> Player: + """The player for whom this behavior support is defined.""" + return self._player + + def __repr__(self) -> str: + return str(list(self._values.values())) + + def __eq__(self, other: typing.Any) -> bool: + values = list(self._values.values()) + if isinstance(other, list): + return values == other + if not isinstance(other, BehaviorSupport) or self.player != other.player: + return False + return values == list(cython.cast(BehaviorSupport, other)._values.values()) + + def __len__(self) -> int: + return len(self._values) + + def __iter__(self) -> typing.Generator[ActionSupport, None, None]: + """Iterate over the action supports specified by the behavior support, one per + information set belonging to the player. + + Yields + ------ + support : ActionSupport + The support at an information set belonging to the player + """ + for infoset in self.player.infosets: + yield self[infoset] + + def __getitem__(self, infoset: Infoset) -> ActionSupport: + """Returns the action support at `infoset`. + + Parameters + ---------- + infoset : Infoset + The information set to return the support for. + + Raises + ------ + MismatchError + If `infoset` does not belong to this player. + """ + if infoset.player != self.player: + raise MismatchError("infoset must belong to this player") + return self._values[infoset] + + +@cython.cclass +class BehaviorSupportProfile: + """A set-like object representing a subset of the actions in an extensive game. + A BehaviorSupportProfile always contains at least one action for each information + set belonging to a personal player in the game. + """ + profile = cython.declare(shared_ptr[c_BehaviorSupportProfile]) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create a BehaviorSupportProfile outside a Game.") + + @staticmethod + @cython.cfunc + def wrap(profile: shared_ptr[c_BehaviorSupportProfile]) -> BehaviorSupportProfile: + obj: BehaviorSupportProfile = BehaviorSupportProfile.__new__(BehaviorSupportProfile) + obj.profile = profile + return obj + + @property + def game(self) -> Game: + """The `Game` on which the support profile is defined.""" + return Game.wrap(deref(self.profile).GetGame()) + + def __repr__(self) -> str: + return f"BehaviorSupportProfile(game={self.game})" + + def __len__(self) -> int: + """Returns the total number of actions in the support profile.""" + return deref(self.profile).BehaviorProfileLength() + + def __eq__(self, other: typing.Any) -> bool: + return ( + isinstance(other, BehaviorSupportProfile) and + deref(self.profile) == deref(cython.cast(BehaviorSupportProfile, other).profile) + ) + + def __iter__(self) -> typing.Generator[BehaviorSupport, None, None]: + """Iterate over the behavior supports in the profile, one per player. + + Yields + ------ + support : BehaviorSupport + The player's behavior support specified in the profile + """ + for player in self.game.players: + yield self[player.label] + + def __getitem__(self, index: typing.Any) -> BehaviorSupport | ActionSupport: + """Access a component of the support profile specified by `index`. + + Parameters + ---------- + index : str or Infoset + The part of the profile to return: + + * If `index` is a ``str``, returns a ``BehaviorSupport`` over the player's + information sets. The player is determined by finding the player with + that label, if any. + * If `index` is an ``Infoset`` (or the value of a node's ``infoset`` + property), returns an ``ActionSupport`` over the actions in the support + at the information set. + + Raises + ------ + TypeError + If `index` is not a ``str`` or an ``Infoset``. + MismatchError + If `index` is an ``Infoset`` from a different game. + KeyError + If `index` is a ``str`` and no player in the game has that label. + """ + resolved_infoset = self._resolve_infoset_arg(index) + if resolved_infoset is not None: + if resolved_infoset.game != self.game: + raise MismatchError("infoset must be part of the same game") + return self._action_support_at(resolved_infoset) + if isinstance(index, str): + resolved_player: Player = self.game.players[index] + values = { + infoset: self._action_support_at(infoset) for infoset in resolved_player.infosets + } + return BehaviorSupport.wrap(resolved_player, values) + raise TypeError( + f"profile index must be str or Infoset, not {index.__class__.__name__}" + ) + + @cython.cfunc + def _resolve_infoset_arg(self, index: object) -> object: + """Resolves index to an Infoset if it is one (or a NodeInfoset, which resolves + via the node it was fetched from), or returns None if index is neither. + """ + if isinstance(index, NodeInfoset): + resolved = cython.cast(NodeInfoset, index)._resolve() + if resolved is None: + raise ValueError("index resolves to no information set (the node is terminal)") + return resolved + if isinstance(index, Infoset): + return index + return None + + def _action_support_at(self, infoset: Infoset) -> ActionSupport: + """Returns a snapshot of the action support at infoset, as of now.""" + infoset_handle = cython.cast(Infoset, infoset).infoset + actions = tuple( + Action.wrap(a).label for a in deref(self.profile).GetActions(infoset_handle) + ) + return ActionSupport.wrap(infoset, actions) + + @cython.cfunc + def _ensure_unshared(self) -> cython.void: + """Clones the underlying profile if it is shared with another wrapper, so that + the mutation about to happen is not observed by any other BehaviorSupportProfile. + """ + if self.profile.use_count() != 1: + self.profile = make_shared[c_BehaviorSupportProfile](deref(self.profile)) + + @cython.cfunc + def _set_support(self, infoset: Infoset, actions: object) -> cython.void: + """Validates and sets the whole support at infoset. + + Every entry of `actions` must be one of the information set's action labels, and + at least one must be given. + """ + labels = {a.label for a in infoset.actions} + given = set(actions) + unknown = given - labels + if unknown: + raise ValueError( + f"not an action label at this information set: {', '.join(sorted(unknown))}" + ) + if not given: + raise ValueError("a support must contain at least one action at the information set") + self._ensure_unshared() + # Actions to keep are added first, so that a subsequent removal is never asked + # to remove the last remaining action at the information set. (Unlike + # RemoveStrategy, RemoveAction does not itself guard against emptying its scope.) + for a in infoset.actions: + if a.label in given: + deref(self.profile).AddAction(cython.cast(Action, a).action) + for a in infoset.actions: + if a.label not in given: + deref(self.profile).RemoveAction(cython.cast(Action, a).action) + + def __setitem__(self, infoset: typing.Any, actions: typing.Iterable[str]) -> None: + """Sets the support at `infoset` to exactly the given actions. + + Parameters + ---------- + infoset : Infoset + The information set whose support is to be set. The value of a node's + ``infoset`` property is also accepted. + actions : Iterable[str] + The labels of the actions which should be in the support at the + information set. Every other action at the information set is removed + from the support. + + Raises + ------ + TypeError + If `infoset` is not an ``Infoset``. + MismatchError + If `infoset` is an `Infoset` from a different game. + ValueError + If any entry of `actions` is not one of the information set's action + labels, or if `actions` is empty. + """ + resolved_infoset = self._resolve_infoset_arg(infoset) + if resolved_infoset is None: + raise TypeError(f"profile index must be Infoset, not {infoset.__class__.__name__}") + if resolved_infoset.game != self.game: + raise MismatchError("infoset must be part of the same game") + self._set_support(resolved_infoset, actions) + + def copy(self) -> BehaviorSupportProfile: + """Creates a copy of the support profile. + + The copy shares its underlying data with the original until one of them is + next assigned into, at which point the one being assigned into transparently + takes its own private copy first. Both profiles are fully independent from + each other's perspective; this only affects when the underlying duplication + happens, not whether it happens. + """ + return BehaviorSupportProfile.wrap(self.profile) + + def is_reachable(self, infoset: InfosetReference) -> bool: + """Returns whether `infoset` can be reached under this support, i.e. whether + there is some path of play consistent with the support that reaches it. + + Parameters + ---------- + infoset : Infoset or str + The information set to check. If a string is passed, the information set + is determined by finding the information set with that label, if any. + + Raises + ------ + MismatchError + If `infoset` is an `Infoset` from a different game. + KeyError + If `infoset` is a string and no information set in the game has that label. + """ + resolved_infoset = self.game._resolve_infoset(infoset, "is_reachable") + return deref(self.profile).IsReachable(cython.cast(Infoset, resolved_infoset).infoset) diff --git a/src/pygambit/enumeration.py b/src/pygambit/enumeration.py deleted file mode 100644 index 595a76ab1e..0000000000 --- a/src/pygambit/enumeration.py +++ /dev/null @@ -1,53 +0,0 @@ -# -# This file is part of Gambit -# Copyright (c) 1994-2026, The Gambit Project (https://www.gambit-project.org) -# -# FILE: src/python/gambit/enumeration.py -# Enumeration of support profiles for a strategic game -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. -# - -class SupportEnumeration: - def enumerate_supports(self, game): - return self.admissible_supports(game.support_profile(), - list(game.strategies)) - - def admissible_supports(self, profile, str_rest): - # Passo 1: closure - while True: - temp_profile = profile.undominated(True, True) - if temp_profile == profile: - break - profile = temp_profile - - # Step 2: y' and z' - new_rest = filter(lambda x: x in list(profile), str_rest) - - # Step 3: return x if z' is empty - if not new_rest: - yield profile - - # Step 4: recursive step if z' isn't empty - else: - elem = new_rest.pop() - - yield from self.admissible_supports(profile, new_rest) - - try: - new_profile = profile.remove(elem) - yield from self.admissible_supports(new_profile, new_rest) - except ValueError: - pass diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index edbc9dbaa3..3afe264736 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -468,6 +468,7 @@ cdef extern from "games/stratspt.h": int MixedProfileLength() except + int GetIndex(c_GameStrategy) except + bool IsSubsetOf(c_StrategySupportProfile) except + + void AddStrategy(c_GameStrategy) except + bool RemoveStrategy(c_GameStrategy) except + Support GetStrategies(c_GamePlayer) except + bool Contains(c_GameStrategy) except + @@ -481,7 +482,29 @@ cdef extern from "games/stratspt.h": cdef extern from "games/behavspt.h": cdef cppclass c_BehaviorSupportProfile "BehaviorSupportProfile": + cppclass Support: + cppclass const_iterator: + c_GameAction operator *() + const_iterator operator++() + bint operator ==(const_iterator) + bint operator !=(const_iterator) + Support() + size_t size() + const_iterator begin() except + + const_iterator end() except + + c_BehaviorSupportProfile(c_Game) except + + c_BehaviorSupportProfile(c_BehaviorSupportProfile) except + + bool operator ==(c_BehaviorSupportProfile) except + + bool operator !=(c_BehaviorSupportProfile) except + + c_Game GetGame() except + + size_t BehaviorProfileLength() except + + Support GetActions(c_GameInfoset) except + + bool HasAction(c_GameInfoset) except + + bool Contains(c_GameAction) except + + void AddAction(c_GameAction) except + + bool RemoveAction(c_GameAction) except + + bool IsReachable(c_GameInfoset) except + cdef extern from "games/layout.h": diff --git a/src/pygambit/gambit.pyx b/src/pygambit/gambit.pyx index bdf10934b3..e49bda1d55 100644 --- a/src/pygambit/gambit.pyx +++ b/src/pygambit/gambit.pyx @@ -199,6 +199,7 @@ include "player.pxi" include "outcome.pxi" include "node.pxi" include "stratspt.pxi" +include "behavspt.pxi" include "stratmixed.pxi" include "behavmixed.pxi" include "game.pxi" diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 402308b0eb..4ea1b9f880 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1363,6 +1363,34 @@ class Game: raise ValueError("attempted to remove the last strategy for player") return profile + def behavior_support_profile( + self, actions: typing.Callable | None = None + ) -> BehaviorSupportProfile: + """Create a new `BehaviorSupportProfile` on the game. + + Parameters + ---------- + actions : function, optional + By default the support profile contains all actions at all information + sets. If specified, only actions for which the supplied function returns + `True` are included. + + Returns + ------- + BehaviorSupportProfile + """ + profile = BehaviorSupportProfile.wrap(make_shared[c_BehaviorSupportProfile](self.game)) + if actions is not None: + for infoset in self.infosets: + for action in infoset.actions: + if not actions(action): + if not (deref(profile.profile) + .RemoveAction(cython.cast(Action, action).action)): + raise ValueError( + "attempted to remove the last action at an information set" + ) + return profile + @cython.cfunc def _to_format( self, diff --git a/src/pygambit/stratspt.pxi b/src/pygambit/stratspt.pxi index 8370f974fc..f4e1b20968 100644 --- a/src/pygambit/stratspt.pxi +++ b/src/pygambit/stratspt.pxi @@ -27,22 +27,54 @@ from libcpp.memory cimport unique_ptr @cython.cclass class StrategySupport: - """A set of strategies for a specified player in a `StrategySupportProfile`. + """The labels of the strategies for a specified player in a + `StrategySupportProfile`. + + An immutable snapshot taken from a ``StrategySupportProfile`` at retrieval time: it + does not reflect later changes to the profile. The player is accessible via `player`. + + .. versionchanged:: 17.0.0 + + No longer a live view onto the profile: holds its own copy of the strategy + labels. Iterates over strategy labels (``str``) rather than ``Strategy`` + objects. """ - _profile = cython.declare(StrategySupportProfile) _player = cython.declare(Player) + _strategies = cython.declare(tuple) + + def __init__(self, *args, **kwargs) -> None: + raise ValueError("Cannot create a StrategySupport outside a Game.") - def __init__(self, profile: StrategySupportProfile, player: Player) -> None: - self._profile = profile - self._player = player + @staticmethod + @cython.cfunc + def wrap(player: Player, strategies: tuple) -> StrategySupport: + obj: StrategySupport = StrategySupport.__new__(StrategySupport) + obj._player = player + obj._strategies = strategies + return obj @property def player(self) -> Player: return self._player - def __iter__(self) -> typing.Generator[Strategy, None, None]: - for strat in deref(self._profile.profile).GetStrategies(self._player.player): - yield Strategy.wrap(strat) + def __repr__(self) -> str: + return str(list(self._strategies)) + + def __eq__(self, other: typing.Any) -> bool: + if isinstance(other, (set, frozenset, list, tuple)): + return set(self._strategies) == set(other) + if not isinstance(other, StrategySupport) or self.player != other.player: + return False + return set(self._strategies) == set(cython.cast(StrategySupport, other)._strategies) + + def __len__(self) -> int: + return len(self._strategies) + + def __iter__(self) -> typing.Generator[str, None, None]: + yield from self._strategies + + def __contains__(self, label: str) -> bool: + return label in self._strategies @cython.cclass @@ -81,209 +113,111 @@ class StrategySupportProfile: deref(self.profile) == deref(cython.cast(StrategySupportProfile, other).profile) ) - def __le__(self, other: StrategySupportProfile) -> bool: - return self.issubset(other) - - def __ge__(self, other: StrategySupportProfile) -> bool: - return self.issuperset(other) - - def __contains__(self, strategy: Strategy) -> bool: - if strategy not in self.game.strategies: - raise MismatchError( - "strategy is not part of the game on which the profile is defined." - ) - return deref(self.profile).Contains(strategy.strategy) - - def __iter__(self) -> typing.Generator[Strategy, None, None]: - for player in deref(self.profile).GetGame().deref().GetPlayers(): - for strat in deref(self.profile).GetStrategies(player): - yield Strategy.wrap(strat) - - def __getitem__(self, player: PlayerReference) -> StrategySupport: - """Return a `StrategySupport` representing the strategies in the support - belonging to `player`. + def __iter__(self) -> typing.Generator[StrategySupport, None, None]: + """Iterate over the strategy supports in the profile, one per player. - Parameters - ---------- - player : Player - The player to extract the support for - - Raises + Yields ------ - MismatchError - If `player` is a `Player` from a different game. - """ - return StrategySupport( - self, - cython.cast(Player, self.game._resolve_player(player, "__getitem__")) - ) - - def __and__(self, other: StrategySupportProfile) -> StrategySupportProfile: - """Operator version of set intersection on support profiles. - - See Also - -------- - intersection + support : StrategySupport + The player's strategy support specified in the profile """ - return self.intersection(other) + for player in self.game.players: + yield self[player.label] - def __or__(self, other: StrategySupportProfile) -> StrategySupportProfile: - """Operator version of set union on support profiles. - - See Also - -------- - union - """ - return self.union(other) - - def __sub__(self, other: StrategySupportProfile) -> StrategySupportProfile: - """Operator version of set difference on support profiles. - - See Also - -------- - difference - """ - return self.difference(other) - - def remove(self, strategy: Strategy) -> StrategySupportProfile: - """Creates a new support profile without the given strategy. + def __getitem__(self, player: str) -> StrategySupport: + """Return a `StrategySupport` representing the labels of the strategies in the + support belonging to the player with label `player`, as of now; it will not + reflect any later changes to this profile. Parameters ---------- - strategy : Strategy - The strategy to remove from the profile. + player : str + The label of the player to extract the support for. Raises ------ - UndefinedOperationError - If `strategy` is the only strategy in the support for its player. + KeyError + If no player in the game has the label `player`. """ - if strategy not in self.game.strategies: - raise MismatchError( - "remove(): strategy is not part of the game on which the profile is defined." - ) - if deref(self.profile).GetStrategies( - cython.cast(Player, strategy.player).player - ).size() == 1: - raise UndefinedOperationError( - "remove(): cannot remove last strategy of a player" - ) - strategies = list(self) - strategies.remove(strategy) - return self.game.strategy_support_profile(lambda x: x in strategies) - - def difference(self, other: StrategySupportProfile) -> StrategySupportProfile: - """Create a support profile which contains all strategies in this profile that - are not in `other`. - - Parameters - ---------- - other : StrategySupportProfile - The support profile to subtract from this one. - - Returns - ------- - StrategySupportProfile - The support profile resulting from the operation. - - Raises - ------ - MismatchError - If the support profiles are defined on different games. - """ - if self.game != other.game: - raise MismatchError("difference(): support profiles are defined on different games") - return self.game.strategy_support_profile(lambda x: x in set(self) - set(other)) - - def intersection(self, other: StrategySupportProfile) -> StrategySupportProfile: - """Create a support profile which contains all strategies that are in both this and - another profile. - - Parameters - ---------- - other : StrategySupportProfile - The support profile to intersect with this one. - - Returns - ------- - StrategySupportProfile - The support profile resulting from the operation. + resolved_player: Player = self.game.players[player] + strategies = tuple( + Strategy.wrap(s).label + for s in deref(self.profile).GetStrategies(resolved_player.player) + ) + return StrategySupport.wrap(resolved_player, strategies) - Raises - ------ - MismatchError - If the support profiles are defined on different games. + @cython.cfunc + def _ensure_unshared(self) -> cython.void: + """Clones the underlying profile if it is shared with another wrapper, so that + the mutation about to happen is not observed by any other StrategySupportProfile. """ - if self.game != other.game: - raise MismatchError("intersection(): support profiles are defined on different games") - return self.game.strategy_support_profile(lambda x: x in set(self) & set(other)) - - def union(self, other: StrategySupportProfile) -> StrategySupportProfile: - """Create a support profile which contains all strategies that are in either this or - another profile. - - Parameters - ---------- - other : StrategySupportProfile - The other support profile to add to this one. + if self.profile.use_count() != 1: + self.profile = make_shared[c_StrategySupportProfile](deref(self.profile)) - Returns - ------- - StrategySupportProfile - The support profile resulting from the operation. + @cython.cfunc + def _set_support(self, player: Player, strategies: object) -> cython.void: + """Validates and sets the whole support for player. - Raises - ------ - MismatchError - If the support profiles are defined on different games. + Every entry of `strategies` must be one of the player's strategy labels, and + at least one must be given. """ - if self.game != other.game: - raise MismatchError("union(): support profiles are defined on different games") - return self.game.strategy_support_profile(lambda x: x in set(self) | set(other)) - - def issubset(self, other: StrategySupportProfile) -> bool: - """Test for whether this support is contained in another. + labels = {s.label for s in player.strategies} + given = set(strategies) + unknown = given - labels + if unknown: + raise ValueError( + f"not a strategy label for this player: {', '.join(sorted(unknown))}" + ) + if not given: + raise ValueError("a support must contain at least one strategy for the player") + self._ensure_unshared() + # Strategies to keep are added first, so that a subsequent removal is never asked + # to remove the last remaining strategy for the player. + for s in player.strategies: + if s.label in given: + deref(self.profile).AddStrategy(cython.cast(Strategy, s).strategy) + for s in player.strategies: + if s.label not in given: + deref(self.profile).RemoveStrategy(cython.cast(Strategy, s).strategy) + + def __setitem__(self, player: str, strategies: typing.Iterable[str]) -> None: + """Sets the support for the player with label `player` to exactly the given + strategies. + + .. versionadded:: 17.0.0 Parameters ---------- - other : StrategySupportProfile - The other support profile to compare to. - - Returns - ------- - bool - `True` if every strategy in the profile is also in `other`. + player : str + The label of the player whose support is to be set. + strategies : Iterable[str] + The labels of the strategies which should be in the support for the + player. Every one of the player's other strategies is removed from the + support. Raises ------ - MismatchError - If the support profiles are defined on different games. + KeyError + If no player in the game has the label `player`. + ValueError + If any entry of `strategies` is not one of the player's strategy labels, + or if `strategies` is empty. """ - if self.game != other.game: - raise MismatchError("issubset(): support profiles are defined on different games") - return deref(self.profile).IsSubsetOf(deref(other.profile)) + resolved_player: Player = self.game.players[player] + self._set_support(resolved_player, strategies) - def issuperset(self, other: StrategySupportProfile) -> bool: - """Test for whether another support is contained in this one. + def copy(self) -> StrategySupportProfile: + """Creates a copy of the support profile. - Parameters - ---------- - other : StrategySupportProfile - The other support profile to compare to. + .. versionadded:: 17.0.0 - Returns - ------- - bool - `True` if every strategy in `other` is in this profile. - - Raises - ------ - MismatchError - If the support profiles are defined on different games. + The copy shares its underlying data with the original until one of them is + next assigned into, at which point the one being assigned into transparently + takes its own private copy first. Both profiles are fully independent from + each other's perspective; this only affects when the underlying duplication + happens, not whether it happens. """ - if self.game != other.game: - raise MismatchError("issuperset(): support profiles are defined on different games") - return other.is_subset_of(self) + return StrategySupportProfile.wrap(self.profile) def restrict(self) -> Game: """Creates a deep copy of the support profile's game, including only the strategies @@ -296,8 +230,36 @@ class StrategySupportProfile: with io.StringIO(WriteNfgFileSupport(deref(self.profile)).decode()) as f: return read_nfg(f) - def is_dominated(self, strategy: Strategy, strict: bool, external: bool = False) -> bool: - return deref(self.profile).IsDominated(strategy.strategy, strict, external) + def is_dominated( + self, player: str, strategy: str, strict: bool, external: bool = False + ) -> bool: + """Returns whether the strategy with label `strategy`, belonging to the player + with label `player`, is dominated. + + Parameters + ---------- + player : str + The label of the player to whom the strategy belongs. + strategy : str + The label of the strategy to check. + strict : bool + If `True`, only checks for strict dominance. + external : bool, default False + The default is to consider dominance only by strategies which are in + the support for the player. If `True`, strategies which are dominated + by another strategy not in the support profile are also considered. + + Raises + ------ + KeyError + If no player in the game has the label `player`, or the player has no + strategy with the label `strategy`. + """ + resolved_player: Player = self.game.players[player] + resolved_strategy: Strategy = resolved_player.strategies[strategy] + return deref(self.profile).IsDominated( + cython.cast(Strategy, resolved_strategy).strategy, strict, external + ) def _undominated_strategies_solve( diff --git a/tests/test_stratprofiles.py b/tests/test_stratprofiles.py index d815aa0f1e..e9db259cc3 100644 --- a/tests/test_stratprofiles.py +++ b/tests/test_stratprofiles.py @@ -5,60 +5,123 @@ from . import games -def test_remove_strategy(): +def test_getitem_labels(): game = games.read_from_file("mixed_strategy.nfg") - support_profile = game.strategy_support_profile() - strategy = game.players["Player 1"].strategies["1"] - new_profile = support_profile.remove(strategy) - assert len(support_profile) == len(new_profile) + 1 - assert strategy not in new_profile + profile = game.strategy_support_profile() + support = profile["Player 1"] + assert set(support) == {"1", "2", "3"} + assert support.player == game.players["Player 1"] + assert "1" in support + assert "not-a-label" not in support -def test_difference(): +def test_getitem_unknown_player(): game = games.read_from_file("mixed_strategy.nfg") - support_profile = game.strategy_support_profile() - strategies = [p.strategies["1"] for p in game.players] - dif_profile = game.strategy_support_profile(lambda x: x in strategies) - new_profile = support_profile - dif_profile - assert len(new_profile) == 3 - for strategy in strategies: - assert strategy not in new_profile + profile = game.strategy_support_profile() + with pytest.raises(KeyError): + profile["not-a-player"] -def test_difference_error(): +def test_getitem_rejects_non_str(): + game = games.read_from_file("mixed_strategy.nfg") + profile = game.strategy_support_profile() + with pytest.raises(TypeError): + profile[game.players["Player 1"]] + + +def test_predicate_construction(): + game = games.read_from_file("mixed_strategy.nfg") + profile = game.strategy_support_profile(lambda x: x.label != "3") + assert set(profile["Player 1"]) == {"1", "2"} + assert set(profile["Player 2"]) == {"1", "2"} + + +def test_predicate_construction_error(): game = games.read_from_file("mixed_strategy.nfg") - support_profile = game.strategy_support_profile() with pytest.raises(ValueError): - game.strategy_support_profile(lambda x: x.label == "1") - support_profile + game.strategy_support_profile(lambda x: x.player.label != "Player 1") + + +def test_iter_yields_one_support_per_player(): + game = games.read_from_file("mixed_strategy.nfg") + profile = game.strategy_support_profile() + supports = list(profile) + assert len(supports) == len(game.players) + assert {s.player.label for s in supports} == {p.label for p in game.players} -def test_intersection(): +def test_setitem_replaces_support(): game = games.read_from_file("mixed_strategy.nfg") - first_profile = game.strategy_support_profile(lambda x: x.label not in ["1", "3"]) - second_profile = game.strategy_support_profile(lambda x: x.label != "3") - intersect_profile = first_profile & second_profile - assert len(intersect_profile) == 2 - assert intersect_profile <= first_profile - assert intersect_profile <= second_profile + profile = game.strategy_support_profile() + profile["Player 1"] = ["1", "2"] + assert set(profile["Player 1"]) == {"1", "2"} + assert set(profile["Player 2"]) == {"1", "2"} -def test_intersection_error(): +def test_setitem_unknown_label(): game = games.read_from_file("mixed_strategy.nfg") - first_profile = game.strategy_support_profile(lambda x: x.label not in ["1", "3"]) - second_profile = game.strategy_support_profile( - lambda x: x.player.label == "Player 1" or x.label == "1" - ) + profile = game.strategy_support_profile() with pytest.raises(ValueError): - _ = first_profile & second_profile + profile["Player 1"] = ["not-a-label"] + + +def test_setitem_empty(): + game = games.read_from_file("mixed_strategy.nfg") + profile = game.strategy_support_profile() + with pytest.raises(ValueError): + profile["Player 1"] = [] + + +def test_setitem_unknown_player(): + game = games.read_from_file("mixed_strategy.nfg") + profile = game.strategy_support_profile() + with pytest.raises(KeyError): + profile["not-a-player"] = ["1"] + + +def test_copy_is_independent(): + game = games.read_from_file("mixed_strategy.nfg") + original = game.strategy_support_profile() + copy = original.copy() + copy["Player 1"] = ["1"] + assert set(copy["Player 1"]) == {"1"} + assert set(original["Player 1"]) == {"1", "2", "3"} + + +def test_strategysupport_is_snapshot(): + game = games.read_from_file("mixed_strategy.nfg") + profile = game.strategy_support_profile() + snapshot = profile["Player 1"] + profile["Player 1"] = ["1"] + assert set(snapshot) == {"1", "2", "3"} -def test_union(): +def test_is_dominated(): game = games.read_from_file("mixed_strategy.nfg") - first_profile = game.strategy_support_profile(lambda x: x.label not in ["1", "3"]) - second_profile = game.strategy_support_profile( - lambda x: x.player.label == "Player 1" or x.label == "1" - ) - assert (first_profile | second_profile) == game.strategy_support_profile() + profile = game.strategy_support_profile() + assert profile.is_dominated("Player 1", "1", strict=False) is False + + +def test_is_dominated_unknown_player(): + game = games.read_from_file("mixed_strategy.nfg") + profile = game.strategy_support_profile() + with pytest.raises(KeyError): + profile.is_dominated("not-a-player", "1", strict=False) + + +def test_is_dominated_unknown_strategy(): + game = games.read_from_file("mixed_strategy.nfg") + profile = game.strategy_support_profile() + with pytest.raises(KeyError): + profile.is_dominated("Player 1", "not-a-label", strict=False) + + +def test_restrict(): + game = games.read_from_file("mixed_strategy.nfg") + profile = game.strategy_support_profile(lambda x: x.label != "3") + restricted = profile.restrict() + assert len(restricted.players["Player 1"].strategies) == 2 + assert len(restricted.players["Player 2"].strategies) == 2 def test_undominated(): @@ -70,11 +133,3 @@ def test_undominated(): break profile = new_profile assert profile == game.strategy_support_profile(lambda x: x.label == "1") - - -def test_remove_error(): - game = games.read_from_file("mixed_strategy.nfg") - support_profile = game.strategy_support_profile() - profile = support_profile.remove(game.players["Player 2"].strategies["1"]) - with pytest.raises(gbt.UndefinedOperationError): - profile.remove(game.players["Player 2"].strategies["2"])