Public Access
feat: planner top-K set enumeration with protein/cuisine diversity penalty
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
"""Top-K set enumeration with diversity penalty.
|
||||||
|
|
||||||
|
Takes the top K (=20) scored recipes, enumerates all C(K, set_size)
|
||||||
|
combinations, applies a pairwise diversity penalty for shared
|
||||||
|
protein and cuisine, and returns the highest-scoring combination.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from itertools import combinations
|
||||||
|
from typing import Iterable, List, Tuple
|
||||||
|
|
||||||
|
from app.services.planner.config import PlannerConfig
|
||||||
|
from app.services.planner.types import ScoredRecipe
|
||||||
|
|
||||||
|
|
||||||
|
def set_diversity_penalty(
|
||||||
|
chosen: List[ScoredRecipe],
|
||||||
|
config: PlannerConfig,
|
||||||
|
) -> float:
|
||||||
|
penalty = 0.0
|
||||||
|
for a, b in combinations(chosen, 2):
|
||||||
|
if a.protein and b.protein and a.protein == b.protein:
|
||||||
|
penalty += config.p_protein
|
||||||
|
if a.cuisine and b.cuisine and a.cuisine == b.cuisine:
|
||||||
|
penalty += config.p_cuisine
|
||||||
|
return penalty
|
||||||
|
|
||||||
|
|
||||||
|
def _set_score(chosen: List[ScoredRecipe], config: PlannerConfig) -> float:
|
||||||
|
return sum(s.score for s in chosen) - set_diversity_penalty(chosen, config)
|
||||||
|
|
||||||
|
|
||||||
|
def select_set(
|
||||||
|
scored: Iterable[ScoredRecipe],
|
||||||
|
config: PlannerConfig,
|
||||||
|
) -> Tuple[List[ScoredRecipe], float]:
|
||||||
|
pool = list(scored)
|
||||||
|
if not pool:
|
||||||
|
return [], 0.0
|
||||||
|
if len(pool) <= config.set_size:
|
||||||
|
return pool, _set_score(pool, config)
|
||||||
|
|
||||||
|
pool.sort(key=lambda s: s.score, reverse=True)
|
||||||
|
candidate_pool = pool[: config.top_k]
|
||||||
|
|
||||||
|
best: List[ScoredRecipe] = []
|
||||||
|
best_score = float("-inf")
|
||||||
|
for combo in combinations(candidate_pool, config.set_size):
|
||||||
|
s = _set_score(list(combo), config)
|
||||||
|
if s > best_score:
|
||||||
|
best_score = s
|
||||||
|
best = list(combo)
|
||||||
|
return best, best_score
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user