From bc263fbfaa12d0dc22253df233f1feedcd661afc Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Wed, 26 Aug 2026 00:22:36 +1200 Subject: [PATCH 1/4] analytics: collapse the per-day sleep-skip spam to one line per pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 the steady state for an importing user 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 is still printed, 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. Nothing skipped stays silent. The Android side has a mechanical constraint worth recording. The first cut put the accumulator in a local, and that blew the JVM method-size budget #1524 guards by 444 bytes — seven times the 64 bytes of headroom the method had. The cost was not the three added lines: 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 — brought it back to +24 bytes, and moving the null-check into the collector left the method 40 bytes under budget rather than 1. Both platforms, byte-identical output, verified by diffing the two implementations across five cases rather than by eye. Verified: compileFullDebugKotlin clean; the full com.noop.analytics suite (1685 tests) green; 5 new JVM tests and a twinned Swift test in StrandAnalytics, which default CI runs; the #1524 budget test passes at 61495/61535; doc_comment_lint and i18n_audit --ci clean. --- .../StrandAnalytics/SleepSkipSummary.swift | 29 +++++++++ .../SleepSkipSummaryTests.swift | 42 ++++++++++++ Strand/Data/IntelligenceEngine.swift | 8 ++- .../com/noop/analytics/IntelligenceEngine.kt | 14 +++- .../com/noop/analytics/SleepSkipSummary.kt | 65 +++++++++++++++++++ .../noop/analytics/SleepSkipSummaryTest.kt | 60 +++++++++++++++++ 6 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 Packages/StrandAnalytics/Sources/StrandAnalytics/SleepSkipSummary.swift create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepSkipSummaryTests.swift create mode 100644 android/app/src/main/java/com/noop/analytics/SleepSkipSummary.kt create mode 100644 android/app/src/test/java/com/noop/analytics/SleepSkipSummaryTest.kt diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepSkipSummary.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepSkipSummary.swift new file mode 100644 index 0000000000..3a0df8f07b --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepSkipSummary.swift @@ -0,0 +1,29 @@ +import Foundation + +/// One line summarising every day a scoring pass skipped for want of HR samples, replacing one line per +/// skipped day. +/// +/// A 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 the steady state for an importing user is a permanent +/// block of un-scoreable days. In one field capture that was 1262 lines - 21 days re-skipped across 63 +/// passes in two hours, about a fifth of everything the log had to say. +/// +/// That matters because the strap log is a fixed-size rolling buffer: noise does not merely annoy, it +/// evicts the older lines an investigation needs. Collapsing per pass keeps every fact - which days, and +/// each day's own HR count - while removing the repetition, so days are grouped by their sample count +/// and listed rather than summarised into a range that could hide a gap. +/// +/// Returns `nil` when nothing was skipped, so a healthy pass stays silent. Kotlin twin: +/// `com.noop.analytics.skippedSleepDaysLine`. +public func skippedSleepDaysLine(_ skipped: [(day: String, hrSamples: Int)], minHrSamples: Int) -> String? { + if skipped.isEmpty { return nil } + var byCount: [Int: [String]] = [:] + for entry in skipped { byCount[entry.hrSamples, default: []].append(entry.day) } + let groups = byCount.keys.sorted().map { count -> String in + let days = byCount[count]!.sorted() + return "hrSamples=\(count) on \(days.count) day(s): \(days.joined(separator: ", "))" + } + return "sleep SKIPPED \(skipped.count) day(s) — need ≥\(minHrSamples) hrSamples: " + + groups.joined(separator: "; ") +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepSkipSummaryTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepSkipSummaryTests.swift new file mode 100644 index 0000000000..e90f095f70 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepSkipSummaryTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import StrandAnalytics + +/// Pins the collapsed skipped-day line (#1121). Kotlin twin: `SleepSkipSummaryTest`. The two must emit +/// byte-identical strings, so these expectations are spelled out rather than pattern-matched. +final class SleepSkipSummaryTests: XCTestCase { + + func testNothingSkippedStaysSilent() { + XCTAssertNil(skippedSleepDaysLine([], minHrSamples: 200)) + } + + func testOneDay() { + XCTAssertEqual( + skippedSleepDaysLine([(day: "2026-08-25", hrSamples: 97)], minHrSamples: 200), + "sleep SKIPPED 1 day(s) — need ≥200 hrSamples: hrSamples=97 on 1 day(s): 2026-08-25" + ) + } + + func testGroupsByCountAscendingAndSortsDays() { + let line = skippedSleepDaysLine([ + (day: "2026-08-07", hrSamples: 0), + (day: "2026-08-25", hrSamples: 97), + (day: "2026-08-05", hrSamples: 0), + ], minHrSamples: 200) + XCTAssertEqual( + line, + "sleep SKIPPED 3 day(s) — need ≥200 hrSamples: " + + "hrSamples=0 on 2 day(s): 2026-08-05, 2026-08-07; " + + "hrSamples=97 on 1 day(s): 2026-08-25" + ) + } + + func testEveryDayIsListed() { + // Lossless on purpose: a range would hide a gap, and a gap in which days lack raw HR is exactly + // the thing an investigation is looking for. + let days = (5...9).map { (day: String(format: "2026-08-%02d", $0), hrSamples: 0) } + let line = skippedSleepDaysLine(days, minHrSamples: 200)! + for d in ["2026-08-05", "2026-08-06", "2026-08-07", "2026-08-08", "2026-08-09"] { + XCTAssertTrue(line.contains(d), "missing \(d)") + } + } +} diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index 0018488fd8..8fdcd39c48 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -804,6 +804,9 @@ final class IntelligenceEngine: ObservableObject { // along on one; carried out alongside `out` and replayed through `diagnosticSink` on the main // actor below, same as `rhrLine`/the trace arrays. Mirrors the Kotlin `diag` sink. var skippedDayLines: [String] = [] + // #1121: days skipped for too little raw HR, collected and emitted as ONE line after the loop + // instead of one line each, every pass — see `skippedSleepDaysLine`. + var skippedSleepDays: [(day: String, hrSamples: Int)] = [] // #938: the WHOOP 4.0 ADC offset is per-device, not per-night. Learn one anchor per owner // from the whole scan window and reuse it for every night so cross-night deviations survive. let skinAnchorScanFrom = nowLocalMidnight - (maxDays - 1) * 86_400 - 30 * 3_600 @@ -904,7 +907,7 @@ final class IntelligenceEngine: ObservableObject { // This day still paid for its read; count it, or the tally under-reports exactly the // sparse-history installs where reads dominate most. dayPrepSeconds += Date().timeIntervalSince(tPrep0) - skippedDayLines.append("sleep day=\(day) SKIPPED hrSamples=\(hr.count) (need ≥200)") + skippedSleepDays.append((day: day, hrSamples: hr.count)) continue } let rr = (try? await store.rrIntervals(deviceId: owner, from: from, to: to, limit: 200_000)) ?? [] @@ -1378,6 +1381,9 @@ 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. + if let line = skippedSleepDaysLine(skippedSleepDays, minHrSamples: 200) { + skippedDayLines.append(line) + } skippedDayLines.append("analyzeRecent dayCache reused=\(dayCacheReused)/" + "\(dayCacheReused + dayCacheCacheable) " + "size=\(dayScanCacheLocal.count) days=\(maxDays)") 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 fa4fc1d613..ad3891769f 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -61,6 +61,14 @@ object IntelligenceEngine { */ private val analyzeGate = Mutex() + /** #1121: days this pass skipped for too little raw HR, emitted as ONE line after the loop instead of + * one line each — see [skippedSleepDaysLine]. A FIELD, not a local, for a mechanical reason: + * [analyzeRecentOnCpu] is `suspend` and sits 64 bytes under the JVM ceiling #1524 guards, so one more + * local live across a loop full of suspension points is saved and restored at every one of them — + * measured at +444 bytes, seven times the whole margin. Cleared at the top of each pass. Safe for the + * same reason [dayScanCache] is: every pass runs under [analyzeGate], so there is no concurrent access. */ + private val skippedSleepDays = SleepSkipCollector() + /** * #1005 BATTERY: in-memory per-day reuse for [analyzeRecent]'s pass-1 loop, keyed by day. On a heavy user * (21 nights, ~178 k HR rows/night, a 1.26 GB store) every re-score re-read *every* night's raw streams @@ -733,6 +741,7 @@ object IntelligenceEngine { // itself. Emitted once per pass beside the reuse line. Byte-identical line to the Swift twin. var dayPrepNanos = 0L var dayScoreNanos = 0L + skippedSleepDays.reset() // #1538: days that were actually cacheable this pass (freshly scored AND stored under a key). // Together with [dayCacheReused] this is the honest denominator for the reuse ratio — see the // diagnostic at the end of the loop. @@ -861,7 +870,9 @@ object IntelligenceEngine { // This day still paid for its read; count it, or the tally under-reports exactly the // sparse-history installs where reads dominate most. dayPrepNanos += System.nanoTime() - tPrep0 - diag("sleep day=$day SKIPPED hrSamples=${hr.size} (need ≥$MIN_HR_SAMPLES)") + // Collected, not emitted: a day that will never have raw HR is re-skipped on every pass, + // so per-day lines repeat forever and evict older lines from the rolling log (#1121). + skippedSleepDays.add(day, hr.size) continue } val rr = repo.rrIntervals(owner, from, to, STREAM_LIMIT) @@ -1274,6 +1285,7 @@ object IntelligenceEngine { // unreadable fingerprint, or a night under the >=200-sample floor — can never be reused, so counting // it against the ratio made a healthy cache look broken and put a floor under how good the number // could ever get. Byte-identical string to the Swift twin. + skippedSleepDays.emit(MIN_HR_SAMPLES, diag) diag("analyzeRecent dayCache reused=$dayCacheReused/${dayCacheReused + dayCacheCacheable} " + "size=${dayScanCache.size} days=$maxDays") // #1538: where the pass actually goes. `prep` is the nine windowed store reads plus the session diff --git a/android/app/src/main/java/com/noop/analytics/SleepSkipSummary.kt b/android/app/src/main/java/com/noop/analytics/SleepSkipSummary.kt new file mode 100644 index 0000000000..74dbdc12bf --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/SleepSkipSummary.kt @@ -0,0 +1,65 @@ +package com.noop.analytics + +/** + * One line summarising every day a scoring pass skipped for want of HR samples, replacing one line per + * skipped day. + * + * A 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 the steady state for an importing user is a permanent + * block of un-scoreable days. In one field capture that was 1262 lines - 21 days re-skipped across 63 + * passes in two hours, about a fifth of everything the log had to say. + * + * That matters because the strap log is a fixed-size rolling buffer: noise does not merely annoy, it + * evicts the older lines an investigation needs. Collapsing per pass keeps every fact - which days, and + * each day's own HR count - while removing the repetition, so days are grouped by their sample count and + * listed rather than summarised into a range that could hide a gap. + * + * Returns null when nothing was skipped, so a healthy pass stays silent. Swift twin: + * `StrandAnalytics.skippedSleepDaysLine`. + */ +internal fun skippedSleepDaysLine(skipped: List>, minHrSamples: Int): String? { + if (skipped.isEmpty()) return null + val groups = skipped.groupBy { it.second }.toSortedMap().map { (count, entries) -> + val days = entries.map { it.first }.sorted() + "hrSamples=$count on ${days.size} day(s): ${days.joinToString(", ")}" + } + return "sleep SKIPPED ${skipped.size} day(s) — need ≥$minHrSamples hrSamples: " + + groups.joinToString("; ") +} + +/** + * Accumulates the skipped days for one pass. + * + * A class rather than a `mutableListOf>()` built inline, for the same reason + * [DayTraceRecorders] is one: `analyzeRecentOnCpu` sits against the JVM's 64 KB per-method ceiling that + * #1524 guards, and the generic construction plus the null-check branch cost enough there to break it. + * Holding both inside the collector leaves the hot method with three plain calls. + */ +internal class SleepSkipCollector { + private val skipped = mutableListOf>() + + fun add(day: String, hrSamples: Int) { + skipped += day to hrSamples + } + + /** Drop the previous pass's days. Called at the top of every pass, under `analyzeGate`. */ + fun reset() { + skipped.clear() + } + + /** The one summary line for this pass, or null when nothing was skipped. */ + fun line(minHrSamples: Int): String? = skippedSleepDaysLine(skipped, minHrSamples) + + /** + * Emit this pass's summary to [diag], if there is one. + * + * The null check lives here rather than at the call site deliberately: the caller is + * `analyzeRecentOnCpu`, which passes the JVM ceiling test with single-digit bytes to spare, so a local + * plus a branch there costs margin that this method has in abundance. + */ + fun emit(minHrSamples: Int, diag: (String) -> Unit) { + val text = line(minHrSamples) + if (text != null) diag(text) + } +} diff --git a/android/app/src/test/java/com/noop/analytics/SleepSkipSummaryTest.kt b/android/app/src/test/java/com/noop/analytics/SleepSkipSummaryTest.kt new file mode 100644 index 0000000000..16b90e99b6 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/SleepSkipSummaryTest.kt @@ -0,0 +1,60 @@ +package com.noop.analytics + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the collapsed skipped-day line (#1121). Swift twin: `SleepSkipSummaryTests`. The two must emit + * byte-identical strings, so these expectations are spelled out rather than pattern-matched. + */ +class SleepSkipSummaryTest { + + @Test + fun `nothing skipped stays silent`() { + assertNull(skippedSleepDaysLine(emptyList(), 200)) + } + + @Test + fun `one day`() { + assertEquals( + "sleep SKIPPED 1 day(s) — need ≥200 hrSamples: hrSamples=97 on 1 day(s): 2026-08-25", + skippedSleepDaysLine(listOf("2026-08-25" to 97), 200), + ) + } + + @Test + fun `groups by count ascending and sorts days`() { + assertEquals( + "sleep SKIPPED 3 day(s) — need ≥200 hrSamples: " + + "hrSamples=0 on 2 day(s): 2026-08-05, 2026-08-07; " + + "hrSamples=97 on 1 day(s): 2026-08-25", + skippedSleepDaysLine( + listOf("2026-08-07" to 0, "2026-08-25" to 97, "2026-08-05" to 0), 200, + ), + ) + } + + @Test + fun `every day is listed`() { + // Lossless on purpose: a range would hide a gap, and a gap in which days lack raw HR is exactly + // the thing an investigation is looking for. + val days = (5..9).map { "2026-08-%02d".format(it) to 0 } + val line = skippedSleepDaysLine(days, 200)!! + days.forEach { assertTrue("missing ${it.first}", line.contains(it.first)) } + } + + @Test + fun `the collector emits once per pass and resets`() { + val out = mutableListOf() + val c = SleepSkipCollector() + c.add("2026-08-05", 0) + c.add("2026-08-06", 0) + c.emit(200) { out += it } + assertEquals(1, out.size) + c.reset() + c.emit(200) { out += it } // a pass that skipped nothing must add no line + assertEquals(1, out.size) + } +} From 639e7763daec26d9d38d36c98465dcf82888578b Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Wed, 26 Aug 2026 00:27:24 +1200 Subject: [PATCH 2/4] analytics: stop the new emit from splitting the #1538 comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review of the previous commit: on both platforms the summary emit was inserted between the #1538 comment and the dayCache line that comment describes, so it read as documentation for the new call instead. Same shape as the detached-doc-comment trap, but with `//` comments, which the doc lint does not check — it only binds `/** */` and `///` blocks. Moved above the comment block on both sides. No behaviour change; the summary still emits immediately before the dayCache line, in the same order on both platforms. Also confirmed while re-reviewing: both analyzeRecentOnCpu call sites are inside analyzeGate.withLock (opened 420, closed 437), so the collector field genuinely has no concurrent access; the #899 heal re-pass calls the method a second time under that same lock, where reset() correctly gives that pass its own summary, matching the old two-sets-of-lines behaviour; and there is no early return between reset() and emit() on either platform, so a pass can never collect days and then drop them. compileFullDebugKotlin clean; budget test 61495/61535; 5 JVM tests green; doc_comment_lint clean. --- Strand/Data/IntelligenceEngine.swift | 6 +++--- .../src/main/java/com/noop/analytics/IntelligenceEngine.kt | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index 8fdcd39c48..c8c44f6e13 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -1375,15 +1375,15 @@ final class IntelligenceEngine: ObservableObject { let dayCacheWindow = Set((0..=200-sample floor — can never be reused, so counting // it against the ratio made a healthy cache look broken and put a floor under how good the number // could ever get. Byte-identical string to the Swift twin. - skippedSleepDays.emit(MIN_HR_SAMPLES, diag) diag("analyzeRecent dayCache reused=$dayCacheReused/${dayCacheReused + dayCacheCacheable} " + "size=${dayScanCache.size} days=$maxDays") // #1538: where the pass actually goes. `prep` is the nine windowed store reads plus the session From 2106b47121538353bbe058372f99938d7847a969 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Wed, 26 Aug 2026 00:30:45 +1200 Subject: [PATCH 3/4] analytics: give Swift the named HR-sample threshold Kotlin already had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity re-review of the collapse change. Kotlin gates on a named constant, MIN_HR_SAMPLES = 200, in one place. Swift carried the bare literal at the guard, and the previous commit added a SECOND executable copy at the new summary call — so a threshold change had to find every copy on this platform and exactly one on the other, and I had just made that worse. Added `IntelligenceEngine.minHrSamples` and routed both uses through it. The two constants are the same number; nothing about the emitted line changes. Also recorded the one structural asymmetry deliberately, at the Swift declaration: the collector is a LOCAL here and a FIELD in Kotlin, because Kotlin's analyzeRecentOnCpu is `suspend` and sits against the JVM method-size ceiling, where an extra local live across the loop costs a save/restore at every suspension point. Swift has no such constraint and the emitted line is identical either way — the note says not to "align" the two shapes, since doing so would either cost Kotlin its margin or add a field to Swift for no reason. Two prose references to `hr.count >= 200` are left as they are: the value is unchanged, so they still describe the behaviour accurately. compileFullDebugKotlin clean; budget test 61495/61535; JVM tests green; doc_comment_lint and i18n_audit clean. The Swift edits are app-target and need app-build. --- Strand/Data/IntelligenceEngine.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index c8c44f6e13..f7bf99a98a 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -11,6 +11,12 @@ import StrandAnalytics /// itself rather than relying on the values WHOOP computed in the imported CSV. @MainActor final class IntelligenceEngine: ObservableObject { + /// Minimum raw HR samples a day needs before it is scored at all. Mirrors the Kotlin + /// `IntelligenceEngine.MIN_HR_SAMPLES`, which has been a named constant there while this side carried + /// the literal in more than one place — so a threshold change had to find every copy on this platform + /// and exactly one on the other. + static let minHrSamples = 200 + private let repo: Repository private let profile: ProfileStore /// The CANONICAL id under whose `-noop` sibling this engine WRITES the computed daily rows, and from @@ -805,7 +811,11 @@ final class IntelligenceEngine: ObservableObject { // actor below, same as `rhrLine`/the trace arrays. Mirrors the Kotlin `diag` sink. var skippedDayLines: [String] = [] // #1121: days skipped for too little raw HR, collected and emitted as ONE line after the loop - // instead of one line each, every pass — see `skippedSleepDaysLine`. + // instead of one line each, every pass — see `skippedSleepDaysLine`. A LOCAL here, where the + // Kotlin twin has to use a field: its `analyzeRecentOnCpu` is `suspend` and sits against the JVM + // method-size ceiling, so an extra local live across the loop costs a save/restore at every + // suspension point. No such constraint applies here, and the emitted line is identical either + // way — do not "align" the two shapes. var skippedSleepDays: [(day: String, hrSamples: Int)] = [] // #938: the WHOOP 4.0 ADC offset is per-device, not per-night. Learn one anchor per owner // from the whole scan window and reuse it for every night so cross-night deviations survive. @@ -903,7 +913,7 @@ final class IntelligenceEngine: ObservableObject { // guessed, for the same reason the day-cache duration is. let tPrep0 = Date() let hr = (try? await store.hrSamples(deviceId: owner, from: from, to: to, limit: 200_000)) ?? [] - guard hr.count >= 200 else { + guard hr.count >= Self.minHrSamples else { // This day still paid for its read; count it, or the tally under-reports exactly the // sparse-history installs where reads dominate most. dayPrepSeconds += Date().timeIntervalSince(tPrep0) @@ -1375,7 +1385,7 @@ final class IntelligenceEngine: ObservableObject { let dayCacheWindow = Set((0.. Date: Wed, 26 Aug 2026 00:33:59 +1200 Subject: [PATCH 4/4] analytics: spell the threshold constant with its type name, not Self MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both new uses sit inside the Task.detached @Sendable closure, and `Self.` in that position raises questions the explicit type name does not — dynamic Self on a class, and whether the reference implies a capture. The class is final so `Self` should resolve statically, but no app-target Swift compiles outside app-build, and "should" is doing real work in that sentence. The boring form costs a few characters and cannot be wrong. Recorded the reason at the declaration so it does not get tidied back. Also confirmed while re-reviewing: on both platforms the skip path `continue`s before the day reaches the reuse cache, so no cached day can replay a diagnostic in the old per-day format — the cache's own docstring already says a night under the sample floor never reaches it. No behaviour change. compileFullDebugKotlin clean; budget 61495/61535; JVM tests green; doc_comment_lint and i18n_audit clean. --- Strand/Data/IntelligenceEngine.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index f7bf99a98a..e30bf429a9 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -15,6 +15,10 @@ final class IntelligenceEngine: ObservableObject { /// `IntelligenceEngine.MIN_HR_SAMPLES`, which has been a named constant there while this side carried /// the literal in more than one place — so a threshold change had to find every copy on this platform /// and exactly one on the other. + /// Spelled `IntelligenceEngine.minHrSamples` at both use sites rather than `Self.` — both are inside + /// the `Task.detached` @Sendable closure, and the explicit type name cannot raise a dynamic-`Self` or + /// closure-capture question there. No app-target Swift compiles outside app-build, so the boring form + /// is worth the few characters. static let minHrSamples = 200 private let repo: Repository @@ -913,7 +917,7 @@ final class IntelligenceEngine: ObservableObject { // guessed, for the same reason the day-cache duration is. let tPrep0 = Date() let hr = (try? await store.hrSamples(deviceId: owner, from: from, to: to, limit: 200_000)) ?? [] - guard hr.count >= Self.minHrSamples else { + guard hr.count >= IntelligenceEngine.minHrSamples else { // This day still paid for its read; count it, or the tally under-reports exactly the // sparse-history installs where reads dominate most. dayPrepSeconds += Date().timeIntervalSince(tPrep0) @@ -1385,7 +1389,7 @@ final class IntelligenceEngine: ObservableObject { let dayCacheWindow = Set((0..