From 99f4c0b7244eade5aeb1747a9211b62f5a438492 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Thu, 17 Sep 2026 10:51:08 +0300 Subject: [PATCH 1/6] perf(rescore): learn sleep habits from finished nights so tonight's sync stops dropping the day cache --- Strand/Data/IntelligenceEngine.swift | 17 ++++++--- .../HabitualSleepFinishedNightsTests.swift | 36 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 StrandTests/HabitualSleepFinishedNightsTests.swift diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index d657e2a979..17aaa6fa28 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -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 @@ -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 @@ -3354,9 +3356,16 @@ 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. + 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)) ?? [] @@ -3368,7 +3377,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: diff --git a/StrandTests/HabitualSleepFinishedNightsTests.swift b/StrandTests/HabitualSleepFinishedNightsTests.swift new file mode 100644 index 0000000000..a2b0d57b28 --- /dev/null +++ b/StrandTests/HabitualSleepFinishedNightsTests.swift @@ -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) + } +} From f8cd2331acefa3069984f2ca8e9a23fa46dc4b1b Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Thu, 17 Sep 2026 10:51:08 +0300 Subject: [PATCH 2/6] perf(rescore): reuse a closed sleep-to-sleep cycle's Effort and calories instead of re-reading its heart rate every pass --- .../DayCycleIntelligenceIntegration.swift | 55 ++++++++++++++----- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/Strand/Data/DayCycleIntelligenceIntegration.swift b/Strand/Data/DayCycleIntelligenceIntegration.swift index 386e805c45..b4e55d493e 100644 --- a/Strand/Data/DayCycleIntelligenceIntegration.swift +++ b/Strand/Data/DayCycleIntelligenceIntegration.swift @@ -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" } @@ -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 { @@ -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)" + 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)" } From 4b5941bf5aab67df48068f9ec9772895b0096898 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Sat, 19 Sep 2026 10:51:59 +0300 Subject: [PATCH 3/6] perf(rescore): Android twin: learn sleep habits from finished nights, and reuse a closed cycle's Effort and calories --- Tools/parity_dispositions.json | 32 ++++++++++++++ .../com/noop/analytics/IntelligenceEngine.kt | 17 +++++++- .../analytics/PhysiologicalStepCycleEngine.kt | 43 +++++++++++++++---- .../java/com/noop/data/WhoopRepository.kt | 10 +++++ .../analytics/RescoreUnchangedInputsTest.kt | 31 +++++++++++++ 5 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 android/app/src/test/java/com/noop/analytics/RescoreUnchangedInputsTest.kt diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 3dcf98fd10..8d19d2fa65 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -24,6 +24,38 @@ "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." } ] } diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 0831a654ef..80da9fbc9c 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -778,7 +778,8 @@ object IntelligenceEngine { // the Sleep tab resolve to the identical block. Mirrors Swift. (#547) val (habitualMidsleepSec, nightlyHours) = computeHabitualSleep( repo, importedDeviceId, computedId, - nowLocalMidnight - maxDays * SECONDS_PER_DAY - StreamReadCap.LOOKBACK_SECONDS, nowSeconds, tzOffsetSeconds, + nowLocalMidnight - maxDays * SECONDS_PER_DAY - StreamReadCap.LOOKBACK_SECONDS, nowSeconds, + finishedBefore = nowLocalMidnight, offsetSec = tzOffsetSeconds, ) // 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 @@ -2521,12 +2522,24 @@ object IntelligenceEngine { * byte-identical to before; the nightly-hours output is the extension. Mirrors Swift * `IntelligenceEngine.computeHabitualSleep`. */ + /** The sessions the sleep habits may learn from: those that ended before [before]. */ + internal fun finishedSessions(sessions: List, before: Long): List = + sessions.filter { it.endTs < before } + + /** + * 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 the day-cache signature changed and every pass re-scored the whole window. + * A night still being slept joins the history the day after. Twin of the Swift + * `computeHabitualSleep(finishedBefore:)`. + */ private suspend fun computeHabitualSleep( repo: WhoopRepository, importedId: String, computedId: String, windowStart: Long, windowEnd: Long, + finishedBefore: Long, offsetSec: Long, ): Pair> { val imported = repo.sleepSessionsForDevice(importedId, windowStart, windowEnd, 4000) @@ -2537,7 +2550,7 @@ object IntelligenceEngine { // 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). Mirrors Swift. - val merged = SleepSessionDedup.dedupe(imported + computed).kept + val merged = finishedSessions(SleepSessionDedup.dedupe(imported + computed).kept, 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: diff --git a/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt b/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt index 82c8fc3066..205c5a3e9f 100644 --- a/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt @@ -32,6 +32,23 @@ internal object PhysiologicalStepCycleEngine { /** Process-local and serialized by IntelligenceEngine's analyze gate. */ private val cache = HashMap() + /** A cycle's Effort and calories, with the key of the inputs they were computed from. */ + private data class CachedLoad(val key: String, val strain: Double?, val calories: Double?) + + /** Same lifetime and serialization as [cache]. */ + private val loadCache = HashMap() + + /** + * The inputs a cycle's Effort and calories depend on. Every pass re-read each cycle's full day of heart + * rate and re-scored it for all 21 cycles, although only the open one gains samples between syncs; the + * index-only HR witness ([WhoopRepository.hrUnionFingerprint]) lets a closed cycle be scored once. Twin of + * the Swift `DayCycleIntelligenceIntegration` load cache. + */ + internal fun loadCacheKey( + onset: Long, endExclusive: Long, hrWitness: String, restingHr: Double, maxHr: Double?, + effortMethod: StrainScorer.Method, profile: UserProfile, + ): String = "$onset-$endExclusive|$hrWitness|rhr=$restingHr|max=${maxHr ?: "nil"}|$effortMethod|$profile" + suspend fun compute( scoredNights: List, editedRows: List, @@ -191,24 +208,34 @@ internal object PhysiologicalStepCycleEngine { val windows = PhysiologicalSteps.cycleWindows(boundaries, nowSeconds) val allDetectedSleep = sleepContext.distinctBy { it.start to it.end } cache.keys.retainAll(windows.mapTo(HashSet()) { it.sleepId }) + loadCache.keys.retainAll(windows.mapTo(HashSet()) { it.sleepId }) for (window in windows) { val wakeDay = dayBySleepId[window.sleepId] ?: continue val fallbackOwner = ownerBySleepId[window.sleepId] ?: continue // DAO ranges are inclusive. Keep adjacent physiological cycles disjoint. - val cycleHr = repo.hrSamplesUnion( - fallbackOwner, window.onset, window.endExclusive - 1L, 200_000, - ) val restingHr = scoredNights.firstOrNull { it.daily.day == wakeDay }?.daily?.restingHr?.toDouble() ?: StrainScorer.defaultRestingHR val effectiveMaxHr = maxHROverride ?: profile.age.takeIf { it > 0 }?.let { StrainScorer.tanakaHRmax(it.toDouble()) } - StrainScorer.strain(cycleHr, effectiveMaxHr, restingHr, effortMethod, profile.sex) - ?.let { strainByWakeDay[wakeDay] = it } - if (cycleHr.isNotEmpty()) { - caloriesByWakeDay[wakeDay] = Calories.estimateDayCalories( - cycleHr, profile, effectiveMaxHr, restingHr, + val loadKey = loadCacheKey( + window.onset, window.endExclusive, + repo.hrUnionFingerprint(fallbackOwner, window.onset, window.endExclusive - 1L), + restingHr, effectiveMaxHr, effortMethod, profile, + ) + val load = loadCache[window.sleepId]?.takeIf { it.key == loadKey } ?: run { + val cycleHr = repo.hrSamplesUnion( + fallbackOwner, window.onset, window.endExclusive - 1L, 200_000, ) + CachedLoad( + key = loadKey, + strain = StrainScorer.strain(cycleHr, effectiveMaxHr, restingHr, effortMethod, profile.sex), + calories = if (cycleHr.isNotEmpty()) { + Calories.estimateDayCalories(cycleHr, profile, effectiveMaxHr, restingHr) + } else null, + ).also { loadCache[window.sleepId] = it } } + load.strain?.let { strainByWakeDay[wakeDay] = it } + load.calories?.let { caloriesByWakeDay[wakeDay] = it } // Count the persisted all-source workout union, not only analyzer-detected bouts. Repository // reads are inclusive, while ownership is [onset, nextOnset). val workoutEndInclusive = window.endExclusive - 1L diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index 387ca29788..75de8b1a2a 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1164,6 +1164,16 @@ class WhoopRepository( * series). Reading the union surfaces the re-added strap's live data AND the canonical import history. * A single-WHOOP install resolves [activeDeviceId] to "my-whoop" ⇒ ONE id ⇒ byte-identical read. */ + /** Count and newest timestamp of measured HR per source [hrSamplesUnion] reads, as one string: an + * index-only witness of whether a window's heart rate changed, without fetching a row. */ + suspend fun hrUnionFingerprint(activeDeviceId: String, from: Long, to: Long): String { + val parts = ArrayList() + for (id in rawWhoopSourceIds(activeDeviceId)) { + parts += "$id=${dao.countHrInWindow(id, from, to)}:${dao.maxHrTsInWindow(id, from, to)}" + } + return parts.joinToString(",") + } + suspend fun hrSamplesUnion(activeDeviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): List = mergeHrByTs(rawWhoopSourceIds(activeDeviceId).map { dao.hrSamples(it, from, to, limit) }) diff --git a/android/app/src/test/java/com/noop/analytics/RescoreUnchangedInputsTest.kt b/android/app/src/test/java/com/noop/analytics/RescoreUnchangedInputsTest.kt new file mode 100644 index 0000000000..45d9555088 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/RescoreUnchangedInputsTest.kt @@ -0,0 +1,31 @@ +package com.noop.analytics + +import com.noop.data.SleepSession +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +/** + * A post-sync pass must not redo work whose inputs did not change: tonight's growing session is not learned + * from (so it cannot drop the day cache), and a closed cycle's Effort/calories key only moves with its inputs. + */ +class RescoreUnchangedInputsTest { + private val midnight = 1_789_603_200L + + @Test + fun tonightsGrowingSessionIsNotLearnedFrom() { + val lastNight = SleepSession(deviceId = "my-whoop-noop", startTs = midnight - 86_400 + 3_600, endTs = midnight - 86_400 + 30_600) + val tonight = SleepSession(deviceId = "my-whoop-noop", startTs = midnight + 1_800, endTs = midnight + 34_200) + assertEquals(listOf(lastNight), IntelligenceEngine.finishedSessions(listOf(lastNight, tonight), midnight)) + } + + @Test + fun theCycleLoadKeyMovesOnlyWithItsInputs() { + val profile = UserProfile() + fun key(witness: String, rhr: Double = 55.0) = PhysiologicalStepCycleEngine.loadCacheKey( + 1_000L, 87_400L, witness, rhr, 192.6, StrainScorer.Method.EDWARDS, profile) + assertEquals(key("my-whoop=86000:87399"), key("my-whoop=86000:87399")) + assertNotEquals(key("my-whoop=86000:87399"), key("my-whoop=86001:87399")) + assertNotEquals(key("my-whoop=86000:87399"), key("my-whoop=86000:87399", rhr = 56.0)) + } +} From 1c6c654a861e00f997b9e4bf09c59685b37af1d2 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Sat, 19 Sep 2026 11:19:22 +0300 Subject: [PATCH 4/6] docs: keep the Kotlin twin's doc comments attached --- .../com/noop/analytics/IntelligenceEngine.kt | 14 ++++++------- .../java/com/noop/data/WhoopRepository.kt | 20 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 80da9fbc9c..06ef62225c 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -2514,6 +2514,10 @@ object IntelligenceEngine { return samples } + /** The sessions the sleep habits may learn from: those that ended before [before]. */ + internal fun finishedSessions(sessions: List, before: Long): List = + sessions.filter { it.endTs < before } + /** * Habitual midsleep (local seconds) AND the trailing per-night sleep DURATIONS (hours, * chronological) from the stored sessions over the window — the longest block per LOCAL day, so @@ -2521,17 +2525,11 @@ object IntelligenceEngine { * 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 extension. Mirrors Swift * `IntelligenceEngine.computeHabitualSleep`. - */ - /** The sessions the sleep habits may learn from: those that ended before [before]. */ - internal fun finishedSessions(sessions: List, before: Long): List = - sessions.filter { it.endTs < before } - - /** + * * 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 the day-cache signature changed and every pass re-scored the whole window. - * A night still being slept joins the history the day after. Twin of the Swift - * `computeHabitualSleep(finishedBefore:)`. + * A night still being slept joins the history the day after (`computeHabitualSleep(finishedBefore:)`). */ private suspend fun computeHabitualSleep( repo: WhoopRepository, diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index 75de8b1a2a..4ecb79e8d1 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1154,16 +1154,6 @@ class WhoopRepository( if (deviceIds.isEmpty()) emptyList() else mergeHrByTs(deviceIds.map { dao.hrSamples(it, from, to, limit) }) - /** - * HR samples over every registered WHOOP plus canonical "my-whoop", deduped by timestamp with the - * active strap winning and archived straps retained for historical windows. - * - * #908: a strap re-added through the in-app device manager banks its LIVE raw under its OWN fresh id - * (e.g. "whoop-"), NOT "my-whoop". A Today-curve / live-Effort read pinned to the hardcoded - * "my-whoop" then finds NOTHING and the day looks frozen (and Effort integrates to 0 off an empty - * series). Reading the union surfaces the re-added strap's live data AND the canonical import history. - * A single-WHOOP install resolves [activeDeviceId] to "my-whoop" ⇒ ONE id ⇒ byte-identical read. - */ /** Count and newest timestamp of measured HR per source [hrSamplesUnion] reads, as one string: an * index-only witness of whether a window's heart rate changed, without fetching a row. */ suspend fun hrUnionFingerprint(activeDeviceId: String, from: Long, to: Long): String { @@ -1174,6 +1164,16 @@ class WhoopRepository( return parts.joinToString(",") } + /** + * HR samples over every registered WHOOP plus canonical "my-whoop", deduped by timestamp with the + * active strap winning and archived straps retained for historical windows. + * + * #908: a strap re-added through the in-app device manager banks its LIVE raw under its OWN fresh id + * (e.g. "whoop-"), NOT "my-whoop". A Today-curve / live-Effort read pinned to the hardcoded + * "my-whoop" then finds NOTHING and the day looks frozen (and Effort integrates to 0 off an empty + * series). Reading the union surfaces the re-added strap's live data AND the canonical import history. + * A single-WHOOP install resolves [activeDeviceId] to "my-whoop" ⇒ ONE id ⇒ byte-identical read. + */ suspend fun hrSamplesUnion(activeDeviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): List = mergeHrByTs(rawWhoopSourceIds(activeDeviceId).map { dao.hrSamples(it, from, to, limit) }) From 61407963e7d0854c90d544b138d8a26be00b7b45 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Mon, 21 Sep 2026 13:10:15 +0300 Subject: [PATCH 5/6] rescore: key the cycle load cache on the profile's fields, not its description String(describing:) of the struct was value-sensitive but its format is not a contract and it ran reflection per cycle per pass. UserProfile.cacheKey names every stored field (bit patterns for doubles) on both platforms, with a test per field. --- .../StrandAnalytics/WorkoutDetector.swift | 8 ++++++++ .../UserProfileCacheKeyTests.swift | 18 ++++++++++++++++++ .../Data/DayCycleIntelligenceIntegration.swift | 2 +- .../java/com/noop/analytics/AnalyticsModels.kt | 12 +++++++++++- .../analytics/PhysiologicalStepCycleEngine.kt | 2 +- .../analytics/RescoreUnchangedInputsTest.kt | 12 ++++++++++++ 6 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/UserProfileCacheKeyTests.swift diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift index 1029bb5a05..97aee8e826 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WorkoutDetector.swift @@ -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. diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/UserProfileCacheKeyTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/UserProfileCacheKeyTests.swift new file mode 100644 index 0000000000..e6bb68c515 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/UserProfileCacheKeyTests.swift @@ -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) + } +} diff --git a/Strand/Data/DayCycleIntelligenceIntegration.swift b/Strand/Data/DayCycleIntelligenceIntegration.swift index b4e55d493e..b7a922925a 100644 --- a/Strand/Data/DayCycleIntelligenceIntegration.swift +++ b/Strand/Data/DayCycleIntelligenceIntegration.swift @@ -214,7 +214,7 @@ import WhoopStore } } let loadKey = "\(window.onset)-\(window.endExclusive)|\(hrWitness.joined(separator: ","))" - + "|rhr=\(restingHR)|max=\(effectiveMaxHR.map { "\($0)" } ?? "nil")|\(effortMethod)|\(profile)" + + "|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 diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt index f3d7fa5927..db7e9ca727 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt @@ -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) diff --git a/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt b/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt index 205c5a3e9f..b017af3bb1 100644 --- a/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/PhysiologicalStepCycleEngine.kt @@ -47,7 +47,7 @@ internal object PhysiologicalStepCycleEngine { internal fun loadCacheKey( onset: Long, endExclusive: Long, hrWitness: String, restingHr: Double, maxHr: Double?, effortMethod: StrainScorer.Method, profile: UserProfile, - ): String = "$onset-$endExclusive|$hrWitness|rhr=$restingHr|max=${maxHr ?: "nil"}|$effortMethod|$profile" + ): String = "$onset-$endExclusive|$hrWitness|rhr=$restingHr|max=${maxHr ?: "nil"}|$effortMethod|${profile.cacheKey}" suspend fun compute( scoredNights: List, diff --git a/android/app/src/test/java/com/noop/analytics/RescoreUnchangedInputsTest.kt b/android/app/src/test/java/com/noop/analytics/RescoreUnchangedInputsTest.kt index 45d9555088..f42cf87956 100644 --- a/android/app/src/test/java/com/noop/analytics/RescoreUnchangedInputsTest.kt +++ b/android/app/src/test/java/com/noop/analytics/RescoreUnchangedInputsTest.kt @@ -28,4 +28,16 @@ class RescoreUnchangedInputsTest { assertNotEquals(key("my-whoop=86000:87399"), key("my-whoop=86001:87399")) assertNotEquals(key("my-whoop=86000:87399"), key("my-whoop=86000:87399", rhr = 56.0)) } + + /** Each profile field moves the key: calories read weight, height, age and sex. */ + @Test + fun everyProfileFieldMovesTheLoadKey() { + val base = UserProfile() + fun key(p: UserProfile) = PhysiologicalStepCycleEngine.loadCacheKey( + 1_000L, 87_400L, "my-whoop=1:2", 55.0, 192.6, StrainScorer.Method.EDWARDS, p) + listOf(base.copy(weightKg = 71.0), base.copy(heightCm = 171.0), base.copy(age = 31.0), + base.copy(sex = "male"), base.copy(stepTicksPerStep = 2.0), base.copy(waistCm = 80.0)) + .forEach { assertNotEquals(key(base), key(it)) } + assertEquals(key(base), key(base.copy())) + } } From 0085da5d2aae62439425a24bee43e592d3c2ce10 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Mon, 21 Sep 2026 13:18:41 +0300 Subject: [PATCH 6/6] chore(parity): refresh derived snapshots; note why computeHabitualSleep is internal --- Strand/Data/IntelligenceEngine.swift | 2 ++ Tools/parity_dispositions.json | 16 ++++++++++++++++ Tools/parity_twin_map.json | 8 ++++---- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index 17aaa6fa28..d49b740c39 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -3363,6 +3363,8 @@ final class IntelligenceEngine: ObservableObject { /// 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, finishedBefore: Int, offsetSec: Int diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 8d19d2fa65..dad0a024bd 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -56,6 +56,22 @@ "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." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 25481c080f..0828e5490d 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -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"} } }