From db8eb5abc8974439e99969cf0cb7068994586d09 Mon Sep 17 00:00:00 2001 From: Albert Julius Liu Date: Sat, 24 Aug 2024 22:22:33 -0700 Subject: [PATCH] replaced `_estimate_order_costs` with a simpler `_preferred_pop_order` --- src/icepool/evaluator/multiset_evaluator.py | 41 ++++------ src/icepool/generator/alignment.py | 8 +- src/icepool/generator/compound_keep.py | 14 ++-- src/icepool/generator/deal.py | 11 +-- src/icepool/generator/mixture.py | 15 ++-- src/icepool/generator/multi_deal.py | 8 +- src/icepool/generator/multiset_generator.py | 15 ++-- src/icepool/generator/pool.py | 29 ++++--- src/icepool/generator/pop_order.py | 87 +++++++++++++------- tests/pool_direction_test.py | 3 +- tests/pop_order_test.py | 88 +++++++++++++++------ 11 files changed, 189 insertions(+), 130 deletions(-) diff --git a/src/icepool/evaluator/multiset_evaluator.py b/src/icepool/evaluator/multiset_evaluator.py index deda0d09..605ad2f2 100644 --- a/src/icepool/evaluator/multiset_evaluator.py +++ b/src/icepool/evaluator/multiset_evaluator.py @@ -2,8 +2,7 @@ import icepool from icepool.collection.counts import sorted_union - -from icepool.typing import Order, T_contra, U_co +from icepool.generator.pop_order import PopOrderReason, merge_pop_orders from abc import ABC, abstractmethod from collections import defaultdict @@ -12,6 +11,8 @@ import itertools import math +from icepool.typing import Order, T_contra, U_co + from typing import Any, Callable, Collection, Generic, Hashable, Mapping, MutableMapping, Sequence, cast, TYPE_CHECKING, overload if TYPE_CHECKING: @@ -338,35 +339,25 @@ def _select_algorithm( # No generators. return self._eval_internal, eval_order - pop_min_costs, pop_max_costs = zip(*(generator._estimate_order_costs() - for generator in generators)) + preferred_pop_order, pop_order_reason = merge_pop_orders( + *(generator._preferred_pop_order() for generator in generators)) - pop_min_cost = math.prod(pop_min_costs) - pop_max_cost = math.prod(pop_max_costs) + if preferred_pop_order is None: + preferred_pop_order = Order.Any + pop_order_reason = PopOrderReason.NoPreference - # No preferred order case: go directly with cost. + # No mandatory evaluation order, go with preferred algorithm. + # Note that this has order *opposite* the pop order. if eval_order == Order.Any: - if pop_max_cost <= pop_min_cost: - return self._eval_internal, Order.Ascending - else: - return self._eval_internal, Order.Descending - - # Preferred order case. - # Go with the preferred order unless there is a "significant" - # cost factor. - - if PREFERRED_ORDER_COST_FACTOR * pop_max_cost < pop_min_cost: - cost_order = Order.Ascending - elif PREFERRED_ORDER_COST_FACTOR * pop_min_cost < pop_max_cost: - cost_order = Order.Descending - else: - cost_order = Order.Any + return self._eval_internal, Order(-preferred_pop_order + or Order.Ascending) - if cost_order == Order.Any or eval_order == cost_order: - # Use the preferred algorithm. + # Mandatory evaluation order. + if preferred_pop_order == Order.Any: + return self._eval_internal, eval_order + elif eval_order != preferred_pop_order: return self._eval_internal, eval_order else: - # Use the less-preferred algorithm. return self._eval_internal_forward, eval_order def _eval_internal( diff --git a/src/icepool/generator/alignment.py b/src/icepool/generator/alignment.py index 2780abcd..61bfe81d 100644 --- a/src/icepool/generator/alignment.py +++ b/src/icepool/generator/alignment.py @@ -1,10 +1,11 @@ __docformat__ = 'google' from icepool.generator.multiset_generator import MultisetGenerator -from icepool.typing import Outcome, T +from icepool.generator.pop_order import PopOrderReason from functools import cached_property +from icepool.typing import Order, T from typing import Collection, Hashable, Iterator, Sequence, TypeAlias InitialAlignmentGenerator: TypeAlias = Iterator[tuple['Alignment', int]] @@ -49,9 +50,8 @@ def _generate_max(self, max_outcome) -> AlignmentGenerator: else: yield Alignment(self.outcomes()[:-1]), (), 1 - def _estimate_order_costs(self) -> tuple[int, int]: - result = len(self.outcomes()) - return result, result + def _preferred_pop_order(self) -> tuple[Order | None, PopOrderReason]: + return Order.Any, PopOrderReason.NoPreference def denominator(self) -> int: return 0 diff --git a/src/icepool/generator/compound_keep.py b/src/icepool/generator/compound_keep.py index 0762423d..ea46fb60 100644 --- a/src/icepool/generator/compound_keep.py +++ b/src/icepool/generator/compound_keep.py @@ -4,12 +4,13 @@ from icepool.collection.counts import sorted_union from icepool.generator.keep import KeepGenerator, pop_max_from_keep_tuple, pop_min_from_keep_tuple from icepool.generator.multiset_generator import InitialMultisetGenerator, NextMultisetGenerator, MultisetGenerator +from icepool.generator.pop_order import PopOrderReason, merge_pop_orders import itertools import math from typing import Hashable, Sequence -from icepool.typing import T +from icepool.typing import Order, T class CompoundKeepGenerator(KeepGenerator[T]): @@ -54,14 +55,9 @@ def _generate_max(self, max_outcome) -> NextMultisetGenerator: yield CompoundKeepGenerator( generators, popped_keep_tuple), (result_count, ), total_weight - def _estimate_order_costs(self) -> tuple[int, int]: - total_pop_min_cost = 1 - total_pop_max_cost = 1 - for inner in self._inners: - pop_min_cost, pop_max_cost = inner._estimate_order_costs() - total_pop_min_cost *= pop_min_cost - total_pop_max_cost *= pop_max_cost - return total_pop_min_cost, total_pop_max_cost + def _preferred_pop_order(self) -> tuple[Order | None, PopOrderReason]: + return merge_pop_orders(*(inner._preferred_pop_order() + for inner in self._inners)) def denominator(self) -> int: return math.prod(inner.denominator() for inner in self._inners) diff --git a/src/icepool/generator/deal.py b/src/icepool/generator/deal.py index a87a749d..5f7331df 100644 --- a/src/icepool/generator/deal.py +++ b/src/icepool/generator/deal.py @@ -1,13 +1,14 @@ __docformat__ = 'google' -from icepool.generator.keep import KeepGenerator, pop_max_from_keep_tuple, pop_min_from_keep_tuple import icepool +from icepool.generator.keep import KeepGenerator, pop_max_from_keep_tuple, pop_min_from_keep_tuple from icepool.collection.counts import CountsKeysView from icepool.generator.multiset_generator import InitialMultisetGenerator, NextMultisetGenerator +from icepool.generator.pop_order import PopOrderReason from functools import cached_property -from icepool.typing import T +from icepool.typing import Order, T from typing import Hashable @@ -120,9 +121,9 @@ def _generate_max(self, max_outcome) -> NextMultisetGenerator: weight = icepool.math.comb(deck_count, count) yield popped_deal, (result_count, ), weight - def _estimate_order_costs(self) -> tuple[int, int]: - result = len(self.outcomes()) * self._hand_size - return result, result + def _preferred_pop_order(self) -> tuple[Order | None, PopOrderReason]: + # TODO: implement skips + return Order.Any, PopOrderReason.NoPreference @cached_property def _hash_key(self) -> Hashable: diff --git a/src/icepool/generator/mixture.py b/src/icepool/generator/mixture.py index ae2e04c5..1b77e7ed 100644 --- a/src/icepool/generator/mixture.py +++ b/src/icepool/generator/mixture.py @@ -4,12 +4,14 @@ from icepool.collection.counts import sorted_union from icepool.generator.multiset_generator import InitialMultisetGenerator, NextMultisetGenerator, MultisetGenerator -from icepool.typing import Outcome, Qs, T, U +from icepool.generator.pop_order import PopOrderReason, merge_pop_orders import math from collections import defaultdict from functools import cached_property + +from icepool.typing import Order, Qs, T, U from types import EllipsisType from typing import TYPE_CHECKING, Callable, Hashable, Literal, Mapping, MutableMapping, Sequence, overload @@ -90,14 +92,9 @@ def _generate_max(self, max_outcome) -> NextMultisetGenerator: 'MixtureMultisetGenerator should have decayed to another generator type by this point.' ) - def _estimate_order_costs(self) -> tuple[int, int]: - total_pop_min_cost = 0 - total_pop_max_cost = 0 - for inner in self._inners: - pop_min_cost, pop_max_cost = inner._estimate_order_costs() - total_pop_min_cost += pop_min_cost - total_pop_max_cost += pop_max_cost - return total_pop_min_cost, total_pop_max_cost + def _preferred_pop_order(self) -> tuple[Order | None, PopOrderReason]: + return merge_pop_orders(*(inner._preferred_pop_order() + for inner in self._inners)) @cached_property def _denominator(self) -> int: diff --git a/src/icepool/generator/multi_deal.py b/src/icepool/generator/multi_deal.py index 5a4015e9..89a3eb3d 100644 --- a/src/icepool/generator/multi_deal.py +++ b/src/icepool/generator/multi_deal.py @@ -1,12 +1,13 @@ __docformat__ = 'google' -from icepool.typing import Outcome, Qs, T +from icepool.typing import Order, Qs, T from typing import Any, Hashable, cast import icepool from icepool.collection.counts import CountsKeysView from icepool.generator.multiset_generator import InitialMultisetGenerator, NextMultisetGenerator, MultisetGenerator from icepool.math import iter_hypergeom +from icepool.generator.pop_order import PopOrderReason from functools import cached_property import math @@ -129,9 +130,8 @@ def _generate_max(self, max_outcome) -> NextMultisetGenerator: yield from self._generate_common(popped_deck, deck_count) - def _estimate_order_costs(self) -> tuple[int, int]: - result = len(self.outcomes()) * math.prod(self.hand_sizes()) - return result, result + def _preferred_pop_order(self) -> tuple[Order | None, PopOrderReason]: + return Order.Any, PopOrderReason.NoPreference @cached_property def _hash_key(self) -> Hashable: diff --git a/src/icepool/generator/multiset_generator.py b/src/icepool/generator/multiset_generator.py index 68647881..03c9802c 100644 --- a/src/icepool/generator/multiset_generator.py +++ b/src/icepool/generator/multiset_generator.py @@ -6,6 +6,7 @@ import icepool.generator from icepool.collection.counts import Counts from icepool.expression.multiset_expression import MultisetExpression +from icepool.generator.pop_order import PopOrderReason from icepool.typing import Order, Outcome, Qs, T import bisect @@ -108,12 +109,12 @@ def _generate_max(self, max_outcome) -> NextMultisetGenerator: """ @abstractmethod - def _estimate_order_costs(self) -> tuple[int, int]: - """Estimates the cost of popping from the min and max sides during an evaluation. + def _preferred_pop_order(self) -> tuple[Order | None, PopOrderReason]: + """Returns the preferred pop order of the generator, along with the priority of that pop order. - Returns: - pop_min_cost: A positive `int`. - pop_max_cost: A positive `int`. + Greater priorities strictly outrank lower priorities. + An order of `None` represents conflicting orders and can occur in the + argument and/or return value. """ @abstractmethod @@ -236,9 +237,9 @@ def sample(self) -> tuple[tuple, ...]: if not self.outcomes(): raise ValueError('Cannot sample from an empty set of outcomes.') - min_cost, max_cost = self._estimate_order_costs() + preferred_pop_order, pop_order_reason = self._preferred_pop_order() - if min_cost < max_cost: + if preferred_pop_order is not None and preferred_pop_order > 0: outcome = self.min_outcome() generated = tuple(self._generate_min(outcome)) else: diff --git a/src/icepool/generator/pool.py b/src/icepool/generator/pool.py index a8f1e460..114f3226 100644 --- a/src/icepool/generator/pool.py +++ b/src/icepool/generator/pool.py @@ -3,10 +3,11 @@ import icepool import icepool.expression import icepool.math -import icepool.generator.pop_order import icepool.creation_args from icepool.generator.keep import KeepGenerator, pop_max_from_keep_tuple, pop_min_from_keep_tuple from icepool.generator.multiset_generator import InitialMultisetGenerator, NextMultisetGenerator +import icepool.generator.pop_order +from icepool.generator.pop_order import PopOrderReason import itertools import math @@ -14,7 +15,7 @@ from collections import defaultdict from functools import cache, cached_property, reduce -from icepool.typing import T +from icepool.typing import T, Order from typing import TYPE_CHECKING, Any, Collection, Iterator, Mapping, MutableMapping, Sequence, cast if TYPE_CHECKING: @@ -174,14 +175,22 @@ def outcomes(self) -> Sequence[T]: def output_arity(self) -> int: return 1 - def _estimate_order_costs(self) -> tuple[int, int]: - """Estimates the cost of popping from the min and max sides. - - Returns: - pop_min_cost - pop_max_cost - """ - return icepool.generator.pop_order.estimate_costs(self) + def _preferred_pop_order(self) -> tuple[Order | None, PopOrderReason]: + can_truncate_min, can_truncate_max = icepool.generator.pop_order.can_truncate( + self.unique_dice()) + if can_truncate_min and not can_truncate_max: + return Order.Ascending, PopOrderReason.PoolComposition + if can_truncate_max and not can_truncate_min: + return Order.Descending, PopOrderReason.PoolComposition + + lo_skip, hi_skip = icepool.generator.pop_order.lo_hi_skip( + self.keep_tuple()) + if lo_skip > hi_skip: + return Order.Descending, PopOrderReason.KeepSkip + if hi_skip > lo_skip: + return Order.Ascending, PopOrderReason.KeepSkip + + return Order.Any, PopOrderReason.NoPreference @cached_property def _min_outcome(self) -> T: diff --git a/src/icepool/generator/pop_order.py b/src/icepool/generator/pop_order.py index 06b7c288..7526c68e 100644 --- a/src/icepool/generator/pop_order.py +++ b/src/icepool/generator/pop_order.py @@ -2,9 +2,65 @@ import icepool +import enum import math -from typing import Collection +from typing import Collection, Iterable + +from icepool.typing import Order + + +class PopOrderReason(enum.IntEnum): + """Greater values represent higher priorities, which strictly override lower priorities.""" + PoolComposition = 2 + """The composition of dice in the pool favor this pop order.""" + KeepSkip = 1 + """The keep_tuple for allows more skips in this pop order.""" + NoPreference = 0 + """There is no preference for either pop order.""" + + +def merge_pop_orders( + *preferences: tuple[Order | None, PopOrderReason], +) -> tuple[Order | None, PopOrderReason]: + """Returns a pop order that fits the highest priority preferences. + + Greater priorities strictly outrank lower priorities. + An order of `None` represents conflicting orders and can occur in the + argument and/or return value. + """ + result_order: Order | None = Order.Any + result_reason = PopOrderReason.NoPreference + for order, reason in preferences: + if order == Order.Any or reason == PopOrderReason.NoPreference: + continue + elif reason > result_reason: + result_order = order + result_reason = reason + elif reason == result_reason: + if result_order == Order.Any: + result_order = order + elif result_order == order: + continue + else: + result_order = None + return result_order, result_reason + + +def pool_pop_order(pool: 'icepool.Pool') -> tuple[Order, PopOrderReason]: + can_truncate_min, can_truncate_max = can_truncate(pool.unique_dice()) + if can_truncate_min and not can_truncate_max: + return Order.Ascending, PopOrderReason.PoolComposition + if can_truncate_max and not can_truncate_min: + return Order.Descending, PopOrderReason.PoolComposition + + lo_skip, hi_skip = lo_hi_skip(pool.keep_tuple()) + if lo_skip > hi_skip: + return Order.Ascending, PopOrderReason.KeepSkip + if hi_skip > lo_skip: + return Order.Descending, PopOrderReason.KeepSkip + + return Order.Any, PopOrderReason.NoPreference def can_truncate(dice: Collection['icepool.Die']) -> tuple[bool, bool]: @@ -69,32 +125,3 @@ def lo_hi_skip(keep_tuple: tuple[int, ...]) -> tuple[int, int]: # Should never reach here. raise RuntimeError('Should not be reached.') - - -def estimate_costs(pool: 'icepool.Pool') -> tuple[int, int]: - """Estimates the cost of popping from the min and max sides. - - Returns: - pop_min_cost: A positive `int`. - pop_max_cost: A positive `int`. - """ - can_truncate_min, can_truncate_max = can_truncate(pool.unique_dice()) - if can_truncate_min or can_truncate_max: - lo_skip, hi_skip = lo_hi_skip(pool.keep_tuple()) - die_sizes: list[int] = sum( - ([len(die)] * count for die, count in pool._dice), start=[]) - die_sizes = sorted(die_sizes, reverse=True) - if not can_truncate_min or not can_truncate_max: - prod_cost = math.prod(len(die)**count for die, count in pool._dice) - - if can_truncate_min: - pop_min_cost = sum(die_sizes[hi_skip:]) - else: - pop_min_cost = prod_cost - - if can_truncate_max: - pop_max_cost = sum(die_sizes[lo_skip:]) - else: - pop_max_cost = prod_cost - - return pop_min_cost, pop_max_cost diff --git a/tests/pool_direction_test.py b/tests/pool_direction_test.py index 0c2237b4..259b378e 100644 --- a/tests/pool_direction_test.py +++ b/tests/pool_direction_test.py @@ -33,7 +33,8 @@ def test_order(eval_pool, pool): eval_pool(pool) -# The auto order should maximize skips if numerous enough. +# The auto order should maximize skips if there are no other considerations. +# Note that this is the *opposite* of the preferred pop order. def test_auto_order_uniform(): algorithm, order = icepool.evaluator.sum_evaluator._select_algorithm( icepool.d6.pool([0, 0, 1, 1])) diff --git a/tests/pop_order_test.py b/tests/pop_order_test.py index 56d98d70..4a99f1aa 100644 --- a/tests/pop_order_test.py +++ b/tests/pop_order_test.py @@ -1,61 +1,97 @@ import icepool import pytest -from icepool import d4, d6, d8, d10, d12 -import icepool.generator.pop_order +from icepool import d4, d6, d8, d10, d12, Order +from icepool.generator.pop_order import PopOrderReason, merge_pop_orders + + +def test_pop_order_empty(): + assert merge_pop_orders() == (Order.Any, PopOrderReason.NoPreference) + + +def test_pop_order_single(): + assert merge_pop_orders( + (Order.Ascending, + PopOrderReason.KeepSkip)) == (Order.Ascending, + PopOrderReason.KeepSkip) + + +def test_pop_order_priority(): + assert merge_pop_orders( + (Order.Ascending, PopOrderReason.KeepSkip), + (Order.Descending, + PopOrderReason.PoolComposition)) == (Order.Descending, + PopOrderReason.PoolComposition) + + +def test_pop_order_any(): + assert merge_pop_orders( + (Order.Any, PopOrderReason.PoolComposition), + (Order.Descending, + PopOrderReason.PoolComposition)) == (Order.Descending, + PopOrderReason.PoolComposition) + + +def test_pop_order_conflict(): + assert merge_pop_orders( + (Order.Ascending, PopOrderReason.PoolComposition), + (Order.Descending, + PopOrderReason.PoolComposition)) == (None, + PopOrderReason.PoolComposition) + + +def test_pop_order_conflict_override(): + assert merge_pop_orders( + (Order.Ascending, PopOrderReason.KeepSkip), + (Order.Descending, PopOrderReason.KeepSkip), + (Order.Descending, + PopOrderReason.PoolComposition)) == (Order.Descending, + PopOrderReason.PoolComposition) def test_pool_single_type(): pool = icepool.Pool([d6, d6, d6]) - pop_min_cost, pop_max_cost = icepool.generator.pop_order.estimate_costs( - pool) - assert pop_min_cost == pop_max_cost + assert pool._preferred_pop_order() == (Order.Any, + PopOrderReason.NoPreference) def test_pool_standard(): pool = icepool.Pool([d8, d12, d6]) - pop_min_cost, pop_max_cost = icepool.generator.pop_order.estimate_costs( - pool) - assert pop_min_cost > pop_max_cost + assert pool._preferred_pop_order() == (Order.Descending, + PopOrderReason.PoolComposition) def test_pool_standard_negative(): pool = icepool.Pool([-d8, -d12, -d6]) - pop_min_cost, pop_max_cost = icepool.generator.pop_order.estimate_costs( - pool) - assert pop_min_cost < pop_max_cost + assert pool._preferred_pop_order() == (Order.Ascending, + PopOrderReason.PoolComposition) def test_pool_non_truncate(): pool = icepool.Pool([-d8, d12, -d6]) - pop_min_cost, pop_max_cost = icepool.generator.pop_order.estimate_costs( - pool) - assert pop_min_cost == pop_max_cost + assert pool._preferred_pop_order() == (Order.Any, + PopOrderReason.NoPreference) def test_pool_skip_min(): pool = icepool.Pool([d6, d6, d6])[0, 1, 1] - pop_min_cost, pop_max_cost = icepool.generator.pop_order.estimate_costs( - pool) - assert pop_min_cost > pop_max_cost + assert pool._preferred_pop_order() == (Order.Descending, + PopOrderReason.KeepSkip) def test_pool_skip_max(): pool = icepool.Pool([d6, d6, d6])[1, 1, 0] - pop_min_cost, pop_max_cost = icepool.generator.pop_order.estimate_costs( - pool) - assert pop_min_cost < pop_max_cost + assert pool._preferred_pop_order() == (Order.Ascending, + PopOrderReason.KeepSkip) def test_pool_skip_min_but_truncate(): pool = icepool.Pool([-d6, -d6, -d8])[0, 1, 1] - pop_min_cost, pop_max_cost = icepool.generator.pop_order.estimate_costs( - pool) - assert pop_min_cost < pop_max_cost + assert pool._preferred_pop_order() == (Order.Ascending, + PopOrderReason.PoolComposition) def test_pool_skip_max_but_truncate(): pool = icepool.Pool([d6, d6, d8])[1, 1, 0] - pop_min_cost, pop_max_cost = icepool.generator.pop_order.estimate_costs( - pool) - assert pop_min_cost > pop_max_cost + assert pool._preferred_pop_order() == (Order.Descending, + PopOrderReason.PoolComposition)