ac124342fa
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
205 lines
7.0 KiB
Swift
205 lines
7.0 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
|
|
}
|
|
|
|
static func autocomplete(
|
|
emptySlots: [MealSlot],
|
|
currentPlan: WeekPlan,
|
|
allDishes: [Dish],
|
|
allTags: [Tag]
|
|
) -> AutocompleteResult {
|
|
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
|
|
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
|
|
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
|
|
var unfilledCount = 0
|
|
|
|
let sortedEmpty = emptySlots
|
|
.filter { !$0.isEatingOut }
|
|
.sorted { ($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType) }
|
|
|
|
for slot in sortedEmpty {
|
|
let strictCandidates = allDishes.filter { dish in
|
|
!violatesRules(
|
|
dish: dish,
|
|
slot: slot,
|
|
plan: currentPlan,
|
|
tagMap: tagMap,
|
|
dishMap: dishMap
|
|
)
|
|
}
|
|
|
|
// Fallback: if strict rules cannot fill a slot, allow repeating dishes
|
|
// that only fail the implicit "no repeat this week" rule.
|
|
let candidates: [Dish]
|
|
if strictCandidates.isEmpty {
|
|
candidates = allDishes.filter { dish in
|
|
!violatesExplicitRules(
|
|
dish: dish,
|
|
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) {
|
|
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)
|
|
}
|
|
|
|
private static func violatesRules(
|
|
dish: Dish,
|
|
slot: MealSlot,
|
|
plan: WeekPlan,
|
|
tagMap: [UUID: Tag],
|
|
dishMap: [UUID: Dish]
|
|
) -> Bool {
|
|
violatesDefaultNoRepeatRule(dish: dish, slot: slot, plan: plan, tagMap: tagMap) ||
|
|
violatesExplicitRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
|
|
}
|
|
|
|
private static func violatesDefaultNoRepeatRule(
|
|
dish: Dish,
|
|
slot: MealSlot,
|
|
plan: WeekPlan,
|
|
tagMap: [UUID: Tag]
|
|
) -> Bool {
|
|
// Default behavior: avoid assigning the same dish twice in the same week,
|
|
// regardless of whether it has explicit tag rules. The fallback path in
|
|
// autocomplete() still allows repeating when no alternative exists.
|
|
return 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 }
|
|
|
|
// Max per week
|
|
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 }
|
|
}
|
|
|
|
// No consecutive
|
|
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 }
|
|
}
|
|
}
|
|
|
|
// No duplicate in day
|
|
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 }
|
|
}
|
|
}
|
|
|
|
// Meal type restriction
|
|
if let restriction = tag.mealTypeRestriction, slot.mealType != restriction {
|
|
return true
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
private static func weightedRandomPick(candidates: [Dish], plan: WeekPlan) -> Dish? {
|
|
guard !candidates.isEmpty else { return nil }
|
|
let weights: [Double] = candidates.map { dish in
|
|
let usage = plan.slots.filter { $0.dishId == dish.id }.count
|
|
switch usage {
|
|
case 0: return 3.0
|
|
case 1: return 2.0
|
|
default: return 1.0
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|