Repository navigation
perf(rescore): stop a post-sync pass re-scoring nights and cycles whose inputs did not change - #2293
Conversation
caa9fd4 to
5ef4775
Compare
|
Thanks @Iskrata, this is careful work: a measured problem, a named cost, both What holds upThe habitual-sleep change is parity-correct. Both platforms filter on The load cache key is complete. The owner set is encoded in the witness The profile interpolation is value-based, which was my main worry. Swift The empty-witness path is consistent. When One code noteThe key's coverage of the profile rests on the DEFAULT description of both types. One thing worth namingThis is filed as performance, and it is, but learning only from finished nights also What is blocking it, which is not your doingThis branch is based on That is the failure mode the workflow header calls out, and the scheduled run on main Nothing above needs action from you yet beyond the profile-key note, which is your |
The scheduled parity-governance run on main went red this morning on test_checked_metadata_is_compact_v3_and_expands_losslessly: the checked-in authority no longer reproduced from the current sources. The cause was #2348, which added a Swift helper inside a governed root without a re-derive. Nothing went red at the time because the workflow deliberately excludes product source from its path filters, so the merge that caused the drift is never the one that reports it. The schedule exists for exactly that, and it did its job. The numbers move by that one function: functions 4453 to 4454, unpaired_functions 4107 to 4108. The baseline file is untouched and the refresh reports the same 300 known findings, so no debt changed hands. --refresh-derived alone refuses here, because the base's stored manifest cannot be reproduced at all, and --repair-stale-base cannot help either: repair is for a base whose governed state matches and whose metadata drifted, which is not this. So this is --refresh-derived --migrate-authority, which waives only the reproducibility of the base manifest and no semantic debt. Every one-sided declaration still needs its own issue-bound disposition, and the tool refuses to migrate onto anything but an exactly derived current authority. This was blocking an outside contributor: PR #2293 branched before #2348 and carries a correct authority for its own three additions, so on a rebase it would have reported a mismatch of one function it never touched. That is the failure the workflow header warns about, and this clears it. Verification: the parity-governance suite CI runs, 124 tests, OK.
|
Main is repaired, so this is unblocked. Reporting the numbers as promised.
The remedy was What that means for this branch: your 4453 to 4456 was correct for the three The review above stands unchanged: the two changes are sound, the twins agree, and |
|
Reviewed at 5ef4775. Three things in here looked wrong on a first read and turned out to be fine, so let me record those first, because the names invite the wrong conclusion and the next reader will land in the same places.
Also verified: Swift declines the cache when a fingerprint read failed ( 1. This is a behaviour change, not only a performance one
I think the change is right. A night still being slept is not a habit, and learning from a session that every sync re-banks while it grows is what produced the churn in the first place. Your doc comments are honest about the mechanism. But the PR is typed "Bug fix (performance / battery)" and the body reads as a pure speedup, and this moves three user-visible numbers. Worth saying so in the description so it is on the record rather than discovered later. Note #2350's open anchor half touches 2. Worth connecting to #2360That is an open background-battery report on a 4.0, and the two costs there are distinct: the radio side, which is the history-offload protocol gap, and the app side. Your 8 813 s and 2 345 s passes are the app side, and 96 s down to 2.9 s on an unchanged re-pass is the most concrete battery improvement anyone has put up this week. The replay methodology, same database copy before and after, with the caveat that it is a Debug build so the numbers are comparable to each other and not to a phone, is exactly the right shape for this claim. 3. Minor
HousekeepingDirty, 32 behind, carrying a One pattern across your three open PRs: this, #2284 and #2285 all report the same two Approving after the rebase, with point 1 in the description. |
5ef4775 to
73a6004
Compare
|
Rebased onto main (
On Locally: |
…ync stops dropping the day cache
…ies instead of re-reading its heart rate every pass
… and reuse a closed cycle's Effort and calories
…scription String(describing:) of the struct was value-sensitive but its format is not a contract and it ran reflection per cycle per pass. UserProfile.cacheKey names every stored field (bit patterns for doubles) on both platforms, with a test per field.
73a6004 to
0085da5
Compare
…e toggle ryanbr#2293's day-cycle load cache keys a cycle's Effort and calories on the window, the per-owner HR witness and the scoring profile. Once the MET-calories toggle routes a cycle's calories through the ring's MET series, that key cannot see what the figure depends on: - a later drain that banks MET minutes inside an ended window leaves the HR witness unchanged, so the cache keeps serving calories from the thinner stream (or a withheld nil, when the first pass was under the floor); - a toggle flip over unchanged data matches the entry cached under the other setting and serves the previous mode's figure. Both platforms now append a MET witness to the key: `met=off` when the toggle is off, otherwise `met=<owner>=<count>:<maxTs>` from an index-only aggregate over ouraMetSample, the same shape as the HR witness. Swift adds WhoopStore.ouraMetFingerprint(deviceId:from:to:), injected into the fold as MetFingerprint beside MetReader, and an unread witness never serves a cached load. Kotlin adds the countOuraMetInWindow / maxOuraMetTsInWindow DAO pair and WhoopRepository.ouraMetFingerprint, and loadCacheKey takes the witness. The rebase onto ryanbr#2293 moved the fold's MET-vs-HR decision into the cache-miss branch as cycleCalories() on both platforms, unchanged in behaviour. Tests: OuraMetStoreTests fingerprint (count/maxTs, inclusive bounds, per device, moves on an in-window insert); DayCycleRecoveryTests and DayCycleMetCaloriesTest each gain "MET banked after a pass is not served from the cache" and "a toggle flip over unchanged data rescores the cycle"; RescoreUnchangedInputsTest pins the witness in the key; Kotlin repo fingerprint. With the witness replaced by a constant, 4 of 6 DayCycleMetCaloriesTest cases fail. Parity: loadCacheKey/8 replaces /7 in the dispositions, the two DAO primitives are platform_specific like their HR counterparts; ratchet 0 errors, ledger no new findings. Refs ryanbr#2242 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq
…e toggle ryanbr#2293's day-cycle load cache keys a cycle's Effort and calories on the window, the per-owner HR witness and the scoring profile. Once the MET-calories toggle routes a cycle's calories through the ring's MET series, that key cannot see what the figure depends on: - a later drain that banks MET minutes inside an ended window leaves the HR witness unchanged, so the cache keeps serving calories from the thinner stream (or a withheld nil, when the first pass was under the floor); - a toggle flip over unchanged data matches the entry cached under the other setting and serves the previous mode's figure. Both platforms now append a MET witness to the key: `met=off` when the toggle is off, otherwise `met=<owner>=<count>:<maxTs>` from an index-only aggregate over ouraMetSample, the same shape as the HR witness. Swift adds WhoopStore.ouraMetFingerprint(deviceId:from:to:), injected into the fold as MetFingerprint beside MetReader, and an unread witness never serves a cached load. Kotlin adds the countOuraMetInWindow / maxOuraMetTsInWindow DAO pair and WhoopRepository.ouraMetFingerprint, and loadCacheKey takes the witness. The rebase onto ryanbr#2293 moved the fold's MET-vs-HR decision into the cache-miss branch as cycleCalories() on both platforms, unchanged in behaviour. Tests: OuraMetStoreTests fingerprint (count/maxTs, inclusive bounds, per device, moves on an in-window insert); DayCycleRecoveryTests and DayCycleMetCaloriesTest each gain "MET banked after a pass is not served from the cache" and "a toggle flip over unchanged data rescores the cycle"; RescoreUnchangedInputsTest pins the witness in the key; Kotlin repo fingerprint. With the witness replaced by a constant, 4 of 6 DayCycleMetCaloriesTest cases fail. Parity: loadCacheKey/8 replaces /7 in the dispositions, the two DAO primitives are platform_specific like their HR counterparts; ratchet 0 errors, ledger no new findings. Refs ryanbr#2242 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq (cherry picked from commit a2031f9)
…e toggle ryanbr#2293's day-cycle load cache keys a cycle's Effort and calories on the window, the per-owner HR witness and the scoring profile. Once the MET-calories toggle routes a cycle's calories through the ring's MET series, that key cannot see what the figure depends on: - a later drain that banks MET minutes inside an ended window leaves the HR witness unchanged, so the cache keeps serving calories from the thinner stream (or a withheld nil, when the first pass was under the floor); - a toggle flip over unchanged data matches the entry cached under the other setting and serves the previous mode's figure. Both platforms now append a MET witness to the key: `met=off` when the toggle is off, otherwise `met=<owner>=<count>:<maxTs>` from an index-only aggregate over ouraMetSample, the same shape as the HR witness. Swift adds WhoopStore.ouraMetFingerprint(deviceId:from:to:), injected into the fold as MetFingerprint beside MetReader, and an unread witness never serves a cached load. Kotlin adds the countOuraMetInWindow / maxOuraMetTsInWindow DAO pair and WhoopRepository.ouraMetFingerprint, and loadCacheKey takes the witness. The rebase onto ryanbr#2293 moved the fold's MET-vs-HR decision into the cache-miss branch as cycleCalories() on both platforms, unchanged in behaviour. Tests: OuraMetStoreTests fingerprint (count/maxTs, inclusive bounds, per device, moves on an in-window insert); DayCycleRecoveryTests and DayCycleMetCaloriesTest each gain "MET banked after a pass is not served from the cache" and "a toggle flip over unchanged data rescores the cycle"; RescoreUnchangedInputsTest pins the witness in the key; Kotlin repo fingerprint. With the witness replaced by a constant, 4 of 6 DayCycleMetCaloriesTest cases fail. Parity: loadCacheKey/8 replaces /7 in the dispositions, the two DAO primitives are platform_specific like their HR counterparts; ratchet 0 errors, ledger no new findings. Refs ryanbr#2242 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq (cherry picked from commit a2031f9)
…e toggle ryanbr#2293's day-cycle load cache keys a cycle's Effort and calories on the window, the per-owner HR witness and the scoring profile. Once the MET-calories toggle routes a cycle's calories through the ring's MET series, that key cannot see what the figure depends on: - a later drain that banks MET minutes inside an ended window leaves the HR witness unchanged, so the cache keeps serving calories from the thinner stream (or a withheld nil, when the first pass was under the floor); - a toggle flip over unchanged data matches the entry cached under the other setting and serves the previous mode's figure. Both platforms now append a MET witness to the key: `met=off` when the toggle is off, otherwise `met=<owner>=<count>:<maxTs>` from an index-only aggregate over ouraMetSample, the same shape as the HR witness. Swift adds WhoopStore.ouraMetFingerprint(deviceId:from:to:), injected into the fold as MetFingerprint beside MetReader, and an unread witness never serves a cached load. Kotlin adds the countOuraMetInWindow / maxOuraMetTsInWindow DAO pair and WhoopRepository.ouraMetFingerprint, and loadCacheKey takes the witness. The rebase onto ryanbr#2293 moved the fold's MET-vs-HR decision into the cache-miss branch as cycleCalories() on both platforms, unchanged in behaviour. Tests: OuraMetStoreTests fingerprint (count/maxTs, inclusive bounds, per device, moves on an in-window insert); DayCycleRecoveryTests and DayCycleMetCaloriesTest each gain "MET banked after a pass is not served from the cache" and "a toggle flip over unchanged data rescores the cycle"; RescoreUnchangedInputsTest pins the witness in the key; Kotlin repo fingerprint. With the witness replaced by a constant, 4 of 6 DayCycleMetCaloriesTest cases fail. Parity: loadCacheKey/8 replaces /7 in the dispositions, the two DAO primitives are platform_specific like their HR counterparts; ratchet 0 errors, ledger no new findings. Refs ryanbr#2242 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq
…e toggle ryanbr#2293's day-cycle load cache keys a cycle's Effort and calories on the window, the per-owner HR witness and the scoring profile. Once the MET-calories toggle routes a cycle's calories through the ring's MET series, that key cannot see what the figure depends on: - a later drain that banks MET minutes inside an ended window leaves the HR witness unchanged, so the cache keeps serving calories from the thinner stream (or a withheld nil, when the first pass was under the floor); - a toggle flip over unchanged data matches the entry cached under the other setting and serves the previous mode's figure. Both platforms now append a MET witness to the key: `met=off` when the toggle is off, otherwise `met=<owner>=<count>:<maxTs>` from an index-only aggregate over ouraMetSample, the same shape as the HR witness. Swift adds WhoopStore.ouraMetFingerprint(deviceId:from:to:), injected into the fold as MetFingerprint beside MetReader, and an unread witness never serves a cached load. Kotlin adds the countOuraMetInWindow / maxOuraMetTsInWindow DAO pair and WhoopRepository.ouraMetFingerprint, and loadCacheKey takes the witness. The rebase onto ryanbr#2293 moved the fold's MET-vs-HR decision into the cache-miss branch as cycleCalories() on both platforms, unchanged in behaviour. Tests: OuraMetStoreTests fingerprint (count/maxTs, inclusive bounds, per device, moves on an in-window insert); DayCycleRecoveryTests and DayCycleMetCaloriesTest each gain "MET banked after a pass is not served from the cache" and "a toggle flip over unchanged data rescores the cycle"; RescoreUnchangedInputsTest pins the witness in the key; Kotlin repo fingerprint. With the witness replaced by a constant, 4 of 6 DayCycleMetCaloriesTest cases fail. Parity: loadCacheKey/8 replaces /7 in the dispositions, the two DAO primitives are platform_specific like their HR counterparts; ratchet 0 errors, ledger no new findings. Refs ryanbr#2242 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq
…default-off Experimental toggle (ryanbr#2242) [1/7] feat(analytics): day energy from a MET stream — Oura's documented rule, no fitted constant Adds Calories.estimateDayEnergyFromMET (Swift) / estimateDayEnergyFromMet (Kotlin): active = Σ_{met ≥ 1.5} (met − 1.5) × 0.0175 kcal·kg⁻¹·min⁻¹ × weight over the day's samples — Oura's support wording ("the portion that exceeds 1.5 MET") at the standard MET→kcal definition (1 MET = 3.5 ml O₂·kg⁻¹·min⁻¹). Resting is the same revised Harris–Benedict the HR path uses, scaled by covered seconds; coverage is reported over the window the caller passes so today's partial day is judged against elapsed hours. Missing minutes are unknown — never extrapolated to activity, never banked as rest. The rule was identified, not fitted: on the Oura export's own per-minute MET series it reproduces active_calories with r = 1.0000 and 0.6 kcal/day RMSE over 75 current-era days (r 0.9999 / 3 kcal over 396 pre-2025 days), zero intercept, the only input being the wearer's weight. A re-served minute counts once. The ring's timestamps go through a per-session 0x13 anchor, so a record served again under a second session lands 2–5 s off its first copy, while the ring's own minute grid steps back a second between records (a real successor starts 59–61 s on). Half a period tells them apart: two samples are the same minute when |Δ| × 2 < min(epochS), and the walk compares against the last counted start. Kotlin is pinned to Swift by oracle: the real Calories enum compiled standalone over a case spread (84 output lines) (four profiles × empty / rest floor / bout / 1.5 threshold edge / decoder boundary / partial coverage / 120 s cadence / duplicate ts / 3-s twins / 59-s phase steps / window edges / partial today / DST 23 h / clamp / bad epochs / realistic day), stdout pasted verbatim into MetCaloriesOracleTest. MetCaloriesTests pins the Swift side with hand-derived literals. [2/7] feat(store): persist the Oura ring's per-minute MET series — ouraMetSample, both platforms Until now the 0x50 MET stream existed only as the diagnostic JSONL sidecar, so nothing scored could read it. GRDB v47-oura-met-sample / Room MIGRATION_40_41 (schema 41) add ouraMetSample(deviceId, ts, met REAL, state, epochS) keyed (deviceId, ts), column order identical on both sides and pinned in schema_oracle.json (both copies). epochS is carried per row so a cadence other than 60 s scales the estimate rather than skewing it. Insert drops a re-served twin: the (deviceId, ts) key alone does not catch a record served again under another session anchor (it lands a few seconds off), so the insert reads the stored rows around the batch and drops any incoming sample within half a period of a stored one or of one accepted earlier in the batch (earlier start wins, lower MET on an exact tie). The pure rule is OuraMetSample.droppingTwins / OuraMetSampleEntity.droppingTwins, same integer test as the estimator, testable without a database. A 59-s phase-step successor is kept. Swift: OuraMetStore (insert, bounded range read, count), the table on DeviceRegistryStore.deviceScopedTables so forgetting a ring clears it. Android: entity, DAO insert/read/delete, DeviceRegistry.deleteDeviceData wiring, repository wrappers, the committed v41 Room schema for the upgrade test. Tests: OuraMetStoreTests (Swift), OuraMetSampleMigrationTest (Kotlin, incl. the twin rule); SchemaOracleTest / WhoopDatabaseUpgradeTest / the device-scoped-table guards on both sides. [3/7] feat(analytics): analyzeDay scores calories from the owner's MET series when supplied analyzeDay gains dayMet (the day owner's own per-minute MET samples), dayMetNow (so today's coverage is judged against elapsed hours, not 24 h) and a caloriesDiag sink. Rule: MET samples present ⇒ activeKcalEst = estimateDayEnergyFromMET(...).totalKcal when the stream covers ≥ 50 % of the window; below that the estimate is withheld (nil) rather than substituted by the HR path, which on a ring day runs over sparse banked HR and does not track Oura's number (r ≈ −0.1). nil/empty dayMet keeps the HR path byte-identical — every WHOOP and pure-function caller is unchanged. One always-on log line per MET day. Both platforms; AnalyticsEngineMetCaloriesTests / AnalyticsEngineMetCaloriesTest pin the selection, the withhold, the elapsed-hours window and the byte-identical no-MET path. [4/7] feat(oura): score active calories from the ring's MET records, behind an Experimental toggle The toggle (iOS AppModel.ouraMetCaloriesKey / Android NoopPrefs.KEY_OURA_MET_CALORIES, default OFF) gates: • the writer — OuraLiveSource fans each anchored 0x50 record out to ouraMetSample, one row per minute, next to the existing JSONL sidecar call. The record's timestamp is the END of its last sample: fitted against Oura's own per-minute export, every record length n matched best at exactly −n minutes (85 % exact minute matches, r 0.90, vs 23 % / 0.57 read forward), so sample i starts at utc − (n − i)·60 and a record straddling local midnight lands its minutes on the right days. • the read — IntelligenceEngine reads the day owner's rows (registry active id, calendar- day scoped like dayHr) and hands them to analyzeDay as dayMet, with dayMetNow = now; the calories line goes through the same per-day diag recorder as the Effort funnel. • the day-cycle fold — DayCycleIntelligenceIntegration (and Android's PhysiologicalStepCycleEngine) recomputed Keytel for every cycle and wrote it over activeKcalEst, which would have hidden the MET decision on any phone with a day-cycle history. The fold now takes the same decision analyzeDay takes, over its own window [onset, min(endExclusive, now)): covered → the MET total, thin → withheld with no HR substitute, no rows or toggle off → Keytel byte for byte. A "stepsCycle calories … path=met" trace line names the decision. The toggle joins the day-cache config signature on both platforms so a flip re-scores every cached day. Android threads the flag Context-free like spo2CandidateDisplay, carries it into the pass on a field and reads the MET rows inside readDaySkinAndWristOff, and hands the fold a Boolean rather than a lambda — all to stay inside analyzeRecentOnCpu's JaCoCo budget (a new suspend call there spills ~0.7 K instructions of continuation state). Settings gains "Experimental · Oura Calories" (iOS card / Android row), shown only with an Oura ring, caption stating the method, the < 50 % coverage withhold, that Oura re-scores a logged workout's minutes by activity type (NOOP does not, so such a day reads lower), and that turning it on starts storing MET samples. Strings in de/es/fr/pt-PT plus it/pl/ru/zh-Hans/ zh-Hant (iOS) and pl/ru/zh (Android). Docs: OURA_PROTOCOL.md §6.13 (day-sum validation of the 0x50 decode, end-of-record timestamps, the formula); README Oura table "Active calories". OFF keeps both the DB and every score byte-identical. Tests: DayCacheConfigFieldTests / DayCacheConfigFieldTest; DayCycleRecoveryTests (Swift, xcodebuild test) and DayCycleMetCaloriesTest (Kotlin, Robolectric + Room) on a seeded 8-h main sleep: covered cycle carries the MET total, 10 %-covered is withheld, no reader / empty table = Keytel. [5/7] feat(oura): pair the MET store with its twins in the parity ledger Review follow-up. The parity ledger flagged the MET store's Kotlin reads and writes as one-sided; run in full it also flagged the MET toggle's arity changes to existing functions, which read as new identities. Paired, with twin claims where the ledger reads them: - `WhoopRepository.insertOuraMetSamples` <-> `WhoopStore.insertOuraMetSamples` - `WhoopRepository.ouraMetSamples` <-> `WhoopStore.ouraMetSamples` (on the Repository, since the DAO's `@Query` puts its declaration outside the claim's attach window) - `AnalyticsEngine.analyzeDay`, `IntelligenceEngine.analyzeRecent` and `DayCycleIntelligenceIntegration.compute`, whose parameter lists grew here Removed: `WhoopStore.ouraMetSampleCount` and `WhoopDao.countOuraMetFor`. Neither had a production caller (the Kotlin one had no caller at all); the store tests count through `ouraMetSamples` instead. `platform_specific` dispositions for what is Kotlin-only by construction: the Room `@Insert` and `@Query` primitives under the paired Repository functions, the per-table forget-device delete (Swift clears the table through the `DeviceRegistryStore` name list), the Room migration override (its GRDB twin is the `v47-oura-met-sample` closure, pinned by the shared schema oracle), and the two JVM 64 KiB extractions `readDaySkinAndWristOff` and `PhysiologicalStepCycleEngine.compute`. The authority is re-derived with `--migrate-authority` because `main` at 5783c49 no longer reproduces its stored one. Verified: `parity_ledger.py` OK; `parity_ratchet.py --migrate-authority` 0 errors; `test_parity_*` 180 OK; StrandAnalytics 2068/0; WhoopStore 617/0; macOS `StrandTests` (DayCacheConfigField, DayCycleRecovery) 17/0; `NOOPiOS` builds; Android 6402 tests, the 8 failures reproduce identically on `upstream/main`; doc lint and `i18n_audit --ci` clean. [6/7] doc(calories): say at the MET assignment that activeKcalEst holds a total Review follow-up. `activeKcalEst = met.totalKcal` reads like a category error: the estimate carries the active share separately. It is correct, because the field is a pre-existing misnomer and the HR path's `estimateDayCalories` already stores `estimateDayEnergy(...).totalKcal` in it. That is now written at both `analyzeDay` sites, so the next reader does not have to work it out again. Comment only. [7/7] fix(oura): key an ended cycle's MET calories on the MET series and the toggle the per-owner HR witness and the scoring profile. Once the MET-calories toggle routes a cycle's calories through the ring's MET series, that key cannot see what the figure depends on: - a later drain that banks MET minutes inside an ended window leaves the HR witness unchanged, so the cache keeps serving calories from the thinner stream (or a withheld nil, when the first pass was under the floor); - a toggle flip over unchanged data matches the entry cached under the other setting and serves the previous mode's figure. Both platforms now append a MET witness to the key: `met=off` when the toggle is off, otherwise `met=<owner>=<count>:<maxTs>` from an index-only aggregate over ouraMetSample, the same shape as the HR witness. Swift adds WhoopStore.ouraMetFingerprint(deviceId:from:to:), injected into the fold as MetFingerprint beside MetReader, and an unread witness never serves a cached load. Kotlin adds the countOuraMetInWindow / maxOuraMetTsInWindow DAO pair and WhoopRepository.ouraMetFingerprint, and loadCacheKey takes the witness. The rebase onto ryanbr#2293 moved the fold's MET-vs-HR decision into the cache-miss branch as cycleCalories() on both platforms, unchanged in behaviour. Tests: OuraMetStoreTests fingerprint (count/maxTs, inclusive bounds, per device, moves on an in-window insert); DayCycleRecoveryTests and DayCycleMetCaloriesTest each gain "MET banked after a pass is not served from the cache" and "a toggle flip over unchanged data rescores the cycle"; RescoreUnchangedInputsTest pins the witness in the key; Kotlin repo fingerprint. With the witness replaced by a constant, 4 of 6 DayCycleMetCaloriesTest cases fail. Parity: loadCacheKey/8 replaces /7 in the dispositions, the two DAO primitives are platform_specific like their HR counterparts; ratchet 0 errors, ledger no new findings. Rebase onto main at ca24978: main's ryanbr#2472 grew analyzeRecentOnCpu to 55,559 instrumented bytes, which left 141 against the 55,700 JaCoCo budget, and this change measured 239. The day's MET rows now reach analyzeDay through a pass field (ouraMetDayForPass, written by readDaySkinAndWristOff), not through the DaySkinReads holder. On the holder, the holder stayed live across every later suspension point of the day loop and was spilled and restored at each one. The helper derives the calendar-day window from dayStart itself (same 14-parameter arity). Measured 55,669. Refs ryanbr#2242 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq Claude-Session: https://claude.ai/code/session_01484Ef871BnAuniyDJYYHoV Claude-Session: https://claude.ai/code/session_01RgqTwKfXLXzKo81ECb4aDz Claude-Session: https://claude.ai/code/session_01RuJdFu6iobsWfRtPYUR4C8
…default-off Experimental toggle (ryanbr#2242) [1/7] feat(analytics): day energy from a MET stream — Oura's documented rule, no fitted constant Adds Calories.estimateDayEnergyFromMET (Swift) / estimateDayEnergyFromMet (Kotlin): active = Σ_{met ≥ 1.5} (met − 1.5) × 0.0175 kcal·kg⁻¹·min⁻¹ × weight over the day's samples — Oura's support wording ("the portion that exceeds 1.5 MET") at the standard MET→kcal definition (1 MET = 3.5 ml O₂·kg⁻¹·min⁻¹). Resting is the same revised Harris–Benedict the HR path uses, scaled by covered seconds; coverage is reported over the window the caller passes so today's partial day is judged against elapsed hours. Missing minutes are unknown — never extrapolated to activity, never banked as rest. The rule was identified, not fitted: on the Oura export's own per-minute MET series it reproduces active_calories with r = 1.0000 and 0.6 kcal/day RMSE over 75 current-era days (r 0.9999 / 3 kcal over 396 pre-2025 days), zero intercept, the only input being the wearer's weight. A re-served minute counts once. The ring's timestamps go through a per-session 0x13 anchor, so a record served again under a second session lands 2–5 s off its first copy, while the ring's own minute grid steps back a second between records (a real successor starts 59–61 s on). Half a period tells them apart: two samples are the same minute when |Δ| × 2 < min(epochS), and the walk compares against the last counted start. Kotlin is pinned to Swift by oracle: the real Calories enum compiled standalone over a case spread (84 output lines) (four profiles × empty / rest floor / bout / 1.5 threshold edge / decoder boundary / partial coverage / 120 s cadence / duplicate ts / 3-s twins / 59-s phase steps / window edges / partial today / DST 23 h / clamp / bad epochs / realistic day), stdout pasted verbatim into MetCaloriesOracleTest. MetCaloriesTests pins the Swift side with hand-derived literals. [2/7] feat(store): persist the Oura ring's per-minute MET series — ouraMetSample, both platforms Until now the 0x50 MET stream existed only as the diagnostic JSONL sidecar, so nothing scored could read it. GRDB v47-oura-met-sample / Room MIGRATION_40_41 (schema 41) add ouraMetSample(deviceId, ts, met REAL, state, epochS) keyed (deviceId, ts), column order identical on both sides and pinned in schema_oracle.json (both copies). epochS is carried per row so a cadence other than 60 s scales the estimate rather than skewing it. Insert drops a re-served twin: the (deviceId, ts) key alone does not catch a record served again under another session anchor (it lands a few seconds off), so the insert reads the stored rows around the batch and drops any incoming sample within half a period of a stored one or of one accepted earlier in the batch (earlier start wins, lower MET on an exact tie). The pure rule is OuraMetSample.droppingTwins / OuraMetSampleEntity.droppingTwins, same integer test as the estimator, testable without a database. A 59-s phase-step successor is kept. Swift: OuraMetStore (insert, bounded range read, count), the table on DeviceRegistryStore.deviceScopedTables so forgetting a ring clears it. Android: entity, DAO insert/read/delete, DeviceRegistry.deleteDeviceData wiring, repository wrappers, the committed v41 Room schema for the upgrade test. Tests: OuraMetStoreTests (Swift), OuraMetSampleMigrationTest (Kotlin, incl. the twin rule); SchemaOracleTest / WhoopDatabaseUpgradeTest / the device-scoped-table guards on both sides. [3/7] feat(analytics): analyzeDay scores calories from the owner's MET series when supplied analyzeDay gains dayMet (the day owner's own per-minute MET samples), dayMetNow (so today's coverage is judged against elapsed hours, not 24 h) and a caloriesDiag sink. Rule: MET samples present ⇒ activeKcalEst = estimateDayEnergyFromMET(...).totalKcal when the stream covers ≥ 50 % of the window; below that the estimate is withheld (nil) rather than substituted by the HR path, which on a ring day runs over sparse banked HR and does not track Oura's number (r ≈ −0.1). nil/empty dayMet keeps the HR path byte-identical — every WHOOP and pure-function caller is unchanged. One always-on log line per MET day. Both platforms; AnalyticsEngineMetCaloriesTests / AnalyticsEngineMetCaloriesTest pin the selection, the withhold, the elapsed-hours window and the byte-identical no-MET path. [4/7] feat(oura): score active calories from the ring's MET records, behind an Experimental toggle The toggle (iOS AppModel.ouraMetCaloriesKey / Android NoopPrefs.KEY_OURA_MET_CALORIES, default OFF) gates: • the writer — OuraLiveSource fans each anchored 0x50 record out to ouraMetSample, one row per minute, next to the existing JSONL sidecar call. The record's timestamp is the END of its last sample: fitted against Oura's own per-minute export, every record length n matched best at exactly −n minutes (85 % exact minute matches, r 0.90, vs 23 % / 0.57 read forward), so sample i starts at utc − (n − i)·60 and a record straddling local midnight lands its minutes on the right days. • the read — IntelligenceEngine reads the day owner's rows (registry active id, calendar- day scoped like dayHr) and hands them to analyzeDay as dayMet, with dayMetNow = now; the calories line goes through the same per-day diag recorder as the Effort funnel. • the day-cycle fold — DayCycleIntelligenceIntegration (and Android's PhysiologicalStepCycleEngine) recomputed Keytel for every cycle and wrote it over activeKcalEst, which would have hidden the MET decision on any phone with a day-cycle history. The fold now takes the same decision analyzeDay takes, over its own window [onset, min(endExclusive, now)): covered → the MET total, thin → withheld with no HR substitute, no rows or toggle off → Keytel byte for byte. A "stepsCycle calories … path=met" trace line names the decision. The toggle joins the day-cache config signature on both platforms so a flip re-scores every cached day. Android threads the flag Context-free like spo2CandidateDisplay, carries it into the pass on a field and reads the MET rows inside readDaySkinAndWristOff, and hands the fold a Boolean rather than a lambda — all to stay inside analyzeRecentOnCpu's JaCoCo budget (a new suspend call there spills ~0.7 K instructions of continuation state). Settings gains "Experimental · Oura Calories" (iOS card / Android row), shown only with an Oura ring, caption stating the method, the < 50 % coverage withhold, that Oura re-scores a logged workout's minutes by activity type (NOOP does not, so such a day reads lower), and that turning it on starts storing MET samples. Strings in de/es/fr/pt-PT plus it/pl/ru/zh-Hans/ zh-Hant (iOS) and pl/ru/zh (Android). Docs: OURA_PROTOCOL.md §6.13 (day-sum validation of the 0x50 decode, end-of-record timestamps, the formula); README Oura table "Active calories". OFF keeps both the DB and every score byte-identical. Tests: DayCacheConfigFieldTests / DayCacheConfigFieldTest; DayCycleRecoveryTests (Swift, xcodebuild test) and DayCycleMetCaloriesTest (Kotlin, Robolectric + Room) on a seeded 8-h main sleep: covered cycle carries the MET total, 10 %-covered is withheld, no reader / empty table = Keytel. [5/7] feat(oura): pair the MET store with its twins in the parity ledger Review follow-up. The parity ledger flagged the MET store's Kotlin reads and writes as one-sided; run in full it also flagged the MET toggle's arity changes to existing functions, which read as new identities. Paired, with twin claims where the ledger reads them: - `WhoopRepository.insertOuraMetSamples` <-> `WhoopStore.insertOuraMetSamples` - `WhoopRepository.ouraMetSamples` <-> `WhoopStore.ouraMetSamples` (on the Repository, since the DAO's `@Query` puts its declaration outside the claim's attach window) - `AnalyticsEngine.analyzeDay`, `IntelligenceEngine.analyzeRecent` and `DayCycleIntelligenceIntegration.compute`, whose parameter lists grew here Removed: `WhoopStore.ouraMetSampleCount` and `WhoopDao.countOuraMetFor`. Neither had a production caller (the Kotlin one had no caller at all); the store tests count through `ouraMetSamples` instead. `platform_specific` dispositions for what is Kotlin-only by construction: the Room `@Insert` and `@Query` primitives under the paired Repository functions, the per-table forget-device delete (Swift clears the table through the `DeviceRegistryStore` name list), the Room migration override (its GRDB twin is the `v47-oura-met-sample` closure, pinned by the shared schema oracle), and the two JVM 64 KiB extractions `readDaySkinAndWristOff` and `PhysiologicalStepCycleEngine.compute`. The authority is re-derived with `--migrate-authority` because `main` at 5783c49 no longer reproduces its stored one. Verified: `parity_ledger.py` OK; `parity_ratchet.py --migrate-authority` 0 errors; `test_parity_*` 180 OK; StrandAnalytics 2068/0; WhoopStore 617/0; macOS `StrandTests` (DayCacheConfigField, DayCycleRecovery) 17/0; `NOOPiOS` builds; Android 6402 tests, the 8 failures reproduce identically on `upstream/main`; doc lint and `i18n_audit --ci` clean. [6/7] doc(calories): say at the MET assignment that activeKcalEst holds a total Review follow-up. `activeKcalEst = met.totalKcal` reads like a category error: the estimate carries the active share separately. It is correct, because the field is a pre-existing misnomer and the HR path's `estimateDayCalories` already stores `estimateDayEnergy(...).totalKcal` in it. That is now written at both `analyzeDay` sites, so the next reader does not have to work it out again. Comment only. [7/7] fix(oura): key an ended cycle's MET calories on the MET series and the toggle the per-owner HR witness and the scoring profile. Once the MET-calories toggle routes a cycle's calories through the ring's MET series, that key cannot see what the figure depends on: - a later drain that banks MET minutes inside an ended window leaves the HR witness unchanged, so the cache keeps serving calories from the thinner stream (or a withheld nil, when the first pass was under the floor); - a toggle flip over unchanged data matches the entry cached under the other setting and serves the previous mode's figure. Both platforms now append a MET witness to the key: `met=off` when the toggle is off, otherwise `met=<owner>=<count>:<maxTs>` from an index-only aggregate over ouraMetSample, the same shape as the HR witness. Swift adds WhoopStore.ouraMetFingerprint(deviceId:from:to:), injected into the fold as MetFingerprint beside MetReader, and an unread witness never serves a cached load. Kotlin adds the countOuraMetInWindow / maxOuraMetTsInWindow DAO pair and WhoopRepository.ouraMetFingerprint, and loadCacheKey takes the witness. The rebase onto ryanbr#2293 moved the fold's MET-vs-HR decision into the cache-miss branch as cycleCalories() on both platforms, unchanged in behaviour. Tests: OuraMetStoreTests fingerprint (count/maxTs, inclusive bounds, per device, moves on an in-window insert); DayCycleRecoveryTests and DayCycleMetCaloriesTest each gain "MET banked after a pass is not served from the cache" and "a toggle flip over unchanged data rescores the cycle"; RescoreUnchangedInputsTest pins the witness in the key; Kotlin repo fingerprint. With the witness replaced by a constant, 4 of 6 DayCycleMetCaloriesTest cases fail. Parity: loadCacheKey/8 replaces /7 in the dispositions, the two DAO primitives are platform_specific like their HR counterparts; ratchet 0 errors, ledger no new findings. Rebase onto main at ca24978: main's ryanbr#2472 grew analyzeRecentOnCpu to 55,559 instrumented bytes, which left 141 against the 55,700 JaCoCo budget, and this change measured 239. The day's MET rows now reach analyzeDay through a pass field (ouraMetDayForPass, written by readDaySkinAndWristOff), not through the DaySkinReads holder. On the holder, the holder stayed live across every later suspension point of the day loop and was spilled and restored at each one. The helper derives the calendar-day window from dayStart itself (same 14-parameter arity). Measured 55,669. Refs ryanbr#2242 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq Claude-Session: https://claude.ai/code/session_01484Ef871BnAuniyDJYYHoV Claude-Session: https://claude.ai/code/session_01RgqTwKfXLXzKo81ECb4aDz Claude-Session: https://claude.ai/code/session_01RuJdFu6iobsWfRtPYUR4C8
…default-off Experimental toggle (ryanbr#2242) [1/7] feat(analytics): day energy from a MET stream — Oura's documented rule, no fitted constant Adds Calories.estimateDayEnergyFromMET (Swift) / estimateDayEnergyFromMet (Kotlin): active = Σ_{met ≥ 1.5} (met − 1.5) × 0.0175 kcal·kg⁻¹·min⁻¹ × weight over the day's samples — Oura's support wording ("the portion that exceeds 1.5 MET") at the standard MET→kcal definition (1 MET = 3.5 ml O₂·kg⁻¹·min⁻¹). Resting is the same revised Harris–Benedict the HR path uses, scaled by covered seconds; coverage is reported over the window the caller passes so today's partial day is judged against elapsed hours. Missing minutes are unknown — never extrapolated to activity, never banked as rest. The rule was identified, not fitted: on the Oura export's own per-minute MET series it reproduces active_calories with r = 1.0000 and 0.6 kcal/day RMSE over 75 current-era days (r 0.9999 / 3 kcal over 396 pre-2025 days), zero intercept, the only input being the wearer's weight. A re-served minute counts once. The ring's timestamps go through a per-session 0x13 anchor, so a record served again under a second session lands 2–5 s off its first copy, while the ring's own minute grid steps back a second between records (a real successor starts 59–61 s on). Half a period tells them apart: two samples are the same minute when |Δ| × 2 < min(epochS), and the walk compares against the last counted start. Kotlin is pinned to Swift by oracle: the real Calories enum compiled standalone over a case spread (84 output lines) (four profiles × empty / rest floor / bout / 1.5 threshold edge / decoder boundary / partial coverage / 120 s cadence / duplicate ts / 3-s twins / 59-s phase steps / window edges / partial today / DST 23 h / clamp / bad epochs / realistic day), stdout pasted verbatim into MetCaloriesOracleTest. MetCaloriesTests pins the Swift side with hand-derived literals. [2/7] feat(store): persist the Oura ring's per-minute MET series — ouraMetSample, both platforms Until now the 0x50 MET stream existed only as the diagnostic JSONL sidecar, so nothing scored could read it. GRDB v47-oura-met-sample / Room MIGRATION_40_41 (schema 41) add ouraMetSample(deviceId, ts, met REAL, state, epochS) keyed (deviceId, ts), column order identical on both sides and pinned in schema_oracle.json (both copies). epochS is carried per row so a cadence other than 60 s scales the estimate rather than skewing it. Insert drops a re-served twin: the (deviceId, ts) key alone does not catch a record served again under another session anchor (it lands a few seconds off), so the insert reads the stored rows around the batch and drops any incoming sample within half a period of a stored one or of one accepted earlier in the batch (earlier start wins, lower MET on an exact tie). The pure rule is OuraMetSample.droppingTwins / OuraMetSampleEntity.droppingTwins, same integer test as the estimator, testable without a database. A 59-s phase-step successor is kept. Swift: OuraMetStore (insert, bounded range read, count), the table on DeviceRegistryStore.deviceScopedTables so forgetting a ring clears it. Android: entity, DAO insert/read/delete, DeviceRegistry.deleteDeviceData wiring, repository wrappers, the committed v41 Room schema for the upgrade test. Tests: OuraMetStoreTests (Swift), OuraMetSampleMigrationTest (Kotlin, incl. the twin rule); SchemaOracleTest / WhoopDatabaseUpgradeTest / the device-scoped-table guards on both sides. [3/7] feat(analytics): analyzeDay scores calories from the owner's MET series when supplied analyzeDay gains dayMet (the day owner's own per-minute MET samples), dayMetNow (so today's coverage is judged against elapsed hours, not 24 h) and a caloriesDiag sink. Rule: MET samples present ⇒ activeKcalEst = estimateDayEnergyFromMET(...).totalKcal when the stream covers ≥ 50 % of the window; below that the estimate is withheld (nil) rather than substituted by the HR path, which on a ring day runs over sparse banked HR and does not track Oura's number (r ≈ −0.1). nil/empty dayMet keeps the HR path byte-identical — every WHOOP and pure-function caller is unchanged. One always-on log line per MET day. Both platforms; AnalyticsEngineMetCaloriesTests / AnalyticsEngineMetCaloriesTest pin the selection, the withhold, the elapsed-hours window and the byte-identical no-MET path. [4/7] feat(oura): score active calories from the ring's MET records, behind an Experimental toggle The toggle (iOS AppModel.ouraMetCaloriesKey / Android NoopPrefs.KEY_OURA_MET_CALORIES, default OFF) gates: • the writer — OuraLiveSource fans each anchored 0x50 record out to ouraMetSample, one row per minute, next to the existing JSONL sidecar call. The record's timestamp is the END of its last sample: fitted against Oura's own per-minute export, every record length n matched best at exactly −n minutes (85 % exact minute matches, r 0.90, vs 23 % / 0.57 read forward), so sample i starts at utc − (n − i)·60 and a record straddling local midnight lands its minutes on the right days. • the read — IntelligenceEngine reads the day owner's rows (registry active id, calendar- day scoped like dayHr) and hands them to analyzeDay as dayMet, with dayMetNow = now; the calories line goes through the same per-day diag recorder as the Effort funnel. • the day-cycle fold — DayCycleIntelligenceIntegration (and Android's PhysiologicalStepCycleEngine) recomputed Keytel for every cycle and wrote it over activeKcalEst, which would have hidden the MET decision on any phone with a day-cycle history. The fold now takes the same decision analyzeDay takes, over its own window [onset, min(endExclusive, now)): covered → the MET total, thin → withheld with no HR substitute, no rows or toggle off → Keytel byte for byte. A "stepsCycle calories … path=met" trace line names the decision. The toggle joins the day-cache config signature on both platforms so a flip re-scores every cached day. Android threads the flag Context-free like spo2CandidateDisplay, carries it into the pass on a field and reads the MET rows inside readDaySkinAndWristOff, and hands the fold a Boolean rather than a lambda — all to stay inside analyzeRecentOnCpu's JaCoCo budget (a new suspend call there spills ~0.7 K instructions of continuation state). Settings gains "Experimental · Oura Calories" (iOS card / Android row), shown only with an Oura ring, caption stating the method, the < 50 % coverage withhold, that Oura re-scores a logged workout's minutes by activity type (NOOP does not, so such a day reads lower), and that turning it on starts storing MET samples. Strings in de/es/fr/pt-PT plus it/pl/ru/zh-Hans/ zh-Hant (iOS) and pl/ru/zh (Android). Docs: OURA_PROTOCOL.md §6.13 (day-sum validation of the 0x50 decode, end-of-record timestamps, the formula); README Oura table "Active calories". OFF keeps both the DB and every score byte-identical. Tests: DayCacheConfigFieldTests / DayCacheConfigFieldTest; DayCycleRecoveryTests (Swift, xcodebuild test) and DayCycleMetCaloriesTest (Kotlin, Robolectric + Room) on a seeded 8-h main sleep: covered cycle carries the MET total, 10 %-covered is withheld, no reader / empty table = Keytel. [5/7] feat(oura): pair the MET store with its twins in the parity ledger Review follow-up. The parity ledger flagged the MET store's Kotlin reads and writes as one-sided; run in full it also flagged the MET toggle's arity changes to existing functions, which read as new identities. Paired, with twin claims where the ledger reads them: - `WhoopRepository.insertOuraMetSamples` <-> `WhoopStore.insertOuraMetSamples` - `WhoopRepository.ouraMetSamples` <-> `WhoopStore.ouraMetSamples` (on the Repository, since the DAO's `@Query` puts its declaration outside the claim's attach window) - `AnalyticsEngine.analyzeDay`, `IntelligenceEngine.analyzeRecent` and `DayCycleIntelligenceIntegration.compute`, whose parameter lists grew here Removed: `WhoopStore.ouraMetSampleCount` and `WhoopDao.countOuraMetFor`. Neither had a production caller (the Kotlin one had no caller at all); the store tests count through `ouraMetSamples` instead. `platform_specific` dispositions for what is Kotlin-only by construction: the Room `@Insert` and `@Query` primitives under the paired Repository functions, the per-table forget-device delete (Swift clears the table through the `DeviceRegistryStore` name list), the Room migration override (its GRDB twin is the `v47-oura-met-sample` closure, pinned by the shared schema oracle), and the two JVM 64 KiB extractions `readDaySkinAndWristOff` and `PhysiologicalStepCycleEngine.compute`. The authority is re-derived with `--migrate-authority` because `main` at 5783c49 no longer reproduces its stored one. Verified: `parity_ledger.py` OK; `parity_ratchet.py --migrate-authority` 0 errors; `test_parity_*` 180 OK; StrandAnalytics 2068/0; WhoopStore 617/0; macOS `StrandTests` (DayCacheConfigField, DayCycleRecovery) 17/0; `NOOPiOS` builds; Android 6402 tests, the 8 failures reproduce identically on `upstream/main`; doc lint and `i18n_audit --ci` clean. [6/7] doc(calories): say at the MET assignment that activeKcalEst holds a total Review follow-up. `activeKcalEst = met.totalKcal` reads like a category error: the estimate carries the active share separately. It is correct, because the field is a pre-existing misnomer and the HR path's `estimateDayCalories` already stores `estimateDayEnergy(...).totalKcal` in it. That is now written at both `analyzeDay` sites, so the next reader does not have to work it out again. Comment only. [7/7] fix(oura): key an ended cycle's MET calories on the MET series and the toggle the per-owner HR witness and the scoring profile. Once the MET-calories toggle routes a cycle's calories through the ring's MET series, that key cannot see what the figure depends on: - a later drain that banks MET minutes inside an ended window leaves the HR witness unchanged, so the cache keeps serving calories from the thinner stream (or a withheld nil, when the first pass was under the floor); - a toggle flip over unchanged data matches the entry cached under the other setting and serves the previous mode's figure. Both platforms now append a MET witness to the key: `met=off` when the toggle is off, otherwise `met=<owner>=<count>:<maxTs>` from an index-only aggregate over ouraMetSample, the same shape as the HR witness. Swift adds WhoopStore.ouraMetFingerprint(deviceId:from:to:), injected into the fold as MetFingerprint beside MetReader, and an unread witness never serves a cached load. Kotlin adds the countOuraMetInWindow / maxOuraMetTsInWindow DAO pair and WhoopRepository.ouraMetFingerprint, and loadCacheKey takes the witness. The rebase onto ryanbr#2293 moved the fold's MET-vs-HR decision into the cache-miss branch as cycleCalories() on both platforms, unchanged in behaviour. Tests: OuraMetStoreTests fingerprint (count/maxTs, inclusive bounds, per device, moves on an in-window insert); DayCycleRecoveryTests and DayCycleMetCaloriesTest each gain "MET banked after a pass is not served from the cache" and "a toggle flip over unchanged data rescores the cycle"; RescoreUnchangedInputsTest pins the witness in the key; Kotlin repo fingerprint. With the witness replaced by a constant, 4 of 6 DayCycleMetCaloriesTest cases fail. Parity: loadCacheKey/8 replaces /7 in the dispositions, the two DAO primitives are platform_specific like their HR counterparts; ratchet 0 errors, ledger no new findings. Rebase onto main at ca24978: main's ryanbr#2472 grew analyzeRecentOnCpu to 55,559 instrumented bytes, which left 141 against the 55,700 JaCoCo budget, and this change measured 239. The day's MET rows now reach analyzeDay through a pass field (ouraMetDayForPass, written by readDaySkinAndWristOff), not through the DaySkinReads holder. On the holder, the holder stayed live across every later suspension point of the day loop and was spilled and restored at each one. The helper derives the calendar-day window from dayStart itself (same 14-parameter arity). Measured 55,669. Refs ryanbr#2242 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq Claude-Session: https://claude.ai/code/session_01484Ef871BnAuniyDJYYHoV Claude-Session: https://claude.ai/code/session_01RgqTwKfXLXzKo81ECb4aDz Claude-Session: https://claude.ai/code/session_01RuJdFu6iobsWfRtPYUR4C8
…default-off Experimental toggle (ryanbr#2242) [1/7] feat(analytics): day energy from a MET stream — Oura's documented rule, no fitted constant Adds Calories.estimateDayEnergyFromMET (Swift) / estimateDayEnergyFromMet (Kotlin): active = Σ_{met ≥ 1.5} (met − 1.5) × 0.0175 kcal·kg⁻¹·min⁻¹ × weight over the day's samples — Oura's support wording ("the portion that exceeds 1.5 MET") at the standard MET→kcal definition (1 MET = 3.5 ml O₂·kg⁻¹·min⁻¹). Resting is the same revised Harris–Benedict the HR path uses, scaled by covered seconds; coverage is reported over the window the caller passes so today's partial day is judged against elapsed hours. Missing minutes are unknown — never extrapolated to activity, never banked as rest. The rule was identified, not fitted: on the Oura export's own per-minute MET series it reproduces active_calories with r = 1.0000 and 0.6 kcal/day RMSE over 75 current-era days (r 0.9999 / 3 kcal over 396 pre-2025 days), zero intercept, the only input being the wearer's weight. A re-served minute counts once. The ring's timestamps go through a per-session 0x13 anchor, so a record served again under a second session lands 2–5 s off its first copy, while the ring's own minute grid steps back a second between records (a real successor starts 59–61 s on). Half a period tells them apart: two samples are the same minute when |Δ| × 2 < min(epochS), and the walk compares against the last counted start. Kotlin is pinned to Swift by oracle: the real Calories enum compiled standalone over a case spread (84 output lines) (four profiles × empty / rest floor / bout / 1.5 threshold edge / decoder boundary / partial coverage / 120 s cadence / duplicate ts / 3-s twins / 59-s phase steps / window edges / partial today / DST 23 h / clamp / bad epochs / realistic day), stdout pasted verbatim into MetCaloriesOracleTest. MetCaloriesTests pins the Swift side with hand-derived literals. [2/7] feat(store): persist the Oura ring's per-minute MET series — ouraMetSample, both platforms Until now the 0x50 MET stream existed only as the diagnostic JSONL sidecar, so nothing scored could read it. GRDB v47-oura-met-sample / Room MIGRATION_40_41 (schema 41) add ouraMetSample(deviceId, ts, met REAL, state, epochS) keyed (deviceId, ts), column order identical on both sides and pinned in schema_oracle.json (both copies). epochS is carried per row so a cadence other than 60 s scales the estimate rather than skewing it. Insert drops a re-served twin: the (deviceId, ts) key alone does not catch a record served again under another session anchor (it lands a few seconds off), so the insert reads the stored rows around the batch and drops any incoming sample within half a period of a stored one or of one accepted earlier in the batch (earlier start wins, lower MET on an exact tie). The pure rule is OuraMetSample.droppingTwins / OuraMetSampleEntity.droppingTwins, same integer test as the estimator, testable without a database. A 59-s phase-step successor is kept. Swift: OuraMetStore (insert, bounded range read, count), the table on DeviceRegistryStore.deviceScopedTables so forgetting a ring clears it. Android: entity, DAO insert/read/delete, DeviceRegistry.deleteDeviceData wiring, repository wrappers, the committed v41 Room schema for the upgrade test. Tests: OuraMetStoreTests (Swift), OuraMetSampleMigrationTest (Kotlin, incl. the twin rule); SchemaOracleTest / WhoopDatabaseUpgradeTest / the device-scoped-table guards on both sides. [3/7] feat(analytics): analyzeDay scores calories from the owner's MET series when supplied analyzeDay gains dayMet (the day owner's own per-minute MET samples), dayMetNow (so today's coverage is judged against elapsed hours, not 24 h) and a caloriesDiag sink. Rule: MET samples present ⇒ activeKcalEst = estimateDayEnergyFromMET(...).totalKcal when the stream covers ≥ 50 % of the window; below that the estimate is withheld (nil) rather than substituted by the HR path, which on a ring day runs over sparse banked HR and does not track Oura's number (r ≈ −0.1). nil/empty dayMet keeps the HR path byte-identical — every WHOOP and pure-function caller is unchanged. One always-on log line per MET day. Both platforms; AnalyticsEngineMetCaloriesTests / AnalyticsEngineMetCaloriesTest pin the selection, the withhold, the elapsed-hours window and the byte-identical no-MET path. [4/7] feat(oura): score active calories from the ring's MET records, behind an Experimental toggle The toggle (iOS AppModel.ouraMetCaloriesKey / Android NoopPrefs.KEY_OURA_MET_CALORIES, default OFF) gates: • the writer — OuraLiveSource fans each anchored 0x50 record out to ouraMetSample, one row per minute, next to the existing JSONL sidecar call. The record's timestamp is the END of its last sample: fitted against Oura's own per-minute export, every record length n matched best at exactly −n minutes (85 % exact minute matches, r 0.90, vs 23 % / 0.57 read forward), so sample i starts at utc − (n − i)·60 and a record straddling local midnight lands its minutes on the right days. • the read — IntelligenceEngine reads the day owner's rows (registry active id, calendar- day scoped like dayHr) and hands them to analyzeDay as dayMet, with dayMetNow = now; the calories line goes through the same per-day diag recorder as the Effort funnel. • the day-cycle fold — DayCycleIntelligenceIntegration (and Android's PhysiologicalStepCycleEngine) recomputed Keytel for every cycle and wrote it over activeKcalEst, which would have hidden the MET decision on any phone with a day-cycle history. The fold now takes the same decision analyzeDay takes, over its own window [onset, min(endExclusive, now)): covered → the MET total, thin → withheld with no HR substitute, no rows or toggle off → Keytel byte for byte. A "stepsCycle calories … path=met" trace line names the decision. The toggle joins the day-cache config signature on both platforms so a flip re-scores every cached day. Android threads the flag Context-free like spo2CandidateDisplay, carries it into the pass on a field and reads the MET rows inside readDaySkinAndWristOff, and hands the fold a Boolean rather than a lambda — all to stay inside analyzeRecentOnCpu's JaCoCo budget (a new suspend call there spills ~0.7 K instructions of continuation state). Settings gains "Experimental · Oura Calories" (iOS card / Android row), shown only with an Oura ring, caption stating the method, the < 50 % coverage withhold, that Oura re-scores a logged workout's minutes by activity type (NOOP does not, so such a day reads lower), and that turning it on starts storing MET samples. Strings in de/es/fr/pt-PT plus it/pl/ru/zh-Hans/ zh-Hant (iOS) and pl/ru/zh (Android). Docs: OURA_PROTOCOL.md §6.13 (day-sum validation of the 0x50 decode, end-of-record timestamps, the formula); README Oura table "Active calories". OFF keeps both the DB and every score byte-identical. Tests: DayCacheConfigFieldTests / DayCacheConfigFieldTest; DayCycleRecoveryTests (Swift, xcodebuild test) and DayCycleMetCaloriesTest (Kotlin, Robolectric + Room) on a seeded 8-h main sleep: covered cycle carries the MET total, 10 %-covered is withheld, no reader / empty table = Keytel. [5/7] feat(oura): pair the MET store with its twins in the parity ledger Review follow-up. The parity ledger flagged the MET store's Kotlin reads and writes as one-sided; run in full it also flagged the MET toggle's arity changes to existing functions, which read as new identities. Paired, with twin claims where the ledger reads them: - `WhoopRepository.insertOuraMetSamples` <-> `WhoopStore.insertOuraMetSamples` - `WhoopRepository.ouraMetSamples` <-> `WhoopStore.ouraMetSamples` (on the Repository, since the DAO's `@Query` puts its declaration outside the claim's attach window) - `AnalyticsEngine.analyzeDay`, `IntelligenceEngine.analyzeRecent` and `DayCycleIntelligenceIntegration.compute`, whose parameter lists grew here Removed: `WhoopStore.ouraMetSampleCount` and `WhoopDao.countOuraMetFor`. Neither had a production caller (the Kotlin one had no caller at all); the store tests count through `ouraMetSamples` instead. `platform_specific` dispositions for what is Kotlin-only by construction: the Room `@Insert` and `@Query` primitives under the paired Repository functions, the per-table forget-device delete (Swift clears the table through the `DeviceRegistryStore` name list), the Room migration override (its GRDB twin is the `v47-oura-met-sample` closure, pinned by the shared schema oracle), and the two JVM 64 KiB extractions `readDaySkinAndWristOff` and `PhysiologicalStepCycleEngine.compute`. The authority is re-derived with `--migrate-authority` because `main` at 5783c49 no longer reproduces its stored one. Verified: `parity_ledger.py` OK; `parity_ratchet.py --migrate-authority` 0 errors; `test_parity_*` 180 OK; StrandAnalytics 2068/0; WhoopStore 617/0; macOS `StrandTests` (DayCacheConfigField, DayCycleRecovery) 17/0; `NOOPiOS` builds; Android 6402 tests, the 8 failures reproduce identically on `upstream/main`; doc lint and `i18n_audit --ci` clean. [6/7] doc(calories): say at the MET assignment that activeKcalEst holds a total Review follow-up. `activeKcalEst = met.totalKcal` reads like a category error: the estimate carries the active share separately. It is correct, because the field is a pre-existing misnomer and the HR path's `estimateDayCalories` already stores `estimateDayEnergy(...).totalKcal` in it. That is now written at both `analyzeDay` sites, so the next reader does not have to work it out again. Comment only. [7/7] fix(oura): key an ended cycle's MET calories on the MET series and the toggle the per-owner HR witness and the scoring profile. Once the MET-calories toggle routes a cycle's calories through the ring's MET series, that key cannot see what the figure depends on: - a later drain that banks MET minutes inside an ended window leaves the HR witness unchanged, so the cache keeps serving calories from the thinner stream (or a withheld nil, when the first pass was under the floor); - a toggle flip over unchanged data matches the entry cached under the other setting and serves the previous mode's figure. Both platforms now append a MET witness to the key: `met=off` when the toggle is off, otherwise `met=<owner>=<count>:<maxTs>` from an index-only aggregate over ouraMetSample, the same shape as the HR witness. Swift adds WhoopStore.ouraMetFingerprint(deviceId:from:to:), injected into the fold as MetFingerprint beside MetReader, and an unread witness never serves a cached load. Kotlin adds the countOuraMetInWindow / maxOuraMetTsInWindow DAO pair and WhoopRepository.ouraMetFingerprint, and loadCacheKey takes the witness. The rebase onto ryanbr#2293 moved the fold's MET-vs-HR decision into the cache-miss branch as cycleCalories() on both platforms, unchanged in behaviour. Tests: OuraMetStoreTests fingerprint (count/maxTs, inclusive bounds, per device, moves on an in-window insert); DayCycleRecoveryTests and DayCycleMetCaloriesTest each gain "MET banked after a pass is not served from the cache" and "a toggle flip over unchanged data rescores the cycle"; RescoreUnchangedInputsTest pins the witness in the key; Kotlin repo fingerprint. With the witness replaced by a constant, 4 of 6 DayCycleMetCaloriesTest cases fail. Parity: loadCacheKey/8 replaces /7 in the dispositions, the two DAO primitives are platform_specific like their HR counterparts; ratchet 0 errors, ledger no new findings. Rebase onto main at ca24978: main's ryanbr#2472 grew analyzeRecentOnCpu to 55,559 instrumented bytes, which left 141 against the 55,700 JaCoCo budget, and this change measured 239. The day's MET rows now reach analyzeDay through a pass field (ouraMetDayForPass, written by readDaySkinAndWristOff), not through the DaySkinReads holder. On the holder, the holder stayed live across every later suspension point of the day loop and was spilled and restored at each one. The helper derives the calendar-day window from dayStart itself (same 14-parameter arity). Measured 55,669. Refs ryanbr#2242 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq Claude-Session: https://claude.ai/code/session_01484Ef871BnAuniyDJYYHoV Claude-Session: https://claude.ai/code/session_01RgqTwKfXLXzKo81ECb4aDz Claude-Session: https://claude.ai/code/session_01RuJdFu6iobsWfRtPYUR4C8
…default-off Experimental toggle (ryanbr#2242) [1/7] feat(analytics): day energy from a MET stream — Oura's documented rule, no fitted constant Adds Calories.estimateDayEnergyFromMET (Swift) / estimateDayEnergyFromMet (Kotlin): active = Σ_{met ≥ 1.5} (met − 1.5) × 0.0175 kcal·kg⁻¹·min⁻¹ × weight over the day's samples — Oura's support wording ("the portion that exceeds 1.5 MET") at the standard MET→kcal definition (1 MET = 3.5 ml O₂·kg⁻¹·min⁻¹). Resting is the same revised Harris–Benedict the HR path uses, scaled by covered seconds; coverage is reported over the window the caller passes so today's partial day is judged against elapsed hours. Missing minutes are unknown — never extrapolated to activity, never banked as rest. The rule was identified, not fitted: on the Oura export's own per-minute MET series it reproduces active_calories with r = 1.0000 and 0.6 kcal/day RMSE over 75 current-era days (r 0.9999 / 3 kcal over 396 pre-2025 days), zero intercept, the only input being the wearer's weight. A re-served minute counts once. The ring's timestamps go through a per-session 0x13 anchor, so a record served again under a second session lands 2–5 s off its first copy, while the ring's own minute grid steps back a second between records (a real successor starts 59–61 s on). Half a period tells them apart: two samples are the same minute when |Δ| × 2 < min(epochS), and the walk compares against the last counted start. Kotlin is pinned to Swift by oracle: the real Calories enum compiled standalone over a case spread (84 output lines) (four profiles × empty / rest floor / bout / 1.5 threshold edge / decoder boundary / partial coverage / 120 s cadence / duplicate ts / 3-s twins / 59-s phase steps / window edges / partial today / DST 23 h / clamp / bad epochs / realistic day), stdout pasted verbatim into MetCaloriesOracleTest. MetCaloriesTests pins the Swift side with hand-derived literals. [2/7] feat(store): persist the Oura ring's per-minute MET series — ouraMetSample, both platforms Until now the 0x50 MET stream existed only as the diagnostic JSONL sidecar, so nothing scored could read it. GRDB v47-oura-met-sample / Room MIGRATION_40_41 (schema 41) add ouraMetSample(deviceId, ts, met REAL, state, epochS) keyed (deviceId, ts), column order identical on both sides and pinned in schema_oracle.json (both copies). epochS is carried per row so a cadence other than 60 s scales the estimate rather than skewing it. Insert drops a re-served twin: the (deviceId, ts) key alone does not catch a record served again under another session anchor (it lands a few seconds off), so the insert reads the stored rows around the batch and drops any incoming sample within half a period of a stored one or of one accepted earlier in the batch (earlier start wins, lower MET on an exact tie). The pure rule is OuraMetSample.droppingTwins / OuraMetSampleEntity.droppingTwins, same integer test as the estimator, testable without a database. A 59-s phase-step successor is kept. Swift: OuraMetStore (insert, bounded range read, count), the table on DeviceRegistryStore.deviceScopedTables so forgetting a ring clears it. Android: entity, DAO insert/read/delete, DeviceRegistry.deleteDeviceData wiring, repository wrappers, the committed v41 Room schema for the upgrade test. Tests: OuraMetStoreTests (Swift), OuraMetSampleMigrationTest (Kotlin, incl. the twin rule); SchemaOracleTest / WhoopDatabaseUpgradeTest / the device-scoped-table guards on both sides. [3/7] feat(analytics): analyzeDay scores calories from the owner's MET series when supplied analyzeDay gains dayMet (the day owner's own per-minute MET samples), dayMetNow (so today's coverage is judged against elapsed hours, not 24 h) and a caloriesDiag sink. Rule: MET samples present ⇒ activeKcalEst = estimateDayEnergyFromMET(...).totalKcal when the stream covers ≥ 50 % of the window; below that the estimate is withheld (nil) rather than substituted by the HR path, which on a ring day runs over sparse banked HR and does not track Oura's number (r ≈ −0.1). nil/empty dayMet keeps the HR path byte-identical — every WHOOP and pure-function caller is unchanged. One always-on log line per MET day. Both platforms; AnalyticsEngineMetCaloriesTests / AnalyticsEngineMetCaloriesTest pin the selection, the withhold, the elapsed-hours window and the byte-identical no-MET path. [4/7] feat(oura): score active calories from the ring's MET records, behind an Experimental toggle The toggle (iOS AppModel.ouraMetCaloriesKey / Android NoopPrefs.KEY_OURA_MET_CALORIES, default OFF) gates: • the writer — OuraLiveSource fans each anchored 0x50 record out to ouraMetSample, one row per minute, next to the existing JSONL sidecar call. The record's timestamp is the END of its last sample: fitted against Oura's own per-minute export, every record length n matched best at exactly −n minutes (85 % exact minute matches, r 0.90, vs 23 % / 0.57 read forward), so sample i starts at utc − (n − i)·60 and a record straddling local midnight lands its minutes on the right days. • the read — IntelligenceEngine reads the day owner's rows (registry active id, calendar- day scoped like dayHr) and hands them to analyzeDay as dayMet, with dayMetNow = now; the calories line goes through the same per-day diag recorder as the Effort funnel. • the day-cycle fold — DayCycleIntelligenceIntegration (and Android's PhysiologicalStepCycleEngine) recomputed Keytel for every cycle and wrote it over activeKcalEst, which would have hidden the MET decision on any phone with a day-cycle history. The fold now takes the same decision analyzeDay takes, over its own window [onset, min(endExclusive, now)): covered → the MET total, thin → withheld with no HR substitute, no rows or toggle off → Keytel byte for byte. A "stepsCycle calories … path=met" trace line names the decision. The toggle joins the day-cache config signature on both platforms so a flip re-scores every cached day. Android threads the flag Context-free like spo2CandidateDisplay, carries it into the pass on a field and reads the MET rows inside readDaySkinAndWristOff, and hands the fold a Boolean rather than a lambda — all to stay inside analyzeRecentOnCpu's JaCoCo budget (a new suspend call there spills ~0.7 K instructions of continuation state). Settings gains "Experimental · Oura Calories" (iOS card / Android row), shown only with an Oura ring, caption stating the method, the < 50 % coverage withhold, that Oura re-scores a logged workout's minutes by activity type (NOOP does not, so such a day reads lower), and that turning it on starts storing MET samples. Strings in de/es/fr/pt-PT plus it/pl/ru/zh-Hans/ zh-Hant (iOS) and pl/ru/zh (Android). Docs: OURA_PROTOCOL.md §6.13 (day-sum validation of the 0x50 decode, end-of-record timestamps, the formula); README Oura table "Active calories". OFF keeps both the DB and every score byte-identical. Tests: DayCacheConfigFieldTests / DayCacheConfigFieldTest; DayCycleRecoveryTests (Swift, xcodebuild test) and DayCycleMetCaloriesTest (Kotlin, Robolectric + Room) on a seeded 8-h main sleep: covered cycle carries the MET total, 10 %-covered is withheld, no reader / empty table = Keytel. [5/7] feat(oura): pair the MET store with its twins in the parity ledger Review follow-up. The parity ledger flagged the MET store's Kotlin reads and writes as one-sided; run in full it also flagged the MET toggle's arity changes to existing functions, which read as new identities. Paired, with twin claims where the ledger reads them: - `WhoopRepository.insertOuraMetSamples` <-> `WhoopStore.insertOuraMetSamples` - `WhoopRepository.ouraMetSamples` <-> `WhoopStore.ouraMetSamples` (on the Repository, since the DAO's `@Query` puts its declaration outside the claim's attach window) - `AnalyticsEngine.analyzeDay`, `IntelligenceEngine.analyzeRecent` and `DayCycleIntelligenceIntegration.compute`, whose parameter lists grew here Removed: `WhoopStore.ouraMetSampleCount` and `WhoopDao.countOuraMetFor`. Neither had a production caller (the Kotlin one had no caller at all); the store tests count through `ouraMetSamples` instead. `platform_specific` dispositions for what is Kotlin-only by construction: the Room `@Insert` and `@Query` primitives under the paired Repository functions, the per-table forget-device delete (Swift clears the table through the `DeviceRegistryStore` name list), the Room migration override (its GRDB twin is the `v47-oura-met-sample` closure, pinned by the shared schema oracle), and the two JVM 64 KiB extractions `readDaySkinAndWristOff` and `PhysiologicalStepCycleEngine.compute`. The authority is re-derived with `--migrate-authority` because `main` at 5783c49 no longer reproduces its stored one. Verified: `parity_ledger.py` OK; `parity_ratchet.py --migrate-authority` 0 errors; `test_parity_*` 180 OK; StrandAnalytics 2068/0; WhoopStore 617/0; macOS `StrandTests` (DayCacheConfigField, DayCycleRecovery) 17/0; `NOOPiOS` builds; Android 6402 tests, the 8 failures reproduce identically on `upstream/main`; doc lint and `i18n_audit --ci` clean. [6/7] doc(calories): say at the MET assignment that activeKcalEst holds a total Review follow-up. `activeKcalEst = met.totalKcal` reads like a category error: the estimate carries the active share separately. It is correct, because the field is a pre-existing misnomer and the HR path's `estimateDayCalories` already stores `estimateDayEnergy(...).totalKcal` in it. That is now written at both `analyzeDay` sites, so the next reader does not have to work it out again. Comment only. [7/7] fix(oura): key an ended cycle's MET calories on the MET series and the toggle the per-owner HR witness and the scoring profile. Once the MET-calories toggle routes a cycle's calories through the ring's MET series, that key cannot see what the figure depends on: - a later drain that banks MET minutes inside an ended window leaves the HR witness unchanged, so the cache keeps serving calories from the thinner stream (or a withheld nil, when the first pass was under the floor); - a toggle flip over unchanged data matches the entry cached under the other setting and serves the previous mode's figure. Both platforms now append a MET witness to the key: `met=off` when the toggle is off, otherwise `met=<owner>=<count>:<maxTs>` from an index-only aggregate over ouraMetSample, the same shape as the HR witness. Swift adds WhoopStore.ouraMetFingerprint(deviceId:from:to:), injected into the fold as MetFingerprint beside MetReader, and an unread witness never serves a cached load. Kotlin adds the countOuraMetInWindow / maxOuraMetTsInWindow DAO pair and WhoopRepository.ouraMetFingerprint, and loadCacheKey takes the witness. The rebase onto ryanbr#2293 moved the fold's MET-vs-HR decision into the cache-miss branch as cycleCalories() on both platforms, unchanged in behaviour. Tests: OuraMetStoreTests fingerprint (count/maxTs, inclusive bounds, per device, moves on an in-window insert); DayCycleRecoveryTests and DayCycleMetCaloriesTest each gain "MET banked after a pass is not served from the cache" and "a toggle flip over unchanged data rescores the cycle"; RescoreUnchangedInputsTest pins the witness in the key; Kotlin repo fingerprint. With the witness replaced by a constant, 4 of 6 DayCycleMetCaloriesTest cases fail. Parity: loadCacheKey/8 replaces /7 in the dispositions, the two DAO primitives are platform_specific like their HR counterparts; ratchet 0 errors, ledger no new findings. Rebase onto main at ca24978: main's ryanbr#2472 grew analyzeRecentOnCpu to 55,559 instrumented bytes, which left 141 against the 55,700 JaCoCo budget, and this change measured 239. The day's MET rows now reach analyzeDay through a pass field (ouraMetDayForPass, written by readDaySkinAndWristOff), not through the DaySkinReads holder. On the holder, the holder stayed live across every later suspension point of the day loop and was spilled and restored at each one. The helper derives the calendar-day window from dayStart itself (same 14-parameter arity). Measured 55,669. Refs ryanbr#2242 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq Claude-Session: https://claude.ai/code/session_01484Ef871BnAuniyDJYYHoV Claude-Session: https://claude.ai/code/session_01RgqTwKfXLXzKo81ECb4aDz Claude-Session: https://claude.ai/code/session_01RuJdFu6iobsWfRtPYUR4C8
…default-off Experimental toggle (ryanbr#2242) [1/7] feat(analytics): day energy from a MET stream — Oura's documented rule, no fitted constant Adds Calories.estimateDayEnergyFromMET (Swift) / estimateDayEnergyFromMet (Kotlin): active = Σ_{met ≥ 1.5} (met − 1.5) × 0.0175 kcal·kg⁻¹·min⁻¹ × weight over the day's samples — Oura's support wording ("the portion that exceeds 1.5 MET") at the standard MET→kcal definition (1 MET = 3.5 ml O₂·kg⁻¹·min⁻¹). Resting is the same revised Harris–Benedict the HR path uses, scaled by covered seconds; coverage is reported over the window the caller passes so today's partial day is judged against elapsed hours. Missing minutes are unknown — never extrapolated to activity, never banked as rest. The rule was identified, not fitted: on the Oura export's own per-minute MET series it reproduces active_calories with r = 1.0000 and 0.6 kcal/day RMSE over 75 current-era days (r 0.9999 / 3 kcal over 396 pre-2025 days), zero intercept, the only input being the wearer's weight. A re-served minute counts once. The ring's timestamps go through a per-session 0x13 anchor, so a record served again under a second session lands 2–5 s off its first copy, while the ring's own minute grid steps back a second between records (a real successor starts 59–61 s on). Half a period tells them apart: two samples are the same minute when |Δ| × 2 < min(epochS), and the walk compares against the last counted start. Kotlin is pinned to Swift by oracle: the real Calories enum compiled standalone over a case spread (84 output lines) (four profiles × empty / rest floor / bout / 1.5 threshold edge / decoder boundary / partial coverage / 120 s cadence / duplicate ts / 3-s twins / 59-s phase steps / window edges / partial today / DST 23 h / clamp / bad epochs / realistic day), stdout pasted verbatim into MetCaloriesOracleTest. MetCaloriesTests pins the Swift side with hand-derived literals. [2/7] feat(store): persist the Oura ring's per-minute MET series — ouraMetSample, both platforms Until now the 0x50 MET stream existed only as the diagnostic JSONL sidecar, so nothing scored could read it. GRDB v47-oura-met-sample / Room MIGRATION_40_41 (schema 41) add ouraMetSample(deviceId, ts, met REAL, state, epochS) keyed (deviceId, ts), column order identical on both sides and pinned in schema_oracle.json (both copies). epochS is carried per row so a cadence other than 60 s scales the estimate rather than skewing it. Insert drops a re-served twin: the (deviceId, ts) key alone does not catch a record served again under another session anchor (it lands a few seconds off), so the insert reads the stored rows around the batch and drops any incoming sample within half a period of a stored one or of one accepted earlier in the batch (earlier start wins, lower MET on an exact tie). The pure rule is OuraMetSample.droppingTwins / OuraMetSampleEntity.droppingTwins, same integer test as the estimator, testable without a database. A 59-s phase-step successor is kept. Swift: OuraMetStore (insert, bounded range read, count), the table on DeviceRegistryStore.deviceScopedTables so forgetting a ring clears it. Android: entity, DAO insert/read/delete, DeviceRegistry.deleteDeviceData wiring, repository wrappers, the committed v41 Room schema for the upgrade test. Tests: OuraMetStoreTests (Swift), OuraMetSampleMigrationTest (Kotlin, incl. the twin rule); SchemaOracleTest / WhoopDatabaseUpgradeTest / the device-scoped-table guards on both sides. [3/7] feat(analytics): analyzeDay scores calories from the owner's MET series when supplied analyzeDay gains dayMet (the day owner's own per-minute MET samples), dayMetNow (so today's coverage is judged against elapsed hours, not 24 h) and a caloriesDiag sink. Rule: MET samples present ⇒ activeKcalEst = estimateDayEnergyFromMET(...).totalKcal when the stream covers ≥ 50 % of the window; below that the estimate is withheld (nil) rather than substituted by the HR path, which on a ring day runs over sparse banked HR and does not track Oura's number (r ≈ −0.1). nil/empty dayMet keeps the HR path byte-identical — every WHOOP and pure-function caller is unchanged. One always-on log line per MET day. Both platforms; AnalyticsEngineMetCaloriesTests / AnalyticsEngineMetCaloriesTest pin the selection, the withhold, the elapsed-hours window and the byte-identical no-MET path. [4/7] feat(oura): score active calories from the ring's MET records, behind an Experimental toggle The toggle (iOS AppModel.ouraMetCaloriesKey / Android NoopPrefs.KEY_OURA_MET_CALORIES, default OFF) gates: • the writer — OuraLiveSource fans each anchored 0x50 record out to ouraMetSample, one row per minute, next to the existing JSONL sidecar call. The record's timestamp is the END of its last sample: fitted against Oura's own per-minute export, every record length n matched best at exactly −n minutes (85 % exact minute matches, r 0.90, vs 23 % / 0.57 read forward), so sample i starts at utc − (n − i)·60 and a record straddling local midnight lands its minutes on the right days. • the read — IntelligenceEngine reads the day owner's rows (registry active id, calendar- day scoped like dayHr) and hands them to analyzeDay as dayMet, with dayMetNow = now; the calories line goes through the same per-day diag recorder as the Effort funnel. • the day-cycle fold — DayCycleIntelligenceIntegration (and Android's PhysiologicalStepCycleEngine) recomputed Keytel for every cycle and wrote it over activeKcalEst, which would have hidden the MET decision on any phone with a day-cycle history. The fold now takes the same decision analyzeDay takes, over its own window [onset, min(endExclusive, now)): covered → the MET total, thin → withheld with no HR substitute, no rows or toggle off → Keytel byte for byte. A "stepsCycle calories … path=met" trace line names the decision. The toggle joins the day-cache config signature on both platforms so a flip re-scores every cached day. Android threads the flag Context-free like spo2CandidateDisplay, carries it into the pass on a field and reads the MET rows inside readDaySkinAndWristOff, and hands the fold a Boolean rather than a lambda — all to stay inside analyzeRecentOnCpu's JaCoCo budget (a new suspend call there spills ~0.7 K instructions of continuation state). Settings gains "Experimental · Oura Calories" (iOS card / Android row), shown only with an Oura ring, caption stating the method, the < 50 % coverage withhold, that Oura re-scores a logged workout's minutes by activity type (NOOP does not, so such a day reads lower), and that turning it on starts storing MET samples. Strings in de/es/fr/pt-PT plus it/pl/ru/zh-Hans/ zh-Hant (iOS) and pl/ru/zh (Android). Docs: OURA_PROTOCOL.md §6.13 (day-sum validation of the 0x50 decode, end-of-record timestamps, the formula); README Oura table "Active calories". OFF keeps both the DB and every score byte-identical. Tests: DayCacheConfigFieldTests / DayCacheConfigFieldTest; DayCycleRecoveryTests (Swift, xcodebuild test) and DayCycleMetCaloriesTest (Kotlin, Robolectric + Room) on a seeded 8-h main sleep: covered cycle carries the MET total, 10 %-covered is withheld, no reader / empty table = Keytel. [5/7] feat(oura): pair the MET store with its twins in the parity ledger Review follow-up. The parity ledger flagged the MET store's Kotlin reads and writes as one-sided; run in full it also flagged the MET toggle's arity changes to existing functions, which read as new identities. Paired, with twin claims where the ledger reads them: - `WhoopRepository.insertOuraMetSamples` <-> `WhoopStore.insertOuraMetSamples` - `WhoopRepository.ouraMetSamples` <-> `WhoopStore.ouraMetSamples` (on the Repository, since the DAO's `@Query` puts its declaration outside the claim's attach window) - `AnalyticsEngine.analyzeDay`, `IntelligenceEngine.analyzeRecent` and `DayCycleIntelligenceIntegration.compute`, whose parameter lists grew here Removed: `WhoopStore.ouraMetSampleCount` and `WhoopDao.countOuraMetFor`. Neither had a production caller (the Kotlin one had no caller at all); the store tests count through `ouraMetSamples` instead. `platform_specific` dispositions for what is Kotlin-only by construction: the Room `@Insert` and `@Query` primitives under the paired Repository functions, the per-table forget-device delete (Swift clears the table through the `DeviceRegistryStore` name list), the Room migration override (its GRDB twin is the `v47-oura-met-sample` closure, pinned by the shared schema oracle), and the two JVM 64 KiB extractions `readDaySkinAndWristOff` and `PhysiologicalStepCycleEngine.compute`. The authority is re-derived with `--migrate-authority` because `main` at 5783c49 no longer reproduces its stored one. Verified: `parity_ledger.py` OK; `parity_ratchet.py --migrate-authority` 0 errors; `test_parity_*` 180 OK; StrandAnalytics 2068/0; WhoopStore 617/0; macOS `StrandTests` (DayCacheConfigField, DayCycleRecovery) 17/0; `NOOPiOS` builds; Android 6402 tests, the 8 failures reproduce identically on `upstream/main`; doc lint and `i18n_audit --ci` clean. [6/7] doc(calories): say at the MET assignment that activeKcalEst holds a total Review follow-up. `activeKcalEst = met.totalKcal` reads like a category error: the estimate carries the active share separately. It is correct, because the field is a pre-existing misnomer and the HR path's `estimateDayCalories` already stores `estimateDayEnergy(...).totalKcal` in it. That is now written at both `analyzeDay` sites, so the next reader does not have to work it out again. Comment only. [7/7] fix(oura): key an ended cycle's MET calories on the MET series and the toggle the per-owner HR witness and the scoring profile. Once the MET-calories toggle routes a cycle's calories through the ring's MET series, that key cannot see what the figure depends on: - a later drain that banks MET minutes inside an ended window leaves the HR witness unchanged, so the cache keeps serving calories from the thinner stream (or a withheld nil, when the first pass was under the floor); - a toggle flip over unchanged data matches the entry cached under the other setting and serves the previous mode's figure. Both platforms now append a MET witness to the key: `met=off` when the toggle is off, otherwise `met=<owner>=<count>:<maxTs>` from an index-only aggregate over ouraMetSample, the same shape as the HR witness. Swift adds WhoopStore.ouraMetFingerprint(deviceId:from:to:), injected into the fold as MetFingerprint beside MetReader, and an unread witness never serves a cached load. Kotlin adds the countOuraMetInWindow / maxOuraMetTsInWindow DAO pair and WhoopRepository.ouraMetFingerprint, and loadCacheKey takes the witness. The rebase onto ryanbr#2293 moved the fold's MET-vs-HR decision into the cache-miss branch as cycleCalories() on both platforms, unchanged in behaviour. Tests: OuraMetStoreTests fingerprint (count/maxTs, inclusive bounds, per device, moves on an in-window insert); DayCycleRecoveryTests and DayCycleMetCaloriesTest each gain "MET banked after a pass is not served from the cache" and "a toggle flip over unchanged data rescores the cycle"; RescoreUnchangedInputsTest pins the witness in the key; Kotlin repo fingerprint. With the witness replaced by a constant, 4 of 6 DayCycleMetCaloriesTest cases fail. Parity: loadCacheKey/8 replaces /7 in the dispositions, the two DAO primitives are platform_specific like their HR counterparts; ratchet 0 errors, ledger no new findings. Rebase onto main at ca24978: main's ryanbr#2472 grew analyzeRecentOnCpu to 55,559 instrumented bytes, which left 141 against the 55,700 JaCoCo budget, and this change measured 239. The day's MET rows now reach analyzeDay through a pass field (ouraMetDayForPass, written by readDaySkinAndWristOff), not through the DaySkinReads holder. On the holder, the holder stayed live across every later suspension point of the day loop and was spilled and restored at each one. The helper derives the calendar-day window from dayStart itself (same 14-parameter arity). Measured 55,669. Refs ryanbr#2242 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq Claude-Session: https://claude.ai/code/session_01484Ef871BnAuniyDJYYHoV Claude-Session: https://claude.ai/code/session_01RgqTwKfXLXzKo81ECb4aDz Claude-Session: https://claude.ai/code/session_01RuJdFu6iobsWfRtPYUR4C8 Claude-Session: https://claude.ai/code/session_01QUwfZ8hUzmV6ScwZcuaTAr
…isagree WhoopRepository carried two functions computing the same value. Issue #2566. hrUnionFingerprint/3 line 1185, from #2293 hrFingerprintWindowUnion/3 line 728, which I added in #2543 Same signature, same rawWhoopSourceIds walk, same COUNT plus MAX per id. They differed only in how they punctuated the result: "id=count:maxTs" joined with a comma against "id:count:maxTs" joined with a pipe. Neither encoding is observable. StressLensCache is an in-memory object holding a single key, and PhysiologicalStepCycleEngine's loadCache is a plain HashMap field of a private data class. Nothing persists either string or sends it anywhere, so both doc comments were right that the format is free to change. The redundancy was not the risk. Two witnesses of one fact that are allowed to drift is the risk, and the fact they witness is whether cached scoring work can be reused. Teach one union to walk an extra id or carry a third aggregate and the other keeps answering the older question, silently. So: hrFingerprintWindowUnion is gone, the daytime stress lens memo reads hrUnionFingerprint, and hrUnionFingerprint now calls hrFingerprintWindow for each id rather than repeating the two DAO reads inline. One union, one encoding, one per-id read. The cycle load cache keeps the exact value it had. Strand/Data/Repository.swift referenced the deleted function as its Kotlin twin, so that pointer moves to the survivor. Both sides now also say what kind of twin this is: each compares its own value against its own previous value, neither is persisted or transmitted, and the two encode the same facts differently. There is no byte-identity contract here and no oracle asserting one. Without that note the next reader could take "twin" for the usual byte-level pair this repository means by the word and align the encodings believing they were repairing something. Neither union had a single test, which is how a duplicate survived review. HrUnionFingerprintTest pins the property that motivated the union at all: a row landing under an alias id or under the canonical id must move the witness even when the active id is untouched (#908). A fingerprint keyed on the active id alone passes a naive equality check and fails these. The stubbed DAO throws on any method other than the two index aggregates, so a row fetch creeping into the cheap path fails too. Mutation-verified rather than assumed. Replacing the union walk with the bare active id reddens 4 of 6; dropping maxTs from each entry reddens a different 4 of 6. The survivors are explainable in both cases: stableWhenNothingChanged only asserts determinism, and canonicalActiveIsNotDuplicated sets the active id to the canonical one, where the union and the single id genuinely coincide.
…default-off Experimental toggle (ryanbr#2242) [1/7] feat(analytics): day energy from a MET stream — Oura's documented rule, no fitted constant Adds Calories.estimateDayEnergyFromMET (Swift) / estimateDayEnergyFromMet (Kotlin): active = Σ_{met ≥ 1.5} (met − 1.5) × 0.0175 kcal·kg⁻¹·min⁻¹ × weight over the day's samples — Oura's support wording ("the portion that exceeds 1.5 MET") at the standard MET→kcal definition (1 MET = 3.5 ml O₂·kg⁻¹·min⁻¹). Resting is the same revised Harris–Benedict the HR path uses, scaled by covered seconds; coverage is reported over the window the caller passes so today's partial day is judged against elapsed hours. Missing minutes are unknown — never extrapolated to activity, never banked as rest. The rule was identified, not fitted: on the Oura export's own per-minute MET series it reproduces active_calories with r = 1.0000 and 0.6 kcal/day RMSE over 75 current-era days (r 0.9999 / 3 kcal over 396 pre-2025 days), zero intercept, the only input being the wearer's weight. A re-served minute counts once. The ring's timestamps go through a per-session 0x13 anchor, so a record served again under a second session lands 2–5 s off its first copy, while the ring's own minute grid steps back a second between records (a real successor starts 59–61 s on). Half a period tells them apart: two samples are the same minute when |Δ| × 2 < min(epochS), and the walk compares against the last counted start. Kotlin is pinned to Swift by oracle: the real Calories enum compiled standalone over a case spread (84 output lines) (four profiles × empty / rest floor / bout / 1.5 threshold edge / decoder boundary / partial coverage / 120 s cadence / duplicate ts / 3-s twins / 59-s phase steps / window edges / partial today / DST 23 h / clamp / bad epochs / realistic day), stdout pasted verbatim into MetCaloriesOracleTest. MetCaloriesTests pins the Swift side with hand-derived literals. [2/7] feat(store): persist the Oura ring's per-minute MET series — ouraMetSample, both platforms Until now the 0x50 MET stream existed only as the diagnostic JSONL sidecar, so nothing scored could read it. GRDB v47-oura-met-sample / Room MIGRATION_40_41 (schema 41) add ouraMetSample(deviceId, ts, met REAL, state, epochS) keyed (deviceId, ts), column order identical on both sides and pinned in schema_oracle.json (both copies). epochS is carried per row so a cadence other than 60 s scales the estimate rather than skewing it. Insert drops a re-served twin: the (deviceId, ts) key alone does not catch a record served again under another session anchor (it lands a few seconds off), so the insert reads the stored rows around the batch and drops any incoming sample within half a period of a stored one or of one accepted earlier in the batch (earlier start wins, lower MET on an exact tie). The pure rule is OuraMetSample.droppingTwins / OuraMetSampleEntity.droppingTwins, same integer test as the estimator, testable without a database. A 59-s phase-step successor is kept. Swift: OuraMetStore (insert, bounded range read, count), the table on DeviceRegistryStore.deviceScopedTables so forgetting a ring clears it. Android: entity, DAO insert/read/delete, DeviceRegistry.deleteDeviceData wiring, repository wrappers, the committed v41 Room schema for the upgrade test. Tests: OuraMetStoreTests (Swift), OuraMetSampleMigrationTest (Kotlin, incl. the twin rule); SchemaOracleTest / WhoopDatabaseUpgradeTest / the device-scoped-table guards on both sides. [3/7] feat(analytics): analyzeDay scores calories from the owner's MET series when supplied analyzeDay gains dayMet (the day owner's own per-minute MET samples), dayMetNow (so today's coverage is judged against elapsed hours, not 24 h) and a caloriesDiag sink. Rule: MET samples present ⇒ activeKcalEst = estimateDayEnergyFromMET(...).totalKcal when the stream covers ≥ 50 % of the window; below that the estimate is withheld (nil) rather than substituted by the HR path, which on a ring day runs over sparse banked HR and does not track Oura's number (r ≈ −0.1). nil/empty dayMet keeps the HR path byte-identical — every WHOOP and pure-function caller is unchanged. One always-on log line per MET day. Both platforms; AnalyticsEngineMetCaloriesTests / AnalyticsEngineMetCaloriesTest pin the selection, the withhold, the elapsed-hours window and the byte-identical no-MET path. [4/7] feat(oura): score active calories from the ring's MET records, behind an Experimental toggle The toggle (iOS AppModel.ouraMetCaloriesKey / Android NoopPrefs.KEY_OURA_MET_CALORIES, default OFF) gates: • the writer — OuraLiveSource fans each anchored 0x50 record out to ouraMetSample, one row per minute, next to the existing JSONL sidecar call. The record's timestamp is the END of its last sample: fitted against Oura's own per-minute export, every record length n matched best at exactly −n minutes (85 % exact minute matches, r 0.90, vs 23 % / 0.57 read forward), so sample i starts at utc − (n − i)·60 and a record straddling local midnight lands its minutes on the right days. • the read — IntelligenceEngine reads the day owner's rows (registry active id, calendar- day scoped like dayHr) and hands them to analyzeDay as dayMet, with dayMetNow = now; the calories line goes through the same per-day diag recorder as the Effort funnel. • the day-cycle fold — DayCycleIntelligenceIntegration (and Android's PhysiologicalStepCycleEngine) recomputed Keytel for every cycle and wrote it over activeKcalEst, which would have hidden the MET decision on any phone with a day-cycle history. The fold now takes the same decision analyzeDay takes, over its own window [onset, min(endExclusive, now)): covered → the MET total, thin → withheld with no HR substitute, no rows or toggle off → Keytel byte for byte. A "stepsCycle calories … path=met" trace line names the decision. The toggle joins the day-cache config signature on both platforms so a flip re-scores every cached day. Android threads the flag Context-free like spo2CandidateDisplay, carries it into the pass on a field and reads the MET rows inside readDaySkinAndWristOff, and hands the fold a Boolean rather than a lambda — all to stay inside analyzeRecentOnCpu's JaCoCo budget (a new suspend call there spills ~0.7 K instructions of continuation state). Settings gains "Experimental · Oura Calories" (iOS card / Android row), shown only with an Oura ring, caption stating the method, the < 50 % coverage withhold, that Oura re-scores a logged workout's minutes by activity type (NOOP does not, so such a day reads lower), and that turning it on starts storing MET samples. Strings in de/es/fr/pt-PT plus it/pl/ru/zh-Hans/ zh-Hant (iOS) and pl/ru/zh (Android). Docs: OURA_PROTOCOL.md §6.13 (day-sum validation of the 0x50 decode, end-of-record timestamps, the formula); README Oura table "Active calories". OFF keeps both the DB and every score byte-identical. Tests: DayCacheConfigFieldTests / DayCacheConfigFieldTest; DayCycleRecoveryTests (Swift, xcodebuild test) and DayCycleMetCaloriesTest (Kotlin, Robolectric + Room) on a seeded 8-h main sleep: covered cycle carries the MET total, 10 %-covered is withheld, no reader / empty table = Keytel. [5/7] feat(oura): pair the MET store with its twins in the parity ledger Review follow-up. The parity ledger flagged the MET store's Kotlin reads and writes as one-sided; run in full it also flagged the MET toggle's arity changes to existing functions, which read as new identities. Paired, with twin claims where the ledger reads them: - `WhoopRepository.insertOuraMetSamples` <-> `WhoopStore.insertOuraMetSamples` - `WhoopRepository.ouraMetSamples` <-> `WhoopStore.ouraMetSamples` (on the Repository, since the DAO's `@Query` puts its declaration outside the claim's attach window) - `AnalyticsEngine.analyzeDay`, `IntelligenceEngine.analyzeRecent` and `DayCycleIntelligenceIntegration.compute`, whose parameter lists grew here Removed: `WhoopStore.ouraMetSampleCount` and `WhoopDao.countOuraMetFor`. Neither had a production caller (the Kotlin one had no caller at all); the store tests count through `ouraMetSamples` instead. `platform_specific` dispositions for what is Kotlin-only by construction: the Room `@Insert` and `@Query` primitives under the paired Repository functions, the per-table forget-device delete (Swift clears the table through the `DeviceRegistryStore` name list), the Room migration override (its GRDB twin is the `v47-oura-met-sample` closure, pinned by the shared schema oracle), and the two JVM 64 KiB extractions `readDaySkinAndWristOff` and `PhysiologicalStepCycleEngine.compute`. The authority is re-derived with `--migrate-authority` because `main` at 5783c49 no longer reproduces its stored one. Verified: `parity_ledger.py` OK; `parity_ratchet.py --migrate-authority` 0 errors; `test_parity_*` 180 OK; StrandAnalytics 2068/0; WhoopStore 617/0; macOS `StrandTests` (DayCacheConfigField, DayCycleRecovery) 17/0; `NOOPiOS` builds; Android 6402 tests, the 8 failures reproduce identically on `upstream/main`; doc lint and `i18n_audit --ci` clean. [6/7] doc(calories): say at the MET assignment that activeKcalEst holds a total Review follow-up. `activeKcalEst = met.totalKcal` reads like a category error: the estimate carries the active share separately. It is correct, because the field is a pre-existing misnomer and the HR path's `estimateDayCalories` already stores `estimateDayEnergy(...).totalKcal` in it. That is now written at both `analyzeDay` sites, so the next reader does not have to work it out again. Comment only. [7/7] fix(oura): key an ended cycle's MET calories on the MET series and the toggle the per-owner HR witness and the scoring profile. Once the MET-calories toggle routes a cycle's calories through the ring's MET series, that key cannot see what the figure depends on: - a later drain that banks MET minutes inside an ended window leaves the HR witness unchanged, so the cache keeps serving calories from the thinner stream (or a withheld nil, when the first pass was under the floor); - a toggle flip over unchanged data matches the entry cached under the other setting and serves the previous mode's figure. Both platforms now append a MET witness to the key: `met=off` when the toggle is off, otherwise `met=<owner>=<count>:<maxTs>` from an index-only aggregate over ouraMetSample, the same shape as the HR witness. Swift adds WhoopStore.ouraMetFingerprint(deviceId:from:to:), injected into the fold as MetFingerprint beside MetReader, and an unread witness never serves a cached load. Kotlin adds the countOuraMetInWindow / maxOuraMetTsInWindow DAO pair and WhoopRepository.ouraMetFingerprint, and loadCacheKey takes the witness. The rebase onto ryanbr#2293 moved the fold's MET-vs-HR decision into the cache-miss branch as cycleCalories() on both platforms, unchanged in behaviour. Tests: OuraMetStoreTests fingerprint (count/maxTs, inclusive bounds, per device, moves on an in-window insert); DayCycleRecoveryTests and DayCycleMetCaloriesTest each gain "MET banked after a pass is not served from the cache" and "a toggle flip over unchanged data rescores the cycle"; RescoreUnchangedInputsTest pins the witness in the key; Kotlin repo fingerprint. With the witness replaced by a constant, 4 of 6 DayCycleMetCaloriesTest cases fail. Parity: loadCacheKey/8 replaces /7 in the dispositions, the two DAO primitives are platform_specific like their HR counterparts; ratchet 0 errors, ledger no new findings. Rebase onto main at ca24978: main's ryanbr#2472 grew analyzeRecentOnCpu to 55,559 instrumented bytes, which left 141 against the 55,700 JaCoCo budget, and this change measured 239. The day's MET rows now reach analyzeDay through a pass field (ouraMetDayForPass, written by readDaySkinAndWristOff), not through the DaySkinReads holder. On the holder, the holder stayed live across every later suspension point of the day loop and was spilled and restored at each one. The helper derives the calendar-day window from dayStart itself (same 14-parameter arity). Measured 55,669. Refs ryanbr#2242 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq Claude-Session: https://claude.ai/code/session_01484Ef871BnAuniyDJYYHoV Claude-Session: https://claude.ai/code/session_01RgqTwKfXLXzKo81ECb4aDz Claude-Session: https://claude.ai/code/session_01RuJdFu6iobsWfRtPYUR4C8 Claude-Session: https://claude.ai/code/session_01QUwfZ8hUzmV6ScwZcuaTAr
…default-off Experimental toggle (ryanbr#2242) [1/7] feat(analytics): day energy from a MET stream — Oura's documented rule, no fitted constant Adds Calories.estimateDayEnergyFromMET (Swift) / estimateDayEnergyFromMet (Kotlin): active = Σ_{met ≥ 1.5} (met − 1.5) × 0.0175 kcal·kg⁻¹·min⁻¹ × weight over the day's samples — Oura's support wording ("the portion that exceeds 1.5 MET") at the standard MET→kcal definition (1 MET = 3.5 ml O₂·kg⁻¹·min⁻¹). Resting is the same revised Harris–Benedict the HR path uses, scaled by covered seconds; coverage is reported over the window the caller passes so today's partial day is judged against elapsed hours. Missing minutes are unknown — never extrapolated to activity, never banked as rest. The rule was identified, not fitted: on the Oura export's own per-minute MET series it reproduces active_calories with r = 1.0000 and 0.6 kcal/day RMSE over 75 current-era days (r 0.9999 / 3 kcal over 396 pre-2025 days), zero intercept, the only input being the wearer's weight. A re-served minute counts once. The ring's timestamps go through a per-session 0x13 anchor, so a record served again under a second session lands 2–5 s off its first copy, while the ring's own minute grid steps back a second between records (a real successor starts 59–61 s on). Half a period tells them apart: two samples are the same minute when |Δ| × 2 < min(epochS), and the walk compares against the last counted start. Kotlin is pinned to Swift by oracle: the real Calories enum compiled standalone over a case spread (84 output lines) (four profiles × empty / rest floor / bout / 1.5 threshold edge / decoder boundary / partial coverage / 120 s cadence / duplicate ts / 3-s twins / 59-s phase steps / window edges / partial today / DST 23 h / clamp / bad epochs / realistic day), stdout pasted verbatim into MetCaloriesOracleTest. MetCaloriesTests pins the Swift side with hand-derived literals. [2/7] feat(store): persist the Oura ring's per-minute MET series — ouraMetSample, both platforms Until now the 0x50 MET stream existed only as the diagnostic JSONL sidecar, so nothing scored could read it. GRDB v47-oura-met-sample / Room MIGRATION_40_41 (schema 41) add ouraMetSample(deviceId, ts, met REAL, state, epochS) keyed (deviceId, ts), column order identical on both sides and pinned in schema_oracle.json (both copies). epochS is carried per row so a cadence other than 60 s scales the estimate rather than skewing it. Insert drops a re-served twin: the (deviceId, ts) key alone does not catch a record served again under another session anchor (it lands a few seconds off), so the insert reads the stored rows around the batch and drops any incoming sample within half a period of a stored one or of one accepted earlier in the batch (earlier start wins, lower MET on an exact tie). The pure rule is OuraMetSample.droppingTwins / OuraMetSampleEntity.droppingTwins, same integer test as the estimator, testable without a database. A 59-s phase-step successor is kept. Swift: OuraMetStore (insert, bounded range read, count), the table on DeviceRegistryStore.deviceScopedTables so forgetting a ring clears it. Android: entity, DAO insert/read/delete, DeviceRegistry.deleteDeviceData wiring, repository wrappers, the committed v41 Room schema for the upgrade test. Tests: OuraMetStoreTests (Swift), OuraMetSampleMigrationTest (Kotlin, incl. the twin rule); SchemaOracleTest / WhoopDatabaseUpgradeTest / the device-scoped-table guards on both sides. [3/7] feat(analytics): analyzeDay scores calories from the owner's MET series when supplied analyzeDay gains dayMet (the day owner's own per-minute MET samples), dayMetNow (so today's coverage is judged against elapsed hours, not 24 h) and a caloriesDiag sink. Rule: MET samples present ⇒ activeKcalEst = estimateDayEnergyFromMET(...).totalKcal when the stream covers ≥ 50 % of the window; below that the estimate is withheld (nil) rather than substituted by the HR path, which on a ring day runs over sparse banked HR and does not track Oura's number (r ≈ −0.1). nil/empty dayMet keeps the HR path byte-identical — every WHOOP and pure-function caller is unchanged. One always-on log line per MET day. Both platforms; AnalyticsEngineMetCaloriesTests / AnalyticsEngineMetCaloriesTest pin the selection, the withhold, the elapsed-hours window and the byte-identical no-MET path. [4/7] feat(oura): score active calories from the ring's MET records, behind an Experimental toggle The toggle (iOS AppModel.ouraMetCaloriesKey / Android NoopPrefs.KEY_OURA_MET_CALORIES, default OFF) gates: • the writer — OuraLiveSource fans each anchored 0x50 record out to ouraMetSample, one row per minute, next to the existing JSONL sidecar call. The record's timestamp is the END of its last sample: fitted against Oura's own per-minute export, every record length n matched best at exactly −n minutes (85 % exact minute matches, r 0.90, vs 23 % / 0.57 read forward), so sample i starts at utc − (n − i)·60 and a record straddling local midnight lands its minutes on the right days. • the read — IntelligenceEngine reads the day owner's rows (registry active id, calendar- day scoped like dayHr) and hands them to analyzeDay as dayMet, with dayMetNow = now; the calories line goes through the same per-day diag recorder as the Effort funnel. • the day-cycle fold — DayCycleIntelligenceIntegration (and Android's PhysiologicalStepCycleEngine) recomputed Keytel for every cycle and wrote it over activeKcalEst, which would have hidden the MET decision on any phone with a day-cycle history. The fold now takes the same decision analyzeDay takes, over its own window [onset, min(endExclusive, now)): covered → the MET total, thin → withheld with no HR substitute, no rows or toggle off → Keytel byte for byte. A "stepsCycle calories … path=met" trace line names the decision. The toggle joins the day-cache config signature on both platforms so a flip re-scores every cached day. Android threads the flag Context-free like spo2CandidateDisplay, carries it into the pass on a field and reads the MET rows inside readDaySkinAndWristOff, and hands the fold a Boolean rather than a lambda — all to stay inside analyzeRecentOnCpu's JaCoCo budget (a new suspend call there spills ~0.7 K instructions of continuation state). Settings gains "Experimental · Oura Calories" (iOS card / Android row), shown only with an Oura ring, caption stating the method, the < 50 % coverage withhold, that Oura re-scores a logged workout's minutes by activity type (NOOP does not, so such a day reads lower), and that turning it on starts storing MET samples. Strings in de/es/fr/pt-PT plus it/pl/ru/zh-Hans/ zh-Hant (iOS) and pl/ru/zh (Android). Docs: OURA_PROTOCOL.md §6.13 (day-sum validation of the 0x50 decode, end-of-record timestamps, the formula); README Oura table "Active calories". OFF keeps both the DB and every score byte-identical. Tests: DayCacheConfigFieldTests / DayCacheConfigFieldTest; DayCycleRecoveryTests (Swift, xcodebuild test) and DayCycleMetCaloriesTest (Kotlin, Robolectric + Room) on a seeded 8-h main sleep: covered cycle carries the MET total, 10 %-covered is withheld, no reader / empty table = Keytel. [5/7] feat(oura): pair the MET store with its twins in the parity ledger Review follow-up. The parity ledger flagged the MET store's Kotlin reads and writes as one-sided; run in full it also flagged the MET toggle's arity changes to existing functions, which read as new identities. Paired, with twin claims where the ledger reads them: - `WhoopRepository.insertOuraMetSamples` <-> `WhoopStore.insertOuraMetSamples` - `WhoopRepository.ouraMetSamples` <-> `WhoopStore.ouraMetSamples` (on the Repository, since the DAO's `@Query` puts its declaration outside the claim's attach window) - `AnalyticsEngine.analyzeDay`, `IntelligenceEngine.analyzeRecent` and `DayCycleIntelligenceIntegration.compute`, whose parameter lists grew here Removed: `WhoopStore.ouraMetSampleCount` and `WhoopDao.countOuraMetFor`. Neither had a production caller (the Kotlin one had no caller at all); the store tests count through `ouraMetSamples` instead. `platform_specific` dispositions for what is Kotlin-only by construction: the Room `@Insert` and `@Query` primitives under the paired Repository functions, the per-table forget-device delete (Swift clears the table through the `DeviceRegistryStore` name list), the Room migration override (its GRDB twin is the `v47-oura-met-sample` closure, pinned by the shared schema oracle), and the two JVM 64 KiB extractions `readDaySkinAndWristOff` and `PhysiologicalStepCycleEngine.compute`. The authority is re-derived with `--migrate-authority` because `main` at 5783c49 no longer reproduces its stored one. Verified: `parity_ledger.py` OK; `parity_ratchet.py --migrate-authority` 0 errors; `test_parity_*` 180 OK; StrandAnalytics 2068/0; WhoopStore 617/0; macOS `StrandTests` (DayCacheConfigField, DayCycleRecovery) 17/0; `NOOPiOS` builds; Android 6402 tests, the 8 failures reproduce identically on `upstream/main`; doc lint and `i18n_audit --ci` clean. [6/7] doc(calories): say at the MET assignment that activeKcalEst holds a total Review follow-up. `activeKcalEst = met.totalKcal` reads like a category error: the estimate carries the active share separately. It is correct, because the field is a pre-existing misnomer and the HR path's `estimateDayCalories` already stores `estimateDayEnergy(...).totalKcal` in it. That is now written at both `analyzeDay` sites, so the next reader does not have to work it out again. Comment only. [7/7] fix(oura): key an ended cycle's MET calories on the MET series and the toggle the per-owner HR witness and the scoring profile. Once the MET-calories toggle routes a cycle's calories through the ring's MET series, that key cannot see what the figure depends on: - a later drain that banks MET minutes inside an ended window leaves the HR witness unchanged, so the cache keeps serving calories from the thinner stream (or a withheld nil, when the first pass was under the floor); - a toggle flip over unchanged data matches the entry cached under the other setting and serves the previous mode's figure. Both platforms now append a MET witness to the key: `met=off` when the toggle is off, otherwise `met=<owner>=<count>:<maxTs>` from an index-only aggregate over ouraMetSample, the same shape as the HR witness. Swift adds WhoopStore.ouraMetFingerprint(deviceId:from:to:), injected into the fold as MetFingerprint beside MetReader, and an unread witness never serves a cached load. Kotlin adds the countOuraMetInWindow / maxOuraMetTsInWindow DAO pair and WhoopRepository.ouraMetFingerprint, and loadCacheKey takes the witness. The rebase onto ryanbr#2293 moved the fold's MET-vs-HR decision into the cache-miss branch as cycleCalories() on both platforms, unchanged in behaviour. Tests: OuraMetStoreTests fingerprint (count/maxTs, inclusive bounds, per device, moves on an in-window insert); DayCycleRecoveryTests and DayCycleMetCaloriesTest each gain "MET banked after a pass is not served from the cache" and "a toggle flip over unchanged data rescores the cycle"; RescoreUnchangedInputsTest pins the witness in the key; Kotlin repo fingerprint. With the witness replaced by a constant, 4 of 6 DayCycleMetCaloriesTest cases fail. Parity: loadCacheKey/8 replaces /7 in the dispositions, the two DAO primitives are platform_specific like their HR counterparts; ratchet 0 errors, ledger no new findings. Rebase onto main at ca24978: main's ryanbr#2472 grew analyzeRecentOnCpu to 55,559 instrumented bytes, which left 141 against the 55,700 JaCoCo budget, and this change measured 239. The day's MET rows now reach analyzeDay through a pass field (ouraMetDayForPass, written by readDaySkinAndWristOff), not through the DaySkinReads holder. On the holder, the holder stayed live across every later suspension point of the day loop and was spilled and restored at each one. The helper derives the calendar-day window from dayStart itself (same 14-parameter arity). Measured 55,669. Refs ryanbr#2242 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq Claude-Session: https://claude.ai/code/session_01484Ef871BnAuniyDJYYHoV Claude-Session: https://claude.ai/code/session_01RgqTwKfXLXzKo81ECb4aDz Claude-Session: https://claude.ai/code/session_01RuJdFu6iobsWfRtPYUR4C8 Claude-Session: https://claude.ai/code/session_01QUwfZ8hUzmV6ScwZcuaTAr
What this PR does
Two changes stop the post-sync re-score from repeating work whose inputs did not change. Most of the cost of a backgrounded pass came from these two places.
1. Sleep habits are learned from finished nights only.
sleepNeedHours,sleepConsistencyandhabitualMidsleepSecare all in the day-cache config signature, andcomputeHabitualSleepread them from every banked session up tonow. That includes tonight's session, which every sync re-banks while it is still growing. So each sync through a morning moved the consistency and midsleep, the signature changed, and the whole 21-night cache was dropped (configDropped(sleepNeedHours+sleepConsistency+habitualMidsleep)).computeHabitualSleepnow takesfinishedBefore:and the pass passes its local midnight. A night joins the learned history the day after it ends, when the window rolls anyway.2. A closed sleep-to-sleep cycle keeps its Effort and calories. In
.sleepOnsetmode (the default),DayCycleIntelligenceIntegration.computere-read each cycle's full day of 1 Hz heart rate from every owner on every pass. It then re-scored Effort and calories for all 21 cycles, although only the open cycle gains samples. Step counts already had a per-cycle cache. Effort and calories now have one too, keyed on the index-onlyhrFingerprint(count and newest timestamp) per owner, the same witness the day cache uses, plus resting HR, HRmax, method and profile.Why
A phone log showed backgrounded passes of 8 813 s and 2 345 s running back to back, with scores arriving hours late. A backgrounded app only gets short execution windows, so a pass that costs seconds of CPU in the foreground takes hours of wall-clock time in the background.
I measured this by replaying
analyzeRecenton a Mac against a copy of that phone's database (21 nights, Debug build, so compare the numbers with each other rather than with a phone):reused=0/21,configDropped(sleepConsistency+habitualMidsleep))reused=21/21)In the last row, the
score2phase went from 5.9 s to 0.3 s. The time before the scoring loop was nearly allDayCycleIntelligenceIntegration.compute(6–12 s, measured with temporary timers). The detected nights, stages, and the resulting sleep sessions are unchanged by either commit.Behaviour change
This is not only a speed-up. Learning from finished nights moves three user-visible numbers:
sleepNeedHours,sleepConsistencyandhabitualMidsleepSecwill differ slightly from what the same database produced before, for everyone, on upgrade. The most recently finished night is held out of the learned history for up to about a day, until the window rolls past it. The reasoning is that a night still being slept is not a habit yet, and learning from a session every sync re-banks while it grows is what produced the churn. Anyone picking up the open anchor half of #2350 should knowhabitualMidsleepSecmoved underneath it.The cycle load key names the profile's fields explicitly (
UserProfile.cacheKeyon both platforms, a test per field) rather than relying onString(describing:)/toString, whose format is not a contract.Type of change
How it was tested
StrandTests/HabitualSleepFinishedNightsTests: tonight's session, synced first to 04:00 and then to 09:30, does not change the learned midsleep or nightly hours.doc_comment_lint.pyis clean, andparity_ratchet.py --base upstream/main --offlinereports 0 errors.Android. The Kotlin twin is in this PR:
IntelligenceEngine.computeHabitualSleep(finishedBefore)learns from finished nights only, throughfinishedSessions.PhysiologicalStepCycleEnginekeeps a per-cycle Effort and calories cache keyed byloadCacheKey, with the index-onlyWhoopRepository.hrUnionFingerprintas the HR witness.RescoreUnchangedInputsTestcovers both changes. The full suite has 6287 tests with 2 failures, both inRecoveryDriversTest(a half-tie rounding assertion), and they fail identically on unmodifiedmainon this Mac.platform_specificinparity_dispositions.json, because their Swift twins sit in the app layer (Strand/Data/), outside the governed roots.parity_ratchet.py --offlinereports 0 errors and the parity-governance suite passes under Python 3.12, as CI runs it.Checklist
docs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/keystores