247 lines
20 KiB
Markdown
247 lines
20 KiB
Markdown
---
|
||
phase: 06-turn-by-turn-guidance-live-driving
|
||
plan: 02
|
||
type: execute
|
||
wave: 1
|
||
depends_on: []
|
||
files_modified:
|
||
- web/src/guidance/guidance.ts
|
||
- web/src/guidance/eta.ts
|
||
- web/src/guidance/watch.ts
|
||
- web/test/guidance-replay.test.mjs
|
||
- web/test/guidance-eta.test.mjs
|
||
- web/test/fixtures/drives/corridor-follow.json
|
||
- web/test/fixtures/drives/off-route-excursion.json
|
||
- web/test/fixtures/drives/missed-turn-excursion.json
|
||
- web/test/fixtures/drives/gps-jitter.json
|
||
autonomous: true
|
||
requirements: [GUID-02, GUID-03, GUID-04, GUID-06]
|
||
must_haves:
|
||
truths:
|
||
- "A recorded drive replays through the state machine asserting every D-04 transition, the storm guard, and arrival (D-16)"
|
||
- "A missed turn silently triggers exactly ONE reroute request — storm-guard holds under excursion storms (GUID-02)"
|
||
- "GPS jitter never leaves ON_ROUTE — the accuracy-aware threshold + 3-consecutive-fixes + 8s grace absorb it"
|
||
- "ETA is EMA-smoothed with the ±1 min / ±5% deadband — no visible whiplash across a jitter stream (D-07)"
|
||
- "Haptic effects fire once per edge: approach at 300 m, impact at the maneuver, success at arrival (D-08)"
|
||
- "Guidance work runs only on ≥10 m moved or ≥5 s elapsed — the D-15 distance throttle is pure and tested"
|
||
artifacts:
|
||
- path: "web/src/guidance/guidance.ts"
|
||
provides: "THE pure D-04 state machine + route projection + named constants"
|
||
exports: ["createGuidanceSession", "stepGuidance", "projectAlongRoute", "OFF_ROUTE_THRESHOLD_M", "OFF_ROUTE_CONSECUTIVE_FIXES", "OFF_ROUTE_GRACE_MS", "STORM_GUARD_MS", "MISSED_TURN_PAST_MANEUVER_M", "ARRIVAL_THRESHOLD_M", "APPROACH_HAPTIC_M", "GUIDANCE_START_ALONG_M"]
|
||
- path: "web/src/guidance/eta.ts"
|
||
provides: "EMA + deadband honest ETA (D-07)"
|
||
exports: ["updateEta", "EMA_ETA_ALPHA", "DEADBAND_MIN_S", "DEADBAND_REMAINING_PCT"]
|
||
- path: "web/src/guidance/watch.ts"
|
||
provides: "watchPosition wrapper + ≥10 m / ≥5 s distance throttle (D-15)"
|
||
exports: ["shouldAcceptFix", "createGuidanceWatch", "THROTTLE_MIN_MOVE_M", "THROTTLE_MIN_ELAPSED_MS"]
|
||
- path: "web/test/fixtures/drives/corridor-follow.json"
|
||
provides: "D-16 drive replay: clean corridor follow to arrival"
|
||
key_links:
|
||
- from: "web/src/guidance/guidance.ts"
|
||
to: "routing/src/geo.ts"
|
||
via: "haversineM for deviation distances (never raw Δdegrees)"
|
||
pattern: "haversineM"
|
||
- from: "web/test/guidance-replay.test.mjs"
|
||
to: "web/src/guidance/guidance.ts"
|
||
via: "drive fixtures replayed through stepGuidance"
|
||
pattern: "stepGuidance"
|
||
- from: "web/test/guidance-replay.test.mjs"
|
||
to: "routing/test/fixtures/golden-pairs.json"
|
||
via: "drives derived from a real committed route line"
|
||
pattern: "golden-pairs"
|
||
---
|
||
|
||
<objective>
|
||
The guidance brain: THE pure, Node-testable D-04 reroute state machine with named replay-tuned constants, the D-07 honest ETA (EMA + deadband), the D-15 distance throttle, and the four D-16 drive fixtures — proven by replay tests before any DOM, map, or Worker surface exists.
|
||
|
||
Purpose: every guidance behavior the user experiences (silent self-heal, honest ETA, haptics, seamless resume) is decided HERE, replay-verifiable in Node in seconds. Plans 05/06 only render and wire what this plan decides.
|
||
Output: `web/src/guidance/{guidance,eta,watch}.ts` + 4 committed drive fixtures + replay/eta suites green.
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||
</execution_context>
|
||
|
||
<context>
|
||
@.planning/PROJECT.md
|
||
@.planning/ROADMAP.md
|
||
@.planning/STATE.md
|
||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md
|
||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md
|
||
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md
|
||
|
||
<interfaces>
|
||
<!-- The Fix shape replay fixtures and the machine consume (web/src/location/types.ts): -->
|
||
export type Fix = {
|
||
lat: number; lng: number;
|
||
accuracyM: number | null;
|
||
headingDeg: number | null;
|
||
ts: number;
|
||
source: FixSource;
|
||
};
|
||
|
||
<!-- Route model pieces from @araharah/routing/src/index.ts: -->
|
||
// RoutePoint {lat,lng}; RouteStep {distanceM?, durationS?, name?, geometry?, kind?}
|
||
// deriveCumulativeDistanceM(points) -> number[] (meters along the line, [0]===0)
|
||
// haversineM(a, b) -> meters
|
||
|
||
<!-- RESEARCH.md Pattern 3 constants table (D-04) — named, replay-tuned, never inline: -->
|
||
| OFF_ROUTE_THRESHOLD_M | 50 | accuracy-aware: max(35, 2 × accuracyM), capped 100 |
|
||
| OFF_ROUTE_CONSECUTIVE_FIXES | 3 | GPS-jitter rejection |
|
||
| OFF_ROUTE_GRACE_MS | 8000 | grace window before acting (D-04) |
|
||
| STORM_GUARD_MS | 20000 | min time between recalculations (D-04) |
|
||
| MISSED_TURN_PAST_MANEUVER_M | 40 | off-route past the maneuver shortens grace (D-04) |
|
||
| ARRIVAL_THRESHOLD_M | 25 | snapped progress ≥ end-of-route (D-14) |
|
||
| APPROACH_HAPTIC_M | 300 | maneuver-approach haptic (D-08 / UI-SPEC) |
|
||
| GUIDANCE_START_ALONG_M | 30 | route drawn + first fix ≥ 30 m along → guidance starts |
|
||
| EMA_ETA_ALPHA (plan task 2) | 0.25 | per-fix smoothing (D-07) |
|
||
| DEADBAND_MIN_S | 60 | ±1 min display deadband (D-07) |
|
||
| DEADBAND_REMAINING_PCT | 0.05 | ±5% remaining deadband (D-07) |
|
||
| THROTTLE_MIN_MOVE_M | 10 | D-15 distance throttle |
|
||
| THROTTLE_MIN_ELAPSED_MS | 5000 | D-15 time floor |
|
||
</interfaces>
|
||
|
||
**Test style:** `web/test/location-chain.test.mjs`'s stub-install + restore-in-finally and the harness registry (`import { test } from "./harness.mjs"`). Node-24 type-stripping dynamic imports of `.ts` sources. NO DOM, NO maplibre, NO console in the three modules (the route.ts law — console state tokens are the CONTROLLER's job, plan 06).
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>task 1: Drive fixtures + THE D-04 state machine (guidance.ts) — RED→GREEN</name>
|
||
<files>web/test/fixtures/drives/*.json, web/src/guidance/guidance.ts, web/test/guidance-replay.test.mjs</files>
|
||
<read_first>
|
||
- web/src/location/types.ts (the six-field Fix — fixtures are Fix-shaped JSON streams, PATTERNS law)
|
||
- routing/test/fixtures/golden-pairs.json (the committed real KL route lines drives are derived from)
|
||
- web/src/route/route.ts lines 30–52 (the pure-injected-deps + named-constants posture this module follows)
|
||
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md Pattern 3 (the state machine + constants table) and Pitfall 6 (reroute storm: after ON_ROUTE, deviation is measured against the NEW route only)
|
||
</read_first>
|
||
<behavior>
|
||
- corridor-follow.json replays: state stays "on_route" throughout, ZERO reroute-requested effects, haptic approach fires once ~300 m before the final maneuver, arrival effect fires at the end (D-16)
|
||
- off-route-excursion.json: 3 consecutive off-threshold fixes spanning ≥8 s → state "off_route" → reroute-requested effect; after reroute-applied → "on_route"; a SECOND excursion injected immediately → NO second reroute-requested within STORM_GUARD_MS (Pitfall 6)
|
||
- missed-turn-excursion.json: off-route ≥ MISSED_TURN_PAST_MANEUVER_M past the maneuver point → grace shortens → reroute-requested fires earlier than the standard grace
|
||
- gps-jitter.json: ±15 m alternating fixes with accuracyM 20 (threshold max(35, 2×20)=40 m) → state NEVER leaves "on_route", zero reroute requests
|
||
- guidance start gate: a stationary first fix (< GUIDANCE_START_ALONG_M along) → not started; a fix ≥ 30 m along → started (PX-03 protections may bind)
|
||
- reroute-failed while rerouting → state "no_reroute" (the honest degradation, D-03)
|
||
</behavior>
|
||
<action>
|
||
1. RED: write `web/test/guidance-replay.test.mjs` — a `replayDrive(fixtureName, overrides?)` helper: loads the fixture JSON, builds the GuidanceRouteModel from the SAME golden-pair route the drive was derived from (`decodePolyline6` → points → `deriveCumulativeDistanceM` → steps synthesized at the fixture's recorded maneuver indices with the fixture's turn kinds), then folds each Fix through `stepGuidance` and records `{snap, effects}` frames. Assertions per behavior list. The initial run fails on import (module absent) — the RED state.
|
||
2. Build the four fixtures FIRST as committed JSON, each `{ "_notice": "synthetic drive derived from golden-pairs.json <pairId>; D-16 replay fixture — Fix-shaped", "pairId": "...", "maneuverIdx": [i0, i1, ...], "fixes": [ {lat,lng,accuracyM,headingDeg,ts,source}, ... ] }`:
|
||
- Decode the pair's polyline6, sample along the line at ~8 m intervals (~1 Hz at 30 km/h) for corridor-follow (accuracyM 8, headingDeg from bearing, source "webview").
|
||
- off-route-excursion: corridor prefix, then a perpendicular offset growing to ~80 m (accuracyM 10 — inside the honest threshold), held ≥ 12 s / 4 fixes, then rejoin.
|
||
- missed-turn-excursion: follow to one recorded maneuver index, then continue straight past it ≥ 60 m off the new leg, then rejoin.
|
||
- gps-jitter: corridor with alternating ±15 m perpendicular noise, accuracyM 20, timestamps 1 s apart.
|
||
Derive coordinates programmatically (a small throwaway generator inline in the test run or a scratch node -e) and COMMIT the resulting JSON — golden-pairs data is committed public ODbL, so derived drives carry the same `_notice` attribution line.
|
||
3. GREEN: `web/src/guidance/guidance.ts` — pure, no DOM/map/console imports:
|
||
- `GuidanceState = "idle" | "on_route" | "off_route" | "rerouting" | "resuming" | "no_reroute"`; `GuidanceRouteModel = { points: RoutePoint[]; cumulative: number[]; steps: RouteStep[]; destinationId: string; destinationName: string }`.
|
||
- `projectAlongRoute(points, cumulative, fix, fromIdx): { idx: number; snapped: RoutePoint; alongM: number; remainingM: number }` — walk FORWARD from fromIdx only (the anti-pattern law: never global nearest-point per fix), point-to-segment in cos(lat)-scaled equirectangular space, remainingM from cumulative difference (never raw fix-to-destination distance, D-07).
|
||
- `createGuidanceSession(model, firstFix, nowMs): GuidanceSnapshot` + `stepGuidance(snap: GuidanceSnapshot, event: GuidanceEvent, nowMs): { snap: GuidanceSnapshot; effects: GuidanceEffect[] }` — a pure reducer over `GuidanceEvent = { type: "fix"; fix: Fix } | { type: "reroute-started" } | { type: "reroute-failed" } | { type: "reroute-applied"; model: GuidanceRouteModel }`. `GuidanceEffect = { type: "haptic"; cue: "approach" | "maneuver" | "arrive" } | { type: "reroute-requested"; origin: RoutePoint } | { type: "arrival" }`. All constants from the context table as named exports. After reroute-applied, deviation is measured against the NEW model only (Pitfall 6). Effects are once-per-edge: approach fires once per maneuver (reset on reroute-apply), maneuver haptic when progress crosses a step boundary, arrive once.
|
||
4. Structural assertions in the same suite: `grep`-style source scan — guidance.ts contains no `console.`, no `document`, no `maplibre`, no `localStorage` (the pure-module + no-storage laws, RS10-analog).
|
||
</action>
|
||
<verify>
|
||
<automated>npm test -w web</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- all four JSON fixtures exist under web/test/fixtures/drives/ and each carries a `_notice` attribution line and Fix-shaped `fixes`
|
||
- guidance-replay.test.mjs asserts: corridor (on_route, 0 reroute, arrival), excursion (exactly 1 reroute-requested, storm guard holds), missed-turn (shortened grace), jitter (never off_route)
|
||
- grep `console\.|document\.|maplibre|localStorage` → 0 matches in web/src/guidance/guidance.ts
|
||
- named constants exported: OFF_ROUTE_THRESHOLD_M=50, OFF_ROUTE_CONSECUTIVE_FIXES=3, OFF_ROUTE_GRACE_MS=8000, STORM_GUARD_MS=20000, MISSED_TURN_PAST_MANEUVER_M=40, ARRIVAL_THRESHOLD_M=25, APPROACH_HAPTIC_M=300, GUIDANCE_START_ALONG_M=30 (suite asserts the exported values, RS4 style)
|
||
- grep `haversineM` matches web/src/guidance/guidance.ts (one distance authority)
|
||
</acceptance_criteria>
|
||
<done>Every D-04 transition, the storm guard, missed-turn shortening, jitter absorption, arrival, and the D-08 effect stream are replay-proven in Node; the machine is pure with zero browser imports</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="true">
|
||
<name>task 2: Honest ETA — EMA + deadband (eta.ts) — RED→GREEN</name>
|
||
<files>web/src/guidance/eta.ts, web/test/guidance-eta.test.mjs</files>
|
||
<read_first>
|
||
- web/src/guidance/guidance.ts (task 1 — remainingM/remainingS come from projectAlongRoute, never from fix-to-destination distance)
|
||
- routing/src/geo.ts (the named-constants pure-module posture)
|
||
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md D-07 + Assumption A5 (α ≈ 0.25/fix, ±1 min / ±5% deadband)
|
||
</read_first>
|
||
<behavior>
|
||
- first update seeds the EMA directly (no smoothing toward zero)
|
||
- a raw remaining-time sequence with ±40 s jitter produces a displayed sequence whose successive DISPLAYED values never differ by less than the deadband allows — updates only when |new − displayed| ≥ 60 s AND ≥ 5% of remaining (either bound passes → update)
|
||
- a genuine 5-minute drop updates the display within 2 fixes
|
||
- remaining distance follows the same deadband (±5%)
|
||
- no input ever reads a raw fix→destination distance (the function consumes remainingS/remainingM computed by the caller from the route model — signature enforces D-07)
|
||
</behavior>
|
||
<action>
|
||
1. RED: `web/test/guidance-eta.test.mjs` — feed synthetic remaining-S/M sequences (jitter, drop, plateau) through `updateEta`; assert deadband behavior + the EMA trajectory (α = 0.25 convergence: after n fixes, value ≈ within 10% of target).
|
||
2. GREEN: `web/src/guidance/eta.ts` — `updateEta(prev: EtaState | null, remainingS: number, remainingM: number): EtaState` where `EtaState = { etaS: number; remainingM: number; displayedEtaS: number; displayedRemainingM: number }`. EMA: `smoothed = α × raw + (1 − α) × smoothed` per axis; display update only past BOTH deadbands? NO — past EITHER deadband (±1 min on time OR ±5% on distance triggers that axis's refresh; axes independent). Constants named + exported. Also export `composeEtaClock(etaS, nowMs): string` — 24-hour `HH:MM` = now + displayed etaS (the UI-SPEC instrument format; en/ms identical).
|
||
</action>
|
||
<verify>
|
||
<automated>npm test -w web</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- web/src/guidance/eta.ts exports updateEta, composeEtaClock, EMA_ETA_ALPHA=0.25, DEADBAND_MIN_S=60, DEADBAND_REMAINING_PCT=0.05
|
||
- guidance-eta.test.mjs asserts: seed, jitter-holds-display, real-drop-updates, independent axes
|
||
- grep `console\.|Fix` → updateEta's signature takes numbers, not a Fix (D-07's "never raw fix-to-destination" is structural)
|
||
- composeEtaClock("…", fixedMs) returns exactly `HH:MM` 24-hour format for known inputs
|
||
</acceptance_criteria>
|
||
<done>ETA smoothing is proven whiplash-free under jitter and responsive to real changes; the deadband constants are exported and pinned by test</done>
|
||
</task>
|
||
|
||
<task type="auto">
|
||
<name>task 3: watchPosition + distance throttle (watch.ts)</name>
|
||
<files>web/src/guidance/watch.ts, web/test/guidance-replay.test.mjs</files>
|
||
<read_first>
|
||
- web/src/location/webview.ts (the promise-wrapped callback + settled latch + hard-timeout + errors-resolve-null posture watch.ts extends)
|
||
- web/src/location/chain.ts lines 1–20 (the D-05 cadence note this phase finally flips to watchPosition)
|
||
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md "Distance throttle (D-15)" code example (the exact accept rule)
|
||
</read_first>
|
||
<behavior>
|
||
- shouldAcceptFix(null, fix, 0) → true (first fix always accepted)
|
||
- 9 m moved, 1 s elapsed → false; 10 m moved → true; 11 m at 1 s → true; 3 m at 5001 ms → true; 3 m at 4999 ms → false
|
||
- createGuidanceWatch starts watchPosition with { enableHighAccuracy: true, maximumAge: 0 } (structural assertion on the recorded options)
|
||
- stopWatch() clears the watcher id (the privacy-minimal stop — D-14's "the watch stops")
|
||
</behavior>
|
||
<action>
|
||
1. Extend `web/test/guidance-replay.test.mjs` with a throttle block (the pure rules are replay-adjacent; keep the suite count at the VALIDATION map's three): assert the shouldAcceptFix truth table and, with a stubbed `geolocation.watchPosition` (the location-chain stub style), that createGuidanceWatch wires high-accuracy options and stopWatch calls clearWatch with the issued id.
|
||
2. `web/src/guidance/watch.ts`: `shouldAcceptFix(last: Fix | null, fix: Fix, lastAcceptedTs: number): boolean` — accept when `last === null || haversineM(last, fix) >= THROTTLE_MIN_MOVE_M || fix.ts - lastAcceptedTs >= THROTTLE_MIN_ELAPSED_MS` (≥10 m moved OR ≥5 s elapsed, whichever first — D-15); `createGuidanceWatch(geo: Geolocation, onFix: (fix: Fix) => void): { stop(): void }` — watchPosition high-accuracy, normalizes the payload into the six-field Fix (`accuracyM ?? null, headingDeg ?? null, source: "webview"` — the telegram.ts normalization precedent), feeds ONLY accepted fixes to onFix.
|
||
</action>
|
||
<verify>
|
||
<automated>npm test -w web</automated>
|
||
</verify>
|
||
<acceptance_criteria>
|
||
- web/src/guidance/watch.ts exports shouldAcceptFix, createGuidanceWatch, THROTTLE_MIN_MOVE_M=10, THROTTLE_MIN_ELAPSED_MS=5000
|
||
- the replay suite's throttle block asserts the full accept/reject truth table
|
||
- structural assertion: watchPosition called with enableHighAccuracy true and maximumAge 0
|
||
- grep `haversineM` matches web/src/guidance/watch.ts
|
||
</acceptance_criteria>
|
||
<done>The D-15 gated-streaming law is flipped ON: a high-accuracy watch whose feed is distance-throttled, fully Node-tested, with a clean stop for trip end</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<threat_model>
|
||
## Trust Boundaries
|
||
|
||
| Boundary | Description |
|
||
|----------|-------------|
|
||
| GPS stream → state machine | Position fixes are device-local sensor input; the machine is pure so nothing about a fix outlives the caller's frame |
|
||
| reroute-requested effect → network (plan 06) | The ONLY in-drive position ever transmitted is a reroute origin |
|
||
|
||
## STRIDE Threat Register
|
||
|
||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||
|-----------|----------|-----------|-------------|-----------------|
|
||
| T-06-05 | Information Disclosure | guidance.ts / watch.ts | mitigate-by-design | On-route fixes NEVER leave the device: the machine computes deviation/progress/ETA locally; the only network-bound position is the reroute-requested effect's origin (one point, on a real excursion). Structural gate: no fetch/console/localStorage in any of the three modules. |
|
||
| T-06-05b | Information Disclosure | watch.ts | mitigate | The watch STOPS at trip end (`stop()` exported + wired in plan 06) — no location flow after the trip; replay/structural tests pin the stop path's existence. |
|
||
| — | DoS (resource) | stepGuidance hot loop | accept | Pure reducer with a forward-walking projection window (O(local)); no unbounded memory; replay fixtures prove bounded work per fix. |
|
||
</threat_model>
|
||
|
||
<verification>
|
||
- `npm test -w web` — replay + eta suites green (all four drives), existing web suites untouched-green
|
||
- All D-04/D-07/D-08/D-14/D-15 constants exported with pinned values
|
||
- Zero browser/DOM/network imports in web/src/guidance/
|
||
</verification>
|
||
|
||
<success_criteria>
|
||
The guidance brain is done and replay-proven: the four recorded drives exercise every transition (corridor → arrival, excursion → exactly one reroute + storm guard, missed turn → shortened grace, jitter → absorbed), ETA never whiplashes, haptics fire once per edge, and the watch throttles at ≥10 m / ≥5 s. Plans 05/06/08 render, wire, and capture this — they never re-decide it.
|
||
</success_criteria>
|
||
|
||
<output>
|
||
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-02-SUMMARY.md`
|
||
</output>
|