Skip to content

perf(steps): persist the steps-calibration motion folds across launches - #2023

Merged
ryanbr merged 1 commit into
mainfrom
perf/steps-motion-cache-persist
Sep 9, 2026
Merged

ryanbr merged 1 commit into
mainfrom
perf/steps-motion-cache-persist

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 9, 2026

Copy link
Copy Markdown
Owner

What

StepsMotionCache stops the steps calibration re-folding sixty days of strap motion on every pass, but only within a process. A relaunch re-paid the whole fold. Measured on a worn 60-day library: persistSteps steps=32864ms cold against 3330ms warm, once per launch.

This persists the folds so the sixty-day fold is paid once per install rather than once per launch.

Why this cache and not the day-scan one

StepsEstimateEngine.dayMotionIntensity is a pure fold over one day's gravity: no profile field, no baseline, no toggle, no other stream, and timestamps never enter it. So there is nothing pass-global to carry across the process boundary.

The day-scan cache cannot do this. It keys on dayScanCacheConfigSig, which folds in baselines1 and the habitual-sleep terms, and the engine's own note says sleepConsistency (1-CV over 28 nights) and habitualMidsleepSec (a circular mean) both shift with any night moving. A persisted day scan would therefore invalidate wholesale on exactly the passes it would need to survive, and would cost the seven-pin schema lockstep to do it.

Fold version

cacheKey witnesses the inputs only. A day whose gravity has not moved keys identically across an app update, so an update that changed dayMotionIntensity would keep serving the pre-update volume until that day's stream happened to change. The payload header carries a fold version and a mismatch discards the lot. Bump it on any change to dayMotionIntensity.

Failure surface

It stays a derived cache that nothing else reads, so the whole surface is one re-fold:

  • missing, truncated, over-long, or written by an older fold: discarded, not repaired
  • a malformed line costs only its own day
  • a zero fold from a read that succeeded is still cached, so unworn gaps do not start re-reading their whole stream again

Shape

Storage is UserDefaults / SharedPreferences, not the record schema, so there is no migration.

Android keeps the analytics layer Context-free by threading accessors down from the caller, mirroring manualStepCoefficient / persistStepsCalibration. They default to no-ops, so an unwired caller keeps today's per-process behaviour. Load and save sit in the analyzeRecent wrapper under analyzeGate, never in analyzeRecentOnCpu, so the #1524 method budget is untouched.

Tests

Both platforms pin the same rendered payload as a literal, so a one-sided change to the header, the separators or the number rendering fails a test rather than quietly ending reuse after an update. 8 new cases per platform: pinned vector, round trip, zero fold, render stability, old fold version, unreadable payloads, per-line malformed handling, oversize rejection.

  • Android: :app:testFullDebugUnitTest green, 685 classes / 5776 tests / 0 failures, IntelligenceEngineJacocoBudgetTest included
  • Swift: StrandAnalytics does not build under WSL, so the serialisation logic was verified in an isolated swiftc harness and renders byte-identically to the Kotlin payload

Not verified here

The engine load/save wiring on both platforms needs CI, and the end-to-end win needs a device: the number to look for is analyzeRecent stepsMotion reused=60/60 on the first pass after a relaunch, where it currently reads folded=60.

@ryanbr
ryanbr force-pushed the perf/steps-motion-cache-persist branch from 11d26bd to d64aeca Compare September 9, 2026 09:32
@ryanbr

ryanbr commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Re-review of my own branch found two things, both now fixed in d64aeca.

1. The store was written on every pass, on an unverified assumption. The save ran unconditionally and the comment claimed the store would coalesce an identical value away. That is true of SharedPreferencesImpl (it skips when existingValue.equals(v)), but I had not verified it for UserDefaults, and the claim was load-bearing: these are the back-to-back passes an offload storm is made of, so it meant a ~4KB write per pass to store what was already there. Now guarded in code against the last-written payload, which makes it true by construction on both platforms instead of depending on what either store does.

2. Android latched the one-shot load even when no store was wired. stepsMotionCacheLoaded was set on the first pass regardless, so a caller using the no-op defaults would consume the single load and leave the rest of the process unpersisted. All three current callers are wired, so this was latent rather than live, but a fourth caller added later and running first would have quietly turned persistence off with nothing to show for it. The accessors are nullable now and the latch only fires when a store was actually consulted.

Both fixes lean on the same invariant, so it is pinned on both platforms: rendering is a fixpoint over a payload this build wrote, and a payload carrying a line this build drops re-renders differently so the cleaned version is written back once rather than re-parsed every launch.

Also checked and clear: the Swift engine is @MainActor so the flag cannot race; BackupSettings.WHITELIST is opt-in and the key is correctly absent, so this derived cache stays out of .noopbak; all three Android callers are wired; and nothing early-returns between the top of the pass and the steps block.

Android suite re-run green: 685 classes / 5778 tests / 0 failures. Swift serialisation re-verified in the isolated harness.

The calibration fits one coefficient from sixty days of strap motion, and
StepsMotionCache already stops it re-folding all sixty on every pass within a
process. The cache was in-memory only, so a relaunch re-paid the whole fold:
32864ms cold against 3330ms warm on a worn 60-day library, once per launch.

Persist it. dayMotionIntensity is a pure fold over one day's gravity stream, so
unlike the day-scan cache there is no pass-config to carry: that one keys on
dayScanCacheConfigSig, which folds in baselines1 and the habitual-sleep terms,
and sleepConsistency (1-CV over 28 nights) and habitualMidsleepSec (a circular
mean) both shift with any night moving, so a persisted day scan would
invalidate wholesale on exactly the passes it would need to survive.

Adds a fold version to the payload, because cacheKey witnesses the inputs only:
a day whose gravity has not moved keys identically across an app update that
changed the fold, and without the version check the pre-update volume would be
served until that day's stream happened to change. Bump it on any change to
dayMotionIntensity.

The payload stays a derived cache that nothing else reads, so the whole failure
surface is one re-fold. A payload that is missing, truncated, over-long or
written by an older fold is discarded rather than repaired, and a malformed
line costs only its own day.

Storage is UserDefaults / SharedPreferences, not the record schema, so there is
no migration. Android keeps the analytics layer Context-free by threading
accessors down from the caller, mirroring manualStepCoefficient and
persistStepsCalibration; they default to no-ops, so an unwired caller keeps
today's per-process behaviour. Load and save sit in the analyzeRecent wrapper
under analyzeGate, never in analyzeRecentOnCpu, so the #1524 method budget is
untouched.

The store is written only when the payload moved. A pass that reused every day
renders the string already stored, and those are the back-to-back passes an
offload storm is made of, so the write is skipped rather than relying on the
store to discard it. The Android accessors are nullable and the one-shot load
latches only when a store was actually consulted, so a caller that does not
wire them cannot spend the single load on nothing and leave the rest of the
process unpersisted.

Both platforms pin the same rendered payload as a literal, so a one-sided
change to the header, the separators or the number rendering fails a test
rather than quietly ending reuse after an update.
@ryanbr
ryanbr force-pushed the perf/steps-motion-cache-persist branch from d64aeca to decd9f1 Compare September 9, 2026 09:37
@ryanbr
ryanbr merged commit 743b8a8 into main Sep 9, 2026
16 checks passed
@ryanbr
ryanbr deleted the perf/steps-motion-cache-persist branch September 9, 2026 09:50
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.

1 participant