diff --git a/Strand/App/ActiveWorkoutPersistence.swift b/Strand/App/ActiveWorkoutPersistence.swift index 61eefa90fa..586a3ac787 100644 --- a/Strand/App/ActiveWorkoutPersistence.swift +++ b/Strand/App/ActiveWorkoutPersistence.swift @@ -10,8 +10,9 @@ import WhoopProtocol /// records a route too, and its fixes are banked by `ActiveRouteStore` rather than by this snapshot, /// which stays the cheap per-sample write it was built as.) This is the Apple analogue of Android's /// `ActiveWorkoutStore`/`ActiveWorkoutPersistence`: a tiny `Codable` snapshot (start time, sport, the -/// accumulated HR samples + running stats) is written to `UserDefaults` on start and on every captured -/// sample, and read back on launch so an interrupted session can still be ended and saved. +/// accumulated HR samples + running stats) is checkpointed on start and lifecycle transitions. Captured +/// samples queue scoring and encoding off the main actor before the snapshot is written to `UserDefaults`. +/// It is read back on launch so an interrupted session can still be ended and saved. /// /// On-device only; mirrors the existing `moments` / `sleepMarks` `UserDefaults` persistence in `AppModel`. /// The encode/decode is pure (no `UserDefaults` dependency on the codec itself) so the persist/rehydrate @@ -69,7 +70,7 @@ enum ActiveWorkoutPersistence { ) } - /// Persist (overwrite) the snapshot. Cheap; called on start + each captured sample. + /// Persist (overwrite) a lifecycle checkpoint synchronously. static func store(_ snapshot: Snapshot, into defaults: UserDefaults = .standard) { guard let data = encode(snapshot) else { return } defaults.set(data, forKey: defaultsKey) diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 71b62e6f26..ee653a98bb 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -110,6 +110,25 @@ final class AppModel: ObservableObject { /// Android's `ActiveWorkout.gpsEnabled`. private var activeWorkoutIsGps = false + private(set) var workoutSampleRevision: UInt64 = 0 + private(set) var workoutSampleTask: Task? + private var pendingWorkoutSample: WorkoutSampleWork? + + struct WorkoutSampleWork { + let revision: UInt64 + let snapshot: ActiveWorkoutPersistence.Snapshot + let hrMax: Double + let method: StrainScorer.Method + let sex: String + + func process() -> (strain: Double, data: Data?) { + var scored = snapshot + scored.liveStrain = StrainScorer.strain(scored.samples, maxHR: hrMax, + method: method, sex: sex) ?? 0 + return (scored.liveStrain, ActiveWorkoutPersistence.encode(scored)) + } + } + /// A manual workout in progress. `samples` accumulate from the smoothed live `bpm`; `liveStrain` /// is recomputed as the window grows so the active card can show strain building in real time. struct ActiveWorkout: Equatable { @@ -155,6 +174,15 @@ final class AppModel: ObservableObject { /// second folded into `peakHr` (`recordSample`). Nil with no samples. var savedPeak: Int? { samples.map(\.bpm).max().map { max($0, peakHr) } } + var snapshot: ActiveWorkoutPersistence.Snapshot { + ActiveWorkoutPersistence.Snapshot( + startSec: Int(start.timeIntervalSince1970), sport: sport, samples: samples, + avgHr: avgHr, peakHr: peakHr, liveStrain: liveStrain, + pausedAtSec: pausedAt.map { Int($0.timeIntervalSince1970) }, + pausedDurationSec: Int(pausedDuration), sessionID: sessionID, + pausedForPulseLoss: pausedForPulseLoss, completedPauses: completedPauses) + } + /// Delegates to `ActiveWorkoutClock` so this and the two card surfaces cannot drift apart again. func elapsed(at now: Date = Date()) -> TimeInterval { ActiveWorkoutClock.activeElapsed(start: start, pausedAt: pausedAt, @@ -887,25 +915,12 @@ final class AppModel: ObservableObject { domain: .dataImport) } - /// Persist the in-flight manual workout to `UserDefaults` so it survives the app being killed mid- - /// session (#529). Called on start + each captured sample. A no-op when nothing is running. The Apple - /// analogue of Android's `persistNonGpsWorkout`. A distance workout records a route as well, and its - /// fixes are banked separately by `ActiveRouteStore`: keeping them out of here is what lets this stay - /// a small per-sample write instead of rewriting a growing route on every beat. - private func persistActiveWorkout() { + /// Bank the latest raw window at a lifecycle boundary, before the app can be suspended. + func persistActiveWorkout() { + workoutSampleRevision &+= 1 + pendingWorkoutSample = nil guard let w = activeWorkout else { return } - ActiveWorkoutPersistence.store( - ActiveWorkoutPersistence.Snapshot( - startSec: Int(w.start.timeIntervalSince1970), - sport: w.sport, - samples: w.samples, - avgHr: w.avgHr, - peakHr: w.peakHr, - liveStrain: w.liveStrain, - pausedAtSec: w.pausedAt.map { Int($0.timeIntervalSince1970) }, - pausedDurationSec: Int(w.pausedDuration), - sessionID: w.sessionID, pausedForPulseLoss: w.pausedForPulseLoss, - completedPauses: w.completedPauses)) + ActiveWorkoutPersistence.store(w.snapshot) } /// If a manual workout was in flight when iOS killed the app, rebuild `activeWorkout` from the durable @@ -974,6 +989,8 @@ final class AppModel: ObservableObject { private func clearActiveWorkout() { guard activeWorkout != nil else { return } + workoutSampleRevision &+= 1 + pendingWorkoutSample = nil activeWorkout = nil if activeWorkoutIsGps { gpsRecorder.stop() } activeWorkoutIsGps = false @@ -1091,25 +1108,49 @@ final class AppModel: ObservableObject { await repo.refresh() } - /// Append the current smoothed `bpm` to the active workout and recompute its running strain. Called - /// from `ingestHR` on every fresh sample; a no-op when no workout is running. Recomputing strain - /// over the growing window each sample is cheap at the ~1 Hz live-HR cadence. - private func captureWorkoutSample() { + /// Keep every accepted sample; score and encode immutable snapshots off the main actor. + func captureWorkoutSample(at date: Date? = nil) { guard var w = activeWorkout, !w.isPaused, let hr = bpm else { return } - // A second that already has its sample moves only the peak: publish that, and skip the rescore and the - // snapshot (the next second's sample carries the peak into the snapshot). let peakBefore = w.peakHr - guard w.recordSample(HRSample(ts: Int(Date().timeIntervalSince1970), bpm: hr)) else { - if w.peakHr != peakBefore { activeWorkout = w } + guard w.recordSample(HRSample(ts: Int((date ?? Date()).timeIntervalSince1970), bpm: hr)) else { + if w.peakHr != peakBefore { + activeWorkout = w + queueWorkoutSample(w) + } return } w.peakHr = max(w.peakHr, hr) - w.avgHr = Int((Double(w.samples.map(\.bpm).reduce(0, +)) / Double(w.samples.count)).rounded()) - w.liveStrain = StrainScorer.strain(w.samples, maxHR: Double(profile.hrMax), - method: PuffinExperiment.effortMethod, sex: profile.sex) ?? 0 + w.avgHr = Int((Double(w.samples.reduce(0) { $0 + $1.bpm }) / Double(w.samples.count)).rounded()) activeWorkout = w - // Re-snapshot the durable session so a kill keeps the latest accumulated HR window (#529). - persistActiveWorkout() + queueWorkoutSample(w) + } + + private func queueWorkoutSample(_ workout: ActiveWorkout) { + workoutSampleRevision &+= 1 + pendingWorkoutSample = WorkoutSampleWork(revision: workoutSampleRevision, + snapshot: workout.snapshot, hrMax: Double(profile.hrMax), + method: PuffinExperiment.effortMethod, sex: profile.sex) + guard workoutSampleTask == nil else { return } + workoutSampleTask = Task { [weak self] in + while let work = self?.pendingWorkoutSample { + self?.pendingWorkoutSample = nil + let result = await Task.detached(priority: .utility) { work.process() }.value + self?.applyWorkoutSample(work, result: result) + } + self?.workoutSampleTask = nil + } + } + + func applyWorkoutSample(_ work: WorkoutSampleWork, result: (strain: Double, data: Data?)) { + guard work.revision == workoutSampleRevision, var workout = activeWorkout, + workout.sessionID == work.snapshot.sessionID, !workout.isPaused else { return } + if workout.liveStrain != result.strain { + workout.liveStrain = result.strain + activeWorkout = workout + } + if let data = result.data { + UserDefaults.standard.set(data, forKey: ActiveWorkoutPersistence.defaultsKey) + } } /// Drop the smoothing window and blank the hero number so a resume / re-attach shows "," diff --git a/Strand/App/StrandApp.swift b/Strand/App/StrandApp.swift index 588e83a2e4..cd78e18b33 100644 --- a/Strand/App/StrandApp.swift +++ b/Strand/App/StrandApp.swift @@ -78,6 +78,7 @@ struct StrandApp: App { // this target is macOS 13. .onChange(of: scenePhase) { phase in if phase == .active { model.ble.requestSync(.foreground) } + if phase == .background { model.persistActiveWorkout() } } } .windowStyle(.hiddenTitleBar) diff --git a/StrandTests/OfflineWorkoutSaveTests.swift b/StrandTests/OfflineWorkoutSaveTests.swift index dc2ad43c87..ff4f95c1ee 100644 --- a/StrandTests/OfflineWorkoutSaveTests.swift +++ b/StrandTests/OfflineWorkoutSaveTests.swift @@ -1,10 +1,20 @@ import XCTest import WhoopStore +import WhoopProtocol @testable import Strand @MainActor final class OfflineWorkoutSaveTests: XCTestCase { func testNoPulseOrGPSStillSavesAndFailedWriteKeepsTheSessionRetryable() async throws { + try await assertFailedWriteIsRetryable(samples: []) + } + + func testFailedWriteRetainsTheLatestSamplesWhileAWorkerIsPending() async throws { + let started = Int(Date().timeIntervalSince1970) - 600 + try await assertFailedWriteIsRetryable(samples: (0..<600).map { HRSample(ts: started + $0, bpm: 150) }) + } + + private func assertFailedWriteIsRetryable(samples: [HRSample]) async throws { let defaults = UserDefaults.standard let keys = [ActiveWorkoutPersistence.defaultsKey, RecordedWorkoutHistory.defaultsKey] let originals = keys.map { ($0, defaults.object(forKey: $0)) } @@ -25,12 +35,19 @@ final class OfflineWorkoutSaveTests: XCTestCase { model.repo.setStoreForTesting(store) let started = Date().addingTimeInterval(-600) model.activeWorkout = AppModel.ActiveWorkout(start: started, sport: "Strength") + for sample in samples { + model.bpm = sample.bpm + model.captureWorkoutSample(at: Date(timeIntervalSince1970: Double(sample.ts))) + } let id = try XCTUnwrap(model.activeWorkout?.sessionID) try execute("CREATE TRIGGER fail_workout_save BEFORE INSERT ON workout BEGIN SELECT RAISE(FAIL, 'test disk error'); END", path: path) do { try await model.finishWorkout(); XCTFail("the write should fail") } catch {} XCTAssertEqual(model.activeWorkout?.sessionID, id) XCTAssertTrue(model.activeWorkout?.isPaused == true) XCTAssertEqual(ActiveWorkoutPersistence.load()?.sessionID, id) + await model.workoutSampleTask?.value + XCTAssertEqual(model.activeWorkout?.samples, samples) + XCTAssertEqual(ActiveWorkoutPersistence.load()?.samples, samples) try execute("DROP TRIGGER fail_workout_save", path: path) try await model.finishWorkout() try await model.finishWorkout() @@ -42,9 +59,16 @@ final class OfflineWorkoutSaveTests: XCTestCase { let row = try XCTUnwrap(rows.first) XCTAssertEqual(row.sport, "Strength") XCTAssertEqual(row.durationS ?? 0, 600, accuracy: 2) - XCTAssertNil(row.avgHr) - XCTAssertNil(row.strain) - XCTAssertNil(row.energyKcal) + if samples.isEmpty { + XCTAssertNil(row.avgHr) + XCTAssertNil(row.strain) + XCTAssertNil(row.energyKcal) + } else { + XCTAssertEqual(row.avgHr, 150) + XCTAssertEqual(row.maxHr, 150) + XCTAssertNotNil(row.strain) + XCTAssertNotNil(row.energyKcal) + } XCTAssertTrue(RecordedWorkoutHistory.load().contains { $0.matches(row, deviceId: model.deviceId) }) } diff --git a/StrandTests/WorkoutSampleBenchmarkTests.swift b/StrandTests/WorkoutSampleBenchmarkTests.swift new file mode 100644 index 0000000000..d5c70d0f6a --- /dev/null +++ b/StrandTests/WorkoutSampleBenchmarkTests.swift @@ -0,0 +1,80 @@ +import XCTest +import StrandAnalytics +import WhoopProtocol +@testable import Strand + +@MainActor +final class WorkoutSampleBenchmarkTests: XCTestCase { + func testReleaseBenchmark() async throws { + guard !_isDebugAssertConfiguration() else { throw XCTSkip("Run this benchmark in Release.") } + let defaults = UserDefaults.standard + let original = defaults.object(forKey: ActiveWorkoutPersistence.defaultsKey) + let previousModel = AppModel.shared + defer { + AppModel.shared = previousModel + if let original { defaults.set(original, forKey: ActiveWorkoutPersistence.defaultsKey) } + else { defaults.removeObject(forKey: ActiveWorkoutPersistence.defaultsKey) } + } + let model = AppModel() + model.bpm = 150 + for count in [0, 600, 3_600, 7_200] { + let start = 1_700_000_000 + let seed = (0.. 0 { samples[count - 1] = HRSample(ts: start + count + trial, bpm: 150) } + var workout = AppModel.ActiveWorkout(start: Date(timeIntervalSince1970: Double(start))) + workout.samples = samples + workout.sessionID = "synthetic-benchmark" + let (score, scoringMs) = timed { + StrainScorer.strain(samples, maxHR: Double(model.profile.hrMax), + method: PuffinExperiment.effortMethod, sex: model.profile.sex) ?? 0 + } + workout.liveStrain = score + let snapshot = ActiveWorkoutPersistence.Snapshot( + startSec: start, sport: workout.sport, samples: samples, + avgHr: workout.avgHr, peakHr: workout.peakHr, liveStrain: score, + pausedDurationSec: 0, sessionID: workout.sessionID, + pausedForPulseLoss: false, completedPauses: []) + let (data, encodingMs) = timed { ActiveWorkoutPersistence.encode(snapshot) } + XCTAssertNotNil(data) + model.activeWorkout = workout + let date = Date(timeIntervalSince1970: Double(start + count + trial + 1)) + let (_, captureMs) = timed { model.captureWorkoutSample(at: date) } + let work = AppModel.WorkoutSampleWork(revision: model.workoutSampleRevision, + snapshot: model.activeWorkout!.snapshot, hrMax: Double(model.profile.hrMax), + method: PuffinExperiment.effortMethod, sex: model.profile.sex) + let result = work.process() + let (_, mergeMs) = timed { model.applyWorkoutSample(work, result: result) } + await model.workoutSampleTask?.value + XCTAssertEqual(model.activeWorkout?.samples.count, count + 1) + if trial >= 5 { + scoreTimes.append(scoringMs) + encodeTimes.append(encodingMs) + captureTimes.append(captureMs) + mergeTimes.append(mergeMs) + mainTimes.append(captureMs + mergeMs) + } + } + print("WORKOUT_BENCHMARK samples=\(count) score=\(summary(scoreTimes)) encode=\(summary(encodeTimes)) capture=\(summary(captureTimes)) merge=\(summary(mergeTimes)) main=\(summary(mainTimes))") + if count == 7_200 { + XCTAssertLessThan(mainTimes.sorted()[Int(ceil(Double(mainTimes.count) * 0.95)) - 1], 2, + "Remaining capture + result merge must stay under 2 ms p95.") + } + } + } + + private func timed(_ body: () -> T) -> (T, Double) { + let start = DispatchTime.now().uptimeNanoseconds + let value = ObjectiveC.autoreleasepool(invoking: body) + return (value, Double(DispatchTime.now().uptimeNanoseconds - start) / 1_000_000) + } + + private func summary(_ times: [Double]) -> String { + let sorted = times.sorted() + return String(format: "%.4f/%.4f", sorted[sorted.count / 2], sorted[Int(ceil(Double(sorted.count) * 0.95)) - 1]) + } +} diff --git a/StrandTests/WorkoutSampleProcessingTests.swift b/StrandTests/WorkoutSampleProcessingTests.swift new file mode 100644 index 0000000000..64f1176bc4 --- /dev/null +++ b/StrandTests/WorkoutSampleProcessingTests.swift @@ -0,0 +1,176 @@ +import XCTest +import StrandAnalytics +import WhoopProtocol +@testable import Strand + +@MainActor +final class WorkoutSampleProcessingTests: XCTestCase { + func testCoalescingRetainsEverySampleAndMatchesTheOriginalStats() async { + await withModel { model in + var expected: [HRSample] = [] + for second in 0..<650 { + let sample = HRSample(ts: 1_700_000_000 + second + (second / 120) * 45, + bpm: second < 200 ? 150 : 110 + second % 65) + expected.append(sample) + model.bpm = sample.bpm + let date = Date(timeIntervalSince1970: Double(sample.ts)) + model.captureWorkoutSample(at: date) + model.bpm = sample.bpm + 5 + model.captureWorkoutSample(at: date) + } + await model.workoutSampleTask?.value + let workout = model.activeWorkout + XCTAssertEqual(workout?.samples, expected) + XCTAssertEqual(workout?.avgHr, Int((Double(expected.map(\.bpm).reduce(0, +)) / Double(expected.count)).rounded())) + XCTAssertEqual(workout?.peakHr, expected.map(\.bpm).max()! + 5) + XCTAssertEqual(workout?.liveStrain, StrainScorer.strain(expected, + maxHR: Double(model.profile.hrMax), method: PuffinExperiment.effortMethod, sex: model.profile.sex) ?? 0) + XCTAssertEqual(ActiveWorkoutPersistence.load(), workout?.snapshot) + XCTAssertNil(model.workoutSampleTask) + } + } + + func testWorkerPreservesBothEffortMethodsAndTheSnapshot() async { + await withModel { model in + for method in [StrainScorer.Method.edwards, .banister] { + for sex in ["male", "female"] { + let samples = (0..<650).map { HRSample(ts: 1_700_000_000 + $0 * 2, bpm: 110 + $0 % 65) } + var workout = model.activeWorkout! + workout.samples = samples + workout.avgHr = Int((Double(samples.map(\.bpm).reduce(0, +)) / Double(samples.count)).rounded()) + workout.peakHr = 185 + workout.completedPauses = [DateInterval(start: workout.start, duration: 20)] + let work = AppModel.WorkoutSampleWork(revision: 1, snapshot: workout.snapshot, + hrMax: 190, method: method, sex: sex) + let result = work.process() + let expected = StrainScorer.strain(samples, maxHR: 190, method: method, sex: sex) ?? 0 + XCTAssertEqual(result.strain, expected) + var snapshot = workout.snapshot + snapshot.liveStrain = expected + XCTAssertEqual(ActiveWorkoutPersistence.decode(result.data), snapshot) + } + } + } + } + + func testLateResultCannotUndoPause() async { + await withModel { model in + let work = captureWork(model) + let result = work.process() + model.pauseWorkout(at: Date(timeIntervalSince1970: 1_700_000_601)) + model.applyWorkoutSample(work, result: result) + await model.workoutSampleTask?.value + XCTAssertTrue(model.activeWorkout?.isPaused == true) + XCTAssertEqual(model.activeWorkout?.liveStrain, 12) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + } + } + + func testLateSampleResultCannotOverwriteANewerWindow() async { + await withModel { model in + model.persistActiveWorkout() + let checkpoint = ActiveWorkoutPersistence.load() + let work = captureWork(model) + let result = work.process() + model.bpm = 160 + model.captureWorkoutSample(at: Date(timeIntervalSince1970: 1_700_000_601)) + model.applyWorkoutSample(work, result: result) + XCTAssertEqual(model.activeWorkout?.samples.count, 602) + XCTAssertEqual(model.activeWorkout?.liveStrain, 12) + XCTAssertEqual(ActiveWorkoutPersistence.load(), checkpoint) + await model.workoutSampleTask?.value + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + } + } + + func testLateResultCannotCrossResumeAndTheNextSampleStillIncludesTheFullWindow() async { + await withModel { model in + let work = captureWork(model) + let result = work.process() + model.pauseWorkout(at: Date(timeIntervalSince1970: 1_700_000_601)) + model.resumeWorkout(at: Date(timeIntervalSince1970: 1_700_000_631)) + model.applyWorkoutSample(work, result: result) + XCTAssertEqual(model.activeWorkout?.liveStrain, 12) + XCTAssertFalse(model.activeWorkout?.isPaused == true) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + model.captureWorkoutSample(at: Date(timeIntervalSince1970: 1_700_000_632)) + await model.workoutSampleTask?.value + XCTAssertEqual(model.activeWorkout?.samples.count, 602) + XCTAssertEqual(model.activeWorkout?.pausedDuration, 30) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + } + } + + func testLateResultCannotReplaceANewSession() async { + await withModel { model in + let work = captureWork(model) + let result = work.process() + model.discardWorkout() + model.startWorkout(sport: "Strength") + let id = model.activeWorkout?.sessionID + model.applyWorkoutSample(work, result: result) + await model.workoutSampleTask?.value + XCTAssertNotEqual(id, work.snapshot.sessionID) + XCTAssertEqual(model.activeWorkout?.sessionID, id) + XCTAssertEqual(model.activeWorkout?.samples.count, 0) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + } + } + + func testLateResultCannotResurrectAFinishedSession() async { + await withModel { model in + model.activeWorkout = AppModel.ActiveWorkout(start: Date()) + let work = captureWork(model) + let result = work.process() + do { try await model.finishWorkout() } catch { XCTFail("\(error)") } + model.applyWorkoutSample(work, result: result) + await model.workoutSampleTask?.value + XCTAssertNil(model.activeWorkout) + XCTAssertNil(ActiveWorkoutPersistence.load()) + } + } + + func testCheckpointBanksTheLatestWindowAndRejectsAnOlderResult() async { + await withModel { model in + let work = captureWork(model) + let result = work.process() + model.persistActiveWorkout() + XCTAssertEqual(ActiveWorkoutPersistence.load()?.samples.count, 601) + model.applyWorkoutSample(work, result: result) + await model.workoutSampleTask?.value + XCTAssertEqual(model.activeWorkout?.liveStrain, 12) + XCTAssertEqual(ActiveWorkoutPersistence.load(), model.activeWorkout?.snapshot) + let restored = AppModel() + XCTAssertEqual(restored.activeWorkout?.snapshot, model.activeWorkout?.snapshot) + } + } + + private func captureWork(_ model: AppModel) -> AppModel.WorkoutSampleWork { + var workout = model.activeWorkout! + workout.samples = (0..<600).map { HRSample(ts: 1_700_000_000 + $0, bpm: 150) } + workout.avgHr = 150 + workout.peakHr = 150 + workout.liveStrain = 12 + model.activeWorkout = workout + model.bpm = 150 + model.captureWorkoutSample(at: Date(timeIntervalSince1970: 1_700_000_600)) + return AppModel.WorkoutSampleWork(revision: model.workoutSampleRevision, + snapshot: model.activeWorkout!.snapshot, hrMax: Double(model.profile.hrMax), + method: PuffinExperiment.effortMethod, sex: model.profile.sex) + } + + private func withModel(_ check: (AppModel) async -> Void) async { + let defaults = UserDefaults.standard + let original = defaults.object(forKey: ActiveWorkoutPersistence.defaultsKey) + let previousModel = AppModel.shared + defer { + AppModel.shared = previousModel + if let original { defaults.set(original, forKey: ActiveWorkoutPersistence.defaultsKey) } + else { defaults.removeObject(forKey: ActiveWorkoutPersistence.defaultsKey) } + } + let model = AppModel() + model.activeWorkout = AppModel.ActiveWorkout(start: Date(timeIntervalSince1970: 1_700_000_000)) + await check(model) + await model.workoutSampleTask?.value + } +} diff --git a/StrandiOS/App/StrandiOSApp.swift b/StrandiOS/App/StrandiOSApp.swift index e06a838a02..36b70decde 100644 --- a/StrandiOS/App/StrandiOSApp.swift +++ b/StrandiOS/App/StrandiOSApp.swift @@ -380,6 +380,7 @@ struct StrandiOSApp: App { await watch.pushLatest(from: model) } } else if phase == .background { + model.persistActiveWorkout() training.reconcile() // Re-submit on every transition because iOS may discard an old best-effort request. HealthWritebackBackgroundScheduler.updateSchedule( diff --git a/docs/FORK_TRAINING_PERFORMANCE.md b/docs/FORK_TRAINING_PERFORMANCE.md new file mode 100644 index 0000000000..e8d8e6ce4d --- /dev/null +++ b/docs/FORK_TRAINING_PERFORMANCE.md @@ -0,0 +1,93 @@ +# Manual workout sample processing + +The local build-450 recording contains 105 seconds of foreground use during a normal +sport recording, including a 518 ms callback interval on Today. It measures delayed +display-link callbacks, without call stacks that would attribute those delays to a +particular function. Raw device logs remain in ignored local storage. + +The baseline sample path calculated a mean, fingerprinted and scored the growing +pulse window, then JSON-encoded and stored the entire workout snapshot on the main +actor. The benchmark calls those production functions with synthetic samples. + +## Reproducing the baseline + +```sh +xcodegen generate +xcodebuild -project Strand.xcodeproj -scheme Strand -configuration Release \ + -destination 'platform=macOS,arch=arm64' -derivedDataPath build/training-macos-release \ + ARCHS=arm64 ONLY_ACTIVE_ARCH=YES ENABLE_TESTABILITY=YES \ + 'OTHER_SWIFT_FLAGS=$(inherited) -D DEBUG' \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= \ + -only-testing:StrandTests/WorkoutSampleBenchmarkTests test +``` + +The existing test bundle requires DEBUG-only test accessors. The command enables +those accessors while retaining Release optimization (`-O`); the measured capture, +scoring and encoding functions have no DEBUG alternative. Device builds use ordinary +Release settings. The benchmark skips unoptimized Debug builds. + +Each size gets five warmups and 101 measured iterations. Nonempty scoring windows +have a different fingerprint on each iteration, so scoring includes a cache miss. +Scoring and encoding are timed separately. Baseline capture is the complete synchronous +`AppModel.captureWorkoutSample` call, including publisher callbacks and persistence. +Its input starts with the listed number of samples and accepts one additional sample. +Initialization, fixture creation and result assertions are outside the timed calls. + +| Starting samples | Scoring median / p95 | Encoding median / p95 | Synchronous capture median / p95 | +| --- | --- | --- | --- | +| 0 | 0.0053 / 0.0065 ms | 0.0039 / 0.0065 ms | 0.1702 / 0.2253 ms | +| 600 | 0.0078 / 0.0099 ms | 0.3110 / 0.3394 ms | 0.4922 / 0.5579 ms | +| 3,600 | 0.0203 / 0.0215 ms | 1.8089 / 1.8705 ms | 2.1028 / 2.1775 ms | +| 7,200 | 0.0353 / 0.0395 ms | 3.6312 / 3.7570 ms | 4.1668 / 4.2842 ms | + +Encoding accounts for most of this measured cost. Moving it off the main actor is +worth testing, but these numbers do not explain the full 518 ms device delay. + +## After moving scoring and encoding off the main actor + +The mean uses a single reduction without an intermediate array. A main-actor task +awaits one detached scoring-and-encoding job at a time, with one latest pending +snapshot. Samples stay in `activeWorkout`; coalescing replaces derived work, without +dropping readings. Higher peaks from duplicate seconds also replace pending work. +Results must match both session ID and revision. Start, pause, resume, discard and +background checkpoints invalidate older results. Persistence retains its key and +Codable shape, with no added checkpoint timer. Lifecycle checkpoints synchronously +bank the full window and its latest completed Effort estimate; final saving still +recomputes Effort using the existing resting-HR input. + +The updated benchmark times capture and a valid result merge separately. The merge +includes publishing a changed Effort estimate and `UserDefaults.set` of encoded data. +Preparation and computation of that result are outside these main-actor timings. +The table sums capture and merge for each trial before taking its median and p95. +The production worker drains before the next trial. Actor scheduling and SwiftUI +rendering are outside these function timings. A 7,200-sample p95 above 2 ms fails the +opt-in Release benchmark. + +| Starting samples | Capture median / p95 | Result merge median / p95 | Main-actor total median / p95 | +| --- | --- | --- | --- | +| 0 | 0.0145 / 0.0170 ms | 0.3911 / 0.4361 ms | 0.4064 / 0.4535 ms | +| 600 | 0.0150 / 0.0190 ms | 0.3911 / 0.4522 ms | 0.4051 / 0.4676 ms | +| 3,600 | 0.0175 / 0.0255 ms | 0.3996 / 0.4639 ms | 0.4177 / 0.4837 ms | +| 7,200 | 0.0218 / 0.0355 ms | 0.5859 / 0.6795 ms | 0.6102 / 0.7050 ms | + +The same isolated scoring calls after the change measured median / p95 of +0.0219 / 0.0268, 0.0262 / 0.0332, 0.0393 / 0.0459 and 0.0551 / 0.0663 ms for the +four sizes. Encoding measured 0.0214 / 0.0259, 0.3445 / 0.3962, 1.8802 / 2.1310 and +3.8180 / 4.1372 ms. Those functions retain their algorithms and run off the main actor +in the sample path; this change targets main-actor occupancy rather than total CPU. + +At 7,200 starting samples, measured main-actor p95 falls from 4.2842 to 0.7050 ms, +about 84%. The 35 focused macOS tests pass, covering numeric and snapshot equivalence, +unchanged pulse values, duplicate seconds and measurement gaps; obsolete results +after newer samples, pause, resume, replacement, checkpoint and finish; and a failed +database write followed by a successful retry with every sample retained. + +No chart renderer, nightly analysis, global Today refresh, widget comparison or +ActivityKit throttle was changed. This is Apple app scheduling and encoding; the +shared numerical and storage contracts are unchanged. Android is outside this fork +change. An iPhone comparison after installation is still required before claiming +the recorded scrolling stalls are resolved. + +These are local CPU costs on an Apple M4 Max with Xcode 27.0. They do not measure +iPhone scrolling, rendered frames, GPU work or physical disk durability. UserDefaults +writes retain the platform's existing persistence semantics.