718eef16bf
- RuleViolation.reasons: el motor devuelve ahora QUE regla rompe cada asignacion (repetido en la semana, maximo por semana superado, no consecutivo, no mismo dia, solo comida/cena, solo entre semana o fin de semana) con su etiqueta y limite - Panel de avisos: bloque con cada regla rota en lenguaje claro + una sugerencia de que hacer, y boton nuevo "Cambiar plato" que abre el selector de ese hueco (antes solo se podia quitar o ignorar) - Estadisticas: matriz "Etiquetas por tipo de comida" (p.ej. cuantas cenas son de pescado) y "Platos por etiqueta" del catalogo - Tests: 4 casos de las razones de incumplimiento; harness que renderiza las secciones nuevas de stats a /tmp/stats_render.png - Strings en 6 idiomas Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
412 lines
16 KiB
Swift
412 lines
16 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
|
||
/// 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 })
|
||
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,
|
||
reasons: reasons(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 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 ≥ 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.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
|
||
}
|
||
}
|