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)
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* 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
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user