Files
FamilyMealPlanner/MealMood/Services/AutocompleteEngine.swift
T
alexandrev-tibco 4ffa15b06a 2.0: CloudKit private-database sync replaces KV snapshot sync
Foundation for 2.1 family sharing (CKShare needs these models + container).
SwiftData cannot share across Apple IDs today, so 2.0 ships true multi-device
sync for the same account instead:

- Models made CloudKit-compatible: dropped @Attribute(.unique) on all 5,
  inline defaults on every attribute, WeekPlan.slots stored as optional
  relationship (name preserved → lightweight migration) with non-optional
  slotList facade; ~73 call sites renamed.
- Container: cloudKitDatabase .automatic, falling back to the local-only
  store when CloudKit is unavailable; failures recorded to Crashlytics.
- Entitlements: iCloud CloudKit service (container already existed);
  remote-notification background mode for push-driven sync.
- Legacy KV snapshot sync (ICloudSyncService) stays inert when CloudKit is
  active — kept only for 1.x devices.
- DeduplicationService collapses cross-device duplicates deterministically on
  launch (settings singleton, default tags with tagId remapping, same-week
  plans).
- Note: AppSettings.isPremium now syncs across same-Apple-ID devices; StoreKit
  (PremiumSyncService + Transaction.updates) remains the source of truth and
  reconciles on every launch/foreground.

All 21 unit tests pass, including ICloudSyncPremiumIsolationTests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
2026-07-12 18:13:45 +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.slotList 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.slotList.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.slotList.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.slotList {
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.slotList 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.slotList.first(where: { $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType }) {
result.append(prev)
}
if slot.dayOfWeek < 6,
let next = plan.slotList.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.slotList.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.slotList.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.slotList.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
}
}