diff --git a/routing/scripts/build-road-graph.mjs b/routing/scripts/build-road-graph.mjs index fe536bd..cadcb58 100644 --- a/routing/scripts/build-road-graph.mjs +++ b/routing/scripts/build-road-graph.mjs @@ -18,24 +18,24 @@ * Pitfall 1: an unfiltered highway=* graph is the classic CPU-budget kill). * 3. Ways → nodes (deduplicated by rounded coordinate) → directed edges with * oneway flags, speed-derived seconds, and street-name pool ids. - * 4. THE ALT PREPROCESSING STAGE (Phase 8, ladder rung c2): the compacted - * edges feed the ONE landmark module (../src/landmark.ts — this script - * quantizes nothing and implements nothing itself): farthestPointAnchors - * picks L = clamp(round(N/13000), 16, 32) road-network anchors (D-02 - * scaling rule; anchors are NOT POIs), landmarkTables runs the integer - * ĉ-metric Dijkstras, and landmarkCoverage gates every anchor at - * ≥ LANDMARK_MIN_COVERAGE_FRACTION node reach — any gate failure exits 1 - * before a byte is written. + * 4. THE CH PREPROCESSING STAGE (Phase 08.1): the compacted edges feed the + * ONE contraction module (../src/contraction.ts — this script quantizes + * nothing and implements nothing itself): contractGraph runs the + * deterministic ordering, the overlay-only bounded witness search, and + * the directed shortcut emission. Any throw names the stage and its + * reason and exits 1 BEFORE a byte is written. * 5. encodeGraph (../src/graph.ts — the layout's SINGLE authority) produces - * the pinned ARG2 CSR binary from the edge set + the landmark block. - * COLLECT-THEN-WRITE: routing/data/kl-roadgraph.v1.bin is written ONLY - * after the encode and the stats gate both pass — any failure exits 1 - * with the previous binary untouched. - * 6. THE STATS GATE prints nodeCount/edgeCount/nameCount/bbox/landmark - * facts/binary size and enforces the D-01 envelope: size ≤ - * MAX_BINARY_BYTES or exit 1. A result below the expected 30–80 MB band - * prints an ADVISORY warning (A2's forecast) — the ENFORCED floor is the - * 1 MB honesty check in --verify, never the forecast band. + * the pinned ARG3 CSR binary from the edge set + the CH tables (the `ch:` + * input — rank + shortcut CSR with unpacking vias). COLLECT-THEN-WRITE: + * routing/data/kl-roadgraph.v1.bin is written ONLY after the encode and + * the stats gate both pass — any failure exits 1 with the previous + * binary untouched. + * 6. THE STATS GATE prints nodeCount/edgeCount/bbox/CH facts (contracted + * nodes, shortcuts + ratio, witness searches + budget hits, CH-section + * bytes, build wall-clock, total bytes — D-08/D-09: MEASURED, never + * hoped) and enforces the D-01 envelope: outside 1-80 MB → exit 1 + * without writing. There is NO fixed ARG3 payload ceiling (D-08): the + * build ships whatever contraction produces, measured. * * The binary is a BUILD ARTIFACT, not committed: routing/data/.gitignore * ignores *.bin — forks build + upload their own (ODbL-derived data stays out @@ -45,19 +45,21 @@ * * Usage: * node routing/scripts/build-road-graph.mjs - * # full build: fetch → landmarks → encode → gate → write - * # (network fetch; minutes on the KL bbox) - * node routing/scripts/build-road-graph.mjs --landmarks - * # full build with an explicit landmark count — the D-02 - * # MEASURED knob (raise L within/at the top of the rule for a - * # CPU-gate retry; hard-capped at 128, never a table bomb) + * # full build: fetch → contract (CH) → encode → gate → write + * # (network fetch; the CH stage is Dijkstra-heavy — + * # D-09: minutes, not hours, and the wall-clock is + * # printed either way) * node routing/scripts/build-road-graph.mjs --check - * # decode an existing binary with decodeGraph, print stats - * # incl. the landmark facts, exit 0/1 — NO network + * # decode an existing ARG3 binary with decodeGraph, print the + * # stats incl. the CH facts, exit 0/1 — NO network. --check is + * # a decode+facts tool for ANY valid ARG3 (the committed test + * # fixture included): the envelope appears as an ADVISORY line; + * # the ENFORCED 1-80 MB gates run in the build path, before + * # any write. * * Exit codes: 0 built and gated (or checked green); 1 pre-flight failure, - * fetch failure, landmark coverage/quantum-gate failure, envelope-gate - * failure, or --check decode failure. + * fetch failure, CH contraction/encode failure, envelope-gate failure, or + * --check decode failure. */ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; @@ -76,20 +78,17 @@ const USER_AGENT = "araharah-roadgraph/1.0 (build-time KL road graph; repo: arah /** D-01 envelope ceiling — a larger binary must NOT be written (shrink the * class filter or the bbox instead). */ const MAX_BINARY_BYTES = 80 * 1024 * 1024; -/** A2's honest-build forecast floor — crossing it downward is an ADVISORY - * (verify the class filter), never a gate. */ -const EXPECTED_MIN_BYTES = 30 * 1024 * 1024; /** How far the computed bbox extends beyond the committed corpus extremes. */ const BBOX_MARGIN_DEG = 0.05; /** Node dedup granularity: 1e-6 deg ≈ 0.11 m — far under GPS noise. */ const COORD_ROUND_DECIMALS = 6; /** drivable classes ONLY (06-RESEARCH Pitfall 1): footway/cycleway/service/ - * path are deliberately absent — a walking path must never enter a road A*. - * The *_link classes are LOAD-BEARING (measured 2026-10-24): they are the - * interchange ramps tying motorways/trunk/primary into the surface network. - * Without them the graph FRAGMENTS — 7.7% of nodes sat in severed motorway - * islands and golden corpus pairs resolved "unreachable" because an endpoint - * snapped onto an isolated fragment. + * path are deliberately absent — a walking path must never enter a road + * search. The *_link classes are LOAD-BEARING (measured 2026-10-24): they + * are the interchange ramps tying motorway/trunk/primary into the surface + * network. Without them the graph FRAGMENTS — 7.7% of nodes sat in severed + * motorway islands and golden corpus pairs resolved "unreachable" because + * an endpoint snapped onto an isolated fragment. * * A1 MITIGATION LADDER HISTORY (measure-route-cpu.mjs is the arbiter): * 2026-10-24 full 13-class linked graph (311k nodes): p95 38.4 ms, ALL @@ -99,14 +98,17 @@ const COORD_ROUND_DECIMALS = 6; * UNREACHABLE — the dropped classes were the fine mesh tying landmarks * to the arterial network. REJECTED on correctness (D-03: an honest * router-pending for half the product's destinations is not a smaller - * graph, it is a broken one). REVERTED to the full 13-class set; the - * CPU gate is carried by ladder step (b) — bidirectional A* in - * routing/src/astar.ts — and the recorded verdict in the plan summary. - * Ladder step (c) — Workers Paid — breaks the $0 law and requires user - * sign-off; it is NOT applied in this script. - * Ladder rung (c2) — EXECUTED in this script's preprocessing stage (Phase 8): - * build-time landmark tables + ALT steering; the verdict lives in - * measure-route-cpu.mjs's history and docs/ROAD-GRAPH.md. */ + * graph, it is a broken one). REVERTED to the full 13-class set. + * Ladder step (b) — bidirectional A*: sound, no worse, better on short + * product routes; kept until superseded. + * Ladder step (c) — Workers Paid — breaks the $0 law and requires user + * sign-off; it is NOT applied in this script. + * Ladder rung (c2) — EXECUTED in Phase 8 (build-time ALT tables): the + * CPU verdict measured over budget; the c3 degradation class stayed. + * Ladder lever (Phase 08.1) — Contraction Hierarchies: THIS script's CH + * preprocessing stage (step 4 below) replaces steering with structure. + * The verdict lives in measure-route-cpu.mjs's history and the budget + * record — the class retires only on a passing measurement (D-01). */ const ACTIVE_CLASSES = new Set([ "motorway", "trunk", @@ -141,51 +143,19 @@ const LINK_CLASS_SPEED_KMH = { secondary_link: 40, tertiary_link: 40, }; -/** ENFORCED floor — an at/below-1 MB "KL road graph" is a broken build (D-01). */ +/** ENFORCED floor — an at/below-1 MB "KL road graph" is a broken build (D-01). + * The floor and the ceiling above are the ONLY byte gates: the ARG3 payload + * itself is measured-first (D-08) — no pre-committed CH payload ceiling. */ const MIN_BINARY_BYTES = 1 * 1024 * 1024; -/** D-02 scaling rule: the landmark count is NODE-COUNT-PROPORTIONAL, never a - * fixed generous constant — per-landmark table bytes scale with N (~1.19 - * MB/landmark at KL's 311k nodes), so over-provisioning explodes at Malaysia - * scale exactly where ALT's advantage itself narrows (that tier is - * Contraction Hierarchies' job, still deferred). L = clamp(round(N/13,000), - * 16, 32) → ~24 at KL scale; a future Malaysia-wide build re-derives the - * count from the same rule at its own scale. */ -const LANDMARK_COUNT_MIN = 16; -const LANDMARK_COUNT_MAX = 32; -const NODES_PER_LANDMARK = 13000; // 311,325 / 13,000 ≈ 24 at KL scale -/** Build-time coverage gate: every anchor's ĉ-Dijkstra must reach at least - * this fraction of nodes (the measured KL giant SCC covers ~0.98 — the - * headroom is honest). The SAME fraction gates ANY --landmarks value: the - * override cannot buy a weaker gate (T-08-11). */ -const LANDMARK_MIN_COVERAGE_FRACTION = 0.9; -/** --landmarks hard cap — the knob exists for measured retries within/at the - * top of the D-02 rule, not for unbounded table bombs (T-08-10). The 80 MB - * envelope gate fires before any write regardless. */ -const LANDMARK_COUNT_OVERRIDE_MAX = 128; + +const mb = (b) => `${(b / (1024 * 1024)).toFixed(1)} MB`; function fail(message) { console.error(`build-road-graph: ${message}`); process.exit(1); } -/* ————— --landmarks override (the D-02 measured knob; parsed BEFORE --check) ————— */ - -const landmarksIdx = process.argv.indexOf("--landmarks"); -let landmarksOverride = null; -if (landmarksIdx !== -1) { - const raw = process.argv[landmarksIdx + 1]; - const n = raw === undefined ? NaN : Number(raw); - if (!Number.isInteger(n) || n < 1) { - fail(`--landmarks requires a positive integer count, got ${raw === undefined ? "(missing value)" : `"${raw}"`}`); - } - if (n > LANDMARK_COUNT_OVERRIDE_MAX) { - fail(`--landmarks ${n} exceeds the hard cap ${LANDMARK_COUNT_OVERRIDE_MAX} — the knob exists for measured ` + - `retries within/at the top of the D-02 rule, not for unbounded table bombs`); - } - landmarksOverride = n; -} - -/* ————— --check mode: decode + stats, no network ————— */ +/* ————— --check mode: decode + CH facts, no network ————— */ const checkIdx = process.argv.indexOf("--check"); if (checkIdx !== -1) { @@ -195,22 +165,10 @@ if (checkIdx !== -1) { const { decodeGraph } = await import("../src/graph.ts"); const buffer = readFileSync(file); const graph = decodeGraph(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)); - if (graph === null) fail(`--check: ${file} is NOT a valid GRAPH_VERSION CSR binary (decodeGraph null)`); + if (graph === null) fail(`--check: ${file} is NOT a valid ARG3 CSR binary (decodeGraph null)`); const size = statSync(file).size; - // ARG2 landmark facts (Phase 8): decoded straight off the binary — the - // SAME ONE module computes the coverage audit here; NO network. - const { LANDMARK_SENTINEL, landmarkCoverage } = await import("../src/landmark.ts"); - let toSentinels = 0; - for (let i = 0; i < graph.landmarkTo.length; i++) { - if (graph.landmarkTo[i] === LANDMARK_SENTINEL) toSentinels += 1; - } - let fromSentinels = 0; - for (let i = 0; i < graph.landmarkFrom.length; i++) { - if (graph.landmarkFrom[i] === LANDMARK_SENTINEL) fromSentinels += 1; - } - const coverage = landmarkCoverage(graph.landmarkTo, graph.landmarkNodes, graph.nodeCount); - const minCoverage = Math.min(...coverage); - const meanCoverage = coverage.reduce((a, b) => a + b, 0) / coverage.length; + // ARG3 CH facts (Phase 08.1): read straight off the decoded views — the + // SAME ONE codec module decoded the bytes; NO network. console.log( `--check ${file}\n` + ` nodeCount: ${graph.nodeCount}\n` + @@ -219,14 +177,17 @@ if (checkIdx !== -1) { ` maxSpeedKmh: ${graph.maxSpeedKmh}\n` + ` bbox: [${graph.bbox.minLat}, ${graph.bbox.minLng}, ${graph.bbox.maxLat}, ${graph.bbox.maxLng}]\n` + ` bytes: ${size}\n` + - ` landmarkCount: ${graph.landmarkCount}\n` + - ` landmarkQuantumS: ${graph.landmarkQuantumS.toFixed(3)}\n` + - ` landmarkNodes: ${[...graph.landmarkNodes].join(", ")}\n` + - ` landmark coverage min/mean: ${minCoverage.toFixed(3)} / ${meanCoverage.toFixed(3)}\n` + - ` landmark sentinel cells: ${toSentinels} to / ${fromSentinels} from`, + ` chShortcutCount: ${graph.chShortcutCount}\n` + + ` shortcut ratio: ${(graph.chShortcutCount / graph.nodeCount).toFixed(2)} per node\n` + + ` chRank length: ${graph.chRank.length}`, ); - if (size > MAX_BINARY_BYTES) fail(`--check: ${size} bytes exceeds the ${MAX_BINARY_BYTES}-byte D-01 envelope`); - if (size <= MIN_BINARY_BYTES) fail(`--check: ${size} bytes is at/below the ${MIN_BINARY_BYTES}-byte floor — not a real road graph`); + // Advisory only (see the usage note): --check serves any valid ARG3, the + // committed test fixture included. The ENFORCED gates run in the build path. + if (size > MAX_BINARY_BYTES || size <= MIN_BINARY_BYTES) { + console.log( + ` envelope advisory: ${mb(size)} sits outside the 1-80 MB D-01 envelope a built KL metro graph lands in (advisory only)`, + ); + } process.exit(0); } @@ -372,7 +333,7 @@ for (const way of ways) { // forward direction: blocked when the way is reverse-only rawEdges.push({ from, to, seconds, flags: reverseOnly ? 1 : 0, nameId }); // reverse direction: blocked when the way is forward-only (bit0 set — - // stays visible to snapping, never traversed by A*) + // stays visible to snapping, never traversed by the search) rawEdges.push({ from: to, to: from, seconds, flags: forwardOnly ? 1 : 0, nameId }); } from = to; @@ -380,7 +341,8 @@ for (const way of ways) { } /* Degree-0 cleanup: a node that lost every segment (collapsed self-loops) is - * dead weight — compact the id space so A* and the snap grid never scan it. */ + * dead weight — compact the id space so the search and the snap grid never + * scan it. */ const degree = new Uint32Array(latOut.length); for (const edge of rawEdges) { degree[edge.from] += 1; @@ -402,86 +364,53 @@ const edges = rawEdges const droppedNodes = latOut.length - cLat.length; if (cLat.length === 0 || edges.length === 0) fail("the extract produced an empty graph — refusing to write"); -/* ————— ALT preprocessing stage (Phase 8 rung c2): landmark tables through the ONE module ————— +/* ————— CH preprocessing stage (Phase 08.1): the hierarchy through the ONE module ————— * * This script quantizes nothing and implements nothing itself: the graph's - * edges feed ../src/landmark.ts (farthestPointAnchors + landmarkTables + - * landmarkCoverage) — there is no second integer-Dijkstra implementation here - * (single-authority law). Gates run BEFORE any write: - * - quantum ceiling: a landmarkTables throw means the ĉ metric overflowed - * the u16 domain → raise LANDMARK_QUANTUM_S in landmark.ts, never clamp - * (Pitfall 5); - * - coverage: every anchor reaches ≥ LANDMARK_MIN_COVERAGE_FRACTION of the - * graph — a fragmented extract fails honestly, it is never forced through. + * edges feed ../src/contraction.ts (contractGraph — ordering + contraction + + * the overlay-only bounded witness search + shortcut CSR emission). There is + * no second CH implementation here (single-authority law). The stage and the + * encode share one wall-clock measurement (D-09: the number prints either + * way — an over-run is an honest measurement, not a hidden failure), and any + * throw names the stage and exits 1 BEFORE a byte is written (T-08.1-10: + * the previous binary survives every build failure — collect-then-write). */ -const { LANDMARK_QUANTUM_S, LANDMARK_SENTINEL, farthestPointAnchors, landmarkTables, landmarkCoverage } = - await import("../src/landmark.ts"); - -// D-02 scaling rule: the count derives from THIS graph's node count; a -// --landmarks override (validated at argv time) replaces the rule wholesale. -const ruleRaw = Math.round(cLat.length / NODES_PER_LANDMARK); -const ruleCount = Math.min(LANDMARK_COUNT_MAX, Math.max(LANDMARK_COUNT_MIN, ruleRaw)); -const landmarkCount = landmarksOverride ?? ruleCount; -console.log( - `landmarks: rule round(${cLat.length} / ${NODES_PER_LANDMARK}) = ${ruleRaw} → clamped ${ruleCount}` + - (landmarksOverride !== null ? `; --landmarks override → L = ${landmarkCount}` : `; L = ${landmarkCount}`), -); - -// The module's input shape — projected ONCE (no mutation of the encode-side -// edge array; encodeGraph re-groups edges itself). -const lmEdges = edges.map((e) => ({ from: e.from, to: e.to, seconds: e.seconds, flags: e.flags })); -const lmInput = { nodeCount: cLat.length, edges: lmEdges, quantumS: LANDMARK_QUANTUM_S }; -const anchors = farthestPointAnchors(lmInput, landmarkCount); -if (anchors.length === 0) { - fail("farthestPointAnchors found no degree>=2 candidate — the extract has no junction-ish node (degenerate/fragmented graph)"); -} -if (anchors.length < landmarkCount) { - fail(`farthestPointAnchors returned only ${anchors.length} of ${landmarkCount} requested anchors (candidates exhausted) — ` + - `refusing to encode a weaker ALT than the rule asked for; investigate the extract before rebuilding`); -} -let to; -let from; -try { - ({ to, from } = landmarkTables(lmInput, anchors)); -} catch (err) { - fail(`landmark tables exceeded the u16 quantum ceiling (${err.message}) — raise LANDMARK_QUANTUM_S in ` + - `routing/src/landmark.ts and rebuild (Pitfall 5: raise the quantum, never clamp)`); -} -// Coverage gate (research Pattern 3, layer 1): the SAME fraction for ANY L — -// the override cannot lower it (T-08-11). -const coverage = landmarkCoverage(to, anchors, cLat.length); -const minCoverage = Math.min(...coverage); -if (minCoverage < LANDMARK_MIN_COVERAGE_FRACTION) { - const worst = coverage.indexOf(minCoverage); - fail( - `landmark coverage gate FAILED at L=${landmarkCount}: anchor ${worst} (node ${anchors[worst]}) reaches only ` + - `${(minCoverage * 100).toFixed(1)}% of nodes (gate: ≥ ${(LANDMARK_MIN_COVERAGE_FRACTION * 100).toFixed(0)}%). ` + - `The KL extract is expected to be ONE giant SCC covering ~98% of nodes (2026-10-24 build fact) — ` + - `a miss this large means a fragmented extract; investigate the fetch, do NOT lower the gate.`, - ); -} -const meanCoverage = coverage.reduce((a, b) => a + b, 0) / coverage.length; -// Sentinel audit — cheap honesty; --check mirrors it from the written bytes. -let toSentinels = 0; -for (let i = 0; i < to.length; i++) if (to[i] === LANDMARK_SENTINEL) toSentinels += 1; -let fromSentinels = 0; -for (let i = 0; i < from.length; i++) if (from[i] === LANDMARK_SENTINEL) fromSentinels += 1; - -/* ————— encode (the layout's single authority) + stats gate ————— */ +const { contractGraph } = await import("../src/contraction.ts"); const names = [...nameIds.keys()]; -const buffer = encodeGraph({ - lat: cLat, - lng: cLng, - edges, - names, - maxSpeedKmh: maxSeenKmh, - landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from }, -}); +// The module's input shape — projected ONCE (no nameId: the CH overlay runs +// on { from, to, seconds, flags }; encodeGraph re-groups the full edges itself). +const chEdges = edges.map((e) => ({ from: e.from, to: e.to, seconds: e.seconds, flags: e.flags })); + +const buildT0 = process.hrtime.bigint(); +let ch; +let buffer; +try { + ch = contractGraph({ nodeCount: cLat.length, edges: chEdges }); + buffer = encodeGraph({ + lat: cLat, + lng: cLng, + edges, + names, + maxSpeedKmh: maxSeenKmh, + ch: { + rank: ch.rank, + shortcutStart: ch.shortcutStart, + shortcutTarget: ch.shortcutTarget, + shortcutSeconds: ch.shortcutSeconds, + shortcutVia: ch.shortcutVia, + }, + }); +} catch (err) { + fail(`CH preprocessing stage failed: ${err.message}`); +} +const buildMs = Number(process.hrtime.bigint() - buildT0) / 1e6; const bytes = buffer.byteLength; -const mb = (b) => `${(b / (1024 * 1024)).toFixed(1)} MB`; +/* ————— stats print (measured, BEFORE the gates — a gate refusal still + * leaves every number on the terminal) + the D-01 envelope gate ————— */ + console.log( `graph stats:\n` + ` nodeCount: ${cLat.length}${droppedNodes > 0 ? ` (${droppedNodes} degree-0 dropped)` : ""}\n` + @@ -493,16 +422,17 @@ console.log( ` binary size: ${mb(bytes)} (${bytes} B)`, ); -// The landmark facts print BEFORE the size gates — the gate numbers exist -// outside the terminal even when a gate then refuses the write. -const tableBytes = 2 * landmarkCount * cLat.length * 2 + 4 * landmarkCount; +// The CH facts (D-08/D-09): shortcut economy, witness behavior, section +// bytes, wall-clock, total bytes — the measured payload, never a forecast. console.log( - `landmark stats (ALT preprocessing through the ONE module):\n` + - ` L: ${landmarkCount} (D-02 rule ${ruleCount}${landmarksOverride !== null ? `, --landmarks override ${landmarksOverride}` : ""})\n` + - ` quantum: ${LANDMARK_QUANTUM_S} s (the ĉ unit)\n` + - ` coverage min/mean: ${minCoverage.toFixed(3)} / ${meanCoverage.toFixed(3)} (gate ≥ ${LANDMARK_MIN_COVERAGE_FRACTION})\n` + - ` table bytes: ${tableBytes} (${mb(tableBytes)} = 2 families × L·N u16 + L u32 anchors)\n` + - ` sentinel cells: ${toSentinels} to / ${fromSentinels} from`, + `CH stats (contraction through the ONE module):\n` + + ` contracted nodes: ${cLat.length}\n` + + ` shortcuts: ${ch.stats.shortcutCount} (${ch.stats.ratioPerNode.toFixed(2)} per node)\n` + + ` witness searches: ${ch.stats.witnessSearches} (${ch.stats.witnessBudgetHits} hit the settle budget)\n` + + ` chRank bytes: ${4 * cLat.length}\n` + + ` shortcut bytes: ${12 * ch.stats.shortcutCount}\n` + + ` build wall-clock: ${buildMs.toFixed(0)} ms\n` + + ` total binary: ${mb(bytes)} (${bytes} B)`, ); // THE ENFORCED GATE (D-01): outside the 1-80 MB envelope → exit 1 WITHOUT writing. @@ -514,19 +444,11 @@ if (bytes <= MIN_BINARY_BYTES) { fail(`binary is ${bytes} bytes — at/below the ${MIN_BINARY_BYTES}-byte floor; a real metro graph is never this small. ` + `Verify the Overpass query actually returned ways, then rebuild.`); } -// THE ADVISORY BAND (A2's 18–50 MB forecast vs D-01's 30–80 MB expectation): -// a small graph is a review prompt, not a failure — the enforced gate stays. -if (bytes < EXPECTED_MIN_BYTES) { - console.log( - `WARNING: ${mb(bytes)} is below the expected 30–80 MB band — verify the drivable-class filter ` + - `(A2 forecast: 18–50 MB for KL metro). Advisory only; the enforced gate is 1–80 MB.`, - ); -} /* ————— collect-then-write (the committed/previous binary survives failures) ————— */ mkdirSync(dirname(OUT_PATH), { recursive: true }); writeFileSync(OUT_PATH, Buffer.from(buffer)); -console.log(`wrote ${OUT_PATH} — ${mb(bytes)}, ${cLat.length} nodes, ${edges.length} edges`); +console.log(`wrote ${OUT_PATH} — ${mb(bytes)}, ${cLat.length} nodes, ${edges.length} edges, ${ch.stats.shortcutCount} shortcuts`); console.log("next: node routing/scripts/upload-road-graph.mjs # push the binary to your R2 bucket"); process.exit(0);