Skip to content
C.W.K.
Stream
Lesson 01 of 04 · published

Fixes In, Events Out

~14 min · swift, architecture, testing, navigation, functional-core

Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

The Part That Decides What Dad Hears

A navigation app is mostly side effects. A GPS chip that answers when it feels like it, a map that animates, a speaker shared with music and calls, a network that drops in tunnels, a watch on the other end of a radio link. Buried inside all of that is a much smaller thing: a set of rules. When do I say "300 m ahead"? Has the car left the road, or did one fix just bounce off a building? Are we there yet?

Those rules are arithmetic over a line and a stream of points. Pathfinder pulls every one of them into a single value, GuidanceEngine, in the PathfinderKit package. Its own doc comment says it plainly: a pure value, feed it fixes, read events and a snapshot. It never talks to Core Location, a provider, a speaker or the network, "so every rule that decides what Dad hears is testable on a Mac." The architecture doc makes that invariant 2.

Inputs, Outputs, Nothing Else

In: a GuidanceFix. Coordinate, horizontal accuracy in metres, speed, course and a timestamp. Core Location reports an unknown speed or course as a negative number; by the time it reaches the engine that has become nil, because the app translates at its edge and the rules never have to remember Apple's sentinel.

Out: one call, mutating func update(_ fix: GuidanceFix) -> [GuidanceEvent], returns what should happen because of that fix. The vocabulary is small: started, announce (carrying which step, which stage, the spoken distance, the instruction and an optional chained one), offRoute(lateral:), backOnRoute, rerouted and arrived. Most fixes return an empty array, and that is the point: silence is an answer too.

Readable at any time: a GuidanceSnapshot. Phase (waitingForFix, onRoute, offRoute, arrived), progress along the route, remaining distance and time, the next spoken step and how far it is, the fix snapped onto the route, the lateral error, the smoothed speed and when the last fix came. The type's comment calls it "everything a screen or Pippa's report shows about the drive right now."

One detail tells you the design is serious: rerouted is in the vocabulary, yet this engine never says it. When Pathfinder reroutes, its session simply builds a new engine on the new route. Only a provider that guides itself (Google's Navigation SDK, read through FeedTrip, which you met in the previous track) has a reroute to report, and it reports it in the same words, so everything downstream reads one vocabulary.

What the Engine Never Touches

Check the imports and the claim holds. Every file in the PathfinderKit target imports Foundation, and the voice cache adds CryptoKit for its SHA-256 keys. No MapKit, no Core Location, no AVFoundation, no URLSession. The kit even owns its own Coordinate type, "so the guidance rules never depend on CoreLocation or MapKit; the app converts at its edge." MapKit lives in a sibling target, PathfinderAppleMaps, which pays its own translation cost.

The engine also never speaks. It says announce step 1, stage prepare, 300 m, and the app's PromptComposer turns that into Pippa's chunks, and the player plays them. Swap the speaker, the language or the voice and not one guidance rule moves.

The side effects live in the app's NavigationSession. On every location sample it logs the fix to the drive log, calls engine.update, copies the snapshot and reacts to each event: say a line, log it, start a reroute. That is the classic shape sometimes called functional core, imperative shell: the decisions are a value; the shell only carries them out.

Why a Value Is Testable

Given the same route, config and fixes, update returns the same events. So a test is three lines of intent: build a route, build fixes, collect events. Time is not read from a clock; it rides inside each fix. A tunnel gap, a four-second confirmation window or a fix that arrives five minutes late are just timestamps in a test, with no sleeping and no flakiness.

The kit's test helper, TestRoutes, lays out synthetic geometry in metres east and north of one origin: "East 3 km, left turn, north 2 km, arrive", with the turn at 3,000 m and the destination at 5,000 m, "so every expected distance is exact by construction." A drive helper emits one fix per second at a chosen speed with a course along the line. On top of that the Guidance suite asserts what Dad hears: each stage of a town drive once, highway warnings earlier, a GPS gap speaking only the latest stage, a single wild fix ignored, a wrong-way drive caught, a car park start joining the route.

Synthetic geometry is not only convenient. It is a privacy rule. A test fixture gets copied, committed and read by people who should never learn where you live, so this quest's sketches use an arbitrary origin and never a real home.

Put the rules where the clock and the world cannot reach them. If a decision needs time, pass time in. If it needs a position, pass a position in. What is left is a value you can run ten thousand times on a laptop, and a thin shell that only does what the value said.

Code

GuidanceEngine.swift: the whole output vocabulary (verbatim, origin/main)·swift
public enum GuidanceEvent: Equatable, Sendable {
  /// The first usable fix matched the route.
  case started
  case announce(Announcement)
  /// The car left the route; the app asks its provider for a new one.
  case offRoute(lateral: Double)
  /// The car came back before a reroute replaced the route.
  case backOnRoute
  /// A provider that guides itself took a new route (`FeedTrip`). Pathfinder's
  /// own engine never says this: its session replaces the engine instead.
  case rerouted
  case arrived
}

public enum GuidancePhase: String, Codable, Sendable {
  case waitingForFix, onRoute, offRoute, arrived
}
NavigationSession.swift: the imperative shell around the engine (trimmed)·swift
  func handle(_ sample: LocationSample) {
    guard !ended else { return }
    lastSample = sample
    deps.log(
      DriveEvent(
        at: sample.timestamp, kind: .fix, coordinate: sample.coordinate, accuracy: sample.accuracy,
        speed: sample.speed, course: sample.course, progress: snapshot.progress,
        remaining: snapshot.remainingDistance, lateral: snapshot.lateralError))
    let events = engine.update(sample.guidanceFix)
    snapshot = engine.snapshot
    for event in events { react(to: event, sample: sample) }
    // ... camera warnings, the ETA refresh and Pippa's report (trimmed)
  }
A minimal sketch: a fix-to-event engine and a test, no GPS anywhere (compiled and run with swiftc)·swift
// A minimal sketch of a fix -> event guidance engine (not Pathfinder's code).
// Geometry is synthetic: metres east/north of an arbitrary origin.
import Foundation

struct Fix { var east: Double; var north: Double; var accuracy: Double; var time: Double }

enum Event: Equatable { case started, announce(step: Int, metres: Int), arrived }

struct Snapshot { var progress: Double; var remaining: Double; var lateral: Double? }

struct Step { var instruction: String; var maneuverAlong: Double }

/// Route: a straight road east, `length` metres long.
struct Engine {
  let length: Double
  let steps: [Step]
  let warnAt: [Double] = [300, 90]          // stage trigger distances
  private(set) var progress = 0.0
  private(set) var started = false
  private(set) var arrived = false
  private var lateral: Double?
  private var spoken: [Int: Int] = [:]       // step -> stages already said

  init(length: Double, steps: [Step]) { self.length = length; self.steps = steps }

  mutating func update(_ fix: Fix) -> [Event] {
    guard !arrived, fix.accuracy <= 120 else { return [] }
    var events: [Event] = []
    progress = min(max(fix.east, 0), length)  // snap onto the line
    lateral = abs(fix.north)
    if !started { started = true; events.append(.started) }
    if length - progress <= 35 { arrived = true; return events + [.arrived] }
    if let (i, step) = steps.enumerated().first(where: { $0.element.maneuverAlong > progress }) {
      let ahead = step.maneuverAlong - progress
      let reached = warnAt.filter { ahead <= $0 }.count   // 0, 1 or 2 stages
      if reached > (spoken[i] ?? 0) {
        spoken[i] = reached
        events.append(.announce(step: i, metres: Int((ahead / 10).rounded()) * 10))
      }
    }
    return events
  }

  var snapshot: Snapshot { Snapshot(progress: progress, remaining: length - progress, lateral: lateral) }
}

// A "test": synthetic fixes in, events out. No GPS, no speaker, no network.
var engine = Engine(length: 1500, steps: [
  Step(instruction: "turn left", maneuverAlong: 1000),
  Step(instruction: "arrive", maneuverAlong: 1500),
])
var heard: [Event] = []
for second in 0...125 {
  let fix = Fix(east: Double(second) * 12, north: 3, accuracy: 5, time: Double(second))
  heard += engine.update(fix)
}
for event in heard { print(event) }
assert(heard.first == .started)
assert(heard.filter { if case .announce(step: 0, _) = $0 { true } else { false } }.count == 2)
assert(heard.last == .arrived)
print("remaining:", engine.snapshot.remaining)
// started
// announce(step: 0, metres: 290)
// announce(step: 0, metres: 90)
// announce(step: 1, metres: 300)
// announce(step: 1, metres: 80)
// arrived
// remaining: 24.0

External links

Exercise

Find one decision in your own code that reads a sensor, a clock or the network and then decides something (a debounce, a retry rule, an idle timeout, a 'has the user stopped typing' check). Extract it into a struct with a single mutating update(input) -> [Event] method where the input carries its own timestamp. Then write three tests that drive it with synthetic inputs, including one that jumps time forward by minutes, and check that none of them sleeps.
Hint
Start by listing everything the current code reaches for: Date(), a timer, a location manager, a shared singleton. Each one becomes either a field on the input or a constant in a config struct. If something cannot be turned into either, it belongs in the shell, not the core.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.