import CoreLocation import Foundation /// The signals relevance scoring reads from a note. A plain value type so /// scoring is testable without SwiftData. struct RelevanceSignals: Sendable, Equatable { var latitude: Double? var longitude: Double? var placeName: String? var locality: String? var createdAt: Date var timeZoneID: String? } /// The recall-time context a query scores against. struct RelevanceQuery: Sendable { var latitude: Double? var longitude: Double? var placeName: String? var locality: String? var date: Date = Date() var timeZone: TimeZone = .current } /// Context-based ranking: given "where am I, what time is it" now, how likely /// is this note the one the user wants at their fingertips? /// /// The score is a sum of independent affinity terms, each decaying smoothly /// from its ideal (same spot, same hour, just written). Weights are tuned so /// physical proximity dominates — being back at the coffee shop should /// surface the barista's name even if the note is months old. enum Relevance { /// Score at or above which a note belongs in the "right here, right now" /// shelf. static let surfacingThreshold = 1.0 static func score(_ note: RelevanceSignals, query: RelevanceQuery) -> Double { var score = 0.0 // Proximity: exponential decay with ~250 m scale — full weight inside // a venue, negligible a few kilometers away. if let noteLat = note.latitude, let noteLon = note.longitude, let hereLat = query.latitude, let hereLon = query.longitude { let meters = CLLocation(latitude: noteLat, longitude: noteLon) .distance(from: CLLocation(latitude: hereLat, longitude: hereLon)) score += 3.0 * exp(-meters / 250) } // Same named place (exact, case-insensitive) — reverse geocoding is // stable enough that this is a strong "you are back here" signal even // when GPS wobbles. if let notePlace = note.placeName, let herePlace = query.placeName, !notePlace.isEmpty, notePlace.caseInsensitiveCompare(herePlace) == .orderedSame { score += 1.5 } else if let noteCity = note.locality, let hereCity = query.locality, !noteCity.isEmpty, noteCity.caseInsensitiveCompare(hereCity) == .orderedSame { score += 0.3 } // Time-of-day affinity: Gaussian over circular hour distance // (σ ≈ 2.5 h) — a "morning" note resurfaces in the morning. let noteHour = localHour(of: note.createdAt, timeZoneID: note.timeZoneID) let queryHour = Double(Calendar.current.component(.hour, from: query.date)) + Double(Calendar.current.component(.minute, from: query.date)) / 60 let hourDelta = circularHourDistance(noteHour, queryHour) score += 0.6 * exp(-(hourDelta * hourDelta) / (2 * 2.5 * 2.5)) // Same weekday: weak habitual signal (the Saturday farmers market). var noteCalendar = Calendar.current if let id = note.timeZoneID, let zone = TimeZone(identifier: id) { noteCalendar.timeZone = zone } var queryCalendar = Calendar.current queryCalendar.timeZone = query.timeZone if noteCalendar.component(.weekday, from: note.createdAt) == queryCalendar.component(.weekday, from: query.date) { score += 0.2 } // Recency: fresh notes float regardless of place (~45-day half-life // feel; purely a tiebreaker against the location terms). let ageDays = max(0, query.date.timeIntervalSince(note.createdAt)) / 86_400 score += 0.5 * exp(-ageDays / 45) return score } /// Hour-of-day (fractional) of `date` in the zone the note was taken in, /// falling back to the current zone. private static func localHour(of date: Date, timeZoneID: String?) -> Double { var calendar = Calendar.current if let timeZoneID, let zone = TimeZone(identifier: timeZoneID) { calendar.timeZone = zone } let hour = calendar.component(.hour, from: date) let minute = calendar.component(.minute, from: date) return Double(hour) + Double(minute) / 60 } /// Distance between two hours on the 24-hour circle (0...12). private static func circularHourDistance(_ a: Double, _ b: Double) -> Double { let diff = abs(a - b).truncatingRemainder(dividingBy: 24) return min(diff, 24 - diff) } } extension RelevanceSignals { init(entity: NoteEntity) { self.init( latitude: entity.latitude, longitude: entity.longitude, placeName: entity.placeName, locality: entity.locality, createdAt: entity.createdAt, timeZoneID: entity.timeZoneID ) } } extension RelevanceQuery { @MainActor init(context service: ContextService) { self.init( latitude: service.current.latitude, longitude: service.current.longitude, placeName: service.current.placeName, locality: service.current.locality ) } }