Files
arah2/routing/src/fixture-source.ts
T
araharah-executor 3e0c5e3eb4 feat(05-01): geo math + fixture RoutingSource backend + public API
- 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)
2026-10-24 05:03:07 +08:00

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
},
};
}