Repository navigation
Android auto-detect: read HR over the active-strap union, not hardcoded my-whoop (#767) - #777
Merged
Merged
Conversation
…ed my-whoop (#767, #717) Workout auto-detection silently stopped for live-BLE users while sleep, charge and everything else kept syncing. Root cause: AutoWorkoutNudge read HR via repo.hrSamples("my-whoop", ...) — a hardcoded id — but a live strap banks its raw under its OWN fresh id ("whoop-<uuid>", #908), so the read returned nothing and detect() bailed at 'hr.size < 2'. The mid-July active-strap-id migration (#175/#512/#513) moved sleep/charge/health/iOS reads to the active-strap union but missed this one. Fix: read the hrSamplesUnion(activeDeviceId) — importedSourceIds(active) ∪ my-whoop, deduped — exactly what every other Today read and iOS Repository.autoDetectCandidate() already use. Android-only; iOS was never affected (it already reads the union). No behaviour change for single-WHOOP installs, whose active id resolves to my-whoop (one id, identical read). App-target Android; not compiled here (aapt2 x86-only on this host) - run ./gradlew compileFullDebugKotlin before merge.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…ed my-whoop (ryanbr#767, ryanbr#717) (ryanbr#777) Workout auto-detection silently stopped for live-BLE users while sleep, charge and everything else kept syncing. Root cause: AutoWorkoutNudge read HR via repo.hrSamples("my-whoop", ...) — a hardcoded id — but a live strap banks its raw under its OWN fresh id ("whoop-<uuid>", ryanbr#908), so the read returned nothing and detect() bailed at 'hr.size < 2'. The mid-July active-strap-id migration (ryanbr#175/ryanbr#512/ryanbr#513) moved sleep/charge/health/iOS reads to the active-strap union but missed this one. Fix: read the hrSamplesUnion(activeDeviceId) — importedSourceIds(active) ∪ my-whoop, deduped — exactly what every other Today read and iOS Repository.autoDetectCandidate() already use. Android-only; iOS was never affected (it already reads the union). No behaviour change for single-WHOOP installs, whose active id resolves to my-whoop (one id, identical read). App-target Android; not compiled here (aapt2 x86-only on this host) - run ./gradlew compileFullDebugKotlin before merge.
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 29, 2026
A session's stage timeline is supposed to TILE its span — SleepStageTotals says so
explicitly, and every consumer is written as though it does. One producer breaks that:
a device-PROVIDED hypnogram assembled from records that arrived INCOMPLETE.
OuraSleepSessionMapping merges only CONTIGUOUS codes, so a sleep-phase page that never
arrived leaves a hole in stagesJSON while startTs/endTs still span the whole night.
Measured over 31 consecutive ring nights against a WHOOP strap worn the same nights,
8 came in under 95% coverage and one covered 23% of its own span — 601 minutes claimed,
140 minutes of segments. That night was stored as 70 minutes of sleep at 50% efficiency
with 3 minutes of REM; the strap recorded 494 minutes at 85%. Mean stored-TST error on
the affected nights was -328 min, against -18..-54 on the healthy ones (the ordinary
two-device offset).
Nothing caught it. The merge's richness rule tests only that stages are PRESENT, and the
holed row has 21 segments, so it passed and won its day. Rest's two confidence guards
both structurally miss this shape: gravitySparse is inert for Oura outright (the ring
banks no gravity at all, so isGravitySparse returns false for every ring night), and the
H9 restorative floor needs efficiency >= 0.85 against this night's 0.50. It reported
SOLID.
The missing quantity is not a new measurement — it is a ratio of two numbers already
stored. HypnogramCoverage derives it on read, so nothing is persisted, there is no
migration, and it applies retroactively to nights already in the database.
* SleepMerge richness becomes a RANK: 2 = stages covering the span they claim,
1 = present but holed, 0 = none; the computed day wins only when it OUT-RANKS the
import. Collapsing 1 and 0 gives back the original presence rule exactly, so this is
a strict generalisation — every case ryanbr#240/ryanbr#241 decided it still decides the same way.
What changes is the pair the old rule could not express: a holed import against a
COMPLETE computed night. A holed import still beats no stages at all.
* ScoreConfidence.rest gains a third downgrade clause on coverage, beside gravitySparse
and the H9 floor. A new clause in the existing mechanism, not a new mechanism.
* AnalyticsEngine sums coverage straight off the already-decoded segments and passes it,
accumulated separately from inBedS because that one later absorbs the inter-fragment
gap (ryanbr#777/ryanbr#705) — a bridged out-of-bed gap is known-awake time, whereas a hole INSIDE
a fragment is time we never observed.
Both guards fail OPEN: coverage is nil for a payload it cannot measure, and nil is "not
measured", never "measured badly". That is what keeps this away from WHOOP/Apple/
Health-Connect imports, whose minute-dict shape carries no timestamps — they are judged
on presence exactly as before.
This never fills a hole in, and deliberately does NOT count uncovered time as awake: we
do not know what happened there, and asserting wake would be the same overreach in the
other direction. It reports only how much of the night was observed.
Confidence-only — no score changes, no invented stages, no recovered sleep. The missing
pages never arrived; that is the overnight link problem, tracked separately.
Verification (no strap needed — all pure, both platforms):
swift test WhoopStore 441 pass, StrandAnalytics 1636 pass
./gradlew testFullDebugUnitTest 4739 pass, 2 pre-existing failures
(RecoveryDriversTest float rounding, -0.5 vs -0.4999999999999929) confirmed
failing identically on a clean tree at this base before these changes
macOS app target builds (app-build.yml is disabled, so built locally)
doc_comment_lint OK, i18n_audit --ci OK
Byte-parity is pinned BY ORACLE, not by eye: HypnogramCoverageTest embeds the verbatim
stdout of the Swift twin compiled standalone over the whole input space that matters —
tiling, an interior hole, the measured night, the gate boundary from both sides and
exactly on it, overlap/overhang, every unmeasurable shape, degenerate spans. The
gate-exact row (950/1000 prints 0.94999999999999996, the same double the 0.95 literal
denotes, so it is NOT holed) is pinned precisely because reading the two sides could
not settle it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwDCKs1RPprtsc97nZFTaC
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 31, 2026
ryanbr#1716's coverage guard measures the right thing and nothing shows it. The gate downgrades Rest to `.building` on a holed night, but that verdict lands in `DayResult.restConfidence`, which — like `chargeConfidence` and `effortConfidence` — has no reader outside the analytics package on either platform. On the 2026-08-28/29 ring night (470.5 min claimed, 440.0 min of segments, 93.5% covered) the guard fired correctly and the Sleep screen was indistinguishable from a complete night. Give it a consumer: a third caveat under the stage breakdown, beside the existing #H9 and ryanbr#345 notes. It is a separate note rather than a coverage argument threaded into the "Low confidence" badge. That badge is a different gate — it calls `ScoreConfidence.rest(...)` itself and then re-tests `restorativeShare < 0.10 && efficiency >= 0.85`, so a coverage-only downgrade would be filtered straight back out — and its wording claims the deep/REM SPLIT is doubted, when the claim here is that part of the night is MISSING. Different fact, different sentence. The copy names NO cause and offers NO remedy, and that is the whole of what it can attribute. Three captures say so. On 08-29/30 and 08-30/31 the final persist logged `hypnogram trimmed 136 code(s)` and `24 code(s) - unwritten (0xFF) and/or pre-onset`, the second matching the 12.0 min hole code for code, so the epochs DID reach the phone and the ring marked them unwritten; re-persisting those nights five and eight times left every hole intact; and part of a shortfall is not a hole at all but the 0x49 sleep-onset anchor landing before the first written code (13.6 min on 08-29/30, 366.8 min on 08-17), which nothing can ever fill. Nor does the note point at the totals by direction — both hosts render it BELOW the stage-breakdown card that carries them. It says only that this share of the window has stage data and that the totals cover only that part of the night. Underneath, one shared accumulation on both platforms: `HypnogramCoverage.coveredSeconds` / `groupFraction` / `isHoledGroup`. - It is asked of the bridged main-night GROUP, never of one row. Two fragments individually 90% and 99% covered are one night at neither figure, and the group total is what `analyzeDay` gates on; a per-row reading in the UI would have been a quietly different rule. - It reproduces `analyzeDay`'s decoded-segment accumulation term for term: every fragment contributes its full span to the denominator whether or not its payload is measurable, and contributes cover only from timestamped segments — so the mixed-source case still reads as holed and an all-timestamp-free group still comes back nil (unknown, not holed). The inter-fragment gap is NOT in the denominator: it is known-awake out-of-bed time (ryanbr#777/ryanbr#705), not time we failed to observe. - `coveredSeconds` returns 0, not nil, for an unmeasurable payload, because it is the summable half; "is this measurable at all" stays in `fraction`, which turns a zero total back into nil. `fraction(stagesJSON: spanSeconds:)` now delegates to it on both sides, which removes a duplicated segment loop and is behaviour-preserving. - The Kotlin twin takes entity-free `(payload, span)` fragments so `com.noop.analytics` need not reach into `com.noop.data` for a row type; the Swift `CachedSleepSession` overload is a thin adapter over the same arithmetic. The printed percentage is floored, never rounded, so 94.8% cannot render as "95%" beside a badge raised for falling below 95%. Pinned by test, since that is a property of the gate rather than of one screen, and both hosts print it. Verification. Swift WhoopStore 455, StrandAnalytics 1647, StrandTests 1403, macOS app target builds; Android 4778 with the two pre-existing `RecoveryDriversTest` float-rounding failures, confirmed failing identically on a clean tree at this base (the six further reds under a fr_FR default locale are the known locale-dependent set and are green under `-Duser.language=en`, on this tree and without it). Kotlin `HypnogramCoverageTest` 10/10, including three new cases pinned against stdout from the shipped Swift implementation linked as a package dependency — 16 group rows covering the mixed-source, all-unmeasurable, gap, overlap and degenerate-span cases, and the knife-edge row where 1900/2000 prints as 0.94999999999999996, the same double the threshold literal denotes, and is therefore NOT holed. Reading the two accumulations side by side could not have settled that row. doc-lint and the i18n gate pass; the new strings ship in en/de/es/fr/pt-PT plus pl/zh so the non-focus ratchet is not worsened. Confirmed on hardware data, both ways: the 2026-08-28/29 and 2026-08-29/30 databases loaded into the macOS build render the amber "Partly recorded" badge and a floored percentage above the raw-stages note, and the clean 2026-08-30/31 night (96.8%) shows no badge at all. No stored number changes. This wires the guard, not the field: `DayResult.restConfidence` and its two siblings still have no reader, and since none of the three is persisted, any future consumer faces the same choice between a migration and recomputing at the render site as this does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153E6spximZQJUii9gn6Kwc
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 31, 2026
ryanbr#1716's coverage guard measures the right thing and nothing shows it. The gate downgrades Rest to `.building` on a holed night, but that verdict lands in `DayResult.restConfidence`, which — like `chargeConfidence` and `effortConfidence` — has no reader outside the analytics package on either platform. On the 2026-08-28/29 ring night (470.5 min claimed, 440.0 min of segments, 93.5% covered) the guard fired correctly and the Sleep screen was indistinguishable from a complete night. Give it a consumer: a third caveat under the stage breakdown, beside the existing #H9 and ryanbr#345 notes. It is a separate note rather than a coverage argument threaded into the "Low confidence" badge. That badge is a different gate — it calls `ScoreConfidence.rest(...)` itself and then re-tests `restorativeShare < 0.10 && efficiency >= 0.85`, so a coverage-only downgrade would be filtered straight back out — and its wording claims the deep/REM SPLIT is doubted, when the claim here is that part of the night is MISSING. Different fact, different sentence. Underneath, one shared accumulation on both platforms: `HypnogramCoverage.coveredSeconds` / `groupFraction` / `isHoledGroup`. - It is asked of the bridged main-night GROUP, never of one row. Two fragments individually 90% and 99% covered are one night at neither figure, and the group total is what `analyzeDay` gates on; a per-row reading in the UI would have been a quietly different rule. - It reproduces `analyzeDay`'s decoded-segment accumulation term for term: every fragment contributes its full span to the denominator whether or not its payload is measurable, and contributes cover only from timestamped segments — so the mixed-source case still reads as holed and an all-timestamp-free group still comes back nil (unknown, not holed). The inter-fragment gap is NOT in the denominator: it is known-awake out-of-bed time (ryanbr#777/ryanbr#705), not time we failed to observe. - `coveredSeconds` returns 0, not nil, for an unmeasurable payload, because it is the summable half; "is this measurable at all" stays in `fraction`, which turns a zero total back into nil. `fraction(stagesJSON: spanSeconds:)` now delegates to it on both sides, which removes a duplicated segment loop and is behaviour-preserving. - The Kotlin twin takes entity-free `(payload, span)` fragments so `com.noop.analytics` need not reach into `com.noop.data` for a row type; the Swift `CachedSleepSession` overload is a thin adapter over the same arithmetic. The printed percentage is floored, never rounded, so 94.8% cannot render as "95%" beside a badge raised for falling below 95%. Pinned by test, since that is a property of the gate rather than of one screen, and both hosts print it. Verification. Swift WhoopStore 455, StrandAnalytics 1647, StrandTests 1403, macOS app target builds; Android 4798 with the two pre-existing `RecoveryDriversTest` float-rounding failures, confirmed failing identically on a clean tree at this base. Kotlin `HypnogramCoverageTest` 10/10, including three new cases pinned against stdout from the shipped Swift implementation linked as a package dependency — 16 group rows covering the mixed-source, all-unmeasurable, gap, overlap and degenerate-span cases, and the knife-edge row where 1900/2000 prints as 0.94999999999999996, the same double the threshold literal denotes, and is therefore NOT holed. Reading the two accumulations side by side could not have settled that row. doc-lint and the i18n gate pass; the new strings ship in en/de/es/fr/pt-PT plus pl/zh so the non-focus ratchet is not worsened. Confirmed on hardware data: the 2026-08-28/29 database loaded into the macOS build renders "Partly recorded — Only 93% of this night's window has stage data" above the raw-stages note. No stored number changes. This wires the guard, not the field: `DayResult.restConfidence` and its two siblings still have no reader, and since none of the three is persisted, any future consumer faces the same choice between a migration and recomputing at the render site as this does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwDCKs1RPprtsc97nZFTaC
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #767. Very likely also fixes #717 (same symptom, WHOOP 4.0 Android).
Symptom
Workout auto-detection silently stopped for a user — no Today suggestion card, no post-workout popup — while sleep, charge/recovery and everything else kept syncing and updating normally. On the latest version.
Root cause
AutoWorkoutNudge.maybeDetectread the detector's heart rate from a hardcoded device id:But a live-BLE strap banks its raw HR under its own fresh id (
"whoop-<uuid>", see #908), not"my-whoop". So the read returned an empty series anddetect()bailed immediately atif (hr.size < 2) return null— no windows, no card. Meanwhile sleep/charge/steps all read the active-strap union (importedSourceIds(activeDeviceId)), so they kept working. That asymmetry is exactly the reported symptom: everything syncs, only auto-detect is dead.This is a leftover from the mid-July "thread the active strap id" migration (#175, #178/#191, #359, #512/#513) that moved sleep/charge/health and the iOS workout reads off hardcoded ids — but missed this one Android read.
Fix
Read the same union everything else uses:
hrSamplesUnionreadsimportedSourceIds(active) ∪ "my-whoop", deduped — the Kotlin twin of iOSRepository.hrSamples(from:to:), which iOSRepository.autoDetectCandidate()already uses.Parity
Android-only. iOS was never affected —
autoDetectCandidate()already reads the union. This brings Android to parity, per the "read the active strap id, not a hardcoded default" rule.For single-WHOOP installs there's no behaviour change:
activeDeviceIdresolves to"my-whoop", so the union is one id — a byte-identical read.Verification
AutoWorkoutDetectorlogic is unchanged (its tests still pass).aapt2is x86-only here). Please run./gradlew compileFullDebugKotlinbefore merge.