e13771fa06
Replace flat recency penalty with cycle detection. For each dish with ≥2 appearances in history, compute the median interval between uses and score by how well weeksSinceLast aligns with that period: ratio < 0.5 → 0.10 (too soon) ratio ~1.0 → 3.00 (right on schedule, boost) ratio > 2.5 → 1.00 (pattern may have changed, neutral) Expand history window from 4 to 12 weeks so monthly cycles (period=4) have enough data points to be detected reliably. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
280 lines
10 KiB
Swift
280 lines
10 KiB
Swift
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] = []
|
||
) -> 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)
|
||
|
||
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) {
|
||
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 ≥ 8–12 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: - Weighted pick
|
||
|
||
private static func weightedRandomPick(
|
||
candidates: [Dish],
|
||
plan: WeekPlan,
|
||
historyScores: [UUID: Double] = [:]
|
||
) -> 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 recencyMultiplier = historyScores[dish.id] ?? 1.0
|
||
let baseWeight: Double
|
||
switch usage {
|
||
case 0: baseWeight = 3.0
|
||
case 1: baseWeight = 2.0
|
||
default: baseWeight = 1.0
|
||
}
|
||
return baseWeight * priorityBoost * recencyMultiplier
|
||
}
|
||
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
|
||
}
|
||
}
|