diff --git a/android/app/src/main/java/com/noop/analytics/SkinTempBackfill.kt b/android/app/src/main/java/com/noop/analytics/SkinTempBackfill.kt new file mode 100644 index 0000000000..82900efc15 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/SkinTempBackfill.kt @@ -0,0 +1,248 @@ +package com.noop.analytics + +import com.noop.data.WhoopRepository +import com.noop.protocol.DeviceFamily +import com.noop.data.HrSample +import com.noop.data.SkinTempSample +import com.noop.protocol.Whoop4SkinTemp + +/** + * #1851: fill in the nightly ABSOLUTE skin temperature for nights that only ever stored a deviation. + * + * `skinTempC` shipped on 2026-08-27 (#1663) and is written by the scoring pass, which walks a rolling + * 21-night window. Nights already outside that window when the column landed were never revisited, so an + * install can hold weeks of deviations and only a handful of temperatures — which is what the Temperature + * setting (#1846) then has to work with. + * + * The raw material is still there: `skinTempSample` has no age-based retention, and the only DELETE is the + * #547 plausibility quarantine, which removes rows OUTSIDE a valid timestamp window rather than old ones. + * + * ## This RE-DERIVES, it does not reconstruct + * + * `absolute = baseline + deviation` is available arithmetic and is wrong: the baseline is a rolling value + * that drifts over weeks, so applying one night's baseline to another night's delta manufactures numbers + * that read as measurements. This instead re-runs [AnalyticsEngine.skinTempFunnel] — the SAME function the + * engine uses, over the same night window — so a backfilled night and a scored night are computed by one + * code path and cannot disagree. + * + * ## It cannot destroy data + * + * Two independent guarantees, because this writes to rows the user cannot regenerate: + * - the write is a single-column UPDATE (`fillSkinTempAbsolute`), never an entity upsert that would blank + * columns it did not carry; + * - that statement carries `AND skinTempC IS NULL`, so it can only fill a hole. A value the engine or an + * import already wrote is never overwritten, and no other column is touched. + * + * Nothing here nulls anything, and a night that yields no mean is simply left alone. + * + * ## The WHOOP 4.0 anchor + * + * On a 4.0 the raw→°C conversion is anchored per device (#938), and that anchor is learned window-wide. + * The engine's own note says the offset is safe because it "cancels in the deviation" — true for a delta, + * NOT for an absolute. Re-learning an anchor from the old window being filled would put those nights on a + * different offset from the ones already stored, and the chart would plot two scales as one line: both + * labelled °C, both plausible, silently wrong. + * + * So the caller passes the anchor the engine is currently using. When a 4.0 has none resolvable, the + * backfill declines that device rather than writing on a guessed offset. WHOOP 5/MG carries no anchor + * (`raw / 100`) and is unaffected. + */ +object SkinTempBackfill { + + /** + * Nights per run — deliberately small. + * + * Each fillable night costs an HR read, and the engine's own note calls those "the big ~86k-row ones". + * A years-deep history in one pass would be exactly the #836/#841 battery shape this feature exists + * beside, so the work drains across successive scoring passes instead. Nights with no skin samples are + * skipped BEFORE their HR read, so the unfillable ones stay cheap. + */ + const val DEFAULT_MAX_NIGHTS = 60 + + /** + * What a run did. `filled` is nights given a temperature; `noMean` is nights whose raw samples exist + * but did not survive the worn gate or the minimum-sample floor; `noSamples` is nights with nothing to + * re-derive from. The three are reported separately because they need different answers — the last one + * is unrecoverable and should not be presented as "try again". + */ + data class Report( + val candidates: Int = 0, + val filled: Int = 0, + val noMean: Int = 0, + val noSamples: Int = 0, + /** Nights with no stored sleep session ending on them — nothing to compute a nightly mean over. */ + val noSessions: Int = 0, + /** The newest day this page examined; the caller's cursor for the next page. Empty when none. */ + val lastDay: String = "", + /** True when the page came back short — every outstanding night has now been visited this sweep. */ + val sweepComplete: Boolean = false, + val declinedNoAnchor: Boolean = false, + ) { + val examined: Int get() = filled + noMean + noSamples + noSessions + } + + /** + * Whether this device can be backfilled at all, and on what anchor. + * + * Returns null when a WHOOP 4.0 has no resolved anchor — see the anchor note above. A null result is a + * DECLINE, never "use the global default": the global 826 anchor maps a second real strap's worn band + * to 47–72 °C, so writing on it would bank temperatures that are not just imprecise but outside human + * range, into a column nothing downstream re-checks. + */ + fun anchorFor(family: DeviceFamily, currentAnchorRaw: Double?): Double? = when (family) { + DeviceFamily.WHOOP5 -> Whoop4SkinTemp.ANCHOR_RAW // ignored by the 5/MG conversion; any value is inert + DeviceFamily.WHOOP4 -> currentAnchorRaw + } + + /** True when [family] needs a per-device anchor before any absolute may be written. */ + fun requiresAnchor(family: DeviceFamily): Boolean = family == DeviceFamily.WHOOP4 + + /** + * The night window for a day, byte-identical to the engine's: 30 h before local midnight (a night that + * began the previous evening) through [readWindowEnd]. Taking the same bounds is what keeps a + * backfilled night's sample set the same one the scoring pass would have used. + */ + fun nightWindow(dayStartLocal: Long, readWindowEnd: Long): LongRange = + (dayStartLocal - NIGHT_LOOKBACK_SECONDS)..readWindowEnd + + /** 30 h, matching `IntelligenceEngine`'s `from = dayStart - 30 * 3_600L`. */ + const val NIGHT_LOOKBACK_SECONDS: Long = 30 * 3_600L + + /** + * Re-derive one night's absolute from its raw samples, or null when the funnel keeps nothing. + * + * Sessions come from the STORED `sleepSession` rows rather than fresh detection — they are that + * detection's own persisted output, so the same nights are covered without paying to re-detect them. + * A night edited or deleted since is the one case where the two could differ, and there the stored row + * is the more truthful input anyway: it is what the rest of the app already shows. + */ + fun nightlyAbsolute( + sessions: List, + hr: List, + skinTemp: List, + family: DeviceFamily, + anchorRaw: Double?, + wornToleranceSec: Long, + ): Double? = AnalyticsEngine.skinTempFunnel( + sessions = sessions, + hr = hr, + skinTemp = skinTemp, + family = family, + anchorRaw = anchorRaw, + wornToleranceSec = wornToleranceSec, + ).mean + + /** + * The sessions belonging to a day, by the engine's own rule (#277): + * + * val matched = allSessions.filter { tsInDay(it.end) } + * // Sessions attributed to `day` = those whose end falls on `day` (LOCAL day) + * + * The night window is 54 h wide — 30 h before local midnight through the next one — because a night + * belonging to day D starts on the evening of D-1. That width also catches the night belonging to + * D-1, so handing the funnel everything the window returned would average two nights into one + * temperature and store it as D's. Attribute by END, exactly as the engine does, or the backfill + * writes numbers that are wrong rather than merely absent. + */ + fun sessionsEndingOnDay( + sessions: List>, + dayStartLocal: Long, + dayEndExclusive: Long, + ): List = + sessions.filter { (_, end) -> end >= dayStartLocal && end < dayEndExclusive } + .map { (start, end) -> sessionOf(start, end) } + + /** A stored sleep row as the funnel's session type. Only `start`/`end` are read; the rest is inert. */ + fun sessionOf(startTs: Long, endTs: Long): DetectedSleep = + DetectedSleep( + start = startTs, + end = endTs, + efficiency = 0.0, + stages = emptyList(), + restingHR = null, + avgHRV = null, + ) + + /** One PAGE of candidate day keys, strictly after [afterDay]. See the DAO's note on why this pages. */ + suspend fun candidates( + repo: WhoopRepository, + deviceId: String, + afterDay: String, + max: Int = DEFAULT_MAX_NIGHTS, + ): List = repo.daysMissingSkinTempAbsolute(deviceId, afterDay, max) + + /** + * Walk the candidate nights and fill what can be re-derived. + * + * [currentAnchorRaw] must be the anchor the engine is using NOW, not one learned from the window being + * filled — see the anchor note on this object. A 4.0 without one is declined outright. + * + * Reads one night at a time rather than one big span: the deep-history case is exactly where a single + * unbounded read would be worst, and a per-night read is also what lets a cancelled run leave every + * night it already filled correctly filled. + */ + suspend fun run( + repo: WhoopRepository, + deviceId: String, + family: DeviceFamily, + currentAnchorRaw: Double?, + wornToleranceSec: Long, + zone: java.time.ZoneId = java.time.ZoneId.systemDefault(), + nowSeconds: Long = System.currentTimeMillis() / 1000, + afterDay: String = "", + max: Int = DEFAULT_MAX_NIGHTS, + limit: Int = STREAM_LIMIT, + ): Report { + if (requiresAnchor(family) && currentAnchorRaw == null) { + return Report(declinedNoAnchor = true) + } + val anchor = anchorFor(family, currentAnchorRaw) + val days = candidates(repo, deviceId, afterDay, max) + if (days.isEmpty()) return Report(sweepComplete = true) + + val nowLocalMidnight = java.time.Instant.ofEpochSecond(nowSeconds) + .atZone(zone).toLocalDate().atStartOfDay(zone).toEpochSecond() + var filled = 0 + var noMean = 0 + var noSamples = 0 + var noSessions = 0 + for (day in days) { + val dayStart = runCatching { + java.time.LocalDate.parse(day).atStartOfDay(zone).toEpochSecond() + }.getOrNull() ?: continue + val window = nightWindow(dayStart, IntelligenceEngine.sleepReadWindowEnd(dayStart, nowLocalMidnight, nowSeconds)) + // 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) + // Honour a user-edited start the same way every other surface does. + .map { (it.startTsAdjusted ?: it.startTs) to it.endTs }, + dayStartLocal = dayStart, + dayEndExclusive = dayStart + SECONDS_PER_DAY, + ) + if (sessions.isEmpty()) { noSessions++; continue } + val readFrom = sessions.minOf { it.start } + val readTo = sessions.maxOf { it.end } + val skin = repo.skinTempSamples(deviceId, readFrom, readTo, limit) + if (skin.isEmpty()) { noSamples++; continue } + val hr = repo.hrSamplesForDevice(deviceId, readFrom, readTo, limit) + 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++ + } + return Report( + candidates = days.size, filled = filled, noMean = noMean, + noSamples = noSamples, noSessions = noSessions, + lastDay = days.last(), + // A short page means this sweep has seen every outstanding night. + sweepComplete = days.size < max, + ) + } + + /** Per-night read cap, matching the engine's stream limit. */ + const val STREAM_LIMIT = 200_000 + + /** Seconds in a local day, for the end-of-day bound the engine attributes sessions by. */ + const val SECONDS_PER_DAY: Long = 86_400L +} diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index f0aaa38755..c08ceb2b4d 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -163,6 +163,47 @@ interface WhoopDao : DeviceRegistryDao { @Upsert suspend fun upsertDailyMetrics(rows: List) + /** + * #1851: the day keys that have a skin-temp DEVIATION but no ABSOLUTE beside it — the nights the + * 21-night `analyzeRecent` window never revisited after `skinTempC` shipped (2026-08-27). + * + * PAGED on [afterDay], not just capped. A plain "oldest N" returns the SAME nights every pass, so a + * run whose first page cannot fill — and the oldest nights are exactly the ones most likely to have + * lost their raw samples — would latch on that page and never reach the newer nights that CAN fill. + * The caller advances a cursor, so one sweep visits every candidate exactly once. + */ + @Query( + "SELECT day FROM dailyMetric WHERE deviceId = :deviceId " + + "AND skinTempC IS NULL AND skinTempDevC IS NOT NULL AND day > :afterDay " + + "ORDER BY day ASC LIMIT :limit" + ) + suspend fun daysMissingSkinTempAbsolute(deviceId: String, afterDay: String, limit: Int): List + + /** #1851: how many nights are outstanding in total — the uncapped count the "nothing left to try" + * watermark keys on, so a newly imported night re-arms the sweep. */ + @Query( + "SELECT COUNT(*) FROM dailyMetric WHERE deviceId = :deviceId " + + "AND skinTempC IS NULL AND skinTempDevC IS NOT NULL" + ) + suspend fun countDaysMissingSkinTempAbsolute(deviceId: String): Int + + /** + * #1851: fill ONE night's absolute, and only when it is absent. + * + * A targeted UPDATE, deliberately not an `@Upsert` of a rebuilt entity: an upsert writes every column, + * so a row assembled from partial data would silently blank whatever it did not carry. This touches + * exactly one column on one row. + * + * `AND skinTempC IS NULL` is the second half of that guarantee — the statement can only ever fill a + * hole, never overwrite a value the engine or an import already wrote. Returns rows changed, so a + * caller can tell "filled" from "already had one" without reading first. + */ + @Query( + "UPDATE dailyMetric SET skinTempC = :celsius " + + "WHERE deviceId = :deviceId AND day = :day AND skinTempC IS NULL" + ) + suspend fun fillSkinTempAbsolute(deviceId: String, day: String, celsius: Double): Int + @Upsert suspend fun upsertSleepSessions(rows: List) 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 3e7af70938..a9a5b53d8b 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1175,6 +1175,22 @@ class WhoopRepository( suspend fun spo2Samples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.spo2Samples(deviceId, from, to, limit) + /** #1851: nights holding a skin-temp deviation but no absolute, oldest first. See [SkinTempBackfill]. */ + suspend fun daysMissingSkinTempAbsolute(deviceId: String, afterDay: String, limit: Int): List = + dao.daysMissingSkinTempAbsolute(deviceId, afterDay, limit) + + /** #1851: total outstanding nights, uncapped. See [daysMissingSkinTempAbsolute]. */ + suspend fun countDaysMissingSkinTempAbsolute(deviceId: String): Int = + dao.countDaysMissingSkinTempAbsolute(deviceId) + + /** + * #1851: fill ONE night's absolute, only where none exists. Returns rows changed (0 = already had one). + * The statement itself carries the guarantee — a single-column UPDATE with `AND skinTempC IS NULL`, so + * this can never overwrite a value or blank a neighbouring column. + */ + suspend fun fillSkinTempAbsolute(deviceId: String, day: String, celsius: Double): Int = + dao.fillSkinTempAbsolute(deviceId, day, celsius) + suspend fun skinTempSamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.skinTempSamples(deviceId, from, to, limit) 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 1da04890bc..add701864c 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -698,6 +698,99 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { var todayVitalityCache: Double? = null var todayVo2maxCache: Double? = null + /** + * #1851 sweep state. A CURSOR, not a "done" flag. + * + * The candidate query is paged, because a plain "oldest N" returns the same nights every pass — and + * the oldest nights are exactly the ones most likely to have lost their raw samples. A page that + * cannot fill would otherwise latch, and every newer night that CAN fill would never be reached. + */ + private val skinTempBackfillCursorKey = "skinTemp.backfillCursor" + + /** + * The outstanding count at which a COMPLETE sweep filled nothing, so later passes can stop paying for + * reads that have already been proved fruitless. Keyed on the uncapped count, so a night arriving + * later — an import brings in old history — changes it and re-arms the sweep. + */ + private val skinTempBackfillStuckKey = "skinTemp.backfillStuckAt" + + /** Fills accumulated across the CURRENT sweep, so "the whole sweep found nothing" is distinguishable + * from "this page found nothing". */ + private val skinTempBackfillSweepFillsKey = "skinTemp.backfillSweepFills" + + /** + * #1851: re-derive the nightly ABSOLUTE skin temperature for nights that only ever stored a deviation. + * + * Runs after a successful scoring pass and independently of the #1846 display preference — the data + * must be there whichever way the setting is left, so switching between Temperature and vs baseline is + * instant rather than kicking off work. + * + * One PAGE per pass ([SkinTempBackfill.DEFAULT_MAX_NIGHTS]), advancing a cursor, so a deep history + * drains over successive passes instead of turning one pass into the #836/#841 battery shape. When a + * page comes back short the sweep has seen everything: the cursor resets, and if the whole sweep filled + * nothing the outstanding count is recorded so identical counts are skipped thereafter. + * + * The WHOOP 4.0 anchor is resolved from the CURRENT scan window, the same span the engine learns its + * own anchor over — never from the old window being filled. An absolute is offset-sensitive, so nights + * written on a re-learned anchor would sit on a different scale from the ones already stored and the + * chart would plot two scales as one line. A 4.0 with no resolvable anchor is declined by the runner, + * and a decline records no progress, so it retries once an anchor exists. + */ + private suspend fun backfillSkinTempAbsolutes(deviceId: String) { + val prefs = NoopPrefs.of(appContext) + val outstanding = repository.countDaysMissingSkinTempAbsolute(deviceId) + if (outstanding == 0) return + if (prefs.getInt(skinTempBackfillStuckKey, -1) == outstanding) return + + val ownerSource = RegistryDayOwnerSource(noopApp.deviceRegistry) + val family = ownerSource.skinTempFamily(deviceId) + val tolerance = ownerSource.skinTempWornToleranceSec(deviceId) + val now = System.currentTimeMillis() / 1000 + val anchor = if (family == com.noop.protocol.DeviceFamily.WHOOP4) { + // Same span the engine learns its anchor over, NOT the window being filled. + val scanFrom = now - 21L * 24 * 3_600 + val windowSkin = repository.skinTempSamples( + deviceId, scanFrom, now, com.noop.analytics.SkinTempBackfill.STREAM_LIMIT, + ) + com.noop.protocol.Whoop4SkinTemp.deviceAnchorRaw(windowSkin.map { it.raw }) + } else { + null + } + val cursor = prefs.getString(skinTempBackfillCursorKey, "") ?: "" + val report = com.noop.analytics.SkinTempBackfill.run( + repo = repository, + deviceId = deviceId, + family = family, + currentAnchorRaw = anchor, + wornToleranceSec = tolerance, + nowSeconds = now, + afterDay = cursor, + ) + // A decline records nothing: a 4.0 whose anchor resolves later must still get its backfill. + if (!report.declinedNoAnchor) { + val sweepFills = prefs.getInt(skinTempBackfillSweepFillsKey, 0) + report.filled + val edit = prefs.edit() + if (report.sweepComplete) { + // Every outstanding night has been visited. Start the next sweep from the top, and only + // stop paying for reads when the WHOLE sweep — not just its last page — found nothing. + edit.remove(skinTempBackfillCursorKey).remove(skinTempBackfillSweepFillsKey) + if (sweepFills == 0) edit.putInt(skinTempBackfillStuckKey, outstanding) + } else { + edit.putString(skinTempBackfillCursorKey, report.lastDay) + .putInt(skinTempBackfillSweepFillsKey, sweepFills) + } + if (report.filled > 0) edit.remove(skinTempBackfillStuckKey) + edit.apply() + } + ble.externalLog( + "skinTempBackfill: page=${report.candidates} filled=${report.filled} noMean=${report.noMean} " + + "noSamples=${report.noSamples} noSessions=${report.noSessions} " + + "outstanding=$outstanding sweepComplete=${report.sweepComplete} " + + "declined=${report.declinedNoAnchor}", + com.noop.testcentre.TestDomain.UNIVERSAL, + ) + } + /** * Recent daily metrics (newest last), backing the Today grid + illness watch. * MERGED: imported "my-whoop" rows win per day; on-device computed "my-whoop-noop" @@ -1188,6 +1281,15 @@ 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 @@ -2961,6 +3063,9 @@ enum class DoubleTapAction { } companion object { + /** #1851 one-shot guard: the absolutes backfill has completed for this install. */ + const val skinTempBackfillDoneKey = "skinTemp.backfillDone" + /** Decode a persisted name back to an action; tolerant of an unknown/blank value (→ [NONE]). */ fun fromRaw(raw: String?): DoubleTapAction = entries.firstOrNull { it.name == raw } ?: NONE diff --git a/android/app/src/test/java/com/noop/analytics/SkinTempBackfillTest.kt b/android/app/src/test/java/com/noop/analytics/SkinTempBackfillTest.kt new file mode 100644 index 0000000000..5e8058ee4c --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/SkinTempBackfillTest.kt @@ -0,0 +1,220 @@ +package com.noop.analytics + +import com.noop.protocol.DeviceFamily +import com.noop.protocol.Whoop4SkinTemp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * #1851: re-deriving a night's absolute skin temperature from raw samples the app still holds. + * + * The parts worth pinning are the ones that could corrupt data rather than merely fail: the WHOOP 4.0 + * anchor decline, and that the night window matches the engine's. + */ +class SkinTempBackfillTest { + + /** + * A 4.0 with no resolved anchor must be DECLINED, never written on the global default. + * + * That default (826) maps a second real strap's worn band to 47-72 °C — outside human range, into a + * column nothing downstream re-checks. Declining leaves the nights as deviations, which is the honest + * state; writing would bank fiction that looks like a reading. + */ + @Test + fun aWhoop4WithoutAnAnchorIsDeclined() { + assertTrue(SkinTempBackfill.requiresAnchor(DeviceFamily.WHOOP4)) + assertNull(SkinTempBackfill.anchorFor(DeviceFamily.WHOOP4, currentAnchorRaw = null)) + } + + /** A 4.0 WITH a resolved anchor uses exactly that one — never re-learned from the window being filled, + * which would put backfilled nights on a different offset from the ones already stored. */ + @Test + fun aWhoop4UsesTheAnchorItIsGiven() { + assertEquals(1290.0, SkinTempBackfill.anchorFor(DeviceFamily.WHOOP4, 1290.0)) + // Notably NOT the global default, which is what a re-learn would have fallen back to. + assertTrue(1290.0 != Whoop4SkinTemp.ANCHOR_RAW) + } + + /** 5/MG converts with raw/100 and carries no anchor, so it is never gated on one. */ + @Test + fun whoop5NeedsNoAnchor() { + assertFalse(SkinTempBackfill.requiresAnchor(DeviceFamily.WHOOP5)) + assertNotNull(SkinTempBackfill.anchorFor(DeviceFamily.WHOOP5, currentAnchorRaw = null)) + } + + /** The night window must match the engine's `from = dayStart - 30h`, or a backfilled night would be + * computed over a different sample set from the one the scoring pass would have used. */ + @Test + fun theNightWindowMatchesTheEngines() { + val dayStart = 1_760_000_000L + val end = dayStart + 86_400 + val w = SkinTempBackfill.nightWindow(dayStart, end) + assertEquals(dayStart - 30 * 3_600L, w.first) + assertEquals(end, w.last) + assertEquals(30 * 3_600L, SkinTempBackfill.NIGHT_LOOKBACK_SECONDS) + } + + /** A stored sleep row becomes a session carrying only its bounds — the funnel reads nothing else, and + * inventing efficiency/stages here would be fabricating inputs the backfill does not have. */ + @Test + fun aStoredRowBecomesABoundsOnlySession() { + val s = SkinTempBackfill.sessionOf(startTs = 100L, endTs = 200L) + assertEquals(100L, s.start) + assertEquals(200L, s.end) + assertTrue(s.stages.isEmpty()) + assertNull(s.restingHR) + assertNull(s.avgHRV) + } + + /** No samples means no temperature — the backfill must never synthesise one from the deviation. */ + @Test + fun noSamplesYieldsNoAbsolute() { + val mean = SkinTempBackfill.nightlyAbsolute( + sessions = listOf(SkinTempBackfill.sessionOf(0L, 1_000L)), + hr = emptyList(), + skinTemp = emptyList(), + family = DeviceFamily.WHOOP5, + anchorRaw = null, + wornToleranceSec = 0, + ) + assertNull(mean) + } + + /** The report separates recoverable from unrecoverable, because they need different answers: a night + * with no banked samples will never fill, however many times the backfill is re-run. */ + @Test + fun theReportDistinguishesWhyANightDidNotFill() { + val r = SkinTempBackfill.Report(candidates = 10, filled = 4, noMean = 2, noSamples = 4) + assertEquals(10, r.examined) + val declined = SkinTempBackfill.Report(declinedNoAnchor = true) + assertEquals(0, declined.examined) + assertTrue(declined.declinedNoAnchor) + } + + // MARK: re-review — the run must drain, and must never strand nights that arrive later + + /** + * The per-run cap is small on purpose: each FILLABLE night costs a large HR read, so a years-deep + * history in one pass is the #836/#841 battery shape. The work drains across scoring passes instead. + */ + @Test + fun theRunIsCappedSmallEnoughToDrainAcrossPasses() { + assertTrue("a cap this large would be a one-pass battery event", + SkinTempBackfill.DEFAULT_MAX_NIGHTS <= 100) + assertTrue(SkinTempBackfill.DEFAULT_MAX_NIGHTS > 0) + } + + /** + * A decline must not look like progress. The caller only records a "fruitless" watermark when the run + * actually examined nights and filled none — a 4.0 declined for a missing anchor examined nothing, so + * it must retry once an anchor exists rather than being written off. + */ + @Test + fun aDeclineIsDistinguishableFromAFruitlessRun() { + val declined = SkinTempBackfill.Report(declinedNoAnchor = true) + val fruitless = SkinTempBackfill.Report(candidates = 5, noSamples = 5) + assertTrue(declined.declinedNoAnchor) + assertFalse(fruitless.declinedNoAnchor) + assertEquals(0, declined.filled) + assertEquals(0, fruitless.filled) + // The two differ in whether anything was looked at, which is what the caller keys on. + assertEquals(0, declined.examined) + assertEquals(5, fruitless.examined) + } + + // MARK: re-review 2 — the 54 h window holds TWO nights, and only one belongs to the day + + /** + * The bug this pins would have written WRONG temperatures rather than none. + * + * The night window runs 30 h before local midnight to the next one, because a night belonging to day D + * starts on the evening of D-1. At 54 h wide it also contains the night belonging to D-1, so handing + * the funnel everything the window returned averaged two nights into one value and stored it as D's. + * + * The engine attributes by END (#277): `matched = allSessions.filter { tsInDay(it.end) }`. + */ + @Test + fun onlyTheNightEndingOnThatDayIsUsed() { + val dayStart = 1_760_000_000L + val dayEnd = dayStart + SkinTempBackfill.SECONDS_PER_DAY + val previousNight = (dayStart - 26 * 3_600L) to (dayStart - 18 * 3_600L) // ends on D-1 + val thisNight = (dayStart - 2 * 3_600L) to (dayStart + 6 * 3_600L) // ends on D + val nextNight = (dayEnd - 2 * 3_600L) to (dayEnd + 6 * 3_600L) // ends on D+1 + + val matched = SkinTempBackfill.sessionsEndingOnDay( + listOf(previousNight, thisNight, nextNight), dayStart, dayEnd, + ) + assertEquals(1, matched.size) + assertEquals(thisNight.first, matched.single().start) + assertEquals(thisNight.second, matched.single().end) + } + + /** A daytime nap ending on the same day counts too — the engine keeps both in `matched`. */ + @Test + fun aNapEndingOnTheSameDayIsIncluded() { + val dayStart = 1_760_000_000L + val dayEnd = dayStart + SkinTempBackfill.SECONDS_PER_DAY + val night = (dayStart - 2 * 3_600L) to (dayStart + 6 * 3_600L) + val nap = (dayStart + 14 * 3_600L) to (dayStart + 15 * 3_600L) + assertEquals(2, SkinTempBackfill.sessionsEndingOnDay(listOf(night, nap), dayStart, dayEnd).size) + } + + /** The day bound is [start, end) — a session ending exactly at the next midnight belongs to the NEXT + * day, matching a local-day bucket that cannot claim the same instant twice. */ + @Test + fun theDayBoundIsHalfOpen() { + val dayStart = 1_760_000_000L + val dayEnd = dayStart + SkinTempBackfill.SECONDS_PER_DAY + assertEquals(1, SkinTempBackfill.sessionsEndingOnDay(listOf(0L to dayStart), dayStart, dayEnd).size) + assertEquals(0, SkinTempBackfill.sessionsEndingOnDay(listOf(0L to dayEnd), dayStart, dayEnd).size) + } + + /** "No session for this night" is not "no samples" — they are different answers to the user, so the + * report keeps them apart. */ + @Test + fun theReportSeparatesMissingSessionsFromMissingSamples() { + val r = SkinTempBackfill.Report(candidates = 6, filled = 1, noMean = 1, noSamples = 2, noSessions = 2) + assertEquals(6, r.examined) + } + + // MARK: re-review 3 — a sweep must reach every night, not re-try the same page forever + + /** + * The bug this pins is silent and permanent. + * + * The candidate query used to be a plain "oldest N", so every pass got the SAME nights — and the + * oldest nights are precisely the ones most likely to have lost their raw samples. A first page that + * could not fill latched the fruitless watermark, and every NEWER night that could have filled was + * never reached. The user would see a handful of temperatures and no reason why. + * + * Paging means a short page — fewer than the cap — is the signal that the sweep has seen everything. + */ + @Test + fun aShortPageMeansTheSweepHasSeenEverything() { + val full = SkinTempBackfill.Report(candidates = SkinTempBackfill.DEFAULT_MAX_NIGHTS, sweepComplete = false) + assertFalse("a full page cannot be the end of a sweep", full.sweepComplete) + val short = SkinTempBackfill.Report(candidates = 3, sweepComplete = true) + assertTrue(short.sweepComplete) + } + + /** An empty page ends the sweep too — nothing outstanding after the cursor. */ + @Test + fun anEmptyPageEndsTheSweep() { + val empty = SkinTempBackfill.Report(sweepComplete = true) + assertTrue(empty.sweepComplete) + assertEquals("", empty.lastDay) + assertEquals(0, empty.examined) + } + + /** The cursor is the newest day the page examined, so the next page starts strictly after it and no + * night is visited twice within one sweep. */ + @Test + fun theCursorIsThePagesNewestDay() { + val r = SkinTempBackfill.Report(candidates = 2, filled = 1, lastDay = "2026-08-11") + assertEquals("2026-08-11", r.lastDay) + } +} \ No newline at end of file