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
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* geo.ts — spherical geo math for the route model (D-07).
|
||||
* --------------------------------------------------------
|
||||
* haversineM: great-circle distance in meters on the mean-earth sphere
|
||||
* (radius 6 371 000 m — at KL-metro scale the model error is far below the
|
||||
* fixture corpus's 200 m origin tolerance). Finite for finite inputs.
|
||||
*
|
||||
* deriveCumulativeDistanceM: the snapped-progress support field (D-07 — the
|
||||
* GUID-05/06 upstream obligation). Produces [0, d(p0→p1), d(p0→p1)+d(p1→p2),
|
||||
* …] — the same length as the input, [0] === 0, non-decreasing. Phase 6's
|
||||
* guidance snaps a live fix's progress along the line using this array
|
||||
* instead of re-deriving it per fix; the fixture source derives it once at
|
||||
* decode/load and memoizes (RESEARCH Finding 1).
|
||||
*
|
||||
* Pure: no I/O, no logging, no storage (the D-15 analog — nothing about a
|
||||
* request outlives the caller's frame).
|
||||
*/
|
||||
|
||||
import type { RoutePoint } from "./types.ts";
|
||||
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
const DEGREES_TO_RADIANS = Math.PI / 180;
|
||||
|
||||
/** Great-circle distance between two WGS84 points, in meters. */
|
||||
export function haversineM(a: RoutePoint, b: RoutePoint): number {
|
||||
const lat1 = a.lat * DEGREES_TO_RADIANS;
|
||||
const lat2 = b.lat * DEGREES_TO_RADIANS;
|
||||
const dLat = (b.lat - a.lat) * DEGREES_TO_RADIANS;
|
||||
const dLng = (b.lng - a.lng) * DEGREES_TO_RADIANS;
|
||||
const sinLat = Math.sin(dLat / 2);
|
||||
const sinLng = Math.sin(dLng / 2);
|
||||
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng;
|
||||
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/** Meters walked along the polyline: same length as points, [0] === 0,
|
||||
* non-decreasing. Empty -> []; single point -> [0]. */
|
||||
export function deriveCumulativeDistanceM(points: readonly RoutePoint[]): number[] {
|
||||
if (points.length === 0) return [];
|
||||
const cumulative: number[] = [0];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1];
|
||||
const curr = points[i];
|
||||
const prevMeters = cumulative[i - 1];
|
||||
if (prev === undefined || curr === undefined || prevMeters === undefined) break;
|
||||
cumulative.push(prevMeters + haversineM(prev, curr));
|
||||
}
|
||||
return cumulative;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* index.ts — the public API surface of @araharah/routing (D-01, D-03, D-07).
|
||||
* ---------------------------------------------------------------------------
|
||||
* Named exports ONLY (no default export). The package has NO exports /
|
||||
* main field (the @araharah/theme + @araharah/search precedent), so
|
||||
* consumers import the EXPLICIT SUBPATH WITH EXTENSION —
|
||||
* import { createFixtureRoutingSource } from "@araharah/routing/src/index.ts";
|
||||
* (both tsconfigs carry allowImportingTsExtensions + bundler resolution;
|
||||
* esbuild and Vite compile the .ts source; Node 24 type-stripping handles
|
||||
* the corpus runner's relative form). A bare "@araharah/routing" specifier
|
||||
* resolves to NOTHING — never use it.
|
||||
*
|
||||
* The module reads NO environment, NO secrets, and NO network (D-05/D-12):
|
||||
* the whole routing stack is V8-native and dependency-free — fixtures ride
|
||||
* the bundle, requests never leave the caller's frame.
|
||||
*/
|
||||
|
||||
export type {
|
||||
RoutePoint, RouteRequest, RouteSummary, RouteStep,
|
||||
RouteGeometry, RouteOption, RouteResult, RoutingSource,
|
||||
FixturePair, FixtureRoute,
|
||||
} from "./types.ts";
|
||||
export { decodePolyline6, encodePolyline6 } from "./polyline.ts";
|
||||
export { haversineM, deriveCumulativeDistanceM } from "./geo.ts";
|
||||
export {
|
||||
ORIGIN_TOLERANCE_M,
|
||||
createFixtureRoutingSource,
|
||||
getPairOrigin,
|
||||
getPairs,
|
||||
} from "./fixture-source.ts";
|
||||
Reference in New Issue
Block a user