Showing 13 changed files with 657 additions and 23 deletions
+12 -0
AGENTS.md
@@ -140,6 +140,18 @@ final class TypeDistributionBin {
140 140
 // changes chain links. Existing stores are backfilled incrementally with a strict
141 141
 // per-launch TypeCount cap to avoid decoding many large archives in one run.
142 142
 
143
+// Interface updated 2026-05-17 — see AGENTS.md
144
+// Models/HealthSnapshot.contentEquivalentSnapshotID marks snapshots whose TypeCount
145
+// content is identical to a previous snapshot on the same device. These snapshots are
146
+// retained as temporal labels but behave as aliases to the representative content
147
+// snapshot for expensive detail cache/diff work.
148
+
149
+// Interface updated 2026-05-17 — see AGENTS.md
150
+// Models/TypeCount.contentEquivalentTypeCountID marks individual data types whose
151
+// content is identical to the previous snapshot's same TypeCount. This allows a
152
+// snapshot to contain real changes for some metrics while long-stable metrics behave
153
+// as temporal aliases and skip per-type detail cache/diff work.
154
+
143 155
 // Models/DetectedAnomaly.swift
144 156
 enum AnomalyType: String, Codable {
145 157
     case historicalInsertion = "historical_insertion"
+16 -0
HealthProbe/ContentView.swift
@@ -36,12 +36,28 @@ struct ContentView: View {
36 36
         didAttemptTypeDetailCacheBackfill = true
37 37
 
38 38
         try? await Task.sleep(for: .seconds(2))
39
+        MemoryLog.log("typeDetailCacheBackfill.begin", metadata: [
40
+            "storedVersion": "\(typeDetailCacheBackfillVersion)",
41
+            "targetVersion": "\(AppSettings.currentTypeDetailCacheBackfillVersion)"
42
+        ])
43
+        let memoryPulse = MemoryLog.startPulse("typeDetailCacheBackfill", metadata: [
44
+            "maxTypeCounts": "1"
45
+        ])
46
+        defer {
47
+            memoryPulse.cancel()
48
+            MemoryLog.log("typeDetailCacheBackfill.end", metadata: [
49
+                "storedVersion": "\(typeDetailCacheBackfillVersion)"
50
+            ])
51
+        }
39 52
 
40 53
         do {
41 54
             let isComplete = try SnapshotLifecycleService.rebuildMissingDetailCaches(
42 55
                 context: modelContext,
43 56
                 maxTypeCounts: 1
44 57
             )
58
+            MemoryLog.log("typeDetailCacheBackfill.result", metadata: [
59
+                "isComplete": "\(isComplete)"
60
+            ])
45 61
             if isComplete {
46 62
                 typeDetailCacheBackfillVersion = AppSettings.currentTypeDetailCacheBackfillVersion
47 63
             }
+89 -10
HealthProbe/Models/HealthRecord.swift
@@ -45,13 +45,58 @@ enum HealthRecordArchive {
45 45
     static func fingerprintSet(from data: Data?) -> Set<String>? {
46 46
         guard let data else { return [] }
47 47
 
48
+        MemoryLog.log("healthRecordArchive.fingerprintSet.begin", metadata: [
49
+            "archive": MemoryLog.format(UInt64(data.count)),
50
+            "format": data.starts(with: compactMagic) ? "compact" : "plist"
51
+        ])
52
+        if data.starts(with: compactMagic) {
53
+            let fingerprints = compactFingerprintSet(from: data)
54
+            MemoryLog.log("healthRecordArchive.fingerprintSet.end", metadata: [
55
+                "archive": MemoryLog.format(UInt64(data.count)),
56
+                "fingerprints": "\(fingerprints?.count ?? 0)",
57
+                "success": "\(fingerprints != nil)"
58
+            ])
59
+            return fingerprints
60
+        }
61
+
48 62
         var fingerprints = Set<String>()
49 63
         let didRead = forEachRecord(in: data) { value in
50 64
             fingerprints.insert(value.recordFingerprint)
51 65
         }
66
+        MemoryLog.log("healthRecordArchive.fingerprintSet.end", metadata: [
67
+            "archive": MemoryLog.format(UInt64(data.count)),
68
+            "fingerprints": "\(fingerprints.count)",
69
+            "success": "\(didRead)"
70
+        ])
52 71
         return didRead ? fingerprints : nil
53 72
     }
54 73
 
74
+    private static func compactFingerprintSet(from data: Data) -> Set<String>? {
75
+        guard data.starts(with: compactMagic) else { return nil }
76
+        var reader = CompactReader(data: data, offset: compactMagic.count)
77
+        guard reader.skipString(),
78
+              let count = reader.readUInt64() else {
79
+            return nil
80
+        }
81
+
82
+        let expectedCount = Int(min(count, UInt64(Int.max)))
83
+        var fingerprints = Set<String>()
84
+        fingerprints.reserveCapacity(expectedCount)
85
+
86
+        for _ in 0..<count {
87
+            guard reader.skipString(),
88
+                  let recordFingerprint = reader.readString(),
89
+                  reader.skipDouble(),
90
+                  reader.skipDouble(),
91
+                  reader.skipOptionalString() else {
92
+                return nil
93
+            }
94
+            fingerprints.insert(recordFingerprint)
95
+        }
96
+
97
+        return fingerprints
98
+    }
99
+
55 100
     static func makeCompactWriter(typeIdentifier: String, estimatedRecordCount: Int = 0) -> CompactWriter {
56 101
         CompactWriter(typeIdentifier: typeIdentifier, estimatedRecordCount: estimatedRecordCount)
57 102
     }
@@ -194,10 +239,10 @@ enum HealthRecordArchive {
194 239
 
195 240
         mutating func readString() -> String? {
196 241
             guard let length = readUInt32(),
197
-                  let bytes = readBytes(count: Int(length)) else {
242
+                  let value = readUTF8String(count: Int(length)) else {
198 243
                 return nil
199 244
             }
200
-            return String(data: Data(bytes), encoding: .utf8)
245
+            return value
201 246
         }
202 247
 
203 248
         mutating func readOptionalString() -> String?? {
@@ -205,8 +250,8 @@ enum HealthRecordArchive {
205 250
             if length < 0 {
206 251
                 return .some(nil)
207 252
             }
208
-            guard let bytes = readBytes(count: Int(length)) else { return nil }
209
-            return .some(String(data: Data(bytes), encoding: .utf8))
253
+            guard let value = readUTF8String(count: Int(length)) else { return nil }
254
+            return .some(value)
210 255
         }
211 256
 
212 257
         mutating func readUInt32() -> UInt32? {
@@ -226,19 +271,53 @@ enum HealthRecordArchive {
226 271
             return Double(bitPattern: bitPattern)
227 272
         }
228 273
 
229
-        private mutating func readBytes(count: Int) -> [UInt8]? {
274
+        private mutating func readUTF8String(count: Int) -> String? {
230 275
             guard count >= 0, offset + count <= data.count else { return nil }
231
-            let bytes = Array(data[offset..<offset + count])
276
+            let startOffset = offset
232 277
             offset += count
233
-            return bytes
278
+            return data.withUnsafeBytes { rawBuffer in
279
+                guard let baseAddress = rawBuffer.baseAddress else {
280
+                    return count == 0 ? "" : nil
281
+                }
282
+                let buffer = UnsafeBufferPointer(
283
+                    start: baseAddress.advanced(by: startOffset).assumingMemoryBound(to: UInt8.self),
284
+                    count: count
285
+                )
286
+                return String(decoding: buffer, as: UTF8.self)
287
+            }
234 288
         }
235 289
 
236 290
         private mutating func readFixedWidthInteger<T: FixedWidthInteger>() -> T? {
237 291
             let size = MemoryLayout<T>.size
238
-            guard let bytes = readBytes(count: size) else { return nil }
239
-            return bytes.enumerated().reduce(T.zero) { result, item in
240
-                result | (T(item.element) << T(item.offset * 8))
292
+            guard size >= 0, offset + size <= data.count else { return nil }
293
+            let startOffset = offset
294
+            offset += size
295
+            var rawValue: UInt64 = 0
296
+            for byteOffset in 0..<size {
297
+                rawValue |= UInt64(data[startOffset + byteOffset]) << UInt64(byteOffset * 8)
241 298
             }
299
+            return T(truncatingIfNeeded: rawValue)
300
+        }
301
+
302
+        mutating func skipString() -> Bool {
303
+            guard let length = readUInt32() else { return false }
304
+            return skipBytes(count: Int(length))
305
+        }
306
+
307
+        mutating func skipOptionalString() -> Bool {
308
+            guard let length = readInt32() else { return false }
309
+            if length < 0 { return true }
310
+            return skipBytes(count: Int(length))
311
+        }
312
+
313
+        mutating func skipDouble() -> Bool {
314
+            skipBytes(count: MemoryLayout<UInt64>.size)
315
+        }
316
+
317
+        private mutating func skipBytes(count: Int) -> Bool {
318
+            guard count >= 0, offset + count <= data.count else { return false }
319
+            offset += count
320
+            return true
242 321
         }
243 322
     }
244 323
 }
+9 -0
HealthProbe/Models/HealthSnapshot.swift
@@ -9,6 +9,7 @@ import SwiftData
9 9
     var deviceID: String = ""
10 10
     var localSequenceNumber: Int = 0
11 11
     var previousSnapshotID: UUID?
12
+    var contentEquivalentSnapshotID: UUID?
12 13
     var isChainStart: Bool = false
13 14
     var recoveredDeviceID: Bool = false
14 15
     var snapshotQualityRaw: String = SnapshotQuality.complete.rawValue
@@ -46,4 +47,12 @@ extension HealthSnapshot {
46 47
         get { (try? JSONDecoder().decode([String].self, from: Data(anomalyFlagsJSON.utf8))) ?? [] }
47 48
         set { anomalyFlagsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" }
48 49
     }
50
+
51
+    var isContentAlias: Bool {
52
+        contentEquivalentSnapshotID != nil
53
+    }
54
+
55
+    var contentRepresentativeSnapshotID: UUID {
56
+        contentEquivalentSnapshotID ?? id
57
+    }
49 58
 }
+9 -0
HealthProbe/Models/TypeCount.swift
@@ -12,6 +12,7 @@ import SwiftData
12 12
     var latestDate: Date?
13 13
     var qualityRaw: String = SnapshotQuality.complete.rawValue
14 14
     var isUnsupported: Bool = false
15
+    var contentEquivalentTypeCountID: UUID?
15 16
     @Attribute(.externalStorage) var recordArchiveData: Data?
16 17
     @Attribute(.externalStorage) var detailCacheData: Data?
17 18
     var snapshot: HealthSnapshot?
@@ -59,4 +60,12 @@ extension TypeCount {
59 60
     @MainActor func setDetailCache(_ cache: TypeCountDetailCache?) {
60 61
         detailCacheData = cache.flatMap(TypeCountDetailCacheArchive.encode)
61 62
     }
63
+
64
+    var isContentAlias: Bool {
65
+        contentEquivalentTypeCountID != nil
66
+    }
67
+
68
+    var contentRepresentativeTypeCountID: UUID {
69
+        contentEquivalentTypeCountID ?? id
70
+    }
62 71
 }
+55 -3
HealthProbe/Models/TypeCountDetailCache.swift
@@ -46,8 +46,40 @@ enum TypeCountDetailCacheBuilder {
46 46
         previous: TypeCount?,
47 47
         baselineSnapshotID: UUID?
48 48
     ) -> TypeCountDetailCache? {
49
-        guard let currentFingerprints = HealthRecordArchive.fingerprintSet(from: current.recordArchiveData),
50
-              let previousFingerprints = HealthRecordArchive.fingerprintSet(from: previous?.recordArchiveData) else {
49
+        let metadata = buildMetadata(current: current, previous: previous)
50
+        MemoryLog.log("typeCountDetailCache.build.begin", metadata: metadata)
51
+        guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
52
+            MemoryLog.log("typeCountDetailCache.build.skippedMemoryPressure", metadata: metadata.merging([
53
+                "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit)
54
+            ]) { _, new in new })
55
+            return nil
56
+        }
57
+
58
+        guard let currentFingerprints = HealthRecordArchive.fingerprintSet(from: current.recordArchiveData) else {
59
+            MemoryLog.log("typeCountDetailCache.build.currentFingerprintFailed", metadata: metadata)
60
+            return nil
61
+        }
62
+        MemoryLog.log("typeCountDetailCache.build.currentFingerprintsReady", metadata: metadata.merging([
63
+            "currentFingerprintCount": "\(currentFingerprints.count)"
64
+        ]) { _, new in new })
65
+        guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
66
+            MemoryLog.log("typeCountDetailCache.build.skippedAfterCurrentFingerprints", metadata: metadata.merging([
67
+                "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit)
68
+            ]) { _, new in new })
69
+            return nil
70
+        }
71
+
72
+        guard let previousFingerprints = HealthRecordArchive.fingerprintSet(from: previous?.recordArchiveData) else {
73
+            MemoryLog.log("typeCountDetailCache.build.previousFingerprintFailed", metadata: metadata)
74
+            return nil
75
+        }
76
+        MemoryLog.log("typeCountDetailCache.build.previousFingerprintsReady", metadata: metadata.merging([
77
+            "previousFingerprintCount": "\(previousFingerprints.count)"
78
+        ]) { _, new in new })
79
+        guard !MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) else {
80
+            MemoryLog.log("typeCountDetailCache.build.skippedAfterPreviousFingerprints", metadata: metadata.merging([
81
+                "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit)
82
+            ]) { _, new in new })
51 83
             return nil
52 84
         }
53 85
 
@@ -62,8 +94,10 @@ enum TypeCountDetailCacheBuilder {
62 94
                     accumulator.add(record, as: .disappeared)
63 95
                 }
64 96
             }) else {
97
+                MemoryLog.log("typeCountDetailCache.build.previousScanFailed", metadata: metadata)
65 98
                 return nil
66 99
             }
100
+            MemoryLog.log("typeCountDetailCache.build.previousScanFinished", metadata: metadata)
67 101
         }
68 102
 
69 103
         if let currentArchive = current.recordArchiveData {
@@ -74,11 +108,13 @@ enum TypeCountDetailCacheBuilder {
74 108
                     accumulator.add(record, as: .added)
75 109
                 }
76 110
             }) else {
111
+                MemoryLog.log("typeCountDetailCache.build.currentScanFailed", metadata: metadata)
77 112
                 return nil
78 113
             }
114
+            MemoryLog.log("typeCountDetailCache.build.currentScanFinished", metadata: metadata)
79 115
         }
80 116
 
81
-        return TypeCountDetailCache(
117
+        let cache = TypeCountDetailCache(
82 118
             baselineSnapshotID: baselineSnapshotID,
83 119
             addedCount: accumulator.addedCount,
84 120
             disappearedCount: accumulator.disappearedCount,
@@ -88,6 +124,22 @@ enum TypeCountDetailCacheBuilder {
88 124
             earliestRecordDate: accumulator.earliestRecordDate,
89 125
             latestRecordDate: accumulator.latestRecordDate
90 126
         )
127
+        MemoryLog.log("typeCountDetailCache.build.finished", metadata: metadata.merging([
128
+            "added": "\(cache.addedCount)",
129
+            "disappeared": "\(cache.disappearedCount)",
130
+            "dailyBins": "\(cache.dailyChangeBins.count)"
131
+        ]) { _, new in new })
132
+        return cache
133
+    }
134
+
135
+    private static func buildMetadata(current: TypeCount, previous: TypeCount?) -> [String: String] {
136
+        [
137
+            "type": current.typeIdentifier,
138
+            "currentCount": "\(current.count)",
139
+            "previousCount": "\(previous?.count ?? 0)",
140
+            "currentArchive": current.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
141
+            "previousArchive": previous?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil"
142
+        ]
91 143
     }
92 144
 }
93 145
 
+14 -0
HealthProbe/Services/DeltaService.swift
@@ -33,6 +33,14 @@ enum DeltaService {
33 33
         delta.checksumBefore = HashService.snapshotChecksum(typeCounts: Array(prevByID.values))
34 34
         delta.checksumAfter  = HashService.snapshotChecksum(typeCounts: Array(currByID.values))
35 35
 
36
+        if current.isContentAlias,
37
+           current.contentEquivalentSnapshotID == previous.contentRepresentativeSnapshotID {
38
+            delta.typeDeltas = []
39
+            context.insert(delta)
40
+            try context.save()
41
+            return delta
42
+        }
43
+
36 44
         let allTypeIDs = Set(prevByID.keys).union(currByID.keys)
37 45
         var typeDeltas: [TypeDelta] = []
38 46
 
@@ -40,6 +48,12 @@ enum DeltaService {
40 48
             let prev = prevByID[typeID]
41 49
             let curr = currByID[typeID]
42 50
 
51
+            if let prev,
52
+               let curr,
53
+               curr.contentEquivalentTypeCountID == prev.contentRepresentativeTypeCountID {
54
+                continue
55
+            }
56
+
43 57
             let effectivePrev = historicalBaselinePreviousTypeCount(
44 58
                 typeID: typeID,
45 59
                 prev: prev,
+118 -0
HealthProbe/Services/HealthKitService.swift
@@ -140,6 +140,11 @@ final class HealthKitService {
140 140
             intendedTypeIDs: active.map { $0.id },
141 141
             context: context
142 142
         )
143
+        markContentEquivalenceIfNeeded(
144
+            snapshot: snapshot,
145
+            typeCounts: typeCounts,
146
+            context: context
147
+        )
143 148
         precomputeTypeCountDetailCaches(
144 149
             snapshot: snapshot,
145 150
             typeCounts: typeCounts,
@@ -172,6 +177,11 @@ final class HealthKitService {
172 177
             intendedTypeIDs: typeCounts.map(\.typeIdentifier),
173 178
             context: context
174 179
         )
180
+        markContentEquivalenceIfNeeded(
181
+            snapshot: snapshot,
182
+            typeCounts: typeCounts,
183
+            context: context
184
+        )
175 185
         precomputeTypeCountDetailCaches(
176 186
             snapshot: snapshot,
177 187
             typeCounts: typeCounts,
@@ -195,6 +205,11 @@ final class HealthKitService {
195 205
             intendedTypeIDs: typeCounts.map(\.typeIdentifier),
196 206
             context: context
197 207
         )
208
+        markContentEquivalenceIfNeeded(
209
+            snapshot: snapshot,
210
+            typeCounts: typeCounts,
211
+            context: context
212
+        )
198 213
         precomputeTypeCountDetailCaches(
199 214
             snapshot: snapshot,
200 215
             typeCounts: typeCounts,
@@ -314,19 +329,40 @@ final class HealthKitService {
314 329
         typeCounts: [TypeCount],
315 330
         context: ModelContext
316 331
     ) {
332
+        MemoryLog.log("healthKit.precomputeDetailCaches.begin", metadata: [
333
+            "typeCountCount": "\(typeCounts.count)",
334
+            "hasPrevious": "\(snapshot.previousSnapshotID != nil)"
335
+        ])
336
+
317 337
         guard let previousID = snapshot.previousSnapshotID,
318 338
               let previous = fetchSnapshot(id: previousID, context: context) else {
319 339
             for typeCount in typeCounts {
320 340
                 typeCount.setDetailCache(nil)
321 341
             }
342
+            MemoryLog.log("healthKit.precomputeDetailCaches.noPrevious", metadata: [
343
+                "typeCountCount": "\(typeCounts.count)"
344
+            ])
322 345
             return
323 346
         }
324 347
 
325 348
         let previousByType = Dictionary(
326 349
             uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
327 350
         )
351
+        var builtCount = 0
352
+        var skippedAliasCount = 0
328 353
 
329 354
         for typeCount in typeCounts {
355
+            if typeCount.isContentAlias {
356
+                typeCount.setDetailCache(nil)
357
+                skippedAliasCount += 1
358
+                continue
359
+            }
360
+
361
+            MemoryLog.log("healthKit.detailCache.buildBegin", metadata: detailCacheMetadata(
362
+                current: typeCount,
363
+                previous: previousByType[typeCount.typeIdentifier],
364
+                source: "snapshotSave"
365
+            ))
330 366
             typeCount.setDetailCache(
331 367
                 TypeCountDetailCacheBuilder.build(
332 368
                     current: typeCount,
@@ -334,9 +370,91 @@ final class HealthKitService {
334 370
                     baselineSnapshotID: previous.id
335 371
                 )
336 372
             )
373
+            builtCount += 1
374
+            MemoryLog.log("healthKit.detailCache.buildEnd", metadata: detailCacheMetadata(
375
+                current: typeCount,
376
+                previous: previousByType[typeCount.typeIdentifier],
377
+                source: "snapshotSave"
378
+            ))
379
+        }
380
+        MemoryLog.log("healthKit.precomputeDetailCaches.end", metadata: [
381
+            "builtCount": "\(builtCount)",
382
+            "skippedAliasCount": "\(skippedAliasCount)"
383
+        ])
384
+    }
385
+
386
+    private func markContentEquivalenceIfNeeded(
387
+        snapshot: HealthSnapshot,
388
+        typeCounts: [TypeCount],
389
+        context: ModelContext
390
+    ) {
391
+        snapshot.contentEquivalentSnapshotID = nil
392
+        for typeCount in typeCounts {
393
+            typeCount.contentEquivalentTypeCountID = nil
394
+        }
395
+
396
+        guard let previousID = snapshot.previousSnapshotID,
397
+              let previous = fetchSnapshot(id: previousID, context: context),
398
+              previous.monitoredTypeSetHash == snapshot.monitoredTypeSetHash else {
399
+            return
400
+        }
401
+
402
+        let previousByType = Dictionary(
403
+            uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
404
+        )
405
+
406
+        for typeCount in typeCounts {
407
+            guard let previousType = previousByType[typeCount.typeIdentifier],
408
+                  areTypeCountsContentEquivalent(typeCount, previousType) else {
409
+                continue
410
+            }
411
+
412
+            typeCount.contentEquivalentTypeCountID = previousType.contentRepresentativeTypeCountID
413
+        }
414
+
415
+        if areTypeCountsContentEquivalent(previous.typeCounts ?? [], typeCounts) {
416
+            snapshot.contentEquivalentSnapshotID = previous.contentRepresentativeSnapshotID
337 417
         }
338 418
     }
339 419
 
420
+    private func areTypeCountsContentEquivalent(_ lhs: [TypeCount], _ rhs: [TypeCount]) -> Bool {
421
+        let lhsByType = Dictionary(uniqueKeysWithValues: lhs.map { ($0.typeIdentifier, $0) })
422
+        let rhsByType = Dictionary(uniqueKeysWithValues: rhs.map { ($0.typeIdentifier, $0) })
423
+        guard lhsByType.keys == rhsByType.keys else { return false }
424
+
425
+        for typeIdentifier in lhsByType.keys {
426
+            guard let lhsType = lhsByType[typeIdentifier],
427
+                  let rhsType = rhsByType[typeIdentifier],
428
+                  lhsType.count == rhsType.count,
429
+                  lhsType.contentHash == rhsType.contentHash,
430
+                  lhsType.quality == rhsType.quality,
431
+                  lhsType.isUnsupported == rhsType.isUnsupported else {
432
+                return false
433
+            }
434
+        }
435
+
436
+        return true
437
+    }
438
+
439
+    private func areTypeCountsContentEquivalent(_ lhs: TypeCount, _ rhs: TypeCount) -> Bool {
440
+        lhs.count == rhs.count &&
441
+        lhs.contentHash == rhs.contentHash &&
442
+        lhs.quality == rhs.quality &&
443
+        lhs.isUnsupported == rhs.isUnsupported
444
+    }
445
+
446
+    private func detailCacheMetadata(current: TypeCount, previous: TypeCount?, source: String) -> [String: String] {
447
+        [
448
+            "source": source,
449
+            "type": current.typeIdentifier,
450
+            "currentCount": "\(current.count)",
451
+            "previousCount": "\(previous?.count ?? 0)",
452
+            "currentArchive": current.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
453
+            "previousArchive": previous?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
454
+            "isAlias": "\(current.isContentAlias)"
455
+        ]
456
+    }
457
+
340 458
     private func hasAmbiguousCompleteDisappearance(
341 459
         snapshot: HealthSnapshot,
342 460
         typeCounts: [TypeCount],
+197 -4
HealthProbe/Services/SnapshotLifecycleService.swift
@@ -88,6 +88,10 @@ enum SnapshotLifecycleService {
88 88
             if let nextSnap = try fetchSnapshot(id: outgoing.toSnapshotID, context: context) {
89 89
                 nextSnap.previousSnapshotID = nil
90 90
                 nextSnap.isChainStart = true
91
+                nextSnap.contentEquivalentSnapshotID = nil
92
+                for typeCount in nextSnap.typeCounts ?? [] {
93
+                    typeCount.contentEquivalentTypeCountID = nil
94
+                }
91 95
                 refreshDetailCaches(for: nextSnap, baseline: nil)
92 96
             }
93 97
             context.delete(outgoing)
@@ -114,6 +118,7 @@ enum SnapshotLifecycleService {
114 118
             for td in merged.typeDeltas ?? [] { context.insert(td) }
115 119
 
116 120
             nextSnap.previousSnapshotID = prevSnap.id
121
+            _ = refreshContentEquivalence(for: nextSnap, baseline: prevSnap)
117 122
             refreshDetailCaches(for: nextSnap, baseline: prevSnap)
118 123
             context.delete(d1)
119 124
             context.delete(d2)
@@ -146,15 +151,57 @@ enum SnapshotLifecycleService {
146 151
         maxTypeCounts: Int
147 152
     ) throws -> Bool {
148 153
         guard maxTypeCounts > 0 else { return false }
154
+        MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.begin", metadata: [
155
+            "maxTypeCounts": "\(maxTypeCounts)"
156
+        ])
149 157
 
150 158
         let descriptor = FetchDescriptor<HealthSnapshot>(
151 159
             sortBy: [SortDescriptor(\.timestamp, order: .forward)]
152 160
         )
153
-        let snapshots = try context.fetch(descriptor)
161
+        let snapshotIDs = try context.fetch(FetchDescriptor<HealthSnapshot>()).map { $0.id }
162
+        MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.snapshotsFetched", metadata: [
163
+            "snapshotCount": "\(snapshotIDs.count)"
164
+        ])
154 165
         var rebuiltCount = 0
166
+        var updatedAliases = 0
167
+
168
+        // Alias pass: process in batches to avoid memory bloat
169
+        let aliasBatchSize = 5
170
+        for batchStart in stride(from: 0, to: snapshotIDs.count, by: aliasBatchSize) {
171
+            let batchEnd = min(batchStart + aliasBatchSize, snapshotIDs.count)
172
+            for id in snapshotIDs[batchStart..<batchEnd] {
173
+                guard let snapshot = try fetchSnapshot(id: id, context: context),
174
+                      let baselineID = snapshot.previousSnapshotID,
175
+                      let baseline = try fetchSnapshot(id: baselineID, context: context) else {
176
+                    continue
177
+                }
178
+
179
+                if refreshContentEquivalence(for: snapshot, baseline: baseline) {
180
+                    updatedAliases += 1
181
+                }
182
+            }
183
+            // Save batch to flush object cache and allow garbage collection
184
+            if updatedAliases > 0 {
185
+                try context.save()
186
+            }
187
+        }
188
+
189
+        MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.aliasPassFinished", metadata: [
190
+            "updatedAliases": "\(updatedAliases)"
191
+        ])
192
+        if MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) {
193
+            MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.pausedMemoryPressure", metadata: [
194
+                "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit),
195
+                "phase": "afterAliasPass",
196
+                "updatedAliases": "\(updatedAliases)"
197
+            ])
198
+            return false
199
+        }
155 200
 
156
-        for snapshot in snapshots {
157
-            guard let baselineID = snapshot.previousSnapshotID,
201
+        // Detail cache pass
202
+        for id in snapshotIDs {
203
+            guard let snapshot = try fetchSnapshot(id: id, context: context),
204
+                  let baselineID = snapshot.previousSnapshotID,
158 205
                   let baseline = try fetchSnapshot(id: baselineID, context: context) else {
159 206
                 continue
160 207
             }
@@ -163,6 +210,11 @@ enum SnapshotLifecycleService {
163 210
                 uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
164 211
             )
165 212
 
213
+            if snapshot.isContentAlias,
214
+               snapshot.contentEquivalentSnapshotID == baseline.contentRepresentativeSnapshotID {
215
+                continue
216
+            }
217
+
166 218
             for typeCount in snapshot.typeCounts ?? [] {
167 219
                 guard shouldBackfillDetailCache(
168 220
                     typeCount: typeCount,
@@ -172,6 +224,24 @@ enum SnapshotLifecycleService {
172 224
                     continue
173 225
                 }
174 226
 
227
+                if MemoryLog.isFootprintAtOrAbove(MemoryLog.detailCacheBuildFootprintLimit) {
228
+                    MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.pausedMemoryPressure", metadata: [
229
+                        "limit": MemoryLog.format(MemoryLog.detailCacheBuildFootprintLimit),
230
+                        "phase": "beforeDetailCacheBuild",
231
+                        "rebuiltCount": "\(rebuiltCount)",
232
+                        "updatedAliases": "\(updatedAliases)"
233
+                    ])
234
+                    if rebuiltCount > 0 || updatedAliases > 0 {
235
+                        try context.save()
236
+                    }
237
+                    return false
238
+                }
239
+
240
+                MemoryLog.log("snapshotLifecycle.detailCache.buildBegin", metadata: detailCacheMetadata(
241
+                    current: typeCount,
242
+                    previous: baselineByType[typeCount.typeIdentifier],
243
+                    source: "backfill"
244
+                ))
175 245
                 typeCount.setDetailCache(
176 246
                     TypeCountDetailCacheBuilder.build(
177 247
                         current: typeCount,
@@ -179,18 +249,31 @@ enum SnapshotLifecycleService {
179 249
                         baselineSnapshotID: baseline.id
180 250
                     )
181 251
                 )
252
+                MemoryLog.log("snapshotLifecycle.detailCache.buildEnd", metadata: detailCacheMetadata(
253
+                    current: typeCount,
254
+                    previous: baselineByType[typeCount.typeIdentifier],
255
+                    source: "backfill"
256
+                ))
182 257
                 rebuiltCount += 1
183 258
 
184 259
                 if rebuiltCount >= maxTypeCounts {
260
+                    MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.pausedAtLimit", metadata: [
261
+                        "rebuiltCount": "\(rebuiltCount)",
262
+                        "updatedAliases": "\(updatedAliases)"
263
+                    ])
185 264
                     try context.save()
186 265
                     return false
187 266
                 }
188 267
             }
189 268
         }
190 269
 
191
-        if rebuiltCount > 0 {
270
+        if rebuiltCount > 0 || updatedAliases > 0 {
192 271
             try context.save()
193 272
         }
273
+        MemoryLog.log("snapshotLifecycle.rebuildMissingDetailCaches.complete", metadata: [
274
+            "rebuiltCount": "\(rebuiltCount)",
275
+            "updatedAliases": "\(updatedAliases)"
276
+        ])
194 277
         return true
195 278
     }
196 279
 
@@ -220,6 +303,11 @@ enum SnapshotLifecycleService {
220 303
         )
221 304
 
222 305
         for typeCount in snapshot.typeCounts ?? [] {
306
+            if typeCount.isContentAlias {
307
+                typeCount.setDetailCache(nil)
308
+                continue
309
+            }
310
+
223 311
             typeCount.setDetailCache(
224 312
                 TypeCountDetailCacheBuilder.build(
225 313
                     current: typeCount,
@@ -230,11 +318,104 @@ enum SnapshotLifecycleService {
230 318
         }
231 319
     }
232 320
 
321
+    @discardableResult
322
+    private static func refreshContentEquivalence(for snapshot: HealthSnapshot, baseline: HealthSnapshot) -> Bool {
323
+        let previousSnapshotAliasID = snapshot.contentEquivalentSnapshotID
324
+        let previousTypeAliasIDs = Dictionary(
325
+            uniqueKeysWithValues: (snapshot.typeCounts ?? []).map { ($0.id, $0.contentEquivalentTypeCountID) }
326
+        )
327
+
328
+        snapshot.contentEquivalentSnapshotID = nil
329
+        for typeCount in snapshot.typeCounts ?? [] {
330
+            typeCount.contentEquivalentTypeCountID = nil
331
+        }
332
+
333
+        guard snapshot.monitoredTypeSetHash == baseline.monitoredTypeSetHash else {
334
+            return contentEquivalenceDidChange(
335
+                snapshot: snapshot,
336
+                previousSnapshotAliasID: previousSnapshotAliasID,
337
+                previousTypeAliasIDs: previousTypeAliasIDs
338
+            )
339
+        }
340
+
341
+        let baselineByType = Dictionary(
342
+            uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
343
+        )
344
+
345
+        for typeCount in snapshot.typeCounts ?? [] {
346
+            guard let baselineType = baselineByType[typeCount.typeIdentifier],
347
+                  areTypeCountsContentEquivalent(typeCount, baselineType) else {
348
+                continue
349
+            }
350
+
351
+            typeCount.contentEquivalentTypeCountID = baselineType.contentRepresentativeTypeCountID
352
+            typeCount.setDetailCache(nil)
353
+        }
354
+
355
+        if areTypeCountsContentEquivalent(snapshot.typeCounts ?? [], baseline.typeCounts ?? []) {
356
+            snapshot.contentEquivalentSnapshotID = baseline.contentRepresentativeSnapshotID
357
+        }
358
+
359
+        return contentEquivalenceDidChange(
360
+            snapshot: snapshot,
361
+            previousSnapshotAliasID: previousSnapshotAliasID,
362
+            previousTypeAliasIDs: previousTypeAliasIDs
363
+        )
364
+    }
365
+
366
+    private static func contentEquivalenceDidChange(
367
+        snapshot: HealthSnapshot,
368
+        previousSnapshotAliasID: UUID?,
369
+        previousTypeAliasIDs: [UUID: UUID?]
370
+    ) -> Bool {
371
+        if snapshot.contentEquivalentSnapshotID != previousSnapshotAliasID {
372
+            return true
373
+        }
374
+
375
+        for typeCount in snapshot.typeCounts ?? [] {
376
+            if typeCount.contentEquivalentTypeCountID != (previousTypeAliasIDs[typeCount.id] ?? nil) {
377
+                return true
378
+            }
379
+        }
380
+
381
+        return false
382
+    }
383
+
384
+    private static func areTypeCountsContentEquivalent(_ lhs: [TypeCount], _ rhs: [TypeCount]) -> Bool {
385
+        let lhsByType = Dictionary(uniqueKeysWithValues: lhs.map { ($0.typeIdentifier, $0) })
386
+        let rhsByType = Dictionary(uniqueKeysWithValues: rhs.map { ($0.typeIdentifier, $0) })
387
+        guard lhsByType.keys == rhsByType.keys else { return false }
388
+
389
+        for typeIdentifier in lhsByType.keys {
390
+            guard let lhsType = lhsByType[typeIdentifier],
391
+                  let rhsType = rhsByType[typeIdentifier],
392
+                  lhsType.count == rhsType.count,
393
+                  lhsType.contentHash == rhsType.contentHash,
394
+                  lhsType.quality == rhsType.quality,
395
+                  lhsType.isUnsupported == rhsType.isUnsupported else {
396
+                return false
397
+            }
398
+        }
399
+
400
+        return true
401
+    }
402
+
403
+    private static func areTypeCountsContentEquivalent(_ lhs: TypeCount, _ rhs: TypeCount) -> Bool {
404
+        lhs.count == rhs.count &&
405
+        lhs.contentHash == rhs.contentHash &&
406
+        lhs.quality == rhs.quality &&
407
+        lhs.isUnsupported == rhs.isUnsupported
408
+    }
409
+
233 410
     @MainActor private static func shouldBackfillDetailCache(
234 411
         typeCount: TypeCount,
235 412
         baseline: TypeCount?,
236 413
         baselineID: UUID
237 414
     ) -> Bool {
415
+        if typeCount.isContentAlias {
416
+            return false
417
+        }
418
+
238 419
         if typeCount.detailCache?.matchesBaseline(baselineID) == true {
239 420
             return false
240 421
         }
@@ -251,6 +432,18 @@ enum SnapshotLifecycleService {
251 432
         typeCount.count <= 0 || typeCount.recordArchiveData != nil
252 433
     }
253 434
 
435
+    private static func detailCacheMetadata(current: TypeCount, previous: TypeCount?, source: String) -> [String: String] {
436
+        [
437
+            "source": source,
438
+            "type": current.typeIdentifier,
439
+            "currentCount": "\(current.count)",
440
+            "previousCount": "\(previous?.count ?? 0)",
441
+            "currentArchive": current.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
442
+            "previousArchive": previous?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
443
+            "isAlias": "\(current.isContentAlias)"
444
+        ]
445
+    }
446
+
254 447
     private static func buildSummary(snapshot: HealthSnapshot, incoming: SnapshotDelta?, outgoing: SnapshotDelta?) -> String {
255 448
         let position: String
256 449
         if incoming == nil && outgoing == nil { position = "standalone" }
+1 -1
HealthProbe/Utilities/AppSettings.swift
@@ -7,7 +7,7 @@ final class AppSettings {
7 7
     private static let selectedDeviceIDsKey = "hp_selectedDeviceIDs"
8 8
     static let adaptiveTimeoutsEnabledKey = "hp_adaptiveTimeoutsEnabled"
9 9
     static let typeDetailCacheBackfillVersionKey = "hp_typeDetailCacheBackfillVersion"
10
-    static let currentTypeDetailCacheBackfillVersion = 1
10
+    static let currentTypeDetailCacheBackfillVersion = 2
11 11
 
12 12
     var selectedTypeIDs: Set<String> {
13 13
         didSet { persistTypes() }
+97 -0
HealthProbe/Utilities/MemoryLog.swift
@@ -0,0 +1,97 @@
1
+import Foundation
2
+import MachO
3
+
4
+struct MemorySample: Sendable {
5
+    let residentBytes: UInt64
6
+    let physicalFootprintBytes: UInt64
7
+    let virtualBytes: UInt64
8
+}
9
+
10
+enum MemoryLog {
11
+    static let detailCacheBuildFootprintLimit: UInt64 = 1_500 * 1_024 * 1_024
12
+
13
+    static func log(_ event: String, metadata: [String: String] = [:]) {
14
+        let sample = currentSample()
15
+        let metadataText = metadata
16
+            .sorted { $0.key < $1.key }
17
+            .map { "\($0.key)=\($0.value)" }
18
+            .joined(separator: " ")
19
+        let memoryText: String
20
+        if let sample {
21
+            memoryText = "rss=\(format(sample.residentBytes)) footprint=\(format(sample.physicalFootprintBytes)) virtual=\(format(sample.virtualBytes))"
22
+        } else {
23
+            memoryText = "memory=unavailable"
24
+        }
25
+
26
+        let line = "[HealthProbeMemory] \(event) \(memoryText)\(metadataText.isEmpty ? "" : " \(metadataText)")"
27
+        print(line)
28
+    }
29
+
30
+    static func startPulse(_ event: String, metadata: [String: String] = [:], intervalSeconds: UInt64 = 1) -> Task<Void, Never> {
31
+        Task.detached(priority: .background) {
32
+            var previousSample = currentSample()
33
+            var previousTime = Date()
34
+            log("\(event).pulse.start", metadata: metadata)
35
+
36
+            while !Task.isCancelled {
37
+                try? await Task.sleep(for: .seconds(intervalSeconds))
38
+                guard !Task.isCancelled else { break }
39
+
40
+                let current = currentSample()
41
+                let now = Date()
42
+                var pulseMetadata = metadata
43
+
44
+                if let previousSample,
45
+                   let current {
46
+                    let elapsed = max(now.timeIntervalSince(previousTime), 0.001)
47
+                    let footprintDelta = Int64(current.physicalFootprintBytes) - Int64(previousSample.physicalFootprintBytes)
48
+                    let bytesPerSecond = Double(footprintDelta) / elapsed
49
+                    pulseMetadata["footprint_delta"] = signedFormat(footprintDelta)
50
+                    pulseMetadata["footprint_rate"] = "\(signedFormat(Int64(bytesPerSecond)))/s"
51
+                }
52
+
53
+                log("\(event).pulse", metadata: pulseMetadata)
54
+                previousSample = current
55
+                previousTime = now
56
+            }
57
+
58
+            log("\(event).pulse.stop", metadata: metadata)
59
+        }
60
+    }
61
+
62
+    static func format(_ bytes: UInt64) -> String {
63
+        ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .memory)
64
+    }
65
+
66
+    static func isFootprintAtOrAbove(_ bytes: UInt64) -> Bool {
67
+        guard let currentSample = currentSample() else { return false }
68
+        return currentSample.physicalFootprintBytes >= bytes
69
+    }
70
+
71
+    private static func signedFormat(_ bytes: Int64) -> String {
72
+        if bytes >= 0 {
73
+            return "+\(format(UInt64(bytes)))"
74
+        }
75
+        return "-\(format(UInt64(-bytes)))"
76
+    }
77
+
78
+    private static func currentSample() -> MemorySample? {
79
+        var info = task_vm_info_data_t()
80
+        var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size)
81
+        let result = withUnsafeMutablePointer(to: &info) { pointer in
82
+            pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { reboundPointer in
83
+                task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), reboundPointer, &count)
84
+            }
85
+        }
86
+
87
+        guard result == KERN_SUCCESS else {
88
+            return nil
89
+        }
90
+
91
+        return MemorySample(
92
+            residentBytes: info.resident_size,
93
+            physicalFootprintBytes: info.phys_footprint,
94
+            virtualBytes: info.virtual_size
95
+        )
96
+    }
97
+}
+10 -4
HealthProbe/Views/DataTypes/RecordChangeEvolutionChart.swift
@@ -41,12 +41,18 @@ struct RecordChangeEvolutionChart: View {
41 41
 
42 42
     private func recordDiff(current: HealthSnapshot, previous: HealthSnapshot?) -> (added: Int, disappeared: Int) {
43 43
         guard let previous = previous else { return (0, 0) }
44
-        guard let cache = current.typeCounts?
45
-            .first(where: { $0.typeIdentifier == typeIdentifier })?
46
-            .detailCache,
47
-              cache.matchesBaseline(previous.id) else {
44
+        guard let currentType = current.typeCounts?
45
+            .first(where: { $0.typeIdentifier == typeIdentifier }) else {
48 46
             return (0, 0)
49 47
         }
48
+
49
+        if let previousType = previous.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier }),
50
+           currentType.contentEquivalentTypeCountID == previousType.contentRepresentativeTypeCountID {
51
+            return (0, 0)
52
+        }
53
+
54
+        guard let cache = currentType.detailCache,
55
+              cache.matchesBaseline(previous.id) else { return (0, 0) }
50 56
         return (cache.addedCount, cache.disappearedCount)
51 57
     }
52 58
 
+30 -1
HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift
@@ -42,6 +42,12 @@ struct DataTypeSnapshotDetailView: View {
42 42
         previousSnapshot.flatMap(typeCount(in:))
43 43
     }
44 44
 
45
+    private var isCurrentTypeContentAliasToPrevious: Bool {
46
+        guard let currentTypeCount,
47
+              let previousTypeCount else { return false }
48
+        return currentTypeCount.contentEquivalentTypeCountID == previousTypeCount.contentRepresentativeTypeCountID
49
+    }
50
+
45 51
     private var diffTaskID: String {
46 52
         [
47 53
             currentSnapshot.id.uuidString,
@@ -278,7 +284,7 @@ struct DataTypeSnapshotDetailView: View {
278 284
 
279 285
     @ViewBuilder
280 286
     private var temporalDistributionSection: some View {
281
-        if previousSnapshot != nil, currentTypeCount != nil {
287
+        if previousSnapshot != nil, currentTypeCount != nil, !isCurrentTypeContentAliasToPrevious {
282 288
             Button {
283 289
                 showTemporalDistribution = true
284 290
             } label: {
@@ -333,6 +339,11 @@ struct DataTypeSnapshotDetailView: View {
333 339
             return
334 340
         }
335 341
 
342
+        if isCurrentTypeContentAliasToPrevious {
343
+            diffState = .loaded(.empty)
344
+            return
345
+        }
346
+
336 347
         let currentCount = currentTypeCount?.count ?? 0
337 348
         let previousCount = previousTypeCount?.count ?? 0
338 349
 
@@ -351,6 +362,10 @@ struct DataTypeSnapshotDetailView: View {
351 362
 
352 363
     @MainActor
353 364
     private func currentDetailCache() -> TypeCountDetailCache? {
365
+        if isCurrentTypeContentAliasToPrevious {
366
+            return nil
367
+        }
368
+
354 369
         if let cache = currentTypeCount?.detailCache,
355 370
            cache.matchesBaseline(previousSnapshot?.id) {
356 371
             return cache
@@ -361,11 +376,25 @@ struct DataTypeSnapshotDetailView: View {
361 376
             return nil
362 377
         }
363 378
 
379
+        MemoryLog.log("dataTypeDetail.detailCache.buildBegin", metadata: [
380
+            "source": "detailView",
381
+            "type": currentTypeCount.typeIdentifier,
382
+            "currentCount": "\(currentTypeCount.count)",
383
+            "previousCount": "\(previousTypeCount?.count ?? 0)",
384
+            "currentArchive": currentTypeCount.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
385
+            "previousArchive": previousTypeCount?.recordArchiveData.map { MemoryLog.format(UInt64($0.count)) } ?? "nil",
386
+            "isAlias": "\(currentTypeCount.isContentAlias)"
387
+        ])
364 388
         let cache = TypeCountDetailCacheBuilder.build(
365 389
             current: currentTypeCount,
366 390
             previous: previousTypeCount,
367 391
             baselineSnapshotID: previousSnapshot.id
368 392
         )
393
+        MemoryLog.log("dataTypeDetail.detailCache.buildEnd", metadata: [
394
+            "source": "detailView",
395
+            "type": currentTypeCount.typeIdentifier,
396
+            "cacheBuilt": "\(cache != nil)"
397
+        ])
369 398
         currentTypeCount.setDetailCache(cache)
370 399
         if cache != nil {
371 400
             try? modelContext.save()