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
22 changes: 19 additions & 3 deletions android/app/src/main/java/com/noop/analytics/SkinTempBackfill.kt
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,20 @@ object SkinTempBackfill {
avgHRV = null,
)

/**
* The COMPUTED namespace a scored row lives in — `<strap>-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,
Expand All @@ -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?,
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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,
Expand Down
27 changes: 17 additions & 10 deletions android/app/src/main/java/com/noop/ui/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
}
Loading