From 5b13802bd542cdd02d146ac9d13a382a0a163e31 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 4 Aug 2026 19:01:19 -0700 Subject: [PATCH] Prototype: declarative TrackingReconcile pure core Extract tracking-toggle decision logic into WhereCore pure functions mirroring TrackingReconciliation TLA properties, wire WhereSession and LocationIngestor.start() to call them. Exploratory follow-on to #184. Co-authored-by: Cursor --- .../Sources/Location/LocationIngestor.swift | 6 +- .../LocationIngestorStartDecision.swift | 21 ++++++ .../Sources/Protocols/TrackingReconcile.swift | 56 ++++++++++++++ .../LocationIngestorStartDecisionTests.swift | 18 +++++ .../Tests/TrackingReconcileTests.swift | 75 +++++++++++++++++++ .../WhereUI/Sources/Model/WhereSession.swift | 23 ++++-- 6 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 Where/WhereCore/Sources/Location/LocationIngestorStartDecision.swift create mode 100644 Where/WhereCore/Sources/Protocols/TrackingReconcile.swift create mode 100644 Where/WhereCore/Tests/LocationIngestorStartDecisionTests.swift create mode 100644 Where/WhereCore/Tests/TrackingReconcileTests.swift diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift index da0ff93a..261e5936 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -125,7 +125,11 @@ public actor LocationIngestor { guard !isMonitoring else { return } isMonitoring = true await locationSource.start() - guard isMonitoring else { return } + guard LocationIngestorStart + .afterLocationSourceStart(isMonitoring: isMonitoring) == .completeSetup + else { + return + } Self.logger { .monitoringStarted } // Seed the in-memory queue from the durable backlog once, so samples that // failed to persist in a prior launch get retried now. diff --git a/Where/WhereCore/Sources/Location/LocationIngestorStartDecision.swift b/Where/WhereCore/Sources/Location/LocationIngestorStartDecision.swift new file mode 100644 index 00000000..9fdadb38 --- /dev/null +++ b/Where/WhereCore/Sources/Location/LocationIngestorStartDecision.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Pure branch after ``LocationIngestor``'s `LocationSource.start()` await. +/// +/// Maps to the re-entrancy boundary in +/// [`TrackingReconciliation`](../../Specifications/TrackingReconciliation/README.md): +/// if `stop()` clears `isMonitoring` while start is parked, setup must not continue. +public enum LocationIngestorStartDecision: Sendable, Hashable { + /// `stop()` ran during the await; leave the ingestor off. + case abortMonitoringStoppedDuringAwait + /// Monitoring is still wanted; run backlog drain and attach the sample stream. + case completeSetup +} + +public enum LocationIngestorStart { + public static func afterLocationSourceStart(isMonitoring: Bool) + -> LocationIngestorStartDecision + { + isMonitoring ? .completeSetup : .abortMonitoringStoppedDuringAwait + } +} diff --git a/Where/WhereCore/Sources/Protocols/TrackingReconcile.swift b/Where/WhereCore/Sources/Protocols/TrackingReconcile.swift new file mode 100644 index 00000000..94054148 --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/TrackingReconcile.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Pure decision logic for the tracking-toggle protocol in +/// [`TrackingReconciliation`](../../Specifications/TrackingReconciliation/README.md). +/// +/// Maps to the TLA+ variables `desired`, `ingestorActive`, `published`, `worker`, +/// and `target`. Async orchestration stays in ``WhereSession``; this type is the +/// declarative digest the spec and production code share. +/// +/// - Property ``shouldPublish(target:currentEffective:reconcilePending:)`` ↔ +/// `CorrectAtQuiescence` (publish only when intent settled). +/// - Property ``shouldPreemptInFlightStop(targetEffective:)`` ↔ coalesced stop +/// while a start is parked on an await. +public enum TrackingReconcile: Sendable { + /// Worker lane phase in the coalesced design (`Coalesced.cfg`). + public enum WorkerPhase: String, Sendable, Hashable, CaseIterable { + case idle + case ready + case starting + case stopping + } + + /// Whether background tracking should be active given intent and authorization. + public static func effectiveTracking( + desired: Bool, + authorizationAllowsBackground: Bool, + ) -> Bool { + desired && authorizationAllowsBackground + } + + /// When a reconcile is already in flight and the latest intent is *off*, + /// stop the ingestor immediately instead of awaiting the parked start. + public static func shouldPreemptInFlightStop(targetEffective: Bool) -> Bool { + !targetEffective + } + + /// After a side effect completes, whether ``WhereSession/isTracking`` may update. + public static func shouldPublish( + target: Bool, + currentEffective: Bool, + reconcilePending: Bool, + ) -> Bool { + currentEffective == target && !reconcilePending + } + + /// UI published state once every command has settled (`CorrectAtQuiescence`). + public static func publishedAtQuiescence( + desired: Bool, + authorizationAllowsBackground: Bool, + ) -> Bool { + effectiveTracking( + desired: desired, + authorizationAllowsBackground: authorizationAllowsBackground, + ) + } +} diff --git a/Where/WhereCore/Tests/LocationIngestorStartDecisionTests.swift b/Where/WhereCore/Tests/LocationIngestorStartDecisionTests.swift new file mode 100644 index 00000000..7367a126 --- /dev/null +++ b/Where/WhereCore/Tests/LocationIngestorStartDecisionTests.swift @@ -0,0 +1,18 @@ +import Testing +import WhereCore + +struct LocationIngestorStartDecisionTests { + @Test func completeSetupWhenMonitoringStillWanted() { + #expect( + LocationIngestorStart.afterLocationSourceStart(isMonitoring: true) + == .completeSetup, + ) + } + + @Test func abortWhenStopRanDuringLocationSourceStart() { + #expect( + LocationIngestorStart.afterLocationSourceStart(isMonitoring: false) + == .abortMonitoringStoppedDuringAwait, + ) + } +} diff --git a/Where/WhereCore/Tests/TrackingReconcileTests.swift b/Where/WhereCore/Tests/TrackingReconcileTests.swift new file mode 100644 index 00000000..8c7d2d11 --- /dev/null +++ b/Where/WhereCore/Tests/TrackingReconcileTests.swift @@ -0,0 +1,75 @@ +import Testing +import WhereCore + +/// Mirrors [`TrackingReconciliation`](../../Specifications/TrackingReconciliation/README.md) +/// properties on the pure decision layer — no `Task`, ingestor, or session wiring. +struct TrackingReconcileTests { + @Test func effectiveTrackingRequiresAlwaysAuthorization() { + #expect(TrackingReconcile.effectiveTracking( + desired: true, + authorizationAllowsBackground: true, + )) + #expect(!TrackingReconcile.effectiveTracking( + desired: true, + authorizationAllowsBackground: false, + )) + #expect(!TrackingReconcile.effectiveTracking( + desired: false, + authorizationAllowsBackground: true, + )) + } + + @Test func preemptInFlightStopWhenTargetIsOff() { + #expect(TrackingReconcile.shouldPreemptInFlightStop(targetEffective: false)) + #expect(!TrackingReconcile.shouldPreemptInFlightStop(targetEffective: true)) + } + + @Test func publishOnlyWhenTargetMatchesAndNothingPending() { + #expect(TrackingReconcile.shouldPublish( + target: false, + currentEffective: false, + reconcilePending: false, + )) + #expect(!TrackingReconcile.shouldPublish( + target: false, + currentEffective: true, + reconcilePending: false, + )) + #expect(!TrackingReconcile.shouldPublish( + target: false, + currentEffective: false, + reconcilePending: true, + )) + } + + @Test func coalescedDisableDuringInFlightStartDoesNotPublishStaleTrue() { + // Modeled sequence: enable, disable while start awaits — target is false, + // current effective false, but an older iteration captured target true. + let targetCapturedForIteration = true + let currentEffective = TrackingReconcile.effectiveTracking( + desired: false, + authorizationAllowsBackground: true, + ) + #expect(!TrackingReconcile.shouldPublish( + target: targetCapturedForIteration, + currentEffective: currentEffective, + reconcilePending: false, + )) + #expect(TrackingReconcile.publishedAtQuiescence( + desired: false, + authorizationAllowsBackground: true, + ) == false) + } + + @Test func quiescenceAfterMatchingEnablePublishesTrue() { + #expect(TrackingReconcile.shouldPublish( + target: true, + currentEffective: true, + reconcilePending: false, + )) + #expect(TrackingReconcile.publishedAtQuiescence( + desired: true, + authorizationAllowsBackground: true, + )) + } +} diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index 6d7aaa73..ce12af69 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -314,8 +314,11 @@ public final class WhereSession { private func runTrackingReconcile() async { if let running = trackingWorkerTask { trackingReconcilePending = true - let targetEffective = wantsTracking && authorizationStatus.allowsBackgroundTracking - if !targetEffective { + let targetEffective = TrackingReconcile.effectiveTracking( + desired: wantsTracking, + authorizationAllowsBackground: authorizationStatus.allowsBackgroundTracking, + ) + if TrackingReconcile.shouldPreemptInFlightStop(targetEffective: targetEffective) { // Stop must not await an in-flight `ingestor.start()` — it can be // parked on `LocationSource.start()` indefinitely. Pause monitoring // now; the worker reruns after that await and publishes intent. @@ -338,7 +341,10 @@ public final class WhereSession { while true { trackingReconcilePending = false - let targetEffective = wantsTracking && authorizationStatus.allowsBackgroundTracking + let targetEffective = TrackingReconcile.effectiveTracking( + desired: wantsTracking, + authorizationAllowsBackground: authorizationStatus.allowsBackgroundTracking, + ) let wasTracking = isTracking if targetEffective { @@ -347,8 +353,15 @@ public final class WhereSession { await services.ingestor.stop() } - let currentEffective = wantsTracking && authorizationStatus.allowsBackgroundTracking - guard currentEffective == targetEffective, !trackingReconcilePending else { + let currentEffective = TrackingReconcile.effectiveTracking( + desired: wantsTracking, + authorizationAllowsBackground: authorizationStatus.allowsBackgroundTracking, + ) + guard TrackingReconcile.shouldPublish( + target: targetEffective, + currentEffective: currentEffective, + reconcilePending: trackingReconcilePending, + ) else { continue }