Files
FamilyMealPlanner/MealMood/Services/AutocompleteEngine.swift
T
alexandrev-tibco 84b74f9ea4 Autocomplete feedback loop: implicit rejection + weekly rating
A) Implicit: HomeViewModel tracks auto-assigned slot IDs after each
   autocomplete run. When the user manually replaces one of those dishes,
   the rejected dish ID is recorded in FeedbackStore (UserDefaults).
   AutocompleteEngine applies a 25% penalty per rejection (floor 0.30).

B) Explicit: WeekPlan gains userRating (0/1/-1). Past weeks with dishes
   show a compact 👍/👎 row (Premium only). Liked weeks boost their
   dishes (+0.20 each, max +0.50); disliked weeks penalise them
   (-0.25 each, max -0.50). Signal is intentionally modest so the
   period cycle score remains dominant.

Also adds Stats + week-rating localization strings to all 6 languages
(were missing from the previous commit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:09:15 +02:00

308 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
struct AutocompleteEngine {
struct AutocompleteResult {
let filledSlots: [(slotId: UUID, dishId: UUID)]
let unfilledCount: Int
}
struct RuleViolation {
let slotId: UUID
let dayOfWeek: Int
let mealType: String
let dishId: UUID
let dishName: String
}
// recentPlans: up to 12 previous weeks for period detection (weekly/bi-weekly/monthly cycles).
static func autocomplete(
emptySlots: [MealSlot],
currentPlan: WeekPlan,
allDishes: [Dish],
allTags: [Tag],
recentPlans: [WeekPlan] = [],
rejectionCounts: [UUID: Int] = [:]
) -> AutocompleteResult {
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
let historyScores = periodScores(dishes: allDishes, recentPlans: recentPlans, currentWeekStart: currentPlan.weekStartDate)
let ratingScores = Self.ratingScores(dishes: allDishes, recentPlans: recentPlans)
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
var unfilledCount = 0
var pendingSlots = emptySlots.filter { !$0.isEatingOut }
// MRV: at each step pick the slot with fewest valid candidates first.
// Assignments update currentPlan.slots in-place, so subsequent candidate
// counts automatically reflect the growing set of committed dishes.
while !pendingSlots.isEmpty {
// Score each remaining slot by strict candidate count
let ranked = pendingSlots
.map { slot -> (slot: MealSlot, strict: [Dish]) in
let strict = allDishes.filter {
!violatesRules(dish: $0, slot: slot, plan: currentPlan, tagMap: tagMap, dishMap: dishMap)
}
return (slot, strict)
}
.sorted { $0.strict.count < $1.strict.count }
let (slot, strictCandidates) = ranked[0]
pendingSlots.removeAll { $0.id == slot.id }
// Fallback: relax the implicit no-repeat rule if strict set is empty
let candidates: [Dish]
if strictCandidates.isEmpty {
candidates = allDishes.filter {
!violatesExplicitRules(dish: $0, slot: slot, plan: currentPlan, tagMap: tagMap, dishMap: dishMap)
}
} else {
candidates = strictCandidates
}
if candidates.isEmpty {
unfilledCount += 1
continue
}
if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan, historyScores: historyScores, ratingScores: ratingScores, rejectionCounts: rejectionCounts) {
slot.dishId = picked.id
filledSlots.append((slotId: slot.id, dishId: picked.id))
} else {
unfilledCount += 1
}
}
return AutocompleteResult(filledSlots: filledSlots, unfilledCount: unfilledCount)
}
static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] {
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
return plan.slots.compactMap { slot in
guard let dishId = slot.dishId,
let dish = dishMap[dishId],
slot.isRuleOverridden,
!slot.isEatingOut else { return nil }
return RuleViolation(
slotId: slot.id,
dayOfWeek: slot.dayOfWeek,
mealType: slot.mealType,
dishId: dishId,
dishName: dish.name
)
}
}
static func validateDrop(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
allTags: [Tag],
allDishes: [Dish]
) -> Bool {
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
return !violatesRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
}
// MARK: - Rule checks
private static func violatesRules(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
tagMap: [UUID: Tag],
dishMap: [UUID: Dish]
) -> Bool {
violatesDefaultNoRepeatRule(dish: dish, slot: slot, plan: plan) ||
violatesExplicitRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
}
private static func violatesDefaultNoRepeatRule(dish: Dish, slot: MealSlot, plan: WeekPlan) -> Bool {
plan.slots.contains { $0.id != slot.id && $0.dishId == dish.id }
}
private static func violatesExplicitRules(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
tagMap: [UUID: Tag],
dishMap: [UUID: Dish]
) -> Bool {
for tagId in dish.tagIds {
guard let tag = tagMap[tagId] else { continue }
if let maxPerWeek = tag.maxPerWeek {
var count = 0
for s in plan.slots {
guard let did = s.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { count += 1 }
}
if count >= maxPerWeek { return true }
}
if tag.noConsecutive {
for adjSlot in adjacentSlots(of: slot, in: plan) {
guard let did = adjSlot.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { return true }
}
}
if tag.noDuplicateInDay {
for sdSlot in plan.slots where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id {
guard let did = sdSlot.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { return true }
}
}
if let restriction = tag.mealTypeRestriction, slot.mealType != restriction {
return true
}
if let dayRestriction = tag.dayRestriction {
switch dayRestriction {
case "weekdays" where slot.dayOfWeek > 4: return true
case "weekend" where slot.dayOfWeek < 5: return true
default: break
}
}
}
return false
}
private static func adjacentSlots(of slot: MealSlot, in plan: WeekPlan) -> [MealSlot] {
var result: [MealSlot] = []
if slot.dayOfWeek > 0,
let prev = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType }) {
result.append(prev)
}
if slot.dayOfWeek < 6,
let next = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType }) {
result.append(next)
}
return result
}
// MARK: - Period-based scoring
// Weight multiplier per dish based on its detected recurrence cycle.
// Values > 1.0 boost a dish that is "due"; < 1.0 suppress one used too recently.
// Requires recentPlans covering 812 weeks to reliably detect monthly cycles.
private static func periodScores(
dishes: [Dish],
recentPlans: [WeekPlan],
currentWeekStart: Date
) -> [UUID: Double] {
guard !recentPlans.isEmpty else { return [:] }
let calendar = Calendar.current
var scores: [UUID: Double] = [:]
for dish in dishes {
// Week offsets: how many weeks ago did this dish appear?
var offsets: [Int] = []
for plan in recentPlans {
guard plan.slots.contains(where: { $0.dishId == dish.id }) else { continue }
let diff = calendar.dateComponents([.weekOfYear], from: plan.weekStartDate, to: currentWeekStart).weekOfYear ?? 0
if diff > 0 { offsets.append(diff) }
}
guard !offsets.isEmpty else { continue }
let weeksSinceLast = offsets.min()!
guard offsets.count >= 2 else {
// Single sighting: simple recency penalty, no cycle to detect
switch weeksSinceLast {
case 1: scores[dish.id] = 0.20
case 2: scores[dish.id] = 0.50
case 3: scores[dish.id] = 0.75
default: break // 4 weeks ago neutral (1.0)
}
continue
}
// Estimate recurrence period via median of consecutive intervals
let sorted = offsets.sorted()
var intervals: [Int] = []
for i in 0..<sorted.count - 1 {
intervals.append(sorted[i + 1] - sorted[i])
}
let period = medianDouble(intervals)
// Score by how well weeksSinceLast aligns with the detected period
let ratio = Double(weeksSinceLast) / period
let score: Double
switch ratio {
case ..<0.50: score = 0.10 // too soon strongly suppress
case 0.50..<0.75: score = 0.30 // a bit early
case 0.75..<1.25: score = 3.00 // right on schedule boost
case 1.25..<1.75: score = 2.00 // slightly overdue
case 1.75..<2.50: score = 1.50 // overdue
default: score = 1.00 // very overdue or pattern changed
}
scores[dish.id] = score
}
return scores
}
private static func medianDouble(_ values: [Int]) -> Double {
let s = values.sorted()
let n = s.count
return n % 2 == 1 ? Double(s[n / 2]) : Double(s[n / 2 - 1] + s[n / 2]) / 2.0
}
// MARK: - Rating-based scoring
// Boost dishes that appeared in liked weeks; penalise those from disliked weeks.
// Signal is intentionally modest (±0.5 max) so period scoring dominates.
private static func ratingScores(dishes: [Dish], recentPlans: [WeekPlan]) -> [UUID: Double] {
let ratedPlans = recentPlans.filter { $0.userRating != 0 }
guard !ratedPlans.isEmpty else { return [:] }
var scores: [UUID: Double] = [:]
for dish in dishes {
var liked = 0, disliked = 0
for plan in ratedPlans {
guard plan.slots.contains(where: { $0.dishId == dish.id }) else { continue }
if plan.userRating > 0 { liked += 1 } else { disliked += 1 }
}
guard liked + disliked > 0 else { continue }
let boost = min(0.50, Double(liked) * 0.20)
let penalty = min(0.50, Double(disliked) * 0.25)
scores[dish.id] = max(0.50, 1.0 + boost - penalty)
}
return scores
}
// MARK: - Weighted pick
private static func weightedRandomPick(
candidates: [Dish],
plan: WeekPlan,
historyScores: [UUID: Double] = [:],
ratingScores: [UUID: Double] = [:],
rejectionCounts: [UUID: Int] = [:]
) -> Dish? {
guard !candidates.isEmpty else { return nil }
let weights: [Double] = candidates.map { dish in
let usage = plan.slots.filter { $0.dishId == dish.id }.count
let priorityBoost: Double = dish.isPriority ? 2.5 : 1.0
let patternScore = historyScores[dish.id] ?? 1.0
let ratingScore = ratingScores[dish.id] ?? 1.0
let rejectionScore = rejectionCounts[dish.id].map { max(0.30, 1.0 - 0.25 * Double($0)) } ?? 1.0
let baseWeight: Double
switch usage {
case 0: baseWeight = 3.0
case 1: baseWeight = 2.0
default: baseWeight = 1.0
}
return baseWeight * priorityBoost * patternScore * ratingScore * rejectionScore
}
let total = weights.reduce(0, +)
var r = Double.random(in: 0..<total)
for (i, w) in weights.enumerated() {
r -= w
if r <= 0 { return candidates[i] }
}
return candidates.last
}
}