@@ -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. |
@@ -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. |
@@ -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 |
|
@@ -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( |
@@ -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 |
-} |
|
@@ -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 |
-} |
|
@@ -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 |
} |
@@ -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 |
+} |
|
@@ -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 |
-} |
|
@@ -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 |
@@ -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 {
|
@@ -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) {
|