feat: planner top-K set enumeration with protein/cuisine diversity penalty

This commit is contained in:
2026-05-06 06:48:26 -07:00
parent 77813cc7d3
commit 63e292a995
2 changed files with 131 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
from decimal import Decimal
from uuid import uuid4
from app.services.planner.config import PlannerConfig
from app.services.planner.select import select_set, set_diversity_penalty
from app.services.planner.types import RecipeCost, ScoredRecipe
_CFG = PlannerConfig()
def _mk(score, protein, cuisine):
rid = uuid4()
return ScoredRecipe(
recipe_id=rid,
score=score,
components={},
cost=RecipeCost(
recipe_id=rid,
total_cost=Decimal("10"),
total_savings=Decimal("1"),
sale_ingredient_count=1,
matched_ingredient_count=1,
total_ingredient_count=1,
pantry_hit_count=0,
line_items=[],
),
protein=protein,
cuisine=cuisine,
)
def test_diversity_penalty_zero_when_all_unique():
a = _mk(0.5, "chicken", "american")
b = _mk(0.5, "beef", "mexican")
c = _mk(0.5, "fish", "italian")
assert set_diversity_penalty([a, b, c], _CFG) == 0.0
def test_diversity_penalty_three_chickens():
a = _mk(0.5, "chicken", "american")
b = _mk(0.5, "chicken", "italian")
c = _mk(0.5, "chicken", "mexican")
# 3 protein pairs * 0.15 = 0.45, no cuisine pairs
p = set_diversity_penalty([a, b, c], _CFG)
assert abs(p - 0.45) < 1e-6
def test_select_set_picks_diverse_over_homogeneous():
"""Three chicken/american (each 0.95) lose to fully-diverse mixed
(each 0.90) once protein and cuisine penalties apply.
Mix candidates each overlap high on exactly one attribute so that
*any* "1 high + 2 mix" pairing also incurs penalty -- otherwise the
optimum would mix a single high with two unrelated mixes."""
high1 = _mk(0.95, "chicken", "american")
high2 = _mk(0.95, "chicken", "american")
high3 = _mk(0.95, "chicken", "american")
mix1 = _mk(0.90, "pork", "thai") # fully distinct from high
mix2 = _mk(0.90, "chicken", "mexican") # shares protein with high
mix3 = _mk(0.90, "fish", "american") # shares cuisine with high
chosen, set_score = select_set([high1, high2, high3, mix1, mix2, mix3], _CFG)
chosen_ids = {s.recipe_id for s in chosen}
assert chosen_ids == {mix1.recipe_id, mix2.recipe_id, mix3.recipe_id}
def test_select_set_handles_too_few():
a = _mk(0.5, "x", "y")
b = _mk(0.5, "x", "y")
chosen, _ = select_set([a, b], _CFG)
assert len(chosen) == 2 # less than set_size returns what we have
def test_select_set_empty_input():
chosen, score = select_set([], _CFG)
assert chosen == []
assert score == 0.0