diff --git a/apidoc/latest/icepool.html b/apidoc/latest/icepool.html index 42243366..b06d5e6e 100644 --- a/apidoc/latest/icepool.html +++ b/apidoc/latest/icepool.html @@ -3,7 +3,7 @@ - + icepool API documentation @@ -2023,848 +2023,852 @@
Arguments:
735 def _sum_all(self, rolls: int, /) -> 'Die': 736 """Roll this `Die` `rolls` times and sum the results. 737 - 738 If `rolls` is negative, roll the `Die` `abs(rolls)` times and negate - 739 the result. + 738 The sum is computed one at a time, with the new item on the right, + 739 similar to `functools.reduce()`. 740 - 741 If you instead want to replace tuple (or other sequence) outcomes with - 742 their sum, use `die.map(sum)`. - 743 """ - 744 if rolls in self._sum_cache: - 745 return self._sum_cache[rolls] - 746 - 747 if rolls < 0: - 748 result = -self._sum_all(-rolls) - 749 elif rolls == 0: - 750 result = self.zero().simplify() - 751 elif rolls == 1: - 752 result = self - 753 else: - 754 # Binary split seems to perform much worse. - 755 result = self + self._sum_all(rolls - 1) - 756 - 757 self._sum_cache[rolls] = result - 758 return result - 759 - 760 def __matmul__(self: 'Die[int]', other) -> 'Die': - 761 """Roll the left `Die`, then roll the right `Die` that many times and sum the outcomes.""" - 762 if isinstance(other, icepool.AgainExpression): - 763 return NotImplemented - 764 other = implicit_convert_to_die(other) - 765 - 766 data: MutableMapping[int, Any] = defaultdict(int) - 767 - 768 max_abs_die_count = max(abs(self.min_outcome()), - 769 abs(self.max_outcome())) - 770 for die_count, die_count_quantity in self.items(): - 771 factor = other.denominator()**(max_abs_die_count - abs(die_count)) - 772 subresult = other._sum_all(die_count) - 773 for outcome, subresult_quantity in subresult.items(): - 774 data[ - 775 outcome] += subresult_quantity * die_count_quantity * factor - 776 - 777 return icepool.Die(data) - 778 - 779 def __rmatmul__(self, other: 'int | Die[int]') -> 'Die': - 780 """Roll the left `Die`, then roll the right `Die` that many times and sum the outcomes.""" - 781 if isinstance(other, icepool.AgainExpression): - 782 return NotImplemented - 783 other = implicit_convert_to_die(other) - 784 return other.__matmul__(self) - 785 - 786 def pool(self, rolls: int | Sequence[int] = 1, /) -> 'icepool.Pool[T_co]': - 787 """Creates a `Pool` from this `Die`. - 788 - 789 You might subscript the pool immediately afterwards, e.g. - 790 `d6.pool(5)[-1, ..., 1]` takes the difference between the highest and - 791 lowest of 5d6. + 741 If `rolls` is negative, roll the `Die` `abs(rolls)` times and negate + 742 the result. + 743 + 744 If you instead want to replace tuple (or other sequence) outcomes with + 745 their sum, use `die.map(sum)`. + 746 """ + 747 if rolls in self._sum_cache: + 748 return self._sum_cache[rolls] + 749 + 750 if rolls < 0: + 751 result = -self._sum_all(-rolls) + 752 elif rolls == 0: + 753 result = self.zero().simplify() + 754 elif rolls == 1: + 755 result = self + 756 else: + 757 # In addition to working similar to reduce(), this seems to perform + 758 # better than binary split. + 759 result = self._sum_all(rolls - 1) + self + 760 + 761 self._sum_cache[rolls] = result + 762 return result + 763 + 764 def __matmul__(self: 'Die[int]', other) -> 'Die': + 765 """Roll the left `Die`, then roll the right `Die` that many times and sum the outcomes.""" + 766 if isinstance(other, icepool.AgainExpression): + 767 return NotImplemented + 768 other = implicit_convert_to_die(other) + 769 + 770 data: MutableMapping[int, Any] = defaultdict(int) + 771 + 772 max_abs_die_count = max(abs(self.min_outcome()), + 773 abs(self.max_outcome())) + 774 for die_count, die_count_quantity in self.items(): + 775 factor = other.denominator()**(max_abs_die_count - abs(die_count)) + 776 subresult = other._sum_all(die_count) + 777 for outcome, subresult_quantity in subresult.items(): + 778 data[ + 779 outcome] += subresult_quantity * die_count_quantity * factor + 780 + 781 return icepool.Die(data) + 782 + 783 def __rmatmul__(self, other: 'int | Die[int]') -> 'Die': + 784 """Roll the left `Die`, then roll the right `Die` that many times and sum the outcomes.""" + 785 if isinstance(other, icepool.AgainExpression): + 786 return NotImplemented + 787 other = implicit_convert_to_die(other) + 788 return other.__matmul__(self) + 789 + 790 def pool(self, rolls: int | Sequence[int] = 1, /) -> 'icepool.Pool[T_co]': + 791 """Creates a `Pool` from this `Die`. 792 - 793 Args: - 794 rolls: The number of copies of this `Die` to put in the pool. - 795 Or, a sequence of one `int` per die acting as - 796 `keep_tuple`. Note that `...` cannot be used in the - 797 argument to this method, as the argument determines the size of - 798 the pool. - 799 """ - 800 if isinstance(rolls, int): - 801 return icepool.Pool({self: rolls}) - 802 else: - 803 pool_size = len(rolls) - 804 # Haven't dealt with narrowing return type. - 805 return icepool.Pool({self: pool_size})[rolls] # type: ignore - 806 - 807 @overload - 808 def keep(self, rolls: Sequence[int], /) -> 'Die': - 809 """Selects elements after drawing and sorting and sums them. + 793 You might subscript the pool immediately afterwards, e.g. + 794 `d6.pool(5)[-1, ..., 1]` takes the difference between the highest and + 795 lowest of 5d6. + 796 + 797 Args: + 798 rolls: The number of copies of this `Die` to put in the pool. + 799 Or, a sequence of one `int` per die acting as + 800 `keep_tuple`. Note that `...` cannot be used in the + 801 argument to this method, as the argument determines the size of + 802 the pool. + 803 """ + 804 if isinstance(rolls, int): + 805 return icepool.Pool({self: rolls}) + 806 else: + 807 pool_size = len(rolls) + 808 # Haven't dealt with narrowing return type. + 809 return icepool.Pool({self: pool_size})[rolls] # type: ignore 810 - 811 Args: - 812 rolls: A sequence of `int` specifying how many times to count each - 813 element in ascending order. - 814 """ - 815 - 816 @overload - 817 def keep(self, rolls: int, - 818 index: slice | Sequence[int | EllipsisType] | int, /): - 819 """Selects elements after drawing and sorting and sums them. - 820 - 821 Args: - 822 rolls: The number of dice to roll. - 823 index: One of the following: - 824 * An `int`. This will count only the roll at the specified index. - 825 In this case, the result is a `Die` rather than a generator. - 826 * A `slice`. The selected dice are counted once each. - 827 * A sequence of one `int` for each `Die`. - 828 Each roll is counted that many times, which could be multiple or - 829 negative times. - 830 - 831 Up to one `...` (`Ellipsis`) may be used. - 832 `...` will be replaced with a number of zero - 833 counts depending on the `rolls`. - 834 This number may be "negative" if more `int`s are provided than - 835 `rolls`. Specifically: - 836 - 837 * If `index` is shorter than `rolls`, `...` - 838 acts as enough zero counts to make up the difference. - 839 E.g. `(1, ..., 1)` on five dice would act as - 840 `(1, 0, 0, 0, 1)`. - 841 * If `index` has length equal to `rolls`, `...` has no effect. - 842 E.g. `(1, ..., 1)` on two dice would act as `(1, 1)`. - 843 * If `index` is longer than `rolls` and `...` is on one side, - 844 elements will be dropped from `index` on the side with `...`. - 845 E.g. `(..., 1, 2, 3)` on two dice would act as `(2, 3)`. - 846 * If `index` is longer than `rolls` and `...` - 847 is in the middle, the counts will be as the sum of two - 848 one-sided `...`. - 849 E.g. `(-1, ..., 1)` acts like `(-1, ...)` plus `(..., 1)`. - 850 If `rolls` was 1 this would have the -1 and 1 cancel each other out. - 851 """ - 852 - 853 def keep(self, - 854 rolls: int | Sequence[int], - 855 index: slice | Sequence[int | EllipsisType] | int | None = None, - 856 /) -> 'Die': - 857 """Selects elements after drawing and sorting and sums them. - 858 - 859 Args: - 860 rolls: The number of dice to roll. - 861 index: One of the following: - 862 * An `int`. This will count only the roll at the specified index. - 863 In this case, the result is a `Die` rather than a generator. - 864 * A `slice`. The selected dice are counted once each. - 865 * A sequence of `int`s with length equal to `rolls`. - 866 Each roll is counted that many times, which could be multiple or - 867 negative times. - 868 - 869 Up to one `...` (`Ellipsis`) may be used. If no `...` is used, - 870 the `rolls` argument may be omitted. - 871 - 872 `...` will be replaced with a number of zero counts in order - 873 to make up any missing elements compared to `rolls`. - 874 This number may be "negative" if more `int`s are provided than - 875 `rolls`. Specifically: - 876 - 877 * If `index` is shorter than `rolls`, `...` - 878 acts as enough zero counts to make up the difference. - 879 E.g. `(1, ..., 1)` on five dice would act as - 880 `(1, 0, 0, 0, 1)`. - 881 * If `index` has length equal to `rolls`, `...` has no effect. - 882 E.g. `(1, ..., 1)` on two dice would act as `(1, 1)`. - 883 * If `index` is longer than `rolls` and `...` is on one side, - 884 elements will be dropped from `index` on the side with `...`. - 885 E.g. `(..., 1, 2, 3)` on two dice would act as `(2, 3)`. - 886 * If `index` is longer than `rolls` and `...` - 887 is in the middle, the counts will be as the sum of two - 888 one-sided `...`. - 889 E.g. `(-1, ..., 1)` acts like `(-1, ...)` plus `(..., 1)`. - 890 If `rolls` was 1 this would have the -1 and 1 cancel each other out. - 891 """ - 892 if isinstance(rolls, int): - 893 if index is None: - 894 raise ValueError( - 895 'If the number of rolls is an integer, an index argument must be provided.' - 896 ) - 897 if isinstance(index, int): - 898 return self.pool(rolls).keep(index) - 899 else: - 900 return self.pool(rolls).keep(index).sum() # type: ignore - 901 else: - 902 if index is not None: - 903 raise ValueError('Only one index sequence can be given.') - 904 return self.pool(len(rolls)).keep(rolls).sum() # type: ignore - 905 - 906 def lowest(self, - 907 rolls: int, - 908 /, - 909 keep: int | None = None, - 910 drop: int | None = None) -> 'Die': - 911 """Roll several of this `Die` and return the lowest result, or the sum of some of the lowest. - 912 - 913 The outcomes should support addition and multiplication if `keep != 1`. - 914 - 915 Args: - 916 rolls: The number of dice to roll. All dice will have the same - 917 outcomes as `self`. - 918 keep, drop: These arguments work together: - 919 * If neither are provided, the single lowest die will be taken. - 920 * If only `keep` is provided, the `keep` lowest dice will be summed. - 921 * If only `drop` is provided, the `drop` lowest dice will be dropped - 922 and the rest will be summed. - 923 * If both are provided, `drop` lowest dice will be dropped, then - 924 the next `keep` lowest dice will be summed. - 925 - 926 Returns: - 927 A `Die` representing the probability distribution of the sum. - 928 """ - 929 index = lowest_slice(keep, drop) - 930 canonical = canonical_slice(index, rolls) - 931 if canonical.start == 0 and canonical.stop == 1: - 932 return self._lowest_single(rolls) - 933 # Expression evaluators are difficult to type. - 934 return self.pool(rolls)[index].sum() # type: ignore - 935 - 936 def _lowest_single(self, rolls: int, /) -> 'Die': - 937 """Roll this die several times and keep the lowest.""" - 938 if rolls == 0: - 939 return self.zero().simplify() - 940 return icepool.from_cumulative( - 941 self.outcomes(), [x**rolls for x in self.quantities('>=')], - 942 reverse=True) - 943 - 944 def highest(self, - 945 rolls: int, - 946 /, - 947 keep: int | None = None, - 948 drop: int | None = None) -> 'Die[T_co]': - 949 """Roll several of this `Die` and return the highest result, or the sum of some of the highest. - 950 - 951 The outcomes should support addition and multiplication if `keep != 1`. - 952 - 953 Args: - 954 rolls: The number of dice to roll. - 955 keep, drop: These arguments work together: - 956 * If neither are provided, the single highest die will be taken. - 957 * If only `keep` is provided, the `keep` highest dice will be summed. - 958 * If only `drop` is provided, the `drop` highest dice will be dropped - 959 and the rest will be summed. - 960 * If both are provided, `drop` highest dice will be dropped, then - 961 the next `keep` highest dice will be summed. - 962 - 963 Returns: - 964 A `Die` representing the probability distribution of the sum. - 965 """ - 966 index = highest_slice(keep, drop) - 967 canonical = canonical_slice(index, rolls) - 968 if canonical.start == rolls - 1 and canonical.stop == rolls: - 969 return self._highest_single(rolls) - 970 # Expression evaluators are difficult to type. - 971 return self.pool(rolls)[index].sum() # type: ignore - 972 - 973 def _highest_single(self, rolls: int, /) -> 'Die[T_co]': - 974 """Roll this die several times and keep the highest.""" - 975 if rolls == 0: - 976 return self.zero().simplify() - 977 return icepool.from_cumulative( - 978 self.outcomes(), [x**rolls for x in self.quantities('<=')]) - 979 - 980 def middle( - 981 self, - 982 rolls: int, - 983 /, - 984 keep: int = 1, - 985 *, - 986 tie: Literal['error', 'high', 'low'] = 'error') -> 'icepool.Die': - 987 """Roll several of this `Die` and sum the sorted results in the middle. - 988 - 989 The outcomes should support addition and multiplication if `keep != 1`. - 990 - 991 Args: - 992 rolls: The number of dice to roll. - 993 keep: The number of outcomes to sum. If this is greater than the - 994 current keep_size, all are kept. - 995 tie: What to do if `keep` is odd but the current keep_size - 996 is even, or vice versa. - 997 * 'error' (default): Raises `IndexError`. - 998 * 'high': The higher outcome is taken. - 999 * 'low': The lower outcome is taken. -1000 """ -1001 # Expression evaluators are difficult to type. -1002 return self.pool(rolls).middle(keep, tie=tie).sum() # type: ignore -1003 -1004 def map_to_pool( -1005 self, -1006 repl: -1007 'Callable[..., Sequence[icepool.Die[U] | U] | Mapping[icepool.Die[U], int] | Mapping[U, int] | icepool.RerollType] | None' = None, -1008 /, -1009 *extra_args: 'Outcome | icepool.Die | icepool.MultisetExpression', -1010 star: bool | None = None, -1011 denominator: int | None = None -1012 ) -> 'icepool.MultisetGenerator[U, tuple[int]]': -1013 """EXPERIMENTAL: Maps outcomes of this `Die` to `Pools`, creating a `MultisetGenerator`. -1014 -1015 As `icepool.map_to_pool(repl, self, ...)`. -1016 -1017 If no argument is provided, the outcomes will be used to construct a -1018 mixture of pools directly, similar to the inverse of `pool.expand()`. -1019 Note that this is not particularly efficient since it does not make much -1020 use of dynamic programming. -1021 -1022 Args: -1023 repl: One of the following: -1024 * A callable that takes in one outcome per element of args and -1025 produces a `Pool` (or something convertible to such). -1026 * A mapping from old outcomes to `Pool` -1027 (or something convertible to such). -1028 In this case args must have exactly one element. -1029 The new outcomes may be dice rather than just single outcomes. -1030 The special value `icepool.Reroll` will reroll that old outcome. -1031 star: If `True`, the first of the args will be unpacked before -1032 giving them to `repl`. -1033 If not provided, it will be guessed based on the signature of -1034 `repl` and the number of arguments. -1035 denominator: If provided, the denominator of the result will be this -1036 value. Otherwise it will be the minimum to correctly weight the -1037 pools. -1038 -1039 Returns: -1040 A `MultisetGenerator` representing the mixture of `Pool`s. Note -1041 that this is not technically a `Pool`, though it supports most of -1042 the same operations. -1043 -1044 Raises: -1045 ValueError: If `denominator` cannot be made consistent with the -1046 resulting mixture of pools. -1047 """ -1048 if repl is None: -1049 repl = lambda x: x -1050 return icepool.map_to_pool(repl, -1051 self, -1052 *extra_args, -1053 star=star, -1054 denominator=denominator) -1055 -1056 def explode_to_pool( -1057 self, -1058 rolls: int, -1059 which: Collection[T_co] | Callable[..., bool] | None = None, -1060 /, -1061 *, -1062 star: bool | None = None, -1063 depth: int = 9) -> 'icepool.MultisetGenerator[T_co, tuple[int]]': -1064 """EXPERIMENTAL: Causes outcomes to be rolled again, keeping that outcome as an individual die in a pool. -1065 -1066 Args: -1067 rolls: The number of initial dice. -1068 which: Which outcomes to explode. Options: -1069 * A single outcome to explode. -1070 * An collection of outcomes to explode. -1071 * A callable that takes an outcome and returns `True` if it -1072 should be exploded. -1073 * If not supplied, the max outcome will explode. -1074 star: Whether outcomes should be unpacked into separate arguments -1075 before sending them to a callable `which`. -1076 If not provided, this will be guessed based on the function -1077 signature. -1078 depth: The maximum depth of explosions for an individual dice. -1079 -1080 Returns: -1081 A `MultisetGenerator` representing the mixture of `Pool`s. Note -1082 that this is not technically a `Pool`, though it supports most of -1083 the same operations. -1084 """ -1085 if depth == 0: -1086 return self.pool(rolls) -1087 if which is None: -1088 explode_set = {self.max_outcome()} -1089 else: -1090 explode_set = self._select_outcomes(which, star) -1091 if not explode_set: -1092 return self.pool(rolls) -1093 explode, not_explode = self.split(explode_set) -1094 -1095 single_data: 'MutableMapping[icepool.Vector[int], int]' = defaultdict( -1096 int) -1097 for i in range(depth + 1): -1098 weight = explode.denominator()**i * self.denominator()**( -1099 depth - i) * not_explode.denominator() -1100 single_data[icepool.Vector((i, 1))] += weight -1101 single_data[icepool.Vector( -1102 (depth + 1, 0))] += explode.denominator()**(depth + 1) -1103 -1104 single_count_die: 'Die[icepool.Vector[int]]' = Die(single_data) -1105 count_die = rolls @ single_count_die -1106 -1107 return count_die.map_to_pool( -1108 lambda x, nx: [explode] * x + [not_explode] * nx) -1109 -1110 def reroll_to_pool( -1111 self, -1112 rolls: int, -1113 which: Callable[..., bool] | Collection[T_co], -1114 /, -1115 max_rerolls: int, -1116 *, -1117 star: bool | None = None, -1118 mode: Literal['random', 'lowest', 'highest', 'drop'] = 'random' -1119 ) -> 'icepool.MultisetGenerator[T_co, tuple[int]]': -1120 """EXPERIMENTAL: Applies a limited number of rerolls shared across a pool. -1121 -1122 Each die can only be rerolled once (effectively `depth=1`), and no more -1123 than `max_rerolls` dice may be rerolled. -1124 -1125 Args: -1126 rolls: How many dice in the pool. -1127 which: Selects which outcomes are eligible to be rerolled. Options: -1128 * A collection of outcomes to reroll. -1129 * A callable that takes an outcome and returns `True` if it -1130 could be rerolled. -1131 max_rerolls: The maximum number of dice to reroll. -1132 Note that each die can only be rerolled once, so if the number -1133 of eligible dice is less than this, the excess rerolls have no -1134 effect. -1135 star: Whether outcomes should be unpacked into separate arguments -1136 before sending them to a callable `which`. -1137 If not provided, this will be guessed based on the function -1138 signature. -1139 mode: How dice are selected for rerolling if there are more eligible -1140 dice than `max_rerolls`. Options: -1141 * `'random'` (default): Eligible dice will be chosen uniformly -1142 at random. -1143 * `'lowest'`: The lowest eligible dice will be rerolled. -1144 * `'highest'`: The highest eligible dice will be rerolled. -1145 * `'drop'`: All dice that ended up on an outcome selected by -1146 `which` will be dropped. This includes both dice that rolled -1147 into `which` initially and were not rerolled, and dice that -1148 were rerolled but rolled into `which` again. This can be -1149 considerably more efficient than the other modes. -1150 -1151 Returns: -1152 A `MultisetGenerator` representing the mixture of `Pool`s. Note -1153 that this is not technically a `Pool`, though it supports most of -1154 the same operations. -1155 """ -1156 rerollable_set = self._select_outcomes(which, star) -1157 if not rerollable_set: -1158 return self.pool(rolls) -1159 -1160 rerollable_die, not_rerollable_die = self.split(rerollable_set) -1161 single_is_rerollable = icepool.coin(rerollable_die.denominator(), -1162 self.denominator()) -1163 rerollable = rolls @ single_is_rerollable -1164 -1165 def split(initial_rerollable: int) -> Die[tuple[int, int, int]]: -1166 """Computes the composition of the pool. -1167 -1168 Returns: -1169 initial_rerollable: The number of dice that initially fell into -1170 the rerollable set. -1171 rerolled_to_rerollable: The number of dice that were rerolled, -1172 but fell into the rerollable set again. -1173 not_rerollable: The number of dice that ended up outside the -1174 rerollable set, including both initial and rerolled dice. -1175 not_rerolled: The number of dice that were eligible for -1176 rerolling but were not rerolled. -1177 """ -1178 initial_not_rerollable = rolls - initial_rerollable -1179 rerolled = min(initial_rerollable, max_rerolls) -1180 not_rerolled = initial_rerollable - rerolled -1181 -1182 def second_split(rerolled_to_rerollable): -1183 """Splits the rerolled dice into those that fell into the rerollable and not-rerollable sets.""" -1184 rerolled_to_not_rerollable = rerolled - rerolled_to_rerollable -1185 return icepool.tupleize( -1186 initial_rerollable, rerolled_to_rerollable, -1187 initial_not_rerollable + rerolled_to_not_rerollable, -1188 not_rerolled) -1189 -1190 return icepool.map(second_split, -1191 rerolled @ single_is_rerollable, -1192 star=False) + 811 @overload + 812 def keep(self, rolls: Sequence[int], /) -> 'Die': + 813 """Selects elements after drawing and sorting and sums them. + 814 + 815 Args: + 816 rolls: A sequence of `int` specifying how many times to count each + 817 element in ascending order. + 818 """ + 819 + 820 @overload + 821 def keep(self, rolls: int, + 822 index: slice | Sequence[int | EllipsisType] | int, /): + 823 """Selects elements after drawing and sorting and sums them. + 824 + 825 Args: + 826 rolls: The number of dice to roll. + 827 index: One of the following: + 828 * An `int`. This will count only the roll at the specified index. + 829 In this case, the result is a `Die` rather than a generator. + 830 * A `slice`. The selected dice are counted once each. + 831 * A sequence of one `int` for each `Die`. + 832 Each roll is counted that many times, which could be multiple or + 833 negative times. + 834 + 835 Up to one `...` (`Ellipsis`) may be used. + 836 `...` will be replaced with a number of zero + 837 counts depending on the `rolls`. + 838 This number may be "negative" if more `int`s are provided than + 839 `rolls`. Specifically: + 840 + 841 * If `index` is shorter than `rolls`, `...` + 842 acts as enough zero counts to make up the difference. + 843 E.g. `(1, ..., 1)` on five dice would act as + 844 `(1, 0, 0, 0, 1)`. + 845 * If `index` has length equal to `rolls`, `...` has no effect. + 846 E.g. `(1, ..., 1)` on two dice would act as `(1, 1)`. + 847 * If `index` is longer than `rolls` and `...` is on one side, + 848 elements will be dropped from `index` on the side with `...`. + 849 E.g. `(..., 1, 2, 3)` on two dice would act as `(2, 3)`. + 850 * If `index` is longer than `rolls` and `...` + 851 is in the middle, the counts will be as the sum of two + 852 one-sided `...`. + 853 E.g. `(-1, ..., 1)` acts like `(-1, ...)` plus `(..., 1)`. + 854 If `rolls` was 1 this would have the -1 and 1 cancel each other out. + 855 """ + 856 + 857 def keep(self, + 858 rolls: int | Sequence[int], + 859 index: slice | Sequence[int | EllipsisType] | int | None = None, + 860 /) -> 'Die': + 861 """Selects elements after drawing and sorting and sums them. + 862 + 863 Args: + 864 rolls: The number of dice to roll. + 865 index: One of the following: + 866 * An `int`. This will count only the roll at the specified index. + 867 In this case, the result is a `Die` rather than a generator. + 868 * A `slice`. The selected dice are counted once each. + 869 * A sequence of `int`s with length equal to `rolls`. + 870 Each roll is counted that many times, which could be multiple or + 871 negative times. + 872 + 873 Up to one `...` (`Ellipsis`) may be used. If no `...` is used, + 874 the `rolls` argument may be omitted. + 875 + 876 `...` will be replaced with a number of zero counts in order + 877 to make up any missing elements compared to `rolls`. + 878 This number may be "negative" if more `int`s are provided than + 879 `rolls`. Specifically: + 880 + 881 * If `index` is shorter than `rolls`, `...` + 882 acts as enough zero counts to make up the difference. + 883 E.g. `(1, ..., 1)` on five dice would act as + 884 `(1, 0, 0, 0, 1)`. + 885 * If `index` has length equal to `rolls`, `...` has no effect. + 886 E.g. `(1, ..., 1)` on two dice would act as `(1, 1)`. + 887 * If `index` is longer than `rolls` and `...` is on one side, + 888 elements will be dropped from `index` on the side with `...`. + 889 E.g. `(..., 1, 2, 3)` on two dice would act as `(2, 3)`. + 890 * If `index` is longer than `rolls` and `...` + 891 is in the middle, the counts will be as the sum of two + 892 one-sided `...`. + 893 E.g. `(-1, ..., 1)` acts like `(-1, ...)` plus `(..., 1)`. + 894 If `rolls` was 1 this would have the -1 and 1 cancel each other out. + 895 """ + 896 if isinstance(rolls, int): + 897 if index is None: + 898 raise ValueError( + 899 'If the number of rolls is an integer, an index argument must be provided.' + 900 ) + 901 if isinstance(index, int): + 902 return self.pool(rolls).keep(index) + 903 else: + 904 return self.pool(rolls).keep(index).sum() # type: ignore + 905 else: + 906 if index is not None: + 907 raise ValueError('Only one index sequence can be given.') + 908 return self.pool(len(rolls)).keep(rolls).sum() # type: ignore + 909 + 910 def lowest(self, + 911 rolls: int, + 912 /, + 913 keep: int | None = None, + 914 drop: int | None = None) -> 'Die': + 915 """Roll several of this `Die` and return the lowest result, or the sum of some of the lowest. + 916 + 917 The outcomes should support addition and multiplication if `keep != 1`. + 918 + 919 Args: + 920 rolls: The number of dice to roll. All dice will have the same + 921 outcomes as `self`. + 922 keep, drop: These arguments work together: + 923 * If neither are provided, the single lowest die will be taken. + 924 * If only `keep` is provided, the `keep` lowest dice will be summed. + 925 * If only `drop` is provided, the `drop` lowest dice will be dropped + 926 and the rest will be summed. + 927 * If both are provided, `drop` lowest dice will be dropped, then + 928 the next `keep` lowest dice will be summed. + 929 + 930 Returns: + 931 A `Die` representing the probability distribution of the sum. + 932 """ + 933 index = lowest_slice(keep, drop) + 934 canonical = canonical_slice(index, rolls) + 935 if canonical.start == 0 and canonical.stop == 1: + 936 return self._lowest_single(rolls) + 937 # Expression evaluators are difficult to type. + 938 return self.pool(rolls)[index].sum() # type: ignore + 939 + 940 def _lowest_single(self, rolls: int, /) -> 'Die': + 941 """Roll this die several times and keep the lowest.""" + 942 if rolls == 0: + 943 return self.zero().simplify() + 944 return icepool.from_cumulative( + 945 self.outcomes(), [x**rolls for x in self.quantities('>=')], + 946 reverse=True) + 947 + 948 def highest(self, + 949 rolls: int, + 950 /, + 951 keep: int | None = None, + 952 drop: int | None = None) -> 'Die[T_co]': + 953 """Roll several of this `Die` and return the highest result, or the sum of some of the highest. + 954 + 955 The outcomes should support addition and multiplication if `keep != 1`. + 956 + 957 Args: + 958 rolls: The number of dice to roll. + 959 keep, drop: These arguments work together: + 960 * If neither are provided, the single highest die will be taken. + 961 * If only `keep` is provided, the `keep` highest dice will be summed. + 962 * If only `drop` is provided, the `drop` highest dice will be dropped + 963 and the rest will be summed. + 964 * If both are provided, `drop` highest dice will be dropped, then + 965 the next `keep` highest dice will be summed. + 966 + 967 Returns: + 968 A `Die` representing the probability distribution of the sum. + 969 """ + 970 index = highest_slice(keep, drop) + 971 canonical = canonical_slice(index, rolls) + 972 if canonical.start == rolls - 1 and canonical.stop == rolls: + 973 return self._highest_single(rolls) + 974 # Expression evaluators are difficult to type. + 975 return self.pool(rolls)[index].sum() # type: ignore + 976 + 977 def _highest_single(self, rolls: int, /) -> 'Die[T_co]': + 978 """Roll this die several times and keep the highest.""" + 979 if rolls == 0: + 980 return self.zero().simplify() + 981 return icepool.from_cumulative( + 982 self.outcomes(), [x**rolls for x in self.quantities('<=')]) + 983 + 984 def middle( + 985 self, + 986 rolls: int, + 987 /, + 988 keep: int = 1, + 989 *, + 990 tie: Literal['error', 'high', 'low'] = 'error') -> 'icepool.Die': + 991 """Roll several of this `Die` and sum the sorted results in the middle. + 992 + 993 The outcomes should support addition and multiplication if `keep != 1`. + 994 + 995 Args: + 996 rolls: The number of dice to roll. + 997 keep: The number of outcomes to sum. If this is greater than the + 998 current keep_size, all are kept. + 999 tie: What to do if `keep` is odd but the current keep_size +1000 is even, or vice versa. +1001 * 'error' (default): Raises `IndexError`. +1002 * 'high': The higher outcome is taken. +1003 * 'low': The lower outcome is taken. +1004 """ +1005 # Expression evaluators are difficult to type. +1006 return self.pool(rolls).middle(keep, tie=tie).sum() # type: ignore +1007 +1008 def map_to_pool( +1009 self, +1010 repl: +1011 'Callable[..., Sequence[icepool.Die[U] | U] | Mapping[icepool.Die[U], int] | Mapping[U, int] | icepool.RerollType] | None' = None, +1012 /, +1013 *extra_args: 'Outcome | icepool.Die | icepool.MultisetExpression', +1014 star: bool | None = None, +1015 denominator: int | None = None +1016 ) -> 'icepool.MultisetGenerator[U, tuple[int]]': +1017 """EXPERIMENTAL: Maps outcomes of this `Die` to `Pools`, creating a `MultisetGenerator`. +1018 +1019 As `icepool.map_to_pool(repl, self, ...)`. +1020 +1021 If no argument is provided, the outcomes will be used to construct a +1022 mixture of pools directly, similar to the inverse of `pool.expand()`. +1023 Note that this is not particularly efficient since it does not make much +1024 use of dynamic programming. +1025 +1026 Args: +1027 repl: One of the following: +1028 * A callable that takes in one outcome per element of args and +1029 produces a `Pool` (or something convertible to such). +1030 * A mapping from old outcomes to `Pool` +1031 (or something convertible to such). +1032 In this case args must have exactly one element. +1033 The new outcomes may be dice rather than just single outcomes. +1034 The special value `icepool.Reroll` will reroll that old outcome. +1035 star: If `True`, the first of the args will be unpacked before +1036 giving them to `repl`. +1037 If not provided, it will be guessed based on the signature of +1038 `repl` and the number of arguments. +1039 denominator: If provided, the denominator of the result will be this +1040 value. Otherwise it will be the minimum to correctly weight the +1041 pools. +1042 +1043 Returns: +1044 A `MultisetGenerator` representing the mixture of `Pool`s. Note +1045 that this is not technically a `Pool`, though it supports most of +1046 the same operations. +1047 +1048 Raises: +1049 ValueError: If `denominator` cannot be made consistent with the +1050 resulting mixture of pools. +1051 """ +1052 if repl is None: +1053 repl = lambda x: x +1054 return icepool.map_to_pool(repl, +1055 self, +1056 *extra_args, +1057 star=star, +1058 denominator=denominator) +1059 +1060 def explode_to_pool( +1061 self, +1062 rolls: int, +1063 which: Collection[T_co] | Callable[..., bool] | None = None, +1064 /, +1065 *, +1066 star: bool | None = None, +1067 depth: int = 9) -> 'icepool.MultisetGenerator[T_co, tuple[int]]': +1068 """EXPERIMENTAL: Causes outcomes to be rolled again, keeping that outcome as an individual die in a pool. +1069 +1070 Args: +1071 rolls: The number of initial dice. +1072 which: Which outcomes to explode. Options: +1073 * A single outcome to explode. +1074 * An collection of outcomes to explode. +1075 * A callable that takes an outcome and returns `True` if it +1076 should be exploded. +1077 * If not supplied, the max outcome will explode. +1078 star: Whether outcomes should be unpacked into separate arguments +1079 before sending them to a callable `which`. +1080 If not provided, this will be guessed based on the function +1081 signature. +1082 depth: The maximum depth of explosions for an individual dice. +1083 +1084 Returns: +1085 A `MultisetGenerator` representing the mixture of `Pool`s. Note +1086 that this is not technically a `Pool`, though it supports most of +1087 the same operations. +1088 """ +1089 if depth == 0: +1090 return self.pool(rolls) +1091 if which is None: +1092 explode_set = {self.max_outcome()} +1093 else: +1094 explode_set = self._select_outcomes(which, star) +1095 if not explode_set: +1096 return self.pool(rolls) +1097 explode, not_explode = self.split(explode_set) +1098 +1099 single_data: 'MutableMapping[icepool.Vector[int], int]' = defaultdict( +1100 int) +1101 for i in range(depth + 1): +1102 weight = explode.denominator()**i * self.denominator()**( +1103 depth - i) * not_explode.denominator() +1104 single_data[icepool.Vector((i, 1))] += weight +1105 single_data[icepool.Vector( +1106 (depth + 1, 0))] += explode.denominator()**(depth + 1) +1107 +1108 single_count_die: 'Die[icepool.Vector[int]]' = Die(single_data) +1109 count_die = rolls @ single_count_die +1110 +1111 return count_die.map_to_pool( +1112 lambda x, nx: [explode] * x + [not_explode] * nx) +1113 +1114 def reroll_to_pool( +1115 self, +1116 rolls: int, +1117 which: Callable[..., bool] | Collection[T_co], +1118 /, +1119 max_rerolls: int, +1120 *, +1121 star: bool | None = None, +1122 mode: Literal['random', 'lowest', 'highest', 'drop'] = 'random' +1123 ) -> 'icepool.MultisetGenerator[T_co, tuple[int]]': +1124 """EXPERIMENTAL: Applies a limited number of rerolls shared across a pool. +1125 +1126 Each die can only be rerolled once (effectively `depth=1`), and no more +1127 than `max_rerolls` dice may be rerolled. +1128 +1129 Args: +1130 rolls: How many dice in the pool. +1131 which: Selects which outcomes are eligible to be rerolled. Options: +1132 * A collection of outcomes to reroll. +1133 * A callable that takes an outcome and returns `True` if it +1134 could be rerolled. +1135 max_rerolls: The maximum number of dice to reroll. +1136 Note that each die can only be rerolled once, so if the number +1137 of eligible dice is less than this, the excess rerolls have no +1138 effect. +1139 star: Whether outcomes should be unpacked into separate arguments +1140 before sending them to a callable `which`. +1141 If not provided, this will be guessed based on the function +1142 signature. +1143 mode: How dice are selected for rerolling if there are more eligible +1144 dice than `max_rerolls`. Options: +1145 * `'random'` (default): Eligible dice will be chosen uniformly +1146 at random. +1147 * `'lowest'`: The lowest eligible dice will be rerolled. +1148 * `'highest'`: The highest eligible dice will be rerolled. +1149 * `'drop'`: All dice that ended up on an outcome selected by +1150 `which` will be dropped. This includes both dice that rolled +1151 into `which` initially and were not rerolled, and dice that +1152 were rerolled but rolled into `which` again. This can be +1153 considerably more efficient than the other modes. +1154 +1155 Returns: +1156 A `MultisetGenerator` representing the mixture of `Pool`s. Note +1157 that this is not technically a `Pool`, though it supports most of +1158 the same operations. +1159 """ +1160 rerollable_set = self._select_outcomes(which, star) +1161 if not rerollable_set: +1162 return self.pool(rolls) +1163 +1164 rerollable_die, not_rerollable_die = self.split(rerollable_set) +1165 single_is_rerollable = icepool.coin(rerollable_die.denominator(), +1166 self.denominator()) +1167 rerollable = rolls @ single_is_rerollable +1168 +1169 def split(initial_rerollable: int) -> Die[tuple[int, int, int]]: +1170 """Computes the composition of the pool. +1171 +1172 Returns: +1173 initial_rerollable: The number of dice that initially fell into +1174 the rerollable set. +1175 rerolled_to_rerollable: The number of dice that were rerolled, +1176 but fell into the rerollable set again. +1177 not_rerollable: The number of dice that ended up outside the +1178 rerollable set, including both initial and rerolled dice. +1179 not_rerolled: The number of dice that were eligible for +1180 rerolling but were not rerolled. +1181 """ +1182 initial_not_rerollable = rolls - initial_rerollable +1183 rerolled = min(initial_rerollable, max_rerolls) +1184 not_rerolled = initial_rerollable - rerolled +1185 +1186 def second_split(rerolled_to_rerollable): +1187 """Splits the rerolled dice into those that fell into the rerollable and not-rerollable sets.""" +1188 rerolled_to_not_rerollable = rerolled - rerolled_to_rerollable +1189 return icepool.tupleize( +1190 initial_rerollable, rerolled_to_rerollable, +1191 initial_not_rerollable + rerolled_to_not_rerollable, +1192 not_rerolled) 1193 -1194 pool_composition = rerollable.map(split, star=False) -1195 -1196 def make_pool(initial_rerollable, rerolled_to_rerollable, -1197 not_rerollable, not_rerolled): -1198 common = rerollable_die.pool( -1199 rerolled_to_rerollable) + not_rerollable_die.pool( -1200 not_rerollable) -1201 match mode: -1202 case 'random': -1203 return common + rerollable_die.pool(not_rerolled) -1204 case 'lowest': -1205 return common + rerollable_die.pool( -1206 initial_rerollable).highest(not_rerolled) -1207 case 'highest': -1208 return common + rerollable_die.pool( -1209 initial_rerollable).lowest(not_rerolled) -1210 case 'drop': -1211 return not_rerollable_die.pool(not_rerollable) -1212 case _: -1213 raise ValueError( -1214 f"Invalid reroll_priority '{mode}'. Allowed values are 'random', 'lowest', 'highest', 'drop'." -1215 ) -1216 -1217 denominator = self.denominator()**(rolls + min(rolls, max_rerolls)) -1218 -1219 return pool_composition.map_to_pool(make_pool, -1220 star=True, -1221 denominator=denominator) +1194 return icepool.map(second_split, +1195 rerolled @ single_is_rerollable, +1196 star=False) +1197 +1198 pool_composition = rerollable.map(split, star=False) +1199 +1200 def make_pool(initial_rerollable, rerolled_to_rerollable, +1201 not_rerollable, not_rerolled): +1202 common = rerollable_die.pool( +1203 rerolled_to_rerollable) + not_rerollable_die.pool( +1204 not_rerollable) +1205 match mode: +1206 case 'random': +1207 return common + rerollable_die.pool(not_rerolled) +1208 case 'lowest': +1209 return common + rerollable_die.pool( +1210 initial_rerollable).highest(not_rerolled) +1211 case 'highest': +1212 return common + rerollable_die.pool( +1213 initial_rerollable).lowest(not_rerolled) +1214 case 'drop': +1215 return not_rerollable_die.pool(not_rerollable) +1216 case _: +1217 raise ValueError( +1218 f"Invalid reroll_priority '{mode}'. Allowed values are 'random', 'lowest', 'highest', 'drop'." +1219 ) +1220 +1221 denominator = self.denominator()**(rolls + min(rolls, max_rerolls)) 1222 -1223 # Unary operators. -1224 -1225 def __neg__(self) -> 'Die[T_co]': -1226 return self.unary_operator(operator.neg) -1227 -1228 def __pos__(self) -> 'Die[T_co]': -1229 return self.unary_operator(operator.pos) -1230 -1231 def __invert__(self) -> 'Die[T_co]': -1232 return self.unary_operator(operator.invert) -1233 -1234 def abs(self) -> 'Die[T_co]': -1235 return self.unary_operator(operator.abs) -1236 -1237 __abs__ = abs -1238 -1239 def round(self, ndigits: int | None = None) -> 'Die': -1240 return self.unary_operator(round, ndigits) -1241 -1242 __round__ = round -1243 -1244 def stochastic_round(self, -1245 *, -1246 max_denominator: int | None = None) -> 'Die[int]': -1247 """Randomly rounds outcomes up or down to the nearest integer according to the two distances. -1248 -1249 Specificially, rounds `x` up with probability `x - floor(x)` and down -1250 otherwise. -1251 -1252 Args: -1253 max_denominator: If provided, each rounding will be performed -1254 using `fractions.Fraction.limit_denominator(max_denominator)`. -1255 Otherwise, the rounding will be performed without -1256 `limit_denominator`. -1257 """ -1258 return self.map(lambda x: icepool.stochastic_round( -1259 x, max_denominator=max_denominator)) -1260 -1261 def trunc(self) -> 'Die': -1262 return self.unary_operator(math.trunc) -1263 -1264 __trunc__ = trunc -1265 -1266 def floor(self) -> 'Die': -1267 return self.unary_operator(math.floor) -1268 -1269 __floor__ = floor -1270 -1271 def ceil(self) -> 'Die': -1272 return self.unary_operator(math.ceil) -1273 -1274 __ceil__ = ceil -1275 -1276 # Binary operators. +1223 return pool_composition.map_to_pool(make_pool, +1224 star=True, +1225 denominator=denominator) +1226 +1227 # Unary operators. +1228 +1229 def __neg__(self) -> 'Die[T_co]': +1230 return self.unary_operator(operator.neg) +1231 +1232 def __pos__(self) -> 'Die[T_co]': +1233 return self.unary_operator(operator.pos) +1234 +1235 def __invert__(self) -> 'Die[T_co]': +1236 return self.unary_operator(operator.invert) +1237 +1238 def abs(self) -> 'Die[T_co]': +1239 return self.unary_operator(operator.abs) +1240 +1241 __abs__ = abs +1242 +1243 def round(self, ndigits: int | None = None) -> 'Die': +1244 return self.unary_operator(round, ndigits) +1245 +1246 __round__ = round +1247 +1248 def stochastic_round(self, +1249 *, +1250 max_denominator: int | None = None) -> 'Die[int]': +1251 """Randomly rounds outcomes up or down to the nearest integer according to the two distances. +1252 +1253 Specificially, rounds `x` up with probability `x - floor(x)` and down +1254 otherwise. +1255 +1256 Args: +1257 max_denominator: If provided, each rounding will be performed +1258 using `fractions.Fraction.limit_denominator(max_denominator)`. +1259 Otherwise, the rounding will be performed without +1260 `limit_denominator`. +1261 """ +1262 return self.map(lambda x: icepool.stochastic_round( +1263 x, max_denominator=max_denominator)) +1264 +1265 def trunc(self) -> 'Die': +1266 return self.unary_operator(math.trunc) +1267 +1268 __trunc__ = trunc +1269 +1270 def floor(self) -> 'Die': +1271 return self.unary_operator(math.floor) +1272 +1273 __floor__ = floor +1274 +1275 def ceil(self) -> 'Die': +1276 return self.unary_operator(math.ceil) 1277 -1278 def __add__(self, other) -> 'Die': -1279 if isinstance(other, icepool.AgainExpression): -1280 return NotImplemented -1281 other = implicit_convert_to_die(other) -1282 return self.binary_operator(other, operator.add) -1283 -1284 def __radd__(self, other) -> 'Die': -1285 if isinstance(other, icepool.AgainExpression): -1286 return NotImplemented -1287 other = implicit_convert_to_die(other) -1288 return other.binary_operator(self, operator.add) -1289 -1290 def __sub__(self, other) -> 'Die': -1291 if isinstance(other, icepool.AgainExpression): -1292 return NotImplemented -1293 other = implicit_convert_to_die(other) -1294 return self.binary_operator(other, operator.sub) -1295 -1296 def __rsub__(self, other) -> 'Die': -1297 if isinstance(other, icepool.AgainExpression): -1298 return NotImplemented -1299 other = implicit_convert_to_die(other) -1300 return other.binary_operator(self, operator.sub) -1301 -1302 def __mul__(self, other) -> 'Die': -1303 if isinstance(other, icepool.AgainExpression): -1304 return NotImplemented -1305 other = implicit_convert_to_die(other) -1306 return self.binary_operator(other, operator.mul) -1307 -1308 def __rmul__(self, other) -> 'Die': -1309 if isinstance(other, icepool.AgainExpression): -1310 return NotImplemented -1311 other = implicit_convert_to_die(other) -1312 return other.binary_operator(self, operator.mul) -1313 -1314 def __truediv__(self, other) -> 'Die': -1315 if isinstance(other, icepool.AgainExpression): -1316 return NotImplemented -1317 other = implicit_convert_to_die(other) -1318 return self.binary_operator(other, operator.truediv) -1319 -1320 def __rtruediv__(self, other) -> 'Die': -1321 if isinstance(other, icepool.AgainExpression): -1322 return NotImplemented -1323 other = implicit_convert_to_die(other) -1324 return other.binary_operator(self, operator.truediv) -1325 -1326 def __floordiv__(self, other) -> 'Die': -1327 if isinstance(other, icepool.AgainExpression): -1328 return NotImplemented -1329 other = implicit_convert_to_die(other) -1330 return self.binary_operator(other, operator.floordiv) -1331 -1332 def __rfloordiv__(self, other) -> 'Die': -1333 if isinstance(other, icepool.AgainExpression): -1334 return NotImplemented -1335 other = implicit_convert_to_die(other) -1336 return other.binary_operator(self, operator.floordiv) -1337 -1338 def __pow__(self, other) -> 'Die': -1339 if isinstance(other, icepool.AgainExpression): -1340 return NotImplemented -1341 other = implicit_convert_to_die(other) -1342 return self.binary_operator(other, operator.pow) -1343 -1344 def __rpow__(self, other) -> 'Die': -1345 if isinstance(other, icepool.AgainExpression): -1346 return NotImplemented -1347 other = implicit_convert_to_die(other) -1348 return other.binary_operator(self, operator.pow) -1349 -1350 def __mod__(self, other) -> 'Die': -1351 if isinstance(other, icepool.AgainExpression): -1352 return NotImplemented -1353 other = implicit_convert_to_die(other) -1354 return self.binary_operator(other, operator.mod) -1355 -1356 def __rmod__(self, other) -> 'Die': -1357 if isinstance(other, icepool.AgainExpression): -1358 return NotImplemented -1359 other = implicit_convert_to_die(other) -1360 return other.binary_operator(self, operator.mod) -1361 -1362 def __lshift__(self, other) -> 'Die': -1363 if isinstance(other, icepool.AgainExpression): -1364 return NotImplemented -1365 other = implicit_convert_to_die(other) -1366 return self.binary_operator(other, operator.lshift) -1367 -1368 def __rlshift__(self, other) -> 'Die': -1369 if isinstance(other, icepool.AgainExpression): -1370 return NotImplemented -1371 other = implicit_convert_to_die(other) -1372 return other.binary_operator(self, operator.lshift) -1373 -1374 def __rshift__(self, other) -> 'Die': -1375 if isinstance(other, icepool.AgainExpression): -1376 return NotImplemented -1377 other = implicit_convert_to_die(other) -1378 return self.binary_operator(other, operator.rshift) -1379 -1380 def __rrshift__(self, other) -> 'Die': -1381 if isinstance(other, icepool.AgainExpression): -1382 return NotImplemented -1383 other = implicit_convert_to_die(other) -1384 return other.binary_operator(self, operator.rshift) -1385 -1386 def __and__(self, other) -> 'Die': -1387 if isinstance(other, icepool.AgainExpression): -1388 return NotImplemented -1389 other = implicit_convert_to_die(other) -1390 return self.binary_operator(other, operator.and_) -1391 -1392 def __rand__(self, other) -> 'Die': -1393 if isinstance(other, icepool.AgainExpression): -1394 return NotImplemented -1395 other = implicit_convert_to_die(other) -1396 return other.binary_operator(self, operator.and_) -1397 -1398 def __or__(self, other) -> 'Die': -1399 if isinstance(other, icepool.AgainExpression): -1400 return NotImplemented -1401 other = implicit_convert_to_die(other) -1402 return self.binary_operator(other, operator.or_) -1403 -1404 def __ror__(self, other) -> 'Die': -1405 if isinstance(other, icepool.AgainExpression): -1406 return NotImplemented -1407 other = implicit_convert_to_die(other) -1408 return other.binary_operator(self, operator.or_) -1409 -1410 def __xor__(self, other) -> 'Die': -1411 if isinstance(other, icepool.AgainExpression): -1412 return NotImplemented -1413 other = implicit_convert_to_die(other) -1414 return self.binary_operator(other, operator.xor) -1415 -1416 def __rxor__(self, other) -> 'Die': -1417 if isinstance(other, icepool.AgainExpression): -1418 return NotImplemented -1419 other = implicit_convert_to_die(other) -1420 return other.binary_operator(self, operator.xor) -1421 -1422 # Comparators. -1423 -1424 def __lt__(self, other) -> 'Die[bool]': -1425 if isinstance(other, icepool.AgainExpression): -1426 return NotImplemented -1427 other = implicit_convert_to_die(other) -1428 return self.binary_operator(other, operator.lt) -1429 -1430 def __le__(self, other) -> 'Die[bool]': -1431 if isinstance(other, icepool.AgainExpression): -1432 return NotImplemented -1433 other = implicit_convert_to_die(other) -1434 return self.binary_operator(other, operator.le) -1435 -1436 def __ge__(self, other) -> 'Die[bool]': -1437 if isinstance(other, icepool.AgainExpression): -1438 return NotImplemented -1439 other = implicit_convert_to_die(other) -1440 return self.binary_operator(other, operator.ge) -1441 -1442 def __gt__(self, other) -> 'Die[bool]': -1443 if isinstance(other, icepool.AgainExpression): -1444 return NotImplemented -1445 other = implicit_convert_to_die(other) -1446 return self.binary_operator(other, operator.gt) -1447 -1448 # Equality operators. These produce a `DieWithTruth`. -1449 -1450 # The result has a truth value, but is not a bool. -1451 def __eq__(self, other) -> 'icepool.DieWithTruth[bool]': # type: ignore -1452 if isinstance(other, icepool.AgainExpression): -1453 return NotImplemented -1454 other_die: Die = implicit_convert_to_die(other) -1455 -1456 def data_callback() -> Counts[bool]: -1457 return self.binary_operator(other_die, operator.eq)._data -1458 -1459 def truth_value_callback() -> bool: -1460 return self.equals(other) -1461 -1462 return icepool.DieWithTruth(data_callback, truth_value_callback) -1463 -1464 # The result has a truth value, but is not a bool. -1465 def __ne__(self, other) -> 'icepool.DieWithTruth[bool]': # type: ignore -1466 if isinstance(other, icepool.AgainExpression): -1467 return NotImplemented -1468 other_die: Die = implicit_convert_to_die(other) -1469 -1470 def data_callback() -> Counts[bool]: -1471 return self.binary_operator(other_die, operator.ne)._data -1472 -1473 def truth_value_callback() -> bool: -1474 return not self.equals(other) -1475 -1476 return icepool.DieWithTruth(data_callback, truth_value_callback) -1477 -1478 def cmp(self, other) -> 'Die[int]': -1479 """A `Die` with outcomes 1, -1, and 0. -1480 -1481 The quantities are equal to the positive outcome of `self > other`, -1482 `self < other`, and the remainder respectively. -1483 -1484 This will include all three outcomes even if they have zero quantity. -1485 """ -1486 other = implicit_convert_to_die(other) +1278 __ceil__ = ceil +1279 +1280 # Binary operators. +1281 +1282 def __add__(self, other) -> 'Die': +1283 if isinstance(other, icepool.AgainExpression): +1284 return NotImplemented +1285 other = implicit_convert_to_die(other) +1286 return self.binary_operator(other, operator.add) +1287 +1288 def __radd__(self, other) -> 'Die': +1289 if isinstance(other, icepool.AgainExpression): +1290 return NotImplemented +1291 other = implicit_convert_to_die(other) +1292 return other.binary_operator(self, operator.add) +1293 +1294 def __sub__(self, other) -> 'Die': +1295 if isinstance(other, icepool.AgainExpression): +1296 return NotImplemented +1297 other = implicit_convert_to_die(other) +1298 return self.binary_operator(other, operator.sub) +1299 +1300 def __rsub__(self, other) -> 'Die': +1301 if isinstance(other, icepool.AgainExpression): +1302 return NotImplemented +1303 other = implicit_convert_to_die(other) +1304 return other.binary_operator(self, operator.sub) +1305 +1306 def __mul__(self, other) -> 'Die': +1307 if isinstance(other, icepool.AgainExpression): +1308 return NotImplemented +1309 other = implicit_convert_to_die(other) +1310 return self.binary_operator(other, operator.mul) +1311 +1312 def __rmul__(self, other) -> 'Die': +1313 if isinstance(other, icepool.AgainExpression): +1314 return NotImplemented +1315 other = implicit_convert_to_die(other) +1316 return other.binary_operator(self, operator.mul) +1317 +1318 def __truediv__(self, other) -> 'Die': +1319 if isinstance(other, icepool.AgainExpression): +1320 return NotImplemented +1321 other = implicit_convert_to_die(other) +1322 return self.binary_operator(other, operator.truediv) +1323 +1324 def __rtruediv__(self, other) -> 'Die': +1325 if isinstance(other, icepool.AgainExpression): +1326 return NotImplemented +1327 other = implicit_convert_to_die(other) +1328 return other.binary_operator(self, operator.truediv) +1329 +1330 def __floordiv__(self, other) -> 'Die': +1331 if isinstance(other, icepool.AgainExpression): +1332 return NotImplemented +1333 other = implicit_convert_to_die(other) +1334 return self.binary_operator(other, operator.floordiv) +1335 +1336 def __rfloordiv__(self, other) -> 'Die': +1337 if isinstance(other, icepool.AgainExpression): +1338 return NotImplemented +1339 other = implicit_convert_to_die(other) +1340 return other.binary_operator(self, operator.floordiv) +1341 +1342 def __pow__(self, other) -> 'Die': +1343 if isinstance(other, icepool.AgainExpression): +1344 return NotImplemented +1345 other = implicit_convert_to_die(other) +1346 return self.binary_operator(other, operator.pow) +1347 +1348 def __rpow__(self, other) -> 'Die': +1349 if isinstance(other, icepool.AgainExpression): +1350 return NotImplemented +1351 other = implicit_convert_to_die(other) +1352 return other.binary_operator(self, operator.pow) +1353 +1354 def __mod__(self, other) -> 'Die': +1355 if isinstance(other, icepool.AgainExpression): +1356 return NotImplemented +1357 other = implicit_convert_to_die(other) +1358 return self.binary_operator(other, operator.mod) +1359 +1360 def __rmod__(self, other) -> 'Die': +1361 if isinstance(other, icepool.AgainExpression): +1362 return NotImplemented +1363 other = implicit_convert_to_die(other) +1364 return other.binary_operator(self, operator.mod) +1365 +1366 def __lshift__(self, other) -> 'Die': +1367 if isinstance(other, icepool.AgainExpression): +1368 return NotImplemented +1369 other = implicit_convert_to_die(other) +1370 return self.binary_operator(other, operator.lshift) +1371 +1372 def __rlshift__(self, other) -> 'Die': +1373 if isinstance(other, icepool.AgainExpression): +1374 return NotImplemented +1375 other = implicit_convert_to_die(other) +1376 return other.binary_operator(self, operator.lshift) +1377 +1378 def __rshift__(self, other) -> 'Die': +1379 if isinstance(other, icepool.AgainExpression): +1380 return NotImplemented +1381 other = implicit_convert_to_die(other) +1382 return self.binary_operator(other, operator.rshift) +1383 +1384 def __rrshift__(self, other) -> 'Die': +1385 if isinstance(other, icepool.AgainExpression): +1386 return NotImplemented +1387 other = implicit_convert_to_die(other) +1388 return other.binary_operator(self, operator.rshift) +1389 +1390 def __and__(self, other) -> 'Die': +1391 if isinstance(other, icepool.AgainExpression): +1392 return NotImplemented +1393 other = implicit_convert_to_die(other) +1394 return self.binary_operator(other, operator.and_) +1395 +1396 def __rand__(self, other) -> 'Die': +1397 if isinstance(other, icepool.AgainExpression): +1398 return NotImplemented +1399 other = implicit_convert_to_die(other) +1400 return other.binary_operator(self, operator.and_) +1401 +1402 def __or__(self, other) -> 'Die': +1403 if isinstance(other, icepool.AgainExpression): +1404 return NotImplemented +1405 other = implicit_convert_to_die(other) +1406 return self.binary_operator(other, operator.or_) +1407 +1408 def __ror__(self, other) -> 'Die': +1409 if isinstance(other, icepool.AgainExpression): +1410 return NotImplemented +1411 other = implicit_convert_to_die(other) +1412 return other.binary_operator(self, operator.or_) +1413 +1414 def __xor__(self, other) -> 'Die': +1415 if isinstance(other, icepool.AgainExpression): +1416 return NotImplemented +1417 other = implicit_convert_to_die(other) +1418 return self.binary_operator(other, operator.xor) +1419 +1420 def __rxor__(self, other) -> 'Die': +1421 if isinstance(other, icepool.AgainExpression): +1422 return NotImplemented +1423 other = implicit_convert_to_die(other) +1424 return other.binary_operator(self, operator.xor) +1425 +1426 # Comparators. +1427 +1428 def __lt__(self, other) -> 'Die[bool]': +1429 if isinstance(other, icepool.AgainExpression): +1430 return NotImplemented +1431 other = implicit_convert_to_die(other) +1432 return self.binary_operator(other, operator.lt) +1433 +1434 def __le__(self, other) -> 'Die[bool]': +1435 if isinstance(other, icepool.AgainExpression): +1436 return NotImplemented +1437 other = implicit_convert_to_die(other) +1438 return self.binary_operator(other, operator.le) +1439 +1440 def __ge__(self, other) -> 'Die[bool]': +1441 if isinstance(other, icepool.AgainExpression): +1442 return NotImplemented +1443 other = implicit_convert_to_die(other) +1444 return self.binary_operator(other, operator.ge) +1445 +1446 def __gt__(self, other) -> 'Die[bool]': +1447 if isinstance(other, icepool.AgainExpression): +1448 return NotImplemented +1449 other = implicit_convert_to_die(other) +1450 return self.binary_operator(other, operator.gt) +1451 +1452 # Equality operators. These produce a `DieWithTruth`. +1453 +1454 # The result has a truth value, but is not a bool. +1455 def __eq__(self, other) -> 'icepool.DieWithTruth[bool]': # type: ignore +1456 if isinstance(other, icepool.AgainExpression): +1457 return NotImplemented +1458 other_die: Die = implicit_convert_to_die(other) +1459 +1460 def data_callback() -> Counts[bool]: +1461 return self.binary_operator(other_die, operator.eq)._data +1462 +1463 def truth_value_callback() -> bool: +1464 return self.equals(other) +1465 +1466 return icepool.DieWithTruth(data_callback, truth_value_callback) +1467 +1468 # The result has a truth value, but is not a bool. +1469 def __ne__(self, other) -> 'icepool.DieWithTruth[bool]': # type: ignore +1470 if isinstance(other, icepool.AgainExpression): +1471 return NotImplemented +1472 other_die: Die = implicit_convert_to_die(other) +1473 +1474 def data_callback() -> Counts[bool]: +1475 return self.binary_operator(other_die, operator.ne)._data +1476 +1477 def truth_value_callback() -> bool: +1478 return not self.equals(other) +1479 +1480 return icepool.DieWithTruth(data_callback, truth_value_callback) +1481 +1482 def cmp(self, other) -> 'Die[int]': +1483 """A `Die` with outcomes 1, -1, and 0. +1484 +1485 The quantities are equal to the positive outcome of `self > other`, +1486 `self < other`, and the remainder respectively. 1487 -1488 data = {} -1489 -1490 lt = self < other -1491 if True in lt: -1492 data[-1] = lt[True] -1493 eq = self == other -1494 if True in eq: -1495 data[0] = eq[True] -1496 gt = self > other -1497 if True in gt: -1498 data[1] = gt[True] -1499 -1500 return Die(data) -1501 -1502 @staticmethod -1503 def _sign(x) -> int: -1504 z = Die._zero(x) -1505 if x > z: -1506 return 1 -1507 elif x < z: -1508 return -1 -1509 else: -1510 return 0 -1511 -1512 def sign(self) -> 'Die[int]': -1513 """Outcomes become 1 if greater than `zero()`, -1 if less than `zero()`, and 0 otherwise. -1514 -1515 Note that for `float`s, +0.0, -0.0, and nan all become 0. -1516 """ -1517 return self.unary_operator(Die._sign) +1488 This will include all three outcomes even if they have zero quantity. +1489 """ +1490 other = implicit_convert_to_die(other) +1491 +1492 data = {} +1493 +1494 lt = self < other +1495 if True in lt: +1496 data[-1] = lt[True] +1497 eq = self == other +1498 if True in eq: +1499 data[0] = eq[True] +1500 gt = self > other +1501 if True in gt: +1502 data[1] = gt[True] +1503 +1504 return Die(data) +1505 +1506 @staticmethod +1507 def _sign(x) -> int: +1508 z = Die._zero(x) +1509 if x > z: +1510 return 1 +1511 elif x < z: +1512 return -1 +1513 else: +1514 return 0 +1515 +1516 def sign(self) -> 'Die[int]': +1517 """Outcomes become 1 if greater than `zero()`, -1 if less than `zero()`, and 0 otherwise. 1518 -1519 # Equality and hashing. -1520 -1521 def __bool__(self) -> bool: -1522 raise TypeError( -1523 'A `Die` only has a truth value if it is the result of == or !=.\n' -1524 'This could result from trying to use a die in an if-statement,\n' -1525 'in which case you should use `die.if_else()` instead.\n' -1526 'Or it could result from trying to use a `Die` inside a tuple or vector outcome,\n' -1527 'in which case you should use `tupleize()` or `vectorize().') -1528 -1529 @cached_property -1530 def _hash_key(self) -> tuple: -1531 """A tuple that uniquely (as `equals()`) identifies this die. +1519 Note that for `float`s, +0.0, -0.0, and nan all become 0. +1520 """ +1521 return self.unary_operator(Die._sign) +1522 +1523 # Equality and hashing. +1524 +1525 def __bool__(self) -> bool: +1526 raise TypeError( +1527 'A `Die` only has a truth value if it is the result of == or !=.\n' +1528 'This could result from trying to use a die in an if-statement,\n' +1529 'in which case you should use `die.if_else()` instead.\n' +1530 'Or it could result from trying to use a `Die` inside a tuple or vector outcome,\n' +1531 'in which case you should use `tupleize()` or `vectorize().') 1532 -1533 Apart from being hashable and totally orderable, this is not guaranteed -1534 to be in any particular format or have any other properties. -1535 """ -1536 return tuple(self.items()) -1537 -1538 @cached_property -1539 def _hash(self) -> int: -1540 return hash(self._hash_key) +1533 @cached_property +1534 def _hash_key(self) -> tuple: +1535 """A tuple that uniquely (as `equals()`) identifies this die. +1536 +1537 Apart from being hashable and totally orderable, this is not guaranteed +1538 to be in any particular format or have any other properties. +1539 """ +1540 return tuple(self.items()) 1541 -1542 def __hash__(self) -> int: -1543 return self._hash -1544 -1545 def equals(self, other, *, simplify: bool = False) -> bool: -1546 """`True` iff both dice have the same outcomes and quantities. -1547 -1548 This is `False` if `other` is not a `Die`, even if it would convert -1549 to an equal `Die`. -1550 -1551 Truth value does NOT matter. -1552 -1553 If one `Die` has a zero-quantity outcome and the other `Die` does not -1554 contain that outcome, they are treated as unequal by this function. -1555 -1556 The `==` and `!=` operators have a dual purpose; they return a `Die` -1557 with a truth value determined by this method. -1558 Only dice returned by these methods have a truth value. The data of -1559 these dice is lazily evaluated since the caller may only be interested -1560 in the `Die` value or the truth value. -1561 -1562 Args: -1563 simplify: If `True`, the dice will be simplified before comparing. -1564 Otherwise, e.g. a 2:2 coin is not `equals()` to a 1:1 coin. -1565 """ -1566 if not isinstance(other, Die): -1567 return False -1568 -1569 if simplify: -1570 return self.simplify()._hash_key == other.simplify()._hash_key -1571 else: -1572 return self._hash_key == other._hash_key -1573 -1574 # Strings. -1575 -1576 def __repr__(self) -> str: -1577 inner = ', '.join(f'{repr(outcome)}: {weight}' -1578 for outcome, weight in self.items()) -1579 return type(self).__qualname__ + '({' + inner + '})' +1542 @cached_property +1543 def _hash(self) -> int: +1544 return hash(self._hash_key) +1545 +1546 def __hash__(self) -> int: +1547 return self._hash +1548 +1549 def equals(self, other, *, simplify: bool = False) -> bool: +1550 """`True` iff both dice have the same outcomes and quantities. +1551 +1552 This is `False` if `other` is not a `Die`, even if it would convert +1553 to an equal `Die`. +1554 +1555 Truth value does NOT matter. +1556 +1557 If one `Die` has a zero-quantity outcome and the other `Die` does not +1558 contain that outcome, they are treated as unequal by this function. +1559 +1560 The `==` and `!=` operators have a dual purpose; they return a `Die` +1561 with a truth value determined by this method. +1562 Only dice returned by these methods have a truth value. The data of +1563 these dice is lazily evaluated since the caller may only be interested +1564 in the `Die` value or the truth value. +1565 +1566 Args: +1567 simplify: If `True`, the dice will be simplified before comparing. +1568 Otherwise, e.g. a 2:2 coin is not `equals()` to a 1:1 coin. +1569 """ +1570 if not isinstance(other, Die): +1571 return False +1572 +1573 if simplify: +1574 return self.simplify()._hash_key == other.simplify()._hash_key +1575 else: +1576 return self._hash_key == other._hash_key +1577 +1578 # Strings. +1579 +1580 def __repr__(self) -> str: +1581 inner = ', '.join(f'{repr(outcome)}: {weight}' +1582 for outcome, weight in self.items()) +1583 return type(self).__qualname__ + '({' + inner + '})' @@ -4173,26 +4177,26 @@
Arguments:
-
786    def pool(self, rolls: int | Sequence[int] = 1, /) -> 'icepool.Pool[T_co]':
-787        """Creates a `Pool` from this `Die`.
-788
-789        You might subscript the pool immediately afterwards, e.g.
-790        `d6.pool(5)[-1, ..., 1]` takes the difference between the highest and
-791        lowest of 5d6.
+            
790    def pool(self, rolls: int | Sequence[int] = 1, /) -> 'icepool.Pool[T_co]':
+791        """Creates a `Pool` from this `Die`.
 792
-793        Args:
-794            rolls: The number of copies of this `Die` to put in the pool.
-795                Or, a sequence of one `int` per die acting as
-796                `keep_tuple`. Note that `...` cannot be used in the
-797                argument to this method, as the argument determines the size of
-798                the pool.
-799        """
-800        if isinstance(rolls, int):
-801            return icepool.Pool({self: rolls})
-802        else:
-803            pool_size = len(rolls)
-804            # Haven't dealt with narrowing return type.
-805            return icepool.Pool({self: pool_size})[rolls]  # type: ignore
+793        You might subscript the pool immediately afterwards, e.g.
+794        `d6.pool(5)[-1, ..., 1]` takes the difference between the highest and
+795        lowest of 5d6.
+796
+797        Args:
+798            rolls: The number of copies of this `Die` to put in the pool.
+799                Or, a sequence of one `int` per die acting as
+800                `keep_tuple`. Note that `...` cannot be used in the
+801                argument to this method, as the argument determines the size of
+802                the pool.
+803        """
+804        if isinstance(rolls, int):
+805            return icepool.Pool({self: rolls})
+806        else:
+807            pool_size = len(rolls)
+808            # Haven't dealt with narrowing return type.
+809            return icepool.Pool({self: pool_size})[rolls]  # type: ignore
 
@@ -4226,58 +4230,58 @@
Arguments:
-
853    def keep(self,
-854             rolls: int | Sequence[int],
-855             index: slice | Sequence[int | EllipsisType] | int | None = None,
-856             /) -> 'Die':
-857        """Selects elements after drawing and sorting and sums them.
-858
-859        Args:
-860            rolls: The number of dice to roll.
-861            index: One of the following:
-862            * An `int`. This will count only the roll at the specified index.
-863            In this case, the result is a `Die` rather than a generator.
-864            * A `slice`. The selected dice are counted once each.
-865            * A sequence of `int`s with length equal to `rolls`.
-866                Each roll is counted that many times, which could be multiple or
-867                negative times.
-868
-869                Up to one `...` (`Ellipsis`) may be used. If no `...` is used,
-870                the `rolls` argument may be omitted.
-871
-872                `...` will be replaced with a number of zero counts in order
-873                to make up any missing elements compared to `rolls`.
-874                This number may be "negative" if more `int`s are provided than
-875                `rolls`. Specifically:
-876
-877                * If `index` is shorter than `rolls`, `...`
-878                    acts as enough zero counts to make up the difference.
-879                    E.g. `(1, ..., 1)` on five dice would act as
-880                    `(1, 0, 0, 0, 1)`.
-881                * If `index` has length equal to `rolls`, `...` has no effect.
-882                    E.g. `(1, ..., 1)` on two dice would act as `(1, 1)`.
-883                * If `index` is longer than `rolls` and `...` is on one side,
-884                    elements will be dropped from `index` on the side with `...`.
-885                    E.g. `(..., 1, 2, 3)` on two dice would act as `(2, 3)`.
-886                * If `index` is longer than `rolls` and `...`
-887                    is in the middle, the counts will be as the sum of two
-888                    one-sided `...`.
-889                    E.g. `(-1, ..., 1)` acts like `(-1, ...)` plus `(..., 1)`.
-890                    If `rolls` was 1 this would have the -1 and 1 cancel each other out.
-891        """
-892        if isinstance(rolls, int):
-893            if index is None:
-894                raise ValueError(
-895                    'If the number of rolls is an integer, an index argument must be provided.'
-896                )
-897            if isinstance(index, int):
-898                return self.pool(rolls).keep(index)
-899            else:
-900                return self.pool(rolls).keep(index).sum()  # type: ignore
-901        else:
-902            if index is not None:
-903                raise ValueError('Only one index sequence can be given.')
-904            return self.pool(len(rolls)).keep(rolls).sum()  # type: ignore
+            
857    def keep(self,
+858             rolls: int | Sequence[int],
+859             index: slice | Sequence[int | EllipsisType] | int | None = None,
+860             /) -> 'Die':
+861        """Selects elements after drawing and sorting and sums them.
+862
+863        Args:
+864            rolls: The number of dice to roll.
+865            index: One of the following:
+866            * An `int`. This will count only the roll at the specified index.
+867            In this case, the result is a `Die` rather than a generator.
+868            * A `slice`. The selected dice are counted once each.
+869            * A sequence of `int`s with length equal to `rolls`.
+870                Each roll is counted that many times, which could be multiple or
+871                negative times.
+872
+873                Up to one `...` (`Ellipsis`) may be used. If no `...` is used,
+874                the `rolls` argument may be omitted.
+875
+876                `...` will be replaced with a number of zero counts in order
+877                to make up any missing elements compared to `rolls`.
+878                This number may be "negative" if more `int`s are provided than
+879                `rolls`. Specifically:
+880
+881                * If `index` is shorter than `rolls`, `...`
+882                    acts as enough zero counts to make up the difference.
+883                    E.g. `(1, ..., 1)` on five dice would act as
+884                    `(1, 0, 0, 0, 1)`.
+885                * If `index` has length equal to `rolls`, `...` has no effect.
+886                    E.g. `(1, ..., 1)` on two dice would act as `(1, 1)`.
+887                * If `index` is longer than `rolls` and `...` is on one side,
+888                    elements will be dropped from `index` on the side with `...`.
+889                    E.g. `(..., 1, 2, 3)` on two dice would act as `(2, 3)`.
+890                * If `index` is longer than `rolls` and `...`
+891                    is in the middle, the counts will be as the sum of two
+892                    one-sided `...`.
+893                    E.g. `(-1, ..., 1)` acts like `(-1, ...)` plus `(..., 1)`.
+894                    If `rolls` was 1 this would have the -1 and 1 cancel each other out.
+895        """
+896        if isinstance(rolls, int):
+897            if index is None:
+898                raise ValueError(
+899                    'If the number of rolls is an integer, an index argument must be provided.'
+900                )
+901            if isinstance(index, int):
+902                return self.pool(rolls).keep(index)
+903            else:
+904                return self.pool(rolls).keep(index).sum()  # type: ignore
+905        else:
+906            if index is not None:
+907                raise ValueError('Only one index sequence can be given.')
+908            return self.pool(len(rolls)).keep(rolls).sum()  # type: ignore
 
@@ -4345,35 +4349,35 @@
Arguments:
-
906    def lowest(self,
-907               rolls: int,
-908               /,
-909               keep: int | None = None,
-910               drop: int | None = None) -> 'Die':
-911        """Roll several of this `Die` and return the lowest result, or the sum of some of the lowest.
-912
-913        The outcomes should support addition and multiplication if `keep != 1`.
-914
-915        Args:
-916            rolls: The number of dice to roll. All dice will have the same
-917                outcomes as `self`.
-918            keep, drop: These arguments work together:
-919                * If neither are provided, the single lowest die will be taken.
-920                * If only `keep` is provided, the `keep` lowest dice will be summed.
-921                * If only `drop` is provided, the `drop` lowest dice will be dropped
-922                    and the rest will be summed.
-923                * If both are provided, `drop` lowest dice will be dropped, then
-924                    the next `keep` lowest dice will be summed.
-925
-926        Returns:
-927            A `Die` representing the probability distribution of the sum.
-928        """
-929        index = lowest_slice(keep, drop)
-930        canonical = canonical_slice(index, rolls)
-931        if canonical.start == 0 and canonical.stop == 1:
-932            return self._lowest_single(rolls)
-933        # Expression evaluators are difficult to type.
-934        return self.pool(rolls)[index].sum()  # type: ignore
+            
910    def lowest(self,
+911               rolls: int,
+912               /,
+913               keep: int | None = None,
+914               drop: int | None = None) -> 'Die':
+915        """Roll several of this `Die` and return the lowest result, or the sum of some of the lowest.
+916
+917        The outcomes should support addition and multiplication if `keep != 1`.
+918
+919        Args:
+920            rolls: The number of dice to roll. All dice will have the same
+921                outcomes as `self`.
+922            keep, drop: These arguments work together:
+923                * If neither are provided, the single lowest die will be taken.
+924                * If only `keep` is provided, the `keep` lowest dice will be summed.
+925                * If only `drop` is provided, the `drop` lowest dice will be dropped
+926                    and the rest will be summed.
+927                * If both are provided, `drop` lowest dice will be dropped, then
+928                    the next `keep` lowest dice will be summed.
+929
+930        Returns:
+931            A `Die` representing the probability distribution of the sum.
+932        """
+933        index = lowest_slice(keep, drop)
+934        canonical = canonical_slice(index, rolls)
+935        if canonical.start == 0 and canonical.stop == 1:
+936            return self._lowest_single(rolls)
+937        # Expression evaluators are difficult to type.
+938        return self.pool(rolls)[index].sum()  # type: ignore
 
@@ -4417,34 +4421,34 @@
Returns:
-
944    def highest(self,
-945                rolls: int,
-946                /,
-947                keep: int | None = None,
-948                drop: int | None = None) -> 'Die[T_co]':
-949        """Roll several of this `Die` and return the highest result, or the sum of some of the highest.
-950
-951        The outcomes should support addition and multiplication if `keep != 1`.
-952
-953        Args:
-954            rolls: The number of dice to roll.
-955            keep, drop: These arguments work together:
-956                * If neither are provided, the single highest die will be taken.
-957                * If only `keep` is provided, the `keep` highest dice will be summed.
-958                * If only `drop` is provided, the `drop` highest dice will be dropped
-959                    and the rest will be summed.
-960                * If both are provided, `drop` highest dice will be dropped, then
-961                    the next `keep` highest dice will be summed.
-962
-963        Returns:
-964            A `Die` representing the probability distribution of the sum.
-965        """
-966        index = highest_slice(keep, drop)
-967        canonical = canonical_slice(index, rolls)
-968        if canonical.start == rolls - 1 and canonical.stop == rolls:
-969            return self._highest_single(rolls)
-970        # Expression evaluators are difficult to type.
-971        return self.pool(rolls)[index].sum()  # type: ignore
+            
948    def highest(self,
+949                rolls: int,
+950                /,
+951                keep: int | None = None,
+952                drop: int | None = None) -> 'Die[T_co]':
+953        """Roll several of this `Die` and return the highest result, or the sum of some of the highest.
+954
+955        The outcomes should support addition and multiplication if `keep != 1`.
+956
+957        Args:
+958            rolls: The number of dice to roll.
+959            keep, drop: These arguments work together:
+960                * If neither are provided, the single highest die will be taken.
+961                * If only `keep` is provided, the `keep` highest dice will be summed.
+962                * If only `drop` is provided, the `drop` highest dice will be dropped
+963                    and the rest will be summed.
+964                * If both are provided, `drop` highest dice will be dropped, then
+965                    the next `keep` highest dice will be summed.
+966
+967        Returns:
+968            A `Die` representing the probability distribution of the sum.
+969        """
+970        index = highest_slice(keep, drop)
+971        canonical = canonical_slice(index, rolls)
+972        if canonical.start == rolls - 1 and canonical.stop == rolls:
+973            return self._highest_single(rolls)
+974        # Expression evaluators are difficult to type.
+975        return self.pool(rolls)[index].sum()  # type: ignore
 
@@ -4487,29 +4491,29 @@
Returns:
-
 980    def middle(
- 981            self,
- 982            rolls: int,
- 983            /,
- 984            keep: int = 1,
- 985            *,
- 986            tie: Literal['error', 'high', 'low'] = 'error') -> 'icepool.Die':
- 987        """Roll several of this `Die` and sum the sorted results in the middle.
- 988
- 989        The outcomes should support addition and multiplication if `keep != 1`.
- 990
- 991        Args:
- 992            rolls: The number of dice to roll.
- 993            keep: The number of outcomes to sum. If this is greater than the
- 994                current keep_size, all are kept.
- 995            tie: What to do if `keep` is odd but the current keep_size
- 996                is even, or vice versa.
- 997                * 'error' (default): Raises `IndexError`.
- 998                * 'high': The higher outcome is taken.
- 999                * 'low': The lower outcome is taken.
-1000        """
-1001        # Expression evaluators are difficult to type.
-1002        return self.pool(rolls).middle(keep, tie=tie).sum()  # type: ignore
+            
 984    def middle(
+ 985            self,
+ 986            rolls: int,
+ 987            /,
+ 988            keep: int = 1,
+ 989            *,
+ 990            tie: Literal['error', 'high', 'low'] = 'error') -> 'icepool.Die':
+ 991        """Roll several of this `Die` and sum the sorted results in the middle.
+ 992
+ 993        The outcomes should support addition and multiplication if `keep != 1`.
+ 994
+ 995        Args:
+ 996            rolls: The number of dice to roll.
+ 997            keep: The number of outcomes to sum. If this is greater than the
+ 998                current keep_size, all are kept.
+ 999            tie: What to do if `keep` is odd but the current keep_size
+1000                is even, or vice versa.
+1001                * 'error' (default): Raises `IndexError`.
+1002                * 'high': The higher outcome is taken.
+1003                * 'low': The lower outcome is taken.
+1004        """
+1005        # Expression evaluators are difficult to type.
+1006        return self.pool(rolls).middle(keep, tie=tie).sum()  # type: ignore
 
@@ -4546,57 +4550,57 @@
Arguments:
-
1004    def map_to_pool(
-1005        self,
-1006        repl:
-1007        'Callable[..., Sequence[icepool.Die[U] | U] | Mapping[icepool.Die[U], int] | Mapping[U, int] | icepool.RerollType] | None' = None,
-1008        /,
-1009        *extra_args: 'Outcome | icepool.Die | icepool.MultisetExpression',
-1010        star: bool | None = None,
-1011        denominator: int | None = None
-1012    ) -> 'icepool.MultisetGenerator[U, tuple[int]]':
-1013        """EXPERIMENTAL: Maps outcomes of this `Die` to `Pools`, creating a `MultisetGenerator`.
-1014
-1015        As `icepool.map_to_pool(repl, self, ...)`.
-1016
-1017        If no argument is provided, the outcomes will be used to construct a
-1018        mixture of pools directly, similar to the inverse of `pool.expand()`.
-1019        Note that this is not particularly efficient since it does not make much
-1020        use of dynamic programming.
-1021
-1022        Args:
-1023            repl: One of the following:
-1024                * A callable that takes in one outcome per element of args and
-1025                    produces a `Pool` (or something convertible to such).
-1026                * A mapping from old outcomes to `Pool` 
-1027                    (or something convertible to such).
-1028                    In this case args must have exactly one element.
-1029                The new outcomes may be dice rather than just single outcomes.
-1030                The special value `icepool.Reroll` will reroll that old outcome.
-1031            star: If `True`, the first of the args will be unpacked before 
-1032                giving them to `repl`.
-1033                If not provided, it will be guessed based on the signature of 
-1034                `repl` and the number of arguments.
-1035            denominator: If provided, the denominator of the result will be this
-1036                value. Otherwise it will be the minimum to correctly weight the
-1037                pools.
-1038
-1039        Returns:
-1040            A `MultisetGenerator` representing the mixture of `Pool`s. Note  
-1041            that this is not technically a `Pool`, though it supports most of 
-1042            the same operations.
-1043
-1044        Raises:
-1045            ValueError: If `denominator` cannot be made consistent with the 
-1046                resulting mixture of pools.
-1047        """
-1048        if repl is None:
-1049            repl = lambda x: x
-1050        return icepool.map_to_pool(repl,
-1051                                   self,
-1052                                   *extra_args,
-1053                                   star=star,
-1054                                   denominator=denominator)
+            
1008    def map_to_pool(
+1009        self,
+1010        repl:
+1011        'Callable[..., Sequence[icepool.Die[U] | U] | Mapping[icepool.Die[U], int] | Mapping[U, int] | icepool.RerollType] | None' = None,
+1012        /,
+1013        *extra_args: 'Outcome | icepool.Die | icepool.MultisetExpression',
+1014        star: bool | None = None,
+1015        denominator: int | None = None
+1016    ) -> 'icepool.MultisetGenerator[U, tuple[int]]':
+1017        """EXPERIMENTAL: Maps outcomes of this `Die` to `Pools`, creating a `MultisetGenerator`.
+1018
+1019        As `icepool.map_to_pool(repl, self, ...)`.
+1020
+1021        If no argument is provided, the outcomes will be used to construct a
+1022        mixture of pools directly, similar to the inverse of `pool.expand()`.
+1023        Note that this is not particularly efficient since it does not make much
+1024        use of dynamic programming.
+1025
+1026        Args:
+1027            repl: One of the following:
+1028                * A callable that takes in one outcome per element of args and
+1029                    produces a `Pool` (or something convertible to such).
+1030                * A mapping from old outcomes to `Pool` 
+1031                    (or something convertible to such).
+1032                    In this case args must have exactly one element.
+1033                The new outcomes may be dice rather than just single outcomes.
+1034                The special value `icepool.Reroll` will reroll that old outcome.
+1035            star: If `True`, the first of the args will be unpacked before 
+1036                giving them to `repl`.
+1037                If not provided, it will be guessed based on the signature of 
+1038                `repl` and the number of arguments.
+1039            denominator: If provided, the denominator of the result will be this
+1040                value. Otherwise it will be the minimum to correctly weight the
+1041                pools.
+1042
+1043        Returns:
+1044            A `MultisetGenerator` representing the mixture of `Pool`s. Note  
+1045            that this is not technically a `Pool`, though it supports most of 
+1046            the same operations.
+1047
+1048        Raises:
+1049            ValueError: If `denominator` cannot be made consistent with the 
+1050                resulting mixture of pools.
+1051        """
+1052        if repl is None:
+1053            repl = lambda x: x
+1054        return icepool.map_to_pool(repl,
+1055                                   self,
+1056                                   *extra_args,
+1057                                   star=star,
+1058                                   denominator=denominator)
 
@@ -4660,59 +4664,59 @@
Raises:
-
1056    def explode_to_pool(
-1057            self,
-1058            rolls: int,
-1059            which: Collection[T_co] | Callable[..., bool] | None = None,
-1060            /,
-1061            *,
-1062            star: bool | None = None,
-1063            depth: int = 9) -> 'icepool.MultisetGenerator[T_co, tuple[int]]':
-1064        """EXPERIMENTAL: Causes outcomes to be rolled again, keeping that outcome as an individual die in a pool.
-1065        
-1066        Args:
-1067            rolls: The number of initial dice.
-1068            which: Which outcomes to explode. Options:
-1069                * A single outcome to explode.
-1070                * An collection of outcomes to explode.
-1071                * A callable that takes an outcome and returns `True` if it
-1072                    should be exploded.
-1073                * If not supplied, the max outcome will explode.
-1074            star: Whether outcomes should be unpacked into separate arguments
-1075                before sending them to a callable `which`.
-1076                If not provided, this will be guessed based on the function
-1077                signature.
-1078            depth: The maximum depth of explosions for an individual dice.
-1079
-1080        Returns:
-1081            A `MultisetGenerator` representing the mixture of `Pool`s. Note  
-1082            that this is not technically a `Pool`, though it supports most of 
-1083            the same operations.
-1084        """
-1085        if depth == 0:
-1086            return self.pool(rolls)
-1087        if which is None:
-1088            explode_set = {self.max_outcome()}
-1089        else:
-1090            explode_set = self._select_outcomes(which, star)
-1091        if not explode_set:
-1092            return self.pool(rolls)
-1093        explode, not_explode = self.split(explode_set)
-1094
-1095        single_data: 'MutableMapping[icepool.Vector[int], int]' = defaultdict(
-1096            int)
-1097        for i in range(depth + 1):
-1098            weight = explode.denominator()**i * self.denominator()**(
-1099                depth - i) * not_explode.denominator()
-1100            single_data[icepool.Vector((i, 1))] += weight
-1101        single_data[icepool.Vector(
-1102            (depth + 1, 0))] += explode.denominator()**(depth + 1)
-1103
-1104        single_count_die: 'Die[icepool.Vector[int]]' = Die(single_data)
-1105        count_die = rolls @ single_count_die
-1106
-1107        return count_die.map_to_pool(
-1108            lambda x, nx: [explode] * x + [not_explode] * nx)
+            
1060    def explode_to_pool(
+1061            self,
+1062            rolls: int,
+1063            which: Collection[T_co] | Callable[..., bool] | None = None,
+1064            /,
+1065            *,
+1066            star: bool | None = None,
+1067            depth: int = 9) -> 'icepool.MultisetGenerator[T_co, tuple[int]]':
+1068        """EXPERIMENTAL: Causes outcomes to be rolled again, keeping that outcome as an individual die in a pool.
+1069        
+1070        Args:
+1071            rolls: The number of initial dice.
+1072            which: Which outcomes to explode. Options:
+1073                * A single outcome to explode.
+1074                * An collection of outcomes to explode.
+1075                * A callable that takes an outcome and returns `True` if it
+1076                    should be exploded.
+1077                * If not supplied, the max outcome will explode.
+1078            star: Whether outcomes should be unpacked into separate arguments
+1079                before sending them to a callable `which`.
+1080                If not provided, this will be guessed based on the function
+1081                signature.
+1082            depth: The maximum depth of explosions for an individual dice.
+1083
+1084        Returns:
+1085            A `MultisetGenerator` representing the mixture of `Pool`s. Note  
+1086            that this is not technically a `Pool`, though it supports most of 
+1087            the same operations.
+1088        """
+1089        if depth == 0:
+1090            return self.pool(rolls)
+1091        if which is None:
+1092            explode_set = {self.max_outcome()}
+1093        else:
+1094            explode_set = self._select_outcomes(which, star)
+1095        if not explode_set:
+1096            return self.pool(rolls)
+1097        explode, not_explode = self.split(explode_set)
+1098
+1099        single_data: 'MutableMapping[icepool.Vector[int], int]' = defaultdict(
+1100            int)
+1101        for i in range(depth + 1):
+1102            weight = explode.denominator()**i * self.denominator()**(
+1103                depth - i) * not_explode.denominator()
+1104            single_data[icepool.Vector((i, 1))] += weight
+1105        single_data[icepool.Vector(
+1106            (depth + 1, 0))] += explode.denominator()**(depth + 1)
+1107
+1108        single_count_die: 'Die[icepool.Vector[int]]' = Die(single_data)
+1109        count_die = rolls @ single_count_die
+1110
+1111        return count_die.map_to_pool(
+1112            lambda x, nx: [explode] * x + [not_explode] * nx)
 
@@ -4759,118 +4763,118 @@
Returns:
-
1110    def reroll_to_pool(
-1111        self,
-1112        rolls: int,
-1113        which: Callable[..., bool] | Collection[T_co],
-1114        /,
-1115        max_rerolls: int,
-1116        *,
-1117        star: bool | None = None,
-1118        mode: Literal['random', 'lowest', 'highest', 'drop'] = 'random'
-1119    ) -> 'icepool.MultisetGenerator[T_co, tuple[int]]':
-1120        """EXPERIMENTAL: Applies a limited number of rerolls shared across a pool.
-1121
-1122        Each die can only be rerolled once (effectively `depth=1`), and no more
-1123        than `max_rerolls` dice may be rerolled.
-1124        
-1125        Args:
-1126            rolls: How many dice in the pool.
-1127            which: Selects which outcomes are eligible to be rerolled. Options:
-1128                * A collection of outcomes to reroll.
-1129                * A callable that takes an outcome and returns `True` if it
-1130                    could be rerolled.
-1131            max_rerolls: The maximum number of dice to reroll. 
-1132                Note that each die can only be rerolled once, so if the number 
-1133                of eligible dice is less than this, the excess rerolls have no
-1134                effect.
-1135            star: Whether outcomes should be unpacked into separate arguments
-1136                before sending them to a callable `which`.
-1137                If not provided, this will be guessed based on the function
-1138                signature.
-1139            mode: How dice are selected for rerolling if there are more eligible
-1140                dice than `max_rerolls`. Options:
-1141                * `'random'` (default): Eligible dice will be chosen uniformly
-1142                    at random.
-1143                * `'lowest'`: The lowest eligible dice will be rerolled.
-1144                * `'highest'`: The highest eligible dice will be rerolled.
-1145                * `'drop'`: All dice that ended up on an outcome selected by 
-1146                    `which` will be dropped. This includes both dice that rolled
-1147                    into `which` initially and were not rerolled, and dice that
-1148                    were rerolled but rolled into `which` again. This can be
-1149                    considerably more efficient than the other modes.
-1150
-1151        Returns:
-1152            A `MultisetGenerator` representing the mixture of `Pool`s. Note  
-1153            that this is not technically a `Pool`, though it supports most of 
-1154            the same operations.
-1155        """
-1156        rerollable_set = self._select_outcomes(which, star)
-1157        if not rerollable_set:
-1158            return self.pool(rolls)
-1159
-1160        rerollable_die, not_rerollable_die = self.split(rerollable_set)
-1161        single_is_rerollable = icepool.coin(rerollable_die.denominator(),
-1162                                            self.denominator())
-1163        rerollable = rolls @ single_is_rerollable
-1164
-1165        def split(initial_rerollable: int) -> Die[tuple[int, int, int]]:
-1166            """Computes the composition of the pool.
-1167
-1168            Returns:
-1169                initial_rerollable: The number of dice that initially fell into
-1170                    the rerollable set.
-1171                rerolled_to_rerollable: The number of dice that were rerolled,
-1172                    but fell into the rerollable set again.
-1173                not_rerollable: The number of dice that ended up outside the
-1174                    rerollable set, including both initial and rerolled dice.
-1175                not_rerolled: The number of dice that were eligible for
-1176                    rerolling but were not rerolled.
-1177            """
-1178            initial_not_rerollable = rolls - initial_rerollable
-1179            rerolled = min(initial_rerollable, max_rerolls)
-1180            not_rerolled = initial_rerollable - rerolled
-1181
-1182            def second_split(rerolled_to_rerollable):
-1183                """Splits the rerolled dice into those that fell into the rerollable and not-rerollable sets."""
-1184                rerolled_to_not_rerollable = rerolled - rerolled_to_rerollable
-1185                return icepool.tupleize(
-1186                    initial_rerollable, rerolled_to_rerollable,
-1187                    initial_not_rerollable + rerolled_to_not_rerollable,
-1188                    not_rerolled)
-1189
-1190            return icepool.map(second_split,
-1191                               rerolled @ single_is_rerollable,
-1192                               star=False)
+            
1114    def reroll_to_pool(
+1115        self,
+1116        rolls: int,
+1117        which: Callable[..., bool] | Collection[T_co],
+1118        /,
+1119        max_rerolls: int,
+1120        *,
+1121        star: bool | None = None,
+1122        mode: Literal['random', 'lowest', 'highest', 'drop'] = 'random'
+1123    ) -> 'icepool.MultisetGenerator[T_co, tuple[int]]':
+1124        """EXPERIMENTAL: Applies a limited number of rerolls shared across a pool.
+1125
+1126        Each die can only be rerolled once (effectively `depth=1`), and no more
+1127        than `max_rerolls` dice may be rerolled.
+1128        
+1129        Args:
+1130            rolls: How many dice in the pool.
+1131            which: Selects which outcomes are eligible to be rerolled. Options:
+1132                * A collection of outcomes to reroll.
+1133                * A callable that takes an outcome and returns `True` if it
+1134                    could be rerolled.
+1135            max_rerolls: The maximum number of dice to reroll. 
+1136                Note that each die can only be rerolled once, so if the number 
+1137                of eligible dice is less than this, the excess rerolls have no
+1138                effect.
+1139            star: Whether outcomes should be unpacked into separate arguments
+1140                before sending them to a callable `which`.
+1141                If not provided, this will be guessed based on the function
+1142                signature.
+1143            mode: How dice are selected for rerolling if there are more eligible
+1144                dice than `max_rerolls`. Options:
+1145                * `'random'` (default): Eligible dice will be chosen uniformly
+1146                    at random.
+1147                * `'lowest'`: The lowest eligible dice will be rerolled.
+1148                * `'highest'`: The highest eligible dice will be rerolled.
+1149                * `'drop'`: All dice that ended up on an outcome selected by 
+1150                    `which` will be dropped. This includes both dice that rolled
+1151                    into `which` initially and were not rerolled, and dice that
+1152                    were rerolled but rolled into `which` again. This can be
+1153                    considerably more efficient than the other modes.
+1154
+1155        Returns:
+1156            A `MultisetGenerator` representing the mixture of `Pool`s. Note  
+1157            that this is not technically a `Pool`, though it supports most of 
+1158            the same operations.
+1159        """
+1160        rerollable_set = self._select_outcomes(which, star)
+1161        if not rerollable_set:
+1162            return self.pool(rolls)
+1163
+1164        rerollable_die, not_rerollable_die = self.split(rerollable_set)
+1165        single_is_rerollable = icepool.coin(rerollable_die.denominator(),
+1166                                            self.denominator())
+1167        rerollable = rolls @ single_is_rerollable
+1168
+1169        def split(initial_rerollable: int) -> Die[tuple[int, int, int]]:
+1170            """Computes the composition of the pool.
+1171
+1172            Returns:
+1173                initial_rerollable: The number of dice that initially fell into
+1174                    the rerollable set.
+1175                rerolled_to_rerollable: The number of dice that were rerolled,
+1176                    but fell into the rerollable set again.
+1177                not_rerollable: The number of dice that ended up outside the
+1178                    rerollable set, including both initial and rerolled dice.
+1179                not_rerolled: The number of dice that were eligible for
+1180                    rerolling but were not rerolled.
+1181            """
+1182            initial_not_rerollable = rolls - initial_rerollable
+1183            rerolled = min(initial_rerollable, max_rerolls)
+1184            not_rerolled = initial_rerollable - rerolled
+1185
+1186            def second_split(rerolled_to_rerollable):
+1187                """Splits the rerolled dice into those that fell into the rerollable and not-rerollable sets."""
+1188                rerolled_to_not_rerollable = rerolled - rerolled_to_rerollable
+1189                return icepool.tupleize(
+1190                    initial_rerollable, rerolled_to_rerollable,
+1191                    initial_not_rerollable + rerolled_to_not_rerollable,
+1192                    not_rerolled)
 1193
-1194        pool_composition = rerollable.map(split, star=False)
-1195
-1196        def make_pool(initial_rerollable, rerolled_to_rerollable,
-1197                      not_rerollable, not_rerolled):
-1198            common = rerollable_die.pool(
-1199                rerolled_to_rerollable) + not_rerollable_die.pool(
-1200                    not_rerollable)
-1201            match mode:
-1202                case 'random':
-1203                    return common + rerollable_die.pool(not_rerolled)
-1204                case 'lowest':
-1205                    return common + rerollable_die.pool(
-1206                        initial_rerollable).highest(not_rerolled)
-1207                case 'highest':
-1208                    return common + rerollable_die.pool(
-1209                        initial_rerollable).lowest(not_rerolled)
-1210                case 'drop':
-1211                    return not_rerollable_die.pool(not_rerollable)
-1212                case _:
-1213                    raise ValueError(
-1214                        f"Invalid reroll_priority '{mode}'. Allowed values are 'random', 'lowest', 'highest', 'drop'."
-1215                    )
-1216
-1217        denominator = self.denominator()**(rolls + min(rolls, max_rerolls))
-1218
-1219        return pool_composition.map_to_pool(make_pool,
-1220                                            star=True,
-1221                                            denominator=denominator)
+1194            return icepool.map(second_split,
+1195                               rerolled @ single_is_rerollable,
+1196                               star=False)
+1197
+1198        pool_composition = rerollable.map(split, star=False)
+1199
+1200        def make_pool(initial_rerollable, rerolled_to_rerollable,
+1201                      not_rerollable, not_rerolled):
+1202            common = rerollable_die.pool(
+1203                rerolled_to_rerollable) + not_rerollable_die.pool(
+1204                    not_rerollable)
+1205            match mode:
+1206                case 'random':
+1207                    return common + rerollable_die.pool(not_rerolled)
+1208                case 'lowest':
+1209                    return common + rerollable_die.pool(
+1210                        initial_rerollable).highest(not_rerolled)
+1211                case 'highest':
+1212                    return common + rerollable_die.pool(
+1213                        initial_rerollable).lowest(not_rerolled)
+1214                case 'drop':
+1215                    return not_rerollable_die.pool(not_rerollable)
+1216                case _:
+1217                    raise ValueError(
+1218                        f"Invalid reroll_priority '{mode}'. Allowed values are 'random', 'lowest', 'highest', 'drop'."
+1219                    )
+1220
+1221        denominator = self.denominator()**(rolls + min(rolls, max_rerolls))
+1222
+1223        return pool_composition.map_to_pool(make_pool,
+1224                                            star=True,
+1225                                            denominator=denominator)
 
@@ -4934,8 +4938,8 @@
Returns:
-
1234    def abs(self) -> 'Die[T_co]':
-1235        return self.unary_operator(operator.abs)
+            
1238    def abs(self) -> 'Die[T_co]':
+1239        return self.unary_operator(operator.abs)
 
@@ -4953,8 +4957,8 @@
Returns:
-
1239    def round(self, ndigits: int | None = None) -> 'Die':
-1240        return self.unary_operator(round, ndigits)
+            
1243    def round(self, ndigits: int | None = None) -> 'Die':
+1244        return self.unary_operator(round, ndigits)
 
@@ -4972,22 +4976,22 @@
Returns:
-
1244    def stochastic_round(self,
-1245                         *,
-1246                         max_denominator: int | None = None) -> 'Die[int]':
-1247        """Randomly rounds outcomes up or down to the nearest integer according to the two distances.
-1248        
-1249        Specificially, rounds `x` up with probability `x - floor(x)` and down
-1250        otherwise.
-1251
-1252        Args:
-1253            max_denominator: If provided, each rounding will be performed
-1254                using `fractions.Fraction.limit_denominator(max_denominator)`.
-1255                Otherwise, the rounding will be performed without
-1256                `limit_denominator`.
-1257        """
-1258        return self.map(lambda x: icepool.stochastic_round(
-1259            x, max_denominator=max_denominator))
+            
1248    def stochastic_round(self,
+1249                         *,
+1250                         max_denominator: int | None = None) -> 'Die[int]':
+1251        """Randomly rounds outcomes up or down to the nearest integer according to the two distances.
+1252        
+1253        Specificially, rounds `x` up with probability `x - floor(x)` and down
+1254        otherwise.
+1255
+1256        Args:
+1257            max_denominator: If provided, each rounding will be performed
+1258                using `fractions.Fraction.limit_denominator(max_denominator)`.
+1259                Otherwise, the rounding will be performed without
+1260                `limit_denominator`.
+1261        """
+1262        return self.map(lambda x: icepool.stochastic_round(
+1263            x, max_denominator=max_denominator))
 
@@ -5019,8 +5023,8 @@
Arguments:
-
1261    def trunc(self) -> 'Die':
-1262        return self.unary_operator(math.trunc)
+            
1265    def trunc(self) -> 'Die':
+1266        return self.unary_operator(math.trunc)
 
@@ -5038,8 +5042,8 @@
Arguments:
-
1266    def floor(self) -> 'Die':
-1267        return self.unary_operator(math.floor)
+            
1270    def floor(self) -> 'Die':
+1271        return self.unary_operator(math.floor)
 
@@ -5057,8 +5061,8 @@
Arguments:
-
1271    def ceil(self) -> 'Die':
-1272        return self.unary_operator(math.ceil)
+            
1275    def ceil(self) -> 'Die':
+1276        return self.unary_operator(math.ceil)
 
@@ -5076,29 +5080,29 @@
Arguments:
-
1478    def cmp(self, other) -> 'Die[int]':
-1479        """A `Die` with outcomes 1, -1, and 0.
-1480
-1481        The quantities are equal to the positive outcome of `self > other`,
-1482        `self < other`, and the remainder respectively.
-1483
-1484        This will include all three outcomes even if they have zero quantity.
-1485        """
-1486        other = implicit_convert_to_die(other)
+            
1482    def cmp(self, other) -> 'Die[int]':
+1483        """A `Die` with outcomes 1, -1, and 0.
+1484
+1485        The quantities are equal to the positive outcome of `self > other`,
+1486        `self < other`, and the remainder respectively.
 1487
-1488        data = {}
-1489
-1490        lt = self < other
-1491        if True in lt:
-1492            data[-1] = lt[True]
-1493        eq = self == other
-1494        if True in eq:
-1495            data[0] = eq[True]
-1496        gt = self > other
-1497        if True in gt:
-1498            data[1] = gt[True]
-1499
-1500        return Die(data)
+1488        This will include all three outcomes even if they have zero quantity.
+1489        """
+1490        other = implicit_convert_to_die(other)
+1491
+1492        data = {}
+1493
+1494        lt = self < other
+1495        if True in lt:
+1496            data[-1] = lt[True]
+1497        eq = self == other
+1498        if True in eq:
+1499            data[0] = eq[True]
+1500        gt = self > other
+1501        if True in gt:
+1502            data[1] = gt[True]
+1503
+1504        return Die(data)
 
@@ -5123,12 +5127,12 @@
Arguments:
-
1512    def sign(self) -> 'Die[int]':
-1513        """Outcomes become 1 if greater than `zero()`, -1 if less than `zero()`, and 0 otherwise.
-1514
-1515        Note that for `float`s, +0.0, -0.0, and nan all become 0.
-1516        """
-1517        return self.unary_operator(Die._sign)
+            
1516    def sign(self) -> 'Die[int]':
+1517        """Outcomes become 1 if greater than `zero()`, -1 if less than `zero()`, and 0 otherwise.
+1518
+1519        Note that for `float`s, +0.0, -0.0, and nan all become 0.
+1520        """
+1521        return self.unary_operator(Die._sign)
 
@@ -5150,34 +5154,34 @@
Arguments:
-
1545    def equals(self, other, *, simplify: bool = False) -> bool:
-1546        """`True` iff both dice have the same outcomes and quantities.
-1547
-1548        This is `False` if `other` is not a `Die`, even if it would convert
-1549        to an equal `Die`.
-1550
-1551        Truth value does NOT matter.
-1552
-1553        If one `Die` has a zero-quantity outcome and the other `Die` does not
-1554        contain that outcome, they are treated as unequal by this function.
-1555
-1556        The `==` and `!=` operators have a dual purpose; they return a `Die`
-1557        with a truth value determined by this method.
-1558        Only dice returned by these methods have a truth value. The data of
-1559        these dice is lazily evaluated since the caller may only be interested
-1560        in the `Die` value or the truth value.
-1561
-1562        Args:
-1563            simplify: If `True`, the dice will be simplified before comparing.
-1564                Otherwise, e.g. a 2:2 coin is not `equals()` to a 1:1 coin.
-1565        """
-1566        if not isinstance(other, Die):
-1567            return False
-1568
-1569        if simplify:
-1570            return self.simplify()._hash_key == other.simplify()._hash_key
-1571        else:
-1572            return self._hash_key == other._hash_key
+            
1549    def equals(self, other, *, simplify: bool = False) -> bool:
+1550        """`True` iff both dice have the same outcomes and quantities.
+1551
+1552        This is `False` if `other` is not a `Die`, even if it would convert
+1553        to an equal `Die`.
+1554
+1555        Truth value does NOT matter.
+1556
+1557        If one `Die` has a zero-quantity outcome and the other `Die` does not
+1558        contain that outcome, they are treated as unequal by this function.
+1559
+1560        The `==` and `!=` operators have a dual purpose; they return a `Die`
+1561        with a truth value determined by this method.
+1562        Only dice returned by these methods have a truth value. The data of
+1563        these dice is lazily evaluated since the caller may only be interested
+1564        in the `Die` value or the truth value.
+1565
+1566        Args:
+1567            simplify: If `True`, the dice will be simplified before comparing.
+1568                Otherwise, e.g. a 2:2 coin is not `equals()` to a 1:1 coin.
+1569        """
+1570        if not isinstance(other, Die):
+1571            return False
+1572
+1573        if simplify:
+1574            return self.simplify()._hash_key == other.simplify()._hash_key
+1575        else:
+1576            return self._hash_key == other._hash_key
 
diff --git a/apidoc/latest/icepool/evaluator.html b/apidoc/latest/icepool/evaluator.html index dfbd8dfd..677c0a18 100644 --- a/apidoc/latest/icepool/evaluator.html +++ b/apidoc/latest/icepool/evaluator.html @@ -3,7 +3,7 @@ - + icepool.evaluator API documentation diff --git a/apidoc/latest/icepool/function.html b/apidoc/latest/icepool/function.html index 81f634a0..397e0cf6 100644 --- a/apidoc/latest/icepool/function.html +++ b/apidoc/latest/icepool/function.html @@ -3,7 +3,7 @@ - + icepool.function API documentation diff --git a/apidoc/latest/icepool/typing.html b/apidoc/latest/icepool/typing.html index 4431bfaa..8290be09 100644 --- a/apidoc/latest/icepool/typing.html +++ b/apidoc/latest/icepool/typing.html @@ -3,7 +3,7 @@ - + icepool.typing API documentation