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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user