- geo.ts: haversineM + deriveCumulativeDistanceM (D-07 snapped-progress field) - fixture-source.ts: backend #1 — ORIGIN_TOLERANCE_M=200 matching law, honest-null pending semantics (D-03), defensive pair skip at index build, lazy memoized index (LAW 1), static JSON import (LAW 2), zero logging/storage/network (LAW 3, D-12) - index.ts: named-export public surface, subpath-with-extension import law - loader proven in three environments (bare Node 24, esbuild, Vite-family)
190 lines
8.1 KiB
TypeScript
190 lines
8.1 KiB
TypeScript
/*
|
|
* fixture-source.ts — RoutingSource backend #1: the fixture corpus (D-02).
|
|
* -------------------------------------------------------------------------
|
|
* Serves build-time-precomputed routes for the golden KL pairs. The dataset
|
|
* enters by STATIC IMPORT (LAW 2 — esbuild and Vite inline it at build time;
|
|
* bare Node 24 reads it via type-stripping; a runtime retrieval would hard-
|
|
* fail in a Worker, which has no fs). The import binding is all module scope
|
|
* holds — the index (Maps, decoded geometry) is built lazily on first use and
|
|
* memoized (LAW 1 — the gazetteer posture; Workers 1 s startup limit and the
|
|
* webview's first paint both pay for eager work).
|
|
*
|
|
* THE MATCHING LAW (D-02/D-03): a pair may serve a request only when
|
|
* (1) the request's destinationId equals the pair's destinationId, and
|
|
* (2) the request origin sits within ORIGIN_TOLERANCE_M (200 m, haversine)
|
|
* of the pair's pinned PUBLIC origin.
|
|
* The 200 m pad absorbs GPS jitter around a known landmark — it never
|
|
* fabricates a route for an unknown origin. Anything unmatched resolves to
|
|
* null: the honest "router pending" state (D-03), never a nearest-match.
|
|
*
|
|
* DEFENSIVE VALIDATION (the bot/src/kv/location-pin.ts precedent): a
|
|
* malformed pair — non-finite distance/duration, geometry that throws on
|
|
* decode, empty or oversized routes array — is SKIPPED at index-build time.
|
|
* A malformed entry renders as an honest null downstream, never a throw in
|
|
* the webview's render path and never a NaN coordinate (T-05-02). Plan
|
|
* 05-02's corpus gate makes malformed pairs unshippable in practice; this is
|
|
* the runtime backstop.
|
|
*
|
|
* LAW 3 — NO LOGGING, NO STORAGE, NO NETWORK (D-12/D-15): the origin is the
|
|
* user's live fix. It exists in the caller's frame, is matched in memory,
|
|
* and dies with the frame. Nothing about a request is persisted, logged, or
|
|
* transmitted. Fixture geometries are public-data routes between public
|
|
* places — no user coordinate can ever enter the dataset.
|
|
*/
|
|
|
|
import fixturesData from "./data/route-fixtures.json" with { type: "json" };
|
|
import { deriveCumulativeDistanceM, haversineM } from "./geo.ts";
|
|
import { decodePolyline6 } from "./polyline.ts";
|
|
import type {
|
|
FixturePair,
|
|
RouteFixturesFile,
|
|
RouteOption,
|
|
RoutePoint,
|
|
RouteRequest,
|
|
RouteResult,
|
|
RoutingSource,
|
|
} from "./types.ts";
|
|
|
|
/** A pair may serve a request only when the origin sits within this many
|
|
* meters (haversine) of the pair's pinned public origin (D-02/D-03). */
|
|
export const ORIGIN_TOLERANCE_M = 200;
|
|
|
|
/** pairIds and destination ids are slugs — anything else is refused before
|
|
* the index is consulted (the search/src/source.ts ENTRY_ID_PATTERN guard;
|
|
* bounds per-request work against hostile shapes, T-05-03). */
|
|
const ID_PATTERN = /^[a-z0-9-]{1,64}$/;
|
|
|
|
/** The lazily built, memoized fixture index (never at module scope — LAW 1). */
|
|
interface PairIndex {
|
|
byId: Map<string, FixturePair>;
|
|
byDestination: Map<string, FixturePair[]>;
|
|
/** pairId -> decoded options, memoized so geometry decodes once per pair. */
|
|
optionCache: Map<string, RouteOption[]>;
|
|
}
|
|
|
|
/** A pair is admitted to the index only when every field is sane. */
|
|
function isUsablePair(pair: unknown): pair is FixturePair {
|
|
if (pair === null || typeof pair !== "object") return false;
|
|
const p = pair as {
|
|
pairId?: unknown;
|
|
destinationId?: unknown;
|
|
origin?: { lat?: unknown; lng?: unknown; label?: unknown };
|
|
routes?: unknown;
|
|
};
|
|
if (typeof p.pairId !== "string" || !ID_PATTERN.test(p.pairId)) return false;
|
|
if (typeof p.destinationId !== "string" || !ID_PATTERN.test(p.destinationId)) return false;
|
|
const origin = p.origin;
|
|
if (origin === null || typeof origin !== "object") return false;
|
|
if (typeof origin.lat !== "number" || !Number.isFinite(origin.lat)) return false;
|
|
if (typeof origin.lng !== "number" || !Number.isFinite(origin.lng)) return false;
|
|
if (typeof origin.label !== "string") return false;
|
|
if (!Array.isArray(p.routes) || p.routes.length === 0 || p.routes.length > 3) return false;
|
|
for (const route of p.routes) {
|
|
if (route === null || typeof route !== "object") return false;
|
|
const r = route as { geometry?: unknown; distanceM?: unknown; durationS?: unknown };
|
|
if (typeof r.geometry !== "string" || r.geometry.length === 0) return false;
|
|
if (typeof r.distanceM !== "number" || !Number.isFinite(r.distanceM)) return false;
|
|
if (typeof r.durationS !== "number" || !Number.isFinite(r.durationS)) return false;
|
|
try {
|
|
decodePolyline6(r.geometry);
|
|
} catch {
|
|
return false; // a throwing geometry is a malformed pair (T-05-02)
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function buildPairIndex(file: RouteFixturesFile): PairIndex {
|
|
const index: PairIndex = {
|
|
byId: new Map<string, FixturePair>(),
|
|
byDestination: new Map<string, FixturePair[]>(),
|
|
optionCache: new Map<string, RouteOption[]>(),
|
|
};
|
|
for (const pair of file.pairs) {
|
|
if (!isUsablePair(pair)) continue; // malformed -> skipped, honest null downstream
|
|
index.byId.set(pair.pairId, pair);
|
|
const bucket = index.byDestination.get(pair.destinationId);
|
|
if (bucket === undefined) {
|
|
index.byDestination.set(pair.destinationId, [pair]);
|
|
} else {
|
|
bucket.push(pair);
|
|
}
|
|
}
|
|
return index;
|
|
}
|
|
|
|
/** Options decode their geometry once, lazily, memoized per pair. */
|
|
function buildOptions(pair: FixturePair, cache: Map<string, RouteOption[]>): RouteOption[] {
|
|
const hit = cache.get(pair.pairId);
|
|
if (hit !== undefined) return hit;
|
|
const options: RouteOption[] = pair.routes.map((route) => {
|
|
const points = decodePolyline6(route.geometry);
|
|
return {
|
|
geometry: { points, cumulativeDistanceM: deriveCumulativeDistanceM(points) },
|
|
summary: { distanceM: route.distanceM, durationS: route.durationS },
|
|
// D-10: fixture-world steps are ALWAYS empty — a fabricated maneuver
|
|
// list would be dishonest; real steps arrive with backends #2/#3.
|
|
steps: [],
|
|
};
|
|
});
|
|
cache.set(pair.pairId, options);
|
|
return options;
|
|
}
|
|
|
|
/** The default index over the committed seed — one lazy build, memoized. */
|
|
let defaultIndex: PairIndex | null = null;
|
|
function loadDefaultIndex(): PairIndex {
|
|
if (defaultIndex === null) {
|
|
defaultIndex = buildPairIndex(fixturesData as RouteFixturesFile);
|
|
}
|
|
return defaultIndex;
|
|
}
|
|
|
|
/** pairId -> pinned origin, or null when unknown (the 05-05 harness knob
|
|
* ?fixpair=<pair-id> reads this; public data only). */
|
|
export function getPairOrigin(pairId: string): RoutePoint | null {
|
|
if (!ID_PATTERN.test(pairId)) return null;
|
|
const pair = loadDefaultIndex().byId.get(pairId);
|
|
return pair === undefined ? null : { lat: pair.origin.lat, lng: pair.origin.lng };
|
|
}
|
|
|
|
/** Introspection for the corpus runner (plan 05-02) — lazy, memoized. */
|
|
export function getPairs(): readonly FixturePair[] {
|
|
return [...loadDefaultIndex().byId.values()];
|
|
}
|
|
|
|
/** Backend #1 of the D-01 ladder. `data` is optional injection — tests pass
|
|
* synthetic datasets; production constructs it with no argument and the
|
|
* committed seed serves. Backend selection happens HERE, in code, at the
|
|
* import site — never via env or config (T-05-04). */
|
|
export function createFixtureRoutingSource(data?: RouteFixturesFile): RoutingSource {
|
|
let index: PairIndex | null = null;
|
|
const load = (): PairIndex => {
|
|
if (index === null) {
|
|
index = data === undefined ? loadDefaultIndex() : buildPairIndex(data);
|
|
}
|
|
return index;
|
|
};
|
|
return {
|
|
name: "fixture",
|
|
async route(req: RouteRequest): Promise<RouteResult | null> {
|
|
// Slug guard FIRST — a hostile shape never reaches the index (T-05-03).
|
|
if (!ID_PATTERN.test(req.destinationId)) return null;
|
|
const idx = load();
|
|
const candidates = idx.byDestination.get(req.destinationId);
|
|
if (candidates === undefined) return null; // D-03: honest pending
|
|
for (const pair of candidates) {
|
|
if (haversineM(req.origin, pair.origin) <= ORIGIN_TOLERANCE_M) {
|
|
return {
|
|
destinationId: req.destinationId,
|
|
origin: req.origin,
|
|
options: buildOptions(pair, idx.optionCache),
|
|
backend: "fixture",
|
|
};
|
|
}
|
|
}
|
|
return null; // origin outside tolerance for every pinned pair — D-03
|
|
},
|
|
};
|
|
}
|