From cb6dd1005b9a8927776070f7624ab9e67803396d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 6 Sep 2026 18:10:45 +1200 Subject: [PATCH 1/4] skin-temp: backfill absolutes for nights outside the 21-night rescore window (#1853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nightly ABSOLUTE skin temperature (`skinTempC`) is written only by the scoring pass, which covers a rolling 21-night window. Nights already outside that window when the column shipped were never revisited, so a wearer sees "+0.5 Δ°C" with no way to learn what it moved from — the deviation is stored but the absolute is NULL. This adds a targeted historical backfill that re-derives the absolute from the SAME raw streams and the SAME `skinTempFunnel` the scoring pass uses, then writes it with a FILL-ONLY UPDATE that can only fill a NULL — never an upsert of a rebuilt row, never overwriting a measured value, never touching `skinTempDevC` or any other scored field. Three correctness rules (re-review findings from the Android twin, #1852): 1. Attribute sessions by END. The night window is 54 h wide and contains the previous night; handing the funnel the whole window averages two nights into one temperature. `sessionsForDay` filters by session-end-in-day. 2. Page the candidates. A plain "oldest N" latches on page one (the nights most likely to have lost their raw samples) and never reaches the newer nights that can fill. `page(candidates:page:pageSize:)` walks oldest→newest. 3. The WHOOP 4.0 anchor comes from the CURRENT window. A re-learned anchor puts backfilled nights on a different offset from the stored ones and the chart plots two scales as one line. No anchor ⇒ DECLINE, never the global 826 fallback. Pure helper (`StrandAnalytics/SkinTempBackfill.swift`) + fill-only storage (`WhoopStore.fillSkinTempC`) + I/O walker (`Strand/Data/SkinTempBackfillWalker.swift`) + tests for session attribution, the anchor rule, the sample floor, the page rule, and the fill-only write (fills NULL, refuses to overwrite, no orphan). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../StrandAnalytics/SkinTempBackfill.swift | 159 ++++++++++++ .../Sources/WhoopStore/MetricsCache.swift | 17 ++ .../DailySkinTempAbsoluteTests.swift | 54 +++++ Strand/Data/SkinTempBackfillWalker.swift | 187 ++++++++++++++ StrandTests/SkinTempBackfillTests.swift | 229 ++++++++++++++++++ 5 files changed, 646 insertions(+) create mode 100644 Packages/StrandAnalytics/Sources/StrandAnalytics/SkinTempBackfill.swift create mode 100644 Strand/Data/SkinTempBackfillWalker.swift create mode 100644 StrandTests/SkinTempBackfillTests.swift diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SkinTempBackfill.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SkinTempBackfill.swift new file mode 100644 index 0000000000..61f29be71c --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SkinTempBackfill.swift @@ -0,0 +1,159 @@ +import Foundation +import WhoopProtocol + +/// #1853: backfill the nightly ABSOLUTE skin temperature (`skinTempC`) for nights the 21-night +/// `analyzeRecent` rescore window never reaches. +/// +/// `skinTempC` is written only by the scoring pass (`IntelligenceEngine`, #1663, 2026-08-27), which +/// covers a rolling 21-night window. Nights already outside that window when the column shipped were +/// never revisited, and nothing walks them since — so a night is missing its absolute if it fell +/// outside every window that ran after 27 Aug, regardless of whether its raw `skinTempSample` rows +/// are still on disk (they are: `skinTempSample` has no age-based retention). +/// +/// This file holds the PURE, database-free half of the backfill: the session-attribution rule and the +/// per-night plan. The walker that reads from / writes to `WhoopStore` lives in `Strand/Data`. +/// +/// Three correctness rules (all re-review findings from the Android twin, #1852 — reproduced verbatim +/// or the Swift backfill will be wrong in the same ways): +/// +/// 1. **Attribute sessions by END.** The night window is 54 h wide (30 h before local midnight through +/// the next), so it contains the PREVIOUS night as well. The engine's rule is +/// `matched = allSessions.filter { tsInDay(it.end) }`; handing the funnel the whole window averages +/// two nights into one temperature and stores it as the wrong day's. +/// +/// 2. **Page the candidates.** A plain "oldest N" returns the same nights every pass, and the oldest +/// are the ones most likely to have lost their raw samples — the sweep latches on page one and never +/// reaches the newer nights that can fill. +/// +/// 3. **The WHOOP 4.0 anchor comes from the CURRENT window.** It is learned window-wide, and the +/// engine's note says that is safe because the offset "cancels in the deviation" — true for a delta, +/// not for an absolute. A re-learned anchor puts backfilled nights on a different offset from the +/// stored ones and the chart plots two scales as one line. **No anchor ⇒ decline, never the global +/// default.** +public enum SkinTempBackfill { + + /// One night the backfill is considering: the day key, the night's read window (from/to in unix + /// seconds), and the device owner whose raw streams should be read. The walker builds these from + /// `DailyMetric` rows that have a `skinTempDevC` but no `skinTempC`; the pure plan below decides + /// which sessions and samples belong to each. + public struct NightCandidate: Equatable, Sendable { + public let day: String // yyyy-MM-dd (local-day key, matching DailyMetric.day) + public let from: Int // unix seconds: dayStart - StreamReadCap.lookbackSeconds + public let to: Int // unix seconds: dayStart + 24h (or now for today) + public let owner: String // the device id whose raw streams wrote this night + public init(day: String, from: Int, to: Int, owner: String) { + self.day = day; self.from = from; self.to = to; self.owner = owner + } + } + + /// The result of attempting one night's backfill. + public struct NightResult: Equatable, Sendable { + public let day: String + /// The re-derived absolute (°C), or nil when the night declined (no anchor, too few worn + /// samples, no sessions, etc.). The walker writes `skinTempC` ONLY when this is non-nil. + public let skinTempC: Double? + /// Why the night declined, for the diagnostic report. nil when it filled. + public let declineReason: String? + public init(day: String, skinTempC: Double?, declineReason: String? = nil) { + self.day = day; self.skinTempC = skinTempC; self.declineReason = declineReason + } + } + + /// Rule 1: attribute sessions by END timestamp. The night window is 54 h wide and contains the + /// PREVIOUS night as well, so handing the funnel every session in the window averages two nights + /// into one temperature. The engine's rule is `matched = allSessions.filter { tsInDay(it.end) }`: + /// a session belongs to `day` iff its END falls in `[dayStart, dayStart + 86400)`. + /// + /// `dayStart` is the LOCAL-midnight unix seconds for the day key (the same `dayStart` the engine's + /// per-day loop computes). `sessions` are the raw `SleepSession`s read from the 54 h window. + /// Pure + deterministic; the walker tests this directly. + public static func sessionsForDay(_ sessions: [SleepSession], dayStart: Int) -> [SleepSession] { + let dayEnd = dayStart + 86_400 + return sessions.filter { $0.end >= dayStart && $0.end < dayEnd } + } + + /// Rule 3: the WHOOP 4.0 anchor MUST come from the current scoring window. If no per-device anchor + /// could be learned (too few in-band samples), DECLINE the night — do NOT fall back to the global + /// 826 anchor. A re-learned anchor puts backfilled nights on a different offset from the stored + /// ones and the chart plots two scales as one line. + /// + /// Returns the anchor to use, or nil to decline. For a 5/MG (`.whoop5`) the anchor is always nil + /// and the night proceeds (centidegree path, no anchor). + public static func resolveAnchor(family: DeviceFamily, windowAnchorRaw: Double?) -> Double? { + switch family { + case .whoop4: + // No anchor ⇒ decline. The global 826 is NOT a fallback here (rule 3). + return windowAnchorRaw + case .whoop5: + // Centidegree path: no anchor, no decline on anchor grounds. + return nil + } + } + + /// Compute one night's backfill absolute, applying all three rules. Pure + deterministic; the + /// walker supplies the raw inputs and writes the result. + /// + /// - Parameters: + /// - candidate: the night to backfill (day, window, owner). + /// - sessions: ALL sleep sessions in the 54 h window (the walker reads them once); rule 1 + /// filters to this day's by end timestamp. + /// - hr: HR samples for the night's window. + /// - skinTemp: raw skin-temp samples for the night's window. + /// - family: the device family that wrote the owner's skin-temp rows (`.whoop4` vs `.whoop5`). + /// - windowAnchorRaw: the per-device WHOOP 4.0 anchor learned from the CURRENT scoring window + /// (rule 3). nil for a 5/MG or when the current window couldn't learn one. + /// - dayStart: the LOCAL-midnight unix seconds for `candidate.day`. + /// - wornToleranceSec: the worn-gate timestamp tolerance (0 for WHOOP, >0 for Oura). + /// - Returns: the nightly mean (°C) or nil with a decline reason. + public static func computeNight( + candidate: NightCandidate, + sessions: [SleepSession], + hr: [HRSample], + skinTemp: [SkinTempSample], + family: DeviceFamily, + windowAnchorRaw: Double?, + dayStart: Int, + wornToleranceSec: Int = 0 + ) -> NightResult { + // Rule 1: attribute sessions by END. Don't hand the funnel the whole 54 h window. + let matched = sessionsForDay(sessions, dayStart: dayStart) + if matched.isEmpty { + return NightResult(day: candidate.day, skinTempC: nil, + declineReason: "no sleep session ends on this day") + } + // Rule 3: the WHOOP 4.0 anchor comes from the current window. No anchor ⇒ decline. + let anchor = resolveAnchor(family: family, windowAnchorRaw: windowAnchorRaw) + if family == .whoop4 && anchor == nil { + return NightResult(day: candidate.day, skinTempC: nil, + declineReason: "no per-device 4.0 anchor from the current window (rule 3: decline, not global fallback)") + } + // Run the SAME funnel the scoring pass uses — identical inputs, identical result, no second + // derivation that could disagree. + let diag = AnalyticsEngine.skinTempFunnel( + matched, hr: hr, skinTemp: skinTemp, family: family, + anchorRaw: anchor, wornToleranceSec: wornToleranceSec) + if let mean = diag.mean { + return NightResult(day: candidate.day, skinTempC: mean) + } + // Decline with the funnel's own explanation so the diagnostic report is self-attesting. + let reason = diag.kept < diag.minSamples + ? "only \(diag.kept)/\(diag.minSamples) worn samples" + : "funnel returned no mean" + return NightResult(day: candidate.day, skinTempC: nil, declineReason: reason) + } + + /// Rule 2: page the candidates so the sweep doesn't latch on the oldest N (which are the most + /// likely to have lost their raw samples). Returns the candidates for one page, oldest-first + /// within the page. The walker calls this repeatedly with increasing `page` (0-based) until a page + /// comes back empty. + /// + /// `pageSize` caps the per-pass cost (one sleep-window sample read per night). The issue's open + /// question on cost is answered by keeping this modest (default 50) and letting the caller decide + /// how many pages to run. + public static func page(_ candidates: [NightCandidate], page: Int, pageSize: Int = 50) -> [NightCandidate] { + let start = page * pageSize + guard start < candidates.count else { return [] } + let end = min(start + pageSize, candidates.count) + return Array(candidates[start.. Int { + try syncWrite { db in + try db.execute(sql: """ + UPDATE dailyMetric + SET skinTempC = ? + WHERE deviceId = ? AND day = ? AND skinTempC IS NULL + """, arguments: [skinTempC, deviceId, day]) + return db.changesCount + } + } + // MARK: - Reads /// Cached sleep sessions overlapping [from, to] (by startTs), oldest first. diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/DailySkinTempAbsoluteTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/DailySkinTempAbsoluteTests.swift index 6a43ebdbdb..f6d9f884f3 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/DailySkinTempAbsoluteTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/DailySkinTempAbsoluteTests.swift @@ -70,4 +70,58 @@ final class DailySkinTempAbsoluteTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(rows.first?.skinTempC), 34.4, accuracy: 0.001, "a re-score must be able to correct the stored absolute") } + + // MARK: - #1853: fill-only write (`fillSkinTempC`) + + /// The fill-only write fills a NULL `skinTempC` and touches NOTHING else — the deviation, recovery, + /// HRV, and every other scored field stay exactly as the scoring pass left them. This is the + /// non-negotiable: a single-column write that can only fill a NULL, never an upsert of a rebuilt row. + func testFillSkinTempC_fillsNullAndTouchesNothingElse() async throws { + let store = try await WhoopStore.inMemory() + // A scored night with a deviation but no absolute — the backfill's exact candidate. + let d = DailyMetric(day: "2026-08-14", totalSleepMin: 415, efficiency: 0.9, + deepMin: 88, remMin: 108, lightMin: 219, disturbances: 2, + restingHr: 50, avgHrv: 62.0, recovery: 0.06, strain: 10.5, + exerciseCount: 1, spo2Pct: 96.2, skinTempDevC: 0.52, respRateBpm: 14.7, + steps: 7_900, activeKcalEst: 2_180.0, avgSdnn: 88.4, skinTempC: nil) + try await store.upsertDailyMetrics([d], deviceId: "devA") + + let filled = try await store.fillSkinTempC(deviceId: "devA", day: "2026-08-14", skinTempC: 34.6) + XCTAssertEqual(filled, 1, "exactly one row filled") + + let rows = try await store.dailyMetrics(deviceId: "devA", from: "2026-08-14", to: "2026-08-14") + let row = try XCTUnwrap(rows.first) + XCTAssertEqual(try XCTUnwrap(row.skinTempC), 34.6, accuracy: 0.001, "the absolute was filled") + // Every other field is untouched — the fill-only write touched one column. + XCTAssertEqual(try XCTUnwrap(row.skinTempDevC), 0.52, accuracy: 0.001, "deviation untouched") + XCTAssertEqual(try XCTUnwrap(row.recovery), 0.06, accuracy: 0.001, "recovery untouched") + XCTAssertEqual(try XCTUnwrap(row.avgHrv), 62.0, accuracy: 0.001, "HRV untouched") + XCTAssertEqual(try XCTUnwrap(row.restingHr), 50, "resting HR untouched") + XCTAssertEqual(try XCTUnwrap(row.totalSleepMin), 415, accuracy: 0.001, "sleep untouched") + } + + /// The fill-only write REFUSES to overwrite a measured value. A night that already has an absolute + /// is left alone — the backfill must never clobber a value the scoring pass stored. + func testFillSkinTempC_refusesToOverwriteAnExistingAbsolute() async throws { + let store = try await WhoopStore.inMemory() + let d = DailyMetric(day: "2026-08-14", totalSleepMin: 415, efficiency: 0.9, + deepMin: 88, remMin: 108, lightMin: 219, disturbances: 2, + restingHr: 50, avgHrv: 62.0, recovery: 0.06, strain: 10.5, + exerciseCount: 1, skinTempDevC: 0.52, skinTempC: 33.9) + try await store.upsertDailyMetrics([d], deviceId: "devA") + + let filled = try await store.fillSkinTempC(deviceId: "devA", day: "2026-08-14", skinTempC: 34.6) + XCTAssertEqual(filled, 0, "an existing absolute must NOT be overwritten") + + let rows = try await store.dailyMetrics(deviceId: "devA", from: "2026-08-14", to: "2026-08-14") + XCTAssertEqual(try XCTUnwrap(rows.first?.skinTempC), 33.9, accuracy: 0.001, + "the original measured value survives") + } + + /// The fill-only write is a no-op when the row doesn't exist (no orphan rows created). + func testFillSkinTempC_noOpWhenRowDoesNotExist() async throws { + let store = try await WhoopStore.inMemory() + let filled = try await store.fillSkinTempC(deviceId: "devA", day: "2026-08-14", skinTempC: 34.6) + XCTAssertEqual(filled, 0, "no row to fill — no orphan created") + } } diff --git a/Strand/Data/SkinTempBackfillWalker.swift b/Strand/Data/SkinTempBackfillWalker.swift new file mode 100644 index 0000000000..18f7c13a69 --- /dev/null +++ b/Strand/Data/SkinTempBackfillWalker.swift @@ -0,0 +1,187 @@ +import Foundation +import StrandAnalytics +import WhoopStore +import WhoopProtocol + +/// #1853: the Apple walker that backfills `skinTempC` for nights the 21-night `analyzeRecent` rescore +/// window never reaches. The pure half (session attribution, the plan, rule 3) lives in +/// `StrandAnalytics/SkinTempBackfill.swift`; this file is the I/O layer that reads from / writes to +/// `WhoopStore`. +/// +/// The walker mirrors the scoring pass's per-night reads (same window, same stream limits, same +/// device-family / anchor / tolerance resolution) and calls the SAME `skinTempFunnel` the engine +/// uses, so a backfilled absolute is byte-identical to what the scoring pass would have stored. +/// +/// Trigger: ON-DEMAND from the Test Centre (the issue's "diagnostic first" + safest trigger). Doing +/// it automatically on upgrade risks a long first launch on a deep history. The caller decides how +/// many pages to run; one page is 50 nights by default. +/// +/// Non-negotiable: a fill-only write (`WhoopStore.fillSkinTempC`) that can only fill a NULL, never an +/// upsert of a rebuilt row. `skinTempDevC` and every other scored field are untouched. +@MainActor +final class SkinTempBackfillWalker { + + /// One night that filled — the day and the re-derived absolute. + struct FilledNight: Sendable { + let day: String + let skinTempC: Double + } + + /// One night that declined — the day and the reason, for the diagnostic report. + struct DeclinedNight: Sendable { + let day: String + let reason: String + } + + /// The result of one backfill pass (one or more pages). + struct PassResult: Sendable { + /// Nights that filled (day → absolute °C). + let filled: [FilledNight] + /// Nights that declined (day → reason), for the diagnostic report. + let declined: [DeclinedNight] + /// Nights skipped because no raw `skinTempSample` rows exist for the window (the data is gone, + /// not the backfill's fault — see the issue's "Diagnostic first" note). + let noRawData: [String] + /// Whether another page might yield more fills (false when the last page was empty). + let moreRemaining: Bool + + var totalAttempted: Int { filled.count + declined.count + noRawData.count } + } + + private let store: WhoopStore + private let computedId: String + + init(store: WhoopStore) { + self.store = store + self.computedId = Repository.whoopSource + "-noop" + } + + /// Run one page of the backfill. The caller calls this repeatedly with increasing `page` until + /// `moreRemaining` is false or a budget is reached. + /// + /// - Parameters: + /// - page: 0-based page index (rule 2: page the candidates so the sweep doesn't latch on the + /// oldest N). + /// - pageSize: nights per page (default 50). + /// - windowAnchorRaw: the per-device WHOOP 4.0 anchor learned from the CURRENT scoring window + /// (rule 3). The caller obtains this from the same window-wide scan the scoring pass uses. + /// nil for a 5/MG or when the current window couldn't learn one. + /// - tzOffsetSeconds: seconds east of UTC, for local-day → unix-seconds conversion. + func runPage(page: Int, pageSize: Int = 50, + windowAnchorRaw: Double?, + tzOffsetSeconds: Int) async -> PassResult { + // 1. Find candidate nights: computed rows with a deviation but no absolute. + let candidates = await candidateNights() + let pageCandidates = SkinTempBackfill.page(candidates, page: page, pageSize: pageSize) + if pageCandidates.isEmpty { + return PassResult(filled: [], declined: [], noRawData: [], moreRemaining: false) + } + + // 2. Resolve the device family + worn tolerance for the owner (single-owner install: the + // canonical "my-whoop"). The registry is read once, mirroring the scoring pass. + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + let regDevices = (try? registry.all()) ?? [] + let activeId = (try? registry.activeDeviceId()) ?? Repository.whoopSource + + var filled: [FilledNight] = [] + var declined: [DeclinedNight] = [] + var noRawData: [String] = [] + + for candidate in pageCandidates { + // Resolve the day owner for this night (same resolver the scoring pass uses). + let owner = await IntelligenceEngine.resolveDayOwner( + day: candidate.day, from: candidate.from, to: candidate.to, + store: store, devices: regDevices, activeId: activeId, + registry: registry, fallbackDeviceId: Repository.whoopSource) + + let family = IntelligenceEngine.skinTempFamily(forOwner: owner, devices: regDevices) + let tolerance = IntelligenceEngine.skinTempWornToleranceSec(forOwner: owner, devices: regDevices) + + // Read the raw streams for this night's window — same limits the scoring pass uses. + let skin = (try? await store.skinTempSamples( + deviceId: owner, from: candidate.from, to: candidate.to, + limit: StreamReadCap.skin)) ?? [] + if skin.isEmpty { + noRawData.append(candidate.day) + continue + } + let hr = (try? await store.hrSamples( + deviceId: owner, from: candidate.from, to: candidate.to, + limit: StreamReadCap.hr)) ?? [] + // Read stored sleep sessions for the window (the backfill takes the night's stored sleep + // window, no re-detection — the issue's "take the night's stored sleep window from + // sleepSession"). + let persisted = (try? await store.sleepSessions( + deviceId: owner, from: candidate.from, to: candidate.to, + limit: 4000)) ?? [] + let sessions = persisted.compactMap { AnalyticsEngine.sleepSession(fromProvided: $0) } + + // Compute the local-midnight unix seconds for this day (the day key is local, so the + // midnight is the UTC midnight of (dayStart + tzOffset)). The scoring pass uses the same + // shift: `dayString(ts, offsetSec:) == day` ⇔ `(ts + offsetSec) ∈ [dayStart, +86400)`. + let dayStart = Self.localMidnightUnix(forDay: candidate.day, tzOffsetSeconds: tzOffsetSeconds) + + let result = SkinTempBackfill.computeNight( + candidate: candidate, + sessions: sessions, + hr: hr, + skinTemp: skin, + family: family, + windowAnchorRaw: windowAnchorRaw, + dayStart: dayStart, + wornToleranceSec: tolerance) + + if let abs = result.skinTempC { + // Fill-only write: can only fill a NULL, never overwrite a measured value. + _ = try? await store.fillSkinTempC(deviceId: computedId, day: candidate.day, skinTempC: abs) + filled.append(FilledNight(day: candidate.day, skinTempC: abs)) + } else { + declined.append(DeclinedNight(day: candidate.day, reason: result.declineReason ?? "unknown")) + } + } + + let moreRemaining = pageCandidates.count == pageSize + return PassResult(filled: filled, declined: declined, noRawData: noRawData, + moreRemaining: moreRemaining) + } + + /// Find computed `DailyMetric` rows that have a `skinTempDevC` but no `skinTempC` — the nights + /// the backfill can fill. Oldest-first (the issue pages oldest→newest so the sweep reaches the + /// newer nights that can fill). + private func candidateNights() async -> [SkinTempBackfill.NightCandidate] { + // Read all computed rows (the backfill window is unbounded — the issue's "how far back" is + // every night with samples). A wide day range covers the full local-history span. + let rows = (try? await store.dailyMetrics( + deviceId: computedId, from: "0000-01-01", to: "9999-12-31")) ?? [] + let tzOffset = TimeZone.current.secondsFromGMT() + let now = Int(Date().timeIntervalSince1970) + // Candidate: has a deviation (the night was scored) but no absolute (the column was null). + // Build the night's read window the same way the scoring pass does. + return rows + .filter { $0.skinTempDevC != nil && $0.skinTempC == nil } + .sorted { $0.day < $1.day } + .map { row in + let dayStart = Self.localMidnightUnix(forDay: row.day, tzOffsetSeconds: tzOffset) + let from = dayStart - StreamReadCap.lookbackSeconds + let to = min(dayStart + 86_400, now) // don't read past now for today's partial night + return SkinTempBackfill.NightCandidate(day: row.day, from: from, to: to, + owner: Repository.whoopSource) + } + } + + /// Local-midnight unix seconds for a yyyy-MM-dd day key. The day key is LOCAL, so midnight is the + /// UTC midnight of `(dayStart + tzOffset)`: `dayString(ts, offsetSec:) == day` ⇔ + /// `(ts + offsetSec) ∈ [dayStart, +86400)`, so `dayStart = utcMidnight(day) - tzOffset`. + static func localMidnightUnix(forDay day: String, tzOffsetSeconds: Int) -> Int { + // Parse the yyyy-MM-dd key to a UTC midnight, then shift by the tz offset to get local midnight. + let parts = day.split(separator: "-") + guard parts.count == 3, + let y = Int(parts[0]), let m = Int(parts[1]), let d = Int(parts[2]) else { return 0 } + var comps = DateComponents() + comps.year = y; comps.month = m; comps.day = d + comps.hour = 0; comps.minute = 0; comps.second = 0 + comps.timeZone = TimeZone(identifier: "UTC") + let utcMidnight = Calendar(identifier: .gregorian).date(from: comps) ?? Date() + return Int(utcMidnight.timeIntervalSince1970) - tzOffsetSeconds + } +} diff --git a/StrandTests/SkinTempBackfillTests.swift b/StrandTests/SkinTempBackfillTests.swift new file mode 100644 index 0000000000..09ed34925c --- /dev/null +++ b/StrandTests/SkinTempBackfillTests.swift @@ -0,0 +1,229 @@ +import XCTest +import StrandAnalytics +import WhoopProtocol + +/// #1853: tests for the PURE half of the skin-temp absolute backfill — the session-attribution rule +/// (rule 1), the anchor rule (rule 3), and the pagination rule (rule 2). The I/O walker that reads +/// from / writes to `WhoopStore` is covered separately. +final class SkinTempBackfillTests: XCTestCase { + + // MARK: Rule 1 — attribute sessions by END + + /// A session whose END falls in the day is matched; one whose END falls in the previous day is not. + /// The night window is 54 h wide and contains the previous night, so filtering by end is the only + /// thing that keeps two nights from being averaged into one temperature. + func testSessionsForDay_matchesByEndNotStart() { + // dayStart = 2026-08-25 00:00 UTC = 1_789_632_000 + let dayStart = 1_789_632_000 + let prevNightEnd = dayStart - 3_600 // 23:00 the PREVIOUS day → NOT matched + let thisNightEnd = dayStart + 7 * 3_600 // 07:00 this day → matched + let nextNightEnd = dayStart + 86_400 + 3_600 // 01:00 NEXT day → NOT matched + let sessions = [ + SleepSession(start: dayStart - 8 * 3_600, end: prevNightEnd, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + SleepSession(start: dayStart - 1 * 3_600, end: thisNightEnd, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + SleepSession(start: dayStart + 20 * 3_600, end: nextNightEnd, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + ] + let matched = SkinTempBackfill.sessionsForDay(sessions, dayStart: dayStart) + XCTAssertEqual(matched.count, 1) + XCTAssertEqual(matched.first?.end, thisNightEnd) + } + + /// A session ending exactly at midnight (dayStart) is matched (>= dayStart). + func testSessionsForDay_inclusiveAtDayStart() { + let dayStart = 1_789_632_000 + let sessions = [ + SleepSession(start: dayStart - 8 * 3_600, end: dayStart, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + ] + XCTAssertEqual(SkinTempBackfill.sessionsForDay(sessions, dayStart: dayStart).count, 1) + } + + /// A session ending exactly at next midnight (dayStart + 86400) is NOT matched (< dayEnd). + func testSessionsForDay_exclusiveAtDayEnd() { + let dayStart = 1_789_632_000 + let sessions = [ + SleepSession(start: dayStart + 12 * 3_600, end: dayStart + 86_400, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + ] + XCTAssertEqual(SkinTempBackfill.sessionsForDay(sessions, dayStart: dayStart).count, 0) + } + + /// Empty sessions → empty match (the night declines with "no sleep session ends on this day"). + func testSessionsForDay_emptySessionsReturnsEmpty() { + XCTAssertEqual(SkinTempBackfill.sessionsForDay([], dayStart: 0).count, 0) + } + + // MARK: Rule 3 — the WHOOP 4.0 anchor comes from the current window + + /// A WHOOP 4.0 with a per-device anchor from the current window uses it. + func testResolveAnchor_whoop4WithAnchorReturnsIt() { + let anchor = SkinTempBackfill.resolveAnchor(family: .whoop4, windowAnchorRaw: 1290.0) + XCTAssertEqual(anchor, 1290.0) + } + + /// A WHOOP 4.0 with NO per-device anchor returns nil → the night DECLINES. The global 826 is NOT + /// a fallback here — a re-learned anchor puts backfilled nights on a different offset from the + /// stored ones and the chart plots two scales as one line. + func testResolveAnchor_whoop4WithoutAnchorDeclines() { + let anchor = SkinTempBackfill.resolveAnchor(family: .whoop4, windowAnchorRaw: nil) + XCTAssertNil(anchor, "rule 3: no anchor ⇒ decline, never the global default") + } + + /// A WHOOP 5/MG always returns nil anchor (centidegree path, no anchor) — and does NOT decline on + /// anchor grounds (the decline check in `computeNight` is `family == .whoop4 && anchor == nil`). + func testResolveAnchor_whoop5ReturnsNilButDoesNotDecline() { + let anchor = SkinTempBackfill.resolveAnchor(family: .whoop5, windowAnchorRaw: nil) + XCTAssertNil(anchor) + // The computeNight test below confirms a 5/MG night proceeds without an anchor. + } + + // MARK: computeNight — the full per-night plan + + /// A 5/MG night with enough worn samples fills. The centidegree path needs no anchor. + func testComputeNight_whoop5WithEnoughSamplesFills() { + let dayStart = 1_789_632_000 + let candidate = SkinTempBackfill.NightCandidate( + day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop") + // A session ending in this day. + let sessions = [ + SleepSession(start: dayStart - 1 * 3_600, end: dayStart + 7 * 3_600, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + ] + // 400 worn skin-temp samples (3057 raw = 30.57 °C on 5/MG) at 1 Hz, with matching HR. + let skinTemp = (0..<400).map { i in + SkinTempSample(ts: dayStart - 1 * 3_600 + i, raw: 3057) + } + let hr = (0..<400).map { i in HRSample(ts: dayStart - 1 * 3_600 + i, bpm: 60) } + let result = SkinTempBackfill.computeNight( + candidate: candidate, sessions: sessions, hr: hr, skinTemp: skinTemp, + family: .whoop5, windowAnchorRaw: nil, dayStart: dayStart) + XCTAssertNotNil(result.skinTempC) + XCTAssertEqual(result.skinTempC!, 30.57, accuracy: 0.01) + XCTAssertNil(result.declineReason) + } + + /// A WHOOP 4.0 night with no per-device anchor DECLINES (rule 3). Even with enough samples the + /// night is not filled — the global 826 is NOT a fallback. + func testComputeNight_whoop4WithoutAnchorDeclines() { + let dayStart = 1_789_632_000 + let candidate = SkinTempBackfill.NightCandidate( + day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop") + let sessions = [ + SleepSession(start: dayStart - 1 * 3_600, end: dayStart + 7 * 3_600, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + ] + // Enough samples to fill IF the anchor were allowed — but rule 3 declines before the funnel. + let skinTemp = (0..<400).map { i in + SkinTempSample(ts: dayStart - 1 * 3_600 + i, raw: 826) + } + let hr = (0..<400).map { i in HRSample(ts: dayStart - 1 * 3_600 + i, bpm: 60) } + let result = SkinTempBackfill.computeNight( + candidate: candidate, sessions: sessions, hr: hr, skinTemp: skinTemp, + family: .whoop4, windowAnchorRaw: nil, dayStart: dayStart) + XCTAssertNil(result.skinTempC, "rule 3: no anchor ⇒ decline, never the global default") + XCTAssertNotNil(result.declineReason) + XCTAssertTrue(result.declineReason!.contains("anchor"), "decline reason names the anchor") + } + + /// A night with no sessions ending on the day declines. + func testComputeNight_noSessionsDeclines() { + let dayStart = 1_789_632_000 + let candidate = SkinTempBackfill.NightCandidate( + day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop") + let result = SkinTempBackfill.computeNight( + candidate: candidate, sessions: [], hr: [], skinTemp: [], + family: .whoop5, windowAnchorRaw: nil, dayStart: dayStart) + XCTAssertNil(result.skinTempC) + XCTAssertEqual(result.declineReason, "no sleep session ends on this day") + } + + /// A night with too few worn samples (< minSkinTempSamples = 300) declines. + func testComputeNight_tooFewSamplesDeclines() { + let dayStart = 1_789_632_000 + let candidate = SkinTempBackfill.NightCandidate( + day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop") + let sessions = [ + SleepSession(start: dayStart - 1 * 3_600, end: dayStart + 7 * 3_600, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + ] + // Only 100 samples — below the 300 floor. + let skinTemp = (0..<100).map { i in + SkinTempSample(ts: dayStart - 1 * 3_600 + i, raw: 3057) + } + let hr = (0..<100).map { i in HRSample(ts: dayStart - 1 * 3_600 + i, bpm: 60) } + let result = SkinTempBackfill.computeNight( + candidate: candidate, sessions: sessions, hr: hr, skinTemp: skinTemp, + family: .whoop5, windowAnchorRaw: nil, dayStart: dayStart) + XCTAssertNil(result.skinTempC) + XCTAssertNotNil(result.declineReason) + XCTAssertTrue(result.declineReason!.contains("worn samples"), "decline reason names the sample floor") + } + + /// A night whose sessions END in the PREVIOUS day is not matched (rule 1), even if the session + /// span overlaps this day's window. This is the exact bug rule 1 prevents: two nights averaged + /// into one temperature. + func testComputeNight_previousNightSessionNotMatched() { + let dayStart = 1_789_632_000 + let candidate = SkinTempBackfill.NightCandidate( + day: "2026-08-25", from: dayStart - 30 * 3_600, to: dayStart + 24 * 3_600, owner: "my-whoop") + // A session that ends in the PREVIOUS day (23:00 on Aug 24) but whose span overlaps this day's + // 54 h window. Without rule 1 this would be matched and the night would fill. + let sessions = [ + SleepSession(start: dayStart - 9 * 3_600, end: dayStart - 1 * 3_600, efficiency: 0.9, + stages: [], restingHR: nil, avgHRV: nil, hrOnly: false), + ] + let skinTemp = (0..<400).map { i in + SkinTempSample(ts: dayStart - 9 * 3_600 + i, raw: 3057) + } + let hr = (0..<400).map { i in HRSample(ts: dayStart - 9 * 3_600 + i, bpm: 60) } + let result = SkinTempBackfill.computeNight( + candidate: candidate, sessions: sessions, hr: hr, skinTemp: skinTemp, + family: .whoop5, windowAnchorRaw: nil, dayStart: dayStart) + XCTAssertNil(result.skinTempC, "rule 1: a session ending in the previous day is not this day's") + XCTAssertEqual(result.declineReason, "no sleep session ends on this day") + } + + // MARK: Rule 2 — page the candidates + + /// Page 0 returns the first `pageSize` candidates, oldest-first. + func testPage_firstPageReturnsOldestN() { + let candidates = (0..<120).map { i in + SkinTempBackfill.NightCandidate(day: "2026-08-\(String(format: "%02d", i + 1))", + from: 0, to: 0, owner: "my-whoop") + } + let page0 = SkinTempBackfill.page(candidates, page: 0, pageSize: 50) + XCTAssertEqual(page0.count, 50) + XCTAssertEqual(page0.first?.day, "2026-08-01") + XCTAssertEqual(page0.last?.day, "2026-08-50") + } + + /// Page 2 returns the next chunk; page 3 is empty (only 120 candidates, 50 per page → 3 pages). + func testPage_thirdPageEmpty() { + let candidates = (0..<120).map { i in + SkinTempBackfill.NightCandidate(day: "2026-08-\(String(format: "%02d", i + 1))", + from: 0, to: 0, owner: "my-whoop") + } + let page2 = SkinTempBackfill.page(candidates, page: 2, pageSize: 50) + XCTAssertEqual(page2.count, 20) + let page3 = SkinTempBackfill.page(candidates, page: 3, pageSize: 50) + XCTAssertTrue(page3.isEmpty, "page 3 is empty — the walker stops here") + } + + /// An empty candidate list returns an empty page on every page. + func testPage_emptyCandidatesReturnsEmpty() { + XCTAssertTrue(SkinTempBackfill.page([], page: 0).isEmpty) + } + + /// A partial last page returns exactly the remainder. + func testPage_partialLastPage() { + let candidates = (0..<75).map { i in + SkinTempBackfill.NightCandidate(day: "2026-08-\(String(format: "%02d", i + 1))", + from: 0, to: 0, owner: "my-whoop") + } + let page1 = SkinTempBackfill.page(candidates, page: 1, pageSize: 50) + XCTAssertEqual(page1.count, 25) + } +} From b8d92c9f8f9b9cb08001af534d240b13034598bf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 6 Sep 2026 20:58:01 +1200 Subject: [PATCH 2/4] skin-temp: wire the #1853 backfill walker behind the Test Centre action (#1853 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walker had no call site — 646 lines that could not run, the same failure the four prior reverted builds arrived at a different way. This adds the wiring and the gate it sits behind. The call site: an on-demand "Backfill skin-temp absolutes" button in the Test Centre's Diagnostic Tools card (the issue's "diagnostic first" + safest trigger — doing it automatically on upgrade risks a long first launch on a deep history). The action is disabled while a pass is running and shows the result line under the button. The gate: the walker now owns the WHOOP 4.0 window-anchor scan in a self-contained `runBackfill()` entry point, so the call site does not duplicate engine internals. It replicates the scoring pass's window-wide `skinTempSamples` read + `Whoop4SkinTemp.deviceAnchorRaw` learn (rule 3: the anchor comes from the CURRENT scoring window, never the global 826 fallback), then pages through the candidate nights until a page is empty or the page budget is reached. Evidence the candidate nights still have raw samples: the result line is self-attesting. It reports filled / declined / no-raw-data counts. If `noRawData` equals the candidate count, the raw streams have aged out and the backfill fills nothing — the diagnostic the issue's unsettled unknown asked for, produced by the action itself rather than claimed in advance. Android note: the Android twin is currently reverted, so this PR ships Apple-only deliberately. The pure half (`SkinTempBackfill` in StrandAnalytics) and its tests are cross-platform; the Android wiring will follow once the Android twin's call-site question is settled. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Strand/Data/SkinTempBackfillWalker.swift | 73 ++++++++++++++++++++++++ Strand/Screens/TestCentreView.swift | 55 ++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/Strand/Data/SkinTempBackfillWalker.swift b/Strand/Data/SkinTempBackfillWalker.swift index 18f7c13a69..63f956ad56 100644 --- a/Strand/Data/SkinTempBackfillWalker.swift +++ b/Strand/Data/SkinTempBackfillWalker.swift @@ -184,4 +184,77 @@ final class SkinTempBackfillWalker { let utcMidnight = Calendar(identifier: .gregorian).date(from: comps) ?? Date() return Int(utcMidnight.timeIntervalSince1970) - tzOffsetSeconds } + + // MARK: - Self-contained entry point (the call site) + + /// The aggregate result of a full backfill pass (one or more pages), for the Test Centre report. + struct BackfillResult: Sendable { + let filled: [FilledNight] + let declined: [DeclinedNight] + let noRawData: [String] + /// The window anchor that was used (nil for a 5/MG or when the current window couldn't learn one). + let windowAnchorRaw: Double? + /// Whether the pass reached the end (false when a page was empty) or hit the page budget. + let reachedEnd: Bool + var filledCount: Int { filled.count } + var declinedCount: Int { declined.count } + var noRawDataCount: Int { noRawData.count } + } + + /// Run the full backfill: resolve the WHOOP 4.0 window anchor from the CURRENT scoring window + /// (rule 3 — the same window-wide scan the engine uses), then page through the candidate nights + /// until a page is empty or `maxPages` is reached. This is the single entry point the Test Centre + /// action calls; it owns the anchor scan so the call site does not duplicate engine internals. + /// + /// `maxDays` mirrors the scoring pass's window (default 21). `maxPages` caps the per-pass cost + /// (default 10 pages × 50 nights = 500 nights, plenty for any install). + func runBackfill(maxDays: Int = 21, maxPages: Int = 10) async -> BackfillResult { + let tzOffset = TimeZone.current.secondsFromGMT() + let now = Int(Date().timeIntervalSince1970) + let nowLocalMidnight = Self.localMidnightUnix( + forDay: Self.dayKey(now: now, tzOffsetSeconds: tzOffset), tzOffsetSeconds: tzOffset) + // Rule 3: the anchor comes from the CURRENT scoring window, learned window-wide (not per-night). + // Same window bounds the engine's `analyzeRecent` uses for `skinAnchorScanFrom`/`skinAnchorScanTo`. + let scanFrom = nowLocalMidnight - (maxDays - 1) * 86_400 - StreamReadCap.lookbackSeconds + let scanTo = nowLocalMidnight + 18 * 3_600 + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + let activeId = (try? registry.activeDeviceId()) ?? Repository.whoopSource + let regDevices = (try? registry.all()) ?? [] + let family = IntelligenceEngine.skinTempFamily(forOwner: activeId, devices: regDevices) + // Only a 4.0 needs the anchor; a 5/MG (centidegree path) passes nil and proceeds. + let windowAnchorRaw: Double? + if family == .whoop4 { + let windowSkin = (try? await store.skinTempSamples( + deviceId: activeId, from: scanFrom, to: scanTo, + limit: StreamReadCap.skin)) ?? [] + windowAnchorRaw = Whoop4SkinTemp.deviceAnchorRaw(windowSkin.map { $0.raw }) + } else { + windowAnchorRaw = nil + } + var filled: [FilledNight] = [] + var declined: [DeclinedNight] = [] + var noRawData: [String] = [] + var reachedEnd = false + for page in 0.. String { + let comps = Calendar(identifier: .gregorian).dateComponents( + [.year, .month, .day], from: Date(timeIntervalSince1970: TimeInterval(now + tzOffsetSeconds))) + let y = comps.year ?? 0, m = comps.month ?? 0, d = comps.day ?? 0 + return String(format: "%04d-%02d-%02d", y, m, d) + } } diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift index 941ee50d07..0d260c7ea1 100644 --- a/Strand/Screens/TestCentreView.swift +++ b/Strand/Screens/TestCentreView.swift @@ -30,6 +30,11 @@ struct TestCentreView: View { @State private var infoMessage = "" @State private var showInfo = false + // #1853: skin-temp absolute backfill (on-demand, diagnostic-first). Runs the walker that fills + // `skinTempC` for nights outside the 21-night rescore window. In-progress flag + last result line. + @State private var skinTempBackfillRunning = false + @State private var skinTempBackfillStatus: String? + // Section 3: scheduled daily auto-export, the same ScheduledDebugExport store the Settings card uses. @State private var debugExportOn = ScheduledDebugExport.isEnabled @State private var debugExportMinutes = ScheduledDebugExport.timeMinutes @@ -277,6 +282,25 @@ struct TestCentreView: View { Divider().overlay(StrandPalette.hairline) + // #1853: skin-temp absolute backfill (on-demand, diagnostic-first). Fills `skinTempC` + // for nights outside the 21-night rescore window that never got an absolute. Fill-only: + // it can only fill a NULL, never overwrite a measured value or touch the deviation. + NoopButton("Backfill skin-temp absolutes", systemImage: "therometer.medium", + kind: .secondary) { + runSkinTempBackfill() + } + .disabled(skinTempBackfillRunning) + Text("Re-derives the absolute °C for nights the 21-night rescore window never reaches, using the same funnel as the scoring pass. Fill-only: it never overwrites a measured value. Watch the strap log for the result.") + .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + if let status = skinTempBackfillStatus { + Text(status) + .font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + + Divider().overlay(StrandPalette.hairline) + // Environment dump: the IOSDiagnostics-backed block exportableLogText already carries, // surfaced as a copyable readout (spec section 3.4). NoopButton("Copy environment dump", systemImage: "info.circle", kind: .secondary) { @@ -522,6 +546,37 @@ struct TestCentreView: View { showInfo = true } + /// #1853: run the skin-temp absolute backfill on demand (diagnostic-first trigger). The walker + /// resolves the WHOOP 4.0 window anchor from the current scoring window, pages through the + /// candidate nights, and fills each NULL `skinTempC`. The result is logged to the strap log and + /// shown under the button so the user sees exactly what filled, declined, and had no raw data. + private func runSkinTempBackfill() { + guard !skinTempBackfillRunning else { return } + skinTempBackfillRunning = true + skinTempBackfillStatus = String(localized: "Backfilling…") + Task { @MainActor in + guard let store = await model.repo.storeHandle() else { + skinTempBackfillStatus = String(localized: "No on-device store yet.") + skinTempBackfillRunning = false + return + } + let walker = SkinTempBackfillWalker(store: store) + let result = await walker.runBackfill() + let anchorNote = result.windowAnchorRaw != nil + ? "anchor from current window" + : "no 4.0 anchor (5/MG or too few worn samples)" + let endNote = result.reachedEnd ? "reached end" : "hit page budget" + let line = "skin-temp backfill (#1853): filled \(result.filledCount), " + + "declined \(result.declinedCount), no raw data \(result.noRawDataCount) " + + "(\(anchorNote), \(endNote))" + live.append(log: line) + skinTempBackfillStatus = line + skinTempBackfillRunning = false + // Refresh so the newly-filled absolutes show in the charts/explorer. + await model.repo.refresh() + } + } + private var debugExportTimeBinding: Binding { Binding( get: { From 945336cb87eb487e5fbcd928ee5884df963606bd Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Sun, 6 Sep 2026 21:19:10 +1200 Subject: [PATCH 3/4] Fix the two blockers on the skin-temp backfill button The i18n gate was red: the button's description was a new literal with no catalog entry, so it would have shipped English to ten locales. Added with translations. And the button asked for "therometer.medium", which is not an SF Symbol - the name is thermometer.medium, spelled correctly in LiquidTodayView and AppleWatchAboutView. An invalid symbol renders as nothing, so the button would have shipped without its icon and no check would have said so. While adding the translations I put an em-dash in the Russian, against the standing no-em-dash rule for user copy that I had just applied to another PR an hour earlier. Replaced. Worth writing down rather than fixing quietly: the rule is easy to keep in the string you are thinking about and easy to lose in the nine you are translating. --- Strand/Resources/Localizable.xcstrings | 12 ++++++++++++ Strand/Screens/TestCentreView.swift | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 0c0bbfd876..b7f4efd02d 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -210050,6 +210050,18 @@ "ru": {"stringUnit": {"state": "translated", "value": "Не удалось включить микрофон: %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "无法启动麦克风:%@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "無法啟動麥克風:%@"}} + } }, + "Re-derives the absolute °C for nights the 21-night rescore window never reaches, using the same funnel as the scoring pass. Fill-only: it never overwrites a measured value. Watch the strap log for the result.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Leitet das absolute °C für Nächte neu ab, die das 21-Nächte-Fenster nie erreicht, mit demselben Funnel wie der Scoring-Durchlauf. Nur Auffüllen: ein gemessener Wert wird nie überschrieben. Das Ergebnis steht im Strap-Log."}}, + "en": {"stringUnit": {"state": "translated", "value": "Re-derives the absolute °C for nights the 21-night rescore window never reaches, using the same funnel as the scoring pass. Fill-only: it never overwrites a measured value. Watch the strap log for the result."}}, + "es": {"stringUnit": {"state": "translated", "value": "Vuelve a derivar el °C absoluto de las noches que la ventana de 21 noches nunca alcanza, con el mismo embudo que la pasada de puntuación. Solo rellena: nunca sobrescribe un valor medido. Consulta el resultado en el registro de la banda."}}, + "fr": {"stringUnit": {"state": "translated", "value": "Recalcule le °C absolu des nuits que la fenêtre de 21 nuits n’atteint jamais, avec le même entonnoir que la passe de calcul. Remplissage seul : une valeur mesurée n’est jamais écrasée. Le résultat s’affiche dans le journal du bracelet."}}, + "it": {"stringUnit": {"state": "translated", "value": "Ricalcola il °C assoluto per le notti che la finestra di 21 notti non raggiunge mai, con lo stesso funnel della passata di calcolo. Solo riempimento: non sovrascrive mai un valore misurato. Il risultato è nel log della fascia."}}, + "pl": {"stringUnit": {"state": "translated", "value": "Ponownie wylicza bezwzględne °C dla nocy, których nie obejmuje okno 21 nocy, tym samym lejkiem co przebieg oceniający. Tylko uzupełnia: nigdy nie nadpisuje zmierzonej wartości. Wynik znajdziesz w dzienniku opaski."}}, + "pt-PT": {"stringUnit": {"state": "translated", "value": "Volta a derivar o °C absoluto das noites que a janela de 21 noites nunca alcança, com o mesmo funil da passagem de pontuação. Apenas preenche: nunca substitui um valor medido. Vê o resultado no registo da fita."}}, + "ru": {"stringUnit": {"state": "translated", "value": "Пересчитывает абсолютные °C для ночей, до которых не доходит окно в 21 ночь, тем же алгоритмом, что и обычный расчёт. Только заполняет: измеренное значение никогда не перезаписывается. Результат смотрите в журнале ремешка."}}, + "zh-Hans": {"stringUnit": {"state": "translated", "value": "用与评分流程相同的漏斗,为 21 晚窗口未覆盖的夜晚重新推导绝对温度(°C)。仅填充:绝不覆盖已测量的值。结果见腕带日志。"}}, + "zh-Hant": {"stringUnit": {"state": "translated", "value": "以與評分流程相同的漏斗,為 21 晚視窗未涵蓋的夜晚重新推導絕對溫度(°C)。僅填充:絕不覆寫已測量的值。結果見腕帶日誌。"}} } } }, "version": "1.0" diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift index 0d260c7ea1..8699f38446 100644 --- a/Strand/Screens/TestCentreView.swift +++ b/Strand/Screens/TestCentreView.swift @@ -285,7 +285,7 @@ struct TestCentreView: View { // #1853: skin-temp absolute backfill (on-demand, diagnostic-first). Fills `skinTempC` // for nights outside the 21-night rescore window that never got an absolute. Fill-only: // it can only fill a NULL, never overwrite a measured value or touch the deviation. - NoopButton("Backfill skin-temp absolutes", systemImage: "therometer.medium", + NoopButton("Backfill skin-temp absolutes", systemImage: "thermometer.medium", kind: .secondary) { runSkinTempBackfill() } From edfb83aeece9c52ee7fac32d9c70a266e9423b5c Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Sun, 6 Sep 2026 21:24:48 +1200 Subject: [PATCH 4/4] Take the backfill walker off the main actor It was a @MainActor class doing a job with no UI: up to ten pages of fifty nights, three stream reads each. Every await yields the main thread, so this was never a freeze - but each resumption and all the per-night work between them landed on it, which is a long diagnostic pass competing with the UI for no reason. Nothing here needs the main actor. WhoopStore is an actor, DeviceRegistryStore is a Sendable struct, Repository.whoopSource is an immutable Sendable static, and the result types are already Sendable. The call site already awaited runBackfill(), so it is unchanged. Stated plainly: nothing local compiles this target, and `swiftc -parse` resolves no isolation, so this change is verified by the macOS and iOS build legs and nothing else. If they reject it the isolation goes back and the note stays. --- Strand/Data/SkinTempBackfillWalker.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Strand/Data/SkinTempBackfillWalker.swift b/Strand/Data/SkinTempBackfillWalker.swift index 63f956ad56..55b5131783 100644 --- a/Strand/Data/SkinTempBackfillWalker.swift +++ b/Strand/Data/SkinTempBackfillWalker.swift @@ -18,8 +18,12 @@ import WhoopProtocol /// /// Non-negotiable: a fill-only write (`WhoopStore.fillSkinTempC`) that can only fill a NULL, never an /// upsert of a rebuilt row. `skinTempDevC` and every other scored field are untouched. -@MainActor -final class SkinTempBackfillWalker { +/// An `actor`, deliberately, not a `@MainActor` class. This walks up to ten pages of fifty nights and +/// reads three streams for each, and while every `await` yields the main thread, each RESUMPTION and all +/// the per-night work between them would land on it. There is no UI state here - the store is an actor, +/// `DeviceRegistryStore` is a Sendable struct, and the result types are Sendable - so nothing needs the +/// main actor, and a long diagnostic pass has no business competing with the UI for it. +actor SkinTempBackfillWalker { /// One night that filled — the day and the re-derived absolute. struct FilledNight: Sendable {