Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(_ night: [T],
nightLo: Int, nightHi: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
32 changes: 32 additions & 0 deletions android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T> daySliceFromNight(
night: List<T>,
nightLo: Long, nightHi: Long,
dayLo: Long, dayHi: Long,
limit: Int = 200_000,
ts: (T) -> Long,
): List<T>? {
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
Expand Down
23 changes: 15 additions & 8 deletions android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}