diff --git a/android/app/src/main/java/com/noop/analytics/SkinTempBackfill.kt b/android/app/src/main/java/com/noop/analytics/SkinTempBackfill.kt index 82900efc15..1facb7f3e2 100644 --- a/android/app/src/main/java/com/noop/analytics/SkinTempBackfill.kt +++ b/android/app/src/main/java/com/noop/analytics/SkinTempBackfill.kt @@ -163,6 +163,20 @@ object SkinTempBackfill { avgHRV = null, ) + /** + * The COMPUTED namespace a scored row lives in — `-noop`, matching the engine's + * `computedId = importedDeviceId + "-noop"`. + * + * This backfill straddles two namespaces and querying one for both finds nothing at all: + * - dailyMetric rows and sleepSession rows are written by the engine under the COMPUTED id; + * - raw skinTempSample / hrSample rows are banked under the STRAP id ("raw rows sit under the strap + * id and the ... computed rows under the -noop id"). + * + * Idempotent on an id that is already computed, mirroring the repository's own `ownerComputed` idiom. + */ + fun computedIdFor(deviceId: String): String = + if (deviceId.endsWith("-noop")) deviceId else "$deviceId-noop" + /** One PAGE of candidate day keys, strictly after [afterDay]. See the DAO's note on why this pages. */ suspend fun candidates( repo: WhoopRepository, @@ -183,6 +197,7 @@ object SkinTempBackfill { */ suspend fun run( repo: WhoopRepository, + /** The STRAP id. Row reads/writes use its computed twin; raw sample reads use it directly. */ deviceId: String, family: DeviceFamily, currentAnchorRaw: Double?, @@ -197,7 +212,8 @@ object SkinTempBackfill { return Report(declinedNoAnchor = true) } val anchor = anchorFor(family, currentAnchorRaw) - val days = candidates(repo, deviceId, afterDay, max) + val rowId = computedIdFor(deviceId) + val days = candidates(repo, rowId, afterDay, max) if (days.isEmpty()) return Report(sweepComplete = true) val nowLocalMidnight = java.time.Instant.ofEpochSecond(nowSeconds) @@ -214,7 +230,7 @@ object SkinTempBackfill { // Sessions FIRST, and attributed to this day, so the reads below cover one night rather than // the 54 h window — which also shrinks the HR read, the expensive one, from ~54 h to ~8 h. val sessions = sessionsEndingOnDay( - repo.sleepSessionsForDevice(deviceId, window.first, window.last, limit) + repo.sleepSessionsForDevice(rowId, window.first, window.last, limit) // Honour a user-edited start the same way every other surface does. .map { (it.startTsAdjusted ?: it.startTs) to it.endTs }, dayStartLocal = dayStart, @@ -229,7 +245,7 @@ object SkinTempBackfill { val mean = nightlyAbsolute(sessions, hr, skin, family, anchor, wornToleranceSec) if (mean == null) { noMean++; continue } // Fill-only by construction; a row that gained an absolute meanwhile reports 0 and is left be. - if (repo.fillSkinTempAbsolute(deviceId, day, mean) > 0) filled++ else noMean++ + if (repo.fillSkinTempAbsolute(rowId, day, mean) > 0) filled++ else noMean++ } return Report( candidates = days.size, filled = filled, noMean = noMean, diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index add701864c..c4cc6ebf67 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -738,7 +738,11 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { */ private suspend fun backfillSkinTempAbsolutes(deviceId: String) { val prefs = NoopPrefs.of(appContext) - val outstanding = repository.countDaysMissingSkinTempAbsolute(deviceId) + // Scored rows live in the COMPUTED namespace; raw samples under the strap id. Counting the strap + // id here found nothing at all, so the sweep looked finished before it began. + val outstanding = repository.countDaysMissingSkinTempAbsolute( + com.noop.analytics.SkinTempBackfill.computedIdFor(deviceId), + ) if (outstanding == 0) return if (prefs.getInt(skinTempBackfillStuckKey, -1) == outstanding) return @@ -1281,15 +1285,6 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // own cancellation — rethrow it so onCleared() actually stops the loop. (#125) }.onSuccess { NoopPrefs.setAnalyzeWatermark(appContext, analyzeFp) - // #1851: fill the skin-temp absolutes the 21-night window never reaches. Runs AFTER a - // successful pass so it never competes with scoring, and independently of the #1846 - // display preference — the data must be there whichever way the setting is left, so - // flipping it is instant rather than kicking off work. - // - // Once per install (a prefs flag), because the nights it fills stay filled; a later - // night that misses out is picked up by the scoring pass that owns it. - runCatching { backfillSkinTempAbsolutes(deviceId) } - .onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } // #1735: the watermark says WHAT was scored, never WHEN. "re-score: done" goes only to // the live log, so hours later the rolling buffer has dropped it and an export cannot // tell a pass that ran from one that never did - which is half of "my ride still is not @@ -1300,6 +1295,18 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { } } .onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } + // #1854: OUTSIDE the `analyzeHasNewData` gate on purpose. + // + // This was inside the scoring pass's onSuccess, which only runs when the fingerprint moved + // — so a feature whose entire job is reprocessing OLD nights never ran on the installs that + // need it most: a strap that has not synced has no new data, so no pass, so no backfill. + // The one install this was built for sat with five temperatures and never attempted the + // rest. + // + // Safe to run on every tick: it costs one COUNT when there is nothing outstanding, and the + // fruitless watermark stops it paying for reads once a whole sweep has come back empty. + runCatching { backfillSkinTempAbsolutes(deviceId) } + .onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } // Opt-in writeback: push the freshly computed nights into Health Connect so other // apps see them. Idempotent (clientRecordId per metric+day), so re-running every // cycle just upserts. Never let an HC hiccup (perm revoked mid-flight, provider diff --git a/android/app/src/test/java/com/noop/analytics/SkinTempBackfillTest.kt b/android/app/src/test/java/com/noop/analytics/SkinTempBackfillTest.kt index 5e8058ee4c..b97ff028c0 100644 --- a/android/app/src/test/java/com/noop/analytics/SkinTempBackfillTest.kt +++ b/android/app/src/test/java/com/noop/analytics/SkinTempBackfillTest.kt @@ -217,4 +217,26 @@ class SkinTempBackfillTest { val r = SkinTempBackfill.Report(candidates = 2, filled = 1, lastDay = "2026-08-11") assertEquals("2026-08-11", r.lastDay) } + + // MARK: re-review 4 — the backfill straddles TWO device namespaces + + /** + * Querying one namespace for both finds nothing at all, and silently: the sweep reports zero + * outstanding and looks finished before it has begun. + * + * The engine writes scored rows under `computedId = importedDeviceId + "-noop"` — dailyMetric AND + * sleepSession — while raw skinTempSample / hrSample rows are banked under the STRAP id. The first cut + * used the strap id for all four, so it found no candidates and no sessions. + */ + @Test + fun scoredRowsLiveInTheComputedNamespace() { + assertEquals("my-whoop-noop", SkinTempBackfill.computedIdFor("my-whoop")) + } + + /** Idempotent on an id that is already computed, mirroring the repository's own ownerComputed idiom — + * otherwise a re-entry would look for "…-noop-noop" and, again, find nothing. */ + @Test + fun theComputedIdIsIdempotent() { + assertEquals("my-whoop-noop", SkinTempBackfill.computedIdFor("my-whoop-noop")) + } } \ No newline at end of file