Skip to content

Stop an active trace from disabling the re-score reuse cache - #1575

Merged
ryanbr merged 4 commits into
mainfrom
perf/1005-cache-under-trace
Aug 24, 2026
Merged

ryanbr merged 4 commits into
mainfrom
perf/1005-cache-under-trace

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 24, 2026 •

Copy link
Copy Markdown
Owner

Turning on a Test Centre trace disabled the #1005 per-day reuse cache outright, so the diagnostic
cost roughly what it was measuring
.

From a WHOOP 4.0 log with sleep + HRV traces on:

analyzeRecent dayCache reused=0/0 size=0 days=21     ← every pass
analyzeRecent cost prep=211663ms score=38583ms       ← 3.5 min in one pass

13 minutes of heavy CPU in a ~70-minute session, on battery, 84% of it in the windowed store
reads — all 21 days re-read and re-scored on every pass, because dayCacheEligible required all
three per-day trace sinks to be null.

The reason was real: a reused night would not re-emit its trace lines. The fix is the shape the
always-on lines already use — record and replay.

The two platforms needed very different work

why
Swift Already safe by construction. DayScan carries sleepTrace/hrvTrace/stepsTrace, a hit appends the whole scan, and the main-actor loop replays all three. The gate was the only thing costing the re-score.
Kotlin Emits its trace inline during pass 1, so it needed per-day recorders that append-and-forward — mirroring the existing dayDiag wrapper — plus replay on a hit.

Same contract, different amount of code, because the engines emit diagnostics at different points.

A correctness catch found while wiring it

The per-window HRV detail is emitted only for dayStart == nowLocalMidnight. Once trace lines
are replayed, the night cached as "today" would keep replaying its detailed trace after midnight —
when a fresh scan would emit only the one-line summary. That silently breaks the cache's whole
promise, that a reused night is indistinguishable from a freshly-scored one.

The flag is now part of the per-day key on both platforms, so exactly the one affected day
invalidates at rollover. Cost: one day's re-score per midnight, and only while a trace is on.

The new parameter is deliberately not defaulted. #1567 — merged earlier today — was caused by
precisely that: a defaulted parameter a caller silently omitted. Every call site now has to state
its answer.

Parity

Diffed, not assumed:

Swift Kotlin
cache key "\(owner)|\(hrCount):\(hrMaxTs):\(anchor):\(hrvWindowDetail ? "d" : "s")" "$owner|$hrCount:$hrMaxTs:$anchor:${if (hrvWindowDetail) "d" else "s"}"
gate let dayCacheEligible = true val dayCacheEligible = true
emit order sleep → hrv → steps sleep → hrv → steps
sink collect-only collect-only

A divergence I introduced and then removed. The first cut had the Kotlin recorders append and
forward live, so a freshly-scored night interleaved its sleep and HRV lines while a reused night
emitted them grouped — the same night reading differently depending on a cache hit, which breaks the
exact promise the cache is built on. Swift has always been collect-only
(traceSink = { sleepTrace.append($0) }) with one grouped emit. Kotlin now matches: recorders
collect, and both the hit and miss paths emit through the same replayDayTraces, so the two
cannot drift.

Consequence worth stating: Kotlin's live trace lines now land grouped at the end of each day's
block
rather than interleaved through it. Same content, and the platforms now agree.

Bytecode budget

analyzeRecentOnCpu sits within ~100 bytes of the JVM's 64 KB per-method ceiling. The first cut
pushed the JaCoCo-instrumented size to 63954 against a 61535 budget, so:

  • the recorders are built by a small class rather than inline lambdas;
  • the replay is a helper;
  • the (pre-existing, behaviourally unchanged) steps raw-counter emit is lifted into a helper.

IntelligenceEngineJacocoBudgetTest (#1524) caught it and is what the sizes were tuned against. No
baseline was raised.

Verification

  • :app:testFullDebugUnitTest --no-build-cache --rerun-tasks — 4274 tests, 0 failures.
  • JaCoCo budget test passes; confirmed it fails without the extractions.
  • doc_comment_lint.py OK.
  • Swift twin tests updated + a matching rollover case; they run on swift-packages.yml.
  • ⚠️ app-build required before merge — Strand/Data/IntelligenceEngine.swift is app-target.
  • Not yet measured on hardware. The proof is a strap log with traces on showing reused=~20/21
    instead of reused=0/0, and prep collapsing with it. That is the number to check, not the tests.

Honest coverage note

The engine-level behaviour — that a reused night under an active trace replays the identical lines —
is not unit-tested. There is no engine-with-repository harness in the tree, and building one is
its own piece of work. What is tested is the key contract; the rest rides on the device measurement
above.

ryanbr added 4 commits August 24, 2026 12:59
Turning on a Test Centre trace disabled the #1005 per-day reuse cache outright, so the
diagnostic cost roughly what it was measuring. From a WHOOP 4.0 log with sleep + HRV
traces on:

    analyzeRecent dayCache reused=0/0 size=0 days=21     (every pass)
    analyzeRecent cost prep=211663ms score=38583ms

13 minutes of heavy CPU in a ~70-minute session, on battery, 84% of it in the windowed
store reads — every one of 21 days re-read and re-scored on every pass, because
`dayCacheEligible` required all three per-day trace sinks to be null.

The reason was real: a reused night would not re-emit its trace lines. The fix is the
shape the always-on lines already use — record and replay.

Swift needed almost nothing: `DayScan` already carries sleepTrace/hrvTrace/stepsTrace, a
hit appends the whole scan, and the main-actor loop replays all three. It was safe by
construction and the gate was the only cost. Kotlin emits its trace inline during pass 1,
so it needed per-day recorders that append-and-forward, mirroring the existing `dayDiag`
wrapper, plus replay on a hit.

One correctness catch found while wiring it: the per-window HRV DETAIL is emitted only for
`dayStart == nowLocalMidnight`. Once trace lines are replayed, the night cached as "today"
would keep replaying its detailed trace after midnight, when a fresh scan would emit only
the summary. That flag now forms part of the per-day key on both platforms, so the one
affected day invalidates at rollover. The new parameter is deliberately NOT defaulted:
#1567 was caused by exactly that, a defaulted parameter a caller silently omitted.

`analyzeRecentOnCpu` is within ~100 bytes of the JVM's 64 KB per-method ceiling, so the
recorders are built by a small class and the replay + the (pre-existing, unchanged) steps
raw-counter emit are lifted into helpers. IntelligenceEngineJacocoBudgetTest caught this
and is what the sizes were tuned against; no baseline was raised.
…miss

Re-review. My recorders appended AND forwarded live, so a freshly-scored night interleaved
its sleep and HRV lines as analyzeDay ran, while a REUSED night emitted them grouped by
channel. The same night read differently depending on a cache hit — which breaks the exact
promise the cache is built on, and the one I quoted defending it.

It also diverged from Swift, whose sink has always been collect-only

    let traceSink = sleepTraceActive ? { sleepTrace.append($0) } : nil

with a single grouped emit in the main-actor loop. So Swift was already consistent and
Kotlin was about to stop being.

The recorders now collect only, and both the miss and hit paths emit through the same
`replayDayTraces`. One function, so the two can no longer drift. Emit order is
sleep -> hrv -> steps on both platforms, matching Swift's existing loop order.

This does change Kotlin's live trace ordering: those lines now land grouped at the end of
each day's block rather than interleaved through it. The content is identical, the
platforms now agree, and a cached night is genuinely indistinguishable from a fresh one.

Checked that no path drops collected lines: the cache-hit `continue` emits first, and the
<200-HR-sample `continue` runs before analyzeDay, so nothing has been collected yet.
Re-review, both small and both in the same spirit as the PR.

The per-day trace recorders were allocated at the top of the loop, before the cache-hit
`continue` — so every reused day built three ArrayLists and up to three closures and threw
them away. That is garbage on precisely the path this change exists to make cheap. They
now sit after the hit check, still well ahead of the analyzeDay call that uses them.

And `sleepSink?.let { _ -> { l -> sleep.add(l) } }` reads as though it captures the sink
when it is only testing it for null. Written as the null test it is.

Also checked this pass and found nothing to change: on both platforms and on both the hit
and miss paths, the day's always-on diag lines are emitted BEFORE its trace lines, so the
per-day block has the same shape everywhere.
Found while answering "so this PR helps with battery?" — which forced the question of who
benefits and who pays.

The rollover guard added the detail flag to the per-day cache key unconditionally. With the
HRV trace OFF no detail line is ever produced, so the flag describes nothing — but it still
flipped "d" to "s" at midnight and invalidated yesterday. That charged EVERY user one extra
day's re-score per rollover to protect lines they never see, on the default path this change
is not otherwise meant to touch.

Gated on the trace being active on both platforms. The default path is now byte-for-byte what
it was, and the cost is what the PR claims: paid only while a trace is on.

Note this is the cache KEY only. The `hrvWindowDetail` argument that analyzeDay uses to decide
whether to EMIT the detail is unchanged — that one is about what a fresh scan produces, not
about what invalidates a reused one.
@ryanbr
ryanbr merged commit 3103e32 into main Aug 24, 2026
16 checks passed
@ryanbr
ryanbr deleted the perf/1005-cache-under-trace branch August 24, 2026 01:39
ryanbr added a commit that referenced this pull request Aug 24, 2026
…ebase of #1568)

@pipiche38's #1568 rebased onto current main, with one integration fix on top.

Their three commits are preserved with their authorship. #1568 was green and CLEAN, but the green was ~10 hours stale and the conflict was never textual: merging their branch with main pushed analyzeRecentOnCpu 30 bytes past the JaCoCo budget, because their SpO2 candidate branch and #1575's trace recorders both add to a method already within ~100 bytes of the JVM's 64 KB ceiling. Neither exceeded it alone.

Verified on the integrated tree: 4283 Android tests 0 failures, release lint and i18n clean, and app-build green on macOS and iOS — the first compile of the integrated Swift, since their local Xcode builds predated the merge.
pipiche38 pushed a commit to pipiche38/noop that referenced this pull request Aug 24, 2026
Integration fix for @pipiche38's ryanbr#1568, rebased onto current main.

Their branch passed CI and so did main, but the MERGE of the two failed:

    analyzeRecentOnCpu instrumented Code length 61565 exceeds the 61535-byte budget

Thirty bytes. `analyzeRecentOnCpu` sits within ~100 bytes of the JVM's 64 KB per-method
ceiling, and their device-conditional SpO2 candidate branch plus the trace recorders from
ryanbr#1575 both add to it. Neither exceeded the budget alone — only together, which is invisible
to either PR's own CI. ryanbr#1568's checks were also ~10 hours stale by then, predating all six
commits main had gained, and GitHub reported the branch CLEAN because the conflict is not
textual.

Their 14-line branch becomes a call to a new private `spo2CandidateMean`. Behaviour is
unchanged: same two paths, same `?.first`, same null cases, still gated by the caller's
toggle, still never written to `spo2Pct` and never scored.

Also worth recording for them: the "2 pre-existing failures in RecoveryDriversTest" in the
PR description do not reproduce. That class is 10/10 on main and 10/10 on their own head,
and their full suite is 4270/0 rather than the 2404/2 reported — a stale local Gradle/KSP
cache, which this repo has a documented history of. Nothing was broken.

Integrated tree: 4283 tests, 0 failures. Release lint, doc-comments and i18n clean.
xxblyxx added a commit to xxblyxx/noop that referenced this pull request Sep 1, 2026
…ache

Port of upstream ryanbr#1575 (Swift half). Turning on a Sleep/HRV/Steps trace
disabled the ryanbr#1005 per-day reuse cache outright, so every night was re-read
and re-scored on every pass — the diagnostic cost roughly what it was
measuring. Upstream measured `prep=211663ms score=38583ms` on a WHOOP 4.0
with two traces on: 13 minutes of CPU in a ~70-minute session, on battery.

The old reasoning was that a reused night would not re-emit its gate trace.
In Swift that was already false, and I verified it in THIS tree rather than
carrying upstream's claim about its own code: `DayScan` carries sleepTrace /
hrvTrace / stepsTrace, a cache hit does `out.append(cached.scan)`, and the
main-actor fold replays all three from the scan for every entry in `out`,
hits included. That replay is the load-bearing half — had it covered only
some of the three, flipping the gate would silently drop trace lines. It
covers all three, so the gate was the only cost.

One correctness catch that comes with it: the per-window HRV DETAIL is
emitted only for `dayStart == nowLocalMidnight`. Once traces are replayed,
the night cached as "today" would keep replaying detail after local
midnight, when a fresh scan would emit only the one-line summary — silently
breaking the cache's whole promise that a reused night is indistinguishable
from a freshly-scored one. The entitlement is now part of the per-day key,
so exactly the one affected day invalidates at rollover.

Kept upstream's `hrvTraceActive &&` gate on that flag. With the HRV trace
off the flag describes nothing, but it would still flip at midnight and
invalidate yesterday — charging every user an extra day's re-score to
protect lines they never see. The parameter is deliberately NOT defaulted:
upstream's ryanbr#1567 was caused by exactly that, a defaulted parameter a caller
silently omitted, which quietly changed the skin-temp scale.

Not load-bearing for the symptom under investigation — this device's only
Test Centre key is `testcentre.active.workouts = False`, so no trace is
active and the 2026-08-26 numbers are unaffected. Taken now because the
analyze-pass-cost work turns traces ON to investigate; left alone, the
instrument would dominate the measurement it exists to take.

Verification: `xcodegen generate`; `xcodebuild -scheme NOOPiOS -destination
generic/platform=iOS build` BUILD SUCCEEDED; `xcodebuild -scheme Strand
-destination platform=macOS test` 1221 tests, 1 skipped, 0 failures;
`swift test` in Packages/StrandAnalytics 1501 tests, 0 failures, including
two new AnalyzeRecentDayCacheTests cases pinning that the detail flag
invalidates and that it is inert with the trace off; doc_comment_lint and
i18n_audit clean. No CI covers the app targets, so these local runs are the
gate. Not yet observed on device.

Kotlin twin: NOT written, and Android keeps the old gate. Upstream's Kotlin
port is 110+ lines of per-day recorder plumbing, needed because that engine
emits its trace inline during pass 1 rather than collecting it onto a scan —
a genuinely different change, not a mirror of this one. It cannot be
compiled or tested on this machine (no JDK, no Android SDK) and no CI covers
it, so writing it blind alongside a correctness-sensitive cache-key change
would be worse than leaving the platforms explicitly out of step here. The
key strings are already documented as not required to match across
platforms (the cache is in-memory and per-platform); what must match is the
invalidation contract, and Android's is unchanged because its gate is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017g6WqitM2Y76kkYsHzpXab
xxblyxx added a commit to xxblyxx/noop that referenced this pull request Sep 1, 2026
…ntry

Two claims from `4104608d` and `a2eac10d`, neither of which is a speedup —
the entry says so explicitly, because the obvious misreading is "the perf
fix is pending validation". Commits 1 and 1b make the pass measurable and
remove a latent trap; the morning is expected to feel exactly as slow as it
did on 2026-08-26.

(a) The three new diagnostic lines are readable and DECISIVE on a real
morning — that they appear on every completed pass, pair with `re-score:
done`, and actually separate a pass-signature drop from per-day eligibility,
which is the ambiguity that left the 2026-08-26 diagnosis resting on
inference. Installed after that morning's sync, so the canonical launch-pass
measurement is the next one.

(b) The ryanbr#1575 port did not break trace replay. This is the larger debt and
it is entirely unobserved: no Test Centre trace has ever been active on this
device, so the path Commit 1b now depends on — a cache HIT replaying three
trace arrays — has never once run. The package tests pin the key's
invalidation contract, not the replay. The owner was advised not to turn
traces on (log noise, 2,000-line tail), which is right for (a) and is
exactly what leaves (b) unchecked, so (b) needs its own deliberate session
rather than riding along on a normal morning.

`passes-if` decides NOW, before the data exists, what each outcome of the
second pass's `DROPPED` line means — including the two that REFUTE the
follow-up plan's premise (a `sig changed:` naming something other than the
three sleep-derived fields, or no `DROPPED` line at all alongside
`reused=0`). Written this way so a future session cannot read a result as
confirmation after the fact. The Commit 4 gate (`prep` vs `score`) is in the
same table, with upstream's 60-84%-reads prior flagged as their hardware and
their store, not necessarily transferable here.

Also records the working `plistlib` pull command — `plutil -extract
strapLog.tail` treats the literal dot as a key path and fails, which both
sibling plans got wrong — and the actual log retention (2,000 live lines
plus 3 generations x 1,000), which bounds how long after a sync the check is
still possible.

Verified with `python3 Tools/pending-validation.py --list`: the entry parses
and reports `not until 2026-08-27`. Not ripe today, so the SessionStart hook
stays quiet, which is correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017g6WqitM2Y76kkYsHzpXab
xxblyxx added a commit to xxblyxx/noop that referenced this pull request Sep 1, 2026
The commit that targets the owner's actual complaint. "When I open the app in
the morning" is a cold-launch pass by construction: dayScanCache is in-memory
only and dayScanCacheConfigSig starts "", which never equals a real signature,
so the FIRST pass of every process drops the whole cache and re-reads plus
re-scores every night. Measured 2026-08-27, backgrounded, 9 nights: 2403s.
Commits 1, 1b, 2 and 5 do nothing for that pass.

SHAPE: the plan recommended rebuilding each night from banked state and
persisting only the cache key. Audited first, and it is not possible losslessly:
nightlySkinTempC is read by the fold (:1503, :1532) and nothing banks the raw
degC mean - only the baseline-relative 2dp skinTempDevC, nil outright when the
baseline is unusable. workouts lose zoneTimePct/avgHRRPct/hrmax/caloriesKJ into
WorkoutRow, and bouts overlapping a real workout are never banked at all.

Shipped instead: persist the PROJECTION pass 2 consumes. A cached scan has one
consumer - on a hit, pass 1 does out.append(cached.scan); continue and performs
no read, no score, no side effect. Pass 2 reads all 13 DayScan fields but only 7
of DayResult's 15; the other 8 are read only inside pass 1 before caching, and
pass 2 recomputes its own from baselines2 anyway. Restoring them at DayResult's
own defaults reproduces what a cache hit hands pass 2 today, byte for byte. Also
smaller than serializing DayScan wholesale: two new Codable types, not nine.

INVALIDATION - the plan was wrong in both directions, corrected here:
- The ".dataChange drops the cache, one line" seam DOES NOT EXIST. removeAll()
  appears once (:854), gated on the signature alone; AnalyzePolicy is a pure
  rate-limiter that never touches the cache.
- Manual sleep edits and dismissed sleep spans need NO invalidation. Both apply
  in pass 2 (:1701-1703, :2186), fresh every pass, after the cache. A registry
  owner change is already in the per-day key.
- The one real hole, which the plan missed: the ryanbr#899 banked-sleep heal deletes
  banked rows whose only route into a later pass is bandSleepStateSamples - a
  pass-1 read NOT in the per-day key - and its own re-arm would then re-run a
  pass that reuses the very days it invalidated. Until now that healed by
  accident on relaunch. Dropped at the delete site, since the heal raises no
  analyzeRecent call of its own.
- Second hole, also not in the plan: the three trace arrays are replayed on a
  cache HIT (ryanbr#1575), so a persisted scan captured with the Sleep trace on would
  replay trace lines after it was switched off. The three trace-active booleans
  now sign the pass: one drop per toggle, which is what ryanbr#1575 intended.

STORAGE: Application Support/noop-dayscan-cache.json, version-tagged,
isExcludedFromBackup. Not UserDefaults - the two session bands are on a 30s grid
(~960 entries/night), so this is hundreds of KB and UserDefaults loads wholesale
at launch. Deliberately NOT added to BackupSettings.whitelist, which is opt-in,
so a restored .noopbak cannot import another device's cache (precedent:
noop.analyzeWatermark, :2372). Any decode failure or version mismatch reads as
"no cache" - i.e. the cold pass we already have.

No Kotlin twin, as a judgement call rather than a rule: a device-local derived
cache with no schema, migration, formula or stored analytics result. It changes
WHICH days recompute, never WHAT they compute to, so both platforms still
produce identical scores; Android keeps the in-memory-only cache and repeats
more work after a relaunch. KOTLIN NOT COMPILED HERE either way - no JDK, no
Android SDK, no CI.

Verification:
- xcodebuild -scheme Strand -destination platform=macOS test -> 1228 tests, 0
  failures (1 pre-existing skip: XiaomiImporterIntegrationTests). Includes 7 new
  DayScanCacheStoreTests: field-by-field round trip of everything the fold
  reads, an edited session's adjusted onset, the 8 omitted fields pinned at
  their defaults, version mismatch rejected, corrupt file rejected, save/load
  including the config signature, and clear.
- Packages/StrandAnalytics: swift build clean after the two Codable additions.
- App targets compiled (CI does not build them): -scheme Strand macOS and
  -scheme NOOPiOS -destination id=819D37A3... both BUILD SUCCEEDED.
- python3 Tools/doc_comment_lint.py -> OK.
- NOT observed on hardware. Expected: pass 1 reuses 8 of 9 nights, so ~300s
  instead of 2403s. Recorded as claim (c) in PENDING_VALIDATION, along with the
  two sub-claims no test can reach: the ryanbr#899 heal drop and the trace-toggle drop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013p6RwiHQZaea2aNPh5jySa
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