Files

247 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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>