Skip to content

analytics: restore the primary-night resting-HR floor - #2557

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
kavemang:fix/primary-sleep-rhr-floor-2522
Sep 28, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
kavemang:fix/primary-sleep-rhr-floor-2522

Conversation

@kavemang

@kavemang kavemang commented Sep 28, 2026 •

Copy link
Copy Markdown

Summary

Refs #2522.

This changes a scored input and may move Charge, Effort, and other downstream values on re-score. It restores the previous floor definition within the primary night; it makes no claim of measured accuracy improvement. Oura's API documents a lowest five-minute HR and a separate average HR for each sleep session.

Verification

  • New Swift/Kotlin parity fixtures cover a main-night floor below its mean, a still-lower short nap, a provided primary value, and a primary night with no HR. The Android fixture passed (PrimaryNightRestingHRDailyTest).
  • Existing Android PrimarySessionRestingHRTest and AnalyticsEngineRefusedMainNightHrvTest passed.
  • python3 Tools/doc_comment_lint.py and git diff --check passed.
  • Swift and Apple app compilation require Xcode/Swift, unavailable on this Linux host; CI is needed for the Swift side.

Validation

  • R1 Full preregistration against a participant reference set: unavailable. The synthetic test expectations were written before running the tests, but they establish behavior only.
  • R2 Self-comparison audit: not applicable; no participant DB or stage reference was scored.
  • R3 Regenerating commands and input: 2026-09-28, checked-in synthetic fixtures; cd android && JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew :app:testFullDebugUnitTest --tests 'com.noop.analytics.PrimaryNightRestingHRDailyTest' --offline --no-daemon and the two existing suites above.
  • R4 Instrument matches the question: unit tests establish the selected formula and precedence, not physiological accuracy.
  • R5 Temporal held-out split: no participant reference set available; no held-out accuracy number is reported.
  • R6 Mechanism: AnalyticsEngine previously selected PrimarySessionRestingHR.meanHR before the session floor; the new fixture reaches that exact path and distinguishes 55 bpm floor from >69 bpm mean.
  • R7 Figures: the values above are synthetic bpm inputs/outputs from three checked-in cases (n=0 participants), measured by the Android unit test on 2026-09-28; no inherited accuracy statistic is used.
  • R8 Accuracy on real nights is not measurable on the available data and is not claimed here.

Command(s): the Android commands above; Swift counterpart is cd Packages/StrandAnalytics && swift test --filter PrimaryNightRestingHRDailyTests on a Swift-equipped host.
Date run / input: 2026-09-28; checked-in synthetic fixtures.
Prediction (pre-registered in the tests): daily RHR is 55 bpm for the mixed main-night/nap fixture, 62 bpm for the provided-value fixture, and absent when the main night has no HR.
Outcome (confirmed): Android fixtures passed. Swift fixtures remain for CI.

@ryanbr

ryanbr commented Sep 28, 2026

Copy link
Copy Markdown
Owner

@kavemang Reviewed. 17/17 green, including test (StrandAnalytics), which covers the Swift side you could not build.

Checked rather than assumed:

  • The mechanism claim holds. sessionRestingHR really is the gated lowest five-minute bin (sessionRestingHR: any bin can win the night's floor, including a one-sample one #1943 gating, then ungated lowest bin, then all-sample mean), and the gating is identical on both platforms.
  • Swift and Kotlin are the same edit structurally.
  • The shadow metric survives: primarySessionRestingHRWithCoverage is still called from IntelligenceEngine.swift:1794 and IntelligenceEngine.kt:1438, so the old mean is not dead code.
  • Test parity is exact. Three cases, same names, same expectations (55 / 62 / nil), and both assert sessionRestingHR(...) == 55 directly, so they pin the mechanism rather than the outcome.

One thing the summary understates. Besides mean to floor, the cross-session .min() fallback is gone:

old:  provided ?? primarySessionRestingHR(...) ?? physiologySessions.compactMap { $0.restingHR }.min()
new:  provided ?? primarySession?.restingHR

Deliberate and tested, and narrower than it looks, since sessionRestingHR is never-null-on-data: the new nil happens only when the primary session has no HR samples at all. Worth naming in the summary though, because it is a coverage change and not only a definition change.

One finding, not blocking. That nil still lowers count { it.restingHr != null }, and V5HealthSignals.kt:71 gates on it:

val rhrBaselineTrusted = baselineWindow.count { it.restingHr != null } >= MIN_BASELINE_NIGHTS  // 14

So a day that used to get a nap-derived RHR no longer counts toward that threshold. || hrvBaselineTrusted means it only bites when HRV is also sparse, and skipping a nap value is still the right call, but this reader counts emptiness rather than reading values and is not mentioned anywhere in the PR. ReadinessEngine uses compactMap and is fine.

Your base is 9 commits behind main. Rebase and I will take it.

Thanks for the R1 to R8 framing, and for marking R1 and R5 unavailable rather than reaching for a number.

@kavemang
kavemang force-pushed the fix/primary-sleep-rhr-floor-2522 branch from f6b47b0 to 2c73b27 Compare September 28, 2026 20:28
@ryanbr
ryanbr merged commit 5de0519 into ryanbr:main Sep 28, 2026
17 checks passed
ryanbr added a commit that referenced this pull request Sep 29, 2026
`main` fails its own ratchet at 5de0519, on a clean tree, with no change
applied:

  ERROR: Tools/parity_ledger_baseline.json: accepted finding group regressed
  test-only-callsite|Packages/StrandAnalytics
  ERROR: Tools/parity_ledger_baseline.json: accepted finding group regressed
  test-only-callsite|android/analytics

I caused it by merging #2557. That change took the daily resting HR from the
primary night's gated five-minute floor instead of the whole-session mean,
which replaced the only production call to `primarySessionRestingHR` on both
platforms:

  -  ?? primarySessionRestingHR(physiologySessions, hr)?.roundToInt()
  +  ?: primarySession?.restingHR

The function is now reached from four test files and nothing else, so the
lexical call graph emits one new `test-only-callsite` finding per platform. The
checked-in baseline did not record them. This records them: 43 to 44 in
`Packages/StrandAnalytics`, 64 to 65 in `android/analytics`, two lines, nothing
else moved.

Why nothing caught it: #2557 touched no `Tools/parity_*` path, so
parity-governance never ran on it. Its seventeen green checks were real and did
not include the gate that governs call-site structure. #2575 and #2576 landed
the same way today and happened to be harmless.

Why my review did not catch it: I checked that
`primarySessionRestingHRWithCoverage` still had callers and concluded the old
mean was not dead code. That is true of the `WithCoverage` variant and false of
the plain function, which is the one #2557 orphaned. I verified the neighbouring
symbol.

Recording rather than deleting, deliberately. The findings are true, and the
baseline exists to hold accepted findings. Whether `primarySessionRestingHR`
should survive as a tested-but-unused function, or be deleted with its tests
repointed at the `WithCoverage` variant, is a change to code merged an hour ago
and belongs in its own review. A governance repair that also deletes product
code gets neither part looked at properly.

This does not make the debt go away, it makes it visible. The next change in
either scope inherits 44 and 65 as the ceiling, so a second orphaned function
there fires the gate again.

Refs #2557, #2567
ryanbr added a commit that referenced this pull request Sep 29, 2026
`Tools/tests/test_parity_governance_acceptance.py` does not test the scanner. It
asserts that the CHECKED-IN authority and baseline still reproduce from the
CURRENT product source, so a product change is what invalidates it. It ran only
in parity-governance.yml, whose filter names `Tools/parity_*`.

So a change could move the declaration and call-site graph the ledger measures
without the gate that measures it ever running.

That is not hypothetical. Earlier today #2557 took the only production call to
`primarySessionRestingHR` on both platforms, turning it test-only, and the two
resulting findings went unrecorded. Seventeen green checks, none of them this
one. `main` carried a red ratchet until #2581, and it surfaced on #2577, an
outside contributor's PR that had caused none of it. parity-governance.yml's own
header notes that shape had already happened four times, three of them on an
outside contributor.

Here, and not behind a filter naming product paths, for the reason the guard test
below already gives about a different suite: the list would be whatever the
assertions happen to read today, so a filter only moves the trap. #1691 is the
precedent, where a `Tools/**` filter silenced a suite that reads
`TodayScreen.kt` and `AppModel.swift`, and main carried it red through nine
merges.

Its own step and its own floor, so the core floor of 114 stays independent, which
is the reasoning already written beside that floor.

Cost, measured on this runner rather than estimated: 51 s for 49 tests, against
roughly 25 s for the rest of the leg. I first assumed this suite cost 4 to 6
minutes and designed a separate parallel workflow around that; the 4 to 6 minutes
is `parity_ratchet.py`, which checks out the base tree and derives twice. This
suite derives once.

The guard is extended to assert the unfiltered leg actually invokes the module.
The failure mode is not someone arguing against this, it is someone dropping the
step or hanging a filter off it later, which is exactly what #1691 was.
Mutation-verified: pointing the step at a different module makes the guard fail.
The assertion reads the dotted module path, which appears only in the invocation
and not in the prose above it, so a comment cannot satisfy it.

Verified: `tests.test_parity_governance_acceptance` plus
`tests.test_parity_disposition_kinds`, 51 tests, pass. The workflow parses.

Refs #2567, #2557, #1691
ryanbr added a commit that referenced this pull request Sep 29, 2026
`Tools/tests/test_parity_governance_acceptance.py` does not test the scanner. It
asserts that the CHECKED-IN authority and baseline still reproduce from the
CURRENT product source, so a product change is what invalidates it. It ran only
in parity-governance.yml, whose filter names `Tools/parity_*`.

So a change could move the declaration and call-site graph the ledger measures
without the gate that measures it ever running.

That is not hypothetical. Earlier today #2557 took the only production call to
`primarySessionRestingHR` on both platforms, turning it test-only, and the two
resulting findings went unrecorded. Seventeen green checks, none of them this
one. `main` carried a red ratchet until #2581, and it surfaced on #2577, an
outside contributor's PR that had caused none of it. parity-governance.yml's own
header notes that shape had already happened four times, three of them on an
outside contributor.

Here, and not behind a filter naming product paths, for the reason the guard test
below already gives about a different suite: the list would be whatever the
assertions happen to read today, so a filter only moves the trap. #1691 is the
precedent, where a `Tools/**` filter silenced a suite that reads
`TodayScreen.kt` and `AppModel.swift`, and main carried it red through nine
merges.

Its own step and its own floor, so the core floor of 114 stays independent, which
is the reasoning already written beside that floor.

Cost, measured on this runner rather than estimated: 51 s for 49 tests, against
roughly 25 s for the rest of the leg. I first assumed this suite cost 4 to 6
minutes and designed a separate parallel workflow around that; the 4 to 6 minutes
is `parity_ratchet.py`, which checks out the base tree and derives twice. This
suite derives once.

The guard is extended to assert the unfiltered leg actually invokes the module.
The failure mode is not someone arguing against this, it is someone dropping the
step or hanging a filter off it later, which is exactly what #1691 was.
Mutation-verified: pointing the step at a different module makes the guard fail.
The assertion reads the dotted module path, which appears only in the invocation
and not in the prose above it, so a comment cannot satisfy it.

Verified: `tests.test_parity_governance_acceptance` plus
`tests.test_parity_disposition_kinds`, 51 tests, pass. The workflow parses.

Refs #2567, #2557, #1691
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants