"""Planner constants. Tune here without touching algorithm code.""" from __future__ import annotations from dataclasses import dataclass from typing import Any @dataclass(frozen=True) class PlannerConfig: # Hard constraints recency_weeks: int = 4 # constraint #3: no repeat within N weeks calorie_tolerance_pct: int = 20 # constraint #4: ±X% of family.calorie_target max_total_minutes: int = 45 # constraint #5: prep + cook max_meal_cost: float = 50.00 # constraint #6: dollars per serving (dimensionless proxy — unit conversion not yet implemented) # Scoring weights (must sum to 1.0) w_savings: float = 0.30 w_coverage: float = 0.25 w_pantry: float = 0.10 w_time: float = 0.15 w_recency: float = 0.20 # Time bonus boundaries time_ideal_minutes: int = 25 # full bonus at <= this time_full_minutes: int = 45 # zero bonus at this; matches max_total_minutes # Recency bonus boundary recency_full_weeks: int = 12 # full bonus when last cooked >= this many weeks ago # Set selection top_k: int = 10 # how many feasible recipes to enumerate over set_size: int = 3 # 3 dinners/week p_protein: float = 0.15 # diversity penalty per shared-protein pair p_cuisine: float = 0.10 # diversity penalty per shared-cuisine pair def validate(self) -> None: total = self.w_savings + self.w_coverage + self.w_pantry + self.w_time + self.w_recency if abs(total - 1.0) > 1e-6: raise ValueError(f"weights must sum to 1.0, got {total}") def merge(self, overrides: "dict[str, Any]") -> "PlannerConfig": """Return a new PlannerConfig with overrides applied.""" current = { "recency_weeks": self.recency_weeks, "calorie_tolerance_pct": self.calorie_tolerance_pct, "max_total_minutes": self.max_total_minutes, "max_meal_cost": self.max_meal_cost, "w_savings": self.w_savings, "w_coverage": self.w_coverage, "w_pantry": self.w_pantry, "w_time": self.w_time, "w_recency": self.w_recency, "time_ideal_minutes": self.time_ideal_minutes, "time_full_minutes": self.time_full_minutes, "recency_full_weeks": self.recency_full_weeks, "top_k": self.top_k, "set_size": self.set_size, "p_protein": self.p_protein, "p_cuisine": self.p_cuisine, } for key, value in overrides.items(): if key in current: current[key] = value inst = PlannerConfig(**current) inst.validate() return inst def to_dict(self) -> dict[str, Any]: return { "recency_weeks": self.recency_weeks, "calorie_tolerance_pct": self.calorie_tolerance_pct, "max_total_minutes": self.max_total_minutes, "max_meal_cost": self.max_meal_cost, "w_savings": self.w_savings, "w_coverage": self.w_coverage, "w_pantry": self.w_pantry, "w_time": self.w_time, "w_recency": self.w_recency, "time_ideal_minutes": self.time_ideal_minutes, "time_full_minutes": self.time_full_minutes, "recency_full_weeks": self.recency_full_weeks, "top_k": self.top_k, "set_size": self.set_size, "p_protein": self.p_protein, "p_cuisine": self.p_cuisine, } DEFAULT = PlannerConfig() DEFAULT.validate()