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
|
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(
|
static func autocomplete(
|
||||||
emptySlots: [MealSlot],
|
emptySlots: [MealSlot],
|
||||||
currentPlan: WeekPlan,
|
currentPlan: WeekPlan,
|
||||||
@@ -25,7 +25,7 @@ struct AutocompleteEngine {
|
|||||||
) -> AutocompleteResult {
|
) -> AutocompleteResult {
|
||||||
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
|
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
|
||||||
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.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 filledSlots: [(slotId: UUID, dishId: UUID)] = []
|
||||||
var unfilledCount = 0
|
var unfilledCount = 0
|
||||||
@@ -181,31 +181,73 @@ struct AutocompleteEngine {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Historical scoring
|
// MARK: - Period-based scoring
|
||||||
|
|
||||||
// Returns a recency-penalty multiplier [0.0, 1.0] per dish.
|
// Weight multiplier per dish based on its detected recurrence cycle.
|
||||||
// Dishes used more recently get a lower multiplier (deprioritised).
|
// Values > 1.0 boost a dish that is "due"; < 1.0 suppress one used too recently.
|
||||||
private static func historicalScores(dishes: [Dish], recentPlans: [WeekPlan]) -> [UUID: Double] {
|
// 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 [:] }
|
guard !recentPlans.isEmpty else { return [:] }
|
||||||
let sorted = recentPlans.sorted { $0.weekStartDate > $1.weekStartDate }
|
let calendar = Calendar.current
|
||||||
var scores: [UUID: Double] = [:]
|
var scores: [UUID: Double] = [:]
|
||||||
|
|
||||||
for dish in dishes {
|
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 }
|
guard plan.slots.contains(where: { $0.dishId == dish.id }) else { continue }
|
||||||
let multiplier: Double
|
let diff = calendar.dateComponents([.weekOfYear], from: plan.weekStartDate, to: currentWeekStart).weekOfYear ?? 0
|
||||||
switch weekIndex {
|
if diff > 0 { offsets.append(diff) }
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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
|
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
|
// MARK: - Weighted pick
|
||||||
|
|
||||||
private static func weightedRandomPick(
|
private static func weightedRandomPick(
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
let recentPlans = allPlans
|
let recentPlans = allPlans
|
||||||
.filter { $0.weekStartDate < plan.weekStartDate }
|
.filter { $0.weekStartDate < plan.weekStartDate }
|
||||||
.sorted { $0.weekStartDate > $1.weekStartDate }
|
.sorted { $0.weekStartDate > $1.weekStartDate }
|
||||||
.prefix(4)
|
.prefix(12)
|
||||||
|
|
||||||
let emptySlots = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut }
|
let emptySlots = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut }
|
||||||
let result = AutocompleteEngine.autocomplete(
|
let result = AutocompleteEngine.autocomplete(
|
||||||
|
|||||||
Reference in New Issue
Block a user