Repository navigation
Re-fold a day's motion only when that day's gravity moved - #2015
Merged
Merged
Conversation
…oved The steps calibration fits one coefficient from sixty days of strap motion, and it re-folded all sixty on EVERY analyzeRecent pass. Each day meant a `resolveDayOwner` plus a gravity read capped at 200,000 rows, so a worn library paid millions of materialised rows per pass to re-derive numbers that had not moved. The phase does not scale with the days being re-scored, since it always reads the same sixty days regardless, which is the property that made it hard to reason about: a two-day pass can cost more than a twenty-one-day one. `StepsEstimateEngine.dayMotionIntensity` is a pure fold over one day's gravity stream. Nothing else reaches it: no profile field, no baseline, no toggle, no other stream. So a day is now re-folded only when its own gravity witness moved. The witness is a COUNT and a MAX over `gravitySample` for that day and device, on the same index the read walks, so a hit replaces a 200,000-row read with a single-row aggregate. Deliberately narrower than the existing `dayStreamFingerprint`, which also counts HR, R-R, respiration, SpO2, steps, skin temp and sleep state: an ordinary HR offload must not invalidate a motion volume it cannot have changed. The cache is in-memory and per-process, exactly like the day-scan cache it sits beside, and needs no config signature for the reason above. It never persists and never crosses the .noopbak boundary; a relaunch pays the full fold once. That is the intent, because the cost being closed is the repeat within a process, where an offload storm fires passes back to back. A zero fold is cached too: storing only the days that moved would leave every unworn gap re-reading its whole stream forever to rediscover that it is empty, which is most of the window on the sparse libraries this is worst for. Behaviour is unchanged. `motionByDay` keeps the same `m > 0` filter, so the calibration points, the estimates and #1816's banked-motion flag are all built from the same values as before. On Swift a fingerprint that cannot be read bypasses the cache in both directions rather than keying on (0, 0), which a genuinely empty day also produces. The second half is why this went unnoticed. #1538 added a per-phase cost line, but it brackets the day loop and is emitted the moment that loop returns, so every phase after it was outside the tally: the steps calibration sat 700 lines past the last number the pass printed. Swift now stamps each post-loop phase and renders them in execution order beside `re-score: done`. Android measures less, and says so. `analyzeRecentOnCpu` sits 210 bytes under the JaCoCo method-size ratchet, and marking its phases costs about 467. That ratchet's own note says to extract rather than raise it, and an extraction is a larger change than this one should carry, so Android brackets the persist helper it can afford and the shared line carries an explicit scope, rather than reporting a `postLoop` total it never measured. Extending it needs that extraction first. The ordered-operations guard in the budget test is repinned rather than loosened: the two new diagnostics are pinned in their own positions, discriminated by argument expression because the guard masks string literals. A new runtime case measures the actual claim, that a second pass over unchanged days reads the witness for all sixty and the gravity stream for none. Mirrored tests both platforms; the Android analytics suite is 2,089 green. Cost noted, not hidden: owner resolution still runs per day. On the default single-device install it short-circuits without a store read, but a multi-strap install still pays sixty LIMIT-1 probes, as it did before. The steps phase was identified by @DX23876.
…re projections live Re-review of the motion cache found the Android witness split across two DAO queries, a COUNT and a MAX. Two reads let an insert land between them and return a count from before it beside a newest-timestamp from after: a key describing a state the day was never in. Harmless in the direction that matters (a torn key misses and re-folds), but it is a witness whose whole job is to describe one moment, and Swift already read both columns in a single aggregate. Both columns now come from one query on Android too, returning a `GravityWitness` projection. That also halves the per-pass round trips, sixty instead of a hundred and twenty, on exactly the path this change exists to make cheaper. The two-query shape was copied from `hrFingerprintWindow`, which has the same torn-read property for the same reason and is worth revisiting separately. `GravityWitness` sits in Entities.kt beside `HrWindowStats`, which is where query-result projections live, rather than at the foot of the DAO. Android analytics + data suites: 2,488 tests green.
…epends on A day is re-folded only when its gravity witness moves, which is sound only while a day's gravity cannot change underneath an unchanged count and newest timestamp. Re-review traced every route that touches `gravitySample` and found the argument holds: inserts conflict-ignore on `(deviceId, ts)`, the timestamp heal only DELETEs out-of-range rows, `reKeyGravity` rewrites `deviceId` and nothing else, and both reads are `ORDER BY ts ASC`. Nothing rewrites a timestamp or a vector in place, so a day cannot be reordered or revalued without moving the count. One link in that chain was load-bearing and unpinned: the conflict strategy. Were a gravity insert ever to REPLACE rather than IGNORE, the vectors under a day would change while its count and newest timestamp held still, and the cache would serve a motion volume for data it no longer describes. Nothing would fail. No read errors, no wrong row count, just a quietly stale number feeding the step estimate. Swift pins it behaviourally in a new `GravityWitnessTests` against a real store: re-inserting a second already banked leaves both the witness and the stored vector alone, an append moves both halves, a backfill moves only the count, and the witness stays scoped to one device and one window. An empty window reports (0, 0) rather than failing, which is the answer the zero-fold caching relies on. Kotlin cannot reach its DAO from a JVM unit test (Room's annotations are not retained at runtime and this module has no Robolectric or in-memory Room), so the twin asserts the declaration in source, the same shape the JaCoCo budget guard already uses for its own invariants. Verified by negative control: flipping the annotation to REPLACE fails the test with the reason attached, so it discriminates rather than merely agreeing. Also renames a test method that read as one word. Android analytics + data suites: 2,489 tests green.
…he read failed Found by auditing the two motion loops against each other. Swift's store reads throw and this block wraps every one in `try?`, so a failed gravity read has always collapsed to an empty list and a zero volume. That cost one pass and corrected itself on the next. Caching it made it cost every pass until that day's gravity happened to change: a transient store error turned into a stale zero feeding the step estimate for the life of the process, with nothing failing to show it. The read now keeps its optionality. A nil is a read that could not be made and is folded fresh without being cached; an empty array is a day that genuinely banked nothing and is cached, which is what stops an unworn gap re-reading its whole stream every pass. Same shape as the witness read directly above it, which already bypassed the cache in both directions when it could not be read. Kotlin has no such branch and does not need one: its repo reads propagate, so a failure aborts the pass before anything is written. That divergence is now stated at the call site, because the obvious "fix" of adding a catch there would swallow the abort and start caching exactly the zeros this change stops trusting. Also checked while comparing: the read cap is 200,000 on both sides (Swift's literal, Kotlin's STREAM_LIMIT), and the witness types differ only as Kotlin's 32-bit Int forces elsewhere, matching `AnalyzeRecentDayCache`. Android analytics + data suites: 2,489 tests green.
DX23876
added a commit
to DX23876/noop
that referenced
this pull request
Sep 16, 2026
Thirty real conflicts. The Android tree is dropped again per FORK_GUIDE; every other hunk was resolved by asking which mechanism this fork actually runs, not by preferring a side. Taken from upstream: • Protocol: ryanbr#2017/ryanbr#2018/ryanbr#2020 — the 5/MG historical optical and raw-IMU layouts, and the v26 window's ABSOLUTE base code beside its deltas. Its migration lands as `v57-ppg-waveform-base-code` (upstream's v44), since this fork's sequence is nine ahead of theirs. • BLE diagnostics ryanbr#1998-ryanbr#2003: a bond end that says WHY, named BLE integers, a session-budgeted hex dump, and "we hold the link" only when the OS says so. • Battery: ryanbr#1951 stop asking a 5/MG for a reading it refuses; ryanbr#1959 poll 0x2A19 for an unbonded one. • Scoring: ryanbr#1978/ryanbr#1982/ryanbr#1990 the RHR/HRV baseline gates, ryanbr#1977 the strain denominator's domain, RMSSD withheld on an over-counted night, ryanbr#1943 the resting-HR bin gate, the closed final HR/HRV window, HR-recovery requiring one CONTINUOUS effort, ryanbr#1984 nights bridged under minSleepMin, ryanbr#2015 motion re-folded only when that day's gravity moved, ryanbr#2016 a provisional Rest. • Charts ryanbr#2004/ryanbr#2005/ryanbr#2007/ryanbr#2009/ryanbr#2011 and ryanbr#1995's hero-ring detail route, ryanbr#1164's "Pending sync" caption under Rest. • Widgets: the heart-rate widget with its trace (ryanbr#1957/ryanbr#1964) and the widget cost counters. • Health: ryanbr#1503 — the Apple Health external UUID drops the strap id, so a re-pair no longer strands every prior record, plus the one-off sweep that reclaims the stranded ones; ryanbr#1205 GPS routes read back from Apple Health. • Storage: rolling retention for ppgWaveformSample, the skin-temp absolute backfill outside the 21-night window, per-table byte estimates. • Units split (body vs exercise distance), per-day wake overrides, the wind-down nudge pinned to its own day, and the German Sie→du conversion (29 entries, merged per LOCALE so the fork's own de edits stayed). Kept on the fork's side, with the reason: • ryanbr#1005's in-memory `dayScanCache` stays out, again: the persisted `DayScanFingerprint` survives relaunch and its key folds the per-day weight and max HR upstream's does not. • The steps-motion cache stays PERSISTED for the same reason. Upstream's failed-read-vs-empty-day distinction IS adopted into it — on a persisted cache a stale zero from a read error would outlive the process that made it. • Every stream read in the day loop keeps `limit: Int.max`. All five are uncapped together; taking StreamReadCap for three of them would leave the loop half-capped, which is worse than either choice. • The coach engine. Upstream's K1-K15 layer is not adopted: its single 40-row `coachMessage` table, its tool-less pre-baked context and its own brief are behind what this fork already ships. Its ADDITIVE half is kept — the on-device voice input, the Siri "Ask Coach" intent, the brief scheduler and brief widget, and streaming for the OpenAI-compatible providers this fork's Anthropic-only StreamingToolClient does not cover. They are wired up in the next commit; `CoachBriefWidget` is deliberately not registered until then. • Liquid's `DomainTheme` labels, value-sampled Charge tint, `LiquidScoreGauge` and `TodayCardSurface`; classic Today's ring order and `LiveEffort.today`. • RootTabView's four-tab shell and `MoreCatalog`: Coach hangs on content here, not on a tab. Two duplicate mechanisms collapsed rather than carried: • `ChargeBreakdownWiring` (upstream) and `ChargeBreakdownFormat` (fork) are the same extraction done twice. The fork's survives, Coupled now reads it, and upstream's TEST is kept — it is coverage this path never had. • Upstream's `CoachMessageStore` is dropped; `CoachConversationStore` already autosaves the whole multi-conversation history. One defect found while resolving, fixed here: `LiveEffort.today` read HR with no explicit limit, so `hrSamples`' 8000-row default truncated it — at ~18k rows a day that covered about ten hours and the live Effort silently stopped climbing. It failed safe (`effectiveEffort` takes the max), which is why it went unseen. `Live5RestFrozenTests` is removed by upstream, which replaces it with `Live5NoGravityRestChainTests`. `local_day_windows_oracle.json` moves out of upstream's android path into `Tests/StrandAnalyticsTests/Oracles/` — NOT `Fixtures/`, which this repo's .gitignore excludes everywhere. Verified: StrandAnalytics 2233, WhoopStore 627, WhoopProtocol 700, StrandImport 293, StrandDesign 120, NoopLocalAccess 14, SemanticMemory 19, OuraProtocol 209 and PolarProtocol 25 all green; macOS and iOS both build; doc_comment_lint clean; `i18n_audit --ci` byte-identical to its pre-merge output (it is red on main for reasons that predate this, and the seven new widget literals were given all nine locales so the gate did not get worse). Six StrandTests were ALREADY failing on main before this merge and still are — `CoachMyLogsAndZonesTests` (the tool census pin was not bumped when the Hevy tools landed) and five in `RawPhysiologyUnionTests`. Every input to them — CoachTools.swift, ToolConsent.swift, Repository.swift and both test files — is byte-identical to main, so this merge neither caused nor fixed them. They went unnoticed because app-build.yml last ran on 2026-08-25. Analysis migration required: YES. The scoring changes above make stored derived values stale; the recipe bump and its resumable migration are the next commit.
DX23876
added a commit
to DX23876/noop
that referenced
this pull request
Sep 16, 2026
The 2026-09-09 sync moves scored VALUES, not just how they are presented, so a `dailyMetric` row computed under v2 no longer means what the current code would produce from the same inputs. Nine changes do it, each on some subset of nights: the RHR and HRV baseline gates (ryanbr#1978/ryanbr#1982/ryanbr#1990), the strain denominator's domain check (ryanbr#1977), RMSSD withheld on a night whose R-R over-counts, the ryanbr#1943 resting-HR bin gate, the closed final HR/HRV window, heart-rate recovery requiring one CONTINUOUS effort, ryanbr#1984's bridged fragmented nights, and ryanbr#2015's gravity-conditional motion re-fold. So: `Analysis migration required: yes`. `currentAnalysisRecipeVersion` 2 → 3. No new migration machinery — the existing one already has the properties CLAUDE.md asks for. `prepareAnalysisRecipe` reads the cursor, sees `.migrate(2, 3)` and runs `runAnalysisMaintenance`, which is a forced `analyzeRecent(maxDays: 21, allowDayReuse: false)` that marks the recipe cursor ONLY after the pass returns, so an interrupted or failed run re-runs rather than silently claiming the rescore. It re-derives; it deletes no raw samples and no user sleep or workout corrections. 21 nights is the narrowest interval provable rather than a round number: every one of the nine is a per-night derivation, and the engine's own scoring window is 21 nights. Older nights keep their v2 values until re-scored for another reason — the same contract v1 → v2 shipped with. Deliberately NOT in this bump: the skin-temp absolute backfill for nights OUTSIDE that window. It is its own resumable walker, fill-only on a NULL column, reached on purpose from the Test Centre rather than on launch, so folding it in would widen the migration to the whole library for a value the rescore does not compute anyway. The decision tests are extended where they were weakest: the three existing cases are written against `current`, so they stay green THROUGH a bump without ever witnessing one. Two literal pins are added — that a v2 install migrates as `2 → 3`, and that an install already at 3 (or rolled back from a 4) never rescores on relaunch, which is the failure mode using a marketing or build number here would cause and which is invisible until someone's phone spends twenty minutes re-scoring after a cosmetic update. The first pin is MEANT to fail on the next bump: that failure is the prompt to answer the migration question for whatever the bump carries. Verified: StrandTests 2950, 0 failures; all nine packages green; macOS and iOS both build; doc_comment_lint clean. Recorded in docs/fork/decisions.md. Analysis migration required: yes (this commit is it).
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.
Problem
The steps calibration fits one coefficient from sixty days of strap motion, and it re-folded all
sixty on every
analyzeRecentpass. Each day is aresolveDayOwnerplus agravitySamplesread capped at 200,000 rows, so a worn library pays millions of materialised rows per pass to
re-derive numbers that have not moved.
It also does not scale with the days being re-scored, since it always reads the same sixty
regardless, so a two-day pass can cost more than a twenty-one-day one. Identical on both platforms
(
IntelligenceEngine.swiftandIntelligenceEngine.kt); Swift hoists the reads off the main actor,which fixes the jank but not the work, and Android has no hoist at all.
Change
StepsEstimateEngine.dayMotionIntensityis a pure fold over one day's gravity stream. Nothing elsereaches it: no profile field, no baseline, no toggle, no other stream. So a day is re-folded only
when that day's gravity witness moved.
gravityFingerprint/gravityFingerprintWindow:COUNT(*)andCOALESCE(MAX(ts), 0)overgravitySamplefor one device and window, on the same(deviceId, ts)index the read walks. A hit replaces a 200,000-row read with a single-row aggregate. Both columns
come from ONE query on both platforms, so an insert cannot land between them and produce a key
describing a state the day was never in.
dayStreamFingerprint, which also counts HR, R-R, respiration,SpO2, steps, skin temp and sleep state. An ordinary HR offload must not invalidate a motion volume
it cannot have changed. It is not a new signal: this pair is exactly the
gsegment thatfingerprint already computes for gravity, taken on its own.
Why count and newest-timestamp are enough
dayMotionIntensitysums the distance between CONSECUTIVE samples, so it depends on the set ofx/y/z values and on their order. Order is fixed by
ORDER BY ts ASCin both reads. That leaves onequestion: can a day's gravity change without moving either column? Only by rewriting a timestamp in
place, and nothing does. Gravity rows are insert-only (
StreamStore/ the offload path), thetimestamp heal exclusively DELETEs out-of-range rows, and an offload re-inserting an existing
tsis absorbed by the conflict key without changing a value. Insert moves the count, delete moves the
count. There is no in-place
tsmutation to reorder a day underneath an unchanged witness.pass-config signature for the reason above. It never persists and never crosses the
.noopbakboundary; a relaunch pays the full fold once. That is the intent: the cost being closed is the
repeat within a process, where an offload storm fires passes back to back.
re-reading its whole stream forever to rediscover that it is empty, which is most of the window on
the sparse libraries this is worst for.
No behaviour change.
motionByDaykeeps the samem > 0filter, so the calibration points, theestimates and #1816's banked-motion flag are built from the same values as before. On Swift a
fingerprint that cannot be read bypasses the cache in both directions rather than keying on
(0, 0), which a genuinely empty day also produces; Kotlin's store surfaces no soft failure there,so a throw aborts the pass exactly as the gravity read already did.
Why nobody saw it
#1538 added a per-phase cost line, but it brackets the day loop and is emitted the moment that loop
returns. Every phase after it was outside the tally, and the steps calibration runs about 700 lines
past the last number the pass printed. Swift now stamps each post-loop phase and renders them in
execution order beside
re-score: done:Android measures less, and says so.
analyzeRecentOnCpusits 210 bytes under the JaCoComethod-size ratchet (55,490 against 55,700) and marking its phases costs about 467. That ratchet's
own note says to extract rather than raise it, and an extraction is a larger change than this one
should carry, so Android brackets the persist helper it can afford. The shared line takes an
explicit
scoperather than reporting apostLooptotal it never measured. Extending Android tothe rest of the post-loop needs that extraction first, and
analyzeRecentOnCpuis untouched by thisPR.
Tests
StepsMotionCacheandAnalysisPhaseTallysuites on both platforms, pinned against thesame literals, covering each input that must invalidate, the empty-day key, the owner boundary,
execution order, the scope, and the clock-skew floor that keeps Swift's round-half-away-from-zero
and Kotlin's round-half-up agreeing.
IntelligenceEngineJacocoBudgetTestis repinned, not loosened: the two new diagnostics taketheir own positions in the ordered-operations guard, discriminated by argument expression because
that guard masks string literals.
unchanged days reads the witness for all sixty and the gravity stream for none.
compiled standalone harness (no macOS available here); the Swift suites rest on CI.
Cost noted, not hidden
Owner resolution still runs per day. On the default single-device install it short-circuits without
a store read (#970), but a multi-strap install still pays sixty LIMIT-1 probes, exactly as before.
The first pass in a process still folds all sixty days.
The steps phase was identified by @DX23876.