diff --git a/backend/app/services/planner/score.py b/backend/app/services/planner/score.py new file mode 100644 index 0000000..c6a25ea --- /dev/null +++ b/backend/app/services/planner/score.py @@ -0,0 +1,110 @@ +"""Per-recipe scoring with the 5 weighted signals from the spec.""" +from __future__ import annotations + +from datetime import date +from decimal import Decimal +from typing import Dict, Iterable, List, Optional +from uuid import UUID + +from app.services.planner.config import PlannerConfig +from app.services.planner.types import RecipeCost, ScoredRecipe + + +def time_bonus(total_minutes: int, config: PlannerConfig) -> float: + if total_minutes <= config.time_ideal_minutes: + return 1.0 + if total_minutes >= config.time_full_minutes: + return 0.0 + span = config.time_full_minutes - config.time_ideal_minutes + over = total_minutes - config.time_ideal_minutes + return max(0.0, min(1.0, 1.0 - over / span)) + + +def recency_bonus( + last_cooked: Optional[date], + today: date, + config: PlannerConfig, +) -> float: + if last_cooked is None: + return 1.0 + weeks_ago = (today - last_cooked).days / 7 + if weeks_ago >= config.recency_full_weeks: + return 1.0 + # Below recency_weeks the recipe wouldn't be in the feasible set, so we treat + # exactly recency_weeks as score 0 and recency_full_weeks as score 1. + if weeks_ago <= config.recency_weeks: + return 0.0 + span = config.recency_full_weeks - config.recency_weeks + over = weeks_ago - config.recency_weeks + return max(0.0, min(1.0, over / span)) + + +def normalize_savings(values: List[Decimal]) -> List[float]: + if not values: + return [] + floats = [float(v) for v in values] + lo, hi = min(floats), max(floats) + if hi == lo: + return [0.0] * len(floats) + return [(v - lo) / (hi - lo) for v in floats] + + +def score_recipes( + *, + recipes: Iterable[dict], + recipe_costs: Dict[UUID, RecipeCost], + last_cooked_at: Dict[UUID, date], + config: PlannerConfig, + today: date, +) -> List[ScoredRecipe]: + """Returns recipes scored DESC. Caller passes only the feasible set.""" + materialized = list(recipes) + if not materialized: + return [] + + savings = [recipe_costs[r["id"]].total_savings for r in materialized] + norm_savings = normalize_savings(savings) + + out: List[ScoredRecipe] = [] + for r, ns in zip(materialized, norm_savings): + rid = r["id"] + if isinstance(rid, str): + rid = UUID(rid) + cost = recipe_costs[rid] + total_min = int(r.get("prep_time_minutes") or 0) + int(r.get("cook_time_minutes") or 0) + tb = time_bonus(total_min, config) + rb = recency_bonus(last_cooked_at.get(rid), today, config) + + components = { + "savings_normalized": ns, + "sale_coverage_pct": cost.sale_coverage_pct, + "pantry_hit_pct": cost.pantry_hit_pct, + "time_bonus": tb, + "recency_bonus": rb, + "savings_dollars": float(cost.total_savings), + } + + score = ( + config.w_savings * ns + + config.w_coverage * cost.sale_coverage_pct + + config.w_pantry * cost.pantry_hit_pct + + config.w_time * tb + + config.w_recency * rb + ) + + cuisine_tags = r.get("cuisine_tags") or [] + primary_cuisine = cuisine_tags[0] if cuisine_tags else None + + out.append( + ScoredRecipe( + recipe_id=rid, + score=score, + components=components, + cost=cost, + protein=r.get("protein_type"), + cuisine=primary_cuisine, + ) + ) + + out.sort(key=lambda s: s.score, reverse=True) + return out diff --git a/backend/tests/test_planner_score.py b/backend/tests/test_planner_score.py new file mode 100644 index 0000000..47b0cda --- /dev/null +++ b/backend/tests/test_planner_score.py @@ -0,0 +1,107 @@ +from datetime import date, timedelta +from decimal import Decimal +from uuid import uuid4 + +from app.services.planner.config import PlannerConfig +from app.services.planner.score import ( + score_recipes, + time_bonus, + recency_bonus, + normalize_savings, +) +from app.services.planner.types import RecipeCost + + +_CFG = PlannerConfig() + + +def test_time_bonus_capped_at_ideal(): + assert time_bonus(20, _CFG) == 1.0 + assert time_bonus(25, _CFG) == 1.0 + + +def test_time_bonus_decays_to_zero_at_full(): + assert time_bonus(45, _CFG) == 0.0 + + +def test_time_bonus_linear_midpoint(): + # 35 min is halfway between 25 and 45 + assert abs(time_bonus(35, _CFG) - 0.5) < 1e-6 + + +def test_recency_bonus_full_when_long_ago(): + today = date(2026, 5, 5) + long_ago = today - timedelta(weeks=20) + assert recency_bonus(long_ago, today, _CFG) == 1.0 + + +def test_recency_bonus_zero_when_just_eligible(): + # right at recency_weeks boundary → 0 + today = date(2026, 5, 5) + cutoff = today - timedelta(weeks=_CFG.recency_weeks) + assert recency_bonus(cutoff, today, _CFG) == 0.0 + + +def test_recency_bonus_full_when_never_cooked(): + assert recency_bonus(None, date(2026, 5, 5), _CFG) == 1.0 + + +def test_normalize_savings_minmax(): + out = normalize_savings([Decimal("0"), Decimal("5"), Decimal("10")]) + assert out == [0.0, 0.5, 1.0] + + +def test_normalize_savings_uniform_returns_zeros(): + out = normalize_savings([Decimal("3"), Decimal("3"), Decimal("3")]) + assert out == [0.0, 0.0, 0.0] + + +def test_score_recipes_orders_by_combined_score(): + recipes = [ + { + "id": uuid4(), + "name": "low_savings", + "prep_time_minutes": 10, + "cook_time_minutes": 30, + "protein_type": "chicken", + "cuisine_tags": ["american"], + }, + { + "id": uuid4(), + "name": "high_savings", + "prep_time_minutes": 10, + "cook_time_minutes": 15, # also lower time + "protein_type": "beef", + "cuisine_tags": ["mexican"], + }, + ] + costs = { + recipes[0]["id"]: RecipeCost( + recipe_id=recipes[0]["id"], + total_cost=Decimal("10"), + total_savings=Decimal("1"), + sale_ingredient_count=1, + matched_ingredient_count=4, + total_ingredient_count=4, + pantry_hit_count=0, + line_items=[], + ), + recipes[1]["id"]: RecipeCost( + recipe_id=recipes[1]["id"], + total_cost=Decimal("12"), + total_savings=Decimal("8"), + sale_ingredient_count=3, + matched_ingredient_count=4, + total_ingredient_count=4, + pantry_hit_count=2, + line_items=[], + ), + } + scored = score_recipes( + recipes=recipes, + recipe_costs=costs, + last_cooked_at={}, + config=_CFG, + today=date(2026, 5, 5), + ) + assert scored[0].recipe_id == recipes[1]["id"] # high_savings first