icepool

Package for computing dice and card probabilities.

Starting with v0.25.1, you can replace latest in the URL with an old version number to get the documentation for that version.

See this JupyterLite distribution for examples.

Visit the project page.

General conventions:

  • Instances are immutable (apart from internal caching). Anything that looks like it mutates an instance actually returns a separate instance with the change.
  1"""Package for computing dice and card probabilities.
  2
  3Starting with `v0.25.1`, you can replace `latest` in the URL with an old version
  4number to get the documentation for that version.
  5
  6See [this JupyterLite distribution](https://highdiceroller.github.io/icepool/notebooks/lab/index.html)
  7for examples.
  8
  9[Visit the project page.](https://github.com/HighDiceRoller/icepool)
 10
 11General conventions:
 12
 13* Instances are immutable (apart from internal caching). Anything that looks
 14    like it mutates an instance actually returns a separate instance with the
 15    change.
 16"""
 17
 18__docformat__ = 'google'
 19
 20__version__ = '2.2.2'
 21
 22from typing import Final
 23
 24from icepool.typing import Outcome, RerollType, NoCacheType
 25from icepool.order import Order, ConflictingOrderError, UnsupportedOrder
 26from icepool.map_tools.common import Break
 27
 28Reroll: Final = RerollType.Reroll
 29"""Indicates that an outcome should be rerolled (with unlimited depth).
 30
 31This effectively removes the outcome from the probability space, along with its
 32contribution to the denominator.
 33
 34This can be used for conditional probability by removing all outcomes not
 35consistent with the given observations.
 36
 37Operation in specific cases:
 38
 39* If sent to the constructor of `Die`, it and the corresponding quantity is
 40    dropped.
 41* When used with `Again` or `map(repeat)`, only that stage is rerolled, not the 
 42    entire rolling process.
 43* To reroll with limited depth, use `Die.reroll()`, or `Again` with no
 44    modification.
 45* When used with `MultisetEvaluator`, this currently has the same meaning as
 46    `Restart`. Prefer using `Restart` in this case.
 47"""
 48Restart: Final = RerollType.Restart
 49"""Indicates that a rolling process should be restarted (with unlimited depth).
 50
 51`Restart` effectively removes the sequence of events from the probability space,
 52along with its contribution to the denominator.
 53
 54`Restart` can be used for conditional probability by removing all sequences of 
 55events not consistent with the given observations.
 56
 57`Restart` can be used with `again_count`, `map(repeat)`, or `MultisetEvaluator`. 
 58When sent to the constructor of `Die`, it has the same effect as `Reroll`; 
 59prefer using `Reroll` in this case.
 60
 61`Restart` can NOT be used with `again_depth`, but `Reroll` can.
 62"""
 63
 64REROLL_TYPES: Final = (Reroll, Restart)
 65"""Explicitly defined since Enum.__contains__ requires that the queried value be hashable."""
 66
 67NoCache: Final = NoCacheType.NoCache
 68"""Indicates that caching should not be performed. Exact meaning depends on context."""
 69
 70# Expose certain names at top-level.
 71
 72from icepool.function import (d, z, __getattr__, coin, stochastic_round,
 73                              one_hot, from_cumulative, from_rv, pointwise_max,
 74                              pointwise_min, min_outcome, max_outcome,
 75                              consecutive, sorted_union,
 76                              harmonize_denominators)
 77from icepool.map_tools.function import (reduce, accumulate, map, map_function,
 78                                        map_and_time, mean_time_to_absorb,
 79                                        map_to_pool)
 80
 81from icepool.population.base import Population
 82from icepool.population.die import implicit_convert_to_die, Die
 83from icepool.expand import iter_cartesian_product, cartesian_product, tupleize, vectorize
 84from icepool.collection.vector import Vector
 85from icepool.collection.vector_with_truth_only import VectorWithTruthOnly
 86from icepool.collection.symbols import Symbols
 87from icepool.population.again import AgainExpression
 88
 89Again: Final = AgainExpression(is_additive=True)
 90"""A symbol indicating that the die should be rolled again, usually with some operation applied.
 91
 92This is designed to be used with the `Die()` constructor.
 93`AgainExpression`s should not be fed to functions or methods other than
 94`Die()` (or indirectly via `map()`), but they can be used with operators.
 95Examples:
 96
 97* `Again + 6`: Roll again and add 6.
 98* `Again + Again`: Roll again twice and sum.
 99
100The `again_count`, `again_depth`, and `again_end` arguments to `Die()`
101affect how these arguments are processed. At most one of `again_count` or
102`again_depth` may be provided; if neither are provided, the behavior is as
103`again_depth=1`.
104
105For finer control over rolling processes, use e.g. `Die.map()` instead.
106
107#### Count mode
108
109When `again_count` is provided, we start with one roll queued and execute one 
110roll at a time. For every `Again` we roll, we queue another roll.
111If we run out of rolls, we sum the rolls to find the result. We evaluate up to
112`again_count` extra rolls. If, at this point, there are still dice remaining:
113
114* `Restart`: If there would be dice over the limit, we restart the entire 
115    process from the beginning, effectively conditioning the process against
116    this sequence of events.
117* `Reroll`: Any remaining dice can't produce more `Again`s.
118* `outcome`: Any remaining dice are each treated as the given outcome.
119* `None`: Any remaining dice are treated as zero.
120
121This mode only allows "additive" expressions to be used with `Again`, which
122means that only the following operators are allowed:
123
124* Binary `+`
125* `n @ AgainExpression`, where `n` is a non-negative `int` or `Population`.
126
127Furthermore, the `+` operator is assumed to be associative and commutative.
128For example, `str` or `tuple` outcomes will not produce elements with a definite
129order.
130
131#### Depth mode
132
133When `again_depth=0`, `again_end` is directly substituted
134for each occurence of `Again`. For other values of `again_depth`, the result for
135`again_depth-1` is substituted for each occurence of `Again`.
136
137If `again_end=Reroll`, then any `AgainExpression`s in the final depth
138are rerolled. `Restart` cannot be used with `again_depth`.
139"""
140
141from icepool.population.die_with_truth import DieWithTruth
142
143from icepool.collection.counts import CountsKeysView, CountsValuesView, CountsItemsView
144
145from icepool.population.keep import lowest, highest, middle
146
147from icepool.generator.pool import Pool, standard_pool, d_pool, z_pool
148from icepool.generator.keep import KeepGenerator
149from icepool.generator.compound_keep import CompoundKeepGenerator
150
151from icepool.generator.multiset_generator import MultisetGenerator
152from icepool.generator.multiset_tuple_generator import MultisetTupleGenerator
153from icepool.generator.weightless import WeightlessGenerator
154from icepool.evaluator.multiset_evaluator import MultisetEvaluator
155
156from icepool.population.deck import Deck
157from icepool.generator.deal import Deal
158from icepool.generator.multi_deal import MultiDeal
159
160from icepool.expression.multiset_expression import MultisetExpression, implicit_convert_to_expression
161from icepool.evaluator.multiset_function import multiset_function
162from icepool.expression.multiset_parameter import MultisetParameter, MultisetTupleParameter
163from icepool.expression.multiset_mixture import MultisetMixture
164
165from icepool.population.format import format_probability_inverse
166
167from icepool.wallenius import Wallenius
168
169import icepool.generator as generator
170import icepool.evaluator as evaluator
171import icepool.operator as operator
172
173import icepool.typing as typing
174from icepool.expand import Expandable
175
176__all__ = [
177    'd', 'z', 'coin', 'stochastic_round', 'one_hot', 'Outcome', 'Die',
178    'Population', 'tupleize', 'vectorize', 'Vector', 'Symbols', 'Again',
179    'CountsKeysView', 'CountsValuesView', 'CountsItemsView', 'from_cumulative',
180    'from_rv', 'pointwise_max', 'pointwise_min', 'lowest', 'highest', 'middle',
181    'min_outcome', 'max_outcome', 'consecutive', 'sorted_union',
182    'harmonize_denominators', 'reduce', 'accumulate', 'map', 'map_function',
183    'map_and_time', 'mean_time_to_absorb', 'map_to_pool', 'Reroll', 'Restart',
184    'Break', 'RerollType', 'Pool', 'd_pool', 'z_pool', 'MultisetGenerator',
185    'MultisetExpression', 'MultisetEvaluator', 'Order',
186    'ConflictingOrderError', 'UnsupportedOrder', 'Deck', 'Deal', 'MultiDeal',
187    'multiset_function', 'MultisetParameter', 'MultisetTupleParameter',
188    'NoCache', 'function', 'typing', 'evaluator', 'format_probability_inverse',
189    'Wallenius'
190]
@cache
def d(sides: int, /) -> Die[int]:
18@cache
19def d(sides: int, /) -> 'icepool.Die[int]':
20    """A standard die, uniformly distributed from `1` to `sides` inclusive.
21
22    Don't confuse this with `icepool.Die()`:
23
24    * `icepool.Die([6])`: A `Die` that always rolls the integer 6.
25    * `icepool.d(6)`: A d6.
26
27    You can also import individual standard dice from the `icepool` module, e.g.
28    `from icepool import d6`.
29    """
30    if not isinstance(sides, int):
31        raise TypeError('sides must be an int.')
32    elif sides < 1:
33        raise ValueError('sides must be at least 1.')
34    return icepool.Die(range(1, sides + 1))

A standard die, uniformly distributed from 1 to sides inclusive.

Don't confuse this with icepool.Die():

You can also import individual standard dice from the icepool module, e.g. from icepool import d6.

@cache
def z(sides: int, /) -> Die[int]:
37@cache
38def z(sides: int, /) -> 'icepool.Die[int]':
39    """A die uniformly distributed from `0` to `sides - 1` inclusive.
40    
41    Equal to d(sides) - 1.
42    """
43    if not isinstance(sides, int):
44        raise TypeError('sides must be an int.')
45    elif sides < 1:
46        raise ValueError('sides must be at least 1.')
47    return icepool.Die(range(0, sides))

A die uniformly distributed from 0 to sides - 1 inclusive.

Equal to d(sides) - 1.

def coin( n: int | float | fractions.Fraction, d: int = 1, /, *, max_denominator: int | None = None) -> Die[bool]:
 73def coin(n: int | float | Fraction,
 74         d: int = 1,
 75         /,
 76         *,
 77         max_denominator: int | None = None) -> 'icepool.Die[bool]':
 78    """A `Die` that rolls `True` with probability `n / d`, and `False` otherwise.
 79
 80    If `n <= 0` or `n >= d` the result will have only one outcome.
 81
 82    Args:
 83        n: An int numerator, or a non-integer probability.
 84        d: An int denominator. Should not be provided if the first argument is
 85            not an int.
 86    """
 87    if not isinstance(n, int):
 88        if d != 1:
 89            raise ValueError(
 90                'If a non-int numerator is provided, a denominator must not be provided.'
 91            )
 92        fraction = Fraction(n)
 93        if max_denominator is not None:
 94            fraction = fraction.limit_denominator(max_denominator)
 95        n = fraction.numerator
 96        d = fraction.denominator
 97    data = {}
 98    if n < d:
 99        data[False] = min(d - n, d)
100    if n > 0:
101        data[True] = min(n, d)
102
103    return icepool.Die(data)

A Die that rolls True with probability n / d, and False otherwise.

If n <= 0 or n >= d the result will have only one outcome.

Arguments:
  • n: An int numerator, or a non-integer probability.
  • d: An int denominator. Should not be provided if the first argument is not an int.
def stochastic_round( x, /, *, max_denominator: int | None = None) -> Die[int]:
106def stochastic_round(x,
107                     /,
108                     *,
109                     max_denominator: int | None = None) -> 'icepool.Die[int]':
110    """Randomly rounds a value up or down to the nearest integer according to the two distances.
111        
112    Specificially, rounds `x` up with probability `x - floor(x)` and down
113    otherwise, producing a `Die` with up to two outcomes.
114
115    Args:
116        max_denominator: If provided, each rounding will be performed
117            using `fractions.Fraction.limit_denominator(max_denominator)`.
118            Otherwise, the rounding will be performed without
119            `limit_denominator`.
120    """
121    integer_part = math.floor(x)
122    fractional_part = x - integer_part
123    return integer_part + coin(fractional_part,
124                               max_denominator=max_denominator)

Randomly rounds a value up or down to the nearest integer according to the two distances.

Specificially, rounds x up with probability x - floor(x) and down otherwise, producing a Die with up to two outcomes.

Arguments:
  • max_denominator: If provided, each rounding will be performed using fractions.Fraction.limit_denominator(max_denominator). Otherwise, the rounding will be performed without limit_denominator.
def one_hot(sides: int, /) -> Die[tuple[bool, ...]]:
127def one_hot(sides: int, /) -> 'icepool.Die[tuple[bool, ...]]':
128    """A `Die` with `Vector` outcomes with one element set to `True` uniformly at random and the rest `False`.
129
130    This is an easy (if somewhat expensive) way of representing how many dice
131    in a pool rolled each number. For example, the outcomes of `10 @ one_hot(6)`
132    are the `(ones, twos, threes, fours, fives, sixes)` rolled in 10d6.
133    """
134    data = []
135    for i in range(sides):
136        outcome = [False] * sides
137        outcome[i] = True
138        data.append(icepool.Vector(outcome))
139    return icepool.Die(data)

A Die with Vector outcomes with one element set to True uniformly at random and the rest False.

This is an easy (if somewhat expensive) way of representing how many dice in a pool rolled each number. For example, the outcomes of 10 @ one_hot(6) are the (ones, twos, threes, fours, fives, sixes) rolled in 10d6.

class Outcome(typing.Hashable, typing.Protocol[-T_contra]):
44class Outcome(Hashable, Protocol[T_contra]):
45    """Protocol to attempt to verify that outcome types are hashable and sortable.
46
47    Far from foolproof, e.g. it cannot enforce total ordering.
48    """
49
50    def __lt__(self, other: T_contra) -> bool:
51        ...

Protocol to attempt to verify that outcome types are hashable and sortable.

Far from foolproof, e.g. it cannot enforce total ordering.

  44class Die(Population[T_co], MaybeHashKeyed):
  45    """Sampling with replacement. Quantities represent weights.
  46
  47    Dice are immutable. Methods do not modify the `Die` in-place;
  48    rather they return a `Die` representing the result.
  49
  50    It's also possible to have "empty" dice with no outcomes at all,
  51    though these have little use other than being sentinel values.
  52    """
  53
  54    _data: Counts[T_co]
  55
  56    @property
  57    def _new_type(self) -> type:
  58        return Die
  59
  60    def __new__(
  61        cls,
  62        outcomes: Sequence | Mapping[Any, int],
  63        times: Sequence[int] | int = 1,
  64        *,
  65        again_count: int | None = None,
  66        again_depth: int | None = None,
  67        again_end: 'T_co | Die[T_co] | icepool.RerollType | None' = None
  68    ) -> 'Die[T_co]':
  69        """Constructor for a `Die`.
  70
  71        Don't confuse this with `d()`:
  72
  73        * `Die([6])`: A `Die` that always rolls the `int` 6.
  74        * `d(6)`: A d6.
  75
  76        Also, don't confuse this with `Pool()`:
  77
  78        * `Die([1, 2, 3, 4, 5, 6])`: A d6.
  79        * `Pool([1, 2, 3, 4, 5, 6])`: A `Pool` of six dice that always rolls one
  80            of each number.
  81
  82        Here are some different ways of constructing a d6:
  83
  84        * Just import it: `from icepool import d6`
  85        * Use the `d()` function: `icepool.d(6)`
  86        * Use a d6 that you already have: `Die(d6)` or `Die([d6])`
  87        * Mix a d3 and a d3+3: `Die([d3, d3+3])`
  88        * Use a dict: `Die({1:1, 2:1, 3:1, 4:1, 5:1, 6:1})`
  89        * Give the faces as a sequence: `Die([1, 2, 3, 4, 5, 6])`
  90
  91        All quantities must be non-negative. Outcomes with zero quantity will be
  92        omitted.
  93
  94        Several methods and functions foward **kwargs to this constructor.
  95        However, these only affect the construction of the returned or yielded
  96        dice. Any other implicit conversions of arguments or operands to dice
  97        will be done with the default keyword arguments.
  98
  99        EXPERIMENTAL: Use `icepool.Again` to roll the dice again, usually with
 100        some modification. See the `Again` documentation for details.
 101
 102        Denominator: For a flat set of outcomes, the denominator is just the
 103        sum of the corresponding quantities. If the outcomes themselves have
 104        secondary denominators, then the overall denominator will be minimized
 105        while preserving the relative weighting of the primary outcomes.
 106
 107        Args:
 108            outcomes: The faces of the `Die`. This can be one of the following:
 109                * A `Sequence` of outcomes. Duplicates will contribute
 110                    quantity for each appearance.
 111                * A `Mapping` from outcomes to quantities.
 112
 113                Individual outcomes can each be one of the following:
 114
 115                * An outcome, which must be hashable and totally orderable.
 116                    * For convenience, `tuple`s containing `Population`s will be
 117                        `tupleize`d into a `Population` of `tuple`s.
 118                        This does not apply to subclasses of `tuple`s such as `namedtuple`
 119                        or other classes such as `Vector`.
 120                * A `Die`, which will be flattened into the result.
 121                    The quantity assigned to a `Die` is shared among its
 122                    outcomes. The total denominator will be scaled up if
 123                    necessary.
 124                * `icepool.Reroll`, which will drop itself from consideration.
 125                * EXPERIMENTAL: `icepool.Again`. See the documentation for
 126                    `Again` for details.
 127            times: Multiplies the quantity of each element of `outcomes`.
 128                `times` can either be a sequence of the same length as
 129                `outcomes` or a single `int` to apply to all elements of
 130                `outcomes`.
 131            again_count, again_depth, again_end: These affect how `Again`
 132                expressions are handled. See the `Again` documentation for
 133                details.
 134        Raises:
 135            ValueError: `None` is not a valid outcome for a `Die`.
 136        """
 137        outcomes, times = icepool.creation_args.itemize(outcomes, times)
 138
 139        # Check for Again.
 140        if icepool.population.again.contains_again(outcomes):
 141            if again_count is not None:
 142                if again_depth is not None:
 143                    raise ValueError(
 144                        'At most one of again_count and again_depth may be used.'
 145                    )
 146                return icepool.population.again.evaluate_agains_using_count(
 147                    outcomes, times, again_count, again_end)
 148            else:
 149                if again_depth is None:
 150                    again_depth = 1
 151                return icepool.population.again.evaluate_agains_using_depth(
 152                    outcomes, times, again_depth, again_end)
 153
 154        # Agains have been replaced by this point.
 155        outcomes = cast(Sequence[T_co | Die[T_co] | icepool.RerollType],
 156                        outcomes)
 157
 158        if len(outcomes) == 1 and times[0] == 1 and isinstance(
 159                outcomes[0], Die):
 160            return outcomes[0]
 161
 162        counts: Counts[T_co] = icepool.creation_args.expand_args_for_die(
 163            outcomes, times)
 164
 165        return Die._new_raw(counts)
 166
 167    @classmethod
 168    def _new_raw(cls, data: Counts[T_co]) -> 'Die[T_co]':
 169        """Creates a new `Die` using already-processed arguments.
 170
 171        Args:
 172            data: At this point, this is a Counts.
 173        """
 174        self = super(Population, cls).__new__(cls)
 175        self._data = data
 176        return self
 177
 178    # Defined separately from the superclass to help typing.
 179    def unary_operator(self: 'icepool.Die[T_co]', op: Callable[..., U], *args,
 180                       **kwargs) -> 'icepool.Die[U]':
 181        """Performs the unary operation on the outcomes.
 182
 183        This is used for the standard unary operators
 184        `-, +, abs, ~, round, trunc, floor, ceil`
 185        as well as the additional methods
 186        `zero, bool`.
 187
 188        This is NOT used for the `[]` operator; when used directly, this is
 189        interpreted as a `Mapping` operation and returns the count corresponding
 190        to a given outcome. See `marginals()` for applying the `[]` operator to
 191        outcomes.
 192
 193        Returns:
 194            A `Die` representing the result.
 195
 196        Raises:
 197            ValueError: If tuples are of mismatched length.
 198        """
 199        return self._unary_operator(op, *args, **kwargs)
 200
 201    def binary_operator(self, other: 'Die', op: Callable[..., U], *args,
 202                        **kwargs) -> 'Die[U]':
 203        """Performs the operation on pairs of outcomes.
 204
 205        By the time this is called, the other operand has already been
 206        converted to a `Die`.
 207
 208        This is used for the standard binary operators
 209        `+, -, *, /, //, %, **, <<, >>, &, |, ^`
 210        and the standard binary comparators
 211        `<, <=, >=, >, ==, !=, cmp`.
 212
 213        `==` and `!=` additionally set the truth value of the `Die` according to
 214        whether the dice themselves are the same or not.
 215
 216        The `@` operator does NOT use this method directly.
 217        It rolls the left `Die`, which must have integer outcomes,
 218        then rolls the right `Die` that many times and sums the outcomes.
 219
 220        Returns:
 221            A `Die` representing the result.
 222
 223        Raises:
 224            ValueError: If tuples are of mismatched length within one of the
 225                dice or between the dice.
 226        """
 227        data: MutableMapping[Any, int] = defaultdict(int)
 228        for (outcome_self,
 229             quantity_self), (outcome_other,
 230                              quantity_other) in itertools.product(
 231                                  self.items(), other.items()):
 232            new_outcome = op(outcome_self, outcome_other, *args, **kwargs)
 233            data[new_outcome] += quantity_self * quantity_other
 234        return self._new_type(data)
 235
 236    # Basic access.
 237
 238    def keys(self) -> CountsKeysView[T_co]:
 239        return self._data.keys()
 240
 241    def values(self) -> CountsValuesView:
 242        return self._data.values()
 243
 244    def items(self) -> CountsItemsView[T_co]:
 245        return self._data.items()
 246
 247    def __getitem__(self, outcome, /) -> int:
 248        return self._data[outcome]
 249
 250    def __iter__(self) -> Iterator[T_co]:
 251        return iter(self.keys())
 252
 253    def __len__(self) -> int:
 254        """The number of outcomes. """
 255        return len(self._data)
 256
 257    def __contains__(self, outcome) -> bool:
 258        return outcome in self._data
 259
 260    # Quantity management.
 261
 262    def simplify(self) -> 'Die[T_co]':
 263        """Divides all quantities by their greatest common denominator. """
 264        return icepool.Die(self._data.simplify())
 265
 266    # Rerolls and other outcome management.
 267
 268    def reroll(self,
 269               outcomes: Callable[..., bool] | Collection[T_co] | None = None,
 270               /,
 271               *,
 272               star: bool | None = None,
 273               depth: int | Literal['inf']) -> 'Die[T_co]':
 274        """Rerolls the given outcomes.
 275
 276        Args:
 277            outcomes: Selects which outcomes to reroll. Options:
 278                * A collection of outcomes to reroll.
 279                * A callable that takes an outcome and returns `True` if it
 280                    should be rerolled.
 281                * If not provided, the min outcome will be rerolled.
 282            star: Whether outcomes should be unpacked into separate arguments
 283                before sending them to a callable `which`.
 284                If not provided, this will be guessed based on the function
 285                signature.
 286            depth: The maximum number of times to reroll.
 287                If `None`, rerolls an unlimited number of times.
 288
 289        Returns:
 290            A `Die` representing the reroll.
 291            If the reroll would never terminate, the result has no outcomes.
 292        """
 293
 294        if outcomes is None:
 295            outcome_set = {self.min_outcome()}
 296        else:
 297            outcome_set = self._select_outcomes(outcomes, star)
 298
 299        if depth == 'inf':
 300            data = {
 301                outcome: quantity
 302                for outcome, quantity in self.items()
 303                if outcome not in outcome_set
 304            }
 305        elif depth < 0:
 306            raise ValueError('reroll depth cannot be negative.')
 307        else:
 308            total_reroll_quantity = sum(quantity
 309                                        for outcome, quantity in self.items()
 310                                        if outcome in outcome_set)
 311            total_stop_quantity = self.denominator() - total_reroll_quantity
 312            rerollable_factor = total_reroll_quantity**depth
 313            stop_factor = (self.denominator()**(depth + 1) - rerollable_factor
 314                           * total_reroll_quantity) // total_stop_quantity
 315            data = {
 316                outcome: (rerollable_factor *
 317                          quantity if outcome in outcome_set else stop_factor *
 318                          quantity)
 319                for outcome, quantity in self.items()
 320            }
 321        return icepool.Die(data)
 322
 323    def filter(self,
 324               outcomes: Callable[..., bool] | Collection[T_co],
 325               /,
 326               *,
 327               star: bool | None = None,
 328               depth: int | Literal['inf']) -> 'Die[T_co]':
 329        """Rerolls until getting one of the given outcomes.
 330
 331        Essentially the complement of `reroll()`.
 332
 333        Args:
 334            outcomes: Selects which outcomes to reroll until. Options:
 335                * A callable that takes an outcome and returns `True` if it
 336                    should be accepted.
 337                * A collection of outcomes to reroll until.
 338            star: Whether outcomes should be unpacked into separate arguments
 339                before sending them to a callable `which`.
 340                If not provided, this will be guessed based on the function
 341                signature.
 342            depth: The maximum number of times to reroll.
 343                If `None`, rerolls an unlimited number of times.
 344
 345        Returns:
 346            A `Die` representing the reroll.
 347            If the reroll would never terminate, the result has no outcomes.
 348        """
 349
 350        if callable(outcomes):
 351            if star is None:
 352                star = infer_star(outcomes)
 353            if star:
 354
 355                not_outcomes = {
 356                    outcome
 357                    for outcome in self.outcomes()
 358                    if not outcomes(*outcome)  # type: ignore
 359                }
 360            else:
 361                not_outcomes = {
 362                    outcome
 363                    for outcome in self.outcomes() if not outcomes(outcome)
 364                }
 365        else:
 366            not_outcomes = {
 367                not_outcome
 368                for not_outcome in self.outcomes()
 369                if not_outcome not in outcomes
 370            }
 371        return self.reroll(not_outcomes, depth=depth)
 372
 373    def truncate(self, min_outcome=None, max_outcome=None) -> 'Die[T_co]':
 374        """Truncates the outcomes of this `Die` to the given range.
 375
 376        The endpoints are included in the result if applicable.
 377        If one of the arguments is not provided, that side will not be truncated.
 378
 379        This effectively rerolls outcomes outside the given range.
 380        If instead you want to replace those outcomes with the nearest endpoint,
 381        use `clip()`.
 382
 383        Not to be confused with `trunc(die)`, which performs integer truncation
 384        on each outcome.
 385        """
 386        if min_outcome is not None:
 387            start = bisect.bisect_left(self.outcomes(), min_outcome)
 388        else:
 389            start = None
 390        if max_outcome is not None:
 391            stop = bisect.bisect_right(self.outcomes(), max_outcome)
 392        else:
 393            stop = None
 394        data = {k: v for k, v in self.items()[start:stop]}
 395        return icepool.Die(data)
 396
 397    def clip(self, min_outcome=None, max_outcome=None) -> 'Die[T_co]':
 398        """Clips the outcomes of this `Die` to the given values.
 399
 400        The endpoints are included in the result if applicable.
 401        If one of the arguments is not provided, that side will not be clipped.
 402
 403        This is not the same as rerolling outcomes beyond this range;
 404        the outcome is simply adjusted to fit within the range.
 405        This will typically cause some quantity to bunch up at the endpoint(s).
 406        If you want to reroll outcomes beyond this range, use `truncate()`.
 407        """
 408        data: MutableMapping[Any, int] = defaultdict(int)
 409        for outcome, quantity in self.items():
 410            if min_outcome is not None and outcome <= min_outcome:
 411                data[min_outcome] += quantity
 412            elif max_outcome is not None and outcome >= max_outcome:
 413                data[max_outcome] += quantity
 414            else:
 415                data[outcome] += quantity
 416        return icepool.Die(data)
 417
 418    @cached_property
 419    def _popped_min(self) -> tuple['Die[T_co]', int]:
 420        die = Die._new_raw(self._data.remove_min())
 421        return die, self.quantities()[0]
 422
 423    def _pop_min(self) -> tuple['Die[T_co]', int]:
 424        """A `Die` with the min outcome removed, and the quantity of the removed outcome.
 425
 426        Raises:
 427            IndexError: If this `Die` has no outcome to pop.
 428        """
 429        return self._popped_min
 430
 431    @cached_property
 432    def _popped_max(self) -> tuple['Die[T_co]', int]:
 433        die = Die._new_raw(self._data.remove_max())
 434        return die, self.quantities()[-1]
 435
 436    def _pop_max(self) -> tuple['Die[T_co]', int]:
 437        """A `Die` with the max outcome removed, and the quantity of the removed outcome.
 438
 439        Raises:
 440            IndexError: If this `Die` has no outcome to pop.
 441        """
 442        return self._popped_max
 443
 444    # Processes.
 445    @overload
 446    def map(
 447            self,
 448            repl:
 449        'Callable[..., U | Die[U] | icepool.RerollType | icepool.AgainExpression] | Mapping[T_co, U | Die[U] | icepool.RerollType | icepool.AgainExpression]',
 450            /,
 451            *extra_args,
 452            star: bool | None = None,
 453            repeat: None = None,
 454            again_count: int | None = None,
 455            again_depth: int | None = None,
 456            again_end: 'U | Die[U] | icepool.RerollType | None' = None,
 457            **kwargs) -> 'Die[U]':
 458        ...
 459
 460    @overload
 461    def map(
 462            self,
 463            repl:
 464        'Callable[..., T_co | Die[T_co] | icepool.RerollType | icepool.AgainExpression] | Mapping[T_co, T_co | Die[T_co] | icepool.RerollType | icepool.AgainExpression]',
 465            /,
 466            *extra_args,
 467            star: bool | None = None,
 468            repeat: int | Literal['inf'],
 469            **kwargs) -> 'Die[T_co]':
 470        ...
 471
 472    def map(
 473            self,
 474            repl:
 475        'Callable[..., U | Die[U] | icepool.RerollType | icepool.AgainExpression] | Mapping[T_co, U | Die[U] | icepool.RerollType | icepool.AgainExpression]',
 476            /,
 477            *extra_args,
 478            star: bool | None = None,
 479            repeat: int | Literal['inf'] | None = None,
 480            again_count: int | None = None,
 481            again_depth: int | None = None,
 482            again_end: 'U | Die[U] | icepool.RerollType | None' = None,
 483            **kwargs) -> 'Die[U]':
 484        """Maps outcomes of the `Die` to other outcomes.
 485
 486        This is also useful for representing processes.
 487
 488        As `icepool.map(repl, self, ...)`.
 489        """
 490        return icepool.map(
 491            repl,
 492            self,
 493            *extra_args,
 494            star=star,
 495            repeat=repeat,  # type:ignore
 496            again_count=again_count,
 497            again_depth=again_depth,
 498            again_end=again_end,
 499            **kwargs)  # type:ignore
 500
 501    def map_and_time(
 502            self,
 503            repl:
 504        'Callable[..., T_co | Die[T_co] | icepool.RerollType] | Mapping[T_co, T_co | Die[T_co] | icepool.RerollType]',
 505            /,
 506            *extra_args,
 507            star: bool | None = None,
 508            repeat: int,
 509            **kwargs) -> 'Die[tuple[T_co, int]]':
 510        """Repeatedly map outcomes of the state to other outcomes, while also
 511        counting timesteps.
 512
 513        This is useful for representing processes.
 514
 515        As `map_and_time(repl, self, ...)`.
 516        """
 517        return icepool.map_and_time(repl,
 518                                    self,
 519                                    *extra_args,
 520                                    star=star,
 521                                    repeat=repeat,
 522                                    **kwargs)
 523
 524    def mean_time_to_absorb(
 525            self,
 526            repl:
 527        'Callable[..., T_co | icepool.Die[T_co] | icepool.RerollType] | Mapping[Any, T_co | icepool.Die[T_co] | icepool.RerollType]',
 528            /,
 529            *extra_args,
 530            star: bool | None = None,
 531            **kwargs) -> Fraction:
 532        """EXPERIMENTAL: The mean time for the process to reach an absorbing state.
 533        
 534        As `mean_time_to_absorb(repl, self, ...)`.
 535        """
 536        return icepool.mean_time_to_absorb(repl,
 537                                           self,
 538                                           *extra_args,
 539                                           star=star,
 540                                           **kwargs)
 541
 542    def time_to_sum(self: 'Die[int]',
 543                    target: int,
 544                    /,
 545                    max_time: int | None = None,
 546                    dnf: 'int|icepool.RerollType|None' = None) -> 'Die[int]':
 547        """The number of rolls until the cumulative sum is greater or equal to the target.
 548        
 549        Args:
 550            target: The number to stop at once reached.
 551            max_time: The maximum number of rolls to run.
 552                If the sum is not reached, the outcome is determined by `dnf`.
 553            dnf: What time to assign in cases where the target was not reached
 554                in `max_time`. If not provided, this is set to `max_time`.
 555                `dnf=icepool.Reroll` will remove this case from the result,
 556                effectively rerolling it.
 557        """
 558        if target <= 0:
 559            return Die([0])
 560
 561        if max_time is None:
 562            if self.min_outcome() <= 0:
 563                raise ValueError(
 564                    'max_time must be provided if not all outcomes are positive.'
 565                )
 566            max_time = (target + self.min_outcome() - 1) // self.min_outcome()
 567
 568        if dnf is None:
 569            dnf = max_time
 570
 571        def step(total, roll):
 572            return min(total + roll, target)
 573
 574        result: 'Die[tuple[int, int]]' = Die([0]).map_and_time(step,
 575                                                               self,
 576                                                               repeat=max_time)
 577
 578        def get_time(total, time):
 579            if total < target:
 580                return dnf
 581            else:
 582                return time
 583
 584        return result.map(get_time)
 585
 586    @cached_property
 587    def _mean_time_to_sum_cache(self) -> list[Fraction]:
 588        return [Fraction(0)]
 589
 590    def mean_time_to_sum(self: 'Die[int]', target: int, /) -> Fraction:
 591        """The mean number of rolls until the cumulative sum is greater or equal to the target.
 592
 593        Args:
 594            target: The target sum.
 595
 596        Raises:
 597            ValueError: If `self` has negative outcomes.
 598            ZeroDivisionError: If `self.mean() == 0`.
 599        """
 600        target = max(target, 0)
 601
 602        if target < len(self._mean_time_to_sum_cache):
 603            return self._mean_time_to_sum_cache[target]
 604
 605        if self.min_outcome() < 0:
 606            raise ValueError(
 607                'mean_time_to_sum does not handle negative outcomes.')
 608        time_per_effect = Fraction(self.denominator(),
 609                                   self.denominator() - self.quantity(0))
 610
 611        for i in range(len(self._mean_time_to_sum_cache), target + 1):
 612            result = time_per_effect + self.reroll([
 613                0
 614            ], depth='inf').map(lambda x: self.mean_time_to_sum(i - x)).mean()
 615            self._mean_time_to_sum_cache.append(result)
 616
 617        return result
 618
 619    def explode(self,
 620                outcomes: Collection[T_co] | Callable[..., bool] | None = None,
 621                /,
 622                *,
 623                star: bool | None = None,
 624                depth: int = 9,
 625                end=None) -> 'Die[T_co]':
 626        """Causes outcomes to be rolled again and added to the total.
 627
 628        Args:
 629            outcomes: Which outcomes to explode. Options:
 630                * An collection of outcomes to explode.
 631                * A callable that takes an outcome and returns `True` if it
 632                    should be exploded.
 633                * If not supplied, the max outcome will explode.
 634            star: Whether outcomes should be unpacked into separate arguments
 635                before sending them to a callable `which`.
 636                If not provided, this will be guessed based on the function
 637                signature.
 638            depth: The maximum number of additional dice to roll, not counting
 639                the initial roll.
 640                If not supplied, a default value will be used.
 641            end: Once `depth` is reached, further explosions will be treated
 642                as this value. By default, a zero value will be used.
 643                `icepool.Reroll` will make one extra final roll, rerolling until
 644                a non-exploding outcome is reached.
 645        """
 646
 647        if outcomes is None:
 648            outcome_set = {self.max_outcome()}
 649        else:
 650            outcome_set = self._select_outcomes(outcomes, star)
 651
 652        if depth < 0:
 653            raise ValueError('depth cannot be negative.')
 654        elif depth == 0:
 655            return self
 656
 657        def map_final(outcome):
 658            if outcome in outcome_set:
 659                return outcome + icepool.Again
 660            else:
 661                return outcome
 662
 663        return self.map(map_final, again_depth=depth, again_end=end)
 664
 665    def if_else(
 666        self,
 667        outcome_if_true: U | 'Die[U]',
 668        outcome_if_false: U | 'Die[U]',
 669        *,
 670        again_count: int | None = None,
 671        again_depth: int | None = None,
 672        again_end: 'U | Die[U] | icepool.RerollType | None' = None
 673    ) -> 'Die[U]':
 674        """Ternary conditional operator.
 675
 676        This replaces truthy outcomes with the first argument and falsy outcomes
 677        with the second argument.
 678
 679        Args:
 680            again_count, again_depth, again_end: Forwarded to the final die constructor.
 681        """
 682        return self.map(lambda x: bool(x)).map(
 683            {
 684                True: outcome_if_true,
 685                False: outcome_if_false
 686            },
 687            again_count=again_count,
 688            again_depth=again_depth,
 689            again_end=again_end)
 690
 691    def is_in(self, outcomes: Container[T_co], /) -> 'Die[bool]':
 692        """A die that returns True iff the roll of the die is contained in the target."""
 693        return self.map(lambda x: x in outcomes)
 694
 695    def count(self, rolls: int, outcomes: Container[T_co], /) -> 'Die[int]':
 696        """Roll this dice a number of times and count how many are in the target."""
 697        return rolls @ self.is_in(outcomes)
 698
 699    # Pools and sums.
 700
 701    @cached_property
 702    def _sum_cache(self) -> MutableMapping[int, 'Die']:
 703        return {}
 704
 705    def _sum_all(self, rolls: int, /) -> 'Die':
 706        """Roll this `Die` `rolls` times and sum the results.
 707
 708        The sum is computed one at a time, with each additional item on the 
 709        right, similar to `functools.reduce()`.
 710
 711        If `rolls` is negative, roll the `Die` `abs(rolls)` times and negate
 712        the result.
 713
 714        If you instead want to replace tuple (or other sequence) outcomes with
 715        their sum, use `die.map(sum)`.
 716        """
 717        if rolls in self._sum_cache:
 718            return self._sum_cache[rolls]
 719
 720        if rolls < 0:
 721            result = -self._sum_all(-rolls)
 722        elif rolls == 0:
 723            result = self.zero().simplify()
 724        elif rolls == 1:
 725            result = self
 726        else:
 727            # In addition to working similar to reduce(), this seems to perform
 728            # better than binary split.
 729            # Iterative rather than recursive so that large counts don't hit
 730            # the recursion limit: count down to the largest sum already
 731            # computed, then add one roll at a time, caching each sum.
 732            pending = []
 733            k = rolls - 1
 734            while k not in self._sum_cache and k > 1:
 735                pending.append(k)
 736                k -= 1
 737            result = self._sum_all(k)
 738            for k in reversed(pending):
 739                result = result + self
 740                self._sum_cache[k] = result
 741            result = result + self
 742
 743        self._sum_cache[rolls] = result
 744        return result
 745
 746    def __matmul__(self: 'Die[int]', other) -> 'Die':
 747        """Roll the left `Die`, then roll the right `Die` that many times and sum the outcomes.
 748        
 749        The sum is computed one at a time, with each additional item on the 
 750        right, similar to `functools.reduce()`.
 751        """
 752        if isinstance(other, icepool.AgainExpression):
 753            return NotImplemented
 754        other = implicit_convert_to_die(other)
 755
 756        data: MutableMapping[int, Any] = defaultdict(int)
 757
 758        max_abs_die_count = max(abs(self.min_outcome()),
 759                                abs(self.max_outcome()))
 760        for die_count, die_count_quantity in self.items():
 761            factor = other.denominator()**(max_abs_die_count - abs(die_count))
 762            subresult = other._sum_all(die_count)
 763            for outcome, subresult_quantity in subresult.items():
 764                data[
 765                    outcome] += subresult_quantity * die_count_quantity * factor
 766
 767        return icepool.Die(data)
 768
 769    def __rmatmul__(self, other: 'int | Die[int]') -> 'Die':
 770        """Roll the left `Die`, then roll the right `Die` that many times and sum the outcomes.
 771        
 772        The sum is computed one at a time, with each additional item on the 
 773        right, similar to `functools.reduce()`.
 774        """
 775        if isinstance(other, icepool.AgainExpression):
 776            return NotImplemented
 777        other = implicit_convert_to_die(other)
 778        return other.__matmul__(self)
 779
 780    def sequence(self, rolls: int) -> 'icepool.Die[tuple[T_co, ...]]':
 781        """Possible sequences produced by rolling this die a number of times.
 782        
 783        This is extremely expensive computationally. If possible, use `reduce()`
 784        instead; if you don't care about order, `Die.pool()` is better.
 785        """
 786        return icepool.cartesian_product(*(self for _ in range(rolls)),
 787                                         outcome_type=tuple)  # type: ignore
 788
 789    def pool(self, rolls: int | Sequence[int] = 1, /) -> 'icepool.Pool[T_co]':
 790        """Creates a `Pool` from this `Die`.
 791
 792        You might subscript the pool immediately afterwards, e.g.
 793        `d6.pool(5)[-1, ..., 1]` takes the difference between the highest and
 794        lowest of 5d6.
 795
 796        Args:
 797            rolls: The number of copies of this `Die` to put in the pool.
 798                Or, a sequence of one `int` per die acting as
 799                `keep_tuple`. Note that `...` cannot be used in the
 800                argument to this method, as the argument determines the size of
 801                the pool.
 802        """
 803        if isinstance(rolls, int):
 804            return icepool.Pool({self: rolls})
 805        else:
 806            pool_size = len(rolls)
 807            # Haven't dealt with narrowing return type.
 808            return icepool.Pool({self: pool_size})[rolls]  # type: ignore
 809
 810    @overload
 811    def keep(self, rolls: Sequence[int], /) -> 'Die':
 812        """Selects elements after drawing and sorting and sums them.
 813
 814        Args:
 815            rolls: A sequence of `int` specifying how many times to count each
 816                element in ascending order.
 817        """
 818
 819    @overload
 820    def keep(self, rolls: int,
 821             index: slice | Sequence[int | EllipsisType] | int, /):
 822        """Selects elements after drawing and sorting and sums them.
 823
 824        Args:
 825            rolls: The number of dice to roll.
 826            index: One of the following:
 827            * An `int`. This will count only the roll at the specified index.
 828                In this case, the result is a `Die` rather than a generator.
 829            * A `slice`. The selected dice are counted once each.
 830            * A sequence of one `int` for each `Die`.
 831                Each roll is counted that many times, which could be multiple or
 832                negative times.
 833
 834                Up to one `...` (`Ellipsis`) may be used.
 835                `...` will be replaced with a number of zero
 836                counts depending on the `rolls`.
 837                This number may be "negative" if more `int`s are provided than
 838                `rolls`. Specifically:
 839
 840                * If `index` is shorter than `rolls`, `...`
 841                    acts as enough zero counts to make up the difference.
 842                    E.g. `(1, ..., 1)` on five dice would act as
 843                    `(1, 0, 0, 0, 1)`.
 844                * If `index` has length equal to `rolls`, `...` has no effect.
 845                    E.g. `(1, ..., 1)` on two dice would act as `(1, 1)`.
 846                * If `index` is longer than `rolls` and `...` is on one side,
 847                    elements will be dropped from `index` on the side with `...`.
 848                    E.g. `(..., 1, 2, 3)` on two dice would act as `(2, 3)`.
 849                * If `index` is longer than `rolls` and `...`
 850                    is in the middle, the counts will be as the sum of two
 851                    one-sided `...`.
 852                    E.g. `(-1, ..., 1)` acts like `(-1, ...)` plus `(..., 1)`.
 853                    If `rolls` was 1 this would have the -1 and 1 cancel each other out.
 854        """
 855
 856    def keep(self,
 857             rolls: int | Sequence[int],
 858             index: slice | Sequence[int | EllipsisType] | int | None = None,
 859             /) -> 'Die':
 860        """Selects elements after drawing and sorting and sums them.
 861
 862        Args:
 863            rolls: The number of dice to roll.
 864            index: One of the following:
 865            * An `int`. This will count only the roll at the specified index.
 866            In this case, the result is a `Die` rather than a generator.
 867            * A `slice`. The selected dice are counted once each.
 868            * A sequence of `int`s with length equal to `rolls`.
 869                Each roll is counted that many times, which could be multiple or
 870                negative times.
 871
 872                Up to one `...` (`Ellipsis`) may be used. If no `...` is used,
 873                the `rolls` argument may be omitted.
 874
 875                `...` will be replaced with a number of zero counts in order
 876                to make up any missing elements compared to `rolls`.
 877                This number may be "negative" if more `int`s are provided than
 878                `rolls`. Specifically:
 879
 880                * If `index` is shorter than `rolls`, `...`
 881                    acts as enough zero counts to make up the difference.
 882                    E.g. `(1, ..., 1)` on five dice would act as
 883                    `(1, 0, 0, 0, 1)`.
 884                * If `index` has length equal to `rolls`, `...` has no effect.
 885                    E.g. `(1, ..., 1)` on two dice would act as `(1, 1)`.
 886                * If `index` is longer than `rolls` and `...` is on one side,
 887                    elements will be dropped from `index` on the side with `...`.
 888                    E.g. `(..., 1, 2, 3)` on two dice would act as `(2, 3)`.
 889                * If `index` is longer than `rolls` and `...`
 890                    is in the middle, the counts will be as the sum of two
 891                    one-sided `...`.
 892                    E.g. `(-1, ..., 1)` acts like `(-1, ...)` plus `(..., 1)`.
 893                    If `rolls` was 1 this would have the -1 and 1 cancel each other out.
 894        """
 895        if isinstance(rolls, int):
 896            if index is None:
 897                raise ValueError(
 898                    'If the number of rolls is an integer, an index argument must be provided.'
 899                )
 900            if isinstance(index, int):
 901                return self.pool(rolls).keep(index)
 902            else:
 903                return self.pool(rolls).keep(index).sum()  # type: ignore
 904        else:
 905            if index is not None:
 906                raise ValueError('Only one index sequence can be given.')
 907            return self.pool(len(rolls)).keep(rolls).sum()  # type: ignore
 908
 909    def lowest(self,
 910               rolls: int,
 911               /,
 912               keep: int | None = None,
 913               drop: int | None = None) -> 'Die':
 914        """Roll several of this `Die` and return the lowest result, or the sum of some of the lowest.
 915
 916        The outcomes should support addition and multiplication if `keep != 1`.
 917
 918        Args:
 919            rolls: The number of dice to roll. All dice will have the same
 920                outcomes as `self`.
 921            keep, drop: These arguments work together:
 922                * If neither are provided, the single lowest die will be taken.
 923                * If only `keep` is provided, the `keep` lowest dice will be summed.
 924                * If only `drop` is provided, the `drop` lowest dice will be dropped
 925                    and the rest will be summed.
 926                * If both are provided, `drop` lowest dice will be dropped, then
 927                    the next `keep` lowest dice will be summed.
 928
 929        Returns:
 930            A `Die` representing the probability distribution of the sum.
 931        """
 932        index = lowest_slice(keep, drop)
 933        canonical = canonical_slice(index, rolls)
 934        if canonical.start == 0 and canonical.stop == 1:
 935            return self._lowest_single(rolls)
 936        # Expression evaluators are difficult to type.
 937        return self.pool(rolls)[index].sum()  # type: ignore
 938
 939    def _lowest_single(self, rolls: int, /) -> 'Die':
 940        """Roll this die several times and keep the lowest."""
 941        if rolls == 0:
 942            return self.zero().simplify()
 943        return icepool.from_cumulative(
 944            self.outcomes(), [x**rolls for x in self.quantities('>=')],
 945            reverse=True)
 946
 947    def highest(self,
 948                rolls: int,
 949                /,
 950                keep: int | None = None,
 951                drop: int | None = None) -> 'Die[T_co]':
 952        """Roll several of this `Die` and return the highest result, or the sum of some of the highest.
 953
 954        The outcomes should support addition and multiplication if `keep != 1`.
 955
 956        Args:
 957            rolls: The number of dice to roll.
 958            keep, drop: These arguments work together:
 959                * If neither are provided, the single highest die will be taken.
 960                * If only `keep` is provided, the `keep` highest dice will be summed.
 961                * If only `drop` is provided, the `drop` highest dice will be dropped
 962                    and the rest will be summed.
 963                * If both are provided, `drop` highest dice will be dropped, then
 964                    the next `keep` highest dice will be summed.
 965
 966        Returns:
 967            A `Die` representing the probability distribution of the sum.
 968        """
 969        index = highest_slice(keep, drop)
 970        canonical = canonical_slice(index, rolls)
 971        if canonical.start == rolls - 1 and canonical.stop == rolls:
 972            return self._highest_single(rolls)
 973        # Expression evaluators are difficult to type.
 974        return self.pool(rolls)[index].sum()  # type: ignore
 975
 976    def _highest_single(self, rolls: int, /) -> 'Die[T_co]':
 977        """Roll this die several times and keep the highest."""
 978        if rolls == 0:
 979            return self.zero().simplify()
 980        return icepool.from_cumulative(
 981            self.outcomes(), [x**rolls for x in self.quantities('<=')])
 982
 983    def middle(
 984            self,
 985            rolls: int,
 986            /,
 987            keep: int = 1,
 988            *,
 989            tie: Literal['error', 'high', 'low'] = 'error') -> 'icepool.Die':
 990        """Roll several of this `Die` and sum the sorted results in the middle.
 991
 992        The outcomes should support addition and multiplication if `keep != 1`.
 993
 994        Args:
 995            rolls: The number of dice to roll.
 996            keep: The number of outcomes to sum. If this is greater than the
 997                current keep_size, all are kept.
 998            tie: What to do if `keep` is odd but the current keep_size
 999                is even, or vice versa.
1000                * 'error' (default): Raises `IndexError`.
1001                * 'high': The higher outcome is taken.
1002                * 'low': The lower outcome is taken.
1003        """
1004        # Expression evaluators are difficult to type.
1005        return self.pool(rolls).middle(keep, tie=tie).sum()  # type: ignore
1006
1007    def map_to_pool(
1008            self,
1009            repl:
1010        'Callable[..., Sequence[icepool.Die[U] | U] | Mapping[icepool.Die[U], int] | Mapping[U, int] | icepool.RerollType] | None' = None,
1011            /,
1012            *extra_args: 'Outcome | icepool.Die | icepool.MultisetExpression',
1013            star: bool | None = None,
1014            **kwargs) -> 'icepool.MultisetExpression[U]':
1015        """EXPERIMENTAL: Maps outcomes of this `Die` to `Pools`, creating a `MultisetGenerator`.
1016
1017        As `icepool.map_to_pool(repl, self, ...)`.
1018
1019        If no argument is provided, the outcomes will be used to construct a
1020        mixture of pools directly, similar to the inverse of `pool.expand()`.
1021        Note that this is not particularly efficient since it does not make much
1022        use of dynamic programming.
1023        """
1024        if repl is None:
1025            repl = lambda x: x
1026        return icepool.map_to_pool(repl,
1027                                   self,
1028                                   *extra_args,
1029                                   star=star,
1030                                   **kwargs)
1031
1032    def explode_to_pool(self,
1033                        rolls: int = 1,
1034                        outcomes: Collection[T_co] | Callable[..., bool]
1035                        | None = None,
1036                        /,
1037                        *,
1038                        star: bool | None = None,
1039                        depth: int = 9) -> 'icepool.MultisetExpression[T_co]':
1040        """EXPERIMENTAL: Causes outcomes to be rolled again, keeping that outcome as an individual die in a pool.
1041        
1042        Args:
1043            rolls: The number of initial dice.
1044            outcomes: Which outcomes to explode. Options:
1045                * A single outcome to explode.
1046                * An collection of outcomes to explode.
1047                * A callable that takes an outcome and returns `True` if it
1048                    should be exploded.
1049                * If not supplied, the max outcome will explode.
1050            star: Whether outcomes should be unpacked into separate arguments
1051                before sending them to a callable `which`.
1052                If not provided, this will be guessed based on the function
1053                signature.
1054            depth: The maximum depth of explosions for an individual dice.
1055
1056        Returns:
1057            A `MultisetGenerator` representing the mixture of `Pool`s. Note  
1058            that this is not technically a `Pool`, though it supports most of 
1059            the same operations.
1060        """
1061        if depth == 0:
1062            return self.pool(rolls)
1063        if outcomes is None:
1064            explode_set = {self.max_outcome()}
1065        else:
1066            explode_set = self._select_outcomes(outcomes, star)
1067        if not explode_set:
1068            return self.pool(rolls)
1069        explode: 'Die[T_co]'
1070        not_explode: 'Die[T_co]'
1071        explode, not_explode = self.split(explode_set)
1072
1073        single_data: 'MutableMapping[icepool.Vector[int], int]' = defaultdict(
1074            int)
1075        for i in range(depth + 1):
1076            weight = explode.denominator()**i * self.denominator()**(
1077                depth - i) * not_explode.denominator()
1078            single_data[icepool.Vector((i, 1))] += weight
1079        single_data[icepool.Vector(
1080            (depth + 1, 0))] += explode.denominator()**(depth + 1)
1081
1082        single_count_die: 'Die[icepool.Vector[int]]' = Die(single_data)
1083        count_die = rolls @ single_count_die
1084
1085        return count_die.map_to_pool(
1086            lambda x, nx: [explode] * x + [not_explode] * nx)
1087
1088    def reroll_to_pool(
1089        self,
1090        rolls: int,
1091        outcomes: Callable[..., bool] | Collection[T_co] | None = None,
1092        /,
1093        *,
1094        max_rerolls: int | Literal['inf'],
1095        star: bool | None = None,
1096        depth: int | Literal['inf'] = 1,
1097        mode: Literal['random', 'low', 'high', 'drop'] = 'random'
1098    ) -> 'icepool.MultisetExpression[T_co]':
1099        """EXPERIMENTAL: Applies a limited number of rerolls shared across a pool.
1100
1101        Each die can only be rerolled once (effectively `depth=1`), and no more
1102        than `max_rerolls` dice may be rerolled.
1103        
1104        Args:
1105            rolls: How many dice in the pool.
1106            outcomes: Selects which outcomes are eligible to be rerolled.
1107                Options:
1108                * A collection of outcomes to reroll.
1109                * A callable that takes an outcome and returns `True` if it
1110                    could be rerolled.
1111                * If not provided, the single minimum outcome will be rerolled.
1112            max_rerolls: The maximum total number of rerolls.
1113                If `max_rerolls == 'inf'`, then this is the same as 
1114                `self.reroll(which, star=star, depth=depth).pool(rolls)`.
1115            depth: EXTRA EXPERIMENTAL: The maximum depth of rerolls.
1116            star: Whether outcomes should be unpacked into separate arguments
1117                before sending them to a callable `which`.
1118                If not provided, this will be guessed based on the function
1119                signature.
1120            mode: How dice are selected for rerolling if there are more eligible
1121                dice than `max_rerolls`. Options:
1122                * `'random'` (default): Eligible dice will be chosen uniformly
1123                    at random.
1124                * `'low'`: The lowest eligible dice will be rerolled.
1125                * `'high'`: The highest eligible dice will be rerolled.
1126                * `'drop'`: All dice that ended up on an outcome selected by 
1127                    `which` will be dropped. This includes both dice that rolled
1128                    into `which` initially and were not rerolled, and dice that
1129                    were rerolled but rolled into `which` again. This can be
1130                    considerably more efficient than the other modes.
1131
1132        Returns:
1133            A `MultisetGenerator` representing the mixture of `Pool`s. Note  
1134            that this is not technically a `Pool`, though it supports most of 
1135            the same operations.
1136        """
1137        if max_rerolls == 'inf':
1138            return self.reroll(outcomes, star=star, depth=depth).pool(rolls)
1139
1140        if outcomes is None:
1141            rerollable_set = {self.min_outcome()}
1142        else:
1143            rerollable_set = self._select_outcomes(outcomes, star)
1144            if not rerollable_set:
1145                return self.pool(rolls)
1146
1147        rerollable_die: 'Die[T_co]'
1148        not_rerollable_die: 'Die[T_co]'
1149        rerollable_die, not_rerollable_die = self.split(rerollable_set)
1150        single_is_rerollable = icepool.coin(rerollable_die.denominator(),
1151                                            self.denominator())
1152
1153        if depth == 'inf':
1154            depth = max_rerolls
1155
1156        def step(rerollable, rerolls_left):
1157            """Advances one step of rerolling if there are enough rerolls left to cover all rerollable dice.
1158            
1159            Returns:
1160                The number of dice showing rerollable outcomes and the number of remaining rerolls.
1161            """
1162            if rerollable == 0:
1163                return 0, 0
1164            if rerolls_left < rerollable:
1165                return rerollable, rerolls_left
1166
1167            return icepool.tupleize(rerollable @ single_is_rerollable,
1168                                    rerolls_left - rerollable)
1169
1170        initial_state = icepool.tupleize(rolls @ single_is_rerollable,
1171                                         max_rerolls)
1172        mid_pool_composition: Die[tuple[int, int]]
1173        mid_pool_composition = icepool.map(step,
1174                                           initial_state,
1175                                           star=True,
1176                                           repeat=depth - 1)
1177
1178        def final_step(rerollable, rerolls_left):
1179            """Performs the final reroll, which might not have enough rerolls to cover all rerollable dice.
1180            
1181            Returns: The number of dice that had a rerollable outcome,
1182                the number of dice that were rerolled due to max_rerolls,
1183                the number of rerolled dice that landed on a rerollable outcome
1184                again.
1185            """
1186            rerolled = min(rerollable, rerolls_left)
1187
1188            return icepool.tupleize(rerollable, rerolled,
1189                                    rerolled @ single_is_rerollable)
1190
1191        pool_composition: Die[tuple[int, int, int]] = mid_pool_composition.map(
1192            final_step, star=True)
1193
1194        denominator = self.denominator()**(rolls + max_rerolls)
1195        pool_composition = pool_composition.multiply_to_denominator(
1196            denominator)
1197
1198        def make_pool(rerollable, rerolled, rerolled_to_rerollable):
1199            rerolls_ran_out = rerollable - rerolled
1200            not_rerollable = rolls - rerolls_ran_out - rerolled_to_rerollable
1201            common = rerollable_die.pool(
1202                rerolled_to_rerollable) + not_rerollable_die.pool(
1203                    not_rerollable)
1204            match mode:
1205                case 'random':
1206                    return common + rerollable_die.pool(rerolls_ran_out)
1207                case 'low':
1208                    return common + rerollable_die.pool(rerollable).highest(
1209                        rerolls_ran_out)
1210                case 'high':
1211                    return common + rerollable_die.pool(rerollable).lowest(
1212                        rerolls_ran_out)
1213                case 'drop':
1214                    return not_rerollable_die.pool(not_rerollable)
1215                case _:
1216                    raise ValueError(
1217                        f"Invalid reroll_priority '{mode}'. Allowed values are 'random', 'low', 'high', 'drop'."
1218                    )
1219
1220        return pool_composition.map_to_pool(make_pool, star=True)
1221
1222    # Unary operators.
1223
1224    def __neg__(self) -> 'Die[T_co]':
1225        return self.unary_operator(operator.neg)
1226
1227    def __pos__(self) -> 'Die[T_co]':
1228        return self.unary_operator(operator.pos)
1229
1230    def __invert__(self) -> 'Die[T_co]':
1231        return self.unary_operator(operator.invert)
1232
1233    def abs(self) -> 'Die[T_co]':
1234        return self.unary_operator(operator.abs)
1235
1236    __abs__ = abs
1237
1238    def round(self, ndigits: int | None = None) -> 'Die':
1239        return self.unary_operator(round, ndigits)
1240
1241    __round__ = round
1242
1243    def stochastic_round(self,
1244                         *,
1245                         max_denominator: int | None = None) -> 'Die[int]':
1246        """Randomly rounds outcomes up or down to the nearest integer according to the two distances.
1247        
1248        Specificially, rounds `x` up with probability `x - floor(x)` and down
1249        otherwise.
1250
1251        Args:
1252            max_denominator: If provided, each rounding will be performed
1253                using `fractions.Fraction.limit_denominator(max_denominator)`.
1254                Otherwise, the rounding will be performed without
1255                `limit_denominator`.
1256        """
1257        return self.map(lambda x: icepool.stochastic_round(
1258            x, max_denominator=max_denominator))
1259
1260    def trunc(self) -> 'Die':
1261        return self.unary_operator(math.trunc)
1262
1263    __trunc__ = trunc
1264
1265    def floor(self) -> 'Die':
1266        return self.unary_operator(math.floor)
1267
1268    __floor__ = floor
1269
1270    def ceil(self) -> 'Die':
1271        return self.unary_operator(math.ceil)
1272
1273    __ceil__ = ceil
1274
1275    # Binary operators.
1276
1277    def __add__(self, other) -> 'Die':
1278        if isinstance(other, icepool.AgainExpression):
1279            return NotImplemented
1280        other = implicit_convert_to_die(other)
1281        return self.binary_operator(other, operator.add)
1282
1283    def __radd__(self, other) -> 'Die':
1284        if isinstance(other, icepool.AgainExpression):
1285            return NotImplemented
1286        other = implicit_convert_to_die(other)
1287        return other.binary_operator(self, operator.add)
1288
1289    def __sub__(self, other) -> 'Die':
1290        if isinstance(other, icepool.AgainExpression):
1291            return NotImplemented
1292        other = implicit_convert_to_die(other)
1293        return self.binary_operator(other, operator.sub)
1294
1295    def __rsub__(self, other) -> 'Die':
1296        if isinstance(other, icepool.AgainExpression):
1297            return NotImplemented
1298        other = implicit_convert_to_die(other)
1299        return other.binary_operator(self, operator.sub)
1300
1301    def __mul__(self, other) -> 'Die':
1302        if isinstance(other, icepool.AgainExpression):
1303            return NotImplemented
1304        other = implicit_convert_to_die(other)
1305        return self.binary_operator(other, operator.mul)
1306
1307    def __rmul__(self, other) -> 'Die':
1308        if isinstance(other, icepool.AgainExpression):
1309            return NotImplemented
1310        other = implicit_convert_to_die(other)
1311        return other.binary_operator(self, operator.mul)
1312
1313    def __truediv__(self, other) -> 'Die':
1314        if isinstance(other, icepool.AgainExpression):
1315            return NotImplemented
1316        other = implicit_convert_to_die(other)
1317        return self.binary_operator(other, operator.truediv)
1318
1319    def __rtruediv__(self, other) -> 'Die':
1320        if isinstance(other, icepool.AgainExpression):
1321            return NotImplemented
1322        other = implicit_convert_to_die(other)
1323        return other.binary_operator(self, operator.truediv)
1324
1325    def __floordiv__(self, other) -> 'Die':
1326        if isinstance(other, icepool.AgainExpression):
1327            return NotImplemented
1328        other = implicit_convert_to_die(other)
1329        return self.binary_operator(other, operator.floordiv)
1330
1331    def __rfloordiv__(self, other) -> 'Die':
1332        if isinstance(other, icepool.AgainExpression):
1333            return NotImplemented
1334        other = implicit_convert_to_die(other)
1335        return other.binary_operator(self, operator.floordiv)
1336
1337    def __pow__(self, other) -> 'Die':
1338        if isinstance(other, icepool.AgainExpression):
1339            return NotImplemented
1340        other = implicit_convert_to_die(other)
1341        return self.binary_operator(other, operator.pow)
1342
1343    def __rpow__(self, other) -> 'Die':
1344        if isinstance(other, icepool.AgainExpression):
1345            return NotImplemented
1346        other = implicit_convert_to_die(other)
1347        return other.binary_operator(self, operator.pow)
1348
1349    def __mod__(self, other) -> 'Die':
1350        if isinstance(other, icepool.AgainExpression):
1351            return NotImplemented
1352        other = implicit_convert_to_die(other)
1353        return self.binary_operator(other, operator.mod)
1354
1355    def __rmod__(self, other) -> 'Die':
1356        if isinstance(other, icepool.AgainExpression):
1357            return NotImplemented
1358        other = implicit_convert_to_die(other)
1359        return other.binary_operator(self, operator.mod)
1360
1361    def __lshift__(self, other) -> 'Die':
1362        if isinstance(other, icepool.AgainExpression):
1363            return NotImplemented
1364        other = implicit_convert_to_die(other)
1365        return self.binary_operator(other, operator.lshift)
1366
1367    def __rlshift__(self, other) -> 'Die':
1368        if isinstance(other, icepool.AgainExpression):
1369            return NotImplemented
1370        other = implicit_convert_to_die(other)
1371        return other.binary_operator(self, operator.lshift)
1372
1373    def __rshift__(self, other) -> 'Die':
1374        if isinstance(other, icepool.AgainExpression):
1375            return NotImplemented
1376        other = implicit_convert_to_die(other)
1377        return self.binary_operator(other, operator.rshift)
1378
1379    def __rrshift__(self, other) -> 'Die':
1380        if isinstance(other, icepool.AgainExpression):
1381            return NotImplemented
1382        other = implicit_convert_to_die(other)
1383        return other.binary_operator(self, operator.rshift)
1384
1385    def __and__(self, other) -> 'Die':
1386        if isinstance(other, icepool.AgainExpression):
1387            return NotImplemented
1388        other = implicit_convert_to_die(other)
1389        return self.binary_operator(other, operator.and_)
1390
1391    def __rand__(self, other) -> 'Die':
1392        if isinstance(other, icepool.AgainExpression):
1393            return NotImplemented
1394        other = implicit_convert_to_die(other)
1395        return other.binary_operator(self, operator.and_)
1396
1397    def __or__(self, other) -> 'Die':
1398        if isinstance(other, icepool.AgainExpression):
1399            return NotImplemented
1400        other = implicit_convert_to_die(other)
1401        return self.binary_operator(other, operator.or_)
1402
1403    def __ror__(self, other) -> 'Die':
1404        if isinstance(other, icepool.AgainExpression):
1405            return NotImplemented
1406        other = implicit_convert_to_die(other)
1407        return other.binary_operator(self, operator.or_)
1408
1409    def __xor__(self, other) -> 'Die':
1410        if isinstance(other, icepool.AgainExpression):
1411            return NotImplemented
1412        other = implicit_convert_to_die(other)
1413        return self.binary_operator(other, operator.xor)
1414
1415    def __rxor__(self, other) -> 'Die':
1416        if isinstance(other, icepool.AgainExpression):
1417            return NotImplemented
1418        other = implicit_convert_to_die(other)
1419        return other.binary_operator(self, operator.xor)
1420
1421    # Comparators.
1422
1423    def __lt__(self, other) -> 'Die[bool]':
1424        if isinstance(other, icepool.AgainExpression):
1425            return NotImplemented
1426        other = implicit_convert_to_die(other)
1427        return self.binary_operator(other, operator.lt)
1428
1429    def __le__(self, other) -> 'Die[bool]':
1430        if isinstance(other, icepool.AgainExpression):
1431            return NotImplemented
1432        other = implicit_convert_to_die(other)
1433        return self.binary_operator(other, operator.le)
1434
1435    def __ge__(self, other) -> 'Die[bool]':
1436        if isinstance(other, icepool.AgainExpression):
1437            return NotImplemented
1438        other = implicit_convert_to_die(other)
1439        return self.binary_operator(other, operator.ge)
1440
1441    def __gt__(self, other) -> 'Die[bool]':
1442        if isinstance(other, icepool.AgainExpression):
1443            return NotImplemented
1444        other = implicit_convert_to_die(other)
1445        return self.binary_operator(other, operator.gt)
1446
1447    # Equality operators. These produce a `DieWithTruth`.
1448
1449    # The result has a truth value, but is not a bool.
1450    def __eq__(self, other) -> 'icepool.DieWithTruth[bool]':  # type: ignore
1451        if isinstance(other, icepool.AgainExpression):
1452            return NotImplemented
1453        other_die: Die = implicit_convert_to_die(other)
1454
1455        def data_callback() -> Counts[bool]:
1456            return self.binary_operator(other_die, operator.eq)._data
1457
1458        def truth_value_callback() -> bool:
1459            return self.equals(other)
1460
1461        return icepool.DieWithTruth(data_callback, truth_value_callback)
1462
1463    # The result has a truth value, but is not a bool.
1464    def __ne__(self, other) -> 'icepool.DieWithTruth[bool]':  # type: ignore
1465        if isinstance(other, icepool.AgainExpression):
1466            return NotImplemented
1467        other_die: Die = implicit_convert_to_die(other)
1468
1469        def data_callback() -> Counts[bool]:
1470            return self.binary_operator(other_die, operator.ne)._data
1471
1472        def truth_value_callback() -> bool:
1473            return not self.equals(other)
1474
1475        return icepool.DieWithTruth(data_callback, truth_value_callback)
1476
1477    def cmp(self, other) -> 'Die[int]':
1478        """A `Die` with outcomes 1, -1, and 0.
1479
1480        The quantities are equal to the positive outcome of `self > other`,
1481        `self < other`, and the remainder respectively.
1482        """
1483        other = implicit_convert_to_die(other)
1484
1485        data = {}
1486
1487        lt = self < other
1488        if True in lt:
1489            data[-1] = lt[True]
1490        eq = self == other
1491        if True in eq:
1492            data[0] = eq[True]
1493        gt = self > other
1494        if True in gt:
1495            data[1] = gt[True]
1496
1497        return Die(data)
1498
1499    @staticmethod
1500    def _sign(x) -> int:
1501        z = Die._zero(x)
1502        if x > z:
1503            return 1
1504        elif x < z:
1505            return -1
1506        else:
1507            return 0
1508
1509    def sign(self) -> 'Die[int]':
1510        """Outcomes become 1 if greater than `zero()`, -1 if less than `zero()`, and 0 otherwise.
1511
1512        Note that for `float`s, +0.0, -0.0, and nan all become 0.
1513        """
1514        return self.unary_operator(Die._sign)
1515
1516    # Equality and hashing.
1517
1518    def __bool__(self) -> bool:
1519        raise TypeError(
1520            'A `Die` only has a truth value if it is the result of == or !=.\n'
1521            'This could result from trying to use a die in an if-statement,\n'
1522            'in which case you should use `die.if_else()` instead.\n'
1523            'Or it could result from trying to use a `Die` inside a tuple or vector outcome,\n'
1524            'in which case you should use `tupleize()` or `vectorize().')
1525
1526    @cached_property
1527    def hash_key(self) -> tuple:
1528        """A tuple that uniquely (as `equals()`) identifies this die.
1529
1530        Apart from being hashable and totally orderable, this is not guaranteed
1531        to be in any particular format or have any other properties.
1532        """
1533        return Die, tuple(self.items())
1534
1535    __hash__ = MaybeHashKeyed.__hash__
1536
1537    def equals(self, other, *, simplify: bool = False) -> bool:
1538        """`True` iff both dice have the same outcomes and quantities.
1539
1540        This is `False` if `other` is not a `Die`, even if it would convert
1541        to an equal `Die`.
1542
1543        Truth value does NOT matter.
1544
1545        If one `Die` has a zero-quantity outcome and the other `Die` does not
1546        contain that outcome, they are treated as unequal by this function.
1547
1548        The `==` and `!=` operators have a dual purpose; they return a `Die`
1549        with a truth value determined by this method.
1550        Only dice returned by these methods have a truth value. The data of
1551        these dice is lazily evaluated since the caller may only be interested
1552        in the `Die` value or the truth value.
1553
1554        Args:
1555            simplify: If `True`, the dice will be simplified before comparing.
1556                Otherwise, e.g. a 2:2 coin is not `equals()` to a 1:1 coin.
1557        """
1558        if self is other:
1559            return True
1560
1561        if not isinstance(other, Die):
1562            return False
1563
1564        if simplify:
1565            return self.simplify().hash_key == other.simplify().hash_key
1566        else:
1567            return self.hash_key == other.hash_key
1568
1569    # Strings.
1570
1571    def __repr__(self) -> str:
1572        items_string = ', '.join(f'{repr(outcome)}: {weight}'
1573                                 for outcome, weight in self.items())
1574        return type(self).__qualname__ + '({' + items_string + '})'

Sampling with replacement. Quantities represent weights.

Dice are immutable. Methods do not modify the Die in-place; rather they return a Die representing the result.

It's also possible to have "empty" dice with no outcomes at all, though these have little use other than being sentinel values.

def unary_operator( self: Die[+T_co], op: Callable[..., ~U], *args, **kwargs) -> Die[~U]:
179    def unary_operator(self: 'icepool.Die[T_co]', op: Callable[..., U], *args,
180                       **kwargs) -> 'icepool.Die[U]':
181        """Performs the unary operation on the outcomes.
182
183        This is used for the standard unary operators
184        `-, +, abs, ~, round, trunc, floor, ceil`
185        as well as the additional methods
186        `zero, bool`.
187
188        This is NOT used for the `[]` operator; when used directly, this is
189        interpreted as a `Mapping` operation and returns the count corresponding
190        to a given outcome. See `marginals()` for applying the `[]` operator to
191        outcomes.
192
193        Returns:
194            A `Die` representing the result.
195
196        Raises:
197            ValueError: If tuples are of mismatched length.
198        """
199        return self._unary_operator(op, *args, **kwargs)

Performs the unary operation on the outcomes.

This is used for the standard unary operators -, +, abs, ~, round, trunc, floor, ceil as well as the additional methods zero, bool.

This is NOT used for the [] operator; when used directly, this is interpreted as a Mapping operation and returns the count corresponding to a given outcome. See marginals() for applying the [] operator to outcomes.

Returns:

A Die representing the result.

Raises:
  • ValueError: If tuples are of mismatched length.
def binary_operator( self, other: Die, op: Callable[..., ~U], *args, **kwargs) -> Die[~U]:
201    def binary_operator(self, other: 'Die', op: Callable[..., U], *args,
202                        **kwargs) -> 'Die[U]':
203        """Performs the operation on pairs of outcomes.
204
205        By the time this is called, the other operand has already been
206        converted to a `Die`.
207
208        This is used for the standard binary operators
209        `+, -, *, /, //, %, **, <<, >>, &, |, ^`
210        and the standard binary comparators
211        `<, <=, >=, >, ==, !=, cmp`.
212
213        `==` and `!=` additionally set the truth value of the `Die` according to
214        whether the dice themselves are the same or not.
215
216        The `@` operator does NOT use this method directly.
217        It rolls the left `Die`, which must have integer outcomes,
218        then rolls the right `Die` that many times and sums the outcomes.
219
220        Returns:
221            A `Die` representing the result.
222
223        Raises:
224            ValueError: If tuples are of mismatched length within one of the
225                dice or between the dice.
226        """
227        data: MutableMapping[Any, int] = defaultdict(int)
228        for (outcome_self,
229             quantity_self), (outcome_other,
230                              quantity_other) in itertools.product(
231                                  self.items(), other.items()):
232            new_outcome = op(outcome_self, outcome_other, *args, **kwargs)
233            data[new_outcome] += quantity_self * quantity_other
234        return self._new_type(data)

Performs the operation on pairs of outcomes.

By the time this is called, the other operand has already been converted to a Die.

This is used for the standard binary operators +, -, *, /, //, %, **, <<, >>, &, |, ^ and the standard binary comparators <, <=, >=, >, ==, !=, cmp.

== and != additionally set the truth value of the Die according to whether the dice themselves are the same or not.

The @ operator does NOT use this method directly. It rolls the left Die, which must have integer outcomes, then rolls the right Die that many times and sums the outcomes.

Returns:

A Die representing the result.

Raises:
  • ValueError: If tuples are of mismatched length within one of the dice or between the dice.
def keys(self) -> CountsKeysView[+T_co]:
238    def keys(self) -> CountsKeysView[T_co]:
239        return self._data.keys()

The outcomes within the population in sorted order.

def values(self) -> CountsValuesView:
241    def values(self) -> CountsValuesView:
242        return self._data.values()

The quantities within the population in outcome order.

def items(self) -> CountsItemsView[+T_co]:
244    def items(self) -> CountsItemsView[T_co]:
245        return self._data.items()

The (outcome, quantity)s of the population in sorted order.

def simplify(self) -> Die[+T_co]:
262    def simplify(self) -> 'Die[T_co]':
263        """Divides all quantities by their greatest common denominator. """
264        return icepool.Die(self._data.simplify())

Divides all quantities by their greatest common denominator.

def reroll( self, outcomes: Callable[..., bool] | Collection[+T_co] | None = None, /, *, star: bool | None = None, depth: int | Literal['inf']) -> Die[+T_co]:
268    def reroll(self,
269               outcomes: Callable[..., bool] | Collection[T_co] | None = None,
270               /,
271               *,
272               star: bool | None = None,
273               depth: int | Literal['inf']) -> 'Die[T_co]':
274        """Rerolls the given outcomes.
275
276        Args:
277            outcomes: Selects which outcomes to reroll. Options:
278                * A collection of outcomes to reroll.
279                * A callable that takes an outcome and returns `True` if it
280                    should be rerolled.
281                * If not provided, the min outcome will be rerolled.
282            star: Whether outcomes should be unpacked into separate arguments
283                before sending them to a callable `which`.
284                If not provided, this will be guessed based on the function
285                signature.
286            depth: The maximum number of times to reroll.
287                If `None`, rerolls an unlimited number of times.
288
289        Returns:
290            A `Die` representing the reroll.
291            If the reroll would never terminate, the result has no outcomes.
292        """
293
294        if outcomes is None:
295            outcome_set = {self.min_outcome()}
296        else:
297            outcome_set = self._select_outcomes(outcomes, star)
298
299        if depth == 'inf':
300            data = {
301                outcome: quantity
302                for outcome, quantity in self.items()
303                if outcome not in outcome_set
304            }
305        elif depth < 0:
306            raise ValueError('reroll depth cannot be negative.')
307        else:
308            total_reroll_quantity = sum(quantity
309                                        for outcome, quantity in self.items()
310                                        if outcome in outcome_set)
311            total_stop_quantity = self.denominator() - total_reroll_quantity
312            rerollable_factor = total_reroll_quantity**depth
313            stop_factor = (self.denominator()**(depth + 1) - rerollable_factor
314                           * total_reroll_quantity) // total_stop_quantity
315            data = {
316                outcome: (rerollable_factor *
317                          quantity if outcome in outcome_set else stop_factor *
318                          quantity)
319                for outcome, quantity in self.items()
320            }
321        return icepool.Die(data)

Rerolls the given outcomes.

Arguments:
  • outcomes: Selects which outcomes to reroll. Options:
    • A collection of outcomes to reroll.
    • A callable that takes an outcome and returns True if it should be rerolled.
    • If not provided, the min outcome will be rerolled.
  • star: Whether outcomes should be unpacked into separate arguments before sending them to a callable which. If not provided, this will be guessed based on the function signature.
  • depth: The maximum number of times to reroll. If None, rerolls an unlimited number of times.
Returns:

A Die representing the reroll. If the reroll would never terminate, the result has no outcomes.

def filter( self, outcomes: Callable[..., bool] | Collection[+T_co], /, *, star: bool | None = None, depth: int | Literal['inf']) -> Die[+T_co]:
323    def filter(self,
324               outcomes: Callable[..., bool] | Collection[T_co],
325               /,
326               *,
327               star: bool | None = None,
328               depth: int | Literal['inf']) -> 'Die[T_co]':
329        """Rerolls until getting one of the given outcomes.
330
331        Essentially the complement of `reroll()`.
332
333        Args:
334            outcomes: Selects which outcomes to reroll until. Options:
335                * A callable that takes an outcome and returns `True` if it
336                    should be accepted.
337                * A collection of outcomes to reroll until.
338            star: Whether outcomes should be unpacked into separate arguments
339                before sending them to a callable `which`.
340                If not provided, this will be guessed based on the function
341                signature.
342            depth: The maximum number of times to reroll.
343                If `None`, rerolls an unlimited number of times.
344
345        Returns:
346            A `Die` representing the reroll.
347            If the reroll would never terminate, the result has no outcomes.
348        """
349
350        if callable(outcomes):
351            if star is None:
352                star = infer_star(outcomes)
353            if star:
354
355                not_outcomes = {
356                    outcome
357                    for outcome in self.outcomes()
358                    if not outcomes(*outcome)  # type: ignore
359                }
360            else:
361                not_outcomes = {
362                    outcome
363                    for outcome in self.outcomes() if not outcomes(outcome)
364                }
365        else:
366            not_outcomes = {
367                not_outcome
368                for not_outcome in self.outcomes()
369                if not_outcome not in outcomes
370            }
371        return self.reroll(not_outcomes, depth=depth)

Rerolls until getting one of the given outcomes.

Essentially the complement of reroll().

Arguments:
  • outcomes: Selects which outcomes to reroll until. Options:
    • A callable that takes an outcome and returns True if it should be accepted.
    • A collection of outcomes to reroll until.
  • star: Whether outcomes should be unpacked into separate arguments before sending them to a callable which. If not provided, this will be guessed based on the function signature.
  • depth: The maximum number of times to reroll. If None, rerolls an unlimited number of times.
Returns:

A Die representing the reroll. If the reroll would never terminate, the result has no outcomes.

def truncate( self, min_outcome=None, max_outcome=None) -> Die[+T_co]:
373    def truncate(self, min_outcome=None, max_outcome=None) -> 'Die[T_co]':
374        """Truncates the outcomes of this `Die` to the given range.
375
376        The endpoints are included in the result if applicable.
377        If one of the arguments is not provided, that side will not be truncated.
378
379        This effectively rerolls outcomes outside the given range.
380        If instead you want to replace those outcomes with the nearest endpoint,
381        use `clip()`.
382
383        Not to be confused with `trunc(die)`, which performs integer truncation
384        on each outcome.
385        """
386        if min_outcome is not None:
387            start = bisect.bisect_left(self.outcomes(), min_outcome)
388        else:
389            start = None
390        if max_outcome is not None:
391            stop = bisect.bisect_right(self.outcomes(), max_outcome)
392        else:
393            stop = None
394        data = {k: v for k, v in self.items()[start:stop]}
395        return icepool.Die(data)

Truncates the outcomes of this Die to the given range.

The endpoints are included in the result if applicable. If one of the arguments is not provided, that side will not be truncated.

This effectively rerolls outcomes outside the given range. If instead you want to replace those outcomes with the nearest endpoint, use clip().

Not to be confused with trunc(die), which performs integer truncation on each outcome.

def clip( self, min_outcome=None, max_outcome=None) -> Die[+T_co]:
397    def clip(self, min_outcome=None, max_outcome=None) -> 'Die[T_co]':
398        """Clips the outcomes of this `Die` to the given values.
399
400        The endpoints are included in the result if applicable.
401        If one of the arguments is not provided, that side will not be clipped.
402
403        This is not the same as rerolling outcomes beyond this range;
404        the outcome is simply adjusted to fit within the range.
405        This will typically cause some quantity to bunch up at the endpoint(s).
406        If you want to reroll outcomes beyond this range, use `truncate()`.
407        """
408        data: MutableMapping[Any, int] = defaultdict(int)
409        for outcome, quantity in self.items():
410            if min_outcome is not None and outcome <= min_outcome:
411                data[min_outcome] += quantity
412            elif max_outcome is not None and outcome >= max_outcome:
413                data[max_outcome] += quantity
414            else:
415                data[outcome] += quantity
416        return icepool.Die(data)

Clips the outcomes of this Die to the given values.

The endpoints are included in the result if applicable. If one of the arguments is not provided, that side will not be clipped.

This is not the same as rerolling outcomes beyond this range; the outcome is simply adjusted to fit within the range. This will typically cause some quantity to bunch up at the endpoint(s). If you want to reroll outcomes beyond this range, use truncate().

def map( self, repl: Callable[..., ~U | Die[~U] | RerollType | icepool.population.again.AgainExpression] | Mapping[+T_co, ~U | Die[~U] | RerollType | icepool.population.again.AgainExpression], /, *extra_args, star: bool | None = None, repeat: int | Literal['inf'] | None = None, again_count: int | None = None, again_depth: int | None = None, again_end: ~U | Die[~U] | RerollType | None = None, **kwargs) -> Die[~U]:
472    def map(
473            self,
474            repl:
475        'Callable[..., U | Die[U] | icepool.RerollType | icepool.AgainExpression] | Mapping[T_co, U | Die[U] | icepool.RerollType | icepool.AgainExpression]',
476            /,
477            *extra_args,
478            star: bool | None = None,
479            repeat: int | Literal['inf'] | None = None,
480            again_count: int | None = None,
481            again_depth: int | None = None,
482            again_end: 'U | Die[U] | icepool.RerollType | None' = None,
483            **kwargs) -> 'Die[U]':
484        """Maps outcomes of the `Die` to other outcomes.
485
486        This is also useful for representing processes.
487
488        As `icepool.map(repl, self, ...)`.
489        """
490        return icepool.map(
491            repl,
492            self,
493            *extra_args,
494            star=star,
495            repeat=repeat,  # type:ignore
496            again_count=again_count,
497            again_depth=again_depth,
498            again_end=again_end,
499            **kwargs)  # type:ignore

Maps outcomes of the Die to other outcomes.

This is also useful for representing processes.

As icepool.map(repl, self, ...).

def map_and_time( self, repl: Callable[..., +T_co | Die[+T_co] | RerollType] | Mapping[+T_co, +T_co | Die[+T_co] | RerollType], /, *extra_args, star: bool | None = None, repeat: int, **kwargs) -> Die[tuple[+T_co, int]]:
501    def map_and_time(
502            self,
503            repl:
504        'Callable[..., T_co | Die[T_co] | icepool.RerollType] | Mapping[T_co, T_co | Die[T_co] | icepool.RerollType]',
505            /,
506            *extra_args,
507            star: bool | None = None,
508            repeat: int,
509            **kwargs) -> 'Die[tuple[T_co, int]]':
510        """Repeatedly map outcomes of the state to other outcomes, while also
511        counting timesteps.
512
513        This is useful for representing processes.
514
515        As `map_and_time(repl, self, ...)`.
516        """
517        return icepool.map_and_time(repl,
518                                    self,
519                                    *extra_args,
520                                    star=star,
521                                    repeat=repeat,
522                                    **kwargs)

Repeatedly map outcomes of the state to other outcomes, while also counting timesteps.

This is useful for representing processes.

As map_and_time(repl, self, ...).

def mean_time_to_absorb( self, repl: Callable[..., +T_co | Die[+T_co] | RerollType] | Mapping[Any, +T_co | Die[+T_co] | RerollType], /, *extra_args, star: bool | None = None, **kwargs) -> fractions.Fraction:
524    def mean_time_to_absorb(
525            self,
526            repl:
527        'Callable[..., T_co | icepool.Die[T_co] | icepool.RerollType] | Mapping[Any, T_co | icepool.Die[T_co] | icepool.RerollType]',
528            /,
529            *extra_args,
530            star: bool | None = None,
531            **kwargs) -> Fraction:
532        """EXPERIMENTAL: The mean time for the process to reach an absorbing state.
533        
534        As `mean_time_to_absorb(repl, self, ...)`.
535        """
536        return icepool.mean_time_to_absorb(repl,
537                                           self,
538                                           *extra_args,
539                                           star=star,
540                                           **kwargs)

EXPERIMENTAL: The mean time for the process to reach an absorbing state.

As mean_time_to_absorb(repl, self, ...).

def time_to_sum( self: Die[int], target: int, /, max_time: int | None = None, dnf: int | RerollType | None = None) -> Die[int]:
542    def time_to_sum(self: 'Die[int]',
543                    target: int,
544                    /,
545                    max_time: int | None = None,
546                    dnf: 'int|icepool.RerollType|None' = None) -> 'Die[int]':
547        """The number of rolls until the cumulative sum is greater or equal to the target.
548        
549        Args:
550            target: The number to stop at once reached.
551            max_time: The maximum number of rolls to run.
552                If the sum is not reached, the outcome is determined by `dnf`.
553            dnf: What time to assign in cases where the target was not reached
554                in `max_time`. If not provided, this is set to `max_time`.
555                `dnf=icepool.Reroll` will remove this case from the result,
556                effectively rerolling it.
557        """
558        if target <= 0:
559            return Die([0])
560
561        if max_time is None:
562            if self.min_outcome() <= 0:
563                raise ValueError(
564                    'max_time must be provided if not all outcomes are positive.'
565                )
566            max_time = (target + self.min_outcome() - 1) // self.min_outcome()
567
568        if dnf is None:
569            dnf = max_time
570
571        def step(total, roll):
572            return min(total + roll, target)
573
574        result: 'Die[tuple[int, int]]' = Die([0]).map_and_time(step,
575                                                               self,
576                                                               repeat=max_time)
577
578        def get_time(total, time):
579            if total < target:
580                return dnf
581            else:
582                return time
583
584        return result.map(get_time)

The number of rolls until the cumulative sum is greater or equal to the target.

Arguments:
  • target: The number to stop at once reached.
  • max_time: The maximum number of rolls to run. If the sum is not reached, the outcome is determined by dnf.
  • dnf: What time to assign in cases where the target was not reached in max_time. If not provided, this is set to max_time. dnf=icepool.Reroll will remove this case from the result, effectively rerolling it.
def mean_time_to_sum( self: Die[int], target: int, /) -> fractions.Fraction:
590    def mean_time_to_sum(self: 'Die[int]', target: int, /) -> Fraction:
591        """The mean number of rolls until the cumulative sum is greater or equal to the target.
592
593        Args:
594            target: The target sum.
595
596        Raises:
597            ValueError: If `self` has negative outcomes.
598            ZeroDivisionError: If `self.mean() == 0`.
599        """
600        target = max(target, 0)
601
602        if target < len(self._mean_time_to_sum_cache):
603            return self._mean_time_to_sum_cache[target]
604
605        if self.min_outcome() < 0:
606            raise ValueError(
607                'mean_time_to_sum does not handle negative outcomes.')
608        time_per_effect = Fraction(self.denominator(),
609                                   self.denominator() - self.quantity(0))
610
611        for i in range(len(self._mean_time_to_sum_cache), target + 1):
612            result = time_per_effect + self.reroll([
613                0
614            ], depth='inf').map(lambda x: self.mean_time_to_sum(i - x)).mean()
615            self._mean_time_to_sum_cache.append(result)
616
617        return result

The mean number of rolls until the cumulative sum is greater or equal to the target.

Arguments:
  • target: The target sum.
Raises:
  • ValueError: If self has negative outcomes.
  • ZeroDivisionError: If self.mean() == 0.
def explode( self, outcomes: Collection[+T_co] | Callable[..., bool] | None = None, /, *, star: bool | None = None, depth: int = 9, end=None) -> Die[+T_co]:
619    def explode(self,
620                outcomes: Collection[T_co] | Callable[..., bool] | None = None,
621                /,
622                *,
623                star: bool | None = None,
624                depth: int = 9,
625                end=None) -> 'Die[T_co]':
626        """Causes outcomes to be rolled again and added to the total.
627
628        Args:
629            outcomes: Which outcomes to explode. Options:
630                * An collection of outcomes to explode.
631                * A callable that takes an outcome and returns `True` if it
632                    should be exploded.
633                * If not supplied, the max outcome will explode.
634            star: Whether outcomes should be unpacked into separate arguments
635                before sending them to a callable `which`.
636                If not provided, this will be guessed based on the function
637                signature.
638            depth: The maximum number of additional dice to roll, not counting
639                the initial roll.
640                If not supplied, a default value will be used.
641            end: Once `depth` is reached, further explosions will be treated
642                as this value. By default, a zero value will be used.
643                `icepool.Reroll` will make one extra final roll, rerolling until
644                a non-exploding outcome is reached.
645        """
646
647        if outcomes is None:
648            outcome_set = {self.max_outcome()}
649        else:
650            outcome_set = self._select_outcomes(outcomes, star)
651
652        if depth < 0:
653            raise ValueError('depth cannot be negative.')
654        elif depth == 0:
655            return self
656
657        def map_final(outcome):
658            if outcome in outcome_set:
659                return outcome + icepool.Again
660            else:
661                return outcome
662
663        return self.map(map_final, again_depth=depth, again_end=end)

Causes outcomes to be rolled again and added to the total.

Arguments:
  • outcomes: Which outcomes to explode. Options:
    • An collection of outcomes to explode.
    • A callable that takes an outcome and returns True if it should be exploded.
    • If not supplied, the max outcome will explode.
  • star: Whether outcomes should be unpacked into separate arguments before sending them to a callable which. If not provided, this will be guessed based on the function signature.
  • depth: The maximum number of additional dice to roll, not counting the initial roll. If not supplied, a default value will be used.
  • end: Once depth is reached, further explosions will be treated as this value. By default, a zero value will be used. icepool.Reroll will make one extra final roll, rerolling until a non-exploding outcome is reached.
def if_else( self, outcome_if_true: ~U | Die[~U], outcome_if_false: ~U | Die[~U], *, again_count: int | None = None, again_depth: int | None = None, again_end: ~U | Die[~U] | RerollType | None = None) -> Die[~U]:
665    def if_else(
666        self,
667        outcome_if_true: U | 'Die[U]',
668        outcome_if_false: U | 'Die[U]',
669        *,
670        again_count: int | None = None,
671        again_depth: int | None = None,
672        again_end: 'U | Die[U] | icepool.RerollType | None' = None
673    ) -> 'Die[U]':
674        """Ternary conditional operator.
675
676        This replaces truthy outcomes with the first argument and falsy outcomes
677        with the second argument.
678
679        Args:
680            again_count, again_depth, again_end: Forwarded to the final die constructor.
681        """
682        return self.map(lambda x: bool(x)).map(
683            {
684                True: outcome_if_true,
685                False: outcome_if_false
686            },
687            again_count=again_count,
688            again_depth=again_depth,
689            again_end=again_end)

Ternary conditional operator.

This replaces truthy outcomes with the first argument and falsy outcomes with the second argument.

Arguments:
  • again_count, again_depth, again_end: Forwarded to the final die constructor.
def is_in(self, outcomes: Container[+T_co], /) -> Die[bool]:
691    def is_in(self, outcomes: Container[T_co], /) -> 'Die[bool]':
692        """A die that returns True iff the roll of the die is contained in the target."""
693        return self.map(lambda x: x in outcomes)

A die that returns True iff the roll of the die is contained in the target.

def count( self, rolls: int, outcomes: Container[+T_co], /) -> Die[int]:
695    def count(self, rolls: int, outcomes: Container[T_co], /) -> 'Die[int]':
696        """Roll this dice a number of times and count how many are in the target."""
697        return rolls @ self.is_in(outcomes)

Roll this dice a number of times and count how many are in the target.

def sequence(self, rolls: int) -> Die[tuple[+T_co, ...]]:
780    def sequence(self, rolls: int) -> 'icepool.Die[tuple[T_co, ...]]':
781        """Possible sequences produced by rolling this die a number of times.
782        
783        This is extremely expensive computationally. If possible, use `reduce()`
784        instead; if you don't care about order, `Die.pool()` is better.
785        """
786        return icepool.cartesian_product(*(self for _ in range(rolls)),
787                                         outcome_type=tuple)  # type: ignore

Possible sequences produced by rolling this die a number of times.

This is extremely expensive computationally. If possible, use reduce() instead; if you don't care about order, Die.pool() is better.

def pool( self, rolls: int | Sequence[int] = 1, /) -> Pool[+T_co]:
789    def pool(self, rolls: int | Sequence[int] = 1, /) -> 'icepool.Pool[T_co]':
790        """Creates a `Pool` from this `Die`.
791
792        You might subscript the pool immediately afterwards, e.g.
793        `d6.pool(5)[-1, ..., 1]` takes the difference between the highest and
794        lowest of 5d6.
795
796        Args:
797            rolls: The number of copies of this `Die` to put in the pool.
798                Or, a sequence of one `int` per die acting as
799                `keep_tuple`. Note that `...` cannot be used in the
800                argument to this method, as the argument determines the size of
801                the pool.
802        """
803        if isinstance(rolls, int):
804            return icepool.Pool({self: rolls})
805        else:
806            pool_size = len(rolls)
807            # Haven't dealt with narrowing return type.
808            return icepool.Pool({self: pool_size})[rolls]  # type: ignore

Creates a Pool from this Die.

You might subscript the pool immediately afterwards, e.g. d6.pool(5)[-1, ..., 1] takes the difference between the highest and lowest of 5d6.

Arguments:
  • rolls: The number of copies of this Die to put in the pool. Or, a sequence of one int per die acting as keep_tuple. Note that ... cannot be used in the argument to this method, as the argument determines the size of the pool.
def keep( self, rolls: int | Sequence[int], index: slice | Sequence[int | ellipsis] | int | None = None, /) -> Die:
856    def keep(self,
857             rolls: int | Sequence[int],
858             index: slice | Sequence[int | EllipsisType] | int | None = None,
859             /) -> 'Die':
860        """Selects elements after drawing and sorting and sums them.
861
862        Args:
863            rolls: The number of dice to roll.
864            index: One of the following:
865            * An `int`. This will count only the roll at the specified index.
866            In this case, the result is a `Die` rather than a generator.
867            * A `slice`. The selected dice are counted once each.
868            * A sequence of `int`s with length equal to `rolls`.
869                Each roll is counted that many times, which could be multiple or
870                negative times.
871
872                Up to one `...` (`Ellipsis`) may be used. If no `...` is used,
873                the `rolls` argument may be omitted.
874
875                `...` will be replaced with a number of zero counts in order
876                to make up any missing elements compared to `rolls`.
877                This number may be "negative" if more `int`s are provided than
878                `rolls`. Specifically:
879
880                * If `index` is shorter than `rolls`, `...`
881                    acts as enough zero counts to make up the difference.
882                    E.g. `(1, ..., 1)` on five dice would act as
883                    `(1, 0, 0, 0, 1)`.
884                * If `index` has length equal to `rolls`, `...` has no effect.
885                    E.g. `(1, ..., 1)` on two dice would act as `(1, 1)`.
886                * If `index` is longer than `rolls` and `...` is on one side,
887                    elements will be dropped from `index` on the side with `...`.
888                    E.g. `(..., 1, 2, 3)` on two dice would act as `(2, 3)`.
889                * If `index` is longer than `rolls` and `...`
890                    is in the middle, the counts will be as the sum of two
891                    one-sided `...`.
892                    E.g. `(-1, ..., 1)` acts like `(-1, ...)` plus `(..., 1)`.
893                    If `rolls` was 1 this would have the -1 and 1 cancel each other out.
894        """
895        if isinstance(rolls, int):
896            if index is None:
897                raise ValueError(
898                    'If the number of rolls is an integer, an index argument must be provided.'
899                )
900            if isinstance(index, int):
901                return self.pool(rolls).keep(index)
902            else:
903                return self.pool(rolls).keep(index).sum()  # type: ignore
904        else:
905            if index is not None:
906                raise ValueError('Only one index sequence can be given.')
907            return self.pool(len(rolls)).keep(rolls).sum()  # type: ignore

Selects elements after drawing and sorting and sums them.

Arguments:
  • rolls: The number of dice to roll.
  • index: One of the following:
    • An int. This will count only the roll at the specified index.
  • In this case, the result is a Die rather than a generator.
    • A slice. The selected dice are counted once each.
    • A sequence of ints with length equal to rolls. Each roll is counted that many times, which could be multiple or negative times.

    Up to one ... (Ellipsis) may be used. If no ... is used, the rolls argument may be omitted.

    ... will be replaced with a number of zero counts in order to make up any missing elements compared to rolls. This number may be "negative" if more ints are provided than rolls. Specifically:

    • If index is shorter than rolls, ... acts as enough zero counts to make up the difference. E.g. (1, ..., 1) on five dice would act as (1, 0, 0, 0, 1).
    • If index has length equal to rolls, ... has no effect. E.g. (1, ..., 1) on two dice would act as (1, 1).
    • If index is longer than rolls and ... is on one side, elements will be dropped from index on the side with .... E.g. (..., 1, 2, 3) on two dice would act as (2, 3).
    • If index is longer than rolls and ... is in the middle, the counts will be as the sum of two one-sided .... E.g. (-1, ..., 1) acts like (-1, ...) plus (..., 1). If rolls was 1 this would have the -1 and 1 cancel each other out.
def lowest( self, rolls: int, /, keep: int | None = None, drop: int | None = None) -> Die:
909    def lowest(self,
910               rolls: int,
911               /,
912               keep: int | None = None,
913               drop: int | None = None) -> 'Die':
914        """Roll several of this `Die` and return the lowest result, or the sum of some of the lowest.
915
916        The outcomes should support addition and multiplication if `keep != 1`.
917
918        Args:
919            rolls: The number of dice to roll. All dice will have the same
920                outcomes as `self`.
921            keep, drop: These arguments work together:
922                * If neither are provided, the single lowest die will be taken.
923                * If only `keep` is provided, the `keep` lowest dice will be summed.
924                * If only `drop` is provided, the `drop` lowest dice will be dropped
925                    and the rest will be summed.
926                * If both are provided, `drop` lowest dice will be dropped, then
927                    the next `keep` lowest dice will be summed.
928
929        Returns:
930            A `Die` representing the probability distribution of the sum.
931        """
932        index = lowest_slice(keep, drop)
933        canonical = canonical_slice(index, rolls)
934        if canonical.start == 0 and canonical.stop == 1:
935            return self._lowest_single(rolls)
936        # Expression evaluators are difficult to type.
937        return self.pool(rolls)[index].sum()  # type: ignore

Roll several of this Die and return the lowest result, or the sum of some of the lowest.

The outcomes should support addition and multiplication if keep != 1.

Arguments:
  • rolls: The number of dice to roll. All dice will have the same outcomes as self.
  • keep, drop: These arguments work together:
    • If neither are provided, the single lowest die will be taken.
    • If only keep is provided, the keep lowest dice will be summed.
    • If only drop is provided, the drop lowest dice will be dropped and the rest will be summed.
    • If both are provided, drop lowest dice will be dropped, then the next keep lowest dice will be summed.
Returns:

A Die representing the probability distribution of the sum.

def highest( self, rolls: int, /, keep: int | None = None, drop: int | None = None) -> Die[+T_co]:
947    def highest(self,
948                rolls: int,
949                /,
950                keep: int | None = None,
951                drop: int | None = None) -> 'Die[T_co]':
952        """Roll several of this `Die` and return the highest result, or the sum of some of the highest.
953
954        The outcomes should support addition and multiplication if `keep != 1`.
955
956        Args:
957            rolls: The number of dice to roll.
958            keep, drop: These arguments work together:
959                * If neither are provided, the single highest die will be taken.
960                * If only `keep` is provided, the `keep` highest dice will be summed.
961                * If only `drop` is provided, the `drop` highest dice will be dropped
962                    and the rest will be summed.
963                * If both are provided, `drop` highest dice will be dropped, then
964                    the next `keep` highest dice will be summed.
965
966        Returns:
967            A `Die` representing the probability distribution of the sum.
968        """
969        index = highest_slice(keep, drop)
970        canonical = canonical_slice(index, rolls)
971        if canonical.start == rolls - 1 and canonical.stop == rolls:
972            return self._highest_single(rolls)
973        # Expression evaluators are difficult to type.
974        return self.pool(rolls)[index].sum()  # type: ignore

Roll several of this Die and return the highest result, or the sum of some of the highest.

The outcomes should support addition and multiplication if keep != 1.

Arguments:
  • rolls: The number of dice to roll.
  • keep, drop: These arguments work together:
    • If neither are provided, the single highest die will be taken.
    • If only keep is provided, the keep highest dice will be summed.
    • If only drop is provided, the drop highest dice will be dropped and the rest will be summed.
    • If both are provided, drop highest dice will be dropped, then the next keep highest dice will be summed.
Returns:

A Die representing the probability distribution of the sum.

def middle( self, rolls: int, /, keep: int = 1, *, tie: Literal['error', 'high', 'low'] = 'error') -> Die:
 983    def middle(
 984            self,
 985            rolls: int,
 986            /,
 987            keep: int = 1,
 988            *,
 989            tie: Literal['error', 'high', 'low'] = 'error') -> 'icepool.Die':
 990        """Roll several of this `Die` and sum the sorted results in the middle.
 991
 992        The outcomes should support addition and multiplication if `keep != 1`.
 993
 994        Args:
 995            rolls: The number of dice to roll.
 996            keep: The number of outcomes to sum. If this is greater than the
 997                current keep_size, all are kept.
 998            tie: What to do if `keep` is odd but the current keep_size
 999                is even, or vice versa.
1000                * 'error' (default): Raises `IndexError`.
1001                * 'high': The higher outcome is taken.
1002                * 'low': The lower outcome is taken.
1003        """
1004        # Expression evaluators are difficult to type.
1005        return self.pool(rolls).middle(keep, tie=tie).sum()  # type: ignore

Roll several of this Die and sum the sorted results in the middle.

The outcomes should support addition and multiplication if keep != 1.

Arguments:
  • rolls: The number of dice to roll.
  • keep: The number of outcomes to sum. If this is greater than the current keep_size, all are kept.
  • tie: What to do if keep is odd but the current keep_size is even, or vice versa.
    • 'error' (default): Raises IndexError.
    • 'high': The higher outcome is taken.
    • 'low': The lower outcome is taken.
def map_to_pool( self, repl: Callable[..., Sequence[Die[~U] | ~U] | Mapping[Die[~U], int] | Mapping[~U, int] | RerollType] | None = None, /, *extra_args: Outcome | Die | MultisetExpression, star: bool | None = None, **kwargs) -> MultisetExpression[~U]:
1007    def map_to_pool(
1008            self,
1009            repl:
1010        'Callable[..., Sequence[icepool.Die[U] | U] | Mapping[icepool.Die[U], int] | Mapping[U, int] | icepool.RerollType] | None' = None,
1011            /,
1012            *extra_args: 'Outcome | icepool.Die | icepool.MultisetExpression',
1013            star: bool | None = None,
1014            **kwargs) -> 'icepool.MultisetExpression[U]':
1015        """EXPERIMENTAL: Maps outcomes of this `Die` to `Pools`, creating a `MultisetGenerator`.
1016
1017        As `icepool.map_to_pool(repl, self, ...)`.
1018
1019        If no argument is provided, the outcomes will be used to construct a
1020        mixture of pools directly, similar to the inverse of `pool.expand()`.
1021        Note that this is not particularly efficient since it does not make much
1022        use of dynamic programming.
1023        """
1024        if repl is None:
1025            repl = lambda x: x
1026        return icepool.map_to_pool(repl,
1027                                   self,
1028                                   *extra_args,
1029                                   star=star,
1030                                   **kwargs)

EXPERIMENTAL: Maps outcomes of this Die to Pools, creating a MultisetGenerator.

As icepool.map_to_pool(repl, self, ...).

If no argument is provided, the outcomes will be used to construct a mixture of pools directly, similar to the inverse of pool.expand(). Note that this is not particularly efficient since it does not make much use of dynamic programming.

def explode_to_pool( self, rolls: int = 1, outcomes: Collection[+T_co] | Callable[..., bool] | None = None, /, *, star: bool | None = None, depth: int = 9) -> MultisetExpression[+T_co]:
1032    def explode_to_pool(self,
1033                        rolls: int = 1,
1034                        outcomes: Collection[T_co] | Callable[..., bool]
1035                        | None = None,
1036                        /,
1037                        *,
1038                        star: bool | None = None,
1039                        depth: int = 9) -> 'icepool.MultisetExpression[T_co]':
1040        """EXPERIMENTAL: Causes outcomes to be rolled again, keeping that outcome as an individual die in a pool.
1041        
1042        Args:
1043            rolls: The number of initial dice.
1044            outcomes: Which outcomes to explode. Options:
1045                * A single outcome to explode.
1046                * An collection of outcomes to explode.
1047                * A callable that takes an outcome and returns `True` if it
1048                    should be exploded.
1049                * If not supplied, the max outcome will explode.
1050            star: Whether outcomes should be unpacked into separate arguments
1051                before sending them to a callable `which`.
1052                If not provided, this will be guessed based on the function
1053                signature.
1054            depth: The maximum depth of explosions for an individual dice.
1055
1056        Returns:
1057            A `MultisetGenerator` representing the mixture of `Pool`s. Note  
1058            that this is not technically a `Pool`, though it supports most of 
1059            the same operations.
1060        """
1061        if depth == 0:
1062            return self.pool(rolls)
1063        if outcomes is None:
1064            explode_set = {self.max_outcome()}
1065        else:
1066            explode_set = self._select_outcomes(outcomes, star)
1067        if not explode_set:
1068            return self.pool(rolls)
1069        explode: 'Die[T_co]'
1070        not_explode: 'Die[T_co]'
1071        explode, not_explode = self.split(explode_set)
1072
1073        single_data: 'MutableMapping[icepool.Vector[int], int]' = defaultdict(
1074            int)
1075        for i in range(depth + 1):
1076            weight = explode.denominator()**i * self.denominator()**(
1077                depth - i) * not_explode.denominator()
1078            single_data[icepool.Vector((i, 1))] += weight
1079        single_data[icepool.Vector(
1080            (depth + 1, 0))] += explode.denominator()**(depth + 1)
1081
1082        single_count_die: 'Die[icepool.Vector[int]]' = Die(single_data)
1083        count_die = rolls @ single_count_die
1084
1085        return count_die.map_to_pool(
1086            lambda x, nx: [explode] * x + [not_explode] * nx)

EXPERIMENTAL: Causes outcomes to be rolled again, keeping that outcome as an individual die in a pool.

Arguments:
  • rolls: The number of initial dice.
  • outcomes: Which outcomes to explode. Options:
    • A single outcome to explode.
    • An collection of outcomes to explode.
    • A callable that takes an outcome and returns True if it should be exploded.
    • If not supplied, the max outcome will explode.
  • star: Whether outcomes should be unpacked into separate arguments before sending them to a callable which. If not provided, this will be guessed based on the function signature.
  • depth: The maximum depth of explosions for an individual dice.
Returns:

A MultisetGenerator representing the mixture of Pools. Note
that this is not technically a Pool, though it supports most of the same operations.

def reroll_to_pool( self, rolls: int, outcomes: Callable[..., bool] | Collection[+T_co] | None = None, /, *, max_rerolls: int | Literal['inf'], star: bool | None = None, depth: int | Literal['inf'] = 1, mode: Literal['random', 'low', 'high', 'drop'] = 'random') -> MultisetExpression[+T_co]:
1088    def reroll_to_pool(
1089        self,
1090        rolls: int,
1091        outcomes: Callable[..., bool] | Collection[T_co] | None = None,
1092        /,
1093        *,
1094        max_rerolls: int | Literal['inf'],
1095        star: bool | None = None,
1096        depth: int | Literal['inf'] = 1,
1097        mode: Literal['random', 'low', 'high', 'drop'] = 'random'
1098    ) -> 'icepool.MultisetExpression[T_co]':
1099        """EXPERIMENTAL: Applies a limited number of rerolls shared across a pool.
1100
1101        Each die can only be rerolled once (effectively `depth=1`), and no more
1102        than `max_rerolls` dice may be rerolled.
1103        
1104        Args:
1105            rolls: How many dice in the pool.
1106            outcomes: Selects which outcomes are eligible to be rerolled.
1107                Options:
1108                * A collection of outcomes to reroll.
1109                * A callable that takes an outcome and returns `True` if it
1110                    could be rerolled.
1111                * If not provided, the single minimum outcome will be rerolled.
1112            max_rerolls: The maximum total number of rerolls.
1113                If `max_rerolls == 'inf'`, then this is the same as 
1114                `self.reroll(which, star=star, depth=depth).pool(rolls)`.
1115            depth: EXTRA EXPERIMENTAL: The maximum depth of rerolls.
1116            star: Whether outcomes should be unpacked into separate arguments
1117                before sending them to a callable `which`.
1118                If not provided, this will be guessed based on the function
1119                signature.
1120            mode: How dice are selected for rerolling if there are more eligible
1121                dice than `max_rerolls`. Options:
1122                * `'random'` (default): Eligible dice will be chosen uniformly
1123                    at random.
1124                * `'low'`: The lowest eligible dice will be rerolled.
1125                * `'high'`: The highest eligible dice will be rerolled.
1126                * `'drop'`: All dice that ended up on an outcome selected by 
1127                    `which` will be dropped. This includes both dice that rolled
1128                    into `which` initially and were not rerolled, and dice that
1129                    were rerolled but rolled into `which` again. This can be
1130                    considerably more efficient than the other modes.
1131
1132        Returns:
1133            A `MultisetGenerator` representing the mixture of `Pool`s. Note  
1134            that this is not technically a `Pool`, though it supports most of 
1135            the same operations.
1136        """
1137        if max_rerolls == 'inf':
1138            return self.reroll(outcomes, star=star, depth=depth).pool(rolls)
1139
1140        if outcomes is None:
1141            rerollable_set = {self.min_outcome()}
1142        else:
1143            rerollable_set = self._select_outcomes(outcomes, star)
1144            if not rerollable_set:
1145                return self.pool(rolls)
1146
1147        rerollable_die: 'Die[T_co]'
1148        not_rerollable_die: 'Die[T_co]'
1149        rerollable_die, not_rerollable_die = self.split(rerollable_set)
1150        single_is_rerollable = icepool.coin(rerollable_die.denominator(),
1151                                            self.denominator())
1152
1153        if depth == 'inf':
1154            depth = max_rerolls
1155
1156        def step(rerollable, rerolls_left):
1157            """Advances one step of rerolling if there are enough rerolls left to cover all rerollable dice.
1158            
1159            Returns:
1160                The number of dice showing rerollable outcomes and the number of remaining rerolls.
1161            """
1162            if rerollable == 0:
1163                return 0, 0
1164            if rerolls_left < rerollable:
1165                return rerollable, rerolls_left
1166
1167            return icepool.tupleize(rerollable @ single_is_rerollable,
1168                                    rerolls_left - rerollable)
1169
1170        initial_state = icepool.tupleize(rolls @ single_is_rerollable,
1171                                         max_rerolls)
1172        mid_pool_composition: Die[tuple[int, int]]
1173        mid_pool_composition = icepool.map(step,
1174                                           initial_state,
1175                                           star=True,
1176                                           repeat=depth - 1)
1177
1178        def final_step(rerollable, rerolls_left):
1179            """Performs the final reroll, which might not have enough rerolls to cover all rerollable dice.
1180            
1181            Returns: The number of dice that had a rerollable outcome,
1182                the number of dice that were rerolled due to max_rerolls,
1183                the number of rerolled dice that landed on a rerollable outcome
1184                again.
1185            """
1186            rerolled = min(rerollable, rerolls_left)
1187
1188            return icepool.tupleize(rerollable, rerolled,
1189                                    rerolled @ single_is_rerollable)
1190
1191        pool_composition: Die[tuple[int, int, int]] = mid_pool_composition.map(
1192            final_step, star=True)
1193
1194        denominator = self.denominator()**(rolls + max_rerolls)
1195        pool_composition = pool_composition.multiply_to_denominator(
1196            denominator)
1197
1198        def make_pool(rerollable, rerolled, rerolled_to_rerollable):
1199            rerolls_ran_out = rerollable - rerolled
1200            not_rerollable = rolls - rerolls_ran_out - rerolled_to_rerollable
1201            common = rerollable_die.pool(
1202                rerolled_to_rerollable) + not_rerollable_die.pool(
1203                    not_rerollable)
1204            match mode:
1205                case 'random':
1206                    return common + rerollable_die.pool(rerolls_ran_out)
1207                case 'low':
1208                    return common + rerollable_die.pool(rerollable).highest(
1209                        rerolls_ran_out)
1210                case 'high':
1211                    return common + rerollable_die.pool(rerollable).lowest(
1212                        rerolls_ran_out)
1213                case 'drop':
1214                    return not_rerollable_die.pool(not_rerollable)
1215                case _:
1216                    raise ValueError(
1217                        f"Invalid reroll_priority '{mode}'. Allowed values are 'random', 'low', 'high', 'drop'."
1218                    )
1219
1220        return pool_composition.map_to_pool(make_pool, star=True)

EXPERIMENTAL: Applies a limited number of rerolls shared across a pool.

Each die can only be rerolled once (effectively depth=1), and no more than max_rerolls dice may be rerolled.

Arguments:
  • rolls: How many dice in the pool.
  • outcomes: Selects which outcomes are eligible to be rerolled. Options:
    • A collection of outcomes to reroll.
    • A callable that takes an outcome and returns True if it could be rerolled.
    • If not provided, the single minimum outcome will be rerolled.
  • max_rerolls: The maximum total number of rerolls. If max_rerolls == 'inf', then this is the same as self.reroll(which, star=star, depth=depth).pool(rolls).
  • depth: EXTRA EXPERIMENTAL: The maximum depth of rerolls.
  • star: Whether outcomes should be unpacked into separate arguments before sending them to a callable which. If not provided, this will be guessed based on the function signature.
  • mode: How dice are selected for rerolling if there are more eligible dice than max_rerolls. Options:
    • 'random' (default): Eligible dice will be chosen uniformly at random.
    • 'low': The lowest eligible dice will be rerolled.
    • 'high': The highest eligible dice will be rerolled.
    • 'drop': All dice that ended up on an outcome selected by which will be dropped. This includes both dice that rolled into which initially and were not rerolled, and dice that were rerolled but rolled into which again. This can be considerably more efficient than the other modes.
Returns:

A MultisetGenerator representing the mixture of Pools. Note
that this is not technically a Pool, though it supports most of the same operations.

def abs(self) -> Die[+T_co]:
1233    def abs(self) -> 'Die[T_co]':
1234        return self.unary_operator(operator.abs)
def round(self, ndigits: int | None = None) -> Die:
1238    def round(self, ndigits: int | None = None) -> 'Die':
1239        return self.unary_operator(round, ndigits)
def stochastic_round( self, *, max_denominator: int | None = None) -> Die[int]:
1243    def stochastic_round(self,
1244                         *,
1245                         max_denominator: int | None = None) -> 'Die[int]':
1246        """Randomly rounds outcomes up or down to the nearest integer according to the two distances.
1247        
1248        Specificially, rounds `x` up with probability `x - floor(x)` and down
1249        otherwise.
1250
1251        Args:
1252            max_denominator: If provided, each rounding will be performed
1253                using `fractions.Fraction.limit_denominator(max_denominator)`.
1254                Otherwise, the rounding will be performed without
1255                `limit_denominator`.
1256        """
1257        return self.map(lambda x: icepool.stochastic_round(
1258            x, max_denominator=max_denominator))

Randomly rounds outcomes up or down to the nearest integer according to the two distances.

Specificially, rounds x up with probability x - floor(x) and down otherwise.

Arguments:
  • max_denominator: If provided, each rounding will be performed using fractions.Fraction.limit_denominator(max_denominator). Otherwise, the rounding will be performed without limit_denominator.
def trunc(self) -> Die:
1260    def trunc(self) -> 'Die':
1261        return self.unary_operator(math.trunc)
def floor(self) -> Die:
1265    def floor(self) -> 'Die':
1266        return self.unary_operator(math.floor)
def ceil(self) -> Die:
1270    def ceil(self) -> 'Die':
1271        return self.unary_operator(math.ceil)
def cmp(self, other) -> Die[int]:
1477    def cmp(self, other) -> 'Die[int]':
1478        """A `Die` with outcomes 1, -1, and 0.
1479
1480        The quantities are equal to the positive outcome of `self > other`,
1481        `self < other`, and the remainder respectively.
1482        """
1483        other = implicit_convert_to_die(other)
1484
1485        data = {}
1486
1487        lt = self < other
1488        if True in lt:
1489            data[-1] = lt[True]
1490        eq = self == other
1491        if True in eq:
1492            data[0] = eq[True]
1493        gt = self > other
1494        if True in gt:
1495            data[1] = gt[True]
1496
1497        return Die(data)

A Die with outcomes 1, -1, and 0.

The quantities are equal to the positive outcome of self > other, self < other, and the remainder respectively.

def sign(self) -> Die[int]:
1509    def sign(self) -> 'Die[int]':
1510        """Outcomes become 1 if greater than `zero()`, -1 if less than `zero()`, and 0 otherwise.
1511
1512        Note that for `float`s, +0.0, -0.0, and nan all become 0.
1513        """
1514        return self.unary_operator(Die._sign)

Outcomes become 1 if greater than zero(), -1 if less than zero(), and 0 otherwise.

Note that for floats, +0.0, -0.0, and nan all become 0.

class Population(abc.ABC, icepool.expand.Expandable[+T_co], typing.Mapping[typing.Any, int]):
  29class Population(ABC, Expandable[T_co], Mapping[Any, int]):
  30    """A mapping from outcomes to `int` quantities.
  31
  32    Outcomes with each instance must be hashable and totally orderable.
  33
  34    Subclasses include `Die` and `Deck`.
  35    """
  36
  37    # Abstract methods.
  38
  39    @property
  40    @abstractmethod
  41    def _new_type(self) -> type:
  42        """The type to use when constructing a new instance."""
  43
  44    @abstractmethod
  45    def keys(self) -> CountsKeysView[T_co]:
  46        """The outcomes within the population in sorted order."""
  47
  48    @abstractmethod
  49    def values(self) -> CountsValuesView:
  50        """The quantities within the population in outcome order."""
  51
  52    @abstractmethod
  53    def items(self) -> CountsItemsView[T_co]:
  54        """The (outcome, quantity)s of the population in sorted order."""
  55
  56    @property
  57    def _items_for_cartesian_product(self) -> Sequence[tuple[T_co, int]]:
  58        return self.items()
  59
  60    def _unary_operator(self, op: Callable, *args, **kwargs):
  61        data: MutableMapping[Any, int] = defaultdict(int)
  62        for outcome, quantity in self.items():
  63            new_outcome = op(outcome, *args, **kwargs)
  64            data[new_outcome] += quantity
  65        return self._new_type(data)
  66
  67    # Outcomes.
  68
  69    def outcomes(self) -> CountsKeysView[T_co]:
  70        """The outcomes of the mapping in ascending order.
  71
  72        These are also the `keys` of the mapping.
  73        Prefer to use the name `outcomes`.
  74        """
  75        return self.keys()
  76
  77    @cached_property
  78    def _common_outcome_length(self) -> int | None:
  79        result = None
  80        for outcome in self.outcomes():
  81            if isinstance(outcome, Mapping):
  82                return None
  83            elif isinstance(outcome, Sized):
  84                if result is None:
  85                    result = len(outcome)
  86                elif len(outcome) != result:
  87                    return None
  88        return result
  89
  90    def common_outcome_length(self) -> int | None:
  91        """The common length of all outcomes.
  92
  93        If outcomes have no lengths or different lengths, the result is `None`.
  94        """
  95        return self._common_outcome_length
  96
  97    def is_empty(self) -> bool:
  98        """`True` iff this population has no outcomes. """
  99        return len(self) == 0
 100
 101    def min_outcome(self) -> T_co:
 102        """The least outcome."""
 103        return self.outcomes()[0]
 104
 105    def max_outcome(self) -> T_co:
 106        """The greatest outcome."""
 107        return self.outcomes()[-1]
 108
 109    def nearest(self, comparison: Literal['<=', '<', '>=', '>'], outcome,
 110                /) -> T_co | None:
 111        """The nearest outcome in this population fitting the comparison.
 112
 113        Args:
 114            comparison: The comparison which the result must fit. For example,
 115                '<=' would find the greatest outcome that is not greater than
 116                the argument.
 117            outcome: The outcome to compare against.
 118        
 119        Returns:
 120            The nearest outcome fitting the comparison, or `None` if there is
 121            no such outcome.
 122        """
 123        match comparison:
 124            case '<=':
 125                if outcome in self:
 126                    return outcome
 127                index = bisect.bisect_right(self.outcomes(), outcome) - 1
 128                if index < 0:
 129                    return None
 130                return self.outcomes()[index]
 131            case '<':
 132                index = bisect.bisect_left(self.outcomes(), outcome) - 1
 133                if index < 0:
 134                    return None
 135                return self.outcomes()[index]
 136            case '>=':
 137                if outcome in self:
 138                    return outcome
 139                index = bisect.bisect_left(self.outcomes(), outcome)
 140                if index >= len(self):
 141                    return None
 142                return self.outcomes()[index]
 143            case '>':
 144                index = bisect.bisect_right(self.outcomes(), outcome)
 145                if index >= len(self):
 146                    return None
 147                return self.outcomes()[index]
 148            case _:
 149                raise ValueError(f'Invalid comparison {comparison}')
 150
 151    @staticmethod
 152    def _zero(x):
 153        return x * 0
 154
 155    def zero(self: C) -> C:
 156        """Zeros all outcomes of this population.
 157
 158        This is done by multiplying all outcomes by `0`.
 159
 160        The result will have the same denominator.
 161
 162        Raises:
 163            ValueError: If the zeros did not resolve to a single outcome.
 164        """
 165        result = self._unary_operator(Population._zero)
 166        if len(result) != 1:
 167            raise ValueError('zero() did not resolve to a single outcome.')
 168        return result
 169
 170    def zero_outcome(self) -> T_co:
 171        """A zero-outcome for this population.
 172
 173        E.g. `0` for a `Population` whose outcomes are `int`s.
 174        """
 175        return self.zero().outcomes()[0]
 176
 177    # Quantities.
 178
 179    @overload
 180    def quantity(self, outcome: Hashable, /) -> int:
 181        """The quantity of a single outcome."""
 182
 183    @overload
 184    def quantity(self, comparison: Literal['==', '!=', '<=', '<', '>=', '>'],
 185                 outcome: Hashable, /) -> int:
 186        """The total quantity fitting a comparison to a single outcome."""
 187
 188    def quantity(self,
 189                 comparison: Literal['==', '!=', '<=', '<', '>=', '>']
 190                 | Hashable,
 191                 outcome: Hashable | None = None,
 192                 /) -> int:
 193        """The quantity of a single outcome.
 194
 195        A comparison can be provided, in which case this returns the total
 196        quantity fitting the comparison.
 197        
 198        Args:
 199            comparison: The comparison to use. This can be omitted, in which
 200                case it is treated as '=='.
 201            outcome: The outcome to query.
 202        """
 203        if outcome is None:
 204            outcome = comparison
 205            comparison = '=='
 206        else:
 207            comparison = cast(Literal['==', '!=', '<=', '<', '>=', '>'],
 208                              comparison)
 209
 210        match comparison:
 211            case '==':
 212                return self.get(outcome, 0)
 213            case '!=':
 214                return self.denominator() - self.get(outcome, 0)
 215            case '<=' | '<':
 216                threshold = self.nearest(comparison, outcome)
 217                if threshold is None:
 218                    return 0
 219                else:
 220                    return self._cumulative_quantities[threshold]
 221            case '>=':
 222                return self.denominator() - self.quantity('<', outcome)
 223            case '>':
 224                return self.denominator() - self.quantity('<=', outcome)
 225            case _:
 226                raise ValueError(f'Invalid comparison {comparison}')
 227
 228    def quantity_where(self,
 229                       which: Callable[..., bool],
 230                       /,
 231                       star: bool | None = None) -> int:
 232        """The quantity fulfilling a boolean condition."""
 233        if star is None:
 234            star = infer_star(which)
 235        if star:
 236            return sum(quantity  # type: ignore
 237                       for outcome, quantity in self.items()
 238                       if which(*outcome))  # type: ignore
 239        else:
 240            return sum(quantity for outcome, quantity in self.items()
 241                       if which(outcome))
 242
 243    @overload
 244    def quantities(self, /) -> CountsValuesView:
 245        """All quantities in sorted order."""
 246
 247    @overload
 248    def quantities(self, comparison: Literal['==', '!=', '<=', '<', '>=', '>'],
 249                   /) -> Sequence[int]:
 250        """The total quantities fitting the comparison for each outcome in sorted order.
 251        
 252        For example, '<=' gives the CDF.
 253
 254        Args:
 255            comparison: One of `'==', '!=', '<=', '<', '>=', '>'`.
 256                May be omitted, in which case equality `'=='` is used.
 257            outcome: The outcome to compare to.
 258            percent: If set, the result will be a percentage expressed as a
 259                `float`.
 260        """
 261
 262    def quantities(self,
 263                   comparison: Literal['==', '!=', '<=', '<', '>=', '>']
 264                   | None = None,
 265                   /) -> CountsValuesView | Sequence[int]:
 266        """The quantities of the mapping in sorted order.
 267
 268        For example, '<=' gives the CDF.
 269
 270        Args:
 271            comparison: One of `'==', '!=', '<=', '<', '>=', '>'`.
 272                May be omitted, in which case equality `'=='` is used.
 273        """
 274        if comparison is None:
 275            comparison = '=='
 276
 277        match comparison:
 278            case '==':
 279                return self.values()
 280            case '<=':
 281                return tuple(itertools.accumulate(self.values()))
 282            case '>=':
 283                if self.is_empty():
 284                    return ()
 285                return tuple(
 286                    itertools.accumulate(self.values()[:-1],
 287                                         operator.sub,
 288                                         initial=self.denominator()))
 289            case '!=':
 290                return tuple(self.denominator() - q for q in self.values())
 291            case '<':
 292                return tuple(self.denominator() - q
 293                             for q in self.quantities('>='))
 294            case '>':
 295                return tuple(self.denominator() - q
 296                             for q in self.quantities('<='))
 297            case _:
 298                raise ValueError(f'Invalid comparison {comparison}')
 299
 300    @cached_property
 301    def _cumulative_quantities(self) -> Mapping[T_co, int]:
 302        result = {}
 303        cdf = 0
 304        for outcome, quantity in self.items():
 305            cdf += quantity
 306            result[outcome] = cdf
 307        return result
 308
 309    @cached_property
 310    def _denominator(self) -> int:
 311        return sum(self.values())
 312
 313    def denominator(self) -> int:
 314        """The sum of all quantities (e.g. weights or duplicates).
 315
 316        For the number of unique outcomes, use `len()`.
 317        """
 318        return self._denominator
 319
 320    def multiply_quantities(self: C, scale: int, /) -> C:
 321        """Multiplies all quantities by an integer."""
 322        if scale == 1:
 323            return self
 324        data = {
 325            outcome: quantity * scale
 326            for outcome, quantity in self.items()
 327        }
 328        return self._new_type(data)
 329
 330    def divide_quantities(self: C, divisor: int, /) -> C:
 331        """Divides all quantities by an integer, rounding down.
 332        
 333        Resulting zero quantities are dropped.
 334        """
 335        if divisor == 0:
 336            return self
 337        data = {
 338            outcome: quantity // divisor
 339            for outcome, quantity in self.items() if quantity >= divisor
 340        }
 341        return self._new_type(data)
 342
 343    def modulo_quantities(self: C, divisor: int, /) -> C:
 344        """Modulus of all quantities with an integer."""
 345        data = {
 346            outcome: quantity % divisor
 347            for outcome, quantity in self.items()
 348        }
 349        return self._new_type(data)
 350
 351    def pad_to_denominator(self: C, denominator: int, /,
 352                           outcome: Hashable) -> C:
 353        """Changes the denominator to a target number by changing the quantity of a specified outcome.
 354        
 355        Args:
 356            `target`: The denominator of the result.
 357            `outcome`: The outcome whose quantity will be adjusted.
 358
 359        Returns:
 360            A `Population` like `self` but with the quantity of `outcome`
 361            adjusted so that the overall denominator is equal to  `target`.
 362            If the denominator is reduced to zero, it will be removed.
 363
 364        Raises:
 365            `ValueError` if this would require the quantity of the specified
 366            outcome to be negative.
 367        """
 368        adjustment = denominator - self.denominator()
 369        data = {outcome: quantity for outcome, quantity in self.items()}
 370        new_quantity = data.get(outcome, 0) + adjustment
 371        if new_quantity > 0:
 372            data[outcome] = new_quantity
 373        elif new_quantity == 0:
 374            # The outcome may not have been present in the first place.
 375            data.pop(outcome, None)
 376        else:
 377            raise ValueError(
 378                f'Padding to denominator of {denominator} would require a negative quantity of {new_quantity} for {outcome}'
 379            )
 380        return self._new_type(data)
 381
 382    def multiply_to_denominator(self: C, denominator: int, /) -> C:
 383        """Multiplies all quantities to reach the target denominiator.
 384        
 385        Raises:
 386            ValueError if this cannot be achieved using an integer scaling.
 387        """
 388        if denominator % self.denominator():
 389            raise ValueError(
 390                'Target denominator is not an integer factor of the current denominator.'
 391            )
 392        return self.multiply_quantities(denominator // self.denominator())
 393
 394    def append(self: C, outcome, quantity: int = 1, /) -> C:
 395        """This population with an outcome appended.
 396        
 397        Args:
 398            outcome: The outcome to append.
 399            quantity: The quantity of the outcome to append. Can be negative,
 400                which removes quantity (but not below zero).
 401        """
 402        data = Counter(self)
 403        data[outcome] = max(data[outcome] + quantity, 0)
 404        return self._new_type(data)
 405
 406    def remove(self: C, outcome, quantity: int | None = None, /) -> C:
 407        """This population with an outcome removed.
 408
 409        Args:
 410            outcome: The outcome to append.
 411            quantity: The quantity of the outcome to remove. If not set, all
 412                quantity of that outcome is removed. Can be negative, which adds
 413                quantity instead.
 414        """
 415        if quantity is None:
 416            data = Counter(self)
 417            data[outcome] = 0
 418            return self._new_type(data)
 419        else:
 420            return self.append(outcome, -quantity)
 421
 422    # Probabilities.
 423
 424    @overload
 425    def probability(self, outcome: Hashable, /, *,
 426                    percent: Literal[False]) -> Fraction:
 427        """The probability of a single outcome, or 0.0 if not present."""
 428
 429    @overload
 430    def probability(self, outcome: Hashable, /, *,
 431                    percent: Literal[True]) -> float:
 432        """The probability of a single outcome, or 0.0 if not present."""
 433
 434    @overload
 435    def probability(self, outcome: Hashable, /) -> Fraction:
 436        """The probability of a single outcome, or 0.0 if not present."""
 437
 438    @overload
 439    def probability(self, comparison: Literal['==', '!=', '<=', '<', '>=',
 440                                              '>'], outcome: Hashable, /, *,
 441                    percent: Literal[False]) -> Fraction:
 442        """The total probability of outcomes fitting a comparison."""
 443
 444    @overload
 445    def probability(self, comparison: Literal['==', '!=', '<=', '<', '>=',
 446                                              '>'], outcome: Hashable, /, *,
 447                    percent: Literal[True]) -> float:
 448        """The total probability of outcomes fitting a comparison."""
 449
 450    @overload
 451    def probability(self, comparison: Literal['==', '!=', '<=', '<', '>=',
 452                                              '>'], outcome: Hashable,
 453                    /) -> Fraction:
 454        """The total probability of outcomes fitting a comparison."""
 455
 456    def probability(self,
 457                    comparison: Literal['==', '!=', '<=', '<', '>=', '>']
 458                    | Hashable,
 459                    outcome: Hashable | None = None,
 460                    /,
 461                    *,
 462                    percent: bool = False) -> Fraction | float:
 463        """The total probability of outcomes fitting a comparison.
 464        
 465        Args:
 466            comparison: One of `'==', '!=', '<=', '<', '>=', '>'`.
 467                May be omitted, in which case equality `'=='` is used.
 468            outcome: The outcome to compare to.
 469            percent: If set, the result will be a percentage expressed as a
 470                `float`. Otherwise, the result is a `Fraction`.
 471        """
 472        if outcome is None:
 473            outcome = comparison
 474            comparison = '=='
 475        else:
 476            comparison = cast(Literal['==', '!=', '<=', '<', '>=', '>'],
 477                              comparison)
 478        result = Fraction(self.quantity(comparison, outcome),
 479                          self.denominator())
 480        return result * 100.0 if percent else result
 481
 482    @overload
 483    def probability_where(self, which: Callable[...,
 484                                                bool], /, star: bool | None,
 485                          percent: Literal[False]) -> Fraction:
 486        ...
 487
 488    @overload
 489    def probability_where(self, which: Callable[..., bool], /,
 490                          star: bool | None, percent: Literal[True]) -> float:
 491        ...
 492
 493    @overload
 494    def probability_where(self, which: Callable[...,
 495                                                bool], /, star: bool | None,
 496                          percent: bool) -> Fraction | float:
 497        ...
 498
 499    def probability_where(self,
 500                          which: Callable[..., bool],
 501                          /,
 502                          star: bool | None = None,
 503                          percent: bool = False) -> Fraction | float:
 504        """The probability fulfilling a boolean condition."""
 505        numerator = self.quantity_where(which, star=star)
 506        if percent:
 507            return 100.0 * numerator / self.denominator()
 508        else:
 509            return Fraction(numerator, self.denominator())
 510
 511    @overload
 512    def probabilities(self, /, *,
 513                      percent: Literal[False]) -> Sequence[Fraction]:
 514        """All probabilities in sorted order."""
 515
 516    @overload
 517    def probabilities(self, /, *, percent: Literal[True]) -> Sequence[float]:
 518        """All probabilities in sorted order."""
 519
 520    @overload
 521    def probabilities(self, /) -> Sequence[Fraction]:
 522        """All probabilities in sorted order."""
 523
 524    @overload
 525    def probabilities(self, comparison: Literal['==', '!=', '<=', '<', '>=',
 526                                                '>'], /, *,
 527                      percent: Literal[False]) -> Sequence[Fraction]:
 528        """The total probabilities fitting the comparison for each outcome in sorted order.
 529        
 530        For example, '<=' gives the CDF.
 531        """
 532
 533    @overload
 534    def probabilities(self, comparison: Literal['==', '!=', '<=', '<', '>=',
 535                                                '>'], /, *,
 536                      percent: Literal[True]) -> Sequence[float]:
 537        """The total probabilities fitting the comparison for each outcome in sorted order.
 538        
 539        For example, '<=' gives the CDF.
 540        """
 541
 542    @overload
 543    def probabilities(self, comparison: Literal['==', '!=', '<=', '<', '>=',
 544                                                '>'], /) -> Sequence[Fraction]:
 545        """The total probabilities fitting the comparison for each outcome in sorted order.
 546        
 547        For example, '<=' gives the CDF.
 548        """
 549
 550    def probabilities(
 551            self,
 552            comparison: Literal['==', '!=', '<=', '<', '>=', '>']
 553        | None = None,
 554            /,
 555            *,
 556            percent: bool = False) -> Sequence[Fraction] | Sequence[float]:
 557        """The total probabilities fitting the comparison for each outcome in sorted order.
 558        
 559        For example, '<=' gives the CDF.
 560
 561        Args:
 562            comparison: One of `'==', '!=', '<=', '<', '>=', '>'`.
 563                May be omitted, in which case equality `'=='` is used.
 564            percent: If set, the result will be a percentage expressed as a
 565                `float`. Otherwise, the result is a `Fraction`.
 566        """
 567        if comparison is None:
 568            comparison = '=='
 569
 570        result = tuple(
 571            Fraction(q, self.denominator())
 572            for q in self.quantities(comparison))
 573
 574        if percent:
 575            return tuple(100.0 * x for x in result)
 576        else:
 577            return result
 578
 579    # Scalar statistics.
 580
 581    def mode(self) -> tuple:
 582        """A tuple containing the most common outcome(s) of the population.
 583
 584        These are sorted from lowest to highest.
 585        """
 586        return tuple(outcome for outcome, quantity in self.items()
 587                     if quantity == self.modal_quantity())
 588
 589    def modal_quantity(self) -> int:
 590        """The highest quantity of any single outcome. """
 591        return max(self.quantities())
 592
 593    def kolmogorov_smirnov(self, other: 'Population') -> Fraction:
 594        """Kolmogorov–Smirnov statistic. The maximum absolute difference between CDFs. """
 595        outcomes = icepool.sorted_union(self, other)
 596        return max(
 597            abs(
 598                self.probability('<=', outcome) -
 599                other.probability('<=', outcome)) for outcome in outcomes)
 600
 601    def cramer_von_mises(self, other: 'Population') -> Fraction:
 602        """Cramér-von Mises statistic. The sum-of-squares difference between CDFs. """
 603        outcomes = icepool.sorted_union(self, other)
 604        return sum(((self.probability('<=', outcome) -
 605                     other.probability('<=', outcome))**2
 606                    for outcome in outcomes),
 607                   start=Fraction(0, 1))
 608
 609    def median(self):
 610        """The median, taking the mean in case of a tie.
 611
 612        This will fail if the outcomes do not support division;
 613        in this case, use `median_low` or `median_high` instead.
 614        """
 615        return self.quantile(1, 2)
 616
 617    def median_low(self) -> T_co:
 618        """The median, taking the lower in case of a tie."""
 619        return self.quantile_low(1, 2)
 620
 621    def median_high(self) -> T_co:
 622        """The median, taking the higher in case of a tie."""
 623        return self.quantile_high(1, 2)
 624
 625    def quantile(self, n: int, d: int = 100):
 626        """The outcome `n / d` of the way through the CDF, taking the mean in case of a tie.
 627
 628        This will fail if the outcomes do not support addition and division;
 629        in this case, use `quantile_low` or `quantile_high` instead.
 630        """
 631        # Should support addition and division.
 632        return (self.quantile_low(n, d) +
 633                self.quantile_high(n, d)) / 2  # type: ignore
 634
 635    def quantile_low(self, n: int, d: int = 100) -> T_co:
 636        """The outcome `n / d` of the way through the CDF, taking the lesser in case of a tie."""
 637        index = bisect.bisect_left(self.quantities('<='),
 638                                   (n * self.denominator() + d - 1) // d)
 639        if index >= len(self):
 640            return self.max_outcome()
 641        return self.outcomes()[index]
 642
 643    def quantile_high(self, n: int, d: int = 100) -> T_co:
 644        """The outcome `n / d` of the way through the CDF, taking the greater in case of a tie."""
 645        index = bisect.bisect_right(self.quantities('<='),
 646                                    n * self.denominator() // d)
 647        if index >= len(self):
 648            return self.max_outcome()
 649        return self.outcomes()[index]
 650
 651    @overload
 652    def mean(self: 'Population[numbers.Rational]') -> Fraction:
 653        ...
 654
 655    @overload
 656    def mean(self: 'Population[float]') -> float:
 657        ...
 658
 659    def mean(
 660        self: 'Population[numbers.Rational] | Population[float]'
 661    ) -> Fraction | float:
 662        return try_fraction(
 663            sum(outcome * quantity for outcome, quantity in self.items()),
 664            self.denominator())
 665
 666    @overload
 667    def variance(self: 'Population[numbers.Rational]') -> Fraction:
 668        ...
 669
 670    @overload
 671    def variance(self: 'Population[float]') -> float:
 672        ...
 673
 674    def variance(
 675        self: 'Population[numbers.Rational] | Population[float]'
 676    ) -> Fraction | float:
 677        """This is the population variance, not the sample variance."""
 678        mean = self.mean()
 679        mean_of_squares = try_fraction(
 680            sum(quantity * outcome**2 for outcome, quantity in self.items()),
 681            self.denominator())
 682        return mean_of_squares - mean * mean
 683
 684    def standard_deviation(
 685            self: 'Population[numbers.Rational] | Population[float]') -> float:
 686        return math.sqrt(self.variance())
 687
 688    sd = standard_deviation
 689
 690    def standardized_moment(
 691            self: 'Population[numbers.Rational] | Population[float]',
 692            k: int) -> float:
 693        sd = self.standard_deviation()
 694        mean = self.mean()
 695        ev = sum(p * (outcome - mean)**k  # type: ignore 
 696                 for outcome, p in zip(self.outcomes(), self.probabilities()))
 697        return ev / (sd**k)
 698
 699    def skewness(
 700            self: 'Population[numbers.Rational] | Population[float]') -> float:
 701        return self.standardized_moment(3)
 702
 703    def excess_kurtosis(
 704            self: 'Population[numbers.Rational] | Population[float]') -> float:
 705        return self.standardized_moment(4) - 3.0
 706
 707    def entropy(self, base: float = 2.0) -> float:
 708        """The entropy of a random sample from this population.
 709        
 710        Args:
 711            base: The logarithm base to use. Default is 2.0, which gives the 
 712                entropy in bits.
 713        """
 714        return -sum(p * math.log(p, base)
 715                    for p in self.probabilities() if p > 0.0)
 716
 717    # Joint statistics.
 718
 719    class _Marginals(Generic[C]):
 720        """Helper class for implementing `marginals()`."""
 721
 722        _population: C
 723
 724        def __init__(self, population, /):
 725            self._population = population
 726
 727        def __len__(self) -> int:
 728            """The minimum len() of all outcomes."""
 729            return min(len(x) for x in self._population.outcomes())
 730
 731        def __getitem__(self, dims: int | slice, /):
 732            """Marginalizes the given dimensions."""
 733            return self._population._unary_operator(operator.getitem, dims)
 734
 735        def __iter__(self) -> Iterator:
 736            for i in range(len(self)):
 737                yield self[i]
 738
 739        def __getattr__(self, key: str):
 740            if key[0] == '_':
 741                raise AttributeError(key)
 742            return self._population._unary_operator(operator.attrgetter(key))
 743
 744    @property
 745    def marginals(self: C) -> _Marginals[C]:
 746        """A property that applies the `[]` operator to outcomes.
 747
 748        For example, `population.marginals[:2]` will marginalize the first two
 749        elements of sequence outcomes.
 750
 751        Attributes that do not start with an underscore will also be forwarded.
 752        For example, `population.marginals.x` will marginalize the `x` attribute
 753        from e.g. `namedtuple` outcomes.
 754        """
 755        return Population._Marginals(self)
 756
 757    @overload
 758    def covariance(self: 'Population[tuple[numbers.Rational, ...]]', i: int,
 759                   j: int) -> Fraction:
 760        ...
 761
 762    @overload
 763    def covariance(self: 'Population[tuple[float, ...]]', i: int,
 764                   j: int) -> float:
 765        ...
 766
 767    def covariance(
 768            self:
 769        'Population[tuple[numbers.Rational, ...]] | Population[tuple[float, ...]]',
 770            i: int, j: int) -> Fraction | float:
 771        mean_i = self.marginals[i].mean()
 772        mean_j = self.marginals[j].mean()
 773        return try_fraction(
 774            sum((outcome[i] - mean_i) * (outcome[j] - mean_j) * quantity
 775                for outcome, quantity in self.items()), self.denominator())
 776
 777    def correlation(
 778            self:
 779        'Population[tuple[numbers.Rational, ...]] | Population[tuple[float, ...]]',
 780            i: int, j: int) -> float:
 781        sd_i = self.marginals[i].standard_deviation()
 782        sd_j = self.marginals[j].standard_deviation()
 783        return self.covariance(i, j) / (sd_i * sd_j)
 784
 785    # Transformations.
 786
 787    def _select_outcomes(self, which: Callable[..., bool] | Collection[T_co],
 788                         star: bool | None) -> Set[T_co]:
 789        """Returns a set of outcomes of self that fit the given condition."""
 790        if callable(which):
 791            if star is None:
 792                star = infer_star(which)
 793            if star:
 794                # Need TypeVarTuple to check this.
 795                return {
 796                    outcome
 797                    for outcome in self.outcomes()
 798                    if which(*outcome)  # type: ignore
 799                }
 800            else:
 801                return {
 802                    outcome
 803                    for outcome in self.outcomes() if which(outcome)
 804                }
 805        else:
 806            # Collection.
 807            return set(outcome for outcome in self.outcomes()
 808                       if outcome in which)
 809
 810    def to_one_hot(self: C, outcomes: Sequence[T_co] | None = None) -> C:
 811        """Converts the outcomes of this population to a one-hot representation.
 812
 813        Args:
 814            outcomes: If provided, each outcome will be mapped to a `Vector`
 815                where the element at `outcomes.index(outcome)` is set to `True`
 816                and the rest to `False`, or all `False` if the outcome is not
 817                in `outcomes`.
 818                If not provided, `self.outcomes()` is used.
 819        """
 820        if outcomes is None:
 821            outcomes = self.outcomes()
 822
 823        data: MutableMapping[Vector[bool], int] = defaultdict(int)
 824        for outcome, quantity in zip(self.outcomes(), self.quantities()):
 825            value = [False] * len(outcomes)
 826            if outcome in outcomes:
 827                value[outcomes.index(outcome)] = True
 828            data[Vector(value)] += quantity
 829        return self._new_type(data)
 830
 831    def split(self,
 832              outcomes: Callable[..., bool] | Collection[T_co],
 833              /,
 834              *,
 835              star: bool | None = None) -> tuple[C, C]:
 836        """Splits this population into one containing selected items and another containing the rest.
 837
 838        The sum of the denominators of the results is equal to the denominator
 839        of this population.
 840
 841        If you want to split more than two ways, use `Population.group_by()`.
 842
 843        Args:
 844            outcomes: Selects which outcomes to select. Options:
 845                * A callable that takes an outcome and returns `True` if it
 846                    should be selected.
 847                * A collection of outcomes to select.
 848            star: Whether outcomes should be unpacked into separate arguments
 849                before sending them to a callable `which`.
 850                If not provided, this will be guessed based on the function
 851                signature.
 852
 853        Returns:
 854            A population consisting of the outcomes that were selected by
 855            `which`, and a population consisting of the unselected outcomes.
 856        """
 857        outcome_set = self._select_outcomes(outcomes, star)
 858
 859        selected = {}
 860        not_selected = {}
 861        for outcome, count in self.items():
 862            if outcome in outcome_set:
 863                selected[outcome] = count
 864            else:
 865                not_selected[outcome] = count
 866
 867        return self._new_type(selected), self._new_type(not_selected)
 868
 869    class _GroupBy(Generic[C]):
 870        """Helper class for implementing `group_by()`."""
 871
 872        _population: C
 873
 874        def __init__(self, population, /):
 875            self._population = population
 876
 877        def __call__(self,
 878                     key_map: Callable[..., U] | Mapping[T_co, U],
 879                     /,
 880                     *,
 881                     star: bool | None = None) -> Mapping[U, C]:
 882            if callable(key_map):
 883                if star is None:
 884                    star = infer_star(key_map)
 885                if star:
 886                    key_function = lambda o: key_map(*o)
 887                else:
 888                    key_function = key_map
 889            else:
 890                key_function = lambda o: key_map.get(o, o)
 891
 892            result_datas: MutableMapping[U, MutableMapping[Any, int]] = {}
 893            outcome: Any
 894            for outcome, quantity in self._population.items():
 895                key = key_function(outcome)
 896                if key not in result_datas:
 897                    result_datas[key] = defaultdict(int)
 898                result_datas[key][outcome] += quantity
 899            return {
 900                k: self._population._new_type(v)
 901                for k, v in result_datas.items()
 902            }
 903
 904        def __getitem__(self, dims: int | slice, /):
 905            """Marginalizes the given dimensions."""
 906            return self(lambda x: x[dims])
 907
 908        def __getattr__(self, key: str):
 909            if key[0] == '_':
 910                raise AttributeError(key)
 911            return self(lambda x: getattr(x, key))
 912
 913    @property
 914    def group_by(self: C) -> _GroupBy[C]:
 915        """A method-like property that splits this population into sub-populations based on a key function.
 916        
 917        The sum of the denominators of the results is equal to the denominator
 918        of this population.
 919
 920        This can be useful when using the law of total probability.
 921
 922        Example: `d10.group_by(lambda x: x % 3)` is
 923        ```python
 924        {
 925            0: Die([3, 6, 9]),
 926            1: Die([1, 4, 7, 10]),
 927            2: Die([2, 5, 8]),
 928        }
 929        ```
 930
 931        You can also use brackets to group by indexes or slices; or attributes
 932        to group by those. Example:
 933
 934        ```python
 935        Die([
 936            'aardvark',
 937            'alligator',
 938            'asp',
 939            'blowfish',
 940            'cat',
 941            'crocodile',
 942        ]).group_by[0]
 943        ```
 944
 945        produces
 946
 947        ```python
 948        {
 949            'a': Die(['aardvark', 'alligator', 'asp']),
 950            'b': Die(['blowfish']),
 951            'c': Die(['cat', 'crocodile']),
 952        }
 953        ```
 954
 955        Args:
 956            key_map: A function or mapping that takes outcomes and produces the
 957                key of the corresponding outcome in the result. If this is
 958                a Mapping, outcomes not in the mapping are their own key.
 959            star: Whether outcomes should be unpacked into separate arguments
 960                before sending them to a callable `key_map`.
 961                If not provided, this will be guessed based on the function
 962                signature.
 963        """
 964        return Population._GroupBy(self)
 965
 966    def sample(self) -> T_co:
 967        """A single random sample from this population.
 968
 969        Note that this is always "with replacement" even for `Deck` since
 970        instances are immutable.
 971
 972        This uses the standard `random` package and is not cryptographically
 973        secure.
 974        """
 975        # We don't use random.choices since that is based on floats rather than ints.
 976        r = random.randrange(self.denominator())
 977        index = bisect.bisect_right(self.quantities('<='), r)
 978        return self.outcomes()[index]
 979
 980    def format(self, format_spec: str, /, **kwargs) -> str:
 981        """Formats this mapping as a string.
 982
 983        `format_spec` should start with the output format,
 984        which can be:
 985        * `md` for Markdown (default)
 986        * `bbcode` for BBCode
 987        * `csv` for comma-separated values
 988        * `html` for HTML
 989
 990        After this, you may optionally add a `:` followed by a series of
 991        requested columns. Allowed columns are:
 992
 993        * `o`: Outcomes.
 994        * `*o`: Outcomes, unpacked if applicable.
 995        * `q==`, `q<=`, `q>=`: Quantities ==, <=, or >= each outcome.
 996        * `p==`, `p<=`, `p>=`: Probabilities (0-1).
 997        * `%==`, `%<=`, `%>=`: Probabilities (0%-100%).
 998        * `i==`, `i<=`, `i>=`: EXPERIMENTAL: "1 in N".
 999
1000        Columns may optionally be separated using `|` characters.
1001
1002        The default setting is equal to `f'{die:md:*o|q==|%==}'`. Here the 
1003        columns are the outcomes (unpacked if applicable) the quantities, and 
1004        the probabilities. The quantities are omitted from the default columns 
1005        if any individual quantity is 10**30 or greater.
1006        """
1007        if not self.is_empty() and self.modal_quantity() < 10**30:
1008            default_column_spec = '*oq==%=='
1009        else:
1010            default_column_spec = '*o%=='
1011        if len(format_spec) == 0:
1012            format_spec = 'md:' + default_column_spec
1013
1014        format_spec = format_spec.replace('|', '')
1015
1016        parts = format_spec.split(':')
1017
1018        if len(parts) == 1:
1019            output_format = parts[0]
1020            col_spec = default_column_spec
1021        elif len(parts) == 2:
1022            output_format = parts[0]
1023            col_spec = parts[1]
1024        else:
1025            raise ValueError('format_spec has too many colons.')
1026
1027        match output_format:
1028            case 'md':
1029                return icepool.population.format.markdown(self, col_spec)
1030            case 'bbcode':
1031                return icepool.population.format.bbcode(self, col_spec)
1032            case 'csv':
1033                return icepool.population.format.csv(self, col_spec, **kwargs)
1034            case 'html':
1035                return icepool.population.format.html(self, col_spec)
1036            case _:
1037                raise ValueError(
1038                    f"Unsupported output format '{output_format}'")
1039
1040    def __format__(self, format_spec: str, /) -> str:
1041        return self.format(format_spec)
1042
1043    def __str__(self) -> str:
1044        return f'{self}'

A mapping from outcomes to int quantities.

Outcomes with each instance must be hashable and totally orderable.

Subclasses include Die and Deck.

@abstractmethod
def keys(self) -> CountsKeysView[+T_co]:
44    @abstractmethod
45    def keys(self) -> CountsKeysView[T_co]:
46        """The outcomes within the population in sorted order."""

The outcomes within the population in sorted order.

@abstractmethod
def values(self) -> CountsValuesView:
48    @abstractmethod
49    def values(self) -> CountsValuesView:
50        """The quantities within the population in outcome order."""

The quantities within the population in outcome order.

@abstractmethod
def items(self) -> CountsItemsView[+T_co]:
52    @abstractmethod
53    def items(self) -> CountsItemsView[T_co]:
54        """The (outcome, quantity)s of the population in sorted order."""

The (outcome, quantity)s of the population in sorted order.

def outcomes(self) -> CountsKeysView[+T_co]:
69    def outcomes(self) -> CountsKeysView[T_co]:
70        """The outcomes of the mapping in ascending order.
71
72        These are also the `keys` of the mapping.
73        Prefer to use the name `outcomes`.
74        """
75        return self.keys()

The outcomes of the mapping in ascending order.

These are also the keys of the mapping. Prefer to use the name outcomes.

def common_outcome_length(self) -> int | None:
90    def common_outcome_length(self) -> int | None:
91        """The common length of all outcomes.
92
93        If outcomes have no lengths or different lengths, the result is `None`.
94        """
95        return self._common_outcome_length

The common length of all outcomes.

If outcomes have no lengths or different lengths, the result is None.

def is_empty(self) -> bool:
97    def is_empty(self) -> bool:
98        """`True` iff this population has no outcomes. """
99        return len(self) == 0

True iff this population has no outcomes.

def min_outcome(self) -> +T_co:
101    def min_outcome(self) -> T_co:
102        """The least outcome."""
103        return self.outcomes()[0]

The least outcome.

def max_outcome(self) -> +T_co:
105    def max_outcome(self) -> T_co:
106        """The greatest outcome."""
107        return self.outcomes()[-1]

The greatest outcome.

def nearest( self, comparison: Literal['<=', '<', '>=', '>'], outcome, /) -> +T_co | None:
109    def nearest(self, comparison: Literal['<=', '<', '>=', '>'], outcome,
110                /) -> T_co | None:
111        """The nearest outcome in this population fitting the comparison.
112
113        Args:
114            comparison: The comparison which the result must fit. For example,
115                '<=' would find the greatest outcome that is not greater than
116                the argument.
117            outcome: The outcome to compare against.
118        
119        Returns:
120            The nearest outcome fitting the comparison, or `None` if there is
121            no such outcome.
122        """
123        match comparison:
124            case '<=':
125                if outcome in self:
126                    return outcome
127                index = bisect.bisect_right(self.outcomes(), outcome) - 1
128                if index < 0:
129                    return None
130                return self.outcomes()[index]
131            case '<':
132                index = bisect.bisect_left(self.outcomes(), outcome) - 1
133                if index < 0:
134                    return None
135                return self.outcomes()[index]
136            case '>=':
137                if outcome in self:
138                    return outcome
139                index = bisect.bisect_left(self.outcomes(), outcome)
140                if index >= len(self):
141                    return None
142                return self.outcomes()[index]
143            case '>':
144                index = bisect.bisect_right(self.outcomes(), outcome)
145                if index >= len(self):
146                    return None
147                return self.outcomes()[index]
148            case _:
149                raise ValueError(f'Invalid comparison {comparison}')

The nearest outcome in this population fitting the comparison.

Arguments:
  • comparison: The comparison which the result must fit. For example, '<=' would find the greatest outcome that is not greater than the argument.
  • outcome: The outcome to compare against.
Returns:

The nearest outcome fitting the comparison, or None if there is no such outcome.

def zero(self: ~C) -> ~C:
155    def zero(self: C) -> C:
156        """Zeros all outcomes of this population.
157
158        This is done by multiplying all outcomes by `0`.
159
160        The result will have the same denominator.
161
162        Raises:
163            ValueError: If the zeros did not resolve to a single outcome.
164        """
165        result = self._unary_operator(Population._zero)
166        if len(result) != 1:
167            raise ValueError('zero() did not resolve to a single outcome.')
168        return result

Zeros all outcomes of this population.

This is done by multiplying all outcomes by 0.

The result will have the same denominator.

Raises:
  • ValueError: If the zeros did not resolve to a single outcome.
def zero_outcome(self) -> +T_co:
170    def zero_outcome(self) -> T_co:
171        """A zero-outcome for this population.
172
173        E.g. `0` for a `Population` whose outcomes are `int`s.
174        """
175        return self.zero().outcomes()[0]

A zero-outcome for this population.

E.g. 0 for a Population whose outcomes are ints.

def quantity( self, comparison: Literal['==', '!=', '<=', '<', '>=', '>'] | Hashable, outcome: Hashable | None = None, /) -> int:
188    def quantity(self,
189                 comparison: Literal['==', '!=', '<=', '<', '>=', '>']
190                 | Hashable,
191                 outcome: Hashable | None = None,
192                 /) -> int:
193        """The quantity of a single outcome.
194
195        A comparison can be provided, in which case this returns the total
196        quantity fitting the comparison.
197        
198        Args:
199            comparison: The comparison to use. This can be omitted, in which
200                case it is treated as '=='.
201            outcome: The outcome to query.
202        """
203        if outcome is None:
204            outcome = comparison
205            comparison = '=='
206        else:
207            comparison = cast(Literal['==', '!=', '<=', '<', '>=', '>'],
208                              comparison)
209
210        match comparison:
211            case '==':
212                return self.get(outcome, 0)
213            case '!=':
214                return self.denominator() - self.get(outcome, 0)
215            case '<=' | '<':
216                threshold = self.nearest(comparison, outcome)
217                if threshold is None:
218                    return 0
219                else:
220                    return self._cumulative_quantities[threshold]
221            case '>=':
222                return self.denominator() - self.quantity('<', outcome)
223            case '>':
224                return self.denominator() - self.quantity('<=', outcome)
225            case _:
226                raise ValueError(f'Invalid comparison {comparison}')

The quantity of a single outcome.

A comparison can be provided, in which case this returns the total quantity fitting the comparison.

Arguments:
  • comparison: The comparison to use. This can be omitted, in which case it is treated as '=='.
  • outcome: The outcome to query.
def quantity_where(self, which: Callable[..., bool], /, star: bool | None = None) -> int:
228    def quantity_where(self,
229                       which: Callable[..., bool],
230                       /,
231                       star: bool | None = None) -> int:
232        """The quantity fulfilling a boolean condition."""
233        if star is None:
234            star = infer_star(which)
235        if star:
236            return sum(quantity  # type: ignore
237                       for outcome, quantity in self.items()
238                       if which(*outcome))  # type: ignore
239        else:
240            return sum(quantity for outcome, quantity in self.items()
241                       if which(outcome))

The quantity fulfilling a boolean condition.

def quantities( self, comparison: Literal['==', '!=', '<=', '<', '>=', '>'] | None = None, /) -> CountsValuesView | Sequence[int]:
262    def quantities(self,
263                   comparison: Literal['==', '!=', '<=', '<', '>=', '>']
264                   | None = None,
265                   /) -> CountsValuesView | Sequence[int]:
266        """The quantities of the mapping in sorted order.
267
268        For example, '<=' gives the CDF.
269
270        Args:
271            comparison: One of `'==', '!=', '<=', '<', '>=', '>'`.
272                May be omitted, in which case equality `'=='` is used.
273        """
274        if comparison is None:
275            comparison = '=='
276
277        match comparison:
278            case '==':
279                return self.values()
280            case '<=':
281                return tuple(itertools.accumulate(self.values()))
282            case '>=':
283                if self.is_empty():
284                    return ()
285                return tuple(
286                    itertools.accumulate(self.values()[:-1],
287                                         operator.sub,
288                                         initial=self.denominator()))
289            case '!=':
290                return tuple(self.denominator() - q for q in self.values())
291            case '<':
292                return tuple(self.denominator() - q
293                             for q in self.quantities('>='))
294            case '>':
295                return tuple(self.denominator() - q
296                             for q in self.quantities('<='))
297            case _:
298                raise ValueError(f'Invalid comparison {comparison}')

The quantities of the mapping in sorted order.

For example, '<=' gives the CDF.

Arguments:
  • comparison: One of '==', '!=', '<=', '<', '>=', '>'. May be omitted, in which case equality '==' is used.
def denominator(self) -> int:
313    def denominator(self) -> int:
314        """The sum of all quantities (e.g. weights or duplicates).
315
316        For the number of unique outcomes, use `len()`.
317        """
318        return self._denominator

The sum of all quantities (e.g. weights or duplicates).

For the number of unique outcomes, use len().

def multiply_quantities(self: ~C, scale: int, /) -> ~C:
320    def multiply_quantities(self: C, scale: int, /) -> C:
321        """Multiplies all quantities by an integer."""
322        if scale == 1:
323            return self
324        data = {
325            outcome: quantity * scale
326            for outcome, quantity in self.items()
327        }
328        return self._new_type(data)

Multiplies all quantities by an integer.

def divide_quantities(self: ~C, divisor: int, /) -> ~C:
330    def divide_quantities(self: C, divisor: int, /) -> C:
331        """Divides all quantities by an integer, rounding down.
332        
333        Resulting zero quantities are dropped.
334        """
335        if divisor == 0:
336            return self
337        data = {
338            outcome: quantity // divisor
339            for outcome, quantity in self.items() if quantity >= divisor
340        }
341        return self._new_type(data)

Divides all quantities by an integer, rounding down.

Resulting zero quantities are dropped.

def modulo_quantities(self: ~C, divisor: int, /) -> ~C:
343    def modulo_quantities(self: C, divisor: int, /) -> C:
344        """Modulus of all quantities with an integer."""
345        data = {
346            outcome: quantity % divisor
347            for outcome, quantity in self.items()
348        }
349        return self._new_type(data)

Modulus of all quantities with an integer.

def pad_to_denominator(self: ~C, denominator: int, /, outcome: Hashable) -> ~C:
351    def pad_to_denominator(self: C, denominator: int, /,
352                           outcome: Hashable) -> C:
353        """Changes the denominator to a target number by changing the quantity of a specified outcome.
354        
355        Args:
356            `target`: The denominator of the result.
357            `outcome`: The outcome whose quantity will be adjusted.
358
359        Returns:
360            A `Population` like `self` but with the quantity of `outcome`
361            adjusted so that the overall denominator is equal to  `target`.
362            If the denominator is reduced to zero, it will be removed.
363
364        Raises:
365            `ValueError` if this would require the quantity of the specified
366            outcome to be negative.
367        """
368        adjustment = denominator - self.denominator()
369        data = {outcome: quantity for outcome, quantity in self.items()}
370        new_quantity = data.get(outcome, 0) + adjustment
371        if new_quantity > 0:
372            data[outcome] = new_quantity
373        elif new_quantity == 0:
374            # The outcome may not have been present in the first place.
375            data.pop(outcome, None)
376        else:
377            raise ValueError(
378                f'Padding to denominator of {denominator} would require a negative quantity of {new_quantity} for {outcome}'
379            )
380        return self._new_type(data)

Changes the denominator to a target number by changing the quantity of a specified outcome.

Arguments:
  • target: The denominator of the result.
  • outcome: The outcome whose quantity will be adjusted.
Returns:

A Population like self but with the quantity of outcome adjusted so that the overall denominator is equal to target. If the denominator is reduced to zero, it will be removed.

Raises:
  • ValueError if this would require the quantity of the specified
  • outcome to be negative.
def multiply_to_denominator(self: ~C, denominator: int, /) -> ~C:
382    def multiply_to_denominator(self: C, denominator: int, /) -> C:
383        """Multiplies all quantities to reach the target denominiator.
384        
385        Raises:
386            ValueError if this cannot be achieved using an integer scaling.
387        """
388        if denominator % self.denominator():
389            raise ValueError(
390                'Target denominator is not an integer factor of the current denominator.'
391            )
392        return self.multiply_quantities(denominator // self.denominator())

Multiplies all quantities to reach the target denominiator.

Raises:
  • ValueError if this cannot be achieved using an integer scaling.
def append(self: ~C, outcome, quantity: int = 1, /) -> ~C:
394    def append(self: C, outcome, quantity: int = 1, /) -> C:
395        """This population with an outcome appended.
396        
397        Args:
398            outcome: The outcome to append.
399            quantity: The quantity of the outcome to append. Can be negative,
400                which removes quantity (but not below zero).
401        """
402        data = Counter(self)
403        data[outcome] = max(data[outcome] + quantity, 0)
404        return self._new_type(data)

This population with an outcome appended.

Arguments:
  • outcome: The outcome to append.
  • quantity: The quantity of the outcome to append. Can be negative, which removes quantity (but not below zero).
def remove(self: ~C, outcome, quantity: int | None = None, /) -> ~C:
406    def remove(self: C, outcome, quantity: int | None = None, /) -> C:
407        """This population with an outcome removed.
408
409        Args:
410            outcome: The outcome to append.
411            quantity: The quantity of the outcome to remove. If not set, all
412                quantity of that outcome is removed. Can be negative, which adds
413                quantity instead.
414        """
415        if quantity is None:
416            data = Counter(self)
417            data[outcome] = 0
418            return self._new_type(data)
419        else:
420            return self.append(outcome, -quantity)

This population with an outcome removed.

Arguments:
  • outcome: The outcome to append.
  • quantity: The quantity of the outcome to remove. If not set, all quantity of that outcome is removed. Can be negative, which adds quantity instead.
def probability( self, comparison: Literal['==', '!=', '<=', '<', '>=', '>'] | Hashable, outcome: Hashable | None = None, /, *, percent: bool = False) -> fractions.Fraction | float:
456    def probability(self,
457                    comparison: Literal['==', '!=', '<=', '<', '>=', '>']
458                    | Hashable,
459                    outcome: Hashable | None = None,
460                    /,
461                    *,
462                    percent: bool = False) -> Fraction | float:
463        """The total probability of outcomes fitting a comparison.
464        
465        Args:
466            comparison: One of `'==', '!=', '<=', '<', '>=', '>'`.
467                May be omitted, in which case equality `'=='` is used.
468            outcome: The outcome to compare to.
469            percent: If set, the result will be a percentage expressed as a
470                `float`. Otherwise, the result is a `Fraction`.
471        """
472        if outcome is None:
473            outcome = comparison
474            comparison = '=='
475        else:
476            comparison = cast(Literal['==', '!=', '<=', '<', '>=', '>'],
477                              comparison)
478        result = Fraction(self.quantity(comparison, outcome),
479                          self.denominator())
480        return result * 100.0 if percent else result

The total probability of outcomes fitting a comparison.

Arguments:
  • comparison: One of '==', '!=', '<=', '<', '>=', '>'. May be omitted, in which case equality '==' is used.
  • outcome: The outcome to compare to.
  • percent: If set, the result will be a percentage expressed as a float. Otherwise, the result is a Fraction.
def probability_where( self, which: Callable[..., bool], /, star: bool | None = None, percent: bool = False) -> fractions.Fraction | float:
499    def probability_where(self,
500                          which: Callable[..., bool],
501                          /,
502                          star: bool | None = None,
503                          percent: bool = False) -> Fraction | float:
504        """The probability fulfilling a boolean condition."""
505        numerator = self.quantity_where(which, star=star)
506        if percent:
507            return 100.0 * numerator / self.denominator()
508        else:
509            return Fraction(numerator, self.denominator())

The probability fulfilling a boolean condition.

def probabilities( self, comparison: Literal['==', '!=', '<=', '<', '>=', '>'] | None = None, /, *, percent: bool = False) -> Sequence[fractions.Fraction] | Sequence[float]:
550    def probabilities(
551            self,
552            comparison: Literal['==', '!=', '<=', '<', '>=', '>']
553        | None = None,
554            /,
555            *,
556            percent: bool = False) -> Sequence[Fraction] | Sequence[float]:
557        """The total probabilities fitting the comparison for each outcome in sorted order.
558        
559        For example, '<=' gives the CDF.
560
561        Args:
562            comparison: One of `'==', '!=', '<=', '<', '>=', '>'`.
563                May be omitted, in which case equality `'=='` is used.
564            percent: If set, the result will be a percentage expressed as a
565                `float`. Otherwise, the result is a `Fraction`.
566        """
567        if comparison is None:
568            comparison = '=='
569
570        result = tuple(
571            Fraction(q, self.denominator())
572            for q in self.quantities(comparison))
573
574        if percent:
575            return tuple(100.0 * x for x in result)
576        else:
577            return result

The total probabilities fitting the comparison for each outcome in sorted order.

For example, '<=' gives the CDF.

Arguments:
  • comparison: One of '==', '!=', '<=', '<', '>=', '>'. May be omitted, in which case equality '==' is used.
  • percent: If set, the result will be a percentage expressed as a float. Otherwise, the result is a Fraction.
def mode(self) -> tuple:
581    def mode(self) -> tuple:
582        """A tuple containing the most common outcome(s) of the population.
583
584        These are sorted from lowest to highest.
585        """
586        return tuple(outcome for outcome, quantity in self.items()
587                     if quantity == self.modal_quantity())

A tuple containing the most common outcome(s) of the population.

These are sorted from lowest to highest.

def modal_quantity(self) -> int:
589    def modal_quantity(self) -> int:
590        """The highest quantity of any single outcome. """
591        return max(self.quantities())

The highest quantity of any single outcome.

def kolmogorov_smirnov(self, other: Population) -> fractions.Fraction:
593    def kolmogorov_smirnov(self, other: 'Population') -> Fraction:
594        """Kolmogorov–Smirnov statistic. The maximum absolute difference between CDFs. """
595        outcomes = icepool.sorted_union(self, other)
596        return max(
597            abs(
598                self.probability('<=', outcome) -
599                other.probability('<=', outcome)) for outcome in outcomes)

Kolmogorov–Smirnov statistic. The maximum absolute difference between CDFs.

def cramer_von_mises(self, other: Population) -> fractions.Fraction:
601    def cramer_von_mises(self, other: 'Population') -> Fraction:
602        """Cramér-von Mises statistic. The sum-of-squares difference between CDFs. """
603        outcomes = icepool.sorted_union(self, other)
604        return sum(((self.probability('<=', outcome) -
605                     other.probability('<=', outcome))**2
606                    for outcome in outcomes),
607                   start=Fraction(0, 1))

Cramér-von Mises statistic. The sum-of-squares difference between CDFs.

def median(self):
609    def median(self):
610        """The median, taking the mean in case of a tie.
611
612        This will fail if the outcomes do not support division;
613        in this case, use `median_low` or `median_high` instead.
614        """
615        return self.quantile(1, 2)

The median, taking the mean in case of a tie.

This will fail if the outcomes do not support division; in this case, use median_low or median_high instead.

def median_low(self) -> +T_co:
617    def median_low(self) -> T_co:
618        """The median, taking the lower in case of a tie."""
619        return self.quantile_low(1, 2)

The median, taking the lower in case of a tie.

def median_high(self) -> +T_co:
621    def median_high(self) -> T_co:
622        """The median, taking the higher in case of a tie."""
623        return self.quantile_high(1, 2)

The median, taking the higher in case of a tie.

def quantile(self, n: int, d: int = 100):
625    def quantile(self, n: int, d: int = 100):
626        """The outcome `n / d` of the way through the CDF, taking the mean in case of a tie.
627
628        This will fail if the outcomes do not support addition and division;
629        in this case, use `quantile_low` or `quantile_high` instead.
630        """
631        # Should support addition and division.
632        return (self.quantile_low(n, d) +
633                self.quantile_high(n, d)) / 2  # type: ignore

The outcome n / d of the way through the CDF, taking the mean in case of a tie.

This will fail if the outcomes do not support addition and division; in this case, use quantile_low or quantile_high instead.

def quantile_low(self, n: int, d: int = 100) -> +T_co:
635    def quantile_low(self, n: int, d: int = 100) -> T_co:
636        """The outcome `n / d` of the way through the CDF, taking the lesser in case of a tie."""
637        index = bisect.bisect_left(self.quantities('<='),
638                                   (n * self.denominator() + d - 1) // d)
639        if index >= len(self):
640            return self.max_outcome()
641        return self.outcomes()[index]

The outcome n / d of the way through the CDF, taking the lesser in case of a tie.

def quantile_high(self, n: int, d: int = 100) -> +T_co:
643    def quantile_high(self, n: int, d: int = 100) -> T_co:
644        """The outcome `n / d` of the way through the CDF, taking the greater in case of a tie."""
645        index = bisect.bisect_right(self.quantities('<='),
646                                    n * self.denominator() // d)
647        if index >= len(self):
648            return self.max_outcome()
649        return self.outcomes()[index]

The outcome n / d of the way through the CDF, taking the greater in case of a tie.

def mean( self: Population[numbers.Rational] | Population[float]) -> fractions.Fraction | float:
659    def mean(
660        self: 'Population[numbers.Rational] | Population[float]'
661    ) -> Fraction | float:
662        return try_fraction(
663            sum(outcome * quantity for outcome, quantity in self.items()),
664            self.denominator())
def variance( self: Population[numbers.Rational] | Population[float]) -> fractions.Fraction | float:
674    def variance(
675        self: 'Population[numbers.Rational] | Population[float]'
676    ) -> Fraction | float:
677        """This is the population variance, not the sample variance."""
678        mean = self.mean()
679        mean_of_squares = try_fraction(
680            sum(quantity * outcome**2 for outcome, quantity in self.items()),
681            self.denominator())
682        return mean_of_squares - mean * mean

This is the population variance, not the sample variance.

def standard_deviation( self: Population[numbers.Rational] | Population[float]) -> float:
684    def standard_deviation(
685            self: 'Population[numbers.Rational] | Population[float]') -> float:
686        return math.sqrt(self.variance())
def sd( self: Population[numbers.Rational] | Population[float]) -> float:
684    def standard_deviation(
685            self: 'Population[numbers.Rational] | Population[float]') -> float:
686        return math.sqrt(self.variance())
def standardized_moment( self: Population[numbers.Rational] | Population[float], k: int) -> float:
690    def standardized_moment(
691            self: 'Population[numbers.Rational] | Population[float]',
692            k: int) -> float:
693        sd = self.standard_deviation()
694        mean = self.mean()
695        ev = sum(p * (outcome - mean)**k  # type: ignore 
696                 for outcome, p in zip(self.outcomes(), self.probabilities()))
697        return ev / (sd**k)
def skewness( self: Population[numbers.Rational] | Population[float]) -> float:
699    def skewness(
700            self: 'Population[numbers.Rational] | Population[float]') -> float:
701        return self.standardized_moment(3)
def excess_kurtosis( self: Population[numbers.Rational] | Population[float]) -> float:
703    def excess_kurtosis(
704            self: 'Population[numbers.Rational] | Population[float]') -> float:
705        return self.standardized_moment(4) - 3.0
def entropy(self, base: float = 2.0) -> float:
707    def entropy(self, base: float = 2.0) -> float:
708        """The entropy of a random sample from this population.
709        
710        Args:
711            base: The logarithm base to use. Default is 2.0, which gives the 
712                entropy in bits.
713        """
714        return -sum(p * math.log(p, base)
715                    for p in self.probabilities() if p > 0.0)

The entropy of a random sample from this population.

Arguments:
  • base: The logarithm base to use. Default is 2.0, which gives the entropy in bits.
marginals: icepool.population.base.Population._Marginals[~C]
744    @property
745    def marginals(self: C) -> _Marginals[C]:
746        """A property that applies the `[]` operator to outcomes.
747
748        For example, `population.marginals[:2]` will marginalize the first two
749        elements of sequence outcomes.
750
751        Attributes that do not start with an underscore will also be forwarded.
752        For example, `population.marginals.x` will marginalize the `x` attribute
753        from e.g. `namedtuple` outcomes.
754        """
755        return Population._Marginals(self)

A property that applies the [] operator to outcomes.

For example, population.marginals[:2] will marginalize the first two elements of sequence outcomes.

Attributes that do not start with an underscore will also be forwarded. For example, population.marginals.x will marginalize the x attribute from e.g. namedtuple outcomes.

def covariance( self: Population[tuple[numbers.Rational, ...]] | Population[tuple[float, ...]], i: int, j: int) -> fractions.Fraction | float:
767    def covariance(
768            self:
769        'Population[tuple[numbers.Rational, ...]] | Population[tuple[float, ...]]',
770            i: int, j: int) -> Fraction | float:
771        mean_i = self.marginals[i].mean()
772        mean_j = self.marginals[j].mean()
773        return try_fraction(
774            sum((outcome[i] - mean_i) * (outcome[j] - mean_j) * quantity
775                for outcome, quantity in self.items()), self.denominator())
def correlation( self: Population[tuple[numbers.Rational, ...]] | Population[tuple[float, ...]], i: int, j: int) -> float:
777    def correlation(
778            self:
779        'Population[tuple[numbers.Rational, ...]] | Population[tuple[float, ...]]',
780            i: int, j: int) -> float:
781        sd_i = self.marginals[i].standard_deviation()
782        sd_j = self.marginals[j].standard_deviation()
783        return self.covariance(i, j) / (sd_i * sd_j)
def to_one_hot(self: ~C, outcomes: Sequence[+T_co] | None = None) -> ~C:
810    def to_one_hot(self: C, outcomes: Sequence[T_co] | None = None) -> C:
811        """Converts the outcomes of this population to a one-hot representation.
812
813        Args:
814            outcomes: If provided, each outcome will be mapped to a `Vector`
815                where the element at `outcomes.index(outcome)` is set to `True`
816                and the rest to `False`, or all `False` if the outcome is not
817                in `outcomes`.
818                If not provided, `self.outcomes()` is used.
819        """
820        if outcomes is None:
821            outcomes = self.outcomes()
822
823        data: MutableMapping[Vector[bool], int] = defaultdict(int)
824        for outcome, quantity in zip(self.outcomes(), self.quantities()):
825            value = [False] * len(outcomes)
826            if outcome in outcomes:
827                value[outcomes.index(outcome)] = True
828            data[Vector(value)] += quantity
829        return self._new_type(data)

Converts the outcomes of this population to a one-hot representation.

Arguments:
  • outcomes: If provided, each outcome will be mapped to a Vector where the element at outcomes.index(outcome) is set to True and the rest to False, or all False if the outcome is not in outcomes. If not provided, self.outcomes() is used.
def split( self, outcomes: Callable[..., bool] | Collection[+T_co], /, *, star: bool | None = None) -> tuple[~C, ~C]:
831    def split(self,
832              outcomes: Callable[..., bool] | Collection[T_co],
833              /,
834              *,
835              star: bool | None = None) -> tuple[C, C]:
836        """Splits this population into one containing selected items and another containing the rest.
837
838        The sum of the denominators of the results is equal to the denominator
839        of this population.
840
841        If you want to split more than two ways, use `Population.group_by()`.
842
843        Args:
844            outcomes: Selects which outcomes to select. Options:
845                * A callable that takes an outcome and returns `True` if it
846                    should be selected.
847                * A collection of outcomes to select.
848            star: Whether outcomes should be unpacked into separate arguments
849                before sending them to a callable `which`.
850                If not provided, this will be guessed based on the function
851                signature.
852
853        Returns:
854            A population consisting of the outcomes that were selected by
855            `which`, and a population consisting of the unselected outcomes.
856        """
857        outcome_set = self._select_outcomes(outcomes, star)
858
859        selected = {}
860        not_selected = {}
861        for outcome, count in self.items():
862            if outcome in outcome_set:
863                selected[outcome] = count
864            else:
865                not_selected[outcome] = count
866
867        return self._new_type(selected), self._new_type(not_selected)

Splits this population into one containing selected items and another containing the rest.

The sum of the denominators of the results is equal to the denominator of this population.

If you want to split more than two ways, use Population.group_by().

Arguments:
  • outcomes: Selects which outcomes to select. Options:
    • A callable that takes an outcome and returns True if it should be selected.
    • A collection of outcomes to select.
  • star: Whether outcomes should be unpacked into separate arguments before sending them to a callable which. If not provided, this will be guessed based on the function signature.
Returns:

A population consisting of the outcomes that were selected by which, and a population consisting of the unselected outcomes.

group_by: icepool.population.base.Population._GroupBy[~C]
913    @property
914    def group_by(self: C) -> _GroupBy[C]:
915        """A method-like property that splits this population into sub-populations based on a key function.
916        
917        The sum of the denominators of the results is equal to the denominator
918        of this population.
919
920        This can be useful when using the law of total probability.
921
922        Example: `d10.group_by(lambda x: x % 3)` is
923        ```python
924        {
925            0: Die([3, 6, 9]),
926            1: Die([1, 4, 7, 10]),
927            2: Die([2, 5, 8]),
928        }
929        ```
930
931        You can also use brackets to group by indexes or slices; or attributes
932        to group by those. Example:
933
934        ```python
935        Die([
936            'aardvark',
937            'alligator',
938            'asp',
939            'blowfish',
940            'cat',
941            'crocodile',
942        ]).group_by[0]
943        ```
944
945        produces
946
947        ```python
948        {
949            'a': Die(['aardvark', 'alligator', 'asp']),
950            'b': Die(['blowfish']),
951            'c': Die(['cat', 'crocodile']),
952        }
953        ```
954
955        Args:
956            key_map: A function or mapping that takes outcomes and produces the
957                key of the corresponding outcome in the result. If this is
958                a Mapping, outcomes not in the mapping are their own key.
959            star: Whether outcomes should be unpacked into separate arguments
960                before sending them to a callable `key_map`.
961                If not provided, this will be guessed based on the function
962                signature.
963        """
964        return Population._GroupBy(self)

A method-like property that splits this population into sub-populations based on a key function.

The sum of the denominators of the results is equal to the denominator of this population.

This can be useful when using the law of total probability.

Example: d10.group_by(lambda x: x % 3) is

{
    0: Die([3, 6, 9]),
    1: Die([1, 4, 7, 10]),
    2: Die([2, 5, 8]),
}

You can also use brackets to group by indexes or slices; or attributes to group by those. Example:

Die([
    'aardvark',
    'alligator',
    'asp',
    'blowfish',
    'cat',
    'crocodile',
]).group_by[0]

produces

{
    'a': Die(['aardvark', 'alligator', 'asp']),
    'b': Die(['blowfish']),
    'c': Die(['cat', 'crocodile']),
}
Arguments:
  • key_map: A function or mapping that takes outcomes and produces the key of the corresponding outcome in the result. If this is a Mapping, outcomes not in the mapping are their own key.
  • star: Whether outcomes should be unpacked into separate arguments before sending them to a callable key_map. If not provided, this will be guessed based on the function signature.
def sample(self) -> +T_co:
966    def sample(self) -> T_co:
967        """A single random sample from this population.
968
969        Note that this is always "with replacement" even for `Deck` since
970        instances are immutable.
971
972        This uses the standard `random` package and is not cryptographically
973        secure.
974        """
975        # We don't use random.choices since that is based on floats rather than ints.
976        r = random.randrange(self.denominator())
977        index = bisect.bisect_right(self.quantities('<='), r)
978        return self.outcomes()[index]

A single random sample from this population.

Note that this is always "with replacement" even for Deck since instances are immutable.

This uses the standard random package and is not cryptographically secure.

def format(self, format_spec: str, /, **kwargs) -> str:
 980    def format(self, format_spec: str, /, **kwargs) -> str:
 981        """Formats this mapping as a string.
 982
 983        `format_spec` should start with the output format,
 984        which can be:
 985        * `md` for Markdown (default)
 986        * `bbcode` for BBCode
 987        * `csv` for comma-separated values
 988        * `html` for HTML
 989
 990        After this, you may optionally add a `:` followed by a series of
 991        requested columns. Allowed columns are:
 992
 993        * `o`: Outcomes.
 994        * `*o`: Outcomes, unpacked if applicable.
 995        * `q==`, `q<=`, `q>=`: Quantities ==, <=, or >= each outcome.
 996        * `p==`, `p<=`, `p>=`: Probabilities (0-1).
 997        * `%==`, `%<=`, `%>=`: Probabilities (0%-100%).
 998        * `i==`, `i<=`, `i>=`: EXPERIMENTAL: "1 in N".
 999
1000        Columns may optionally be separated using `|` characters.
1001
1002        The default setting is equal to `f'{die:md:*o|q==|%==}'`. Here the 
1003        columns are the outcomes (unpacked if applicable) the quantities, and 
1004        the probabilities. The quantities are omitted from the default columns 
1005        if any individual quantity is 10**30 or greater.
1006        """
1007        if not self.is_empty() and self.modal_quantity() < 10**30:
1008            default_column_spec = '*oq==%=='
1009        else:
1010            default_column_spec = '*o%=='
1011        if len(format_spec) == 0:
1012            format_spec = 'md:' + default_column_spec
1013
1014        format_spec = format_spec.replace('|', '')
1015
1016        parts = format_spec.split(':')
1017
1018        if len(parts) == 1:
1019            output_format = parts[0]
1020            col_spec = default_column_spec
1021        elif len(parts) == 2:
1022            output_format = parts[0]
1023            col_spec = parts[1]
1024        else:
1025            raise ValueError('format_spec has too many colons.')
1026
1027        match output_format:
1028            case 'md':
1029                return icepool.population.format.markdown(self, col_spec)
1030            case 'bbcode':
1031                return icepool.population.format.bbcode(self, col_spec)
1032            case 'csv':
1033                return icepool.population.format.csv(self, col_spec, **kwargs)
1034            case 'html':
1035                return icepool.population.format.html(self, col_spec)
1036            case _:
1037                raise ValueError(
1038                    f"Unsupported output format '{output_format}'")

Formats this mapping as a string.

format_spec should start with the output format, which can be:

  • md for Markdown (default)
  • bbcode for BBCode
  • csv for comma-separated values
  • html for HTML

After this, you may optionally add a : followed by a series of requested columns. Allowed columns are:

  • o: Outcomes.
  • *o: Outcomes, unpacked if applicable.
  • q==, q<=, q>=: Quantities ==, <=, or >= each outcome.
  • p==, p<=, p>=: Probabilities (0-1).
  • %==, %<=, %>=: Probabilities (0%-100%).
  • i==, i<=, i>=: EXPERIMENTAL: "1 in N".

Columns may optionally be separated using | characters.

The default setting is equal to f'{die:md:*o|q==|%==}'. Here the columns are the outcomes (unpacked if applicable) the quantities, and the probabilities. The quantities are omitted from the default columns if any individual quantity is 10**30 or greater.

def tupleize( *args: ~T | Population[~T] | RerollType) -> tuple[~T, ...] | Population[tuple[~T, ...]] | RerollType:
110def tupleize(
111    *args: 'T | icepool.Population[T] | icepool.RerollType'
112) -> 'tuple[T, ...] | icepool.Population[tuple[T, ...]] | icepool.RerollType':
113    """Returns the Cartesian product of the arguments as `tuple`s or a `Population` thereof.
114
115    For example:
116    * `tupleize(1, 2)` would produce `(1, 2)`.
117    * `tupleize(d6, 0)` would produce a `Die` with outcomes `(1, 0)`, `(2, 0)`,
118        ... `(6, 0)`.
119    * `tupleize(d6, d6)` would produce a `Die` with outcomes `(1, 1)`, `(1, 2)`,
120        ... `(6, 5)`, `(6, 6)`.
121
122    If `Population`s are provided, they must all be `Die` or all `Deck` and not
123    a mixture of the two.
124
125    If any argument is `icepool.Reroll`, the result is `icepool.Reroll`.
126
127    Returns:
128        If none of the outcomes is a `Population`, the result is a `tuple`
129        with one element per argument. Otherwise, the result is a `Population`
130        of the same type as the input `Population`, and the outcomes are
131        `tuple`s with one element per argument.
132    """
133    return cartesian_product(*args, outcome_type=tuple)

Returns the Cartesian product of the arguments as tuples or a Population thereof.

For example:

  • tupleize(1, 2) would produce (1, 2).
  • tupleize(d6, 0) would produce a Die with outcomes (1, 0), (2, 0), ... (6, 0).
  • tupleize(d6, d6) would produce a Die with outcomes (1, 1), (1, 2), ... (6, 5), (6, 6).

If Populations are provided, they must all be Die or all Deck and not a mixture of the two.

If any argument is icepool.Reroll, the result is icepool.Reroll.

Returns:

If none of the outcomes is a Population, the result is a tuple with one element per argument. Otherwise, the result is a Population of the same type as the input Population, and the outcomes are tuples with one element per argument.

def vectorize( *args: ~T | Population[~T] | RerollType) -> Vector[~T] | Population[Vector[~T]] | RerollType:
150def vectorize(
151    *args: 'T | icepool.Population[T] | icepool.RerollType'
152) -> 'icepool.Vector[T] | icepool.Population[icepool.Vector[T]] | icepool.RerollType':
153    """Returns the Cartesian product of the arguments as `Vector`s or a `Population` thereof.
154
155    For example:
156    * `vectorize(1, 2)` would produce `Vector(1, 2)`.
157    * `vectorize(d6, 0)` would produce a `Die` with outcomes `Vector(1, 0)`,
158        `Vector(2, 0)`, ... `Vector(6, 0)`.
159    * `vectorize(d6, d6)` would produce a `Die` with outcomes `Vector(1, 1)`,
160        `Vector(1, 2)`, ... `Vector(6, 5)`, `Vector(6, 6)`.
161
162    If `Population`s are provided, they must all be `Die` or all `Deck` and not
163    a mixture of the two.
164
165    If any argument is `icepool.Reroll`, the result is `icepool.Reroll`.
166
167    Returns:
168        If none of the outcomes is a `Population`, the result is a `Vector`
169        with one element per argument. Otherwise, the result is a `Population`
170        of the same type as the input `Population`, and the outcomes are
171        `Vector`s with one element per argument.
172    """
173    return cartesian_product(*args, outcome_type=icepool.Vector)

Returns the Cartesian product of the arguments as Vectors or a Population thereof.

For example:

  • vectorize(1, 2) would produce Vector(1, 2).
  • vectorize(d6, 0) would produce a Die with outcomes Vector(1, 0), Vector(2, 0), ... Vector(6, 0).
  • vectorize(d6, d6) would produce a Die with outcomes Vector(1, 1), Vector(1, 2), ... Vector(6, 5), Vector(6, 6).

If Populations are provided, they must all be Die or all Deck and not a mixture of the two.

If any argument is icepool.Reroll, the result is icepool.Reroll.

Returns:

If none of the outcomes is a Population, the result is a Vector with one element per argument. Otherwise, the result is a Population of the same type as the input Population, and the outcomes are Vectors with one element per argument.

class Vector(icepool.Outcome, typing.Sequence[+T_co]):
125class Vector(Outcome, Sequence[T_co]):
126    """Immutable tuple-like class that applies most operators elementwise.
127
128    May become a variadic generic type in the future.
129    """
130    __slots__ = ['_data', '_truth_value']
131
132    _data: tuple[T_co, ...]
133    _truth_value: bool | None
134
135    def __init__(self,
136                 elements: Iterable[T_co],
137                 *,
138                 truth_value: bool | None = None) -> None:
139        self._data = tuple(elements)
140        self._truth_value = truth_value
141
142    def __hash__(self) -> int:
143        return hash((Vector, self._data))
144
145    def __len__(self) -> int:
146        return len(self._data)
147
148    @overload
149    def __getitem__(self, index: int) -> T_co:
150        ...
151
152    @overload
153    def __getitem__(self, index: slice) -> 'Vector[T_co]':
154        ...
155
156    def __getitem__(self, index: int | slice) -> 'T_co | Vector[T_co]':
157        if isinstance(index, int):
158            return self._data[index]
159        else:
160            return Vector(self._data[index])
161
162    def __iter__(self) -> Iterator[T_co]:
163        return iter(self._data)
164
165    # Unary operators.
166
167    def unary_operator(self, op: Callable[..., U], *args,
168                       **kwargs) -> 'Vector[U]':
169        """Unary operators on `Vector` are applied elementwise.
170
171        This is used for the standard unary operators
172        `-, +, abs, ~, round, trunc, floor, ceil`
173        """
174        return Vector(op(x, *args, **kwargs) for x in self)
175
176    def __neg__(self) -> 'Vector[T_co]':
177        return self.unary_operator(operator.neg)
178
179    def __pos__(self) -> 'Vector[T_co]':
180        return self.unary_operator(operator.pos)
181
182    def __invert__(self) -> 'Vector[T_co]':
183        return self.unary_operator(operator.invert)
184
185    def abs(self) -> 'Vector[T_co]':
186        return self.unary_operator(operator.abs)
187
188    __abs__ = abs
189
190    def round(self, ndigits: int | None = None) -> 'Vector':
191        return self.unary_operator(round, ndigits)
192
193    __round__ = round
194
195    def trunc(self) -> 'Vector':
196        return self.unary_operator(math.trunc)
197
198    __trunc__ = trunc
199
200    def floor(self) -> 'Vector':
201        return self.unary_operator(math.floor)
202
203    __floor__ = floor
204
205    def ceil(self) -> 'Vector':
206        return self.unary_operator(math.ceil)
207
208    __ceil__ = ceil
209
210    # Binary operators.
211
212    def binary_operator(self,
213                        other,
214                        op: Callable[..., U],
215                        *args,
216                        compare_for_truth: bool = False,
217                        compare_non_vector: bool | None = None,
218                        **kwargs) -> 'Vector[U]':
219        """Binary operators on `Vector` are applied elementwise.
220
221        If the other operand is also a `Vector`, the operator is applied to each
222        pair of elements from `self` and `other`. Both must have the same
223        length.
224
225        Otherwise the other operand is broadcast to each element of `self`.
226
227        This is used for the standard binary operators
228        `+, -, *, /, //, %, **, <<, >>, &, |, ^`.
229
230        `@` is not included due to its different meaning in `Die`.
231
232        This is also used for the comparators
233        `<, <=, >, >=, ==, !=`.
234
235        In this case, the result also has a truth value based on lexicographic
236        ordering.
237        """
238        if isinstance(other, Vector):
239            if len(self) == len(other):
240                data = tuple(
241                    op(x, y, *args, **kwargs) for x, y in zip(self, other))
242                if compare_for_truth:
243                    truth_value = _lexicographic_truth(self, other, op, data)
244                else:
245                    truth_value = None
246                return Vector(data, truth_value=truth_value)
247            else:
248                if compare_for_truth:
249                    truth_value = _lexicographic_truth(self, other, op)
250                    return icepool.VectorWithTruthOnly(truth_value)
251                else:
252                    raise IndexError(
253                        f'Binary operators on Vectors are only valid if both are the same length ({len(self)} vs. {len(other)}).'
254                    )
255        elif isinstance(other, (icepool.Population, icepool.AgainExpression)):
256            return NotImplemented  # delegate to the other
257        else:
258            return Vector((op(x, other, *args, **kwargs) for x in self),
259                          truth_value=compare_non_vector)
260
261    def reverse_binary_operator(self, other, op: Callable[..., U], *args,
262                                **kwargs) -> 'Vector[U]':
263        """Reverse version of `binary_operator()`."""
264        if isinstance(other, Vector):
265            if len(self) == len(other):
266                return Vector(
267                    op(y, x, *args, **kwargs) for x, y in zip(self, other))
268            else:
269                raise IndexError(
270                    f'Binary operators on Vectors are only valid if both are the same length ({len(other)} vs. {len(self)}).'
271                )
272        elif isinstance(other, (icepool.Population, icepool.AgainExpression)):
273            return NotImplemented  # delegate to the other
274        else:
275            return Vector(op(other, x, *args, **kwargs) for x in self)
276
277    def __add__(self, other) -> 'Vector':
278        return self.binary_operator(other, operator.add)
279
280    def __radd__(self, other) -> 'Vector':
281        return self.reverse_binary_operator(other, operator.add)
282
283    def __sub__(self, other) -> 'Vector':
284        return self.binary_operator(other, operator.sub)
285
286    def __rsub__(self, other) -> 'Vector':
287        return self.reverse_binary_operator(other, operator.sub)
288
289    def __mul__(self, other) -> 'Vector':
290        return self.binary_operator(other, operator.mul)
291
292    def __rmul__(self, other) -> 'Vector':
293        return self.reverse_binary_operator(other, operator.mul)
294
295    def __truediv__(self, other) -> 'Vector':
296        return self.binary_operator(other, operator.truediv)
297
298    def __rtruediv__(self, other) -> 'Vector':
299        return self.reverse_binary_operator(other, operator.truediv)
300
301    def __floordiv__(self, other) -> 'Vector':
302        return self.binary_operator(other, operator.floordiv)
303
304    def __rfloordiv__(self, other) -> 'Vector':
305        return self.reverse_binary_operator(other, operator.floordiv)
306
307    def __pow__(self, other) -> 'Vector':
308        return self.binary_operator(other, operator.pow)
309
310    def __rpow__(self, other) -> 'Vector':
311        return self.reverse_binary_operator(other, operator.pow)
312
313    def __mod__(self, other) -> 'Vector':
314        return self.binary_operator(other, operator.mod)
315
316    def __rmod__(self, other) -> 'Vector':
317        return self.reverse_binary_operator(other, operator.mod)
318
319    def __lshift__(self, other) -> 'Vector':
320        return self.binary_operator(other, operator.lshift)
321
322    def __rlshift__(self, other) -> 'Vector':
323        return self.reverse_binary_operator(other, operator.lshift)
324
325    def __rshift__(self, other) -> 'Vector':
326        return self.binary_operator(other, operator.rshift)
327
328    def __rrshift__(self, other) -> 'Vector':
329        return self.reverse_binary_operator(other, operator.rshift)
330
331    def __and__(self, other) -> 'Vector':
332        return self.binary_operator(other, operator.and_)
333
334    def __rand__(self, other) -> 'Vector':
335        return self.reverse_binary_operator(other, operator.and_)
336
337    def __or__(self, other) -> 'Vector':
338        return self.binary_operator(other, operator.or_)
339
340    def __ror__(self, other) -> 'Vector':
341        return self.reverse_binary_operator(other, operator.or_)
342
343    def __xor__(self, other) -> 'Vector':
344        return self.binary_operator(other, operator.xor)
345
346    def __rxor__(self, other) -> 'Vector':
347        return self.reverse_binary_operator(other, operator.xor)
348
349    # Comparators.
350    # These returns a value with a truth value, but not a bool.
351
352    def __lt__(self, other) -> 'Vector':  # type: ignore
353        return self.binary_operator(other,
354                                    operator.lt,
355                                    compare_for_truth=True,
356                                    compare_non_vector=None)
357
358    def __le__(self, other) -> 'Vector':  # type: ignore
359        return self.binary_operator(other,
360                                    operator.le,
361                                    compare_for_truth=True,
362                                    compare_non_vector=None)
363
364    def __gt__(self, other) -> 'Vector':  # type: ignore
365        return self.binary_operator(other,
366                                    operator.gt,
367                                    compare_for_truth=True,
368                                    compare_non_vector=None)
369
370    def __ge__(self, other) -> 'Vector':  # type: ignore
371        return self.binary_operator(other,
372                                    operator.ge,
373                                    compare_for_truth=True,
374                                    compare_non_vector=None)
375
376    def __eq__(self, other) -> 'Vector | bool':  # type: ignore
377        return self.binary_operator(other,
378                                    operator.eq,
379                                    compare_for_truth=True,
380                                    compare_non_vector=False)
381
382    def __ne__(self, other) -> 'Vector | bool':  # type: ignore
383        return self.binary_operator(other,
384                                    operator.ne,
385                                    compare_for_truth=True,
386                                    compare_non_vector=True)
387
388    def __bool__(self) -> bool:
389        if self._truth_value is None:
390            raise TypeError(
391                'Vector only has a truth value if it is the result of a comparison operator.'
392            )
393        return self._truth_value
394
395    # Sequence manipulation.
396
397    def append(self, other) -> 'Vector':
398        return Vector(self._data + (other, ))
399
400    def concatenate(self, other: 'Iterable') -> 'Vector':
401        return Vector(itertools.chain(self, other))
402
403    # Strings.
404
405    def __repr__(self) -> str:
406        return type(self).__qualname__ + '(' + repr(self._data) + ')'
407
408    def __str__(self) -> str:
409        return type(self).__qualname__ + '(' + str(self._data) + ')'

Immutable tuple-like class that applies most operators elementwise.

May become a variadic generic type in the future.

def unary_operator( self, op: Callable[..., ~U], *args, **kwargs) -> Vector[~U]:
167    def unary_operator(self, op: Callable[..., U], *args,
168                       **kwargs) -> 'Vector[U]':
169        """Unary operators on `Vector` are applied elementwise.
170
171        This is used for the standard unary operators
172        `-, +, abs, ~, round, trunc, floor, ceil`
173        """
174        return Vector(op(x, *args, **kwargs) for x in self)

Unary operators on Vector are applied elementwise.

This is used for the standard unary operators -, +, abs, ~, round, trunc, floor, ceil

def abs(self) -> Vector[+T_co]:
185    def abs(self) -> 'Vector[T_co]':
186        return self.unary_operator(operator.abs)
def round(self, ndigits: int | None = None) -> Vector:
190    def round(self, ndigits: int | None = None) -> 'Vector':
191        return self.unary_operator(round, ndigits)
def trunc(self) -> Vector:
195    def trunc(self) -> 'Vector':
196        return self.unary_operator(math.trunc)
def floor(self) -> Vector:
200    def floor(self) -> 'Vector':
201        return self.unary_operator(math.floor)
def ceil(self) -> Vector:
205    def ceil(self) -> 'Vector':
206        return self.unary_operator(math.ceil)
def binary_operator( self, other, op: Callable[..., ~U], *args, compare_for_truth: bool = False, compare_non_vector: bool | None = None, **kwargs) -> Vector[~U]:
212    def binary_operator(self,
213                        other,
214                        op: Callable[..., U],
215                        *args,
216                        compare_for_truth: bool = False,
217                        compare_non_vector: bool | None = None,
218                        **kwargs) -> 'Vector[U]':
219        """Binary operators on `Vector` are applied elementwise.
220
221        If the other operand is also a `Vector`, the operator is applied to each
222        pair of elements from `self` and `other`. Both must have the same
223        length.
224
225        Otherwise the other operand is broadcast to each element of `self`.
226
227        This is used for the standard binary operators
228        `+, -, *, /, //, %, **, <<, >>, &, |, ^`.
229
230        `@` is not included due to its different meaning in `Die`.
231
232        This is also used for the comparators
233        `<, <=, >, >=, ==, !=`.
234
235        In this case, the result also has a truth value based on lexicographic
236        ordering.
237        """
238        if isinstance(other, Vector):
239            if len(self) == len(other):
240                data = tuple(
241                    op(x, y, *args, **kwargs) for x, y in zip(self, other))
242                if compare_for_truth:
243                    truth_value = _lexicographic_truth(self, other, op, data)
244                else:
245                    truth_value = None
246                return Vector(data, truth_value=truth_value)
247            else:
248                if compare_for_truth:
249                    truth_value = _lexicographic_truth(self, other, op)
250                    return icepool.VectorWithTruthOnly(truth_value)
251                else:
252                    raise IndexError(
253                        f'Binary operators on Vectors are only valid if both are the same length ({len(self)} vs. {len(other)}).'
254                    )
255        elif isinstance(other, (icepool.Population, icepool.AgainExpression)):
256            return NotImplemented  # delegate to the other
257        else:
258            return Vector((op(x, other, *args, **kwargs) for x in self),
259                          truth_value=compare_non_vector)

Binary operators on Vector are applied elementwise.

If the other operand is also a Vector, the operator is applied to each pair of elements from self and other. Both must have the same length.

Otherwise the other operand is broadcast to each element of self.

This is used for the standard binary operators +, -, *, /, //, %, **, <<, >>, &, |, ^.

@ is not included due to its different meaning in Die.

This is also used for the comparators <, <=, >, >=, ==, !=.

In this case, the result also has a truth value based on lexicographic ordering.

def reverse_binary_operator( self, other, op: Callable[..., ~U], *args, **kwargs) -> Vector[~U]:
261    def reverse_binary_operator(self, other, op: Callable[..., U], *args,
262                                **kwargs) -> 'Vector[U]':
263        """Reverse version of `binary_operator()`."""
264        if isinstance(other, Vector):
265            if len(self) == len(other):
266                return Vector(
267                    op(y, x, *args, **kwargs) for x, y in zip(self, other))
268            else:
269                raise IndexError(
270                    f'Binary operators on Vectors are only valid if both are the same length ({len(other)} vs. {len(self)}).'
271                )
272        elif isinstance(other, (icepool.Population, icepool.AgainExpression)):
273            return NotImplemented  # delegate to the other
274        else:
275            return Vector(op(other, x, *args, **kwargs) for x in self)

Reverse version of binary_operator().

def append(self, other) -> Vector:
397    def append(self, other) -> 'Vector':
398        return Vector(self._data + (other, ))
def concatenate(self, other: Iterable) -> Vector:
400    def concatenate(self, other: 'Iterable') -> 'Vector':
401        return Vector(itertools.chain(self, other))
Inherited Members
Outcome
Outcome
class Symbols(typing.Mapping[str, int]):
 16class Symbols(Mapping[str, int]):
 17    """EXPERIMENTAL: Immutable multiset of single characters.
 18
 19    Spaces, dashes, and underscores cannot be used as symbols.
 20
 21    Operations include:
 22
 23    | Operation                   | Count / notes                      |
 24    |:----------------------------|:-----------------------------------|
 25    | `additive_union`, `+`       | `l + r`                            |
 26    | `difference`, `-`           | `l - r`                            |
 27    | `intersection`, `&`         | `min(l, r)`                        |
 28    | `union`, `\\|`               | `max(l, r)`                        |
 29    | `symmetric_difference`, `^` | `abs(l - r)`                       |
 30    | `multiply_counts`, `*`      | `count * n`                        |
 31    | `divide_counts`, `//`       | `count // n`                       |
 32    | `issubset`, `<=`            | all counts l <= r                  |
 33    | `issuperset`, `>=`          | all counts l >= r                  |
 34    | `==`                        | all counts l == r                  |
 35    | `!=`                        | any count l != r                   |
 36    | unary `+`                   | drop all negative counts           |
 37    | unary `-`                   | reverses the sign of all counts    |
 38
 39    `<` and `>` are lexicographic orderings rather than subset relations.
 40    Specifically, they compare the count of each character in alphabetical
 41    order. For example:
 42    * `'a' > ''` since one `'a'` is more than zero `'a'`s.
 43    * `'a' > 'bb'` since `'a'` is compared first.
 44    * `'-a' < 'bb'` since the left side has -1 `'a'`s.
 45    * `'a' < 'ab'` since the `'a'`s are equal but the right side has more `'b'`s.
 46    
 47    Binary operators other than `*` and `//` implicitly convert the other
 48    argument to `Symbols` using the constructor.
 49
 50    Subscripting with a single character returns the count of that character
 51    as an `int`. E.g. `symbols['a']` -> number of `a`s as an `int`.
 52    You can also access it as an attribute, e.g.  `symbols.a`.
 53
 54    Subscripting with multiple characters returns a `Symbols` with only those
 55    characters, dropping the rest.
 56    E.g. `symbols['ab']` -> number of `a`s and `b`s as a `Symbols`.
 57    Again you can also access it as an attribute, e.g. `symbols.ab`.
 58    This is useful for reducing the outcome space, which reduces computational
 59    cost for further operations. If you want to keep only a single character
 60    while keeping the type as `Symbols`, you can subscript with that character
 61    plus an unused character.
 62
 63    Subscripting with duplicate characters currently has no further effect, but
 64    this may change in the future.
 65
 66    `Population.marginals` forwards attribute access, so you can use e.g.
 67    `die.marginals.a` to get the marginal distribution of `a`s.
 68
 69    Note that attribute access only works with valid identifiers,
 70    so e.g. emojis would need to use the subscript method.
 71    """
 72    _data: Mapping[str, int]
 73
 74    def __new__(cls,
 75                symbols: str | Iterable[str] | Mapping[str, int]) -> 'Symbols':
 76        """Constructor.
 77        
 78        The argument can be a string, an iterable of characters, or a mapping of
 79        characters to counts.
 80        
 81        If the argument is a string, negative symbols can be specified using a
 82        minus sign optionally surrounded by whitespace. For example,
 83        `a - b` has one positive a and one negative b.
 84        """
 85        self = super(Symbols, cls).__new__(cls)
 86        if isinstance(symbols, str):
 87            data: MutableMapping[str, int] = defaultdict(int)
 88            positive, *negative = re.split(r'\s*-\s*', symbols)
 89            for s in positive:
 90                data[s] += 1
 91            if len(negative) > 1:
 92                raise ValueError('Multiple dashes not allowed.')
 93            if len(negative) == 1:
 94                for s in negative[0]:
 95                    data[s] -= 1
 96        elif isinstance(symbols, Mapping):
 97            data = defaultdict(int, symbols)
 98        else:
 99            data = defaultdict(int)
100            for s in symbols:
101                data[s] += 1
102
103        for s in data:
104            if len(s) != 1:
105                raise ValueError(f'Symbol {s} is not a single character.')
106            if re.match(r'[\s_-]', s):
107                raise ValueError(
108                    f'{s} (U+{ord(s):04X}) is not a legal symbol.')
109
110        self._data = defaultdict(int,
111                                 {k: data[k]
112                                  for k in sorted(data.keys())})
113
114        return self
115
116    @classmethod
117    def _new_raw(cls, data: defaultdict[str, int]) -> 'Symbols':
118        self = super(Symbols, cls).__new__(cls)
119        self._data = data
120        return self
121
122    # Mapping interface.
123
124    def __getitem__(self, key: str) -> 'int | Symbols':  # type: ignore
125        if len(key) == 1:
126            return self._data.get(key, 0)
127        else:
128            return Symbols._new_raw(
129                defaultdict(int, {s: self._data.get(s, 0)
130                                  for s in key}))
131
132    def __getattr__(self, key: str) -> 'int | Symbols':
133        if key[0] == '_':
134            raise AttributeError(key)
135        return self[key]
136
137    def __iter__(self) -> Iterator[str]:
138        return iter(self._data)
139
140    def __len__(self) -> int:
141        return len(self._data)
142
143    # Binary operators.
144
145    def additive_union(self, *args:
146                       Iterable[str] | Mapping[str, int]) -> 'Symbols':
147        """The sum of counts of each symbol."""
148        return functools.reduce(operator.add, args, initial=self)
149
150    def __add__(self, other: Iterable[str] | Mapping[str, int]) -> 'Symbols':
151        if isinstance(other, (icepool.Population, icepool.AgainExpression)):
152            return NotImplemented  # delegate to the other
153        data = defaultdict(int, self._data)
154        for s, count in Symbols(other).items():
155            data[s] += count
156        return Symbols._new_raw(data)
157
158    __radd__ = __add__
159
160    def difference(self, *args:
161                   Iterable[str] | Mapping[str, int]) -> 'Symbols':
162        """The difference between the counts of each symbol."""
163        return functools.reduce(operator.sub, args, initial=self)
164
165    def __sub__(self, other: Iterable[str] | Mapping[str, int]) -> 'Symbols':
166        if isinstance(other, (icepool.Population, icepool.AgainExpression)):
167            return NotImplemented  # delegate to the other
168        data = defaultdict(int, self._data)
169        for s, count in Symbols(other).items():
170            data[s] -= count
171        return Symbols._new_raw(data)
172
173    def __rsub__(self, other: Iterable[str] | Mapping[str, int]) -> 'Symbols':
174        if isinstance(other, (icepool.Population, icepool.AgainExpression)):
175            return NotImplemented  # delegate to the other
176        data = defaultdict(int, Symbols(other)._data)
177        for s, count in self.items():
178            data[s] -= count
179        return Symbols._new_raw(data)
180
181    def intersection(self, *args:
182                     Iterable[str] | Mapping[str, int]) -> 'Symbols':
183        """The min count of each symbol."""
184        return functools.reduce(operator.and_, args, initial=self)
185
186    def __and__(self, other: Iterable[str] | Mapping[str, int]) -> 'Symbols':
187        if isinstance(other, (icepool.Population, icepool.AgainExpression)):
188            return NotImplemented  # delegate to the other
189        data: defaultdict[str, int] = defaultdict(int)
190        for s, count in Symbols(other).items():
191            data[s] = min(self.get(s, 0), count)
192        return Symbols._new_raw(data)
193
194    __rand__ = __and__
195
196    def union(self, *args: Iterable[str] | Mapping[str, int]) -> 'Symbols':
197        """The max count of each symbol."""
198        return functools.reduce(operator.or_, args, initial=self)
199
200    def __or__(self, other: Iterable[str] | Mapping[str, int]) -> 'Symbols':
201        if isinstance(other, (icepool.Population, icepool.AgainExpression)):
202            return NotImplemented  # delegate to the other
203        data = defaultdict(int, self._data)
204        for s, count in Symbols(other).items():
205            data[s] = max(data[s], count)
206        return Symbols._new_raw(data)
207
208    __ror__ = __or__
209
210    def symmetric_difference(
211            self, other: Iterable[str] | Mapping[str, int]) -> 'Symbols':
212        """The absolute difference in symbol counts between the two sets."""
213        return self ^ other
214
215    def __xor__(self, other: Iterable[str] | Mapping[str, int]) -> 'Symbols':
216        if isinstance(other, (icepool.Population, icepool.AgainExpression)):
217            return NotImplemented  # delegate to the other
218        data = defaultdict(int, self._data)
219        for s, count in Symbols(other).items():
220            data[s] = abs(data[s] - count)
221        return Symbols._new_raw(data)
222
223    __rxor__ = __xor__
224
225    def multiply_counts(self, other: int) -> 'Symbols':
226        """Multiplies all counts by an integer."""
227        return self * other
228
229    def __mul__(self, other: int) -> 'Symbols':
230        if not isinstance(other, int):
231            return NotImplemented
232        data = defaultdict(int, {
233            s: count * other
234            for s, count in self.items()
235        })
236        return Symbols._new_raw(data)
237
238    __rmul__ = __mul__
239
240    def divide_counts(self, other: int) -> 'Symbols':
241        """Divides all counts by an integer, rounding down."""
242        data = defaultdict(int, {
243            s: count // other
244            for s, count in self.items()
245        })
246        return Symbols._new_raw(data)
247
248    def count_subset(self,
249                     divisor: Iterable[str] | Mapping[str, int],
250                     *,
251                     empty_divisor: int | None = None) -> int:
252        """The number of times the divisor is contained in this multiset."""
253        if not isinstance(divisor, Mapping):
254            divisor = Counter(divisor)
255        result = None
256        for s, count in divisor.items():
257            current = self._data.get(s, 0) // count
258            if result is None or current < result:
259                result = current
260        if result is None:
261            if empty_divisor is None:
262                raise ZeroDivisionError('Divisor is empty.')
263            else:
264                return empty_divisor
265        else:
266            return result
267
268    @overload
269    def __floordiv__(self, other: int) -> 'Symbols':
270        """Same as divide_counts()."""
271
272    @overload
273    def __floordiv__(self, other: Iterable[str] | Mapping[str, int]) -> int:
274        """Same as count_subset()."""
275
276    @overload
277    def __floordiv__(
278            self,
279            other: int | Iterable[str] | Mapping[str, int]) -> 'Symbols | int':
280        ...
281
282    def __floordiv__(
283            self,
284            other: int | Iterable[str] | Mapping[str, int]) -> 'Symbols | int':
285        if isinstance(other, int):
286            return self.divide_counts(other)
287        elif isinstance(other, Iterable):
288            return self.count_subset(other)
289        else:
290            return NotImplemented
291
292    def __rfloordiv__(self, other: Iterable[str] | Mapping[str, int]) -> int:
293        return Symbols(other).count_subset(self)
294
295    def modulo_counts(self, other: int) -> 'Symbols':
296        return self % other
297
298    def __mod__(self, other: int) -> 'Symbols':
299        if not isinstance(other, int):
300            return NotImplemented
301        data = defaultdict(int, {
302            s: count % other
303            for s, count in self.items()
304        })
305        return Symbols._new_raw(data)
306
307    def __lt__(self, other: 'Symbols') -> bool:
308        if not isinstance(other, Symbols):
309            return NotImplemented
310        keys = sorted(set(self.keys()) | set(other.keys()))
311        for k in keys:
312            if self[k] < other[k]:  # type: ignore
313                return True
314            if self[k] > other[k]:  # type: ignore
315                return False
316        return False
317
318    def __gt__(self, other: 'Symbols') -> bool:
319        if not isinstance(other, Symbols):
320            return NotImplemented
321        keys = sorted(set(self.keys()) | set(other.keys()))
322        for k in keys:
323            if self[k] > other[k]:  # type: ignore
324                return True
325            if self[k] < other[k]:  # type: ignore
326                return False
327        return False
328
329    def issubset(self, other: Iterable[str] | Mapping[str, int]) -> bool:
330        """Whether `self` is a subset of the other.
331
332        Same as `<=`.
333        
334        Note that the `<` and `>` operators are lexicographic orderings,
335        not proper subset relations.
336        """
337        return self <= other
338
339    def __le__(self, other: Iterable[str] | Mapping[str, int]) -> bool:
340        if isinstance(other, (icepool.Population, icepool.AgainExpression)):
341            return NotImplemented  # delegate to the other
342        other = Symbols(other)
343        return all(self[s] <= other[s]  # type: ignore
344                   for s in itertools.chain(self, other))
345
346    def issuperset(self, other: Iterable[str] | Mapping[str, int]) -> bool:
347        """Whether `self` is a superset of the other.
348
349        Same as `>=`.
350        
351        Note that the `<` and `>` operators are lexicographic orderings,
352        not proper subset relations.
353        """
354        return self >= other
355
356    def __ge__(self, other: Iterable[str] | Mapping[str, int]) -> bool:
357        if isinstance(other, (icepool.Population, icepool.AgainExpression)):
358            return NotImplemented  # delegate to the other
359        other = Symbols(other)
360        return all(self[s] >= other[s]  # type: ignore
361                   for s in itertools.chain(self, other))
362
363    def isdisjoint(self, other: Iterable[str] | Mapping[str, int]) -> bool:
364        """Whether `self` has no positive elements in common with the other.
365        
366        Raises:
367            ValueError if either has negative elements.
368        """
369        other = Symbols(other)
370        return not any(self[s] > 0 and other[s] > 0  # type: ignore
371                       for s in self)
372
373    def __eq__(self, other) -> bool:
374        if isinstance(other, (icepool.Population, icepool.AgainExpression)):
375            return NotImplemented  # delegate to the other
376        try:
377            other = Symbols(other)
378        except (TypeError, ValueError):
379            return NotImplemented
380        return all(self[s] == other[s]  # type: ignore
381                   for s in itertools.chain(self, other))
382
383    def __ne__(self, other) -> bool:
384        if isinstance(other, (icepool.Population, icepool.AgainExpression)):
385            return NotImplemented  # delegate to the other
386        try:
387            other = Symbols(other)
388        except (TypeError, ValueError):
389            return NotImplemented
390        return any(self[s] != other[s]  # type: ignore
391                   for s in itertools.chain(self, other))
392
393    # Unary operators.
394
395    def has_negative_counts(self) -> bool:
396        """Whether any counts are negative."""
397        return any(c < 0 for c in self.values())
398
399    def __pos__(self) -> 'Symbols':
400        data = defaultdict(int, {
401            s: count
402            for s, count in self.items() if count > 0
403        })
404        return Symbols._new_raw(data)
405
406    def __neg__(self) -> 'Symbols':
407        data = defaultdict(int, {s: -count for s, count in self.items()})
408        return Symbols._new_raw(data)
409
410    @cached_property
411    def _hash(self) -> int:
412        return hash((Symbols, str(self)))
413
414    def __hash__(self) -> int:
415        return self._hash
416
417    def size(self) -> int:
418        """The total number of elements."""
419        return sum(self._data.values())
420
421    @cached_property
422    def _str(self) -> str:
423        sorted_keys = sorted(self)
424        positive = ''.join(s * self._data[s] for s in sorted_keys
425                           if self._data[s] > 0)
426        negative = ''.join(s * -self._data[s] for s in sorted_keys
427                           if self._data[s] < 0)
428        if positive:
429            if negative:
430                return positive + ' - ' + negative
431            else:
432                return positive
433        else:
434            if negative:
435                return '-' + negative
436            else:
437                return ''
438
439    def __str__(self) -> str:
440        """All symbols in unary form (i.e. including duplicates) in ascending order.
441
442        If there are negative elements, they are listed following a ` - ` sign.
443        """
444        return self._str
445
446    def __repr__(self) -> str:
447        return type(self).__qualname__ + f"('{str(self)}')"

EXPERIMENTAL: Immutable multiset of single characters.

Spaces, dashes, and underscores cannot be used as symbols.

Operations include:

Operation Count / notes
additive_union, + l + r
difference, - l - r
intersection, & min(l, r)
union, | max(l, r)
symmetric_difference, ^ abs(l - r)
multiply_counts, * count * n
divide_counts, // count // n
issubset, <= all counts l <= r
issuperset, >= all counts l >= r
== all counts l == r
!= any count l != r
unary + drop all negative counts
unary - reverses the sign of all counts

< and > are lexicographic orderings rather than subset relations. Specifically, they compare the count of each character in alphabetical order. For example:

  • 'a' > '' since one 'a' is more than zero 'a's.
  • 'a' > 'bb' since 'a' is compared first.
  • '-a' < 'bb' since the left side has -1 'a's.
  • 'a' < 'ab' since the 'a's are equal but the right side has more 'b's.

Binary operators other than * and // implicitly convert the other argument to Symbols using the constructor.

Subscripting with a single character returns the count of that character as an int. E.g. symbols['a'] -> number of as as an int. You can also access it as an attribute, e.g. symbols.a.

Subscripting with multiple characters returns a Symbols with only those characters, dropping the rest. E.g. symbols['ab'] -> number of as and bs as a Symbols. Again you can also access it as an attribute, e.g. symbols.ab. This is useful for reducing the outcome space, which reduces computational cost for further operations. If you want to keep only a single character while keeping the type as Symbols, you can subscript with that character plus an unused character.

Subscripting with duplicate characters currently has no further effect, but this may change in the future.

Population.marginals forwards attribute access, so you can use e.g. die.marginals.a to get the marginal distribution of as.

Note that attribute access only works with valid identifiers, so e.g. emojis would need to use the subscript method.

Symbols(symbols: str | Iterable[str] | Mapping[str, int])
 74    def __new__(cls,
 75                symbols: str | Iterable[str] | Mapping[str, int]) -> 'Symbols':
 76        """Constructor.
 77        
 78        The argument can be a string, an iterable of characters, or a mapping of
 79        characters to counts.
 80        
 81        If the argument is a string, negative symbols can be specified using a
 82        minus sign optionally surrounded by whitespace. For example,
 83        `a - b` has one positive a and one negative b.
 84        """
 85        self = super(Symbols, cls).__new__(cls)
 86        if isinstance(symbols, str):
 87            data: MutableMapping[str, int] = defaultdict(int)
 88            positive, *negative = re.split(r'\s*-\s*', symbols)
 89            for s in positive:
 90                data[s] += 1
 91            if len(negative) > 1:
 92                raise ValueError('Multiple dashes not allowed.')
 93            if len(negative) == 1:
 94                for s in negative[0]:
 95                    data[s] -= 1
 96        elif isinstance(symbols, Mapping):
 97            data = defaultdict(int, symbols)
 98        else:
 99            data = defaultdict(int)
100            for s in symbols:
101                data[s] += 1
102
103        for s in data:
104            if len(s) != 1:
105                raise ValueError(f'Symbol {s} is not a single character.')
106            if re.match(r'[\s_-]', s):
107                raise ValueError(
108                    f'{s} (U+{ord(s):04X}) is not a legal symbol.')
109
110        self._data = defaultdict(int,
111                                 {k: data[k]
112                                  for k in sorted(data.keys())})
113
114        return self

Constructor.

The argument can be a string, an iterable of characters, or a mapping of characters to counts.

If the argument is a string, negative symbols can be specified using a minus sign optionally surrounded by whitespace. For example, a - b has one positive a and one negative b.

def additive_union( self, *args: Iterable[str] | Mapping[str, int]) -> Symbols:
145    def additive_union(self, *args:
146                       Iterable[str] | Mapping[str, int]) -> 'Symbols':
147        """The sum of counts of each symbol."""
148        return functools.reduce(operator.add, args, initial=self)

The sum of counts of each symbol.

def difference( self, *args: Iterable[str] | Mapping[str, int]) -> Symbols:
160    def difference(self, *args:
161                   Iterable[str] | Mapping[str, int]) -> 'Symbols':
162        """The difference between the counts of each symbol."""
163        return functools.reduce(operator.sub, args, initial=self)

The difference between the counts of each symbol.

def intersection( self, *args: Iterable[str] | Mapping[str, int]) -> Symbols:
181    def intersection(self, *args:
182                     Iterable[str] | Mapping[str, int]) -> 'Symbols':
183        """The min count of each symbol."""
184        return functools.reduce(operator.and_, args, initial=self)

The min count of each symbol.

def union( self, *args: Iterable[str] | Mapping[str, int]) -> Symbols:
196    def union(self, *args: Iterable[str] | Mapping[str, int]) -> 'Symbols':
197        """The max count of each symbol."""
198        return functools.reduce(operator.or_, args, initial=self)

The max count of each symbol.

def symmetric_difference( self, other: Iterable[str] | Mapping[str, int]) -> Symbols:
210    def symmetric_difference(
211            self, other: Iterable[str] | Mapping[str, int]) -> 'Symbols':
212        """The absolute difference in symbol counts between the two sets."""
213        return self ^ other

The absolute difference in symbol counts between the two sets.

def multiply_counts(self, other: int) -> Symbols:
225    def multiply_counts(self, other: int) -> 'Symbols':
226        """Multiplies all counts by an integer."""
227        return self * other

Multiplies all counts by an integer.

def divide_counts(self, other: int) -> Symbols:
240    def divide_counts(self, other: int) -> 'Symbols':
241        """Divides all counts by an integer, rounding down."""
242        data = defaultdict(int, {
243            s: count // other
244            for s, count in self.items()
245        })
246        return Symbols._new_raw(data)

Divides all counts by an integer, rounding down.

def count_subset( self, divisor: Iterable[str] | Mapping[str, int], *, empty_divisor: int | None = None) -> int:
248    def count_subset(self,
249                     divisor: Iterable[str] | Mapping[str, int],
250                     *,
251                     empty_divisor: int | None = None) -> int:
252        """The number of times the divisor is contained in this multiset."""
253        if not isinstance(divisor, Mapping):
254            divisor = Counter(divisor)
255        result = None
256        for s, count in divisor.items():
257            current = self._data.get(s, 0) // count
258            if result is None or current < result:
259                result = current
260        if result is None:
261            if empty_divisor is None:
262                raise ZeroDivisionError('Divisor is empty.')
263            else:
264                return empty_divisor
265        else:
266            return result

The number of times the divisor is contained in this multiset.

def modulo_counts(self, other: int) -> Symbols:
295    def modulo_counts(self, other: int) -> 'Symbols':
296        return self % other
def issubset(self, other: Iterable[str] | Mapping[str, int]) -> bool:
329    def issubset(self, other: Iterable[str] | Mapping[str, int]) -> bool:
330        """Whether `self` is a subset of the other.
331
332        Same as `<=`.
333        
334        Note that the `<` and `>` operators are lexicographic orderings,
335        not proper subset relations.
336        """
337        return self <= other

Whether self is a subset of the other.

Same as <=.

Note that the < and > operators are lexicographic orderings, not proper subset relations.

def issuperset(self, other: Iterable[str] | Mapping[str, int]) -> bool:
346    def issuperset(self, other: Iterable[str] | Mapping[str, int]) -> bool:
347        """Whether `self` is a superset of the other.
348
349        Same as `>=`.
350        
351        Note that the `<` and `>` operators are lexicographic orderings,
352        not proper subset relations.
353        """
354        return self >= other

Whether self is a superset of the other.

Same as >=.

Note that the < and > operators are lexicographic orderings, not proper subset relations.

def isdisjoint(self, other: Iterable[str] | Mapping[str, int]) -> bool:
363    def isdisjoint(self, other: Iterable[str] | Mapping[str, int]) -> bool:
364        """Whether `self` has no positive elements in common with the other.
365        
366        Raises:
367            ValueError if either has negative elements.
368        """
369        other = Symbols(other)
370        return not any(self[s] > 0 and other[s] > 0  # type: ignore
371                       for s in self)

Whether self has no positive elements in common with the other.

Raises:
  • ValueError if either has negative elements.
def has_negative_counts(self) -> bool:
395    def has_negative_counts(self) -> bool:
396        """Whether any counts are negative."""
397        return any(c < 0 for c in self.values())

Whether any counts are negative.

def size(self) -> int:
417    def size(self) -> int:
418        """The total number of elements."""
419        return sum(self._data.values())

The total number of elements.

Again: Final = <icepool.population.again.AgainExpression object>

A symbol indicating that the die should be rolled again, usually with some operation applied.

This is designed to be used with the Die() constructor. AgainExpressions should not be fed to functions or methods other than Die() (or indirectly via map()), but they can be used with operators. Examples:

  • Again + 6: Roll again and add 6.
  • Again + Again: Roll again twice and sum.

The again_count, again_depth, and again_end arguments to Die() affect how these arguments are processed. At most one of again_count or again_depth may be provided; if neither are provided, the behavior is as again_depth=1.

For finer control over rolling processes, use e.g. Die.map() instead.

Count mode

When again_count is provided, we start with one roll queued and execute one roll at a time. For every Again we roll, we queue another roll. If we run out of rolls, we sum the rolls to find the result. We evaluate up to again_count extra rolls. If, at this point, there are still dice remaining:

  • Restart: If there would be dice over the limit, we restart the entire process from the beginning, effectively conditioning the process against this sequence of events.
  • Reroll: Any remaining dice can't produce more Agains.
  • outcome: Any remaining dice are each treated as the given outcome.
  • None: Any remaining dice are treated as zero.

This mode only allows "additive" expressions to be used with Again, which means that only the following operators are allowed:

  • Binary +
  • n @ AgainExpression, where n is a non-negative int or Population.

Furthermore, the + operator is assumed to be associative and commutative. For example, str or tuple outcomes will not produce elements with a definite order.

Depth mode

When again_depth=0, again_end is directly substituted for each occurence of Again. For other values of again_depth, the result for again_depth-1 is substituted for each occurence of Again.

If again_end=Reroll, then any AgainExpressions in the final depth are rerolled. Restart cannot be used with again_depth.

class CountsKeysView(typing.KeysView[~T], typing.Sequence[~T]):
144class CountsKeysView(KeysView[T], Sequence[T]):
145    """This functions as both a `KeysView` and a `Sequence`."""
146
147    def __init__(self, counts: Counts[T]):
148        self._mapping = counts
149
150    def __getitem__(self, index):
151        return self._mapping._keys[index]
152
153    def __len__(self) -> int:
154        return len(self._mapping)
155
156    def __eq__(self, other):
157        return self._mapping._keys == other

This functions as both a KeysView and a Sequence.

CountsKeysView(counts: icepool.collection.counts.Counts[~T])
147    def __init__(self, counts: Counts[T]):
148        self._mapping = counts
class CountsValuesView(typing.ValuesView[int], typing.Sequence[int]):
160class CountsValuesView(ValuesView[int], Sequence[int]):
161    """This functions as both a `ValuesView` and a `Sequence`."""
162
163    def __init__(self, counts: Counts):
164        self._mapping = counts
165
166    def __getitem__(self, index):
167        return self._mapping._values[index]
168
169    def __len__(self) -> int:
170        return len(self._mapping)
171
172    def __eq__(self, other):
173        return self._mapping._values == other

This functions as both a ValuesView and a Sequence.

CountsValuesView(counts: icepool.collection.counts.Counts)
163    def __init__(self, counts: Counts):
164        self._mapping = counts
class CountsItemsView(typing.ItemsView[~T, int], typing.Sequence[tuple[~T, int]]):
176class CountsItemsView(ItemsView[T, int], Sequence[tuple[T, int]]):
177    """This functions as both an `ItemsView` and a `Sequence`."""
178
179    def __init__(self, counts: Counts):
180        self._mapping = counts
181
182    def __getitem__(self, index):
183        return self._mapping._items[index]
184
185    def __eq__(self, other):
186        return self._mapping._items == other

This functions as both an ItemsView and a Sequence.

CountsItemsView(counts: icepool.collection.counts.Counts)
179    def __init__(self, counts: Counts):
180        self._mapping = counts
def from_cumulative( outcomes: Sequence[~T], cumulative: Sequence[int] | Sequence[Die[bool]], *, reverse: bool = False) -> Die[~T]:
142def from_cumulative(outcomes: Sequence[T],
143                    cumulative: 'Sequence[int] | Sequence[icepool.Die[bool]]',
144                    *,
145                    reverse: bool = False) -> 'icepool.Die[T]':
146    """Constructs a `Die` from a sequence of cumulative values.
147
148    Args:
149        outcomes: The outcomes of the resulting die. Sorted order is recommended
150            but not necessary.
151        cumulative: The cumulative values (inclusive) of the outcomes in the
152            order they are given to this function. These may be:
153            * `int` cumulative quantities.
154            * Dice representing the cumulative distribution at that point.
155        reverse: Iff true, both of the arguments will be reversed. This allows
156            e.g. constructing using a survival distribution.
157    """
158    if len(outcomes) == 0:
159        return icepool.Die({})
160
161    if reverse:
162        outcomes = list(reversed(outcomes))
163        cumulative = list(reversed(cumulative))  # type: ignore
164
165    prev = 0
166    d = {}
167
168    if isinstance(cumulative[0], icepool.Die):
169        cumulative = harmonize_denominators(cumulative)
170        for outcome, die in zip(outcomes, cumulative):
171            d[outcome] = die.quantity('!=', False) - prev
172            prev = die.quantity('!=', False)
173    elif isinstance(cumulative[0], int):
174        cumulative = cast(Sequence[int], cumulative)
175        for outcome, quantity in zip(outcomes, cumulative):
176            d[outcome] = quantity - prev
177            prev = quantity
178    else:
179        raise TypeError(
180            f'Unsupported type {type(cumulative)} for cumulative values.')
181
182    return icepool.Die(d)

Constructs a Die from a sequence of cumulative values.

Arguments:
  • outcomes: The outcomes of the resulting die. Sorted order is recommended but not necessary.
  • cumulative: The cumulative values (inclusive) of the outcomes in the order they are given to this function. These may be:
    • int cumulative quantities.
    • Dice representing the cumulative distribution at that point.
  • reverse: Iff true, both of the arguments will be reversed. This allows e.g. constructing using a survival distribution.
def from_rv( rv, outcomes: Sequence[int] | Sequence[float], denominator: int, **kwargs) -> Die[int] | Die[float]:
197def from_rv(rv, outcomes: Sequence[int] | Sequence[float], denominator: int,
198            **kwargs) -> 'icepool.Die[int] | icepool.Die[float]':
199    """Constructs a `Die` from a rv object (as `scipy.stats`).
200
201    This is done using the CDF.
202
203    Args:
204        rv: A rv object (as `scipy.stats`).
205        outcomes: An iterable of `int`s or `float`s that will be the outcomes
206            of the resulting `Die`.
207            If the distribution is discrete, outcomes must be `int`s.
208            Some outcomes may be omitted if their probability is too small
209            compared to the denominator.
210        denominator: The denominator of the resulting `Die` will be set to this.
211        **kwargs: These will be forwarded to `rv.cdf()`.
212    """
213    if hasattr(rv, 'pdf'):
214        # Continuous distributions use midpoints.
215        midpoints = [(a + b) / 2 for a, b in zip(outcomes[:-1], outcomes[1:])]
216        cdf = rv.cdf(midpoints, **kwargs)
217        quantities_le = tuple(int(round(x * denominator))
218                              for x in cdf) + (denominator, )
219    else:
220        cdf = rv.cdf(outcomes, **kwargs)
221        quantities_le = tuple(int(round(x * denominator)) for x in cdf)
222    return from_cumulative(outcomes, quantities_le)

Constructs a Die from a rv object (as scipy.stats).

This is done using the CDF.

Arguments:
  • rv: A rv object (as scipy.stats).
  • outcomes: An iterable of ints or floats that will be the outcomes of the resulting Die. If the distribution is discrete, outcomes must be ints. Some outcomes may be omitted if their probability is too small compared to the denominator.
  • denominator: The denominator of the resulting Die will be set to this.
  • **kwargs: These will be forwarded to rv.cdf().
def pointwise_max( arg0, /, *more_args: Die[~T]) -> Die[~T]:
252def pointwise_max(arg0, /, *more_args: 'icepool.Die[T]') -> 'icepool.Die[T]':
253    """Selects the highest chance of rolling >= each outcome among the arguments.
254
255    Naming not finalized.
256
257    Specifically, for each outcome, the chance of the result rolling >= to that 
258    outcome is the same as the highest chance of rolling >= that outcome among
259    the arguments.
260
261    Equivalently, any quantile in the result is the highest of that quantile
262    among the arguments.
263
264    This is useful for selecting from several possible moves where you are
265    trying to get >= a threshold that is known but could change depending on the
266    situation.
267    
268    Args:
269        dice: Either an iterable of dice, or two or more dice as separate
270            arguments.
271    """
272    if len(more_args) == 0:
273        if isinstance(arg0, icepool.Die):
274            # A single die is itself, not an iterable of its outcomes.
275            args = (arg0, )
276        else:
277            args = arg0
278    else:
279        args = (arg0, ) + more_args
280    args = harmonize_denominators(args)
281    outcomes = sorted_union(*args)
282    cumulative = [
283        min(die.quantity('<=', outcome) for die in args)
284        for outcome in outcomes
285    ]
286    return from_cumulative(outcomes, cumulative)

Selects the highest chance of rolling >= each outcome among the arguments.

Naming not finalized.

Specifically, for each outcome, the chance of the result rolling >= to that outcome is the same as the highest chance of rolling >= that outcome among the arguments.

Equivalently, any quantile in the result is the highest of that quantile among the arguments.

This is useful for selecting from several possible moves where you are trying to get >= a threshold that is known but could change depending on the situation.

Arguments:
  • dice: Either an iterable of dice, or two or more dice as separate arguments.
def pointwise_min( arg0, /, *more_args: Die[~T]) -> Die[~T]:
303def pointwise_min(arg0, /, *more_args: 'icepool.Die[T]') -> 'icepool.Die[T]':
304    """Selects the highest chance of rolling <= each outcome among the arguments.
305
306    Naming not finalized.
307    
308    Specifically, for each outcome, the chance of the result rolling <= to that 
309    outcome is the same as the highest chance of rolling <= that outcome among
310    the arguments.
311
312    Equivalently, any quantile in the result is the lowest of that quantile
313    among the arguments.
314
315    This is useful for selecting from several possible moves where you are
316    trying to get <= a threshold that is known but could change depending on the
317    situation.
318    
319    Args:
320        dice: Either an iterable of dice, or two or more dice as separate
321            arguments.
322    """
323    if len(more_args) == 0:
324        if isinstance(arg0, icepool.Die):
325            # A single die is itself, not an iterable of its outcomes.
326            args = (arg0, )
327        else:
328            args = arg0
329    else:
330        args = (arg0, ) + more_args
331    args = harmonize_denominators(args)
332    outcomes = sorted_union(*args)
333    cumulative = [
334        max(die.quantity('<=', outcome) for die in args)
335        for outcome in outcomes
336    ]
337    return from_cumulative(outcomes, cumulative)

Selects the highest chance of rolling <= each outcome among the arguments.

Naming not finalized.

Specifically, for each outcome, the chance of the result rolling <= to that outcome is the same as the highest chance of rolling <= that outcome among the arguments.

Equivalently, any quantile in the result is the lowest of that quantile among the arguments.

This is useful for selecting from several possible moves where you are trying to get <= a threshold that is known but could change depending on the situation.

Arguments:
  • dice: Either an iterable of dice, or two or more dice as separate arguments.
def lowest( arg0, /, *more_args: ~T | Die[~T], keep: int | None = None, drop: int | None = None, default: ~T | None = None) -> Die[~T]:
 99def lowest(arg0,
100           /,
101           *more_args: 'T | icepool.Die[T]',
102           keep: int | None = None,
103           drop: int | None = None,
104           default: T | None = None) -> 'icepool.Die[T]':
105    """The lowest outcome among the rolls, or the sum of some of the lowest.
106
107    The outcomes should support addition and multiplication if `keep != 1`.
108
109    Args:
110        args: Dice or individual outcomes in a single iterable, or as two or
111            more separate arguments. Similar to the built-in `min()`.
112        keep, drop: These arguments work together:
113            * If neither are provided, the single lowest die will be taken.
114            * If only `keep` is provided, the `keep` lowest dice will be summed.
115            * If only `drop` is provided, the `drop` lowest dice will be dropped
116                and the rest will be summed.
117            * If both are provided, `drop` lowest dice will be dropped, then
118                the next `keep` lowest dice will be summed.
119        default: If an empty iterable is provided, the result will be a die that
120            always rolls this value.
121
122    Raises:
123        ValueError if an empty iterable is provided with no `default`.
124    """
125    if len(more_args) == 0:
126        args = arg0
127    else:
128        args = (arg0, ) + more_args
129
130    if len(args) == 0:
131        if default is None:
132            raise ValueError(
133                "lowest() arg is an empty sequence and no default was provided."
134            )
135        else:
136            return icepool.Die([default])
137
138    index_slice = lowest_slice(keep, drop)
139    return _sum_slice(*args, index_slice=index_slice)

The lowest outcome among the rolls, or the sum of some of the lowest.

The outcomes should support addition and multiplication if keep != 1.

Arguments:
  • args: Dice or individual outcomes in a single iterable, or as two or more separate arguments. Similar to the built-in min().
  • keep, drop: These arguments work together:
    • If neither are provided, the single lowest die will be taken.
    • If only keep is provided, the keep lowest dice will be summed.
    • If only drop is provided, the drop lowest dice will be dropped and the rest will be summed.
    • If both are provided, drop lowest dice will be dropped, then the next keep lowest dice will be summed.
  • default: If an empty iterable is provided, the result will be a die that always rolls this value.
Raises:
  • ValueError if an empty iterable is provided with no default.
def highest( arg0, /, *more_args: ~T | Die[~T], keep: int | None = None, drop: int | None = None, default: ~T | None = None) -> Die[~T]:
153def highest(arg0,
154            /,
155            *more_args: 'T | icepool.Die[T]',
156            keep: int | None = None,
157            drop: int | None = None,
158            default: T | None = None) -> 'icepool.Die[T]':
159    """The highest outcome among the rolls, or the sum of some of the highest.
160
161    The outcomes should support addition and multiplication if `keep != 1`.
162
163    Args:
164        args: Dice or individual outcomes in a single iterable, or as two or
165            more separate arguments. Similar to the built-in `max()`.
166        keep, drop: These arguments work together:
167            * If neither are provided, the single highest die will be taken.
168            * If only `keep` is provided, the `keep` highest dice will be summed.
169            * If only `drop` is provided, the `drop` highest dice will be dropped
170                and the rest will be summed.
171            * If both are provided, `drop` highest dice will be dropped, then
172                the next `keep` highest dice will be summed.
173        drop: This number of highest dice will be dropped before keeping dice
174            to be summed.
175        default: If an empty iterable is provided, the result will be a die that
176            always rolls this value.
177
178    Raises:
179        ValueError if an empty iterable is provided with no `default`.
180    """
181    if len(more_args) == 0:
182        args = arg0
183    else:
184        args = (arg0, ) + more_args
185
186    if len(args) == 0:
187        if default is None:
188            raise ValueError(
189                "highest() arg is an empty sequence and no default was provided."
190            )
191        else:
192            return icepool.Die([default])
193
194    index_slice = highest_slice(keep, drop)
195    return _sum_slice(*args, index_slice=index_slice)

The highest outcome among the rolls, or the sum of some of the highest.

The outcomes should support addition and multiplication if keep != 1.

Arguments:
  • args: Dice or individual outcomes in a single iterable, or as two or more separate arguments. Similar to the built-in max().
  • keep, drop: These arguments work together:
    • If neither are provided, the single highest die will be taken.
    • If only keep is provided, the keep highest dice will be summed.
    • If only drop is provided, the drop highest dice will be dropped and the rest will be summed.
    • If both are provided, drop highest dice will be dropped, then the next keep highest dice will be summed.
  • drop: This number of highest dice will be dropped before keeping dice to be summed.
  • default: If an empty iterable is provided, the result will be a die that always rolls this value.
Raises:
  • ValueError if an empty iterable is provided with no default.
def middle( arg0, /, *more_args: ~T | Die[~T], keep: int = 1, tie: Literal['error', 'high', 'low'] = 'error', default: ~T | None = None) -> Die[~T]:
209def middle(arg0,
210           /,
211           *more_args: 'T | icepool.Die[T]',
212           keep: int = 1,
213           tie: Literal['error', 'high', 'low'] = 'error',
214           default: T | None = None) -> 'icepool.Die[T]':
215    """The middle of the outcomes among the rolls, or the sum of some of the middle.
216
217    The outcomes should support addition and multiplication if `keep != 1`.
218
219    Args:
220        args: Dice or individual outcomes in a single iterable, or as two or
221            more separate arguments.
222        keep: The number of outcomes to sum.
223        tie: What to do if `keep` is odd but the the number of args is even, or
224            vice versa.
225            * 'error' (default): Raises `IndexError`.
226            * 'high': The higher outcome is taken.
227            * 'low': The lower outcome is taken.
228        default: If an empty iterable is provided, the result will be a die that
229            always rolls this value.
230
231    Raises:
232        ValueError if an empty iterable is provided with no `default`.
233    """
234    if len(more_args) == 0:
235        args = arg0
236    else:
237        args = (arg0, ) + more_args
238
239    if len(args) == 0:
240        if default is None:
241            raise ValueError(
242                "middle() arg is an empty sequence and no default was provided."
243            )
244        else:
245            return icepool.Die([default])
246
247    # Expression evaluators are difficult to type.
248    return icepool.Pool(args).middle(keep, tie=tie).sum()  # type: ignore

The middle of the outcomes among the rolls, or the sum of some of the middle.

The outcomes should support addition and multiplication if keep != 1.

Arguments:
  • args: Dice or individual outcomes in a single iterable, or as two or more separate arguments.
  • keep: The number of outcomes to sum.
  • tie: What to do if keep is odd but the the number of args is even, or vice versa.
    • 'error' (default): Raises IndexError.
    • 'high': The higher outcome is taken.
    • 'low': The lower outcome is taken.
  • default: If an empty iterable is provided, the result will be a die that always rolls this value.
Raises:
  • ValueError if an empty iterable is provided with no default.
def min_outcome(*args: Iterable[~T | Population[~T]] | ~T) -> ~T:
350def min_outcome(*args: 'Iterable[T | icepool.Population[T]] | T') -> T:
351    """The minimum possible outcome among the populations.
352    
353    Args:
354        Populations or single outcomes. Alternatively, a single iterable argument of such.
355    """
356    return min(_iter_outcomes(*args))

The minimum possible outcome among the populations.

Arguments:
  • Populations or single outcomes. Alternatively, a single iterable argument of such.
def max_outcome(*args: Iterable[~T | Population[~T]] | ~T) -> ~T:
369def max_outcome(*args: 'Iterable[T | icepool.Population[T]] | T') -> T:
370    """The maximum possible outcome among the populations.
371    
372    Args:
373        Populations or single outcomes. Alternatively, a single iterable argument of such.
374    """
375    return max(_iter_outcomes(*args))

The maximum possible outcome among the populations.

Arguments:
  • Populations or single outcomes. Alternatively, a single iterable argument of such.
def consecutive(*args: Iterable[int]) -> Sequence[int]:
378def consecutive(*args: Iterable[int]) -> Sequence[int]:
379    """A minimal sequence of consecutive ints covering the argument sets."""
380    # Materialize the arguments since they are chained twice.
381    args = tuple(tuple(arg) for arg in args)
382    start = min((x for x in itertools.chain(*args)), default=None)
383    if start is None:
384        return ()
385    stop = max(x for x in itertools.chain(*args))
386    return tuple(range(start, stop + 1))

A minimal sequence of consecutive ints covering the argument sets.

def sorted_union(*args: Iterable[~T]) -> tuple[~T, ...]:
389def sorted_union(*args: Iterable[T]) -> tuple[T, ...]:
390    """Merge sets into a sorted sequence."""
391    if not args:
392        return ()
393    return tuple(sorted(set.union(*(set(arg) for arg in args))))

Merge sets into a sorted sequence.

def harmonize_denominators( dice: Sequence[~T | Die[~T]], weights: Sequence[int] | None = None, /) -> tuple[Die[~T], ...]:
396def harmonize_denominators(dice: 'Sequence[T | icepool.Die[T]]',
397                           weights: Sequence[int] | None = None,
398                           /) -> tuple['icepool.Die[T]', ...]:
399    """Scale the quantities of the dice so that the denominators are proportional to given weights.
400
401    Args:
402        dice: Any number of dice or single outcomes convertible to dice.
403        weights: The target relative denominators of the dice. If not provided,
404            all dice will be scaled to the same denominator, the same as
405            `weights = [1] * len(dice)`.
406
407    Returns:
408        A tuple of dice with the adjusted denominators.
409    """
410    if weights is None:
411        weights = [1] * len(dice)
412    converted_dice = [icepool.implicit_convert_to_die(die) for die in dice]
413    scale_factors = weighted_lcm([d.denominator() for d in converted_dice],
414                                 weights)
415    return tuple(
416        die.multiply_quantities(scale_factor)
417        for die, scale_factor in zip(converted_dice, scale_factors))

Scale the quantities of the dice so that the denominators are proportional to given weights.

Arguments:
  • dice: Any number of dice or single outcomes convertible to dice.
  • weights: The target relative denominators of the dice. If not provided, all dice will be scaled to the same denominator, the same as weights = [1] * len(dice).
Returns:

A tuple of dice with the adjusted denominators.

def reduce( function: Callable[[~T, ~T], ~T | Die[~T] | RerollType], dice: Iterable[~T | Die[~T]], *, initial: ~T | Die[~T] | None = None) -> Die[~T]:
460def reduce(
461        function: 'Callable[[T, T], T | icepool.Die[T] | icepool.RerollType]',
462        dice: 'Iterable[T | icepool.Die[T]]',
463        *,
464        initial: 'T | icepool.Die[T] | None' = None) -> 'icepool.Die[T]':
465    """Applies a function of two arguments cumulatively to a sequence of dice.
466
467    Analogous to the
468    [`functools` function of the same name.](https://docs.python.org/3/library/functools.html#functools.reduce)
469
470    Args:
471        function: The function to map. The function should take two arguments,
472            which are an outcome from each of two dice, and produce an outcome
473            of the same type. It may also return `Reroll`, in which case the
474            entire sequence is effectively rerolled.
475        dice: A sequence of dice to map the function to, from left to right.
476        initial: If provided, this will be placed at the front of the sequence
477            of dice.
478        again_count, again_depth, again_end: Forwarded to the final die constructor.
479    """
480    # Conversion to dice is not necessary since map() takes care of that.
481    iter_dice = iter(dice)
482    if initial is not None:
483        result: 'icepool.Die[T]' = icepool.implicit_convert_to_die(initial)
484    else:
485        result = icepool.implicit_convert_to_die(next(iter_dice))
486    for die in iter_dice:
487        result = map(function, result, die)
488    return result

Applies a function of two arguments cumulatively to a sequence of dice.

Analogous to the .reduce">functools function of the same name.

Arguments:
  • function: The function to map. The function should take two arguments, which are an outcome from each of two dice, and produce an outcome of the same type. It may also return Reroll, in which case the entire sequence is effectively rerolled.
  • dice: A sequence of dice to map the function to, from left to right.
  • initial: If provided, this will be placed at the front of the sequence of dice.
  • again_count, again_depth, again_end: Forwarded to the final die constructor.
def accumulate( function: Callable[[~T, ~T], ~T | Die[~T]], dice: Iterable[~T | Die[~T]], *, initial: ~T | Die[~T] | None = None) -> Iterator[Die[~T]]:
491def accumulate(
492        function: 'Callable[[T, T], T | icepool.Die[T]]',
493        dice: 'Iterable[T | icepool.Die[T]]',
494        *,
495        initial: 'T | icepool.Die[T] | None' = None
496) -> Iterator['icepool.Die[T]']:
497    """Applies a function of two arguments cumulatively to a sequence of dice, yielding each result in turn.
498
499    Analogous to the
500    [`itertools function of the same name`](https://docs.python.org/3/library/itertools.html#itertools.accumulate)
501    , though with no default function and
502    the same parameter order as `reduce()`.
503
504    The number of results is equal to the number of elements of `dice`, with
505    one additional element if `initial` is provided.
506
507    Args:
508        function: The function to map. The function should take two arguments,
509            which are an outcome from each of two dice.
510        dice: A sequence of dice to map the function to, from left to right.
511        initial: If provided, this will be placed at the front of the sequence
512            of dice.
513    """
514    # Conversion to dice is not necessary since map() takes care of that.
515    iter_dice = iter(dice)
516    if initial is not None:
517        result: 'icepool.Die[T]' = icepool.implicit_convert_to_die(initial)
518    else:
519        try:
520            result = icepool.implicit_convert_to_die(next(iter_dice))
521        except StopIteration:
522            return
523    yield result
524    for die in iter_dice:
525        result = map(function, result, die)
526        yield result

Applies a function of two arguments cumulatively to a sequence of dice, yielding each result in turn.

Analogous to the .accumulate">itertools function of the same name , though with no default function and the same parameter order as reduce().

The number of results is equal to the number of elements of dice, with one additional element if initial is provided.

Arguments:
  • function: The function to map. The function should take two arguments, which are an outcome from each of two dice.
  • dice: A sequence of dice to map the function to, from left to right.
  • initial: If provided, this will be placed at the front of the sequence of dice.
def map( repl: Callable[..., ~T | Die[~T] | RerollType | icepool.population.again.AgainExpression] | Mapping[Any, ~T | Die[~T] | RerollType | icepool.population.again.AgainExpression], /, *args: Outcome | Die | MultisetExpression, star: bool | None = None, repeat: int | Literal['inf'] | None = None, again_count: int | None = None, again_depth: int | None = None, again_end: ~T | Die[~T] | RerollType | None = None, **kwargs) -> Die[~T]:
 52def map(
 53        repl:
 54    'Callable[..., T | icepool.Die[T] | icepool.RerollType | icepool.AgainExpression] | Mapping[Any, T | icepool.Die[T] | icepool.RerollType | icepool.AgainExpression]',
 55        /,
 56        *args: 'Outcome | icepool.Die | icepool.MultisetExpression',
 57        star: bool | None = None,
 58        repeat: int | Literal['inf'] | None = None,
 59        again_count: int | None = None,
 60        again_depth: int | None = None,
 61        again_end: 'T | icepool.Die[T] | icepool.RerollType | None' = None,
 62        **kwargs) -> 'icepool.Die[T]':
 63    """Applies `func(outcome_of_die_0, outcome_of_die_1, ...)` for all joint outcomes, returning a Die.
 64
 65    See `map_function` for a decorator version of this.
 66
 67    Example: `map(lambda a, b: a + b, d6, d6)` is the same as d6 + d6.
 68
 69    `map()` is flexible but not very efficient for more than a few dice.
 70    If at all possible, use `reduce()`, `MultisetExpression` methods, and/or
 71    `MultisetEvaluator`s. Even `Pool.expand()` (which sorts rolls) is more
 72    efficient than using `map` on the dice in order.
 73
 74    `Again` can be used but can't be combined with `repeat`.
 75
 76    Args:
 77        repl: One of the following:
 78            * A callable that takes in one outcome per element of args and
 79                produces a new outcome.
 80            * A mapping from old outcomes to new outcomes.
 81                Unmapped old outcomes stay the same.
 82                In this case args must have exactly one element.
 83            As with the `Die` constructor, the new outcomes:
 84            * May be dice rather than just single outcomes.
 85            * The special value `icepool.Reroll` will reroll that old outcome.
 86            * `tuples` containing `Population`s will be `tupleize`d into
 87                `Population`s of `tuple`s.
 88                This does not apply to subclasses of `tuple`s such as `namedtuple`
 89                or other classes such as `Vector`.
 90        *args: `repl` will be called with all joint outcomes of these.
 91            Allowed arg types are:
 92            * Single outcome.
 93            * `Die`. All outcomes will be sent to `repl`.
 94            * `MultisetExpression`. All sorted tuples of outcomes will be sent
 95                to `repl`, as `MultisetExpression.expand()`.
 96        star: If `True`, the first of the args will be unpacked before giving
 97            them to `repl`.
 98            If not provided, it will be inferred based on the signature of `repl`
 99            and the number of arguments.
100        repeat: If provided, `map` will be repeated with the same arguments on 
101            the result this many times, except the first of `args` will be 
102            replaced by the result of the previous iteration. In other words,
103            this produces the result of a Markov process.
104
105            `map(repeat)` will stop early if the entire state distribution has
106            converged to absorbing states. You can force an absorption to a 
107            desired state using `Break(state)`. Furthermore, if a state only
108            leads to itself, reaching that state is considered an absorption.
109
110            `Reroll` can be used to reroll the current stage, while `Restart`
111            restarts the process from the beginning, effectively conditioning
112            against that sequence of state transitions.
113
114            `repeat` is not compatible with `Again`.
115
116            EXPERIMENTAL: If set to `'inf'`, the result will be as if this
117            were repeated an infinite number of times. In this case, the
118            result will be in simplest form.
119        again_count, again_depth, again_end: Forwarded to the final die constructor.
120        **kwargs: Keyword-only arguments can be forwarded to a callable `repl`.
121            Unlike *args, outcomes will not be expanded, i.e. `Die` and
122            `MultisetExpression` will be passed as-is. This is invalid for
123            non-callable `repl`.
124    """
125
126    if len(args) == 0:
127        if repeat is not None:
128            raise ValueError(
129                'If no arguments are given, repeat cannot be used.')
130        if isinstance(repl, Mapping):
131            raise ValueError(
132                'If no arguments are given, repl must be a callable.')
133        return icepool.Die([repl(**kwargs)])
134
135    # Here len(args) is at least 1.
136    die_args: 'Sequence[T | icepool.Die[T]]' = [
137        (
138            arg.expand() if isinstance(arg, icepool.MultisetExpression) else
139            arg  # type: ignore
140        ) for arg in args
141    ]
142
143    first_arg = die_args[0]
144    extra_args = die_args[1:]
145
146    if repeat is None:
147        return map_simple(repl,
148                          first_arg,
149                          *extra_args,
150                          star=star,
151                          again_count=again_count,
152                          again_depth=again_depth,
153                          again_end=again_end,
154                          **kwargs)
155
156    # No Agains allowed past here.
157    repl = cast('Callable[..., T | icepool.Die[T] | icepool.RerollType]', repl)
158    transition_cache = TransitionCache(repl, *extra_args, star=star, **kwargs)
159
160    if repeat == 'inf':
161        # Infinite repeat.
162        # T_co and U should be the same in this case.
163        return icepool.map_tools.markov_chain.absorbing_markov_chain_die(
164            transition_cache, first_arg)
165    elif repeat < 0:
166        raise ValueError('repeat cannot be negative.')
167    elif repeat == 0:
168        return icepool.Die([first_arg])
169    else:
170        transition_die = transition_cache.self_loop_die(
171            icepool.Die([first_arg]))
172        for i in range(repeat):
173            transition_die = transition_cache.step_transition_die(
174                transition_die)
175            if not any(transition_type == TransitionType.DEFAULT
176                       for transition_type, _ in transition_die):
177                break
178        return transition_die.map(final_map, star=True)

Applies func(outcome_of_die_0, outcome_of_die_1, ...) for all joint outcomes, returning a Die.

See map_function for a decorator version of this.

Example: map(lambda a, b: a + b, d6, d6) is the same as d6 + d6.

map() is flexible but not very efficient for more than a few dice. If at all possible, use reduce(), MultisetExpression methods, and/or MultisetEvaluators. Even Pool.expand() (which sorts rolls) is more efficient than using map on the dice in order.

Again can be used but can't be combined with repeat.

Arguments:
  • repl: One of the following:
    • A callable that takes in one outcome per element of args and produces a new outcome.
    • A mapping from old outcomes to new outcomes. Unmapped old outcomes stay the same. In this case args must have exactly one element. As with the Die constructor, the new outcomes:
    • May be dice rather than just single outcomes.
    • The special value icepool.Reroll will reroll that old outcome.
    • tuples containing Populations will be tupleized into Populations of tuples. This does not apply to subclasses of tuples such as namedtuple or other classes such as Vector.
  • *args: repl will be called with all joint outcomes of these. Allowed arg types are:
  • star: If True, the first of the args will be unpacked before giving them to repl. If not provided, it will be inferred based on the signature of repl and the number of arguments.
  • repeat: If provided, map will be repeated with the same arguments on the result this many times, except the first of args will be replaced by the result of the previous iteration. In other words, this produces the result of a Markov process.

    map(repeat) will stop early if the entire state distribution has converged to absorbing states. You can force an absorption to a desired state using Break(state). Furthermore, if a state only leads to itself, reaching that state is considered an absorption.

    Reroll can be used to reroll the current stage, while Restart restarts the process from the beginning, effectively conditioning against that sequence of state transitions.

    repeat is not compatible with Again.

    EXPERIMENTAL: If set to 'inf', the result will be as if this were repeated an infinite number of times. In this case, the result will be in simplest form.

  • again_count, again_depth, again_end: Forwarded to the final die constructor.
  • ****kwargs:** Keyword-only arguments can be forwarded to a callable repl. Unlike *args, outcomes will not be expanded, i.e. Die and MultisetExpression will be passed as-is. This is invalid for non-callable repl.
def map_function( function: Callable[..., ~T | Die[~T] | RerollType | icepool.population.again.AgainExpression] | None = None, /, *, star: bool | None = None, repeat: int | Literal['inf'] | None = None, again_count: int | None = None, again_depth: int | None = None, again_end: ~T | Die[~T] | RerollType | None = None, **kwargs) -> Callable[..., Die[~T]] | Callable[..., Callable[..., Die[~T]]]:
219def map_function(
220    function:
221    'Callable[..., T | icepool.Die[T] | icepool.RerollType | icepool.AgainExpression] | None' = None,
222    /,
223    *,
224    star: bool | None = None,
225    repeat: int | Literal['inf'] | None = None,
226    again_count: int | None = None,
227    again_depth: int | None = None,
228    again_end: 'T | icepool.Die[T] | icepool.RerollType | None' = None,
229    **kwargs
230) -> 'Callable[..., icepool.Die[T]] | Callable[..., Callable[..., icepool.Die[T]]]':
231    """Decorator that turns a function that takes outcomes into a function that takes dice.
232
233    The result must be a `Die`.
234
235    This is basically a decorator version of `map()` and produces behavior
236    similar to AnyDice functions, though Icepool has different typing rules
237    among other differences.
238
239    `map_function` can either be used with no arguments:
240
241    ```python
242    @map_function
243    def explode_six(x):
244        if x == 6:
245            return 6 + Again
246        else:
247            return x
248
249    explode_six(d6, again_depth=2)
250    ```
251
252    Or with keyword arguments, in which case the extra arguments are bound:
253
254    ```python
255    @map_function(again_depth=2)
256    def explode_six(x):
257        if x == 6:
258            return 6 + Again
259        else:
260            return x
261
262    explode_six(d6)
263    ```
264
265    Args:
266        again_count, again_depth, again_end: Forwarded to the final die constructor.
267    """
268
269    if function is not None:
270        return update_wrapper(partial(map, function, **kwargs), function)
271    else:
272
273        def decorator(
274            function:
275            'Callable[..., T | icepool.Die[T] | icepool.RerollType | icepool.AgainExpression]'
276        ) -> 'Callable[..., icepool.Die[T]]':
277
278            return update_wrapper(
279                partial(map,
280                        function,
281                        star=star,
282                        repeat=repeat,
283                        again_count=again_count,
284                        again_depth=again_depth,
285                        again_end=again_end,
286                        **kwargs), function)
287
288        return decorator

Decorator that turns a function that takes outcomes into a function that takes dice.

The result must be a Die.

This is basically a decorator version of map() and produces behavior similar to AnyDice functions, though Icepool has different typing rules among other differences.

map_function can either be used with no arguments:

@map_function
def explode_six(x):
    if x == 6:
        return 6 + Again
    else:
        return x

explode_six(d6, again_depth=2)

Or with keyword arguments, in which case the extra arguments are bound:

@map_function(again_depth=2)
def explode_six(x):
    if x == 6:
        return 6 + Again
    else:
        return x

explode_six(d6)
Arguments:
  • again_count, again_depth, again_end: Forwarded to the final die constructor.
def map_and_time( repl: Callable[..., ~T | Die[~T] | RerollType] | Mapping[Any, ~T | Die[~T] | RerollType], initial_state: ~T | Die[~T], /, *extra_args, star: bool | None = None, repeat: int, **kwargs) -> Die[tuple[~T, int]]:
291def map_and_time(
292        repl:
293    'Callable[..., T | icepool.Die[T] | icepool.RerollType] | Mapping[Any, T | icepool.Die[T] | icepool.RerollType]',
294        initial_state: 'T | icepool.Die[T]',
295        /,
296        *extra_args,
297        star: bool | None = None,
298        repeat: int,
299        **kwargs) -> 'icepool.Die[tuple[T, int]]':
300    """Repeatedly map outcomes of the state to other outcomes, while also
301    counting timesteps.
302
303    This is useful for representing processes.
304
305    The outcomes of the result are  `(outcome, time)`, where `time` is the
306    number of repeats needed to reach an absorbing outcome (an outcome that
307    only leads to itself), or `repeat`, whichever is lesser.
308
309    This will return early if it reaches a fixed point.
310    Therefore, you can set `repeat` equal to the maximum number of
311    time you could possibly be interested in without worrying about
312    it causing extra computations after the fixed point.
313
314    Args:
315        repl: One of the following:
316            * A callable returning a new outcome for each old outcome.
317            * A mapping from old outcomes to new outcomes.
318                Unmapped old outcomes stay the same.
319            The new outcomes may be dice rather than just single outcomes.
320            The special value `icepool.Reroll` will reroll that old outcome.
321        initial_state: The initial state of the process, which could be a
322            single state or a `Die`.
323        extra_args: Extra arguments to use, as per `map`. Note that these are
324            rerolled at every time step.
325        star: If `True`, the first of the args will be unpacked before giving
326            them to `func`.
327            If not provided, it will be guessed based on the signature of `func`
328            and the number of arguments.
329        repeat: This will be repeated with the same arguments on the result
330            up to this many times.
331        **kwargs: Keyword-only arguments can be forwarded to a callable `repl`.
332            Unlike *args, outcomes will not be expanded, i.e. `Die` and
333            `MultisetExpression` will be passed as-is. This is invalid for
334            non-callable `repl`.
335
336    Returns:
337        The `Die` after the modification.
338    """
339    # Here len(args) is at least 1.
340    extra_dice: 'Sequence[T | icepool.Die[T]]' = [
341        (
342            arg.expand() if isinstance(arg, icepool.MultisetExpression) else
343            arg  # type: ignore
344        ) for arg in extra_args
345    ]
346
347    transition_cache = TransitionCache(repl, *extra_dice, star=star, **kwargs)
348
349    transition_die = transition_cache.self_loop_die_with_zero_time(
350        icepool.Die([initial_state]))
351    for i in range(repeat):
352        transition_die = transition_cache.step_transition_die_with_time(
353            transition_die)
354        if not any(transition_type == TransitionType.DEFAULT
355                   for transition_type, state, time in transition_die):
356            break
357    return transition_die.marginals[1:]

Repeatedly map outcomes of the state to other outcomes, while also counting timesteps.

This is useful for representing processes.

The outcomes of the result are (outcome, time), where time is the number of repeats needed to reach an absorbing outcome (an outcome that only leads to itself), or repeat, whichever is lesser.

This will return early if it reaches a fixed point. Therefore, you can set repeat equal to the maximum number of time you could possibly be interested in without worrying about it causing extra computations after the fixed point.

Arguments:
  • repl: One of the following:
    • A callable returning a new outcome for each old outcome.
    • A mapping from old outcomes to new outcomes. Unmapped old outcomes stay the same. The new outcomes may be dice rather than just single outcomes. The special value icepool.Reroll will reroll that old outcome.
  • initial_state: The initial state of the process, which could be a single state or a Die.
  • extra_args: Extra arguments to use, as per map. Note that these are rerolled at every time step.
  • star: If True, the first of the args will be unpacked before giving them to func. If not provided, it will be guessed based on the signature of func and the number of arguments.
  • repeat: This will be repeated with the same arguments on the result up to this many times.
  • ****kwargs:** Keyword-only arguments can be forwarded to a callable repl. Unlike *args, outcomes will not be expanded, i.e. Die and MultisetExpression will be passed as-is. This is invalid for non-callable repl.
Returns:

The Die after the modification.

def mean_time_to_absorb( repl: Callable[..., ~T | Die[~T] | RerollType] | Mapping[Any, ~T | Die[~T] | RerollType], initial_state: ~T | Die[~T], /, *extra_args, star: bool | None = None, **kwargs) -> fractions.Fraction:
360def mean_time_to_absorb(
361        repl:
362    'Callable[..., T | icepool.Die[T] | icepool.RerollType] | Mapping[Any, T | icepool.Die[T] | icepool.RerollType]',
363        initial_state: 'T | icepool.Die[T]',
364        /,
365        *extra_args,
366        star: bool | None = None,
367        **kwargs) -> Fraction:
368    """EXPERIMENTAL: The mean time for the process to reach an absorbing state.
369    
370    An absorbing state is one that maps to itself with unity probability.
371
372    Args:
373        repl: One of the following:
374            * A callable returning a new outcome for each old outcome.
375            * A mapping from old outcomes to new outcomes.
376                Unmapped old outcomes stay the same.
377            The new outcomes may be dice rather than just single outcomes.
378            The special value `Reroll` will reroll that old outcome.
379            Currently, `mean_time_to_absorb` does not support `Restart`.
380        initial_state: The initial state of the process, which could be a
381            single state or a `Die`.
382        extra_args: Extra arguments to use, as per `map`. Note that these are
383            rerolled at every time step.
384        star: If `True`, the first of the args will be unpacked before giving
385            them to `func`.
386            If not provided, it will be guessed based on the signature of `func`
387            and the number of arguments.
388        **kwargs: Keyword-only arguments can be forwarded to a callable `repl`.
389            Unlike *args, outcomes will not be expanded, i.e. `Die` and
390            `MultisetExpression` will be passed as-is. This is invalid for
391            non-callable `repl`.
392
393    Returns:
394        The mean time to absorption.
395    """
396    transition_cache = TransitionCache(repl, *extra_args, star=star, **kwargs)
397
398    # Infinite repeat.
399    # T_co and U should be the same in this case.
400
401    return icepool.map_tools.markov_chain.absorbing_markov_chain_mean_absorption_time(
402        transition_cache, initial_state)

EXPERIMENTAL: The mean time for the process to reach an absorbing state.

An absorbing state is one that maps to itself with unity probability.

Arguments:
  • repl: One of the following:
    • A callable returning a new outcome for each old outcome.
    • A mapping from old outcomes to new outcomes. Unmapped old outcomes stay the same. The new outcomes may be dice rather than just single outcomes. The special value Reroll will reroll that old outcome. Currently, mean_time_to_absorb does not support Restart.
  • initial_state: The initial state of the process, which could be a single state or a Die.
  • extra_args: Extra arguments to use, as per map. Note that these are rerolled at every time step.
  • star: If True, the first of the args will be unpacked before giving them to func. If not provided, it will be guessed based on the signature of func and the number of arguments.
  • ****kwargs:** Keyword-only arguments can be forwarded to a callable repl. Unlike *args, outcomes will not be expanded, i.e. Die and MultisetExpression will be passed as-is. This is invalid for non-callable repl.
Returns:

The mean time to absorption.

def map_to_pool( repl: Callable[..., MultisetExpression | Sequence[~T | Die[~T]] | Mapping[Die[~T], int] | Mapping[~T, int] | RerollType] | Mapping[Any, MultisetExpression | Sequence[~T | Die[~T]] | Mapping[Die[~T], int] | Mapping[~T, int] | RerollType], /, *args: Outcome | Die | MultisetExpression, star: bool | None = None, **kwargs) -> MultisetExpression[~T]:
405def map_to_pool(
406        repl:
407    'Callable[..., icepool.MultisetExpression | Sequence[icepool.Die[T] | T] | Mapping[icepool.Die[T], int] | Mapping[T, int] | icepool.RerollType] | Mapping[Any, icepool.MultisetExpression | Sequence[icepool.Die[T] | T] | Mapping[icepool.Die[T], int] | Mapping[T, int] | icepool.RerollType]',
408        /,
409        *args: 'Outcome | icepool.Die | icepool.MultisetExpression',
410        star: bool | None = None,
411        **kwargs) -> 'icepool.MultisetExpression[T]':
412    """EXPERIMENTAL: Applies `repl(outcome_of_die_0, outcome_of_die_1, ...)` for all joint outcomes, producing a MultisetExpression.
413    
414    Args:
415        repl: One of the following:
416            * A callable that takes in one outcome per element of args and
417                produces a `MultisetExpression` or something convertible to a `Pool`.
418            * A mapping from old outcomes to `MultisetExpression` 
419                or something convertible to a `Pool`.
420                In this case args must have exactly one element.
421            The new outcomes may be dice rather than just single outcomes.
422            The special value `icepool.Reroll` will reroll that old outcome.
423        star: If `True`, the first of the args will be unpacked before giving
424            them to `repl`.
425            If not provided, it will be guessed based on the signature of `repl`
426            and the number of arguments.
427        **kwargs: Keyword-only arguments can be forwarded to a callable `repl`.
428            Unlike *args, outcomes will not be expanded, i.e. `Die` and
429            `MultisetExpression` will be passed as-is. This is invalid for
430            non-callable `repl`.
431
432    Returns:
433        A `MultisetExpression` representing the mixture of `Pool`s. Note  
434        that this is not technically a `Pool`, though it supports most of 
435        the same operations.
436
437    Raises:
438        ValueError: If `denominator` cannot be made consistent with the 
439            resulting mixture of pools.
440    """
441    transition_function, star = transition_and_star(repl, len(args), star)
442
443    data: 'MutableMapping[icepool.MultisetExpression[T], int]' = defaultdict(
444        int)
445    for outcomes, quantity in icepool.iter_cartesian_product(*args):
446        if star:
447            pool = transition_function(*outcomes[0], *outcomes[1:], **kwargs)
448        else:
449            pool = transition_function(*outcomes, **kwargs)
450        if pool in icepool.REROLL_TYPES:
451            continue
452        elif isinstance(pool, icepool.MultisetExpression):
453            data[pool] += quantity
454        else:
455            data[icepool.Pool(pool)] += quantity
456    # I couldn't get the covariance / contravariance to work.
457    return icepool.MultisetMixture(data)  # type: ignore

EXPERIMENTAL: Applies repl(outcome_of_die_0, outcome_of_die_1, ...) for all joint outcomes, producing a MultisetExpression.

Arguments:
  • repl: One of the following:
    • A callable that takes in one outcome per element of args and produces a MultisetExpression or something convertible to a Pool.
    • A mapping from old outcomes to MultisetExpression or something convertible to a Pool. In this case args must have exactly one element. The new outcomes may be dice rather than just single outcomes. The special value icepool.Reroll will reroll that old outcome.
  • star: If True, the first of the args will be unpacked before giving them to repl. If not provided, it will be guessed based on the signature of repl and the number of arguments.
  • ****kwargs:** Keyword-only arguments can be forwarded to a callable repl. Unlike *args, outcomes will not be expanded, i.e. Die and MultisetExpression will be passed as-is. This is invalid for non-callable repl.
Returns:

A MultisetExpression representing the mixture of Pools. Note
that this is not technically a Pool, though it supports most of the same operations.

Raises:
  • ValueError: If denominator cannot be made consistent with the resulting mixture of pools.
Reroll: Final = <RerollType.Reroll: 'Reroll'>

Indicates that an outcome should be rerolled (with unlimited depth).

This effectively removes the outcome from the probability space, along with its contribution to the denominator.

This can be used for conditional probability by removing all outcomes not consistent with the given observations.

Operation in specific cases:

  • If sent to the constructor of Die, it and the corresponding quantity is dropped.
  • When used with Again or map(repeat), only that stage is rerolled, not the entire rolling process.
  • To reroll with limited depth, use Die.reroll(), or Again with no modification.
  • When used with MultisetEvaluator, this currently has the same meaning as Restart. Prefer using Restart in this case.
Restart: Final = <RerollType.Restart: 'Restart'>

Indicates that a rolling process should be restarted (with unlimited depth).

Restart effectively removes the sequence of events from the probability space, along with its contribution to the denominator.

Restart can be used for conditional probability by removing all sequences of events not consistent with the given observations.

Restart can be used with again_count, map(repeat), or MultisetEvaluator. When sent to the constructor of Die, it has the same effect as Reroll; prefer using Reroll in this case.

Restart can NOT be used with again_depth, but Reroll can.

class Break(typing.Generic[~T]):
19class Break(Generic[T]):
20    """EXPERIMENTAL: Wrapper around a return value for triggering an early exit from `map(repeat)`.
21    
22    For example, to add successive dice until the total reaches 10:
23    ```python
24    def example(total, new_roll):
25        if total >= 10:
26            return Break()  # same as Break(total)
27        else:
28            return total + new_roll
29    map(example, 0, d(6))
30    ```
31    """
32
33    def __init__(self, outcome: T | None = None):
34        """Constructor.
35        
36        Args:
37            outcome: The wrapped outcome. If `None`, it is considered to be
38                equal to the first argument to the current iteration of `map()`.
39        """
40        self.outcome = outcome
41
42    def __hash__(self) -> int:
43        return hash((Break, self.outcome))
44
45    def __repr__(self) -> str:
46        return f'Break({repr(self.outcome)})'
47
48    def __str__(self) -> str:
49        return f'Break({str(self.outcome)})'

EXPERIMENTAL: Wrapper around a return value for triggering an early exit from map(repeat).

For example, to add successive dice until the total reaches 10:

def example(total, new_roll):
    if total >= 10:
        return Break()  # same as Break(total)
    else:
        return total + new_roll
map(example, 0, d(6))
Break(outcome: ~T | None = None)
33    def __init__(self, outcome: T | None = None):
34        """Constructor.
35        
36        Args:
37            outcome: The wrapped outcome. If `None`, it is considered to be
38                equal to the first argument to the current iteration of `map()`.
39        """
40        self.outcome = outcome

Constructor.

Arguments:
  • outcome: The wrapped outcome. If None, it is considered to be equal to the first argument to the current iteration of map().
outcome
class RerollType(enum.Enum):
32class RerollType(enum.Enum):
33    """The type of the Reroll and Restart singletons."""
34    Reroll = 'Reroll'
35    Restart = 'Restart'

The type of the Reroll and Restart singletons.

Reroll = <RerollType.Reroll: 'Reroll'>
Restart = <RerollType.Restart: 'Restart'>
class Pool(icepool.generator.keep.KeepGenerator[~T]):
 27class Pool(KeepGenerator[T]):
 28    """Represents a multiset of outcomes resulting from the roll of several dice.
 29
 30    This should be used in conjunction with `MultisetEvaluator` to generate a
 31    result.
 32
 33    Note that operators are performed on the multiset of rolls, not the multiset
 34    of dice. For example, `d6.pool(3) - d6.pool(3)` is not an empty pool, but
 35    an expression meaning "roll two pools of 3d6 and with rolls in the second 
 36    pool cancelling matching rolls in the first pool one-for-one".
 37    """
 38
 39    _dice: tuple[tuple['icepool.Die[T]', int], ...]
 40    _outcomes: tuple[T, ...]
 41
 42    def __new__(
 43            cls,
 44            dice:
 45        'Sequence[icepool.Die[T] | T] | Mapping[icepool.Die[T], int] | Mapping[T, int] | Mapping[icepool.Die[T] | T, int]',
 46            times: Sequence[int] | int = 1) -> 'Pool':
 47        """Public constructor for a pool.
 48
 49        Evaulation is most efficient when the dice are the same or same-side
 50        truncations of each other. For example, d4, d6, d8, d10, d12 are all
 51        same-side truncations of d12.
 52
 53        It is permissible to create a `Pool` without providing dice, but not all
 54        evaluators will handle this case, especially if they depend on the
 55        outcome type. Dice may be in the pool zero times, in which case their
 56        outcomes will be considered but without any count (unless another die
 57        has that outcome).
 58
 59        Args:
 60            dice: The dice to put in the `Pool`. This can be one of the following:
 61
 62                * A `Sequence` of `Die` or outcomes.
 63                * A `Mapping` of `Die` or outcomes to how many of that `Die` or
 64                    outcome to put in the `Pool`.
 65
 66                All outcomes within a `Pool` must be totally orderable.
 67            times: Multiplies the number of times each element of `dice` will
 68                be put into the pool.
 69                `times` can either be a sequence of the same length as
 70                `outcomes` or a single `int` to apply to all elements of
 71                `outcomes`.
 72
 73        Raises:
 74            ValueError: If a bare `Deck` or `Die` argument is provided.
 75                A `Pool` of a single `Die` should constructed as `Pool([die])`.
 76        """
 77        if isinstance(dice, Pool):
 78            if times == 1:
 79                return dice
 80            else:
 81                dice = {die: quantity for die, quantity in dice._dice}
 82
 83        if isinstance(dice, (icepool.Die, icepool.Deck, icepool.MultiDeal)):
 84            raise ValueError(
 85                f'A Pool cannot be constructed with a {type(dice).__name__} argument.'
 86            )
 87
 88        dice, times = icepool.creation_args.itemize(dice, times)
 89        converted_dice = [icepool.implicit_convert_to_die(die) for die in dice]
 90
 91        dice_counts: MutableMapping['icepool.Die[T]', int] = defaultdict(int)
 92        for die, qty in zip(converted_dice, times):
 93            if qty == 0:
 94                continue
 95            dice_counts[die] += qty
 96        keep_tuple = (1, ) * sum(times)
 97
 98        # Includes dice with zero qty.
 99        outcomes = icepool.sorted_union(*converted_dice)
100        return cls._new_from_mapping(dice_counts, outcomes, keep_tuple)
101
102    @classmethod
103    def _new_raw(cls, dice: tuple[tuple['icepool.Die[T]', int], ...],
104                 outcomes: tuple[T, ...], keep_tuple: tuple[int,
105                                                            ...]) -> 'Pool[T]':
106        """Create using a keep_tuple directly.
107
108        Args:
109            dice: A tuple of (die, count) pairs.
110            keep_tuple: A tuple of how many times to count each die.
111        """
112        self = super(Pool, cls).__new__(cls)
113        self._dice = dice
114        self._outcomes = outcomes
115        self._keep_tuple = keep_tuple
116        return self
117
118    @classmethod
119    def clear_cache(cls):
120        """Clears the global PoolSource cache."""
121        PoolSource._new_raw.cache_clear()
122
123    @classmethod
124    def _new_from_mapping(cls, dice_counts: Mapping['icepool.Die[T]', int],
125                          outcomes: tuple[T, ...],
126                          keep_tuple: tuple[int, ...]) -> 'Pool[T]':
127        """Creates a new pool.
128
129        Args:
130            dice_counts: A map from dice to rolls.
131            keep_tuple: A tuple with length equal to the number of dice.
132        """
133        dice = tuple(sorted(dice_counts.items(),
134                            key=lambda kv: kv[0].hash_key))
135        return Pool._new_raw(dice, outcomes, keep_tuple)
136
137    def _make_source(self):
138        return PoolSource(self._dice, self._outcomes, self._keep_tuple)
139
140    @cached_property
141    def _raw_size(self) -> int:
142        return sum(count for _, count in self._dice)
143
144    def raw_size(self) -> int:
145        """The number of dice in this pool before the keep_tuple is applied."""
146        return self._raw_size
147
148    @cached_property
149    def _denominator(self) -> int:
150        return math.prod(die.denominator()**count for die, count in self._dice)
151
152    def denominator(self) -> int:
153        return self._denominator
154
155    @cached_property
156    def _dice_tuple(self) -> tuple['icepool.Die[T]', ...]:
157        return sum(((die, ) * count for die, count in self._dice), start=())
158
159    @cached_property
160    def _unique_dice(self) -> Collection['icepool.Die[T]']:
161        return set(die for die, _ in self._dice)
162
163    def unique_dice(self) -> Collection['icepool.Die[T]']:
164        """The collection of unique dice in this pool."""
165        return self._unique_dice
166
167    def outcomes(self) -> Sequence[T]:
168        """The union of possible outcomes among all dice in this pool in ascending order."""
169        return self._outcomes
170
171    def _set_keep_tuple(self, keep_tuple: tuple[int,
172                                                ...]) -> 'KeepGenerator[T]':
173        return Pool._new_raw(self._dice, self._outcomes, keep_tuple)
174
175    def additive_union(
176        *args: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]'
177    ) -> 'MultisetExpression[T]':
178        args = tuple(
179            icepool.expression.multiset_expression.
180            implicit_convert_to_expression(arg) for arg in args)
181        if all(isinstance(arg, Pool) for arg in args):
182            pools = cast(tuple[Pool[T], ...], args)
183            keep_tuple: tuple[int, ...] = tuple(
184                reduce(operator.add, (pool.keep_tuple() for pool in pools),
185                       ()))
186            if len(keep_tuple) == 0 or all(x == keep_tuple[0]
187                                           for x in keep_tuple):
188                # All sorted positions count the same, so we can merge the
189                # pools.
190                dice: 'MutableMapping[icepool.Die, int]' = defaultdict(int)
191                for pool in pools:
192                    for die, die_count in pool._dice:
193                        dice[die] += die_count
194                outcomes = icepool.sorted_union(*(pool.outcomes()
195                                                  for pool in pools))
196                return Pool._new_from_mapping(dice, outcomes, keep_tuple)
197        return KeepGenerator.additive_union(*args)
198
199    @property
200    def hash_key(self):
201        return Pool, self._dice, self._keep_tuple
202
203    def __str__(self) -> str:
204        return (
205            f'Pool of {self.raw_size()} dice with keep_tuple={self.keep_tuple()}\n'
206            + ''.join(f'  {repr(die)} : {count},\n'
207                      for die, count in self._dice))

Represents a multiset of outcomes resulting from the roll of several dice.

This should be used in conjunction with MultisetEvaluator to generate a result.

Note that operators are performed on the multiset of rolls, not the multiset of dice. For example, d6.pool(3) - d6.pool(3) is not an empty pool, but an expression meaning "roll two pools of 3d6 and with rolls in the second pool cancelling matching rolls in the first pool one-for-one".

@classmethod
def clear_cache(cls):
118    @classmethod
119    def clear_cache(cls):
120        """Clears the global PoolSource cache."""
121        PoolSource._new_raw.cache_clear()

Clears the global PoolSource cache.

def raw_size(self) -> int:
144    def raw_size(self) -> int:
145        """The number of dice in this pool before the keep_tuple is applied."""
146        return self._raw_size

The number of dice in this pool before the keep_tuple is applied.

def denominator(self) -> int:
152    def denominator(self) -> int:
153        return self._denominator
def unique_dice(self) -> Collection[Die[~T]]:
163    def unique_dice(self) -> Collection['icepool.Die[T]']:
164        """The collection of unique dice in this pool."""
165        return self._unique_dice

The collection of unique dice in this pool.

def outcomes(self) -> Sequence[~T]:
167    def outcomes(self) -> Sequence[T]:
168        """The union of possible outcomes among all dice in this pool in ascending order."""
169        return self._outcomes

The union of possible outcomes among all dice in this pool in ascending order.

def additive_union( *args: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T]) -> MultisetExpression[~T]:
175    def additive_union(
176        *args: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]'
177    ) -> 'MultisetExpression[T]':
178        args = tuple(
179            icepool.expression.multiset_expression.
180            implicit_convert_to_expression(arg) for arg in args)
181        if all(isinstance(arg, Pool) for arg in args):
182            pools = cast(tuple[Pool[T], ...], args)
183            keep_tuple: tuple[int, ...] = tuple(
184                reduce(operator.add, (pool.keep_tuple() for pool in pools),
185                       ()))
186            if len(keep_tuple) == 0 or all(x == keep_tuple[0]
187                                           for x in keep_tuple):
188                # All sorted positions count the same, so we can merge the
189                # pools.
190                dice: 'MutableMapping[icepool.Die, int]' = defaultdict(int)
191                for pool in pools:
192                    for die, die_count in pool._dice:
193                        dice[die] += die_count
194                outcomes = icepool.sorted_union(*(pool.outcomes()
195                                                  for pool in pools))
196                return Pool._new_from_mapping(dice, outcomes, keep_tuple)
197        return KeepGenerator.additive_union(*args)

The combined elements from all of the multisets.

Specifically, the counts for each outcome will be summed across the arguments.

Same as a + b + c + ....

Example:

[1, 2, 2, 3] + [1, 2, 4] -> [1, 1, 2, 2, 2, 3, 4]
hash_key
199    @property
200    def hash_key(self):
201        return Pool, self._dice, self._keep_tuple

A hash key for this object. This should include a type.

If None, this will not compare equal to any other object.

def d_pool( die_sizes: Collection[int] | Mapping[int, int]) -> Pool[int]:
345def d_pool(die_sizes: Collection[int] | Mapping[int, int]) -> 'Pool[int]':
346    """A `Pool` of standard dice (e.g. d6, d8...).
347
348    Args:
349        die_sizes: A collection of die sizes, which will put one die of that
350            sizes in the pool for each element.
351            Or, a mapping of die sizes to how many dice of that size to put
352            into the pool.
353            If empty, the pool will be considered to consist of zero zeros.
354    """
355    if not die_sizes:
356        return Pool({icepool.Die([0]): 0})
357    if isinstance(die_sizes, Mapping):
358        die_sizes = list(
359            itertools.chain.from_iterable([k] * v
360                                          for k, v in die_sizes.items()))
361    return Pool(list(icepool.d(x) for x in die_sizes))

A Pool of standard dice (e.g. d6, d8...).

Arguments:
  • die_sizes: A collection of die sizes, which will put one die of that sizes in the pool for each element. Or, a mapping of die sizes to how many dice of that size to put into the pool. If empty, the pool will be considered to consist of zero zeros.
def z_pool( die_sizes: Collection[int] | Mapping[int, int]) -> Pool[int]:
372def z_pool(die_sizes: Collection[int] | Mapping[int, int]) -> 'Pool[int]':
373    """A `Pool` of zero-indexed dice (e.g. z6, z8...).
374    
375    Args:
376        die_sizes: A collection of die sizes, which will put one die of that
377            sizes in the pool for each element.
378            Or, a mapping of die sizes to how many dice of that size to put
379            into the pool.
380            If empty, the pool will be considered to consist of zero zeros.
381    """
382    if not die_sizes:
383        return Pool({icepool.Die([0]): 0})
384    if isinstance(die_sizes, Mapping):
385        die_sizes = list(
386            itertools.chain.from_iterable([k] * v
387                                          for k, v in die_sizes.items()))
388    return Pool(list(icepool.z(x) for x in die_sizes))

A Pool of zero-indexed dice (e.g. z6, z8...).

Arguments:
  • die_sizes: A collection of die sizes, which will put one die of that sizes in the pool for each element. Or, a mapping of die sizes to how many dice of that size to put into the pool. If empty, the pool will be considered to consist of zero zeros.
class MultisetGenerator(icepool.MultisetExpression[~T]):
18class MultisetGenerator(MultisetExpression[T]):
19    """Abstract base class for generating multisets.
20
21    These include dice pools (`Pool`) and card deals (`Deal`). Most likely you
22    will be using one of these two rather than writing your own subclass of
23    `MultisetGenerator`.
24
25    The multisets are incrementally generated one outcome at a time.
26    For each outcome, a `count` and `weight` are generated, along with a
27    smaller generator to produce the rest of the multiset.
28
29    You can perform simple evaluations using built-in operators and methods in
30    this class.
31    For more complex evaluations and better performance, particularly when
32    multiple generators are involved, you will want to write your own subclass
33    of `MultisetEvaluator`.
34    """
35
36    _children = ()
37
38    @abstractmethod
39    def _make_source(self) -> 'MultisetSource':
40        """Create a source from this generator."""
41
42    @property
43    def _has_parameter(self) -> bool:
44        return False
45
46    def _prepare(
47        self
48    ) -> Iterator[tuple['tuple[Dungeonlet[T, Any], ...]',
49                        'tuple[Questlet[T, Any], ...]',
50                        'tuple[MultisetSourceBase[T, Any], ...]', int]]:
51        dungeonlets = (MultisetFreeVariable[T, int](), )
52        questlets = (MultisetGeneratorQuestlet[T](), )
53        sources = (self._make_source(), )
54        weight = 1
55        yield dungeonlets, questlets, sources, weight
56
57    def weightless(self) -> 'MultisetGenerator[T]':
58        """EXPERIMENTAL: Produces a wrapped generator in which each possible multiset is equally weighted.
59
60        In other words, given a generator `g`,
61        ```python
62        g.expand()
63        g.weightless().expand()
64        ```
65        have the same set of outcomes, but the weightless version has every
66        outcome with quantity 1. Other operators and evaluations can be
67        attached to the result of `weightless()` as usual, in which case the
68        quantity of each outcome the number of *unique* multisets producing that
69        given outcome, rather than the ordinary probabilistic weighting.
70        
71        `weightless()` requires that each call to the underlying `source.pop()`  
72        does not yield duplicate count values; if so, the evaluation will raise
73        `UnsupportedOrder`. Keeps and mixed pools usually fail this.
74        """
75        if isinstance(self, icepool.WeightlessGenerator):
76            return self
77        return icepool.WeightlessGenerator(self)

Abstract base class for generating multisets.

These include dice pools (Pool) and card deals (Deal). Most likely you will be using one of these two rather than writing your own subclass of MultisetGenerator.

The multisets are incrementally generated one outcome at a time. For each outcome, a count and weight are generated, along with a smaller generator to produce the rest of the multiset.

You can perform simple evaluations using built-in operators and methods in this class. For more complex evaluations and better performance, particularly when multiple generators are involved, you will want to write your own subclass of MultisetEvaluator.

def weightless(self) -> MultisetGenerator[~T]:
57    def weightless(self) -> 'MultisetGenerator[T]':
58        """EXPERIMENTAL: Produces a wrapped generator in which each possible multiset is equally weighted.
59
60        In other words, given a generator `g`,
61        ```python
62        g.expand()
63        g.weightless().expand()
64        ```
65        have the same set of outcomes, but the weightless version has every
66        outcome with quantity 1. Other operators and evaluations can be
67        attached to the result of `weightless()` as usual, in which case the
68        quantity of each outcome the number of *unique* multisets producing that
69        given outcome, rather than the ordinary probabilistic weighting.
70        
71        `weightless()` requires that each call to the underlying `source.pop()`  
72        does not yield duplicate count values; if so, the evaluation will raise
73        `UnsupportedOrder`. Keeps and mixed pools usually fail this.
74        """
75        if isinstance(self, icepool.WeightlessGenerator):
76            return self
77        return icepool.WeightlessGenerator(self)

EXPERIMENTAL: Produces a wrapped generator in which each possible multiset is equally weighted.

In other words, given a generator g,

g.expand()
g.weightless().expand()

have the same set of outcomes, but the weightless version has every outcome with quantity 1. Other operators and evaluations can be attached to the result of weightless() as usual, in which case the quantity of each outcome the number of unique multisets producing that given outcome, rather than the ordinary probabilistic weighting.

weightless() requires that each call to the underlying source.pop()
does not yield duplicate count values; if so, the evaluation will raise UnsupportedOrder. Keeps and mixed pools usually fail this.

class MultisetExpression(icepool.expression.multiset_expression_base.MultisetExpressionBase[~T, int], icepool.expand.Expandable[tuple[~T, ...]]):
  66class MultisetExpression(MultisetExpressionBase[T, int],
  67                         Expandable[tuple[T, ...]]):
  68    """Abstract base class representing an expression that operates on single multisets.
  69
  70    There are three types of multiset expressions:
  71
  72    * `MultisetGenerator`, which produce raw outcomes and counts.
  73    * `MultisetOperator`, which takes outcomes with one or more counts and
  74        produces a count.
  75    * `MultisetVariable`, which is a temporary placeholder for some other 
  76        expression.
  77
  78    Expression methods can be applied to `MultisetGenerator`s to do simple
  79    evaluations. For joint evaluations, try `multiset_function`.
  80
  81    Use the provided operations to build up more complicated
  82    expressions, or to attach a final evaluator.
  83
  84    Operations include:
  85
  86    | Operation                   | Count / notes                               |
  87    |:----------------------------|:--------------------------------------------|
  88    | `additive_union`, `+`       | `l + r`                                     |
  89    | `difference`, `-`           | `l - r`                                     |
  90    | `intersection`, `&`         | `min(l, r)`                                 |
  91    | `union`, `\\|`               | `max(l, r)`                                 |
  92    | `symmetric_difference`, `^` | `abs(l - r)`                                |
  93    | `multiply_counts`, `*`      | `count * n`                                 |
  94    | `divide_counts`, `//`       | `count // n`                                |
  95    | `modulo_counts`, `%`        | `count % n`                                 |
  96    | `keep_counts`               | `count if count >= n else 0` etc.           |
  97    | unary `+`                   | same as `keep_counts('>=', 0)`              |
  98    | unary `-`                   | reverses the sign of all counts             |
  99    | `unique`                    | `min(count, n)`                             |
 100    | `keep_outcomes`             | `count if outcome in t else 0`              |
 101    | `drop_outcomes`             | `count if outcome not in t else 0`          |
 102    | `map_counts`                | `f(outcome, *counts)`                       |
 103    | `keep`, `[]`                | less capable than `KeepGenerator` version   |
 104    | `highest`                   | less capable than `KeepGenerator` version   |
 105    | `lowest`                    | less capable than `KeepGenerator` version   |
 106
 107    | Evaluator                      | Summary                                                                    |
 108    |:-------------------------------|:---------------------------------------------------------------------------|
 109    | `issubset`, `<=`               | Whether the left side's counts are all <= their counterparts on the right  |
 110    | `issuperset`, `>=`             | Whether the left side's counts are all >= their counterparts on the right  |
 111    | `isdisjoint`                   | Whether the left side has no positive counts in common with the right side |
 112    | `<`                            | As `<=`, but `False` if the two multisets are equal                        |
 113    | `>`                            | As `>=`, but `False` if the two multisets are equal                        |
 114    | `==`                           | Whether the left side has all the same counts as the right side            |
 115    | `!=`                           | Whether the left side has any different counts to the right side           |
 116    | `expand`                       | All elements in ascending order                                            |
 117    | `sum`                          | Sum of all elements                                                        |
 118    | `size`                         | The number of elements                                                     |
 119    | `empty`                        | Whether all counts are zero                                                |
 120    | `all_counts`                   | All counts in descending order                                             |
 121    | `product_of_counts`            | The product of all counts                                                  |
 122    | `highest_outcome_and_count`    | The highest outcome and how many of that outcome                           |
 123    | `largest_count`                | The single largest count, aka x-of-a-kind                                  |
 124    | `largest_count_and_outcome`    | Same but also with the corresponding outcome                               |
 125    | `count_subset`, `//`           | The number of times the right side is contained in the left side           |
 126    | `largest_straight`             | Length of longest consecutive sequence                                     |
 127    | `largest_straight_and_outcome` | Same but also with the corresponding outcome                               |
 128    | `all_straights`                | Lengths of all consecutive sequences in descending order                   |
 129    """
 130
 131    def _make_param(self,
 132                    name: str,
 133                    arg_index: int,
 134                    star_index: int | None = None) -> 'MultisetParameter[T]':
 135        if star_index is not None:
 136            raise TypeError(
 137                'The single int count of MultisetExpression cannot be starred.'
 138            )
 139        return icepool.MultisetParameter(name, arg_index, star_index)
 140
 141    @property
 142    def _items_for_cartesian_product(
 143            self) -> Sequence[tuple[tuple[T, ...], int]]:
 144        expansion = cast('icepool.Die[tuple[T, ...]]', self.expand())
 145        return expansion.items()
 146
 147    # We need to reiterate this since we override __eq__.
 148    __hash__ = MaybeHashKeyed.__hash__  # type: ignore
 149
 150    # Binary operators.
 151
 152    def __add__(self,
 153                other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
 154                /) -> 'MultisetExpression[T]':
 155        try:
 156            return MultisetExpression.additive_union(self, other)
 157        except ImplicitConversionError:
 158            return NotImplemented
 159
 160    def __radd__(
 161            self,
 162            other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
 163            /) -> 'MultisetExpression[T]':
 164        try:
 165            return MultisetExpression.additive_union(other, self)
 166        except ImplicitConversionError:
 167            return NotImplemented
 168
 169    def additive_union(
 170        *args: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]'
 171    ) -> 'MultisetExpression[T]':
 172        """The combined elements from all of the multisets.
 173
 174        Specifically, the counts for each outcome will be summed across the
 175        arguments.
 176
 177        Same as `a + b + c + ...`.
 178
 179        Example:
 180        ```python
 181        [1, 2, 2, 3] + [1, 2, 4] -> [1, 1, 2, 2, 2, 3, 4]
 182        ```
 183        """
 184        expressions = tuple(
 185            implicit_convert_to_expression(arg) for arg in args)
 186        return icepool.operator.MultisetAdditiveUnion(*expressions)
 187
 188    def __sub__(self,
 189                other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
 190                /) -> 'MultisetExpression[T]':
 191        try:
 192            return MultisetExpression.difference(self, other)
 193        except ImplicitConversionError:
 194            return NotImplemented
 195
 196    def __rsub__(
 197            self,
 198            other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
 199            /) -> 'MultisetExpression[T]':
 200        try:
 201            return MultisetExpression.difference(other, self)
 202        except ImplicitConversionError:
 203            return NotImplemented
 204
 205    def difference(
 206            *args: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
 207            keep_negative_counts: bool = False) -> 'MultisetExpression[T]':
 208        """The elements from the left multiset that are not in any of the others.
 209
 210        Specifically, for each outcome, the count of that outcome is that of
 211        the leftmost argument minus the counts from all other arguments.
 212        By default, if the result would be negative, it is set to zero.
 213
 214        Same as `a - b - c - ...`.
 215
 216        Example:
 217        ```python
 218        [1, 2, 2, 3] - [1, 2, 4] -> [2, 3]
 219        ```
 220
 221        If no arguments are given, the result will be an empty multiset, i.e.
 222        all zero counts.
 223
 224        Aas a multiset operation, this will only cancel elements 1:1.
 225        If you want to drop all elements in a set of outcomes regardless of
 226        count, either use `drop_outcomes()` instead, or use a large number of
 227        counts on the right side.
 228
 229        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
 230        gives an overview of several opposed dice pool mechanics, including this
 231        one.
 232
 233        Args:
 234            *args: All but the leftmost argument will subtract their counts
 235                from the leftmost argument.
 236            keep_negative_counts: If set (default False), negative resultig 
 237                counts will be preserved.
 238        """
 239        expressions = tuple(
 240            implicit_convert_to_expression(arg) for arg in args)
 241        if keep_negative_counts:
 242            return icepool.operator.MultisetDifferenceKeepNegative(
 243                *expressions)
 244        else:
 245            return icepool.operator.MultisetDifferenceDropNegative(
 246                *expressions)
 247
 248    def __and__(self,
 249                other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
 250                /) -> 'MultisetExpression[T]':
 251        try:
 252            return MultisetExpression.intersection(self, other)
 253        except ImplicitConversionError:
 254            return NotImplemented
 255
 256    def __rand__(
 257            self,
 258            other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
 259            /) -> 'MultisetExpression[T]':
 260        try:
 261            return MultisetExpression.intersection(other, self)
 262        except ImplicitConversionError:
 263            return NotImplemented
 264
 265    def intersection(
 266        *args: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]'
 267    ) -> 'MultisetExpression[T]':
 268        """The elements that all the multisets have in common.
 269
 270        Specifically, the count for each outcome is the minimum count among the
 271        arguments.
 272
 273        Same as `a & b & c & ...`.
 274
 275        Example:
 276        ```python
 277        [1, 2, 2, 3] & [1, 2, 4] -> [1, 2]
 278        ```
 279
 280        As a multiset operation, this will only intersect elements 1:1.
 281        If you want to keep all elements in a set of outcomes regardless of
 282        count, either use `keep_outcomes()` instead, or use a large number of
 283        counts on the right side.
 284        """
 285        expressions = tuple(
 286            implicit_convert_to_expression(arg) for arg in args)
 287        return icepool.operator.MultisetIntersection(*expressions)
 288
 289    def __or__(self,
 290               other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
 291               /) -> 'MultisetExpression[T]':
 292        try:
 293            return MultisetExpression.union(self, other)
 294        except ImplicitConversionError:
 295            return NotImplemented
 296
 297    def __ror__(self,
 298                other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
 299                /) -> 'MultisetExpression[T]':
 300        try:
 301            return MultisetExpression.union(other, self)
 302        except ImplicitConversionError:
 303            return NotImplemented
 304
 305    def union(
 306        *args: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]'
 307    ) -> 'MultisetExpression[T]':
 308        """The most of each outcome that appear in any of the multisets.
 309
 310        Specifically, the count for each outcome is the maximum count among the
 311        arguments.
 312
 313        Same as `a | b | c | ...`.
 314
 315        Example:
 316        ```python
 317        [1, 2, 2, 3] | [1, 2, 4] -> [1, 2, 2, 3, 4]
 318        ```
 319        """
 320        expressions = tuple(
 321            implicit_convert_to_expression(arg) for arg in args)
 322        return icepool.operator.MultisetUnion(*expressions)
 323
 324    def __xor__(self,
 325                other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
 326                /) -> 'MultisetExpression[T]':
 327        try:
 328            return MultisetExpression.symmetric_difference(self, other)
 329        except ImplicitConversionError:
 330            return NotImplemented
 331
 332    def __rxor__(
 333            self,
 334            other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
 335            /) -> 'MultisetExpression[T]':
 336        try:
 337            # Symmetric.
 338            return MultisetExpression.symmetric_difference(self, other)
 339        except ImplicitConversionError:
 340            return NotImplemented
 341
 342    def symmetric_difference(
 343            self,
 344            other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
 345            /) -> 'MultisetExpression[T]':
 346        """The elements that appear in the left or right multiset but not both.
 347
 348        Specifically, the count for each outcome is the absolute difference
 349        between the counts from the two arguments.
 350
 351        Same as `a ^ b`.
 352
 353        Specifically, this produces the absolute difference between counts.
 354        If you don't want negative counts to be used from the inputs, you can
 355        do `+left ^ +right`.
 356
 357        Example:
 358        ```python
 359        [1, 2, 2, 3] ^ [1, 2, 4] -> [2, 3, 4]
 360        ```
 361        """
 362        return icepool.operator.MultisetSymmetricDifference(
 363            self, implicit_convert_to_expression(other))
 364
 365    def keep_outcomes(
 366            self, outcomes:
 367        'Callable[[T], bool] | Collection[T] | MultisetExpression[T]',
 368            /) -> 'MultisetExpression[T]':
 369        """Keeps the designated outcomes, and drops the rest by setting their counts to zero.
 370
 371        This is similar to `intersection()`, except the right side is considered
 372        to have unlimited multiplicity.
 373
 374        Args:
 375            outcomes: A callable returning `True` iff the outcome should be kept,
 376                or an expression or collection of outcomes to keep.
 377        """
 378        if isinstance(outcomes, MultisetExpression):
 379            return icepool.operator.MultisetFilterOutcomesBinary(
 380                self, outcomes)
 381        else:
 382            return icepool.operator.MultisetFilterOutcomes(self,
 383                                                           outcomes=outcomes)
 384
 385    def drop_outcomes(
 386            self, outcomes:
 387        'Callable[[T], bool] | Collection[T] | MultisetExpression[T]',
 388            /) -> 'MultisetExpression[T]':
 389        """Drops the designated outcomes by setting their counts to zero, and keeps the rest.
 390
 391        This is similar to `difference()`, except the right side is considered
 392        to have unlimited multiplicity.
 393
 394        Args:
 395            outcomes: A callable returning `True` iff the outcome should be
 396                dropped, or an expression or collection of outcomes to drop.
 397        """
 398        if isinstance(outcomes, MultisetExpression):
 399            return icepool.operator.MultisetFilterOutcomesBinary(self,
 400                                                                 outcomes,
 401                                                                 invert=True)
 402        else:
 403            return icepool.operator.MultisetFilterOutcomes(self,
 404                                                           outcomes=outcomes,
 405                                                           invert=True)
 406
 407    # Adjust counts.
 408
 409    def map_counts(*args:
 410                   'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
 411                   function: Callable[..., int]) -> 'MultisetExpression[T]':
 412        """Maps the counts to new counts.
 413
 414        Args:
 415            function: A function that takes `outcome, *counts` and produces a
 416                combined count.
 417        """
 418        expressions = tuple(
 419            implicit_convert_to_expression(arg) for arg in args)
 420        return icepool.operator.MultisetMapCounts(*expressions,
 421                                                  function=function)
 422
 423    def __mul__(self, n: int) -> 'MultisetExpression[T]':
 424        if not isinstance(n, int):
 425            return NotImplemented
 426        return self.multiply_counts(n)
 427
 428    # Commutable in this case.
 429    def __rmul__(self, n: int) -> 'MultisetExpression[T]':
 430        if not isinstance(n, int):
 431            return NotImplemented
 432        return self.multiply_counts(n)
 433
 434    def multiply_counts(self, n: int, /) -> 'MultisetExpression[T]':
 435        """Multiplies all counts by n.
 436
 437        Same as `self * n`.
 438
 439        Example:
 440        ```python
 441        Pool([1, 2, 2, 3]) * 2 -> [1, 1, 2, 2, 2, 2, 3, 3]
 442        ```
 443        """
 444        return icepool.operator.MultisetMultiplyCounts(self, constant=n)
 445
 446    @overload
 447    def __floordiv__(self, other: int) -> 'MultisetExpression[T]':
 448        ...
 449
 450    @overload
 451    def __floordiv__(
 452        self, other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]'
 453    ) -> 'icepool.Die[int] | MultisetFunctionRawResult[T, int]':
 454        """Same as divide_counts()."""
 455
 456    @overload
 457    def __floordiv__(
 458        self,
 459        other: 'int | MultisetExpression[T] | Mapping[T, int] | Sequence[T]'
 460    ) -> 'MultisetExpression[T] | icepool.Die[int] | MultisetFunctionRawResult[T, int]':
 461        """Same as count_subset()."""
 462
 463    def __floordiv__(
 464        self,
 465        other: 'int | MultisetExpression[T] | Mapping[T, int] | Sequence[T]'
 466    ) -> 'MultisetExpression[T] | icepool.Die[int] | MultisetFunctionRawResult[T, int]':
 467        if isinstance(other, int):
 468            return self.divide_counts(other)
 469        else:
 470            return self.count_subset(other)
 471
 472    def divide_counts(self, n: int, /) -> 'MultisetExpression[T]':
 473        """Divides all counts by n (rounding down).
 474
 475        Same as `self // n`.
 476
 477        Example:
 478        ```python
 479        Pool([1, 2, 2, 3]) // 2 -> [2]
 480        ```
 481        """
 482        return icepool.operator.MultisetFloordivCounts(self, constant=n)
 483
 484    def __mod__(self, n: int, /) -> 'MultisetExpression[T]':
 485        if not isinstance(n, int):
 486            return NotImplemented
 487        return icepool.operator.MultisetModuloCounts(self, constant=n)
 488
 489    def modulo_counts(self, n: int, /) -> 'MultisetExpression[T]':
 490        """Moduos all counts by n.
 491
 492        Same as `self % n`.
 493
 494        Example:
 495        ```python
 496        Pool([1, 2, 2, 3]) % 2 -> [1, 3]
 497        ```
 498        """
 499        return self % n
 500
 501    def __pos__(self) -> 'MultisetExpression[T]':
 502        """Sets all negative counts to zero."""
 503        return icepool.operator.MultisetKeepCounts(self,
 504                                                   comparison='>=',
 505                                                   constant=0)
 506
 507    def __neg__(self) -> 'MultisetExpression[T]':
 508        """As -1 * self."""
 509        return -1 * self
 510
 511    def keep_counts(self, comparison: Literal['==', '!=', '<=', '<', '>=',
 512                                              '>'], n: int,
 513                    /) -> 'MultisetExpression[T]':
 514        """Keeps counts fitting the comparison, treating the rest as zero.
 515
 516        For example, `expression.keep_counts('>=', 2)` would keep pairs,
 517        triplets, etc. and drop singles.
 518
 519        ```python
 520        Pool([1, 2, 2, 3, 3, 3]).keep_counts('>=', 2) -> [2, 2, 3, 3, 3]
 521        ```
 522        
 523        Args:
 524            comparison: The comparison to use.
 525            n: The number to compare counts against.
 526        """
 527        return icepool.operator.MultisetKeepCounts(self,
 528                                                   comparison=comparison,
 529                                                   constant=n)
 530
 531    def unique(self, n: int = 1, /) -> 'MultisetExpression[T]':
 532        """Counts each outcome at most `n` times.
 533
 534        For example, `generator.unique(2)` would count each outcome at most
 535        twice.
 536
 537        Example:
 538        ```python
 539        Pool([1, 2, 2, 3]).unique() -> [1, 2, 3]
 540        ```
 541        """
 542        return icepool.operator.MultisetUnique(self, constant=n)
 543
 544    # Keep highest / lowest.
 545
 546    @overload
 547    def keep(
 548        self, index: slice | Sequence[int | EllipsisType]
 549    ) -> 'MultisetExpression[T]':
 550        ...
 551
 552    @overload
 553    def keep(self,
 554             index: int) -> 'icepool.Die[T] | MultisetFunctionRawResult[T, T]':
 555        ...
 556
 557    def keep(
 558        self, index: slice | Sequence[int | EllipsisType] | int
 559    ) -> 'MultisetExpression[T] | icepool.Die[T] | MultisetFunctionRawResult[T, T]':
 560        """Selects elements after drawing and sorting.
 561
 562        This is less capable than the `KeepGenerator` version.
 563        In particular, it does not know how many elements it is selecting from,
 564        so it must be anchored at the starting end. The advantage is that it
 565        can be applied to any expression.
 566
 567        The valid types of argument are:
 568
 569        * A `slice`. If both start and stop are provided, they must both be
 570            non-negative or both be negative. step is not supported.
 571        * A sequence of `int` with `...` (`Ellipsis`) at exactly one end.
 572            Each sorted element will be counted that many times, with the
 573            `Ellipsis` treated as enough zeros (possibly "negative") to
 574            fill the rest of the elements.
 575        * An `int`, which evaluates by taking the element at the specified
 576            index. In this case the result is a `Die`.
 577
 578        Negative incoming counts are treated as zero counts.
 579
 580        Use the `[]` operator for the same effect as this method.
 581        """
 582        if isinstance(index, int):
 583            return icepool.evaluator.keep_evaluator.evaluate(self, index=index)
 584        else:
 585            return icepool.operator.MultisetKeep(self, index=index)
 586
 587    @overload
 588    def __getitem__(
 589        self, index: slice | Sequence[int | EllipsisType]
 590    ) -> 'MultisetExpression[T]':
 591        ...
 592
 593    @overload
 594    def __getitem__(
 595            self,
 596            index: int) -> 'icepool.Die[T] | MultisetFunctionRawResult[T, T]':
 597        ...
 598
 599    def __getitem__(
 600        self, index: slice | Sequence[int | EllipsisType] | int
 601    ) -> 'MultisetExpression[T] | icepool.Die[T] | MultisetFunctionRawResult[T, T]':
 602        return self.keep(index)
 603
 604    def lowest(self,
 605               keep: int | None = None,
 606               drop: int | None = None) -> 'MultisetExpression[T]':
 607        """Keep some of the lowest elements from this multiset and drop the rest.
 608
 609        In contrast to the die and free function versions, this does not
 610        automatically sum the dice. Use `.sum()` afterwards if you want to sum.
 611        Alternatively, you can perform some other evaluation.
 612
 613        This requires the outcomes to be evaluated in ascending order.
 614
 615        Args:
 616            keep, drop: These arguments work together:
 617                * If neither are provided, the single lowest element
 618                    will be kept.
 619                * If only `keep` is provided, the `keep` lowest elements
 620                    will be kept.
 621                * If only `drop` is provided, the `drop` lowest elements
 622                    will be dropped and the rest will be kept.
 623                * If both are provided, `drop` lowest elements will be dropped,
 624                    then the next `keep` lowest elements will be kept.
 625        """
 626        index = lowest_slice(keep, drop)
 627        return self.keep(index)
 628
 629    def highest(self,
 630                keep: int | None = None,
 631                drop: int | None = None) -> 'MultisetExpression[T]':
 632        """Keep some of the highest elements from this multiset and drop the rest.
 633
 634        In contrast to the die and free function versions, this does not
 635        automatically sum the dice. Use `.sum()` afterwards if you want to sum.
 636        Alternatively, you can perform some other evaluation.
 637
 638        This requires the outcomes to be evaluated in descending order.
 639
 640        Args:
 641            keep, drop: These arguments work together:
 642                * If neither are provided, the single highest element
 643                    will be kept.
 644                * If only `keep` is provided, the `keep` highest elements
 645                    will be kept.
 646                * If only `drop` is provided, the `drop` highest elements
 647                    will be dropped and the rest will be kept.
 648                * If both are provided, `drop` highest elements will be dropped, 
 649                    then the next `keep` highest elements will be kept.
 650        """
 651        index = highest_slice(keep, drop)
 652        return self.keep(index)
 653
 654    # Pairing.
 655
 656    def sort_pair(
 657        self,
 658        comparison: Literal['==', '!=', '<=', '<', '>=', '>'],
 659        other: 'MultisetExpression[T]',
 660        /,
 661        order: Order = Order.Descending,
 662        extra: Literal['early', 'late', 'low', 'high', 'equal', 'keep',
 663                       'drop'] = 'drop'
 664    ) -> 'MultisetExpression[T]':
 665        """EXPERIMENTAL: Sort `self` and `other` and make pairs of one element from each, then keep the elements from `self` from each pair that fit the given comparision.
 666
 667        Example: An attacker rolls 3d6 versus a defender's 2d6 in the game of
 668        *RISK*. Which pairs did the attacker win?
 669        ```python
 670        d6.pool(3).highest(2).sort_pair('>', d6.pool(2))
 671        ```
 672        
 673        Suppose the attacker rolled 6, 4, 3 and the defender 5, 5.
 674        In this case the 4 would be blocked since the attacker lost that pair,
 675        leaving the attacker's 6. If you want to keep the extra element (3), you 
 676        can use the `extra` parameter.
 677        ```python
 678
 679        Pool([6, 4, 3]).sort_pair('>', [5, 5]) -> [6]
 680        Pool([6, 4, 3]).sort_pair('>', [5, 5], extra='keep') -> [6, 3]
 681        ```
 682
 683        Contrast `max_pair_keep()` and `max_pair_drop()`, which first 
 684        create the maximum number of pairs that fit the comparison, not
 685        necessarily in sorted order.
 686        In the above example, `max_pair()` would allow the defender to
 687        assign their 5s to block both the 4 and the 3.
 688
 689        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
 690        gives an overview of several opposed dice pool mechanics, including this
 691        one.
 692
 693        This is not designed for use with negative counts.
 694        
 695        Args:
 696            comparison: The comparison to filter by. If you want to drop rather
 697                than keep, use the complementary comparison:
 698                * `'=='` vs. `'!='`
 699                * `'<='` vs. `'>'`
 700                * `'>='` vs. `'<'`
 701            other: The other multiset to pair elements with.
 702            order: The order in which to sort before forming pairs.
 703                Default is descending.
 704            extra: If the left operand has more elements than the right
 705                operand, this determines what is done with the extra elements.
 706                The default is `'drop'`.
 707                * `'early'`, `'late'`: The extra elements are considered to   
 708                    occur earlier or later in `order` than their missing
 709                    counterparts.
 710                * `'low'`, `'high'`, `'equal'`: The extra elements are 
 711                    considered to be lower, higher, or equal to their missing
 712                    counterparts.
 713                * `'keep'`, `'drop'`: The extra elements are always kept or 
 714                    dropped.
 715        """
 716        other = implicit_convert_to_expression(other)
 717
 718        return icepool.operator.MultisetSortPair(self,
 719                                                 other,
 720                                                 comparison=comparison,
 721                                                 sort_order=order,
 722                                                 extra=extra)
 723
 724    def sort_pair_keep_while(self,
 725                             comparison: Literal['==', '!=', '<=', '<', '>=',
 726                                                 '>'],
 727                             other: 'MultisetExpression[T]',
 728                             /,
 729                             order: Order = Order.Descending,
 730                             extra: Literal['early', 'late', 'low', 'high',
 731                                            'equal', 'continue',
 732                                            'break'] = 'break'):
 733        """EXPERIMENTAL: Sort `self` and `other` and make pairs of one element from each, then go through the pairs and keep elements from `self` while the `comparison` holds, dropping the rest.
 734
 735        This is not designed for use with negative counts.
 736
 737        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
 738        gives an overview of several opposed dice pool mechanics, including this
 739        one.
 740
 741        Args:
 742            comparison: The comparison for which to continue the "while".
 743            other: The other multiset to pair elements with.
 744            order: The order in which to sort before forming pairs.
 745                Default is descending.
 746            extra: If the left operand has more elements than the right
 747                operand, this determines what is done with the extra elements.
 748                The default is `'break'`.
 749                * `'early'`, `'late'`: The extra elements are considered to   
 750                    occur earlier or later in `order` than their missing
 751                    counterparts.
 752                * `'low'`, `'high'`, `'equal'`: The extra elements are 
 753                    considered to be lower, higher, or equal to their missing
 754                    counterparts.
 755                * `'continue'`, `'break'`: If the "while" still holds upon 
 756                    reaching the extra elements, whether those elements
 757                    continue to be kept.
 758        """
 759        other = implicit_convert_to_expression(other)
 760        return icepool.operator.MultisetSortPairWhile(self,
 761                                                      other,
 762                                                      keep=True,
 763                                                      comparison=comparison,
 764                                                      sort_order=order,
 765                                                      extra=extra)
 766
 767    def sort_pair_drop_while(self,
 768                             comparison: Literal['==', '!=', '<=', '<', '>=',
 769                                                 '>'],
 770                             other: 'MultisetExpression[T]',
 771                             /,
 772                             order: Order = Order.Descending,
 773                             extra: Literal['early', 'late', 'low', 'high',
 774                                            'equal', 'continue',
 775                                            'break'] = 'break'):
 776        """EXPERIMENTAL: Sort `self` and `other` and make pairs of one element from each, then go through the pairs and drop elements from `self` while the `comparison` holds, keeping the rest.
 777
 778        This is not designed for use with negative counts.
 779
 780        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
 781        gives an overview of several opposed dice pool mechanics, including this
 782        one.
 783
 784        Args:
 785            comparison: The comparison for which to continue the "while".
 786            other: The other multiset to pair elements with.
 787            order: The order in which to sort before forming pairs.
 788                Default is descending.
 789            extra: If the left operand has more elements than the right
 790                operand, this determines what is done with the extra elements.
 791                The default is `'break'`.
 792                * `'early'`, `'late'`: The extra elements are considered to   
 793                    occur earlier or later in `order` than their missing
 794                    counterparts.
 795                * `'low'`, `'high'`, `'equal'`: The extra elements are 
 796                    considered to be lower, higher, or equal to their missing
 797                    counterparts.
 798                * `'continue'`, `'break'`: If the "while" still holds upon 
 799                    reaching the extra elements, whether those elements
 800                    continue to be dropped.
 801        """
 802        other = implicit_convert_to_expression(other)
 803        return icepool.operator.MultisetSortPairWhile(self,
 804                                                      other,
 805                                                      keep=False,
 806                                                      comparison=comparison,
 807                                                      sort_order=order,
 808                                                      extra=extra)
 809
 810    def max_pair_keep(self,
 811                      comparison: Literal['==', '<=', '<', '>=', '>'],
 812                      other: 'MultisetExpression[T]',
 813                      priority: Literal['low', 'high'] | None = None,
 814                      /) -> 'MultisetExpression[T]':
 815        """EXPERIMENTAL: Form as many pairs of elements between `self` and `other` fitting the comparison, then keep the paired elements from `self`.
 816
 817        This pairs elements of `self` with elements of `other`, such that in
 818        each pair the element from `self` fits the `comparison` with the
 819        element from `other`. As many such pairs of elements will be created as 
 820        possible, prioritizing either the lowest or highest possible elements.
 821        Finally, the paired elements from `self` are kept, dropping the rest.
 822
 823        This requires that outcomes be evaluated in descending order if
 824        prioritizing high elements, or ascending order if prioritizing low
 825        elements.
 826
 827        This is not designed for use with negative counts.
 828
 829        Example: An attacker rolls a pool of 4d6 and a defender rolls a pool of 
 830        3d6. Defender dice can be used to block attacker dice of equal or lesser
 831        value, and the defender prefers to block the highest attacker dice
 832        possible. Which attacker dice were blocked?
 833        ```python
 834        d6.pool(4).max_pair_keep('<=', d6.pool(3), 'high').sum()
 835        ```
 836
 837        Suppose the attacker rolls 6, 4, 3, 1 and the defender rolls 5, 5.
 838        Then the result would be [4, 3].
 839        ```python
 840        Pool([6, 4, 3, 1]).max_pair('<=', [5, 5], 'high')
 841        -> [4, 3]
 842        ```
 843
 844        The complement of this is `max_pair_drop`, which drops the paired
 845        elements from `self` and keeps the rest.
 846
 847        Contrast `sort_pair()`, which first creates pairs in
 848        sorted order and then filters them by `comparison`.
 849        In the above example, `sort_pair()` would force the defender to pair
 850        against the 6 and the 4, which would only allow them to block the 4
 851        and let the 6, 3, and 1 through.
 852
 853        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
 854        gives an overview of several opposed dice pool mechanics, including this
 855        one.
 856
 857        Args:
 858            comparison: The comparison that the pairs must satisfy.
 859                `'=='` is the same as `+self & +other`.
 860            other: The other multiset to pair elements with.
 861            priority: Optional paramter to prioritize pairing `'low'` or
 862                `'high'` elements. Note that this does not change the number of
 863                elements that are paired.
 864        """
 865        other = implicit_convert_to_expression(other)
 866        if comparison == '==':
 867            return +self & +other
 868
 869        cls: Type[icepool.operator.MultisetMaxPairLate] | Type[
 870            icepool.operator.MultisetMaxPairEarly]
 871
 872        if priority is None:
 873            order = Order.Ascending
 874            left_first, tie, _ = compute_lexi_tuple(comparison, order)
 875            if left_first:
 876                order = Order.Descending
 877            cls = icepool.operator.MultisetMaxPairLate
 878        else:
 879            match priority:
 880                case 'low':
 881                    order = Order.Ascending
 882                case 'high':
 883                    order = Order.Descending
 884                case _:
 885                    raise ValueError("priority must be 'low' or 'high'.")
 886
 887            left_first, tie, _ = compute_lexi_tuple(comparison, order)
 888
 889            if left_first:
 890                cls = icepool.operator.MultisetMaxPairEarly
 891            else:
 892                cls = icepool.operator.MultisetMaxPairLate
 893
 894        return cls(self,
 895                   other,
 896                   order=order,
 897                   pair_equal=cast(bool, tie),
 898                   keep=True)
 899
 900    def max_pair_drop(self,
 901                      comparison: Literal['==', '<=', '<', '>=', '>'],
 902                      other: 'MultisetExpression[T]',
 903                      priority: Literal['low', 'high'] | None = None,
 904                      /) -> 'MultisetExpression[T]':
 905        """EXPERIMENTAL: Form as many pairs of elements between `self` and `other` fitting the comparison, then drop the paired elements from `self`.
 906
 907        This pairs elements of `self` with elements of `other`, such that in
 908        each pair the element from `self` fits the `comparison` with the
 909        element from `other`. As many such pairs of elements will be created as 
 910        possible, prioritizing either the lowest or highest possible elements.
 911        Finally, the paired elements from `self` are dropped, keeping the rest.
 912
 913        This requires that outcomes be evaluated in descending order if
 914        prioritizing high elements, or ascending order if prioritizing low
 915        elements.
 916
 917        This is not designed for use with negative counts.
 918
 919        Example: An attacker rolls a pool of 4d6 and a defender rolls a pool of 
 920        3d6. Defender dice can be used to block attacker dice of equal or lesser
 921        value, and the defender prefers to block the highest attacker dice
 922        possible. Which attacker dice were NOT blocked?
 923        ```python
 924        d6.pool(4).max_pair_drop('<=', d6.pool(3), 'high').sum()
 925        ```
 926
 927        Suppose the attacker rolls 6, 4, 3, 1 and the defender rolls 5, 5.
 928        Then the result would be [4, 3].
 929        ```python
 930        Pool([6, 4, 3, 1]).max_pair_drop('<=', [5, 5], 'high')
 931        -> [6, 1]
 932        ```
 933
 934        The complement of this is `max_pair_keep`, which keeps the paired
 935        elements from `self` and drops the rest.
 936
 937        Contrast `sort_pair()`, which first creates pairs in
 938        sorted order and then filters them by `comparison`.
 939        In the above example, `sort_pair()` would force the defender to pair
 940        against the 6 and the 4, which would only allow them to block the 4
 941        and let the 6, 3, and 1 through.
 942
 943        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
 944        gives an overview of several opposed dice pool mechanics, including this
 945        one.
 946
 947        Args:
 948            comparison: The comparison that the pairs must satisfy.
 949                `'=='` is the same as `self - other`.
 950            other: The other multiset to pair elements with.
 951            priority: Optional paramter to prioritize pairing `'low'` or
 952                `'high'` elements. Note that this does not change the number of
 953                elements that are paired.
 954        """
 955        other = implicit_convert_to_expression(other)
 956        if comparison == '==':
 957            return self - other
 958
 959        cls: Type[icepool.operator.MultisetMaxPairLate] | Type[
 960            icepool.operator.MultisetMaxPairEarly]
 961
 962        if priority is None:
 963            order = Order.Ascending
 964            left_first, tie, _ = compute_lexi_tuple(comparison, order)
 965            if left_first:
 966                order = Order.Descending
 967            cls = icepool.operator.MultisetMaxPairLate
 968        else:
 969            match priority:
 970                case 'low':
 971                    order = Order.Ascending
 972                case 'high':
 973                    order = Order.Descending
 974                case _:
 975                    raise ValueError("priority must be 'low' or 'high'.")
 976
 977            left_first, tie, _ = compute_lexi_tuple(comparison, order)
 978
 979            if left_first:
 980                cls = icepool.operator.MultisetMaxPairEarly
 981            else:
 982                cls = icepool.operator.MultisetMaxPairLate
 983
 984        return cls(self,
 985                   other,
 986                   order=order,
 987                   pair_equal=cast(bool, tie),
 988                   keep=False)
 989
 990    def versus_all(self, comparison: Literal['<=', '<', '>=', '>'],
 991                   other: 'MultisetExpression[T]') -> 'MultisetExpression[T]':
 992        """EXPERIMENTAL: Keeps elements from `self` that fit the comparison against all elements of the other multiset.
 993
 994        Contrast `versus_any()`.
 995
 996        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
 997        gives an overview of several opposed dice pool mechanics, including this
 998        one.
 999        
1000        Args:
1001            comparison: One of `'<=', '<', '>=', '>'`.
1002            other: The other multiset to compare to. Negative counts are treated
1003                as 0.
1004        """
1005        other = implicit_convert_to_expression(other)
1006        lexi_tuple, order = compute_lexi_tuple_with_zero_right_first(
1007            comparison)
1008        return icepool.operator.MultisetVersus(self,
1009                                               other,
1010                                               lexi_tuple=lexi_tuple,
1011                                               order=order)
1012
1013    def versus_any(self, comparison: Literal['<=', '<', '>=', '>'],
1014                   other: 'MultisetExpression[T]') -> 'MultisetExpression[T]':
1015        """EXPERIMENTAL: Keeps elements from `self` that fit the comparison against any element of the other multiset.
1016
1017        Contrast `versus_all()`.
1018
1019        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
1020        gives an overview of several opposed dice pool mechanics, including this
1021        one.
1022        
1023        Args:
1024            comparison: One of `'<=', '<', '>=', '>'`.
1025            other: The other multiset to compare to. Negative counts are treated
1026                as 0.
1027        """
1028        other = implicit_convert_to_expression(other)
1029        lexi_tuple, order = compute_lexi_tuple_with_zero_right_first(
1030            comparison)
1031        lexi_tuple = tuple(reversed(lexi_tuple))  # type: ignore
1032        order = -order
1033
1034        return icepool.operator.MultisetVersus(self,
1035                                               other,
1036                                               lexi_tuple=lexi_tuple,
1037                                               order=order)
1038
1039    # Evaluations.
1040
1041    def expand(
1042        self,
1043        order: Order = Order.Ascending
1044    ) -> 'icepool.Die[tuple[T, ...]] | MultisetFunctionRawResult[T, tuple[T, ...]]':
1045        """Evaluation: All elements of the multiset in ascending order.
1046
1047        This is expensive and not recommended unless there are few possibilities.
1048
1049        Args:
1050            order: Whether the elements are in ascending (default) or descending
1051                order.
1052        """
1053        return icepool.evaluator.ExpandEvaluator().evaluate(self, order=order)
1054
1055    def sum(
1056        self,
1057        map: Callable[[T], U] | Mapping[T, U] | None = None
1058    ) -> 'icepool.Die[U] | MultisetFunctionRawResult[T, U]':
1059        """Evaluation: The sum of all elements.
1060        
1061        Args:
1062            map: If provided, this will be used to map outcomes before summing
1063                the elements. For a mapping, unmapped outcomes are left as-is.
1064        """
1065        if map is None:
1066            return icepool.evaluator.sum_evaluator.evaluate(self)
1067        else:
1068            return icepool.evaluator.SumEvaluator(map).evaluate(self)
1069
1070    def size(self) -> 'icepool.Die[int] | MultisetFunctionRawResult[T, int]':
1071        """Evaluation: The total number of elements in the multiset.
1072
1073        This is usually not very interesting unless some other operation is
1074        performed first. Examples:
1075
1076        `generator.unique().size()` will count the number of unique outcomes.
1077
1078        `(generator & [4, 5, 6]).size()` will count up to one each of
1079        4, 5, and 6.
1080        """
1081        return icepool.evaluator.size_evaluator.evaluate(self)
1082
1083    def empty(
1084            self) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1085        """Evaluation: Whether the multiset contains only zero counts.
1086        
1087        The opposite of `any()`.
1088        """
1089        return icepool.evaluator.empty_evaluator.evaluate(self)
1090
1091    def any(self) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1092        """Evaluation: Whether the multiset contains any nonzero count.
1093        
1094        The opposite of `empty()`.
1095        """
1096        return icepool.evaluator.empty_evaluator.evaluate(self, any=True)
1097
1098    def product_of_counts(
1099        self, ) -> 'icepool.Die[int] | MultisetFunctionRawResult[T, int]':
1100        """Evaluation: The product of counts in the multiset."""
1101        return icepool.evaluator.product_of_counts_evaluator.evaluate(self)
1102
1103    def highest_outcome_and_count(
1104        self
1105    ) -> 'icepool.Die[tuple[T, int]] | MultisetFunctionRawResult[T, tuple[T, int]]':
1106        """Evaluation: The highest outcome with positive count, along with that count.
1107
1108        If no outcomes have positive count, the min outcome will be returned with 0 count.
1109        """
1110        return icepool.evaluator.highest_outcome_and_count_evaluator.evaluate(
1111            self)
1112
1113    def all_counts(
1114        self,
1115        filter: int | Literal['all'] = 1
1116    ) -> 'icepool.Die[tuple[int, ...]] | MultisetFunctionRawResult[T, tuple[int, ...]]':
1117        """Evaluation: Sorted tuple of all counts, i.e. the sizes of all matching sets.
1118
1119        The sizes are in **descending** order.
1120
1121        Args:
1122            filter: Any counts below this value will not be in the output.
1123                For example, `filter=2` will only produce pairs and better.
1124                If `'all'`, no filtering will be done.
1125
1126                Why not just place `keep_counts('>=')` before this?
1127                `keep_counts('>=')` operates by setting counts to zero, so we
1128                would still need an argument to specify whether we want to
1129                output zero counts. So we might as well use the argument to do
1130                both.
1131        """
1132        return icepool.evaluator.AllCountsEvaluator(
1133            filter=filter).evaluate(self)
1134
1135    def largest_count(
1136        self,
1137        *,
1138        wild: Callable[[T], bool] | Collection[T] | None = None,
1139        wild_low: Callable[[T], bool] | Collection[T] | None = None,
1140        wild_high: Callable[[T], bool] | Collection[T] | None = None,
1141    ) -> 'icepool.Die[int] | MultisetFunctionRawResult[T, int]':
1142        """Evaluation: The size of the largest matching set among the elements.
1143        
1144        Args:
1145            wild: If provided, the counts of these outcomes will be combined
1146                with the counts of any other outcomes.
1147            wild_low: These wilds can only be combined with outcomes that they
1148                are lower than.
1149            wild_high: These wilds can only be combined with outcomes that they
1150                are higher than.
1151        """
1152        if wild is None and wild_low is None and wild_high is None:
1153            return icepool.evaluator.largest_count_evaluator.evaluate(self)
1154        else:
1155            return icepool.evaluator.LargestCountWithWildEvaluator(
1156                wild=wild, wild_low=wild_low,
1157                wild_high=wild_high).evaluate(self)
1158
1159    def largest_count_and_outcome(
1160        self
1161    ) -> 'icepool.Die[tuple[int, T]] | MultisetFunctionRawResult[T, tuple[int, T]]':
1162        """Evaluation: The largest matching set among the elements and the corresponding outcome."""
1163        return icepool.evaluator.largest_count_and_outcome_evaluator.evaluate(
1164            self)
1165
1166    def __rfloordiv__(
1167        self, other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]'
1168    ) -> 'icepool.Die[int] | MultisetFunctionRawResult[T, int]':
1169        return implicit_convert_to_expression(other).count_subset(self)
1170
1171    def count_subset(
1172        self,
1173        divisor: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1174        /,
1175        *,
1176        empty_divisor: int | None = None
1177    ) -> 'icepool.Die[int] | MultisetFunctionRawResult[T, int]':
1178        """Evaluation: The number of times the divisor is contained in this multiset.
1179        
1180        Args:
1181            divisor: The multiset to divide by.
1182            empty_divisor: If the divisor is empty, the outcome will be this.
1183                If not set, `ZeroDivisionError` will be raised for an empty
1184                right side.
1185
1186        Raises:
1187            ZeroDivisionError: If the divisor may be empty and 
1188                `empty_divisor` is not set.
1189        """
1190        divisor = implicit_convert_to_expression(divisor)
1191        return icepool.evaluator.CountSubsetEvaluator(
1192            empty_divisor=empty_divisor).evaluate(self, divisor)
1193
1194    def largest_straight(
1195        self: 'MultisetExpression[int]'
1196    ) -> 'icepool.Die[int] | MultisetFunctionRawResult[int, int]':
1197        """Evaluation: The size of the largest straight among the elements.
1198
1199        Outcomes must be `int`s.
1200        """
1201        return icepool.evaluator.largest_straight_evaluator.evaluate(self)
1202
1203    def largest_straight_and_outcome(
1204        self: 'MultisetExpression[int]',
1205        priority: Literal['low', 'high'] = 'high',
1206        /
1207    ) -> 'icepool.Die[tuple[int, int]] | MultisetFunctionRawResult[int, tuple[int, int]]':
1208        """Evaluation: The size of the largest straight among the elements and the highest (optionally, lowest) outcome in that straight.
1209
1210        Straight size is prioritized first, then the outcome.
1211
1212        Outcomes must be `int`s.
1213
1214        Args:
1215            priority: Controls which outcome within the straight is returned,
1216                and which straight is picked if there is a tie for largest
1217                straight.
1218        """
1219        if priority == 'high':
1220            return icepool.evaluator.largest_straight_and_outcome_evaluator_high.evaluate(
1221                self)
1222        elif priority == 'low':
1223            return icepool.evaluator.largest_straight_and_outcome_evaluator_low.evaluate(
1224                self)
1225        else:
1226            raise ValueError("priority must be 'low' or 'high'.")
1227
1228    def all_straights(
1229        self: 'MultisetExpression[int]'
1230    ) -> 'icepool.Die[tuple[int, ...]] | MultisetFunctionRawResult[int, tuple[int, ...]]':
1231        """Evaluation: The sizes of all straights.
1232
1233        The sizes are in **descending** order.
1234
1235        Each element can only contribute to one straight, though duplicate
1236        elements can produces straights that overlap in outcomes. In this case,
1237        elements are preferentially assigned to the longer straight.
1238        """
1239        return icepool.evaluator.all_straights_evaluator.evaluate(self)
1240
1241    def all_straights_reduce_counts(
1242        self: 'MultisetExpression[int]',
1243        reducer: Callable[[int, int], int] = operator.mul
1244    ) -> 'icepool.Die[tuple[tuple[int, int], ...]] | MultisetFunctionRawResult[int, tuple[tuple[int, int], ...]]':
1245        """Experimental: All straights with a reduce operation on the counts.
1246
1247        This can be used to evaluate e.g. cribbage-style straight counting.
1248
1249        The result is a tuple of `(run_length, run_score)`s.
1250        """
1251        return icepool.evaluator.AllStraightsReduceCountsEvaluator(
1252            reducer=reducer).evaluate(self)
1253
1254    def argsort(
1255        self: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1256        *args: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1257        order: Order = Order.Descending,
1258        limit: int | None = None,
1259        limit_pad: int | None = None,
1260        tie: Literal['drop', 'left', 'right']
1261    ) -> 'icepool.Die[tuple[int, ...]] | MultisetFunctionRawResult[T, tuple[int, ...]]':
1262        """Experimental: Returns the indexes of the originating multisets for each rank in their additive union.
1263
1264        Args:
1265            self, *args: The multiset expressions to be evaluated.
1266            order: Which order the ranks are to be emitted in. Default is descending.
1267            limit: How many ranks to emit.
1268            limit_pad: If `limit` is also set and there are not enough elements 
1269                to reach it, the result is padded with `limit_pad`. The result
1270                will therefore always have length exactly equal to `limit`.
1271            tie: What to do with ties. Options are:
1272            * `'drop'`: Any outcomes existing in more than one multiset are
1273                dropped on a 1:1:1... basis until there are no more ties.
1274            * `'left'`: The leftmost multiset (lower indexes) is ranked first.
1275            * `'right'`: The rightmost multiset (higher indexes) is ranked
1276                first.
1277        """
1278        self = implicit_convert_to_expression(self)
1279        converted_args = [implicit_convert_to_expression(arg) for arg in args]
1280        if tie == 'drop':
1281            return icepool.evaluator.ArgsortEvaluatorDrop(
1282                order=order, limit=limit,
1283                limit_pad=limit_pad).evaluate(self, *converted_args)
1284        elif tie == 'left':
1285            return icepool.evaluator.ArgsortEvaluatorLeft(
1286                order=order, limit=limit,
1287                limit_pad=limit_pad).evaluate(self, *converted_args)
1288        elif tie == 'right':
1289            return icepool.evaluator.ArgsortEvaluatorRight(
1290                order=order, limit=limit,
1291                limit_pad=limit_pad).evaluate(self, *converted_args)
1292        raise ValueError('tie must be "drop", "left", or "right".')
1293
1294    def argsort_grouped(
1295        self: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1296        *args: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1297        order: Order = Order.Descending,
1298        limit: int | None = None
1299    ) -> 'icepool.Die[tuple[tuple[int, ...], ...]] | MultisetFunctionRawResult[T, tuple[tuple[int, ...], ...]]':
1300        """Experimental: Returns the indexes of the originating multisets for each rank in their additive union.
1301
1302        Example:
1303        ```python
1304        MultisetExpression.argsort([10, 9, 5], [9, 9])
1305        ```
1306        produces
1307        ```python
1308        ((0,), (0, 1, 1), (0,))
1309        ```
1310        
1311        Args:
1312            self, *args: The multiset expressions to be evaluated.
1313            order: Which order the ranks are to be emitted in. Default is descending.
1314            limit: How many ranks (unique input outcomes) to emit. Default will  
1315                emit all ranks, which makes the length of each outcome equal to
1316                `additive_union(+self, +arg1, +arg2, ...).unique().size()`
1317        """
1318        self = implicit_convert_to_expression(self)
1319        converted_args = [implicit_convert_to_expression(arg) for arg in args]
1320        return icepool.evaluator.ArgsortGroupedEvaluator(order=order,
1321                                                         limit=limit).evaluate(
1322                                                             self,
1323                                                             *converted_args)
1324
1325    # Comparators.
1326
1327    def _compare(
1328        self,
1329        right: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1330        operation_class: Type['icepool.evaluator.ComparisonEvaluator'],
1331        *,
1332        truth_value_callback: 'Callable[[], bool] | None' = None
1333    ) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1334        right = icepool.implicit_convert_to_expression(right)
1335
1336        if truth_value_callback is not None:
1337
1338            def data_callback() -> Counts[bool]:
1339                die = cast('icepool.Die[bool]',
1340                           operation_class().evaluate(self, right))
1341                if not isinstance(die, icepool.Die):
1342                    raise TypeError('Did not resolve to a die.')
1343                return die._data
1344
1345            return icepool.DieWithTruth(data_callback, truth_value_callback)
1346        else:
1347            return operation_class().evaluate(self, right)
1348
1349    def __lt__(self,
1350               other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1351               /) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1352        try:
1353            return self._compare(other,
1354                                 icepool.evaluator.IsProperSubsetEvaluator)
1355        except TypeError:
1356            return NotImplemented
1357
1358    def __le__(self,
1359               other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1360               /) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1361        try:
1362            return self._compare(other, icepool.evaluator.IsSubsetEvaluator)
1363        except TypeError:
1364            return NotImplemented
1365
1366    def issubset(
1367            self,
1368            other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1369            /) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1370        """Evaluation: Whether this multiset is a subset of the other multiset.
1371
1372        Specifically, if this multiset has a lesser or equal count for each
1373        outcome than the other multiset, this evaluates to `True`; 
1374        if there is some outcome for which this multiset has a greater count 
1375        than the other multiset, this evaluates to `False`.
1376
1377        `issubset` is the same as `self <= other`.
1378        
1379        `self < other` evaluates a proper subset relation, which is the same
1380        except the result is `False` if the two multisets are exactly equal.
1381        """
1382        return self._compare(other, icepool.evaluator.IsSubsetEvaluator)
1383
1384    def __gt__(self,
1385               other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1386               /) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1387        try:
1388            return self._compare(other,
1389                                 icepool.evaluator.IsProperSupersetEvaluator)
1390        except TypeError:
1391            return NotImplemented
1392
1393    def __ge__(self,
1394               other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1395               /) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1396        try:
1397            return self._compare(other, icepool.evaluator.IsSupersetEvaluator)
1398        except TypeError:
1399            return NotImplemented
1400
1401    def issuperset(
1402            self,
1403            other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1404            /) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1405        """Evaluation: Whether this multiset is a superset of the other multiset.
1406
1407        Specifically, if this multiset has a greater or equal count for each
1408        outcome than the other multiset, this evaluates to `True`; 
1409        if there is some  outcome for which this multiset has a lesser count 
1410        than the other multiset, this evaluates to `False`.
1411        
1412        A typical use of this evaluation is testing for the presence of a
1413        combo of cards in a hand, e.g.
1414
1415        ```python
1416        deck.deal(5) >= ['a', 'a', 'b']
1417        ```
1418
1419        represents the chance that a deal of 5 cards contains at least two 'a's
1420        and one 'b'.
1421
1422        `issuperset` is the same as `self >= other`.
1423
1424        `self > other` evaluates a proper superset relation, which is the same
1425        except the result is `False` if the two multisets are exactly equal.
1426        """
1427        return self._compare(other, icepool.evaluator.IsSupersetEvaluator)
1428
1429    def __eq__(  # type: ignore
1430            self,
1431            other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1432            /) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1433        try:
1434
1435            def truth_value_callback() -> bool:
1436                return self.equals(other)
1437
1438            return self._compare(other,
1439                                 icepool.evaluator.IsEqualSetEvaluator,
1440                                 truth_value_callback=truth_value_callback)
1441        except TypeError:
1442            return NotImplemented
1443
1444    def __ne__(  # type: ignore
1445            self,
1446            other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1447            /) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1448        try:
1449
1450            def truth_value_callback() -> bool:
1451                return not self.equals(other)
1452
1453            return self._compare(other,
1454                                 icepool.evaluator.IsNotEqualSetEvaluator,
1455                                 truth_value_callback=truth_value_callback)
1456        except TypeError:
1457            return NotImplemented
1458
1459    def isdisjoint(
1460            self,
1461            other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1462            /) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1463        """Evaluation: Whether this multiset is disjoint from the other multiset.
1464        
1465        Specifically, this evaluates to `False` if there is any outcome for
1466        which both multisets have positive count, and `True` if there is not.
1467
1468        Negative incoming counts are treated as zero counts.
1469        """
1470        return self._compare(other, icepool.evaluator.IsDisjointSetEvaluator)
1471
1472    # Lexicographic comparisons.
1473
1474    def leximin(
1475        self,
1476        comparison: Literal['==', '!=', '<=', '<', '>=', '>', 'cmp'],
1477        other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1478        /,
1479        extra: Literal['low', 'high', 'drop'] = 'high'
1480    ) -> 'icepool.Die[int] | MultisetFunctionRawResult[T, int]':
1481        """Evaluation: EXPERIMENTAL: Lexicographic comparison after sorting each multiset in ascending order.
1482
1483        Compares the lowest element of each multiset; if they are equal,
1484        compares the next-lowest element, and so on.
1485
1486        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
1487        gives an overview of several opposed dice pool mechanics, including this
1488        one.
1489        
1490        Args:
1491            comparison: The comparison to use.
1492            other: The multiset to compare to.
1493            extra: If one side has more elements than the other, how the extra
1494                elements are considered compared to their missing counterparts.
1495        """
1496        lexi_tuple = compute_lexi_tuple_with_extra(comparison, Order.Ascending,
1497                                                   extra)
1498        return icepool.evaluator.lexi_comparison_evaluator.evaluate(
1499            self,
1500            implicit_convert_to_expression(other),
1501            sort_order=Order.Ascending,
1502            lexi_tuple=lexi_tuple)
1503
1504    def leximax(
1505        self,
1506        comparison: Literal['==', '!=', '<=', '<', '>=', '>', 'cmp'],
1507        other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1508        /,
1509        extra: Literal['low', 'high', 'drop'] = 'high'
1510    ) -> 'icepool.Die[int] | MultisetFunctionRawResult[T, int]':
1511        """Evaluation: EXPERIMENTAL: Lexicographic comparison after sorting each multiset in descending order.
1512
1513        Compares the highest element of each multiset; if they are equal,
1514        compares the next-highest element, and so on.
1515
1516        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
1517        gives an overview of several opposed dice pool mechanics, including this
1518        one.
1519        
1520        Args:
1521            comparison: The comparison to use.
1522            other: The multiset to compare to.
1523            extra: If one side has more elements than the other, how the extra
1524                elements are considered compared to their missing counterparts.
1525        """
1526        lexi_tuple = compute_lexi_tuple_with_extra(comparison,
1527                                                   Order.Descending, extra)
1528        return icepool.evaluator.lexi_comparison_evaluator.evaluate(
1529            self,
1530            implicit_convert_to_expression(other),
1531            sort_order=Order.Descending,
1532            lexi_tuple=lexi_tuple)
1533
1534    # For helping debugging / testing.
1535    def force_order(self, force_order: Order) -> 'MultisetExpression[T]':
1536        """Forces outcomes to be seen by the evaluator in the given order.
1537
1538        This can be useful for debugging / testing.
1539        """
1540        if force_order == Order.Any:
1541            return self
1542        return icepool.operator.MultisetForceOrder(self,
1543                                                   force_order=force_order)

Abstract base class representing an expression that operates on single multisets.

There are three types of multiset expressions:

  • MultisetGenerator, which produce raw outcomes and counts.
  • MultisetOperator, which takes outcomes with one or more counts and produces a count.
  • MultisetVariable, which is a temporary placeholder for some other expression.

Expression methods can be applied to MultisetGenerators to do simple evaluations. For joint evaluations, try multiset_function.

Use the provided operations to build up more complicated expressions, or to attach a final evaluator.

Operations include:

Operation Count / notes
additive_union, + l + r
difference, - l - r
intersection, & min(l, r)
union, | max(l, r)
symmetric_difference, ^ abs(l - r)
multiply_counts, * count * n
divide_counts, // count // n
modulo_counts, % count % n
keep_counts count if count >= n else 0 etc.
unary + same as keep_counts('>=', 0)
unary - reverses the sign of all counts
unique min(count, n)
keep_outcomes count if outcome in t else 0
drop_outcomes count if outcome not in t else 0
map_counts f(outcome, *counts)
keep, [] less capable than KeepGenerator version
highest less capable than KeepGenerator version
lowest less capable than KeepGenerator version
Evaluator Summary
issubset, <= Whether the left side's counts are all <= their counterparts on the right
issuperset, >= Whether the left side's counts are all >= their counterparts on the right
isdisjoint Whether the left side has no positive counts in common with the right side
< As <=, but False if the two multisets are equal
> As >=, but False if the two multisets are equal
== Whether the left side has all the same counts as the right side
!= Whether the left side has any different counts to the right side
expand All elements in ascending order
sum Sum of all elements
size The number of elements
empty Whether all counts are zero
all_counts All counts in descending order
product_of_counts The product of all counts
highest_outcome_and_count The highest outcome and how many of that outcome
largest_count The single largest count, aka x-of-a-kind
largest_count_and_outcome Same but also with the corresponding outcome
count_subset, // The number of times the right side is contained in the left side
largest_straight Length of longest consecutive sequence
largest_straight_and_outcome Same but also with the corresponding outcome
all_straights Lengths of all consecutive sequences in descending order
def additive_union( *args: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T]) -> MultisetExpression[~T]:
169    def additive_union(
170        *args: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]'
171    ) -> 'MultisetExpression[T]':
172        """The combined elements from all of the multisets.
173
174        Specifically, the counts for each outcome will be summed across the
175        arguments.
176
177        Same as `a + b + c + ...`.
178
179        Example:
180        ```python
181        [1, 2, 2, 3] + [1, 2, 4] -> [1, 1, 2, 2, 2, 3, 4]
182        ```
183        """
184        expressions = tuple(
185            implicit_convert_to_expression(arg) for arg in args)
186        return icepool.operator.MultisetAdditiveUnion(*expressions)

The combined elements from all of the multisets.

Specifically, the counts for each outcome will be summed across the arguments.

Same as a + b + c + ....

Example:

[1, 2, 2, 3] + [1, 2, 4] -> [1, 1, 2, 2, 2, 3, 4]
def difference( *args: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T], keep_negative_counts: bool = False) -> MultisetExpression[~T]:
205    def difference(
206            *args: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
207            keep_negative_counts: bool = False) -> 'MultisetExpression[T]':
208        """The elements from the left multiset that are not in any of the others.
209
210        Specifically, for each outcome, the count of that outcome is that of
211        the leftmost argument minus the counts from all other arguments.
212        By default, if the result would be negative, it is set to zero.
213
214        Same as `a - b - c - ...`.
215
216        Example:
217        ```python
218        [1, 2, 2, 3] - [1, 2, 4] -> [2, 3]
219        ```
220
221        If no arguments are given, the result will be an empty multiset, i.e.
222        all zero counts.
223
224        Aas a multiset operation, this will only cancel elements 1:1.
225        If you want to drop all elements in a set of outcomes regardless of
226        count, either use `drop_outcomes()` instead, or use a large number of
227        counts on the right side.
228
229        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
230        gives an overview of several opposed dice pool mechanics, including this
231        one.
232
233        Args:
234            *args: All but the leftmost argument will subtract their counts
235                from the leftmost argument.
236            keep_negative_counts: If set (default False), negative resultig 
237                counts will be preserved.
238        """
239        expressions = tuple(
240            implicit_convert_to_expression(arg) for arg in args)
241        if keep_negative_counts:
242            return icepool.operator.MultisetDifferenceKeepNegative(
243                *expressions)
244        else:
245            return icepool.operator.MultisetDifferenceDropNegative(
246                *expressions)

The elements from the left multiset that are not in any of the others.

Specifically, for each outcome, the count of that outcome is that of the leftmost argument minus the counts from all other arguments. By default, if the result would be negative, it is set to zero.

Same as a - b - c - ....

Example:

[1, 2, 2, 3] - [1, 2, 4] -> [2, 3]

If no arguments are given, the result will be an empty multiset, i.e. all zero counts.

Aas a multiset operation, this will only cancel elements 1:1. If you want to drop all elements in a set of outcomes regardless of count, either use drop_outcomes() instead, or use a large number of counts on the right side.

This infographic gives an overview of several opposed dice pool mechanics, including this one.

Arguments:
  • *args: All but the leftmost argument will subtract their counts from the leftmost argument.
  • keep_negative_counts: If set (default False), negative resultig counts will be preserved.
def intersection( *args: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T]) -> MultisetExpression[~T]:
265    def intersection(
266        *args: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]'
267    ) -> 'MultisetExpression[T]':
268        """The elements that all the multisets have in common.
269
270        Specifically, the count for each outcome is the minimum count among the
271        arguments.
272
273        Same as `a & b & c & ...`.
274
275        Example:
276        ```python
277        [1, 2, 2, 3] & [1, 2, 4] -> [1, 2]
278        ```
279
280        As a multiset operation, this will only intersect elements 1:1.
281        If you want to keep all elements in a set of outcomes regardless of
282        count, either use `keep_outcomes()` instead, or use a large number of
283        counts on the right side.
284        """
285        expressions = tuple(
286            implicit_convert_to_expression(arg) for arg in args)
287        return icepool.operator.MultisetIntersection(*expressions)

The elements that all the multisets have in common.

Specifically, the count for each outcome is the minimum count among the arguments.

Same as a & b & c & ....

Example:

[1, 2, 2, 3] & [1, 2, 4] -> [1, 2]

As a multiset operation, this will only intersect elements 1:1. If you want to keep all elements in a set of outcomes regardless of count, either use keep_outcomes() instead, or use a large number of counts on the right side.

def union( *args: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T]) -> MultisetExpression[~T]:
305    def union(
306        *args: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]'
307    ) -> 'MultisetExpression[T]':
308        """The most of each outcome that appear in any of the multisets.
309
310        Specifically, the count for each outcome is the maximum count among the
311        arguments.
312
313        Same as `a | b | c | ...`.
314
315        Example:
316        ```python
317        [1, 2, 2, 3] | [1, 2, 4] -> [1, 2, 2, 3, 4]
318        ```
319        """
320        expressions = tuple(
321            implicit_convert_to_expression(arg) for arg in args)
322        return icepool.operator.MultisetUnion(*expressions)

The most of each outcome that appear in any of the multisets.

Specifically, the count for each outcome is the maximum count among the arguments.

Same as a | b | c | ....

Example:

[1, 2, 2, 3] | [1, 2, 4] -> [1, 2, 2, 3, 4]
def symmetric_difference( self, other: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T], /) -> MultisetExpression[~T]:
342    def symmetric_difference(
343            self,
344            other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
345            /) -> 'MultisetExpression[T]':
346        """The elements that appear in the left or right multiset but not both.
347
348        Specifically, the count for each outcome is the absolute difference
349        between the counts from the two arguments.
350
351        Same as `a ^ b`.
352
353        Specifically, this produces the absolute difference between counts.
354        If you don't want negative counts to be used from the inputs, you can
355        do `+left ^ +right`.
356
357        Example:
358        ```python
359        [1, 2, 2, 3] ^ [1, 2, 4] -> [2, 3, 4]
360        ```
361        """
362        return icepool.operator.MultisetSymmetricDifference(
363            self, implicit_convert_to_expression(other))

The elements that appear in the left or right multiset but not both.

Specifically, the count for each outcome is the absolute difference between the counts from the two arguments.

Same as a ^ b.

Specifically, this produces the absolute difference between counts. If you don't want negative counts to be used from the inputs, you can do +left ^ +right.

Example:

[1, 2, 2, 3] ^ [1, 2, 4] -> [2, 3, 4]
def keep_outcomes( self, outcomes: Callable[[~T], bool] | Collection[~T] | MultisetExpression[~T], /) -> MultisetExpression[~T]:
365    def keep_outcomes(
366            self, outcomes:
367        'Callable[[T], bool] | Collection[T] | MultisetExpression[T]',
368            /) -> 'MultisetExpression[T]':
369        """Keeps the designated outcomes, and drops the rest by setting their counts to zero.
370
371        This is similar to `intersection()`, except the right side is considered
372        to have unlimited multiplicity.
373
374        Args:
375            outcomes: A callable returning `True` iff the outcome should be kept,
376                or an expression or collection of outcomes to keep.
377        """
378        if isinstance(outcomes, MultisetExpression):
379            return icepool.operator.MultisetFilterOutcomesBinary(
380                self, outcomes)
381        else:
382            return icepool.operator.MultisetFilterOutcomes(self,
383                                                           outcomes=outcomes)

Keeps the designated outcomes, and drops the rest by setting their counts to zero.

This is similar to intersection(), except the right side is considered to have unlimited multiplicity.

Arguments:
  • outcomes: A callable returning True iff the outcome should be kept, or an expression or collection of outcomes to keep.
def drop_outcomes( self, outcomes: Callable[[~T], bool] | Collection[~T] | MultisetExpression[~T], /) -> MultisetExpression[~T]:
385    def drop_outcomes(
386            self, outcomes:
387        'Callable[[T], bool] | Collection[T] | MultisetExpression[T]',
388            /) -> 'MultisetExpression[T]':
389        """Drops the designated outcomes by setting their counts to zero, and keeps the rest.
390
391        This is similar to `difference()`, except the right side is considered
392        to have unlimited multiplicity.
393
394        Args:
395            outcomes: A callable returning `True` iff the outcome should be
396                dropped, or an expression or collection of outcomes to drop.
397        """
398        if isinstance(outcomes, MultisetExpression):
399            return icepool.operator.MultisetFilterOutcomesBinary(self,
400                                                                 outcomes,
401                                                                 invert=True)
402        else:
403            return icepool.operator.MultisetFilterOutcomes(self,
404                                                           outcomes=outcomes,
405                                                           invert=True)

Drops the designated outcomes by setting their counts to zero, and keeps the rest.

This is similar to difference(), except the right side is considered to have unlimited multiplicity.

Arguments:
  • outcomes: A callable returning True iff the outcome should be dropped, or an expression or collection of outcomes to drop.
def map_counts( *args: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T], function: Callable[..., int]) -> MultisetExpression[~T]:
409    def map_counts(*args:
410                   'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
411                   function: Callable[..., int]) -> 'MultisetExpression[T]':
412        """Maps the counts to new counts.
413
414        Args:
415            function: A function that takes `outcome, *counts` and produces a
416                combined count.
417        """
418        expressions = tuple(
419            implicit_convert_to_expression(arg) for arg in args)
420        return icepool.operator.MultisetMapCounts(*expressions,
421                                                  function=function)

Maps the counts to new counts.

Arguments:
  • function: A function that takes outcome, *counts and produces a combined count.
def multiply_counts( self, n: int, /) -> MultisetExpression[~T]:
434    def multiply_counts(self, n: int, /) -> 'MultisetExpression[T]':
435        """Multiplies all counts by n.
436
437        Same as `self * n`.
438
439        Example:
440        ```python
441        Pool([1, 2, 2, 3]) * 2 -> [1, 1, 2, 2, 2, 2, 3, 3]
442        ```
443        """
444        return icepool.operator.MultisetMultiplyCounts(self, constant=n)

Multiplies all counts by n.

Same as self * n.

Example:

Pool([1, 2, 2, 3]) * 2 -> [1, 1, 2, 2, 2, 2, 3, 3]
def divide_counts( self, n: int, /) -> MultisetExpression[~T]:
472    def divide_counts(self, n: int, /) -> 'MultisetExpression[T]':
473        """Divides all counts by n (rounding down).
474
475        Same as `self // n`.
476
477        Example:
478        ```python
479        Pool([1, 2, 2, 3]) // 2 -> [2]
480        ```
481        """
482        return icepool.operator.MultisetFloordivCounts(self, constant=n)

Divides all counts by n (rounding down).

Same as self // n.

Example:

Pool([1, 2, 2, 3]) // 2 -> [2]
def modulo_counts( self, n: int, /) -> MultisetExpression[~T]:
489    def modulo_counts(self, n: int, /) -> 'MultisetExpression[T]':
490        """Moduos all counts by n.
491
492        Same as `self % n`.
493
494        Example:
495        ```python
496        Pool([1, 2, 2, 3]) % 2 -> [1, 3]
497        ```
498        """
499        return self % n

Moduos all counts by n.

Same as self % n.

Example:

Pool([1, 2, 2, 3]) % 2 -> [1, 3]
def keep_counts( self, comparison: Literal['==', '!=', '<=', '<', '>=', '>'], n: int, /) -> MultisetExpression[~T]:
511    def keep_counts(self, comparison: Literal['==', '!=', '<=', '<', '>=',
512                                              '>'], n: int,
513                    /) -> 'MultisetExpression[T]':
514        """Keeps counts fitting the comparison, treating the rest as zero.
515
516        For example, `expression.keep_counts('>=', 2)` would keep pairs,
517        triplets, etc. and drop singles.
518
519        ```python
520        Pool([1, 2, 2, 3, 3, 3]).keep_counts('>=', 2) -> [2, 2, 3, 3, 3]
521        ```
522        
523        Args:
524            comparison: The comparison to use.
525            n: The number to compare counts against.
526        """
527        return icepool.operator.MultisetKeepCounts(self,
528                                                   comparison=comparison,
529                                                   constant=n)

Keeps counts fitting the comparison, treating the rest as zero.

For example, expression.keep_counts('>=', 2) would keep pairs, triplets, etc. and drop singles.

Pool([1, 2, 2, 3, 3, 3]).keep_counts('>=', 2) -> [2, 2, 3, 3, 3]
Arguments:
  • comparison: The comparison to use.
  • n: The number to compare counts against.
def unique( self, n: int = 1, /) -> MultisetExpression[~T]:
531    def unique(self, n: int = 1, /) -> 'MultisetExpression[T]':
532        """Counts each outcome at most `n` times.
533
534        For example, `generator.unique(2)` would count each outcome at most
535        twice.
536
537        Example:
538        ```python
539        Pool([1, 2, 2, 3]).unique() -> [1, 2, 3]
540        ```
541        """
542        return icepool.operator.MultisetUnique(self, constant=n)

Counts each outcome at most n times.

For example, generator.unique(2) would count each outcome at most twice.

Example:

Pool([1, 2, 2, 3]).unique() -> [1, 2, 3]
def keep( self, index: slice | Sequence[int | ellipsis] | int) -> MultisetExpression[~T] | Die[~T] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, ~T]:
557    def keep(
558        self, index: slice | Sequence[int | EllipsisType] | int
559    ) -> 'MultisetExpression[T] | icepool.Die[T] | MultisetFunctionRawResult[T, T]':
560        """Selects elements after drawing and sorting.
561
562        This is less capable than the `KeepGenerator` version.
563        In particular, it does not know how many elements it is selecting from,
564        so it must be anchored at the starting end. The advantage is that it
565        can be applied to any expression.
566
567        The valid types of argument are:
568
569        * A `slice`. If both start and stop are provided, they must both be
570            non-negative or both be negative. step is not supported.
571        * A sequence of `int` with `...` (`Ellipsis`) at exactly one end.
572            Each sorted element will be counted that many times, with the
573            `Ellipsis` treated as enough zeros (possibly "negative") to
574            fill the rest of the elements.
575        * An `int`, which evaluates by taking the element at the specified
576            index. In this case the result is a `Die`.
577
578        Negative incoming counts are treated as zero counts.
579
580        Use the `[]` operator for the same effect as this method.
581        """
582        if isinstance(index, int):
583            return icepool.evaluator.keep_evaluator.evaluate(self, index=index)
584        else:
585            return icepool.operator.MultisetKeep(self, index=index)

Selects elements after drawing and sorting.

This is less capable than the KeepGenerator version. In particular, it does not know how many elements it is selecting from, so it must be anchored at the starting end. The advantage is that it can be applied to any expression.

The valid types of argument are:

  • A slice. If both start and stop are provided, they must both be non-negative or both be negative. step is not supported.
  • A sequence of int with ... (Ellipsis) at exactly one end. Each sorted element will be counted that many times, with the Ellipsis treated as enough zeros (possibly "negative") to fill the rest of the elements.
  • An int, which evaluates by taking the element at the specified index. In this case the result is a Die.

Negative incoming counts are treated as zero counts.

Use the [] operator for the same effect as this method.

def lowest( self, keep: int | None = None, drop: int | None = None) -> MultisetExpression[~T]:
604    def lowest(self,
605               keep: int | None = None,
606               drop: int | None = None) -> 'MultisetExpression[T]':
607        """Keep some of the lowest elements from this multiset and drop the rest.
608
609        In contrast to the die and free function versions, this does not
610        automatically sum the dice. Use `.sum()` afterwards if you want to sum.
611        Alternatively, you can perform some other evaluation.
612
613        This requires the outcomes to be evaluated in ascending order.
614
615        Args:
616            keep, drop: These arguments work together:
617                * If neither are provided, the single lowest element
618                    will be kept.
619                * If only `keep` is provided, the `keep` lowest elements
620                    will be kept.
621                * If only `drop` is provided, the `drop` lowest elements
622                    will be dropped and the rest will be kept.
623                * If both are provided, `drop` lowest elements will be dropped,
624                    then the next `keep` lowest elements will be kept.
625        """
626        index = lowest_slice(keep, drop)
627        return self.keep(index)

Keep some of the lowest elements from this multiset and drop the rest.

In contrast to the die and free function versions, this does not automatically sum the dice. Use .sum() afterwards if you want to sum. Alternatively, you can perform some other evaluation.

This requires the outcomes to be evaluated in ascending order.

Arguments:
  • keep, drop: These arguments work together:
    • If neither are provided, the single lowest element will be kept.
    • If only keep is provided, the keep lowest elements will be kept.
    • If only drop is provided, the drop lowest elements will be dropped and the rest will be kept.
    • If both are provided, drop lowest elements will be dropped, then the next keep lowest elements will be kept.
def highest( self, keep: int | None = None, drop: int | None = None) -> MultisetExpression[~T]:
629    def highest(self,
630                keep: int | None = None,
631                drop: int | None = None) -> 'MultisetExpression[T]':
632        """Keep some of the highest elements from this multiset and drop the rest.
633
634        In contrast to the die and free function versions, this does not
635        automatically sum the dice. Use `.sum()` afterwards if you want to sum.
636        Alternatively, you can perform some other evaluation.
637
638        This requires the outcomes to be evaluated in descending order.
639
640        Args:
641            keep, drop: These arguments work together:
642                * If neither are provided, the single highest element
643                    will be kept.
644                * If only `keep` is provided, the `keep` highest elements
645                    will be kept.
646                * If only `drop` is provided, the `drop` highest elements
647                    will be dropped and the rest will be kept.
648                * If both are provided, `drop` highest elements will be dropped, 
649                    then the next `keep` highest elements will be kept.
650        """
651        index = highest_slice(keep, drop)
652        return self.keep(index)

Keep some of the highest elements from this multiset and drop the rest.

In contrast to the die and free function versions, this does not automatically sum the dice. Use .sum() afterwards if you want to sum. Alternatively, you can perform some other evaluation.

This requires the outcomes to be evaluated in descending order.

Arguments:
  • keep, drop: These arguments work together:
    • If neither are provided, the single highest element will be kept.
    • If only keep is provided, the keep highest elements will be kept.
    • If only drop is provided, the drop highest elements will be dropped and the rest will be kept.
    • If both are provided, drop highest elements will be dropped, then the next keep highest elements will be kept.
def sort_pair( self, comparison: Literal['==', '!=', '<=', '<', '>=', '>'], other: MultisetExpression[~T], /, order: Order = <Order.Descending: -1>, extra: Literal['early', 'late', 'low', 'high', 'equal', 'keep', 'drop'] = 'drop') -> MultisetExpression[~T]:
656    def sort_pair(
657        self,
658        comparison: Literal['==', '!=', '<=', '<', '>=', '>'],
659        other: 'MultisetExpression[T]',
660        /,
661        order: Order = Order.Descending,
662        extra: Literal['early', 'late', 'low', 'high', 'equal', 'keep',
663                       'drop'] = 'drop'
664    ) -> 'MultisetExpression[T]':
665        """EXPERIMENTAL: Sort `self` and `other` and make pairs of one element from each, then keep the elements from `self` from each pair that fit the given comparision.
666
667        Example: An attacker rolls 3d6 versus a defender's 2d6 in the game of
668        *RISK*. Which pairs did the attacker win?
669        ```python
670        d6.pool(3).highest(2).sort_pair('>', d6.pool(2))
671        ```
672        
673        Suppose the attacker rolled 6, 4, 3 and the defender 5, 5.
674        In this case the 4 would be blocked since the attacker lost that pair,
675        leaving the attacker's 6. If you want to keep the extra element (3), you 
676        can use the `extra` parameter.
677        ```python
678
679        Pool([6, 4, 3]).sort_pair('>', [5, 5]) -> [6]
680        Pool([6, 4, 3]).sort_pair('>', [5, 5], extra='keep') -> [6, 3]
681        ```
682
683        Contrast `max_pair_keep()` and `max_pair_drop()`, which first 
684        create the maximum number of pairs that fit the comparison, not
685        necessarily in sorted order.
686        In the above example, `max_pair()` would allow the defender to
687        assign their 5s to block both the 4 and the 3.
688
689        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
690        gives an overview of several opposed dice pool mechanics, including this
691        one.
692
693        This is not designed for use with negative counts.
694        
695        Args:
696            comparison: The comparison to filter by. If you want to drop rather
697                than keep, use the complementary comparison:
698                * `'=='` vs. `'!='`
699                * `'<='` vs. `'>'`
700                * `'>='` vs. `'<'`
701            other: The other multiset to pair elements with.
702            order: The order in which to sort before forming pairs.
703                Default is descending.
704            extra: If the left operand has more elements than the right
705                operand, this determines what is done with the extra elements.
706                The default is `'drop'`.
707                * `'early'`, `'late'`: The extra elements are considered to   
708                    occur earlier or later in `order` than their missing
709                    counterparts.
710                * `'low'`, `'high'`, `'equal'`: The extra elements are 
711                    considered to be lower, higher, or equal to their missing
712                    counterparts.
713                * `'keep'`, `'drop'`: The extra elements are always kept or 
714                    dropped.
715        """
716        other = implicit_convert_to_expression(other)
717
718        return icepool.operator.MultisetSortPair(self,
719                                                 other,
720                                                 comparison=comparison,
721                                                 sort_order=order,
722                                                 extra=extra)

EXPERIMENTAL: Sort self and other and make pairs of one element from each, then keep the elements from self from each pair that fit the given comparision.

Example: An attacker rolls 3d6 versus a defender's 2d6 in the game of RISK. Which pairs did the attacker win?

d6.pool(3).highest(2).sort_pair('>', d6.pool(2))

Suppose the attacker rolled 6, 4, 3 and the defender 5, 5. In this case the 4 would be blocked since the attacker lost that pair, leaving the attacker's 6. If you want to keep the extra element (3), you can use the extra parameter.

Pool([6, 4, 3]).sort_pair('>', [5, 5]) -> [6]
Pool([6, 4, 3]).sort_pair('>', [5, 5], extra='keep') -> [6, 3]

Contrast max_pair_keep() and max_pair_drop(), which first create the maximum number of pairs that fit the comparison, not necessarily in sorted order. In the above example, max_pair() would allow the defender to assign their 5s to block both the 4 and the 3.

This infographic gives an overview of several opposed dice pool mechanics, including this one.

This is not designed for use with negative counts.

Arguments:
  • comparison: The comparison to filter by. If you want to drop rather than keep, use the complementary comparison:
    • '==' vs. '!='
    • '<=' vs. '>'
    • '>=' vs. '<'
  • other: The other multiset to pair elements with.
  • order: The order in which to sort before forming pairs. Default is descending.
  • extra: If the left operand has more elements than the right operand, this determines what is done with the extra elements. The default is 'drop'.
    • 'early', 'late': The extra elements are considered to
      occur earlier or later in order than their missing counterparts.
    • 'low', 'high', 'equal': The extra elements are considered to be lower, higher, or equal to their missing counterparts.
    • 'keep', 'drop': The extra elements are always kept or dropped.
def sort_pair_keep_while( self, comparison: Literal['==', '!=', '<=', '<', '>=', '>'], other: MultisetExpression[~T], /, order: Order = <Order.Descending: -1>, extra: Literal['early', 'late', 'low', 'high', 'equal', 'continue', 'break'] = 'break'):
724    def sort_pair_keep_while(self,
725                             comparison: Literal['==', '!=', '<=', '<', '>=',
726                                                 '>'],
727                             other: 'MultisetExpression[T]',
728                             /,
729                             order: Order = Order.Descending,
730                             extra: Literal['early', 'late', 'low', 'high',
731                                            'equal', 'continue',
732                                            'break'] = 'break'):
733        """EXPERIMENTAL: Sort `self` and `other` and make pairs of one element from each, then go through the pairs and keep elements from `self` while the `comparison` holds, dropping the rest.
734
735        This is not designed for use with negative counts.
736
737        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
738        gives an overview of several opposed dice pool mechanics, including this
739        one.
740
741        Args:
742            comparison: The comparison for which to continue the "while".
743            other: The other multiset to pair elements with.
744            order: The order in which to sort before forming pairs.
745                Default is descending.
746            extra: If the left operand has more elements than the right
747                operand, this determines what is done with the extra elements.
748                The default is `'break'`.
749                * `'early'`, `'late'`: The extra elements are considered to   
750                    occur earlier or later in `order` than their missing
751                    counterparts.
752                * `'low'`, `'high'`, `'equal'`: The extra elements are 
753                    considered to be lower, higher, or equal to their missing
754                    counterparts.
755                * `'continue'`, `'break'`: If the "while" still holds upon 
756                    reaching the extra elements, whether those elements
757                    continue to be kept.
758        """
759        other = implicit_convert_to_expression(other)
760        return icepool.operator.MultisetSortPairWhile(self,
761                                                      other,
762                                                      keep=True,
763                                                      comparison=comparison,
764                                                      sort_order=order,
765                                                      extra=extra)

EXPERIMENTAL: Sort self and other and make pairs of one element from each, then go through the pairs and keep elements from self while the comparison holds, dropping the rest.

This is not designed for use with negative counts.

This infographic gives an overview of several opposed dice pool mechanics, including this one.

Arguments:
  • comparison: The comparison for which to continue the "while".
  • other: The other multiset to pair elements with.
  • order: The order in which to sort before forming pairs. Default is descending.
  • extra: If the left operand has more elements than the right operand, this determines what is done with the extra elements. The default is 'break'.
    • 'early', 'late': The extra elements are considered to
      occur earlier or later in order than their missing counterparts.
    • 'low', 'high', 'equal': The extra elements are considered to be lower, higher, or equal to their missing counterparts.
    • 'continue', 'break': If the "while" still holds upon reaching the extra elements, whether those elements continue to be kept.
def sort_pair_drop_while( self, comparison: Literal['==', '!=', '<=', '<', '>=', '>'], other: MultisetExpression[~T], /, order: Order = <Order.Descending: -1>, extra: Literal['early', 'late', 'low', 'high', 'equal', 'continue', 'break'] = 'break'):
767    def sort_pair_drop_while(self,
768                             comparison: Literal['==', '!=', '<=', '<', '>=',
769                                                 '>'],
770                             other: 'MultisetExpression[T]',
771                             /,
772                             order: Order = Order.Descending,
773                             extra: Literal['early', 'late', 'low', 'high',
774                                            'equal', 'continue',
775                                            'break'] = 'break'):
776        """EXPERIMENTAL: Sort `self` and `other` and make pairs of one element from each, then go through the pairs and drop elements from `self` while the `comparison` holds, keeping the rest.
777
778        This is not designed for use with negative counts.
779
780        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
781        gives an overview of several opposed dice pool mechanics, including this
782        one.
783
784        Args:
785            comparison: The comparison for which to continue the "while".
786            other: The other multiset to pair elements with.
787            order: The order in which to sort before forming pairs.
788                Default is descending.
789            extra: If the left operand has more elements than the right
790                operand, this determines what is done with the extra elements.
791                The default is `'break'`.
792                * `'early'`, `'late'`: The extra elements are considered to   
793                    occur earlier or later in `order` than their missing
794                    counterparts.
795                * `'low'`, `'high'`, `'equal'`: The extra elements are 
796                    considered to be lower, higher, or equal to their missing
797                    counterparts.
798                * `'continue'`, `'break'`: If the "while" still holds upon 
799                    reaching the extra elements, whether those elements
800                    continue to be dropped.
801        """
802        other = implicit_convert_to_expression(other)
803        return icepool.operator.MultisetSortPairWhile(self,
804                                                      other,
805                                                      keep=False,
806                                                      comparison=comparison,
807                                                      sort_order=order,
808                                                      extra=extra)

EXPERIMENTAL: Sort self and other and make pairs of one element from each, then go through the pairs and drop elements from self while the comparison holds, keeping the rest.

This is not designed for use with negative counts.

This infographic gives an overview of several opposed dice pool mechanics, including this one.

Arguments:
  • comparison: The comparison for which to continue the "while".
  • other: The other multiset to pair elements with.
  • order: The order in which to sort before forming pairs. Default is descending.
  • extra: If the left operand has more elements than the right operand, this determines what is done with the extra elements. The default is 'break'.
    • 'early', 'late': The extra elements are considered to
      occur earlier or later in order than their missing counterparts.
    • 'low', 'high', 'equal': The extra elements are considered to be lower, higher, or equal to their missing counterparts.
    • 'continue', 'break': If the "while" still holds upon reaching the extra elements, whether those elements continue to be dropped.
def max_pair_keep( self, comparison: Literal['==', '<=', '<', '>=', '>'], other: MultisetExpression[~T], priority: Literal['low', 'high'] | None = None, /) -> MultisetExpression[~T]:
810    def max_pair_keep(self,
811                      comparison: Literal['==', '<=', '<', '>=', '>'],
812                      other: 'MultisetExpression[T]',
813                      priority: Literal['low', 'high'] | None = None,
814                      /) -> 'MultisetExpression[T]':
815        """EXPERIMENTAL: Form as many pairs of elements between `self` and `other` fitting the comparison, then keep the paired elements from `self`.
816
817        This pairs elements of `self` with elements of `other`, such that in
818        each pair the element from `self` fits the `comparison` with the
819        element from `other`. As many such pairs of elements will be created as 
820        possible, prioritizing either the lowest or highest possible elements.
821        Finally, the paired elements from `self` are kept, dropping the rest.
822
823        This requires that outcomes be evaluated in descending order if
824        prioritizing high elements, or ascending order if prioritizing low
825        elements.
826
827        This is not designed for use with negative counts.
828
829        Example: An attacker rolls a pool of 4d6 and a defender rolls a pool of 
830        3d6. Defender dice can be used to block attacker dice of equal or lesser
831        value, and the defender prefers to block the highest attacker dice
832        possible. Which attacker dice were blocked?
833        ```python
834        d6.pool(4).max_pair_keep('<=', d6.pool(3), 'high').sum()
835        ```
836
837        Suppose the attacker rolls 6, 4, 3, 1 and the defender rolls 5, 5.
838        Then the result would be [4, 3].
839        ```python
840        Pool([6, 4, 3, 1]).max_pair('<=', [5, 5], 'high')
841        -> [4, 3]
842        ```
843
844        The complement of this is `max_pair_drop`, which drops the paired
845        elements from `self` and keeps the rest.
846
847        Contrast `sort_pair()`, which first creates pairs in
848        sorted order and then filters them by `comparison`.
849        In the above example, `sort_pair()` would force the defender to pair
850        against the 6 and the 4, which would only allow them to block the 4
851        and let the 6, 3, and 1 through.
852
853        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
854        gives an overview of several opposed dice pool mechanics, including this
855        one.
856
857        Args:
858            comparison: The comparison that the pairs must satisfy.
859                `'=='` is the same as `+self & +other`.
860            other: The other multiset to pair elements with.
861            priority: Optional paramter to prioritize pairing `'low'` or
862                `'high'` elements. Note that this does not change the number of
863                elements that are paired.
864        """
865        other = implicit_convert_to_expression(other)
866        if comparison == '==':
867            return +self & +other
868
869        cls: Type[icepool.operator.MultisetMaxPairLate] | Type[
870            icepool.operator.MultisetMaxPairEarly]
871
872        if priority is None:
873            order = Order.Ascending
874            left_first, tie, _ = compute_lexi_tuple(comparison, order)
875            if left_first:
876                order = Order.Descending
877            cls = icepool.operator.MultisetMaxPairLate
878        else:
879            match priority:
880                case 'low':
881                    order = Order.Ascending
882                case 'high':
883                    order = Order.Descending
884                case _:
885                    raise ValueError("priority must be 'low' or 'high'.")
886
887            left_first, tie, _ = compute_lexi_tuple(comparison, order)
888
889            if left_first:
890                cls = icepool.operator.MultisetMaxPairEarly
891            else:
892                cls = icepool.operator.MultisetMaxPairLate
893
894        return cls(self,
895                   other,
896                   order=order,
897                   pair_equal=cast(bool, tie),
898                   keep=True)

EXPERIMENTAL: Form as many pairs of elements between self and other fitting the comparison, then keep the paired elements from self.

This pairs elements of self with elements of other, such that in each pair the element from self fits the comparison with the element from other. As many such pairs of elements will be created as possible, prioritizing either the lowest or highest possible elements. Finally, the paired elements from self are kept, dropping the rest.

This requires that outcomes be evaluated in descending order if prioritizing high elements, or ascending order if prioritizing low elements.

This is not designed for use with negative counts.

Example: An attacker rolls a pool of 4d6 and a defender rolls a pool of 3d6. Defender dice can be used to block attacker dice of equal or lesser value, and the defender prefers to block the highest attacker dice possible. Which attacker dice were blocked?

d6.pool(4).max_pair_keep('<=', d6.pool(3), 'high').sum()

Suppose the attacker rolls 6, 4, 3, 1 and the defender rolls 5, 5. Then the result would be [4, 3].

Pool([6, 4, 3, 1]).max_pair('<=', [5, 5], 'high')
-> [4, 3]

The complement of this is max_pair_drop, which drops the paired elements from self and keeps the rest.

Contrast sort_pair(), which first creates pairs in sorted order and then filters them by comparison. In the above example, sort_pair() would force the defender to pair against the 6 and the 4, which would only allow them to block the 4 and let the 6, 3, and 1 through.

This infographic gives an overview of several opposed dice pool mechanics, including this one.

Arguments:
  • comparison: The comparison that the pairs must satisfy. '==' is the same as +self & +other.
  • other: The other multiset to pair elements with.
  • priority: Optional paramter to prioritize pairing 'low' or 'high' elements. Note that this does not change the number of elements that are paired.
def max_pair_drop( self, comparison: Literal['==', '<=', '<', '>=', '>'], other: MultisetExpression[~T], priority: Literal['low', 'high'] | None = None, /) -> MultisetExpression[~T]:
900    def max_pair_drop(self,
901                      comparison: Literal['==', '<=', '<', '>=', '>'],
902                      other: 'MultisetExpression[T]',
903                      priority: Literal['low', 'high'] | None = None,
904                      /) -> 'MultisetExpression[T]':
905        """EXPERIMENTAL: Form as many pairs of elements between `self` and `other` fitting the comparison, then drop the paired elements from `self`.
906
907        This pairs elements of `self` with elements of `other`, such that in
908        each pair the element from `self` fits the `comparison` with the
909        element from `other`. As many such pairs of elements will be created as 
910        possible, prioritizing either the lowest or highest possible elements.
911        Finally, the paired elements from `self` are dropped, keeping the rest.
912
913        This requires that outcomes be evaluated in descending order if
914        prioritizing high elements, or ascending order if prioritizing low
915        elements.
916
917        This is not designed for use with negative counts.
918
919        Example: An attacker rolls a pool of 4d6 and a defender rolls a pool of 
920        3d6. Defender dice can be used to block attacker dice of equal or lesser
921        value, and the defender prefers to block the highest attacker dice
922        possible. Which attacker dice were NOT blocked?
923        ```python
924        d6.pool(4).max_pair_drop('<=', d6.pool(3), 'high').sum()
925        ```
926
927        Suppose the attacker rolls 6, 4, 3, 1 and the defender rolls 5, 5.
928        Then the result would be [4, 3].
929        ```python
930        Pool([6, 4, 3, 1]).max_pair_drop('<=', [5, 5], 'high')
931        -> [6, 1]
932        ```
933
934        The complement of this is `max_pair_keep`, which keeps the paired
935        elements from `self` and drops the rest.
936
937        Contrast `sort_pair()`, which first creates pairs in
938        sorted order and then filters them by `comparison`.
939        In the above example, `sort_pair()` would force the defender to pair
940        against the 6 and the 4, which would only allow them to block the 4
941        and let the 6, 3, and 1 through.
942
943        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
944        gives an overview of several opposed dice pool mechanics, including this
945        one.
946
947        Args:
948            comparison: The comparison that the pairs must satisfy.
949                `'=='` is the same as `self - other`.
950            other: The other multiset to pair elements with.
951            priority: Optional paramter to prioritize pairing `'low'` or
952                `'high'` elements. Note that this does not change the number of
953                elements that are paired.
954        """
955        other = implicit_convert_to_expression(other)
956        if comparison == '==':
957            return self - other
958
959        cls: Type[icepool.operator.MultisetMaxPairLate] | Type[
960            icepool.operator.MultisetMaxPairEarly]
961
962        if priority is None:
963            order = Order.Ascending
964            left_first, tie, _ = compute_lexi_tuple(comparison, order)
965            if left_first:
966                order = Order.Descending
967            cls = icepool.operator.MultisetMaxPairLate
968        else:
969            match priority:
970                case 'low':
971                    order = Order.Ascending
972                case 'high':
973                    order = Order.Descending
974                case _:
975                    raise ValueError("priority must be 'low' or 'high'.")
976
977            left_first, tie, _ = compute_lexi_tuple(comparison, order)
978
979            if left_first:
980                cls = icepool.operator.MultisetMaxPairEarly
981            else:
982                cls = icepool.operator.MultisetMaxPairLate
983
984        return cls(self,
985                   other,
986                   order=order,
987                   pair_equal=cast(bool, tie),
988                   keep=False)

EXPERIMENTAL: Form as many pairs of elements between self and other fitting the comparison, then drop the paired elements from self.

This pairs elements of self with elements of other, such that in each pair the element from self fits the comparison with the element from other. As many such pairs of elements will be created as possible, prioritizing either the lowest or highest possible elements. Finally, the paired elements from self are dropped, keeping the rest.

This requires that outcomes be evaluated in descending order if prioritizing high elements, or ascending order if prioritizing low elements.

This is not designed for use with negative counts.

Example: An attacker rolls a pool of 4d6 and a defender rolls a pool of 3d6. Defender dice can be used to block attacker dice of equal or lesser value, and the defender prefers to block the highest attacker dice possible. Which attacker dice were NOT blocked?

d6.pool(4).max_pair_drop('<=', d6.pool(3), 'high').sum()

Suppose the attacker rolls 6, 4, 3, 1 and the defender rolls 5, 5. Then the result would be [4, 3].

Pool([6, 4, 3, 1]).max_pair_drop('<=', [5, 5], 'high')
-> [6, 1]

The complement of this is max_pair_keep, which keeps the paired elements from self and drops the rest.

Contrast sort_pair(), which first creates pairs in sorted order and then filters them by comparison. In the above example, sort_pair() would force the defender to pair against the 6 and the 4, which would only allow them to block the 4 and let the 6, 3, and 1 through.

This infographic gives an overview of several opposed dice pool mechanics, including this one.

Arguments:
  • comparison: The comparison that the pairs must satisfy. '==' is the same as self - other.
  • other: The other multiset to pair elements with.
  • priority: Optional paramter to prioritize pairing 'low' or 'high' elements. Note that this does not change the number of elements that are paired.
def versus_all( self, comparison: Literal['<=', '<', '>=', '>'], other: MultisetExpression[~T]) -> MultisetExpression[~T]:
 990    def versus_all(self, comparison: Literal['<=', '<', '>=', '>'],
 991                   other: 'MultisetExpression[T]') -> 'MultisetExpression[T]':
 992        """EXPERIMENTAL: Keeps elements from `self` that fit the comparison against all elements of the other multiset.
 993
 994        Contrast `versus_any()`.
 995
 996        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
 997        gives an overview of several opposed dice pool mechanics, including this
 998        one.
 999        
1000        Args:
1001            comparison: One of `'<=', '<', '>=', '>'`.
1002            other: The other multiset to compare to. Negative counts are treated
1003                as 0.
1004        """
1005        other = implicit_convert_to_expression(other)
1006        lexi_tuple, order = compute_lexi_tuple_with_zero_right_first(
1007            comparison)
1008        return icepool.operator.MultisetVersus(self,
1009                                               other,
1010                                               lexi_tuple=lexi_tuple,
1011                                               order=order)

EXPERIMENTAL: Keeps elements from self that fit the comparison against all elements of the other multiset.

Contrast versus_any().

This infographic gives an overview of several opposed dice pool mechanics, including this one.

Arguments:
  • comparison: One of '<=', '<', '>=', '>'.
  • other: The other multiset to compare to. Negative counts are treated as 0.
def versus_any( self, comparison: Literal['<=', '<', '>=', '>'], other: MultisetExpression[~T]) -> MultisetExpression[~T]:
1013    def versus_any(self, comparison: Literal['<=', '<', '>=', '>'],
1014                   other: 'MultisetExpression[T]') -> 'MultisetExpression[T]':
1015        """EXPERIMENTAL: Keeps elements from `self` that fit the comparison against any element of the other multiset.
1016
1017        Contrast `versus_all()`.
1018
1019        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
1020        gives an overview of several opposed dice pool mechanics, including this
1021        one.
1022        
1023        Args:
1024            comparison: One of `'<=', '<', '>=', '>'`.
1025            other: The other multiset to compare to. Negative counts are treated
1026                as 0.
1027        """
1028        other = implicit_convert_to_expression(other)
1029        lexi_tuple, order = compute_lexi_tuple_with_zero_right_first(
1030            comparison)
1031        lexi_tuple = tuple(reversed(lexi_tuple))  # type: ignore
1032        order = -order
1033
1034        return icepool.operator.MultisetVersus(self,
1035                                               other,
1036                                               lexi_tuple=lexi_tuple,
1037                                               order=order)

EXPERIMENTAL: Keeps elements from self that fit the comparison against any element of the other multiset.

Contrast versus_all().

This infographic gives an overview of several opposed dice pool mechanics, including this one.

Arguments:
  • comparison: One of '<=', '<', '>=', '>'.
  • other: The other multiset to compare to. Negative counts are treated as 0.
def expand( self, order: Order = <Order.Ascending: 1>) -> Die[tuple[~T, ...]] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, tuple[~T, ...]]:
1041    def expand(
1042        self,
1043        order: Order = Order.Ascending
1044    ) -> 'icepool.Die[tuple[T, ...]] | MultisetFunctionRawResult[T, tuple[T, ...]]':
1045        """Evaluation: All elements of the multiset in ascending order.
1046
1047        This is expensive and not recommended unless there are few possibilities.
1048
1049        Args:
1050            order: Whether the elements are in ascending (default) or descending
1051                order.
1052        """
1053        return icepool.evaluator.ExpandEvaluator().evaluate(self, order=order)

Evaluation: All elements of the multiset in ascending order.

This is expensive and not recommended unless there are few possibilities.

Arguments:
  • order: Whether the elements are in ascending (default) or descending order.
def sum( self, map: Callable[[~T], ~U] | Mapping[~T, ~U] | None = None) -> Die[~U] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, ~U]:
1055    def sum(
1056        self,
1057        map: Callable[[T], U] | Mapping[T, U] | None = None
1058    ) -> 'icepool.Die[U] | MultisetFunctionRawResult[T, U]':
1059        """Evaluation: The sum of all elements.
1060        
1061        Args:
1062            map: If provided, this will be used to map outcomes before summing
1063                the elements. For a mapping, unmapped outcomes are left as-is.
1064        """
1065        if map is None:
1066            return icepool.evaluator.sum_evaluator.evaluate(self)
1067        else:
1068            return icepool.evaluator.SumEvaluator(map).evaluate(self)

Evaluation: The sum of all elements.

Arguments:
  • map: If provided, this will be used to map outcomes before summing the elements. For a mapping, unmapped outcomes are left as-is.
def size( self) -> Die[int] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, int]:
1070    def size(self) -> 'icepool.Die[int] | MultisetFunctionRawResult[T, int]':
1071        """Evaluation: The total number of elements in the multiset.
1072
1073        This is usually not very interesting unless some other operation is
1074        performed first. Examples:
1075
1076        `generator.unique().size()` will count the number of unique outcomes.
1077
1078        `(generator & [4, 5, 6]).size()` will count up to one each of
1079        4, 5, and 6.
1080        """
1081        return icepool.evaluator.size_evaluator.evaluate(self)

Evaluation: The total number of elements in the multiset.

This is usually not very interesting unless some other operation is performed first. Examples:

generator.unique().size() will count the number of unique outcomes.

(generator & [4, 5, 6]).size() will count up to one each of 4, 5, and 6.

def empty( self) -> Die[bool] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, bool]:
1083    def empty(
1084            self) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1085        """Evaluation: Whether the multiset contains only zero counts.
1086        
1087        The opposite of `any()`.
1088        """
1089        return icepool.evaluator.empty_evaluator.evaluate(self)

Evaluation: Whether the multiset contains only zero counts.

The opposite of any().

def any( self) -> Die[bool] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, bool]:
1091    def any(self) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1092        """Evaluation: Whether the multiset contains any nonzero count.
1093        
1094        The opposite of `empty()`.
1095        """
1096        return icepool.evaluator.empty_evaluator.evaluate(self, any=True)

Evaluation: Whether the multiset contains any nonzero count.

The opposite of empty().

def product_of_counts( self) -> Die[int] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, int]:
1098    def product_of_counts(
1099        self, ) -> 'icepool.Die[int] | MultisetFunctionRawResult[T, int]':
1100        """Evaluation: The product of counts in the multiset."""
1101        return icepool.evaluator.product_of_counts_evaluator.evaluate(self)

Evaluation: The product of counts in the multiset.

def highest_outcome_and_count( self) -> Die[tuple[~T, int]] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, tuple[~T, int]]:
1103    def highest_outcome_and_count(
1104        self
1105    ) -> 'icepool.Die[tuple[T, int]] | MultisetFunctionRawResult[T, tuple[T, int]]':
1106        """Evaluation: The highest outcome with positive count, along with that count.
1107
1108        If no outcomes have positive count, the min outcome will be returned with 0 count.
1109        """
1110        return icepool.evaluator.highest_outcome_and_count_evaluator.evaluate(
1111            self)

Evaluation: The highest outcome with positive count, along with that count.

If no outcomes have positive count, the min outcome will be returned with 0 count.

def all_counts( self, filter: int | Literal['all'] = 1) -> Die[tuple[int, ...]] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, tuple[int, ...]]:
1113    def all_counts(
1114        self,
1115        filter: int | Literal['all'] = 1
1116    ) -> 'icepool.Die[tuple[int, ...]] | MultisetFunctionRawResult[T, tuple[int, ...]]':
1117        """Evaluation: Sorted tuple of all counts, i.e. the sizes of all matching sets.
1118
1119        The sizes are in **descending** order.
1120
1121        Args:
1122            filter: Any counts below this value will not be in the output.
1123                For example, `filter=2` will only produce pairs and better.
1124                If `'all'`, no filtering will be done.
1125
1126                Why not just place `keep_counts('>=')` before this?
1127                `keep_counts('>=')` operates by setting counts to zero, so we
1128                would still need an argument to specify whether we want to
1129                output zero counts. So we might as well use the argument to do
1130                both.
1131        """
1132        return icepool.evaluator.AllCountsEvaluator(
1133            filter=filter).evaluate(self)

Evaluation: Sorted tuple of all counts, i.e. the sizes of all matching sets.

The sizes are in descending order.

Arguments:
  • filter: Any counts below this value will not be in the output. For example, filter=2 will only produce pairs and better. If 'all', no filtering will be done.

    Why not just place keep_counts('>=') before this? keep_counts('>=') operates by setting counts to zero, so we would still need an argument to specify whether we want to output zero counts. So we might as well use the argument to do both.

def largest_count( self, *, wild: Callable[[~T], bool] | Collection[~T] | None = None, wild_low: Callable[[~T], bool] | Collection[~T] | None = None, wild_high: Callable[[~T], bool] | Collection[~T] | None = None) -> Die[int] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, int]:
1135    def largest_count(
1136        self,
1137        *,
1138        wild: Callable[[T], bool] | Collection[T] | None = None,
1139        wild_low: Callable[[T], bool] | Collection[T] | None = None,
1140        wild_high: Callable[[T], bool] | Collection[T] | None = None,
1141    ) -> 'icepool.Die[int] | MultisetFunctionRawResult[T, int]':
1142        """Evaluation: The size of the largest matching set among the elements.
1143        
1144        Args:
1145            wild: If provided, the counts of these outcomes will be combined
1146                with the counts of any other outcomes.
1147            wild_low: These wilds can only be combined with outcomes that they
1148                are lower than.
1149            wild_high: These wilds can only be combined with outcomes that they
1150                are higher than.
1151        """
1152        if wild is None and wild_low is None and wild_high is None:
1153            return icepool.evaluator.largest_count_evaluator.evaluate(self)
1154        else:
1155            return icepool.evaluator.LargestCountWithWildEvaluator(
1156                wild=wild, wild_low=wild_low,
1157                wild_high=wild_high).evaluate(self)

Evaluation: The size of the largest matching set among the elements.

Arguments:
  • wild: If provided, the counts of these outcomes will be combined with the counts of any other outcomes.
  • wild_low: These wilds can only be combined with outcomes that they are lower than.
  • wild_high: These wilds can only be combined with outcomes that they are higher than.
def largest_count_and_outcome( self) -> Die[tuple[int, ~T]] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, tuple[int, ~T]]:
1159    def largest_count_and_outcome(
1160        self
1161    ) -> 'icepool.Die[tuple[int, T]] | MultisetFunctionRawResult[T, tuple[int, T]]':
1162        """Evaluation: The largest matching set among the elements and the corresponding outcome."""
1163        return icepool.evaluator.largest_count_and_outcome_evaluator.evaluate(
1164            self)

Evaluation: The largest matching set among the elements and the corresponding outcome.

def count_subset( self, divisor: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T], /, *, empty_divisor: int | None = None) -> Die[int] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, int]:
1171    def count_subset(
1172        self,
1173        divisor: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1174        /,
1175        *,
1176        empty_divisor: int | None = None
1177    ) -> 'icepool.Die[int] | MultisetFunctionRawResult[T, int]':
1178        """Evaluation: The number of times the divisor is contained in this multiset.
1179        
1180        Args:
1181            divisor: The multiset to divide by.
1182            empty_divisor: If the divisor is empty, the outcome will be this.
1183                If not set, `ZeroDivisionError` will be raised for an empty
1184                right side.
1185
1186        Raises:
1187            ZeroDivisionError: If the divisor may be empty and 
1188                `empty_divisor` is not set.
1189        """
1190        divisor = implicit_convert_to_expression(divisor)
1191        return icepool.evaluator.CountSubsetEvaluator(
1192            empty_divisor=empty_divisor).evaluate(self, divisor)

Evaluation: The number of times the divisor is contained in this multiset.

Arguments:
  • divisor: The multiset to divide by.
  • empty_divisor: If the divisor is empty, the outcome will be this. If not set, ZeroDivisionError will be raised for an empty right side.
Raises:
  • ZeroDivisionError: If the divisor may be empty and empty_divisor is not set.
def largest_straight( self: MultisetExpression[int]) -> Die[int] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[int, int]:
1194    def largest_straight(
1195        self: 'MultisetExpression[int]'
1196    ) -> 'icepool.Die[int] | MultisetFunctionRawResult[int, int]':
1197        """Evaluation: The size of the largest straight among the elements.
1198
1199        Outcomes must be `int`s.
1200        """
1201        return icepool.evaluator.largest_straight_evaluator.evaluate(self)

Evaluation: The size of the largest straight among the elements.

Outcomes must be ints.

def largest_straight_and_outcome( self: MultisetExpression[int], priority: Literal['low', 'high'] = 'high', /) -> Die[tuple[int, int]] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[int, tuple[int, int]]:
1203    def largest_straight_and_outcome(
1204        self: 'MultisetExpression[int]',
1205        priority: Literal['low', 'high'] = 'high',
1206        /
1207    ) -> 'icepool.Die[tuple[int, int]] | MultisetFunctionRawResult[int, tuple[int, int]]':
1208        """Evaluation: The size of the largest straight among the elements and the highest (optionally, lowest) outcome in that straight.
1209
1210        Straight size is prioritized first, then the outcome.
1211
1212        Outcomes must be `int`s.
1213
1214        Args:
1215            priority: Controls which outcome within the straight is returned,
1216                and which straight is picked if there is a tie for largest
1217                straight.
1218        """
1219        if priority == 'high':
1220            return icepool.evaluator.largest_straight_and_outcome_evaluator_high.evaluate(
1221                self)
1222        elif priority == 'low':
1223            return icepool.evaluator.largest_straight_and_outcome_evaluator_low.evaluate(
1224                self)
1225        else:
1226            raise ValueError("priority must be 'low' or 'high'.")

Evaluation: The size of the largest straight among the elements and the highest (optionally, lowest) outcome in that straight.

Straight size is prioritized first, then the outcome.

Outcomes must be ints.

Arguments:
  • priority: Controls which outcome within the straight is returned, and which straight is picked if there is a tie for largest straight.
def all_straights( self: MultisetExpression[int]) -> Die[tuple[int, ...]] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[int, tuple[int, ...]]:
1228    def all_straights(
1229        self: 'MultisetExpression[int]'
1230    ) -> 'icepool.Die[tuple[int, ...]] | MultisetFunctionRawResult[int, tuple[int, ...]]':
1231        """Evaluation: The sizes of all straights.
1232
1233        The sizes are in **descending** order.
1234
1235        Each element can only contribute to one straight, though duplicate
1236        elements can produces straights that overlap in outcomes. In this case,
1237        elements are preferentially assigned to the longer straight.
1238        """
1239        return icepool.evaluator.all_straights_evaluator.evaluate(self)

Evaluation: The sizes of all straights.

The sizes are in descending order.

Each element can only contribute to one straight, though duplicate elements can produces straights that overlap in outcomes. In this case, elements are preferentially assigned to the longer straight.

def all_straights_reduce_counts( self: MultisetExpression[int], reducer: Callable[[int, int], int] = <built-in function mul>) -> Die[tuple[tuple[int, int], ...]] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[int, tuple[tuple[int, int], ...]]:
1241    def all_straights_reduce_counts(
1242        self: 'MultisetExpression[int]',
1243        reducer: Callable[[int, int], int] = operator.mul
1244    ) -> 'icepool.Die[tuple[tuple[int, int], ...]] | MultisetFunctionRawResult[int, tuple[tuple[int, int], ...]]':
1245        """Experimental: All straights with a reduce operation on the counts.
1246
1247        This can be used to evaluate e.g. cribbage-style straight counting.
1248
1249        The result is a tuple of `(run_length, run_score)`s.
1250        """
1251        return icepool.evaluator.AllStraightsReduceCountsEvaluator(
1252            reducer=reducer).evaluate(self)

Experimental: All straights with a reduce operation on the counts.

This can be used to evaluate e.g. cribbage-style straight counting.

The result is a tuple of (run_length, run_score)s.

def argsort( self: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T], *args: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T], order: Order = <Order.Descending: -1>, limit: int | None = None, limit_pad: int | None = None, tie: Literal['drop', 'left', 'right']) -> Die[tuple[int, ...]] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, tuple[int, ...]]:
1254    def argsort(
1255        self: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1256        *args: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1257        order: Order = Order.Descending,
1258        limit: int | None = None,
1259        limit_pad: int | None = None,
1260        tie: Literal['drop', 'left', 'right']
1261    ) -> 'icepool.Die[tuple[int, ...]] | MultisetFunctionRawResult[T, tuple[int, ...]]':
1262        """Experimental: Returns the indexes of the originating multisets for each rank in their additive union.
1263
1264        Args:
1265            self, *args: The multiset expressions to be evaluated.
1266            order: Which order the ranks are to be emitted in. Default is descending.
1267            limit: How many ranks to emit.
1268            limit_pad: If `limit` is also set and there are not enough elements 
1269                to reach it, the result is padded with `limit_pad`. The result
1270                will therefore always have length exactly equal to `limit`.
1271            tie: What to do with ties. Options are:
1272            * `'drop'`: Any outcomes existing in more than one multiset are
1273                dropped on a 1:1:1... basis until there are no more ties.
1274            * `'left'`: The leftmost multiset (lower indexes) is ranked first.
1275            * `'right'`: The rightmost multiset (higher indexes) is ranked
1276                first.
1277        """
1278        self = implicit_convert_to_expression(self)
1279        converted_args = [implicit_convert_to_expression(arg) for arg in args]
1280        if tie == 'drop':
1281            return icepool.evaluator.ArgsortEvaluatorDrop(
1282                order=order, limit=limit,
1283                limit_pad=limit_pad).evaluate(self, *converted_args)
1284        elif tie == 'left':
1285            return icepool.evaluator.ArgsortEvaluatorLeft(
1286                order=order, limit=limit,
1287                limit_pad=limit_pad).evaluate(self, *converted_args)
1288        elif tie == 'right':
1289            return icepool.evaluator.ArgsortEvaluatorRight(
1290                order=order, limit=limit,
1291                limit_pad=limit_pad).evaluate(self, *converted_args)
1292        raise ValueError('tie must be "drop", "left", or "right".')

Experimental: Returns the indexes of the originating multisets for each rank in their additive union.

Arguments:
  • self, *args: The multiset expressions to be evaluated.
  • order: Which order the ranks are to be emitted in. Default is descending.
  • limit: How many ranks to emit.
  • limit_pad: If limit is also set and there are not enough elements to reach it, the result is padded with limit_pad. The result will therefore always have length exactly equal to limit.
  • tie: What to do with ties. Options are:
  • *** 'drop':** Any outcomes existing in more than one multiset are dropped on a 1:1:1... basis until there are no more ties.
  • *** 'left':** The leftmost multiset (lower indexes) is ranked first.
  • *** 'right':** The rightmost multiset (higher indexes) is ranked first.
def argsort_grouped( self: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T], *args: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T], order: Order = <Order.Descending: -1>, limit: int | None = None) -> Die[tuple[tuple[int, ...], ...]] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, tuple[tuple[int, ...], ...]]:
1294    def argsort_grouped(
1295        self: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1296        *args: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1297        order: Order = Order.Descending,
1298        limit: int | None = None
1299    ) -> 'icepool.Die[tuple[tuple[int, ...], ...]] | MultisetFunctionRawResult[T, tuple[tuple[int, ...], ...]]':
1300        """Experimental: Returns the indexes of the originating multisets for each rank in their additive union.
1301
1302        Example:
1303        ```python
1304        MultisetExpression.argsort([10, 9, 5], [9, 9])
1305        ```
1306        produces
1307        ```python
1308        ((0,), (0, 1, 1), (0,))
1309        ```
1310        
1311        Args:
1312            self, *args: The multiset expressions to be evaluated.
1313            order: Which order the ranks are to be emitted in. Default is descending.
1314            limit: How many ranks (unique input outcomes) to emit. Default will  
1315                emit all ranks, which makes the length of each outcome equal to
1316                `additive_union(+self, +arg1, +arg2, ...).unique().size()`
1317        """
1318        self = implicit_convert_to_expression(self)
1319        converted_args = [implicit_convert_to_expression(arg) for arg in args]
1320        return icepool.evaluator.ArgsortGroupedEvaluator(order=order,
1321                                                         limit=limit).evaluate(
1322                                                             self,
1323                                                             *converted_args)

Experimental: Returns the indexes of the originating multisets for each rank in their additive union.

Example:

MultisetExpression.argsort([10, 9, 5], [9, 9])

produces

((0,), (0, 1, 1), (0,))
Arguments:
  • self, *args: The multiset expressions to be evaluated.
  • order: Which order the ranks are to be emitted in. Default is descending.
  • limit: How many ranks (unique input outcomes) to emit. Default will
    emit all ranks, which makes the length of each outcome equal to additive_union(+self, +arg1, +arg2, ...).unique().size()
def issubset( self, other: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T], /) -> Die[bool] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, bool]:
1366    def issubset(
1367            self,
1368            other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1369            /) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1370        """Evaluation: Whether this multiset is a subset of the other multiset.
1371
1372        Specifically, if this multiset has a lesser or equal count for each
1373        outcome than the other multiset, this evaluates to `True`; 
1374        if there is some outcome for which this multiset has a greater count 
1375        than the other multiset, this evaluates to `False`.
1376
1377        `issubset` is the same as `self <= other`.
1378        
1379        `self < other` evaluates a proper subset relation, which is the same
1380        except the result is `False` if the two multisets are exactly equal.
1381        """
1382        return self._compare(other, icepool.evaluator.IsSubsetEvaluator)

Evaluation: Whether this multiset is a subset of the other multiset.

Specifically, if this multiset has a lesser or equal count for each outcome than the other multiset, this evaluates to True; if there is some outcome for which this multiset has a greater count than the other multiset, this evaluates to False.

issubset is the same as self <= other.

self < other evaluates a proper subset relation, which is the same except the result is False if the two multisets are exactly equal.

def issuperset( self, other: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T], /) -> Die[bool] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, bool]:
1401    def issuperset(
1402            self,
1403            other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1404            /) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1405        """Evaluation: Whether this multiset is a superset of the other multiset.
1406
1407        Specifically, if this multiset has a greater or equal count for each
1408        outcome than the other multiset, this evaluates to `True`; 
1409        if there is some  outcome for which this multiset has a lesser count 
1410        than the other multiset, this evaluates to `False`.
1411        
1412        A typical use of this evaluation is testing for the presence of a
1413        combo of cards in a hand, e.g.
1414
1415        ```python
1416        deck.deal(5) >= ['a', 'a', 'b']
1417        ```
1418
1419        represents the chance that a deal of 5 cards contains at least two 'a's
1420        and one 'b'.
1421
1422        `issuperset` is the same as `self >= other`.
1423
1424        `self > other` evaluates a proper superset relation, which is the same
1425        except the result is `False` if the two multisets are exactly equal.
1426        """
1427        return self._compare(other, icepool.evaluator.IsSupersetEvaluator)

Evaluation: Whether this multiset is a superset of the other multiset.

Specifically, if this multiset has a greater or equal count for each outcome than the other multiset, this evaluates to True; if there is some outcome for which this multiset has a lesser count than the other multiset, this evaluates to False.

A typical use of this evaluation is testing for the presence of a combo of cards in a hand, e.g.

deck.deal(5) >= ['a', 'a', 'b']

represents the chance that a deal of 5 cards contains at least two 'a's and one 'b'.

issuperset is the same as self >= other.

self > other evaluates a proper superset relation, which is the same except the result is False if the two multisets are exactly equal.

def isdisjoint( self, other: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T], /) -> Die[bool] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, bool]:
1459    def isdisjoint(
1460            self,
1461            other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1462            /) -> 'icepool.Die[bool] | MultisetFunctionRawResult[T, bool]':
1463        """Evaluation: Whether this multiset is disjoint from the other multiset.
1464        
1465        Specifically, this evaluates to `False` if there is any outcome for
1466        which both multisets have positive count, and `True` if there is not.
1467
1468        Negative incoming counts are treated as zero counts.
1469        """
1470        return self._compare(other, icepool.evaluator.IsDisjointSetEvaluator)

Evaluation: Whether this multiset is disjoint from the other multiset.

Specifically, this evaluates to False if there is any outcome for which both multisets have positive count, and True if there is not.

Negative incoming counts are treated as zero counts.

def leximin( self, comparison: Literal['==', '!=', '<=', '<', '>=', '>', 'cmp'], other: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T], /, extra: Literal['low', 'high', 'drop'] = 'high') -> Die[int] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, int]:
1474    def leximin(
1475        self,
1476        comparison: Literal['==', '!=', '<=', '<', '>=', '>', 'cmp'],
1477        other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1478        /,
1479        extra: Literal['low', 'high', 'drop'] = 'high'
1480    ) -> 'icepool.Die[int] | MultisetFunctionRawResult[T, int]':
1481        """Evaluation: EXPERIMENTAL: Lexicographic comparison after sorting each multiset in ascending order.
1482
1483        Compares the lowest element of each multiset; if they are equal,
1484        compares the next-lowest element, and so on.
1485
1486        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
1487        gives an overview of several opposed dice pool mechanics, including this
1488        one.
1489        
1490        Args:
1491            comparison: The comparison to use.
1492            other: The multiset to compare to.
1493            extra: If one side has more elements than the other, how the extra
1494                elements are considered compared to their missing counterparts.
1495        """
1496        lexi_tuple = compute_lexi_tuple_with_extra(comparison, Order.Ascending,
1497                                                   extra)
1498        return icepool.evaluator.lexi_comparison_evaluator.evaluate(
1499            self,
1500            implicit_convert_to_expression(other),
1501            sort_order=Order.Ascending,
1502            lexi_tuple=lexi_tuple)

Evaluation: EXPERIMENTAL: Lexicographic comparison after sorting each multiset in ascending order.

Compares the lowest element of each multiset; if they are equal, compares the next-lowest element, and so on.

This infographic gives an overview of several opposed dice pool mechanics, including this one.

Arguments:
  • comparison: The comparison to use.
  • other: The multiset to compare to.
  • extra: If one side has more elements than the other, how the extra elements are considered compared to their missing counterparts.
def leximax( self, comparison: Literal['==', '!=', '<=', '<', '>=', '>', 'cmp'], other: MultisetExpression[~T] | Mapping[~T, int] | Sequence[~T], /, extra: Literal['low', 'high', 'drop'] = 'high') -> Die[int] | icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, int]:
1504    def leximax(
1505        self,
1506        comparison: Literal['==', '!=', '<=', '<', '>=', '>', 'cmp'],
1507        other: 'MultisetExpression[T] | Mapping[T, int] | Sequence[T]',
1508        /,
1509        extra: Literal['low', 'high', 'drop'] = 'high'
1510    ) -> 'icepool.Die[int] | MultisetFunctionRawResult[T, int]':
1511        """Evaluation: EXPERIMENTAL: Lexicographic comparison after sorting each multiset in descending order.
1512
1513        Compares the highest element of each multiset; if they are equal,
1514        compares the next-highest element, and so on.
1515
1516        [This infographic](https://github.com/HighDiceRoller/icepool/blob/main/images/opposed_pools.png?raw=true)
1517        gives an overview of several opposed dice pool mechanics, including this
1518        one.
1519        
1520        Args:
1521            comparison: The comparison to use.
1522            other: The multiset to compare to.
1523            extra: If one side has more elements than the other, how the extra
1524                elements are considered compared to their missing counterparts.
1525        """
1526        lexi_tuple = compute_lexi_tuple_with_extra(comparison,
1527                                                   Order.Descending, extra)
1528        return icepool.evaluator.lexi_comparison_evaluator.evaluate(
1529            self,
1530            implicit_convert_to_expression(other),
1531            sort_order=Order.Descending,
1532            lexi_tuple=lexi_tuple)

Evaluation: EXPERIMENTAL: Lexicographic comparison after sorting each multiset in descending order.

Compares the highest element of each multiset; if they are equal, compares the next-highest element, and so on.

This infographic gives an overview of several opposed dice pool mechanics, including this one.

Arguments:
  • comparison: The comparison to use.
  • other: The multiset to compare to.
  • extra: If one side has more elements than the other, how the extra elements are considered compared to their missing counterparts.
def force_order( self, force_order: Order) -> MultisetExpression[~T]:
1535    def force_order(self, force_order: Order) -> 'MultisetExpression[T]':
1536        """Forces outcomes to be seen by the evaluator in the given order.
1537
1538        This can be useful for debugging / testing.
1539        """
1540        if force_order == Order.Any:
1541            return self
1542        return icepool.operator.MultisetForceOrder(self,
1543                                                   force_order=force_order)

Forces outcomes to be seen by the evaluator in the given order.

This can be useful for debugging / testing.

class MultisetEvaluator(icepool.evaluator.multiset_evaluator_base.MultisetEvaluatorBase[~T, +U_co]):
 21class MultisetEvaluator(MultisetEvaluatorBase[T, U_co]):
 22    """Evaluates a multiset based on a state transition function."""
 23
 24    @abstractmethod
 25    def next_state(self, state: Hashable, order: Order, outcome: T, /,
 26                   *counts) -> Hashable:
 27        """State transition function.
 28
 29        This should produce a state given the previous state, an outcome,
 30        the count of that outcome produced by each multiset input, and any
 31        **kwargs provided to `evaluate()`.
 32
 33        `evaluate()` will always call this with `state, outcome, *counts` as
 34        positional arguments. Furthermore, there is no expectation that a 
 35        subclass be able to handle an arbitrary number of counts. 
 36        
 37        Thus, you are free to:
 38        * Rename `state` or `outcome` in a subclass.
 39        * Replace `*counts` with a fixed set of parameters.
 40
 41        States must be hashable. At current, they do not have to be orderable.
 42        However, this may change in the future, and if they are not totally
 43        orderable, you must override `final_outcome` to create totally orderable
 44        final outcomes.
 45
 46        Returning a `Die` is not supported.
 47
 48        Args:
 49            state: A hashable object indicating the state before rolling the
 50                current outcome. If `initial_state()` is not overridden, the
 51                initial state is `None`.
 52            order: The order in which outcomes are seen. You can raise an 
 53                `UnsupportedOrder` if you don't want to support the current 
 54                order. In this case, the opposite order will then be attempted
 55                (if it hasn't already been attempted).
 56            outcome: The current outcome.
 57                If there are multiple inputs, the set of outcomes is at 
 58                least the union of the outcomes of the invididual inputs. 
 59                You can use `extra_outcomes()` to add extra outcomes.
 60            *counts: One value (usually an `int`) for each input indicating how
 61                many of the current outcome were produced. You may replace this
 62                with a fixed series of parameters.
 63
 64        Returns:
 65            A hashable object indicating the next state.
 66            The special value `icepool.Reroll` can be used to immediately remove
 67            the state from consideration, effectively performing a full reroll.
 68        """
 69
 70    def extra_outcomes(self, outcomes: Sequence[T]) -> Collection[T]:
 71        """Optional method to specify extra outcomes that should be seen as inputs to `next_state()`.
 72
 73        These will be seen by `next_state` even if they do not appear in the
 74        input(s). The default implementation returns `()`, or no additional
 75        outcomes.
 76
 77        If you want `next_state` to see consecutive `int` outcomes, you can set
 78        `extra_outcomes = icepool.MultisetEvaluator.consecutive`.
 79        See `consecutive()` below.
 80
 81        Args:
 82            outcomes: The outcomes that could be produced by the inputs, in
 83            ascending order.
 84        """
 85        return ()
 86
 87    def initial_state(self, order: Order, outcomes: Sequence[T], /, *sizes,
 88                      **kwargs: Hashable):
 89        """Optional method to produce an initial evaluation state.
 90
 91        If not overriden, the initial state is `None`. Note that this is not a
 92        valid `final_outcome()`.
 93
 94        All non-keyword arguments will be given positionally, so you are free
 95        to:
 96        * Rename any of them.
 97        * Replace `sizes` with a fixed series of arguments.
 98        * Replace trailing positional arguments with `*_` if you don't care
 99            about them.
100
101        Args:
102            order: The order in which outcomes will be seen by `next_state()`.
103            outcomes: All outcomes that will be seen by `next_state()`.
104            sizes: The sizes of the input multisets, provided
105                that the multiset has inferrable size with non-negative
106                counts. If not, the corresponding size is None.
107            kwargs: Non-multiset arguments that were provided to `evaluate()`.
108                You may replace `**kwargs` with a fixed set of keyword
109                parameters; `final_outcome()` should take the same set of
110                keyword parameters.
111
112        Raises:
113            UnsupportedOrder if the given order is not supported.
114        """
115        return None
116
117    def final_outcome(
118            self, final_state: Hashable, order: Order, outcomes: tuple[T, ...],
119            /, *sizes, **kwargs: Hashable
120    ) -> 'U_co | icepool.Die[U_co] | icepool.RerollType':
121        """Optional method to generate a final output outcome from a final state.
122
123        By default, the final outcome is equal to the final state.
124        Note that `None` is not a valid outcome for a `Die`,
125        and if there are no outcomes, `final_outcome` will be immediately
126        be callled with `final_state=None`.
127        Subclasses that want to handle this case should explicitly define what
128        happens.
129
130        All non-keyword arguments will be given positionally, so you are free
131        to:
132        * Rename any of them.
133        * Replace `sizes` with a fixed series of arguments.
134        * Replace trailing positional arguments with `*_` if you don't care
135            about them.
136
137        Args:
138            final_state: A state after all outcomes have been processed.
139            order: The order in which outcomes were seen by `next_state()`.
140            outcomes: All outcomes that were seen by `next_state()`.
141            sizes: The sizes of the input multisets, provided
142                that the multiset has inferrable size with non-negative
143                counts. If not, the corresponding size is None.
144            kwargs: Non-multiset arguments that were provided to `evaluate()`.
145                You may replace `**kwargs` with a fixed set of keyword
146                parameters; `initial_state()` should take the same set of
147                keyword parameters.
148
149        Returns:
150            A final outcome that will be used as part of constructing the result `Die`.
151            As usual for `Die()`, this could itself be a `Die` or `icepool.Reroll`.
152        """
153        # If not overriden, the final_state should have type U_co.
154        return cast(U_co, final_state)
155
156    def consecutive(self, outcomes: Sequence[int]) -> Collection[int]:
157        """Example implementation of `extra_outcomes()` that produces consecutive `int` outcomes.
158
159        Set `extra_outcomes = icepool.MultisetEvaluator.consecutive` to use this.
160
161        Returns:
162            All `int`s from the min outcome to the max outcome among the inputs,
163            inclusive.
164
165        Raises:
166            TypeError: if any input has any non-`int` outcome.
167        """
168        if not outcomes:
169            return ()
170
171        if any(not isinstance(x, int) for x in outcomes):
172            raise TypeError(
173                "consecutive cannot be used with outcomes of type other than 'int'."
174            )
175
176        return range(outcomes[0], outcomes[-1] + 1)
177
178    @property
179    def next_state_key(self) -> Hashable:
180        """Subclasses may optionally provide a key that uniquely identifies the `next_state()` computation.
181
182        This is used to persistently cache intermediate results between calls
183        to `evaluate()`. By default, `next_state_key` is `None`, which only
184        caches if not inside a `@multiset_function`.
185        
186        If you do implement this, `next_state_key` should include any members 
187        used in `next_state()` but does NOT need to include members that are 
188        only used in other methods, i.e. 
189        * `extra_outcomes()`
190        * `initial_state()`
191        * `final_outcome()`.
192        
193        For example, if `next_state()` is a pure function other than being 
194        defined by its evaluator class, you can use `type(self)`.
195
196        If you want to disable caching between calls to `evaluate()` even
197        outside of `@multiset_function`, return the special value 
198        `icepool.NoCache`.
199        """
200        return None
201
202    def _prepare(
203        self,
204        input_exps: tuple[MultisetExpressionBase[T, Any], ...],
205        kwargs: Mapping[str, Hashable],
206    ) -> Iterator[tuple['Dungeon[T]', 'Quest[T, U_co]',
207                        'tuple[MultisetSourceBase[T, Any], ...]', int]]:
208
209        for t in itertools.product(*(exp._prepare() for exp in input_exps)):
210            if t:
211                dungeonlet_flats, questlet_flats, sources, weights = zip(*t)
212            else:
213                dungeonlet_flats = ()
214                questlet_flats = ()
215                sources = ()
216                weights = ()
217            next_state_key: Hashable
218            if self.next_state_key is None:
219                # This should only get cached inside this evaluator, but add
220                # self id to be safe.
221                next_state_key = id(self)
222                multiset_function_can_cache = False
223            elif self.next_state_key is icepool.NoCache:
224                next_state_key = icepool.NoCache
225                multiset_function_can_cache = False
226            else:
227                next_state_key = self.next_state_key
228                multiset_function_can_cache = True
229            dungeon: MultisetEvaluatorDungeon[T] = MultisetEvaluatorDungeon(
230                self.next_state, next_state_key, multiset_function_can_cache,
231                dungeonlet_flats)
232            quest: MultisetEvaluatorQuest[T, U_co] = MultisetEvaluatorQuest(
233                self.initial_state, self.extra_outcomes, self.final_outcome,
234                questlet_flats)
235            sources = tuple(itertools.chain.from_iterable(sources))
236            weight = math.prod(weights)
237            yield dungeon, quest, sources, weight
238
239    def _should_cache(self, dungeon: 'Dungeon[T]') -> bool:
240        return dungeon.__hash__ is not None

Evaluates a multiset based on a state transition function.

@abstractmethod
def next_state( self, state: Hashable, order: Order, outcome: ~T, /, *counts) -> Hashable:
24    @abstractmethod
25    def next_state(self, state: Hashable, order: Order, outcome: T, /,
26                   *counts) -> Hashable:
27        """State transition function.
28
29        This should produce a state given the previous state, an outcome,
30        the count of that outcome produced by each multiset input, and any
31        **kwargs provided to `evaluate()`.
32
33        `evaluate()` will always call this with `state, outcome, *counts` as
34        positional arguments. Furthermore, there is no expectation that a 
35        subclass be able to handle an arbitrary number of counts. 
36        
37        Thus, you are free to:
38        * Rename `state` or `outcome` in a subclass.
39        * Replace `*counts` with a fixed set of parameters.
40
41        States must be hashable. At current, they do not have to be orderable.
42        However, this may change in the future, and if they are not totally
43        orderable, you must override `final_outcome` to create totally orderable
44        final outcomes.
45
46        Returning a `Die` is not supported.
47
48        Args:
49            state: A hashable object indicating the state before rolling the
50                current outcome. If `initial_state()` is not overridden, the
51                initial state is `None`.
52            order: The order in which outcomes are seen. You can raise an 
53                `UnsupportedOrder` if you don't want to support the current 
54                order. In this case, the opposite order will then be attempted
55                (if it hasn't already been attempted).
56            outcome: The current outcome.
57                If there are multiple inputs, the set of outcomes is at 
58                least the union of the outcomes of the invididual inputs. 
59                You can use `extra_outcomes()` to add extra outcomes.
60            *counts: One value (usually an `int`) for each input indicating how
61                many of the current outcome were produced. You may replace this
62                with a fixed series of parameters.
63
64        Returns:
65            A hashable object indicating the next state.
66            The special value `icepool.Reroll` can be used to immediately remove
67            the state from consideration, effectively performing a full reroll.
68        """

State transition function.

This should produce a state given the previous state, an outcome, the count of that outcome produced by each multiset input, and any **kwargs provided to evaluate().

evaluate() will always call this with state, outcome, *counts as positional arguments. Furthermore, there is no expectation that a subclass be able to handle an arbitrary number of counts.

Thus, you are free to:

  • Rename state or outcome in a subclass.
  • Replace *counts with a fixed set of parameters.

States must be hashable. At current, they do not have to be orderable. However, this may change in the future, and if they are not totally orderable, you must override final_outcome to create totally orderable final outcomes.

Returning a Die is not supported.

Arguments:
  • state: A hashable object indicating the state before rolling the current outcome. If initial_state() is not overridden, the initial state is None.
  • order: The order in which outcomes are seen. You can raise an UnsupportedOrder if you don't want to support the current order. In this case, the opposite order will then be attempted (if it hasn't already been attempted).
  • outcome: The current outcome. If there are multiple inputs, the set of outcomes is at least the union of the outcomes of the invididual inputs. You can use extra_outcomes() to add extra outcomes.
  • *counts: One value (usually an int) for each input indicating how many of the current outcome were produced. You may replace this with a fixed series of parameters.
Returns:

A hashable object indicating the next state. The special value icepool.Reroll can be used to immediately remove the state from consideration, effectively performing a full reroll.

def extra_outcomes(self, outcomes: Sequence[~T]) -> Collection[~T]:
70    def extra_outcomes(self, outcomes: Sequence[T]) -> Collection[T]:
71        """Optional method to specify extra outcomes that should be seen as inputs to `next_state()`.
72
73        These will be seen by `next_state` even if they do not appear in the
74        input(s). The default implementation returns `()`, or no additional
75        outcomes.
76
77        If you want `next_state` to see consecutive `int` outcomes, you can set
78        `extra_outcomes = icepool.MultisetEvaluator.consecutive`.
79        See `consecutive()` below.
80
81        Args:
82            outcomes: The outcomes that could be produced by the inputs, in
83            ascending order.
84        """
85        return ()

Optional method to specify extra outcomes that should be seen as inputs to next_state().

These will be seen by next_state even if they do not appear in the input(s). The default implementation returns (), or no additional outcomes.

If you want next_state to see consecutive int outcomes, you can set extra_outcomes = icepool.MultisetEvaluator.consecutive. See consecutive() below.

Arguments:
  • outcomes: The outcomes that could be produced by the inputs, in
  • ascending order.
def initial_state( self, order: Order, outcomes: Sequence[~T], /, *sizes, **kwargs: Hashable):
 87    def initial_state(self, order: Order, outcomes: Sequence[T], /, *sizes,
 88                      **kwargs: Hashable):
 89        """Optional method to produce an initial evaluation state.
 90
 91        If not overriden, the initial state is `None`. Note that this is not a
 92        valid `final_outcome()`.
 93
 94        All non-keyword arguments will be given positionally, so you are free
 95        to:
 96        * Rename any of them.
 97        * Replace `sizes` with a fixed series of arguments.
 98        * Replace trailing positional arguments with `*_` if you don't care
 99            about them.
100
101        Args:
102            order: The order in which outcomes will be seen by `next_state()`.
103            outcomes: All outcomes that will be seen by `next_state()`.
104            sizes: The sizes of the input multisets, provided
105                that the multiset has inferrable size with non-negative
106                counts. If not, the corresponding size is None.
107            kwargs: Non-multiset arguments that were provided to `evaluate()`.
108                You may replace `**kwargs` with a fixed set of keyword
109                parameters; `final_outcome()` should take the same set of
110                keyword parameters.
111
112        Raises:
113            UnsupportedOrder if the given order is not supported.
114        """
115        return None

Optional method to produce an initial evaluation state.

If not overriden, the initial state is None. Note that this is not a valid final_outcome().

All non-keyword arguments will be given positionally, so you are free to:

  • Rename any of them.
  • Replace sizes with a fixed series of arguments.
  • Replace trailing positional arguments with *_ if you don't care about them.
Arguments:
  • order: The order in which outcomes will be seen by next_state().
  • outcomes: All outcomes that will be seen by next_state().
  • sizes: The sizes of the input multisets, provided that the multiset has inferrable size with non-negative counts. If not, the corresponding size is None.
  • kwargs: Non-multiset arguments that were provided to evaluate(). You may replace **kwargs with a fixed set of keyword parameters; final_outcome() should take the same set of keyword parameters.
Raises:
  • UnsupportedOrder if the given order is not supported.
def final_outcome( self, final_state: Hashable, order: Order, outcomes: tuple[~T, ...], /, *sizes, **kwargs: Hashable) -> +U_co | Die[+U_co] | RerollType:
117    def final_outcome(
118            self, final_state: Hashable, order: Order, outcomes: tuple[T, ...],
119            /, *sizes, **kwargs: Hashable
120    ) -> 'U_co | icepool.Die[U_co] | icepool.RerollType':
121        """Optional method to generate a final output outcome from a final state.
122
123        By default, the final outcome is equal to the final state.
124        Note that `None` is not a valid outcome for a `Die`,
125        and if there are no outcomes, `final_outcome` will be immediately
126        be callled with `final_state=None`.
127        Subclasses that want to handle this case should explicitly define what
128        happens.
129
130        All non-keyword arguments will be given positionally, so you are free
131        to:
132        * Rename any of them.
133        * Replace `sizes` with a fixed series of arguments.
134        * Replace trailing positional arguments with `*_` if you don't care
135            about them.
136
137        Args:
138            final_state: A state after all outcomes have been processed.
139            order: The order in which outcomes were seen by `next_state()`.
140            outcomes: All outcomes that were seen by `next_state()`.
141            sizes: The sizes of the input multisets, provided
142                that the multiset has inferrable size with non-negative
143                counts. If not, the corresponding size is None.
144            kwargs: Non-multiset arguments that were provided to `evaluate()`.
145                You may replace `**kwargs` with a fixed set of keyword
146                parameters; `initial_state()` should take the same set of
147                keyword parameters.
148
149        Returns:
150            A final outcome that will be used as part of constructing the result `Die`.
151            As usual for `Die()`, this could itself be a `Die` or `icepool.Reroll`.
152        """
153        # If not overriden, the final_state should have type U_co.
154        return cast(U_co, final_state)

Optional method to generate a final output outcome from a final state.

By default, the final outcome is equal to the final state. Note that None is not a valid outcome for a Die, and if there are no outcomes, final_outcome will be immediately be callled with final_state=None. Subclasses that want to handle this case should explicitly define what happens.

All non-keyword arguments will be given positionally, so you are free to:

  • Rename any of them.
  • Replace sizes with a fixed series of arguments.
  • Replace trailing positional arguments with *_ if you don't care about them.
Arguments:
  • final_state: A state after all outcomes have been processed.
  • order: The order in which outcomes were seen by next_state().
  • outcomes: All outcomes that were seen by next_state().
  • sizes: The sizes of the input multisets, provided that the multiset has inferrable size with non-negative counts. If not, the corresponding size is None.
  • kwargs: Non-multiset arguments that were provided to evaluate(). You may replace **kwargs with a fixed set of keyword parameters; initial_state() should take the same set of keyword parameters.
Returns:

A final outcome that will be used as part of constructing the result Die. As usual for Die(), this could itself be a Die or icepool.Reroll.

def consecutive(self, outcomes: Sequence[int]) -> Collection[int]:
156    def consecutive(self, outcomes: Sequence[int]) -> Collection[int]:
157        """Example implementation of `extra_outcomes()` that produces consecutive `int` outcomes.
158
159        Set `extra_outcomes = icepool.MultisetEvaluator.consecutive` to use this.
160
161        Returns:
162            All `int`s from the min outcome to the max outcome among the inputs,
163            inclusive.
164
165        Raises:
166            TypeError: if any input has any non-`int` outcome.
167        """
168        if not outcomes:
169            return ()
170
171        if any(not isinstance(x, int) for x in outcomes):
172            raise TypeError(
173                "consecutive cannot be used with outcomes of type other than 'int'."
174            )
175
176        return range(outcomes[0], outcomes[-1] + 1)

Example implementation of extra_outcomes() that produces consecutive int outcomes.

Set extra_outcomes = icepool.MultisetEvaluator.consecutive to use this.

Returns:

All ints from the min outcome to the max outcome among the inputs, inclusive.

Raises:
  • TypeError: if any input has any non-int outcome.
next_state_key: Hashable
178    @property
179    def next_state_key(self) -> Hashable:
180        """Subclasses may optionally provide a key that uniquely identifies the `next_state()` computation.
181
182        This is used to persistently cache intermediate results between calls
183        to `evaluate()`. By default, `next_state_key` is `None`, which only
184        caches if not inside a `@multiset_function`.
185        
186        If you do implement this, `next_state_key` should include any members 
187        used in `next_state()` but does NOT need to include members that are 
188        only used in other methods, i.e. 
189        * `extra_outcomes()`
190        * `initial_state()`
191        * `final_outcome()`.
192        
193        For example, if `next_state()` is a pure function other than being 
194        defined by its evaluator class, you can use `type(self)`.
195
196        If you want to disable caching between calls to `evaluate()` even
197        outside of `@multiset_function`, return the special value 
198        `icepool.NoCache`.
199        """
200        return None

Subclasses may optionally provide a key that uniquely identifies the next_state() computation.

This is used to persistently cache intermediate results between calls to evaluate(). By default, next_state_key is None, which only caches if not inside a @multiset_function.

If you do implement this, next_state_key should include any members used in next_state() but does NOT need to include members that are only used in other methods, i.e.

For example, if next_state() is a pure function other than being defined by its evaluator class, you can use type(self).

If you want to disable caching between calls to evaluate() even outside of @multiset_function, return the special value icepool.NoCache.

class Order(enum.IntEnum):
30class Order(enum.IntEnum):
31    """Can be used to define what order outcomes are seen in by MultisetEvaluators."""
32    Ascending = 1
33    Descending = -1
34    Any = 0
35
36    def merge(*orders: 'Order') -> 'Order':
37        """Merges the given Orders.
38
39        Returns:
40            `Any` if all arguments are `Any`.
41            `Ascending` if there is at least one `Ascending` in the arguments.
42            `Descending` if there is at least one `Descending` in the arguments.
43
44        Raises:
45            `ConflictingOrderError` if both `Ascending` and `Descending` are in 
46            the arguments.
47        """
48        result = Order.Any
49        for order in orders:
50            if (result > 0 and order < 0) or (result < 0 and order > 0):
51                raise ConflictingOrderError(
52                    f'Conflicting orders {orders}.\n' +
53                    'Tip: If you are using highest(keep=k), try using lowest(drop=n-k) instead, or vice versa.'
54                )
55            if result == Order.Any:
56                result = order
57        return result
58
59    def __neg__(self) -> 'Order':
60        if self is Order.Ascending:
61            return Order.Descending
62        elif self is Order.Descending:
63            return Order.Ascending
64        else:
65            return Order.Any

Can be used to define what order outcomes are seen in by MultisetEvaluators.

Ascending = <Order.Ascending: 1>
Descending = <Order.Descending: -1>
Any = <Order.Any: 0>
def merge(*orders: Order) -> Order:
36    def merge(*orders: 'Order') -> 'Order':
37        """Merges the given Orders.
38
39        Returns:
40            `Any` if all arguments are `Any`.
41            `Ascending` if there is at least one `Ascending` in the arguments.
42            `Descending` if there is at least one `Descending` in the arguments.
43
44        Raises:
45            `ConflictingOrderError` if both `Ascending` and `Descending` are in 
46            the arguments.
47        """
48        result = Order.Any
49        for order in orders:
50            if (result > 0 and order < 0) or (result < 0 and order > 0):
51                raise ConflictingOrderError(
52                    f'Conflicting orders {orders}.\n' +
53                    'Tip: If you are using highest(keep=k), try using lowest(drop=n-k) instead, or vice versa.'
54                )
55            if result == Order.Any:
56                result = order
57        return result

Merges the given Orders.

Returns:

Any if all arguments are Any. Ascending if there is at least one Ascending in the arguments. Descending if there is at least one Descending in the arguments.

Raises:
class ConflictingOrderError(icepool.order.OrderError):
19class ConflictingOrderError(OrderError):
20    """Indicates that two conflicting mandatory outcome orderings were encountered."""

Indicates that two conflicting mandatory outcome orderings were encountered.

class UnsupportedOrder(icepool.order.OrderException):
23class UnsupportedOrder(OrderException):
24    """Indicates that the given order is not supported under the current context.
25    
26    It may still be possible that retrying with the opposite order will succeed.
27    """

Indicates that the given order is not supported under the current context.

It may still be possible that retrying with the opposite order will succeed.

 20class Deck(Population[T_co], MaybeHashKeyed):
 21    """Sampling without replacement (within a single evaluation).
 22
 23    Quantities represent duplicates.
 24    """
 25
 26    _data: Counts[T_co]
 27    _deal: int
 28
 29    @property
 30    def _new_type(self) -> type:
 31        return Deck
 32
 33    def __new__(cls,
 34                outcomes: Sequence | Mapping[Any, int] = (),
 35                times: Sequence[int] | int = 1) -> 'Deck[T_co]':
 36        """Constructor for a `Deck`.
 37
 38        All quantities must be non-negative. Outcomes with zero quantity will be
 39        omitted.
 40
 41        Args:
 42            outcomes: The cards of the `Deck`. This can be one of the following:
 43                * A `Sequence` of outcomes. Duplicates will contribute
 44                    quantity for each appearance.
 45                * A `Mapping` from outcomes to quantities.
 46
 47                Each outcome may be one of the following:
 48                * An outcome, which must be hashable and totally orderable.
 49                * A `Deck`, which will be flattened into the result. If a
 50                    `times` is assigned to the `Deck`, the entire `Deck` will
 51                    be duplicated that many times.
 52            times: Multiplies the number of times each element of `outcomes`
 53                will be put into the `Deck`.
 54                `times` can either be a sequence of the same length as
 55                `outcomes` or a single `int` to apply to all elements of
 56                `outcomes`.
 57        """
 58
 59        if icepool.population.again.contains_again(outcomes):
 60            raise ValueError('Again cannot be used with Decks.')
 61
 62        outcomes, times = icepool.creation_args.itemize(outcomes, times)
 63
 64        if len(outcomes) == 1 and times[0] == 1 and isinstance(
 65                outcomes[0], Deck):
 66            return outcomes[0]
 67
 68        counts: Counts[T_co] = icepool.creation_args.expand_args_for_deck(
 69            outcomes, times)
 70
 71        return Deck._new_raw(counts)
 72
 73    @classmethod
 74    def _new_raw(cls, data: Counts[T_co]) -> 'Deck[T_co]':
 75        """Creates a new `Deck` using already-processed arguments.
 76
 77        Args:
 78            data: At this point, this is a Counts.
 79        """
 80        self = super(Population, cls).__new__(cls)
 81        self._data = data
 82        return self
 83
 84    def keys(self) -> CountsKeysView[T_co]:
 85        return self._data.keys()
 86
 87    def values(self) -> CountsValuesView:
 88        return self._data.values()
 89
 90    def items(self) -> CountsItemsView[T_co]:
 91        return self._data.items()
 92
 93    def __getitem__(self, outcome) -> int:
 94        return self._data[outcome]
 95
 96    def __iter__(self) -> Iterator[T_co]:
 97        return iter(self.keys())
 98
 99    def __len__(self) -> int:
100        return len(self._data)
101
102    size = icepool.Population.denominator
103
104    @cached_property
105    def _popped_min(self) -> tuple['Deck[T_co]', int]:
106        return self._new_raw(self._data.remove_min()), self.quantities()[0]
107
108    def _pop_min(self) -> tuple['Deck[T_co]', int]:
109        """A `Deck` with the min outcome removed."""
110        return self._popped_min
111
112    @cached_property
113    def _popped_max(self) -> tuple['Deck[T_co]', int]:
114        return self._new_raw(self._data.remove_max()), self.quantities()[-1]
115
116    def _pop_max(self) -> tuple['Deck[T_co]', int]:
117        """A `Deck` with the max outcome removed."""
118        return self._popped_max
119
120    @overload
121    def deal(self, hand_size: int, /) -> 'icepool.Deal[T_co]':
122        ...
123
124    @overload
125    def deal(self,
126             hand_sizes: Iterable[int]) -> 'icepool.MultiDeal[T_co, Any]':
127        ...
128
129    @overload
130    def deal(
131        self, hand_sizes: int | Iterable[int]
132    ) -> 'icepool.Deal[T_co] | icepool.MultiDeal[T_co, Any]':
133        ...
134
135    def deal(
136        self, hand_sizes: int | Iterable[int]
137    ) -> 'icepool.Deal[T_co] | icepool.MultiDeal[T_co, Any]':
138        """Deals the specified number of cards from this deck.
139
140        Args:
141            hand_sizes: Either an integer, in which case a `Deal` will be
142                returned, or an iterable of multiple hand sizes, in which case a
143                `MultiDeal` will be returned.
144        """
145        if isinstance(hand_sizes, int):
146            return icepool.Deal(self, hand_sizes)
147        else:
148            return icepool.MultiDeal(
149                self, tuple((hand_size, 1) for hand_size in hand_sizes))
150
151    def deal_groups(
152            self, *hand_groups: tuple[int,
153                                      int]) -> 'icepool.MultiDeal[T_co, Any]':
154        """EXPERIMENTAL: Deal cards into groups of hands, where the hands in each group could be produced in arbitrary order.
155        
156        Args:
157            hand_groups: Each argument is a tuple (hand_size, group_size),
158                denoting the number of cards in each hand of the group and
159                the number of hands in the group respectively.
160        """
161        return icepool.MultiDeal(self, hand_groups)
162
163    # Binary operators.
164
165    def additive_union(
166            self, *args: Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
167        """Both decks merged together."""
168        return functools.reduce(operator.add, args,
169                                initial=self)  # type: ignore
170
171    def __add__(self,
172                other: Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
173        data = Counter(self._data)
174        for outcome, count in Counter(other).items():
175            data[outcome] += count
176        return Deck(+data)
177
178    __radd__ = __add__
179
180    def difference(self, *args:
181                   Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
182        """This deck with the other cards removed (but not below zero of each card)."""
183        return functools.reduce(operator.sub, args,
184                                initial=self)  # type: ignore
185
186    def __sub__(self,
187                other: Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
188        data = Counter(self._data)
189        for outcome, count in Counter(other).items():
190            data[outcome] -= count
191        return Deck(+data)
192
193    def __rsub__(self,
194                 other: Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
195        data = Counter(other)
196        for outcome, count in self.items():
197            data[outcome] -= count
198        return Deck(+data)
199
200    def intersection(
201            self, *args: Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
202        """The cards that both decks have."""
203        return functools.reduce(operator.and_, args,
204                                initial=self)  # type: ignore
205
206    def __and__(self,
207                other: Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
208        data: Counter[T_co] = Counter()
209        for outcome, count in Counter(other).items():
210            data[outcome] = min(self.get(outcome, 0), count)
211        return Deck(+data)
212
213    __rand__ = __and__
214
215    def union(self, *args:
216              Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
217        """As many of each card as the deck that has more of them."""
218        return functools.reduce(operator.or_, args,
219                                initial=self)  # type: ignore
220
221    def __or__(self,
222               other: Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
223        data = Counter(self._data)
224        for outcome, count in Counter(other).items():
225            data[outcome] = max(data[outcome], count)
226        return Deck(+data)
227
228    __ror__ = __or__
229
230    def symmetric_difference(
231            self, other: Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
232        """As many of each card as the deck that has more of them."""
233        return self ^ other
234
235    def __xor__(self,
236                other: Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
237        data = Counter(self._data)
238        for outcome, count in Counter(other).items():
239            data[outcome] = abs(data[outcome] - count)
240        return Deck(+data)
241
242    def __mul__(self, other: int) -> 'Deck[T_co]':
243        if not isinstance(other, int):
244            return NotImplemented
245        return self.multiply_quantities(other)
246
247    __rmul__ = __mul__
248
249    def __floordiv__(self, other: int) -> 'Deck[T_co]':
250        if not isinstance(other, int):
251            return NotImplemented
252        return self.divide_quantities(other)
253
254    def __mod__(self, other: int) -> 'Deck[T_co]':
255        if not isinstance(other, int):
256            return NotImplemented
257        return self.modulo_quantities(other)
258
259    def map(
260            self,
261            repl:
262        'Callable[..., U | Deck[U] | icepool.RerollType] | Mapping[T_co, U | Deck[U] | icepool.RerollType]',
263            /,
264            *,
265            star: bool | None = None) -> 'Deck[U]':
266        """Maps outcomes of this `Deck` to other outcomes.
267
268        Args:
269            repl: One of the following:
270                * A callable returning a new outcome for each old outcome.
271                * A map from old outcomes to new outcomes.
272                    Unmapped old outcomes stay the same.
273                The new outcomes may be `Deck`s, in which case one card is
274                replaced with several. This is not recommended.
275            star: Whether outcomes should be unpacked into separate arguments
276                before sending them to a callable `repl`.
277                If not provided, this will be guessed based on the function
278                signature.
279        """
280        # Convert to a single-argument function.
281        if callable(repl):
282            if star is None:
283                star = infer_star(repl)
284            if star:
285
286                def transition_function(outcome):
287                    return repl(*outcome)
288            else:
289
290                def transition_function(outcome):
291                    return repl(outcome)
292        else:
293            # repl is a mapping.
294            def transition_function(outcome):
295                if outcome in repl:
296                    return repl[outcome]
297                else:
298                    return outcome
299
300        return Deck(
301            [transition_function(outcome) for outcome in self.outcomes()],
302            times=self.quantities())
303
304    @cached_property
305    def _sequence_cache(
306            self) -> 'MutableSequence[icepool.Die[tuple[T_co, ...]]]':
307        return [icepool.Die([()])]
308
309    def sequence(self, deals: int, /) -> 'icepool.Die[tuple[T_co, ...]]':
310        """Possible sequences produced by dealing from this deck a number of times.
311        
312        This is extremely expensive computationally. If you don't care about
313        order, use `deal()` instead.
314        """
315        if deals < 0:
316            raise ValueError('The number of cards dealt cannot be negative.')
317        for i in range(len(self._sequence_cache), deals + 1):
318
319            def transition(curr):
320                remaining = icepool.Die(self - curr)
321                return icepool.map(lambda curr, next: curr + (next, ), curr,
322                                   remaining)
323
324            result: 'icepool.Die[tuple[T_co, ...]]' = self._sequence_cache[
325                i - 1].map(transition)
326            self._sequence_cache.append(result)
327        return self._sequence_cache[deals]
328
329    @cached_property
330    def hash_key(self) -> tuple:
331        return Deck, tuple(self.items())
332
333    def __repr__(self) -> str:
334        items_string = ', '.join(f'{repr(outcome)}: {quantity}'
335                                 for outcome, quantity in self.items())
336        return type(self).__qualname__ + '({' + items_string + '})'

Sampling without replacement (within a single evaluation).

Quantities represent duplicates.

def keys(self) -> CountsKeysView[+T_co]:
84    def keys(self) -> CountsKeysView[T_co]:
85        return self._data.keys()

The outcomes within the population in sorted order.

def values(self) -> CountsValuesView:
87    def values(self) -> CountsValuesView:
88        return self._data.values()

The quantities within the population in outcome order.

def items(self) -> CountsItemsView[+T_co]:
90    def items(self) -> CountsItemsView[T_co]:
91        return self._data.items()

The (outcome, quantity)s of the population in sorted order.

def size(self) -> int:
313    def denominator(self) -> int:
314        """The sum of all quantities (e.g. weights or duplicates).
315
316        For the number of unique outcomes, use `len()`.
317        """
318        return self._denominator

The sum of all quantities (e.g. weights or duplicates).

For the number of unique outcomes, use len().

def deal( self, hand_sizes: int | Iterable[int]) -> Deal[+T_co] | MultiDeal[+T_co, Any]:
135    def deal(
136        self, hand_sizes: int | Iterable[int]
137    ) -> 'icepool.Deal[T_co] | icepool.MultiDeal[T_co, Any]':
138        """Deals the specified number of cards from this deck.
139
140        Args:
141            hand_sizes: Either an integer, in which case a `Deal` will be
142                returned, or an iterable of multiple hand sizes, in which case a
143                `MultiDeal` will be returned.
144        """
145        if isinstance(hand_sizes, int):
146            return icepool.Deal(self, hand_sizes)
147        else:
148            return icepool.MultiDeal(
149                self, tuple((hand_size, 1) for hand_size in hand_sizes))

Deals the specified number of cards from this deck.

Arguments:
  • hand_sizes: Either an integer, in which case a Deal will be returned, or an iterable of multiple hand sizes, in which case a MultiDeal will be returned.
def deal_groups( self, *hand_groups: tuple[int, int]) -> MultiDeal[+T_co, typing.Any]:
151    def deal_groups(
152            self, *hand_groups: tuple[int,
153                                      int]) -> 'icepool.MultiDeal[T_co, Any]':
154        """EXPERIMENTAL: Deal cards into groups of hands, where the hands in each group could be produced in arbitrary order.
155        
156        Args:
157            hand_groups: Each argument is a tuple (hand_size, group_size),
158                denoting the number of cards in each hand of the group and
159                the number of hands in the group respectively.
160        """
161        return icepool.MultiDeal(self, hand_groups)

EXPERIMENTAL: Deal cards into groups of hands, where the hands in each group could be produced in arbitrary order.

Arguments:
  • hand_groups: Each argument is a tuple (hand_size, group_size), denoting the number of cards in each hand of the group and the number of hands in the group respectively.
def additive_union( self, *args: Iterable[+T_co] | Mapping[+T_co, int]) -> Deck[+T_co]:
165    def additive_union(
166            self, *args: Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
167        """Both decks merged together."""
168        return functools.reduce(operator.add, args,
169                                initial=self)  # type: ignore

Both decks merged together.

def difference( self, *args: Iterable[+T_co] | Mapping[+T_co, int]) -> Deck[+T_co]:
180    def difference(self, *args:
181                   Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
182        """This deck with the other cards removed (but not below zero of each card)."""
183        return functools.reduce(operator.sub, args,
184                                initial=self)  # type: ignore

This deck with the other cards removed (but not below zero of each card).

def intersection( self, *args: Iterable[+T_co] | Mapping[+T_co, int]) -> Deck[+T_co]:
200    def intersection(
201            self, *args: Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
202        """The cards that both decks have."""
203        return functools.reduce(operator.and_, args,
204                                initial=self)  # type: ignore

The cards that both decks have.

def union( self, *args: Iterable[+T_co] | Mapping[+T_co, int]) -> Deck[+T_co]:
215    def union(self, *args:
216              Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
217        """As many of each card as the deck that has more of them."""
218        return functools.reduce(operator.or_, args,
219                                initial=self)  # type: ignore

As many of each card as the deck that has more of them.

def symmetric_difference( self, other: Iterable[+T_co] | Mapping[+T_co, int]) -> Deck[+T_co]:
230    def symmetric_difference(
231            self, other: Iterable[T_co] | Mapping[T_co, int]) -> 'Deck[T_co]':
232        """As many of each card as the deck that has more of them."""
233        return self ^ other

As many of each card as the deck that has more of them.

def map( self, repl: Callable[..., ~U | Deck[~U] | RerollType] | Mapping[+T_co, ~U | Deck[~U] | RerollType], /, *, star: bool | None = None) -> Deck[~U]:
259    def map(
260            self,
261            repl:
262        'Callable[..., U | Deck[U] | icepool.RerollType] | Mapping[T_co, U | Deck[U] | icepool.RerollType]',
263            /,
264            *,
265            star: bool | None = None) -> 'Deck[U]':
266        """Maps outcomes of this `Deck` to other outcomes.
267
268        Args:
269            repl: One of the following:
270                * A callable returning a new outcome for each old outcome.
271                * A map from old outcomes to new outcomes.
272                    Unmapped old outcomes stay the same.
273                The new outcomes may be `Deck`s, in which case one card is
274                replaced with several. This is not recommended.
275            star: Whether outcomes should be unpacked into separate arguments
276                before sending them to a callable `repl`.
277                If not provided, this will be guessed based on the function
278                signature.
279        """
280        # Convert to a single-argument function.
281        if callable(repl):
282            if star is None:
283                star = infer_star(repl)
284            if star:
285
286                def transition_function(outcome):
287                    return repl(*outcome)
288            else:
289
290                def transition_function(outcome):
291                    return repl(outcome)
292        else:
293            # repl is a mapping.
294            def transition_function(outcome):
295                if outcome in repl:
296                    return repl[outcome]
297                else:
298                    return outcome
299
300        return Deck(
301            [transition_function(outcome) for outcome in self.outcomes()],
302            times=self.quantities())

Maps outcomes of this Deck to other outcomes.

Arguments:
  • repl: One of the following:
    • A callable returning a new outcome for each old outcome.
    • A map from old outcomes to new outcomes. Unmapped old outcomes stay the same. The new outcomes may be Decks, in which case one card is replaced with several. This is not recommended.
  • star: Whether outcomes should be unpacked into separate arguments before sending them to a callable repl. If not provided, this will be guessed based on the function signature.
def sequence(self, deals: int, /) -> Die[tuple[+T_co, ...]]:
309    def sequence(self, deals: int, /) -> 'icepool.Die[tuple[T_co, ...]]':
310        """Possible sequences produced by dealing from this deck a number of times.
311        
312        This is extremely expensive computationally. If you don't care about
313        order, use `deal()` instead.
314        """
315        if deals < 0:
316            raise ValueError('The number of cards dealt cannot be negative.')
317        for i in range(len(self._sequence_cache), deals + 1):
318
319            def transition(curr):
320                remaining = icepool.Die(self - curr)
321                return icepool.map(lambda curr, next: curr + (next, ), curr,
322                                   remaining)
323
324            result: 'icepool.Die[tuple[T_co, ...]]' = self._sequence_cache[
325                i - 1].map(transition)
326            self._sequence_cache.append(result)
327        return self._sequence_cache[deals]

Possible sequences produced by dealing from this deck a number of times.

This is extremely expensive computationally. If you don't care about order, use deal() instead.

class Deal(icepool.generator.keep.KeepGenerator[~T]):
15class Deal(KeepGenerator[T]):
16    """Represents an unordered deal of a single hand from a `Deck`."""
17
18    _deck: 'icepool.Deck[T]'
19
20    def __init__(self, deck: 'icepool.Deck[T]', hand_size: int) -> None:
21        """Constructor.
22
23        For algorithmic reasons, you must pre-commit to the number of cards to
24        deal.
25
26        It is permissible to deal zero cards from an empty deck, but not all
27        evaluators will handle this case, especially if they depend on the
28        outcome type. Dealing zero cards from a non-empty deck does not have
29        this issue.
30
31        Args:
32            deck: The `Deck` to deal from.
33            hand_size: How many cards to deal.
34        """
35        if hand_size < 0:
36            raise ValueError('hand_size cannot be negative.')
37        if hand_size > deck.size():
38            raise ValueError(
39                'The number of cards dealt cannot exceed the size of the deck.'
40            )
41        self._deck = deck
42        self._keep_tuple = (1, ) * hand_size
43
44    @classmethod
45    def _new_raw(cls, deck: 'icepool.Deck[T]',
46                 keep_tuple: tuple[int, ...]) -> 'Deal[T]':
47        self = super(Deal, cls).__new__(cls)
48        self._deck = deck
49        self._keep_tuple = keep_tuple
50        return self
51
52    def _make_source(self):
53        return DealSource(self._deck, self._keep_tuple)
54
55    def _set_keep_tuple(self, keep_tuple: tuple[int, ...]) -> 'Deal[T]':
56        return Deal._new_raw(self._deck, keep_tuple)
57
58    def deck(self) -> 'icepool.Deck[T]':
59        """The `Deck` the cards are dealt from."""
60        return self._deck
61
62    def hand_size(self) -> int:
63        """The number of cards dealt to each hand as a tuple."""
64        return len(self._keep_tuple)
65
66    def outcomes(self) -> CountsKeysView[T]:
67        """The outcomes of the `Deck` in ascending order.
68
69        These are also the `keys` of the `Deck` as a `Mapping`.
70        Prefer to use the name `outcomes`.
71        """
72        return self.deck().outcomes()
73
74    def denominator(self) -> int:
75        return icepool.math.comb(self.deck().size(), self.hand_size())
76
77    @property
78    def hash_key(self):
79        return Deal, self._deck, self._keep_tuple
80
81    def __repr__(self) -> str:
82        return type(
83            self
84        ).__qualname__ + f'({repr(self.deck())}, hand_size={self.hand_size()})'
85
86    def __str__(self) -> str:
87        return type(
88            self
89        ).__qualname__ + f' of hand_size={self.hand_size()} from deck:\n' + str(
90            self.deck())

Represents an unordered deal of a single hand from a Deck.

Deal(deck: Deck[~T], hand_size: int)
20    def __init__(self, deck: 'icepool.Deck[T]', hand_size: int) -> None:
21        """Constructor.
22
23        For algorithmic reasons, you must pre-commit to the number of cards to
24        deal.
25
26        It is permissible to deal zero cards from an empty deck, but not all
27        evaluators will handle this case, especially if they depend on the
28        outcome type. Dealing zero cards from a non-empty deck does not have
29        this issue.
30
31        Args:
32            deck: The `Deck` to deal from.
33            hand_size: How many cards to deal.
34        """
35        if hand_size < 0:
36            raise ValueError('hand_size cannot be negative.')
37        if hand_size > deck.size():
38            raise ValueError(
39                'The number of cards dealt cannot exceed the size of the deck.'
40            )
41        self._deck = deck
42        self._keep_tuple = (1, ) * hand_size

Constructor.

For algorithmic reasons, you must pre-commit to the number of cards to deal.

It is permissible to deal zero cards from an empty deck, but not all evaluators will handle this case, especially if they depend on the outcome type. Dealing zero cards from a non-empty deck does not have this issue.

Arguments:
  • deck: The Deck to deal from.
  • hand_size: How many cards to deal.
def deck(self) -> Deck[~T]:
58    def deck(self) -> 'icepool.Deck[T]':
59        """The `Deck` the cards are dealt from."""
60        return self._deck

The Deck the cards are dealt from.

def hand_size(self) -> int:
62    def hand_size(self) -> int:
63        """The number of cards dealt to each hand as a tuple."""
64        return len(self._keep_tuple)

The number of cards dealt to each hand as a tuple.

def outcomes(self) -> CountsKeysView[~T]:
66    def outcomes(self) -> CountsKeysView[T]:
67        """The outcomes of the `Deck` in ascending order.
68
69        These are also the `keys` of the `Deck` as a `Mapping`.
70        Prefer to use the name `outcomes`.
71        """
72        return self.deck().outcomes()

The outcomes of the Deck in ascending order.

These are also the keys of the Deck as a Mapping. Prefer to use the name outcomes.

def denominator(self) -> int:
74    def denominator(self) -> int:
75        return icepool.math.comb(self.deck().size(), self.hand_size())
hash_key
77    @property
78    def hash_key(self):
79        return Deal, self._deck, self._keep_tuple

A hash key for this object. This should include a type.

If None, this will not compare equal to any other object.

class MultiDeal(icepool.generator.multiset_tuple_generator.MultisetTupleGenerator[~T, ~IntTupleOut]):
 20class MultiDeal(MultisetTupleGenerator[T, IntTupleOut]):
 21    """Represents an deal of multiple hands from a `Deck`.
 22    
 23    The cards within each hand are in sorted order. Furthermore, hands may be
 24    organized into groups in which the hands are initially indistinguishable.
 25    """
 26
 27    _deck: 'icepool.Deck[T]'
 28    # An ordered tuple of hand groups.
 29    # Each group is designated by (hand_size, hand_count).
 30    _hand_groups: tuple[tuple[int, int], ...]
 31
 32    def __init__(self, deck: 'icepool.Deck[T]',
 33                 hand_groups: tuple[tuple[int, int], ...]) -> None:
 34        """Constructor.
 35
 36        For algorithmic reasons, you must pre-commit to the number of cards to
 37        deal for each hand.
 38
 39        It is permissible to deal zero cards from an empty deck, but not all
 40        evaluators will handle this case, especially if they depend on the
 41        outcome type. Dealing zero cards from a non-empty deck does not have
 42        this issue.
 43
 44        Args:
 45            deck: The `Deck` to deal from.
 46            hand_groups: An ordered tuple of hand groups.
 47                Each group is designated by (hand_size, hand_count) with the
 48                hands of each group being arbitrarily ordered.
 49                The resulting counts are produced in a flat tuple.
 50        """
 51        self._deck = deck
 52        self._hand_groups = hand_groups
 53        if self.total_cards_dealt() > self.deck().size():
 54            raise ValueError(
 55                'The total number of cards dealt cannot exceed the size of the deck.'
 56            )
 57
 58    @classmethod
 59    def _new_raw(
 60        cls, deck: 'icepool.Deck[T]',
 61        hand_sizes: tuple[tuple[int, int],
 62                          ...]) -> 'MultiDeal[T, IntTupleOut]':
 63        self = super(MultiDeal, cls).__new__(cls)
 64        self._deck = deck
 65        self._hand_groups = hand_sizes
 66        return self
 67
 68    def deck(self) -> 'icepool.Deck[T]':
 69        """The `Deck` the cards are dealt from."""
 70        return self._deck
 71
 72    def hand_sizes(self) -> IntTupleOut:
 73        """The number of cards dealt to each hand as a tuple."""
 74        return cast(
 75            IntTupleOut,
 76            tuple(
 77                itertools.chain.from_iterable(
 78                    (hand_size, ) * group_size
 79                    for hand_size, group_size in self._hand_groups)))
 80
 81    def total_cards_dealt(self) -> int:
 82        """The total number of cards dealt."""
 83        return sum(hand_size * group_size
 84                   for hand_size, group_size in self._hand_groups)
 85
 86    def outcomes(self) -> CountsKeysView[T]:
 87        """The outcomes of the `Deck` in ascending order.
 88
 89        These are also the `keys` of the `Deck` as a `Mapping`.
 90        Prefer to use the name `outcomes`.
 91        """
 92        return self.deck().outcomes()
 93
 94    def __len__(self) -> int:
 95        return sum(group_size for _, group_size in self._hand_groups)
 96
 97    @cached_property
 98    def _denominator(self) -> int:
 99        d_total = icepool.math.comb(self.deck().size(),
100                                    self.total_cards_dealt())
101        # The ways to split the dealt cards into hands of the given sizes:
102        # the multinomial coefficient total! / (h1! h2! ... hn!).
103        d_split = 1
104        remaining = self.total_cards_dealt()
105        for h in self.hand_sizes():
106            d_split *= icepool.math.comb(remaining, h)
107            remaining -= h
108        return d_total * d_split
109
110    def denominator(self) -> int:
111        return self._denominator
112
113    def _make_source(self) -> 'MultisetTupleSource[T, IntTupleOut]':
114        return MultiDealSource(self._deck, self._hand_groups)
115
116    @property
117    def hash_key(self) -> Hashable:
118        return MultiDeal, self._deck, self._hand_groups
119
120    def __repr__(self) -> str:
121        return type(
122            self
123        ).__qualname__ + f'({repr(self.deck())}, hand_groups={self._hand_groups})'
124
125    def __str__(self) -> str:
126        return type(
127            self
128        ).__qualname__ + f' of hand_groups={self._hand_groups} from deck:\n' + str(
129            self.deck())

Represents an deal of multiple hands from a Deck.

The cards within each hand are in sorted order. Furthermore, hands may be organized into groups in which the hands are initially indistinguishable.

MultiDeal( deck: Deck[~T], hand_groups: tuple[tuple[int, int], ...])
32    def __init__(self, deck: 'icepool.Deck[T]',
33                 hand_groups: tuple[tuple[int, int], ...]) -> None:
34        """Constructor.
35
36        For algorithmic reasons, you must pre-commit to the number of cards to
37        deal for each hand.
38
39        It is permissible to deal zero cards from an empty deck, but not all
40        evaluators will handle this case, especially if they depend on the
41        outcome type. Dealing zero cards from a non-empty deck does not have
42        this issue.
43
44        Args:
45            deck: The `Deck` to deal from.
46            hand_groups: An ordered tuple of hand groups.
47                Each group is designated by (hand_size, hand_count) with the
48                hands of each group being arbitrarily ordered.
49                The resulting counts are produced in a flat tuple.
50        """
51        self._deck = deck
52        self._hand_groups = hand_groups
53        if self.total_cards_dealt() > self.deck().size():
54            raise ValueError(
55                'The total number of cards dealt cannot exceed the size of the deck.'
56            )

Constructor.

For algorithmic reasons, you must pre-commit to the number of cards to deal for each hand.

It is permissible to deal zero cards from an empty deck, but not all evaluators will handle this case, especially if they depend on the outcome type. Dealing zero cards from a non-empty deck does not have this issue.

Arguments:
  • deck: The Deck to deal from.
  • hand_groups: An ordered tuple of hand groups. Each group is designated by (hand_size, hand_count) with the hands of each group being arbitrarily ordered. The resulting counts are produced in a flat tuple.
def deck(self) -> Deck[~T]:
68    def deck(self) -> 'icepool.Deck[T]':
69        """The `Deck` the cards are dealt from."""
70        return self._deck

The Deck the cards are dealt from.

def hand_sizes(self) -> ~IntTupleOut:
72    def hand_sizes(self) -> IntTupleOut:
73        """The number of cards dealt to each hand as a tuple."""
74        return cast(
75            IntTupleOut,
76            tuple(
77                itertools.chain.from_iterable(
78                    (hand_size, ) * group_size
79                    for hand_size, group_size in self._hand_groups)))

The number of cards dealt to each hand as a tuple.

def total_cards_dealt(self) -> int:
81    def total_cards_dealt(self) -> int:
82        """The total number of cards dealt."""
83        return sum(hand_size * group_size
84                   for hand_size, group_size in self._hand_groups)

The total number of cards dealt.

def outcomes(self) -> CountsKeysView[~T]:
86    def outcomes(self) -> CountsKeysView[T]:
87        """The outcomes of the `Deck` in ascending order.
88
89        These are also the `keys` of the `Deck` as a `Mapping`.
90        Prefer to use the name `outcomes`.
91        """
92        return self.deck().outcomes()

The outcomes of the Deck in ascending order.

These are also the keys of the Deck as a Mapping. Prefer to use the name outcomes.

def denominator(self) -> int:
110    def denominator(self) -> int:
111        return self._denominator
hash_key: Hashable
116    @property
117    def hash_key(self) -> Hashable:
118        return MultiDeal, self._deck, self._hand_groups

A hash key for this object. This should include a type.

If None, this will not compare equal to any other object.

def multiset_function( wrapped: Callable[..., icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, +U_co] | tuple[icepool.evaluator.multiset_function.MultisetFunctionRawResult[~T, +U_co], ...]], /) -> icepool.evaluator.multiset_evaluator_base.MultisetEvaluatorBase[~T, +U_co]:
 55def multiset_function(wrapped: Callable[
 56    ...,
 57    'MultisetFunctionRawResult[T, U_co] | tuple[MultisetFunctionRawResult[T, U_co], ...]'],
 58                      /) -> 'MultisetEvaluatorBase[T, U_co]':
 59    """EXPERIMENTAL: A decorator that turns a function into a multiset evaluator.
 60
 61    The provided function should take in arguments representing multisets,
 62    do a limited set of operations on them (see `MultisetExpression`), and
 63    finish off with an evaluation. You can return a tuple to perform a joint
 64    evaluation.
 65
 66    For example, to create an evaluator which computes the elements each of two
 67    multisets has that the other doesn't:
 68    ```python
 69    @multiset_function
 70    def two_way_difference(a, b):
 71        return (a - b).expand(), (b - a).expand()
 72    ```
 73
 74    The special `star` keyword argument will unpack tuple-valued counts of the
 75    first argument inside the multiset function. For example,
 76    ```python
 77    hands = deck.deal((5, 5))
 78    two_way_difference(hands, star=True)
 79    ```
 80    effectively unpacks as if we had written
 81    ```python
 82    @multiset_function
 83    def two_way_difference(hands):
 84        a, b = hands
 85        return (a - b).expand(), (b - a).expand()
 86    ```
 87
 88    If not provided explicitly, `star` will be inferred automatically.
 89
 90    You can pass non-multiset values as keyword-only arguments.
 91    ```python
 92    @multiset_function
 93    def count_outcomes(a, *, target):
 94        return a.keep_outcomes(target).size()
 95
 96    count_outcomes(a, target=[5, 6])
 97    ```
 98
 99    While in theory `@multiset_function` implements late binding similar to
100    ordinary Python functions, I recommend using only pure functions.
101
102    Be careful when using control structures: you cannot branch on the value of
103    a multiset expression or evaluation, so e.g.
104
105    ```python
106    @multiset_function
107    def bad(a, b)
108        if a == b:
109            ...
110    ```
111
112    is not allowed.
113
114    `multiset_function` has considerable overhead, being effectively a
115    mini-language within Python. For better performance, you can try
116    implementing your own subclass of `MultisetEvaluator` directly.
117
118    Args:
119        function: This should take in multiset expressions as positional
120            arguments, and non-multiset variables as keyword arguments.
121    """
122    return MultisetFunctionEvaluator(wrapped)

EXPERIMENTAL: A decorator that turns a function into a multiset evaluator.

The provided function should take in arguments representing multisets, do a limited set of operations on them (see MultisetExpression), and finish off with an evaluation. You can return a tuple to perform a joint evaluation.

For example, to create an evaluator which computes the elements each of two multisets has that the other doesn't:

@multiset_function
def two_way_difference(a, b):
    return (a - b).expand(), (b - a).expand()

The special star keyword argument will unpack tuple-valued counts of the first argument inside the multiset function. For example,

hands = deck.deal((5, 5))
two_way_difference(hands, star=True)

effectively unpacks as if we had written

@multiset_function
def two_way_difference(hands):
    a, b = hands
    return (a - b).expand(), (b - a).expand()

If not provided explicitly, star will be inferred automatically.

You can pass non-multiset values as keyword-only arguments.

@multiset_function
def count_outcomes(a, *, target):
    return a.keep_outcomes(target).size()

count_outcomes(a, target=[5, 6])

While in theory @multiset_function implements late binding similar to ordinary Python functions, I recommend using only pure functions.

Be careful when using control structures: you cannot branch on the value of a multiset expression or evaluation, so e.g.

@multiset_function
def bad(a, b)
    if a == b:
        ...

is not allowed.

multiset_function has considerable overhead, being effectively a mini-language within Python. For better performance, you can try implementing your own subclass of MultisetEvaluator directly.

Arguments:
  • function: This should take in multiset expressions as positional arguments, and non-multiset variables as keyword arguments.
class MultisetParameter(icepool.expression.multiset_parameter.MultisetParameterBase[~T, int], icepool.MultisetExpression[~T]):
48class MultisetParameter(MultisetParameterBase[T, int], MultisetExpression[T]):
49    """A multiset parameter with a count of a single `int`."""
50
51    def __init__(self, name: str, arg_index: int, star_index: int | None):
52        self._name = name
53        self._arg_index = arg_index
54        self._star_index = star_index

A multiset parameter with a count of a single int.

MultisetParameter(name: str, arg_index: int, star_index: int | None)
51    def __init__(self, name: str, arg_index: int, star_index: int | None):
52        self._name = name
53        self._arg_index = arg_index
54        self._star_index = star_index
class MultisetTupleParameter(icepool.expression.multiset_parameter.MultisetParameterBase[~T, ~IntTupleOut], icepool.expression.multiset_tuple_expression.MultisetTupleExpression[~T, ~IntTupleOut]):
57class MultisetTupleParameter(MultisetParameterBase[T, IntTupleOut],
58                             MultisetTupleExpression[T, IntTupleOut]):
59    """A multiset parameter with a count of a tuple of `int`s."""
60
61    def __init__(self, name: str, arg_index: int, length: int):
62        self._name = name
63        self._arg_index = arg_index
64        self._star_index = None
65        self._length = length
66
67    def __len__(self):
68        return self._length

A multiset parameter with a count of a tuple of ints.

MultisetTupleParameter(name: str, arg_index: int, length: int)
61    def __init__(self, name: str, arg_index: int, length: int):
62        self._name = name
63        self._arg_index = arg_index
64        self._star_index = None
65        self._length = length
NoCache: Final = <NoCacheType.NoCache: 'NoCache'>

Indicates that caching should not be performed. Exact meaning depends on context.

def format_probability_inverse(probability, /, int_start: int = 20):
22def format_probability_inverse(probability, /, int_start: int = 20):
23    """EXPERIMENTAL: Formats the inverse of a value as "1 in N".
24    
25    Args:
26        probability: The value to be formatted.
27        int_start: If N = 1 / probability is between this value and 1 million
28            times this value it will be formatted as an integer. Otherwise it 
29            be formatted asa float with precision at least 1 part in int_start.
30    """
31    max_precision = math.ceil(math.log10(int_start))
32    if probability <= 0 or probability > 1:
33        return 'n/a'
34    product = probability * int_start
35    if product <= 1:
36        if probability * int_start * 10**6 <= 1:
37            return f'1 in {1.0 / probability:<.{max_precision}e}'
38        else:
39            return f'1 in {round(1 / probability)}'
40
41    precision = 0
42    precision_factor = 1
43    while product > precision_factor and precision < max_precision:
44        precision += 1
45        precision_factor *= 10
46    return f'1 in {1.0 / probability:<.{precision}f}'

EXPERIMENTAL: Formats the inverse of a value as "1 in N".

Arguments:
  • probability: The value to be formatted.
  • int_start: If N = 1 / probability is between this value and 1 million times this value it will be formatted as an integer. Otherwise it be formatted asa float with precision at least 1 part in int_start.
class Wallenius(typing.Generic[~T]):
28class Wallenius(Generic[T]):
29    """EXPERIMENTAL: Wallenius' noncentral hypergeometric distribution.
30
31    This is sampling without replacement with weights, where the entire weight
32    of a card goes away when it is pulled.
33    """
34    _weight_decks: 'MutableMapping[int, icepool.Deck[T]]'
35    _weight_die: 'icepool.Die[int]'
36
37    def __init__(self, data: Iterable[tuple[T, int]]
38                 | Mapping[T, int | tuple[int, int]]):
39        """Constructor.
40        
41        Args:
42            data: Either an iterable of (outcome, weight), or a mapping from
43                outcomes to either weights or (weight, quantity).
44            hand_size: The number of outcomes to pull.
45        """
46        self._weight_decks = {}
47
48        if isinstance(data, Mapping):
49            for outcome, v in data.items():
50                if isinstance(v, int):
51                    weight = v
52                    quantity = 1
53                else:
54                    weight, quantity = v
55                self._weight_decks[weight] = self._weight_decks.get(
56                    weight, icepool.Deck()).append(outcome, quantity)
57        else:
58            for outcome, weight in data:
59                self._weight_decks[weight] = self._weight_decks.get(
60                    weight, icepool.Deck()).append(outcome)
61
62        self._weight_die = icepool.Die({
63            weight: weight * deck.denominator()
64            for weight, deck in self._weight_decks.items()
65        })
66
67    def deal(self, hand_size: int, /) -> 'icepool.MultisetExpression[T]':
68        """Deals the specified number of outcomes from the Wallenius.
69        
70        The result is a `MultisetExpression` representing the multiset of
71        outcomes dealt.
72        """
73        if hand_size == 0:
74            return icepool.Pool([])
75
76        def inner(weights):
77            weight_counts = Counter(weights)
78            result = None
79            for weight, count in weight_counts.items():
80                deal = self._weight_decks[weight].deal(count)
81                if result is None:
82                    result = deal
83                else:
84                    result = result + deal
85            return result
86
87        hand_weights = _wallenius_weights(self._weight_die, hand_size)
88        return hand_weights.map_to_pool(inner, star=False)

EXPERIMENTAL: Wallenius' noncentral hypergeometric distribution.

This is sampling without replacement with weights, where the entire weight of a card goes away when it is pulled.

Wallenius(data: Iterable[tuple[~T, int]] | Mapping[~T, int | tuple[int, int]])
37    def __init__(self, data: Iterable[tuple[T, int]]
38                 | Mapping[T, int | tuple[int, int]]):
39        """Constructor.
40        
41        Args:
42            data: Either an iterable of (outcome, weight), or a mapping from
43                outcomes to either weights or (weight, quantity).
44            hand_size: The number of outcomes to pull.
45        """
46        self._weight_decks = {}
47
48        if isinstance(data, Mapping):
49            for outcome, v in data.items():
50                if isinstance(v, int):
51                    weight = v
52                    quantity = 1
53                else:
54                    weight, quantity = v
55                self._weight_decks[weight] = self._weight_decks.get(
56                    weight, icepool.Deck()).append(outcome, quantity)
57        else:
58            for outcome, weight in data:
59                self._weight_decks[weight] = self._weight_decks.get(
60                    weight, icepool.Deck()).append(outcome)
61
62        self._weight_die = icepool.Die({
63            weight: weight * deck.denominator()
64            for weight, deck in self._weight_decks.items()
65        })

Constructor.

Arguments:
  • data: Either an iterable of (outcome, weight), or a mapping from outcomes to either weights or (weight, quantity).
  • hand_size: The number of outcomes to pull.
def deal( self, hand_size: int, /) -> MultisetExpression[~T]:
67    def deal(self, hand_size: int, /) -> 'icepool.MultisetExpression[T]':
68        """Deals the specified number of outcomes from the Wallenius.
69        
70        The result is a `MultisetExpression` representing the multiset of
71        outcomes dealt.
72        """
73        if hand_size == 0:
74            return icepool.Pool([])
75
76        def inner(weights):
77            weight_counts = Counter(weights)
78            result = None
79            for weight, count in weight_counts.items():
80                deal = self._weight_decks[weight].deal(count)
81                if result is None:
82                    result = deal
83                else:
84                    result = result + deal
85            return result
86
87        hand_weights = _wallenius_weights(self._weight_die, hand_size)
88        return hand_weights.map_to_pool(inner, star=False)

Deals the specified number of outcomes from the Wallenius.

The result is a MultisetExpression representing the multiset of outcomes dealt.