Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ public struct UserProfile: Equatable, Sendable {
self.age = age; self.sex = sex
self.stepTicksPerStep = stepTicksPerStep
}

/// Every stored field, for a cache key that must change when the profile does (the per-cycle load
/// cache, `IntelligenceEngine`). Named explicitly rather than read from `String(describing:)`, whose
/// format is not a contract and costs reflection per call. A new field belongs here too. Doubles by
/// bit pattern, so the key is exact and locale-free. Twin of Kotlin `UserProfile.cacheKey`.
public var cacheKey: String {
"w=\(weightKg.bitPattern),h=\(heightCm.bitPattern),a=\(age.bitPattern),s=\(sex),t=\(stepTicksPerStep.bitPattern)"
}
}

/// A detected workout window. All intensity fields are APPROXIMATE.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import XCTest
@testable import StrandAnalytics

/// `UserProfile.cacheKey` must move with every stored field, or the per-cycle load cache serves stale Effort
/// and calories after a profile edit. Twin of Kotlin `RescoreUnchangedInputsTest.everyProfileFieldMovesTheLoadKey`.
final class UserProfileCacheKeyTests: XCTestCase {
func testEveryFieldMovesTheKey() {
let base = UserProfile()
var edits: [UserProfile] = []
var p = base; p.weightKg = 71; edits.append(p)
p = base; p.heightCm = 171; edits.append(p)
p = base; p.age = 31; edits.append(p)
p = base; p.sex = "male"; edits.append(p)
p = base; p.stepTicksPerStep = 2; edits.append(p)
for edit in edits { XCTAssertNotEqual(edit.cacheKey, base.cacheKey) }
XCTAssertEqual(UserProfile().cacheKey, base.cacheKey)
}
}
55 changes: 41 additions & 14 deletions Strand/Data/DayCycleIntelligenceIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,13 @@ import WhoopStore
let key: String; let count: SleepAwareStepCounter.Count
let pages: Int; let samples: Int; let evaluated: Bool
}
/// A cycle's Effort and calories, with the key of the inputs they were computed from.
fileprivate struct CachedLoad {
let key: String; let strain: Double?; let calories: Double?
}
final class Cache {
fileprivate var cycles: [String: CachedCycle] = [:]
fileprivate var loads: [String: CachedLoad] = [:]
}
private static func computedId(_ owner: String) -> String { owner + "-noop" }

Expand Down Expand Up @@ -172,6 +177,7 @@ import WhoopStore

let windows = PhysiologicalSteps.cycleWindows(boundaries, now: now)
cache.cycles = cache.cycles.filter { entry in windows.contains(where: { $0.sleepId == entry.key }) }
cache.loads = cache.loads.filter { entry in windows.contains(where: { $0.sleepId == entry.key }) }
let priorities = Dictionary(candidates.map { ($0.owner, $0.priority) }, uniquingKeysWith: min)
let witnesses = Dictionary(uniqueKeysWithValues: nights.map { night in
let sleeps = night.sleeps.sorted { $0.startTs < $1.startTs }.map {
Expand All @@ -192,25 +198,46 @@ import WhoopStore
let owners = ([fallback] + physiologyOwners).reduce(into: [String]()) {
if !$0.contains($1) { $0.append($1) }
}
var hrByTimestamp: [Int: HRSample] = [:]
let restingHR = nights.first(where: { $0.daily.day == day })?.daily.restingHr.map(Double.init)
?? StrainScorer.defaultRestingHR
let effectiveMaxHR = maxHROverride ?? (profile.age > 0 ? StrainScorer.tanakaHRmax(age: profile.age) : nil)
// Every pass re-read each cycle's full day of 1 Hz heart rate from every owner and re-scored it,
// for all 21 cycles, although only the open one gains samples between syncs. On a replayed
// phone database this was the costliest step of a pass in which every night was otherwise
// reused (6–12 s of a 16 s pass). The index-only count and newest timestamp per owner witness
// the heart rate the same way the day cache does, so a closed cycle is scored once.
var hrWitness: [String] = []
if hrEndInclusive >= window.onset {
for owner in owners {
let rows = (try? await store.hrSamples(
deviceId: owner, from: window.onset, to: hrEndInclusive, limit: 200_000)) ?? []
for row in rows where hrByTimestamp[row.ts] == nil { hrByTimestamp[row.ts] = row }
let fp = try? await store.hrFingerprint(deviceId: owner, from: window.onset, to: hrEndInclusive)
hrWitness.append("\(owner)=\(fp.map { "\($0.count):\($0.maxTs)" } ?? "unread")")
}
}
let cycleHR = hrByTimestamp.values.sorted { $0.ts < $1.ts }
let restingHR = nights.first(where: { $0.daily.day == day })?.daily.restingHr.map(Double.init)
?? StrainScorer.defaultRestingHR
let effectiveMaxHR = maxHROverride ?? (profile.age > 0 ? StrainScorer.tanakaHRmax(age: profile.age) : nil)
if let strain = StrainScorer.strain(cycleHR, maxHR: effectiveMaxHR,
restingHR: restingHR, method: effortMethod,
sex: profile.sex) { strains[day] = strain }
if !cycleHR.isEmpty {
calories[day] = Calories.estimateDayCalories(
cycleHR, profile: profile, hrmax: effectiveMaxHR, restingHR: restingHR)
let loadKey = "\(window.onset)-\(window.endExclusive)|\(hrWitness.joined(separator: ","))"
+ "|rhr=\(restingHR)|max=\(effectiveMaxHR.map { "\($0)" } ?? "nil")|\(effortMethod)|\(profile.cacheKey)"
let load: CachedLoad
if let hit = cache.loads[window.sleepId], hit.key == loadKey, !hrWitness.contains(where: { $0.hasSuffix("=unread") }) {
load = hit
} else {
var hrByTimestamp: [Int: HRSample] = [:]
if hrEndInclusive >= window.onset {
for owner in owners {
let rows = (try? await store.hrSamples(
deviceId: owner, from: window.onset, to: hrEndInclusive, limit: 200_000)) ?? []
for row in rows where hrByTimestamp[row.ts] == nil { hrByTimestamp[row.ts] = row }
}
}
let cycleHR = hrByTimestamp.values.sorted { $0.ts < $1.ts }
load = CachedLoad(
key: loadKey,
strain: StrainScorer.strain(cycleHR, maxHR: effectiveMaxHR, restingHR: restingHR,
method: effortMethod, sex: profile.sex),
calories: cycleHR.isEmpty ? nil : Calories.estimateDayCalories(
cycleHR, profile: profile, hrmax: effectiveMaxHR, restingHR: restingHR))
cache.loads[window.sleepId] = load
}
if let strain = load.strain { strains[day] = strain }
if let kcal = load.calories { calories[day] = kcal }
let persistedWorkoutKeys = workouts
.filter { $0.startTs >= window.onset && $0.startTs < window.endExclusive }
.map { "\($0.startTs):\($0.endTs)" }
Expand Down
19 changes: 15 additions & 4 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,7 @@ final class IntelligenceEngine: ObservableObject {
let (habitualMidsleepSec, nightlyHours) = await Self.computeHabitualSleep(
store: store, importedId: deviceId, computedId: deviceId + "-noop",
windowStart: nowLocalMidnight - maxDays * 86_400 - StreamReadCap.lookbackSeconds,
windowEnd: now, offsetSec: tzOffset)
windowEnd: now, finishedBefore: nowLocalMidnight, offsetSec: tzOffset)
// Wave 0 (SL1/T1): personal sleep REGULARITY + population-anchored NEED, computed ONCE from the
// trailing per-night durations and threaded to every analyzeDay below (mirrors the midsleep
// learner just above — one personal trait per run, applied to the whole re-scored history so
Expand Down Expand Up @@ -999,6 +999,8 @@ final class IntelligenceEngine: ObservableObject {
// But that is CORRECT invalidation, not churn to be quantized away — a night going from
// half-loaded to complete really does change what every day should be scored against, and the
// swings are large rather than drift, so no tolerance both preserves scores and stops the drop.
// What stops the churn instead is learning only from nights that finished before today
// (`computeHabitualSleep(finishedBefore:)`): the night still being synced was the one moving.
// What keeps it affordable is that the post-backfill re-score is COALESCED on both platforms: iOS
// debounces `lastSyncedAt` by 2 s (#755), Android gates on `analyzeAfterBackfillScheduled` plus a
// trailing delay. So this fires once per completed backfill, not once per chunk. That coalescing is
Expand Down Expand Up @@ -3354,9 +3356,18 @@ final class IntelligenceEngine: ObservableObject {
/// naps drop out. One read serves both the main-night midsleep learner (#547) and the personal
/// sleep-need + regularity that thread into `analyzeDay` (Wave 0 · SL1/T1). The midsleep result is
/// byte-identical to before; the nightly-hours output is the Swift-side extension.
private static func computeHabitualSleep(
///
/// Only sessions that ended before `finishedBefore` (the pass's local midnight) are learned from. Tonight's
/// session is re-banked by every sync while it is still growing, and each time it moved the learned
/// consistency and midsleep, so every pass through a morning found the day-cache signature changed and
/// re-scored all 21 nights from scratch. On a backgrounded phone that turned a seconds-long pass into
/// hours (a field log: 8 813 s and 2 345 s, back to back). A night still being slept is not a habit yet;
/// it joins the history the day after, once, when the window rolls anyway.
///
/// Internal rather than private only so a test can drive the `finishedBefore` cutoff directly.
static func computeHabitualSleep(
store: WhoopStore, importedId: String, computedId: String,
windowStart: Int, windowEnd: Int, offsetSec: Int
windowStart: Int, windowEnd: Int, finishedBefore: Int, offsetSec: Int
) async -> (midsleepSec: Int?, nightlyHours: [Double]) {
let imported = (try? await store.sleepSessions(deviceId: importedId, from: windowStart,
to: windowEnd, limit: 4000)) ?? []
Expand All @@ -3368,7 +3379,7 @@ final class IntelligenceEngine: ObservableObject {
// then steered the main-night pick (day assignment) to the stale block. The same collapse also
// covers an imported night and its computed twin (the longest capture wins, exactly what the
// per-day length rule chose anyway).
let merged = SleepSessionDedup.dedupe(imported + computed).kept
let merged = SleepSessionDedup.dedupe(imported + computed).kept.filter { $0.endTs < finishedBefore }
// Longest block per LOCAL day (naps drop out), chosen by in-bed SPAN — reused for BOTH the
// midsleep learner and the per-night durations (Wave 0 · SL1/T1), so the two can never read a
// different history. For the DURATIONS we keep TST (span × efficiency), NOT the in-bed span:
Expand Down
36 changes: 36 additions & 0 deletions StrandTests/HabitualSleepFinishedNightsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import XCTest
import WhoopStore
@testable import Strand

/// The sleep habits every day is scored against are learned from finished nights only, so the night still
/// being synced cannot change them pass after pass and drop the whole day cache.
@MainActor
final class HabitualSleepFinishedNightsTests: XCTestCase {
func testTonightsGrowingSessionIsNotLearnedFrom() async throws {
let store = try await WhoopStore.inMemory()
let midnight = 1_789_603_200 // 2026-09-17 00:00 UTC
let nights = (1...5).map { back in
CachedSleepSession(startTs: midnight - back * 86_400 + 3_600, endTs: midnight - back * 86_400 + 30_600,
efficiency: 0.9, restingHr: 55, avgHrv: 80, stagesJSON: nil)
}
_ = try await store.upsertSleepSessions(nights, deviceId: "my-whoop-noop")
func learn() async -> (Int?, [Double]) {
await IntelligenceEngine.computeHabitualSleep(
store: store, importedId: "my-whoop", computedId: "my-whoop-noop",
windowStart: midnight - 30 * 86_400, windowEnd: midnight + 86_400,
finishedBefore: midnight, offsetSec: 0)
}
let before = await learn()

// Tonight, first synced to 04:00, then again once it reached 09:30.
for end in [midnight + 14_400, midnight + 34_200] {
_ = try await store.upsertSleepSessions(
[CachedSleepSession(startTs: midnight + 1_800, endTs: end, efficiency: 0.95, restingHr: 54,
avgHrv: 85, stagesJSON: nil)], deviceId: "my-whoop-noop")
let now = await learn()
XCTAssertEqual(now.0, before.0)
XCTAssertEqual(now.1, before.1)
}
XCTAssertEqual(before.1.count, 5)
}
}
48 changes: 48 additions & 0 deletions Tools/parity_dispositions.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,54 @@
"identity_sha256": "88a1bdfb13c227664f15d962fb6d41a5733fb4c2956e445e42c5e4d495bde0cb",
"platform": "swift",
"rationale": "Used only by the iOS HealthKitBridge write-back to hold a still-open night out of Apple Health; the Android Health Connect exporter does not call it (this PR is Swift-only)."
},
{
"type": "platform_specific",
"kind": "add-unpaired-function",
"identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::computeHabitualSleep/7#1",
"identity_sha256": "0f4c9098de75c40d0ed87c6ad6111d259d664e632fe4b34ea7c77fef57fe8484",
"platform": "kotlin",
"rationale": "Twin of the Swift IntelligenceEngine.computeHabitualSleep(finishedBefore:) in the app layer (Strand/Data/IntelligenceEngine.swift), outside the governed Swift roots."
},
{
"type": "platform_specific",
"kind": "add-unpaired-function",
"identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::finishedSessions/2#1",
"identity_sha256": "8f5d3811a0bc62b7a45c7faadd3a96ba6cd93609fdd023d540b41d19fff6a5d7",
"platform": "kotlin",
"rationale": "Kotlin-side test seam for the finished-nights filter the Swift twin applies inline in Strand/Data/IntelligenceEngine.swift (app layer, outside the governed roots)."
},
{
"type": "platform_specific",
"kind": "add-unpaired-function",
"identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt::loadCacheKey/7#1",
"identity_sha256": "d975e64811f395077839f35c6797284bac55e6ad3e3b5e049bf826d69d5a2131",
"platform": "kotlin",
"rationale": "Twin of the Swift cycle load cache key built inline in Strand/Data/DayCycleIntelligenceIntegration.swift (app layer, outside the governed roots)."
},
{
"type": "platform_specific",
"kind": "add-unpaired-function",
"identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopRepository.kt::hrUnionFingerprint/3#1",
"identity_sha256": "c6879bd0c72d81c059254b85d6826258e1cf3cd97bf81e26bf013559678f415b",
"platform": "kotlin",
"rationale": "Repository witness for the cycle load cache; the Swift side calls WhoopStore.hrFingerprint(deviceId:from:to:) per owner inline in Strand/Data/DayCycleIntelligenceIntegration.swift."
},
{
"type": "platform_specific",
"kind": "add-unpaired-property",
"identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt::cacheKey@property#1",
"identity_sha256": "44346ba83a628dc53e4f7eb6757d3ece1d98c77c612653df659182d5aceeecf0",
"platform": "kotlin",
"rationale": "Twin of the Swift UserProfile.cacheKey, which lives with the Swift UserProfile in Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift; the two types sit in differently named files, so the ledger cannot pair them."
},
{
"type": "platform_specific",
"kind": "add-unpaired-property",
"identity": "swift\u0000Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift::cacheKey@property#1",
"identity_sha256": "5c7b107d76b7c42ea4a0deb3bbd2bd62dc3d5f267b276af2f521502add2c73a2",
"platform": "swift",
"rationale": "Twin of the Kotlin UserProfile.cacheKey in android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt; the two types sit in differently named files, so the ledger cannot pair them."
}
]
}
8 changes: 4 additions & 4 deletions Tools/parity_twin_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@
},
"authority": {
"files": {"count": 502, "sha256": "322bb433fab56fef5ec926d7847faef78e4c64ea409d161184baaf483c8eec95"},
"functions": {"count": 4467, "sha256": "89193c662ac8a26e009ea954a06b1017b8dc0163cc33695a43c0bd7610bbc918"},
"properties": {"count": 458, "sha256": "d654302949fe0cb34f6e43a757e3a47fe485758e8a902c247285c0e288964d45"},
"functions": {"count": 4470, "sha256": "4c19c5b69d287817fda2e2e1e8b91ab5da8305b6febec3b31b6bf41f602761c1"},
"properties": {"count": 460, "sha256": "c7db673bedc58d2708acc8bcb3c56e287718b058f5891ffad7cce2931562dbfe"},
"constants": {"count": 1951, "sha256": "aa7ce58efe6a8d3409abd3ccad24d22889514d4555faca323a0ee751ff7925b5"},
"file_pairs": {"count": 72, "sha256": "d2e92c41a46e76927016cd9254cdc7ac9a2222142f002b063715b6650c402421"},
"function_pairs": {"count": 184, "sha256": "849418e724ed78c7d28d52c5e37be548c05ce02253f661ca4c2470f24ce21bc9"},
"property_pairs": {"count": 148, "sha256": "6fa59982fca9e8e306562b9d027676457c9fa4cc6766c1d6171e86084f3b2c54"},
"constant_pairs": {"count": 678, "sha256": "350d339d5fca3416a600ca96939a8ae8d269201e83ab5b05a8accbc6d3f85aa2"},
"unpaired_files": {"count": 379, "sha256": "c73d98b5389e33803b26a7e4df4e5b2a7e6590e5f5d03b84f225910cfcd4d123"},
"unpaired_functions": {"count": 4110, "sha256": "59be0bc6c3a8228656c330869bdc5641f3b667922b23db6a0a1d334246c0d762"},
"unpaired_properties": {"count": 162, "sha256": "eede893a804203b080f27bb05ca6a09ee9ac9d708ade984e2855813f35b6ae22"},
"unpaired_functions": {"count": 4113, "sha256": "9455f235143adef089d343d45a1b43e10445953b866afb351dfa1ab9b27b4d5b"},
"unpaired_properties": {"count": 164, "sha256": "099b516b8e64cbeab831751571b57a20d29d86b5e8b195f7e097c40c96403618"},
"unpaired_constants": {"count": 595, "sha256": "f065b8e34db78569a84449444539956cb48df105c9b66274398422cf8c520bc7"}
}
}
12 changes: 11 additions & 1 deletion android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,17 @@ data class UserProfile(
* (the body term cancels out of the age formula). Default param so existing call-sites compile.
*/
val waistCm: Double = 0.0,
)
) {
/**
* Every stored field, for a cache key that must change when the profile does (the per-cycle load cache,
* `IntelligenceEngine.loadCacheKey`). Named explicitly rather than read from the generated `toString`,
* which is not a contract. A new field belongs here too. Doubles by bit pattern, so the key is exact.
* Twin of Swift `UserProfile.cacheKey`.
*/
val cacheKey: String
get() = "w=${weightKg.toRawBits()},h=${heightCm.toRawBits()},a=${age.toRawBits()},s=$sex," +
"t=${stepTicksPerStep.toRawBits()},waist=${waistCm.toRawBits()}"
}

// ─────────────────────────────────────────────────────────────────────────────
// Sleep staging output shapes (SleepStager.swift)
Expand Down
Loading
Loading