diff --git a/bot/test/guidance-cron.test.mjs b/bot/test/guidance-cron.test.mjs index 636a530..502e3b7 100644 --- a/bot/test/guidance-cron.test.mjs +++ b/bot/test/guidance-cron.test.mjs @@ -49,7 +49,7 @@ const { strings } = await import("../src/i18n/strings.ts"); const { encodePolyline6 } = await import("@araharah/routing/src/polyline.ts"); const { deriveCumulativeDistanceM, haversineM } = await import("@araharah/routing/src/geo.ts"); const { encodeGraph, decodeGraph } = await import("@araharah/routing/src/graph.ts"); -const { LANDMARK_QUANTUM_S, landmarkTables } = await import("@araharah/routing/src/landmark.ts"); +const { contractGraph } = await import("@araharah/routing/src/contraction.ts"); /* ————— geometry helpers (1e-6-grid-exact construction, the codec's grid law) ————— */ @@ -250,23 +250,26 @@ test("GC6b unnamed matched edge → cardStreetUnnamed ('UNNAMED ROAD') — still // A purpose-built unnamed way through the same encodeGraph contract // (names: [] → every edge carries the UNNAMED_NAME_ID sentinel). const meters = haversineM({ lat: 3.15, lng: 101.69 }, { lat: 3.1505, lng: 101.69 }); - const edges = [ + // A 3-node star through the same encodeGraph contract (names: [] → + // every edge carries the UNNAMED_NAME_ID sentinel). ARG3 (Phase 08.1): + // the ch block comes from the ONE contraction core, and the MID owns + // the LOWEST id — the priority tie contracts lowest-id-first, so a mid + // with id 1 would starve to zero shortcuts and an ARG3 with no + // shortcuts is not a road graph. Geometry: north leg (0->1), east leg + // (0->2) — the turn at the shared mid vertex. + const starEdges = [ { from: 0, to: 1, seconds: meters / (30 / 3.6) }, { from: 1, to: 0, seconds: meters / (30 / 3.6) }, + { from: 0, to: 2, seconds: meters / (30 / 3.6) }, + { from: 2, to: 0, seconds: meters / (30 / 3.6) }, ]; - // A 2-node graph has no degree>=2 farthest-point candidate (the selection - // filter), so the anchor pins directly — the tables still come from the - // ONE integer-Dijkstra core (08-02), through the ONE encode authority. - const landmarkInput = { nodeCount: 2, edges, quantumS: LANDMARK_QUANTUM_S }; - const anchors = Uint32Array.of(0); - const { to, from } = landmarkTables(landmarkInput, anchors); const buf = encodeGraph({ - lat: [3.15, 3.1505], - lng: [101.69, 101.69], - edges, + lat: [3.15, 3.1505, 3.15], + lng: [101.69, 101.69, 101.6905], + edges: starEdges, names: [], maxSpeedKmh: 30, - landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from }, + ch: contractGraph({ nodeCount: 3, edges: starEdges }), }); const graph = decodeGraph(buf); assert.notEqual(graph, null); diff --git a/bot/test/webhook.test.mjs b/bot/test/webhook.test.mjs index 178973e..ff854eb 100644 --- a/bot/test/webhook.test.mjs +++ b/bot/test/webhook.test.mjs @@ -472,31 +472,37 @@ test("GW11 PUT /api/guidance → 405", async () => { const { encodeGraph } = await import("../../routing/src/graph.ts"); const { haversineM } = await import("../../routing/src/geo.ts"); -const { LANDMARK_QUANTUM_S, farthestPointAnchors, landmarkTables } = await import( - "../../routing/src/landmark.ts" -); +const { contractGraph } = await import("../../routing/src/contraction.ts"); -/** Tiny REAL landmark block through the ONE preprocessing core (08-02): - * farthest-point anchor + integer ĉ tables — computed, never faked. - * `{ pin }` pins the anchor for graphs with no degree>=2 candidate (the - * selection filter's law — e.g. a single-segment graph); the tables still - * come from the ONE integer-Dijkstra core. */ -function landmarkBlock(nodeCount, edges, { pin } = {}) { - const input = { nodeCount, edges, quantumS: LANDMARK_QUANTUM_S }; - const anchors = pin !== undefined ? Uint32Array.of(pin) : farthestPointAnchors(input, 1); - if (anchors.length !== 1) throw new Error("graph has no degree-2 anchor candidate"); - const { to, from } = landmarkTables(input, anchors); - return { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from }; +/** The ARG3 ch block through the ONE contraction core (Phase 08.1): + * computed, never faked. A shape that contracts to ZERO shortcuts is a + * builder bug — an ARG3 with chShortcutCount = 0 is not a road graph + * (the decode law refuses it), so fail loud here instead of shipping an + * undecodable test binary. */ +function chBlock(nodeCount, edges) { + const ch = contractGraph({ nodeCount, edges }); + if (ch.stats.shortcutCount === 0) { + throw new Error("graph contracted to zero shortcuts — ARG3 has no CH-less layout"); + } + return ch; } /** The committed 06-01 ground truth (25 nodes around KL center) via fs. */ const MINI_GRAPH = readFileSync(new URL("../../routing/test/fixtures/mini-graph.bin", import.meta.url)); -/** Public gazetteer landmarks only — no user coordinate enters a test (D-12). */ +/** Public gazetteer POIs only — no user coordinate enters a test (D-12). */ const KL_TOWER = { lat: 3.1528, lng: 101.7038 }; const KL_SENTRAL = { lat: 3.1343, lng: 101.6869 }; -/** 8-node straight corridor kl-tower → suria-klcc (public coords), two-way. */ +/** 8-node straight corridor kl-tower → suria-klcc (public coords), two-way. + * ARG3 (Phase 08.1): the corridor gains ONE fast nonstop chord (0<->7, + * 120 km/h vs the 60 km/h local road) — a pure line contracts to ZERO + * shortcuts (endpoints starve the interior on the priority tie) and a + * shortcut-less ARG3 is not a road graph. The chord gives the contraction + * a real hierarchy; the split variant keeps the gap AND drops the chord, + * so its two line components stay disconnected (RT9's A* miss) — the + * resulting binary decodes to null under the CH-less law, which flows to + * the same 200-null contract. */ function corridorGraphBuffer({ gap = false } = {}) { const n = 8; const lat = []; @@ -512,26 +518,40 @@ function corridorGraphBuffer({ gap = false } = {}) { const seconds = meters / (60 / 3.6); edges.push({ from: i, to: i + 1, seconds, nameId: 0 }, { from: i + 1, to: i, seconds, nameId: 0 }); } + if (!gap) { + const meters = haversineM({ lat: lat[0], lng: lng[0] }, { lat: lat[n - 1], lng: lng[n - 1] }); + const seconds = meters / (120 / 3.6); + edges.push({ from: 0, to: n - 1, seconds, nameId: 0 }, { from: n - 1, to: 0, seconds, nameId: 0 }); + } return encodeGraph({ lat, lng, edges, names: ["Jalan Koridor Ujian"], maxSpeedKmh: 60, - landmarks: { ...landmarkBlock(n, edges) }, + ch: chBlock(n, edges), }); } /** Nodes cluster at two far-apart cells with a nodeless middle: an in-bbox - * origin between them is the snap-miss carrier (3x3-neighborhood law). */ + * origin between them is the snap-miss carrier (3x3-neighborhood law). + * ARG3: node 0 is the star's hub (two unnamed legs, ids kept so the + * contraction's lowest-id tie-break contracts the MID first — a star + * whose mid id is higher starves to zero shortcuts) — the node set is + * unchanged from the ARG2-era fixture, keeping RT8's snap-miss geometry + * exact. */ function sparseGraphBuffer() { const lat = [3.15, 3.1501, 3.1515]; const lng = [101.69, 101.6905, 101.7]; const meters = haversineM({ lat: lat[0], lng: lng[0] }, { lat: lat[1], lng: lng[1] }); const seconds = meters / (50 / 3.6); + const meters2 = haversineM({ lat: lat[0], lng: lng[0] }, { lat: lat[2], lng: lng[2] }); + const seconds2 = meters2 / (50 / 3.6); const edges = [ { from: 0, to: 1, seconds, nameId: 0 }, { from: 1, to: 0, seconds, nameId: 0 }, + { from: 0, to: 2, seconds: seconds2, nameId: 0 }, + { from: 2, to: 0, seconds: seconds2, nameId: 0 }, ]; return encodeGraph({ lat, @@ -539,12 +559,42 @@ function sparseGraphBuffer() { edges, names: ["Jalan Sparse Ujian"], maxSpeedKmh: 50, - landmarks: { ...landmarkBlock(lat.length, edges, { pin: 0 }) }, + ch: chBlock(lat.length, edges), + }); +} + +/** RT9's disconnected carrier: two independent 3-node stars — each + * decodable (real shortcuts) but the component A stars around kl-tower + * and the component B stars around kl-sentra never touch, so the route + * is the honest A* miss (NOT a decode refusal). */ +function splitGraphBuffer() { + const lat = [3.1528, 3.1533, 3.1523, 3.1343, 3.1348, 3.1338]; + const lng = [101.7038, 101.7043, 101.7033, 101.6869, 101.6874, 101.6864]; + const edges = []; + const star = (mid, a, b) => { + const m1 = haversineM({ lat: lat[mid], lng: lng[mid] }, { lat: lat[a], lng: lng[a] }); + const m2 = haversineM({ lat: lat[mid], lng: lng[mid] }, { lat: lat[b], lng: lng[b] }); + edges.push( + { from: mid, to: a, seconds: m1 / (60 / 3.6), nameId: 0 }, + { from: a, to: mid, seconds: m1 / (60 / 3.6), nameId: 0 }, + { from: mid, to: b, seconds: m2 / (60 / 3.6), nameId: 0 }, + { from: b, to: mid, seconds: m2 / (60 / 3.6), nameId: 0 }, + ); + }; + star(0, 1, 2); // component A around kl-tower + star(3, 4, 5); // component B around kl-sentra — no edge bridges the two + return encodeGraph({ + lat, + lng, + edges, + names: ["Jalan Split Ujian"], + maxSpeedKmh: 60, + ch: chBlock(lat.length, edges), }); } const CORRIDOR_BUF = corridorGraphBuffer(); -const SPLIT_BUF = corridorGraphBuffer({ gap: true }); +const SPLIT_BUF = splitGraphBuffer(); const SPARSE_BUF = sparseGraphBuffer(); /** An R2 fake over one fixed binary; counts + records its gets (the memo probe). diff --git a/routing/test/astar.test.mjs b/routing/test/astar.test.mjs index 84ecb14..b494f23 100644 --- a/routing/test/astar.test.mjs +++ b/routing/test/astar.test.mjs @@ -5,14 +5,14 @@ * RED first (plan 06-01 task 3): behavior cases fail while * routing/src/astar.ts does not exist (lazy-import pattern). * - * Phase 08.1 re-pin (plan 01 task 2): routeAstar is CH-backed — the ALT + * Phase 08.1 re-pin (plan 01 tasks 2-3): routeAstar is CH-backed — the ALT * machinery is DELETED (D-04/D-05: one search path, no dormant engine). * The exhaustive equivalence law (A1's 625-pair sweep) and the oneway / * steps / classifier laws carry over UNCHANGED in intent; the - * committed-fixture cases (A1/A2/A4/A5/A6) are SUSPENDED here because - * mini-graph.bin is still ARG2 at this point in the plan — task 3 - * regenerates the fixture through the ARG3 encode path and un-suspends - * them in place. The in-memory and structural cases are re-pinned HERE: + * committed-fixture cases (A1/A2/A4/A5/A6) were held out in task 2 while + * mini-graph.bin was still ARG2 and are restored here (task 3 + * regenerated the fixture through the ARG3 encode path). The in-memory + * and structural cases were re-pinned in task 2: * * A3 unreachable -> null — honest miss (synthetic disconnected * graph, ch block through the ONE authority) @@ -24,13 +24,16 @@ * unpacking exist in ch-query.ts CODE; * astar.ts contains NO landmark token * (D-04's one-search-path pin) - * A1 exhaustive equivalence — SUSPENDED until task 3 (ARG3 fixture) - * A2 oneway respected — SUSPENDED until task 3 (ARG3 fixture) - * A4 deriveSteps L-path — SUSPENDED until task 3 (ARG3 fixture) - * A5 depart + arrive always — SUSPENDED until task 3 (ARG3 fixture) - * A6 classifyTurn pins — SUSPENDED until task 3 (ARG3 fixture) + * A1 exhaustive equivalence — 625 pairs, cost equality + path endpoints + * A2 oneway respected — the flagged edge is never traversed; the + * open direction is direct + * A4 deriveSteps L-path — 90-deg bearing changes classify left/right; + * steps carry name/distanceM/polyline6 + * A5 depart + arrive always — straight paths still start "depart" and + * end "arrive" + * A6 classifyTurn pins — sign convention: negative delta = left * - * The A9/A10/A12/A13 ALT-era cases (island / search-scoped skip / oneway + * The A9/A10/A12/A13 ALT-era cases (island / search-scoped drop / oneway * ring / mixed columns) folded into ch-query.test.mjs: their surviving * content is CH-law Dijkstra equivalence over directed fixtures — the * ALT-specific table assertions are gone with the tables (D-05). @@ -60,8 +63,13 @@ function toArrayBuffer(buf) { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); } -// loadMiniGraph() returns in task 3, when the committed fixture is ARG3 -// and the suspended cases un-suspend. +async function loadMiniGraph() { + const { decodeGraph } = await loadGraph(); + const g = decodeGraph(toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph.bin")))); + assert.ok(g, "the committed mini-graph decodes"); + return g; +} + /** Brute-force O(n^2) Dijkstra — plain arrays, SAME traversal law as CH. */ function dijkstra(g, origin, goal) { @@ -192,25 +200,136 @@ test("A11 structural (CH law) — the uphill rank filter + unpacking live in ch- /* ————— suspended committed-fixture cases (task 3 un-suspends) ————— */ -test("A1 exhaustive equivalence — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => { - // The committed fixture is still ARG2 here; the CH query's exhaustive - // 625-pair Dijkstra-equivalence law runs in ch-query.test.mjs (Q1) on - // an in-memory ARG3 grid until task 3 re-pins this case in place. - assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)"); +test("A1 exhaustive equivalence — A* matches brute-force Dijkstra on EVERY 25x25 node pair", async () => { + const { routeAstar } = await loadAstar(); + const g = await loadMiniGraph(); + let pairs = 0; + let reachable = 0; + for (let o = 0; o < g.nodeCount; o++) { + for (let t = 0; t < g.nodeCount; t++) { + pairs++; + const optimal = dijkstra(g, o, t); + const result = routeAstar(g, o, t); + if (optimal === Infinity) { + assert.equal(result, null, `pair ${o}->${t}: unreachable must resolve null, never a path`); + continue; + } + reachable++; + assert.ok(result, `pair ${o}->${t}: reachable must route`); + assert.ok( + Math.abs(result.seconds - optimal) < 1e-6, + `pair ${o}->${t}: A* cost ${result.seconds} vs Dijkstra ${optimal}`, + ); + assert.equal(result.nodes[0], o, `pair ${o}->${t}: path starts at origin`); + assert.equal(result.nodes[result.nodes.length - 1], t, `pair ${o}->${t}: path ends at goal`); + assert.ok(result.distanceM > 0 || o === t, `pair ${o}->${t}: distanceM summed over the path`); + } + } + assert.equal(pairs, 625, "the full 25x25 sweep ran"); + assert.ok(reachable > 600, `the grid is densely connected (${reachable} reachable pairs)`); }); -test("A2 oneway respected — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => { - assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)"); +test("A2 oneway respected — the flagged edge is never traversed; the open direction is direct", async () => { + const { routeAstar } = await loadAstar(); + const g = await loadMiniGraph(); + // The fixture's single oneway-reverse edge is 3 -> 2 (perimeter, row 0). + // Routing AGAINST it (3 -> 2) must take the long way around the block. + const against = routeAstar(g, 3, 2); + assert.ok(against, "3->2 routes via the detour"); + assert.equal( + against.nodes.length > 2, + true, + `the flagged 1-edge path must NOT be used (path: ${against.nodes.join("->")})`, + ); + assert.ok( + Math.abs(against.seconds - dijkstra(g, 3, 2)) < 1e-6, + "the detour cost matches Dijkstra", + ); + // The open direction is the direct edge. + const with_flow = routeAstar(g, 2, 3); + assert.ok(with_flow, "2->3 routes"); + assert.equal(with_flow.nodes.length, 2, "2->3 is the direct single edge"); + assert.ok(Math.abs(with_flow.seconds - dijkstra(g, 2, 3)) < 1e-6); }); -test("A4 deriveSteps L-path — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => { - assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)"); +test("A4 deriveSteps L-path — 90-deg turns classify, steps carry name/distance/polyline6", async () => { + const { routeAstar, deriveSteps } = await loadAstar(); + const { decodePolyline6 } = await import("../src/polyline.ts"); + const g = await loadMiniGraph(); + // 0=(r0,c0) -> 1=(r0,c1) -> 6=(r1,c1) -> 7=(r1,c2): east, then north + // (increasing row = increasing lat), then east. + const steps = deriveSteps(g, [0, 1, 6, 7]); + const kinds = steps.map((s) => s.kind); + assert.deepEqual(kinds, ["depart", "left", "right", "arrive"]); + assert.equal(steps[0].name, "Jalan Persiaran Mini", "step 0 rides the perimeter"); + assert.equal(steps[1].name, "Jalan Mini 2", "the left turn turns onto Jalan Mini 2"); + assert.equal(steps[2].name, "Jalan Mini 1", "the right turn turns onto Jalan Mini 1"); + for (const step of steps) { + if (step.kind === "arrive") continue; + assert.ok(step.distanceM > 0, `${step.kind} step has positive distance`); + const points = decodePolyline6(step.geometry); + assert.equal(points.length, 2, `${step.kind} step geometry decodes to its two endpoints`); + } + const arrive = steps[steps.length - 1]; + assert.equal(decodePolyline6(arrive.geometry).length, 1, "the arrive step anchors the goal node"); + // round-trip: a full route's steps derive from A*'s own node path + const routed = routeAstar(g, 0, 11); + assert.ok(routed, "0->11 routes"); + const routeSteps = deriveSteps(g, routed.nodes); + assert.equal(routeSteps[0].kind, "depart", "route-derived steps start with depart"); + assert.equal(routeSteps[routeSteps.length - 1].kind, "arrive", "and end with arrive"); + const total = routeSteps + .filter((s) => s.kind !== "arrive") + .reduce((sum, s) => sum + s.distanceM, 0); + assert.ok( + Math.abs(total - routed.distanceM) < 1e-6, + `step distances sum to the route distance (${total} vs ${routed.distanceM})`, + ); }); -test("A5 depart + arrive always — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => { - assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)"); +test("A5 depart + arrive always — a straight same-name path still yields [depart, arrive]", async () => { + const { deriveSteps } = await loadAstar(); + const g = await loadMiniGraph(); + const steps = deriveSteps(g, [0, 1]); + assert.deepEqual( + steps.map((s) => s.kind), + ["depart", "arrive"], + "even the simplest path starts depart and ends arrive", + ); + assert.equal(steps[0].name, "Jalan Persiaran Mini"); + assert.ok(steps[0].distanceM > 0); + assert.equal(deriveSteps(g, []).length, 0, "an empty path derives no steps"); + assert.deepEqual( + deriveSteps(g, [12]).map((s) => s.kind), + ["arrive"], + "a trivial single-node path is just the arrive step", + ); }); -test("A6 classifyTurn pins — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => { - assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)"); +test("A6 classifyTurn pins — compass convention: negative bearing delta = left", async () => { + const { classifyTurn, TURN_STRAIGHT_MAX_DEG, TURN_SLIGHT_MAX_DEG, TURN_UTURN_MIN_DEG } = await loadAstar(); + assert.equal(TURN_STRAIGHT_MAX_DEG, 25); + assert.equal(TURN_SLIGHT_MAX_DEG, 60); + assert.equal(TURN_UTURN_MIN_DEG, 150); + assert.equal(classifyTurn(0, -90), "left"); + assert.equal(classifyTurn(0, 90), "right"); + assert.equal(classifyTurn(0, 20), "straight"); + assert.equal(classifyTurn(0, -20), "straight"); + // The plan's "(0, 40) -> slight-left" contradicts its own (0,-90)->left / + // (0,90)->right cases; the compass-consistent reading is pinned: + assert.equal(classifyTurn(0, 40), "slight-right"); + assert.equal(classifyTurn(0, -40), "slight-left"); + assert.equal(classifyTurn(0, 175), "uturn"); + assert.equal(classifyTurn(0, -175), "uturn"); + assert.equal(classifyTurn(0, 180), "uturn"); + // boundaries: 25 stays straight, 61 is a full turn, 150 is uturn + assert.equal(classifyTurn(0, 25), "straight"); + assert.equal(classifyTurn(0, 26), "slight-right"); + assert.equal(classifyTurn(0, 60), "slight-right"); + assert.equal(classifyTurn(0, 61), "right"); + assert.equal(classifyTurn(0, 149), "right"); + assert.equal(classifyTurn(0, 150), "uturn"); + // wrap-around normalization + assert.equal(classifyTurn(350, 10), "straight", "20 deg across north is straight"); + assert.equal(classifyTurn(350, 80), "right", "90 deg across north is a right"); }); diff --git a/routing/test/fixtures/build-mini-graph.mjs b/routing/test/fixtures/build-mini-graph.mjs index 36969ff..fa2818e 100644 --- a/routing/test/fixtures/build-mini-graph.mjs +++ b/routing/test/fixtures/build-mini-graph.mjs @@ -3,24 +3,28 @@ * build-mini-graph.mjs — builds the committed mini-graph.bin fixture. * ---------------------------------------------------------------------- * A MAINTAINER-TIME REPO TOOL: regenerates routing/test/fixtures/ - * mini-graph.bin — the A* correctness ground truth (plan 06-01) and the - * snap/astar suites' shared fixture. NOT a runtime dependency; nothing in - * bot/src/, web/src/, or routing/src/ imports it. Tests consume the - * COMMITTED binary; only a deliberate fixture redesign re-runs this. + * mini-graph.bin — the routing correctness ground truth (plan 06-01) and + * the snap/search suites' shared fixture. NOT a runtime dependency; + * nothing in bot/src/, web/src/, or routing/src/ imports it. Tests + * consume the COMMITTED binary; only a deliberate fixture redesign re-runs + * this. * * PROVENANCE (Phase 8 plan 08-01): routing/test/fixtures/mini-graph-arg1.bin * holds the frozen ARG1-era bytes preserved expressly as the v1 * decode-rejection vector consumed by the G-suite — it is NOT regenerated * by this script, ever. + * PROVENANCE (Phase 08.1 plan 01): routing/test/fixtures/mini-graph-arg2.bin + * holds the frozen ARG2-era bytes preserved expressly as the ARG2 + * decode-rejection vector consumed by the G-suite (D-12) — it is NOT + * regenerated by this script, ever. * * Usage: node routing/test/fixtures/build-mini-graph.mjs * Exit: 0 on write; throws on any invariant violation (never writes). * - * ARG2 (plan 08-02): the fixture carries a tiny real landmark stage — - * L=2 anchors via farthestPointAnchors + quantized c-metric tables via - * landmarkTables (the SAME pure module the real build script uses), passed - * through the ONE encodeGraph authority. Deterministic: identical output - * bytes on every re-run. + * ARG3 (Phase 08.1, D-12): the fixture carries a tiny real CH stage — + * contraction via contractGraph (the SAME pure module the real build + * script uses), passed through the ONE encodeGraph authority as the `ch` + * block. Deterministic: identical output bytes on every re-run. * * Design (pinned by 06-01): a hand-built 5x5 grid in a ~0.002 deg x 0.002 deg * bbox near KL center (lat 3.1500–3.1520, lng 101.6900–101.6920): @@ -34,7 +38,7 @@ import { writeFileSync } from "node:fs"; import { encodeGraph } from "../../src/graph.ts"; import { haversineM } from "../../src/geo.ts"; -import { LANDMARK_QUANTUM_S, farthestPointAnchors, landmarkTables, landmarkCoverage } from "../../src/landmark.ts"; +import { contractGraph, CH_SHORTCUT_EXPLOSION_RATIO } from "../../src/contraction.ts"; const GRID = 5; const LAT0 = 3.15; @@ -92,27 +96,86 @@ reverse.flags = 1; if (edges.length !== 80) throw new Error(`expected 80 directed edges, built ${edges.length}`); -// LANDMARK STAGE (plan 08-02): the fixture regenerates as ARG2 THROUGH the -// one encode authority — the same farthestPointAnchors + landmarkTables the -// real build script (08-04) will run, at a tiny L. Fail-loud invariants -// before any write (the `edges.length !== 80` style). -const LANDMARK_COUNT = 2; // tiny L: the 5x5 grid needs exactly 2 well-spread anchors -const landmarkInput = { nodeCount: GRID * GRID, edges, quantumS: LANDMARK_QUANTUM_S }; -const anchors = farthestPointAnchors(landmarkInput, LANDMARK_COUNT); -const { to, from } = landmarkTables(landmarkInput, anchors); -if (anchors.length !== LANDMARK_COUNT) throw new Error(`expected ${LANDMARK_COUNT} anchors, got ${anchors.length}`); -if (to.length !== LANDMARK_COUNT * GRID * GRID || from.length !== LANDMARK_COUNT * GRID * GRID) { - throw new Error("landmark tables are not L*N row-major"); +// CH STAGE (Phase 08.1): the fixture regenerates as ARG3 THROUGH the one +// encode authority — the same contractGraph the real build script (plan 02) +// runs. Fail-loud invariants before any write (the `edges.length !== 80` +// style), per RESEARCH §Pattern 4. +const ch = contractGraph({ nodeCount: GRID * GRID, edges }); +const rankValues = [...ch.rank]; +if (new Set(rankValues).size !== GRID * GRID) { + throw new Error(`rank is not a total order — ${new Set(rankValues).size} distinct for ${GRID * GRID} nodes`); } -for (let l = 0; l < anchors.length; l++) { - const a = anchors[l]; - if (to[l * GRID * GRID + a] !== 0 || from[l * GRID * GRID + a] !== 0) { - throw new Error(`anchor ${l} (node ${a}) own cells are not 0 — table law broken`); +if (rankValues.some((r) => !Number.isInteger(r) || r < 0 || r >= GRID * GRID)) { + throw new Error("a rank value is outside [0, nodeCount)"); +} +// The shortcut band for this grid (measured 2026-10-26, this host: 41 — +// the literature's healthy ~1.4-2.0 per node; the tripwire sits at 4x). +const SHORTCUT_BAND_MIN = 20; +const SHORTCUT_BAND_MAX = 80; +if (ch.stats.shortcutCount < SHORTCUT_BAND_MIN || ch.stats.shortcutCount > SHORTCUT_BAND_MAX) { + throw new Error( + `shortcut count ${ch.stats.shortcutCount} outside the expected grid band ` + + `[${SHORTCUT_BAND_MIN}, ${SHORTCUT_BAND_MAX}] — check the contraction/witness budget`, + ); +} +// Overlay-aware weight map: a cascading shortcut's constituents are the +// OVERLAY edges it replaced (originals OR earlier shortcuts — emission +// order is the contraction order, so replaying rank[via]-ascending +// rebuilds every weight exactly). +const weight = new Map(); // "a,b" -> min overlay weight +for (const e of edges) { + if ((e.flags & 1) !== 0) continue; + const key = `${e.from},${e.to}`; + const prev = weight.get(key); + if (prev === undefined || e.seconds < prev) weight.set(key, e.seconds); +} +const shortcutEntries = []; +for (let u = 0; u < GRID * GRID; u++) { + for (let s = ch.shortcutStart[u]; s < ch.shortcutStart[u + 1]; s++) { + shortcutEntries.push({ from: u, to: ch.shortcutTarget[s], seconds: ch.shortcutSeconds[s], via: ch.shortcutVia[s] }); } } -const coverage = landmarkCoverage(to, anchors, GRID * GRID); -if (coverage.some((c) => c !== 1)) { - throw new Error(`the grid is strongly connected — coverage must be 1.0, got ${coverage.join(", ")}`); +shortcutEntries.sort((a, b) => ch.rank[a.via] - ch.rank[b.via] || a.from - b.from || a.to - b.to); +for (const sc of shortcutEntries) { + const via = sc.via; + if (!Number.isInteger(via) || via < 0 || via >= GRID * GRID) { + throw new Error(`shortcut ${sc.from}->${sc.to} has an out-of-range via ${via}`); + } + if (ch.rank[sc.from] <= ch.rank[via] || ch.rank[sc.to] <= ch.rank[via]) { + throw new Error(`shortcut ${sc.from}->${sc.to} (via ${via}) is not upward`); + } + const w1 = weight.get(`${sc.from},${via}`); + const w2 = weight.get(`${via},${sc.to}`); + if (w1 === undefined || w2 === undefined) { + throw new Error(`shortcut ${sc.from}->${sc.to} (via ${via}): a constituent overlay edge is missing`); + } + if (sc.seconds !== Math.fround(w1 + w2)) { + throw new Error(`shortcut ${sc.from}->${sc.to} weight ${sc.seconds} != f32(${w1} + ${w2})`); + } + const key = `${sc.from},${sc.to}`; + const prev = weight.get(key); + // The contraction's overlay accumulates f64 sums (the f32 rounding + // happens once, at CSR assembly) — replay the same f64 domain so the + // next cascade level compares exactly. + if (prev === undefined || w1 + w2 < prev) weight.set(key, w1 + w2); +} + +// Strong connectivity (the grid is bidirectional except the ONE oneway +// mirror — every node reaches every node). +const adj = Array.from({ length: GRID * GRID }, () => []); +for (const e of edges) { + if ((e.flags & 1) !== 0) continue; + adj[e.from].push(e.to); +} +const seen = new Uint8Array(GRID * GRID); +const stack = [0]; +seen[0] = 1; +while (stack.length > 0) { + const u = stack.pop(); + for (const v of adj[u]) if (!seen[v]) { seen[v] = 1; stack.push(v); } +} +if (seen.some((s) => s === 0)) { + throw new Error("the grid is not strongly connected — fixture invariant broken"); } const bin = encodeGraph({ @@ -121,11 +184,18 @@ const bin = encodeGraph({ edges, names, maxSpeedKmh: SPEED_PERIMETER_KMH, - landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from }, + ch: { + rank: ch.rank, + shortcutStart: ch.shortcutStart, + shortcutTarget: ch.shortcutTarget, + shortcutSeconds: ch.shortcutSeconds, + shortcutVia: ch.shortcutVia, + }, }); writeFileSync(new URL("./mini-graph.bin", import.meta.url), Buffer.from(bin)); console.log( `mini-graph.bin: ${GRID * GRID} nodes, ${edges.length} edges, 3 names, ` + - `L=${LANDMARK_COUNT} landmarks (nodes ${[...anchors].join(",")}), quantum ${LANDMARK_QUANTUM_S}s, ` + - `coverage ${coverage.map((c) => c.toFixed(2)).join("/")}, ${bin.byteLength} B`, + `CH shortcuts ${ch.stats.shortcutCount} (${ch.stats.ratioPerNode.toFixed(2)}/node, ` + + `witness searches ${ch.stats.witnessSearches}, budget hits ${ch.stats.witnessBudgetHits}), ` + + `${bin.byteLength} B`, ); diff --git a/routing/test/fixtures/mini-graph-arg2.bin b/routing/test/fixtures/mini-graph-arg2.bin new file mode 100644 index 0000000..d8c9fed Binary files /dev/null and b/routing/test/fixtures/mini-graph-arg2.bin differ diff --git a/routing/test/fixtures/mini-graph.bin b/routing/test/fixtures/mini-graph.bin index d8c9fed..1caa6d4 100644 Binary files a/routing/test/fixtures/mini-graph.bin and b/routing/test/fixtures/mini-graph.bin differ diff --git a/routing/test/graph.test.mjs b/routing/test/graph.test.mjs index 5b72579..ac590c5 100644 --- a/routing/test/graph.test.mjs +++ b/routing/test/graph.test.mjs @@ -31,6 +31,9 @@ * G3 bad version -> null — ARG1 fixture bytes -> null (D-01 one-path * law); version 1, 2 and 4 tampers -> null * (the accepted version is 3) + * G4 committed fixture — mini-graph.bin (ARG3) is 25 nodes / 80 + * edges / rank 25 / >= 1 shortcut; the frozen + * mini-graph-arg2.bin decodes to null (D-12) * G4 truncated/hostile -> null — byte-length ≠ header-declared size * (incl. huge-count header on a tiny buffer, * T-06-03b: size check BEFORE view allocation) @@ -38,10 +41,12 @@ * flipping an input byte flips the decoded view * G6 name pool — nameAt(0) === "Jalan Mini 1"; * nameAt(UNNAMED_NAME_ID) === undefined - * G7 committed fixture — SUSPENDED until task 3 regenerates - * mini-graph.bin through the ARG3 encode path - * (the committed bytes are still ARG2 here); - * task 3 re-pins this case in place + * G7 committed fixture — mini-graph.bin (ARG3, regenerated through + * the ONE encode authority) decodes: 25 nodes, + * 80 edges, 3 names, maxSpeed 60, exactly ONE + * oneway flag, a total-order rank, >= 1 + * upward shortcut; the frozen ARG2 vector + * decodes to null * G8 exported constants — GRAPH_MAGIC === 0x41524733, GRAPH_VERSION === 3 * G9 table round-trip — the CH fields restore exactly; the CH arrays * are zero-copy views over the input buffer @@ -164,9 +169,12 @@ test("G2 bad magic -> null — a tampered header never half-decodes (T-06-03)", test("G3 bad version -> null — ARG1 fixture bytes refuse; version 1, 2 and 4 tampers to null (D-01 one-path law)", async () => { const { encodeGraph, decodeGraph } = await loadGraph(); - // The frozen Plan 08-01 vector: REAL ARG1 bytes decode to null — the + // The frozen rejection vectors: REAL ARG1 bytes decode to null — the // Phase 6 router-pending state covers every pre-ARG3 binary (D-01). // There is exactly one decode path; no v1/v2 fallback branch exists. + // (mini-graph-arg2.bin — the frozen ARG2-era bytes, D-12 — is pinned in + // the committed-fixture cases G4/G7; the version-2 tamper here covers + // the same law in memory.) const arg1 = toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph-arg1.bin"))); assert.equal(decodeGraph(arg1), null, "ARG1 bytes -> null, never a throw or a fallback decode"); const buf = encodeGraph(syntheticInput()); @@ -181,6 +189,26 @@ test("G3 bad version -> null — ARG1 fixture bytes refuse; version 1, 2 and 4 t assert.equal(decodeGraph(v4), null, "version 4 -> null (the accepted version is 3)"); }); +test("G4 committed ARG3 fixture — 25 nodes / 80 edges / rank 25 / >=1 shortcut; the preserved ARG2 vector decodes to null", async () => { + const { decodeGraph, GRAPH_MAGIC, GRAPH_VERSION } = await loadGraph(); + // The committed ground truth, read as bytes: ARG3 header fields only. + const bin = readFileSync(path.join(here, "fixtures", "mini-graph.bin")); + assert.equal(bin.readUInt32LE(0), GRAPH_MAGIC, "the committed fixture is ARG3-magic"); + assert.equal(bin.readUInt32LE(4), GRAPH_VERSION, "the committed fixture is version 3"); + assert.equal(bin.readUInt32LE(8), 25, "25 nodes"); + assert.equal(bin.readUInt32LE(12), 80, "80 directed edges"); + assert.ok(bin.readUInt32LE(60) >= 1, "the committed hierarchy carries at least one shortcut"); + // Decoding sees the rank table's length too. + const g = decodeGraph(toArrayBuffer(bin)); + assert.ok(g, "the committed fixture decodes as ARG3"); + assert.equal(g.chRank.length, 25, "one rank per node"); + assert.equal(g.chShortcutCount, bin.readUInt32LE(60), "the header count agrees with the decoded graph"); + // The preserved ARG2-era bytes (frozen before this fixture's + // regeneration — D-12) decode to null: one decode path, no fallback. + const arg2 = toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph-arg2.bin"))); + assert.equal(decodeGraph(arg2), null, "ARG2 bytes -> null (the D-07 rejection vector)"); +}); + test("G4 truncated + hostile-size buffer -> null — exact byte-size check BEFORE any view allocation (T-06-03b)", async () => { const { encodeGraph, decodeGraph } = await loadGraph(); const buf = encodeGraph(syntheticInput()); @@ -227,13 +255,51 @@ test("G6 name pool — nameAt(0) resolves the first pool name; the unnamed senti assert.equal(g.nameAt(999), undefined, "out-of-range id -> undefined"); }); -test("G7 committed ARG3 fixture — SUSPENDED until task 3 regenerates mini-graph.bin through the ARG3 encode path", async () => { - // The committed mini-graph.bin is still ARG2 at this point in the plan - // (task 2 flips the codec; task 3 regenerates the fixture through the - // ONE encode authority and re-pins this case in place, together with - // the preserved mini-graph-arg2.bin rejection vector). Documented - // no-op — the ONLY suspended case in this suite. - assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)"); +test("G7 committed ARG3 fixture — decodes to 25 nodes / 80 edges / 3 names / a real hierarchy, maxSpeed 60, ONE oneway", async () => { + const { decodeGraph } = await loadGraph(); + const bin = toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph.bin"))); + assert.ok(bin.byteLength > 1000, `the committed fixture is substantial (${bin.byteLength} B)`); + const g = decodeGraph(bin); + assert.ok(g, "the committed fixture decodes cleanly (ARG3 — regenerated through the ONE encode authority, task 3)"); + assert.equal(g.nodeCount, 25, "5x5 grid"); + assert.equal(g.edgeCount, 80, "20 H + 20 V segments, both directions"); + assert.equal(g.nameCount, 3, "Jalan Mini 1 / Jalan Mini 2 / Jalan Persiaran Mini"); + assert.equal(g.nameAt(0), "Jalan Mini 1"); + assert.equal(g.nameAt(2), "Jalan Persiaran Mini"); + assert.equal(g.maxSpeedKmh, 60, "the perimeter speed class is the header max"); + // bbox of the hand-built grid (lat 3.1500–3.1520, lng 101.6900–101.6920) + assert.deepEqual(g.bbox, { minLat: 3.15, minLng: 101.69, maxLat: 3.152, maxLng: 101.692 }); + let flagged = 0; + for (let e = 0; e < g.edgeCount; e++) if ((g.edgeFlags[e] & 1) !== 0) flagged++; + assert.equal(flagged, 1, "exactly ONE oneway-reverse edge in the whole fixture"); + // both speed classes present: perimeter ~55.6 m / 16.67 ≈ 3.3 s; interior ≈ 6.7 s + let sawFast = false; + let sawSlow = false; + for (let e = 0; e < g.edgeCount; e++) { + if (g.edgeSeconds[e] < 5) sawFast = true; + if (g.edgeSeconds[e] > 5) sawSlow = true; + } + assert.ok(sawFast && sawSlow, "both speed classes (60 and 30 km/h) exist in the weights"); + // ARG3 CH laws (Phase 08.1, D-12): the fixture regenerates THROUGH the + // encode authority with the tiny real contraction stage (S = 41 on the + // 5x5 grid — the healthy ~1.6/node band). + assert.equal(g.chRank.length, 25, "one rank per node"); + assert.equal(g.shortcutStart.length, 26, "CSR row pointers: nodeCount + 1"); + assert.equal(g.shortcutTarget.length, g.chShortcutCount, "shortcut CSR lengths agree with the header"); + assert.equal(g.shortcutSeconds.length, g.chShortcutCount, "shortcut seconds length agrees"); + assert.equal(g.shortcutVia.length, g.chShortcutCount, "shortcut via length agrees"); + assert.ok(g.chShortcutCount >= 1, "the committed hierarchy carries at least one shortcut"); + // Rank is a total order (Pitfall 9) and the shortcuts are upward. + assert.equal(new Set(g.chRank).size, 25, "ranks are DISTINCT"); + for (let u = 0; u < g.nodeCount; u++) { + for (let s = g.shortcutStart[u]; s < g.shortcutStart[u + 1]; s++) { + const via = g.shortcutVia[s]; + assert.ok(g.chRank[u] > g.chRank[via] && g.chRank[g.shortcutTarget[s]] > g.chRank[via], `shortcut ${u}->${g.shortcutTarget[s]} is upward against via ${via}`); + } + } + // The preserved ARG2-era bytes decode to null (D-12's rejection vector). + const arg2 = toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph-arg2.bin"))); + assert.equal(decodeGraph(arg2), null, "ARG2 bytes -> null (the D-07 rejection vector)"); }); test("G8 exported constants — GRAPH_MAGIC === 0x41524733 ('ARG3'), GRAPH_VERSION === 3", async () => { diff --git a/routing/test/snap.test.mjs b/routing/test/snap.test.mjs index a11c021..b63464e 100644 --- a/routing/test/snap.test.mjs +++ b/routing/test/snap.test.mjs @@ -25,15 +25,11 @@ * (WeakMap; build-counter, kept loose) + * structural: WeakMap and cos() in the source * - * Phase 08.1 re-pin: S1/S2/S3/S4/S5/S6 read the committed mini-graph.bin (S3's - * perimeter half) — they are - * which is still ARG2 mid-plan — they are SUSPENDED until task 3 - * regenerates the fixture through the ARG3 encode path (the same - * suspension pattern as graph.test.mjs G7 and astar.test.mjs A1–A6). - * S3 was re-pinned NOW: its synthetic graph moved to the ARG3 ch block - * (a 3-node star — a 2-node graph can carry no valid shortcut). - * - * Run: npm run routing:test (exit 0 = all green) + * Phase 08.1 re-pin (plan 01 tasks 2-3): the committed mini-graph.bin is + * now ARG3 (regenerated through the ONE encode authority) and every case + * runs unchanged. S3's synthetic unnamed-edge graph moved to the ARG3 ch + * block (a 3-node star — a 2-node graph can carry no valid shortcut, and + * a shortcut-less ARG3 is not a road graph). */ import { strict as assert } from "node:assert"; import { readFileSync } from "node:fs"; @@ -82,36 +78,124 @@ function nodeLng(c) { /* ————— S1–S6 ————— */ -test("S1 exact-node hit — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => { - // The committed fixture is still ARG2 (decode refuses by the D-07 law); task 3 regenerates it through the ARG3 encode path and un-suspends this case in place (real body: git show f75dc9e:routing/test/snap.test.mjs). - assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)"); +test("S1 exact-node hit — a fix at node 12's coordinates snaps to node 12 at ~0 m", async () => { + const { snapToGraph } = await loadSnap(); + const g = await loadMiniGraph(); + const result = snapToGraph(g, { lat: nodeLat(2), lng: nodeLng(2) }); + assert.ok(result, "an on-graph fix snaps"); + assert.equal(result.node, 12, "the exact node wins"); + assert.ok(result.distanceM < 0.001, `distance ~ 0 (got ${result.distanceM})`); }); -test("S2 mid-edge projection — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => { - // Suspended with S1; task 3 restores the real body. - assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)"); +test("S2 mid-edge projection — a fix a quarter along edge 6->7 snaps to the closer endpoint 6, haversine distance", async () => { + const { snapToGraph } = await loadSnap(); + const { haversineM } = await loadGeo(); + const g = await loadMiniGraph(); + // Point 25% of the way from node 6 (r1,c1) to node 7 (r1,c2). + const point = { lat: nodeLat(1), lng: (101690000 + 625) / 1e6 }; + const result = snapToGraph(g, point); + assert.ok(result, "the mid-edge fix snaps"); + assert.equal(result.node, 6, "the CLOSER endpoint wins — never a fabricated mid-node"); + assert.equal(result.nameId, 0, "the best segment is 'Jalan Mini 1' (nameId 0)"); + const expected = haversineM(point, { lat: nodeLat(1), lng: nodeLng(1) }); + assert.ok( + Math.abs(result.distanceM - expected) < 1e-9, + `distanceM is the haversine distance to the endpoint (${result.distanceM} vs ${expected})`, + ); }); -test("S3 nameId — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => { - // Both halves read ARG2-era data mid-plan: the perimeter half reads the - // committed fixture, and the unnamed half's re-pinned ch block rides the - // same restore. Task 3 un-suspends with the ARG3 fixture (the unnamed - // half's 3-node-star ch-block builder is already in this file's history: - // git show :routing/test/snap.test.mjs). - assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)"); +test("S3 nameId — a perimeter fix resolves 'Jalan Persiaran Mini'; an unnamed segment resolves null", async () => { + const { snapToGraph } = await loadSnap(); + const { encodeGraph } = await loadGraph(); + const g = await loadMiniGraph(); + const atNode1 = snapToGraph(g, { lat: nodeLat(0), lng: nodeLng(1) }); + assert.ok(atNode1, "perimeter fix snaps"); + assert.notEqual(atNode1.nameId, null, "perimeter edges carry names"); + assert.equal(g.nameAt(atNode1.nameId), "Jalan Persiaran Mini"); + + // Synthetic 3-node star with UNNAMED edges -> nameId null. + // ARG3 (Phase 08.1): the ch block is required by the one encode + // authority; it comes from the ONE contraction core. A 2-node graph can + // carry no valid shortcut (no via node exists), so the fixture is the + // C4 star — mid node 0, unnamed legs to 1 and 2 — whose contraction + // emits the two endpoint shortcuts (decode refuses a shortcut-less + // ARG3). The snapped segment 0->1 covers the unnamed fix. + const { contractGraph } = await import("../src/contraction.ts"); + const starEdges = [ + { from: 0, to: 1, seconds: 4 }, + { from: 1, to: 0, seconds: 4 }, + { from: 0, to: 2, seconds: 4 }, + { from: 2, to: 0, seconds: 4 }, + ]; + const bare = encodeGraph({ + lat: [3.15, 3.1505, 3.16], + lng: [101.69, 101.6905, 101.7], + edges: starEdges, + names: [], + maxSpeedKmh: 60, + ch: contractGraph({ nodeCount: 3, edges: starEdges }), + }); + const { decodeGraph, UNNAMED_NAME_ID } = await loadGraph(); + const bg = decodeGraph(bare); + assert.ok(bg, "synthetic graph decodes"); + assert.equal(bg.edgeNameId[0], UNNAMED_NAME_ID, "the synthetic edge is unnamed"); + const unnamed = snapToGraph(bg, { lat: 3.15025, lng: 101.69025 }); + assert.ok(unnamed, "the unnamed segment still snaps"); + assert.equal(unnamed.nameId, null, "UNNAMED_NAME_ID resolves to null for consumers"); }); -test("S4 bbox margin — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => { - // Suspended with S1; task 3 restores the real body. - assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)"); +test("S4 bbox margin — outside bbox + SNAP_BBOX_MARGIN_DEG -> null; just outside the bbox still snaps", async () => { + const { snapToGraph, SNAP_BBOX_MARGIN_DEG } = await loadSnap(); + assert.equal(SNAP_BBOX_MARGIN_DEG, 0.01, "the named margin constant is 0.01 deg"); + const g = await loadMiniGraph(); + // bbox maxLat = 3.152; +0.008 above it is inside the margin AND near row-4 roads. + const justOutside = snapToGraph(g, { lat: 3.1528, lng: nodeLng(1) }); + assert.ok(justOutside, "a fix just outside the bbox but near roads still snaps"); + assert.ok(justOutside.node >= 20 && justOutside.node <= 24, "snaps to a row-4 node (ids 20..24)"); + // 0.011 deg above the bbox top -> outside bbox + margin -> honest null. + assert.equal( + snapToGraph(g, { lat: 3.163, lng: nodeLng(1) }), + null, + "beyond bbox + margin -> null (router-pending upstream, D-03)", + ); + assert.equal( + snapToGraph(g, { lat: nodeLat(1), lng: 101.703 }), + null, + "beyond bbox + margin in lng -> null", + ); }); -test("S5 oneway ignored — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => { - // Suspended with S1; task 3 restores the real body. - assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)"); +test("S5 oneway ignored — a fix near the oneway segment snaps to its endpoints like any segment", async () => { + const { snapToGraph } = await loadSnap(); + const g = await loadMiniGraph(); + // The fixture's single oneway-reverse edge is 3 -> 2 (segment 2-3, perimeter). + // A fix 25% along segment 2->3 snaps to node 2 — direction plays no part. + const point = { lat: nodeLat(0), lng: (101690000 + 1125) / 1e6 }; + const result = snapToGraph(g, point); + assert.ok(result, "the oneway segment's fix snaps"); + assert.equal(result.node, 2, "the closer endpoint of the oneway segment wins"); + assert.notEqual(result.nameId, null); + assert.equal(g.nameAt(result.nameId), "Jalan Persiaran Mini"); }); -test("S6 memoized grid — SUSPENDED until task 3 regenerates mini-graph.bin as ARG3", async () => { - // Suspended with S1 (the memo law re-pins against the ARG3 fixture in task 3). - assert.ok(true, "suspended: deferred to task 3 (fixture regeneration)"); +test("S6 memoized grid — the coarse grid builds once per RoadGraph (WeakMap) + structural gates", async () => { + const mod = await loadSnap(); + const g = await loadMiniGraph(); + const before = mod.gridBuildCount(); + mod.snapToGraph(g, { lat: nodeLat(2), lng: nodeLng(2) }); + assert.equal(mod.gridBuildCount(), before + 1, "first snap on a graph builds the grid once"); + mod.snapToGraph(g, { lat: nodeLat(0), lng: nodeLng(1) }); + mod.snapToGraph(g, { lat: nodeLat(4), lng: nodeLng(4) }); + assert.equal(mod.gridBuildCount(), before + 1, "subsequent snaps REUSE the memoized grid"); + const g2 = await loadMiniGraph(); // a second decode -> a fresh RoadGraph instance + mod.snapToGraph(g2, { lat: nodeLat(2), lng: nodeLng(2) }); + assert.equal(mod.gridBuildCount(), before + 2, "a new graph instance gets its own grid"); + + // Structural: the memo is a WeakMap and the projection scales lng by cos. + const src = readFileSync(path.join(here, "..", "src", "snap.ts"), "utf8"); + const code = src + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); + assert.ok(code.includes("WeakMap"), "the per-graph memo is a WeakMap (no global leak)"); + assert.ok(/Math\.cos\(/.test(code), "lng is cos(lat)-scaled — Pitfall 8 (never raw degrees)"); });