Showing 10 changed files with 304 additions and 207 deletions
+2 -2
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -27,7 +27,7 @@ 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. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace Settings/local rows, 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 has moved to a local Codable store outside `ModelContainer`. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace remaining Settings/local rows, 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 32
 | Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications |
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 |
@@ -49,7 +49,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
49 49
 - SwiftData currently blocks iOS 15-era device support.
50 50
 - Existing `Anomaly*` model/service names are legacy language.
51 51
 - Some screens still imply snapshot-count monitoring rather than Time Machine inspection.
52
-- Current UI/cache layers still depend on 28 SwiftData-backed files for launch container, local Settings rows, capture review actions, navigation handles, some charts, and PDF paths.
52
+- Current UI/cache layers still depend on 27 SwiftData-backed files for launch container, remaining local Settings rows, capture review actions, navigation handles, some charts, and PDF paths.
53 53
 - 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 54
 - Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now reset for archive v2 test installs rather than migrated.
55 55
 - Capture strategy and some legacy SwiftData transition paths may still decode or cache too much data for low-end devices.
+4 -1
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -246,7 +246,10 @@ Acceptance:
246 246
 
247 247
 Checklist:
248 248
 - [x] Identify all remaining SwiftData imports.
249
-- [ ] Replace SwiftData models used by active flows.
249
+- [ ] Replace SwiftData models used by active flows. Metric timeout calibration
250
+  has been moved to a local Codable store and removed from `ModelContainer`;
251
+  `DeviceProfile`, `OperationLog`, and SwiftData snapshot/navigation handles
252
+  remain.
250 253
 - [ ] Remove/disable `ModelContainer` as required for target builds.
251 254
 - [x] Add prototype-store ignore/delete/reset path for test installs.
252 255
 - [ ] Verify no old-store compatibility layer remains in active flows.
+16 -13
HealthProbe/Doc/04-project/SwiftData-Retirement-Inventory.md
@@ -9,7 +9,7 @@ local settings stored outside SwiftData where needed.
9 9
 
10 10
 ## Current Count
11 11
 
12
-After removing unused imports from pure legacy services, 28 app files still have
12
+After moving timeout calibration out of SwiftData, 27 app files still have
13 13
 SwiftData imports.
14 14
 
15 15
 ## Launch Container
@@ -20,8 +20,8 @@ These files keep SwiftData required at app launch:
20 20
 - `HealthProbe/ContentView.swift`
21 21
 
22 22
 Retirement path:
23
-- move local settings models (`DeviceProfile`, `MetricTimeoutProfile`,
24
-  `OperationLog`) to non-SwiftData storage or Core Data cache/local store;
23
+- move remaining local settings models (`DeviceProfile`, `OperationLog`) to
24
+  non-SwiftData storage or Core Data cache/local store;
25 25
 - replace prototype snapshot model dependencies in tab roots;
26 26
 - remove `.modelContainer(...)` once no active view needs `@Query` or
27 27
   `ModelContext`.
@@ -35,7 +35,6 @@ block:
35 35
 - `HealthProbe/Models/DeviceProfile.swift`
36 36
 - `HealthProbe/Models/HealthRecord.swift`
37 37
 - `HealthProbe/Models/HealthSnapshot.swift`
38
-- `HealthProbe/Models/MetricTimeoutProfile.swift`
39 38
 - `HealthProbe/Models/OperationLog.swift`
40 39
 - `HealthProbe/Models/SnapshotDelta.swift`
41 40
 - `HealthProbe/Models/TypeCount.swift`
@@ -48,8 +47,8 @@ Retirement path:
48 47
   `YearlyCount`, `TypeDistributionBin`, and `HealthRecord` active reads with
49 48
   archive/cache DTOs;
50 49
 - replace `AnomalyRecord` flows with neutral change/diff DTOs;
51
-- move `DeviceProfile`, `MetricTimeoutProfile`, and `OperationLog` to a local
52
-  non-SwiftData store before removing the launch container.
50
+- move `DeviceProfile` and `OperationLog` to a local non-SwiftData store before
51
+  removing the launch container.
53 52
 
54 53
 ## Capture And Maintenance Services
55 54
 
@@ -64,7 +63,7 @@ These services still write/read legacy SwiftData transition models:
64 63
 Retirement path:
65 64
 - make capture persist archive observations first and expose only bridge ids
66 65
   while transition UI still exists;
67
-- move timeout learning and operation logging out of SwiftData;
66
+- move operation logging out of SwiftData;
68 67
 - delete legacy record repair once old SwiftData stores are no longer opened;
69 68
 - remove snapshot deletion/repair logic after archive/cache navigation replaces
70 69
   prototype snapshots.
@@ -90,22 +89,26 @@ Retirement path:
90 89
   queries plus archive ids;
91 90
 - replace detail navigation parameters from SwiftData models to observation/type
92 91
   DTOs;
93
-- move Settings device profile and timeout profile rows to local non-SwiftData
94
-  storage;
92
+- move Settings device profile rows to local non-SwiftData storage;
95 93
 - keep paged record drill-down and export paths on archive APIs.
96 94
 
97 95
 ## Removed During This Pass
98 96
 
99
-The following files no longer import SwiftData because they only use already
100
-declared app model types and do not need SwiftData APIs directly:
97
+The following SwiftData dependencies were removed from active flows:
101 98
 
102 99
 - `HealthProbe/Services/AnomalyDetector.swift`
103 100
 - `HealthProbe/Services/IntegrityService.swift`
101
+- `HealthProbe/Models/MetricTimeoutProfile.swift` was deleted.
102
+- Metric timeout learning/calibration now uses
103
+  `HealthProbe/Utilities/LocalMetricTimeoutProfile.swift`, a Codable local
104
+  store outside `ModelContainer`.
105
+- `SettingsView`, `DashboardView`, and `HealthKitService` read/write timeout
106
+  calibration through the local store.
104 107
 
105 108
 ## Next Recommended Slices
106 109
 
107
-1. Replace Settings local rows (`DeviceProfile`, `MetricTimeoutProfile`) with a
108
-   small non-SwiftData local store so Settings no longer requires `@Query`.
110
+1. Replace Settings local rows (`DeviceProfile`) with a small non-SwiftData
111
+   local store so Settings no longer requires `@Query` for local configuration.
109 112
 2. Replace `ContentView` preview/container dependency after tab roots stop using
110 113
    `@Query`.
111 114
 3. Move `DashboardView` capture review actions away from `ModelContext`.
+2 -2
HealthProbe/HealthProbeApp.swift
@@ -33,7 +33,7 @@ struct HealthProbeApp: App {
33 33
         let fullSchema = Schema([
34 34
             HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self,
35 35
             SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self,
36
-            OperationLog.self, DeviceProfile.self, MetricTimeoutProfile.self,
36
+            OperationLog.self, DeviceProfile.self,
37 37
         ])
38 38
 
39 39
         let appSupportURL = URL.applicationSupportDirectory
@@ -44,7 +44,7 @@ struct HealthProbeApp: App {
44 44
             HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self,
45 45
             SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self,
46 46
         ])
47
-        let localModels = Schema([OperationLog.self, DeviceProfile.self, MetricTimeoutProfile.self])
47
+        let localModels = Schema([OperationLog.self, DeviceProfile.self])
48 48
 
49 49
         let uiCacheConfig = ModelConfiguration(
50 50
             "ui-cache",
+0 -140
HealthProbe/Models/MetricTimeoutProfile.swift
@@ -1,140 +0,0 @@
1
-import Foundation
2
-import SwiftData
3
-
4
-@Model final class MetricTimeoutProfile {
5
-    static let defaultInitialTimeout: TimeInterval = 15
6
-    static let maximumTimeout: TimeInterval = 120
7
-    private static let sampleLimit = 30
8
-    private static let p95MinimumSampleCount = 5
9
-
10
-    var id: UUID = UUID()
11
-    var metricIdentifier: String = ""
12
-    var displayName: String = ""
13
-    var lastSuccessfulElapsed: TimeInterval = 0
14
-    var averageSuccessfulElapsed: TimeInterval = 0
15
-    var p95SuccessfulElapsed: TimeInterval = 0
16
-    var lastTimeoutElapsed: TimeInterval = 0
17
-    var timeoutCount: Int = 0
18
-    var successCount: Int = 0
19
-    var lastUpdatedAt: Date = Date.distantPast
20
-    var currentTimeout: TimeInterval = MetricTimeoutProfile.defaultInitialTimeout
21
-    var isManuallyOverridden: Bool = false
22
-    var manualTimeoutValue: TimeInterval = MetricTimeoutProfile.defaultInitialTimeout
23
-    var successfulElapsedSamplesJSON: String = "[]"
24
-
25
-    init(metricIdentifier: String, displayName: String) {
26
-        self.id = UUID()
27
-        self.metricIdentifier = metricIdentifier
28
-        self.displayName = displayName
29
-        self.currentTimeout = Self.defaultInitialTimeout
30
-        self.manualTimeoutValue = Self.defaultInitialTimeout
31
-    }
32
-}
33
-
34
-extension MetricTimeoutProfile {
35
-    var effectiveTimeout: TimeInterval {
36
-        clamp(isManuallyOverridden ? manualTimeoutValue : currentTimeout)
37
-    }
38
-
39
-    var timeoutMode: String {
40
-        if isManuallyOverridden { return "manual" }
41
-        return successCount == 0 && timeoutCount == 0 ? "default" : "adaptive"
42
-    }
43
-
44
-    var hasP95: Bool {
45
-        successSamples.count >= Self.p95MinimumSampleCount
46
-    }
47
-
48
-    var p95SuccessfulElapsedIfAvailable: TimeInterval? {
49
-        hasP95 ? p95SuccessfulElapsed : nil
50
-    }
51
-
52
-    var suggestedRetryTimeout: TimeInterval {
53
-        clamp(max(effectiveTimeout * 2, lastSuccessfulElapsed * 2, Self.defaultInitialTimeout))
54
-    }
55
-
56
-    func recordSuccess(elapsed: TimeInterval, displayName: String) {
57
-        self.displayName = displayName
58
-        lastSuccessfulElapsed = elapsed
59
-        successCount += 1
60
-        lastUpdatedAt = Date()
61
-
62
-        var samples = successSamples
63
-        samples.append(elapsed)
64
-        if samples.count > Self.sampleLimit {
65
-            samples.removeFirst(samples.count - Self.sampleLimit)
66
-        }
67
-        successSamples = samples
68
-
69
-        averageSuccessfulElapsed = samples.reduce(0, +) / Double(samples.count)
70
-        p95SuccessfulElapsed = Self.percentile95(samples)
71
-        currentTimeout = automaticTimeout(from: samples)
72
-    }
73
-
74
-    func recordTimeout(elapsed: TimeInterval, displayName: String) {
75
-        self.displayName = displayName
76
-        lastTimeoutElapsed = elapsed
77
-        timeoutCount += 1
78
-        lastUpdatedAt = Date()
79
-
80
-        guard !isManuallyOverridden else { return }
81
-        let progressive = max(currentTimeout * 1.5, elapsed * 2, Self.defaultInitialTimeout)
82
-        currentTimeout = clamp(progressive)
83
-    }
84
-
85
-    func resetLearning(displayName: String? = nil) {
86
-        if let displayName { self.displayName = displayName }
87
-        lastSuccessfulElapsed = 0
88
-        averageSuccessfulElapsed = 0
89
-        p95SuccessfulElapsed = 0
90
-        lastTimeoutElapsed = 0
91
-        timeoutCount = 0
92
-        successCount = 0
93
-        lastUpdatedAt = Date()
94
-        currentTimeout = Self.defaultInitialTimeout
95
-        isManuallyOverridden = false
96
-        manualTimeoutValue = Self.defaultInitialTimeout
97
-        successfulElapsedSamplesJSON = "[]"
98
-    }
99
-
100
-    func setManualTimeout(_ value: TimeInterval) {
101
-        manualTimeoutValue = clamp(value)
102
-        isManuallyOverridden = true
103
-        lastUpdatedAt = Date()
104
-    }
105
-
106
-    func returnToAutomatic() {
107
-        isManuallyOverridden = false
108
-        lastUpdatedAt = Date()
109
-    }
110
-
111
-    private var successSamples: [TimeInterval] {
112
-        get {
113
-            guard let data = successfulElapsedSamplesJSON.data(using: .utf8),
114
-                  let values = try? JSONDecoder().decode([TimeInterval].self, from: data) else {
115
-                return []
116
-            }
117
-            return values
118
-        }
119
-        set {
120
-            successfulElapsedSamplesJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]"
121
-        }
122
-    }
123
-
124
-    private func automaticTimeout(from samples: [TimeInterval]) -> TimeInterval {
125
-        let basis = samples.count >= Self.p95MinimumSampleCount ? Self.percentile95(samples) : samples.max() ?? 0
126
-        return clamp(max(Self.defaultInitialTimeout, basis * 2, currentTimeout * 0.9))
127
-    }
128
-
129
-    private func clamp(_ value: TimeInterval) -> TimeInterval {
130
-        min(max(value, Self.defaultInitialTimeout), Self.maximumTimeout)
131
-    }
132
-
133
-    private static func percentile95(_ samples: [TimeInterval]) -> TimeInterval {
134
-        guard !samples.isEmpty else { return 0 }
135
-        let sorted = samples.sorted()
136
-        let rawIndex = Int(ceil(Double(sorted.count) * 0.95)) - 1
137
-        let index = min(max(rawIndex, 0), sorted.count - 1)
138
-        return sorted[index]
139
-    }
140
-}
+12 -23
HealthProbe/Services/HealthKitService.swift
@@ -46,8 +46,8 @@ final class HealthKitService {
46 46
 
47 47
     static let allTypes: [MonitoredType] = buildAllTypes()
48 48
 
49
-    static let defaultInitialTimeoutSeconds: TimeInterval = MetricTimeoutProfile.defaultInitialTimeout
50
-    static let maximumTimeoutSeconds: TimeInterval = MetricTimeoutProfile.maximumTimeout
49
+    static let defaultInitialTimeoutSeconds: TimeInterval = LocalMetricTimeoutProfile.defaultInitialTimeout
50
+    static let maximumTimeoutSeconds: TimeInterval = LocalMetricTimeoutProfile.maximumTimeout
51 51
     static let fullHistoryImportTimeoutSeconds: TimeInterval = 30 * 60
52 52
     // Prevents 3N simultaneous HK queries from exhausting resources at N=20 types.
53 53
     static let maxConcurrentTypeFetches = 6
@@ -611,7 +611,7 @@ final class HealthKitService {
611 611
         typeCounts.reserveCapacity(active.count)
612 612
 
613 613
         for monitoredType in active {
614
-            let profile = timeoutProfile(for: monitoredType, context: context)
614
+            var profile = timeoutProfile(for: monitoredType)
615 615
             let timeout = timeoutForFetch(
616 616
                 profile: profile,
617 617
                 adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
@@ -625,7 +625,8 @@ final class HealthKitService {
625 625
                 archiveObservationID: archiveObservationID,
626 626
                 progress: progress
627 627
             )
628
-            updateTimeoutProfile(profile, with: result, monitoredType: monitoredType)
628
+            updateTimeoutProfile(&profile, with: result, monitoredType: monitoredType)
629
+            LocalMetricTimeoutProfileStore.save(profile)
629 630
             result.applyTimeoutProfile(profile)
630 631
             progress?.updateTimeoutProfile(from: profile, for: monitoredType.id)
631 632
             let typeCount = result.makeTypeCount()
@@ -637,7 +638,7 @@ final class HealthKitService {
637 638
 
638 639
     private func fetchTypeCountData(
639 640
         for monitoredType: MonitoredType,
640
-        timeoutProfile: MetricTimeoutProfile,
641
+        timeoutProfile: LocalMetricTimeoutProfile,
641 642
         timeoutSeconds: TimeInterval,
642 643
         previousTypeCount: TypeCount?,
643 644
         archiveObservationID: Int64,
@@ -1475,24 +1476,12 @@ final class HealthKitService {
1475 1476
 
1476 1477
     // MARK: - Timeout profiles
1477 1478
 
1478
-    private func timeoutProfile(for monitoredType: MonitoredType, context: ModelContext) -> MetricTimeoutProfile {
1479
-        let identifier = monitoredType.id
1480
-        var descriptor = FetchDescriptor<MetricTimeoutProfile>(
1481
-            predicate: #Predicate<MetricTimeoutProfile> { $0.metricIdentifier == identifier }
1482
-        )
1483
-        descriptor.fetchLimit = 1
1484
-        if let existing = try? context.fetch(descriptor).first {
1485
-            existing.displayName = monitoredType.displayName
1486
-            return existing
1487
-        }
1488
-
1489
-        let profile = MetricTimeoutProfile(metricIdentifier: monitoredType.id, displayName: monitoredType.displayName)
1490
-        context.insert(profile)
1491
-        return profile
1479
+    private func timeoutProfile(for monitoredType: MonitoredType) -> LocalMetricTimeoutProfile {
1480
+        LocalMetricTimeoutProfileStore.profile(for: monitoredType)
1492 1481
     }
1493 1482
 
1494 1483
     private func timeoutForFetch(
1495
-        profile: MetricTimeoutProfile,
1484
+        profile: LocalMetricTimeoutProfile,
1496 1485
         adaptiveTimeoutsEnabled: Bool,
1497 1486
         timeoutMultiplier: Double
1498 1487
     ) -> TimeInterval {
@@ -1501,7 +1490,7 @@ final class HealthKitService {
1501 1490
     }
1502 1491
 
1503 1492
     private func updateTimeoutProfile(
1504
-        _ profile: MetricTimeoutProfile,
1493
+        _ profile: inout LocalMetricTimeoutProfile,
1505 1494
         with result: TypeCountFetchResult,
1506 1495
         monitoredType: MonitoredType
1507 1496
     ) {
@@ -2083,7 +2072,7 @@ private struct TypeCountFetchResult: Sendable {
2083 2072
     var timeoutCount: Int = 0
2084 2073
     var successCount: Int = 0
2085 2074
 
2086
-    mutating func applyTimeoutProfile(_ profile: MetricTimeoutProfile) {
2075
+    mutating func applyTimeoutProfile(_ profile: LocalMetricTimeoutProfile) {
2087 2076
         timeoutMode = profile.timeoutMode
2088 2077
         lastSuccessfulElapsed = profile.lastSuccessfulElapsed
2089 2078
         learnedTimeout = profile.effectiveTimeout
@@ -2175,7 +2164,7 @@ private extension SnapshotFetchProgress {
2175 2164
         )
2176 2165
     }
2177 2166
 
2178
-    func updateTimeoutProfile(from profile: MetricTimeoutProfile, for typeID: String) {
2167
+    func updateTimeoutProfile(from profile: LocalMetricTimeoutProfile, for typeID: String) {
2179 2168
         updateTimeoutProfile(
2180 2169
             typeID,
2181 2170
             timeoutMode: profile.timeoutMode,
+169 -0
HealthProbe/Utilities/LocalMetricTimeoutProfile.swift
@@ -0,0 +1,169 @@
1
+import Foundation
2
+
3
+struct LocalMetricTimeoutProfile: Codable, Equatable, Identifiable, Sendable {
4
+    static let defaultInitialTimeout: TimeInterval = 15
5
+    static let maximumTimeout: TimeInterval = 120
6
+    private static let sampleLimit = 30
7
+    private static let p95MinimumSampleCount = 5
8
+
9
+    var id: String { metricIdentifier }
10
+    var metricIdentifier: String
11
+    var displayName: String
12
+    var lastSuccessfulElapsed: TimeInterval = 0
13
+    var averageSuccessfulElapsed: TimeInterval = 0
14
+    var p95SuccessfulElapsed: TimeInterval = 0
15
+    var lastTimeoutElapsed: TimeInterval = 0
16
+    var timeoutCount: Int = 0
17
+    var successCount: Int = 0
18
+    var lastUpdatedAt: Date = Date.distantPast
19
+    var currentTimeout: TimeInterval = LocalMetricTimeoutProfile.defaultInitialTimeout
20
+    var isManuallyOverridden: Bool = false
21
+    var manualTimeoutValue: TimeInterval = LocalMetricTimeoutProfile.defaultInitialTimeout
22
+    var successfulElapsedSamples: [TimeInterval] = []
23
+
24
+    init(metricIdentifier: String, displayName: String) {
25
+        self.metricIdentifier = metricIdentifier
26
+        self.displayName = displayName
27
+    }
28
+
29
+    var effectiveTimeout: TimeInterval {
30
+        clamp(isManuallyOverridden ? manualTimeoutValue : currentTimeout)
31
+    }
32
+
33
+    var timeoutMode: String {
34
+        if isManuallyOverridden { return "manual" }
35
+        return successCount == 0 && timeoutCount == 0 ? "default" : "adaptive"
36
+    }
37
+
38
+    var p95SuccessfulElapsedIfAvailable: TimeInterval? {
39
+        successfulElapsedSamples.count >= Self.p95MinimumSampleCount ? p95SuccessfulElapsed : nil
40
+    }
41
+
42
+    var suggestedRetryTimeout: TimeInterval {
43
+        clamp(max(effectiveTimeout * 2, lastSuccessfulElapsed * 2, Self.defaultInitialTimeout))
44
+    }
45
+
46
+    mutating func recordSuccess(elapsed: TimeInterval, displayName: String) {
47
+        self.displayName = displayName
48
+        lastSuccessfulElapsed = elapsed
49
+        successCount += 1
50
+        lastUpdatedAt = Date()
51
+
52
+        successfulElapsedSamples.append(elapsed)
53
+        if successfulElapsedSamples.count > Self.sampleLimit {
54
+            successfulElapsedSamples.removeFirst(successfulElapsedSamples.count - Self.sampleLimit)
55
+        }
56
+
57
+        averageSuccessfulElapsed = successfulElapsedSamples.reduce(0, +) / Double(successfulElapsedSamples.count)
58
+        p95SuccessfulElapsed = Self.percentile95(successfulElapsedSamples)
59
+        currentTimeout = automaticTimeout(from: successfulElapsedSamples)
60
+    }
61
+
62
+    mutating func recordTimeout(elapsed: TimeInterval, displayName: String) {
63
+        self.displayName = displayName
64
+        lastTimeoutElapsed = elapsed
65
+        timeoutCount += 1
66
+        lastUpdatedAt = Date()
67
+
68
+        guard !isManuallyOverridden else { return }
69
+        let progressive = max(currentTimeout * 1.5, elapsed * 2, Self.defaultInitialTimeout)
70
+        currentTimeout = clamp(progressive)
71
+    }
72
+
73
+    mutating func resetLearning(displayName: String? = nil) {
74
+        if let displayName { self.displayName = displayName }
75
+        lastSuccessfulElapsed = 0
76
+        averageSuccessfulElapsed = 0
77
+        p95SuccessfulElapsed = 0
78
+        lastTimeoutElapsed = 0
79
+        timeoutCount = 0
80
+        successCount = 0
81
+        lastUpdatedAt = Date()
82
+        currentTimeout = Self.defaultInitialTimeout
83
+        isManuallyOverridden = false
84
+        manualTimeoutValue = Self.defaultInitialTimeout
85
+        successfulElapsedSamples = []
86
+    }
87
+
88
+    mutating func setManualTimeout(_ value: TimeInterval) {
89
+        manualTimeoutValue = clamp(value)
90
+        isManuallyOverridden = true
91
+        lastUpdatedAt = Date()
92
+    }
93
+
94
+    mutating func returnToAutomatic() {
95
+        isManuallyOverridden = false
96
+        lastUpdatedAt = Date()
97
+    }
98
+
99
+    private func automaticTimeout(from samples: [TimeInterval]) -> TimeInterval {
100
+        let basis = samples.count >= Self.p95MinimumSampleCount ? Self.percentile95(samples) : samples.max() ?? 0
101
+        return clamp(max(Self.defaultInitialTimeout, basis * 2, currentTimeout * 0.9))
102
+    }
103
+
104
+    private func clamp(_ value: TimeInterval) -> TimeInterval {
105
+        min(max(value, Self.defaultInitialTimeout), Self.maximumTimeout)
106
+    }
107
+
108
+    private static func percentile95(_ samples: [TimeInterval]) -> TimeInterval {
109
+        guard !samples.isEmpty else { return 0 }
110
+        let sorted = samples.sorted()
111
+        let rawIndex = Int(ceil(Double(sorted.count) * 0.95)) - 1
112
+        let index = min(max(rawIndex, 0), sorted.count - 1)
113
+        return sorted[index]
114
+    }
115
+}
116
+
117
+enum LocalMetricTimeoutProfileStore {
118
+    private static let key = "hp_localMetricTimeoutProfiles"
119
+
120
+    static func allProfiles(for monitoredTypes: [MonitoredType] = HealthKitService.allTypes) -> [LocalMetricTimeoutProfile] {
121
+        let existing = Dictionary(uniqueKeysWithValues: load().map { ($0.metricIdentifier, $0) })
122
+        return monitoredTypes.map { type in
123
+            var profile = existing[type.id] ?? LocalMetricTimeoutProfile(metricIdentifier: type.id, displayName: type.displayName)
124
+            profile.displayName = type.displayName
125
+            return profile
126
+        }
127
+        .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
128
+    }
129
+
130
+    static func profile(for monitoredType: MonitoredType) -> LocalMetricTimeoutProfile {
131
+        allProfiles(for: [monitoredType]).first ?? LocalMetricTimeoutProfile(
132
+            metricIdentifier: monitoredType.id,
133
+            displayName: monitoredType.displayName
134
+        )
135
+    }
136
+
137
+    static func save(_ profile: LocalMetricTimeoutProfile) {
138
+        var profiles = load()
139
+        if let index = profiles.firstIndex(where: { $0.metricIdentifier == profile.metricIdentifier }) {
140
+            profiles[index] = profile
141
+        } else {
142
+            profiles.append(profile)
143
+        }
144
+        save(profiles)
145
+    }
146
+
147
+    static func resetAll(monitoredTypes: [MonitoredType] = HealthKitService.allTypes) {
148
+        let profiles = monitoredTypes.map { type in
149
+            LocalMetricTimeoutProfile(metricIdentifier: type.id, displayName: type.displayName)
150
+        }
151
+        save(profiles)
152
+    }
153
+
154
+    static func removeAll() {
155
+        UserDefaults.standard.removeObject(forKey: key)
156
+    }
157
+
158
+    private static func load() -> [LocalMetricTimeoutProfile] {
159
+        guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
160
+        return (try? JSONDecoder().decode([LocalMetricTimeoutProfile].self, from: data)) ?? []
161
+    }
162
+
163
+    private static func save(_ profiles: [LocalMetricTimeoutProfile]) {
164
+        let sorted = profiles.sorted {
165
+            $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
166
+        }
167
+        UserDefaults.standard.set(try? JSONEncoder().encode(sorted), forKey: key)
168
+    }
169
+}
+3 -3
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -373,8 +373,8 @@ struct DashboardView: View {
373 373
         lines.append("")
374 374
         lines.append("CONFIGURATION")
375 375
         lines.append("adaptiveTimeoutsEnabled: \(progress.adaptiveTimeoutsEnabled ? "true" : "false")")
376
-        lines.append("defaultInitialTimeout:   \(formatDuration(MetricTimeoutProfile.defaultInitialTimeout))")
377
-        lines.append("maximumTimeout:          \(formatDuration(MetricTimeoutProfile.maximumTimeout))")
376
+        lines.append("defaultInitialTimeout:   \(formatDuration(LocalMetricTimeoutProfile.defaultInitialTimeout))")
377
+        lines.append("maximumTimeout:          \(formatDuration(LocalMetricTimeoutProfile.maximumTimeout))")
378 378
         lines.append("maxConcurrentTypeFetches: \(progress.maxConcurrentTypeFetches)")
379 379
         lines.append("")
380 380
         lines.append("CHAIN/INTEGRITY CONTEXT")
@@ -732,7 +732,7 @@ struct DashboardView: View {
732 732
                                         Text("Observed needed: ~\(formatDuration(type.learnedTimeout))")
733 733
                                             .font(.caption).foregroundStyle(.secondary)
734 734
                                     }
735
-                                    Text("Suggested retry: \(type.suggestedRetryTimeout > 0 ? formatDuration(type.suggestedRetryTimeout) : formatDuration(MetricTimeoutProfile.maximumTimeout))")
735
+                                    Text("Suggested retry: \(type.suggestedRetryTimeout > 0 ? formatDuration(type.suggestedRetryTimeout) : formatDuration(LocalMetricTimeoutProfile.maximumTimeout))")
736 736
                                         .font(.caption).foregroundStyle(.secondary)
737 737
                                 }
738 738
                             }
+57 -23
HealthProbe/Views/Settings/SettingsView.swift
@@ -8,11 +8,11 @@ struct SettingsView: View {
8 8
     @Environment(AppSettings.self) private var appSettings
9 9
     @Query private var snapshots: [HealthSnapshot]
10 10
     @Query private var deviceProfiles: [DeviceProfile]
11
-    @Query(sort: \MetricTimeoutProfile.displayName) private var timeoutProfiles: [MetricTimeoutProfile]
12 11
     @AppStorage("checkFrequencyHours") private var checkFrequencyHours: Int = 6
13 12
     @State private var showDeleteConfirm = false
14 13
     @State private var showRepairLegacyRecordsConfirm = false
15 14
     @State private var dataMaintenanceMessage: String?
15
+    @State private var timeoutProfiles: [LocalMetricTimeoutProfile] = []
16 16
 
17 17
     private var currentDeviceID: String {
18 18
         AppSettings.currentDeviceID
@@ -36,7 +36,7 @@ struct SettingsView: View {
36 36
             .navigationTitle("Settings")
37 37
             .onAppear {
38 38
                 ensureCurrentDeviceProfile()
39
-                ensureTimeoutProfiles()
39
+                loadTimeoutProfiles()
40 40
             }
41 41
             .confirmationDialog(
42 42
                 "Delete All Audit Data",
@@ -139,17 +139,23 @@ struct SettingsView: View {
139 139
             ))
140 140
 
141 141
             InfoRow(label: "Default Initial Timeout") {
142
-                Text(formatDuration(MetricTimeoutProfile.defaultInitialTimeout))
142
+                Text(formatDuration(LocalMetricTimeoutProfile.defaultInitialTimeout))
143 143
                     .foregroundStyle(.secondary)
144 144
             }
145 145
 
146 146
             InfoRow(label: "Maximum Timeout") {
147
-                Text(formatDuration(MetricTimeoutProfile.maximumTimeout))
147
+                Text(formatDuration(LocalMetricTimeoutProfile.maximumTimeout))
148 148
                     .foregroundStyle(.secondary)
149 149
             }
150 150
 
151 151
             ForEach(timeoutProfiles) { profile in
152
-                TimeoutProfileRow(profile: profile)
152
+                TimeoutProfileRow(
153
+                    profile: profile,
154
+                    onManualTimeoutChanged: { setManualTimeout(profile.metricIdentifier, value: $0) },
155
+                    onSetManual: { setManualTimeout(profile.metricIdentifier, value: profile.effectiveTimeout) },
156
+                    onAutomatic: { returnTimeoutProfileToAutomatic(profile.metricIdentifier) },
157
+                    onReset: { resetTimeoutProfile(profile.metricIdentifier) }
158
+                )
153 159
             }
154 160
 
155 161
             Button(role: .destructive) {
@@ -220,19 +226,43 @@ struct SettingsView: View {
220 226
         try? modelContext.save()
221 227
     }
222 228
 
223
-    private func ensureTimeoutProfiles() {
224
-        let existingIDs = Set(timeoutProfiles.map(\.metricIdentifier))
225
-        for type in HealthKitService.allTypes where !existingIDs.contains(type.id) {
226
-            modelContext.insert(MetricTimeoutProfile(metricIdentifier: type.id, displayName: type.displayName))
227
-        }
228
-        try? modelContext.save()
229
+    private func loadTimeoutProfiles() {
230
+        timeoutProfiles = LocalMetricTimeoutProfileStore.allProfiles()
229 231
     }
230 232
 
231 233
     private func resetAllTimeoutProfiles() {
232
-        for profile in timeoutProfiles {
234
+        LocalMetricTimeoutProfileStore.resetAll()
235
+        loadTimeoutProfiles()
236
+    }
237
+
238
+    private func setManualTimeout(_ metricIdentifier: String, value: TimeInterval) {
239
+        updateTimeoutProfile(metricIdentifier) { profile in
240
+            profile.setManualTimeout(value)
241
+        }
242
+    }
243
+
244
+    private func returnTimeoutProfileToAutomatic(_ metricIdentifier: String) {
245
+        updateTimeoutProfile(metricIdentifier) { profile in
246
+            profile.returnToAutomatic()
247
+        }
248
+    }
249
+
250
+    private func resetTimeoutProfile(_ metricIdentifier: String) {
251
+        updateTimeoutProfile(metricIdentifier) { profile in
233 252
             profile.resetLearning()
234 253
         }
235
-        try? modelContext.save()
254
+    }
255
+
256
+    private func updateTimeoutProfile(
257
+        _ metricIdentifier: String,
258
+        mutate: (inout LocalMetricTimeoutProfile) -> Void
259
+    ) {
260
+        guard let index = timeoutProfiles.firstIndex(where: { $0.metricIdentifier == metricIdentifier }) else {
261
+            return
262
+        }
263
+        mutate(&timeoutProfiles[index])
264
+        LocalMetricTimeoutProfileStore.save(timeoutProfiles[index])
265
+        loadTimeoutProfiles()
236 266
     }
237 267
 
238 268
     private func deleteAllData() {
@@ -278,7 +308,11 @@ private struct TypeToggleRow: View {
278 308
 }
279 309
 
280 310
 private struct TimeoutProfileRow: View {
281
-    @Bindable var profile: MetricTimeoutProfile
311
+    let profile: LocalMetricTimeoutProfile
312
+    let onManualTimeoutChanged: (TimeInterval) -> Void
313
+    let onSetManual: () -> Void
314
+    let onAutomatic: () -> Void
315
+    let onReset: () -> Void
282 316
 
283 317
     var body: some View {
284 318
         VStack(alignment: .leading, spacing: 8) {
@@ -327,24 +361,24 @@ private struct TimeoutProfileRow: View {
327 361
             if profile.isManuallyOverridden {
328 362
                 Stepper(
329 363
                     "Manual \(formatDuration(profile.manualTimeoutValue))",
330
-                    value: $profile.manualTimeoutValue,
331
-                    in: MetricTimeoutProfile.defaultInitialTimeout...MetricTimeoutProfile.maximumTimeout,
364
+                    value: Binding(
365
+                        get: { profile.manualTimeoutValue },
366
+                        set: onManualTimeoutChanged
367
+                    ),
368
+                    in: LocalMetricTimeoutProfile.defaultInitialTimeout...LocalMetricTimeoutProfile.maximumTimeout,
332 369
                     step: 5
333 370
                 )
334 371
                 .font(.caption)
335
-                .onChange(of: profile.manualTimeoutValue) { _, newValue in
336
-                    profile.setManualTimeout(newValue)
337
-                }
338 372
             }
339 373
 
340 374
             HStack {
341 375
                 Button("Set Manual") {
342
-                    profile.setManualTimeout(profile.effectiveTimeout)
376
+                    onSetManual()
343 377
                 }
344 378
                 .buttonStyle(.borderless)
345 379
 
346 380
                 Button("Automatic") {
347
-                    profile.returnToAutomatic()
381
+                    onAutomatic()
348 382
                 }
349 383
                 .buttonStyle(.borderless)
350 384
                 .disabled(!profile.isManuallyOverridden)
@@ -352,7 +386,7 @@ private struct TimeoutProfileRow: View {
352 386
                 Spacer()
353 387
 
354 388
                 Button("Reset", role: .destructive) {
355
-                    profile.resetLearning()
389
+                    onReset()
356 390
                 }
357 391
                 .buttonStyle(.borderless)
358 392
             }
@@ -386,6 +420,6 @@ private func formatDuration(_ seconds: TimeInterval) -> String {
386 420
 
387 421
 #Preview {
388 422
     SettingsView()
389
-        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self, MetricTimeoutProfile.self], inMemory: true)
423
+        .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true)
390 424
         .environment(AppSettings())
391 425
 }
+39 -0
HealthProbeTests/LocalMetricTimeoutProfileTests.swift
@@ -0,0 +1,39 @@
1
+import XCTest
2
+@testable import HealthProbe
3
+
4
+final class LocalMetricTimeoutProfileTests: XCTestCase {
5
+    override func tearDown() {
6
+        LocalMetricTimeoutProfileStore.removeAll()
7
+        super.tearDown()
8
+    }
9
+
10
+    func testRecordsSuccessAndPersistsProfile() {
11
+        var profile = LocalMetricTimeoutProfile(metricIdentifier: "step_count", displayName: "Step Count")
12
+
13
+        profile.recordSuccess(elapsed: 12, displayName: "Step Count")
14
+        LocalMetricTimeoutProfileStore.save(profile)
15
+
16
+        let loaded = LocalMetricTimeoutProfileStore.profile(for: MonitoredType(
17
+            id: "step_count",
18
+            displayName: "Step Count",
19
+            category: .activity,
20
+            isEnabledByDefault: true,
21
+            objectType: nil
22
+        ))
23
+        XCTAssertEqual(loaded.successCount, 1)
24
+        XCTAssertEqual(loaded.lastSuccessfulElapsed, 12)
25
+        XCTAssertEqual(loaded.timeoutMode, "adaptive")
26
+    }
27
+
28
+    func testManualTimeoutIsClampedAndResettable() {
29
+        var profile = LocalMetricTimeoutProfile(metricIdentifier: "heart_rate", displayName: "Heart Rate")
30
+
31
+        profile.setManualTimeout(1_000)
32
+        XCTAssertEqual(profile.effectiveTimeout, LocalMetricTimeoutProfile.maximumTimeout)
33
+        XCTAssertEqual(profile.timeoutMode, "manual")
34
+
35
+        profile.resetLearning()
36
+        XCTAssertEqual(profile.effectiveTimeout, LocalMetricTimeoutProfile.defaultInitialTimeout)
37
+        XCTAssertEqual(profile.timeoutMode, "default")
38
+    }
39
+}