Autocomplete: period-based dish scoring

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>
This commit is contained in:
alexandrev-tibco
2026-05-27 16:30:22 +02:00
parent 330f21a019
commit e13771fa06
2 changed files with 60 additions and 18 deletions
+59 -17
View File
@@ -15,7 +15,7 @@ struct AutocompleteEngine {
let dishName: String
}
// recentPlans: up to 4 previous weeks (sorted newest-first) for historical scoring.
// recentPlans: up to 12 previous weeks for period detection (weekly/bi-weekly/monthly cycles).
static func autocomplete(
emptySlots: [MealSlot],
currentPlan: WeekPlan,
@@ -25,7 +25,7 @@ struct AutocompleteEngine {
) -> AutocompleteResult {
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
let historyScores = historicalScores(dishes: allDishes, recentPlans: recentPlans)
let historyScores = periodScores(dishes: allDishes, recentPlans: recentPlans, currentWeekStart: currentPlan.weekStartDate)
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
var unfilledCount = 0
@@ -181,31 +181,73 @@ struct AutocompleteEngine {
return result
}
// MARK: - Historical scoring
// MARK: - Period-based scoring
// Returns a recency-penalty multiplier [0.0, 1.0] per dish.
// Dishes used more recently get a lower multiplier (deprioritised).
private static func historicalScores(dishes: [Dish], recentPlans: [WeekPlan]) -> [UUID: Double] {
// 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 sorted = recentPlans.sorted { $0.weekStartDate > $1.weekStartDate }
let calendar = Calendar.current
var scores: [UUID: Double] = [:]
for dish in dishes {
for (weekIndex, plan) in sorted.enumerated() {
// 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 multiplier: Double
switch weekIndex {
case 0: multiplier = 0.20 // used last week strongly deprioritise
case 1: multiplier = 0.50 // 2 weeks ago
case 2: multiplier = 0.75 // 3 weeks ago
default: multiplier = 0.90
}
scores[dish.id] = multiplier
break // only the most recent appearance matters
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(
+1 -1
View File
@@ -120,7 +120,7 @@ final class HomeViewModel: ObservableObject {
let recentPlans = allPlans
.filter { $0.weekStartDate < plan.weekStartDate }
.sorted { $0.weekStartDate > $1.weekStartDate }
.prefix(4)
.prefix(12)
let emptySlots = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut }
let result = AutocompleteEngine.autocomplete(