Repository navigation
perf(steps): persist the steps-calibration motion folds across launches - #2023
Conversation
11d26bd to
d64aeca
Compare
|
Re-review of my own branch found two things, both now fixed in 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 2. Android latched the one-shot load even when no store was wired. 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 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.
d64aeca to
decd9f1
Compare
What
StepsMotionCachestops 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=32864mscold against3330mswarm, 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.dayMotionIntensityis 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 inbaselines1and the habitual-sleep terms, and the engine's own note sayssleepConsistency(1-CV over 28 nights) andhabitualMidsleepSec(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
cacheKeywitnesses the inputs only. A day whose gravity has not moved keys identically across an app update, so an update that changeddayMotionIntensitywould 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 todayMotionIntensity.Failure surface
It stays a derived cache that nothing else reads, so the whole surface is one re-fold:
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 theanalyzeRecentwrapper underanalyzeGate, never inanalyzeRecentOnCpu, 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.
:app:testFullDebugUnitTestgreen, 685 classes / 5776 tests / 0 failures,IntelligenceEngineJacocoBudgetTestincludedStrandAnalyticsdoes not build under WSL, so the serialisation logic was verified in an isolatedswiftcharness and renders byte-identically to the Kotlin payloadNot 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/60on the first pass after a relaunch, where it currently readsfolded=60.