Files
FamilyMealPlanner/MealMood/Services/AutocompleteEngine.swift
T
alexandrev-tibco 24a6743ed2 reglas: los avisos se calculan del plan actual, no de una marca guardada
Un plato aparecia en la lista de incumplimientos sin explicacion (captura
del usuario: "Ensalada campera"): findViolations listaba los slots con
isRuleOverridden, una marca puesta al asignar que quedaba obsoleta cuando
el conflicto ya se habia resuelto. Y al reves, editar una regla despues
de planificar no detectaba nada.

- findViolations recorre el plan y reporta solo lo que incumple AHORA,
  siempre con sus razones (nunca una entrada vacia)
- MealSlot.isRuleIgnored: "Ignorar" pasa a ser persistente, porque si no
  el aviso recalculado volveria enseguida; se resetea al cambiar o
  quitar el plato del hueco
- El triangulo del calendario usa tambien el estado real
- Campo en el payload KV (opcional, retrocompatible) y en el esquema
  CloudKit de Development
- 3 tests: marca obsoleta no se lista, regla endurecida despues se
  detecta, y lo ignorado no reaparece

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
2026-09-10 21:54:37 +02:00

420 lines
17 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
/// Which rules this assignment breaks the panel turns these into a
/// plain-language explanation and a suggested fix.
let reasons: [Reason]
struct Reason: Hashable {
enum Kind: Hashable {
case maxPerWeek
case noConsecutive
case noDuplicateInDay
case mealTypeOnly
case dayRestriction
case repeatedInWeek
}
let kind: Kind
let tagId: UUID?
let limit: Int?
/// "lunch"/"dinner" for mealTypeOnly, "weekdays"/"weekend" for dayRestriction.
let restriction: String?
init(kind: Kind, tagId: UUID? = nil, limit: Int? = nil, restriction: String? = nil) {
self.kind = kind
self.tagId = tagId
self.limit = limit
self.restriction = restriction
}
}
}
// 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(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
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 && !$0.isSkipped }
// Fixed-slot rules win outright: a dish pinned to a weekday+meal fills
// that slot before any scoring. Runs before the MRV loop so the pinned
// dish also counts against no-repeat/tag rules for the rest of the week.
for slot in pendingSlots {
guard let fixed = allDishes.first(where: {
$0.fixedDayOfWeek == slot.dayOfWeek && $0.fixedMealType == slot.mealType
}) else { continue }
slot.dishId = fixed.id
filledSlots.append((slotId: slot.id, dishId: fixed.id))
}
pendingSlots.removeAll { $0.dishId != nil }
// 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(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let tagMap = Dictionary(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
// Computed from the current plan instead of the stored
// `isRuleOverridden` flag: that flag went stale (it listed meals whose
// conflict had already been resolved, with nothing to explain) and it
// missed violations that only appear when a rule is edited after
// planning. Slots the user explicitly dismissed stay hidden.
return plan.slotList.compactMap { slot in
guard let dishId = slot.dishId,
let dish = dishMap[dishId],
!slot.isEatingOut,
!slot.isSkipped,
!slot.isRuleIgnored else { return nil }
let found = reasons(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
guard !found.isEmpty else { return nil }
return RuleViolation(
slotId: slot.id,
dayOfWeek: slot.dayOfWeek,
mealType: slot.mealType,
dishId: dishId,
dishName: dish.name,
reasons: found
)
}
}
/// Every rule the dish breaks in this slot. Mirrors `violatesExplicitRules`
/// but collects instead of short-circuiting, so the UI can explain itself.
static func reasons(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
tagMap: [UUID: Tag],
dishMap: [UUID: Dish]
) -> [RuleViolation.Reason] {
var found: [RuleViolation.Reason] = []
if plan.slotList.contains(where: { $0.id != slot.id && $0.dishId == dish.id }) {
found.append(.init(kind: .repeatedInWeek))
}
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 {
found.append(.init(kind: .maxPerWeek, tagId: tagId, limit: maxPerWeek))
}
}
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) {
found.append(.init(kind: .noConsecutive, tagId: tagId))
break
}
}
}
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) {
found.append(.init(kind: .noDuplicateInDay, tagId: tagId))
break
}
}
}
if let restriction = tag.mealTypeRestriction, slot.mealType != restriction {
found.append(.init(kind: .mealTypeOnly, tagId: tagId, restriction: restriction))
}
if let dayRestriction = tag.dayRestriction {
let broken = (dayRestriction == "weekdays" && slot.dayOfWeek > 4) ||
(dayRestriction == "weekend" && slot.dayOfWeek < 5)
if broken {
found.append(.init(kind: .dayRestriction, tagId: tagId, restriction: dayRestriction))
}
}
}
return found
}
static func validateDrop(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
allTags: [Tag],
allDishes: [Dish]
) -> Bool {
let tagMap = Dictionary(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
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
}
}