Showing 12 changed files with 34 additions and 470 deletions
+3 -4
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -27,9 +27,9 @@ There are no real deployments, only test installations. Existing prototype datab
27 27
 | HealthKit capture | Capture now opens one archive observation per user-visible snapshot and attaches HealthKit pages, deleted-object evidence, and type verification to that observation id before finishing it | Continue moving UI/cache reads to archive-backed observation ids |
28 28
 | SQLite archive | Archive v2 schema, snapshot-level observation grouping, differential write path, v2 verification/delete bookkeeping, daily aggregate rebuilds, integrity report, v2 record reads, SQL diff/count/aggregate/provenance/consolidation-evidence APIs, large synthetic diff pagination coverage, formal timing/memory metrics, and XCTest coverage are in place; the legacy `archive_samples` mirror has been removed | Move Snapshots/Data Types from SwiftData previews to archive/cache DTOs |
29 29
 | Core Data cache | Initial programmatic Core Data model, full-cache rebuild service, read DTOs for observation/type/diff/health rows, and Dashboard archive-cache status wiring are in place | Move remaining export/report paths to cache DTOs and add targeted partial invalidation |
30
-| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations. Metric timeout calibration, local device profile settings, operation logging, ContentView preview, and Settings data maintenance have moved outside SwiftData. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace capture review actions and navigation handles before removing `ModelContainer` |
30
+| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations. Metric timeout calibration, local device profile settings, operation logging, ContentView preview, Settings data maintenance, and legacy anomaly/count-drop review have moved outside SwiftData or been removed. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace capture review actions and navigation handles before removing `ModelContainer` |
31 31
 | UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status prefers archive/cache observation rows and shows cache health; Snapshots timeline, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; export preview reads the archive export API before showing/exporting JSON; simplified detail mode replaces heavy charts with summary rows on small/accessibility layouts or when enabled in Settings; visible change labels now use neutral new/missing/change-review language, with SwiftData detail cache as transition fallback | Remove remaining SwiftData navigation handles |
32
-| Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications |
32
+| Diff/change explanation | SQL diff summaries, paged diff records, aggregate comparisons, and consolidation-evidence labels exist; legacy anomaly/count-drop review has been removed from active flows | Continue moving remaining SwiftData fallback detail paths to archive/cache DTOs |
33 33
 | Export | SQLite export preview, paged JSON writing, SHA256 manifest hashing, and `export_manifests` rows are in place for selected records and observation diffs | Fill remaining recovery-compatible envelope metadata, CSV export, relationship preservation, and reproducibility checks |
34 34
 | Legacy device support | Simplified detail UI mode is implemented for small/accessibility layouts and as a Settings toggle | Remove SwiftData dependency and validate lower deployment targets |
35 35
 | Recovery workflows | Not supported | Preserve export/archive structure for external recovery tools only |
@@ -47,9 +47,8 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
47 47
 ## Known Prototype Mismatches
48 48
 
49 49
 - SwiftData currently blocks iOS 15-era device support.
50
-- Existing `Anomaly*` model/service names are legacy language.
51 50
 - Some screens still imply snapshot-count monitoring rather than Time Machine inspection.
52
-- Current UI/cache layers still depend on 23 SwiftData-backed files for launch container, capture review actions, navigation handles, some charts, and PDF paths.
51
+- Current UI/cache layers still depend on 22 SwiftData-backed files for launch container, capture review actions, navigation handles, some charts, and PDF paths.
53 52
 - Snapshots timeline, snapshot detail summary/type rows, Data Types list rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist, but navigation still uses SwiftData snapshot handles during the transition.
54 53
 - Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now reset for archive v2 test installs rather than migrated.
55 54
 - Capture strategy and some legacy SwiftData transition paths may still decode or cache too much data for low-end devices.
+3 -2
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -249,8 +249,9 @@ Checklist:
249 249
 - [ ] Replace SwiftData models used by active flows. Metric timeout
250 250
   calibration, local device profile settings, and operation logging have been
251 251
   moved to local Codable stores and removed from `ModelContainer`; Settings
252
-  data maintenance now uses the rebuildable Core Data cache; SwiftData
253
-  snapshot/navigation handles remain.
252
+  data maintenance now uses the rebuildable Core Data cache; legacy
253
+  anomaly/count-drop review has been deleted; SwiftData snapshot/navigation
254
+  handles remain.
254 255
 - [ ] Remove/disable `ModelContainer` as required for target builds.
255 256
 - [x] Add prototype-store ignore/delete/reset path for test installs.
256 257
 - [ ] Verify no old-store compatibility layer remains in active flows.
+7 -4
HealthProbe/Doc/04-project/SwiftData-Retirement-Inventory.md
@@ -9,8 +9,8 @@ local settings stored outside SwiftData where needed.
9 9
 
10 10
 ## Current Count
11 11
 
12
-After moving local settings/data-maintenance flows out of SwiftData, 23 app
13
-files still have SwiftData imports.
12
+After removing the legacy anomaly/count-drop alerting flow, 22 app files still
13
+have SwiftData imports.
14 14
 
15 15
 ## Launch Container
16 16
 
@@ -28,7 +28,6 @@ Retirement path:
28 28
 These files define SwiftData `@Model` classes and are the largest retirement
29 29
 block:
30 30
 
31
-- `HealthProbe/Models/AnomalyRecord.swift`
32 31
 - `HealthProbe/Models/HealthRecord.swift`
33 32
 - `HealthProbe/Models/HealthSnapshot.swift`
34 33
 - `HealthProbe/Models/SnapshotDelta.swift`
@@ -41,7 +40,6 @@ Retirement path:
41 40
 - replace `HealthSnapshot`, `TypeCount`, `SnapshotDelta`, `TypeDelta`,
42 41
   `YearlyCount`, `TypeDistributionBin`, and `HealthRecord` active reads with
43 42
   archive/cache DTOs;
44
-- replace `AnomalyRecord` flows with neutral change/diff DTOs;
45 43
 - retire active reads/writes before removing the launch container.
46 44
 
47 45
 ## Capture And Maintenance Services
@@ -110,6 +108,11 @@ The following SwiftData dependencies were removed from active flows:
110 108
 - `HealthProbe/Views/Settings/SettingsView.swift` no longer imports SwiftData.
111 109
   Its Data section now reports/rebuilds/deletes the rebuildable Core Data UI
112 110
   cache and leaves the SQLite archive untouched.
111
+- `HealthProbe/Models/AnomalyRecord.swift`,
112
+  `HealthProbe/Models/AnomalyType.swift`, and
113
+  `HealthProbe/Services/AnomalyDetector.swift` were deleted. The app no longer
114
+  writes count-drop anomaly rows or shows the old Dashboard anomaly review
115
+  section.
113 116
 
114 117
 ## Next Recommended Slices
115 118
 
+2 -2
HealthProbe/HealthProbeApp.swift
@@ -28,7 +28,7 @@ struct HealthProbeApp: App {
28 28
 
29 29
         let fullSchema = Schema([
30 30
             HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self,
31
-            SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self,
31
+            SnapshotDelta.self, TypeDelta.self,
32 32
         ])
33 33
 
34 34
         let appSupportURL = URL.applicationSupportDirectory
@@ -37,7 +37,7 @@ struct HealthProbeApp: App {
37 37
 
38 38
         let uiCacheModels = Schema([
39 39
             HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self,
40
-            SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self,
40
+            SnapshotDelta.self, TypeDelta.self,
41 41
         ])
42 42
 
43 43
         let uiCacheConfig = ModelConfiguration(
+0 -34
HealthProbe/Models/AnomalyRecord.swift
@@ -1,34 +0,0 @@
1
-import Foundation
2
-import SwiftData
3
-
4
-@Model final class AnomalyRecord {
5
-    var id: UUID = UUID()
6
-    var detectedAt: Date = Date.now
7
-    var snapshotID: UUID = UUID()
8
-    var deltaID: UUID?
9
-    var deviceID: String = ""
10
-    var anomalyTypeRaw: String = "deletion"
11
-    var severityRaw: String = "info"
12
-    var typeIdentifier: String?
13
-    var message: String = ""
14
-    var isResolved: Bool = false
15
-
16
-    init(snapshotID: UUID, deviceID: String, anomalyType: AnomalyType, severity: Severity) {
17
-        self.id = UUID()
18
-        self.snapshotID = snapshotID
19
-        self.deviceID = deviceID
20
-        self.anomalyTypeRaw = anomalyType.rawValue
21
-        self.severityRaw = severity.rawValue
22
-    }
23
-}
24
-
25
-extension AnomalyRecord {
26
-    var anomalyType: AnomalyType {
27
-        get { AnomalyType(rawValue: anomalyTypeRaw) ?? .deletion }
28
-        set { anomalyTypeRaw = newValue.rawValue }
29
-    }
30
-    var severity: Severity {
31
-        get { Severity(rawValue: severityRaw) ?? .info }
32
-        set { severityRaw = newValue.rawValue }
33
-    }
34
-}
+0 -33
HealthProbe/Models/AnomalyType.swift
@@ -1,33 +0,0 @@
1
-import Foundation
2
-
3
-enum AnomalyType: String, Codable {
4
-    case historicalInsertion = "historical_insertion"
5
-    case deletion            = "deletion"
6
-    case duplication         = "duplication"
7
-    case silentReplacement   = "silent_replacement"
8
-    case syncAnomaly         = "sync_anomaly"
9
-}
10
-
11
-enum Severity: String, Codable, Comparable {
12
-    case info, warning, critical
13
-
14
-    private static let order: [Severity] = [.info, .warning, .critical]
15
-    static func < (lhs: Severity, rhs: Severity) -> Bool {
16
-        order.firstIndex(of: lhs)! < order.firstIndex(of: rhs)!
17
-    }
18
-}
19
-
20
-enum TypeTransition: String, Codable {
21
-    case unchanged    // type present in both, count and hash identical
22
-    case changed      // type present in both, count or hash differs
23
-    case appeared     // type missing in previous, present in current
24
-    case disappeared  // type present in previous, missing in current
25
-}
26
-
27
-enum TypeDeltaReason: String, Codable {
28
-    case normal               // expected delta, no external cause
29
-    case registryChanged      // monitoredTypeSetHash changed between snapshots
30
-    case authorizationChanged // type quality == .unauthorized
31
-    case unsupported          // type unavailable on this OS/device
32
-    case unknown
33
-}
+0 -7
HealthProbe/Models/HealthSnapshot.swift
@@ -14,12 +14,10 @@ import SwiftData
14 14
     var isChainStart: Bool = false
15 15
     var recoveredDeviceID: Bool = false
16 16
     var snapshotQualityRaw: String = SnapshotQuality.complete.rawValue
17
-    var anomalyFlagsJSON: String = "[]"
18 17
     var triggerReason: String = "manual"
19 18
     var retryOfSnapshotID: UUID?
20 19
     var isPostRestore: Bool = false
21 20
     var isPostRestoreInferred: Bool = false
22
-    var isPostRestoreSuppressedDeltaID: UUID?
23 21
     var hardwareModel: String = ""
24 22
     var appBuildVersion: String = ""
25 23
     var monitoredTypeSetHash: String = ""
@@ -85,11 +83,6 @@ extension HealthSnapshot {
85 83
         set { snapshotQualityRaw = newValue.rawValue }
86 84
     }
87 85
 
88
-    var anomalyFlags: [String] {
89
-        get { (try? JSONDecoder().decode([String].self, from: Data(anomalyFlagsJSON.utf8))) ?? [] }
90
-        set { anomalyFlagsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" }
91
-    }
92
-
93 86
     var isContentAlias: Bool {
94 87
         contentEquivalentSnapshotID != nil
95 88
     }
+16 -0
HealthProbe/Models/TypeDeltaClassification.swift
@@ -0,0 +1,16 @@
1
+import Foundation
2
+
3
+enum TypeTransition: String, Codable {
4
+    case unchanged
5
+    case changed
6
+    case appeared
7
+    case disappeared
8
+}
9
+
10
+enum TypeDeltaReason: String, Codable {
11
+    case normal
12
+    case registryChanged
13
+    case authorizationChanged
14
+    case unsupported
15
+    case unknown
16
+}
+0 -213
HealthProbe/Services/AnomalyDetector.swift
@@ -1,213 +0,0 @@
1
-import Foundation
2
-
3
-enum AnomalyDetector {
4
-    struct DetectionResult {
5
-        var records: [AnomalyRecord]
6
-        var consumedPostRestoreSuppressionDeltaID: UUID? = nil
7
-    }
8
-
9
-    // AnomalyDetector is pure — must not mutate SwiftData models, must not call context.save().
10
-    // currentTypeCounts and previousTypeCounts are pre-built maps provided by the caller.
11
-    // detect() sets record.deltaID = delta.id on EVERY created AnomalyRecord internally —
12
-    // the caller must not set deltaID externally. All returned records have deltaID == delta.id.
13
-    static func detect(
14
-        delta: SnapshotDelta,
15
-        current: HealthSnapshot,
16
-        previous: HealthSnapshot,
17
-        currentTypeCounts: [String: TypeCount],
18
-        previousTypeCounts: [String: TypeCount]
19
-    ) -> DetectionResult {
20
-                // Quality gate: current snapshot must be complete.
21
-                // Previous may be partial due known unavailable metrics; per-type quality guards
22
-                // below still prevent analysis on impaired comparisons.
23
-                guard current.snapshotQuality == SnapshotQuality.complete else {
24
-            return DetectionResult(records: [])
25
-        }
26
-
27
-        var records: [AnomalyRecord] = []
28
-        var syncAnomalyCount = 0
29
-        var consumedDeltaID: UUID? = nil
30
-
31
-        let typeDeltas = delta.typeDeltas ?? []
32
-
33
-        for typeDelta in typeDeltas {
34
-            // Registry gate — suppress appeared/disappeared anomalies from non-normal reasons
35
-            guard typeDelta.reason == TypeDeltaReason.normal else { continue }
36
-
37
-            // count = -1 guard: skip any TypeDelta where either quality is not complete
38
-            let prevQuality = typeDelta.qualityBefore
39
-            let currQuality = typeDelta.qualityAfter
40
-            if prevQuality != nil && prevQuality != SnapshotQuality.complete { continue }
41
-            if currQuality != nil && currQuality != SnapshotQuality.complete { continue }
42
-
43
-            guard typeDelta.transition == .changed else { continue }
44
-
45
-            let typeID = typeDelta.typeIdentifier
46
-            let prevTC = previousTypeCounts[typeID]
47
-            let currTC = currentTypeCounts[typeID]
48
-            let countDelta = typeDelta.countDelta
49
-
50
-            // historicalInsertion
51
-            if countDelta > 0 {
52
-                let currEarliest = currTC?.earliestDate
53
-                let prevEarliest = prevTC?.earliestDate
54
-                let currLatest   = currTC?.latestDate
55
-                let prevLatest   = prevTC?.latestDate
56
-
57
-                let isHistorical: Bool
58
-                if let ce = currEarliest, let pe = prevEarliest {
59
-                    isHistorical = ce < pe
60
-                } else if let cl = currLatest, let pl = prevLatest {
61
-                    let dayDiff = abs(cl.timeIntervalSince(pl))
62
-                    isHistorical = dayDiff < 86_400 // within 1 day
63
-                } else {
64
-                    isHistorical = false
65
-                }
66
-
67
-                if isHistorical {
68
-                    let record = makeRecord(
69
-                        delta: delta,
70
-                        snapshotID: current.id,
71
-                        deviceID: current.deviceID,
72
-                        type: .historicalInsertion,
73
-                        severity: .warning,
74
-                        typeID: typeID,
75
-                        message: "Historical insertion detected for \(typeDelta.displayName): +\(countDelta) records"
76
-                    )
77
-                    records.append(record)
78
-                }
79
-            }
80
-
81
-            // deletion
82
-            if countDelta < 0 {
83
-                let prevCount = prevTC?.count ?? 0
84
-                let severity: Severity
85
-                if prevCount > 0 {
86
-                    let ratio = Double(abs(countDelta)) / Double(prevCount)
87
-                    severity = ratio >= 0.05 ? .critical : .warning
88
-                } else {
89
-                    severity = .warning
90
-                }
91
-                let record = makeRecord(
92
-                    delta: delta,
93
-                    snapshotID: current.id,
94
-                    deviceID: current.deviceID,
95
-                    type: .deletion,
96
-                    severity: severity,
97
-                    typeID: typeID,
98
-                    message: "Records disappeared for \(typeDelta.displayName): \(countDelta) records. Verify Health read authorization first; if access was intentionally revoked, confirm that decision. Otherwise, treat this as possible data deletion."
99
-                )
100
-                records.append(record)
101
-            }
102
-
103
-            // duplication
104
-            if countDelta > 0, let prevCount = prevTC?.count, prevCount > 0 {
105
-                let ratio = Double(countDelta) / Double(prevCount)
106
-                if ratio > 0.5 {
107
-                    let currEarliest = currTC?.earliestDate
108
-                    let prevEarliest = prevTC?.earliestDate
109
-                    let currLatest   = currTC?.latestDate
110
-                    let prevLatest   = prevTC?.latestDate
111
-
112
-                    let earliestClose = zip(currEarliest, prevEarliest)
113
-                        .map { abs($0.timeIntervalSince($1)) < 86_400 } ?? true
114
-                    let latestClose = zip(currLatest, prevLatest)
115
-                        .map { abs($0.timeIntervalSince($1)) < 86_400 } ?? true
116
-
117
-                    if earliestClose && latestClose {
118
-                        let record = makeRecord(
119
-                            delta: delta,
120
-                            snapshotID: current.id,
121
-                            deviceID: current.deviceID,
122
-                            type: .duplication,
123
-                            severity: .warning,
124
-                            typeID: typeID,
125
-                            message: "Duplication detected for \(typeDelta.displayName): +\(countDelta) records (\(Int(ratio * 100))% increase)"
126
-                        )
127
-                        records.append(record)
128
-                    }
129
-                }
130
-            }
131
-
132
-            // silentReplacement
133
-            if countDelta == 0 && typeDelta.hashBefore != typeDelta.hashAfter {
134
-                let record = makeRecord(
135
-                    delta: delta,
136
-                    snapshotID: current.id,
137
-                    deviceID: current.deviceID,
138
-                    type: .silentReplacement,
139
-                    severity: .info,
140
-                    typeID: typeID,
141
-                    message: "Silent replacement detected for \(typeDelta.displayName): count unchanged but hash differs"
142
-                )
143
-                records.append(record)
144
-            }
145
-
146
-            // Count types contributing to syncAnomaly
147
-            if abs(countDelta) > 0, let prevCount = prevTC?.count, prevCount > 0 {
148
-                let ratio = Double(abs(countDelta)) / Double(prevCount)
149
-                if ratio > 0.10 { syncAnomalyCount += 1 }
150
-            }
151
-        }
152
-
153
-        // syncAnomaly — ≥ 4 types simultaneously changed by >10%
154
-        if syncAnomalyCount >= 4 {
155
-            // isPostRestore suppression: suppress syncAnomaly if previous snapshot has the flag
156
-            // and the suppression token hasn't been consumed yet.
157
-            // Suppression is only consumed when current.snapshotQuality == .complete (enforced
158
-            // by the quality gate at the top — if we get here, both are complete).
159
-            if previous.isPostRestore && previous.isPostRestoreSuppressedDeltaID == nil {
160
-                // Suppression consumed — return the delta ID for the caller to persist
161
-                consumedDeltaID = delta.id
162
-                // Do not emit syncAnomaly
163
-            } else {
164
-                let record = makeRecord(
165
-                    delta: delta,
166
-                    snapshotID: current.id,
167
-                    deviceID: current.deviceID,
168
-                    type: .syncAnomaly,
169
-                    severity: .critical,
170
-                    typeID: nil,
171
-                    message: "Sync anomaly detected: \(syncAnomalyCount) types changed by >10% simultaneously"
172
-                )
173
-                records.append(record)
174
-            }
175
-        }
176
-
177
-        // Set anomalyFlags on current snapshot (non-persisted here — caller does context.save())
178
-        let flagValues = Set(records.map { $0.anomalyType.rawValue })
179
-        current.anomalyFlags = Array(flagValues)
180
-
181
-        return DetectionResult(
182
-            records: records,
183
-            consumedPostRestoreSuppressionDeltaID: consumedDeltaID
184
-        )
185
-    }
186
-
187
-    private static func makeRecord(
188
-        delta: SnapshotDelta,
189
-        snapshotID: UUID,
190
-        deviceID: String,
191
-        type anomalyType: AnomalyType,
192
-        severity: Severity,
193
-        typeID: String?,
194
-        message: String
195
-    ) -> AnomalyRecord {
196
-        let record = AnomalyRecord(
197
-            snapshotID: snapshotID,
198
-            deviceID: deviceID,
199
-            anomalyType: anomalyType,
200
-            severity: severity
201
-        )
202
-        record.deltaID = delta.id  // set structurally inside detect(), never by caller
203
-        record.typeIdentifier = typeID
204
-        record.message = message
205
-        return record
206
-    }
207
-}
208
-
209
-// Optional zip for two optionals (avoids force-unwrapping in comparisons)
210
-private func zip<A, B>(_ a: A?, _ b: B?) -> (A, B)? {
211
-    guard let a, let b else { return nil }
212
-    return (a, b)
213
-}
+3 -79
HealthProbe/Services/HealthKitService.swift
@@ -280,8 +280,7 @@ final class HealthKitService {
280 280
 
281 281
         try context.save()
282 282
 
283
-        // Post-save pipeline: delta computation + anomaly detection
284
-        try await runPostSavePipeline(snapshot: snapshot, typeCounts: typeCounts, context: context)
283
+        try await runPostSavePipeline(snapshot: snapshot, context: context)
285 284
     }
286 285
 
287 286
     private func updateSnapshotSummaryCache(
@@ -539,58 +538,10 @@ final class HealthKitService {
539 538
 
540 539
     private func runPostSavePipeline(
541 540
         snapshot: HealthSnapshot,
542
-        typeCounts: [TypeCount],
543 541
         context: ModelContext
544 542
     ) async throws {
545
-        guard let prevID = snapshot.previousSnapshotID else { return }
546
-
547
-        let prevDescriptor = FetchDescriptor<HealthSnapshot>(
548
-            predicate: #Predicate<HealthSnapshot> { $0.id == prevID }
549
-        )
550
-        guard let previous = try context.fetch(prevDescriptor).first else { return }
551
-
552
-        guard let delta = try DeltaService.computeAndSave(current: snapshot, context: context) else { return }
553
-
554
-        // Build type count maps for AnomalyDetector (never access relationship properties directly)
555
-        let currentTypeCounts = Dictionary(uniqueKeysWithValues: typeCounts.map { ($0.typeIdentifier, $0) })
556
-                var previousTypeCounts = Dictionary(
557
-            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
558
-        )
559
-
560
-                for (typeID, currentType) in currentTypeCounts {
561
-                        guard currentType.quality == .complete,
562
-                                    currentType.count > 0,
563
-                                    let immediatePreviousType = previousTypeCounts[typeID],
564
-                                    immediatePreviousType.quality == .unauthorized,
565
-                                    let historicalBaseline = findLastCompleteValuedTypeCount(
566
-                                        typeID: typeID,
567
-                                        before: previous,
568
-                                        context: context
569
-                                    ) else {
570
-                                continue
571
-                        }
572
-
573
-                        previousTypeCounts[typeID] = historicalBaseline
574
-                }
575
-
576
-        let detection = AnomalyDetector.detect(
577
-            delta: delta,
578
-            current: snapshot,
579
-            previous: previous,
580
-            currentTypeCounts: currentTypeCounts,
581
-            previousTypeCounts: previousTypeCounts
582
-        )
583
-
584
-        for record in detection.records {
585
-            context.insert(record)
586
-        }
587
-        if let consumedDeltaID = detection.consumedPostRestoreSuppressionDeltaID {
588
-            previous.isPostRestoreSuppressedDeltaID = consumedDeltaID
589
-        }
590
-
591
-        if !detection.records.isEmpty || detection.consumedPostRestoreSuppressionDeltaID != nil {
592
-            try context.save()
593
-        }
543
+        guard snapshot.previousSnapshotID != nil else { return }
544
+        _ = try DeltaService.computeAndSave(current: snapshot, context: context)
594 545
     }
595 546
 
596 547
     // MARK: - Per-type fetch pipeline
@@ -1682,33 +1633,6 @@ final class HealthKitService {
1682 1633
         return try? context.fetch(descriptor).first
1683 1634
     }
1684 1635
 
1685
-    private func findLastCompleteValuedTypeCount(
1686
-        typeID: String,
1687
-        before snapshot: HealthSnapshot,
1688
-        context: ModelContext
1689
-    ) -> TypeCount? {
1690
-        var visited: Set<UUID> = []
1691
-        var cursorID = snapshot.previousSnapshotID
1692
-
1693
-        while let snapshotID = cursorID, !visited.contains(snapshotID) {
1694
-            visited.insert(snapshotID)
1695
-
1696
-            guard let historicalSnapshot = fetchSnapshot(id: snapshotID, context: context) else {
1697
-                break
1698
-            }
1699
-
1700
-            if let candidate = historicalSnapshot.typeCounts?.first(where: { $0.typeIdentifier == typeID }),
1701
-               candidate.quality == .complete,
1702
-               candidate.count > 0 {
1703
-                return candidate
1704
-            }
1705
-
1706
-            cursorID = historicalSnapshot.previousSnapshotID
1707
-        }
1708
-
1709
-        return nil
1710
-    }
1711
-
1712 1636
     private func isStoreEmpty(context: ModelContext) -> Bool {
1713 1637
         var descriptor = FetchDescriptor<HealthSnapshot>()
1714 1638
         descriptor.fetchLimit = 1
+0 -86
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -59,7 +59,6 @@ struct DashboardView: View {
59 59
         NavigationStack {
60 60
             List {
61 61
                 statusSection
62
-                anomalySummarySection
63 62
                 actionsSection
64 63
                 if let msg = viewModel.authError ?? viewModel.snapshotError {
65 64
                     Section {
@@ -1230,11 +1229,6 @@ struct DashboardView: View {
1230 1229
         }
1231 1230
     }
1232 1231
 
1233
-    @ViewBuilder
1234
-    private var anomalySummarySection: some View {
1235
-        AnomalySummarySection()
1236
-    }
1237
-
1238 1232
     private var actionsSection: some View {
1239 1233
         Section("Actions") {
1240 1234
             Button {
@@ -1392,86 +1386,6 @@ private struct DiagnosticReportSheet: View {
1392 1386
     }
1393 1387
 }
1394 1388
 
1395
-private struct AnomalySummarySection: View {
1396
-    @Environment(\.modelContext) private var modelContext
1397
-    @Query(filter: #Predicate<AnomalyRecord> { !$0.isResolved })
1398
-    private var unresolved: [AnomalyRecord]
1399
-
1400
-    private var criticalCount: Int { unresolved.filter { $0.severityRaw == Severity.critical.rawValue }.count }
1401
-    private var warningCount:  Int { unresolved.filter { $0.severityRaw == Severity.warning.rawValue }.count }
1402
-    private var disappearedRecords: [AnomalyRecord] {
1403
-        unresolved
1404
-            .filter { $0.anomalyType == .deletion }
1405
-            .sorted { $0.detectedAt > $1.detectedAt }
1406
-    }
1407
-
1408
-    private func confirmAuthorizationChange(for anomaly: AnomalyRecord) {
1409
-        defer {
1410
-            anomaly.isResolved = true
1411
-            try? modelContext.save()
1412
-        }
1413
-
1414
-        guard let typeIdentifier = anomaly.typeIdentifier else { return }
1415
-        let snapshotID = anomaly.snapshotID
1416
-
1417
-        let descriptor = FetchDescriptor<HealthSnapshot>(
1418
-            predicate: #Predicate<HealthSnapshot> { $0.id == snapshotID }
1419
-        )
1420
-        guard let snapshot = try? modelContext.fetch(descriptor).first,
1421
-              let typeCount = snapshot.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier }) else {
1422
-            return
1423
-        }
1424
-
1425
-        typeCount.count = -1
1426
-        typeCount.contentHash = ""
1427
-        typeCount.earliestDate = nil
1428
-        typeCount.latestDate = nil
1429
-        typeCount.quality = .unauthorized
1430
-        typeCount.yearlyCounts?.removeAll()
1431
-        snapshot.snapshotQuality = HealthKitService.shared.deriveSnapshotQuality(from: snapshot.typeCounts ?? [])
1432
-    }
1433
-
1434
-    var body: some View {
1435
-        if !unresolved.isEmpty {
1436
-            Section("Change Review") {
1437
-                if criticalCount > 0 {
1438
-                    Label("\(criticalCount) critical \(criticalCount == 1 ? "change" : "changes")",
1439
-                          systemImage: "exclamationmark.circle.fill")
1440
-                        .foregroundStyle(Color.criticalRed)
1441
-                }
1442
-                if warningCount > 0 {
1443
-                    Label("\(warningCount) \(warningCount == 1 ? "warning" : "warnings")",
1444
-                          systemImage: "exclamationmark.triangle.fill")
1445
-                        .foregroundStyle(Color.warningAmber)
1446
-                }
1447
-                if !disappearedRecords.isEmpty {
1448
-                    VStack(alignment: .leading, spacing: 8) {
1449
-                        Label("Records missing from current view", systemImage: "eye.slash")
1450
-                            .font(.subheadline.weight(.semibold))
1451
-                            .foregroundStyle(Color.criticalRed)
1452
-                        Text("Check Health read access for the affected metrics first. If you intentionally revoked access, confirm that here; otherwise treat this as a change that needs external review.")
1453
-                            .font(.caption)
1454
-                            .foregroundStyle(.secondary)
1455
-                        ForEach(disappearedRecords.prefix(3)) { anomaly in
1456
-                            VStack(alignment: .leading, spacing: 6) {
1457
-                                Text(anomaly.message)
1458
-                                    .font(.caption)
1459
-                                    .foregroundStyle(.secondary)
1460
-                                Button("Confirm Authorization Change") {
1461
-                                    confirmAuthorizationChange(for: anomaly)
1462
-                                }
1463
-                                .buttonStyle(.bordered)
1464
-                                .controlSize(.small)
1465
-                            }
1466
-                        }
1467
-                    }
1468
-                    .padding(.vertical, 4)
1469
-                }
1470
-            }
1471
-        }
1472
-    }
1473
-}
1474
-
1475 1389
 extension Bundle {
1476 1390
     var appVersion: String {
1477 1391
         guard let version = infoDictionary?["CFBundleShortVersionString"] as? String else {
+0 -6
HealthProbe/Views/Snapshots/SnapshotsView.swift
@@ -343,12 +343,6 @@ private struct SnapshotRow: View {
343 343
                         .font(.caption)
344 344
                         .accessibilityLabel("Selected as comparison baseline")
345 345
                 }
346
-                if let snapshot, !snapshot.anomalyFlags.isEmpty {
347
-                    Image(systemName: "exclamationmark.triangle.fill")
348
-                        .foregroundStyle(Color.warningAmber)
349
-                        .font(.caption)
350
-                        .accessibilityLabel("Has changes to review")
351
-                }
352 346
             }
353 347
 
354 348
             HStack(spacing: 6) {