diff --git a/Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift b/Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift index a69cd79337..e135b30450 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift @@ -43,6 +43,31 @@ extension WhoopStore { } } + /// Whether the strict WHOOP 5 read policy withheld this exact window solely because it contains + /// unlabelled, non-quarantined legacy rows and no verified scoring transport. This is deliberately + /// narrower than "the R-R read was empty": an unworn night, WHOOP 4, another brand, and a labelled + /// but insufficient transport all return false and therefore remain ordinary current-score outcomes. + public func legacyWhoop5RRWithheld(deviceId: String, from: Int, to: Int, + unlabelledAliasOfWhoop5: Bool = false) async throws -> Bool { + try syncRead { db in + guard try Self.isWhoop5RRSource(db: db, deviceId: deviceId, + unlabelledAliasOfWhoop5: unlabelledAliasOfWhoop5) else { + return false + } + return try Bool.fetchOne(db, sql: """ + SELECT + EXISTS(SELECT 1 FROM rrInterval + WHERE deviceId = :d AND ts >= :f AND ts <= :t + AND srcChannel IS NULL + AND (tsSuspect IS NULL OR tsSuspect <> 1)) + AND NOT EXISTS(SELECT 1 FROM rrInterval + WHERE deviceId = :d AND ts >= :f AND ts <= :t + AND srcChannel IN \(Self.scorableWhoop5Channels) + AND (tsSuspect IS NULL OR tsSuspect <> 1)) + """, arguments: ["d": deviceId, "f": from, "t": to]) ?? false + } + } + /// Shared by RR reads and consumers whose cached/union reads must obey the same owner policy. public func isWhoop5RRSource(deviceId: String, unlabelledAliasOfWhoop5: Bool = false) async throws -> Bool { try syncRead { try Self.isWhoop5RRSource(db: $0, deviceId: deviceId, diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RRStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RRStoreTests.swift index 278e5aaa8f..c5ff1f53cd 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RRStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RRStoreTests.swift @@ -72,6 +72,9 @@ final class Whoop5RRStoreTests: XCTestCase { let guarded = try await store.rrIntervals(deviceId: "my-whoop", from: 0, to: 1000, limit: 100, unlabelledAliasOfWhoop5: true) XCTAssertTrue(guarded.isEmpty) + let withheld = try await store.legacyWhoop5RRWithheld( + deviceId: "my-whoop", from: 0, to: 1000, unlabelledAliasOfWhoop5: true) + XCTAssertTrue(withheld, "the canonical alias must expose the same strict-window status as its read") try registry(store, model: "4.0") let confirmedFour = try await store.isWhoop5RRSource(deviceId: "my-whoop", unlabelledAliasOfWhoop5: true) XCTAssertFalse(confirmedFour) @@ -128,6 +131,40 @@ final class Whoop5RRStoreTests: XCTestCase { XCTAssertEqual(stored.count, 2, "source policy never rewrites or removes legacy intervals") } + func testLegacyWithheldStatusUsesTheExactScoringWindowAndSourcePolicy() async throws { + let s = try await WhoopStore.inMemory() + try registry(s, model: "5.0 MG") + _ = try await s.insert(Streams(rr: [RRInterval(ts: 100, rrMs: 1000)]), deviceId: id) + let withheld = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 200) + XCTAssertTrue(withheld) + let outside = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 101, to: 200) + XCTAssertFalse(outside, + "legacy rows outside the exact read window cannot protect a score") + try await s.dbWriter.write { db in + try db.execute(sql: "UPDATE rrInterval SET tsSuspect = 1 WHERE deviceId = ? AND ts = 100", + arguments: [self.id]) + } + let quarantined = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 200) + XCTAssertFalse(quarantined, "quarantined rows are excluded exactly like the scoring read") + try await s.dbWriter.write { db in + try db.execute(sql: "UPDATE rrInterval SET tsSuspect = NULL WHERE deviceId = ? AND ts = 100", + arguments: [self.id]) + } + + _ = try await s.insert(Streams(rr: [RRInterval(ts: 150, rrMs: 990, + srcChannel: .whoop5Standard)]), deviceId: id) + let labelled = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 200) + XCTAssertFalse(labelled, + "any scorable labelled transport ends legacy protection") + + try registry(s, model: "4.0") + let whoop4 = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 149) + XCTAssertFalse(whoop4) + try registry(s, model: "5.0 MG", brand: "Oura") + let otherBrand = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 149) + XCTAssertFalse(otherBrand) + } + func testSourceSelectionPrecedesLimitAndSharesBoundsAndQuarantine() async throws { let s = try await WhoopStore.inMemory() try registry(s, model: "5.0 MG") diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index ccfeee9963..5fd997daee 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -247,6 +247,13 @@ final class IntelligenceEngine: ObservableObject { let primarySessionRHRCoverage: PrimarySessionRestingHR.Coverage? } + /// Exact pre-upgrade score cells retained only while an unlabelled WHOOP 5 R-R window is withheld. + private struct LegacyScoreSnapshot { + let avgHrv: Double + let recovery: Double? + let recoverySource: String? + } + struct Computed: Identifiable { let day: String let recovery: Double? @@ -272,6 +279,11 @@ final class IntelligenceEngine: ObservableObject { /// "+0.3 C vs your normal" with a relative tier tag; never a fake clinical absolute. var skinTempRel: SkinTempRelative? = nil var id: String { day } + + func withLegacyScore(hrv: Double, recovery: Double?) -> Computed { + Computed(day: day, recovery: recovery, strain: strain, sleepMin: sleepMin, hrv: hrv, rhr: rhr, + source: source, confidence: confidence, drivers: drivers, skinTempRel: skinTempRel) + } } /// Optional sink for the per-day scoring diagnostic, fed line-by-line into the SAME shareable strap @@ -2016,6 +2028,16 @@ final class IntelligenceEngine: ObservableObject { // the auto path produced NO trace at all (the "mode was on but produced NO trace" report), so an // "auto workout appeared then vanished" could not be explained from an export. Diagnostic only. let workoutsTraceActive = TestCentre.active(.workouts) + let oldestDay = AnalyticsEngine.dayString(nowLocalMidnight - (maxDays - 1) * 86_400, + offsetSec: tzOffset) + let newestDay = AnalyticsEngine.dayString(nowLocalMidnight, offsetSec: tzOffset) + let strictCanonicalAlias = (try? await store.isWhoop5RRSource(deviceId: regActiveId)) ?? true + let legacySnapshots = await Self.legacyScoreSnapshots( + store: store, computedId: computedId, from: oldestDay, to: newestDay, + fresh: scoredNights.map { $0.daily }, ownerByDay: resolvedScoreOwnerByDay, + nowLocalMidnight: nowLocalMidnight, now: now, offsetSec: tzOffset, + maxDays: maxDays, strictCanonicalAlias: strictCanonicalAlias) + var appliedLegacySnapshots: [String: LegacyScoreSnapshot] = [:] for night in scoredNights { // #299: scope the edits to THIS day before folding. A userEdited row / hand-logged nap belongs // to exactly ONE day — the day its night ENDS on, matching the daily's end-day bucket. `endTs` @@ -2263,10 +2285,6 @@ final class IntelligenceEngine: ObservableObject { // only , imported "my-whoop" rows are never touched (a BLE-only WHOOP 4.0 user has no import // fallback). Rows older than the window keep their old keys (cosmetic off-by-one, acceptable). // yyyy-MM-dd sorts chronologically, so the string range IS a date range. - let oldestDay = AnalyticsEngine.dayString(nowLocalMidnight - (maxDays - 1) * 86_400, - offsetSec: tzOffset) - let newestDay = AnalyticsEngine.dayString(nowLocalMidnight, offsetSec: tzOffset) - // ── Source-only Charge/Rest fold for wearable imports (Oura / Fitbit / Garmin / Health Connect) ── // Same honesty gap the watch fold above closes (#823), extended to the other import-only sources: a // user who ONLY imports an Oura/Fitbit/Garmin export (or Health Connect) has DAILY aggregates (HRV + @@ -2302,6 +2320,22 @@ final class IntelligenceEngine: ObservableObject { } } + // Apply the exact pair only after current-score traces and derived series were produced from the + // current inputs. A legacy snapshot must not masquerade as a value recalculated against today's + // baselines; it only protects the two persisted/displayed cells from a destructive nil overwrite. + var persistedDailies = dailies + for index in persistedDailies.indices { + let fresh = persistedDailies[index] + guard let snapshot = legacySnapshots[fresh.day], fresh.avgHrv == nil, + (fresh.totalSleepMin ?? 0) > 0 else { continue } + persistedDailies[index] = fresh.with(avgHrv: snapshot.avgHrv, recovery: snapshot.recovery) + appliedLegacySnapshots[fresh.day] = snapshot + } + for index in out.indices { + guard let snapshot = appliedLegacySnapshots[out[index].day] else { continue } + out[index] = out[index].withLegacyScore(hrv: snapshot.avgHrv, recovery: snapshot.recovery) + } + // Persist the computed scores under a dedicated "-noop" source so the WHOLE dashboard // (Today / Recovery / Strain / Sleep / Trends), not just this screen, reads them. The // Repository merges these UNDER any imported "my-whoop" rows, so a real WHOOP import @@ -2316,11 +2350,18 @@ final class IntelligenceEngine: ObservableObject { // resolver override). Persist scores + provenance atomically so a failed write can never label an // older score with a newer provider. The last row for a duplicate day wins, matching the upsert. var provenanceByCell: [String: ScoreInputProvenanceRow] = [:] - for daily in dailies { + for daily in persistedDailies { guard let source = resolvedScoreOwnerByDay[daily.day] else { continue } if daily.recovery != nil { - provenanceByCell["\(daily.day)\u{1F}recovery"] = - ScoreInputProvenanceRow(day: daily.day, key: "recovery", sourceId: source) + if let snapshot = appliedLegacySnapshots[daily.day] { + if let oldSource = snapshot.recoverySource { + provenanceByCell["\(daily.day)\u{1F}recovery"] = + ScoreInputProvenanceRow(day: daily.day, key: "recovery", sourceId: oldSource) + } + } else { + provenanceByCell["\(daily.day)\u{1F}recovery"] = + ScoreInputProvenanceRow(day: daily.day, key: "recovery", sourceId: source) + } } if daily.strain != nil { provenanceByCell["\(daily.day)\u{1F}strain"] = @@ -2346,7 +2387,7 @@ final class IntelligenceEngine: ObservableObject { markerSources = sourceIds } try? await store.persistComputedScores( - dailyMetrics: dailies, + dailyMetrics: persistedDailies, metricPoints: restPoints, provenance: Array(provenanceByCell.values), deviceId: computedId, @@ -2368,8 +2409,8 @@ final class IntelligenceEngine: ObservableObject { // covers the window, so eviction runs exactly as before; `persistComputedScores` is guarded the // same way, so an empty pass leaves the persisted window untouched. Twin of the Android // WhoopDao.replaceComputedScoreWindow empty guard. - if !dailies.isEmpty { - let freshKeys = Set(dailies.map { $0.day }) + if !persistedDailies.isEmpty { + let freshKeys = Set(persistedDailies.map { $0.day }) let existingWindow = (try? await store.dailyMetrics(deviceId: computedId, from: oldestDay, to: newestDay)) ?? [] for stale in existingWindow where !freshKeys.contains(stale.day) { _ = try? await store.deleteDailyMetrics(deviceId: computedId, from: stale.day, to: stale.day) @@ -2995,6 +3036,44 @@ final class IntelligenceEngine: ObservableObject { skinTempC: nightlySkinTempC) } + /// Resolve conservative legacy snapshots before the computed-window upsert can replace them. The date + /// loop reuses the scorer's own day/window arithmetic instead of parsing calendar dates, keeping both + /// DST behaviour and today's shortened forward window identical to the scoring read. + private static func legacyScoreSnapshots( + store: WhoopStore, computedId: String, from: String, to: String, + fresh: [DailyMetric], ownerByDay: [String: String], + nowLocalMidnight: Int, now: Int, offsetSec: Int, maxDays: Int, + strictCanonicalAlias: Bool + ) async -> [String: LegacyScoreSnapshot] { + let existing = (try? await store.dailyMetrics(deviceId: computedId, from: from, to: to)) ?? [] + let existingByDay = Dictionary(existing.map { ($0.day, $0) }, uniquingKeysWith: { a, _ in a }) + var snapshots: [String: LegacyScoreSnapshot] = [:] + for day in fresh { + guard day.avgHrv == nil, let old = existingByDay[day.day], let oldHrv = old.avgHrv, + let owner = ownerByDay[day.day] else { continue } + var dayStart: Int? + for offset in 0.. DailyMetric { + DailyMetric(day: day, totalSleepMin: totalSleepMin, efficiency: efficiency, deepMin: deepMin, + remMin: remMin, lightMin: lightMin, disturbances: disturbances, restingHr: restingHr, + avgHrv: hrv, recovery: r, strain: strain, exerciseCount: exerciseCount, + spo2Pct: spo2Pct, skinTempDevC: skinTempDevC, respRateBpm: respRateBpm, + steps: steps, activeKcalEst: activeKcalEst, + spo2Red: spo2Red, spo2Ir: spo2Ir, avgSdnn: avgSdnn, skinTempC: skinTempC, + sleepHrOnly: sleepHrOnly) + } + /// Rebuild the immutable DailyMetric with a substituted recovery + skin-temp deviation /// (the struct has no `copy()`). (#78) func with(recovery r: Double?, skinTempDevC sd: Double?, skinTempC sa: Double?) -> DailyMetric { diff --git a/StrandTests/IntelligenceRRSourceTests.swift b/StrandTests/IntelligenceRRSourceTests.swift index 75b0cb1a14..d086610d3f 100644 --- a/StrandTests/IntelligenceRRSourceTests.swift +++ b/StrandTests/IntelligenceRRSourceTests.swift @@ -71,7 +71,7 @@ final class IntelligenceRRSourceTests: XCTestCase { firstScorableDay: dayKey(firstScorable), avgHrv: day.avgHrv, totalSleepMin: day.totalSleepMin) } - func testLegacyGapAppearsForMissingScoresAndClearsAfterSourcePromotion() async throws { + func testLegacySnapshotSurvivesAndClearsAfterSourcePromotion() async throws { try await withPreferences { let store = try await WhoopStore.inMemory() try register(DeviceRegistryStore(dbQueue: store.registryWriter), canonicalModel: "WHOOP") @@ -94,12 +94,35 @@ final class IntelligenceRRSourceTests: XCTestCase { let noBeatsGap = try await showsLegacyGap(noBeats, store: store, owner: active) XCTAssertFalse(noBeatsGap, "ordinary missing beats must not be blamed on legacy units") + // Restore the exact 11.5-era computed cells before the 11.6 source-policy re-score. The + // fixture deliberately gives every other field stale values: only HRV + Charge may survive. + let legacySnapshot = DailyMetric(day: input.day, totalSleepMin: 1, efficiency: 0.01, + deepMin: 1, remMin: 1, lightMin: 1, disturbances: 99, restingHr: 199, + avgHrv: 77.25, recovery: 0.42, strain: 99, exerciseCount: 99) + try await store.persistComputedScores( + dailyMetrics: [legacySnapshot], metricPoints: [], + provenance: [ScoreInputProvenanceRow(day: input.day, key: "recovery", sourceId: "legacy-owner")], + deviceId: canonical + "-noop", from: input.day, to: input.day) _ = try await store.insert(Streams(rr: input.rr), deviceId: active) let legacy = try await score() - XCTAssertNil(legacy.avgHrv) - XCTAssertNil(legacy.recovery, "a seeded baseline cannot supply the missing nightly HRV") + XCTAssertEqual(legacy.avgHrv, legacySnapshot.avgHrv, "the restored HRV cell must survive") + XCTAssertEqual(legacy.recovery, legacySnapshot.recovery, "HRV and Charge survive as one snapshot") + XCTAssertNotEqual(legacy.totalSleepMin, legacySnapshot.totalSleepMin, + "sleep is freshly scored rather than copied from the snapshot") + XCTAssertNotEqual(legacy.restingHr, legacySnapshot.restingHr, + "only HRV and Charge receive legacy protection") + let preservedSource = try await store.scoreInputSource(deviceId: canonical + "-noop", + day: input.day, key: "recovery") + XCTAssertEqual(preservedSource, "legacy-owner", "snapshot provenance must survive") let legacyGap = try await showsLegacyGap(legacy, store: store, owner: active) - XCTAssertTrue(legacyGap, "the scored legacy night must reach the shipped explanation predicate") + XCTAssertFalse(legacyGap, "a protected snapshot is no longer an empty-score diagnostic") + + let stable = try await score() + XCTAssertEqual(stable.avgHrv, legacySnapshot.avgHrv) + XCTAssertEqual(stable.recovery, legacySnapshot.recovery) + let stableSource = try await store.scoreInputSource(deviceId: canonical + "-noop", + day: input.day, key: "recovery") + XCTAssertEqual(stableSource, "legacy-owner") // Keep the unlabelled-era bounds unchanged: valid displayed HRV alone must rule out this cause. let calibrating = DailyMetric(day: legacy.day, totalSleepMin: legacy.totalSleepMin, @@ -109,12 +132,30 @@ final class IntelligenceRRSourceTests: XCTestCase { let calibrationGap = try await showsLegacyGap(calibrating, store: store, owner: active) XCTAssertFalse(calibrationGap, "valid HRV without Charge is ordinary calibration") + // A marked but too-thin transport is a current measurement verdict, not a legacy gap. It must + // clear the old cells rather than letting the protection disguise insufficient new input. + let thin = try XCTUnwrap(input.rr.first) + _ = try await store.insert(Streams(rr: [RRInterval(ts: thin.ts, rrMs: thin.rrMs, + srcChannel: .whoop5Standard)]), deviceId: active) + let insufficient = try await score() + XCTAssertNil(insufficient.avgHrv) + XCTAssertNil(insufficient.recovery) + + // Recreate the pre-upgrade snapshot, then prove a complete canonical transport supersedes it. + try await store.persistComputedScores( + dailyMetrics: [legacySnapshot], metricPoints: [], + provenance: [ScoreInputProvenanceRow(day: input.day, key: "recovery", sourceId: "legacy-owner")], + deviceId: canonical + "-noop", from: input.day, to: input.day) let tagged = input.rr.map { RRInterval(ts: $0.ts, rrMs: $0.rrMs, srcChannel: .whoop5Historical) } let inserted = try await store.insert(Streams(rr: tagged), deviceId: active) XCTAssertEqual(inserted.rr, 0, "re-offload changes only source provenance, not row count") let restored = try await score() XCTAssertGreaterThan(try XCTUnwrap(restored.avgHrv), 0) + XCTAssertNotEqual(restored.avgHrv, legacySnapshot.avgHrv) XCTAssertNotNil(restored.recovery) + let promotedSource = try await store.scoreInputSource(deviceId: canonical + "-noop", + day: input.day, key: "recovery") + XCTAssertEqual(promotedSource, active, "freshly scored provenance replaces the snapshot") let restoredGap = try await showsLegacyGap(restored, store: store, owner: active) XCTAssertFalse(restoredGap) let idle = try await score() diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 624dd884a6..7fe8ac0249 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,16 +19,16 @@ }, "authority": { "files": {"count": 486, "sha256": "7b34d14347539b6e66f59381ccc543116dbef11df84b32f1b0aff452f6f99c41"}, - "functions": {"count": 4303, "sha256": "8b312f9e17750837c22b7d5817d8f87444efeef04dd74b2a6ea99c9bda748575"}, + "functions": {"count": 4308, "sha256": "b1257babf9295cef3e6f357ebcc42ca8474dc428024e548e6d0415874dcd707a"}, "properties": {"count": 448, "sha256": "e90524140aa597cab434483743950f0b78110140d1e2b1dd9ed7d72e8d25fddf"}, - "constants": {"count": 1883, "sha256": "c852e1a0140d3b42974223581b6171b92ff52dc5b79f776527c09d57ef3e488d"}, - "file_pairs": {"count": 59, "sha256": "9764a5ec6b7b6a26401236e162165d689cadc87a867d50af2655c4d7e6d18b50"}, - "function_pairs": {"count": 150, "sha256": "9f2795faa60a7cf83b6bf6df047c301689370d24af687b8a5b60524618e6eb6b"}, + "constants": {"count": 1884, "sha256": "e32fc633154d7008224eeb30abd943c1bce0fc84cede4ff67b3c7b3d9ae6136e"}, + "file_pairs": {"count": 60, "sha256": "e33d176c759b3a24dc457b3f0c382feee463ec5f25df45d2766cf9074dea9313"}, + "function_pairs": {"count": 151, "sha256": "5cf1404076b0320be25cc5ffbeccef95f3fa6a1eb86a5827a6a7775148dfef8b"}, "property_pairs": {"count": 147, "sha256": "781073f4b7af31db4400da247eb16910c2c8adbbb4ab5f9169b852566ed3dbc8"}, "constant_pairs": {"count": 662, "sha256": "7b35a28702a8358ea4bf53380949a0e3d160737ed0e83ae950882f3479d0637c"}, "unpaired_files": {"count": 384, "sha256": "633111368a11750f987b0e847432453f64326a71821e5a765b7f994912e07091"}, - "unpaired_functions": {"count": 4009, "sha256": "01aa80bf6b229a85e67e4515c979814929c8f8d50d4779e00e74ec6097500998"}, + "unpaired_functions": {"count": 4012, "sha256": "939c4e5cb8760c4042570177c467bb786beebbda8a227559449f901f255183b1"}, "unpaired_properties": {"count": 154, "sha256": "e38ff018589491eedd40569390492f97f5d9dd2a12acd8a2880a34d1d58c73e8"}, - "unpaired_constants": {"count": 559, "sha256": "5700956cc8268d606fd1494779b6fa2d503f57744e7c8a7a27ede7374047afb5"} + "unpaired_constants": {"count": 560, "sha256": "7edadfead59eb823725ed319636e573e73f623e4e4ce39ac85144d2b3531f787"} } } diff --git a/Tools/tests/test_rr_legacy_preservation_contract.py b/Tools/tests/test_rr_legacy_preservation_contract.py new file mode 100644 index 0000000000..ab669b8110 --- /dev/null +++ b/Tools/tests/test_rr_legacy_preservation_contract.py @@ -0,0 +1,57 @@ +"""Source contracts for cross-platform legacy WHOOP 5 score preservation.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SWIFT_ENGINE = ROOT / "Strand/Data/IntelligenceEngine.swift" +ANDROID_PERSISTENCE = ( + ROOT + / "android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt" +) + + +class LegacyScorePreservationContractTests(unittest.TestCase): + def test_swift_protects_only_the_persisted_and_displayed_copy(self) -> None: + source = SWIFT_ENGINE.read_text() + protection_start = source.index("// Apply the exact pair only after") + persistence_end = source.index("markPostLoopPhase(\"persist\")", protection_start) + persistence = source[protection_start:persistence_end] + + self.assertIn("var persistedDailies = dailies", persistence) + self.assertIn("for index in persistedDailies.indices", persistence) + self.assertIn("let fresh = persistedDailies[index]", persistence) + self.assertIn( + "persistedDailies[index] = fresh.with(avgHrv: snapshot.avgHrv, " + "recovery: snapshot.recovery)", + persistence, + ) + self.assertIn("for daily in persistedDailies", persistence) + self.assertIn("dailyMetrics: persistedDailies", persistence) + self.assertNotIn("dailies[index] = fresh.with(avgHrv:", persistence) + + derivations = source[persistence_end:] + self.assertIn("let fa7 = dailies.sorted", derivations) + self.assertIn("for d in dailies { faGateByDay[d.day] = d }", derivations) + self.assertIn("let vHRVs = fa7.compactMap { $0.avgHrv }", derivations) + self.assertNotIn("persistedDailies", derivations) + + def test_android_also_protects_a_separate_persistence_copy(self) -> None: + source = ANDROID_PERSISTENCE.read_text() + preparation_start = source.index("suspend fun prepareComputedWindow") + preparation_end = source.index("fun scoreProvenance", preparation_start) + preparation = source[preparation_start:preparation_end] + + self.assertIn("val mutableDailies = dailies.toMutableList()", preparation) + self.assertIn( + "repo, computedId, from, to, mutableDailies, ownerByDay,", + preparation, + ) + self.assertIn("dailies = mutableDailies", preparation) + + +if __name__ == "__main__": + unittest.main() diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index e2c319a407..d2365f300d 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -1923,6 +1923,7 @@ object IntelligenceEngine { val computedWindow = IntelligencePersistence.prepareComputedWindow( repo, importedDeviceId, computedId, oldestDay, newestDay, dailies, restRows, physiologicalSteps, candidatePriorities, resolvedScoreOwnerByDay, + IntelligencePersistence.LegacyScoreClock(nowLocalMidnight, nowSeconds, tzOffsetSeconds), out, ) repo.replaceComputedScoreWindow(computedWindow) diff --git a/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt b/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt index 871bd0fa99..6e1382bc10 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt @@ -8,6 +8,18 @@ import com.noop.data.WhoopRepository /** Persistence-only helpers kept out of the already large scoring orchestrator. */ internal object IntelligencePersistence { + data class LegacyScoreSnapshot( + val avgHrv: Double, + val recovery: Double?, + val recoverySource: String?, + ) + + data class LegacyScoreClock( + val nowLocalMidnight: Long, + val nowSeconds: Long, + val tzOffsetSeconds: Long, + ) + data class ComputedWindow( val deviceId: String, val from: String, @@ -29,13 +41,21 @@ internal object IntelligencePersistence { cycle: PhysiologicalStepCycleEngine.Result, candidatePriorities: List>, ownerByDay: Map, + legacyClock: LegacyScoreClock, + computed: MutableList, ): ComputedWindow { - val provenance = scoreProvenance(computedId, dailies, metricRows, ownerByDay) + val mutableDailies = dailies.toMutableList() + val snapshots = preserveLegacyScores( + repo, computedId, from, to, mutableDailies, ownerByDay, + legacyClock, + ) + applyLegacyScoresToOutput(computed, snapshots) + val provenance = scoreProvenance(computedId, mutableDailies, metricRows, ownerByDay, snapshots) return ComputedWindow( deviceId = computedId, from = from, to = to, - dailies = dailies, + dailies = mutableDailies, metricRows = (metricRows + cycle.recoveredOwnerMarkerRows) .distinctBy { Triple(it.deviceId, it.day, it.key) }, provenance = provenance, @@ -51,14 +71,19 @@ internal object IntelligencePersistence { dailies: List, metricRows: List, ownerByDay: Map, + legacySnapshots: Map = emptyMap(), ): List { val byCell = LinkedHashMap, ScoreInputProvenanceRow>() for (daily in dailies) { val source = ownerByDay[daily.day] ?: continue if (daily.recovery != null) { - byCell[daily.day to "recovery"] = ScoreInputProvenanceRow( - computedId, daily.day, "recovery", source, - ) + val legacy = legacySnapshots[daily.day] + val recoverySource = legacy?.recoverySource ?: if (legacy == null) source else null + if (recoverySource != null) { + byCell[daily.day to "recovery"] = ScoreInputProvenanceRow( + computedId, daily.day, "recovery", recoverySource, + ) + } } if (daily.strain != null) { byCell[daily.day to "strain"] = ScoreInputProvenanceRow( @@ -75,6 +100,52 @@ internal object IntelligencePersistence { return byCell.values.toList() } + private suspend fun preserveLegacyScores( + repo: WhoopRepository, + computedId: String, + from: String, + to: String, + dailies: MutableList, + ownerByDay: Map, + clock: LegacyScoreClock, + ): Map { + val existingByDay = repo.dailyMetrics(computedId, from, to).associateBy { it.day } + val snapshots = LinkedHashMap() + for (index in dailies.indices) { + val fresh = dailies[index] + val existing = existingByDay[fresh.day] ?: continue + val oldHrv = existing.avgHrv ?: continue + if (fresh.avgHrv != null || (fresh.totalSleepMin ?: 0.0) <= 0.0) continue + val owner = ownerByDay[fresh.day] ?: continue + val dayStart = try { + java.time.LocalDate.parse(fresh.day).toEpochDay() * 86_400L - clock.tzOffsetSeconds + } catch (_: java.time.format.DateTimeParseException) { continue } + val readFrom = dayStart - StreamReadCap.LOOKBACK_SECONDS + val readTo = IntelligenceEngine.sleepReadWindowEnd( + dayStart, clock.nowLocalMidnight, clock.nowSeconds, + ) + if (!repo.legacyWhoop5RrWithheld(owner, readFrom, readTo)) continue + val snapshot = LegacyScoreSnapshot( + avgHrv = oldHrv, + recovery = existing.recovery, + recoverySource = repo.scoreInputSource(computedId, fresh.day, "recovery"), + ) + dailies[index] = fresh.copy(avgHrv = snapshot.avgHrv, recovery = snapshot.recovery) + snapshots[fresh.day] = snapshot + } + return snapshots + } + + private fun applyLegacyScoresToOutput( + computed: MutableList, + snapshots: Map, + ) { + for (index in computed.indices) { + val snapshot = snapshots[computed[index].day] ?: continue + computed[index] = computed[index].copy(hrv = snapshot.avgHrv, recovery = snapshot.recovery) + } + } + suspend fun persistDetectedSleepDetails( repo: WhoopRepository, computedId: String, 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 c0cbc43936..f4b66f88d1 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -129,6 +129,14 @@ internal const val FIRST_SCORABLE_WHOOP5_RR_SQL = internal const val HAS_WHOOP5_RR_SOURCE_SQL = "SELECT EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = :deviceId AND srcChannel IN (5, 6, 7))" +internal const val LEGACY_WHOOP5_RR_WITHHELD_SQL = + "SELECT EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = :deviceId " + + "AND ts >= :from AND ts <= :to AND srcChannel IS NULL " + + "AND (tsSuspect IS NULL OR tsSuspect <> 1)) " + + "AND NOT EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = :deviceId " + + "AND ts >= :from AND ts <= :to AND srcChannel IN " + SCORABLE_WHOOP5_CHANNELS + " " + + "AND (tsSuspect IS NULL OR tsSuspect <> 1))" + internal const val PROMOTE_WHOOP5_RR_SOURCE_SQL = "UPDATE rrInterval SET srcChannel = :source, ord = :ord " + "WHERE deviceId = :deviceId AND ts = :ts AND rrMs = :rrMs AND seq = :seq " + @@ -597,6 +605,10 @@ interface WhoopDao : DeviceRegistryDao { @Query(HAS_WHOOP5_RR_SOURCE_SQL) suspend fun hasWhoop5RrSource(deviceId: String): Boolean + /** Exact-window twin of Swift `legacyWhoop5RRWithheld`; source-family gating stays in Repository. */ + @Query(LEGACY_WHOOP5_RR_WITHHELD_SQL) + suspend fun legacyWhoop5RrWithheld(deviceId: String, from: Long, to: Long): Boolean + /** Nullable because MIN over no rows is SQL NULL: a device with nothing scorable yet. */ @Query(FIRST_SCORABLE_WHOOP5_RR_SQL) suspend fun firstScorableWhoop5RrTs(deviceId: String): Long? 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 2bbfb83b17..1b32a207ba 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1342,6 +1342,17 @@ class WhoopRepository( suspend fun firstRecordedRrTs(deviceId: String): Long? = dao.firstRecordedRrTs(deviceId) + /** True only when strict WHOOP 5 policy withheld unlabelled legacy beats in this exact scoring window. */ + suspend fun legacyWhoop5RrWithheld( + deviceId: String, + from: Long, + to: Long, + unlabelledAliasOfWhoop5: Boolean = false, + ): Boolean = transactor.run { + isWhoop5RrSource(deviceId, unlabelledAliasOfWhoop5) && + dao.legacyWhoop5RrWithheld(deviceId, from, to) + } + /** Diagnostic export keeps all WHOOP transports and legacy values without scoring selection. * Existing quarantine and Oura SpO2-IBI exclusions still apply. */ suspend fun rawRrIntervalsForDevice(deviceId: String, from: Long, to: Long, diff --git a/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt b/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt index de7b08e6fb..e56fcb4b27 100644 --- a/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt +++ b/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt @@ -30,6 +30,7 @@ class Whoop5RRSqliteTest { private val owners = linkedMapOf() private val sleeps = linkedMapOf, SleepSession>() private val days = linkedMapOf, DailyMetric>() + private val provenance = linkedMapOf, ScoreInputProvenanceRow>() private val gravity = mutableListOf() private val id = "my-whoop" @@ -88,9 +89,18 @@ class Whoop5RRSqliteTest { if (sleeps.replace(row.deviceId to row.startTs, row) != null) 1 else 0 } "replaceComputedScoreWindow" -> { + val deviceId = args[0] as String + val from = args[1] as String + val to = args[2] as String + days.keys.removeAll { it.first == deviceId && it.second in from..to } + provenance.keys.removeAll { it.first == deviceId && it.second in from..to && it.third != "vo2max_est" } (args[3] as List<*>).filterIsInstance().forEach { days[it.deviceId to it.day] = it } + (args[5] as List<*>).filterIsInstance().forEach { + provenance[Triple(it.deviceId, it.day, it.key)] = it + } Unit } + "scoreInputSource" -> provenance[Triple(args[0] as String, args[1] as String, args[2] as String)]?.sourceId "upsertMetricSeries", "upsertMetricSeriesWithProvenance", "deleteWorkoutsBySport" -> Unit "sessionSleepStateJson" -> null "appleDaily", "workouts", "dismissedSleeps" -> emptyList() @@ -120,6 +130,10 @@ class Whoop5RRSqliteTest { "hasWhoop5RrSource" -> query(HAS_WHOOP5_RR_SOURCE_SQL, mapOf("deviceId" to args[0])) { it.getBoolean(1) }.single() + "legacyWhoop5RrWithheld" -> query(LEGACY_WHOOP5_RR_WITHHELD_SQL, + listOf("deviceId", "from", "to").zip(args.take(3)).toMap()) { + it.getBoolean(1) + }.single() "firstScorableWhoop5RrTs", "firstRecordedRrTs" -> query( if (method.name == "firstRecordedRrTs") FIRST_RECORDED_RR_SQL else FIRST_SCORABLE_WHOOP5_RR_SQL, @@ -205,7 +219,7 @@ class Whoop5RRSqliteTest { ) } - private suspend fun assertLegacyGapScoringLifecycle(model: String, expectsLegacyGap: Boolean) { + private suspend fun assertLegacySnapshotScoringLifecycle(model: String, expectsProtection: Boolean) { val owner = "physical-strap" registry(model, owner = owner) activate(owner) @@ -232,13 +246,37 @@ class Whoop5RRSqliteTest { assertNull(noBeats.recovery) assertFalse("ordinary missing beats are not legacy units", showsLegacyGap(noBeats, owner)) + val computedId = "$id-noop" + val legacySnapshot = noBeats.copy(deviceId = computedId, totalSleepMin = 1.0, efficiency = 0.01, + restingHr = 199, avgHrv = 77.25, recovery = 0.42, strain = 99.0) + days[computedId to legacySnapshot.day] = legacySnapshot + provenance[Triple(computedId, legacySnapshot.day, "recovery")] = ScoreInputProvenanceRow( + computedId, legacySnapshot.day, "recovery", "legacy-owner") val legacyRr = (start until end).map { RrRow(it, if (it % 2L == 0L) 980 else 1020) } repo.insert(StreamBatch(rr = legacyRr), owner) val legacy = score() - assertEquals(expectsLegacyGap, showsLegacyGap(legacy, owner)) - if (expectsLegacyGap) { - assertNull(legacy.avgHrv) - assertNull("a seeded baseline cannot supply missing nightly HRV", legacy.recovery) + assertFalse(showsLegacyGap(legacy, owner)) + if (expectsProtection) { + assertEquals(legacySnapshot.avgHrv, legacy.avgHrv) + assertEquals(legacySnapshot.recovery, legacy.recovery) + assertNotEquals("sleep must be freshly scored", legacySnapshot.totalSleepMin, legacy.totalSleepMin) + assertNotEquals("only HRV and Charge are protected", legacySnapshot.restingHr, legacy.restingHr) + assertEquals("legacy-owner", repo.scoreInputSource(computedId, legacy.day, "recovery")) + val stable = score() + assertEquals(legacySnapshot.avgHrv, stable.avgHrv) + assertEquals(legacySnapshot.recovery, stable.recovery) + assertEquals("legacy-owner", repo.scoreInputSource(computedId, legacy.day, "recovery")) + + val thin = legacyRr.first() + assertEquals(0, repo.insert(StreamBatch(rr = listOf(thin.copy( + srcChannel = RrSourceChannel.WHOOP5_STANDARD))), owner).rr) + val insufficient = score() + assertNull("a marked but insufficient transport must not be masked", insufficient.avgHrv) + assertNull(insufficient.recovery) + + days[computedId to legacySnapshot.day] = legacySnapshot + provenance[Triple(computedId, legacySnapshot.day, "recovery")] = ScoreInputProvenanceRow( + computedId, legacySnapshot.day, "recovery", "legacy-owner") // Keep the same unlabelled-era bounds: valid HRV alone must rule out this cause. assertFalse("valid HRV without Charge is ordinary calibration", showsLegacyGap(legacy.copy(avgHrv = 40.0), owner)) @@ -246,6 +284,7 @@ class Whoop5RRSqliteTest { it.copy(srcChannel = RrSourceChannel.WHOOP5_HISTORICAL) }), owner).rr) } else { + assertNotEquals(legacySnapshot.avgHrv, legacy.avgHrv) assertEquals(40.0, legacy.avgHrv!!, 0.001) assertNotNull(legacy.recovery) } @@ -253,6 +292,7 @@ class Whoop5RRSqliteTest { val restored = score() assertEquals(40.0, restored.avgHrv!!, 0.001) assertNotNull(restored.recovery) + if (expectsProtection) assertEquals(owner, repo.scoreInputSource(computedId, restored.day, "recovery")) assertFalse(showsLegacyGap(restored, owner)) val idle = score() assertEquals(restored.avgHrv, idle.avgHrv) @@ -260,12 +300,12 @@ class Whoop5RRSqliteTest { assertFalse("a cache hit must not revive the explanation", showsLegacyGap(idle, owner)) } - @Test fun actualWhoop5GapClearsAfterSourcePromotion() = runBlocking { - assertLegacyGapScoringLifecycle("5.0 MG", expectsLegacyGap = true) + @Test fun actualWhoop5LegacySnapshotClearsAfterSourcePromotion() = runBlocking { + assertLegacySnapshotScoringLifecycle("5.0 MG", expectsProtection = true) } @Test fun actualWhoop4LegacyNightKeepsScoresWithoutExplanation() = runBlocking { - assertLegacyGapScoringLifecycle("4.0", expectsLegacyGap = false) + assertLegacySnapshotScoringLifecycle("4.0", expectsProtection = false) } /** The date the "this night cannot be scored" explanation names comes from this query, so it has to @@ -310,6 +350,29 @@ class Whoop5RRSqliteTest { assertFalse(plan.any { it.contains("SCAN rrInterval") }) } + @Test fun legacyWithheldStatusUsesExactScoringWindowAndSourcePolicy() = runBlocking { + repo.insert(StreamBatch(rr = listOf(RrRow(100, 1000))), id) + assertTrue(repo.legacyWhoop5RrWithheld(id, 100, 200)) + assertFalse("rows outside the exact read window cannot protect a score", + repo.legacyWhoop5RrWithheld(id, 101, 200)) + statement("UPDATE rrInterval SET tsSuspect=1 WHERE deviceId=:id AND ts=100", + mapOf("id" to id)).use { it.executeUpdate() } + assertFalse("quarantined rows follow the scoring read exclusion", + repo.legacyWhoop5RrWithheld(id, 100, 200)) + statement("UPDATE rrInterval SET tsSuspect=NULL WHERE deviceId=:id AND ts=100", + mapOf("id" to id)).use { it.executeUpdate() } + + repo.insert(StreamBatch(rr = listOf(RrRow(150, 990, + srcChannel = RrSourceChannel.WHOOP5_STANDARD))), id) + assertFalse("a scorable labelled transport ends protection", + repo.legacyWhoop5RrWithheld(id, 100, 200)) + + registry("4.0") + assertFalse(repo.legacyWhoop5RrWithheld(id, 100, 149)) + registry("5.0 MG", brand = "Oura") + assertFalse(repo.legacyWhoop5RrWithheld(id, 100, 149)) + } + @Test fun actualNightlyScorerGuardsCanonicalHistoryAfterRePairing() = runBlocking { registry("WHOOP") registry("5.0 MG", owner = "new-five") @@ -322,6 +385,7 @@ class Whoop5RRSqliteTest { hr = (start until end).map { HrRow(it, 60) }, rr = (start until end).map { RrRow(it, if (it % 2L == 0L) 980 else 1020) }, ), id) + assertTrue(repo.legacyWhoop5RrWithheld(id, start, end)) repo.upsertSleepSessions(listOf(SleepSession(deviceId = id, startTs = start, endTs = end, efficiency = 1.0, restingHr = null, avgHrv = null, stagesJSON = AnalyticsEngine.encodeStages(listOf(StageSegment(start, end, "light")))))) diff --git a/okf/index.md b/okf/index.md new file mode 100644 index 0000000000..ce3043cb70 --- /dev/null +++ b/okf/index.md @@ -0,0 +1,11 @@ +--- +okf_version: "0.2" +--- + +# Index + + + +## Entscheidungen + +- [Bestandsschutz für uneindeutige WHOOP-5-RR-Altdaten](rr-legacy-bestandsschutz.md) diff --git a/okf/log.md b/okf/log.md new file mode 100644 index 0000000000..559054f31b --- /dev/null +++ b/okf/log.md @@ -0,0 +1,7 @@ +# Directory Update Log + + + +## 2026-09-12 + +- Entscheidung zum verlustfreien Upgrade-/Restore-Bestandsschutz für WHOOP-5-RR-Altdaten ergänzt. diff --git a/okf/rr-legacy-bestandsschutz.md b/okf/rr-legacy-bestandsschutz.md new file mode 100644 index 0000000000..3ec7a5d1e4 --- /dev/null +++ b/okf/rr-legacy-bestandsschutz.md @@ -0,0 +1,38 @@ +--- +type: Decision +title: Bestandsschutz für uneindeutige WHOOP-5-RR-Altdaten +description: Historische HRV- und Recovery-Ergebnisse bleiben erhalten, ohne unmarkierte RR-Rohdaten spekulativ neu auszuwerten. +tags: [whoop5, rr, hrv, recovery, upgrade, restore] +status: draft +generated: + by: codex-orchestrator +sources: + - id: archived-design + type: local + target: ../openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/design.md +--- + +# Entscheidung + +Unmarkierte WHOOP-5-RR-Zeilen aus Versionen vor 11.6 bleiben vom Scoring ausgeschlossen, weil ihr +Transport und damit ihre Einheit nachträglich nicht sicher bestimmbar ist. Eine pauschale Konvertierung +oder Wertebereichsheuristik ist unzulässig. + +Wenn ein Re-Score ausschließlich durch dieses Gate kein HRV liefert, bewahrt NOOP ein bereits vorhandenes +berechnetes HRV-/Recovery-Paar als unveränderten Legacy-Snapshot. Alle anderen Tageswerte und Ableitungen +werden aus aktuellen, akzeptierten Eingaben berechnet. Ein markierter Scoring-Transport beendet den Schutz. + +# Geltungsbereich + +- Direkter Upgrade-Pfad aus 11.5 und Restore einer intakten älteren Sicherung. +- Apple und Android mit derselben konservativen Fenster- und Gerätepolicy. +- Keine Rekonstruktion von Werten, die 11.6.0 bereits geleert hat; dafür bleibt eine ältere Sicherung oder + ein erneuter Strap-Offload erforderlich. + +# Nachweis und Restgrenzen + +Android-Engine-, Store-, Schema-, Transaktions- und Bytecode-Tests sowie ein plattformübergreifender +Quellvertrag sind lokal grün. Apple-Kompilation, echte Nutzersicherung und Hardware-Offload benötigen +CI/macOS beziehungsweise freiwillige Nutzertests. + +[^archived-design]: [Archiviertes Design](../openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/design.md) diff --git a/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/approval.md b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/approval.md new file mode 100644 index 0000000000..e7eab43fca --- /dev/null +++ b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/approval.md @@ -0,0 +1,11 @@ +# Freigabe: RR-Legacy-Bestandsschutz + +- Freigebende Person: Nutzer im aktuellen Codex-Thread +- Zeitpunkt: 2026-09-11T23:02:02Z +- Freigegebene Revision: r1 +- Gebundener Inhalts-Commit: 915158f2006aed15709fbc0307058acc6d61e845 +- Freigegebener Umfang: Bestandsschutz für vorhandene HRV-/Recovery-Ergebnisse beim direkten Upgrade + und Restore auf Apple und Android, ohne Konvertierung uneindeutiger RR-Rohdaten; lokale Prüfung, + Abschlussreview, Pull Request und öffentlicher Testaufruf gemäß E1–E3. +- Ausführungsgrenzen: `ledger.md`, Abschnitte „Route und Risiko“, „Entscheidungsvorgaben“ und „Grenzen“. +- Freigabeantwort: „1“ diff --git a/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/design.md b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/design.md new file mode 100644 index 0000000000..706c9bb0b2 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/design.md @@ -0,0 +1,73 @@ +# Design: RR-Legacy-Bestandsschutz + +Revision: r1 + +## Kontext + +Der neue WHOOP-5-Read-Filter ist fachlich korrekt: Unmarkierte Altzeilen können zwei Einheiten enthalten +und dürfen nicht als ein homogener Beat-Train interpretiert werden. Der Defekt entsteht danach, wenn ein +vollständiger Tagesdatensatz mit leerem HRV und leerer Recovery einen vorhandenen berechneten Tageswert +überschreibt. Ein allgemeines `NULL bedeutet alten Wert behalten` wäre falsch, weil legitime Löschungen, +Schlafkorrekturen und Rekalibrierungen dann nicht mehr wirksam würden. + +## Architektur und Datenfluss + +Vor der Neuberechnung lädt jede Plattform die vorhandenen berechneten Tageswerte ausschließlich für das +aktuelle Re-Score-Fenster. Der Store stellt für Owner und exaktes RR-Lesefenster einen kleinen Status bereit, +der dieselben Geräte-, Zeit-, Quarantäne- und Transportprädikate wie der Scoring-Read verwendet. + +Ein frisch berechneter Tag darf vorhandenes HRV und vorhandene Recovery nur übernehmen, wenn: + +1. die tatsächliche Quelle unter der strikten WHOOP-5-Regel steht, +2. im exakten Fenster unmarkierte, nicht verdächtige RR-Zeilen existieren, +3. im Fenster kein sicher auswertbarer Transport existiert, +4. der frische Tag Schlaf, aber kein HRV-Ergebnis besitzt, und +5. ein vorhandener berechneter Tag mindestens HRV enthält. + +Die beiden geschützten Zellen werden gemeinsam übernommen. Alle übrigen Felder stammen aus der aktuellen +Analyse. Die bestehende Score-Provenienz des Snapshots darf in diesem Fall nicht gelöscht oder als neu +berechnet ausgegeben werden. Markierte, aber zu dünne oder ungültige RR-Daten lösen keinen Bestandsschutz +aus; deren leeres Ergebnis ist eine aktuelle fachliche Aussage. + +## Kompatibilität und Migration + +Es gibt keine Schemaänderung. Der Schutz greift beim ersten Re-Score direkt auf die bereits vorhandenen +berechneten Zeilen aus 11.5 oder einer intakten Sicherung zu. Nutzer, deren Werte durch 11.6.0 bereits auf +leer gesetzt wurden, benötigen eine ältere Sicherung oder einen erneuten Strap-Offload. + +## Sicherheit und Datenschutz + +Keine neuen Daten verlassen das Gerät. Die Lösung vermeidet eine spekulative Interpretation biometrischer +Rohdaten und verändert keine Exportinhalte. Der öffentliche Testaufruf darf keine persönlichen Daten oder +Rohdaten anfordern; bestehende redigierte Diagnosewege bleiben maßgeblich. + +## Rollout und Rollback + +Der Change wird mit deterministischen Store- und Engine-Tests beider Plattformen geprüft. Ein Pull Request +kennzeichnet ausdrücklich, dass der Ablauf mit einer echten 11.5-Nutzersicherung und realer Hardware noch +nicht ausgeführt wurde. Rücknahme des Changes stellt das 11.6.0-Verhalten wieder her, ohne Datenmigration +rückgängig machen zu müssen. + +## Entscheidungen und Abwägungen + +- Gemäß E1 werden vorhandene Ergebnisse geschützt, bevor der erzwungene Re-Score sie löschen kann. +- Gemäß E2 bleibt der RR-Filter unverändert; weder Datum noch Wertebereich gelten als Einheitenbeweis. +- HRV und Recovery bleiben exakt zusammen, statt Recovery mit veränderten Baselines neu zu berechnen. +- Ein exakter Fensterstatus wird einer groben ersten/letzten Tagesgrenze vorgezogen, damit gewöhnlich + fehlende oder später unzureichende RR-Daten keine alten Scores festhalten. +- Die allgemeine Persistenzschicht wird nicht verändert; dadurch bleiben legitime Null-Updates möglich. + +## Risiken + +- Unterschiedliche Fenster- oder Owner-Auflösung zwischen Status und Read könnte falsch schützen. Deshalb + müssen beide dieselben Prädikate verwenden und Paritätstests erhalten. +- Das Laden bestehender Tageswerte erhöht die Arbeit pro Re-Score geringfügig, bleibt aber auf das bereits + begrenzte Fenster beschränkt. +- Ein synthetischer Restore-Test ersetzt keinen Test mit einer echten Nutzersicherung; dieses Restrisiko + wird im Pull Request und Issue sichtbar gemacht. + +## Fertigstellung + +Fertig ist der Change, wenn alle Spezifikationsszenarien auf beiden Plattformen nachgewiesen sind, keine +allgemeine RR- oder Upsert-Regel aufgeweicht wurde, der Abschlussreview keine offene kritische Abweichung +enthält und Pull Request sowie Testaufruf mit ehrlicher Testgrenze veröffentlicht sind. diff --git a/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/ledger.md b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/ledger.md new file mode 100644 index 0000000000..89316594c0 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/ledger.md @@ -0,0 +1,84 @@ +# Ledger: RR-Legacy-Bestandsschutz + +Revision: r1 + +## Arbeitsort + +Worktree wt-rr-legacy-bestandsschutz, Branch feature/rr-legacy-bestandsschutz, abgezweigt von main 450256a0c8b439c18407e61fe7485b04b0db58c9 + +## Route und Risiko + +- Aufwandsklasse: Standard +- Prüfprofil: 0P1A +- Changes: 1 — RR-Legacy-Bestandsschutz +- Prüfbudget: 0P + 1A + 0I gesamt; 0P + 1A + 0I verbleibend +- Risk: high — persistierte biometrische Ergebnisse, Upgrade-/Restore-Verhalten und Plattformparität +- Profil: standard +- Recording: inaktiv; weder Nutzer noch Projektregel haben Messaufzeichnung aktiviert + +## Entscheidungsvorgaben + +| ID | Frage | Entscheidung | Quelle | Datum | Status | +| --- | --- | --- | --- | --- | --- | +| E1 | Soll der direkte Upgrade-Pfad Bestand erhalten? | Vorhandene HRV-/Recovery-Ergebnisse dürfen beim ersten Re-Score nicht verschwinden. | Nutzer: „ich hab noch nicht auf 11.6.0 geupdated und mit dem fix geht das dann smooth?“ | 2026-09-12 | gültig | +| E2 | Sollen alte RR-Rohdaten konvertiert werden? | Keine heuristische oder pauschale Konvertierung; uneindeutige Rohdaten bleiben vom Scoring ausgeschlossen. | Nutzerklärung nach Erläuterung der gemischten Einheiten | 2026-09-12 | gültig | +| E3 | Welche externe Übergabe ist beauftragt? | Nach Fertigstellung Pull Request erstellen, Realbackup-/Hardwarepfad als ungetestet kennzeichnen und im Issue Tester ansprechen. | Nutzer: „Wenn du fertig ist mach einen MR und sag das er ungetestet ist, pinge aber die leute im issue ob sie es testen wollen.“ | 2026-09-12 | gültig | + +## Annahmen und Spikes + +| Annahme | Risiko wenn falsch | Spike/Proof | Resultat | Planfolge | +| --- | --- | --- | --- | --- | +| 11.6 überschreibt vorhandene Werte statt nur RR auszublenden | Fix am falschen Ort | Read-only Pfadprüfung beider Plattformen | Vollzeilen-Write ersetzt HRV/Recovery durch NULL | Bestandsschutz vor der allgemeinen Persistenz | +| Altzeilen sind nicht sicher konvertierbar | Falsche biometrische Ergebnisse | Historie, Read-Policy und Datenmodell geprüft | Herkunft fehlt; korrekte Millisekunden und rohe Ticks können gemischt sein | Keine Rohdatenmigration | +| Ein exakter Status kann normale No-RR-Nächte unterscheiden | Alte Scores könnten unzulässig festgehalten werden | Vorhandene Zeit-, Owner-, Quarantäne- und Transportprädikate geprüft | Store besitzt alle erforderlichen Signale | Gemeinsame gekapselte Statusabfrage | + +## Akzeptanzmatrix + +| Scenario reference | Proof | Package | Status | Evidence | +| --- | --- | --- | --- | --- | +| rr-legacy-score-preservation / Legacy-Ergebnisse überleben Upgrade und Restore / Vorhandener Snapshot bleibt erhalten | Engine-Regressionstest je Plattform, restore-nahe SQLite-Fixture | 1 | proven | Android-Integrationstest grün; spiegelbildlicher Swift-Test liegt vor, Ausführung bleibt CI/macOS | +| rr-legacy-score-preservation / Legacy-Ergebnisse überleben Upgrade und Restore / Gewöhnlich fehlende RR bleiben leer | Negativtest je Plattform | 1 | proven | Android-Integrationstest grün; Swift-Gegenstück im Quelltest | +| rr-legacy-score-preservation / Legacy-Ergebnisse überleben Upgrade und Restore / Andere Geräte bleiben unverändert | WHOOP-4-/Fremdmarkentest | 1 | proven | Android-Integrationstest und bestehende Store-Tests grün; Swift-Gegenstück vorhanden | +| rr-legacy-score-preservation / Sichere Neuberechnung beendet den Bestandsschutz / Markierter Transport ersetzt den Snapshot | Promotionstest je Plattform | 1 | proven | Android-Promotion und Folge-Re-Score grün; Swift-Gegenstück vorhanden | +| rr-legacy-score-preservation / Sichere Neuberechnung beendet den Bestandsschutz / Unzureichende markierte Daten werden nicht kaschiert | Negativtest je Plattform | 1 | proven | Android-Negativtest grün; Swift-Gegenstück vorhanden | +| rr-legacy-score-preservation / Sichere Neuberechnung beendet den Bestandsschutz / Folge-Re-Score bleibt stabil | Wiederholungstest inklusive Provenienz | 1 | proven | Android-Provenienz-/Stabilitätstest grün; Swift-Gegenstück vorhanden | + +## Run Ledger + +| Phase | Rolle | Ergebnis | Budgetwirkung | +| --- | --- | --- | --- | +| Planung | Orchestrator mit zwei unabhängigen read-only Plattformanalysen | Ursache und kleinste sichere Semantik bestimmt | 0P; profilgemäß keine Planungsprüfung | +| 2026-09-12 01:05 CEST | Codex-Orchestrator, Session-Modell | hoch | Ausführungs-Preflight für r1 bestanden; Hostmodus Codex, Paket 1, Basis laut Arbeitsort, Skills-Stand d343278; 1A verbleibt | +| 2026-09-12 01:32 CEST | Implementer, gpt-5.6-sol | high | Paket integriert; Android rot vor Fix, danach gezielte Engine-, Store-, Schema-, Transaktions- und Bytecode-Prüfungen grün | +| 2026-09-12 01:33 CEST | Codex-Orchestrator, Session-Modell | hoch | Integrator-Smoke auf integriertem Stand grün; 43 Android-Tests über fünf betroffene Klassen, Swift lokal nicht verfügbar | +| 2026-09-12 01:41 CEST | Abschlussreview, gpt-5.6-sol | high | 1A verbraucht: 0 P0, 1 P1, 2 P2; Swift-Nebenwirkung auf Wochenableitungen bestätigt | +| 2026-09-12 01:48 CEST | Implementer, gpt-5.6-sol | high | P1 korrigiert; separater Swift-Persistenzpfad, Quellvertrag rot vor und grün nach Fix, Android-Regressionslauf grün | +| 2026-09-12 01:49 CEST | Codex-Orchestrator, Session-Modell | hoch | Fix integriert; Quellvertrag 2/2 grün, gebundener Android-Rerun 23/23 grün; 0A verbleibt | +| 2026-09-12 01:53 CEST | Codex-Orchestrator, Session-Modell | hoch | Draft-Pull-Request veröffentlicht; Realbackup, Hardware und Apple-Ausführung ausdrücklich als ungetestet markiert | +| 2026-09-12 01:54 CEST | Codex-Orchestrator, Session-Modell | hoch | Öffentlicher Testaufruf im bestehenden Issue veröffentlicht; Reporter angesprochen, Datenschutzgrenze genannt | + +## Finding Ledger + +| ID | Severity | Claim | Evidence/reproduction | Affected scenario/section | Status | Resolution | Verification | +| --- | --- | --- | --- | --- | --- | --- | --- | +| F1 | P1 | Swift ließ geschütztes HRV in Vitality/Body-Age einfließen, Android nicht. | Abschlussreview zeigte mutierte Arbeitsliste vor Wochenableitungen; Quellvertrag reproduzierte den Unterschied rot. | Vorhandener Snapshot bleibt erhalten; Plattformparität | resolved | Geschützte Swift-Tageswerte auf separate Persistenz-/Ausgabekopie begrenzt. | Quellvertrag 2/2 grün; Android 23/23 grün | +| F2 | P2 | Swift-App-Code konnte lokal nicht kompiliert oder ausgeführt werden. | Linux-Host ohne Swift/Xcode. | Gesamte Apple-Lieferung | accepted risk | Pull Request verlangt CI/macOS-Nachweis und nennt Hardware-/Realbackup-Pfad ungetestet. | Offen bis PR-CI/Nutzertest | +| F3 | P2 | Kein kombinierter E2E-Test für Alias nach Re-Pairing an DST-Grenze. | Store-/Scorer-Teiltests decken Signale getrennt ab. | Fenster- und Owner-Auflösung | accepted risk | Kein Scope-Ausbau; bestehende exakte Prädikate und fester Offset bleiben maßgeblich. | Revisit bei CI- oder Nutzertestabweichung | + +## Grenzen + +- Paket 1 besitzt Implementierung und Tests für beide Plattformen gemäß `tasks.md`. +- Ein Implementer arbeitet im Feature-Worktree; der Orchestrator integriert und prüft seriell. +- Ein unabhängiger Abschlussreview ist nach integrierter Umsetzung verpflichtend. +- Pull Request und Issue-Kommentar erfolgen erst nach lokaler Prüfung; kein Release wird erstellt. + +## Cleanup + +- Registriert: Paket-Worktree `/root/whoop/wt-rr-legacy-bestandsschutz-crossplatform` und Branch `bau/rr-legacy-bestandsschutz-crossplatform` nach erfolgreicher Integration entfernbar. +- Paket-Worktree nach Integration bereinigt; Feature-Worktree bleibt für Pull Request und Übergabe erhalten. + +## Externe Übergabe + +- Draft-Pull-Request: https://github.com/ryanbr/noop/pull/2115 +- Testaufruf: https://github.com/ryanbr/noop/issues/2101#issuecomment-5641963221 +- Kein Release und keine Store-Veröffentlichung durchgeführt. diff --git a/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/proposal.md b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/proposal.md new file mode 100644 index 0000000000..ef92cd8eae --- /dev/null +++ b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/proposal.md @@ -0,0 +1,53 @@ +# RR-Legacy-Bestandsschutz + +Revision: r1 + +## Warum + +NOOP 11.6.0 kennzeichnet neue WHOOP-5-RR-Daten nach Transport und wertet alte, unmarkierte RR-Zeilen +bewusst nicht mehr aus, weil der Altbestand Millisekunden und 1/1024-Sekunden-Ticks ununterscheidbar +mischen kann. Der einmalige neue Analyse-Fingerprint stößt nach Upgrade oder Restore einen Re-Score an. +Dabei werden vorhandene, unter 11.5 berechnete HRV- und Recovery-Werte durch leere Werte ersetzt. + +Nutzer, die direkt von 11.5 auf eine korrigierte Folgeversion aktualisieren, sollen ihre vorhandenen +historischen Ergebnisse behalten, bis eindeutig markierte RR-Daten eine sichere Neuberechnung erlauben. + +## Was sich ändert + +- Apple und Android erkennen konservativ eine Nacht, deren RR-Daten ausschließlich durch die neue + WHOOP-5-Legacy-Regel zurückgehalten werden. +- Für genau diese Nacht werden vorhandene berechnete HRV- und Recovery-Zellen als unveränderter + Legacy-Snapshot erhalten; alle anderen Tagesfelder werden weiterhin frisch berechnet. +- Sobald ein sicher auswertbarer markierter RR-Transport vorliegt, endet der Bestandsschutz und die + normale Neuberechnung gewinnt. +- Upgrade- und restore-nahe Regressionstests sichern die Semantik plattformgleich ab. +- Nach erfolgreicher lokaler Prüfung wird ein Pull Request erstellt und das bestehende öffentliche + Issue um einen Testaufruf ergänzt; der fehlende Test mit echter Nutzer-Sicherung und Hardware wird + dort ausdrücklich benannt. + +## Entscheidungen + +- E1: Ein direkter Upgrade-Pfad von 11.5 soll vorhandene HRV-/Recovery-Ergebnisse verlustfrei erhalten. + Quelle: Nutzer, 2026-09-12. +- E2: Alte RR-Rohzeilen werden weder heuristisch konvertiert noch pauschal wieder zum Scoring zugelassen. + Quelle: Nutzerklärung und bestätigte Datenambiguität, 2026-09-12. +- E3: Nach Abschluss werden Pull Request und öffentlicher Testaufruf erstellt; der Realbackup-/Hardwarepfad + wird als ungetestet ausgewiesen. Quelle: Nutzer, 2026-09-12. + +Der Bestandsschutz bewahrt HRV und Recovery exakt als zusammengehörigen historischen Snapshot. Recovery +wird nicht aus altem HRV und heutigen Baselines neu erfunden. Die allgemeine Upsert-Semantik bleibt +unverändert; die Entscheidung liegt im eng begrenzten WHOOP-5-Scoringpfad. + +## Nicht-Ziele + +- Keine Rekonstruktion bereits durch 11.6.0 verlorener Werte ohne ältere Sicherung oder erneuten Offload. +- Keine automatische Einheitenklassifikation oder Konvertierung unmarkierter RR-Rohdaten. +- Keine Änderung an WHOOP 4, anderen Marken oder gewöhnlichen Nächten ohne RR-Daten. +- Kein App-Release und keine Veröffentlichung in einem Store. + +## Auswirkungen + +Betroffen sind die RR-Statusabfrage, die Score-Persistenz und deren Tests auf Apple und Android. Die +Änderung berührt persistierte Nutzerdaten und Plattformparität, verändert aber weder das Datenbankschema +noch das Backupformat. Rollback ist durch Rücknahme des Changes möglich; erhaltene Legacy-Snapshots +bleiben dabei normale vorhandene Tageswerte. diff --git a/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/specs/rr-legacy-score-preservation/spec.md b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/specs/rr-legacy-score-preservation/spec.md new file mode 100644 index 0000000000..a6bf6ef116 --- /dev/null +++ b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/specs/rr-legacy-score-preservation/spec.md @@ -0,0 +1,57 @@ +# RR-Legacy-Score-Bestandsschutz + +Revision: r1 + +## Purpose + +Diese Fähigkeit verhindert, dass ein sicherheitsbedingtes Zurückhalten uneindeutiger WHOOP-5-RR-Rohdaten +bereits vorhandene historische HRV- und Recovery-Ergebnisse beim Upgrade oder Restore zerstört. + +## ADDED Requirements + +### Requirement: Legacy-Ergebnisse überleben Upgrade und Restore + +Das System MUST vorhandene berechnete HRV- und Recovery-Werte gemeinsam und unverändert erhalten, wenn +ein WHOOP-5-Re-Score ausschließlich deshalb kein HRV liefert, weil im exakten Lesefenster vorhandene +unmarkierte Legacy-RR-Daten durch die strikte Transportregel zurückgehalten werden. + +#### Scenario: Vorhandener Snapshot bleibt erhalten + +- **GIVEN** ein berechneter Tag mit HRV und Recovery sowie passende unmarkierte WHOOP-5-RR-Daten +- **WHEN** der nach Upgrade oder Restore ausgelöste Re-Score läuft +- **THEN** bleiben HRV und Recovery exakt erhalten, während andere Tagesfelder aktuell berechnet werden + +#### Scenario: Gewöhnlich fehlende RR bleiben leer + +- **GIVEN** ein Tag ohne zurückgehaltene Legacy-RR-Daten oder ohne vorhandenen HRV-Snapshot +- **WHEN** der Re-Score kein HRV berechnen kann +- **THEN** erfindet oder konserviert das System weder HRV noch Recovery + +#### Scenario: Andere Geräte bleiben unverändert + +- **GIVEN** unmarkierte RR-Daten eines bestätigten WHOOP-4-Geräts oder einer anderen Marke +- **WHEN** der Re-Score läuft +- **THEN** bleibt deren bisherige RR- und Score-Semantik unverändert + +### Requirement: Sichere Neuberechnung beendet den Bestandsschutz + +Das System MUST den Legacy-Bestandsschutz beenden, sobald das betroffene Fenster einen eindeutig markierten, +vom WHOOP-5-Scoring akzeptierten RR-Transport enthält. + +#### Scenario: Markierter Transport ersetzt den Snapshot + +- **GIVEN** ein zuvor geschützter Tag und neu synchronisierte markierte, ausreichende RR-Daten +- **WHEN** der nächste Re-Score läuft +- **THEN** ersetzen frisch berechnetes HRV und frisch berechnete Recovery den Legacy-Snapshot + +#### Scenario: Unzureichende markierte Daten werden nicht kaschiert + +- **GIVEN** ein zuvor gespeicherter Tageswert und markierte, aber fachlich unzureichende RR-Daten +- **WHEN** der Re-Score kein gültiges HRV ergibt +- **THEN** wird der alte Snapshot nicht durch die Legacy-Regel konserviert + +#### Scenario: Folge-Re-Score bleibt stabil + +- **GIVEN** ein geschützter oder nach Promotion frisch berechneter Tag ohne weitere Eingabeänderung +- **WHEN** ein weiterer Re-Score läuft +- **THEN** bleiben Ergebnis und Provenienz stabil diff --git a/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/tasks.md b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/tasks.md new file mode 100644 index 0000000000..88fdf854db --- /dev/null +++ b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/tasks.md @@ -0,0 +1,28 @@ +# Aufgaben: RR-Legacy-Bestandsschutz + +Revision: r1 + +## 1. Plattformgleicher Scoring-Bestandsschutz + +Eigentum: Apple- und Android-RR-Read-Policy, Intelligence-Engine-Persistenzpfad und gezielte Tests. +Verboten: allgemeine Backupformat- oder Datenbankschemaänderungen, pauschale RR-Konvertierung, +Release-/Store-Dateien und fachfremde Änderungen. + +Eingaben: bestehende strikte WHOOP-5-Policy, berechnete Tageswerte im Re-Score-Fenster und E1–E3. +Ausgabe: identische konservative Bestandsschutzentscheidung auf beiden Plattformen. +Abhängigkeiten: keine. Ressourcen: lokale Tests; kein Hardwarezugriff und keine Echtdaten erforderlich. + +- [x] 1.1 Zuerst rote Regressionstests für direkten Upgrade-/Restore-Bestand, gewöhnlich fehlende RR, + vorhandene markierte aber unzureichende RR, WHOOP 4 und Transport-Promotion ergänzen. +- [x] 1.2 Eine eng gekapselte Store-Abfrage für den exakten Legacy-zurückgehalten-Status implementieren, + deren Prädikate dem Scoring-Read entsprechen. +- [x] 1.3 Vorhandene berechnete Tageswerte fensterbegrenzt laden und ausschließlich HRV/Recovery gemeinsam + erhalten, wenn der Status dies erlaubt; Provenienz erhalten. +- [x] 1.4 Swift/Kotlin-Parität und stabile Folge-Re-Scores absichern. +- [x] 1.5 Betroffene Store-, Engine- und Paritätstests ausführen; vollständige App-Builds nur seriell. + +Szenarien: `rr-legacy-score-preservation / Legacy-Ergebnisse überleben Upgrade und Restore / Vorhandener +Snapshot bleibt erhalten`, `rr-legacy-score-preservation / Legacy-Ergebnisse überleben Upgrade und Restore / +Gewöhnlich fehlende RR bleiben leer`, `rr-legacy-score-preservation / Sichere Neuberechnung beendet den +Bestandsschutz / Markierter Transport ersetzt den Snapshot`, `rr-legacy-score-preservation / Sichere +Neuberechnung beendet den Bestandsschutz / Unzureichende markierte Daten werden nicht kaschiert`. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000000..82fe4cb4b2 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,28 @@ +schema: spec-driven + +context: | + Language: de + All artifacts must be written in de. + Keep OpenSpec structural headings and SHALL/MUST keywords in English. + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours + +# Per-operation guidance (optional) +# Add advisory guidance for how apply and archive work should be conducted. +# This is separate from artifact rules above. +# Example: +# operations: +# apply: +# guidance: +# - Keep test summaries concise +# archive: +# guidance: +# - Summarize the archive outcome before finishing diff --git a/openspec/specs/rr-legacy-score-preservation/spec.md b/openspec/specs/rr-legacy-score-preservation/spec.md new file mode 100644 index 0000000000..d7c70cb346 --- /dev/null +++ b/openspec/specs/rr-legacy-score-preservation/spec.md @@ -0,0 +1,54 @@ +# rr-legacy-score-preservation Specification + +## Purpose +Diese Fähigkeit verhindert, dass ein sicherheitsbedingtes Zurückhalten uneindeutiger WHOOP-5-RR-Rohdaten +bereits vorhandene historische HRV- und Recovery-Ergebnisse beim Upgrade oder Restore zerstört. + +## Requirements + +### Requirement: Legacy-Ergebnisse überleben Upgrade und Restore + +Das System MUST vorhandene berechnete HRV- und Recovery-Werte gemeinsam und unverändert erhalten, wenn +ein WHOOP-5-Re-Score ausschließlich deshalb kein HRV liefert, weil im exakten Lesefenster vorhandene +unmarkierte Legacy-RR-Daten durch die strikte Transportregel zurückgehalten werden. + +#### Scenario: Vorhandener Snapshot bleibt erhalten + +- **GIVEN** ein berechneter Tag mit HRV und Recovery sowie passende unmarkierte WHOOP-5-RR-Daten +- **WHEN** der nach Upgrade oder Restore ausgelöste Re-Score läuft +- **THEN** bleiben HRV und Recovery exakt erhalten, während andere Tagesfelder aktuell berechnet werden + +#### Scenario: Gewöhnlich fehlende RR bleiben leer + +- **GIVEN** ein Tag ohne zurückgehaltene Legacy-RR-Daten oder ohne vorhandenen HRV-Snapshot +- **WHEN** der Re-Score kein HRV berechnen kann +- **THEN** erfindet oder konserviert das System weder HRV noch Recovery + +#### Scenario: Andere Geräte bleiben unverändert + +- **GIVEN** unmarkierte RR-Daten eines bestätigten WHOOP-4-Geräts oder einer anderen Marke +- **WHEN** der Re-Score läuft +- **THEN** bleibt deren bisherige RR- und Score-Semantik unverändert + +### Requirement: Sichere Neuberechnung beendet den Bestandsschutz + +Das System MUST den Legacy-Bestandsschutz beenden, sobald das betroffene Fenster einen eindeutig markierten, +vom WHOOP-5-Scoring akzeptierten RR-Transport enthält. + +#### Scenario: Markierter Transport ersetzt den Snapshot + +- **GIVEN** ein zuvor geschützter Tag und neu synchronisierte markierte, ausreichende RR-Daten +- **WHEN** der nächste Re-Score läuft +- **THEN** ersetzen frisch berechnetes HRV und frisch berechnete Recovery den Legacy-Snapshot + +#### Scenario: Unzureichende markierte Daten werden nicht kaschiert + +- **GIVEN** ein zuvor gespeicherter Tageswert und markierte, aber fachlich unzureichende RR-Daten +- **WHEN** der Re-Score kein gültiges HRV ergibt +- **THEN** wird der alte Snapshot nicht durch die Legacy-Regel konserviert + +#### Scenario: Folge-Re-Score bleibt stabil + +- **GIVEN** ein geschützter oder nach Promotion frisch berechneter Tag ohne weitere Eingabeänderung +- **WHEN** ein weiterer Re-Score läuft +- **THEN** bleiben Ergebnis und Provenienz stabil