Public Access
feat: planner config (weights, thresholds, K) and shared types
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Planner constants. Tune here without touching algorithm code."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@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 = 30.00 # constraint #6: dollars per meal
|
||||
|
||||
# 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 = 20 # 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}")
|
||||
|
||||
|
||||
DEFAULT = PlannerConfig()
|
||||
DEFAULT.validate()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Shared planner data structures."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@dataclass
|
||||
class IngredientCost:
|
||||
ingredient_id: UUID
|
||||
qty: float
|
||||
unit: Optional[str]
|
||||
grocery_item_id: Optional[UUID] # None when no match found
|
||||
grocery_item_name: Optional[str]
|
||||
current_price: Optional[Decimal]
|
||||
regular_price: Optional[Decimal]
|
||||
is_on_sale: bool
|
||||
estimated_cost: Decimal # current_price scaled to recipe qty (best-effort)
|
||||
estimated_savings: Decimal # max(regular - current, 0) scaled to qty
|
||||
matched: bool # False if no ingredient_grocery_match row found
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipeCost:
|
||||
recipe_id: UUID
|
||||
total_cost: Decimal
|
||||
total_savings: Decimal
|
||||
sale_ingredient_count: int
|
||||
matched_ingredient_count: int
|
||||
total_ingredient_count: int
|
||||
pantry_hit_count: int
|
||||
line_items: List[IngredientCost] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def sale_coverage_pct(self) -> float:
|
||||
if self.total_ingredient_count == 0:
|
||||
return 0.0
|
||||
return self.sale_ingredient_count / self.total_ingredient_count
|
||||
|
||||
@property
|
||||
def pantry_hit_pct(self) -> float:
|
||||
if self.total_ingredient_count == 0:
|
||||
return 0.0
|
||||
return self.pantry_hit_count / self.total_ingredient_count
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterResult:
|
||||
feasible_recipe_ids: List[UUID]
|
||||
rejected: dict # recipe_id -> reason string
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoredRecipe:
|
||||
recipe_id: UUID
|
||||
score: float
|
||||
components: dict # signal name -> raw value (for debug payload)
|
||||
cost: RecipeCost
|
||||
protein: Optional[str]
|
||||
cuisine: Optional[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerationResult:
|
||||
meal_plan_id: UUID
|
||||
selected: List[ScoredRecipe]
|
||||
feasible_count: int
|
||||
rejected_summary: dict # reason -> count
|
||||
set_score: float
|
||||
Reference in New Issue
Block a user