From 915158f2006aed15709fbc0307058acc6d61e845 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Sat, 12 Sep 2026 01:00:27 +0200 Subject: [PATCH 01/13] plan: preserve legacy WHOOP 5 scores across upgrade --- .../rr-legacy-bestandsschutz/design.md | 73 +++++++++++++++++++ .../rr-legacy-bestandsschutz/ledger.md | 61 ++++++++++++++++ .../rr-legacy-bestandsschutz/proposal.md | 53 ++++++++++++++ .../rr-legacy-score-preservation/spec.md | 57 +++++++++++++++ .../changes/rr-legacy-bestandsschutz/tasks.md | 28 +++++++ 5 files changed, 272 insertions(+) create mode 100644 openspec/changes/rr-legacy-bestandsschutz/design.md create mode 100644 openspec/changes/rr-legacy-bestandsschutz/ledger.md create mode 100644 openspec/changes/rr-legacy-bestandsschutz/proposal.md create mode 100644 openspec/changes/rr-legacy-bestandsschutz/specs/rr-legacy-score-preservation/spec.md create mode 100644 openspec/changes/rr-legacy-bestandsschutz/tasks.md diff --git a/openspec/changes/rr-legacy-bestandsschutz/design.md b/openspec/changes/rr-legacy-bestandsschutz/design.md new file mode 100644 index 0000000000..706c9bb0b2 --- /dev/null +++ b/openspec/changes/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/rr-legacy-bestandsschutz/ledger.md b/openspec/changes/rr-legacy-bestandsschutz/ledger.md new file mode 100644 index 0000000000..cb161ac796 --- /dev/null +++ b/openspec/changes/rr-legacy-bestandsschutz/ledger.md @@ -0,0 +1,61 @@ +# 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 | unproven | — | +| rr-legacy-score-preservation / Legacy-Ergebnisse überleben Upgrade und Restore / Gewöhnlich fehlende RR bleiben leer | Negativtest je Plattform | 1 | unproven | — | +| rr-legacy-score-preservation / Legacy-Ergebnisse überleben Upgrade und Restore / Andere Geräte bleiben unverändert | WHOOP-4-/Fremdmarkentest | 1 | unproven | — | +| rr-legacy-score-preservation / Sichere Neuberechnung beendet den Bestandsschutz / Markierter Transport ersetzt den Snapshot | Promotionstest je Plattform | 1 | unproven | — | +| rr-legacy-score-preservation / Sichere Neuberechnung beendet den Bestandsschutz / Unzureichende markierte Daten werden nicht kaschiert | Negativtest je Plattform | 1 | unproven | — | +| rr-legacy-score-preservation / Sichere Neuberechnung beendet den Bestandsschutz / Folge-Re-Score bleibt stabil | Wiederholungstest inklusive Provenienz | 1 | unproven | — | + +## 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 | + +## 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 + +- Noch offen: temporäre Testartefakte prüfen, Worktree nach Übergabe erhalten. diff --git a/openspec/changes/rr-legacy-bestandsschutz/proposal.md b/openspec/changes/rr-legacy-bestandsschutz/proposal.md new file mode 100644 index 0000000000..ef92cd8eae --- /dev/null +++ b/openspec/changes/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/rr-legacy-bestandsschutz/specs/rr-legacy-score-preservation/spec.md b/openspec/changes/rr-legacy-bestandsschutz/specs/rr-legacy-score-preservation/spec.md new file mode 100644 index 0000000000..a6bf6ef116 --- /dev/null +++ b/openspec/changes/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/rr-legacy-bestandsschutz/tasks.md b/openspec/changes/rr-legacy-bestandsschutz/tasks.md new file mode 100644 index 0000000000..82c9f89334 --- /dev/null +++ b/openspec/changes/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. + +- [ ] 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. +- [ ] 1.2 Eine eng gekapselte Store-Abfrage für den exakten Legacy-zurückgehalten-Status implementieren, + deren Prädikate dem Scoring-Read entsprechen. +- [ ] 1.3 Vorhandene berechnete Tageswerte fensterbegrenzt laden und ausschließlich HRV/Recovery gemeinsam + erhalten, wenn der Status dies erlaubt; Provenienz erhalten. +- [ ] 1.4 Swift/Kotlin-Parität und stabile Folge-Re-Scores absichern. +- [ ] 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`. From 060b9326712c8658de3cc4f0442455a1f64b550d Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Sat, 12 Sep 2026 01:02:24 +0200 Subject: [PATCH 02/13] approve: preserve legacy WHOOP 5 scores --- openspec/changes/rr-legacy-bestandsschutz/approval.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 openspec/changes/rr-legacy-bestandsschutz/approval.md diff --git a/openspec/changes/rr-legacy-bestandsschutz/approval.md b/openspec/changes/rr-legacy-bestandsschutz/approval.md new file mode 100644 index 0000000000..e7eab43fca --- /dev/null +++ b/openspec/changes/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“ From b2a944abdd4e40fdd9ddb41edb3df43927b6aaa2 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Sat, 12 Sep 2026 01:33:46 +0200 Subject: [PATCH 03/13] fix: preserve legacy WHOOP 5 scores --- .../WhoopStore/Whoop5RRReadPolicy.swift | 25 +++++ .../WhoopStoreTests/Whoop5RRStoreTests.swift | 37 +++++++ Strand/Data/IntelligenceEngine.swift | 101 ++++++++++++++++-- StrandTests/IntelligenceRRSourceTests.swift | 49 ++++++++- .../com/noop/analytics/IntelligenceEngine.kt | 1 + .../noop/analytics/IntelligencePersistence.kt | 81 +++++++++++++- .../src/main/java/com/noop/data/WhoopDao.kt | 12 +++ .../java/com/noop/data/WhoopRepository.kt | 11 ++ .../java/com/noop/data/Whoop5RRSqliteTest.kt | 80 ++++++++++++-- 9 files changed, 374 insertions(+), 23 deletions(-) diff --git a/Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift b/Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift index 61991964ca..b84d8cc9b0 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..9e18b758a7 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: [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: [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..38ed33bf80 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,21 @@ 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. + for index in dailies.indices { + let fresh = dailies[index] + guard let snapshot = legacySnapshots[fresh.day], fresh.avgHrv == nil, + (fresh.totalSleepMin ?? 0) > 0 else { continue } + dailies[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 @@ -2319,8 +2352,15 @@ final class IntelligenceEngine: ObservableObject { for daily in dailies { 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"] = @@ -2995,6 +3035,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/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")))))) From e07623a8b366bd112182beff3f977f70558102d5 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Sat, 12 Sep 2026 01:34:30 +0200 Subject: [PATCH 04/13] ledger: record execution preflight --- openspec/changes/rr-legacy-bestandsschutz/ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/rr-legacy-bestandsschutz/ledger.md b/openspec/changes/rr-legacy-bestandsschutz/ledger.md index cb161ac796..03c8c3e7f0 100644 --- a/openspec/changes/rr-legacy-bestandsschutz/ledger.md +++ b/openspec/changes/rr-legacy-bestandsschutz/ledger.md @@ -48,6 +48,7 @@ Worktree wt-rr-legacy-bestandsschutz, Branch feature/rr-legacy-bestandsschutz, a | 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 | ## Grenzen From 7e1785cfc09e81ffdb5ec7f6c8d3dba0661dc845 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Sat, 12 Sep 2026 01:35:44 +0200 Subject: [PATCH 05/13] ledger: bind integration evidence --- .../changes/rr-legacy-bestandsschutz/ledger.md | 17 ++++++++++------- .../changes/rr-legacy-bestandsschutz/tasks.md | 10 +++++----- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/openspec/changes/rr-legacy-bestandsschutz/ledger.md b/openspec/changes/rr-legacy-bestandsschutz/ledger.md index 03c8c3e7f0..05342453d9 100644 --- a/openspec/changes/rr-legacy-bestandsschutz/ledger.md +++ b/openspec/changes/rr-legacy-bestandsschutz/ledger.md @@ -36,12 +36,12 @@ Worktree wt-rr-legacy-bestandsschutz, Branch feature/rr-legacy-bestandsschutz, a | 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 | unproven | — | -| rr-legacy-score-preservation / Legacy-Ergebnisse überleben Upgrade und Restore / Gewöhnlich fehlende RR bleiben leer | Negativtest je Plattform | 1 | unproven | — | -| rr-legacy-score-preservation / Legacy-Ergebnisse überleben Upgrade und Restore / Andere Geräte bleiben unverändert | WHOOP-4-/Fremdmarkentest | 1 | unproven | — | -| rr-legacy-score-preservation / Sichere Neuberechnung beendet den Bestandsschutz / Markierter Transport ersetzt den Snapshot | Promotionstest je Plattform | 1 | unproven | — | -| rr-legacy-score-preservation / Sichere Neuberechnung beendet den Bestandsschutz / Unzureichende markierte Daten werden nicht kaschiert | Negativtest je Plattform | 1 | unproven | — | -| rr-legacy-score-preservation / Sichere Neuberechnung beendet den Bestandsschutz / Folge-Re-Score bleibt stabil | Wiederholungstest inklusive Provenienz | 1 | unproven | — | +| 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 @@ -49,6 +49,8 @@ Worktree wt-rr-legacy-bestandsschutz, Branch feature/rr-legacy-bestandsschutz, a | --- | --- | --- | --- | | 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 | ## Grenzen @@ -59,4 +61,5 @@ Worktree wt-rr-legacy-bestandsschutz, Branch feature/rr-legacy-bestandsschutz, a ## Cleanup -- Noch offen: temporäre Testartefakte prüfen, Worktree nach Übergabe erhalten. +- Registriert: Paket-Worktree `/root/whoop/wt-rr-legacy-bestandsschutz-crossplatform` und Branch `bau/rr-legacy-bestandsschutz-crossplatform` nach erfolgreicher Integration entfernbar. +- Noch offen: Paket-Worktree bereinigen, Feature-Worktree nach Übergabe erhalten. diff --git a/openspec/changes/rr-legacy-bestandsschutz/tasks.md b/openspec/changes/rr-legacy-bestandsschutz/tasks.md index 82c9f89334..88fdf854db 100644 --- a/openspec/changes/rr-legacy-bestandsschutz/tasks.md +++ b/openspec/changes/rr-legacy-bestandsschutz/tasks.md @@ -12,14 +12,14 @@ Eingaben: bestehende strikte WHOOP-5-Policy, berechnete Tageswerte im Re-Score-F Ausgabe: identische konservative Bestandsschutzentscheidung auf beiden Plattformen. Abhängigkeiten: keine. Ressourcen: lokale Tests; kein Hardwarezugriff und keine Echtdaten erforderlich. -- [ ] 1.1 Zuerst rote Regressionstests für direkten Upgrade-/Restore-Bestand, gewöhnlich fehlende RR, +- [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. -- [ ] 1.2 Eine eng gekapselte Store-Abfrage für den exakten Legacy-zurückgehalten-Status implementieren, +- [x] 1.2 Eine eng gekapselte Store-Abfrage für den exakten Legacy-zurückgehalten-Status implementieren, deren Prädikate dem Scoring-Read entsprechen. -- [ ] 1.3 Vorhandene berechnete Tageswerte fensterbegrenzt laden und ausschließlich HRV/Recovery gemeinsam +- [x] 1.3 Vorhandene berechnete Tageswerte fensterbegrenzt laden und ausschließlich HRV/Recovery gemeinsam erhalten, wenn der Status dies erlaubt; Provenienz erhalten. -- [ ] 1.4 Swift/Kotlin-Parität und stabile Folge-Re-Scores absichern. -- [ ] 1.5 Betroffene Store-, Engine- und Paritätstests ausführen; vollständige App-Builds nur seriell. +- [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 / From 069390278e4055f6f93942e26533063e4cd23b08 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Sat, 12 Sep 2026 01:49:51 +0200 Subject: [PATCH 06/13] fix: isolate legacy score persistence --- Strand/Data/IntelligenceEngine.swift | 15 ++--- .../test_rr_legacy_preservation_contract.py | 57 +++++++++++++++++++ 2 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 Tools/tests/test_rr_legacy_preservation_contract.py diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index 38ed33bf80..5fd997daee 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -2323,11 +2323,12 @@ 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. - for index in dailies.indices { - let fresh = dailies[index] + 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 } - dailies[index] = fresh.with(avgHrv: snapshot.avgHrv, recovery: snapshot.recovery) + persistedDailies[index] = fresh.with(avgHrv: snapshot.avgHrv, recovery: snapshot.recovery) appliedLegacySnapshots[fresh.day] = snapshot } for index in out.indices { @@ -2349,7 +2350,7 @@ 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 { if let snapshot = appliedLegacySnapshots[daily.day] { @@ -2386,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, @@ -2408,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) 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() From 98b91416105ba77e96dbe0fe6df976b68f2e58a8 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Sat, 12 Sep 2026 01:50:55 +0200 Subject: [PATCH 07/13] ledger: close final review finding --- openspec/changes/rr-legacy-bestandsschutz/ledger.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/openspec/changes/rr-legacy-bestandsschutz/ledger.md b/openspec/changes/rr-legacy-bestandsschutz/ledger.md index 05342453d9..bcc074a760 100644 --- a/openspec/changes/rr-legacy-bestandsschutz/ledger.md +++ b/openspec/changes/rr-legacy-bestandsschutz/ledger.md @@ -51,6 +51,17 @@ Worktree wt-rr-legacy-bestandsschutz, Branch feature/rr-legacy-bestandsschutz, a | 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 | + +## 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 @@ -62,4 +73,4 @@ Worktree wt-rr-legacy-bestandsschutz, Branch feature/rr-legacy-bestandsschutz, a ## Cleanup - Registriert: Paket-Worktree `/root/whoop/wt-rr-legacy-bestandsschutz-crossplatform` und Branch `bau/rr-legacy-bestandsschutz-crossplatform` nach erfolgreicher Integration entfernbar. -- Noch offen: Paket-Worktree bereinigen, Feature-Worktree nach Übergabe erhalten. +- Paket-Worktree nach Integration bereinigt; Feature-Worktree bleibt für Pull Request und Übergabe erhalten. From 5350b72968cdc5c94a89038df9e3541942eb3f51 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Sat, 12 Sep 2026 01:53:11 +0200 Subject: [PATCH 08/13] archive: rr legacy score preservation --- okf/index.md | 11 ++++++ okf/log.md | 7 ++++ okf/rr-legacy-bestandsschutz.md | 38 +++++++++++++++++++ .../approval.md | 0 .../design.md | 0 .../ledger.md | 0 .../proposal.md | 0 .../rr-legacy-score-preservation/spec.md | 0 .../tasks.md | 0 9 files changed, 56 insertions(+) create mode 100644 okf/index.md create mode 100644 okf/log.md create mode 100644 okf/rr-legacy-bestandsschutz.md rename openspec/changes/{rr-legacy-bestandsschutz => archive/2026-09-12-rr-legacy-bestandsschutz}/approval.md (100%) rename openspec/changes/{rr-legacy-bestandsschutz => archive/2026-09-12-rr-legacy-bestandsschutz}/design.md (100%) rename openspec/changes/{rr-legacy-bestandsschutz => archive/2026-09-12-rr-legacy-bestandsschutz}/ledger.md (100%) rename openspec/changes/{rr-legacy-bestandsschutz => archive/2026-09-12-rr-legacy-bestandsschutz}/proposal.md (100%) rename openspec/changes/{rr-legacy-bestandsschutz => archive/2026-09-12-rr-legacy-bestandsschutz}/specs/rr-legacy-score-preservation/spec.md (100%) rename openspec/changes/{rr-legacy-bestandsschutz => archive/2026-09-12-rr-legacy-bestandsschutz}/tasks.md (100%) 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/rr-legacy-bestandsschutz/approval.md b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/approval.md similarity index 100% rename from openspec/changes/rr-legacy-bestandsschutz/approval.md rename to openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/approval.md diff --git a/openspec/changes/rr-legacy-bestandsschutz/design.md b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/design.md similarity index 100% rename from openspec/changes/rr-legacy-bestandsschutz/design.md rename to openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/design.md diff --git a/openspec/changes/rr-legacy-bestandsschutz/ledger.md b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/ledger.md similarity index 100% rename from openspec/changes/rr-legacy-bestandsschutz/ledger.md rename to openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/ledger.md diff --git a/openspec/changes/rr-legacy-bestandsschutz/proposal.md b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/proposal.md similarity index 100% rename from openspec/changes/rr-legacy-bestandsschutz/proposal.md rename to openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/proposal.md diff --git a/openspec/changes/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 similarity index 100% rename from openspec/changes/rr-legacy-bestandsschutz/specs/rr-legacy-score-preservation/spec.md rename to openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/specs/rr-legacy-score-preservation/spec.md diff --git a/openspec/changes/rr-legacy-bestandsschutz/tasks.md b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/tasks.md similarity index 100% rename from openspec/changes/rr-legacy-bestandsschutz/tasks.md rename to openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/tasks.md From eaf543a3289d458deabceafeaaa3d7744ad71bb4 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Sat, 12 Sep 2026 01:53:26 +0200 Subject: [PATCH 09/13] spec: publish legacy score preservation --- openspec/config.yaml | 28 ++++++++++ .../rr-legacy-score-preservation/spec.md | 54 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 openspec/config.yaml create mode 100644 openspec/specs/rr-legacy-score-preservation/spec.md 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 From df6e4d589fe8d1f9b7030fa7207c72822750a840 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Sat, 12 Sep 2026 01:54:54 +0200 Subject: [PATCH 10/13] ledger: record PR and test request --- .../archive/2026-09-12-rr-legacy-bestandsschutz/ledger.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index bcc074a760..adf0a6c413 100644 --- a/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/ledger.md +++ b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/ledger.md @@ -54,6 +54,8 @@ Worktree wt-rr-legacy-bestandsschutz, Branch feature/rr-legacy-bestandsschutz, a | 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 02:00 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 02:01 CEST | Codex-Orchestrator, Session-Modell | hoch | Öffentlicher Testaufruf im bestehenden Issue veröffentlicht; Reporter angesprochen, Datenschutzgrenze genannt | ## Finding Ledger @@ -74,3 +76,9 @@ Worktree wt-rr-legacy-bestandsschutz, Branch feature/rr-legacy-bestandsschutz, a - 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. From 186622a52d0cb2a0539a3de1be830d1e9dc706e9 Mon Sep 17 00:00:00 2001 From: Bernd Helm Date: Sat, 12 Sep 2026 01:55:34 +0200 Subject: [PATCH 11/13] ledger: correct delivery timestamps --- .../archive/2026-09-12-rr-legacy-bestandsschutz/ledger.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index adf0a6c413..89316594c0 100644 --- a/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/ledger.md +++ b/openspec/changes/archive/2026-09-12-rr-legacy-bestandsschutz/ledger.md @@ -54,8 +54,8 @@ Worktree wt-rr-legacy-bestandsschutz, Branch feature/rr-legacy-bestandsschutz, a | 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 02:00 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 02:01 CEST | Codex-Orchestrator, Session-Modell | hoch | Öffentlicher Testaufruf im bestehenden Issue veröffentlicht; Reporter angesprochen, Datenschutzgrenze genannt | +| 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 From 97d15f64c1c91c6fae1178d0a534bb6e1ab5fbcd Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Sat, 12 Sep 2026 14:21:44 +1200 Subject: [PATCH 12/13] test: make the two closure captures explicit so the Swift tests compile `test (WhoopStore)` was red on a compile error rather than a logic failure: `arguments: [id]` inside `dbWriter.write { db in ... }` references the class's `private let id`, which an escaping closure has to spell as `self.id`. The Linux environment this branch was developed in cannot compile the Swift tests, so the file had never been through a compiler anywhere. With the two additions the whole package runs: 535 tests, 0 failures, including the new legacy-withheld coverage in this file. docs/BUILD.md has a snapshot-enabled SQLite recipe that makes WhoopStore, StrandAnalytics, StrandImport and NoopLocalAccess build AND test on Linux, which is what this was verified with. --- .../WhoopStore/Tests/WhoopStoreTests/Whoop5RRStoreTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RRStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RRStoreTests.swift index 9e18b758a7..c5ff1f53cd 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RRStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RRStoreTests.swift @@ -142,13 +142,13 @@ final class Whoop5RRStoreTests: XCTestCase { "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: [id]) + 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: [id]) + arguments: [self.id]) } _ = try await s.insert(Streams(rr: [RRInterval(ts: 150, rrMs: 990, From 0589f748bb707d682c34b9cad66493e83cfc2a74 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Sat, 12 Sep 2026 14:37:01 +1200 Subject: [PATCH 13/13] tools: re-derive the parity snapshots for this branch Maintainer commit, not the PR author's work: these are generated files and the branch's environment cannot produce them, so they are regenerated here rather than asked for. The gate was red for two reasons, neither of them this branch's doing and only one of them fixable on main. The checked-in snapshots were stale before this branch existed, which main has since corrected. And the authority pins an exact count and hash of the whole inventory, so the three functions this branch adds (legacyWhoop5RRWithheld and its two Kotlin twins) invalidate it again no matter how correct they are. Derived from a pristine clone of this branch merged with main, because the scanner walks the filesystem rather than the index and local .build directories are counted. 486 files, 4308 functions, 151 twin pairs, 295 findings. Findings are UNCHANGED from main: the new declarations are reached from production, so none of them lands as test-only debt. Verified in that clone: 105 of 105. --- Tools/parity_twin_map.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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"} } }