feat(06-02): D-15 distance-throttled watchPosition wrapper + throttle truth table
- shouldAcceptFix: accept when first fix, >=10 m moved, or >=5 s elapsed (whichever first) — the pure accept rule over haversineM - createGuidanceWatch(geo, onFix): high-accuracy watchPosition (maximumAge: 0), six-field Fix normalization (null accuracy/heading, source webview), rejected fixes die in the wrapper, idempotent stop() clearing the watcher id (T-06-05b's privacy-minimal trip end) - replay suite T1-T3 + R12b: truth table, recorded-options structural gate, feed-only-accepted proof, stop idempotence, module purity scan
This commit is contained in:
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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");
|
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) {
|
function stripComments(src) {
|
||||||
return src.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
|
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.doesNotMatch(src, /localStorage|sessionStorage|fetch\(/, "no storage, no network (T-06-05)");
|
||||||
assert.match(src, /haversineM/, "one distance authority");
|
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");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user