1 contributor
import Foundation
import SwiftData
import os.log
private let logger = Logger(subsystem: "ro.xdev.healthprobe", category: "DeltaService")
enum DeltaService {
@discardableResult
static func computeAndSave(current: HealthSnapshot, context: ModelContext) throws -> SnapshotDelta? {
// No previous snapshot → chain start, no delta to compute
guard let prevID = current.previousSnapshotID else { return nil }
let prevDescriptor = FetchDescriptor<HealthSnapshot>(
predicate: #Predicate<HealthSnapshot> { $0.id == prevID }
)
guard let previous = try context.fetch(prevDescriptor).first else {
logger.error("DeltaService: previousSnapshotID \(prevID) not found")
return nil
}
let prevByID = Dictionary(
uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
)
let currByID = Dictionary(
uniqueKeysWithValues: (current.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
)
let delta = SnapshotDelta(
fromSnapshotID: previous.id,
toSnapshotID: current.id,
deviceID: current.deviceID
)
delta.checksumBefore = HashService.snapshotChecksum(typeCounts: Array(prevByID.values))
delta.checksumAfter = HashService.snapshotChecksum(typeCounts: Array(currByID.values))
if current.isContentAlias,
current.contentEquivalentSnapshotID == previous.contentRepresentativeSnapshotID {
delta.typeDeltas = []
updateListSummary(for: delta, typeDeltas: [])
context.insert(delta)
try context.save()
return delta
}
let allTypeIDs = Set(prevByID.keys).union(currByID.keys)
var typeDeltas: [TypeDelta] = []
for typeID in allTypeIDs {
let prev = prevByID[typeID]
let curr = currByID[typeID]
if let prev,
let curr,
curr.contentEquivalentTypeCountID == prev.contentRepresentativeTypeCountID {
continue
}
let effectivePrev = historicalBaselinePreviousTypeCount(
typeID: typeID,
prev: prev,
curr: curr,
previousSnapshot: previous,
context: context
) ?? prev
let td = buildTypeDelta(
typeID: typeID,
prev: effectivePrev,
curr: curr,
previous: previous,
current: current
)
td.delta = delta
typeDeltas.append(td)
context.insert(td)
}
delta.typeDeltas = typeDeltas
updateListSummary(for: delta, typeDeltas: typeDeltas)
context.insert(delta)
try context.save()
return delta
}
@discardableResult
static func rebuildMissingListSummaries(context: ModelContext, maxCount: Int) throws -> Bool {
guard maxCount > 0 else { return false }
let summaryVersion = SnapshotDelta.currentListSummaryVersion
var descriptor = FetchDescriptor<SnapshotDelta>(
predicate: #Predicate<SnapshotDelta> { $0.listSummaryVersion < summaryVersion }
)
descriptor.fetchLimit = maxCount
let deltas = try context.fetch(descriptor)
guard !deltas.isEmpty else { return false }
for delta in deltas {
updateListSummary(for: delta, typeDeltas: delta.typeDeltas ?? [])
}
try context.save()
return true
}
// MARK: - Delta merge (for intermediate snapshot deletion)
// snapshotBefore and snapshotAfter are the real surrounding snapshots (N-1 and N+1).
// Their typeCounts are used to recompute fresh checksums.
static func mergeDeltas(
d1: SnapshotDelta,
d2: SnapshotDelta,
snapshotBefore: HealthSnapshot,
snapshotAfter: HealthSnapshot
) -> SnapshotDelta {
let merged = SnapshotDelta(
fromSnapshotID: d1.fromSnapshotID,
toSnapshotID: d2.toSnapshotID,
deviceID: d1.deviceID
)
// Always recompute from the actual surrounding snapshots — never copy old checksums
merged.checksumBefore = HashService.snapshotChecksum(typeCounts: snapshotBefore.typeCounts ?? [])
merged.checksumAfter = HashService.snapshotChecksum(typeCounts: snapshotAfter.typeCounts ?? [])
let d1Map = Dictionary(uniqueKeysWithValues: (d1.typeDeltas ?? []).map { ($0.typeIdentifier, $0) })
let d2Map = Dictionary(uniqueKeysWithValues: (d2.typeDeltas ?? []).map { ($0.typeIdentifier, $0) })
let allIDs = Set(d1Map.keys).union(d2Map.keys)
var mergedTypeDeltas: [TypeDelta] = []
for typeID in allIDs {
let td1 = d1Map[typeID]
let td2 = d2Map[typeID]
if let merged1 = td1, let merged2 = td2 {
// Type present in both deltas
if merged1.transition == .appeared && merged2.transition == .disappeared {
// Existed only in deleted snapshot N — remove from merged delta
continue
}
let td = mergeTypeDelta(d1td: merged1, d2td: merged2)
td.delta = merged
mergedTypeDeltas.append(td)
} else if let only1 = td1 {
let td = copyTypeDelta(only1)
td.delta = merged
mergedTypeDeltas.append(td)
} else if let only2 = td2 {
let td = copyTypeDelta(only2)
td.delta = merged
mergedTypeDeltas.append(td)
}
}
merged.typeDeltas = mergedTypeDeltas
updateListSummary(for: merged, typeDeltas: mergedTypeDeltas)
return merged
}
// MARK: - Private helpers
private static func buildTypeDelta(
typeID: String,
prev: TypeCount?,
curr: TypeCount?,
previous: HealthSnapshot,
current: HealthSnapshot
) -> TypeDelta {
let displayName = curr?.displayName ?? prev?.displayName ?? typeID
let td = TypeDelta(typeIdentifier: typeID, displayName: displayName)
td.qualityBefore = prev?.quality
td.qualityAfter = curr?.quality
let prevCount = prev?.count ?? 0
let currCount = curr?.count ?? 0
let prevHash = prev?.contentHash ?? ""
let currHash = curr?.contentHash ?? ""
if let prev, let curr {
// Type present in both snapshots
// If either count is -1, do not compute a numeric delta
if prev.count == -1 || curr.count == -1 {
td.countDelta = 0
} else {
td.countDelta = currCount - prevCount
}
td.hashBefore = prevHash
td.hashAfter = currHash
td.transition = (prevHash == currHash && prevCount == currCount) ? .unchanged : .changed
} else if let curr {
// Type appeared — missing in previous
td.countDelta = curr.count == -1 ? 0 : curr.count
td.hashBefore = ""
td.hashAfter = currHash
td.transition = .appeared
} else if let prev {
// Type disappeared — missing in current
td.countDelta = prev.count == -1 ? 0 : -prev.count
td.hashBefore = prevHash
td.hashAfter = ""
td.transition = .disappeared
}
// Reason assignment — explicit priority order (highest wins):
// 1. authorizationChanged — type quality == .unauthorized
// 2. unsupported — type cannot be instantiated by HK factory
// 3. registryChanged — type appeared/disappeared AND monitoredTypeSetHash changed
// 4. unknown — type quality == .failed for other reasons
// 5. normal — none of the above
td.reason = assignReason(
prevQuality: prev?.quality,
currQuality: curr?.quality,
prevUnsupported: prev?.isUnsupported ?? false,
currUnsupported: curr?.isUnsupported ?? false,
transition: td.transition,
typeSetHashChanged: previous.monitoredTypeSetHash != current.monitoredTypeSetHash
)
// YearlyCount timezone guard
if previous.yearlyCountTimezoneIdentifier != current.yearlyCountTimezoneIdentifier {
td.yearlyCountNote = "yearly attribution unreliable — timezone changed between snapshots"
}
return td
}
private static func updateListSummary(for delta: SnapshotDelta, typeDeltas: [TypeDelta]) {
var absoluteRecordChangeCount = 0
var changedMetricCount = 0
var appearedMetricCount = 0
var disappearedMetricCount = 0
for typeDelta in typeDeltas {
switch typeDelta.transition {
case .unchanged:
continue
case .changed:
changedMetricCount += 1
if typeDelta.qualityBefore == .complete,
typeDelta.qualityAfter == .complete {
absoluteRecordChangeCount += abs(typeDelta.countDelta)
}
case .appeared:
appearedMetricCount += 1
case .disappeared:
disappearedMetricCount += 1
}
}
delta.absoluteRecordChangeCount = absoluteRecordChangeCount
delta.changedMetricCount = changedMetricCount
delta.appearedMetricCount = appearedMetricCount
delta.disappearedMetricCount = disappearedMetricCount
delta.listSummaryVersion = SnapshotDelta.currentListSummaryVersion
}
private static func historicalBaselinePreviousTypeCount(
typeID: String,
prev: TypeCount?,
curr: TypeCount?,
previousSnapshot: HealthSnapshot,
context: ModelContext
) -> TypeCount? {
guard let prev,
let curr,
prev.quality == .unauthorized,
curr.quality == .complete,
curr.count > 0 else {
return nil
}
return findLastCompleteValuedTypeCount(
typeID: typeID,
before: previousSnapshot,
context: context
)
}
private static func findLastCompleteValuedTypeCount(
typeID: String,
before snapshot: HealthSnapshot,
context: ModelContext
) -> TypeCount? {
var visited: Set<UUID> = []
var cursorID = snapshot.previousSnapshotID
while let snapshotID = cursorID, !visited.contains(snapshotID) {
visited.insert(snapshotID)
guard let historicalSnapshot = fetchSnapshot(id: snapshotID, context: context) else {
break
}
if let candidate = historicalSnapshot.typeCounts?.first(where: { $0.typeIdentifier == typeID }),
candidate.quality == .complete,
candidate.count > 0 {
return candidate
}
cursorID = historicalSnapshot.previousSnapshotID
}
return nil
}
private static func fetchSnapshot(id: UUID, context: ModelContext) -> HealthSnapshot? {
let descriptor = FetchDescriptor<HealthSnapshot>(
predicate: #Predicate<HealthSnapshot> { $0.id == id }
)
return try? context.fetch(descriptor).first
}
private static func assignReason(
prevQuality: SnapshotQuality?,
currQuality: SnapshotQuality?,
prevUnsupported: Bool,
currUnsupported: Bool,
transition: TypeTransition,
typeSetHashChanged: Bool
) -> TypeDeltaReason {
// Priority 1: authorizationChanged
if prevQuality == SnapshotQuality.unauthorized || currQuality == SnapshotQuality.unauthorized {
return .authorizationChanged
}
// Priority 2: unsupported
if prevUnsupported || currUnsupported {
return .unsupported
}
// Priority 3: registryChanged (only for appeared/disappeared transitions)
if (transition == .appeared || transition == .disappeared) && typeSetHashChanged {
return .registryChanged
}
// Priority 4: unknown (failed)
if prevQuality == SnapshotQuality.failed || currQuality == SnapshotQuality.failed {
return .unknown
}
return .normal
}
private static func mergeTypeDelta(d1td: TypeDelta, d2td: TypeDelta) -> TypeDelta {
let td = TypeDelta(typeIdentifier: d1td.typeIdentifier, displayName: d1td.displayName)
if d1td.transition == .disappeared && d2td.transition == .appeared {
// Type disappeared in N, reappeared in N+1 → treat as changed
td.transition = .changed
td.hashBefore = d1td.hashBefore
td.hashAfter = d2td.hashAfter
td.qualityBefore = d1td.qualityBefore
td.qualityAfter = d2td.qualityAfter
// Unavailable count guard: if either source has quality != complete, force countDelta = 0
let d1Impaired = (d1td.qualityBefore != SnapshotQuality.complete)
let d2Impaired = (d2td.qualityAfter != SnapshotQuality.complete)
td.countDelta = (d1Impaired || d2Impaired) ? 0 : d1td.countDelta + d2td.countDelta
} else {
// Both transitions are the same type (e.g. both unchanged, both changed)
td.transition = deriveTransition(hashBefore: d1td.hashBefore, hashAfter: d2td.hashAfter,
d1: d1td, d2: d2td)
td.hashBefore = d1td.hashBefore
td.hashAfter = d2td.hashAfter
td.qualityBefore = d1td.qualityBefore
td.qualityAfter = d2td.qualityAfter
// Unavailable count guard
let anyImpaired = (d1td.qualityBefore != SnapshotQuality.complete) ||
(d1td.qualityAfter != SnapshotQuality.complete) ||
(d2td.qualityBefore != SnapshotQuality.complete) ||
(d2td.qualityAfter != SnapshotQuality.complete)
td.countDelta = anyImpaired ? 0 : d1td.countDelta + d2td.countDelta
}
// Reason: apply same priority table; use highest-priority reason from both source deltas
td.reason = highestPriorityReason(d1td.reason, d2td.reason)
// Timezone note: carry forward if either source had it
if !d1td.yearlyCountNote.isEmpty || !d2td.yearlyCountNote.isEmpty {
td.yearlyCountNote = "yearly attribution unreliable — timezone changed between snapshots"
}
return td
}
private static func deriveTransition(
hashBefore: String, hashAfter: String,
d1: TypeDelta, d2: TypeDelta
) -> TypeTransition {
// Infer transition from the merged hash pair
if hashBefore.isEmpty && !hashAfter.isEmpty { return .appeared }
if !hashBefore.isEmpty && hashAfter.isEmpty { return .disappeared }
if hashBefore == hashAfter && d1.countDelta + d2.countDelta == 0 { return .unchanged }
return .changed
}
private static func highestPriorityReason(_ a: TypeDeltaReason, _ b: TypeDeltaReason) -> TypeDeltaReason {
// Priority: authorizationChanged > unsupported > registryChanged > unknown > normal
let priority: [TypeDeltaReason] = [.authorizationChanged, .unsupported, .registryChanged, .unknown, .normal]
let aIdx = priority.firstIndex(of: a) ?? priority.count
let bIdx = priority.firstIndex(of: b) ?? priority.count
return priority[min(aIdx, bIdx)]
}
private static func copyTypeDelta(_ source: TypeDelta) -> TypeDelta {
let td = TypeDelta(typeIdentifier: source.typeIdentifier, displayName: source.displayName)
td.countDelta = source.countDelta
td.hashBefore = source.hashBefore
td.hashAfter = source.hashAfter
td.qualityBefore = source.qualityBefore
td.qualityAfter = source.qualityAfter
td.transition = source.transition
td.reason = source.reason
td.yearlyCountNote = source.yearlyCountNote
return td
}
}