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 @@ -14,9 +14,22 @@ import Foundation
/// there is no pass-config signature to invalidate against — the value changes exactly when that one day's
/// gravity changes, and the key below is the whole story.
///
/// Like the day-scan cache this is in-memory and per-process. It never persists, never crosses the
/// `.noopbak` boundary, and a relaunch pays the full fold once. That is deliberate: the cost being closed is
/// the repeat within a process, where an offload storm fires passes back to back.
/// Unlike the day-scan cache this one PERSISTS across launches, and the paragraph above is why it can. A
/// persisted day scan would have to carry `dayCacheConfigSig`, which folds in baselines1 and the habitual
/// sleep terms — and `sleepConsistency` (1-CV over 28 nights) and `habitualMidsleepSec` (a circular mean)
/// shift with ANY night moving, so it would invalidate wholesale on exactly the passes it would need to
/// survive. Nothing pass-global reaches this fold, so the payload stays valid while gravity stands still and
/// the sixty-day fold is paid once per install instead of once per launch: measured 32.9 s -> 3.3 s on a
/// worn 60-day library, which the cold pass otherwise repaid after every relaunch.
///
/// It stays a DERIVED cache and nothing else reads it, so the whole failure surface is one re-fold: a
/// payload that is missing, unreadable, or written by an older fold is discarded rather than repaired.
///
/// It still does NOT cross the `.noopbak` boundary, and must not start: the key is deliberately absent from
/// both `BackupSettings` whitelists. A restore carries the settings and the record store, and this describes
/// neither — it describes gravity rows AS THEY WERE ON ONE DEVICE. Shipping it to another device would be
/// the one way to serve a fold whose key no longer witnesses anything, which is the failure every other
/// guard here exists to prevent. A restored device simply re-folds once.
public enum StepsMotionCache {
/// The per-day reuse key. Reuse a cached motion volume iff this string is unchanged.
///
Expand All @@ -39,4 +52,54 @@ public enum StepsMotionCache {
public static func logLine(reused: Int, folded: Int, size: Int) -> String {
"analyzeRecent stepsMotion reused=\(reused)/\(reused + folded) size=\(size)"
}

/// The version of the FOLD the persisted values were produced by. Bump on any change to
/// `StepsEstimateEngine.dayMotionIntensity` that moves what it returns for the same samples.
///
/// In memory this could not exist: a process cannot outlive the binary that filled it, so the fold that
/// produced a cached value is always the fold that would reproduce it. A persisted entry outlives its
/// build, and `cacheKey` witnesses the INPUTS only — a day whose gravity has not moved keys identically
/// across an app update, so without this the old volume would be served until that day's stream happened
/// to change. A bump discards every entry and costs one full re-fold, once, which is the cheap side.
public static let foldVersion = 1

/// Render the cache for storage. Days are emitted in sorted order so an unchanged cache renders to an
/// identical payload and the write is a no-op rather than churn.
///
/// One line per day, `day\tkey\tmotion`, under a header naming `foldVersion`. Tab-separated because the
/// key itself contains `|`; the motion is written by raw bit pattern so it round-trips exactly and
/// locale-free, the same reason `cacheKey` encodes the skin anchor that way.
public static func serialize(_ entries: [String: (key: String, motion: Double)]) -> String {
var out = header
for day in entries.keys.sorted() {
guard let e = entries[day] else { continue }
out += "\n\(day)\t\(e.key)\t\(e.motion.bitPattern)"
}
return out
}

/// Parse a stored payload. Returns empty on anything it cannot vouch for — a wrong/absent header (an
/// older `foldVersion` included), a malformed line, or an implausible entry count. Every rejection costs
/// one re-fold, so this discards rather than salvages: a half-trusted cache is the one failure mode that
/// could feed a stale volume into the calibration fit.
public static func deserialize(_ raw: String) -> [String: (key: String, motion: Double)] {
var lines = raw.split(separator: "\n", omittingEmptySubsequences: false)
guard lines.first == Substring(header) else { return [:] }
lines.removeFirst()
guard lines.count <= maxEntries else { return [:] }
var out: [String: (key: String, motion: Double)] = [:]
for line in lines {
let f = line.split(separator: "\t", omittingEmptySubsequences: false)
guard f.count == 3, !f[0].isEmpty, !f[1].isEmpty, let bits = UInt64(f[2]) else { continue }
out[String(f[0])] = (key: String(f[1]), motion: Double(bitPattern: bits))
}
return out
}

/// Payload header. Carries `foldVersion` so a fold change invalidates by failing the equality check.
private static var header: String { "stepsMotion v\(foldVersion)" }

/// Upper bound on entries a payload may declare. The writer prunes to the calibration window every pass,
/// so a payload far above it did not come from this cache and is not worth parsing.
private static let maxEntries = 512
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,110 @@ final class StepsMotionCacheTests: XCTestCase {
XCTAssertEqual(StepsMotionCache.logLine(reused: 0, folded: 60, size: 60),
"analyzeRecent stepsMotion reused=0/60 size=60")
}

// MARK: - Persistence

/// The exact payload the cache renders, pinned as a literal. This is the cross-platform contract that
/// matters most now the cache is stored: the Kotlin twin pins the SAME string, so a one-sided change to
/// the header, the separators or the number rendering is caught here rather than by a user whose folds
/// silently stopped being reused after an update.
static let vector = """
stepsMotion v1
2026-09-01\tmy-whoop|8640|1757000000\t4659742922898407424
2026-09-02\tmy-whoop|0|0\t0
"""

static let vectorEntries: [String: (key: String, motion: Double)] = [
"2026-09-01": (key: "my-whoop|8640|1757000000", motion: 3421.75),
"2026-09-02": (key: "my-whoop|0|0", motion: 0),
]

func testSerializeRendersThePinnedPayload() {
XCTAssertEqual(StepsMotionCache.serialize(Self.vectorEntries), Self.vector)
}

func testRoundTripPreservesKeysAndVolumes() {
let back = StepsMotionCache.deserialize(StepsMotionCache.serialize(Self.vectorEntries))
XCTAssertEqual(back.count, 2)
for (day, want) in Self.vectorEntries {
XCTAssertEqual(back[day]?.key, want.key)
XCTAssertEqual(back[day]?.motion, want.motion)
}
}

/// A ZERO fold is a cached VALUE, not a missing one — the engine caches a zero from a read that
/// succeeded so unworn gaps stop re-reading their whole stream every pass. A round trip that dropped it
/// would quietly reintroduce exactly the cost the cache exists to remove, on the sparse libraries it is
/// worst for, so the zero day is asserted present rather than merely equal.
func testZeroFoldSurvivesTheRoundTrip() {
let back = StepsMotionCache.deserialize(StepsMotionCache.serialize(Self.vectorEntries))
XCTAssertNotNil(back["2026-09-02"])
XCTAssertEqual(back["2026-09-02"]?.motion, 0)
}

/// Rendering is sorted, so an unchanged cache re-renders byte-identically and the store write coalesces
/// instead of churning a 60-entry payload on every pass.
func testRenderingIsStableAcrossDictionaryOrder() {
var shuffled: [String: (key: String, motion: Double)] = [:]
for (day, e) in Self.vectorEntries.sorted(by: { $0.key > $1.key }) { shuffled[day] = e }
XCTAssertEqual(StepsMotionCache.serialize(shuffled), StepsMotionCache.serialize(Self.vectorEntries))
}

/// An older fold's payload must be DISCARDED, not read. `cacheKey` witnesses the inputs only, so a day
/// whose gravity has not moved keys identically across an app update — without the header check the
/// pre-update volume would be served until that day's stream happened to change.
func testOlderFoldVersionIsDiscarded() {
let stale = Self.vector.replacingOccurrences(of: "stepsMotion v1", with: "stepsMotion v0")
XCTAssertTrue(StepsMotionCache.deserialize(stale).isEmpty)
}

/// Anything that is not a payload this cache wrote yields an empty cache and one re-fold, never a
/// partially-trusted one.
func testUnreadablePayloadsYieldNothing() {
for raw in ["", "stepsMotion", "garbage", "\n", "v1\n2026-09-01\tk\t0"] {
XCTAssertTrue(StepsMotionCache.deserialize(raw).isEmpty, "expected empty for \(raw.debugDescription)")
}
}

/// A malformed line is skipped and its neighbours survive: a truncated write should cost the days it
/// truncated, not the whole window.
func testMalformedLinesAreSkippedIndividually() {
let raw = """
stepsMotion v1
2026-09-01\tmy-whoop|8640|1757000000\t4659742922898407424
2026-09-02\tmissing-a-field
2026-09-03\tmy-whoop|1|2\tnot-a-number
\tempty-day\t0
2026-09-05\t\t0
2026-09-06\tmy-whoop|3|4\t4623226492472524800
"""
let back = StepsMotionCache.deserialize(raw)
XCTAssertEqual(Set(back.keys), ["2026-09-01", "2026-09-06"])
XCTAssertEqual(back["2026-09-06"]?.motion, 12.5)
}

/// The writer prunes to the calibration window every pass, so a payload far above it did not come from
/// this cache. Rejecting it whole keeps a hand-edited or corrupt store from being parsed at length.
func testImplausiblyLargePayloadIsRejected() {
var raw = "stepsMotion v1"
for i in 0...513 { raw += "\nday-\(i)\tmy-whoop|1|2\t0" }
XCTAssertTrue(StepsMotionCache.deserialize(raw).isEmpty)
}

/// Rendering is a FIXPOINT over a payload this build wrote. The engine skips the store write when the
/// rendered cache equals what is stored, so a pass that reused every day must produce the string it
/// read: if this ever stopped holding, every pass of an offload storm would write ~4 KB to say nothing.
func testRenderingIsAFixpoint() {
let once = StepsMotionCache.serialize(Self.vectorEntries)
XCTAssertEqual(StepsMotionCache.serialize(StepsMotionCache.deserialize(once)), once)
}

/// The other half of that guard: a payload carrying a line this build DROPS must not re-render to
/// itself, so the cleaned version is written back once rather than being re-parsed every launch.
func testDroppedLinesReRenderDifferentlyAndAreRewritten() {
let dirty = Self.vector + "\n2026-09-03\tmy-whoop|1|2\tnot-a-number"
let cleaned = StepsMotionCache.serialize(StepsMotionCache.deserialize(dirty))
XCTAssertNotEqual(cleaned, dirty)
XCTAssertEqual(cleaned, Self.vector)
}
}
32 changes: 32 additions & 0 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ final class IntelligenceEngine: ObservableObject {
/// never crosses `.noopbak`. The engine is a single long-lived instance (AppModel), so this survives the
/// storm's back-to-back passes the drain is made of. See `AnalyzeRecentDayCache` (StrandAnalytics).
private var dayScanCache: [String: (key: String, scan: DayScan)] = [:]
/// UserDefaults key holding the persisted `stepsMotionCache` payload. Versioned in the key as well as
/// in the payload header so a format change cannot even be read, let alone half-parsed.
private static let stepsMotionCacheDefaultsKey = "analyzeRecent.stepsMotionCache.v1"
/// Whether this process has already seeded `stepsMotionCache` from `stepsMotionCacheDefaultsKey`. The
/// read happens once per process, not once per pass: after the first pass the in-memory cache is at
/// least as fresh as the payload, so re-reading it could only ever put back what we just pruned.
private var stepsMotionCacheLoaded = false
/// The payload currently in UserDefaults, so an unchanged cache does not rewrite it. A pass that reused
/// every day renders the string it read, and the passes that do so are the back-to-back ones an offload
/// storm is made of — writing ~4 KB on each of them to store what is already there is the cost this
/// avoids. Not an assumption about what the store does with an identical value: it simply is not asked.
private var stepsMotionCachePersisted = ""
/// Per-day steps-calibration motion folds, `[day: (key, motion)]`, keyed by `StepsMotionCache.cacheKey`.
/// In-memory and per-process exactly like `dayScanCache`; see `StepsMotionCache` for why this one needs
/// no config signature. Pruned to the calibration window each pass so it cannot grow without bound.
Expand Down Expand Up @@ -2374,6 +2386,19 @@ final class IntelligenceEngine: ObservableObject {
// `deviceId` (a MainActor instance `let`) to a local Sendable `String` so the @Sendable detached
// closure captures the VALUE, never `self`, exactly as FIX 1's `ownerFallbackId`.
let stepsFallbackId = deviceId
// Seed the fold cache from storage on the first pass of the process. Without this the sixty-day
// fold is re-paid in full after every relaunch — the cache's whole win is a repeat, and a relaunch
// is a repeat the process boundary hid. Nothing pass-global feeds the fold, so a payload written by
// a previous launch is as good as one written by the previous pass; see `StepsMotionCache`.
if !stepsMotionCacheLoaded {
stepsMotionCacheLoaded = true
if let raw = UserDefaults.standard.string(forKey: Self.stepsMotionCacheDefaultsKey) {
stepsMotionCache = StepsMotionCache.deserialize(raw)
// Seed the write guard with what is actually stored. A payload this build renders identically
// then costs no write at all; one carrying a line we dropped is rewritten clean on this pass.
stepsMotionCachePersisted = raw
}
}
let inStepsMotionCache = stepsMotionCache
let (refStepsByDay, motionByDay, updatedStepsMotionCache, stepsMotionLogLine):
([String: Double], [String: Double], [String: (key: String, motion: Double)], String) =
Expand Down Expand Up @@ -2444,6 +2469,13 @@ final class IntelligenceEngine: ObservableObject {
return (refSteps, motion, motionCacheLocal, motionLog)
}.value
stepsMotionCache = updatedStepsMotionCache
// Write the pruned cache back, only when it moved. `serialize` renders sorted, so a pass that reused
// every day produces the string already stored and skips the write entirely.
let stepsMotionPayload = StepsMotionCache.serialize(stepsMotionCache)
if stepsMotionPayload != stepsMotionCachePersisted {
stepsMotionCachePersisted = stepsMotionPayload
UserDefaults.standard.set(stepsMotionPayload, forKey: Self.stepsMotionCacheDefaultsKey)
}
diagnosticSink?(stepsMotionLogLine, nil)
// #1816: persist whether the strap has banked ANY motion in the calibration scan window, so the
// Today tile can distinguish "Need N more phone-step days" (motion exists, phone half missing)
Expand Down
Loading