diff --git a/web/src/guidance/watch.ts b/web/src/guidance/watch.ts new file mode 100644 index 0000000..98add39 --- /dev/null +++ b/web/src/guidance/watch.ts @@ -0,0 +1,89 @@ +/* + * watch.ts — the guidance position stream: watchPosition behind the D-15 + * --------------------------------------------------------------------------- + * distance throttle. D-05's gated-streaming law flips its watch ON here: + * guidance subscribes to navigator.geolocation.watchPosition at HIGH + * ACCURACY (maximumAge: 0 — a cached position would poison progress), but + * the web platform has NO native distance filter, so the throttle is + * app-side by design: a fix does guidance WORK only when the car moved + * >= THROTTLE_MIN_MOVE_M (10 m) OR >= THROTTLE_MIN_ELAPSED_MS (5 s) + * elapsed since the last accepted fix — whichever first. Everything else + * is dropped before it can touch the state machine, the projection, or + * the ETA (battery cadence, D-15). + * + * Each payload normalizes into the six-field Fix (the telegram.ts + * normalization precedent): accuracyM/headingDeg null when the platform + * omits them, source "webview". Rejected fixes leave NO trace — the + * machine never sees them. + * + * Privacy (T-06-05/T-06-05b): fixes are consumed in the caller's frame; + * nothing is stored, logged, or transmitted (no fetch/console/storage — + * gated by the replay suite's structural case). stop() clears the + * watcher id — the privacy-minimal trip end (D-14's "the watch stops"); + * calling it twice is harmless. + */ +import type { Fix } from "../location/types.ts"; +import { haversineM } from "@araharah/routing/src/geo.ts"; + +/** D-15: guidance work runs on >= 10 m moved. */ +export const THROTTLE_MIN_MOVE_M = 10; +/** D-15: ...or >= 5 s elapsed, whichever first (stationary cars still progress clocks). */ +export const THROTTLE_MIN_ELAPSED_MS = 5000; + +/** + * The pure accept rule: first fix always accepted; afterwards accept when + * the car moved >= 10 m OR >= 5 s elapsed since the last ACCEPTED fix. + */ +export function shouldAcceptFix(last: Fix | null, fix: Fix, lastAcceptedTs: number): boolean { + if (last === null) return true; + if (haversineM(last, fix) >= THROTTLE_MIN_MOVE_M) return true; + return fix.ts - lastAcceptedTs >= THROTTLE_MIN_ELAPSED_MS; +} + +/** The watch handle: stop() clears the watcher (idempotent; D-14). */ +export interface GuidanceWatch { + stop(): void; +} + +/** + * Start the guidance watch. `geo` is injected (the location-chain law) so + * the whole throttle is Node-testable against a stub. Only ACCEPTED fixes + * reach `onFix`; rejections die here silently. + */ +export function createGuidanceWatch(geo: Geolocation, onFix: (fix: Fix) => void): GuidanceWatch { + let last: Fix | null = null; + let lastAcceptedTs = 0; + let watchId: number | null = null; + let stopped = false; + watchId = geo.watchPosition( + (pos) => { + if (stopped) return; + const raw = pos.timestamp; + const fix: Fix = { + lat: pos.coords.latitude, + lng: pos.coords.longitude, + accuracyM: pos.coords.accuracy ?? null, + headingDeg: pos.coords.heading ?? null, + ts: typeof raw === "number" && raw > 0 ? raw : Date.now(), + source: "webview", + }; + if (!shouldAcceptFix(last, fix, lastAcceptedTs)) return; // dropped pre-work (D-15) + last = fix; + lastAcceptedTs = fix.ts; + onFix(fix); + }, + () => { + // a mid-drive error (denial/unavailable) is not a crash: the watch + // keeps running and the chain's staleness axis reports the silence + }, + { enableHighAccuracy: true, maximumAge: 0 }, + ); + return { + stop(): void { + if (stopped || watchId === null) return; + stopped = true; + geo.clearWatch(watchId); + watchId = null; + }, + }; +} diff --git a/web/test/guidance-replay.test.mjs b/web/test/guidance-replay.test.mjs index 8e83a73..f9b1ff3 100644 --- a/web/test/guidance-replay.test.mjs +++ b/web/test/guidance-replay.test.mjs @@ -473,7 +473,7 @@ test("R11 offRouteThresholdM: max(35, 2 x accuracyM) capped at 100; null uses th assert.equal(offRouteThresholdM(25), 50, "the nominal accuracy reads the named constant"); }); -/* ————— R12 — structural: THE pure module ————— */ +/* ————— R12 — structural: THE pure modules ————— */ function stripComments(src) { return src.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); @@ -487,3 +487,111 @@ test("R12 guidance.ts is pure: no console/document/maplibre/localStorage; havers assert.doesNotMatch(src, /localStorage|sessionStorage|fetch\(/, "no storage, no network (T-06-05)"); assert.match(src, /haversineM/, "one distance authority"); }); + +test("R12b watch.ts is pure: no console/document/maplibre/localStorage; haversineM is the distance authority", () => { + const src = stripComments(readFileSync(path.join(HERE, "..", "src", "guidance", "watch.ts"), "utf8")); + assert.doesNotMatch(src, /console\./, "no console in the watch"); + assert.doesNotMatch(src, /\bdocument\b/, "no DOM"); + assert.doesNotMatch(src, /maplibre/, "no maplibre"); + assert.doesNotMatch(src, /localStorage|sessionStorage|fetch\(/, "no storage, no network (T-06-05)"); + assert.match(src, /haversineM/, "one distance authority"); +}); + +/* ————— T1..T3 — the D-15 distance throttle + the watch wrapper ————— */ + +const w = await import("../src/guidance/watch.ts"); +const { shouldAcceptFix, createGuidanceWatch, THROTTLE_MIN_MOVE_M, THROTTLE_MIN_ELAPSED_MS } = w; + +/** A fix `d` meters north of the KL origin (pure-latitude spacing reads + * exactly d meters through the same haversineM the throttle uses). The + * constant is geo.ts's own EARTH_RADIUS_M-derived meters-per-degree — a + * rounded stand-in leaves a 3e-8 relative error that flips boundaries. */ +const KL = { lat: 3.1513, lng: 101.6958 }; +const M_PER_DEG = (6371000 * Math.PI) / 180; +const fixAt = (m, ts) => ({ + lat: KL.lat + m / M_PER_DEG, + lng: KL.lng, + accuracyM: 8, + headingDeg: null, + ts, + source: "webview", +}); + +test("T1 throttle constants and the full shouldAcceptFix truth table", () => { + assert.equal(THROTTLE_MIN_MOVE_M, 10); + assert.equal(THROTTLE_MIN_ELAPSED_MS, 5000); + + assert.equal(shouldAcceptFix(null, fixAt(0, 1000), 0), true, "the first fix is always accepted"); + assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(9, 2000), 1000), false, "9 m at 1 s -> rejected"); + // 10 m + 0.1 um: the haversine round-trip costs ~5e-12 m, which would flip a + // bare 10.0 — the >= semantics stay pinned exactly by the millisecond cases + assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(10.0000001, 2000), 1000), true, "10 m moved -> accepted"); + assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(11, 2000), 1000), true, "11 m at 1 s -> accepted"); + assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(3, 6001), 1000), true, "3 m at 5001 ms -> accepted"); + assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(3, 5999), 1000), false, "3 m at 4999 ms -> rejected"); +}); + +test("T2 createGuidanceWatch wires high-accuracy options, normalizes the Fix, feeds only accepted fixes", () => { + let recordedOptions = null; + let successCb = null; + const feeds = []; + const geo = { + watchPosition(success, _error, options) { + recordedOptions = options; + successCb = success; + return 42; + }, + clearWatch() {}, + }; + const watch = createGuidanceWatch(geo, (fix) => feeds.push(fix)); + + assert.deepEqual(recordedOptions, { enableHighAccuracy: true, maximumAge: 0 }, "high accuracy, fresh fixes only"); + + successCb({ + coords: { latitude: KL.lat, longitude: KL.lng, accuracy: 12, heading: null }, + timestamp: 1760000000000, + }); + assert.equal(feeds.length, 1, "the first fix passes"); + assert.deepEqual(feeds[0], { + lat: KL.lat, + lng: KL.lng, + accuracyM: 12, + headingDeg: null, + ts: 1760000000000, + source: "webview", + }); + + // 3 m drift 1 s later — below both thresholds — dies in the wrapper + successCb({ + coords: { latitude: KL.lat + 3 / M_PER_DEG, longitude: KL.lng, accuracy: 12, heading: 45 }, + timestamp: 1760000000001, + }); + assert.equal(feeds.length, 1, "the rejected fix never reaches onFix"); + + // 11 m jump — accepted + successCb({ + coords: { latitude: KL.lat + 11 / M_PER_DEG, longitude: KL.lng, accuracy: null, heading: null }, + timestamp: 1760000000002, + }); + assert.equal(feeds.length, 2, "the accepted fix passes"); + assert.equal(feeds[1].accuracyM, null, "missing accuracy normalizes to null"); + assert.equal(feeds[1].headingDeg, null, "missing heading normalizes to null"); + watch.stop(); +}); + +test("T3 stopWatch clears the issued watcher id exactly once (the privacy-minimal stop, D-14)", () => { + const cleared = []; + const geo = { + watchPosition() { + return 77; + }, + clearWatch(id) { + cleared.push(id); + }, + }; + const watch = createGuidanceWatch(geo, () => {}); + watch.stop(); + assert.deepEqual(cleared, [77], "clearWatch receives the issued id"); + watch.stop(); + assert.equal(cleared.length, 1, "stop is idempotent — no double clear"); +});