Public Access
- set_size 21→3, top_k 20→10: generate 3 weekly dinners not full 3×7 matrix - All 3 items assigned MealType.DINNER on Mon/Wed/Fri - RecipeCost gains servings field + cost_per_serving property - compute_recipe_cost accepts servings param (default 4) - filter.py gates on cost_per_serving instead of total_cost - max_meal_cost 500→50 (now a meaningful $/serving threshold) - Email displays ~$X/serving instead of inflated raw total - select_set: candidate_pool uses max(top_k, set_size) to prevent combinations(n<set_size) returning empty iterator Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
55 lines
1.7 KiB
Python
55 lines
1.7 KiB
Python
"""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)
|
|
# top_k must cover at least set_size items or combinations() yields nothing
|
|
candidate_pool = pool[: max(config.top_k, config.set_size)]
|
|
|
|
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
|