Repository navigation
Conversation
…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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ryanbr#931 is approved and the only thing holding it is
CONFLICTINGonStrand/Resources/Localizable.xcstrings. Merging this into your branch clears it.The conflict was mechanical
Both sides only added keys — nothing was edited on both:
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 NOOPand 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 mainandTools/doc_comment_lint.pyboth clean.Strand(universal macOS) andNOOPiOSboth compile, andStrandTestsruns. Worth flagging because that workflow was disabled from 21 July until today, so #931's earlier checks never compiledStrand/Liquid/,TodayVieworSettingsViewat 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.