From 1562913aa8d6f5da80a3d67a2f741e61dad165aa Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Sat, 15 Aug 2026 21:40:29 +1200 Subject: [PATCH] perf(android): skip the redundant per-day store re-read in analyzeRecent (#997 twin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../StrandAnalytics/AnalyticsEngine.swift | 2 +- .../DaySliceFromNightTests.swift | 2 +- .../com/noop/analytics/AnalyticsEngine.kt | 32 ++++++++ .../com/noop/analytics/IntelligenceEngine.kt | 23 ++++-- .../analytics/AnalyticsEngineDaySliceTest.kt | 73 +++++++++++++++++++ 5 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 android/app/src/test/java/com/noop/analytics/AnalyticsEngineDaySliceTest.kt diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index 20c1af1656..2effa22c10 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -189,7 +189,7 @@ public enum AnalyticsEngine { /// `h.ts = p.ts` anti-join, so coalescing-then-filtering equals coalescing over the day range. The /// guards are self-protecting — a DST-shifted `dayLo`/`dayHi` simply falls outside the window and /// declines — so the shortcut can only ever DECLINE to a direct read, never return wrong data. - /// Mirrors Kotlin `IntelligenceEngine.daySliceFromNight`; lives here (like `offWristIntervals`) + /// Mirrors Kotlin `AnalyticsEngine.daySliceFromNight`; lives here (like `offWristIntervals`) /// so the pure logic is package-testable. (#997) public static func daySliceFromNight(_ night: [T], nightLo: Int, nightHi: Int, diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaySliceFromNightTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaySliceFromNightTests.swift index ca155ca07e..2d150c74da 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaySliceFromNightTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/DaySliceFromNightTests.swift @@ -8,7 +8,7 @@ import XCTest /// And the shortcut must DECLINE (nil → the caller reads directly) in the unsafe cases: TODAY's calendar /// day runs past the 18 h night cap, and a night read at the stream limit may be truncated inside the day /// span. If any of that drifts, samples get attributed to the wrong day / dropped, so this is the safety -/// net for the read-skip. Mirrors the Android `IntelligenceEngineDaySliceTest` (same bounds fixture). +/// net for the read-skip. Mirrors the Android `AnalyticsEngineDaySliceTest` (same bounds fixture). final class DaySliceFromNightTests: XCTestCase { private struct S: Equatable { let ts: Int } diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt index cde7afd0f1..2703bb6b03 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -42,6 +42,38 @@ import kotlin.math.roundToLong */ object AnalyticsEngine { + /** + * Skip the redundant calendar-day re-read in [IntelligenceEngine.analyzeRecent]'s per-day scan + * (#997, ryanbr). For a PAST day the night window [nightLo, nightHi] reads through to the NEXT + * local midnight, so the calendar day [dayLo, dayHi] is a strict SUBSET of the hr/steps/gravity + * streams already in memory — the dayHr/daySteps/dayGravity re-reads (~60 per pass, including the + * big ~86k-row HR ones) re-query rows the caller already holds. When the day span is a + * non-truncated subset of the night window, return the day's samples by filtering the night list + * in memory; return null when the shortcut is unsafe and the caller must read the store directly: + * - TODAY: its calendar day runs past the 18 h night cap ([dayHi] > [nightHi]). + * - a night read that came back at [limit] rows may be truncated INSIDE the day span (ORDER BY + * ts ASC LIMIT drops the LATE rows — exactly where the day sits). + * Byte-identical to the direct read: same owner (the caller reads both windows from one device), + * same INCLUSIVE [dayLo, dayHi] bounds (matching the DAO's `ts >= from AND ts <= to` range), same + * ts-ASC order (the night list came from the SAME ts-ASC DAO method, and filtering preserves + * order), and the store's HR coalesce (measured ∪ v26 PPG, #172/#219) dedups on a + * range-INDEPENDENT `ts` anti-join, so coalescing-then-filtering equals coalescing over the day + * range. The guards are self-protecting — a DST-shifted [dayLo]/[dayHi] simply falls outside the + * window and declines — so the shortcut can only ever DECLINE to a direct read, never return wrong + * data. Mirrors Swift `AnalyticsEngine.daySliceFromNight`; lives here (like [offWristIntervals]) so + * the pure logic is unit-testable. (#997) + */ + fun daySliceFromNight( + night: List, + nightLo: Long, nightHi: Long, + dayLo: Long, dayHi: Long, + limit: Int = 200_000, + ts: (T) -> Long, + ): List? { + if (dayLo < nightLo || dayHi > nightHi || night.size >= limit) return null + return night.filter { ts(it) in dayLo..dayHi } + } + /** * Pair the strap's WRIST_OFF/WRIST_ON events into off-wrist [start, end) intervals for the sleep * detector's fractional wear filter (#500; design credited to j0b-dev's #504). Each WRIST_OFF opens diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index a3e8b31b81..7300ba0769 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -566,14 +566,21 @@ object IntelligenceEngine { val dayEnd = dayMidnight + SECONDS_PER_DAY - 1 // Same [owner] as the night window above (I2): the additive day totals must come from the one // device that owns the day, never a mix. - val dayHr = repo.hrSamples(owner, dayMidnight, dayEnd, STREAM_LIMIT) - val daySteps = repo.stepSamples(owner, dayMidnight, dayEnd, STREAM_LIMIT) - // Full calendar-day gravity for WORKOUT detection. The night window above ends at - // dayStart+12h (≈ noon), so an afternoon/evening workout sits outside it and was only - // detected once a later pass re-read it through the next night window , a ~day lag. This - // [localMidnight, +24h) read (today: clamped to now by the DAO) lets the detector see the - // whole day, so a 5 pm run shows up the same day. - val dayGrav = repo.gravitySamples(owner, dayMidnight, dayEnd, STREAM_LIMIT) + // #997: for a PAST day the [from, to] night read above already spans this calendar day (to = + // nextMidnight ≥ dayEnd), so derive the day streams by filtering the in-memory night lists + // instead of re-reading them from the store (~60 redundant reads/pass, incl. the big HR ones). + // TODAY (dayEnd past the 18 h cap) and a limit-truncated night read DECLINE (null) → direct + // read, so the shortcut only ever skips work, never changes data. Twin of Swift's #997. + val dayHr = AnalyticsEngine.daySliceFromNight(hr, from, to, dayMidnight, dayEnd) { it.ts.toLong() } + ?: repo.hrSamples(owner, dayMidnight, dayEnd, STREAM_LIMIT) + val daySteps = AnalyticsEngine.daySliceFromNight(steps, from, to, dayMidnight, dayEnd) { it.ts } + ?: repo.stepSamples(owner, dayMidnight, dayEnd, STREAM_LIMIT) + // Full calendar-day gravity for WORKOUT detection. For a PAST day the night window runs to the + // next local midnight so the afternoon/evening is already in `grav`; only TODAY (18 h cap) reads + // directly, which the slice's `dayHi > nightHi` guard handles — a 5 pm run still shows up the + // same day. + val dayGrav = AnalyticsEngine.daySliceFromNight(grav, from, to, dayMidnight, dayEnd) { it.ts } + ?: repo.gravitySamples(owner, dayMidnight, dayEnd, STREAM_LIMIT) // CONSUME (#531 / #175): the strap's OWN band sleep_state for the night window as (ts, state) // samples, so the H7 morning-stillness guard can confirm a borderline re-onset against the strap's diff --git a/android/app/src/test/java/com/noop/analytics/AnalyticsEngineDaySliceTest.kt b/android/app/src/test/java/com/noop/analytics/AnalyticsEngineDaySliceTest.kt new file mode 100644 index 0000000000..50617a234b --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/AnalyticsEngineDaySliceTest.kt @@ -0,0 +1,73 @@ +package com.noop.analytics + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Locks `AnalyticsEngine.daySliceFromNight` (#997): for a PAST day the calendar-day streams + * (dayHr/daySteps/dayGravity) are a non-truncated subset of the night window analyzeRecent already read, + * so re-reading them from the store is redundant — the slice must equal an in-range filter of the night + * list (which, for a complete night, equals the direct read: same inclusive bounds, same ts-ASC order). + * And the shortcut must DECLINE (null -> the caller reads directly) in the unsafe cases: TODAY's calendar + * day runs past the 18 h night cap, and a night read at the stream limit may be truncated inside the day + * span. If any of that drifts, samples get attributed to the wrong day / dropped, so this is the safety + * net for the read-skip. Mirrors the macOS `DaySliceFromNightTests` (same bounds fixture). + */ +class AnalyticsEngineDaySliceTest { + + private data class S(val ts: Long) + + // A past day's night window: [dayStart − 30 h, nextMidnight]; the calendar day + // [dayStart, dayStart + 86400 − 1] sits strictly inside it. Mirrors the real IntelligenceEngine bounds. + private val dayStart = 1_700_000_000L + private val nightLo = dayStart - 30 * 3_600L + private val nightHi = dayStart + 86_400L // = nextMidnight (a past day's `to`) + private val dayLo = dayStart + private val dayHi = dayStart + 86_400L - 1 + private val night = (nightLo..nightHi step 60).map { S(it) } + + @Test fun pastDayReturnsTheInRangeFilterOfTheNightList() { + val slice = AnalyticsEngine.daySliceFromNight( + night, nightLo, nightHi, dayLo, dayHi) { it.ts } + // Byte-identical to filtering the night list (which, for a complete night, equals the direct read). + assertEquals(night.filter { it.ts in dayLo..dayHi }, slice) + // Nothing outside the day leaks in; order is preserved (ascending, as the store returned it). + assertEquals(slice, slice!!.sortedBy { it.ts }) + } + + @Test fun todayDayEndPastTheNightCapDeclines() { + // TODAY: the night window caps at dayStart + 18 h, so the calendar day (to +24 h) reaches past it. + val todayNightHi = dayStart + 18 * 3_600L + assertNull(AnalyticsEngine.daySliceFromNight( + night, nightLo, todayNightHi, dayLo, dayHi) { it.ts }) + } + + @Test fun dstShiftedDayBeforeTheNightWindowDeclines() { + // The self-protecting guard the other way: a shifted dayLo that falls before the night window + // (e.g. a DST-moved local midnight) must decline to the direct read, never slice a partial window. + assertNull(AnalyticsEngine.daySliceFromNight( + night, nightLo, nightHi, nightLo - 1, dayHi) { it.ts }) + } + + @Test fun truncatedNightReadDeclines() { + // A night read that returned exactly `limit` rows may be truncated inside the day span (ORDER BY + // ts ASC LIMIT drops the LATE rows — exactly where the day sits). Locked at an injected small + // limit AND at the real 200_000 default the IntelligenceEngine call sites rely on. + val small = (0L until 10L).map { S(it) } + assertNull(AnalyticsEngine.daySliceFromNight( + small, 0, 10, 0, 5, limit = 10) { it.ts }) + val atDefaultLimit = (0L until 200_000L).map { S(it) } + assertNull(AnalyticsEngine.daySliceFromNight( + atDefaultLimit, 0, 200_000, 0, 100) { it.ts }) + } + + @Test fun boundsAreInclusiveOnBothEnds() { + // The store range is inclusive [dayLo, dayHi] (`ts >= from AND ts <= to`); the filter must keep + // the boundary samples and drop their immediate neighbours. + val edge = listOf(S(dayLo - 1), S(dayLo), S(dayHi), S(dayHi + 1)) + val slice = AnalyticsEngine.daySliceFromNight( + edge, nightLo, nightHi, dayLo, dayHi) { it.ts } + assertEquals(listOf(S(dayLo), S(dayHi)), slice) + } +}