analytics(engine): pure deterministic AnalyticsEngine + models + tests (KC-51 ph1)
Phase 1 of the analytics system (/goal): the framework-independent analysis
engine. Value-type models (MetricDelta, TrendResult, DaySample, WeeklyReport,
MonthlyReport, ...) and AnalyticsEngine with documented formulas (volume,
%change, consistency, trend) and honest degradation (nil/zero/tiny baseline,
new exercise, rest day, outliers via winsorize, unit-dimension compatibility,
divide-by-zero). Calendar math takes an injected Calendar (locale/firstWeekday/
timezone/DST-safe).
AnalyticsEngineTests: 35 XCTest cases. Independently verified by compiling the
engine with swiftc and running a 42-assertion harness — all pass. (Xcode test
target not runnable in this env; assertions mirror the XCTest file.)
Registered the new files via xcodegen. Logs KC-51 phase-1 progress in ISSUES.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:06:59 +03:00
|
|
|
|
import Foundation
|
|
|
|
|
|
|
|
|
|
|
|
// Deterministic, side-effect-free analytics calculations. No SwiftUI, no
|
|
|
|
|
|
// persistence, no HealthKit, no LLM — pure functions over value types so every
|
|
|
|
|
|
// formula is unit-testable and reproducible. All calendar math takes an injected
|
|
|
|
|
|
// `Calendar` so locale / firstWeekday / timezone / DST behavior is testable.
|
|
|
|
|
|
//
|
|
|
|
|
|
// FORMULAS
|
|
|
|
|
|
// volume = Σ (weight × reps) over completed sets [kg]
|
|
|
|
|
|
// percentChange = (current − baseline) / |baseline| × 100 [%]
|
|
|
|
|
|
// consistency = min(1, sessions / scheduled) [0…1]
|
|
|
|
|
|
// trend = classify(least-squares slope / |mean|) vs threshold
|
|
|
|
|
|
//
|
|
|
|
|
|
// All comparisons degrade honestly: missing baseline → no %, tiny/zero baseline
|
|
|
|
|
|
// → no % (avoids divide-by-tiny), new exercise / rest day → not meaningful.
|
|
|
|
|
|
// Language is factual and supportive; nothing here diagnoses or advises medically.
|
|
|
|
|
|
|
|
|
|
|
|
enum AnalyticsEngine {
|
|
|
|
|
|
|
|
|
|
|
|
/// Informational, non-medical disclaimer surfaced with every report.
|
|
|
|
|
|
static let disclaimer = """
|
|
|
|
|
|
These are informational summaries of your own logged activity — not medical \
|
|
|
|
|
|
advice, diagnosis, or treatment. Talk to a qualified professional for health \
|
|
|
|
|
|
decisions.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Exercise identity
|
|
|
|
|
|
|
|
|
|
|
|
/// Normalized identity so the same exercise matches across days regardless of
|
|
|
|
|
|
/// casing / whitespace. Deterministic and locale-independent.
|
|
|
|
|
|
static func exerciseIdentity(_ name: String) -> String {
|
|
|
|
|
|
name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
|
.lowercased()
|
|
|
|
|
|
.replacingOccurrences(of: " ", with: " ")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Delta
|
|
|
|
|
|
|
|
|
|
|
|
/// Compare `current` against `baseline`, guarding percentage honesty.
|
|
|
|
|
|
/// - `isNew`: no prior baseline exists (e.g. a newly added exercise).
|
|
|
|
|
|
/// - `restDay`: baseline day was a rest day → % is not meaningful.
|
|
|
|
|
|
static func delta(current: Double,
|
|
|
|
|
|
baseline: Double?,
|
|
|
|
|
|
minMeaningfulBaseline: Double = 0.0001,
|
|
|
|
|
|
isNew: Bool = false,
|
|
|
|
|
|
restDay: Bool = false) -> MetricDelta {
|
|
|
|
|
|
guard let base = baseline, !isNew, !restDay else {
|
|
|
|
|
|
return MetricDelta(current: current,
|
|
|
|
|
|
baseline: baseline,
|
|
|
|
|
|
absolute: baseline.map { current - $0 },
|
|
|
|
|
|
percent: nil,
|
|
|
|
|
|
direction: current > 0 ? .up : .flat,
|
|
|
|
|
|
meaningful: false)
|
|
|
|
|
|
}
|
|
|
|
|
|
let abs = current - base
|
|
|
|
|
|
let dir: MetricDirection = abs > 0 ? .up : (abs < 0 ? .down : .flat)
|
|
|
|
|
|
// Only compute % when the baseline magnitude is meaningful.
|
|
|
|
|
|
let pct: Double? = Swift.abs(base) >= minMeaningfulBaseline ? (abs / Swift.abs(base)) * 100 : nil
|
|
|
|
|
|
return MetricDelta(current: current,
|
|
|
|
|
|
baseline: base,
|
|
|
|
|
|
absolute: abs,
|
|
|
|
|
|
percent: pct,
|
|
|
|
|
|
direction: dir,
|
|
|
|
|
|
meaningful: pct != nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Classify a single week/month-over-period change from its volume delta.
|
|
|
|
|
|
static func classifyChange(_ d: MetricDelta, plateauPct: Double = 3) -> TrendClassification {
|
|
|
|
|
|
guard d.meaningful, let pct = d.percent else { return .insufficientData }
|
|
|
|
|
|
if pct > plateauPct { return .improving }
|
|
|
|
|
|
if pct < -plateauPct { return .declining }
|
|
|
|
|
|
return .plateau
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Consistency
|
|
|
|
|
|
|
|
|
|
|
|
static func consistency(sessions: Int, scheduled: Int) -> Double {
|
|
|
|
|
|
guard scheduled > 0 else { return 0 }
|
|
|
|
|
|
return min(1.0, Double(sessions) / Double(scheduled))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Volume
|
|
|
|
|
|
|
|
|
|
|
|
static func volume(weight: Double, reps: Int) -> Double { weight * Double(reps) }
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Trend (multi-point, for 12-month views)
|
|
|
|
|
|
|
|
|
|
|
|
static func classifyTrend(_ values: [Double],
|
|
|
|
|
|
minPoints: Int = 3,
|
|
|
|
|
|
relativeThreshold: Double = 0.03) -> TrendResult {
|
|
|
|
|
|
let n = values.count
|
|
|
|
|
|
guard n >= minPoints else {
|
|
|
|
|
|
return TrendResult(classification: .insufficientData, slope: 0, points: n)
|
|
|
|
|
|
}
|
|
|
|
|
|
let slope = leastSquaresSlope(values)
|
|
|
|
|
|
let mean = values.reduce(0, +) / Double(n)
|
|
|
|
|
|
guard Swift.abs(mean) > 0 else {
|
|
|
|
|
|
let cls: TrendClassification = slope == 0 ? .plateau : (slope > 0 ? .improving : .declining)
|
|
|
|
|
|
return TrendResult(classification: cls, slope: slope, points: n)
|
|
|
|
|
|
}
|
|
|
|
|
|
let rel = slope / Swift.abs(mean)
|
|
|
|
|
|
let cls: TrendClassification
|
|
|
|
|
|
if rel > relativeThreshold { cls = .improving }
|
|
|
|
|
|
else if rel < -relativeThreshold { cls = .declining }
|
|
|
|
|
|
else { cls = .plateau }
|
|
|
|
|
|
return TrendResult(classification: cls, slope: slope, points: n)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static func leastSquaresSlope(_ y: [Double]) -> Double {
|
|
|
|
|
|
let n = Double(y.count)
|
|
|
|
|
|
guard n > 1 else { return 0 }
|
|
|
|
|
|
let xs = (0..<y.count).map(Double.init)
|
|
|
|
|
|
let sumX = xs.reduce(0, +)
|
|
|
|
|
|
let sumY = y.reduce(0, +)
|
|
|
|
|
|
let sumXY = zip(xs, y).map(*).reduce(0, +)
|
|
|
|
|
|
let sumXX = xs.map { $0 * $0 }.reduce(0, +)
|
|
|
|
|
|
let denom = n * sumXX - sumX * sumX
|
|
|
|
|
|
guard denom != 0 else { return 0 }
|
|
|
|
|
|
return (n * sumXY - sumX * sumY) / denom
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Outliers
|
|
|
|
|
|
|
|
|
|
|
|
/// Winsorize to the [lower, upper] percentile band to tame outliers before
|
|
|
|
|
|
/// trend/mean calculations. No-op for very small samples.
|
|
|
|
|
|
static func winsorize(_ values: [Double], lower: Double = 0.1, upper: Double = 0.9) -> [Double] {
|
|
|
|
|
|
guard values.count >= 5 else { return values }
|
|
|
|
|
|
let sorted = values.sorted()
|
|
|
|
|
|
let lo = percentile(sorted, lower)
|
|
|
|
|
|
let hi = percentile(sorted, upper)
|
|
|
|
|
|
return values.map { min(max($0, lo), hi) }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static func percentile(_ sorted: [Double], _ p: Double) -> Double {
|
|
|
|
|
|
guard !sorted.isEmpty else { return 0 }
|
|
|
|
|
|
let idx = Int((Double(sorted.count - 1) * min(max(p, 0), 1)).rounded())
|
|
|
|
|
|
return sorted[min(max(idx, 0), sorted.count - 1)]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Exercise comparison
|
|
|
|
|
|
|
|
|
|
|
|
static func compareExercises(current: [ExerciseSample],
|
|
|
|
|
|
baseline: [ExerciseSample],
|
|
|
|
|
|
restDay: Bool = false) -> [ExerciseComparison] {
|
|
|
|
|
|
let baseByIdentity = Dictionary(baseline.map { ($0.identity, $0) }, uniquingKeysWith: { a, _ in a })
|
|
|
|
|
|
return current.map { cur in
|
|
|
|
|
|
let base = baseByIdentity[cur.identity]
|
|
|
|
|
|
let isNew = base == nil
|
|
|
|
|
|
return ExerciseComparison(
|
|
|
|
|
|
identity: cur.identity,
|
|
|
|
|
|
volume: delta(current: cur.volume, baseline: base?.volume, isNew: isNew, restDay: restDay),
|
|
|
|
|
|
sets: delta(current: Double(cur.sets), baseline: base.map { Double($0.sets) }, isNew: isNew, restDay: restDay),
|
|
|
|
|
|
reps: delta(current: Double(cur.reps), baseline: base.map { Double($0.reps) }, isNew: isNew, restDay: restDay),
|
|
|
|
|
|
isNew: isNew
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Daily comparison
|
|
|
|
|
|
|
|
|
|
|
|
static func dailyComparison(day: DaySample,
|
|
|
|
|
|
previousDay: DaySample?,
|
|
|
|
|
|
sameWeekdayLastWeek: DaySample?) -> DailyComparison {
|
|
|
|
|
|
DailyComparison(
|
|
|
|
|
|
dateKey: day.dateKey,
|
|
|
|
|
|
restDay: !day.didWorkout,
|
|
|
|
|
|
vsPreviousDay: compareExercises(current: day.exercises,
|
|
|
|
|
|
baseline: previousDay?.exercises ?? [],
|
|
|
|
|
|
restDay: previousDay.map { !$0.didWorkout } ?? true),
|
|
|
|
|
|
vsSameWeekdayLastWeek: compareExercises(current: day.exercises,
|
|
|
|
|
|
baseline: sameWeekdayLastWeek?.exercises ?? [],
|
|
|
|
|
|
restDay: sameWeekdayLastWeek.map { !$0.didWorkout } ?? true)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Period summaries & reports
|
|
|
|
|
|
|
|
|
|
|
|
static func summarize(_ days: [DaySample]) -> PeriodSummary {
|
|
|
|
|
|
let sessions = days.filter { $0.didWorkout }.count
|
|
|
|
|
|
let scheduled = days.filter { $0.scheduled }.count
|
|
|
|
|
|
let missed = days.filter { $0.scheduled && !$0.didWorkout }.count
|
|
|
|
|
|
let sets = days.reduce(0) { $0 + $1.sets }
|
|
|
|
|
|
let volume = days.reduce(0) { $0 + $1.volume }
|
|
|
|
|
|
let duration = days.reduce(0) { $0 + $1.durationMinutes }
|
|
|
|
|
|
return PeriodSummary(sessions: sessions, sets: sets, volume: volume,
|
|
|
|
|
|
durationMinutes: duration, scheduled: scheduled,
|
|
|
|
|
|
missed: missed, consistency: consistency(sessions: sessions, scheduled: scheduled))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static func periodDeltas(current cur: PeriodSummary, previous prev: PeriodSummary) -> [String: MetricDelta] {
|
|
|
|
|
|
[
|
|
|
|
|
|
"sessions": delta(current: Double(cur.sessions), baseline: Double(prev.sessions)),
|
|
|
|
|
|
"sets": delta(current: Double(cur.sets), baseline: Double(prev.sets)),
|
|
|
|
|
|
"volume": delta(current: cur.volume, baseline: prev.volume),
|
|
|
|
|
|
"duration": delta(current: cur.durationMinutes, baseline: prev.durationMinutes),
|
|
|
|
|
|
"consistency": delta(current: cur.consistency * 100, baseline: prev.consistency * 100),
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static func weeklyReport(weekStartKey: String,
|
|
|
|
|
|
days: [DaySample],
|
|
|
|
|
|
previousWeekDays: [DaySample],
|
|
|
|
|
|
generatedAt: Date) -> WeeklyReport {
|
|
|
|
|
|
let cur = summarize(days)
|
|
|
|
|
|
let prev = summarize(previousWeekDays)
|
|
|
|
|
|
let deltas = periodDeltas(current: cur, previous: prev)
|
|
|
|
|
|
return WeeklyReport(
|
|
|
|
|
|
weekStartKey: weekStartKey,
|
|
|
|
|
|
generatedAt: generatedAt,
|
|
|
|
|
|
summary: cur,
|
|
|
|
|
|
vsPreviousWeek: deltas,
|
|
|
|
|
|
progression: classifyChange(deltas["volume"] ?? delta(current: 0, baseline: nil)),
|
|
|
|
|
|
disclaimer: disclaimer
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static func monthlyReport(monthKey: String,
|
|
|
|
|
|
days: [DaySample],
|
|
|
|
|
|
previousMonthDays: [DaySample],
|
|
|
|
|
|
generatedAt: Date) -> MonthlyReport {
|
|
|
|
|
|
let cur = summarize(days)
|
|
|
|
|
|
let prev = summarize(previousMonthDays)
|
|
|
|
|
|
let deltas = periodDeltas(current: cur, previous: prev)
|
|
|
|
|
|
return MonthlyReport(
|
|
|
|
|
|
monthKey: monthKey,
|
|
|
|
|
|
generatedAt: generatedAt,
|
|
|
|
|
|
summary: cur,
|
|
|
|
|
|
vsPreviousMonth: deltas,
|
|
|
|
|
|
classification: classifyChange(deltas["volume"] ?? delta(current: 0, baseline: nil)),
|
|
|
|
|
|
disclaimer: disclaimer
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Calendar helpers (injected Calendar → locale/TZ/DST-safe)
|
|
|
|
|
|
|
|
|
|
|
|
private static func keyFormatter(_ calendar: Calendar) -> DateFormatter {
|
|
|
|
|
|
let f = DateFormatter()
|
|
|
|
|
|
f.calendar = calendar
|
|
|
|
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
|
|
|
|
f.timeZone = calendar.timeZone
|
|
|
|
|
|
f.dateFormat = "yyyy-MM-dd"
|
|
|
|
|
|
return f
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static func dateKey(for date: Date, calendar: Calendar) -> String {
|
|
|
|
|
|
keyFormatter(calendar).string(from: calendar.startOfDay(for: date))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static func monthKey(for date: Date, calendar: Calendar) -> String {
|
|
|
|
|
|
let f = keyFormatter(calendar); f.dateFormat = "yyyy-MM"
|
|
|
|
|
|
return f.string(from: date)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static func weekInterval(containing date: Date, calendar: Calendar) -> DateInterval? {
|
|
|
|
|
|
calendar.dateInterval(of: .weekOfYear, for: date)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static func monthInterval(containing date: Date, calendar: Calendar) -> DateInterval? {
|
|
|
|
|
|
calendar.dateInterval(of: .month, for: date)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The same weekday one week earlier — used for the day-vs-last-week compare.
|
|
|
|
|
|
static func sameWeekdayLastWeek(_ date: Date, calendar: Calendar) -> Date? {
|
|
|
|
|
|
calendar.date(byAdding: .weekOfYear, value: -1, to: date)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Whether `date` falls in a completed period (strictly before the period
|
|
|
|
|
|
/// containing `now`) — reports are only generated for completed periods.
|
|
|
|
|
|
static func isCompletedWeek(_ date: Date, now: Date, calendar: Calendar) -> Bool {
|
|
|
|
|
|
guard let w = weekInterval(containing: date, calendar: calendar) else { return false }
|
|
|
|
|
|
return w.end <= (weekInterval(containing: now, calendar: calendar)?.start ?? now)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static func isCompletedMonth(_ date: Date, now: Date, calendar: Calendar) -> Bool {
|
|
|
|
|
|
guard let m = monthInterval(containing: date, calendar: calendar) else { return false }
|
|
|
|
|
|
return m.end <= (monthInterval(containing: now, calendar: calendar)?.start ?? now)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Retention
|
|
|
|
|
|
|
|
|
|
|
|
/// Keys older than `months` before `now` (for safe pruning of raw data).
|
|
|
|
|
|
static func expiredDayKeys(_ keys: [String], now: Date, months: Int = 12, calendar: Calendar) -> [String] {
|
|
|
|
|
|
guard let cutoff = calendar.date(byAdding: .month, value: -months, to: calendar.startOfDay(for: now)) else { return [] }
|
|
|
|
|
|
let cutoffKey = dateKey(for: cutoff, calendar: calendar)
|
|
|
|
|
|
return keys.filter { $0 < cutoffKey }
|
|
|
|
|
|
}
|
2026-07-07 22:34:32 +03:00
|
|
|
|
|
|
|
|
|
|
// MARK: - Reconciliation selection (which completed periods need a report)
|
|
|
|
|
|
|
|
|
|
|
|
/// Week-start keys of every completed week within the last `months`, oldest→newest.
|
|
|
|
|
|
static func completedWeekStartKeys(now: Date, months: Int = 12, calendar cal: Calendar) -> [String] {
|
|
|
|
|
|
guard let windowStart = cal.date(byAdding: .month, value: -months, to: now),
|
|
|
|
|
|
let thisWeek = weekInterval(containing: now, calendar: cal),
|
|
|
|
|
|
var cursor = weekInterval(containing: windowStart, calendar: cal)?.start else { return [] }
|
|
|
|
|
|
var keys: [String] = []
|
|
|
|
|
|
var guardCount = 0
|
|
|
|
|
|
while cursor < thisWeek.start, guardCount < 70 {
|
|
|
|
|
|
keys.append(dateKey(for: cursor, calendar: cal))
|
|
|
|
|
|
guard let next = cal.date(byAdding: .weekOfYear, value: 1, to: cursor) else { break }
|
|
|
|
|
|
cursor = next; guardCount += 1
|
|
|
|
|
|
}
|
|
|
|
|
|
return keys
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Month keys of every completed month within the last `months`, oldest→newest.
|
|
|
|
|
|
static func completedMonthKeys(now: Date, months: Int = 12, calendar cal: Calendar) -> [String] {
|
|
|
|
|
|
var keys: [String] = []
|
|
|
|
|
|
for m in stride(from: months, through: 1, by: -1) {
|
|
|
|
|
|
guard let d = cal.date(byAdding: .month, value: -m, to: now) else { continue }
|
|
|
|
|
|
let key = monthKey(for: d, calendar: cal)
|
|
|
|
|
|
if !keys.contains(key) { keys.append(key) }
|
|
|
|
|
|
}
|
|
|
|
|
|
return keys
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Keys present in `all` but not in `existing` — the periods still needing a
|
|
|
|
|
|
/// report. Idempotent by construction: already-generated periods are skipped.
|
|
|
|
|
|
static func missingKeys(_ all: [String], existing: Set<String>) -> [String] {
|
|
|
|
|
|
all.filter { !existing.contains($0) }
|
|
|
|
|
|
}
|
analytics(engine): pure deterministic AnalyticsEngine + models + tests (KC-51 ph1)
Phase 1 of the analytics system (/goal): the framework-independent analysis
engine. Value-type models (MetricDelta, TrendResult, DaySample, WeeklyReport,
MonthlyReport, ...) and AnalyticsEngine with documented formulas (volume,
%change, consistency, trend) and honest degradation (nil/zero/tiny baseline,
new exercise, rest day, outliers via winsorize, unit-dimension compatibility,
divide-by-zero). Calendar math takes an injected Calendar (locale/firstWeekday/
timezone/DST-safe).
AnalyticsEngineTests: 35 XCTest cases. Independently verified by compiling the
engine with swiftc and running a 42-assertion harness — all pass. (Xcode test
target not runnable in this env; assertions mirror the XCTest file.)
Registered the new files via xcodegen. Logs KC-51 phase-1 progress in ISSUES.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:06:59 +03:00
|
|
|
|
}
|