initial commit

This commit is contained in:
2026-06-22 12:24:22 +09:00
commit 21206b44e2
+316
View File
@@ -0,0 +1,316 @@
# 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.py``OsrmProvider`: HTTP client to a local OSRM
instance; normalizes `legs[].steps[].maneuver` + decoded `geometry` into the
internal `Route`/`Maneuver` model. **Primary backend.**
- `route_provider/file.py``FileProvider`: loads a pre-decoded route from disk.
For desk testing and reproducible runs.
- `route_provider/google.py``GoogleProvider`: 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.py``GpsdSource`: client of local `gpsd`; `gpsd.next()`
as the blocking read. **Primary.**
- `position/nmea_source.py``NmeaSource`: direct `/dev/ttyUSB*` NMEA parse
(fallback if `gpsd` is undesired).
- `position/replay_source.py``ReplaySource`: 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/`)
```python
@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 poller** — `PositionSource.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.
```