2026-06-22 12:24:22 +09:00
2026-06-22 12:24:22 +09:00

codriver

A deterministic, local navigation daemon that emits turn directives ("Turn left") at the right moment. No LLM on the hot path. The LLM is a later, off-path layer that decides whether to speak and how to phrase; this repo is the dumb, fast substrate it sits on top of.

Target hardware: NVIDIA Jetson Orin Nano with bolt-on GPS (NMEA-over-UART, wrapped by gpsd) and IMU (I2C). Visual input and LLM co-driver are explicitly out of scope for the current milestone.


Scope (current milestone — "step 0")

In: Consume a pre-computed route, poll position, emit a maneuver string at a speed-adaptive lead time, detect going off-route and reroute.

Out (deferred): TTS, LLM phrasing/decision layer, IMU sensor fusion, visual input, rally pace-note generation, recce-run note authoring.

The deliverable of step 0 is: a process that prints "Turn left" (or the configured flavour) when the driver should turn, not too late, and recomputes the route when the driver deviates.


SWE-1 — Software Requirements

Requirements are tagged SWR-n for traceability into the SWE-2 architecture and later test cases (SWE-4/5/6).

Functional

  • SWR-1 The system shall obtain a route from an origin to a destination as an ordered geometry (polyline) plus a sparse, ordered list of maneuvers.
  • SWR-2 The system shall acquire the vehicle's current position, speed, and heading by polling a position source.
  • SWR-3 The system shall project the current position onto the active route geometry, yielding distance-along-route and cross-track distance.
  • SWR-4 The system shall identify the next not-yet-announced maneuver ahead of the current along-route position.
  • SWR-5 The system shall compute a trigger distance as a function of current speed and a configured lead time (constant time to maneuver, not constant distance).
  • SWR-6 The system shall emit exactly one directive string per maneuver, when the distance to that maneuver first falls within the trigger distance.
  • SWR-7 The system shall detect an off-route condition when sustained cross-track distance exceeds a threshold (debounced over N consecutive fixes).
  • SWR-8 On off-route, the system shall request a new route from current position to the unchanged destination and atomically replace the active route.
  • SWR-9 The directive string content shall be configurable ("flavour"), so the phrasing layer is decoupled from the trigger logic.

Non-functional / constraints

  • SWR-10 The decision path (acquire → match → decide → emit) shall contain no network call and no LLM invocation; route acquisition (incl. reroute) is the only component permitted to block on I/O and shall run off the decision path.
  • SWR-11 The position source has no native subscribe/interrupt mechanism; the architecture shall treat polling as the normative interface.
  • SWR-12 Routing shall be performable fully offline from a periodically-updated local dataset (no per-trip dependency on a cloud routing service).
  • SWR-13 The route source shall be replaceable (local engine, cloud API, or recorded file) without change to the decision path.
  • SWR-14 "Not too late": at the configured lead time, the directive shall be emitted with enough distance for the driver to act at the current speed.
  • SWR-15 A single noisy position fix shall not by itself cause a reroute or a spurious/duplicate directive.

SWE-2 — Software Architectural Design

Layering

Two tiers. The decision tier is the hot loop: pure arithmetic over an in-memory route, runs at the position-source cadence (~110 Hz), never blocks on network. The planning tier is off-path: it produces a Route artifact and hands it to the decision tier through a single shared, atomically-swappable slot.

                      planning tier (slow, blocking I/O OK)
   ┌─────────────────────────────────────────────────────────┐
   │  RouteProvider ──► Route artifact ──►  ActiveRouteStore   │
   └───────────────────────────────────────────▲──────────────┘
                                                │ atomic swap
   ┌────────────────────────────────────────────┼─────────────┐
   │  PositionSource ─► StateStore ─► [ poll loop: MapMatcher   │
   │                                   ─► ManeuverTracker       │
   │                                   ─► TriggerPolicy         │
   │                                   ─► OffRouteMonitor ]     │
   │                                   ─► DirectiveEmitter ─►   │
   └────────────────────────────────── decision tier (fast) ───┘

Module decomposition

Each module below maps to a Python package/module. Interfaces are given as the contract; types reference the data model in the next section.

route_provider/ — route acquisition (planning tier)

  • Responsibility: turn (origin, destination) into a normalized Route. Isolates all backend messiness (OSRM/Valhalla/Google/file) behind one contract.
  • Interface:
    • RouteProvider.get_route(origin: LatLon, dest: LatLon) -> Route
  • Implementations (selected at config time):
    • route_provider/osrm.pyOsrmProvider: HTTP client to a local OSRM instance; normalizes legs[].steps[].maneuver + decoded geometry into the internal Route/Maneuver model. Primary backend.
    • route_provider/file.pyFileProvider: loads a pre-decoded route from disk. For desk testing and reproducible runs.
    • route_provider/google.pyGoogleProvider: optional online fallback.
  • Satisfies: SWR-1, SWR-12, SWR-13. Normalization point also serves SWR-9's decoupling (maneuver semantics fixed here, phrasing fixed downstream).
  • Note: Owns no routing algorithm. Preprocessing of OSM extracts (osrm-extract/partition/customize) is an operational step documented in ops/, not code in this repo.

position/ — position acquisition (decision tier, ingress)

  • Responsibility: poll the position source, parse to a Fix, publish the freshest fix.
  • Interface:
    • PositionSource.read() -> Fix (blocking until next fix; the GPS cadence is the loop clock)
  • Implementations:
    • position/gpsd_source.pyGpsdSource: client of local gpsd; gpsd.next() as the blocking read. Primary.
    • position/nmea_source.pyNmeaSource: direct /dev/ttyUSB* NMEA parse (fallback if gpsd is undesired).
    • position/replay_source.pyReplaySource: replays a recorded NMEA/fix log at wall-clock or accelerated rate. For desk testing without hardware.
  • Satisfies: SWR-2, SWR-11.

state/ — shared state slots

  • Responsibility: last-write-wins handoff between threads. Two slots: current vehicle Fix, and the ActiveRouteStore for the route.
  • Interface:
    • StateStore.put(fix: Fix) / StateStore.latest() -> Fix | None
    • ActiveRouteStore.swap(route: Route) / ActiveRouteStore.current() -> Route | None
  • Satisfies: SWR-8 (atomic swap), SWR-10 (decouples blocking planning tier from the non-blocking decision tier).

matching/ — map matching

  • Responsibility: project a Fix onto the active Route polyline.
  • Interface:
    • MapMatcher.match(fix: Fix, route: Route) -> MatchResult where MatchResult = {along_dist_m, cross_track_m, segment_index, snapped: LatLon}
  • Step-0 algorithm: nearest-segment + perpendicular projection. No probabilistic HMM matching yet.
  • Satisfies: SWR-3. Produces the cross-track value consumed by OffRouteMonitor for free.

maneuvers/ — maneuver tracking

  • Responsibility: given along_dist, find the next not-yet-announced maneuver and the along-route distance to it; own the per-maneuver "announced" markers.
  • Interface:
    • ManeuverTracker.next_pending(along_dist_m: float) -> tuple[Maneuver, float] | None
    • ManeuverTracker.mark_announced(maneuver_id) -> None
    • ManeuverTracker.reset() -> None (called on route swap)
  • Satisfies: SWR-4, SWR-6 (exactly-once via markers).

trigger/ — trigger policy

  • Responsibility: decide whether the next maneuver should fire now.
  • Interface:
    • TriggerPolicy.should_fire(distance_to_maneuver_m: float, fix: Fix) -> bool
  • Step-0 policy: trigger_distance = speed_mps * lead_time_s + reaction_buffer_m; fire when distance_to_maneuver <= trigger_distance.
  • Config: lead_time_s, reaction_buffer_m. (A later second-stage early pre-announce — "in 300 meters…" — slots in here as an additional threshold without touching other modules.)
  • Satisfies: SWR-5, SWR-14.

offroute/ — off-route monitor

  • Responsibility: debounced off-route detection; request reroute.
  • Interface:
    • OffRouteMonitor.update(match: MatchResult) -> OffRouteState (ON_ROUTE | OFF_ROUTE)
  • Step-0 algorithm: cross_track_m > threshold_m sustained for N consecutive fixes (hysteresis on recovery). On transition to OFF_ROUTE, signals the planning tier to call RouteProvider.get_route(current, dest) and ActiveRouteStore.swap, then ManeuverTracker.reset.
  • Config: threshold_m, confirm_fixes_n.
  • Satisfies: SWR-7, SWR-8, SWR-15.

directive/ — directive emitter (decision tier, egress)

  • Responsibility: render a Maneuver into the output string and emit it.
  • Interface:
    • DirectiveEmitter.emit(maneuver: Maneuver, fix: Fix) -> None
    • Phrasebook.render(maneuver: Maneuver) -> str
  • Step-0 implementation: table-driven phrasebook (maneuver.type + modifier → string); emit = print() / write to stdout or a socket. TTS and the LLM phrasing layer subscribe here later; the seam is Phrasebook.
  • Satisfies: SWR-6, SWR-9.

app/ — composition root

  • Responsibility: wire modules from config, own the two threads (position poller, decision loop) and the planning-tier reroute worker, handle lifecycle.
  • Interface: main() + config.py (dataclass-validated config: provider selection, lead time, thresholds, phrasebook flavour, source selection).
  • Decision loop (pseudocode):
    fix = state.latest()
    route = active_route.current()
    m = matcher.match(fix, route)
    if offroute.update(m) == OFF_ROUTE:
        request_reroute(fix.pos, dest)        # hands off to planning tier
    pending = tracker.next_pending(m.along_dist_m)
    if pending:
        maneuver, dist = pending
        if trigger.should_fire(dist, fix):
            emitter.emit(maneuver, fix)
            tracker.mark_announced(maneuver.id)
    
  • Satisfies: SWR-10 (keeps blocking reroute off this loop).

Data model (model/)

@dataclass(frozen=True)
class LatLon:
    lat: float
    lon: float

@dataclass(frozen=True)
class Fix:
    pos: LatLon
    speed_mps: float
    heading_deg: float | None      # may be unreliable at low speed
    t_monotonic: float
    valid: bool

@dataclass(frozen=True)
class Maneuver:
    id: int
    location: LatLon
    type: str                      # turn | keep | uturn | roundabout | arrive ...
    modifier: str | None           # left | right | slight_left ...
    entry_heading: float | None
    exit_heading: float | None
    road_name: str | None

@dataclass(frozen=True)
class Route:
    polyline: list[LatLon]         # dense shape points
    maneuvers: list[Maneuver]      # sparse, ordered along polyline
    destination: LatLon

Maneuver.type/modifier is the fixed internal vocabulary every provider normalizes to; the phrasebook and (later) the LLM phrasing layer key off it. This is the single contract that keeps backend messiness out of the rest of the system.

Concurrency model

Three threads, no shared mutable state except the two state/ slots (last-write-wins, guarded by a lock or an atomic reference):

  1. Position pollerPositionSource.read() in a loop → StateStore.put.
  2. Decision loop — reads both slots, runs the pipeline above at its cadence, emits directives. Never blocks on I/O.
  3. Reroute worker — waits for an off-route signal, calls the (blocking) RouteProvider, swaps ActiveRouteStore. Keeps SWR-10 intact.

Traceability summary

Module Requirements
route_provider SWR-1, 9, 12, 13
position SWR-2, 11
state SWR-8, 10
matching SWR-3
maneuvers SWR-4, 6
trigger SWR-5, 14
offroute SWR-7, 8, 15
directive SWR-6, 9
app SWR-10

Repository layout

codriver/
├── README.md
├── pyproject.toml
├── codriver/
│   ├── app/            # composition root, config, main()
│   ├── model/          # LatLon, Fix, Maneuver, Route
│   ├── route_provider/ # osrm.py, file.py, google.py
│   ├── position/       # gpsd_source.py, nmea_source.py, replay_source.py
│   ├── state/          # StateStore, ActiveRouteStore
│   ├── matching/       # MapMatcher
│   ├── maneuvers/      # ManeuverTracker
│   ├── trigger/        # TriggerPolicy
│   ├── offroute/       # OffRouteMonitor
│   └── directive/      # DirectiveEmitter, Phrasebook
├── ops/                # OSM extract + OSRM preprocessing runbook (not app code)
└── tests/
    ├── fixtures/       # recorded NMEA logs, pre-decoded routes
    └── ...

Out of scope, but the seams are placed for them

  • TTS — subscribes at DirectiveEmitter; today's emit is print().
  • LLM co-driver — a gate before DirectiveEmitter.emit (decide whether to speak) and/or a Phrasebook implementation (decide phrasing). Off the hot path by construction.
  • IMU fusion — a PositionSource decorator that fuses I2C accelerometer with GPS to stabilize heading/speed at low speed; the Fix contract is unchanged.
  • Rally pace notes — a different RouteProvider (notes authored from a recce run) plus a richer Maneuver vocabulary; the decision tier is reused as-is.
S
Description
The navigation bit for now
Readme 29 KiB