Skip to content

perf(android): skip the redundant per-day store re-read in analyzeRecent (#997 twin) - #1346

Merged
ryanbr merged 1 commit into
mainfrom
997-android-dayslice
Aug 15, 2026
Merged

perf(android): skip the redundant per-day store re-read in analyzeRecent (#997 twin)#1346
ryanbr merged 1 commit into
mainfrom
997-android-dayslice

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Ports the Swift #997 per-day read-skip to Android, closing a real parity gap and cutting per-pass I/O on the exact path behind the #1005 background-drain reports.

The gap

During analyzeRecent's per-day scan, Swift avoids re-reading each calendar day's HR/steps/gravity from the store: for a past day the night window it already read — [dayStart − 30h, nextMidnight] — spans the whole calendar day, so it derives the day streams by filtering the in-memory night lists (AnalyticsEngine.daySliceFromNight, #997). Android had no twin and re-read all three streams from the store per day:

val dayHr   = repo.hrSamples(owner, dayMidnight, dayEnd, STREAM_LIMIT)
val daySteps = repo.stepSamples(owner, dayMidnight, dayEnd, STREAM_LIMIT)
val dayGrav  = repo.gravitySamples(owner, dayMidnight, dayEnd, STREAM_LIMIT)

That's ~3 reads × ~20 past days = ~60 redundant store reads per pass — including the big ~86k-row HR reads — and the pass runs ~every 15 min while a strap is connected, plus on every offload. (The Swift doc/test even claimed a Kotlin twin existed; it didn't. Now it does.)

The change

  • Add AnalyticsEngine.daySliceFromNight to the Kotlin AnalyticsEngine (next to offWristIntervals, mirroring where the Swift twin lives), a pure generic:
    if (dayLo < nightLo || dayHi > nightHi || night.size >= limit) return null
    return night.filter { ts(it) in dayLo..dayHi }
  • Wire the three day-read sites to slice-then-fall-back: daySliceFromNight(...) ?: repo.X(...).
  • Correct the Swift AnalyticsEngine.swift + DaySliceFromNightTests.swift comments that referenced a nonexistent IntelligenceEngine.daySliceFromNightAnalyticsEngine.daySliceFromNight.

Why it's safe (byte-identical, can only skip work)

The guard declines to a direct read whenever the shortcut would be unsafe:

  • TODAY — its calendar day runs past the 18 h night cap (dayHi > nightHi) → direct read (so an afternoon/evening workout is still seen same-day).
  • A limit-truncated night read (night.size >= limit) — ORDER BY ts ASC LIMIT could have dropped the late rows exactly where the day sits → direct read.
  • A DST-shifted day bound simply falls outside the window and declines.

When it does slice, it's byte-identical to the direct read: same owner, same inclusive [dayLo, dayHi] bounds (matching the DAO's ts >= from AND ts <= to), same ts-ASC order (filtering preserves it), and the HR COALESCE union (measured ∪ v26 PPG, #172/#219) dedups on a range-independent ts anti-join, so coalescing-then-filtering equals coalescing over the day range.

Verification

  • compileFullDebugKotlin — clean.
  • New AnalyticsEngineDaySliceTest (twin of the macOS DaySliceFromNightTests, same bounds fixture: past-day slice equals the in-range filter, TODAY/DST/truncated-limit decline, inclusive both ends) — green.
  • Every analyzeRecent-exercising suite green, incl. day-owner, edit-scope, and repeat-repro — the ones that would catch a wrong-day attribution if the slice ever diverged from the direct read.
  • Swift side is comment-only (the SleepPSG: sync RecipeConfig.shipped with #987's awake row (main is red) #997 logic already shipped there), so no app-build gate needed; swift-packages covers StrandAnalytics.

Both platforms now share the same read-skip; no behaviour or stored-data change on either.

…ent (#997 twin)

Swift already avoids re-reading each calendar day's HR/steps/gravity from
the store during analyzeRecent's per-day scan: for a PAST day the night
window it already read [dayStart-30h, nextMidnight] spans the day, so it
slices the day streams out of the in-memory night lists
(AnalyticsEngine.daySliceFromNight, #997). Android had no twin and re-read all
three streams per day — ~60 redundant store reads per pass (including the big
~86k-row HR ones), every ~15 min while a strap is connected.

Port daySliceFromNight to the Kotlin AnalyticsEngine (next to offWristIntervals,
mirroring the Swift placement) and wire the three day-read sites to slice-then-
fall-back. Safe by construction: the guard declines (-> direct read) for TODAY
(day runs past the 18h night cap) and for a limit-truncated night read, so it
can only ever skip work, never change data. Same owner, same inclusive bounds,
same ts-ASC order, same range-independent HR coalesce -> byte-identical to the
direct read.

Also correct the Swift doc/test comments that named a Kotlin
`IntelligenceEngine.daySliceFromNight` twin: it now exists, as
`AnalyticsEngine.daySliceFromNight`.

Adds AnalyticsEngineDaySliceTest, the twin of the macOS DaySliceFromNightTests
(same bounds fixture). compileFullDebugKotlin + the new test + every
analyzeRecent-exercising suite (day-owner, edit-scope, repeat-repro) green.
@ryanbr
ryanbr merged commit db20b7a into main Aug 15, 2026
14 checks passed
@ryanbr
ryanbr deleted the 997-android-dayslice branch August 15, 2026 09:46
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ent (ryanbr#997 twin) (ryanbr#1346)

Swift already avoids re-reading each calendar day's HR/steps/gravity from
the store during analyzeRecent's per-day scan: for a PAST day the night
window it already read [dayStart-30h, nextMidnight] spans the day, so it
slices the day streams out of the in-memory night lists
(AnalyticsEngine.daySliceFromNight, ryanbr#997). Android had no twin and re-read all
three streams per day — ~60 redundant store reads per pass (including the big
~86k-row HR ones), every ~15 min while a strap is connected.

Port daySliceFromNight to the Kotlin AnalyticsEngine (next to offWristIntervals,
mirroring the Swift placement) and wire the three day-read sites to slice-then-
fall-back. Safe by construction: the guard declines (-> direct read) for TODAY
(day runs past the 18h night cap) and for a limit-truncated night read, so it
can only ever skip work, never change data. Same owner, same inclusive bounds,
same ts-ASC order, same range-independent HR coalesce -> byte-identical to the
direct read.

Also correct the Swift doc/test comments that named a Kotlin
`IntelligenceEngine.daySliceFromNight` twin: it now exists, as
`AnalyticsEngine.daySliceFromNight`.

Adds AnalyticsEngineDaySliceTest, the twin of the macOS DaySliceFromNightTests
(same bounds fixture). compileFullDebugKotlin + the new test + every
analyzeRecent-exercising suite (day-owner, edit-scope, repeat-repro) green.
ryanbr added a commit that referenced this pull request Aug 23, 2026
…ads (#1559)

Pricing the "make the pass cheaper" option on the re-score work got as far as
static analysis can and then hit a wall. Instrumentation only: no behaviour
change, no scoring change, one new diagnostic line per pass.

What the static analysis settled. Each day reads a 54-hour night window
(dayStart-30h to dayStart+24h) on a 24-hour stride, so consecutive windows
overlap by 30 hours and every row is materialised about 2.25 times per pass --
1134 window-hours read against 534 distinct, so 53% of the read volume is
redundant. Nine windowed store reads per day survive; the day-window reads
(dayHr/daySteps/dayGrav) were already sliced out of the night lists by
#997/#1346, so that part is done and should not be re-done.

What it could not settle: whether reads or analyzeDay dominate. The pass has only
ever timed itself end to end, so ~22.6 s per night is an undifferentiated number,
and that single ratio decides whether narrowing the windows is worth building.

The design this unlocks, and the one it rules out. Reading the whole 21-day span
once and slicing does NOT work -- it would hold ~1.8M HR rows in memory, and OOM
on big-import libraries is a known failure of this exact path. A sliding window
would: the loop already walks newest-to-oldest, so it could read only the
incremental 24 hours per step and drop the tail, keeping today's peak memory
while materialising 2.25x fewer rows. Only worth building if reads dominate.

So: time the two phases. `prep` brackets the nine windowed reads plus the session
matching between them; `score` brackets analyzeDay. They deliberately do not sum
to the pass total -- pass 2, the baseline folds and the reconciliation are
outside this loop -- so the line reads as a RATIO, which is all the question
needs. prep >> score means build the sliding window, ceiling ~53% of read time;
score >> prep means narrowing windows is a dead end whatever the row counts
suggest.

Accounting verified complete on both platforms: the cache-hit continue precedes
the prep timer, so a reused day contributes nothing to either phase; the only
other early exit is the too-few-samples guard, which still records its read time
because it paid for it; and analyzeDay is a single expression with the add on the
next line, so nothing can exit between capture and accounting. Both platforms
emit a byte-identical string and both truncate rather than round.

Known limitation, recorded rather than hidden: Kotlin times with the monotonic
System.nanoTime() while Swift uses Date(), matching how the pass total has
measured itself since #1344. A clock step mid-pass would skew the Swift figure.
The exposure is pre-existing and consistent within that file.

Verified: Android CI (assembleFullDebug + testFullDebugUnitTest) green; local
Android suite 4229 tests, 0 failures matching main; app-build green on both
Strand macOS and NOOPiOS; doc lint clean.

Not run on hardware, which is the entire point -- this exists so a real device
can answer a question a desk cannot. Partial work on the re-score performance
issue; #1538 stays open.
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