Conversation
|
I can close the Android gap you flagged, and I have one substantive comment on how the change is argued. Android: compiles and passesBuilt your branch at Both new classes ran rather than silently not existing, checked by name in the result XML, and the XML timestamp was verified against the clock so this is not a stale read: So the Kotlin twin is sound as written. Room schema snapshot was synced in its own invocation first, which this suite needs after a real recompile. The argument could be stronger than the one you madeThe PR leads on the WHOOP comparison, and
I do not think that sinks the PR, because the real argument is already in your description and is an INTERNAL one:
Leading with those two, and keeping the table as corroboration that the new statistic lands in a plausible place, would make this much harder to argue with. As written, a reviewer applying SCOPE.md literally could bounce it on the framing alone. What I would still wantThe evidence is one wearer over 23 nights. That is a better evidence base than most changes here get, and the direction is convincing. But this moves displayed RHR, the daily row, Apple Health, Effort's heart-rate reserve and the recovery baseline for EVERY user, and it deletes and rewrites previously exported Apple Health values. A second wearer, even briefly, would materially change how much that blast radius has to be taken on faith. The Apple Health rewrite deserves its own scrutiny. Deleting our own The Android exporter does not get the equivalent rewrite, as you note. That leaves Android wearers with Health Connect rows still carrying the old statistic while the app shows the new one. Worth an issue so it is tracked rather than implied. Nice piece of work, and the shared-binning detail (keeping the lowest-bin statistic for the daytime false-sleep guard whose thresholds were tuned against it, rather than repointing it) is exactly the kind of thing that usually gets missed. |
9fec01d to
43317e3
Compare
|
Thanks for building the Android side. That settles the part I couldn't run. Framing: agreed, and done. The description now leads with the two internal arguments: the old statistic measured the calmest 5 minutes rather than a resting level, and the baseline mixed imported resting HR with a figure roughly 8 bpm lower. The WHOOP table is kept only as corroboration. One wearer: true, and I don't have a second one. The description now says so plainly, so the blast radius isn't taken on faith. Numbers from anyone with a strap-only history would be very welcome. Interrupted Apple Health rewrite: the owed flag is cleared only after
The worst case is a window in which Health has no NOOP resting HR for older nights. Duplicates can't occur, and other sources' samples are never touched, because the delete is scoped to A bug in my own change: on my phone the one-time re-score never ran. At every launch a post-offload pass already held Rebased onto current main (the branch point was 12 commits behind). No conflicts, and Android: I'd rather close this gap than leave it implied, so I'm setting up a local Android toolchain and will add the Health Connect equivalent of the rewrite to this PR, built and tested locally before I push. I'll also check whether Android's one-time re-score can lose the same race; its passes are serialised differently, so it may not. The Kotlin twin already here stays as you verified it. |
43317e3 to
2dcebe4
Compare
|
Added the Health Connect side as promised (2dcebe4). After the resting-HR rescore, the next export reaches past its 60-day window once and upserts every computed day's resting HR under the same |
2dcebe4 to
9d1d583
Compare
|
Reviewed at 9d1d583. This is the best-argued PR in the queue right now, so let me start with what I checked that could have gone wrong, then the three things worth acting on. Verified clean:
1. A diagnostic now reports the old statistic under the new name
What makes this an oversight rather than a decision is that you fixed exactly this hazard on the other line, changing 2. The flag-clearing claim is stronger than the codeThe description says the owed flag "is cleared only after the delete and the rewrite both return without throwing". The delete is Narrow, but real: the broad delete's stated purpose includes clearing samples "written under an older key scheme", which 3. This substantially overlaps #2358This changes I have said the same on #2358. The two should not be evaluated independently, and I would like a decision on sequencing before either merges. On the evidenceI held #2358 partly because its evidence is a single participant, and this is also one wearer over 23 nights, so let me be explicit that the distinction is real rather than convenient. #2358's argument IS the comparison, so one participant undermines it directly. This PR's argument is internal: the baseline mixes the imported export's resting HR with computed lowest-bin values roughly 8 bpm lower, so a wearer's own nights read as unusually low and inflate Charge. That is a defect provable with no external reference at all, and you label the 23-night table as corroboration rather than the case. That discipline is what makes this reviewable. HousekeepingThe branch is dirty, 27 commits behind, and carries a Your note that Python 3.14 misreports the base authority while 3.12 does not is worth pinning down on its own; CI pins 3.12, so nothing is broken today, but a version-sensitive derivation is a trap for the next person. Approving once the trace label is corrected and the branch is rebased. Point 2 I would take either here or as a follow-up. Point 3 is not yours to resolve. |
|
Sequencing decided: this goes in first, ahead of #2358. The reasoning is the one in my review. Your case is internal to the codebase, that the baseline mixes the imported export's resting HR with computed lowest-bin values roughly 8 bpm lower, so a wearer's own nights read as unusually low and inflate Charge. That is provable here with no external reference, and it does not depend on the 23-night table being representative. #2358's argument is the comparison itself, so it has to be re-derived once the statistic underneath it moves, which is exactly what this changes. Three things and it can go in:
Point 2 on the flag-clearing claim I raised is fine as a follow-up; it does not need to hold this up. |
…owest 5-min bin The night's resting HR was the minimum of its 5-min bin means: the single calmest stretch of the night, not a resting level. On one WHOOP 5.0 wearer's 23 nights it averaged 48.9 bpm against the 55.5 bpm WHOOP reported for the same wearer the month before (56.9 over its last 60 days). The value is displayed, stored on the daily row, exported to Apple Health, used for Effort's heart-rate reserve, and fed to the recovery baseline, whose imported WHOOP history sat ~8 bpm above every computed night and read each one as an unusually low resting HR. It is now the mean of the plausible HR samples inside the night's deep-sleep segments (`SleepStager.sessionDeepSleepRestingHR`): the slow-wave window WHOOP measures in, and the one NOOP's WHOOP-style HRV already pools over (ryanbr#141). On the same nights it averaged 54.9 bpm with a night-to-night spread (SD 3.1) close to WHOOP's (3.6); the whole-night mean read 58.1 (SD 2.2) and the last deep run alone 54.2 (SD 4.4). Under 300 deep-sleep samples it falls back to the lower quartile of the qualifying 5-min bins. The lowest bin is kept for the daytime false-sleep guard, whose resting-HR dip thresholds were tuned against it, and the binning is shared rather than copied. Stored nights move with it: a one-shot full-history rescore reuses the Effort rescore pass under its own flag, and on iOS the Apple Health write-back then deletes the resting HR it wrote since the first computed night (our own source only) and writes the recomputed values over that span. The strap-log line now carries `rhr=` (what NOOP reports) beside `floor=` (the lowest bin) and `nightMean=`, and the ryanbr#1943 bin-gate check compares against the floor it actually tests. Kotlin twin included; FAQ and ANALYTICS.md updated.
…ore instead of retrying next launch
…window once the rescore has run
…iled one-time Health delete keeps the owed flag The accepted-run trace printed the lowest bin as restingHR while the session reports the deep-sleep mean, so a night NOOP reports as 55 traced as 48. It now prints both, named like the rhr day= line. The one-time resting-HR delete in Apple Health was try?, so a failure still cleared the flag and the cleanup never ran again; it now throws (a no-match is not a failure).
9d1d583 to
ec94cb9
Compare
|
Done, rebased onto main (
Locally: StrandAnalytics Point 2 in your 07:38 comment, the learned-habits note, reads as if it's about #2293 ( |
The helper's doc block was headed "Deliberately PURE and UNWIRED" and stated that nothing consumes it, that switching the consumers is a re-baselining of core scores, and that the issue asks for a larger multi-participant holdout first. The commit under this one performs that switch: restingHRDaily now prefers a device-provided primary-session value, then this mean, and falls back to the old floor only when coverage is sparse. Leaving the block in place would put a doc asserting nothing consumes the helper directly above a helper that sets the headline resting HR, and with it recovery, strain, workout detection and energy. Rewritten on both platforms to say what is true: that it is wired, that the switch was a maintainer call made ahead of the holdout the issue asks for, that the MAE evidence is one participant over five nights, and that ryanbr#2284 changes the statistic the fallback rests on, so the baseline those figures were measured against will not survive unchanged.
…e night with the lowest bin (#2358) restingHRDaily was the MINIMUM across sessions of each session's lowest 5-minute bin mean, so a short low-HR nap could displace the main overnight session and set the headline figure. It now prefers a device-provided primary-session value, then PrimarySessionRestingHR's arithmetic sample mean over the longest session, and falls back to the old floor only when coverage is sparse. The #804 ring path is preserved: a provided resting HR for the primary session wins outright. Parity holds. Swift max(by:) and Kotlin maxByOrNull both keep the first maximum on ties, and Int(rounded()) against roundToInt() differ only on negative halves, which a resting HR never produces. Merged as a maintainer call, and the scope is worth stating plainly. This moves the headline resting HR for every existing user on their next score, and with it recovery, strain, workout detection and energy. The supporting evidence is one participant over five nights against a pre-declared split, rounded MAE 6.0 to 2.0 on dev and 7.5 to 0.8 on holdout, which is thinner than the change it carries and is the larger holdout #1169 itself asks for. #2284, which is sequenced ahead of this and not yet in, replaces what a session's restingHR IS with the deep-sleep mean, so the floor this falls back to will change under it and those figures will need re-deriving. The helper's doc block said "Deliberately PURE and UNWIRED ... nothing consumes it yet". It is rewritten on both platforms rather than left to contradict the code, and now records the wiring, the maintainer call, the thinness of the evidence and the pending interaction with #2284. Verified on current main: PrimarySessionRestingHRTest 14/14 and six AnalyticsEngine suites 20/20 locally, doc-comment lint clean, 16/16 in CI. Author: @moyasserr; doc correction added on merge. Refs #1169.
What this PR does
A night's resting HR was the minimum of its 5-minute bin means. That is wrong on NOOP's own terms, before any comparison with another device:
The session's resting HR is now the mean HR across its deep-sleep segments (
SleepStager.sessionDeepSleepRestingHR). That is the slow-wave window NOOP's HRV already uses (#141). Details:HKSourceonly) and writes the recomputed values.rhr=(reported) besidefloor=(lowest bin) andnightMean=. The sessionRestingHR: any bin can win the night's floor, including a one-sample one #1943 bin-gate check now compares against the floor it actually tests; otherwise it would have reported a mismatch every night.Evidence
One wearer, a WHOOP 5.0 on an iPhone 16, with raw HR read from the device DB over 23 nights. The comparison with that wearer's WHOOP history is corroboration that the new statistic lands somewhere plausible, not the argument for the change. A second wearer's numbers would help size the effect for everyone else.
Type of change
How it was tested
swift testinPackages/StrandAnalytics: 2025 tests pass, including the newSleepStagerDeepSleepRestingHRTests(deep mean beats a lower dip, dropouts excluded, short/absent deep-sleep fallback, empty session).StrandTests/IntelligenceRhrFloorMeanTests(updated line format): passes.NOOPiOSbuilds.StrandTests/IntelligenceOneShotRescoreTests: the one-time re-score waits for a pass holding the lock, then runs its own and marks itself done.parity_ratchet.py --offlineare clean (Python 3.12, as CI uses; 3.14 misreports the base authority);doc_comment_lint.pyis clean.SleepStager.kt,AnalyticsEngine.kt,IntelligenceEngine.kt, the one-time re-score inAppViewModel, and tests) was built and tested by the maintainer (6235 tests, 0 failures).NoopPrefs.KEY_HC_RESTING_HR_REWRITE_OWED. The next export then covers every computed day instead of its 60-day window (HealthConnectWriter.dailyCutoff). Records are upserted by the samenoop-rhr-<day>clientRecordId with a higher version, so no delete is needed. The flag is cleared only after the daily insert succeeds, so a failed or interrupted export retries.analyzeGate(a Mutex), so its one-time rescore waits for a running pass instead of losing the race.HealthConnectRestingHrRewriteTestis new;SleepStagerDeepSleepRestingHRTestandIntelligenceRhrFloorMeanTestpass. The full suite has 6290 tests with 2 failures, both inRecoveryDriversTest(a half-tie rounding assertion). They fail identically on unmodifiedmainon this Mac, so they are unrelated.Checklist
swift testinPackages/StrandAnalytics)android/(run locally; see the two unrelatedRecoveryDriversTestfailures above)docs/CONTRIBUTING.md; FAQ and ANALYTICS.md updatedStrand.xcodeproj/) or any secrets/keystoresRelated issues
Refs #691, #1943, #141