Skip to content

Merge upstream ryanbr/noop v10.5.0 - #10

Merged
DX23876 merged 132 commits into
mainfrom
sync/upstream-10.5.0
Aug 20, 2026
Merged

Merge upstream ryanbr/noop v10.5.0#10
DX23876 merged 132 commits into
mainfrom
sync/upstream-10.5.0

Conversation

@DX23876

@DX23876 DX23876 commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Merges upstream/main (ryanbr/noop) at v10.5.0 — 129 commits since the 10.1.0 sync — and moves the fork's release identity to 10.5.0 / build 230, the version line this tree now carries.

What upstream brings

Fork-side resolutions

26 real conflicts, plus 22 android/ delete/modify conflicts dropped wholesale (git rm -r --cached android) as FORK_GUIDE.md prescribes. The four judgement calls are recorded in docs/fork/decisions.md:

  • Upstream's in-memory per-day analyze cache (Battery consumption on Android very high, mostly background ryanbr/noop#1005) is not carried. The fork's persisted dayScanFingerprint / analysisInputRevision skip (migrations v38–v40) answers the same question, survives relaunch, and is invalidated by the write-side sensor revision instead of a session-local HR key. Two skip layers with different invalidation would be one too many. AnalyzeRecentDayCache still merges in, unused, so reconsidering costs nothing.
    • Consequence: upstream's v38-apple-step-hour is registered here as v41-apple-step-hour — v38–v40 are taken and SchemaOracleTests requires the vN to match registration order. Safe because this fork never shipped the upstream id, and upstream's own ifNotExists anticipates exactly this case.
  • Every re-bounded read keeps limit: Int.max. The 200k cap is the truncation 10.1.1 removed on purpose. The real fixes in those same hunks — reading gravity from the same source id as the R-R (oura: Rhythm can never produce a reading on a ring — say so, instead of blaming the night ryanbr/noop#1360), splitting the Oura vendor respiration rate out of the stager's input — are taken in full.
  • Power saving's Settings card and its SettingsSearchCatalog entry are deleted in favour of upstream's screen. The @AppStorage properties the card read moved out with the same upstream change, so keeping it would not compile; discovery survives through the More-tab row and the macOS sidebar.
  • altstore-source.json and docs/IOS.md keep the fork's versions — the first is this fork's own release feed (upstream's entries point at ryanbr's releases), the second is a deliberate rewrite upstream has never seen.

The 128 new catalog keys were merged three-way per key (fork wins where both sides edited; upstream wins where only they did), and the 13 keys upstream ships focus-locale-only were translated into it/ru/zh-Hans/zh-Hant so the ratchet does not grow.

Verification

Check Result
Strand (macOS) build
NOOPiOS build
StrandTests ✅ 2183 tests, 0 failures
Packages/** swift test ✅ WhoopStore 463 · StrandAnalytics 1614 · WhoopProtocol 622 · OuraProtocol 204 · PolarProtocol 25 · StrandImport 249 · StrandDesign 98 · SemanticMemory 19 · NoopLocalAccess 9
Tools / Tools/linux-capture ✅ 52 / 212
doc_comment_lint.py
check-sideload-manifest.py ✅ after declaring com.apple.developer.healthkit.background-delivery, which upstream's write-back adds
i18n_audit.py --ci main ✅ exit 0

The i18n gate is green here while main is red: main fails on a pre-existing CoachSettingsView literal (This session: %@), now added to the catalog in all nine locales.

Not verified: BLE behaviour — nothing on the CoreBluetooth/offload path can be proven by a compile, and this was not tested against a strap.

ryanbr and others added 30 commits August 16, 2026 13:39
…n skin temp (ryanbr#1331 display facet) (ryanbr#1362)

The classic Today dashboard was the last surface still bypassing the shared resolvers every other screen uses. Skin Temp now routes through SkinTempDisplay (no more +33.4° on imports; honors the °C/°F preference) and Respiratory through the staleness-bounded carry (no more a fortnight-old 15.6). Both platforms.

Display facet of ryanbr#1331 only — does not address the WHOOP 4.0 blank/ABSENT respiratory (the RSA gate refusing the over-counted R-R), so ryanbr#1331 stays open.

Co-authored-by: DX23876 <176692557+DX23876@users.noreply.github.com>
…ation (ryanbr#1359)

Splits the 0x7E/0x7F real_steps tags out of the raw .tierB wrapper into a cited third-party 14-field unpack (decodeRealStepsFields, [oura-rs]), both platforms. Still Tier B: gated behind allowTierB, dropped by OuraStreamMapping (never a steps/scored row, pinned by a test each platform), logged once per kind and appended to a JSONL research corpus.

NOT a step count — ground truth (a 13,349-step day, 805 paired records) shows no field is a cumulative counter and fields run higher asleep; they are the inputs to Oura's step model. Lands as instrumentation per the ryanbr#194 precedent.
Follows up ryanbr#1029: 'der Strap' is masculine, so the nine accusative onboarding contexts take 'deinen Strap' (not 'dein Strap') and the pronoun 'es' becomes 'ihn'. Nominative/dative forms left untouched. Brings Android German into line with iOS, which already used the correct accusative. Android values-de only.
…yanbr#1354)

A rolled strap-log generation keeps only its last 1,000 lines but printed the pre-clip total, so a generation holding 1,000 of 2,000 lines announced '2000 line(s)' and read as complete — the dropped head then measures as silence (the app-suspended-vs-running signal the overnight Oura investigation turns on). Now prints '1000 of 2000 line(s), head clipped'; unclipped generations keep the old wording. Byte-identical wording both platforms (the shared log tools parse both report.txt files). Diagnostics only.
…nd log what adjudicates (ryanbr#1353)

Android: the ryanbr#1284 dup-gen diagnostic's DB read shared the outer runCatching with the sleep-session upsert and ran ahead of it, so a throw from the read (locked DB / teardown / disk I/O on the persist path) silently skipped the upsert and lost the ring's hypnogram night. The read now sits in its own runCatching — log-only means log-only — restoring parity with the Swift twin's contained read.

Both platforms: dupGenShape replaces the duration-derived codes= term (fully implied by the two printed timestamps) with segs= (stage-segment count) + json= (segments-JSON length) — the measures that actually adjudicate which of two duplicate rows is fuller, since the mode-1 re-anchor mints identical-duration rows at different 0x49 onsets. Diagnostics only.
… device (ryanbr#1355)

bootstrapStore() wrote the literal "WHOOP 4.0" into the device table's name column for every paired device, mislabeling 5.0/MG straps and Oura rings — a pre-multi-device leftover. Now looks up the active device's real brand/model via the registry (PairedDevice.displayName), reusing the existing registry handle. device.name has no production reader today (dormant), so this fixes wrong stored data before a future reader inherits it; an unreadable registry writes NULL (unknown) rather than a confident wrong literal. No Android twin owed — its live path never hardcoded the name.
…ted layout (ryanbr#1357)

OuraDecoders.decodeMotionPeriod implemented the earlier reading OURA_PROTOCOL.md s6.13 already documents as wrong: it skipped two header bytes (dropping every record's first four MOTION_STATE codes) and unpacked four codes from the final byte (manufacturing noMotion from padding). Corrected to a 1-byte header (bits[5:4]=count of codes in the final byte, count==0 meaning a full 4), codes from byte1, last byte truncated to count. Byte-identical Swift/Kotlin, proven by three shared golden vectors. 0x6B is dropped by OuraStreamMapping (never scored/stored), so this only corrects the decoded motion diagnostic; layout re-derived from a real capture with attribution, matching the already-landed doc.
… payload shape (ryanbr#1356)

zh (a shipped Android locale) was missing timeline_metric_movement — ryanbr#834 added it to de/es/fr/pt-rPT only, so a Chinese user saw the raw key/English fallback. Adds 体动 and lowers the zh ratchet baseline 43->42. iOS already localizes Movement in zh-Hans/zh-Hant (no parity gap). Also documents two OURA_MOTION (0x47) facts in OURA_PROTOCOL.md s6.13: low/high_intensity are optional keys (a short record omits them rather than faking 0), and the (deviceId,ts,kind) ON CONFLICT DO NOTHING PK could drop a same-second window — observed never (2917 windows, gap min 26s). Docs + one locale string, no code change.
…read off the strap (ryanbr#1367)

NoopLimitationsView marked Respiratory rate .full ("read live off the strap") on both WHOOP generations, but on neither is it read off the strap: the displayed value is always SleepStager.respRateFromRR — an on-device RSA estimate off the R-R stream (AnalyticsEngine respRateDaily, no family branch), which is what .partial means. 5.0/MG's v18 wire carries no respiratory channel; 4.0's v24 resp_rate_raw ADC is stored unconverted ("computed server-side") and never shown. Both columns -> .partial, Swift + Kotlin. That the shown value is RSA-derived is why an over-counted-R-R 4.0 night (ryanbr#1331) blanks it.

Supersedes ryanbr#1365; 5.0/MG half by gdorgian.

Co-authored-by: gdorgian <172943411+gdorgian@users.noreply.github.com>
…ryanbr#1368)

MetricDetailView gated the whole screen on a cross-catalog correlation scan (~60 sequential exploreSeries reads, each walking repo.days on the @mainactor) that only feeds the bottom correlation card — so every metric detail paid ~60 full-history reads before its first pixel. Split into two phases: this metric's own series flips `loaded` and the screen renders; the catalog scan runs behind it, gating only the correlation card (with a 'Scanning the catalog…' pill). Same reads/results, gate moved; correlation cache key folds in others.count to avoid a mid-scan blank.

Also replaces the catalog no-data-dot probe (~60 exploreSeries reads for 60 booleans) with Repository.nonEmptyMetricIDs: one metricKeys DISTINCT query per source + one in-memory pass, mirroring exploreSeries source precedence (pinned by MetricEmptinessProbeTests). iOS/macOS only — Android's Explore already keys off metricKeys and has no correlation sweep, so no twin owed.

Co-authored-by: DX23876 <176692557+DX23876@users.noreply.github.com>
… platforms) (ryanbr#1369)

The coverage gate checks whether a key EXISTS in a language, never whether its value differs from the English source — so a catalog reads 100% complete while a user sees English sentences. Adds an echo gate (localizations whose value IS the English source verbatim), ratcheted against Tools/i18n_echo_baseline.txt like the extra-locale allowance: blocks the count growing, residue is a hand-drawn-down work list. iOS/xcstrings detector ported from DX23876 (value == key); Android twin added (values-<locale> value == base values value). The >=2-translatable-words floor (format specifiers stripped, both %@/%lld and %1$s/%d) filters brands/terms of art. Baseline = current 233 echoes (16 iOS + 7 Android lines); adds EchoDetectionWordFilter tests.

Co-authored-by: DX23876 <176692557+DX23876@users.noreply.github.com>
…ms) (ryanbr#1370)

IntelligenceEngine ended today's sleep-read window at dayStart + 18h, so a night-shift worker or day-sleeper (asleep ~12:00, awake ~20:00, still inside today when they wake) saw a flat 18:00 wake until local midnight flipped the day to past and the same night silently re-scored to the real time (the half of ryanbr#500 left behind). Extracts a pure sleepReadWindowEnd(dayStart, nowLocalMidnight, now) capping today at min(nextMidnight, now) — never read the future — instead of a fixed hour.

Both platforms: IntelligenceEngine.kt had the identical dayStart + 18*3600 cap (its comment even read "Matches the Swift window"), so a Swift-only fix would have left Android day-sleepers stuck at 18:00 AND diverged the scored sleep window. Swift + SleepReadWindowTests by gdorgian; byte-twin Kotlin sleepReadWindowEnd + SleepReadWindowTest (7 cases) added. Supersedes ryanbr#1363.

Co-authored-by: gdorgian <172943411+gdorgian@users.noreply.github.com>
…yanbr#1371)

requestAuthorization(toShare:) does NOT fail softly for a write-reserved HealthKit type (e.g. .appleSleepingWristTemperature, reserved to Apple Watch): it raises an uncatchable ObjC NSInvalidArgumentException that terminates the app on launch, and Swift try/catch can't intercept an NSException. A well-meaning 'just add the type' change to a write list would therefore ship a launch crash. Adds a writeDenied denylist filtered out of every write set (writeTypes, legacyCoreWriteTypes), making a read-only id in a write list a structural no-op instead of a brick-on-launch. Pure defensive change — no real write type added or removed. Footgun found + verified on device by gdorgian while researching ryanbr#1366 (skin temp: decoded and scored, but no honest HealthKit type to publish it under).

Co-authored-by: gdorgian <172943411+gdorgian@users.noreply.github.com>
…1361)

Adds a platform-neutral journal.customBehaviors key (a newline-joined list of
custom behaviour names) to the byte-identical settings.json whitelist, so a
restore brings back the user's custom journal behaviour DEFINITIONS, not just
their logged effects (the DB half).

The WhoopStore/BackupSettings package can't see the app's catalog types, so the
bridging lives in the app layer on both platforms:
- Android: BackupSettingsBridge reads the live v2 catalog blob (noop.journalCatalogV2)
  on export and, on restore, writes the names to the legacy custom key + clears the
  v2 blob so the next load re-migrates them.
- iOS: DataBackup decodes the catalog v2 items blob off the main actor on export and
  bridges restore the same way.

SCOPE: names only — the wire carries no kind/group, so a numeric custom behaviour
restores as a plain toggle (identical on both platforms; historical DB entries keep
their numericValue). Round-trip tested on both sides (embedded newline survives).

Reported/requested by @riebschlaegerstar-sys (ryanbr#1361).
…reading (ryanbr#1360)

Diagnose an empty Rhythm night honestly instead of always showing "try again after a settled night" for a device that can never satisfy the read (an Oura ring: gravitySample empty for the whole DB per ryanbr#804, beats banked onto record timestamps per ryanbr#1108). No gate is relaxed — the refusal is only named.

RhythmScreener.classifyEmptyState + nightBeatsAreBanked (both platforms, byte-identical) → none / gatheringData / deviceNoMotion / deviceBanksBeats. A structural verdict is reached only when the device recorded dense R-R yet NO stillness signal at all (the ring signature), so a WHOOP — whose banked R-R can itself measure over-counted (ryanbr#1118) — keeps the honest "try again". beatsAreBanked is measured from the record timestamps, never the interval values.

iOS RhythmView shows the three-way copy; V5PillarHosts diagnoses the night and reads gravity from the same source id as the R-R. Android carries the twin and wires the screen. New copy localized across all iOS locales + the Android shared title. Tests both platforms, incl. the WHOOP-banked regression guard.

Reported and analysed by @pipiche38 (ryanbr#1360).
…on the Kotlin ryanbr#512-class paths (ryanbr#1304)

The Swift side is already union-safe everywhere, so every remaining ryanbr#512-class bug was a Kotlin caller handing the literal "my-whoop" into a union-aware repo method — which collapses the union (importedSourceIdsFor("my-whoop") == ["my-whoop"]) and re-drops a strap banked under "whoop-<uuid>". Thread the active strap id instead:

- CoupledScreen: sleep + habitual midsleep via sleepSessionsUnion/computedSleepSessionsUnion/habitualMidsleepSec(vm.activeStrapId), matching SleepScreen.
- TodayScreen: the sleepToday hero unions, and the battery estimate reads the active strap's own SoC.
- WhoopConnectionService: notification recentDaysMergedFlow + battery read use NoopApplication.activeDeviceId (as at :606).
- AiCoach: daysMerged/rrIntervals/Lab markers read the active strap (from CoachViewModel), matching Swift AICoach; journal stays canonical (user-global).

Verified NOT ryanbr#512-class and left alone: stress + sleep_performance/need are imported metrics (canonical by definition); ShortcutHealthImport.forbiddenSources is a write guard. DataSourcesScreen badge under-count is a cosmetic follow-up (no union-aware count variant on either platform).

Parity confirmed by reading each Swift twin (allSleepSessions / habitualMidsleepSec() / repo.sleeps / AICoach repo.deviceId) — all already union; Kotlin-only change. Locked by MultiWhoopSourceUnionTest; 319 data+ai unit tests green.
…p union (ryanbr#1304)

The Data Sources "WHOOP data" badge counted under a hardcoded "my-whoop", under-reporting a strap banked under "whoop-<uuid>" (cosmetic follow-up to ryanbr#1374). Count across the active-strap union instead:

- days: daysMerged(vm.activeStrapId).size — the exact twin of Swift's repo.days (mergeActivityFileSteps(mergeDaily(imported, computed)); apple is not folded in on either side), so the Android count matches iOS, including a strap-only user's computed-only ("-noop") days.
- workouts: workoutsUnion(...).size (deduped union).
- HR: latestHrSampleTsUnion(...) (newest across the union; null -> no HR).

Single-WHOOP / import-only (activeStrapId == "my-whoop") collapses to one id, byte-identical.

Kotlin-only: Swift's badge already counts via the unioned repo.days (verified the refresh() assignment) and dataVolumeSnapshot() unions — iOS never under-counted. Locked by MultiWhoopSourceUnionTest; 321 data+ai tests green.
…rument-first)

Detect a parasympathetic-saturation night (HRV low AND resting HR low, decoupled) and REPORT the easing it would apply to the dominant low-HRV Charge penalty — but do NOT apply it. recovery() scores the raw HRV z, so Charge is byte-identical to pre-guard behaviour; the signature surfaces only in the Charge trace ("charge saturation active ... wouldRaiseCharge=N") and the ChargeDrivers HRV verdict, so real low-HRV + low-RHR nights can be counted before the easing is ever allowed to move the score.

Why instrument-first: the real-data validation recorded zero firings (evidence it does not over-fire, NOT that the easing size is right on a real saturation night), and low-HRV + low-RHR is also a signature of maladaptive parasympathetic OVERREACHING, which wants the opposite correction — indistinguishable from the HRV/RHR pair alone. Enabling the easing is a follow-up gated on confirmed real firings.

Byte-identical Swift/Kotlin: same satEnterZ=0.5 / satFullZ=1.5 / satMaxDampFraction=0.5, same min-driven couplingStrength and damp formula, logisticScore factored out identically, and the guard is consumed only by the trace + drivers (never by recovery()) on both platforms. 11+11 RecoverySaturationGuardTest(s) pin score-unchanged + detection-fires; StrandAnalytics + Android suites green.

Co-authored-by: vishk23 <119831996+vishk23@users.noreply.github.com>
…ing, clarify the card (ryanbr#159)

The Android "Broadcast HR" enabled warning was wrong across all six locales: it said the STRAP was advertising (it's the phone), Spanish/Portuguese mistranslated "HR" as Recursos Humanos (RRHH/RH), and its second half was a hardcoded English string concatenated in Kotlin (so DE/FR/ES/PL/PT/ZH saw a partial sentence + an English tail). Fixed the factual error, the mistranslations, and folded the full sentence into the localized resource. Also clarified the card subtitle for ryanbr#159 (leave the option off if your strap/watch already broadcasts HR natively).

Parity: the enabled warning is Android-only — iOS's broadcast card has no equivalent warning string (its "Acts as a standard Bluetooth heart-rate strap" footnote is already folded/correct on iOS), so there is no Swift twin. Key ids kept stable across the copy change, matching how main folds these strings.

Supersedes ryanbr#508 (its _f8d13439 half was already fixed on main); reuses @kavemang's DE/FR/ES wording and adds the missing PL/PT/ZH. i18n audit + GermanLocalizationTest + full-debug Kotlin compile green.

Closes ryanbr#159.
Co-authored-by: kavemang <kavemang@users.noreply.github.com>
… WHOOP-4.0 serial (ryanbr#1303)

The ryanbr#1303 4.0 stable-serial work needs the strap serial, which lives in the GET_HELLO_HARVARD (cmd 35) response the app already sends on connect — but its byte offset is undocumented. Log the raw cmd-35 response payload hex once per connect (WHOOP 4.0 only) so the serial offset can be correlated against the serial the app shows.

Gated behind Test Centre -> Connection: the response carries the full serial AND a device key/uuid, so it must never land in a default (shareable) strap log — mirroring the DIS log, which logs only the serial prefix for the same reason. Log-only; decodes and persists nothing.

Swift (FrameRouter) + Android (WhoopBleClient) twins: same whoop4 gate, same Test Centre gate, same space-separated hex, reusing the existing commandResponsePayloadHex / whoop4CommandResponsePayload helpers. Routing verified against the enums (PacketType 36 = COMMAND_RESPONSE, CommandNumber 35 = GET_HELLO_HARVARD) and the working GET_ADVERTISING_NAME_HARVARD / GET_ALARM_TIME sibling diagnostics. Superseded by the real serial decode + adoptSerialIdentity wiring once the offset is located.
…atforms)

Advances the Polar deep-stream (PMD) roadmap groundwork with the pure, testable pieces a live PolarPMDSource will need — clean-room from the official polarofficial/polar-ble-sdk (the same source PmdDecoder used), NOT any GPL third-party app.

- PolarPmdControl: control-point command builder (getSettings/start/stop, (recording<<7)|type start byte, u16-LE setting blocks) + 0xF0 response parse. Pure bytes, no BLE.
- PolarModel: advertised-name -> model (H10/H9/OH1/Verity Sense) + per-model PMD stream capabilities, so a source requests the right measurement (PPI on the optical bands, ECG on the H10). Matching anchored on the model token (not a whole-name substring, so a serial carrying a model token can't misidentify). OH1 vs Verity diverge on the gyroscope.
- PmdDecoder: documented the 2000-epoch timestamp (ns since 2000-01-01 UTC, NOT Unix) + pmdEpochUnixOffsetNs / unixTimestampNs converter, so a future fixed-rate stream can't misread it.

Byte-identical Swift/Kotlin: PolarProtocol 22/22, com.noop.polar 22/22. Roadmap DEVICE_SUPPORT_ROADMAP.md updated.

HARDWARE-GATED / alpha: the 0xF0 response layout and PPI skin-contact flag polarity are per the SDK, not yet device-confirmed; no consumer gates on them. The live PolarPMDSource BLE wrapper is deliberately NOT built pending a decision on whether PMD PPI beats the already-shipped standard-HR R-R for HRV.
… RSSI first)

The standard-HR strap scan listed discovered straps in discovery order, so in a gym with several straps advertising, the closest one wasn't surfaced first. Order the list by RSSI descending — closest first. Generic: benefits every 0x180D strap (Polar / Wahoo / Coospo / Garmin HRM / Amazfit Helio), not one brand.

Extracted a pure, nonisolated upsertByProximity(list, strap) on both platforms (upsert by id/address with the newest RSSI, then stable-sort) so the dedup + ordering contract is unit-testable without the BLE stack. Byte-identical Swift/Kotlin; tests both platforms.

Display-order only — no scan/connect/data behavior changed, and WHOOP (BLEManager) + Oura (OuraLiveSource) run on separate sources that never touch StandardHRSource, so no regression there. Verified on the app-build gate (Strand macOS StrandTests + NOOPiOS).
…based) (ryanbr#1380)

Adds a user-controlled cycleAwarenessHidden opt-out (default off, never age-based) so a non-male profile can dismiss cycle awareness on Today + Health entirely. Composes with the ryanbr#801 sex gate through one pure predicate per platform (ProfileStore.cycleAwarenessVisible / cycleAwarenessVisible); a reversible "Show cycle awareness" master toggle in iOS Automations / Android Settings hides the card and stops tracking. iOS + Android built as twins; pure predicate pinned by a test on both.
…le (ryanbr#1381)

Wires PolarModel identification in as diagnostics only (no live PMD data path): pure isPolar/debugIdentification helpers (both platforms, unit-tested), a polarDebugLogging pref shown in the Test Centre ONLY when a Polar strap is paired, auto-detecting the model from the paired record and logging it to the strap log once per connect. Links the previously-dormant PolarProtocol package into the Strand + NOOPiOS app targets. No schema/migration, no version bump.
…ent naps (ryanbr#1284) (ryanbr#1382)

Heal-side ryanbr#1284 residual-3 fixes: SleepSessionDedup.isDuplicate now collapses a short fragment grazing a night across the overlap==0 seam (fragment-ratio gate, 0.10), and stops merging two genuine adjacent naps (a data-loss bug — the heal deletes dropped rows). Adds survivor-selection tests (mode-2 partial drain keeps the fuller row; mode-1 identical re-anchors resolve by latest-endTs), enriches the heal drop log with dropped/kept shapes for the corpus, and corrects the dup-gen 'onset jitter' annotation to 'end-anchor drift'. Byte-identical Swift/Kotlin. No schema/migration, no version bump.
…yanbr#1381 Test Centre strings (ryanbr#1385)

Two CI gates went red on main from merges completed via gh api merge (bypassing PR checks): test (WhoopStore) since ryanbr#1382 (the ryanbr#1284 tests assert on internal overlapSeconds/edgeGapSeconds but used a plain import WhoopStore → macOS compile error; fixed by @testable import, the convention in 44/47 WhoopStore test files), and i18n Coverage since ryanbr#1381 (the Test Centre 'Polar debug logging' toggle strings were never baselined; --update-baseline adds exactly those 3). Test-only + baseline; no source change.
…reath rate (ryanbr#1384)

Decodes Oura BLE tag 0x6A (sleep_period_info) on both platforms and persists its breath field as a respSample row under the ring's deviceId — INSTRUMENTATION only: stored and shown on the respiration track, nothing scores from it. dailyMetric.respRateBpm is untouched (verified: every scoring read filters ring rows via OuraRespScale.forScoring; analyzeDay's rate derives only from respRateFromRR). Byte-identical Swift/Kotlin decode + scale (raw = wireByte*125, exact for all 256 values); clean-room facts cited to open_ring/ringverse, layout re-derived and checked against captures. Hardware-validated (Gen 3 overnight, 219 rows, dailyMetric.respRateBpm NULL). §6.12 rewritten, §9 census 0x6a marked SOLVED.

Co-authored-by: Pipiche <patrick@pichon.me>
…ateBpm (ryanbr#1384 follow-up) (ryanbr#1386)

Six comments carried over from an earlier ryanbr#1384 draft still claimed the ring's breath field feeds/supplies dailyMetric.respRateBpm, contradicting both the code and the correct OuraSleepPeriodInfo type-doc in the same files. ryanbr#1384 made 0x6A pure instrumentation (a respSample row, shown but never scored; refused at every scoring read by OuraRespScale.forScoring). Corrected all six (EventTags / OuraEvents / OuraDriver, both platforms) to state the instrumentation disposition. Comments only; no code or behaviour change.
Adds docs/releases/v10.1.0.md (the GitHub release body + the whatsnew
front-matter) and the generated in-app What's New entry on both platforms
(AppChangelog.swift / .kt, byte-identical, currentVersion → 10.1.0), plus
the localized changelog title across all locales incl. Polish (ryanbr#1250).

The version was already bumped in source (project.yml / build.gradle.kts
= 10.1.0), so fork-release skips appchangelog-gen; this runs it ahead of a
bump=none dispatch.
bhelm and others added 23 commits August 20, 2026 16:41
…with independent HR/temp clocks (ryanbr#1467) (ryanbr#1467)

* fix(oura): widen the skin-temp worn-gate timestamp match for devices with independent HR/temp clocks (ryanbr#1467)

The Skin Temp Key Metrics card reads "–" for an Oura-primary install even
after 20+ nights of wear. The nightly skin-temp mean (AnalyticsEngine
wornNightlySkinTempC) requires a valid HR sample at the EXACT SAME SECOND
as each skin-temp sample before counting it "worn" — a fine proxy for a
WHOOP strap, which streams HR and skin-temp as one co-sampled 1 Hz
channel, but wrong for an Oura ring, whose HR (dense, near-continuous)
and skin-temp (~1/min) are independently clocked streams that only
coincide on the exact same second by chance.

Measured against a real 7-night gap on one wearer's own staging capture
(oura-2H3B2405003655, 2026-08-13 through 08-19): every single main night
landed just under minSkinTempSamples (155-296 kept, floor 300) despite
269-675 raw skin-temp samples and 3,900-14,600 valid HR samples each
night. A +-2s tolerance alone recovered every real (non-fragment) night
comfortably past the floor (622-635 kept); the two genuinely short
sessions in that window (135 and 269 total skin-temp samples) correctly
stay under the floor regardless, since they are naps/fragments, not
full nights.

Fix: a new wornToleranceSec parameter on wornNightlySkinTempC /
skinTempFunnel (Swift) and their Kotlin twins, threaded from
IntelligenceEngine per the OWNER device rather than globally. Default 0
preserves the exact-timestamp match byte-identically for every existing
caller and every WHOOP night; IntelligenceEngine resolves a
positively-identified Oura device (registry brand == "Oura", the same
lookup skinTempFamily already uses) to a 5s tolerance
(AnalyticsEngine.defaultOuraWornToleranceSec /
DEFAULT_OURA_WORN_TOLERANCE_SEC). Tolerance 0 keeps the original O(1)
exact-second Set lookup; tolerance > 0 sorts the valid HR timestamps
once and binary-searches each skin-temp sample, so the check stays
O(hr log hr + skinTemp log hr) rather than an O(hr x skinTemp) scan.

Verification
  - New tests (6 Swift, 6 Kotlin, byte-parity) isolate the tolerance
    behavior: default-0 stays an exact match, a real gap gets rescued
    within tolerance, a gap beyond tolerance still excludes, the match
    is symmetric (HR before or after), a shape modeled directly on the
    real 08-13/14 night reproduces null-then-recovered, and the
    funnel's drop-bucket accounting still sums to totalSamples with the
    rescued samples moving specifically from droppedNotWorn to kept.
  - Built and tested from a clean worktree on this branch's upstream/main
    base (not the stacked integration branch): Swift `swift test`
    Packages/StrandAnalytics 1502/0 (0 new failures; one pre-existing
    XCTExpectFailure for ryanbr#977 is expected, unrelated). macOS `Strand`
    scheme builds and StrandTests 1172/0. NOOPiOS builds
    (CODE_SIGNING_ALLOWED=NO, iOS 26.5 simulator). Android
    compileFullDebugKotlin clean, testFullDebugUnitTest (this file)
    29/0.
  - Real-data root cause confirmed against the actual staging DB before
    writing the fix (not guessed): direct SQL/Python query of the
    07-31.08-19 window showed every populated skinTempDevC day
    corresponds exactly to a night whose exact-timestamp HR/skin-temp
    overlap exceeded 300, and every nil day to one that fell short —
    a clean, fully-explained correlation, not a scoping/feed bug.
  - Not yet validated end-to-end on hardware (the "–" card actually
    turning into a number needs the next real Oura-primary capture with
    this build flashed); the underlying math is unit-tested and the
    real-data root cause is directly confirmed against a live capture.

worklog/BOARD.md queue 11b and
worklog/analysis/2026-08-19-1745-oura-app-skintemp-groundtruth-check.txt
have the full investigation this fix is based on (not part of this
public repo).

Claude-Session: https://claude.ai/code/session_01KZe4bEwsx5FpRtKCpXD1F3

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix(oura): un-stack the skin-temp worn-gate doc comment (ryanbr#1467 CI feedback)

Tools/doc_comment_lint.py caught it: the new DEFAULT_OURA_WORN_TOLERANCE_SEC
doc comment (Kotlin) landed directly above the pre-existing skinTempFunnel
doc with no declaration between them, so skinTempFunnel's own doc bound to
nothing (Kotlin binds only the LAST of two stacked blocks). Reordered so
the new comment sits above its own constant and the original stays
directly above skinTempFunnel. The Swift sibling had the same shape
(contiguous /// lines merge into one block, so the whole thing bound to
defaultOuraWornToleranceSec and skinTempFunnel lost its doc) — not caught
by this lint tool (Kotlin-only today) but fixed the same way for
correctness.

No logic change. python3 Tools/doc_comment_lint.py: OK, 25 baselined
sites remaining (back to this file's baseline of 1). Re-verified: Swift
StrandAnalytics build + SkinTemp tests 45/0, Android
compileFullDebugKotlin + SkinTempAnalyticsTest 29/0.

Claude-Session: https://claude.ai/code/session_01KZe4bEwsx5FpRtKCpXD1F3

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…lossy data (ryanbr#1465)

The ~51%/no-reconciliation numbers were from the pre-ryanbr#1070 corpus, where 12 of
every 13 samples were dropped non-randomly (survivor always the record's first
value). Re-measured on three clean post-fix nights (95-97% of 1Hz, n=93,700):
20.4% over 100, and offset -0.32 + clamp[85,100] nearly reproduces Cloud truth
(median exact). Also adds a first same-night paired comparison against the same
wearer's WHOOP export, which lands on the same correction independently
(MAE 0.49 vs 0.50) - still not enough to ship (n=3 nights), but the doc's prior
"no correction reconciles it" framing no longer holds as stated.


Claude-Session: https://claude.ai/code/session_01KZe4bEwsx5FpRtKCpXD1F3

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Testing build over the day's merges: twelve community PRs (ten from @bhelm,
two from @pipiche38) plus the productive-timeout banner fix and the per-chunk
clock diagnostic.

MARKETING_VERSION and versionName stay 10.1.1 - one above released 10.1.0 -
with only the build counters moving, so a side-installed staging build still
sorts above the previous one.
Follow-up to ryanbr#1468. The exported text is unchanged; only how it is built and
carried changes.

The live readouts call the log export on every 250 ms coalesce tick while a
panel is open, so a screen left open during an offload paid the full cost four
times a second:

- The PREVIOUS-sessions half re-read SharedPreferences and re-formatted every
  past session each tick, despite being invariant for the process -
  persistLogGenerations is called from exactly one place, inside the latched
  roll. Now memoised under genLock, the same lock the roll uses, so the memo
  cannot be filled from a pre-roll read.
- The CURRENT half joined the whole buffer while holding the logBuffer lock.
  log() runs on the GATT binder thread and blocks on that same lock for every
  line it writes, so a readout refresh was throttling the writer it was reading,
  at the moment both are busiest. The buffer is now copied under the lock and
  joined outside it - the pattern flushDurableLogTail already uses two functions
  away.

The parity half: Apple's readouts read LiveState.taggedTail(domain:), a filtered
view of the log; Android's read the full export, then TestCentreLiveReadouts.rows
split it back apart and filtered to one domain tag, and CaptureAccumulator did
split("\n") over the same text. Both consumers want LINES - the joined string
existed only between them. exportLogLines() now serves them; the share and
report paths keep the string, since a report file genuinely needs one.

Matching Apple exactly would mean a CURRENT-session tail, which was rejected on
purpose: capturedDays counts distinct days a guided mode has captured and that
legitimately spans app sessions, so a restart mid-capture would have silently
reset "K of N days". The line form therefore carries the same content, and only
the representation changed.

Two implementations of one thing caused the only real defect found here: the
line form was first built with flatten(), which drops an EMPTY generation where
joinToString renders it as a blank line, misaligning every line after it in one
form but not the other. Fixed by deleting the second implementation rather than
correcting it - previousSessionsLines is now the single renderer and
previousSessionsText derives from it, so they cannot drift. The session marker
is likewise single-sourced.

Tests pin what the shortcuts rest on, and the empty-generation test was
RED-checked: reverting to flatten() fails it and nothing else.

Verification: compileFullDebugKotlin clean, full Android unit suite green,
assembleFullDebug builds, StrapLogGenerationsTest 13, CaptureAccumulatorTest 10,
TestCentreLiveReadoutsTest 6, doc_comment_lint and i18n_audit --ci clean.
Android-only; no Swift twin, since the refresh loop this serves is Android-only.

Not profiled on a device - the case is structural, not measured.
Reported on the puck: the battery percent is eventually right, but never looks
live while charging.

The cadence is 60 s by design - the keep-alive polls on every SECOND 30 s tick.
A field log shows it exactly: reads at 08:05:56, 08:06:56, 08:07:56, one per
minute. That is the right trade while the charge only creeps downward, but a
charging strap is the one case where the value moves visibly and the user is
usually watching it.

While charging, both platforms now poll on EVERY tick (~30 s). The window is
short and bounded, it costs one extra read per minute, and nothing changes the
rest of the time. The 5/MG path on Swift throttles by TIME rather than by tick,
so that interval halves while charging too - otherwise a charging 5/MG would sit
a minute behind a charging 4.0 on the same screen.

Docking is noticed immediately: the strap raises CHARGING_ON(7)/CHARGING_OFF(8)
the instant a pack goes on, and both platforms already handle those events, so
the first fast poll lands on the next tick. Bit 0 of BATTERY_LEVEL is a ~8-min
backstop, not the primary signal.

Deliberately NOT included: firing the poll on the CHARGING_ON edge itself, which
would remove the last ~30 s. It is two lines on Android, where the handler sits
inside WhoopBleClient, but Apple's FrameRouter mutates LiveState directly and has
no send capability, so it needs a new seam. A one-sided version would put Android
30 s ahead of Apple at exactly the moment the user is watching, so it is left for
its own change.

The discharging cadence is untouched on purpose. Roughly 59 of every 60 reads
return an unchanged value there, but the battery reply is often the only inbound
data resetting the 120 s stall fuse on a quiet off-wrist link - slowing it would
trade a cheap command for a reconnect burst, the known 4.0 drain path.

Verification: batteryPollDue is one expression on both platforms, pinned by
mirrored tests; the pre-existing shouldPollWhoop5Battery tests call it without
the new argument and so pin that discharging is unchanged. compileFullDebugKotlin
clean, full Android unit suite green, ConnectionPriorityTest 16, doc_comment_lint
clean, both app-build legs green with StrandTests executed.

Not verified on hardware: whether 30 s reads as "semi-realtime" on the puck is
what the reporter has to judge.
…and fix the four u32 narrowings it found (ryanbr#869)

* fix(protocol): keep the Android v18 record_index in the unsigned domain

`record_index@11` is an unsigned 32-bit field. The Kotlin decoder narrowed it
with `toInt()`, and Kotlin's Int is 32-bit where Swift's is 64-bit, so a value
with bit 31 set decoded to -1_062_772_323 on Android and 3_232_194_973 on
Swift/macOS from byte-identical bytes.

The per-platform fixture-hex tests cannot see this: the wire bytes are the same
on both platforms and each suite asserts its own platform's answer, so the
divergence lives entirely downstream of the bytes. PR ryanbr#848 hit the same
32-vs-64-bit split in the v18 storage codec and had to re-widen there; this is
the decoder key itself.

Carried as a Long, matching the u32 reader's own return type. No main-code
consumer reads the key (the only readers are tests and the field census), so
the change is confined to the decoded map and its assertion.

* test(protocol): extend the golden decoder oracle past frame decode

`decoder_oracle.json` already made both decoders prove the same output against
the same bytes, but it stopped at frame decode. It now pins three layers, each
with a twin test in `DecoderOracleTests.swift` and `DecoderOracleTest.kt`, and
the file stays byte-identical across the two copies.

1. Decoded VALUES, including the 32-vs-64-bit boundary. Adds
   `whoop5_v18_synthetic_record_index_high_bit` - a real worn frame with only
   the u32 `record_index@11` replaced by 0xC0A7619D and the CRC32 recomputed.
   Bit 31 is where Swift and Kotlin stop agreeing, and no real capture reaches
   it (observed lifetime counters are ~8-25 million), so it has to be
   synthesised to be covered at all.

2. Stream ASSEMBLY (`stream_batches`). Six batches run named fixture frames
   through `extractHistoricalStreams` and pin every stream's row count - zeros
   included, so a stream materialising on one platform only is a failure - plus
   the emptiness verdict and the ryanbr#547 dropped-record counter. Frame decode
   agreeing does not imply assembly agrees. Because a batch fixture can only
   cover the streams it happens to populate, a constructive test also builds a
   one-stream batch per stream and asserts each is non-empty: `insert`
   early-returns on an empty batch, so a stream missing from the verdict is
   silent data loss on that platform.

3. A `coverage` manifest, so the oracle cannot quietly stop covering something.
   It pins every fixture and batch name, every stream, and every asserted
   decoder key mapped to HOW MANY frames assert it - the count, not just the
   name, so dropping a key from one frame while another still carries it is
   also a failure. Every non-derived key must additionally appear in a real
   decode, the check PR ryanbr#848 needed after a rename silently broke an extractor.
   Both suites also assert the stream list equals the array-typed fields
   `Streams`/`StreamBatch` actually declare, so a new stream added on one
   platform without oracle coverage fails there.

Batches use identity clock refs (the archive-replay seam), so no clock
correction applies and they stay reproducible.

Refs ryanbr#647, ryanbr#775.

* fix(protocol): keep the Android historical unix in the unsigned domain

`unix` is an unsigned 32-bit field at three separate decode sites (WHOOP 4 v24/v5
@11, WHOOP 4 v25 @11, WHOOP 5/MG v18 @15). All three narrowed it with `toInt()`,
and Kotlin's Int is 32-bit where Swift's is 64-bit, so from 2038-01-19 — the
second bit 31 first sets — Android decodes a NEGATIVE timestamp where macOS/iOS
decodes 2147483648 from byte-identical bytes.

For `unix` that is worse than a wrong number. The negative value fails the ryanbr#547
`MIN_PLAUSIBLE_UNIX` floor in `extractHistoricalStreams`, so the record is
dropped: every offloaded record disappears on Android while macOS/iOS keeps
ingesting the same bytes, with the UI still reporting a healthy sync. That
violates the cross-platform parity contract, and it is silent.

Carried as a Long, matching the u32 reader's own return type. The widening alone
is NOT sufficient: `intOrNull`'s `is Long -> v.toInt()` branch would have
re-narrowed it right back at the read site, so this adds `longOrNull` and reads
`unix` through it. Two downstream consumers move with it — `Backfiller`'s SpO2 RE
dump (an `as? Int` cast that would otherwise miss on every record and silently
stop dumping) and `Spo2ReTrace.recordLine`, whose Swift twin already takes a
64-bit Int, so widening restores the byte-identical-line promise rather than
breaking it.

The v26 PPG path is widened for consistency only; it was never wrong, because its
single consumer re-widened with `and 0xFFFFFFFFL`. That mask is now unnecessary
and is removed. It remains the one site the oracle cannot pin: v26 returns null
from `decodeHistorical`, so no v26 frame can live in the fixture set.

Pinned on both platforms by three new synthetic oracle frames, one per decode
site, each its real source capture with only the four `unix` bytes replaced by
0x80000000 and the CRC32 trailer recomputed (the two families checksum different
ranges). Three paired stream batches assert the record still assembles into rows
instead of vanishing, which is the actual user-visible bug; they need a `wall_now`
override because the records are ~12 years in the future, so this also adds that
test seam to Swift's `extractHistoricalStreams` (Android already had it). Every
production caller passes nil and behaviour is byte-identical.

Verified: swift test 350 passed; ./gradlew testFullDebugUnitTest 3096 passed
(SyncChipStateTest fails on pristine upstream/main, unrelated). Each of the three
decoder lines and the `longOrNull` read was reverted in isolation to confirm the
matching fixture fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(oracle): account for the v18_aux stream the manifest was missing

`testDeclaredStreamsMatchOracleManifest` has been red since 2026-07-27. ryanbr#848
added `v18_aux` to `Streams` / `StreamBatch` and to both platforms' emptiness
verdicts, but not to this branch's oracle manifest, so the check that exists to
catch exactly that — a new stream with no oracle counts — caught it.

The manifest is the fix, not the test. `v18_aux` belongs in `coverage.streams`
rather than `non_stream_lists` because both `Streams.isEmpty` (Streams.swift:646)
and `StreamBatch.isEmpty` (WhoopRepository.kt:99) include it: it gates the
insert, so a batch carrying only v18_aux rows must not read as empty.

Counts are MEASURED, not chosen — each one is what `extractHistoricalStreams`
actually produced over the pinned fixture frames:

  whoop5_v18_worn_pair               2
  whoop5_v18_offwrist_single         1
  whoop5_v18_high_bit_unix_survives  1
  the other six batches              0

Both `streamCounts` helpers gain the stream, so the counts are compared against
a real row count rather than silently absent. Both `emptinessVerdictCovers…`
one-stream cases gain a `v18_aux` case, which is the half of the ryanbr#848 defence
that a fixture batch cannot provide.

The one-stream case carries `recordIndex: 1` rather than a bare `ts`:
`V18AuxSample.isEmpty` / `V18AuxRow.isEmpty` is "every slot is null", so an
all-null row would still make the list non-empty and pass the check while
proving nothing about a row that carries data.

Both oracle copies stay byte-identical (`testOracleCopiesAreIdentical`).

Swift: 445 tests, 0 failures. Kotlin: 3225 tests, 0 failures, 0 errors,
5 skipped across 393 classes (JUnit XML). DecoderOracleTest 6/6 on each side.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ryanbr#869 widened the decoded `unix` from Int to Long, which is the point of it: a
u32 with bit 31 set narrowed to a NEGATIVE Int on Kotlin, and the ryanbr#547
plausibility gate then dropped the record - silently losing all history from
2038-01-19, and today on a future-dated strap.

UnmappedHistoricalLayoutTest asserts the decoded value with an Int literal, so
assertEquals compared Integer against Long and failed on the box. Same number,
wider type; the literal needs the L.

Nobody missed this. ryanbr#869's own Android CI passed on 2026-07-29, and the test it
trips was created by ryanbr#1101 on 2026-08-06 - eight days later. Git merges the two
cleanly because the conflict is semantic, not textual: a new test asserting the
type the older PR changes. Neither the PR's checks nor a clean merge can show
it, only building the merge result.

Verification: full Android unit suite 4146 tests, 0 failures;
UnmappedHistoricalLayoutTest 6.
Two field logs from confirmed WHOOP 5 straps arrived headed "Model: WHOOP 4.0"
(ryanbr#1451, ryanbr#1464). Both misdirected triage until the decoded layout version gave the
hardware away. Each platform had its own reason, failing in opposite directions.

ANDROID fabricated a 4.0. NoopPrefs.lastDevice widens a missing model to WHOOP4 -
right for RECONNECTING, since the code must pick a service to try, but a lie in a
report, which is read as an observation. The export now prefers
noop.selectedWhoopModel, written from the family that actually advertised, and
says unknown when neither pref knows.

APPLE never reported a model at all. Its header switched on "whoop5"/"whoop4" -
the enum CASE names - while both writers store WhoopModel.rawValue ("WHOOP 4.0" /
"WHOOP 5.0 / MG"). Neither case could ever match, so every Apple export has read
"unknown (never paired)" for every strap, forever, including one actively syncing.

The same literal bug sat in a THIRD place, and that one computes rather than
labels: it selects the DeviceFamily for the skin-temp funnel, so the funnel took
the WHOOP-4 device anchor and ran under the WHOOP-4 family for every 5/MG on
Apple. That diagnostic has been reporting 4.0 numbers for 5/MG straps.

All three now parse through WhoopModel(rawValue:), making the enum the only
parser so a rename cannot re-open the gap. Both platforms also stopped spelling
the model by hand: Android's alarm block said "WHOOP 5.0/MG" while the enum - and
the header above it - says "WHOOP 5.0 / MG", so one export disagreed with itself.

A report may say unknown. It may not invent a model. A diagnostic that guesses is
worse than one that abstains, because the guess is read as evidence - which is
exactly what happened on two issues.

Found across four review passes; the second caught a parity gap the first commit
opened (Apple's alarm block still fabricated a 4.0 after Android stopped), and
the third caught the wrong-family computation. Android was checked for the
symmetric literal bug and has none - its model decisions all go through enums.

Verification: compileFullDebugKotlin clean, full Android unit suite green,
doc_comment_lint clean, both app-build legs green.
Testing build over the afternoon's merges: the diagnostics model reporting
(ryanbr#1451/ryanbr#1464), the charging battery cadence, the Test Centre log-lines rework,
the golden decoder oracle with its u32 narrowing fixes, and its follow-up.

MARKETING_VERSION and versionName stay 10.1.1 with only the build counters
moving, so a side-installed staging build still sorts above the previous one.
…yanbr#1008)

Instrumentation for ryanbr#1331 / ryanbr#1008 / ryanbr#1118 / ryanbr#1451, which are one defect.

`ord` restarts at 0 on every delivery, so two rows on one second both carrying
ord 0 came from two different offloads writing the same wall second. The 5/MG log
on ryanbr#1451 shows exactly that - `-1s[872#0, 893#0]` - but densestSecondWindowSample
only dumps the 5-8 seconds around the densest one. That is a sample, not a
measurement, and the fix turns on a question it cannot answer: is the over-count
mostly seconds touched by SEVERAL deliveries, or genuinely too many beats inside
one? If the former, the fix belongs at ingest and this sizes it.

  rr deliveries secs[1/2/3/4+]=2/1/0/0 multiSec=33% multiRows=33% multiMs=36%
    maxDeliv=2 secsNoStart=0 ordUnknown=0

Read-side only: no new capture, no write-path change, so it lights up on data
already stored the moment the app opens.

Four review passes, four things it would otherwise have got wrong - none of which
would have failed a build, because each produced a plausible number rather than
an error:

- multiRows divided by every row including unattributable ones, so a night that
  half-predates the ord column would have read artificially benign - the exact
  conclusion this exists to prevent. It now divides by attributable rows.
- Seconds whose ord-0 row was absorbed by the primary key vanished from `secs`
  entirely, shrinking the multiSec denominator invisibly. Now counted as
  secsNoStart.
- rrMs was taken and never read - only its count. Coverage is the sum of rrMs
  over wall span, so beat-time is what inflates it; multiMs now reports the share
  on multi-delivery seconds, which is the number the fix is sized against.
- Beat-time was rounded with `.rounded()` on Swift and kotlin.math.round on
  Kotlin, which agree only for positive values - the ryanbr#1473 shape. Both now use
  `x + 0.5` truncated, so the agreement is structural.

Optimised on the last pass: four collections keyed by the same second became one
tally map, one hash lookup per row instead of three or four. This runs once per
over-counted night and analyzeRecent re-scores ~21 days every 15 minutes, so the
redundancy was millions of lookups per cycle for a diagnostic that feeds nothing.
The verbatim-line tests passed untouched through the rewrite, which is what made
it safe to attempt.

Verification: mirrored tests on both platforms asserting the rendered line
verbatim, the three expected strings extracted from both suites and compared
identical, full Android unit suite green, HrvAnalyzerSampleOrdTest 8,
doc_comment_lint clean, 14 CI checks including test (StrandAnalytics), and both
app-build legs green.

No behaviour change: nothing here feeds a stored value, a gate or a score.
Follow-up to ryanbr#1486. No behaviour change - this removes instrumentation that has
finished its job.

The shadow de-dup block ran 13 full passes over a night's R-R rows, six of them
collapseOverCount, and each collapse SORTS the night's ~50-70k intervals. It runs
for EVERY night of an affected strap, and analyzeRecent re-scores ~21 days every
15 minutes, so the phones least able to spare the work were paying the most for
instrumentation that feeds no stored value.

The same-second TOLERANCE SWEEP (20/34/60) was sizing a fix already ruled out:
every affected night reads crossSecondOverCount, so no same-second tolerance can
reach duplicates that straddle the boundary.

`xsec` - the 40 ms collapse widened to a 1-second window - was documented from
the start as a strict UPPER BOUND that over-merges real beats, kept only to size
how far a cross-second collapse COULD get. It has now produced that number in the
field: covXsec 0.80 with beatAccXsec 0.26, coverage driven BELOW 1.0 and accuracy
collapsed, eating real beats exactly as its own comment predicted. The
measurement succeeded, so it can stop running.

What survives is the honest floor (`ex`, exact duplicates only, provably no
real-beat loss) and the incumbent candidate (`dd`, 40 ms same-second). The
delivery histogram from ryanbr#1486 supersedes what both retired measurements reached
for, at one pass instead of nine.

13 passes per night became 6; six sorts became two.

Parity verified call-for-call rather than by reading the diff: eight analyzer
calls in the same order on both platforms, the nine surviving `hrv dedup` fields
matching name for name, and the emitted fragment byte-identical. That check
mattered - an unused local on Kotlin compiles with only a warning, so a leftover
accEx would have kept costing a full pass while looking clean.

Also verified before deleting: nothing anywhere consumes `hrv sweep` or the xsec
fields, and no orphaned variables survive on either platform.

Verification: compileFullDebugKotlin clean, full Android unit suite green,
doc_comment_lint clean.
The 10.5.0 notes cover 114 merged PRs since v10.1.0: training load (CTL/ATL/
form), a VO2max that no longer needs a waist measurement, the per-day reuse cache
that fixes the ryanbr#1005 background battery drain, Apple Health write-back, hourly
step import, and a long run of Oura decode and honesty work.

Also fixes a gap found while preparing them. appchangelog-gen's LOCALE_DIRS had
no `pl` entry, so a `title_locales.pl` in the front matter was accepted and then
silently dropped. Polish shipped in ryanbr#1250 - during the v10.1.0 cycle - and that
release's notes DID supply a Polish title, so Polish users have been seeing the
English What's New card ever since. One line, verified by running the generator
and confirming the translated title lands in values-pl.

Credits follow ryanbr#736: third-party contributors by @handle, the maintainer's own
handles left out. Eleven people are credited - six for code, five for reports
that carried logs, screenshots or measurements.

Verification: front matter parses, five items, six locale titles matching the
previous release's set, appchangelog-gen accepts the file and exits 0, and
test_appchangelog_gen passes with the new locale. The generated AppChangelog
entries are deliberately NOT committed here - fork-release regenerates them as
part of the version-bump commit.
Folds in 129 upstream commits since the 10.1.0 sync and moves the fork's release
identity to 10.5.0 / build 230, the version line this tree now carries.

What arrives: training load (CTL/ATL/form), the resting-HR VO2max estimate
(ryanbr#1391), Apple Health write-back plus hourly step import with a 90-day backfill
(ryanbr#1432/ryanbr#1429), honest Oura reads (ryanbr#1384/ryanbr#1450/ryanbr#1467/ryanbr#1360), Power saving as its
own screen (ryanbr#1431), Polar PMD support, and the u32 decoder fixes behind ryanbr#869.

Fork-side resolutions, all recorded in docs/fork/decisions.md:

- android/ dropped wholesale again (22 delete/modify conflicts), per FORK_GUIDE.
- Upstream's in-memory per-day analyze cache (ryanbr#1005) is not carried; the
  persisted dayScanFingerprint/analysisInputRevision skip (v38-v40) stays,
  because it survives relaunch and is invalidated by the write-side revision.
  Upstream's v38-apple-step-hour is therefore registered as v41-apple-step-hour
  (v38-v40 are taken and SchemaOracleTests requires vN to match order).
- Every re-bounded read keeps limit: Int.max; the fixes in those same hunks
  (same-source gravity for ryanbr#1360, the Oura vendor-rate split) are taken in full.
- Power saving's Settings card and search entry are deleted in favour of the new
  screen, reachable from the More tab and the sidebar.
- altstore-source.json and docs/IOS.md keep the fork's versions.

Verification: Strand (macOS) and NOOPiOS build; StrandTests 2183 pass; every
Packages suite passes (WhoopStore 463 after the migration renumber, Analytics
1614, Protocol 622, Oura 204, Polar 25, Import 249, Design 98, SemanticMemory 19,
NoopLocalAccess 9); Tools 52 + linux-capture 212 pass; doc_comment_lint,
check-sideload-manifest (needed the healthkit background-delivery entitlement
declared) and i18n_audit --ci all exit 0 — the last of these is green here while
main is red on a pre-existing CoachSettingsView literal, now catalogued.

Not verified: BLE behaviour, which needs a strap.
Copilot AI balanced review requested due to automatic review settings August 20, 2026 11:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An upstream sync adds every upstream commit to the job's base..head range, and
four of them carry AI co-author trailers (ryanbr#869, ryanbr#1465, ryanbr#1467, ryanbr#1390 — all by
third-party contributors). The job therefore failed on the v10.5.0 merge for
messages that are not this fork's to rewrite: clearing them would change their
shas, drop their signatures and destroy the ancestry the next sync merges
against.

Skip any commit whose author email is not the fork identity, which is the scope
the job's own comment already claimed ("inherited from upstream, where they are
ryanbr's to keep"). A commit authored as DX23876 is audited exactly as before —
verified on the v10.5.0 merge range: 129 upstream commits skipped, the merge
commit audited clean, and the same range still fails when the filter is pointed
at one of the flagged authors.
@DX23876
DX23876 force-pushed the sync/upstream-10.5.0 branch from 76455ad to 95eba44 Compare August 20, 2026 13:03
@DX23876
DX23876 merged commit a7a9bdd into main Aug 20, 2026
20 checks passed
@DX23876
DX23876 deleted the sync/upstream-10.5.0 branch August 20, 2026 13:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants