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:
@@ -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 ≥ 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 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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user