Skip to content

Keep Android intelligence analysis JaCoCo-instrumentable - #1524

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
bhelm:codex/issue102-jacoco-intelligence-split
Aug 22, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
bhelm:codex/issue102-jacoco-intelligence-split

Conversation

@bhelm

@bhelm bhelm commented Aug 21, 2026

Copy link
Copy Markdown

Summary

  • extract the contiguous Fitness Age, Vitality, and WHOOP 4 Steps persistence phase into one private suspend helper at the same serial call site
  • preserve repository reads, writes, callbacks, traces, cancellation, and failure propagation
  • add a direct JaCoCo 0.8.12 offline-instrumentation regression guard with exact JVM method-size budgets and runtime ordering/failure checks

Verification

  • focused runtime order/failure guard: 1 passed
  • affected analytics selection: 92 passed
  • FullDebug unit suite: 4,192 passed, 0 failures/errors, 6 established skips
  • fresh targeted and full JaCoCo reports: no MethodTooLargeException, IllegalClassFormatException, or instrumentation errors
  • raw method sizes: analyzeRecentOnCpu 50,459 bytes; helper 4,693 bytes
  • instrumented method sizes: analyzeRecentOnCpu 59,171 bytes (6,364-byte JVM-limit margin); helper 6,389 bytes
  • assembleFullDebug: BUILD SUCCESSFUL, 38/38 tasks

Fixes bhelm#102

@ryanbr

ryanbr commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Reviewed. This is a clean piece of work and I'd take it as-is. One cross-PR note at the end that's worth sequencing around.

The extraction is a pure move, verified mechanically

The thing that matters for a 50 KB method being carved up is whether anything changed on the way out. I diffed removed against added lines, normalised for whitespace:

  • 117 removed lines, 0 of which fail to reappear. Every line is a verbatim move.
  • The only genuinely new lines are 33: the 15 named arguments at the call site, the matching 15-parameter signature, and the closing brace. Nothing else.

That is exactly what I wanted to see and it's rare — no smuggled edits, no reordering, no defaults quietly changed. Specifically:

  • suspend is preserved, so cancellation still propagates through the same structured-concurrency path;
  • no try/catch was introduced around the call, so failure propagation is unchanged;
  • named arguments throughout, so a 15-parameter signature can't be silently mis-wired positionally.

The guard is well built

The part I probed hardest was whether the budget test warns or just waits for the cliff. It warns: assertExactMethodBudget(lengths, "analyzeRecentOnCpu", 61_535) sits 4,000 bytes below the JVM's 65,535 limit, so CI goes red with real headroom left rather than at the moment JaCoCo actually breaks. The helper gets 12,000 against a current 6,389.

The ordering tests are the right complement: exactly one declaration, exactly one call, positioned after replaceComputedScoreWindow and before DismissedSleepGuard.keeping, with each internal operation asserted to appear once and in sequence. A future refactor that relocates the block fails loudly instead of silently reordering persistence.

testImplementation-scoped, so JaCoCo never reaches the APK — right call for this project — and registered in both the lockfile and verification-metadata.xml.

Verified on the merged tree

Your branch is based on ec200630f, several merges behind, and it touches the same file my #1521 hoist landed in, so I tested the merge rather than the branch:

  • zero conflicts, and the rrOrds hoist survives intact (declared once, used at all three call sites)
  • 4,211 tests, 0 failures, 6 established skips (--no-build-cache --rerun-tasks; main is 4,208, so +3)
  • all three new tests pass: offlineInstrumentedMethodsKeepSafeCodeMargins, extractedBlockRemainsSerialAndAtTheRequiredCallSite, extractedBlockRetainsRuntimeReadCallbackTraceAndFailureOrder

One note, and it's about your other PR

This adds a new one-sided Kotlin function — persistFitnessVitalityAndSteps has no Swift counterpart, and correctly so: it's a JVM method-size workaround and Swift has no 64 KB limit, so a twin would be structure-for-structure mimicry with no value.

But that is precisely the shape your parity ledger in #1534 reports as add-unpaired-function kotlin. When I fed it a synthetic one-sided Kotlin helper, that is exactly the rule it fired. So whichever of these lands second will need either an issue-bound exemption or a deliberate note in the authority — worth deciding the order now rather than discovering it as a red gate later. Given #1534 regenerates its authority from whatever tree it rebases onto, merging this one first is probably the cheaper sequence.

Not a change request. Ready to merge from my side.

@ryanbr
ryanbr merged commit dcc03fb into ryanbr:main Aug 22, 2026
3 checks passed
ryanbr added a commit that referenced this pull request Aug 25, 2026
…1626)

A scoring pass walks a fixed recent window, so a day whose raw HR never
arrived is re-read, re-skipped and re-logged on every pass, forever. The
strap banks days rather than weeks of raw HR while an import can supply a
much longer spine of daily rows, so for an importing user the steady state
is a permanent block of un-scoreable days.

In a field capture that came to 1262 lines — 21 days re-skipped across 63
passes in two hours, about a fifth of everything the log had to say. The
strap log is a fixed-size rolling buffer, so this is not merely noise: it
evicts the older lines an investigation needs.

Collect the skipped days and emit ONE line per pass, grouped by sample
count. Lossless — every day and its own HR count still prints, listed
rather than reduced to a range, because a range would hide a gap and which
days lack raw HR is exactly what an investigation is looking for. A day
just under the floor still shows its number. Nothing skipped stays silent.

The Android side carries a mechanical constraint worth recording. Putting
the accumulator in a local blew the JVM method-size budget #1524 guards by
444 bytes, seven times the 64 bytes of headroom the method had — not
because of the three added lines, but because analyzeRecentOnCpu is
`suspend`, so one more local live across a loop full of suspension points
is saved and restored at every one of them. Moving it to a field (safe for
the same reason dayScanCache is a field: every pass runs under analyzeGate)
and moving the null-check into the collector leaves the method 40 bytes
under budget rather than 1.

Swift keeps a local, and the declaration says why the two shapes differ so
nobody aligns them. Swift also gains the named minHrSamples constant Kotlin
already had, since this change would otherwise have added a second bare 200
literal on that side.

Both platforms, byte-identical output, verified by diffing the two
implementations across five cases rather than by eye.

Verified: app-build green both legs; full com.noop.analytics suite (1685
tests) green; 5 JVM tests and a twinned Swift test in StrandAnalytics that
default CI runs; #1524 budget test at 61495/61535; doc_comment_lint and
i18n_audit clean.
ryanbr added a commit that referenced this pull request Sep 9, 2026
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 added a commit that referenced this pull request Sep 9, 2026
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 added a commit that referenced this pull request Sep 9, 2026
StepsMotionCache stopped the calibration re-folding sixty days of strap motion
on every pass, but only within a process, so a relaunch re-paid the whole fold:
32864ms cold against 3330ms warm on a worn 60-day library.

dayMotionIntensity is a pure fold over one day's gravity, so unlike the
day-scan cache there is no pass-config to carry across the process boundary.
That one keys on dayScanCacheConfigSig, which folds in baselines1 and the
habitual-sleep terms, and sleepConsistency and habitualMidsleepSec both shift
with any night moving, so a persisted day scan would invalidate wholesale on
exactly the passes it would need to survive.

A fold version rides in the payload header because cacheKey witnesses the
inputs only: a day whose gravity has not moved keys identically across an app
update that changed the maths. The payload stays a derived cache nothing else
reads, so the whole failure surface is one re-fold, and it deliberately stays
out of both BackupSettings whitelists because it describes gravity rows as they
were on one device.

Storage is UserDefaults / SharedPreferences, not the record schema, so there is
no migration. The store is written only when the payload moved, and Android's
accessors are nullable so a caller that does not wire them cannot spend the
one-shot load on nothing. Load and save sit in the analyzeRecent wrapper, never
in analyzeRecentOnCpu, so the #1524 method budget is untouched.

Both platforms pin the same rendered payload as a literal, and rendering is a
fixpoint over a payload this build wrote.
@bhelm
bhelm deleted the codex/issue102-jacoco-intelligence-split branch September 15, 2026 22:35
suprfire pushed a commit to suprfire/noop that referenced this pull request Oct 10, 2026
…for how far tonight's data can be trusted (ryanbr#15/ryanbr#27)

Composes the honesty signals that were scattered across consumers into ONE per-night
figure: stream coverage, PPG-derived-HR fill share (ryanbr#156), gravity span sparsity and
>20-min dropouts (the ryanbr#345 sparse test), and off-wrist seconds inside the bed window
(ryanbr#500/ryanbr#504). Channels a device never carries (thermal/SpO2 on WHOOP 4.0) are ABSENT
from the weighted mean, never scored zero - a 4.0 night is not punished for hardware
it does not own; absent components renormalize the weights (the Charge nil-renorm
idiom). Persisted as shadow keys night_trust / night_wear_frac under the computed
source: instrumentation only, never shown, never fed to any score, accruing on real
devices so the trust surface and later alert gating ship on measured data.

Wiring respects the ryanbr#1524 JaCoCo per-method budget: analyzeRecentOnCpu measured
57,412 instrumented bytes inline against its 55,700 ceiling, so the census
(recordNightTrust/nightTrustCensus), the six per-night shadow-row appends
(shadowMetricRows/nightTrustShadowRows), the cache-hit replay (replayCachedNight),
the cache store (storeCachedNightScan) and the sleep-diag line (sleepDayDiagLine)
are lifted into named helpers; the census call site is one statement.

Gates: NightlyTrustTest 13/13 (clean night, off-wrist, PPG-fill, gravity gap/sparse,
device-without-thermal renorm, degenerate inputs); IntelligenceEngineJacocoBudgetTest
4/4; full com.noop.analytics.* suite green; doc_comment_lint OK; i18n audit: Android
section clean (no new hardcoded copy) - its iOS xcstrings ru/zh-Hant FAILs are
pre-existing at HEAD and this diff touches android/ + CHANGELOG.md only.
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.

[P1 validation/coverage] Split analyzeRecentOnCpu below JaCoCo JVM method limit

2 participants