Showing 4 changed files with 27 additions and 4 deletions
+4 -2
HealthProbe/Doc/04-project/Import-Optimization-Log.md
@@ -581,6 +581,7 @@ rows exist".
581 581
 | 2026-06-03 | pending | Fast-path unchanged verification after an empty HealthKit delta page. | The successful full-profile reimport spent `15.1s` in finalize despite `0.1s` insert time; Heart Rate alone spent `9.0s` finalizing. The empty-delta path now calls an explicit unchanged-verification store method that copies the previous type summary and daily aggregates without first scanning sample events to prove zero changes. Expected signal: repeated no-delta/full-profile `SummedFinalizeElapsed` and Heart Rate finalize time drop sharply. |
582 582
 | 2026-06-03 | pending | Persist HealthKit capture anchors in SQLite. | A later no-delta attempt still reimported full high-volume types (`Records: 2,646,590`, `WallClock: 45.8s`, Heart Rate `922,521`, Active Energy `346,473`) because incremental anchors were still read from legacy SwiftData `TypeCount`, while the refactored app now chains observations from SQLite. SQLite now stores per-type capture state (`anchor_data`, count, content hash, yearly counts, range) and the import path uses it when legacy `TypeCount` state is missing. Expected signal: the first run after this change may seed anchors; the following no-delta run should report empty HealthKit delta pages instead of full `record_import` counts for large stable types. |
583 583
 | 2026-06-03 | pending | Clarify capture mode in record-import diagnostics. | Two full-profile repeated snapshots after SQLite capture-state persistence completed successfully with stable checksum and identical record count (`2,646,613`). The first ran in `6.3s` with `SummedProcessingElapsed: 0.0s`, `SummedInsertElapsed: 0.0s`, `SummedFinalizeElapsed: 2.6s`; the second ran in `6.4s` with `0.0s` processing/insert and `2.7s` finalize. Heart Rate `922,526` and Active Energy `346,478` each completed around `0.2s`, proving the heavy full reimport path was avoided. The report wording still said "`N` samples in 1 anchored segment", which is ambiguous for inherited unchanged summaries; diagnostics now label unchanged empty-delta, delta-apply, and full-import modes explicitly. |
584
+| 2026-06-03 | pending | Add capture-mode summary to diagnostics. | Repeated full-profile captures rarely produce a perfect no-delta report because at least one metric can change between manual runs. Diagnostic reports now include aggregate `CaptureModes` counts plus per-metric `captureMode`, so comparisons can separate unchanged empty-delta metrics from delta-applied metrics and full imports without manually reading every `record_import` line. Expected signal: stable checksum plus high `unchangedDelta` count and zero summed processing/insert confirms the fast path even when a few metrics changed. |
584 585
 
585 586
 ## Current Diagnosis
586 587
 
@@ -667,8 +668,9 @@ Prioritize experiments in this order:
667 668
 3. Continue repeated full-profile captures, but do not require perfect no-delta.
668 669
    Compare `snapshotChecksum`, `Records`, `WallClockDuration`,
669 670
    `SummedProcessingElapsed`, `SummedInsertElapsed`, `SummedFinalizeElapsed`,
670
-   and high-volume type timings. Treat stable checksum + zero processing/insert
671
-   as the main unchanged-path signal.
671
+   `CaptureModes`, and high-volume type timings. Treat stable checksum, a high
672
+   `unchangedDelta` count, and zero processing/insert as the main unchanged-path
673
+   signal.
672 674
 4. Add or inspect timing around per-record processing for changed high-volume metrics, especially Heart Rate, to separate sample DTO/fingerprint work from SQLite idempotency checks.
673 675
 5. Run a non-chain-start/full-scan benchmark after skipping unchanged `verified` events and fast-pathing already-open visibility ranges. Compare `SummedInsertElapsed`, `Heart Rate insertElapsed`, `Steps insertElapsed`, and `Walking + Running Distance insertElapsed`.
674 676
 6. Reduce any remaining per-sample SQLite writes for unchanged existing samples during non-chain-start full scans.
+15 -1
HealthProbe/Services/HealthKitService.swift
@@ -1029,6 +1029,7 @@ final class HealthKitService {
1029 1029
             records: [],
1030 1030
             recordArchiveData: recordArchiveData
1031 1031
         )
1032
+        result.captureMode = distribution.captureMode.diagnosticValue
1032 1033
         result.timingBreakdown = timingBreakdown
1033 1034
         await persistTypeCaptureState(from: result, observationID: archiveObservationID)
1034 1035
         return result
@@ -2451,6 +2452,17 @@ private struct SampleDistribution: Sendable {
2451 2452
         case initialImport
2452 2453
         case delta
2453 2454
         case unchangedDelta
2455
+
2456
+        var diagnosticValue: String {
2457
+            switch self {
2458
+            case .initialImport:
2459
+                return "initialImport"
2460
+            case .delta:
2461
+                return "delta"
2462
+            case .unchangedDelta:
2463
+                return "unchangedDelta"
2464
+            }
2465
+        }
2454 2466
     }
2455 2467
 
2456 2468
     struct Bin: Sendable {
@@ -2747,6 +2759,7 @@ private struct TypeCountFetchResult: Sendable {
2747 2759
     let distributionBins: [DistributionBinData]
2748 2760
     let records: [RecordData]
2749 2761
     let recordArchiveData: Data?
2762
+    var captureMode: String = "unavailable"
2750 2763
     var timeoutConfiguredSeconds: TimeInterval = 0
2751 2764
     var totalElapsedSeconds: TimeInterval = 0
2752 2765
     var timeoutMode: String = "default"
@@ -2844,7 +2857,8 @@ private extension SnapshotFetchProgress {
2844 2857
             suggestedRetryTimeout: result.suggestedRetryTimeout,
2845 2858
             timeoutCount: result.timeoutCount,
2846 2859
             successCount: result.successCount,
2847
-            timingBreakdown: result.timingBreakdown
2860
+            timingBreakdown: result.timingBreakdown,
2861
+            captureMode: result.captureMode
2848 2862
         )
2849 2863
     }
2850 2864
 
+4 -1
HealthProbe/Utilities/SnapshotFetchProgress.swift
@@ -113,6 +113,7 @@ final class SnapshotFetchProgress {
113 113
         var timeoutCount: Int = 0
114 114
         var successCount: Int = 0
115 115
         var timingBreakdown: ImportTimingBreakdown = .zero
116
+        var captureMode: String = "unavailable"
116 117
         var blockProgress: String = ""
117 118
         var blockElapsedSeconds: TimeInterval = 0
118 119
         var blockSamplesPerSecond: Double = 0
@@ -213,7 +214,8 @@ final class SnapshotFetchProgress {
213 214
         suggestedRetryTimeout: TimeInterval,
214 215
         timeoutCount: Int,
215 216
         successCount: Int,
216
-        timingBreakdown: ImportTimingBreakdown
217
+        timingBreakdown: ImportTimingBreakdown,
218
+        captureMode: String = "unavailable"
217 219
     ) {
218 220
         let index = visibleTypeIndex(for: id)
219 221
         types[index].quality = quality
@@ -233,6 +235,7 @@ final class SnapshotFetchProgress {
233 235
         types[index].timeoutCount = timeoutCount
234 236
         types[index].successCount = successCount
235 237
         types[index].timingBreakdown = timingBreakdown
238
+        types[index].captureMode = captureMode
236 239
     }
237 240
 
238 241
     func updateTimeoutProfile(
+4 -0
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -382,12 +382,15 @@ struct DashboardView: View {
382 382
         let summedMetricResidualElapsed = progress.types.reduce(0) { partial, type in
383 383
             partial + timingResidual(totalElapsed: type.totalElapsedSeconds, breakdown: type.timingBreakdown)
384 384
         }
385
+        let captureModeCounts = Dictionary(grouping: progress.types, by: \.captureMode)
386
+            .mapValues(\.count)
385 387
         lines.append("SummedMetricTotalElapsed: \(formatDuration(summedMetricTotalElapsed))")
386 388
         lines.append("SummedFetchElapsed: \(formatDuration(aggregateTiming.fetchElapsedSeconds))")
387 389
         lines.append("SummedProcessingElapsed: \(formatDuration(aggregateTiming.processingElapsedSeconds))")
388 390
         lines.append("SummedInsertElapsed: \(formatDuration(aggregateTiming.insertElapsedSeconds))")
389 391
         lines.append("SummedFinalizeElapsed: \(formatDuration(aggregateTiming.finalizeElapsedSeconds))")
390 392
         lines.append("SummedResidualElapsed: \(formatDuration(summedMetricResidualElapsed))")
393
+        lines.append("CaptureModes: unchangedDelta=\(captureModeCounts["unchangedDelta", default: 0]), delta=\(captureModeCounts["delta", default: 0]), initialImport=\(captureModeCounts["initialImport", default: 0]), unavailable=\(captureModeCounts["unavailable", default: 0])")
391 394
         lines.append("")
392 395
         lines.append(failedLines)
393 396
 
@@ -418,6 +421,7 @@ struct DashboardView: View {
418 421
             lines.append("  identifier: \(type.id)")
419 422
             lines.append("  quality: \(type.quality)")
420 423
             lines.append("  count: \(type.recordCount)")
424
+            lines.append("  captureMode: \(type.captureMode)")
421 425
             lines.append("  timeoutMode: \(type.timeoutMode)")
422 426
             lines.append("  timeoutConfigured: \(formatDuration(type.timeoutConfiguredSeconds))")
423 427
             lines.append("  lastSuccessfulElapsed: \(type.lastSuccessfulElapsed > 0 ? formatDuration(type.lastSuccessfulElapsed) : "none")")