Showing 6 changed files with 187 additions and 6 deletions
+4 -2
HealthProbe/Doc/00-agent-guides/AGENTS.md
@@ -196,8 +196,10 @@ final class TypeDistributionBin {
196 196
 // Interface updated 2026-05-23 — see AGENTS.md
197 197
 // HealthArchiveStore exposes SQL-first observation diff APIs:
198 198
 // diffSummary(_:) returns appeared/disappeared/representationChanged counts and
199
-// diffRecords(_:) returns a paged record list for one change kind. UI/cache agents
200
-// should consume these APIs instead of loading full observation record sets.
199
+// diffRecords(_:) returns a paged record list for one change kind.
200
+// aggregateComparison(_:) compares materialized daily aggregates between two
201
+// observations. UI/cache agents should consume these APIs instead of loading full
202
+// observation record sets.
201 203
 
202 204
 // Storage objective updated 2026-05-23 — see AGENTS.md
203 205
 // Recurring complete snapshots are out of scope for the target architecture.
+3 -3
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -25,7 +25,7 @@ There are no real deployments, only test installations. Existing prototype datab
25 25
 |------|----------------|--------------------|
26 26
 | Product docs | Updated | Keep `HealthProbe/Doc/README.md` as canonical index |
27 27
 | HealthKit capture | Prototype exists | Adapt capture to write differential SQLite observations first |
28
-| SQLite archive | Archive v2 schema, differential write path, daily aggregate rebuilds, integrity report, v2 record reads, initial SQL diff/count APIs, and XCTest coverage are in place; legacy write mirror still exists | Add aggregate/provenance SQL analysis, large synthetic-data tests, then retire `archive_samples` |
28
+| SQLite archive | Archive v2 schema, differential write path, daily aggregate rebuilds, integrity report, v2 record reads, initial SQL diff/count/aggregate APIs, and XCTest coverage are in place; legacy write mirror still exists | Add provenance and consolidation-evidence SQL analysis, large synthetic-data tests, then retire `archive_samples` |
29 29
 | Core Data cache | Not implemented | Add rebuildable cache for expensive counts, summaries, report metadata, UI state |
30 30
 | SwiftData cache | Exists | Treat as disposable prototype data; reset/ignore during v2 transition |
31 31
 | UI | Prototype exists | Reframe screens around observations, diffs, export, archive status |
@@ -38,7 +38,7 @@ There are no real deployments, only test installations. Existing prototype datab
38 38
 
39 39
 Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.md).
40 40
 
41
-1. Add aggregate/provenance/consolidation evidence queries on top of the SQLite archive.
41
+1. Add provenance and consolidation-evidence queries on top of the SQLite archive.
42 42
 2. Expand the synthetic large-data test harness for diff/export memory behavior.
43 43
 3. Add Core Data UI/report cache and rebuild pipeline.
44 44
 4. Replace SwiftData UI dependencies with Core Data/cache DTOs.
@@ -54,7 +54,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
54 54
 - Current archive schema is not sufficient as the long-term source of truth.
55 55
 - Existing implementation may decode or cache too much data for low-end devices.
56 56
 - Old prototype database compatibility is no longer required.
57
-- Initial SQLite archive tests cover open/init/reset/idempotency and small observation diffs, but not yet large-volume diff/export behavior.
57
+- Initial SQLite archive tests cover open/init/reset/idempotency, small observation diffs, and materialized aggregate comparison, but not yet large-volume diff/export behavior.
58 58
 
59 59
 ## Verification Checklist
60 60
 
+1 -1
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -149,7 +149,7 @@ Checklist:
149 149
 - [x] Implement disappeared query between observations.
150 150
 - [x] Implement representationChanged query between observations.
151 151
 - [x] Implement diff counts using temp tables or equivalent SQL-first strategy.
152
-- [ ] Implement aggregate comparison query.
152
+- [x] Implement aggregate comparison query.
153 153
 - [ ] Implement consolidation-likely evidence query.
154 154
 - [ ] Implement source/provenance breakdown query.
155 155
 - [ ] Add query timing/memory tests on synthetic large datasets.
+50 -0
HealthProbe/Services/Protocols/HealthArchiveStore.swift
@@ -9,6 +9,7 @@ protocol HealthArchiveStore {
9 9
     func records(for request: HealthArchiveRecordRequest) async throws -> [ArchivedHealthRecord]
10 10
     func diffSummary(_ request: HealthArchiveDiffRequest) async throws -> HealthArchiveDiffSummary
11 11
     func diffRecords(_ request: HealthArchiveDiffRecordRequest) async throws -> [ArchivedHealthRecord]
12
+    func aggregateComparison(_ request: HealthArchiveAggregateComparisonRequest) async throws -> [HealthArchiveAggregateComparisonRow]
12 13
     func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL
13 14
     func checkIntegrity() async throws -> HealthArchiveIntegrityReport
14 15
 }
@@ -126,6 +127,55 @@ struct HealthArchiveDiffRecordRequest: Equatable, Sendable {
126 127
     }
127 128
 }
128 129
 
130
+struct HealthArchiveAggregateComparisonRequest: Equatable, Sendable {
131
+    let fromObservationID: Int64
132
+    let toObservationID: Int64
133
+    let sampleTypeIdentifier: String?
134
+    let afterBucketStart: Date?
135
+    let limit: Int?
136
+
137
+    init(
138
+        fromObservationID: Int64,
139
+        toObservationID: Int64,
140
+        sampleTypeIdentifier: String? = nil,
141
+        afterBucketStart: Date? = nil,
142
+        limit: Int? = nil
143
+    ) {
144
+        self.fromObservationID = fromObservationID
145
+        self.toObservationID = toObservationID
146
+        self.sampleTypeIdentifier = sampleTypeIdentifier
147
+        self.afterBucketStart = afterBucketStart
148
+        self.limit = limit
149
+    }
150
+}
151
+
152
+struct HealthArchiveAggregateComparisonRow: Equatable, Sendable {
153
+    let sampleTypeIdentifier: String
154
+    let bucketStart: Date
155
+    let bucketEnd: Date
156
+    let fromVisibleRecordCount: Int
157
+    let toVisibleRecordCount: Int
158
+    let fromValueSum: Double?
159
+    let toValueSum: Double?
160
+
161
+    var visibleRecordDelta: Int {
162
+        toVisibleRecordCount - fromVisibleRecordCount
163
+    }
164
+
165
+    var valueSumDelta: Double? {
166
+        switch (fromValueSum, toValueSum) {
167
+        case let (fromValueSum?, toValueSum?):
168
+            return toValueSum - fromValueSum
169
+        case let (nil, toValueSum?):
170
+            return toValueSum
171
+        case let (fromValueSum?, nil):
172
+            return -fromValueSum
173
+        case (nil, nil):
174
+            return nil
175
+        }
176
+    }
177
+}
178
+
129 179
 struct HealthArchiveReportRequest: Equatable, Sendable {
130 180
     let reportID: UUID
131 181
     let title: String
+100 -0
HealthProbe/Services/SQLiteHealthArchiveStore.swift
@@ -459,6 +459,106 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
459 459
         }
460 460
     }
461 461
 
462
+    func aggregateComparison(_ request: HealthArchiveAggregateComparisonRequest) async throws -> [HealthArchiveAggregateComparisonRow] {
463
+        let db = try openDatabase()
464
+        defer { sqlite3_close(db) }
465
+        try prepareSchemaIfNeeded(db)
466
+
467
+        var clauses: [String] = []
468
+        if request.sampleTypeIdentifier != nil {
469
+            clauses.append("t.type_identifier = ?")
470
+        }
471
+        if request.afterBucketStart != nil {
472
+            clauses.append("k.bucket_start > ?")
473
+        }
474
+        let whereClause = clauses.isEmpty ? "" : "WHERE \(clauses.joined(separator: " AND "))"
475
+        let limitClause = request.limit.map { "LIMIT \(max($0, 0))" } ?? ""
476
+        let sql = """
477
+        WITH from_aggregates AS (
478
+            SELECT
479
+                sample_type_id,
480
+                bucket_start,
481
+                bucket_end,
482
+                SUM(visible_record_count) AS visible_record_count,
483
+                SUM(value_sum) AS value_sum
484
+            FROM daily_type_aggregates
485
+            WHERE observation_id = ?
486
+            GROUP BY sample_type_id, bucket_start, bucket_end
487
+        ),
488
+        to_aggregates AS (
489
+            SELECT
490
+                sample_type_id,
491
+                bucket_start,
492
+                bucket_end,
493
+                SUM(visible_record_count) AS visible_record_count,
494
+                SUM(value_sum) AS value_sum
495
+            FROM daily_type_aggregates
496
+            WHERE observation_id = ?
497
+            GROUP BY sample_type_id, bucket_start, bucket_end
498
+        ),
499
+        aggregate_keys AS (
500
+            SELECT sample_type_id, bucket_start, bucket_end FROM from_aggregates
501
+            UNION
502
+            SELECT sample_type_id, bucket_start, bucket_end FROM to_aggregates
503
+        )
504
+        SELECT
505
+            t.type_identifier,
506
+            k.bucket_start,
507
+            k.bucket_end,
508
+            COALESCE(f.visible_record_count, 0) AS from_visible_record_count,
509
+            COALESCE(ta.visible_record_count, 0) AS to_visible_record_count,
510
+            f.value_sum,
511
+            ta.value_sum
512
+        FROM aggregate_keys k
513
+        JOIN sample_types t ON t.id = k.sample_type_id
514
+        LEFT JOIN from_aggregates f
515
+          ON f.sample_type_id = k.sample_type_id
516
+         AND f.bucket_start = k.bucket_start
517
+         AND f.bucket_end = k.bucket_end
518
+        LEFT JOIN to_aggregates ta
519
+          ON ta.sample_type_id = k.sample_type_id
520
+         AND ta.bucket_start = k.bucket_start
521
+         AND ta.bucket_end = k.bucket_end
522
+        \(whereClause)
523
+        ORDER BY k.bucket_start ASC, t.type_identifier ASC
524
+        \(limitClause)
525
+        """
526
+
527
+        return try withStatement(sql, db: db) { statement in
528
+            var index: Int32 = 1
529
+            bindInt64(request.fromObservationID, to: index, in: statement)
530
+            index += 1
531
+            bindInt64(request.toObservationID, to: index, in: statement)
532
+            index += 1
533
+            if let sampleTypeIdentifier = request.sampleTypeIdentifier {
534
+                bindText(sampleTypeIdentifier, to: index, in: statement)
535
+                index += 1
536
+            }
537
+            if let afterBucketStart = request.afterBucketStart {
538
+                sqlite3_bind_double(statement, index, afterBucketStart.timeIntervalSince1970)
539
+                index += 1
540
+            }
541
+
542
+            var rows: [HealthArchiveAggregateComparisonRow] = []
543
+            while sqlite3_step(statement) == SQLITE_ROW {
544
+                guard let bucketStart = columnUnixDate(statement, 1),
545
+                      let bucketEnd = columnUnixDate(statement, 2) else {
546
+                    continue
547
+                }
548
+                rows.append(HealthArchiveAggregateComparisonRow(
549
+                    sampleTypeIdentifier: columnText(statement, 0) ?? "",
550
+                    bucketStart: bucketStart,
551
+                    bucketEnd: bucketEnd,
552
+                    fromVisibleRecordCount: columnInt(statement, 3) ?? 0,
553
+                    toVisibleRecordCount: columnInt(statement, 4) ?? 0,
554
+                    fromValueSum: columnDouble(statement, 5),
555
+                    toValueSum: columnDouble(statement, 6)
556
+                ))
557
+            }
558
+            return rows
559
+        }
560
+    }
561
+
462 562
     func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL {
463 563
         let recordRequest = HealthArchiveRecordRequest(
464 564
             sampleTypeIdentifier: request.typeIdentifierFilter,
+29 -0
HealthProbeTests/SQLiteHealthArchiveStoreTests.swift
@@ -130,6 +130,35 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
130 130
         XCTAssertNotNil(disappearedRecords.first?.disappearedAt)
131 131
     }
132 132
 
133
+    func testAggregateComparisonUsesMaterializedDailyAggregates() async throws {
134
+        let url = databaseURL()
135
+        let store = SQLiteHealthArchiveStore(databaseURL: url)
136
+        let firstSample = makeStepCountSample(value: 42, start: 1_000)
137
+        let secondSample = makeStepCountSample(value: 7, start: 2_000)
138
+        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
139
+
140
+        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
141
+        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
142
+        let observationIDs = try observationIDs(at: url)
143
+        XCTAssertEqual(observationIDs.count, 2)
144
+
145
+        let rows = try await store.aggregateComparison(HealthArchiveAggregateComparisonRequest(
146
+            fromObservationID: observationIDs[0],
147
+            toObservationID: observationIDs[1],
148
+            sampleTypeIdentifier: typeIdentifier,
149
+            limit: 10
150
+        ))
151
+
152
+        XCTAssertEqual(rows.count, 1)
153
+        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
154
+        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 1)
155
+        XCTAssertEqual(rows.first?.toVisibleRecordCount, 2)
156
+        XCTAssertEqual(rows.first?.visibleRecordDelta, 1)
157
+        XCTAssertEqual(rows.first?.fromValueSum, 42)
158
+        XCTAssertEqual(rows.first?.toValueSum, 49)
159
+        XCTAssertEqual(rows.first?.valueSumDelta, 7)
160
+    }
161
+
133 162
     private func databaseURL() -> URL {
134 163
         temporaryDirectory.appending(path: "Archive.sqlite")
135 164
     }