Skip to content

Merge main into the quiet-motion branch (resolves the xcstrings conflict, #931) - #14

Closed
ryanbr wants to merge 49 commits into
vishk23:perf/quiet-motionfrom
ryanbr:fix/931-merge-main
Closed

ryanbr wants to merge 49 commits into
vishk23:perf/quiet-motionfrom
ryanbr:fix/931-merge-main

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 2, 2026

Copy link
Copy Markdown

ryanbr#931 is approved and the only thing holding it is CONFLICTING on Strand/Resources/Localizable.xcstrings. Merging this into your branch clears it.

The conflict was mechanical

Both sides only added keys — nothing was edited on both:

keys
merge base 3283
added by #931 2
added by main since 40
edited on both sides 0

So the resolution is exact rather than a judgement call: main's catalogue, in main's existing order, plus your two keys (Reduce motion in NOOP and its caption, both already carrying all eight locales). 105 lines added, 1 removed. Everything else in the merge auto-resolved.

One thing worth knowing if you redo this yourself: the catalogue on main is not key-sorted. I sorted it on a first attempt and it reordered all 3,325 entries into a useless diff — appending preserves main's order and keeps the change to the two keys.

Verified

  • Tools/i18n_audit.py --ci main and Tools/doc_comment_lint.py both clean.
  • app-build green on the merged result — Strand (universal macOS) and NOOPiOS both compile, and StrandTests runs. Worth flagging because that workflow was disabled from 21 July until today, so #931's earlier checks never compiled Strand/Liquid/, TodayView or SettingsView at all. This is the first time this branch's app-target Swift has been built.

Nothing of yours is changed — this is a merge commit plus the catalogue resolution. Your 15 files still contribute +552/-63 over main.

Still outstanding on #931 itself is the question from the review, unrelated to this: the "Reduce motion in NOOP" toggle is Apple-only, and Android's gate stays two-signal. Follow-up issue rather than a blocker, if you'd rather.

vishk23 and others added 30 commits July 28, 2026 22:57
…apture (#845, #103) (#942)

#845 asked for the `spo2_candidate_82` instrumentation because "the app itself
cannot currently contribute the data needed to resolve its own open question,
only the offline tooling can". Half of that is now fixed and half was not:
#848 banks the raw @82 byte into `v18AuxSample`, so the value IS retained — but
`validate_spo2_candidate.py` could only read a capture.json or the CAPTURE
TOOLING's frame store, and its own docstring said so:

    NOTE: this is the CAPTURE TOOLING's database, not the app's. Neither shipped
    app has a `frames` table [...] so this cannot be pointed at a phone's store.

So a NOOP user who synced a strap held the evidence #103 has been waiting on and
had no way to run the gate over it. Only someone running linux-capture could
contribute. This closes that.

The harness now accepts a third input kind: the app's own SQLite, read from
`v18AuxSample` joined to `sleepStateSample.state` for the asleep gate. An app
store is told apart from a capture store by the `v18AuxSample` table rather than
by extension or a flag, so a user does not need to know which file they hold.
Opened `mode=ro&immutable=1` — no WAL recovery write against a file pulled off a
phone, the same reasoning Tools/SleepBench documents.

`unpack_v18_aux` is a Python port of Swift `V18AuxCodec.unpack`. It is pinned
against the SHIPPED codec rather than round-tripped against itself: both test
fixtures are literal `V18AuxCodec.pack` output captured from
`Packages/WhoopStore`, so a wire-format change fails the test instead of
silently mis-slicing real rows into plausible-looking numbers.

ONE THING AN APP STORE CANNOT DO, reported rather than papered over. The app
banks decoded slots, not frame bytes, so there is no neighbour byte to rank @82
against and the OFFSET SPECIFICITY SCAN cannot run. Recording `offset_82_wins:
false` there would fail the gate for a reason that is about the input format,
and "@82 did not win" is precisely the wrong thing to record about a byte that
was never ranked — so it is n/a, with `source` and `specificity_scan` naming
which of the two happened. A capture is still required to settle specificity.
`byte_at_offset` returns None (not 0) for unknowable offsets, because 0 is a
legitimate @82 reading outside the duty window and "cannot know" read as "read
zero" would manufacture out-of-band samples and depress coverage.

Nothing about the promote gate is relaxed: no card, no score, no `spo2Pct`, and
the multi-device thresholds are untouched. This only widens who can produce the
evidence.

tools/linux-capture: 50 tests, 0 failures (39 before, 11 new).
… not just kappa (#935)

* SleepBench measures stage-fraction calibration and first-REM latency, not just kappa

PR #348 fitted the stager to DREAMT. It raised kappa on all three benchmarks
with a held-out gap of -0.027 — clean by every ML criterion — and PR #437
reverted it 48 hours later because it re-scored a healthy night from 6% to 23%
awake. The revert's own words: "kappa doesn't guard stage-fraction
calibration."

SleepBench as it landed in #925 reproduces exactly that blind spot. Sections B
and C score agreement (accuracy, kappa, per-stage sensitivity/specificity) and
wake minutes, none of which constrain how much of the night a recipe spends at
each stage. A recipe can raise kappa while systematically reallocating stages,
because the epochs it newly gets right can outnumber the epochs it newly
mislabels.

Adds two sections. Nothing existing is replaced; the old section E is renamed G.

E. PER-STAGE FRACTION CALIBRATION. Predicted % of night vs reference % of
   night for wake/light/deep/REM, as a signed bias in percentage points,
   per night and aggregated, plus the unsigned MAE so a recipe that over- and
   under-calls in equal measure cannot pass as well calibrated.
   - E.0 prints accuracy and kappa on the SAME nights, so the number that is
     not sufficient sits directly above the numbers that guard it.
   - E.2 reports a HEALTHY stratum (in-bed >= 5 h) separately. The aggregate
     hides the failure: #348's healthy-night blowout was ~17 pp of wake, which
     pooling with short fragmented nights dilutes.
   - E.3 reports the shipped population's stage fractions over every replayed
     night with no reference at all, so a distribution shift is catchable on a
     database with no human labels.

F. FIRST-REM LATENCY. Minutes from staged sleep onset to the first REM epoch,
   per night and as a median/p10/p90/min/max, for each recipe and for the human
   reference. Calibration pins how MUCH REM a recipe emits, not WHEN; REM in
   the first minutes after onset is physiologically implausible in a healthy
   adult. Nights that never reach REM are counted separately rather than folded
   in as a zero.

Reference set. The calibration sections score the stage-locked rows, not
section B's set. B excludes an edited night whose stored hypnogram is a
byte-exact replay of the CURRENT V2 — right for B's question, but
version-dependent by construction: change the recipe and a night can enter or
leave the exclusion, silently swapping the denominator underneath a
before/after. The stagelock cursor set comes from `cursors` and does not move
when the recipe moves.

Read-only properties are unchanged: DB.swift is untouched, the open is still
SQLITE_OPEN_READONLY | immutable=1, there is no write surface, and the database
path is still a required argument.

Verification. 13 new unit tests over the pure label-array primitives, no
database needed (swift test in Tools/SleepBench). Measured on a real 36-session
database with 15 stage-locked human-authored references (9,163 epochs),
replaying the same harness against two builds of SleepStagerV2 — this branch's
base, and the base plus the REM-latency guard change proposed in #934:

  V2 4-class          kappa 0.691 -> 0.689, accuracy 78.2% -> 78.2%
  stage bias, pp      wake  -7.26 -> -7.13
                      light -7.54 -> -6.10
                      deep  +6.65 -> +6.65
                      rem   +8.15 -> +6.57
  first-REM latency   median 72.2 -> 83.2 min, minimum 10.0 -> 43.0 min
  healthy-stratum wake fraction  9.40% -> 9.43%  (+0.03 pp)

Kappa moved -0.002 and would have called that change nothing. The latency
minimum moving off 10 minutes, and the healthy-stratum wake fraction holding to
+0.03 pp, are the evidence — and neither was measurable in this repository
before this commit.

* Stop SleepBench's calibration set scoring V2 against V2's own output

Section E's reference set keys on `stagelock` so the denominator cannot move
between two builds — the right call, and the reason section B's recipe-dependent
exclusion is not reused there. But a `stagelock` cursor proves only that the
stages arrived through `edit_sleep_stages`; it does not prove they differ from
what V2 emits. On the database this was developed against, 3 of the 15
stage-locked reference nights replay BYTE-EXACT from V2 and a 4th agrees at
99.09%. Those rows carry no information about V2: they hand the incumbent a
guaranteed perfect night and charge every alternative recipe for the same
nights.

The effect is large enough to invert a verdict. Scoring PR #348's parameter set
on this database gives 4-class kappa 0.640 against the incumbent's 0.689 on the
contaminated 15-night set — #348 loses — and 0.705 against 0.604 on the same set
with the three byte-exact nights held out — #348 wins by +0.101. Section E exists
precisely to judge a #348-style tune, so a 0.085-kappa thumb on the scale in
favour of "change nothing" is the one bias it must not have.

Section E cannot simply drop the rows the way section B does without
reintroducing the version-dependence it was built to avoid, so the harness names
them instead: a new E.-1 audit lists every stage-locked night whose stored
hypnogram replays from V2 at >= 95%, flags the byte-exact ones, reports what
fraction of the reference set they are, and prints the exact `--exclude` line to
pin the same held-out set across both builds of a comparison. Excluding by
explicit timestamp keeps the set frozen; excluding by "matches this build's V2"
would not. The audit asserts no mechanism for the match, only that a row cannot
be an independent reference for the recipe it replays.

Verification: `swift test` in Tools/SleepBench = 13 tests, 0 failures; release
build clean; run against a 37-session device database copy, E.-1 names the four
nights above and every downstream section is unchanged when `--exclude` is not
passed. Tools-only — no analytics, no stager, no Android twin, no goldens.

Refs #348, #437.
* Run the tool tests that nothing was running

Three places in this repo had tests, or code the app's conclusions depend on,
that no CI job ever executed.

tools/linux-capture carries 212 tests across 8 files — the frame decoder, the
HCI extractor, the activity and sync decoders, spot HRV, and the SpO2 promote
gate #103 is waiting on. They ran only when someone remembered to. All 212 pass
today, so this starts green; the point is that a regression would previously
have gone unnoticed by a suite that exists to catch it.

Tools/SleepBench is the instrument the sleep work is argued with — #934's
stage-fraction tables came out of it — and #935 added 13 tests nothing ran. It
path-depends on Packages/StrandAnalytics and Packages/WhoopProtocol, so it also
bitrots the first time either changes shape. #935 is the case in point: that
tool was scoring V2 against V2's own output for a week and no check could have
told anyone.

Tools/Backfill has no tests yet, so it is build-only, guarded by a grep for a
testTarget rather than assuming one.

The Swift side joins swift-packages.yml, whose paths now include Tools/** so a
package change can fail its dependents. The Python side is its own ubuntu job:
stdlib unittest, no dependencies, and `discover` rather than a hand-listed set
so a new test file cannot be silently left unrun — which is the exact failure
this closes.

app-build.yml stays disabled; app-target Swift is a separate problem.

* Fix the Backfill build the new job just caught

The tool did not compile: line 57's stage dictionary mixes Double? sources with
untyped 0 fallbacks, so Swift could only infer [String: Any] and the emit-module
step failed. Nothing built Tools/Backfill, so nobody found out.

Annotated [String: Double] — all four sources are Double? on WearableSleepSession,
and that is the minute-dict shape SleepStageTotals already decodes. No behaviour
change is possible here: the tool could not run at all before this.

This is the job earning its place on its first run.

* Fix three things the re-review found in this PR

pipefail. GitHub's default shell is `bash -e` WITHOUT pipefail, so piping the
test run into tee returned tee's status and a failing suite would have exited 0.
The count assertion I added would therefore have MASKED every real failure —
strictly worse than not adding it. Proven both ways locally: without pipefail a
red suite reports exit 0; with it, exit 1.

Tools/** was too wide. It matched appchangelog-gen.py, doc_comment_lint.py, the
anonymize shell scripts and __pycache__, so editing a Python lint script would
have queued ten macos-15 jobs. Narrowed to Tools/SleepBench/** and
Tools/Backfill/**, which is what the Swift job actually builds.

Tools/test_i18n_audit.py was still unrun — 36 tests covering the audit that
gates every PR's translations. i18n-coverage.yml runs `i18n_audit.py --ci` but
never its test file. Now covered, from inside Tools/ since the suite imports the
module by bare name.

Both steps now assert a floor on the collected count, because `discover` exits 0
having found NOTHING: a wrong working-directory would otherwise turn this job
green while running no tests, which is the exact failure the PR exists to end.

* Write the scratch capture outside the checkout

tee wrote out.txt into tools/linux-capture and Tools, neither of which is
gitignored — so rehearsing these commands locally leaves a stray file that can
be committed by accident. $RUNNER_TEMP keeps the streamed log and leaves the
working tree clean.
…st (#946)

- DevicesScreen's device-actions menu had 10 raw-English MenuItem labels
  (8 distinct) in a file that otherwise localizes via uiString; they now
  route through string resources, with de/es/fr/pt-PT/zh translations.
  Labels that already exist elsewhere in the catalog (Remove, Disconnect,
  Rename, Make active — same text, same hash suffix) reuse those exact
  translations; the four new ones follow each locale's established
  terminology for the strap (Band/correa/bracelet/bracelete/手环).
- SleepOnsetStubTest: the selectNight goldens use fixed unix timestamps,
  but the scorer's cold-start alignment bonus reads the machine timezone
  (uiTzOffsetSec) — on a UTC+7 host fragment B's 02:46 UTC midpoint falls
  outside the bonus window, flipping the main-block pick and failing the
  'session is fragment B' golden. Pin UTC in @Before/@after exactly as
  ConsistencyNightSpansTest already does. Test-only; the Swift twin only
  covers the timezone-free stub predicate, so no iOS change is needed.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
……>.jsonl (#937)

* diag(oura): raw undecoded history-notification capture → oura-raw-<id>.jsonl

Adds a RAW capture sidecar complementing the decoded MET/motion dumps: it records
the undecoded history-drain TLV notification bytes exactly as received, so after a
full connect we know precisely which records arrived. A hole in a decoded file can
now be pinned as a decode drop (present in raw, absent in decoded) vs ring-side
(absent in both), instead of being ambiguous.

Motivation: an iOS 9.2.1 field export (real Oura ring, Diagnostics capture on) carried
oura-activity.jsonl but no oura-raw.jsonl — because the raw writer had only ever lived
on a feature branch and was never merged to main. The bundle assembler already lists
oura-raw.jsonl in its attach-if-present set, so on main it silently attaches nothing.
This lands the missing writer.

- OuraRawDumpLine (WhoopStore, pure) + tests: byte-stable {schema,deviceId,utc,iso,hex}
  line, contiguous lowercase hex. 4 tests green.
- OuraRawDump (app): append-only writer, no dedup (a re-serve is still evidence the ring
  re-sent it; the offline reframer collapses by tag+ring-time).
- OuraLiveSource: rawDump property + tap on both history TLV feed points (secure and
  non-secure). Same feedsLive && !deviceId.isEmpty gate as the activity/motion dumps;
  the live-HR push is excluded so a night stays bounded.

Diagnostic only, never scored, never read back, safe to delete. Cross-platform: the
decoded Oura sidecars have Android twins, but this raw capture is Apple-only in origin
and is a diagnostic artifact (not stored data / analytics), so it is not parity-gated;
the Android twin is a fast-follow and the only parity-sensitive surface is the pure
OuraRawDumpLine encoder, which will be mirrored byte-for-byte.

Verified: WhoopStore OuraRawDumpLine 4/4 green; Strand macOS app BUILD SUCCEEDED
(app-target Swift — no default CI covers it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6

* diag(oura): rotate oura-raw.jsonl at 25MB like OuraMotionDump

ryanbr flagged in review: this sidecar stores full hex of every raw
notification with no dedup, so it grows faster than OuraActivityDump
or OuraMotionDump (a ~12MB file already hung the report review
sheet), yet was the only one of the three with no size cap.

Lift the same rotate-past-maxBytes-keep-one-".1" logic used by
OuraMotionDump so the corpus stays bounded to ~2x 25MB instead of
growing forever.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Emit a localized Android What's New title (#878)

appchangelog-gen wrote the Kotlin title as a raw literal, which the i18n gate
rejects. Because that gate audits the whole tree, one generated line red-checked
every open PR on code none of them touched. It happened on 9.2.0 and again on
9.2.1, and both times someone cleared it by hand afterwards.

The generator now emits `title = uiString(R.string.<key>)` and writes the string
itself, using the repo's existing scheme: the first six slugged words plus
sha1(title)[:8]. Verified against the 9.2.1 key that is already shipping, and a
test pins it there so a drift in either the hashing or the slugging fails rather
than mints a key nothing translates.

Translations come from an optional `whatsnew.title_locales` block in the release
notes front-matter. A locale with no entry falls back to the English title and is
named in a warning. That trade is deliberate: leaving the key out of a locale
fails the same gate this exists to prevent, and an English title on a German card
is visible and fixable where a red main after every release is neither.

Swift is untouched — SwiftUI auto-extracts, so its literal title is correct.
Items stay literals on both platforms; only the title ever failed.

Reproduced the failure first (reverting 9.2.1's title to a literal gives "FAIL 1
NEW hardcoded literal at AppChangelog.kt:42") and confirmed generated output now
passes the audit end to end.

Also switched the Tools/ CI step from a named module to discover. It ran
test_i18n_audit by name, so this PR's 14 new tests would have been added and
never run — the exact hole #943 was meant to close.

* Import yaml lazily so the tests can run without it

CI caught this: the new tests import appchangelog-gen to exercise title_key and
esc_xml, and the module did `import yaml` at load, so on a clean runner with no
PyYAML the import failed and the suite errored. It passed locally only because
PyYAML happens to be installed here.

Moved the import into frontmatter(), the only function that needs it, with the
same failure message. The pure helpers now import on any Python. Proven by
running the suite with yaml blocked from sys.meta_path: 14 tests, 0 errors.

The alternative was pip-installing PyYAML in the workflow, as fork-release.yml
does. Not needing the dependency at all is better than installing it to test
functions that never touch it.

Note the count floor did its job here — 37 collected against a floor of 45 is
what turned an import error into a red step rather than a quiet pass.

* Refresh an existing entry's title on re-run (re-review finding)

Found by doing the obvious thing: generate, edit the headline, re-run.

apply() skipped an entry that already existed for that version, while
write_title_strings ran unconditionally. So an edited headline minted a NEW key
and wrote it into all six locale files, while the entry kept referencing the OLD
one. The card showed the previous headline, the new key sat orphaned, and nothing
failed — a silent wrong answer, which is worse than the red gate this PR is
fixing.

apply() now takes the platform's rendered title line and re-applies it to an
existing entry, on both platforms so Kotlin and Swift cannot drift apart. A
re-run with an unchanged headline is a no-op on the entry and still refreshes the
constant, as before.

Three regression tests. One of them was wrong on the first attempt — it asserted
the whole file was byte-identical, when apply() legitimately rewrites the version
constant every run. It now asserts the title line, which is the actual invariant.
Adds explicit UI copy next to the manual backup export button (Settings)
and the folder auto-backup / cloud-sync picker (Backup & Sync) on both
Android and Apple, stating plainly that .noopbak is a plain ZIP anyone
with the file can open, and that pointing the auto-backup folder at a
cloud sync service uploads that readable file there too. Scoped to the
warning-copy alternative from the issue, not encryption.

Co-authored-by: tigercraft4 <>
Scheduled debug exports (strap log + optional raw 5/MG capture) accumulated
on disk indefinitely with no cleanup and no visible control:
- Android wrote noop-straplog-<stamp>.txt/.bin into cache/logs on every daily
  WorkManager run.
- iOS/macOS wrote noop-strap-log-<stamp>.txt / noop-raw-capture-<stamp>.json
  into the Files-visible Documents folder on every scheduled/"Run now" drop.

Both platforms now prune to a keep-count of generations (a log+raw pair
counts once) after every write, mirroring the existing BackupSync keep-count
pattern (pure stamp-extraction + prune-list helpers, unit tested). Default
keeps 14 generations; the Test Centre export card exposes a keep-count
picker (3/7/14/30/60) plus a manual "Clear scheduled exports" action with a
confirmation dialog, for an immediate wipe independent of the daily cap.

New user-facing strings are translated for de/es/fr/pt-PT (plus zh/zh-Hans,
it, ru to satisfy the i18n audit's non-regression ratchet).

Co-authored-by: tigercraft4 <>
meta.json's capture_check was computed over report.txt AFTER the Capture-check
section had been appended to it. A missing domain renders as

    workouts: MISSING (expected session event=)

and that line CONTAINS the killer token, so the re-scan found it and recorded
the domain as present. Every missing trace flipped, and `complete` went true
while report.txt itself said INCOMPLETE.

Found in the wild: the export attached to #950 says `"workouts": "present",
"complete": true` in meta.json, above a report.txt reading `workouts: MISSING
(expected session event=)` and `INCOMPLETE: missing workouts`. So a bug report
advertises a trace it does not carry — and the maintainer reading meta.json
believes the capture is complete when it is not.

Now computed over `reportBody`, the same pre-append text the section itself was
built from. Android-only: the Swift twin already evaluates once and uses that
one result for both the section and meta.

The regression test pins the self-poisoning directly — it asserts that scanning
body+section DOES return "present", so the test states the hazard rather than
just the fix, and will speak up if someone reroutes the argument.
…950) (#972)

Split out of #963 so it stands on its own: this half is confirmed against the
reporter's data, the sample-duration half is not.

The day's Effort passes the wearer's measured resting HR into the %HRR
denominator; the workout rescore always took StrainScorer's default 60, and so
did its calories call. For a fit wearer the real reserve is wider, every sample
sits lower in the zone table than it should, and the workout under-scores
relative to the very day it sits in.

Confirmed on #950's export: `rhr day=2026-07-30 floor=50`. A 10 bpm error in the
denominator, on a real user.

scored() gains restingHR (null = old behaviour, byte for byte), threaded into
both the strain and the calories active-threshold. The engine derives it from
the days it just scored — the FIRST Computed with a measured rhr, because `out`
is newest-first (both engines count backwards from today) — identically on both
platforms.

Display-fill and live-session paths still use the default; they are transient
renderings and need a resting source threaded through app-target code, which is
a separate change.
…973)

* Haptics: stop the app claiming things the 5/MG buzz cannot do (#926)

From vishk23's finding, whose claims I verified against the tree — all of them
hold.

On a 5/MG the buzz payload is a hardcoded 12-byte literal whose byte[11]
(overallLoop) is 0, so the caller's repeat count never reaches the wire. All
four BuzzPattern choices produce the same buzz, and the picker offered them
ungated on BOTH platforms. A control that silently does nothing is worse than
an absent one: the user concludes the strap is broken rather than the feature
unsupported.

The picker keeps all four — the choice is stored per app and DOES apply to a
WHOOP 4.0, so hiding it would lose a real setting — and now says so, following
the honesty wording SmartAlarmView/SmartAlarmScreen already use for their own
5/MG-unconfirmed case.

Deleted AppModel.buzz(pattern:loops:): zero callers, and its docstring claimed
"used by the notification-pattern picker" when the picker calls buzz(loops:).

Corrected two claims that were becoming folklore:
  - BLE_REVERSE_ENGINEERING's "7 presets" describes the STRAP's own table, read
    via GET_ALL_HAPTICS_PATTERN (80) — a command neither platform has ever sent.
    NOOP has never enumerated it. What the app exposes is four, all patternId 2.
  - MaverickHaptics.notificationBuzz now says plainly that nothing ships it and
    why it is kept: it is the only code that can build a variable-loop body, so
    it is what a future overallLoop != 0 test would need.

The payload itself is deliberately untouched. The golden frame is hardware-
confirmed at 0, nobody has tried non-zero on 0x13, and the alarm path already
ships 7 — so the field is accepted non-zero, but proving it on the buzz wants a
staged probe, not a blanket change to a proven payload.

* Gate the 5/MG note on the stored model, not live detection

Re-review catch, and it was a parity bug I introduced.

Android's `live.whoop5Detected` is reset to false on every scan and disconnect
(WhoopBleClient.kt:2416,2468), so gating on it hid the note whenever the strap
was offline — exactly when someone browses notification settings. Apple's
`isWhoop5` reads `selectedModel`, which persists, so the two platforms would
have disagreed about when to show the same sentence.

Now matches the Settings #22 gate that SettingsScreen and TestCentreScreen:87
already use: the stored model OR a live-detected 5/MG this session. The `OR`
keeps the case those two exist for — a 5/MG connected before its pref is
written.
…974)

* Hydration: import water from Apple Health and Health Connect (#949)

Drinks logged in a dedicated hydration app or by a smart bottle had to be
entered into NOOP a second time. Both platforms already read their health
store; this adds water to what they read.

Imported water is kept in its OWN metricSeries row (`hydrationImported`)
rather than folded into the hand-logged total, because the two behave
differently on write: the hand-logged row ACCUMULATES (each tap adds to it)
while the imported row is REPLACED with the health store's recomputed day
sum. That replacement is what makes re-importing idempotent without tracking
individual sample ids, and it means a drink deleted in the source app makes
the figure go down here instead of being stranded forever. Every day in the
window is written, zeros included, so a deletion actually propagates.

Keeping them apart is also what keeps the write paths correct. A quick-add
stores `current + amount`, so reading the COMBINED figure there would copy
every imported millilitre into the hand-logged row, and the next import would
add the imported water on top of that copy — one tap after an import would
silently double the day and compound from there. `log`, `remove` and `set`
all read the manual row; only display reads the sum.

Both platforms sum water ACROSS sources rather than taking the max the way
steps and calories do (#589): a phone and a watch measure the same walk, but
a smart bottle and a manual entry are two different real glasses.

- iOS: `.dietaryWater` as a cumulative day sum, excluded from NOOP's own
  samples by the existing `notNoopAuthored` predicate.
- Android: `HydrationRecord` over its own 30-day window (the aggregate window
  is 10 years, which would mean ~3,650 zero-fill rows) plus the READ_HYDRATION
  manifest declaration, without which Health Connect silently drops the
  runtime request.
- Both gate the write on the hydration toggle, which is opt-in and default
  OFF, so an import never populates a feature the user turned off.
- The detail screen names the imported share instead of letting the day total
  move on its own, and it is not editable there — it belongs to the app that
  logged it. Android's "Clear today" now clears only what NOOP owns.

Tests: 10 JVM tests over the two pure helpers (the same-day merge that a
plain `associate {}` would silently halve, and the zero-fill/idempotency
contract). i18n audit and doc-comment lint pass.

* Hydration: don't let a failed health read wipe imported water (#949)

Found re-reviewing the PR. Both platforms swallow a per-type read failure —
Android's readAll catches and logs, iOS's collect discards the error — which
is correct for the aggregate types, because a failed type simply contributes
nothing and no row is written for it. Its own comment says so: "a partial type
is simply absent, never corrupt."

That reasoning does not survive a write that REPLACES rather than adds. For
imported water, "read nothing" and "there is nothing" arrive as the same empty
result, and the zero-fill then writes 0 for every day in the window — so a
transient Health Connect hiccup or a HealthKit query error would erase up to
30 days of imported water and report a clean import.

Both readers now say whether they actually completed, and the write is gated
on it. readAll returns Boolean and collect returns Bool (@discardableResult,
so the accumulating callers are unchanged). Nothing is lost when the read does
fail: the health store is the source of truth and the next import restores it.

collect also stopped ignoring the error argument — a nil result with an error
was previously indistinguishable from an empty day range.

* Hydration: ask existing users for the newly-added health permission (#949)

Without this the whole feature is dead for everyone who already connected —
which is most users. It reads as "you have no water logged", silently, forever.

iOS: HealthKit never reports read authorization, and requestAuthorization is
only called from the connect button, so a read type added in an update stays
notDetermined for anyone who granted access before it existed and its queries
just return empty. Store a fingerprint of the read set and re-request when it
changes. Quiet by construction: HealthKit presents the sheet only for types
still undetermined, so an unchanged set shows no UI and a returning user is
asked about exactly the new one. Stored only on success, so a failed request
is retried rather than swallowed.

Android: the same gap for the same reason. The import gate is
`granted.any { ... }` by design (#150 partial grants), so an existing user goes
straight to importing and READ_HYDRATION is never requested. Same fix — compare
a stored fingerprint of PERMISSIONS and route through the request once when the
set grows. Marked before launching so declining is remembered and this never
becomes a nag.

Found by asking what an existing user would actually see, rather than what a
fresh install does.

* Hydration: only re-request health permission in the foreground (#949)

sync() is also driven by background observer wakes, where no permission sheet
can be presented. If such a call reported success without showing anything the
signature would be stored and the user would never be asked at all — the exact
silent failure the re-request exists to prevent. Gate on the app being active;
a background wake simply leaves it for the next foreground sync.

* Hydration: comment accuracy — caffeine is not read in this PR (#949)
* Hydration: only write imported water that actually changed (#949)

sync() hands setImportedHydration its whole 31-day window every run, and sync
is driven by six HKObserverQuery observers at hourly background delivery. So a
watch user was doing ~31 SQLite upserts plus a hydrationSeq bump repeatedly
through the day to store numbers that were already there — thirty of those days
are historical and immutable, and the honest answer is almost always "nothing
moved". The seq bump is the worse half: it makes the Today card re-read its
hydration total each time for no change.

Read the window's existing imported rows once and upsert only the days whose
value actually moved, bumping the seq only when something did. One ranged read
replaces the write burst; the common case now writes nothing at all.

A day with no row and a value of 0 is skipped rather than written, since an
absent row already reads as 0 — that stops a user with no water in Health from
accumulating a wall of zeros. A day that HAS a row and is now 0 is still
written: that is a deletion propagating, and it must not be confused with the
no-op case.

For the same reason the existing-row read does not fall back to []. A failed
read that looked like "no rows" would send a just-deleted day into the skip
path and leave its stale figure behind, so it bails and lets the next sync do
it properly. Found re-reviewing this change, not after.

Also corrects a comment in TodayView that still described hydrationTotal as
reading one row; it has read two since the import landed.

* Hydration: spell out the closure return type (#949)

App-target Swift that no CI compiles is a poor place to lean on multi-statement
closure inference. No behaviour change.
…one (#963)

* Strain: weight each HR sample by its own gap, not the window's first one (#950)

TRIMP inferred ONE per-sample duration from the first two timestamps and
multiplied the whole zone-weight sum by it. NOOP's HR stream is not uniformly
spaced — live Bluetooth ~1 s, banked 5/MG history ~30 s, dropouts larger — so
whichever gap happened to come first set the scale for the entire window. A
workout window and the day containing it start at different samples, picked
different factors, and produced Effort numbers that could not be compared:
the #950 ride scored 6.9 while its own day scored 14.

Each reading now carries the gap to its successor, clamped to 2 min so a
dropout hole cannot be credited to the reading before it (one zone-5 sample
could otherwise invent hours of effort). The clamp is 4x the sparsest real
cadence, so nothing genuine is truncated.

For a uniformly spaced series every gap equals the first gap, so results are
byte-identical to the old code — no existing test moves, which is the point:
this only changes windows whose spacing varies, which are exactly the broken
ones.

Both platforms in lockstep; twin test files with the same fixtures. Modelled
old-vs-new before writing it: uniform hour identical (240 TRIMP), the reported
mixed-cadence shape recovers 25.5 -> 61.9 Effort, and the 3 h dropout pair is
capped at 16 TRIMP instead of 1440.

The second defect found in #950 — ManualWorkoutRescore never passing the
wearer's resting HR, so workouts score against a hardcoded 60 — is deliberately
NOT here; it needs the resting HR threaded through IntelligenceEngine and is
its own concern.

* Import com.noop.data.HrSample in the new test

Two HrSample types exist (com.noop.data vs com.noop.protocol) and the test
named neither, so the unit-test compile failed. StrainScorer and the sibling
strain tests use the data one. Caught by Android CI; my local partial compile
never built the test file, only the source.

* Rescore workouts against the measured resting HR, not a hardcoded 60 (#950)

The second defect behind #950. The day's Effort passes the wearer's measured
resting HR into the %HRR denominator; the workout rescore always took
StrainScorer's default 60 — and so did its calories call, whose fourth argument
was a literal null. For a fit wearer the real reserve is wider, every sample
sits lower in the zone table than it should, and the workout under-scores
relative to the very day it sits in.

ManualWorkoutRescore.scored gains restingHR (null = old behaviour, byte for
byte), threaded to both the strain and the Keytel calories model. The engine
derives it from the days it just scored — the most recent Computed with a
measured rhr — identically on both platforms, so a cold start with no measured
resting keeps the old default rather than inventing one.

The display-fill and live-session paths still use the default; they are
transient renderings, and fixing them needs a resting source threaded through
app-target code on both platforms — a follow-up, not a rider on this PR.

Twin tests pin the seam: 148 bpm is chosen because the Edwards zone genuinely
flips with the reserve (67.7% of a 60-resting reserve = zone 2; 71.0% of a
45-resting one = zone 3) — the first fixture, 145 bpm, sat in zone 2 under
BOTH restings and would have passed vacuously as an equality. null-resting is
pinned byte-identical, and the kcal movement proves the calories model sees
the same value.

* Fix the kcal test: resting HR gates the active threshold, not the burn rate

Both platforms failed the same new assertion with identical kcal under both
restings, and they were right to: in estimateBoutCalories the resting HR only
sets activeThreshold (resting + 30% HRR). An all-148bpm window is active under
either resting, so kcal cannot move. The test now mixes in a 95 bpm warm-up,
which is active under a 45-resting threshold (88.5) and resting-rate under a
60-resting one (99) — the mechanism the model actually has. The scored() doc
comments overstated the same thing and are corrected.

* Say why sampleDurationMinutes is kept, honestly

The doc claimed it was kept for callers wanting one representative figure;
no such caller exists. Its only consumer is the uniform-identity test, which
uses it to compare the new accumulation against the shipped old formula
rather than a reimplementation. Said so, with a deletion note.

* Take the FRESHEST measured resting HR, not the oldest

Re-review catch, and it was my own comment that gave it away: I wrote "the most
recent scored day" and then used lastOrNull / last(where:).

`out` is NEWEST-FIRST. Both engines score with `for offset in 0..<maxDays` and
`dayStart = nowLocalMidnight - offset * 86_400`, counting backwards from today,
so out[0] is today and the tail is the oldest day in the window. Taking the last
match scored today's workout against a resting HR up to maxDays old.

Wrong identically on both platforms, so parity held while the intent did not —
which is the failure mode a byte-parity contract cannot catch on its own.

No test added deliberately: the ordering is a property of a day loop 300 lines
away, so a unit test over a projection would pin my helper rather than the risk
(someone reversing that loop). The comment names the loop and its direction so
the next reader can check it without re-deriving it.

* Split the resting-HR fix out into its own PR

That half is confirmed against #950's export (rhr floor=50); this one is a real
defect whose impact on the reporter I can no longer claim — his strap banks at
0.997 records/second, so the first-gap guess was probably right for his ride.
Two independent analytics changes with different evidence and different risk
should not ride together. Moved to #972.
…#984) (#989)

Two symptoms in the report, one shared cause plus a second bug underneath.

The sheet could not scroll. ModalBottomSheet hands its content a plain
ColumnScope and does not scroll it, and the inbox root was a bare Column — so
everything past the bottom of the screen was clipped and unreachable, the
Clear all / Mark all read footer included.

That is also why a tapped row looked deleted. Tapping marks the row read, which
moves it out of "New" and down into "Earlier" — rendered below, i.e. into the
part that could not be reached. The row was never removed; it relocated
somewhere the user could not get to.

Underneath that, the What's New row was posted with no deepLink at all while
its own message reads "tap to read what's new". handleTap bails on a null link,
so the tap could never have opened anything: every release since the inbox
shipped has posted an entry that cannot be read. The destination already
existed — WhatsNewSheet, the same one Settings > About opens — it was simply
never wired to the row.

Fixes all three: verticalScroll on the root, a deepLink on the seeded row, and
a "whatsNew" case in AppRoot's onDeepLink that presents the changelog. It is
handled beside the route table rather than in it because What's New is a sheet,
not a nav destination.

The tap rule moves into UpdateStore.deepLinkTarget so it can be unit-tested —
the store's own persistence is SharedPreferences + org.json and is not reachable
from a plain JVM test, the same constraint NapStoreTest documents. It also falls
back by kind, which matters: What's New rows already sitting in people's inboxes
carry no deepLink and would otherwise stay inert until a later release replaced
them. That includes the reporter's.

iOS is deliberately not matched. Its inbox is a ScrollView, so the scroll bug
does not exist there. It has the same missing deepLink, but per #980 the inbox
is only mounted in classic TodayView and not in Liquid, which is the default —
so there is no reachable surface to fix until that lands.

Tests: 6 JVM tests over the tap rule, including the already-posted-row case.
…#992)

* Workouts: score the SAVED workout with the measured resting HR (#983)

Replaces #990, cut back after two rounds of self-review each found a defect
that PR had introduced.

The bug is unchanged: the workout paths called StrainScorer.strain without a
restingHR, so they silently took the hardcoded default of 60. %HRR is
(bpm - resting) / (max - resting), so the default moves every zone boundary —
at 136 bpm with maxHR 190 it is the difference between zone 1 and zone 2.
Today's Effort and the manual rescore (#972) already thread the measured value,
so a stored workout disagreed with its own re-score.

This version fixes ONLY the saved workout, on both platforms: one read at save
time, no new state, no lifecycle.

#990 also threaded it into the live per-sample readout, which needs the value
latched for the session — and that latch is where both of its bugs came from.
First it re-read the denominator on every sample, so a mid-session sync could
re-score the whole window and visibly step the number. Then, with the latch in
place, endWorkout cleared it before the save, so the STORED number could differ
from the one shown on screen. Neither was inherited; both were introduced by
that PR.

The live readout is a transient running estimate and stays on the default here.
That means it can now differ from the saved figure at the end of a session —
accepted deliberately, because the stored number is what history compares and
what the rescore path already agrees with, and because a per-sample denominator
has now been got wrong twice.

Tests carried over from #990 unchanged: a parity pair pinning that resting HR
moves the zone a sample lands in, and that it carries through to the score.

* Workouts: thread the same resting HR into the saved workout's calories (#983)

Re-review catch. The fix was incomplete: two lines below the strain call,
estimateBoutCalories was still being passed null/nil for resting HR — the same
defect, in the same function, with the correct value already computed and in
scope one line above.

It matters for the same reason: the calories model's active-vs-resting
threshold sits at resting + 30% HRR, so the hardcoded default silently shifts
what counts as active. And #972 already threads it in the rescore path, so
leaving it null here meant a saved workout's kcal disagreed with its own
re-score exactly as its Effort did — this PR would have fixed one of the two
and left the other, side by side.

One argument per platform.
… the gate costs (#982) (#993)

ActivityBin.activity was documented as "the motion volume in that bin (e.g.
StepsEstimateEngine.dayMotionIntensity per hour)". It has never been fed that.
The only production caller pools per-hour MEAN HEART RATE in bpm
(AppModel.swift, sums[hour] += b.bpm), which is the right choice on this
hardware — WHOOP 4.0 motion is too sparse to stage sleep at all (#345) — but
the doc has been telling readers the opposite since it was written.

That matters because minRelativeAmplitude gates on amplitude / |mesor|, and HR
arrives with a ~45-75 bpm DC offset a motion volume does not have. The
effective bar is therefore an absolute 0.10 x mesor bpm.

NOT re-tuned to an absolute floor, deliberately. #982 raised this as the gate
penalising the fittest wearers, and that direction does not hold: because the
bar scales WITH the mesor, a low-resting wearer faces a LOWER absolute
requirement, not a higher one. The same 5 bpm swing is arrhythmic at a 65 bpm
mesor and readable at 45. The concern only applies to someone whose amplitude
is disproportionately small for their mesor, which is a single observation, and
nobody is currently silenced by the gate. Shipping an invented bpm constant into
everyone's analytics on that basis is exactly what the project's own rule about
unvalidated tuning warns against.

So: correct the record, and make the trade executable instead of arguable. A
parity test pair pins what the gate costs at 65 and at 45 bpm, including the
pair that shows the direction, so whoever does have the data to re-tune it
starts from a fact.

Both platforms; docs and tests only, no behaviour change.
)

* RawOutbox: say that Policy 1 is dormant, and why it is kept (#981)

pruneRaw documents two policies as though both operate. Policy 1 cannot fire
in a shipping build: syncedAt is only ever set by markRawBatchSynced, which has
no production caller — and cannot get one, because marking a batch "synced"
needs somewhere to sync it to, and NOOP has no server, account or cloud sync by
design. Both are inherited from the upstream my-whoop store, which had a sync
lane.

Documented rather than deleted. The code is correct and tested, and an
on-device EXPORT lane would be a legitimate server-free way to drain the
outbox — at which point marking exported batches synced and letting Policy 1
reap them is the right shape. Deleting it now would remove the natural hook and
buy nothing: nothing is unbounded, because Policy 2's size cap is the real
growth bound and is already what the 19 GB note refers to.

Docs only, no behaviour change.

* RawOutbox: separate the summary line from the dormancy note (#981)

Without a blank /// between them the note is absorbed into the one-line summary
Xcode shows in Quick Help and completion, which is the opposite of the intent.
Matches the paragraph separation pruneRaw's own doc already uses.
)

* fix(import): select Oura's main sleep session for the daily rollup

The per-day fold in OuraApiParser.parseSleep used first-write-wins:
whichever session the API listed first for a `day` claimed every daily
field, so a short nap/fragment could beat the real night. Audited across
a real 24-month dataset: 36 days where totalSleepMin was 0.5-25 min while
the true main sleep (250-780 min) existed adjacent, including two
impossible daily restingHr values (92 bpm from a 32-min wake-dominated
micro-session where the real night was 277 min at 57 bpm; 90 bpm amid
41-48 bpm neighbors) plus five near-misses at 89-100 bpm. A sudden 90+ bpm
daily resting-HR spike is the visible symptom of this bug.

Select the day's MAIN session instead: a `long_sleep` session always
outranks any other type; among same-rank candidates, the longer
total_sleep_duration wins. The winner supplies every rollup field as a
unit, so fields are never mixed across two sessions on the same day.

dailyMetric is upserted keyed by (deviceId, day) (WhoopStore.upsertDailyMetrics),
so re-running Oura sync heals every already-imported day in place - no
migration needed, the same way the earlier RHR-score corruption healed.

* fix(import): skip Oura 'deleted' sleep periods in the API parser

The CSV-side OuraExportParser already skips type == "deleted" rows (a
night the user removed in the Oura app, #862); the API parser had no
type check at all, so a deleted period still became a session and --
with its full bedtime span -- could even win the day's rollup over the
real night. Skip it before it becomes a period, same rule both parsers.

* fix(import): extend main-session day-rollup selection to the file-export lane (Swift + Kotlin)

Closes the file-lane exposure flagged in review: the account-export importer folded a day's
sleep sessions per-FIELD first-write-wins (OuraExportParser.swift JSON + CSV paths, and the
Kotlin WearableExportImporter.parseOura/parseOuraCsv twins), so a nap/fragment listed before
the real night could mix its totalSleepMin with the main session's restingHr. Applies the same
long_sleep-outranks-any-other-type, then longer-total_sleep_duration rule as OuraApiParser's
dayWinner, on both the JSON and the real per-category CSV sleep rows, on both platforms — the
winner now supplies every rollup field as a unit, never mixed across sessions.

StrandImport: 207/207 (3 new OuraExportParserTests + 1 new WearableExportImporterTests fixture).
Kotlin: transcribed carefully (no Android toolchain available to run testFullDebugUnitTest here);
4 new tests added to WearableExportImporterTest mirroring the Swift fixtures.
… with a mandatory read-back (#174) (#932)

* Make the R22 deep-data unlock actually reversible (#174)

The Settings copy has said since #174 that the R22 unlock is "reversible".
That was true about the hardware and false about the app: NOOP shipped an
enable button and nothing that writes the flags back, so the only ways out
were the official WHOOP app or a factory reset. Turning the switch off only
gated future sends — it left all sixteen flags set on the strap.

This adds the undo, and verifies it by reading the strap rather than by
believing an ack.

What it writes

`Whoop5Config.disableR22Sequence` is the same sixteen keys in the same order
as the enable sequence, each carrying `featureFlagOffValue` = ASCII '0'
(0x30). Master flag first, so a run interrupted by a disconnect leaves the
strap nearer off than it started.

On the off value, precisely: 0x30 is CONFIRMED as this firmware's off value
in the device-config namespace — `setBroadcastHr(false)` has written it to
`whoop_live_hr_in_adv_ind_pkt` through SET_DEVICE_CONFIG_VALUE(119) since
independently reads '0' on an MG whose ECG is idle. It is INFERRED for
SET_FF_VALUE(120) by shared convention: identical body layout, identical
ASCII-digit value convention, same `[0x01] +` prefix, differing only in
opcode and body length. Writes through 120 demonstrably change stored state —
the enable sequence moved `enable_sig12` from '2' to '1', confirmed by a
GET_FF_VALUE(128) read either side.

What is NOT claimed: no feature flag has ever been observed holding '0'; the
only values ever read back in that namespace are '1' and '2', and those are a
round-trip of NOOP's own writes. `disable_pip_r26_packets` is written '2'
despite being named `disable_*`, and `enable_sig12` was corrected '2'→'1'
from a real capture, so the official app picks between '1' and '2' per flag
rather than using one canonical true. Tri-state semantics stay unestablished.

So the run tests the byte instead of asserting it

`R22DisableReport` is staged. It writes '0' to ONE flag, reads it back, and
declines to touch the other fifteen unless the strap stopped reporting the
old value. The probe key is `enable_sig12` — the only key with a hardware
demonstration that a write to it moves stored state, so a failure there is
attributable to the VALUE rather than to the key or the verb.

Read-back separates three outcomes that a boolean "did it work" would blur:
value '0' (cleared), FAILURE (no stored value at all — nothing predicts it),
or the old value (write refused). Enumeration cannot substitute: '0' is a
stored value, so a cleared key stays listed, and the 117/118 walk carries no
value field.

Every write ack is recorded and not believed. SELECT_WRIST returns SUCCESS
for a no-op and FAILURE for a real mutation on this firmware, so only the
value a 128 read returns is reported as state (#907/#891 discipline).

Also in here

- `FeatureFlagWriteGate` tightens the send allowlist for opcode 120. It was
  opcode-only — any feature-flag key with any value travelled while the
  deep-data opt-in happened to be on. It is now key- and value-aware: the
  sixteen R22 keys, each only for its own enable value or the off value. Same
  weakness #907 closed on opcode 119.
- The toggle gets an `.onChange` that offers the disable instead of silently
  writing nothing. Broadcast HR has had one since #181.
- The card said "15" while the sequence carried 16, so it declared success a
  flag early. Threshold and number now come from the sequence.
- A disable run's acks no longer tick the enable counter upward.
- An interrupted run is rendered, not dropped: it has already written, so the
  user is told how far it got and which keys are still set.

Verification

Swift `swift test` in Packages/WhoopProtocol: 454 tests, 0 failures (24 new).
Android `testFullDebugUnitTest`: 3226 tests, 0 failures across 392 classes,
counted from the JUnit XML rather than the wrapper exit code (25 new).
`assembleFullDebug` clean. macOS `Strand` and iOS `NOOPiOS` both build.
`Tools/i18n_audit.py --ci` passes; new copy is translated in every focus
locale and in it/ru/zh-Hans/zh-Hant so the extra-locale ratchet does not move.

NOT verified on hardware: whether the strap accepts '0' for a feature flag,
and whether clearing the flags makes the deep records stop. The first is what
the probe stage measures on first run; the second needs a wear-and-sync
afterwards and is called out in the UI and the report rather than claimed.

* Fix the toggle-off path: gate the R22 undo on the run, not the pref (#174)

The disable sequence worked only from the explicit "Turn deep data back off"
button. The path a user actually reaches for — flipping the Settings switch OFF
and confirming "Clear flags on strap" — was dead, for two independent reasons,
and reported nothing either way.

1. `deepDataEnabled` is @AppStorage bound straight to the switch, so flipping it
   off writes the pref false BEFORE `.onChange` raises the confirmation dialog.
   By the time the user confirmed, `disableWhoop5DeepData()` returned at its own
   `guard PuffinExperiment.deepDataEnabled`, and the send allowlist would have
   refused all sixteen writes anyway. The explicit button worked only because it
   renders while the pref is still true.

2. The disable report rendered INSIDE `if deepDataEnabled`, so even a working run
   would have hidden its progress line and its whole read-back table for exactly
   the run a user is most likely to start.

`FeatureFlagWriteGate.admitsSend` is split into two predicates with different
gates, following the shape the allowlist already used for the 128 read-back
(`isReadBackOpcode(...) && r22DisableRun != nil`):

  - `admitsEnableWrite` — opcode 120, an R22 key, that key's own ENABLE value,
    while the deep-data opt-in is on.
  - `admitsDisableWrite` — opcode 120, an R22 key, `featureFlagOffValue` only,
    while a disable run is in flight.

Gating the undo on the run rather than the pref is also strictly narrower in the
other direction: with the opt-in merely left on, no off value reaches the wire
unless a run is genuinely walking its plan. Splitting them makes the invariant
exact rather than approximate — an off value on the wire means a disable run is
in flight — where one predicate admitting either value under either gate could
not say that. The now-redundant `deepDataEnabled` guard in
`disableWhoop5DeepData()` is deleted on both platforms.

Also fail closed in `nextStep()`. A key list without the probe key planned a
blanket sixteen-write and set `probePassed = true`: fail-open on the one write
path whose entire safety argument is the probe, writing an inferred value to
every persistent flag without testing it on one, and recording a passed gate for
a probe that never ran. It now refuses, marks every key skipped, and reports the
new `probeUnavailable` verdict. Unreachable from either shipped call site, but
not a defensible default whatever the current call sites do.

Swift `swift test --filter R22DisableTests`: 31 tests, 0 failures.
Kotlin `testFullDebugUnitTest --tests com.noop.protocol.R22DisableTest`: 32
tests, 0 failures (JUnit XML). Both suites gain a direct regression test that
the off value is admitted with the opt-in OFF while a run is in flight, and that
the two directions are disjoint on value.
…991)

* Tools/SleepPSG: score the shipped sleep stager against PSG truth, reproducibly

Every reference NOOP's sleep work can currently reach is either the recipe's own output handed back to it
or another vendor's black box. Tools/SleepBench says so in its own header. A harness that scored
SleepStagerV2 against real polysomnography existed once, produced the single most decisive result this
project has had — that REM detection measures physiology and not the clock — and then vanished, because it
lived in a session scratchpad. The next sleep investigation had to run on one instrument instead of two.

This is that harness, in the repository, with a CI job.

WHAT IT IS. `sleeppsg --dataset <path>` replays SleepStagerV2 over PhysioNet sleep-accel (Walch, Huang,
Forger & Goldstein, SLEEP 42(12) zsz180, 2019) — 31 subjects of wrist accelerometer and heart rate recorded
alongside human-scored PSG hypnograms — and scores it epoch-for-epoch. Read-only, dataset path always an
argument, dataset never committed. README.md carries the download step and the attribution both the Open
Data Commons licence and the MIT companion code require.

THE PORT, AND WHY IT CANNOT DRIFT. Baseline numbers come from StrandAnalytics.SleepStagerV2.stageSession
itself — the shipped file, no reimplementation. Variant numbers cannot: the recipe holds its constants as
`static let`s and keeps Epoch/features() internal, so asking "what would this score with one transition row
changed" needs the recipe restated with knobs. RecipePort.swift does that, and PortValidation measures the
equivalence rather than asserting it: 48 randomised nights (off-grid starts, channel dropout, arousals,
motion bursts, unsorted input, R-R present so the RSA term is exercised — the dataset cannot exercise it)
plus 7 degenerate cases (all-HR-missing, zero-variance HR, 1- and 2-epoch nights, saturated motion gate,
no coverage at all, motion absent). Every epoch label from both paths must agree. It runs in `swift test`,
needs no dataset, and names the night and epoch when it fails. A failure there is never flakiness — it means
RecipeConfig.shipped and SleepStagerV2.swift have diverged.

WHAT IT REPORTS, AND WHY IN THAT SHAPE.

  - Stage fractions are a first-class result, not an appendix. Kappa does not constrain them: #348 improved
    kappa on all three of its benchmarks and #437 reverted it 48 h later for re-scoring a healthy night from
    6 % to 23 % awake. A harness reporting only kappa could not have caught that, and ScoringTests pins a
    case where kappa rises while the wake fraction blows out, so the point survives as an assertion.
  - Splits are leave-one-SUBJECT-out, never epoch-level. Consecutive 30 s epochs from one night are nearly
    identical; a random epoch split lets any model memorise the subject. AblationTests fails if a held-out
    subject's rows can reach its own training fold.
  - Night length is a stratification variable, not a footnote. A REM-fraction-vs-duration coupling measured
    +0.579 on one wearer's nights; the report gives terciles and the same correlation for PSG TRUTH beside
    the prediction, because a coupling truth also carries is physiology and one only the prediction carries
    is an artefact.

Wired into the `tools:` matrix that #943 added, alongside SleepBench and Backfill, so the equivalence check
actually runs.

Limits are stated in the README rather than buried: sleep-accel carries no beat-to-beat intervals, so the
RSA respiration term is inert here (nil on every epoch, contributing exactly 0.0) and results are a lower
bound on the same recipe with R-R present; the accelerometer is an Apple Watch's, not a WHOOP gravity
decode, which tests rather than violates the recipe's night-relative motion thresholds; only staging is
exercised, not session detection; and n = 31.

* SleepPSG: print a self-check against the old harness's figures, and a variant that falsifies the easy answer

The rebuilt harness has to be able to say whether it is the SAME instrument as the one it replaces, so
section 3 now prints the previous harness's reported figures beside its own. Nothing is tuned to them; the
point is to expose disagreement, not to close it.

Every truth-side figure reproduces exactly — 31 subjects, 26 773 PSG-scored epochs, deep 14.76 % of night,
truth median first-REM latency 88.5 min. Predicted deep lands at 19.24 % against a reported 19.25 %, and
four-class kappa at 0.356 against 0.349.

Three independent numbers from PR #348's OWN Walch column also reproduce, which is the stronger check
because they were produced by a different (Python) implementation of the same recipe: that PR reported the
pre-#348 incumbent at kappa 0.356 (measured here: 0.356), mean deep 19.2 % (19.24 %) and wake recall 33 %
(30.4 %), and the full #348 build at deep 9.9 % (10.63 %).

The REM prediction does not reproduce: 27.0 % of night against a reported 20.8 %, REM F1 0.569 against
0.515, median first-REM 91.5 min against 142.0.

There was an obvious explanation and it is WRONG. #930 replaced a `c < 0.12 ? 3.0 : 0` step in the session
-fraction domain with a graded penalty in minutes from sleep onset; the old figures were reported while
#930 was the candidate, so the incumbent they describe predates it, and the graded guard is the weaker one
averaged over the epochs it covers — which would produce exactly this "more REM, arriving earlier" pattern.
`Variants.preNine30Guard` runs that guard. It moves nothing: kappa 0.356 -> 0.356, REM F1 0.569 -> 0.569,
REM 27.02 % -> 26.97 %. On 8-hour lab nights the guard barely binds in either form. The hypothesis is
falsified and is kept in the tree because the variant is what falsified it.

Two constraints on whatever the residual turns out to be, both readable off the same table: it is not the
session window (`clock` drives the deep prior and the REM ramp alike, and predicted deep reproduces to
0.01 pp), and it is not the latency guard. One coincidence recorded as an observation, not a conclusion:
the reported "REM F1 0.515" is exactly this harness's measured REM PRECISION.

Also here:
  - wake SENSITIVITY joins the variant table, because that is the quantity #987 was landed on against the
    strap's band state, and a claim you cannot read off the table is a claim you cannot check;
  - the variant CSV carries precision/recall/F1/fraction/bias for all four stages instead of three F1s;
  - the ablation fits IRLS over one flat design matrix per model instead of refiltering row structs and
    reallocating a feature array per row per iteration — same arithmetic, ~93x fewer allocations, which is
    the difference between a minute and an afternoon in an unoptimised build;
  - the dataset reader clips to the recipe's own read window during the read. Each subject's files span
    DAYS around the lab night (the earliest rows sit four days before lights-out) and stageSession clips to
    [start-330, end+390) before doing anything, so this drops only rows the recipe would discard itself;
  - second bucketing is a floor, not a round, so a sample at t = 0.9 s stays in second 0 rather than
    shifting the motion series against the heart-rate series by up to a second.

33 tests, 0 failures. Full report runs in 28 s on the 31-subject cohort.

* SleepPSG: make the #987 comparison work from either side of the PR

`RecipeConfig.shipped` has to describe `SleepStagerV2` as compiled on the branch it sits on — that is the
whole basis of the port-validation check. The fork and upstream currently disagree about one row of the
transition matrix: this repository's main carries PR #987 (wake→deep and wake→rem zeroed), upstream's does
not until the PR merges. Hard-coding the variant as "apply #987" is therefore wrong on one of the two
branches, and would have shipped a benchmark whose headline comparison is a no-op against itself.

`Variants.pr987` now reads the shipped awake row and offers the OTHER one, naming itself accordingly
("#987 awake-transition row" upstream, "#987 REVERTED (pre-#987 row)" here). Same comparison, same pair of
rows; only the sign of the delta and the label flip. The test asserts both directions rather than assuming
one, so it passes wherever it is compiled, and it fails if the shipped row is neither side of #987 — which
would mean `RecipeConfig.shipped` had gone stale for a different reason.

`#348 entire` pins the awake row to the literal instead of to `pr987`, since #348's own row is the one #987
later restored and that is a fixed historical fact, not a branch-relative one.

33 tests, 0 failures.

* SleepPSG: say which denominator every stage fraction uses

The tool has always printed both conventions, labelled, one table above the other. The README quoted
one of them in the self-check and the other in the variant section without ever saying so, which read
as two different answers to the same question:

    section 3, pooled over all scored epochs      deep truth 13.76 %   predicted 18.94 %   bias +5.18 pp
    section 3, mean of per-subject percentages    deep truth 14.76 %   predicted 19.24 %   bias +4.48 pp

Both are right and both are measured on the same 26 773 epochs. The denominator WITHIN a night is
identical either way — all scored epochs, wake included. What differs is the weighting ACROSS the 31
subjects: pooled gives a 490-minute night more weight than a 208-minute one, the per-subject mean gives
every subject one vote. For deep that is a ~1 pp gap, which is large enough to look like a contradiction
and small enough to go unnoticed.

The self-check is stated in the per-subject mean because that is the convention the previous harness
reported in, and reproducing it on any other convention would not be reproducing it. Section 6's variant
table and every `bias pp` column in it are pooled, which is why the incumbent's deep bias reads +5.18 pp
there. Neither changes; both are now named at the point of use.

Adds a convention table to the README, labels every percentage in the self-check section, and records
the same thing on `Variants.preNine30Guard`, whose docstring quotes the per-subject figures. Also
un-rounds "REM 27.0 %" to 26.96 % — 27.0 was ambiguous between the two conventions (26.96 per-subject,
27.02 pooled) precisely where the distinction matters.

Documentation only: no measurement, no threshold and no reported number changes. Verified against the
full 31-subject cohort locally; `swift test` in Tools/SleepPSG 33 tests, 0 failures.
… checked (#112) (#995)

* Diagnostics: a nightly mean for the 5/MG SpO2 candidate, so it can be checked (#112)

The SpO2 candidate byte (@82) is decoded and stored but deliberately never
scored, because two straps disagree about it: on one independent 8-night check
it tracked the WHOOP app almost exactly, and on the two nights from the strap
it was found on it moved the opposite way. It cannot be promoted until a third
device breaks the tie.

Nothing was blocking that except how hard it is to read. The only way to see
the candidate today is to scroll the Deep Timeline chart and eyeball it, which
is a poor instrument to hand a volunteer and produces a figure nobody else can
check. So the ask on #112 currently amounts to "note the rough nightly level",
which is not evidence.

This adds the nightly gated mean over the detected in-bed sessions, paired with
the sample count it rests on. That turns the comparison into one number against
one number: this, and what the WHOOP app reports for the same night.

Gated to 70...100 — the SAME in-band window the decoder applies when it emits
spo2_candidate_82. Sub-70 nonzero values are diagnostic codes and bit-7 values
are saturation sentinels; averaging those in would produce a number that is not
a percentage of anything, which is the most damaging thing this could do
because the result would still look plausible. Pinned in both directions.

The sample count travels with the mean deliberately: a mean over 3 readings and
a mean over 3000 are not the same evidence, and a correlation built on the
first would be worthless.

Diagnostic only. Nothing scores it, it never writes spo2Pct, and it is not a
blood-oxygen reading — it is the raw candidate averaged, surfaced so it can be
checked against a real one. Byte-parity twins with 8 tests each.

Does nothing for a WHOOP 4.0, whose records carry raw red/IR ADC and no
candidate at all.

* Move the Kotlin helper below nightlySpo2RawMeans, not between its doc and itself

Inserting before the anchor put my function between the existing KDoc and the
function it documents, orphaning that doc — doc_comment_lint caught it. The
Swift side inserted before a MARK and was never affected.

* Surface the SpO2 candidate mean in the diagnostics both platforms already export (#112)

Fixes the defect I found re-reviewing this: the helper had no production caller,
which is precisely the pattern #981 and #982 are about. A tested pure function
nobody calls is next year's audit entry.

Wired into the per-night funnel diagnostics instead of threading the aux stream
through analyzeDay — DebugDataDiagnostics (Swift) and AndroidDiagnostics
(Kotlin) already build the detected session, already hold the store, and are
already where skinTempFunnel and remFunnelDiagnostic surface. That is the seam
this belongs on, and it needs no change to the analytics entry point.

The line names itself UNVERIFIED and points at #103, and says explicitly that a
WHOOP 4.0 does not carry the candidate at all rather than printing nothing — a
4.0 owner reading this should learn why it is absent, which is the confusion
#112 was opened about.

Also makes the Swift function public. It was internal, so the app-target call
would not have compiled — and nothing in CI compiles app-target Swift, so that
would have shipped as a broken build rather than a failed check. Caught by
comparing its visibility against skinTempFunnel, which the same file already
calls.

* Don't let a failed aux read print as "no readings" (#112)

Re-review nit, and it is the same defect I fixed three times today in #949: a
`try?` collapsed to `?? []` makes a FAILED read indistinguishable from a night
that genuinely carried no candidate. The line would then have said "no in-band
readings inside this night's span" because the query threw.

Worse here than in the water and caffeine cases, because this is a DIAGNOSTIC.
A confident false statement in the one surface whose entire job is to report
what is actually present is the failure mode that surface exists to prevent —
and someone reading it would reasonably conclude their strap records nothing.

Now says which it is. Also pins the aux read window to an explicit shared
constant on both platforms instead of Swift taking the store default (200_000)
while Kotlin passed Int.MAX_VALUE, so the two visibly read the same span.

* Bring the Kotlin SpO2-candidate line to parity with the Swift twin (#112)

Fixing the Swift read-failure path in the previous commit opened a gap I had
not closed on the other side. Three differences, all mine:

1. Kotlin had NO read-failure branch. A throw from v18AuxSamples propagated to
   the enclosing runCatching, which aborts the WHOLE funnels block and prints a
   generic "(funnels unavailable)" — so the same failure produced a specific,
   honest line on iOS and a vanished section on Android. The aux read now has
   its own runCatching and the same four outcomes in the same order.

2. "SpO2" vs "SpO₂" — the Swift twin uses the subscript, matching how the rest
   of the app writes it. Someone grepping diagnostics across platforms for one
   spelling would have found only half the reports.

3. An ASCII hyphen where Swift has an em dash.

The four output strings are now byte-identical modulo the interpolation syntax
the two languages force. Diagnostics are the surface people paste into issues,
so two platforms describing the same state in different words costs real time
when a report has to be read against the code.
* Caffeine: import intakes from Apple Health (#949)

The caffeine window (#526) already stores exactly what a HealthKit caffeine
sample carries — a timestamp and an optional mg — so a coffee logged in
another app can feed the decay estimate instead of being typed in twice.

A SAMPLE query, not the day-bucketed one water uses: the estimate decays each
intake from its own time, so a day total would collapse a 7am coffee and a 9pm
one into a number that answers no question the card asks.

Dedup is harder than water's. Water is one day total that can simply be
replaced; caffeine is a list of individual events. So an imported intake
carries its HealthKit sample UUID in a new optional `externalId`, and a sync
replaces the imported SUBSET wholesale, leaving hand-logged intakes alone.
That keeps a re-import idempotent instead of logging a second copy of every
coffee, and makes an intake deleted in the source app disappear here too.
The field is optional so existing stored JSON still decodes, with every
existing intake correctly reading back as hand-logged.

Imported intakes are not editable here: remove/clearAll skip them and the UI
offers no control, because the next sync would re-read the same window and
bring the entry straight back. Remove it where it was logged.

The store gains a `shared` instance — the card used to own its own, which was
fine while every write came from the card, but two instances over one
UserDefaults key would leave the card publishing a stale array while the
imported intakes sat invisible on disk. Follows the LiquidPowerMonitor
pattern already used for @mainactor ObservableObject singletons.

ANDROID IS DELIBERATELY NOT MATCHED. Health Connect has no caffeine-only
scope: caffeine is a field on NutritionRecord behind READ_NUTRITION, which
grants the user's entire food log. Apple exposes .dietaryCaffeine as its own
narrow type. Asking every Android user for their whole nutrition history to
read one number is not a trade this app should make quietly, so the manifest
is untouched and the gap is documented instead.

Tests: 15 in StrandTests covering idempotency, the vanished-sample case, the
backward-compatible decode, and the not-editable rules. No CI runs them —
app-build.yml is disabled and StrandTests executes nowhere.

* Caffeine: stable ids for imported intakes, and don't wipe them on a read error

Two defects found re-reviewing the PR.

The intake id was freshly minted on every sync. CaffeineIntake.id defaults to
UUID(), and the bridge rebuilds every intake from the HealthKit samples each
time, so the same coffee got a new identity on every pass. That defeated
replaceImported's "nothing changed" guard (a pointless JSON rewrite and
republish on every sync) and gave ForEach an entirely new set of rows to
rebuild each time. The sample's own uuid is stable, so use it.

My own test could not have caught this: it reused one CaffeineIntake value for
both imports, so the ids matched trivially. It now builds a fresh intake per
import, the way the bridge does, plus an explicit test that two intakes built
from one sample are equal.

Second, collectCaffeine discarded the query error, so a failed read returned
an empty array and replaceImported deleted every imported intake — the same
class of bug as the hydration one in the previous commit, and worse here
because the caller's whole job is to replace the set. It now returns nil on
failure, and only a genuine empty result clears anything.

* Caffeine: the permission note covers caffeine too, now that it is read (#949)

* Treat a failed sample cast as a failed read, not an empty night (#949)

The last instance of the pattern that has bitten four times today: the cast
used `?? []`, so an unexpected sample type would read as "no caffeine" — and
because the caller replaces the imported set wholesale, that would silently
delete every imported intake. Vanishingly unlikely on a query typed to a
quantity type, but it is the same failure shape as the error check sitting
beside it, so it now shares that check.
… survives a de-contaminated benchmark (#987)

* fix(sleep): forbid wake -> deep/REM transitions — the one part of #348 that survives measurement

PR #348 re-tuned seven things about SleepStagerV2 on DREAMT; PR #437 reverted all of them 48 h later
because one healthy night went from 6 % to 23 % awake ("kappa doesn't guard stage-fraction calibration").
Re-litigating that revert component-by-component, on a reference set de-contaminated with the
`--exclude` flag added in af44563, six of the seven components measure neutral-to-negative and stay
reverted. One does not: the AWAKE transition row.

Restored from #348, on both platforms:
    "awake": deep 0.01 -> 0.0, rem 0.02 -> 0.0, light 0.27 -> 0.10, awake 0.70 -> 0.90

This is a physiological claim, not a fitted constant: sleep onset descends through N1/N2, so wake never
transitions straight into N3 or REM, and the freed mass makes a WASO episode span several epochs rather
than flicker back to sleep after one. The `ln(max(v, 1e-9))` viterbi floor that makes a zeroed entry
safe already landed with #348 and survived #437, so no other change is needed to support it.

MEASUREMENT (Tools/SleepBench, one wearer's 36 recorded nights).

The only reference here that V2 cannot contaminate is the strap's own band `sleep_state` — the v18 @81
nibble, WHOOP's verdict rather than a re-derivation of NOOP's. Over 21 banded nights / 15 554 epochs:

    sleep/wake kappa   0.105 -> 0.118        wake sensitivity  16.0 % -> 17.6 %
    accuracy           79.1 % -> 79.2 %

The #437 guard holds — this is the number that must not blow out, and does not:

    healthy-stratum wake fraction (n = 20)   9.43 % -> 9.96 %      (#348 entire: 32.66 %)
    healthy deep                            22.09 % -> 22.11 %
    first-REM latency MAE                    53.9  -> 41.6 min

Why the other six stay reverted, each measured alone against the same band reference / wake guard:

    base priors (deep .15, awake .34)   healthy wake 9.43 % -> 17.76 %   the #437 blow-out, confirmed
    motion gate (jerk 75/35, boost 4)   healthy wake 9.43 % -> 15.92 %   a SECOND wake channel, not free
    emission coefficients               band kappa   0.105 -> 0.094
    deep gate .25 -> .40                healthy deep 22.09 % -> 25.47 %  worsens an existing over-call
    awake dead-zone 0.30                band kappa   0.105 -> 0.099
    deep/rem/light transition rows      band kappa   0.105 -> 0.101, healthy REM 29.58 % -> 33.11 %

LIMITS, stated plainly. n = 1 wearer. #348's out-of-cohort evidence was +0.028 (AAUWSS) and +0.006
(Walch); its headline +0.17 is DREAMT in-sample, on the cohort its constants were tuned to. 13 of this
database's 15 stage-locked nights are byte-exact or >= 95 % V2 replays and cannot serve as an independent
reference for V2 at all, leaving 2 usable hand-labelled nights (kappa 0.857 -> 0.863 there, a tie); the
band comparison above is the load-bearing one precisely because it does not depend on those labels.

Verification: StrandAnalytics `swift test` 1248 tests, 0 failures. Android
`:app:testFullDebugUnitTest --rerun-tasks` 3239 tests, 0 failures, 0 errors (counted from JUnit XML),
SleepStagerV2Test 12/12. Both frozen goldens pass UNCHANGED — they are insensitive to this row, which
is why this adds a direct pin for it on both platforms (twin of the existing deep-row pin), asserting
the zeros and that a zeroed entry still reaches the lattice as a finite log-weight.

* docs(sleep): the viterbi floor is load-bearing now, and stop citing a withdrawn latency figure

Review fix for #987, both platforms.

The comment above the ln floor read "Inert for the current matrix (no zero entries)" — a statement this
PR is precisely what falsifies. The awake row now carries wake->deep = wake->rem = 0.0, so the floor is
the only thing standing between those entries and ln(0) = -Inf, while the transition docstring sends
readers to that note to learn "why a zero is safe" and they arrived at an assertion that zeros do not
exist. Someone tidying dead code later had written permission to delete the one line holding this up.
The comment now says LOAD-BEARING, names the two entries that depend on it, and gives the magnitude
(-20.7 floored vs wake->light's -2.3).

Same reason, in the transition docstring: ZERO reads as absolute and is not. Floored, wake->deep costs
~18.4 log-units more than wake->light — a penalty a strong enough emission can still cross, so a genuine
sleep-onset REM period stays representable rather than structurally impossible. That is the intended
behaviour and it was not written down.

Also drops the "first-REM latency MAE 53.9 -> 41.6 min" clause from the same docstring. That figure is
withdrawn: measured against human-scored PSG hypnograms rather than the strap's band state, the same
change moves first-REM MAE 45.1 -> 49.9 min, i.e. slightly worse. Replaced with the figures that do
reproduce on that instrument (PhysioNet sleep-accel, 31 subjects / 26 773 epochs): 4-class kappa
0.356 -> 0.363, REM F1 0.569 -> 0.575, wake sensitivity 30.42 % -> 30.84 %, #437 stage-fraction guard
intact. Comments only; the matrix, the recipe and every test are untouched.

Verification: StrandAnalytics `swift test` 1175 tests, 0 failures (the "1248" in the PR description does
not reproduce and is corrected there). Android `:app:testFullDebugUnitTest --rerun-tasks` 3232 tests,
0 failures, 0 errors, 5 skipped, counted from the JUnit XML rather than the wrapper exit code — which
returned 0 on a run that never found the SDK. SleepStagerV2Test 12/12, SleepStagerV2Tests 14/14 incl.
testFrozenGoldenHypnogram, SleepStageTotalsTests 63/63. Both frozen goldens pass unchanged and their
sources are byte-identical: the diff touches two files and only comment lines in them.
…d) (#997)

#987 changed SleepStagerV2's awake transition row; #991's port-equivalence
guard landed before it and correctly failed on main:

  RecipeConfig.shipped no longer reproduces SleepStagerV2 —
  195 of 12377 epoch labels differ.

This is the guard working, not a defect in it. #991 merged AFTER #987 was
opened, so #987's own green CI never ran the SleepPSG job — its branch predates
the tool. MERGEABLE CLEAN only ever meant no textual conflict, and there was
none: the two files never touch. The coupling is semantic, which is exactly the
kind a test has to catch because review will not.

RecipePort's own comment already called this out — the awake row "is the one
line that legitimately differs between branches" and RecipeConfig.shipped "must
always describe SleepStagerV2 AS COMPILED on this branch". So the fix is the
single line it predicted, and Variants.pr987 needs no edit: it reads the shipped
row and offers whichever one is not there, so the #987 ablation keeps working
from the other side.
….2.2/304 (#998)

main has been on 9.2.1 for 42 commits. That was invisible until the testing
build cut from main stamped 9.2.1 / versionCode 303, and Android refused to
install it over the 29 July staging build (9.2.2 / versionCode 304) — a
downgrade, surfaced as the unhelpful "App not installed".

versionCode goes to 305 rather than 304 because 304 is already published on the
rolling testing-latest tag; reusing it would leave two different builds sharing
a code.

MARKETING_VERSION and versionName move together per CLAUDE.md. No release notes
here — this is the version bump only, and 9.2.2's notes still owe users the
user-visible changes from the last 42 commits (Effort shifts from #963 and
#992, the new Apple Health permission prompt, the Oura resting-HR heal that
needs a re-sync, and the sleep-staging change from #987).
* SourceBadge: centre the label in the pill on both axes (#112 screenshot)

Reported against the WHOOP pill on Today, and visible on ON-DEVICE and on the
Sleep tab too — because it is one component used 20 times across 9 screens,
not several bugs.

VERTICAL, and Android-only. The label WAS the pill: `heightIn(min = 18.dp)`
applied straight to the Text. A 10.sp line box is about 13.dp, so roughly 5.dp
of slack all landed below the glyphs and pushed the label upward. The SwiftUI
twin pins the same 18 with `.frame(height:)`, which centres by default — so the
two platforms drew the same badge differently from the same numbers. A Box now
owns the height and centres its content, which both fixes it and makes the two
implementations agree.

HORIZONTAL, on both platforms, and far smaller. `letterSpacing` / `.tracking`
add their gap after EVERY character including the last, so the laid-out text is
half a point wider than the ink and centring the node leaves the glyphs left of
true centre by half the tracking. Both sides now nudge back by 0.25. Sub-pixel
at normal density and nobody would have reported it alone, but the ask was that
the text be centred in the pill, and it now is.

The NOOP wordmark was checked and needs nothing: it is a Row with
spacedBy(14.dp, CenterHorizontally), which puts gaps only BETWEEN characters —
the correct idiom for tracked, centred text.

No behaviour change, no new strings, no API change.

* SourceBadge: drop the horizontal tracking nudge

The vertical fix stands: heightIn(min:) on the label top-aligns it in the
18dp pill, so the slack fell below the glyphs. A Box that owns the height
and centres its content matches the SwiftUI twin, which gets this free
from .frame(height:).

The horizontal nudge does not stand, and is removed from both platforms.
It assumed letterSpacing/tracking pads only the trailing edge on both.
That is documented SwiftUI behaviour for .tracking, but Android splits
letter spacing half before and half after each glyph, so the Compose label
is already symmetric and the nudge would have pushed it off centre by the
amount it claimed to correct. Neither claim is measurable on this host,
and at 0.25dp neither is visible, so the unverifiable half is dropped
rather than shipped into a component used 20 times across 9 screens.

Applying one hardcoded nudge to two different text engines would have
repeated the exact bug being fixed here: same number, different render.
Notes covering everything merged since v9.2.1 (43 PRs), the in-app What's
New entry generated for both platforms, and the version bumps.

- docs/releases/v9.3.0.md — release notes plus the whatsnew front-matter,
  with title translations for all five locales so the i18n gate has no
  English fallback to warn about.
- AppChangelog.{kt,swift} + six strings.xml — generated by
  Tools/appchangelog-gen.py, not hand-written, so the two platforms carry
  byte-identical items.
- CHANGELOG.md — a 9.3.0 section in the existing house format.
- MARKETING_VERSION 9.2.2 -> 9.3.0, versionName 9.2.2 -> 9.3.0,
  versionCode 305 -> 306.

The notes lead with a 'Scores that change' section because three merges
move numbers users have already seen — gap-weighted strain (#963), the
workout resting-HR fix (#983), and the sleep transition rule (#348) — and
the Oura fix (#375) corrects the importer, not rows already written, so it
needs a re-import to take effect.
…) (#1003)

* Health Connect: key each record by the offset it was recorded in (#1002)

HealthConnectImporter bucketed every record with ZoneId.systemDefault(),
the phone's zone at import time, and never read the startZoneOffset /
zoneOffset that Health Connect attaches to each record. WINDOW_YEARS is
10, so one import keys a decade of history that way, and importing again
after moving re-keys the same records differently.

The report attributes this to DST as well as travel. DST was not affected:
ZoneId.systemDefault() returns a REGION id, and LocalDate.ofInstant
resolves it with the rules in force at the given instant, so a January
record is already keyed with January's offset when the import runs in
July. Verified on a JVM before writing the fix, and pinned by a test so a
later change cannot swap the region zone for a fixed current offset and
break what was working.

What was broken is travel and relocation, where the record's zone differs
from the phone's current one: a record written at 01:30 in Tokyo resolves
to the previous day on a phone set to Europe/London.

localDayKey(instant, offset, zone) uses the record's offset when it has
one and falls back to the phone's zone when it does not, so behaviour is
unchanged for the many writers that leave the field null. Threaded through
every call site: interval records use startZoneOffset (endZoneOffset for
sleep, which keys to the wake day), instantaneous records use zoneOffset,
and heart-rate samples inherit their parent record's.

Workouts needed the key carried forward — WorkoutRow holds only epoch
seconds, so re-deriving it in the #835 calorie post-pass would have fallen
back to the phone's zone and undone the fix for exactly the travelled
sessions it exists for. workoutDays is index-parallel to workouts.

iOS is deliberately not changed here; see the PR for why.

* Keep hydration on the phone zone, and fall through to the start offset for sleep

Two fixes from re-reviewing the change.

Hydration must NOT be keyed by the record's own offset. Its write is a
windowed REPLACE: windowDays is built from LocalDate.now(zone) and
HydrationStore.importWindow keeps only keys inside that window, dropping
anything else rather than merging it. Keying a record by its own offset can
move it a day off the phone zone, so a drink near either edge of the window
would land outside it and be discarded without a trace. That is a worse bug
than the one being fixed, and the same quiet-loss-in-a-replace-path shape as
#986. Fixing it properly means making the window offset-aware too, which is
shared code with an iOS twin, so it gets its own PR.

Checked the other write paths for the same shape: appleRows/dailyRows are
built from acc's own keys with no window clamp, and coveredDays is a
semantic gate rather than a range, so hydration was the only one that
silently drops a key.

Sleep now falls through endZoneOffset to startZoneOffset before the phone
zone. The wake day makes the end offset the right first choice, but writers
that set only the start are common, and the start is far better evidence of
the sleeper's zone than wherever the phone happens to be at import time.

* Steps top-up: reach back a day so the phone-zone filter can't hide a record the record-zone key claims

The top-up's read filter is in the phone's zone but its day key is now the
record's own, and the two can disagree. A record whose offset sits east of
the phone's zone can belong to today while having started before the phone's
midnight: the filter never fetches it, the sum comes back low, and the write
replaces the stored count with any non-zero sum — so a miss overwrites a good
figure with a low one. The full import keys the same way, so the two would
then disagree and flip-flop the number on each run, which is worse than the
undercount on its own.

Reading from yesterday's midnight closes it. A day of slack covers every real
offset (-12..+14), the == dayKey test still decides membership so the extra
rows are read and discarded, and step records are coarse interval rows a
handful per day, so this stays cheap on a screen refresh.

Before the change the filter and the key used the same zone, so the set was
consistent by construction; keying by the record's offset is what introduced
the asymmetry.
ryanbr and others added 19 commits July 31, 2026 17:51
Effort was resolved independently in several places and only the hero ring
knew about the live in-progress value. The Key Metrics tile and the HR
chart's edge badge read DailyMetric.strain straight off the daily row, which
is rewritten only when the heavy daily pass runs. The reporter's morning
showed 2.3 on the ring and 0.5 in both of the others, with the HR trace
climbing from about 09:00 and the screenshot taken at 12:22 — the row
predated the whole morning. Recovery and Rest matched everywhere precisely
because neither has a live path.

Not a 9.3.0 regression: the live hero landed in v4.0.1 on 15 June and the
tile has read the stored column unchanged since 14 June. #963 made Effort
track an active morning more closely, which widened the gap enough to see.

StrainScorer.effectiveEffort(live:stored:) is now the one rule, Swift and
Kotlin, with the twin test suites pinning it. The MAX in it is not a
tie-break but the never-drop floor from #489/#506, where a sparse-HR live
under-read replaced a real 38.3 with 0 — worth naming, because it is the
part someone would 'simplify' into preferring live.

Android: resolved once in TodayScreen and threaded to MetricGrid and
HeartRateTrendCard, alongside the resolved values those already take
(caloriesForDay, importedStepsForDay). The near-zero Effort caption had a
fourth hand-rolled copy of the same expression; it now uses the shared one
and keeps returning null for past days so the caption stays today-only.

iOS already had effortStrain() as the single point, and its gauge and
near-zero note used it; effortMarker and the Effort StatTile reached past it
to the row. Both now resolve through it, and it delegates to the shared rule.
…979) (#1009)

* Normalise tools/ into Tools/ so the two trees stop colliding on case (#979)

The repo carried Tools/ (50 files) and tools/ (27, all of them
linux-capture) as distinct trees. On a case-insensitive checkout — macOS,
which is the reference platform and what CI runs Swift on — they merge into
one visible directory, so a contributor can add a file to what they see as
Tools/linux-capture/ and have git record it under the other tree.

Nothing was broken: there are no colliding paths between the two, checked
rather than assumed. The reason to fix it anyway is what the collision can
silently defeat. tools-python.yml exists because 212 linux-capture tests
were only run when someone remembered to; it is path-filtered on tools/**.
A file landing in the wrong tree does not match that filter, so a new suite
would go unrun — the exact failure mode that workflow was written to end.

tools/linux-capture -> Tools/linux-capture, with 22 references rewritten
across docs, workflows and four doc comments, plus the directory diagram in
CONTRIBUTING.md, which had been drawing the two trees as siblings.

The path filter becomes Tools/linux-capture/** rather than Tools/**, which
preserves the trigger exactly: lowercase tools/ only ever contained
linux-capture, so Tools/** would newly fire this job on all 50 unrelated
Tools/ files.

Checked that the move cannot be swallowed by the sibling 'Run Tools/ tests'
step, which discovers from Tools/ with a 45-test floor: unittest discovery
does not recurse into a directory without __init__.py, verified by running
it both ways rather than trusting the rule. linux-capture has none.

Both suites run from their new homes: 212 tests (floor 200) and 53 (floor
45, unchanged — not 265, so no absorption).

* Keep the legacy lowercase path ignored so orphaned captures stay invisible (#979)

The linux-capture .gitignore is privacy-critical: it ignores ALL JSON and
.db in that directory because, in its own words, captures carry HR and
timeline and the .db carries the strap serial, session token and MAC.

Moving the directory moves that .gitignore with it. On a case-SENSITIVE
filesystem a contributor's existing captures stay behind at
tools/linux-capture/, where nothing ignores them any more — verified by
dropping a capture.json and a whoop4.db at the old path and watching both
show up in git status, ready to be committed by accident.

macOS is unaffected: the two paths are the same directory there, which is
the whole reason the trees collided.

Scoped to tools/linux-capture/ rather than all of tools/, so a deliberately
new lowercase tree stays visible instead of being silently ignored.

* Never ignore the legacy path as a bare directory — it case-folds onto the real tree

The previous commit's `tools/linux-capture/` pattern is unsafe. Git folds
case in .gitignore when core.ignorecase is on, and git sets that
automatically on macOS — so the pattern also matches the REAL
`Tools/linux-capture/` and silently ignores every new file added there.

A contributor adding a test suite on macOS would have it vanish: never
committed, never run. That is the exact failure mode this normalisation
exists to prevent, reintroduced in a worse form by the fix for it.

Verified with core.ignorecase=true on a scratch repo: a new .py under the
uppercase tree came back IGNORED.

Replaced with DATA patterns only — *.json, *.db, *.db-wal, *.db-shm and
captures/ — never the bare directory. Every one is a pattern
Tools/linux-capture/.gitignore already applies, so folding onto the real
tree is a no-op there while the orphaned old path stays covered.

Checked both ways on a scratch repo. With ignorecase true and false alike:
a new .py under Tools/linux-capture stays visible, and capture data under
either path stays ignored.
…1010)

* DeepCapture test: name the set for what it actually asserts (#979)

testNoSlotDuplicatesAnAlreadyPersistedField checked a set called
persistedElsewhere, and told the reader on failure that the key 'already
has a durable home'. Two of its seventeen entries do not.

motion_wear_quality@63 is the same byte as activity_class under a second
name; spo2_candidate_82 is a gated 70-100 view of auxByte82. Neither is
persisted in its own right — both are RECOVERABLE from a byte that is,
which is equally good grounds for refusing to bank them and is not the same
claim. Streams.swift already words it correctly; only the test overstated.

It reads as 'SpO2 is being stored', which it is not, and briefly convinced
the reporter of exactly that.

Behaviour is unchanged — same set, same assertion, same slots. Only the
name and the failure message move, plus a comment recording why the
distinction is worth keeping.

* Rename the Kotlin twin to match

DeepCaptureChannelsTest.noSlotDuplicatesAnAlreadyPersistedField carried the
same overstated set name and the same failure message. Neither the issue nor
the Swift-side pass mentioned it; found by grepping for the old name after
renaming the Swift side.

Renaming one and not the other would leave the two suites asserting the same
contract under different names, which is the kind of drift the parity rule
exists to stop.
…#979) (#1011)

* Sleep stages: recognise both spellings of wake in segment comparisons (#979)

Stored hypnograms carry both 'wake' and 'awake'. Five segment comparisons on
EACH platform recognised only one, and they are the same five sites — the
reimplementation reproduced the bug faithfully:

  SleepStager 1075/2193      <-> SleepStager.kt 1157/2378
  WakeMotionRefinement 188   <-> WakeMotionRefinement.kt 223
  WearableImporter 134       <-> WearableExportImporter.kt 788
  XiaomiImporter 136         <-> XiaomiBandImporter.kt 335

The damaging shape is `stage != "wake"` used to mean asleep: an imported
'awake' segment falls through it and is counted as SLEEP, inflating the sleep
efficiency figure. The mirror shape `stage == "wake"` under-counts wake time,
and made #987's wake refinement skip those segments entirely.

Six other sites already defended with `case "wake", "awake"`, which is what
makes this a missing shared rule rather than a missing idea.

Two vocabularies exist by design and neither moves here: segment strings
canonicalise to 'wake', minutes-dictionary keys to 'awake'. The bug is the
dictionary vocabulary reaching a segment comparison, which happens because
imports do not pass through SleepStagerV2 — Oura's phase table is
[deep, light, rem, awake]. So this is a PREDICATE, not a canonicaliser: it
fixes the comparisons without rewriting any stored string, and no persisted
hypnogram changes meaning.

SleepStagerV2's own "awake" tests are deliberately untouched; that is its
internal model vocabulary, renamed to 'wake' on output.

Android's UI canonicalStage() now folds through the shared predicate so the
alias rule has one definition. It still returns 'awake', because that is the
key its colour table and the minutes dictionaries use.

6 twin tests per platform, same cases in the same order.

* Add an integration test per platform, so a reverted call site fails

The six predicate tests pass whether or not the five call sites were
actually changed - they exercise the rule, not its users. Nothing in the
suite noticed a reverted site, which for a latent bug is the only way a
regression would ever surface.

Each platform now also drives a real caller: SleepStager.hypnogramMetrics
over the same night as the existing AASM test, with the WASO segment spelled
'awake'. tst comes from a positive list (light/deep/rem) so it is immune
either way at 1080 s; WASO and the disturbance count are not, and read 0/0
before the fix.

Verified the two implementations agree before asserting: onset, sptEnd and
the clip arithmetic are identical in Swift and Kotlin, so both expect
waso 60 and disturbances 1.
…hem per workout (#1012)

* Health Connect import: range-scan the kcal lists instead of walking them per workout

The #835 post-pass called sumKcalInWindow twice per workout, and each call
scanned every record in the import window. WINDOW_YEARS is 10 and providers
write calories in ~15-minute buckets, so each list runs to hundreds of
thousands of rows and a first import paid that per workout. The original
comment named the fix: "sort both lists by start and range-scan rather than
filtering the whole list each time".

KcalIndex sorts once and bisects. Correctness rests on one bound: a record
overlaps [start, end) only if it STARTS before end and ENDS after start. The
first is a binary search; the second needs a floor, and the longest record in
the list supplies it, since none can reach further back than that. The slice
is therefore a superset of the overlapping records, never a subset.

It then hands that slice to sumKcalInWindow UNCHANGED, so the per-source max,
the partial-overlap proration and the null behaviour are the same code rather
than a reimplementation. The arithmetic that decides a calorie figure is not
worth duplicating for speed.

Measured on the desktop JVM at 350,400 records and 1,500 workouts:
450 ms of scanning becomes 37 ms of sorting plus 4 ms of querying, with
byte-identical results. The real import does this for two lists.

The existing 15-test HealthConnectActiveKcalTest passes unmodified, and the
new suite asserts the index against the full scan as an oracle over a swept
range of windows rather than against hand-computed figures.

* Move the inserted blocks above their neighbours' doc comments

Both insertions landed between an existing doc block and the declaration it
described, so the #117 doc bound itself to KcalIndex and the #835 doc to the
new overload. Tools/doc_comment_lint.py caught it; I had already pushed,
because I ran the linter before committing rather than gating on it.

Relocating them left a blank line between each doc and its declaration,
which the same linter treats as a separate detachment, so that is removed
too. Recompiled and re-ran both suites afterwards since code moved: 2517
errors matching main, 0 in this file, 4 + 15 tests green.

* Make the index bit-exact, not merely close

sumKcalInWindow accumulates each source with `+`, and floating-point
addition is not associative. Summing a slice in start-sorted order rather
than as-read therefore moves the result: on an adversarial corpus, 104 of
595 windows differed by up to 1.8e-15.

Nothing would ever have surfaced that. round1() downstream absorbs a delta
that size before the figure is stored, my sweep asserted with a 1e-9
tolerance, and the benchmark's own check used 1e-6. "Identical results" in
the PR was true to within tolerance and false as written.

The slice is restored to its original record order before delegating, which
buys exactness for 4 ms across 1500 queries against a 365 ms saving. The
sweep now asserts with delta 0.0, and a new test pins it on input whose
insertion order is deliberately not its sorted order — the only thing that
would notice the index quietly ceasing to be equivalent.

595/595 bit-exact after the change, max delta 0.0. Speed 423 ms -> 58 ms.

* Never let the index be slower than the scan it replaces

The lower bound reaches back by the longest record's span, so ONE record
covering a large share of the corpus makes every slice the whole list — and
sorting that per query is O(n log n) against the O(n) scan. Measured at
931 ms versus 113 ms on a corpus with one such record: an eightfold
regression, from an optimisation.

Not exotic input. The workoutKcal note already says providers write COARSE
aggregate rows; a per-day one is harmless here, a weekly or lifetime one is
not.

KcalIndex now decides once, at construction, whether narrowing can narrow
anything, and when it cannot it does not sort at all and routes every query
to the original scan. Warm-up-corrected, the pathological corpus benchmarks
at parity (scan 127-141 ms, index 123-129 ms) instead of 8x worse. A second
guard covers a wide WINDOW rather than a long record.

Normal case unchanged at 421 ms -> 65 ms, still 595/595 bit-exact.

The new test pins the fallback route's correctness rather than its speed:
it is a different path through the same class and must still return
bit-identical figures.

* Nits from a third pass: don't allocate, don't build what is never queried

Three things, none of which changed an answer:

The index was built unconditionally, so an import with no exercise sessions
paid two sorts of a ten-year record list to answer nothing. Both are now
lazy (NONE mode - the import is single-threaded), so a workout-less import
does no sorting at all.

withIndex().sortedBy{} allocated one IndexedValue per record: on the 350k
corpus that is megabytes of garbage on a phone mid-import, to serve a few
hundred queries. Replaced with an IntArray of record indices, which doubles
as the original ordering the slice restores. Query time incidentally fell
from 9 ms to 3 ms.

maxLenS, usable and order initialise in declaration order and each reads the
one above it. Reordering them would yield 0/false/empty silently rather than
a compile error, so that is now written down beside them.

Renaming the property caught a real mistake: lowerBound still read
`sorted.size`, which resolved to the stdlib `sorted` extension instead of
failing outright. Four compile errors, fixed before this commit.

Normal 458 ms -> 71 ms, pathological still at parity, 595/595 bit-exact,
6 + 15 tests green, 2517/0 against main.

* Say what the IntArray actually buys

The comment claimed the sort was unboxed. It is not - toTypedArray().sortedBy
boxes every index to get a comparator. What the IntArray buys is the RETAINED
footprint: the boxes and the temporary list are collectable the moment the
constructor returns, where a sorted List<IndexedValue> would be held for the
whole import.

A primitive sort-by-key would avoid the boxing too, but packing a start time
and an index into one Long assumes both stay inside 32 bits and breaks
silently on a pre-1970 timestamp. Not worth it for a one-off sort, and now
recorded so the next reader does not have to work that out.
* What's New: build the changelog lazily, as iOS already does

The sheet composed EVERY release card up front on each open. There are 267
entries, each a surface plus a version/date row plus one row per bullet, so
roughly a thousand text nodes measured in the frame the sheet appears - and
one more release every time we ship.

Not a new idea: the Swift twin already uses LazyVStack and says why in a PERF
comment. This is the Android half of that fix, which was missed. Swept for
the same shape elsewhere first - WorkoutsScreen is the other list that grows
without bound, and it was already solved by pagination (#797), so this is the
only remaining instance.

contentPadding, NOT Modifier.padding: the old padding was applied AFTER
verticalScroll, so it scrolled with the content. contentPadding preserves
that exactly; a padding modifier would inset the viewport instead and move
where content clips at the edges. That is the easy thing to get wrong in a
Column-to-LazyColumn swap.

Keyed by release.version - unique and stable - mirroring the Swift ForEach
id. Arrangement.spacedBy carries over unchanged, so spacing is untouched.

Visually identical; only construction becomes on demand. No measurement:
there is no Compose runtime here, so the magnitude is a structural estimate
rather than a number.

* Drop the item keys — they buy nothing here and add a crash mode

LazyColumn THROWS IllegalArgumentException on a duplicate key. The
forEachIndexed this replaces rendered duplicates without complaint, so
keying by release.version would have handed a generated file the power to
crash the What's New screen at runtime.

All 266 entries are unique today, checked. But appchangelog-gen.py only
guards the NEWEST entry against duplication, so a re-released version or a
hand-edit would be enough, and the failure would land on a user-facing
screen rather than in CI.

Keys exist so recomposition can track identity when a list inserts, removes
or reorders. AppChangelog.releases is a compile-time constant that cannot
change during a session, so there is no identity to track and the key was
pure cost.

The Swift twin's `id:` is not a precedent for keeping one: SwiftUI's ForEach
requires an identifier, LazyColumn does not.
…ation (#1016)

* Secure prefs: one helper, opened once per process instead of per operation

AiKeyStore and OuraInstallKeyStore each carried a byte-identical prefs()
builder, differing only in the file name. They are the app's only two
encrypted files, and they hold the AI provider API key and the per-ring Oura
install keys - so a hardening change, a key-scheme migration or a fallback
fix could land on one and silently miss the other.

The dedup is the smaller half. Neither store CACHED the result, and
EncryptedSharedPreferences.create is not a cheap accessor: it builds a
MasterKey (an Android Keystore round trip) and sets up Tink AEAD primitives,
every call. Twenty read/write sites across the two stores paid that per
operation.

CoachViewModel reads five values in PROPERTY INITIALISERS - provider, model,
consent, custom base URL, custom connected - so constructing it ran five full
Keystore plus Tink setups synchronously on the main thread. Opening the Coach
screen did that every time. They now collapse to one per file for the process
lifetime.

ConcurrentHashMap.computeIfAbsent rather than a plain map or a lazy: AiKeyStore
is reached from the main thread via CoachViewModel while OuraInstallKeyStore is
reached from BLE callbacks on binder threads, so two threads racing the first
call must get the same instance.

Only the application context is captured, so nothing holds an Activity.

* Write down why computeIfAbsent is acceptable for a slow builder

ConcurrentHashMap documents that the mapping function should be short and
simple. This one is a Keystore round trip plus Tink setup, with the bin
locked throughout, so the reasoning for using it anyway belongs beside it
rather than in a reviewer's head: two files, each built once per process, no
re-entry into the map, so the worst case is one thread briefly serialising
behind another. It would not hold if this map grew keys at runtime.

Also records the two properties the choice is actually for - exactly-once
construction under a race, and a thrown build being retried rather than
memoised as a broken store.
* Importers: one capped stream reader instead of five

readCapped is the memory-exhaustion guard on user-supplied import files - a
content:// URI or a zip entry can claim any size, and every importer reads
the whole thing before parsing. It existed FIVE times, with identical bodies,
under two names (readCapped / readCappedBytes) and two shapes (extension /
member).

The duplication was known, not accidental: each copy carried a comment
saying the twins elsewhere "are not visible here". That is the wrong thing to
accept for a guard - a hardening or a fix lands on one and misses four.

The CAP is deliberately NOT centralised. It is policy and legitimately
differs by file type: 32 MB for a lab-marker CSV, 512 MB for a Xiaomi zip
entry. Each importer keeps its constant and passes it; only the mechanism
moves.

The failure wording is preserved exactly via a `what` parameter - everything
says "Input exceeds ..." except the zip-entry path, which says "Entry" - so
no user-visible message changes.

Seven tests, where the five copies had none between them: content under the
cap returned byte-for-byte, multi-chunk reads, exactly-at-cap allowed,
one-over throws, empty allowed, both failure messages, and that it throws
BEFORE buffering an oversize stream, which is the property the guard exists
for rather than merely returning an error.

Nine orphaned java.io imports removed with the bodies. Kotlin only warns on
those and the ad-hoc compile runs -nowarn, so they would have sat unnoticed.

* Keep the shared reader internal, as every copy it replaces was private

All five originals were file-private. Extracting them produced a PUBLIC
extension on InputStream, which is a wider API than the refactor asked for -
it would surface on every InputStream in the module's autocomplete and be
callable from anywhere.

Nothing outside com.noop.ingest calls it, and Kotlin has no package-private,
so internal is the tightest scope that still reaches the five importers and
the unit tests. Precedent in the tree: HealthConnectImporter.maxSourceLong is
internal and tested the same way.

Widening a private helper to public API is the kind of change that should be
asked for, not arrive as a side effect of moving code.
…f) (#1019)

* HRV: default Overnight only ON for fresh installs (#1008, minimum half)

WHOOP publishes no daytime HRV figure - its reading is an overnight one - so
a 24/7 stream has no official-app analogue, and the setting's own copy says
overnight-only roughly halves the battery cost. Turning on "Continuous HRV
capture" currently gives you the expensive, non-WHOOP-like behaviour unless
you separately find "Overnight only", which is the wrong way round.

EXISTING USERS ARE UNCHANGED. The unset case resolves from whether the base
Continuous HRV key exists: present means the user has been through this
screen and experienced always-on, so they keep it; absent means a fresh
install, which gets overnight-only. That preserves the deliberate choice the
#927 comment records ("Default OFF, so existing Continuous HRV users keep the
always-on behaviour with no migration") while changing what new users get.

Resolved at read time rather than by writing a migration, because the only
thing that must not happen is silently narrowing capture for someone relying
on it for daytime Stress - and a migration that runs at the wrong moment does
exactly that.

An explicit choice always wins in both directions, including an explicit OFF
on a fresh install.

This is the SAFE half of #1008. The other half - defaulting the HRV window to
DEEP_SLEEP to match WHOOP's reading - is deliberately not here: it moves every
existing HRV number and its baselines, and it depends on deep-sleep staging
quality that is still unsettled on 5/MG. That needs SleepPSG evidence first.

Rule lifted into continuousHrvOvernightDefault on both platforms so it is
testable without a Context or UserDefaults. Four twin tests each; the Kotlin
ones run in CI, the Swift ones live in StrandTests which app-build.yml would
run if it were enabled.

* Fix the default flip: decide once at launch, not on every read

The first version of this resolved the default at READ time, keyed on
whether Continuous HRV had ever been enabled. That fact is created by the
user's own opt-in, so a fresh install read overnight-only ON and then flipped
to OFF the moment they enabled Continuous HRV - the exact opposite of the
intent.

State-by-state tests could not see it: every individual state was correct,
and only the sequence was wrong.

Replaced with a one-time launch migration on both platforms: an install that
has used Continuous HRV and never chose an overnight setting is pinned to the
old OFF; everything else is left alone and takes the new ON default. Taken
before the user can reach either toggle, so the inputs cannot move under it.

A @AppStorage onChange hook cannot substitute on iOS - @AppStorage writes the
value BEFORE the handler runs, so a first-ever toggle is indistinguishable
from any other by then. That asymmetry is why this is a launch migration
rather than something wired to the toggle.

The extracted rule now describes the MIGRATION decision rather than the read,
because the read is simply getBoolean(key, true) - the previous extraction had
become dead code describing the broken design, and its tests were exercising
that dead code rather than production.

Five twin tests each, including the sequence that broke it and idempotence.

* Remove the Swift copy of the rejected read-time rule

The replacement added shouldPinLegacyOvernightDefault but left
continuousHrvOvernightDefault behind on the Swift side - dead code whose doc
describes the design this PR abandoned, and points at a Kotlin twin that no
longer exists.

Worse than ordinary dead code: it documents the read-time approach as though
it were current, which is the specific mistake the migration exists to
prevent someone repeating.

Found by grepping for stale references after the rewrite; nothing referenced
it, so removal is inert.

* Match the iOS toggle's default to the behaviour it controls

Changing the read default left SettingsView's @AppStorage on false, so a
fresh install would show "Overnight only" OFF while capture was actually
overnight-only. They read the same key by different routes.

The failure mode is worse than a wrong label: a user "correcting" the toggle
by flipping it on and off would write an explicit false and end up with the
24/7 behaviour they were trying to avoid.

Android was never affected - its toggle reads through
NoopPrefs.continuousHrvOvernight, so it cannot disagree with what the BLE
client acts on. Its stale "Default OFF" comment is corrected too.

Checked the rest of SettingsView for the same class: journalReminderEnabled
and experimentalSleepV2Enabled are the only other true-default toggles, and
both already pair with an accessor that handles the unset case - the first
with the exact `object(forKey:) as? Bool ?? true` spelling used here. So this
now matches how the codebase already solves it, and no other instance exists.
#1023)

Five local teardown paths all printed the bare `reason=localTerminate`, so a report of thousands of reconnects could not be diagnosed from the log. Each site now records its origin (`via=bondWatchdog`, `via=gattThrow:requestMtu`, …) and `connect down` carries the session length on both platforms.

Diagnostic only: no reconnect logic, timing or teardown path changes. The session-duration formatter is shared via ConnectionTrace on both platforms; `via=` stays Android-only because GATT status 22 has no CoreBluetooth equivalent.

The reconnect-backoff gap #1020 also exposed is deliberately left alone - it would mask the signal this adds.
…und entry (#1021) (#1024)

The only automatic write-back ran on scenePhase == .active - the same block that kicks the strap offload, so it raced the data it was meant to publish and last night's sleep reached Health an app-open late.

Hooks the write to refreshAfterCompletedBackfill, the real "new data landed" signal that #980 already publishes the widget from, and resumes Health auth on that path so a backfill completing in a backgrounded or BLE-relaunched process is not silently dropped. Write-only, matching the Android twin (WhoopBleClient calls HealthConnectWriter.write after backfill, since #660).

Not compile-verified: app-build.yml is disabled, so no CI builds app-target Swift.
#1008) (#1025)

Both HrvWindow enums described DEEP_SLEEP as "comparable to WHOOP's reading", and #1008 cited that line as the reason to make it the default. The phrase is about the method: the deep windows come from NOOP's own stager, which Tools/SleepPSG measures at 18.94% deep predicted against 13.76% truth over 31 subjects - a +5.18 pp bias at four-class kappa 0.356.

An over-inclusive deep window pulls the value back toward the whole-night mean, which is what the setting exists not to be. Recorded at both definitions with a re-run instruction, so the next person weighing the default finds it there rather than in an issue thread.

Docs only: no behaviour, no schema, no user-facing copy. The benchmark needs a PhysioNet download and cannot run in CI, hence a recorded figure rather than a test.
)

The zip build and file read for the log/capture exports ran on the caller, freezing the UI on a multi-MB bundle. They now run on Dispatchers.IO / off the main actor, with the save panel and share sheet still presenting on main.

Because the buttons no longer block, each export button gains a disable-while-busy guard and a spinner so a second tap mid-export can't fire two zips and two chooser intents.
…#961 follow-up) (#1026)

#961's guards cleared their flag as the last statement inside the coroutine, so only on the happy path. That worked, but only because every callee happens to wrap its body in runCatching - and a throw would have stranded the button disabled behind a spinner that never stops. Four Android sites move to try/finally; Swift uses defer, which also covers cancellation.

The busy spinner row was copied verbatim at five call sites across Settings and Test Centre, down to the 18.dp and the 2.dp stroke. Collapsed into NoopBusyRow beside NoopButton, and the import it orphaned in TestCentreScreen is removed. The update checker's spinner stays as it is - different size, inside its button, different word.

No behaviour change: same flags, same call sites, same rendering.
History offload is the dominant battery cost - ~2.4 h of radio a day on a WHOOP 4.0 (#1007). Two levers that attack it, #477's CONNECTION_PRIORITY_HIGH for the burst and #533's LE 2M, are written and tested but both ship off, gated on a real-strap comparison that never happened - because nothing measured the thing they change.

exitBackfilling now logs the burst's frame count, elapsed and achieved rate beside its exit reason, and reset() reports a burst a disconnect cut short, so the absence of a line is never ambiguous. Unconditional and one line per burst, so an ordinary exported strap log carries it.

Diagnostic only: no BLE op, no timing, no default flipped. Neither lever is enabled here - that decision now has a measurement behind it instead of interval arithmetic.
The app-target compile gate had been disabled manually since 21 July. In the eleven days since, 49 commits touched app-target Swift with no compile check - swiftc -parse is syntax only - and StrandTests, which runs in no other workflow, had not executed either.

A dispatch probe from a disposable ci/ branch at 8560f9a passed both legs in ~5 minutes, so nothing had broken in the meantime and the gate could come back on as-is.

Dropping the push:main leg roughly halves the cost of keeping it. Every change reaches main through a PR, which the pull_request trigger already gates, so the push run rebuilt content that had just been built on a two-runner macOS matrix billed at 10x Linux. Releases push straight to main with no PR, but fork-release.yml builds both Strand and NOOPiOS itself, so that path keeps its own gate; workflow_dispatch covers a direct non-release push on demand.
…-on probe (default OFF) (#896)

Decoders for the MG's ECG packet family on both platforms, plus an opt-in, hand-run probe that asks an MG to start its ECG subsystem and reports what comes back.

Safety construction: the probe ships default OFF, is registered in fiveMGGatedKeys so a strap family switch clears it, refuses to send unless the strap identifies as an MG (a plain 5.0, a 4.0 or an unidentified strap all read false), and carries the family invariant inside send() rather than resting on callers. Every surface a user could read as a result carries the not-a-medical-device framing.

The report states what it cannot tell you: a SUCCESS confirms only that an opcode was accepted, silence has five explanations rather than one, and an open electrode circuit is reported as a question about the run rather than a finding.

Merged with the i18n check red: the three medical disclaimers are deliberately untranslated (see their catalogue comments). Follow-up immediately after declares that intent via shouldTranslate:false; #1030 tracks the translation debt.
#896 landed with the i18n check red: its three medical disclaimers carry no
localizations, and the gate is whole-tree, so until this lands every open PR
red-checks on a line none of their authors touched (the 9.0.1 failure the
workflow's own comment records).

They are untranslated on purpose. Each key's comment says so - "needs a native
speaker, not a machine translation... a disclaimer that drifts in meaning is
worse than one shown in English" - and the runtime falls back to the English
source, which is accurate and complete. A machine translation of all eight
locales was written and discarded for exactly that reason.

shouldTranslate:false is the only thing i18n_audit.py reads as "not translated
on purpose", and it honours the flag in both the focus-language count and the
extra-locale allowance. The comments stay verbatim: they carry what a translator
must preserve, including that the long body's middle paragraph is a run
instruction, not a disclaimer.

Nothing a user sees changes - every locale showed this English text before and
shows it now. #1030 tracks the debt, since the audit will not mention these keys
again.
# Conflicts:
#	Strand/Resources/Localizable.xcstrings
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants