From 3e0c5e3eb456009e455754c2de38bab07b256d15 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 05:03:07 +0800 Subject: [PATCH] feat(05-01): geo math + fixture RoutingSource backend + public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- routing/src/fixture-source.ts | 189 ++++++++++++++++++++++++++++++++++ routing/src/geo.ts | 49 +++++++++ routing/src/index.ts | 30 ++++++ 3 files changed, 268 insertions(+) create mode 100644 routing/src/fixture-source.ts create mode 100644 routing/src/geo.ts create mode 100644 routing/src/index.ts diff --git a/routing/src/fixture-source.ts b/routing/src/fixture-source.ts new file mode 100644 index 0000000..e099426 --- /dev/null +++ b/routing/src/fixture-source.ts @@ -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; + byDestination: Map; + /** pairId -> decoded options, memoized so geometry decodes once per pair. */ + optionCache: Map; +} + +/** 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(), + byDestination: new Map(), + optionCache: new Map(), + }; + 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): 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= 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 { + // 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 + }, + }; +} diff --git a/routing/src/geo.ts b/routing/src/geo.ts new file mode 100644 index 0000000..ed0df0b --- /dev/null +++ b/routing/src/geo.ts @@ -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; +} diff --git a/routing/src/index.ts b/routing/src/index.ts new file mode 100644 index 0000000..e08fd8f --- /dev/null +++ b/routing/src/index.ts @@ -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";