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 @@ -17,6 +17,29 @@ import Foundation
/// it only has to invalidate correctly on one platform; the Kotlin twin (`AnalyzeRecentDayCache`) mirrors
/// the shape but the two key strings are NOT required to match byte-for-byte across platforms.
public enum AnalyzeRecentDayCache {

/// WHICH part of a day's cache key moved, for the miss reason on the reuse line (#2073).
///
/// The line has only ever reported how many nights were reused. A healthy pass reuses all but today,
/// whose heart rate is still growing; a pass that reuses NOTHING has had something shared by every
/// day's key change, and the two cases need different fixes. A field log showed 0 of 21 on 13 passes
/// out of 17, each costing about 50 seconds of prep and 1.75M row reads, and the reuse count alone
/// could not say why. `rrAlias5` is called out separately because it is the one input computed ONCE
/// per pass and folded into all 21 keys, so it alone can turn a single flip into a total miss.
///
/// Keys are `owner|hrCount:hrMaxTs:anchor:detail|streams`, so the segment that differs names the
/// cause. Pure, and pinned by the same table of cases as the Kotlin twin.
public static func missReason(cachedKey: String, freshKey: String) -> String {
if cachedKey == freshKey { return "none" }
let a = cachedKey.split(separator: "|", maxSplits: 2, omittingEmptySubsequences: false)
let b = freshKey.split(separator: "|", maxSplits: 2, omittingEmptySubsequences: false)
if a.count < 3 || b.count < 3 { return "shape" }
if a[0] != b[0] { return "owner" }
if a[1] != b[1] { return "hr" }
let ra = a[2].range(of: "rrAlias5=").map { String(a[2][$0.upperBound...]) } ?? ""
let rb = b[2].range(of: "rrAlias5=").map { String(b[2][$0.upperBound...]) } ?? ""
return ra != rb ? "rrAlias5" : "streams"
}
/// The per-day reuse key. Reuse a cached day iff this string is unchanged since the scan was cached.
///
/// - `hrCount` / `hrMaxTs`: the night-window HR fingerprint (row count + newest timestamp) — the SAME
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ public final class SlidingStreamWindow<T> {
/// even the 32-bit `Int` of `arm64_32` that this package also builds for. Stated because a width left
/// to be inferred is how the 32-bit `pct` over-count got in (#1685).
public private(set) var rowsServed = 0
/// How many reads were forced by the OWNER changing since the last one (#2073). Diagnostic only.
///
/// `served` near zero already tells a reader the windows are declining, and `WindowedStreamPlan`'s own
/// doc names the three ways that happens: truncation, an owner flip, or a gap. `truncated` is already
/// on the line, so counting the flips separates the remaining two instead of leaving them as one
/// unanswerable number. A field log showed a stream reading 4,084 rows and serving none with
/// `truncated=0`, and there was no way to tell which of the other two it was.
public private(set) var ownerFlips = 0

/// Reads that bypassed reuse entirely because the caller passed `allowReuse: false` (#2073).
///
/// This is the answer `ownerFlips` alone would get WRONG. A WHOOP 5 R-R read passes false, because
/// transport selection is range-dependent and a slice could mix transports, so that window can never
/// serve from its buffer: `served=0` there is BY DESIGN, not a decline. Without this count the line
/// said `served=0 truncated=0 ownerFlips=0` and invited a reader to conclude "a gap", which is the
/// opposite of true. The bypass also clears the buffer, so a genuine owner change on such a stream is
/// never even seen as a flip.
public private(set) var reuseOffReads = 0

/// Rows this window read from the store. Diagnostic only. Same width note as `rowsServed`.
public private(set) var rowsRead = 0
/// Reads whose RESULT came back at the store's cap, so the newest rows were dropped and the day was
Expand All @@ -67,12 +86,14 @@ public final class SlidingStreamWindow<T> {
// A range-dependent source choice is not composable: a slice or extension may select another
// transport. Read the whole interval and retain no buffer, while keeping cost/truncation truthful.
if !allowReuse {
reuseOffReads += 1
_ = failedRead()
guard let full = await read(owner, from, to) else { return [] }
rowsRead += full.count
if full.count >= limit { truncatedReads += 1 }
return full
}
if let cached = self.owner, cached != owner { ownerFlips += 1 }
let plan = WindowedStreamPlan.plan(cachedOwner: self.owner, cachedFrom: self.from,
cachedTo: self.to, cachedTruncated: truncated,
owner: owner, from: from, to: to)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,20 @@ public enum WindowedStreamPlan {
hrTruncated: Int,
rrRead: Int,
rrServed: Int,
rrTruncated: Int
rrTruncated: Int,
hrOwnerFlips: Int = 0,
rrOwnerFlips: Int = 0,
hrReuseOff: Int = 0,
rrReuseOff: Int = 0
) -> String {
"analyzeRecent windows hr[read=\(hrRead) served=\(hrServed) truncated=\(hrTruncated)] "
+ "rr[read=\(rrRead) served=\(rrServed) truncated=\(rrTruncated)]"
// #2073: `served` near zero says the windows are declining, and the doc above names three causes.
// `truncated` covered one. `ownerFlips` separates the other two, and `reuseOff` covers the case
// none of them describe: a stream the caller told not to reuse at all, where served=0 is by design
// and reading it as a decline is simply wrong.
"analyzeRecent windows hr[read=\(hrRead) served=\(hrServed) truncated=\(hrTruncated) "
+ "ownerFlips=\(hrOwnerFlips) reuseOff=\(hrReuseOff)] "
+ "rr[read=\(rrRead) served=\(rrServed) truncated=\(rrTruncated) "
+ "ownerFlips=\(rrOwnerFlips) reuseOff=\(rrReuseOff)]"
}

/// What the caller should do to obtain rows for the requested window.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import XCTest
@testable import StrandAnalytics

/// #2073: the day-cache reuse line reported only HOW MANY nights were reused, never why the rest missed.
///
/// A field log showed `reused=0/21` on 13 of 17 passes, each costing about 50 seconds of prep and 1.75M
/// row reads, every 15 minutes on battery. A healthy pass reuses all but today, whose heart rate is still
/// growing, so a total miss means something shared by every key moved. Those are different bugs and the
/// count could not tell them apart.
///
/// Keys are `owner|hrCount:hrMaxTs:anchor:detail|streams`. Same cases as the Kotlin twin.
final class DayCacheMissReasonTests: XCTestCase {
private func key(_ owner: String, _ hr: String, _ streams: String) -> String { "\(owner)|\(hr)|\(streams)" }

func testAnIdenticalKeyIsNotAMiss() {
let k = key("my-whoop", "100:200:nil:s", "a|rrAlias5=true")
XCTAssertEqual(AnalyzeRecentDayCache.missReason(cachedKey: k, freshKey: k), "none")
}

func testTodaysGrowingHeartRateIsNamedAsTheHrSegment() {
XCTAssertEqual(AnalyzeRecentDayCache.missReason(
cachedKey: key("my-whoop", "100:200:nil:s", "a|rrAlias5=true"),
freshKey: key("my-whoop", "140:260:nil:s", "a|rrAlias5=true")), "hr")
}

func testAMovedOwnerIsNamedBecauseItInvalidatesEveryDayAtOnce() {
XCTAssertEqual(AnalyzeRecentDayCache.missReason(
cachedKey: key("my-whoop", "100:200:nil:s", "a|rrAlias5=true"),
freshKey: key("whoop-5A0", "100:200:nil:s", "a|rrAlias5=true")), "owner")
}

/// The one input computed ONCE per pass and folded into all 21 keys, so a single flip is a total miss.
func testThePassGlobalRrAliasIsCalledOutSeparately() {
XCTAssertEqual(AnalyzeRecentDayCache.missReason(
cachedKey: key("my-whoop", "100:200:nil:s", "a|rrAlias5=true"),
freshKey: key("my-whoop", "100:200:nil:s", "a|rrAlias5=false")), "rrAlias5")
XCTAssertEqual(AnalyzeRecentDayCache.missReason(
cachedKey: key("my-whoop", "100:200:nil:s", "a|rrAlias5=true"),
freshKey: key("my-whoop", "100:200:nil:s", "b|rrAlias5=true")), "streams")
}

func testAKeyThatIsNotTheExpectedShapeSaysSo() {
XCTAssertEqual(AnalyzeRecentDayCache.missReason(cachedKey: "nopipes", freshKey: "alsonone"), "shape")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,22 @@ final class WindowedStreamPlanTests: XCTestCase {
XCTAssertEqual(
WindowedStreamPlan.logLine(hrRead: 1_000, hrServed: 9_000, hrTruncated: 0,
rrRead: 250, rrServed: 750, rrTruncated: 3),
"analyzeRecent windows hr[read=1000 served=9000 truncated=0] "
+ "rr[read=250 served=750 truncated=3]")
"analyzeRecent windows hr[read=1000 served=9000 truncated=0 ownerFlips=0 reuseOff=0] "
+ "rr[read=250 served=750 truncated=3 ownerFlips=0 reuseOff=0]")
XCTAssertEqual(
WindowedStreamPlan.logLine(hrRead: 0, hrServed: 0, hrTruncated: 0,
rrRead: 0, rrServed: 0, rrTruncated: 0),
"analyzeRecent windows hr[read=0 served=0 truncated=0] rr[read=0 served=0 truncated=0]")
"analyzeRecent windows hr[read=0 served=0 truncated=0 ownerFlips=0 reuseOff=0] "
+ "rr[read=0 served=0 truncated=0 ownerFlips=0 reuseOff=0]")
// #2073: the shape the field log actually had, and the one a reader would have got WRONG.
// A WHOOP 5 R-R window is told not to reuse at all, so served=0 is by design; ownerFlips alone
// reads 0 there and would have suggested a gap, which is the opposite of true.
XCTAssertEqual(
WindowedStreamPlan.logLine(hrRead: 1_752_302, hrServed: 1_926_517, hrTruncated: 0,
rrRead: 9_044, rrServed: 0, rrTruncated: 0,
hrOwnerFlips: 0, rrOwnerFlips: 0,
hrReuseOff: 0, rrReuseOff: 21),
"analyzeRecent windows hr[read=1752302 served=1926517 truncated=0 ownerFlips=0 reuseOff=0] "
+ "rr[read=9044 served=0 truncated=0 ownerFlips=0 reuseOff=21]")
}
}
40 changes: 34 additions & 6 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -915,9 +915,13 @@ final class IntelligenceEngine: ObservableObject {
// Drop the whole cache on a config change, then snapshot it into a Sendable `let` for the detached
// loop (the engine is @MainActor; the loop can't touch `self`). The loop returns the updated cache
// and we write it back after `.value`.
// #2073: recorded, because a dropped cache leaves no entry to compare and the miss tally would
// otherwise stay silent on the very case it exists to explain.
var dayCacheConfigDropped = false
if dayCacheConfigSig != dayScanCacheConfigSig {
dayScanCache.removeAll()
dayScanCacheConfigSig = dayCacheConfigSig
dayCacheConfigDropped = true
}
let inDayScanCache = dayScanCache

Expand Down Expand Up @@ -946,6 +950,12 @@ final class IntelligenceEngine: ObservableObject {
// diagnostic carried on `skippedDayLines`.
var dayScanCacheLocal = inDayScanCache
var dayCacheReused = 0
// #2073: WHY a night missed, tallied by cause. The reuse count alone cannot separate "today's
// heart rate grew" from "something shared by all 21 keys moved", and those need different fixes.
var dayCacheMissBy: [String: Int] = [:]
// #2073: a cache dropped wholesale leaves NO entry to compare, so without this the tally would
// stay silent and the line would read reused=0/21 with nothing saying why.
if dayCacheConfigDropped { dayCacheMissBy["configDropped"] = 1 }
// #1538: per-phase cost tally. `prep` brackets the nine windowed store reads plus the
// session matching that sits between them and `analyzeDay`; `score` brackets `analyzeDay`
// itself. Emitted once per pass beside the reuse line.
Expand Down Expand Up @@ -1051,10 +1061,19 @@ final class IntelligenceEngine: ObservableObject {
// path exactly as it was.
hrvWindowDetail: hrvTraceActive && dayStart == nowLocalMidnight)
dayCacheKey = key
if let cached = dayScanCacheLocal[day], cached.key == key {
out.append(cached.scan)
dayCacheReused += 1
continue
if dayScanCacheLocal[day] == nil {
// No entry at all: a first pass, a night new to the window, or a cache just
// dropped wholesale. Counted so a total miss always carries a cause.
dayCacheMissBy["absent", default: 0] += 1
}
if let cached = dayScanCacheLocal[day] {
if cached.key == key {
out.append(cached.scan)
dayCacheReused += 1
continue
}
let why = AnalyzeRecentDayCache.missReason(cachedKey: cached.key, freshKey: key)
dayCacheMissBy[why, default: 0] += 1
}
}
}
Expand Down Expand Up @@ -1614,9 +1633,16 @@ final class IntelligenceEngine: ObservableObject {
// so counting it against the ratio made a healthy cache look broken and put a floor under how
// good the number could ever get. On a store with gaps the old form could not reach `21/21` even
// in principle, which is exactly the misreading #1538 opened with.
// #2073: the miss tally rides the same line. Absent when nothing was cached to miss, so a
// first pass and a healthy pass both read exactly as before.
var missBy = ""
if !dayCacheMissBy.isEmpty {
let parts = dayCacheMissBy.sorted { $0.key < $1.key }.map { "\($0.key):\($0.value)" }
missBy = " missBy=" + parts.joined(separator: ",")
}
skippedDayLines.append("analyzeRecent dayCache reused=\(dayCacheReused)/"
+ "\(dayCacheReused + dayCacheCacheable) "
+ "size=\(dayScanCacheLocal.count) days=\(maxDays)")
+ "size=\(dayScanCacheLocal.count) days=\(maxDays)\(missBy)")
// #1538: where the pass actually goes. `prep` is the nine windowed store reads plus the
// session matching between them; `score` is `analyzeDay`. The two do not sum to the pass
// total — pass 2, the baseline folds and the reconciliation are outside this loop — so read
Expand All @@ -1633,7 +1659,9 @@ final class IntelligenceEngine: ObservableObject {
hrRead: hrWindow.rowsRead, hrServed: hrWindow.rowsServed,
hrTruncated: hrWindow.truncatedReads,
rrRead: rrWindow.rowsRead, rrServed: rrWindow.rowsServed,
rrTruncated: rrWindow.truncatedReads))
rrTruncated: rrWindow.truncatedReads,
hrOwnerFlips: hrWindow.ownerFlips, rrOwnerFlips: rrWindow.ownerFlips,
hrReuseOff: hrWindow.reuseOffReads, rrReuseOff: rrWindow.reuseOffReads))
return (out, skippedDayLines, dayScanCacheLocal)
}.value
// #1005: write the loop's updated reuse cache back to the (main-actor) stored property. The pass ran
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,31 @@ package com.noop.analytics
* contract (see the tests, kept in lockstep with the Swift oracle).
*/
object AnalyzeRecentDayCache {
/**
* WHICH part of a day's cache key moved, for the miss reason on the reuse line (#2073).
*
* The line has only ever reported how many nights were reused. A healthy pass reuses all but today,
* whose heart rate is still growing; a pass that reuses NOTHING has had something shared by every
* day's key change, and the two cases need different fixes. A field log showed 0 of 21 on 13 passes
* out of 17, each costing ~50s of prep and 1.75M row reads, and the reuse count alone could not say
* why. `rrAlias5` is called out separately because it is the one input computed ONCE per pass and
* folded into all 21 keys, so it alone can turn a single flip into a total miss.
*
* Keys are `owner|hrCount:hrMaxTs:anchor:detail|streams`, so the segment that differs names the
* cause. Pure, so it is unit-tested directly.
*/
internal fun missReason(cachedKey: String, freshKey: String): String {
if (cachedKey == freshKey) return "none"
val a = cachedKey.split("|", limit = 3)
val b = freshKey.split("|", limit = 3)
if (a.size < 3 || b.size < 3) return "shape"
if (a[0] != b[0]) return "owner"
if (a[1] != b[1]) return "hr"
val ra = a[2].substringAfter("rrAlias5=", "")
val rb = b[2].substringAfter("rrAlias5=", "")
return if (ra != rb) "rrAlias5" else "streams"
}

/**
* The per-day reuse key. Reuse a cached night iff this string is unchanged since the scan was cached.
*
Expand Down
Loading