Showing 10 changed files with 24 additions and 555 deletions
+6 -6
HealthProbe/Doc/00-agent-guides/AGENTS.md
@@ -253,15 +253,15 @@ final class TypeDistributionBin {
253 253
 // in memory, so snapshot list/detail screens never recompute them by traversing
254 254
 // snapshot.typeCounts on the UI thread.
255 255
 
256
-// Interface updated 2026-05-17 — see AGENTS.md
257
-// Models/SnapshotDelta stores cached list/detail summary scalars derived from TypeDelta.
258
-// Overview screens consume these scalars and type-delta summaries directly instead of
259
-// recalculating per-snapshot changes from HealthSnapshot.typeCounts.
256
+// Interface updated 2026-05-26 — see AGENTS.md
257
+// SnapshotDelta/TypeDelta and their post-save DeltaService were deleted.
258
+// Active overview and detail screens consume SQLite/Core Data archive/cache
259
+// observation diff summaries instead of recalculating changes from
260
+// HealthSnapshot.typeCounts.
260 261
 
261 262
 // Interface updated 2026-05-23 — see AGENTS.md
262 263
 // Future UI/domain naming should prefer "change" or "observation diff" over
263
-// "anomaly". Existing AnomalyRecord/AnomalyType code is legacy naming until the
264
-// model replacement/refactor is implemented.
264
+// "anomaly". The legacy AnomalyRecord/AnomalyType models have been removed.
265 265
 enum ChangeClassification: String, Codable {
266 266
     case appeared
267 267
     case disappeared
+1 -1
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -48,7 +48,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
48 48
 
49 49
 - SwiftData currently blocks iOS 15-era device support.
50 50
 - Some screens still imply snapshot-count monitoring rather than Time Machine inspection.
51
-- Current UI/cache layers still depend on 12 SwiftData-backed files for launch container, capture review actions, legacy delta creation, and model definitions.
51
+- Current UI/cache layers still depend on 9 SwiftData-backed files for launch container, capture review actions, capture bridge writes, and remaining model definitions.
52 52
 - Snapshots timeline/detail rows, Data Types list/detail rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist.
53 53
 - Legacy SwiftData-only snapshots are reset for archive v2 test installs rather than migrated.
54 54
 - Capture strategy and some legacy SwiftData transition paths may still decode or cache too much data for low-end devices.
+4 -2
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -235,6 +235,8 @@ Checklist:
235 235
 - [x] Delete unused legacy SwiftData snapshot/type detail views and the PDF
236 236
   exporter tied to those views.
237 237
 - [x] Delete unused legacy SwiftData lifecycle/observer/repair services.
238
+- [x] Stop writing legacy SwiftData `SnapshotDelta`/`TypeDelta` rows and delete
239
+  the unused delta service/models.
238 240
 - [x] Data type detail uses Core Data/SQLite `diffSummary` when archive observation ids exist and no longer queries `SnapshotDelta`/`TypeDelta` or rebuilds legacy detail caches from the UI.
239 241
 - [x] Remove unused legacy Type Evolution timeline and direct `HealthSnapshot.typeCounts` diff helper.
240 242
 - [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist.
@@ -266,8 +268,8 @@ Checklist:
266 268
   anomaly/count-drop review has been deleted; Snapshots/Data Types tab roots no
267 269
   longer import SwiftData; unused legacy snapshot/type detail and PDF views have
268 270
   been deleted; unused legacy lifecycle/observer/repair services have been
269
-  deleted; Dashboard capture/review actions and capture-time legacy delta
270
-  creation remain.
271
+  deleted; unused legacy delta service/models have been deleted; Dashboard
272
+  capture/review actions and capture bridge writes remain.
271 273
 - [ ] Remove/disable `ModelContainer` as required for target builds.
272 274
 - [x] Add prototype-store ignore/delete/reset path for test installs.
273 275
 - [ ] Verify no old-store compatibility layer remains in active flows.
+13 -11
HealthProbe/Doc/04-project/SwiftData-Retirement-Inventory.md
@@ -10,9 +10,9 @@ local settings stored outside SwiftData where needed.
10 10
 ## Current Count
11 11
 
12 12
 After moving the Snapshots and Data Types tab roots to archive/cache
13
-observations and deleting unused legacy repair/detail services, 12 app files
14
-still have SwiftData imports because capture, Dashboard review actions, legacy
15
-delta creation, and model definitions still use prototype snapshot handles.
13
+observations and deleting unused legacy repair/detail/delta services, 9 app
14
+files still have SwiftData imports because capture, Dashboard review actions,
15
+and remaining model definitions still use prototype snapshot handles.
16 16
 
17 17
 ## Launch Container
18 18
 
@@ -31,15 +31,13 @@ block:
31 31
 
32 32
 - `HealthProbe/Models/HealthRecord.swift`
33 33
 - `HealthProbe/Models/HealthSnapshot.swift`
34
-- `HealthProbe/Models/SnapshotDelta.swift`
35 34
 - `HealthProbe/Models/TypeCount.swift`
36
-- `HealthProbe/Models/TypeDelta.swift`
37 35
 - `HealthProbe/Models/TypeDistributionBin.swift`
38 36
 - `HealthProbe/Models/YearlyCount.swift`
39 37
 
40 38
 Retirement path:
41
-- replace `HealthSnapshot`, `TypeCount`, `SnapshotDelta`, `TypeDelta`,
42
-  `YearlyCount`, `TypeDistributionBin`, and `HealthRecord` active reads with
39
+- replace `HealthSnapshot`, `TypeCount`, `YearlyCount`,
40
+  `TypeDistributionBin`, and `HealthRecord` active reads/writes with
43 41
   archive/cache DTOs;
44 42
 - retire active reads/writes before removing the launch container.
45 43
 
@@ -47,14 +45,11 @@ Retirement path:
47 45
 
48 46
 These services still write/read legacy SwiftData transition models:
49 47
 
50
-- `HealthProbe/Services/DeltaService.swift`
51 48
 - `HealthProbe/Services/HealthKitService.swift`
52 49
 
53 50
 Retirement path:
54 51
 - make capture persist archive observations without writing prototype
55
-  `HealthSnapshot` bridge rows;
56
-- replace or remove `DeltaService` once capture no longer writes prototype
57
-  `SnapshotDelta` rows.
52
+  `HealthSnapshot` bridge rows.
58 53
 
59 54
 ## UI And View Models
60 55
 
@@ -131,6 +126,13 @@ The following SwiftData dependencies were removed from active flows:
131 126
   `HealthProbe/Utilities/TypeCountArchiveRepair.swift` were deleted. Active
132 127
   observation deletion/repair/background-capture policy now belongs to the
133 128
   SQLite archive/cache design, not the old SwiftData chain.
129
+- The unused legacy delta stack
130
+  `HealthProbe/Services/DeltaService.swift`,
131
+  `HealthProbe/Models/SnapshotDelta.swift`,
132
+  `HealthProbe/Models/TypeDelta.swift`, and
133
+  `HealthProbe/Models/TypeDeltaClassification.swift` was deleted.
134
+  Capture no longer writes prototype `SnapshotDelta`/`TypeDelta` rows after the
135
+  archive observation is saved.
134 136
 - The unused `HealthProbe/Views/DataTypes/TypeEvolutionTimeline.swift` legacy
135 137
   chart was deleted, and the remaining `TypeDiff`/`DiffFilter` DTOs now live in
136 138
   `HealthProbe/Models/TypeDiff.swift` instead of the removed
+0 -2
HealthProbe/HealthProbeApp.swift
@@ -28,7 +28,6 @@ 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,
32 31
         ])
33 32
 
34 33
         let appSupportURL = URL.applicationSupportDirectory
@@ -37,7 +36,6 @@ struct HealthProbeApp: App {
37 36
 
38 37
         let uiCacheModels = Schema([
39 38
             HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self,
40
-            SnapshotDelta.self, TypeDelta.self,
41 39
         ])
42 40
 
43 41
         let uiCacheConfig = ModelConfiguration(
+0 -54
HealthProbe/Models/SnapshotDelta.swift
@@ -1,54 +0,0 @@
1
-import Foundation
2
-import SwiftData
3
-
4
-@Model final class SnapshotDelta {
5
-    var id: UUID = UUID()
6
-    var fromSnapshotID: UUID = UUID()
7
-    var toSnapshotID: UUID = UUID()
8
-    var deviceID: String = ""
9
-    var computedAt: Date = Date.now
10
-    var checksumBefore: String = ""
11
-    var checksumAfter: String = ""
12
-    var listSummaryVersion: Int = 0
13
-    var absoluteRecordChangeCount: Int = 0
14
-    var changedMetricCount: Int = 0
15
-    var appearedMetricCount: Int = 0
16
-    var disappearedMetricCount: Int = 0
17
-    @Relationship(deleteRule: .cascade, inverse: \TypeDelta.delta)
18
-    var typeDeltas: [TypeDelta]? = []
19
-
20
-    init(fromSnapshotID: UUID, toSnapshotID: UUID, deviceID: String) {
21
-        self.id = UUID()
22
-        self.fromSnapshotID = fromSnapshotID
23
-        self.toSnapshotID = toSnapshotID
24
-        self.deviceID = deviceID
25
-    }
26
-}
27
-
28
-struct SnapshotDeltaListSummary {
29
-    let absoluteRecordChangeCount: Int
30
-    let changedMetricCount: Int
31
-    let appearedMetricCount: Int
32
-    let disappearedMetricCount: Int
33
-
34
-    var affectedMetricCount: Int {
35
-        changedMetricCount + appearedMetricCount + disappearedMetricCount
36
-    }
37
-
38
-    var hasChanges: Bool {
39
-        absoluteRecordChangeCount > 0 || affectedMetricCount > 0
40
-    }
41
-}
42
-
43
-extension SnapshotDelta {
44
-    static let currentListSummaryVersion = 1
45
-
46
-    var listSummary: SnapshotDeltaListSummary {
47
-        SnapshotDeltaListSummary(
48
-            absoluteRecordChangeCount: absoluteRecordChangeCount,
49
-            changedMetricCount: changedMetricCount,
50
-            appearedMetricCount: appearedMetricCount,
51
-            disappearedMetricCount: disappearedMetricCount
52
-        )
53
-    }
54
-}
+0 -42
HealthProbe/Models/TypeDelta.swift
@@ -1,42 +0,0 @@
1
-import Foundation
2
-import SwiftData
3
-
4
-@Model final class TypeDelta {
5
-    var id: UUID = UUID()
6
-    var typeIdentifier: String = ""
7
-    var displayName: String = ""
8
-    var countDelta: Int = 0
9
-    var hashBefore: String = ""
10
-    var hashAfter: String = ""
11
-    var qualityBeforeRaw: String?
12
-    var qualityAfterRaw: String?
13
-    var transitionRaw: String = "unchanged"
14
-    var reasonRaw: String = "normal"
15
-    var yearlyCountNote: String = ""
16
-    var delta: SnapshotDelta?
17
-
18
-    init(typeIdentifier: String, displayName: String) {
19
-        self.id = UUID()
20
-        self.typeIdentifier = typeIdentifier
21
-        self.displayName = displayName
22
-    }
23
-}
24
-
25
-extension TypeDelta {
26
-    var transition: TypeTransition {
27
-        get { TypeTransition(rawValue: transitionRaw) ?? .unchanged }
28
-        set { transitionRaw = newValue.rawValue }
29
-    }
30
-    var reason: TypeDeltaReason {
31
-        get { TypeDeltaReason(rawValue: reasonRaw) ?? .unknown }
32
-        set { reasonRaw = newValue.rawValue }
33
-    }
34
-    var qualityBefore: SnapshotQuality? {
35
-        get { qualityBeforeRaw.flatMap { SnapshotQuality(rawValue: $0) } }
36
-        set { qualityBeforeRaw = newValue?.rawValue }
37
-    }
38
-    var qualityAfter: SnapshotQuality? {
39
-        get { qualityAfterRaw.flatMap { SnapshotQuality(rawValue: $0) } }
40
-        set { qualityAfterRaw = newValue?.rawValue }
41
-    }
42
-}
+0 -16
HealthProbe/Models/TypeDeltaClassification.swift
@@ -1,16 +0,0 @@
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 -409
HealthProbe/Services/DeltaService.swift
@@ -1,409 +0,0 @@
1
-import Foundation
2
-import SwiftData
3
-import os.log
4
-
5
-private let logger = Logger(subsystem: "ro.xdev.healthprobe", category: "DeltaService")
6
-
7
-enum DeltaService {
8
-    @discardableResult
9
-    static func computeAndSave(current: HealthSnapshot, context: ModelContext) throws -> SnapshotDelta? {
10
-        // No previous snapshot → chain start, no delta to compute
11
-        guard let prevID = current.previousSnapshotID else { return nil }
12
-
13
-        let prevDescriptor = FetchDescriptor<HealthSnapshot>(
14
-            predicate: #Predicate<HealthSnapshot> { $0.id == prevID }
15
-        )
16
-        guard let previous = try context.fetch(prevDescriptor).first else {
17
-            logger.error("DeltaService: previousSnapshotID \(prevID) not found")
18
-            return nil
19
-        }
20
-
21
-        let prevByID = Dictionary(
22
-            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
23
-        )
24
-        let currByID = Dictionary(
25
-            uniqueKeysWithValues: (current.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
26
-        )
27
-
28
-        let delta = SnapshotDelta(
29
-            fromSnapshotID: previous.id,
30
-            toSnapshotID: current.id,
31
-            deviceID: current.deviceID
32
-        )
33
-        delta.checksumBefore = HashService.snapshotChecksum(typeCounts: Array(prevByID.values))
34
-        delta.checksumAfter  = HashService.snapshotChecksum(typeCounts: Array(currByID.values))
35
-
36
-        if current.isContentAlias,
37
-           current.contentEquivalentSnapshotID == previous.contentRepresentativeSnapshotID {
38
-            delta.typeDeltas = []
39
-            updateListSummary(for: delta, typeDeltas: [])
40
-            context.insert(delta)
41
-            try context.save()
42
-            return delta
43
-        }
44
-
45
-        let allTypeIDs = Set(prevByID.keys).union(currByID.keys)
46
-        var typeDeltas: [TypeDelta] = []
47
-
48
-        for typeID in allTypeIDs {
49
-            let prev = prevByID[typeID]
50
-            let curr = currByID[typeID]
51
-
52
-            if let prev,
53
-               let curr,
54
-               curr.contentEquivalentTypeCountID == prev.contentRepresentativeTypeCountID {
55
-                continue
56
-            }
57
-
58
-            let effectivePrev = historicalBaselinePreviousTypeCount(
59
-                typeID: typeID,
60
-                prev: prev,
61
-                curr: curr,
62
-                previousSnapshot: previous,
63
-                context: context
64
-            ) ?? prev
65
-
66
-            let td = buildTypeDelta(
67
-                typeID: typeID,
68
-                prev: effectivePrev,
69
-                curr: curr,
70
-                previous: previous,
71
-                current: current
72
-            )
73
-            td.delta = delta
74
-            typeDeltas.append(td)
75
-            context.insert(td)
76
-        }
77
-
78
-        delta.typeDeltas = typeDeltas
79
-        updateListSummary(for: delta, typeDeltas: typeDeltas)
80
-        context.insert(delta)
81
-        try context.save()
82
-        return delta
83
-    }
84
-
85
-    @discardableResult
86
-    static func rebuildMissingListSummaries(context: ModelContext, maxCount: Int) throws -> Bool {
87
-        guard maxCount > 0 else { return false }
88
-
89
-        let summaryVersion = SnapshotDelta.currentListSummaryVersion
90
-        var descriptor = FetchDescriptor<SnapshotDelta>(
91
-            predicate: #Predicate<SnapshotDelta> { $0.listSummaryVersion < summaryVersion }
92
-        )
93
-        descriptor.fetchLimit = maxCount
94
-
95
-        let deltas = try context.fetch(descriptor)
96
-        guard !deltas.isEmpty else { return false }
97
-
98
-        for delta in deltas {
99
-            updateListSummary(for: delta, typeDeltas: delta.typeDeltas ?? [])
100
-        }
101
-
102
-        try context.save()
103
-        return true
104
-    }
105
-
106
-    // MARK: - Delta merge (for intermediate snapshot deletion)
107
-
108
-    // snapshotBefore and snapshotAfter are the real surrounding snapshots (N-1 and N+1).
109
-    // Their typeCounts are used to recompute fresh checksums.
110
-    static func mergeDeltas(
111
-        d1: SnapshotDelta,
112
-        d2: SnapshotDelta,
113
-        snapshotBefore: HealthSnapshot,
114
-        snapshotAfter: HealthSnapshot
115
-    ) -> SnapshotDelta {
116
-        let merged = SnapshotDelta(
117
-            fromSnapshotID: d1.fromSnapshotID,
118
-            toSnapshotID: d2.toSnapshotID,
119
-            deviceID: d1.deviceID
120
-        )
121
-        // Always recompute from the actual surrounding snapshots — never copy old checksums
122
-        merged.checksumBefore = HashService.snapshotChecksum(typeCounts: snapshotBefore.typeCounts ?? [])
123
-        merged.checksumAfter  = HashService.snapshotChecksum(typeCounts: snapshotAfter.typeCounts ?? [])
124
-
125
-        let d1Map = Dictionary(uniqueKeysWithValues: (d1.typeDeltas ?? []).map { ($0.typeIdentifier, $0) })
126
-        let d2Map = Dictionary(uniqueKeysWithValues: (d2.typeDeltas ?? []).map { ($0.typeIdentifier, $0) })
127
-        let allIDs = Set(d1Map.keys).union(d2Map.keys)
128
-
129
-        var mergedTypeDeltas: [TypeDelta] = []
130
-        for typeID in allIDs {
131
-            let td1 = d1Map[typeID]
132
-            let td2 = d2Map[typeID]
133
-            if let merged1 = td1, let merged2 = td2 {
134
-                // Type present in both deltas
135
-                if merged1.transition == .appeared && merged2.transition == .disappeared {
136
-                    // Existed only in deleted snapshot N — remove from merged delta
137
-                    continue
138
-                }
139
-                let td = mergeTypeDelta(d1td: merged1, d2td: merged2)
140
-                td.delta = merged
141
-                mergedTypeDeltas.append(td)
142
-            } else if let only1 = td1 {
143
-                let td = copyTypeDelta(only1)
144
-                td.delta = merged
145
-                mergedTypeDeltas.append(td)
146
-            } else if let only2 = td2 {
147
-                let td = copyTypeDelta(only2)
148
-                td.delta = merged
149
-                mergedTypeDeltas.append(td)
150
-            }
151
-        }
152
-        merged.typeDeltas = mergedTypeDeltas
153
-        updateListSummary(for: merged, typeDeltas: mergedTypeDeltas)
154
-        return merged
155
-    }
156
-
157
-    // MARK: - Private helpers
158
-
159
-    private static func buildTypeDelta(
160
-        typeID: String,
161
-        prev: TypeCount?,
162
-        curr: TypeCount?,
163
-        previous: HealthSnapshot,
164
-        current: HealthSnapshot
165
-    ) -> TypeDelta {
166
-        let displayName = curr?.displayName ?? prev?.displayName ?? typeID
167
-        let td = TypeDelta(typeIdentifier: typeID, displayName: displayName)
168
-        td.qualityBefore = prev?.quality
169
-        td.qualityAfter  = curr?.quality
170
-
171
-        let prevCount = prev?.count ?? 0
172
-        let currCount = curr?.count ?? 0
173
-        let prevHash  = prev?.contentHash ?? ""
174
-        let currHash  = curr?.contentHash ?? ""
175
-
176
-        if let prev, let curr {
177
-            // Type present in both snapshots
178
-            // If either count is -1, do not compute a numeric delta
179
-            if prev.count == -1 || curr.count == -1 {
180
-                td.countDelta = 0
181
-            } else {
182
-                td.countDelta = currCount - prevCount
183
-            }
184
-            td.hashBefore = prevHash
185
-            td.hashAfter  = currHash
186
-            td.transition = (prevHash == currHash && prevCount == currCount) ? .unchanged : .changed
187
-        } else if let curr {
188
-            // Type appeared — missing in previous
189
-            td.countDelta = curr.count == -1 ? 0 : curr.count
190
-            td.hashBefore = ""
191
-            td.hashAfter  = currHash
192
-            td.transition = .appeared
193
-        } else if let prev {
194
-            // Type disappeared — missing in current
195
-            td.countDelta = prev.count == -1 ? 0 : -prev.count
196
-            td.hashBefore = prevHash
197
-            td.hashAfter  = ""
198
-            td.transition = .disappeared
199
-        }
200
-
201
-        // Reason assignment — explicit priority order (highest wins):
202
-        // 1. authorizationChanged — type quality == .unauthorized
203
-        // 2. unsupported — type cannot be instantiated by HK factory
204
-        // 3. registryChanged — type appeared/disappeared AND monitoredTypeSetHash changed
205
-        // 4. unknown — type quality == .failed for other reasons
206
-        // 5. normal — none of the above
207
-        td.reason = assignReason(
208
-            prevQuality: prev?.quality,
209
-            currQuality: curr?.quality,
210
-            prevUnsupported: prev?.isUnsupported ?? false,
211
-            currUnsupported: curr?.isUnsupported ?? false,
212
-            transition: td.transition,
213
-            typeSetHashChanged: previous.monitoredTypeSetHash != current.monitoredTypeSetHash
214
-        )
215
-
216
-        // YearlyCount timezone guard
217
-        if previous.yearlyCountTimezoneIdentifier != current.yearlyCountTimezoneIdentifier {
218
-            td.yearlyCountNote = "yearly attribution unreliable — timezone changed between snapshots"
219
-        }
220
-
221
-        return td
222
-    }
223
-
224
-    private static func updateListSummary(for delta: SnapshotDelta, typeDeltas: [TypeDelta]) {
225
-        var absoluteRecordChangeCount = 0
226
-        var changedMetricCount = 0
227
-        var appearedMetricCount = 0
228
-        var disappearedMetricCount = 0
229
-
230
-        for typeDelta in typeDeltas {
231
-            switch typeDelta.transition {
232
-            case .unchanged:
233
-                continue
234
-            case .changed:
235
-                changedMetricCount += 1
236
-                if typeDelta.qualityBefore == .complete,
237
-                   typeDelta.qualityAfter == .complete {
238
-                    absoluteRecordChangeCount += abs(typeDelta.countDelta)
239
-                }
240
-            case .appeared:
241
-                appearedMetricCount += 1
242
-            case .disappeared:
243
-                disappearedMetricCount += 1
244
-            }
245
-        }
246
-
247
-        delta.absoluteRecordChangeCount = absoluteRecordChangeCount
248
-        delta.changedMetricCount = changedMetricCount
249
-        delta.appearedMetricCount = appearedMetricCount
250
-        delta.disappearedMetricCount = disappearedMetricCount
251
-        delta.listSummaryVersion = SnapshotDelta.currentListSummaryVersion
252
-    }
253
-
254
-    private static func historicalBaselinePreviousTypeCount(
255
-        typeID: String,
256
-        prev: TypeCount?,
257
-        curr: TypeCount?,
258
-        previousSnapshot: HealthSnapshot,
259
-        context: ModelContext
260
-    ) -> TypeCount? {
261
-        guard let prev,
262
-              let curr,
263
-              prev.quality == .unauthorized,
264
-              curr.quality == .complete,
265
-              curr.count > 0 else {
266
-            return nil
267
-        }
268
-
269
-        return findLastCompleteValuedTypeCount(
270
-            typeID: typeID,
271
-            before: previousSnapshot,
272
-            context: context
273
-        )
274
-    }
275
-
276
-    private static func findLastCompleteValuedTypeCount(
277
-        typeID: String,
278
-        before snapshot: HealthSnapshot,
279
-        context: ModelContext
280
-    ) -> TypeCount? {
281
-        var visited: Set<UUID> = []
282
-        var cursorID = snapshot.previousSnapshotID
283
-
284
-        while let snapshotID = cursorID, !visited.contains(snapshotID) {
285
-            visited.insert(snapshotID)
286
-
287
-            guard let historicalSnapshot = fetchSnapshot(id: snapshotID, context: context) else {
288
-                break
289
-            }
290
-
291
-            if let candidate = historicalSnapshot.typeCounts?.first(where: { $0.typeIdentifier == typeID }),
292
-               candidate.quality == .complete,
293
-               candidate.count > 0 {
294
-                return candidate
295
-            }
296
-
297
-            cursorID = historicalSnapshot.previousSnapshotID
298
-        }
299
-
300
-        return nil
301
-    }
302
-
303
-    private static func fetchSnapshot(id: UUID, context: ModelContext) -> HealthSnapshot? {
304
-        let descriptor = FetchDescriptor<HealthSnapshot>(
305
-            predicate: #Predicate<HealthSnapshot> { $0.id == id }
306
-        )
307
-        return try? context.fetch(descriptor).first
308
-    }
309
-
310
-    private static func assignReason(
311
-        prevQuality: SnapshotQuality?,
312
-        currQuality: SnapshotQuality?,
313
-        prevUnsupported: Bool,
314
-        currUnsupported: Bool,
315
-        transition: TypeTransition,
316
-        typeSetHashChanged: Bool
317
-    ) -> TypeDeltaReason {
318
-        // Priority 1: authorizationChanged
319
-        if prevQuality == SnapshotQuality.unauthorized || currQuality == SnapshotQuality.unauthorized {
320
-            return .authorizationChanged
321
-        }
322
-        // Priority 2: unsupported
323
-        if prevUnsupported || currUnsupported {
324
-            return .unsupported
325
-        }
326
-        // Priority 3: registryChanged (only for appeared/disappeared transitions)
327
-        if (transition == .appeared || transition == .disappeared) && typeSetHashChanged {
328
-            return .registryChanged
329
-        }
330
-        // Priority 4: unknown (failed)
331
-        if prevQuality == SnapshotQuality.failed || currQuality == SnapshotQuality.failed {
332
-            return .unknown
333
-        }
334
-        return .normal
335
-    }
336
-
337
-    private static func mergeTypeDelta(d1td: TypeDelta, d2td: TypeDelta) -> TypeDelta {
338
-        let td = TypeDelta(typeIdentifier: d1td.typeIdentifier, displayName: d1td.displayName)
339
-
340
-        if d1td.transition == .disappeared && d2td.transition == .appeared {
341
-            // Type disappeared in N, reappeared in N+1 → treat as changed
342
-            td.transition = .changed
343
-            td.hashBefore = d1td.hashBefore
344
-            td.hashAfter  = d2td.hashAfter
345
-            td.qualityBefore = d1td.qualityBefore
346
-            td.qualityAfter  = d2td.qualityAfter
347
-            // Unavailable count guard: if either source has quality != complete, force countDelta = 0
348
-            let d1Impaired = (d1td.qualityBefore != SnapshotQuality.complete)
349
-            let d2Impaired = (d2td.qualityAfter  != SnapshotQuality.complete)
350
-            td.countDelta = (d1Impaired || d2Impaired) ? 0 : d1td.countDelta + d2td.countDelta
351
-        } else {
352
-            // Both transitions are the same type (e.g. both unchanged, both changed)
353
-            td.transition = deriveTransition(hashBefore: d1td.hashBefore, hashAfter: d2td.hashAfter,
354
-                                             d1: d1td, d2: d2td)
355
-            td.hashBefore = d1td.hashBefore
356
-            td.hashAfter  = d2td.hashAfter
357
-            td.qualityBefore = d1td.qualityBefore
358
-            td.qualityAfter  = d2td.qualityAfter
359
-            // Unavailable count guard
360
-            let anyImpaired = (d1td.qualityBefore != SnapshotQuality.complete) ||
361
-                              (d1td.qualityAfter  != SnapshotQuality.complete) ||
362
-                              (d2td.qualityBefore != SnapshotQuality.complete) ||
363
-                              (d2td.qualityAfter  != SnapshotQuality.complete)
364
-            td.countDelta = anyImpaired ? 0 : d1td.countDelta + d2td.countDelta
365
-        }
366
-
367
-        // Reason: apply same priority table; use highest-priority reason from both source deltas
368
-        td.reason = highestPriorityReason(d1td.reason, d2td.reason)
369
-
370
-        // Timezone note: carry forward if either source had it
371
-        if !d1td.yearlyCountNote.isEmpty || !d2td.yearlyCountNote.isEmpty {
372
-            td.yearlyCountNote = "yearly attribution unreliable — timezone changed between snapshots"
373
-        }
374
-
375
-        return td
376
-    }
377
-
378
-    private static func deriveTransition(
379
-        hashBefore: String, hashAfter: String,
380
-        d1: TypeDelta, d2: TypeDelta
381
-    ) -> TypeTransition {
382
-        // Infer transition from the merged hash pair
383
-        if hashBefore.isEmpty && !hashAfter.isEmpty { return .appeared }
384
-        if !hashBefore.isEmpty && hashAfter.isEmpty { return .disappeared }
385
-        if hashBefore == hashAfter && d1.countDelta + d2.countDelta == 0 { return .unchanged }
386
-        return .changed
387
-    }
388
-
389
-    private static func highestPriorityReason(_ a: TypeDeltaReason, _ b: TypeDeltaReason) -> TypeDeltaReason {
390
-        // Priority: authorizationChanged > unsupported > registryChanged > unknown > normal
391
-        let priority: [TypeDeltaReason] = [.authorizationChanged, .unsupported, .registryChanged, .unknown, .normal]
392
-        let aIdx = priority.firstIndex(of: a) ?? priority.count
393
-        let bIdx = priority.firstIndex(of: b) ?? priority.count
394
-        return priority[min(aIdx, bIdx)]
395
-    }
396
-
397
-    private static func copyTypeDelta(_ source: TypeDelta) -> TypeDelta {
398
-        let td = TypeDelta(typeIdentifier: source.typeIdentifier, displayName: source.displayName)
399
-        td.countDelta       = source.countDelta
400
-        td.hashBefore       = source.hashBefore
401
-        td.hashAfter        = source.hashAfter
402
-        td.qualityBefore    = source.qualityBefore
403
-        td.qualityAfter     = source.qualityAfter
404
-        td.transition       = source.transition
405
-        td.reason           = source.reason
406
-        td.yearlyCountNote  = source.yearlyCountNote
407
-        return td
408
-    }
409
-}
+0 -12
HealthProbe/Services/HealthKitService.swift
@@ -279,8 +279,6 @@ final class HealthKitService {
279 279
         snapshot.typeCounts = typeCounts
280 280
 
281 281
         try context.save()
282
-
283
-        try await runPostSavePipeline(snapshot: snapshot, context: context)
284 282
     }
285 283
 
286 284
     private func updateSnapshotSummaryCache(
@@ -534,16 +532,6 @@ final class HealthKitService {
534 532
         }
535 533
     }
536 534
 
537
-    // MARK: - Post-save pipeline
538
-
539
-    private func runPostSavePipeline(
540
-        snapshot: HealthSnapshot,
541
-        context: ModelContext
542
-    ) async throws {
543
-        guard snapshot.previousSnapshotID != nil else { return }
544
-        _ = try DeltaService.computeAndSave(current: snapshot, context: context)
545
-    }
546
-
547 535
     // MARK: - Per-type fetch pipeline
548 536
 
549 537
     // Fetches sequentially to prevent race conditions and resource exhaustion.