Repository navigation
Add hr-only sleep staging and expand analytics coverage - #1
Merged
Merged
Conversation
…yanbr#2142) The suite is path filtered to Tools/ on both triggers, but one of its tests is not about the scanner at all: the repository acceptance asserts the checked-in authority still reproduces from the CURRENT product source. Product changes are what invalidates it, and product changes are exactly what both filters exclude. So main could carry stale authority indefinitely with nothing red, until some later PR touched Tools/ and inherited the whole accumulated drift. Four staleness events, three of them landing on an outside contributor's PR that caused none of it. The last was ryanbr#2141: the same 4308 against 4310 and the same hashes failing on main, on that branch, and at 0b0d206 before any of that day's merges. A daily run on main finds it within a day, where re-deriving is a two-minute maintainer action. Deliberately a schedule rather than an unfiltered push. The first attempt removed the push filter and the suite's own test_core_tools_filter_covers_every_governance_tool_path rejected it: that test pins BOTH filters and forbids product globs, so not running on product source is a deliberate invariant rather than an oversight. Both path lists are therefore untouched and that test passes unchanged. This does NOT make drift repairable. --refresh-derived still fails "migration required" because the checked-in authority cannot be reproduced from the base, so the only route remains deleting both snapshots and re-bootstrapping, which no contributor can do. All this does is make the maintainer the one who finds out.
…nbr#2138) The accessibility line carries TWO counts and Android <plurals> inflects one quantity per resource, so each count gets its own <plurals> and a template joins them. The joiner lives inside the template, never on a resource edge, which is the aapt2 edge-strip that ryanbr#2041 had to clean up across 59 sites. That also corrects "1 chunks", which predates this and was wrong in every locale. Apple needed less: the chunk string already had plural variations in 9 of 10 catalogue locales, and only pl was still flat. The template wording is derived from each locale's existing chunk translation plus its joiner, so nothing already reviewed is re-translated. The figure is sampled once from the connect-time GET_DATA_RANGE reply and never re-polled (ryanbr#377), so the copy now says "at connect" rather than leaving a static number under a "syncing" label to read as a stalled live one. A zero backlog is dropped rather than rendered, decided in `resolve` on both platforms so neither view layer decides it alone, pinned by a test each side. Where it surfaces differs deliberately: Android's chip is a capsule with room for a second text run, Apple's is a spinner with the count inside it, so on Apple the figure lands in the accessibility read-out that already spoke the chunk count. Feature-level parity, not pixel parity. Reviewed on a checkout. The contributor flagged the four new Swift tests as unrun because their invocation swallowed the result: they DID run. app-build.yml has a Test Strand step gated to the macOS leg, and it reports success on this PR. Verified here, since CI does not run either: Tools/i18n_audit.py --ci main is clean with both ratchets moving down, and lintVitalFullRelease -PstagingRelease is SUCCESSFUL, which is the gate that matters for a plural added across seven locales because fatal ExtraTranslation is caught by neither a compile nor the audit. SyncChipStateTest 11 of 11. Merging onto main leaves the parity authority reproducing. The pl and ru tables follow CLDR including the part most often missed: `other` is the FRACTIONAL form and carries the genitive singular, 1,5 strony and 1,5 страницы, rather than repeating `few`. Supersedes ryanbr#833, by the same author. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ts them (ryanbr#2134) Follow-up to ryanbr#2133, taking up the wording note from its review. Comment only, no behaviour change. The comment as merged attributed archived rows to the day-cycle consumers, so `resolveDayOwner` read as an incidental beneficiary. It is not: `RegistryDayOwnerSourceTest.archivedDeviceRemainsALowestPriorityHistoricalCandidate` pins an archived device winning a day nothing live covers and losing the moment something live does. That is documented intent, and the whole point of ryanbr#2133 was to stop the contract saying less than what happens. The rationale it adds was not written down anywhere: keeping archived rows is what stops removing and later re-adding a strap erasing its earlier coverage. Second, "Swift drops archived outright" left out what that leaves behind. `ranked` inside resolveDayOwner has only FOUR branches, with no archived case at all, because liveDevices already removed them, while the cycle list carries all five and Android carries five for both. The two tables differ in SHAPE, not only in membership, and the old wording suggested the same table minus a row. The open question, whether the day-owner path should exclude archived the way Swift does, stays in ryanbr#2026 and is untouched. Verified on a checkout: both claims hold against source, and CI is green. The contributor also flagged that Tools/parity_ledger.py could not run to completion in their environment rather than claiming a pass. That turned out to be a real portability bug, now ryanbr#2143: the temp checkout root is left unresolved, so --base and --refresh-derived crash on macOS, where /var resolves through a symlink. It cannot fire on Linux, which is why it survived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r-reporting retirement (ryanbr#2160) Two bugs, one in copy and one in behaviour, found from a field log and then from re-reading the function the copy depends on. THE COPY. Reaching the retired branch of the backfill hint PROVES the switch is off, because the opted-in case above it short-circuits to an empty hint. So "turn it off and on", which reads as advice to someone whose switch is on, is the one thing every reader of that sentence cannot do. The same link carried both of these twenty minutes apart: [connection] unbonded probe skipped, the unbonded-offload experiment is off Backfill: deferred ... has retired for this strap, turn it off and on Turning it ON is the whole action, and it is the off-to-ON edge `unbondedProbeBudgetRearms` looks for. Since ryanbr#2135 that edge clears the refusal latch too, so the sentence can promise something it could not before. Only this site. `unbondedProbeGaveUpLine` says "off and on" as well and is CORRECT: it prints at the moment of retirement, while the probe runs and the switch is on. The two differ because the reader's state differs. THE BEHAVIOUR. `unbondedProbeRetired` retires on any of three grounds: a latched refusal, a spent silence budget, or a spent INCONCLUSIVE budget. The gate passed all three. Both consumers in WhoopBleClient passed two and took the default of 0 for the third, so a strap retired only by the inconclusive budget reported NOT retired. That is the strap ryanbr#1804 was written for, where every link is a local teardown. At the supersedes call that is not cosmetic: probeRetired reads false, the skip keeps returning true, and the CLIENT_HELLO stays suppressed forever for a probe that will never run. Its own parameter doc says why that must not happen, that skipping the hello then buys nothing and costs the strap its bond and its offload. The count was a property on the class the whole time, two lines from each call. The default is gone. A required parameter fails both sites at compile time, which is the same lesson as the nullable prefs default removed in ryanbr#2136: an optional argument whose omission changes behaviour is a trap, not a convenience. The equivalence test that exists to stop exactly this stranding held the inconclusive axis at 0 while the ground was live, pinning two thirds of a three-part rule. It sweeps all three now. A pure-function sweep cannot see a call site that forgets an argument, so it would not have caught the omission; the required parameter does that, and the sweep stops the two rules drifting once both are given the axis. 52 and 32 tests green. The wording test is repinned and now also asserts the old phrase is ABSENT, since the failure mode is a sentence that still parses and still names the right toggle while misinstructing. Android only, no Swift twin of this diagnostic, no declarations added.
…yanbr#2159) `expand_twin_map` took its inventory from the root as given, then passed it to `build_twin_map` and `semantic_authority`, which each canonicalise their own root. Same shape as ryanbr#2143, reached through a different function, so a caller handing it a root that is not already canonical raised in `relative_to`. In practice the CLI canonicalises `--root` before calling it, so nothing broke from the command line, only for callers passing a raw root as the tests do. The PR says so rather than overstating the reach. The new test reaches the root through a SYMLINK rather than through macOS, so the Linux runner guards it too. Verified here: it passes with the one-line change and fails with only that line reverted, same `relative_to` trace. Two test expectations move with it, and both are the kind of change that usually hides a bug, so: the ledger reads through canonical paths on purpose and `main()` canonicalises `args.root` on purpose, and neither assertion's subject changes. One still guards the read COUNT, the other still guards WHICH ref is scanned. The load-bearing decision is what was NOT done. Canonicalising the root in `setUp` would have turned three red tests green in one line and left the production defect in place. Declining that is how the defect was found. The PR also corrects the count it inherited: the full suite on unmodified main fails five on macOS, not three, and two of those were already handled elsewhere. Recounting rather than repeating a figure is easy to skip. Verified on Linux in a pristine clone: 106 run and OK on this branch, and 107 run and OK merged with the sibling change, which auto-merges clean. Reported and fixed by @digitalerdude. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…om it (ryanbr#2158) `_base_semantic_state` took its temp checkout root as given, built the inventory from that spelling, then handed it to `build_twin_map`, which canonicalises its own root first. The two spellings then disagreed and `_relative` raised. On macOS the temp directory is reached through a symlink, so this fired every time; on Linux it cannot fire at all, which is why it survived. The reported diagnosis had the direction backwards, and the contributor corrected it: the ROOT is the canonicalised side and the PATH is not. The named one-line change still lands, because doing it before the inventory makes both spellings agree. The second site gets the same line for consistency rather than as a fix. `finding_identities_at_git_ref` hands its root to `build_compact_twin_map` and `scan`, neither of which takes a pre-built inventory, so each derives its own paths and nothing can disagree. Said plainly in the PR rather than banked as a second fix. Both sites are conditional, which is the rest of why nobody hit this: `_base_semantic_state` runs only on authority drift, and `finding_identities_at_git_ref` only with `--base` plus a decrease, so an ordinary run on a clean tree reaches neither. The regression test reproduces the crash through a symlink rather than through macOS, so the Linux runner guards it too. Verified here: it passes with the change and fails with only the `_base_semantic_state` half reverted. Full suite 106 and OK on Linux in a pristine clone. This matters beyond one contributor's machine. The guarded refresh already cannot repair drift on its own, failing with "migration required", and a crash on macOS narrowed the set of people who can run the gate at all to one maintainer on one operating system, on tooling whose staleness has landed on somebody else's PR four times. Reported and fixed by @digitalerdude. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…br#2154) A Test Centre export is capped at 20 MB and the Oura raw-wire sidecar, the only file a protocol fact can be re-derived from, kept losing the night to it three separate ways. Measured across the August capture corpus rather than reasoned about: 11 of 31 nights' sidecars did not contain the morning drain, and the offline reframer refused them. 1. Proportional capping handed 53.9% of the budget to a bulky research dump and left the raw wire 1.9%, 8 minutes of an 8.4 hour night. 2. A size-based roll could split one capture session across two files, and the export only ever shipped the live one, so a 30-second session went out while the night sat in a generation nothing looked at. 3. Largest-wins between generations is not newest-wins, so a morning export could ship a stale night. Worse than an omission: the tool reports the WRONG night and says nothing. The fixes are separable. capEntries shares the cap max-min fairly by water-filling, so a modest stream ships whole and the bytes come off the merely bulky one. OuraRawDump is scoped to one capture session and rolls only at session boundaries. Generations are kept as a ring and normalizedOuraEntryName recognises the suffix. OuraSidecarGenerations merges the generations behind one entry name oldest to newest under the ceiling, so the cap's keep-the-tail trim preserves the newest capture. Apple-only, and the rationale was verified rather than taken: TestBundleAssembler.kt names Oura zero times, and OuraMotionDump and OuraRealStepsDump are constructed by OuraLiveSource but referenced by no exporter, so they write files nothing ships. The Android bundler has nothing to merge yet. That wider gap belongs to ryanbr#2151. capEntries is the widest part of this, since water-filling changes every export rather than only the Oura path. The three new functions in OuraSidecarGenerations are one-sided, and the authority is re-derived in the commit after this one. The documented route for that, a typed disposition, does not work for this rule; that is tracked in ryanbr#2163 and is not a fact about this change. 15 of 15 CI checks green, including every package test suite and both app builds. StrandTests 1767 with 0 failures, reported by the contributor. Reported, measured and fixed by @pipiche38. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ryanbr#2154 adds OuraSidecarGenerations.swift under Packages/WhoopStore, which is inside the scan, so the authority moves: 257 Swift files to 258 and 1870 Swift functions to 1873. Findings are unchanged at 295, so nothing new is accepted as debt. The three declarations simply enter the recorded inventory. The change is Apple-only for a verified reason rather than an unexamined one: TestBundleAssembler.kt names Oura zero times, and OuraMotionDump and OuraRealStepsDump are built by OuraLiveSource but referenced by no exporter, so Android writes those files and ships none of them. Its bundler has nothing to merge yet. The wider gap is ryanbr#2151. This commit rather than a disposition because the documented route does not work for `add-unpaired-function`: build_compact_baseline raises for any rule outside BASELINE_REASONS and consults no registry on the way. I wrote the three platform_specific entries in the documented shape, with exact identities and matching identity_sha256, and the failure did not move. Tracked in ryanbr#2163. Regenerated from a PRISTINE clone. parity_ledger_baseline.json came back byte-identical and is untouched here. Fifth staleness event, and the first that was NOT inherited by an unrelated PR: the daily run added yesterday would have caught this tomorrow morning, and doing it now means it never reaches anyone else.
… works fine (ryanbr#2149) The documented macOS limitation was wider than the behaviour: a factory-reset ring pairs normally, so the note overstated what does not work. Author's description, not independently re-verified beyond CI. Reported and fixed by @pipiche38. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A heading whose section number stopped matching its content after 450256a moved things around. Author's description, not independently re-verified beyond CI. Reported and corrected by @pipiche38. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eriodic tick (ryanbr#2147) A night arriving by offload waited for the next periodic tick before it was scored, so a wearer who synced and opened the app saw the previous night until the timer came round. Author's description, not independently re-verified beyond CI. Reported and fixed by @pipiche38. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ryanbr#2146) The onset rekey fired even when the assembler's clip had not bound, so a night could be rekeyed against an anchor that was never established. Author's description, not independently re-verified beyond CI. Reported and fixed by @pipiche38. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ple (ryanbr#2152) spo2Sample carried dc_raw alongside the SpO2 raw channel, persisting a column nothing reads. Author's description, not independently re-verified beyond CI. Reported and fixed by @pipiche38. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The periodic fetch is the real data cadence for the ring, and a 15 minute interval left a wearer further behind than the hardware required. Author's description, not independently re-verified beyond CI. Reported and fixed by @pipiche38. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ryanbr#2097 anchor check only considered a 30 second gap, so a 10 second one went unjudged. Author's description, not independently re-verified beyond CI. Reported and fixed by @pipiche38. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
baselineTrusted counted nights carrying ANY vital, so fourteen nights of resting HR made the gate read "trusted" for HRV purposes even when HRV had almost no usable history. iOS asks whether a signal's own baseline is trustworthy; Android asked whether a row existed. Trust is now derived per signal, from the trailing window the latest z-score uses, and the cycle classifier keeps its own availability gate. That divergence is ryanbr#2131, filed against a device whose census read hrv=2 of 21 nights while rhr=18, so the one flag meant to prevent exactly this read "trusted". Author's description and CI; not independently re-verified here. Worth a device look, since it changes when the illness engine may raise. Reported and fixed by @kavemang.
Eight PRs went in together and the authority moved with them: 4313 functions to 4315, one Swift and one Kotlin, which paired (declared twin references 830 to 831, all resolved). Findings are unchanged at 295, so no new debt is accepted. Caused by merging that batch without re-deriving behind it, which is the risk I flagged before doing it. Sixth staleness event, and the second in a row that reached nobody else: the ledger's own findings check still read OK, because it does not compare the pinned authority, and only the acceptance test does. The daily run added in ryanbr#2142 would have caught this tomorrow morning. Also verified on this tree while here: the full Android unit suite, 708 suites and 6049 tests with 0 failures and 6 skips, after syncRoomSchemaSnapshot in its own invocation. Those eight merges had all been tested against d6ad093, before the sidecar rewrite and six adjacent Oura changes, so a green check on each did not say the batch was green together. It is. Regenerated from a PRISTINE clone. parity_ledger_baseline.json came back byte-identical and is untouched.
…yanbr#2165) The widget printed a level with String.format(Locale.getDefault(), "%.1f") and the Today card built tenths by hand, so the same number appeared two ways on a screen and its own widget: 2,8 against 2.8 for a German reader, and an unclamped 3.1 against a clamped 3.0 above the ceiling. StressTrace.formatLevel is now the one place and every surface calls it. The dot is not a preference: Swift's String(format:) without a locale does not localise, so the iOS figures and the card were already dot-decimal and the Android widget was the outlier. The clamp comes along so both sides are braced the same way. Rounding stays arithmetic rather than printf, the side SleepStagerTrace.round1 already settled on after a harness caught Java's half-up and C's half-to-even disagreeing on a real number. iOS stays on String(format:), which a logistic squash cannot be shown to differ from in practice; the doc block says so rather than claiming a fuller agreement than there is. Raised from a report of the widget and card showing different numbers, which this is very unlikely to be: two spellings of one number do not read as two numbers. The likelier cause is freshness, and ryanbr#2164 says so rather than letting a tidy-up masquerade as a diagnosis. Android only. StressTraceTest 29 tests, 0 failures; doc lint clean; parity ledger unmoved, ui/** and widget/** being outside the scanned globs.
…anbr#2168) The widget's series goes through prefs as text, and encode wrote the level at two decimals. The widget then rounded that to one for display, while the Today card rounded the live double once. Two roundings move a value across a boundary one rounding does not: a raw 2.2450 became 2.25 in the snapshot and printed 2.3, where the card printed 2.2. Same device, same data, no staleness in it. Across the domain the two disagreed by a tenth on 5.02% of levels, one render in twenty, and the skew reached the peak and the average too. Both surfaces call StressWidgetProducer.todayCurve with the same arguments, so the round trip was the only thing between them. Unifying the formatter in ryanbr#2165 could not have touched it: what differed was the number handed to formatLevel, not the spelling. encode now writes Double.toString, the shortest decimal that reads back as the same double, so the widget formats exactly what the card formats. A wider fixed precision would only have made the disagreement rarer, which is the objection ryanbr#2167 raises about the iOS side. The cost is a few hundred characters a day and untidy digits in a bug report. Older snapshots still decode, two decimals and all, so there is no migration and no version bump. Two things the review passes added. Double.toString emits scientific notation under 1e-3, which the old fixed-point never could, so the round trip of a deeply suppressed level is pinned. And decode admitted a NaN, every comparison against NaN being false, so the domain guard is now the negated in-range test. Neither is reachable from scoring, both z-terms being gated on sd > 0.0001; the NaN case is the malformed payload decode already promises to absorb. Android only, display path only. The analytic series is untouched. StressTraceTest 34 tests, 0 failures. The tests that matter were each confirmed to fail against the code they guard.
…ryanbr#2172) Reported by @bartmuskala. The Resting HR and HRV tiles clipped both halves of their row, the reading cut to "53 ..." beside a chip cut to "+7 vs ...". The two tiles on the same rows without a chip were never affected, which places it in the row that holds a value beside one. ryanbr#492 item 5 had been here before. The chip used to take its natural width, about 90dp of a row with about 140dp to give, and left the reading as "4...", so the row was set to split evenly. That starves both sides rather than one: a weighted child with fill = true is held to exactly its share, so the value sits at half however little the chip needs, and half fits neither string. The chip now carries the signed magnitude and nothing else. The section header above these tiles already reads "vs 30-day baseline", so those words were bought at the reading's expense to repeat what the reader had just been given. At 12sp with at most three digits the chip measures under 45dp, which leaves the value the rest of the row at full size, so the even split goes and the natural-width path comes back. No caller passes compactDelta now, and its documentation says to reach for a shorter chip first. Two English literals leave the Android UI with it, the Apple twin having passed both through String(localized:) all along. Tools/i18n_audit.py never saw them, its count being 234 either side, which is the known blind spot for literals assigned outside a composable call. The review pass found the tile claiming a fact it did not have: the delta is null when today has no reading or there is no baseline yet, and both fell through to the at-baseline chip, so an empty tile carried that chip beside the empty-value dash and a first-week tile put a reading exactly on a baseline that did not exist. Null draws no chip now, on both platforms, the Swift twin having had the identical flaw. Android for the layout, both platforms for the null claim. AutoSizeValue truncating where it should shrink is tracked in ryanbr#2171 and is not needed for this to be right; the reading fits at full size with no shrink at all.
…xists (ryanbr#2175) The rolling testing release is deleted and recreated each build, and the delete ended in `|| true`. That cannot tell "the tag is not there yet", the normal first-run case, from "the delete failed", which makes the create on the next line certain to fail. Build 499 hit a 500 from GitHub on the delete. The create then stopped on "Release.tag_name already exists", which describes the symptom and points away from the cause, and since meta gates the four build legs a blip in a metadata step threw away the Android, iOS and macOS builds behind it. A re-dispatch of the same commit went through untouched. The delete now retries three times with a short backoff, reads "release not found" as the success it is, and stops the job with a message about itself if it never gets there. Two asserts follow it, because a half-done delete has two shapes. A release left standing would be read by the create as a name clash, which is the same confusion one step later. A tag left behind is the quieter one: create ignores --target when the tag exists, so it would publish this build's assets against a tag still pointing at the previous build's commit, with the title carrying today's SHA and nothing saying otherwise. The ref gets one explicit delete, since that is what --cleanup-tag was trying to do, before the job gives up. Exercised against a stubbed gh under bash -e, seven paths: a clean delete, a missing release, 500 twice then success which is build 499's case, 500 every time, a release that survives, a tag that survives and clears, and a tag that will not clear. The retry path really does call three times. Known limit, written into the step: a 5xx on the release assert's view reads the same as a clean absence, so a blip there lands back on the create's own error, which is where we were rather than somewhere worse. The larger option from ryanbr#2174, updating the release in place so the window never opens, stays open there. It needs a forced tag ref update, create not moving an existing tag, and explicit removal of assets outside the new set to keep stale ones from lingering.
…anbr#2176) Reported by @Fr1tz77. FitnessAgeEngine clamps to [20, 80] on both platforms, so every model output below 20 banks as exactly 20.0 and every output above 80 as exactly 80.0. The card showed that like any other reading, so a floored result looked exact and, since it stops moving while everything around it updates, read as a sync or scoring fault rather than the end of the scale. A value on a bound now reads "≤20" or "≥80" on the Health hero and on both Today tiles. The age-difference line was the worse half and the report did not raise it. It is built from the clamped value, so a 45-year-old whose model output is 15 was told "25 years younger than your age" when the model said 30: a trimmed number presented as a measurement, with nothing to show it had been trimmed. A bounded reading can only be stated as a direction, so it now says "At least 25 years younger", and where no distance is safe to give it states the bound alone. A floored reading against a chronological age of 18 says "20 or younger" rather than "2 years older", because below the floor the true value could be either side of them. The ceiling behaves the same way at 85. The upper bound was not raised either and had the identical defect, so it is done here rather than left to be reported next. Decided from the value rather than carried out of the engine, because the card reads a weekly row from the fitness_age series and not a FitnessAgeResult: a flag added to the engine would reach neither the screen nor a single row already banked. The clamp returns the bound constant itself, so equality is exact and needs no tolerance. The cost is that a reading which is genuinely 20.0 is also called "20 or younger", which is true of it. Saying "<20" needs the unclamped value, and that is discarded one line after it exists. The engines, the model and its supported range are untouched, which is what the report asked for. Whether 20 is the right floor stays open. Both platforms' branch logic was walked through fifteen readings, each bound, either side of it, both singulars, and a chronological age inside each bound, and they agree on every one. That check caught the iOS bounded line saying "At least 1 years younger", which had hardcoded the plural the unbounded branch four lines below it splits on. The trend chart and CompareScreen still plot a bounded point as a plain point. Marking a series is a presentation question of its own and is not what made the headline misleading.
…br#2177) Reported by @bartmuskala: the Today card's stress curve ended at 1pm, the Stress screen's ended at 2pm, and it was twenty past four. Neither view recomputed as heart rate arrived. Each scored once, when its own trigger last fired, and then held. The card sat in LaunchedEffect(needsStressCurve, days, activeStrapId), and none of those keys moves when a sample lands: `days` is the daily rows, not the intraday samples the curve is built from. The screen read once, on open. So walking from one to the other always gave the screen the later read, and the gap was however long sat between the two triggers. Same producer and same scoring on both sides; the whole difference was when each last asked. Both now ask again on the cadence the widget already uses. That constant moves to StressWidgetProducer, which owns the rescoring contract, and the connection service reads it from there rather than keeping its own copy, so the three surfaces cannot drift apart by definition. Fifteen minutes is matched to the data rather than the stream: the curve resolves to half-hours. Both loops are gated on repeatOnLifecycle(STARTED), the shape HealthScreen already uses, because a LaunchedEffect follows the composition and not the lifecycle: ungated they would score a day of heart-rate rows every fifteen minutes with the screen off, which the service is already doing and nothing is there to read. The screen's reload is additionally guarded on the heart-rate fingerprint the producer memoises against, since three windowed fetches and two HRV engines repeated over an unchanged day produce what is already drawn. A fingerprint that cannot be read loads rather than skips. A null from the producer now leaves the previous curve alone, which is what a null is documented to mean, rather than blanking a card that is sitting open. The timeline also says where it stops, once the newest scored hour is far enough behind the clock to look broken rather than merely recent. The threshold is generous on purpose: an hour is scored on sample count alone, with no completeness rule, so the hour in progress scores as soon as it banks enough and a healthy curve can end anywhere from minutes to an hour or two back depending on how the day's data arrived. Android only, and iOS was checked rather than assumed: both its surfaces key on repo.refreshSeq, so they refresh together and cannot drift this way. The new string is in all eight values files, the three non-focus locales ratcheting rather than tolerating a gap.
Requested by @paulinyrobert-del, who runs with background connection off and wanted a sync without leaving Today. There was no way to start a scan from Today at all: the quick-actions menu is four navigations, the header's other controls go to Settings, Devices and that sheet, and there is no pull to refresh on Today or in its scaffold. Two affordances, one action, and neither takes width from anything. A disc on the leading edge of the wordmark row, which was a spacer. Not decoration: ryanbr#2110 put it there to mirror the trailing Customize control exactly, keeping the wordmark optically centred after the two had previously overlapped. The new disc is the same HeaderClusterControl size as the one it mirrors, so that invariant holds and the wordmark does not move. Deliberately not in the right-hand cluster, where ryanbr#2110 measured that a fifth control leaves the 28sp day title about 117dp against the 139dp "Yesterday" needs. And the sync chip takes a tap, being the one header element that already reports sync state and had none. It carries an onClickLabel rather than a contentDescription, so the action is named alongside the state the icon describes instead of replacing it. Both route through rememberRequestScan, the wrapper Settings' Re-scan and Live's Connect already use, which requests the runtime Bluetooth permission first. Calling connect() directly silently does nothing on Android 12+ when that permission was denied or revoked, which was #1, so a third entry point had to go through the gate rather than around it. The disc greys and refuses taps while a scan runs, matching the Settings and Live buttons. The chip keeps its tap during a sync, a backfill in flight not being a scan in flight. Android only. The disc stays visible when a strap is already connected, which matches Settings' Re-scan rather than inventing a rule for the same action. The new label is in all eight values files. SyncChipStateTest and BackupSyncTest, 26 tests, 0 failures; i18n gate, doc lint and release lint clean. Two things found while reviewing and filed rather than folded in. Today lost its recording-status chip in the 7.0.0 redesign and the composable is still present with no call site (ryanbr#2179), which is the status dot the report remembers. And a permanently denied permission still ends in a silent tap on every entry point including these, which wants deciding alongside ryanbr#2179, since Today having no connection status is the same gap from the other side.
A 4.0 can advertise as a standard BLE HR sensor via TOGGLE_GENERIC_HR_PROFILE (opcode 14), so a Garmin, Zwift or gym HR client can pair to the strap itself. The 5/MG keeps its whoop_live_hr_in_adv_ind_pkt device-config path. The send paths say the opcode was SENT and that the effect is not confirmed, and a WHOOP 4 COMMAND_RESPONSE for 14 is logged with its result byte and raw frame as evidence the strap answered, explicitly not as a read-back of the advertising state. There is no readable confirmation of the physical effect, and the opcode was verified on one 4.0 on one firmware. Broadcast mode is link state on a 4.0, so an opted-in mode is re-sent after reconnect. That restore sits in the WHOOP4-only handshake on both platforms and is disjoint from the 5/MG re-apply, so neither strap can end up with two device-config writes or two opcode-121 read-backs in flight. Opt-in and reversible, default off. Opcode and name come from the canonical CommandNumber schema and the WHOOP 4 command matrix. Authored by @don86nl.
ryanbr#2400 flipped "Strap broadcasts its own HR" to a green Yes for the 4.0. That is the one place the feature asserts the effect works. Every log line on the same path deliberately refuses to: the strap is asked over TOGGLE_GENERIC_HR_PROFILE and answers, but nothing reads back whether it then advertises 0x180D, and the opcode is confirmed on one strap on one firmware. The comparison screen is where a 4.0 owner decides whether to rely on this, so it must not claim more than the code does. Partly, in the warning tone, with the description saying what is and is not known, which is the convention every other Partly row on this screen already follows. The 5/MG column keeps Yes: its device-config write is read back on opcode 121.
…ded (ryanbr#2412) Every Oura connect ran the driver's live-HR enable triplet regardless of the app's screen-off suspend, and the re-engage timer's suspended guard undid it a second later: the ring logged DHR_mode:3 then DHR_mode:0 on every overnight reconnect. The Oura app never runs live mode during a sync. OuraDriver.liveHRWanted (default true, both platforms) lets auth success go straight to streaming, authenticated and idle, which is all the history drain, SyncTime, battery and status reads need. Nothing is written to the daytime-HR feature at all. iOS clears the flag at the auth-OK step when suspended, says which path it took, and gates the disable on a new liveHRArmedThisSession so a suspended connect does not follow a skipped enable with a gratuitous mode 0x00 write. A live push while suspended marks the session armed first, so the existing self-heal still sends the disable. Android takes the driver flag and its tests only: OuraLiveSource there has no screen-off suspend yet, so nothing clears the flag and behaviour is unchanged. Evidence, Gen 3 over three nights: 34 suspended connects with no enable after any of them and the ring's own 0x43 log holding only check_sleep on those seconds; 18 overnight reconnects with 0 triplets; and on the rebased tree 51 suspended overnight connects with 0 enables between 22:15 and 07:50, hypnogram complete. Authored by @pipiche38.
…2415) The Live HR banner was pushed on the first heart-rate tick more than 2s after the last push, about every 3s, 1200 times an hour, for as long as a strap stayed connected and whether or not any of the three numbers it shows had moved. Each push wakes the widget extension to re-render, and ActivityKit budgets how often an app may update an activity. It is now pushed when what it shows changes, with the same 2s spacing, and an unchanged banner is re-pushed once a minute, half its 120s stale date, so it never goes stale while the strap is connected. What the banner shows, and how soon a new number reaches it, is unchanged. A steady hour is 60 pushes instead of 1200. This is the rule Android's connection notification has followed since ryanbr#216. The decision lives in LiveHRBannerPushPolicy, pure and free of ActivityKit so StrandTests can cover it, generic over an Equatable content type. The compared type is the ContentState handed to activity.update, and the widget renders only those fields plus the static title, so nothing on screen can change while the policy stays silent. Authored by @UtkuDenizAltiok.
AGENTS.md listed app-build.yml as disabled and on-demand. It is active: it auto-runs on any PR touching Strand, StrandTests, StrandiOS and its siblings, NOOPWatch, Packages or project.yml, and it has no push trigger, so only a direct commit to main needs a dispatch. The row now says that. The stale row had propagated. Three comments assert that no CI job compiles the iOS app target, and one of them landed an hour ago in ryanbr#2415, written by a contributor who read AGENTS.md and believed it: Strand/Data/LiveHRBannerPushPolicy.swift Strand/Data/LiftBannerPushPolicy.swift android/app/src/test/java/com/noop/ui/Spo2MissingCaptionTest.kt The controller both policies serve is compiled by app-build's NOOPiOS leg, which went green on ryanbr#2415 itself. The reason those policies are pure still holds and is what the comments now say: StrandTests runs on macOS and cannot exercise ActivityKit, so the rule has to sit outside the controller to be unit tested at all. The Kotlin test reads both catalogues because a Kotlin test cannot import Swift, not because Swift is uncompiled. A contributor who believes the app targets are unverified either skips checking them or writes it into the tree, and both happened.
…yanbr#2377) The Key Metrics header carried a trailing "7-day trend" caption naming the window the DETAILED tiles graph over. Those tiles are opt-in and default off, and the flag behind them gated only the sparkline and the tile's minimum height. The header sat outside that gate on both platforms, so on a default install nothing in the section drew a trend and the header announced one anyway. The tiles in that state render a value and a caption, so the label was the only thing framing the section as a trend, and no setting removed it. Both platforms now gate the caption on the same stored flag, today.keyMetricsDetailed, which is the one that already decides the sparkline and the tile height, so the header cannot disagree with the tiles. Android's SectionHeader already took a nullable trailing, so only its call site moved. The Apple sectionHead took a required String and always drew the Text; it becomes optional, the shape its Kotlin twin already had, and renders the caption only when one is passed. Its three other callers pass a non-nil string and are unaffected; DevicesView has its own sectionHead and is out of reach. Nothing about the window picker, the sparkline data, the tile layout or the wording changes, only whether the caption is drawn. Raised on ryanbr#2376. Authored by @andremiliano, rebased onto current main for the merge.
707 lines of private declarations that nothing reads, across 11 app files. Swift's private is file-scoped, so each was found by its name having no other use in its own file, and both app targets confirm none was still needed. Nothing a user can reach changes. Most of it is Settings' WHOOP 5/MG research card and what only that card used: the ECG raw-data gate readouts, the deep-data button state, the capture exports and their AppStorage mirrors. ryanbr#1709 removed the card's only call site when the IMU capture sessions replaced it, and the body stayed behind. The UserDefaults keys and the features behind them are untouched; only unreachable UI goes. The rest are small leftovers across RawHistoryArchive, BiofeedbackController, FullDayChartView, SleepView, LiveWorkoutView, TodayView, LiveView, IntervalTimerView, TrendsView and WeeklyDigestView. Deliberately kept: the UIApplicationDelegateAdaptor and DomainTheme's colorScheme, which are unread by name but make SwiftUI host the delegate and re-render; and LiftLogView.unitSystem, whose AppStorage re-render was not settled, so that file is untouched. Verified beyond the compile: every removed declaration was checked for a surviving reference in the file it came from, which is the scope that decides it for a private symbol. The conditional-compilation gap was checked too, since a use the built configuration never sees would pass a compile: the only conditions in these files are os(iOS), os(macOS), DEBUG and canImport(UIKit), all compiled by at least one leg, and the three release-only else branches, all in TodayView, name none of the removed symbols. Authored by @UtkuDenizAltiok.
ryanbr#2417 removed 707 lines of unreachable private code. Three comments named symbols that went with it, so a reader chasing them found nothing. SettingsView.swift the SpO2 card's provenance cited `fiveMGCard` SettingsView.swift the raw-sensor CSV export cited exportPuffinCaptures() BLEManager.swift the ECG bond gate cited the button's `ecgGateReady` Each keeps its history and says where the referent went, rather than dropping the sentence: the provenance of the SpO2 split and the reason the ECG write needs the full encrypted bond are both worth more than the dead name was. The BLEManager one is the interesting miss. Reviewing ryanbr#2417 I checked every removed declaration for a surviving reference in the file it came from, which is the scope that decides whether a private symbol can still be referenced in CODE. A comment has no such scope, and that one sat in a different file. Backticked identifiers are the convention here, so the sweep that finds these is for a backticked name in a comment in a file that no longer declares it.
…yanbr#2416) A manual workout records the live heart rate into its sample list and scores its Effort from that list. Both platforms recorded more than one sample for the same second: on Apple captureWorkoutSample runs from two published sinks, and a strap sets the R-R intervals on every packet and the rate whenever it moves, so a second in which the rate changes arrives twice with one ts; on Android it runs on every LiveState emission carrying a heart rate, not only on a rate change. StrainScorer.sampleDurationsMinutes credits each sample with the gap to the next one and a zero gap with a full second, so every repeated second counted as one more second of effort, live and in the saved workout. Calories were never affected, since estimateBoutCalories integrates over the real gaps. A second that already has its sample now takes no other, so the workout is scored from one reading a second, which is what the stream is. A refused reading still reaches the peak. The sinks fire because the rate moved, so a repeat is often a different bpm for that second and can be that second's high. It folds into peakHr and publishes only that, with no rescore and no snapshot, and the SAVED row takes the folded peak too: both platforms had been deriving the saved maximum from the samples alone, so a within-second high was missing from the stored workout as well as the live card. Android's live peak is now grown by comparison rather than recomputed from the sample list, which would have discarded the fold at the next second. Verified by mutation on both halves: with the dedupe removed a repeated second is kept and twenty minutes of 100 to 160 bpm scores 42.86 instead of 35.3, and with the fold line removed the peak reads 120 instead of 131. Raised on ryanbr#950. Authored by @UtkuDenizAltiok.
…ries The scheduled Parity Governance run went red on 971d0d9 and stayed red through today's merges. Two tests: compact_baseline_drift returned three test-only-callsite scopes, and the stored authority manifest could not be reproduced, 4463 files against 4467 derived. The drift was the "debt decreased; baseline cleanup is optional" warning the ledger prints on every run. The ledger treats it as optional and the governance suite asserts it is empty, so a warning nobody had to act on was the thing holding main red. Eight findings had gone away and the baseline still listed them: Packages/StrandAnalytics 44 -> 43 android/analytics 70 -> 64 android/data 14 -> 13 Every count moves DOWN, no scope is added and no new identity appears, so the migration waived manifest reproducibility and nothing semantic. Known findings stay at 292. --migrate-authority rather than --repair-stale-base, for the same reason as 0599d8a: repair is for a base whose governed state matches and whose metadata drifted, and here the stored manifest could not be reproduced at all, so there was no exact basis to compare against. Three contributors wrote "this fails on clean main too" into their PR bodies while this stood, which is triage they should not have had to do.
The iOS stress check-in appended the live R-R intervals to its buffer every time ingestHR ran, and ingestHR runs from two published sinks, the heart rate and the R-R. A sink runs inside willSet, so the handler reads the packet before the one being written, and a packet whose heart rate also moved reached it twice: its intervals entered rrBuf twice and the detector's slow baseline advanced on every call rather than every packet. Driven through a real LiveState in the standard-HR write order, 21 packets came out as 800, 800, 801, 802, 802, 803, 804, 804. RRPacketCursor states for a non-view consumer the rule RRPacketObserver already gives views, take a packet once keyed on rrSeq, and evaluateStress asks it first. setRRIntervals assigns rr and then bumps rrSeq, so live.rr always belongs to live.rrSeq whichever sink is running, and a packet the heart-rate sink never carries is taken on the next packet's R-R sink instead. rrSeq is incremented before any sink can observe it, so a real packet never carries the cursor's initial 0. Android runs the same detector once per history offload on rrRecent and is unchanged. The platforms still differ in how often they evaluate, per packet here and per offload there, which this leaves alone deliberately. Raised alongside ryanbr#2416, the same two sinks double-recording manual-workout samples. Authored by @UtkuDenizAltiok.
…se inputs did not change (ryanbr#2293) Two changes, both cutting work a post-sync pass repeated for no new answer. Sleep habits are learned only from FINISHED nights: computeHabitualSleep filters endTs < local midnight. A night still being slept is re-banked as it grows, and learning from it dropped the day cache on every sync, which is what produced the churn. A closed sleep-to-sleep cycle reuses its Effort and calories instead of re-reading a day of heart rate each pass. The load cache is keyed on witnesses that are index-only, COUNT and MAX(ts) per owner, so a closed cycle costs two aggregates rather than a row scan, and the key carries resting HR, HRmax, effort method and the profile's own fields. BEHAVIOUR, not only speed: holding the most recently finished night out of the learned history for up to about a day shifts sleepNeedHours, sleepConsistency and habitualMidsleepSec slightly for everyone on upgrade. That is the right call, a night still being slept is not a habit, but it is a decision rather than a side effect. ryanbr#2350's open anchor half touches habitualMidsleepSec too, so whoever picks that up should know this moved underneath it. The profile is keyed on its named fields, doubles by bit pattern, rather than on String(describing:), so a field added later cannot silently stop being witnessed and serve stale calories after a profile edit. Measured by replay on one database copy: 8813s and 2345s passes, and 96s down to 2.9s on an unchanged re-pass. Debug build, so the numbers compare to each other rather than to a phone. Authored by @Iskrata. Rebased onto the repaired parity authority for the merge.
…ote it A crash record survives updates, so the "Last crash" section routinely carries one older than the log around it. A record from 30 Aug, build 382, arrived under a "build 534" header twice in one week of triage and read as a fresh crash both times, costing a check of the date before it could be dismissed. Both numbers were already present, the record's own version code and the header's, but nothing put them together, so noticing was left to the reader. When they differ the section now says so, above the record: NOTE: this crash is from build 382, not the build that wrote this log (534). A crash from the RUNNING build gets no note. That case is the alarming one and softening it would be the actual harm, so it is pinned by its own test rather than left to follow from the other. An unreadable record is left alone instead of guessed at: crashVersionCode returns null when the `app:` line is missing or shaped differently, because a wrong build number in that note would be worse than no note. Mutation-checked: with the staleness comparison forced to null, exactly one of the six tests fails, the one that asserts the note appears. The three that assert SILENCE stay green, which is what tells them apart from tests that would pass for the wrong reason.
…#2429) The reassembler guarded a declared length below the family floor and above the 8 KB ceiling, and trusted everything between. indexOfSOF matches a bare 0xAA, which occurs inside payload as readily as at a frame start, so a stream that desyncs can put the read cursor on a payload byte whose next bytes read as a PLAUSIBLE length: past the floor, under the ceiling, accepted by both guards. feed() then waited for that many bytes and emitted them as one frame, consuming whatever valid frames fell inside it. They never reached a parser and nothing downstream could return them, because the CRC32 that rejects the bad frame runs after the cursor has advanced past them. The header checksum settles it without new protocol knowledge, because it already covers the length field on both families: CRC-8 over the two length bytes on WHOOP 4.0, CRC-16-Modbus over the first six on 5/MG. A real frame always passes; a false SOF passes with probability 1/256 or 1/65536. The gate sits AFTER the floor and ceiling checks so those keep their counters and their tests, and it only sees lengths the old code accepted. headerChecksumDrops counts the rejections and is folded into the connection's reject tally, under HEADER_CHECKSUM_MISMATCH rather than the floor bucket: a floor drop is a malformed frame, this is a read cursor that was not on a frame boundary at all. Without that fold the drops would have vanished with no trace, which is the failure the neighbouring absorbReassemblerDrops exists to prevent. Both absorbed totals clear on the tally's reset, or the fold reads as "already folded" against a total that never goes down and goes silent for every later connection. Both platforms start a connection with the counters and the absorbed totals at zero, so the fold cannot be silenced by a lifecycle mismatch: Apple reassigns the reassembler and sets router.family on adjacent lines, and its didSet clears the tally; Android resets the tally in STATE_CONNECTED, before service discovery reassigns the reassembler. ryanbr#374 already handled the out-of-range half of this and its comment named the trigger, "a bit-flip, or a spurious 0xAA injected mid-frame". This is the in-range half of the same problem. Mutation-checked on both platforms independently. With the gate removed, feed() returns nothing where two valid frames should be: the false SOF eats them both while waiting for 104 bytes. With the reset line removed, only the post-reset assertion fails. Every pre-existing framing test stays green under both mutations, which is what separates a gate that catches a false SOF from one that rejects real frames. Swift 791 tests, Kotlin 17 and 39, parity clean, 16 of 16 in CI. Not reproduced on hardware: the trigger needs a desync, and the reassembler is rebuilt per connection, so the residual window is a subscribe landing mid-frame. Found by an AI code review, which reported it as corruption under the ceiling. The mechanism is a false SOF after a desync rather than corruption, since BLE hands up only CRC-valid PDUs, and the severity is lower than it judged. The gap it pointed at is real, and the header-checksum resync it recommended is what this implements.
…yanbr#2426) The day's HRV is an in-bed-weighted mean over the sessions that have one. When the ryanbr#1118 over-count gate refuses a main night, that night stops contributing any weight at all, so whichever short naps passed become 100 percent of the figure. On one reported day two 26-minute naps gave (69.51 + 27.90) / 2 = 48.7 ms, the log printed hrv day=2026-09-20 window=whole avgHrv=48.7, and the baseline folded it as a full night, moving the mean from 32.2 to 35.6 on the second of the four nights it needs to seed. The comment justifying day-wide physiology says the main overnight dominates these anyway, being far longer than any nap and weighted by duration. That holds only while the night HAS a value. The gate makes it nil, and zero weight is not dominance. So when a main-group session's HRV is nil BECAUSE the gate refused it, only the main group is pooled, and the day holds nil unless another fragment of that night measured cleanly. A main night that simply banked no R-R is not refused, so ryanbr#1884's fill-in from the day's other sessions is unchanged. That boundary is the design, and it is pinned on both platforms. The gate is EXTRACTED rather than restated: sessionHrvOverCounted is now the one definition, sessionAvgHRV gates on it, and AnalyticsEngine asks it whether a missing night was refused or never measured. The two cannot disagree about which nights were refused, and a test asserts they agree. The new call short circuits on the nil test first, so it costs an R-R pass only on a night that already has no HRV. Raised on ryanbr#2425. Authored by @pipiche38.
ryanbr#2427) The always-on hrv diag line pooled every sleep session of the day and divided their beat-time by first-beat-to-last-beat, so the hours BETWEEN a night and a nap entered the denominator. On a day whose main night the ryanbr#1118 gate refused at 1.31 coverage, the line printed coverage=0.48 rrIntegrity=underCovered: the log said "not an over-count" about a night refused for exactly that, and the HRV card's flag, read from the same verdict, agreed with the log rather than with the gate. Two readouts of one fact disagreeing, which sent triage of the report the wrong way first. The line now reads the MAIN-night group, the same group scoring uses, over the gate's own inclusive window. On a one-block night that is exactly the set of beats sessionHrvOverCounted judged, so the over-count half of the verdict cannot disagree with the gate. A bridged multi-fragment night still spans its short bridge gap, which can only pull coverage DOWN. A day with no main night keeps the old pooled set, since there is no night to describe. mainNightBlocks carries that group on DayResult rather than having the diagnostic re-derive it, so the line and the rule cannot pick different nights. Raised on ryanbr#2425, the second half of the same report. Authored by @pipiche38, rebased onto the merged fix for the merge.
…cified The note above the label says the word "reserves 16.dp each side so it can never run under the chevron (which needs 14.dp)". The code did not: the inset had drifted to 2.dp leading against 18.dp trailing, reserving the chevron on one side only. TextAlign.Center then centred the word inside a box 16.dp narrower on the right, putting it 8.dp LEFT of the box centre, and so 8.dp left of the ring directly above it, on CHARGE, EFFORT and REST alike. This is the contract the file already documents, restored. Reported on ryanbr#2421 by @mailingjash, who saw the offset on a screenshot before anyone reading the file next to it did. Symmetric at 16 rather than 18 because that width is not spare margin: it is what AutoSizeValue shrinks into before it ellipsises, and ryanbr#1502 was exactly that failure, a German "ERHOLUNG" cut to "R…". 16 restores the documented reservation and clears the 14.dp chevron by 2. The chevron is unmoved, still floating at CenterEnd, so the tap target, the RTL mirroring and the long-translation behaviour are as they were. Compile only: this is layout on a surface no test renders, so the check that matters is a build in a language whose labels are longest.
…banner until its switch turns it off (ryanbr#2422) A tester on a WHOOP 5.0 reported two things, both traced through four strap logs: with the strap off the wrist the app and the Lock Screen could go on showing a number, once frozen at 93 while the Live screen showed a dash; and over days the banner was there, then gone until NOOP was opened. Only a readable sample reaches the live heart rate now. A run of three unreadable samples clears it, so one glitch while worn cannot, and a WRIST_OFF clears it at once. Ten seconds with no readable sample clears it too, which is the case a 5.0 produces by simply going quiet: no zero, no event, the link up. The collector still stores every sample as it arrived. The reason the old clear never worked is worth recording: AppModel's sinks ran in willSet and read `live`, so a clear found the previous values still in place and the ryanbr#39 reset never ran. Each sink now hands on the value being written. `clearLiveHeartRate` clears R-R BEFORE the heart rate, so the heart-rate listener sees the R-R already gone, which is what makes that reset fire. The banner is kept until its switch turns it off. iOS lets an app start a Live Activity only while it is on screen, so a banner ended for a dropped link, a background sync or a strap off the wrist could not come back until NOOP was opened, which is the unreliability the tester described. Nothing that passes ends it now: it shows the dash and the number again by itself. It ends only on its switch, or while the Lift Log banner is on screen. That reverses the first version of this change, at the tester's request after living with both. Each push carries a 30 s stale date, so iOS draws the dash itself when NOOP is suspended and no timer of ours can run, and a steady number is re-pushed every 15 s. That is four times the push rate ryanbr#2415 established and still five times better than before it, bought for the one mechanism that works while the app is asleep. The number giving way to the dash, or coming back, is pushed at once whatever the spacing: a strap taken off sends nothing more, so a push skipped for spacing would leave the last number standing, which is what kept "91" on a banner for minutes. Commits 7 to 11 are not yet verified on the tester's phone. Authored by @UtkuDenizAltiok.
Parity Governance is red on clean main at 9c99138. The ratchet reads the committed map on origin/main and compares it against a manifest derived from origin/main's own tree, so "base authority cannot be reproduced" is a statement about main alone. No working-tree run on any branch can clear it, which is why it looked for a while like a branch problem. Nine commits landed after 44ef71e re-derived the authority this morning. None of them touched Tools/, and parity-governance is path-gated on Tools/, so not one of them ran the check that would have caught the drift. What moved, all of it twins the merged work added in pairs: functions 4470 -> 4475 file_pairs 72 -> 73 function_pairs 184 -> 187 unpaired_files 379 -> 378 unpaired_functions holds at 4113 and unpaired_properties at 164, and unpaired_files goes DOWN, so this waives manifest reproducibility and no semantic debt. Known findings stay at 292. That is the opposite direction from 44ef71e, where every count fell because stale debt was dropped; here the counts rise because real twins arrived. --migrate-authority rather than --repair-stale-base, following 44ef71e and 0599d8a: repair is for a base whose stored manifest drifted, and here it could not be reproduced at all. The parity item on ryanbr#2408 is blocked behind this, not behind anything in that contributor's branch.
… shipped 06083a0 centred the Today hero label on its ring for ryanbr#2421, replacing the drifted `start = space2, end = space18` with `horizontal = space16`. This test still asserted the old asymmetric pair, so it has been red since that commit. It went unseen because linux-capture is path-gated on Tools/ and 06083a0 touched no file there. The next commit that did touch Tools/, the parity re-derivation, is what finally ran the suite. That is the second time today a path gate has hidden a real failure on main. Repinned to the same shape rather than dropped. The assertion guards that the label reserves horizontal room so a long translation shrinks instead of clipping, and 16dp each side reserves more of it than the 2/18 pair did, so the property it protects is stronger now, not weaker. 130 tests in the suite, one failure, this one.
…yanbr#2408) One `Oura: first SpO2 decoded` line covered two quantities three orders of magnitude apart. `0x6F` and `0x7B` carry a firmware-computed percentage tagged "raw"; `0x77` carries a raw DC perfusion magnitude tagged "dc_raw". A single latch printed whichever the history drain served first, so one ring on consecutive reconnects of the same night printed a value of 93 and then 101144, with nothing in either line to tell the two quantities apart. A reporter read the five-digit value as a percentage and raised a defect against SpO2 that was never wrong. OuraSpO2Channel, new in OuraProtocol and com.noop.oura, resolves the channel from the unit tag and owns both the label and the log line, so the two platforms cannot drift on what they call these numbers. One latch per channel replaces the single flag, and the percent sign is appended on the percentage channel only, with a test pinning exactly that. Both known tags match exactly and anything else lands in an `unknown` channel that names its tag and never takes a percent sign, so a future tag cannot print as a percentage. Log copy only. No behaviour change, no stored value, no schema, no new user-facing strings, no BLE path. The Swift `var channel` reads as one-sided to the parity ledger, because its Kotlin twin is a top-level extension property with a dotted receiver and the Kotlin property scan does not index that shape. Carried as a typed disposition rather than a declared twin reference, which would have resolved to nothing and landed as a dead-twin-reference. Refs ryanbr#2372 Authored by @pipiche38.
…nbr#2432) Reported in ryanbr#2431 by @bartmuskala: the Stress chart's colour coding ran left to right when it should run bottom to top. The line brush was `Brush.horizontalGradient(*StressRamp.stops.toTypedArray())`, the same blue/green/amber ramp the hero PipBar and the totals bar use. Those are horizontal BARS, where length carries the value, so a ramp along x is right for them. This is a line chart whose value sits on y, so along x it coloured by time of day instead: a calm 9pm hour drew amber and a tense 7am one drew blue. The colour said nothing about the score while looking exactly as though it did. Three things in the app already contradicted it: `yForC` maps the 0-3 level onto y with level 3 at the top, the gauge higher up the same file already ramps vertically tense-first, and the screen prints a 0-1 LOW / 1-2 MEDIUM / 2-3 HIGH legend under the chart. Strand/Screens/StressView.swift moved to a vertical ramp in ryanbr#2053 and its note says this side does the same. It never did: the Kotlin has been horizontal since 4da3cfb, so the note described a parity that was not there. The brush moves inside `drawWithCache`, where the plot bounds exist, and spans `topPad .. topPad + usable`, which is `yForC`'s own range rather than the whole canvas or `h - botPad`. The canvas would sit a pad out from the level it claims at both ends, and `h - botPad` parts company with `yForC` once `usable` hits its 1px floor on a very short chart. The stops are mirrored about the midpoint rather than reduced to a bare colour list, which would be spaced evenly and so track `StressRamp.color` only while the stops sit at 0/0.5/1. The lone-hour dot is coloured by `StressRamp.color(level)` while the line is coloured by position, so mirroring is what makes the two agree for any spacing rather than by coincidence. Pinned by StressLineVerticalRampTest against the source, the way GlowRingFlatArcTest pins GlowRing. Mutation-tested five ways, each reddening only what it should. Full Android suite 6411 tests across 754 classes, no failures, so nothing existing was pinning the old direction. Android only. The iOS side has been correct since ryanbr#2053.
…anning for one already bonded (ryanbr#2436) Reported in ryanbr#2433 by @pipiche38, with the root cause and the remedy already traced. Every claim was checked against the source rather than the trace, and there was one more door than the report names. `retrievePeripherals(withIdentifiers:)` answers nothing before `.poweredOn`. Every source read that empty answer as "this device has never been seen here" and took the discovery branch: park the id, arm a service-filtered scan. The replay on `.poweredOn` then looked the id up in `seenPeripherals` only, which is empty for the same reason, so it fell through to the scan it had just armed. The targeted connect CoreBluetooth honours while the app is suspended was never issued. In the foreground this costs nothing: a scan finds the device in 1-6 s. In the background iOS throttles scanning hard. The report has a Gen3 ring bonded to the phone for weeks found 29 min 54 s after a background relaunch, and 4 min 31 s earlier the same day, beside a WHOOP central that attached immediately. Every launch printed "not cached yet", 8 of 8 in that export, for a ring that plainly was. BLEManager already learned this for straps in ryanbr#2272, where the comment reads "a background scan would not find it". The four peripheral sources never did. Two doors, not one. `issueStandingConnect` carries the same conflation, and it is worse there, because its fallback calls `connect(id)`, which scans. Scanning is the one thing a standing connect exists to avoid, against its own promise that CoreBluetooth reconnects "whenever the ring is reachable, including while the app is suspended". Both entry points now park the intent before the retrieve when the state is not `.poweredOn`, which also stops the log claiming something the code never checked. The replay re-issues the retrieve, now that an empty answer means something, and connects what it finds. Parking means nothing calls `scan()` on the way past, so a parked connect that still cannot be resolved has no deferred scan to pick up: it arms discovery itself rather than answering "nothing to do", which would drop a first-ever pairing in silence. Four outcomes, not three, and `didDiscover` still connects the parked id on sight. Same change in the three sibling sources carrying the identical block: StandardHRSource, HuamiHRSource, FTMSSource. BLEManager was checked and is clean throughout, guarding the state before every retrieve. The decision is extracted as `PendingConnect.replay`, a pure function of (parked, held, retrieved, scanning), testable without a radio, a peripheral or a CBCentralManager, as the report suggested. Eight cases in StrandTests, two of them invariants over the whole input space: a parked connect is never dropped, and nothing is ever started for a caller who asked for nothing. Mutation-tested twice, each reddening only what it should. Android needs no twin: `getRemoteDevice(address)` rehydrates from a stable MAC whatever the adapter state, while CoreBluetooth's per-app UUIDs need the radio up to resolve. What no unit test covers is the hardware half, which is the acceptance signal the report names: a background relaunch followed within seconds by a connect line rather than a scan. Refs ryanbr#1213, ryanbr#1215
…ice over itself (ryanbr#2447) The Trends chart's x-axis asked Swift Charts for a COUNT, `.automatic(desiredCount: 5)`, and let it choose the stride. Over a short window the stride that best fits the count is sub-day, so several marks land inside one calendar day. Every one of them formats to a date, and the axis prints "Sep 21" on top of "Sep 21", then "Sep 22" on top of "Sep 22". The reported screenshot is DUPLICATION, not crowding, which is the part worth being precise about: a wider card or a smaller font would have spread the duplicates out and left them just as wrong. The marks are now named outright, as distinct start-of-day instants, so no two can format to the same date whatever the window, and the label takes an explicit day-only format so a mark cannot print a time either. Named days rather than `.stride(by: .day, count:)`, for two reasons. A stride steps from the domain's LOWER bound, so a 30-day window marks days 0/6/12/18/24 and leaves day 29 bare, and the newest day is the one a trend is usually read for; mutating the walk to run forward loses it in 393 of the 400 windows from one to four hundred days. And `.stride` carries `roundLowerBound` / `roundUpperBound`, which could widen an inferred domain and put dead space at the chart edges, a render-time change that cannot be checked off-device. Naming the days cannot alter the domain. Each step is re-anchored to the start of its day. A day-add preserves the TIME of day, and in a zone that shifts its clocks at midnight the 00:00 it aims for does not exist on the transition date, so Foundation hands back 01:00. One stepped mark then sits off the day boundary and every mark after it inherits the 01:00, so the tail of the axis drifts. A sweep of two years of windows in America/Santiago put 52 marks at 01:00 before this. The rest of the suite runs in UTC, which has no transitions and so could not see it at all. Windows the Trends ranges offer, with the marks each now yields: 1 day, 24 samples 1 mark (the reported shape) 7 days 4 marks 30 days 5 marks 90 days 5 marks 365 days 5 marks TrendChart is shared, so this lands on all nine screens that draw it: Trends, Stress, Workouts, Workout Detail, Metric Explorer, Apple Health, Xiaomi Band, Asleep Duration and the hosted Today cards. `xAxisDays` is a pure function over the series dates, so the contract is pinned in StrandDesignTests without Swift Charts, and pinned as the invariant itself rather than a proxy: across every window from 1 to 400 days, no two marks share a day, every mark is day-aligned, the marks ascend, there are never more than five, none precedes the series, and the newest day is always among them. Plus the reported single-day shape, degenerate input, an unordered series, and two years of windows in America/Santiago holding every mark on its own day. Four other charts use the same `.automatic(desiredCount:)` pattern and could show this on a short enough window: OverviewHRChart, CompareView, HealthView and the WorkoutsView chart. They are left alone, each wanting a look at its own data first. Apple side only. Android formats its trend axis as "d MMM" and draws three labels with an ellipsis, so it never had this.
…% column 0x7B spo2_stable decodes a big-endian u16 whose scale no source pins (the golden vector reads 970), but it took OuraSpO2's default "raw" unit - the 0x6F percentage tag. That let it through OuraStreamMapping's `unit == "raw"` persist gate (ryanbr#2152) into spo2Sample.red beside the ring's real percentages, and made OuraSpO2Channel call it a percentage. 0x7B now stamps OuraSpO2Channel.stableUnit / STABLE_UNIT ("stable_raw"), which resolves to .unknown ("NOT known to be a percentage") and is dropped at the persist gate. Promote it once a capture pins its scale. Both platforms. Tests: decoder/driver golden vectors carry the new tag, the channel test asserts .unknown and a %-free log line, and a mapping test feeds the real decoder's output and asserts nothing is persisted. Parity snapshots refreshed through the ratchet (new constant pair). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017WPQfkW6oMWQ1idoxetd5G
nightlySpo2RawMeans averaged every spo2Sample row in the night, including the Oura ring's single-channel rows (red = the ring's reading, ir = 0). A ring night therefore stored spo2Red ~97 beside spo2Ir 0, and the Raw SpO2 tile's (red + ir) / 2 showed a ~97 % reading as "~49 ADC". Legacy pre-ryanbr#2152 dc_raw rows (up to ~11.7M, stored with ir = 0) inflated it too. - AnalyticsEngine.nightlySpo2RawMeans (Swift + Kotlin) counts two-channel rows only (ir > 0). A ring night yields nil; its SpO2 still reaches nightlySpo2CeilingMean unchanged. A WHOOP 4.0 sample whose IR read 0 is no longer averaged in either. - The tile refuses rows with spo2Ir <= 0 (iOS/macOS spo2rawPoints, Android twoChannelRawSpo2Mean, shared by the value and the latest-row predicate), because already-scored ring nights keep their stored row until rescored. Tests: Swift Spo2RawNightlyTests (new), Kotlin NightlySpo2RawTest additions and TwoChannelRawSpo2MeanTest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017WPQfkW6oMWQ1idoxetd5G
1 task done
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.
What this PR does
Adds support for heart-rate-only sleep staging (when a strap streams HR but banks no motion data), expands the analytics engine with new metrics and helpers, and adds comprehensive test coverage across multiple domains.
Key changes:
Sleep staging without motion (Sleep: no HR-only staging fallback, so a strap that never banks motion can never report Rest ryanbr/noop#1801): Adds
hrOnlyfield toSleepSessionto flag nights staged from heart rate alone. Includes new test suites (SleepStagerHrOnlySessionsTests,SleepStagerHrOnlySpineTests,SleepStagerHrOnlyTraceTests,SleepStagerHrOnlyAnchorTests) and integration tests (HrOnlyPhysiologyIsolationTests,AnalyticsEngineHrOnlyDayTests).New analytics primitives: Adds
LocalDayWindows(calendar date helper mirroring Kotlin'sjava.time.LocalDate),PreSleepHeartRateFeedback,LiftMetrics,AdaptiveExpenditureEngine,SkinTempBackfill,SlidingStreamWindow, and supporting infrastructure (StreamReadCap,WindowedStreamPlan,StepsDetailDensity,SleepAwareStepCounter,PhysiologicalSteps,DayCycle,ClockFormat,AlarmReadback,SleepNightLabel,CoachSuggestions,StoreProbeTally,AnalysisPhaseTally,SseDeltas).Oura protocol expansion: Adds
UserInfosetters (opcode 0x20),OuraAuthWatchdog,OuraSpO2enhancements, and supporting test coverage.WhoopProtocol enhancements: Adds
Hex,FrameDiagnostics,FirmwareGateDiagnostic,HistoricalLayoutSupport,ScanAdvertisementSummary,Whoop4HelloSerial,Whoop5RRstream support, and comprehensive test oracles.Store layer: Adds
ClockRef,CoachMessageStore,HypnogramCoverage,LiftLogStore,LiftMuscle,OuraRingEpochScan,OuraSerialIdentity,OuraSidecarGenerations,SourceIdentity,StoreProbeCounts.Import layer: Adds
HevyAPI,LiftProgramSheetImporter,MuscleGroups,XlsxSheetfor lift log import; addsCorelibsCompatfor compatibility.Local access CLI: Adds
NoopLocalAccessCorewithCLIQuery,ToolDispatcher,MCPServerupdates for bounded read-only queries.Documentation: Adds
AGENTS.md(contributor guidance),NoopLocalAccess/README.md; updatesCLAUDE.mdto redirect toAGENTS.md.CI/tooling: Updates GitHub Actions workflows (
parity-governance.yml,tools-python.yml,fork-testing-build.yml,fork-release.yml,app-build.yml,android.yml,i18n-coverage.yml,source-hygiene.yml,swift-packages.yml); updates issue template and PR template.Localization: Adds Chinese (Simplified & Traditional) translations to
NOOPWatchComplications/Localizable.xcstrings.Test coverage: Adds 60+ new test files covering the new primitives, analytics, protocol features, and cross-platform parity.
Type of change
How it was tested
Packages/StrandAnalytics/Tests,Packages/WhoopProtocol/Tests,Packages/OuraProtocol/Tests, `https://claude.ai/code/session_017WPQfkW6oMWQ1idoxetd5G