@@ -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. |
@@ -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 |
|
@@ -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. |
@@ -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 |
@@ -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, |
@@ -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 |
} |