diff --git a/routing/scripts/experiment-alt-direction-split.mjs b/routing/scripts/experiment-alt-direction-split.mjs new file mode 100644 index 0000000..47bd238 --- /dev/null +++ b/routing/scripts/experiment-alt-direction-split.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/* Branch-only directional ALT experiment. Neither production nor integrations import this. */ +import { readFileSync, existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { hostname } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { decodeGraph } from '../src/graph.ts'; +import { haversineM } from '../src/geo.ts'; +import { snapToGraph } from '../src/snap.ts'; +import { routeAstar } from '../src/astar.ts'; +import { generateCrossMetroPairs } from './measure-optimality-baseline.mjs'; +import { sweepOracle } from './measure-astar-oracle-sweep.mjs'; + +const INF = Infinity, SENTINEL = 0xffff; +class Heap { + a = []; + push(k, g, v) { + const a = this.a; let i = a.length; a.push({ k, g, v }); const x = a[i]; + while (i) { const p = (i - 1) >> 1; if (a[p].k <= k) break; a[i] = a[p]; i = p; } a[i] = x; + } + pop() { + const a = this.a, top = a[0], x = a.pop(); + if (a.length) { let i = 0; for (;;) { let j = i * 2 + 1; if (j >= a.length) break; if (j + 1 < a.length && a[j + 1].k < a[j].k) j++; if (a[j].k >= x.k) break; a[i] = a[j]; i = j; } a[i] = x; } + return top; + } + peek(dist, closed) { while (this.a.length && (closed[this.a[0].v] || dist[this.a[0].v] !== this.a[0].g)) this.pop(); return this.a[0]?.k ?? INF; } +} + +const reverseCache = new WeakMap(); +function reverse(graph) { + if (reverseCache.has(graph)) return reverseCache.get(graph); + const rows = new Uint32Array(graph.nodeCount + 1); + for (let e = 0; e < graph.edgeCount; e++) rows[graph.edgeTarget[e] + 1]++; + for (let v = 0; v < graph.nodeCount; v++) rows[v + 1] += rows[v]; + const source = new Uint32Array(graph.edgeCount), ids = new Uint32Array(graph.edgeCount), cursor = rows.slice(0, -1); + for (let u = 0; u < graph.nodeCount; u++) for (let e = graph.edgeStart[u]; e < graph.edgeStart[u + 1]; e++) { + const pos = cursor[graph.edgeTarget[e]]++; source[pos] = u; ids[pos] = e; + } + const result = { rows, source, ids }; reverseCache.set(graph, result); return result; +} + +/** Returns null on an unreachable pair; throws on a violated proof assumption. */ +export function routeDirectionSplit(graph, o, g) { + const n = graph.nodeCount; + if (!Number.isInteger(o) || !Number.isInteger(g) || o < 0 || g < 0 || o >= n || g >= n) return null; + if (o === g) return { nodes: [o], seconds: 0, expansionsForward: 0, expansionsBackward: 0, activeTo: 0, activeFrom: 0 }; + const T = [], F = [], Q = graph.landmarkQuantumS; + for (let l = 0; l < graph.landmarkCount; l++) { + const base = l * n; + if (graph.landmarkTo[base + g] !== SENTINEL) { + if (graph.landmarkTo[base + o] === SENTINEL) return null; + T.push([base, graph.landmarkTo[base + g]]); + } + if (graph.landmarkFrom[base + o] !== SENTINEL) { + if (graph.landmarkFrom[base + g] === SENTINEL) return null; + F.push([base, graph.landmarkFrom[base + o]]); + } + } + const domain = new Int8Array(n); + function inside(v) { + if (domain[v]) return domain[v] === 1; + for (const [base] of T) if (graph.landmarkTo[base + v] === SENTINEL) { domain[v] = -1; return false; } + for (const [base] of F) if (graph.landmarkFrom[base + v] === SENTINEL) { domain[v] = -1; return false; } + domain[v] = 1; return true; + } + const scale = 3.6 / graph.maxSpeedKmh; + if (!(scale > 0 && Number.isFinite(scale))) throw Error('nonpositive graph maxSpeedKmh'); + const hf = new Float64Array(n).fill(NaN), hb = new Float64Array(n).fill(NaN); + function heuristic(v, forward) { + const cache = forward ? hf : hb; + if (!Number.isNaN(cache[v])) return cache[v]; + const target = forward ? g : o; + let h = haversineM({ lat: graph.lat[v], lng: graph.lng[v] }, { lat: graph.lat[target], lng: graph.lng[target] }) * scale; + for (const [base, constant] of (forward ? T : F)) { + const cell = (forward ? graph.landmarkTo : graph.landmarkFrom)[base + v]; + if (cell === SENTINEL) throw Error('sentinel in included domain'); + h = Math.max(h, (cell - constant) * Q); + } + cache[v] = h; return h; + } + const df = new Float64Array(n).fill(INF), db = new Float64Array(n).fill(INF); + const prev = new Int32Array(n).fill(-1), next = new Int32Array(n).fill(-1); + const cf = new Uint8Array(n), cb = new Uint8Array(n), a = new Heap(), b = new Heap(), rev = reverse(graph); + df[o] = 0; db[g] = 0; a.push(heuristic(o, true), 0, o); b.push(heuristic(g, false), 0, g); + let U = INF, meeting = -1, expansionsForward = 0, expansionsBackward = 0; + while (true) { + const fa = a.peek(df, cf), bb = b.peek(db, cb); + if (fa === INF || bb === INF || fa >= U || bb >= U) break; + const forward = a.a.length <= b.a.length; + const heap = forward ? a : b, distances = forward ? df : db, other = forward ? db : df; + const closed = forward ? cf : cb, chain = forward ? prev : next; + const u = heap.pop().v, du = distances[u]; closed[u] = 1; + if (forward) expansionsForward++; else expansionsBackward++; + if (du + other[u] < U) { U = du + other[u]; meeting = u; } + const start = forward ? graph.edgeStart[u] : rev.rows[u], end = forward ? graph.edgeStart[u + 1] : rev.rows[u + 1]; + for (let r = start; r < end; r++) { + const e = forward ? r : rev.ids[r]; + if (graph.edgeFlags[e] & 1) continue; + const v = forward ? graph.edgeTarget[e] : rev.source[r]; + if (!inside(v)) continue; + const seconds = graph.edgeSeconds[e]; + // Enforce both directed inequalities on each inspected edge. Tiny f64 rounding tolerance only in assertion. + const delta = heuristic(u, forward) - heuristic(v, forward); + if (delta > seconds + 1e-7) throw Error(`inconsistent ${forward ? 'forward' : 'backward'} potential ${u}->${v}: delta=${delta}, seconds=${seconds}, Q=${Q}`); + const tentative = du + seconds; + if (tentative < distances[v]) { + if (closed[v]) throw Error('improved closed label'); + distances[v] = tentative; chain[v] = u; heap.push(tentative + heuristic(v, forward), tentative, v); + } + if (distances[v] + other[v] < U) { U = distances[v] + other[v]; meeting = v; } + } + } + if (meeting < 0) return null; + const left = []; for (let v = meeting; v !== -1; v = prev[v]) left.push(v); left.reverse(); + const right = []; for (let v = next[meeting]; v !== -1; v = next[v]) right.push(v); + const nodes = left.concat(right); + let seconds = 0; + for (let i = 1; i < nodes.length; i++) { + let e = graph.edgeStart[nodes[i - 1]], end = graph.edgeStart[nodes[i - 1] + 1]; + while (e < end && (graph.edgeTarget[e] !== nodes[i] || (graph.edgeFlags[e] & 1))) e++; + if (e === end) throw Error('invalid reconstructed edge'); + seconds += graph.edgeSeconds[e]; + } + if (Math.abs(seconds - U) > 1e-6) throw Error(`reconstructed seconds mismatch: ${seconds} vs ${U}`); + return { nodes, seconds, expansionsForward, expansionsBackward, activeTo: T.length, activeFrom: F.length }; +} + +export function dijkstra(graph, o, g) { + const dist = new Float64Array(graph.nodeCount).fill(INF), closed = new Uint8Array(graph.nodeCount), heap = new Heap(); + dist[o] = 0; heap.push(0, 0, o); + while (heap.peek(dist, closed) !== INF) { + const u = heap.pop().v; if (u === g) return dist[u]; closed[u] = 1; + for (let e = graph.edgeStart[u]; e < graph.edgeStart[u + 1]; e++) { + if (graph.edgeFlags[e] & 1) continue; + const v = graph.edgeTarget[e], nd = dist[u] + graph.edgeSeconds[e]; + if (nd < dist[v]) { dist[v] = nd; heap.push(nd, nd, v); } + } + } + return INF; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + const path = new URL('../data/kl-roadgraph.v1.bin', import.meta.url); + if (!existsSync(path)) { console.error('NOT MEASURED: missing ARG2 binary'); process.exitCode = 2; } + else { + const bytes = readFileSync(path), graph = decodeGraph(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); + if (!graph) { console.error('NOT MEASURED: undecodable ARG2 binary'); process.exitCode = 2; } + else { + console.log(`host=${hostname()} node=${process.version} sha256=${createHash('sha256').update(bytes).digest('hex')} graph=${graph.nodeCount}/${graph.edgeCount}`); + const raw = generateCrossMetroPairs(graph.bbox, { count: 600, seed: 0x08f1, minPairKm: 12, haversineM }); + const pairs = [], missing = { snap: 0 }; + for (const pair of raw) { if (pairs.length === 200) break; if (!snapToGraph(graph, pair.origin) || !snapToGraph(graph, pair.destination)) missing.snap++; else pairs.push(pair); } + if (pairs.length < 200) throw Error(`only ${pairs.length}/200 snapped pairs`); + const verdict = sweepOracle(graph, pairs, { snap: snapToGraph, astar: routeDirectionSplit, oracle: dijkstra }); + console.log(`oracle: compared=${pairs.length} snap-miss=${missing.snap} routed=${verdict.routed} unreachable=${verdict.unreachable} suboptimal=${verdict.suboptimal} oracle-mismatch=${verdict.oracleMismatch} class-break=${verdict.classBreaks} pass=${verdict.pass}`); + if (!verdict.pass) { console.error(verdict.rows.filter(r => !['pass', 'unreachable'].includes(r.verdict))); process.exitCode = 1; } + const extended = generateCrossMetroPairs(graph.bbox, { count: 100, seed: 0x08a1, minPairKm: 12, haversineM }); + const warm = (fn) => { const a = process.hrtime.bigint(); fn(); return Number(process.hrtime.bigint() - a) / 1e6; }; + console.log(`warm-up ms: experimental=${warm(() => routeDirectionSplit(graph, 0, 1)).toFixed(3)} shipped=${warm(() => routeAstar(graph, 0, 1)).toFixed(3)}`); + const times = [[], []], counts = [0, 0], misses = { snap: 0, unreachable: 0 }, active = [0, 0]; + for (let round = 0; round < 2; round++) for (let i = 0; i < extended.length; i++) { + const pair = extended[i], s = snapToGraph(graph, pair.origin), t = snapToGraph(graph, pair.destination); + if (!s || !t) { if (round === 0) misses.snap++; continue; } + const results = []; + for (const k of ((i + round) % 2 ? [1, 0] : [0, 1])) { + const st = process.hrtime.bigint(), result = k === 0 ? routeDirectionSplit(graph, s.node, t.node) : routeAstar(graph, s.node, t.node); + const ms = Number(process.hrtime.bigint() - st) / 1e6; results[k] = { result, ms }; + } + if (results[0].result === null || results[1].result === null) { + if (results[0].result !== results[1].result) throw Error(`paired reachability mismatch ${pair.pairId}`); + if (round === 0) misses.unreachable++; continue; + } + if (Math.abs(results[0].result.seconds - results[1].result.seconds) > 1e-6) throw Error(`paired cost mismatch ${pair.pairId}`); + for (let k = 0; k < 2; k++) times[k].push(results[k].ms); + counts[0] += results[0].result.expansionsForward + results[0].result.expansionsBackward; + active[0] += results[0].result.activeTo; active[1] += results[0].result.activeFrom; + } + const stats = a => { a.sort((x, y) => x - y); return `n=${a.length} median=${a[Math.floor(a.length / 2)].toFixed(3)} mean=${(a.reduce((x,y) => x + y,0)/a.length).toFixed(3)} p95=${a[Math.ceil(a.length * .95)-1].toFixed(3)} ms`; }; + console.log(`extended snap-miss=${misses.snap} unreachable=${misses.unreachable} (outside routed p95), 2 alternating rounds`); + console.log(`experimental: ${stats(times[0])} expansions=${counts[0]} active-to/from=${active.join('/')}`); + console.log(`shipped: ${stats(times[1])}; shipped expansions and expansion ratio NOT MEASURED (no production instrumentation)`); + } + } +} diff --git a/routing/test/alt-direction-split-experiment.test.mjs b/routing/test/alt-direction-split-experiment.test.mjs new file mode 100644 index 0000000..3180e2b --- /dev/null +++ b/routing/test/alt-direction-split-experiment.test.mjs @@ -0,0 +1,78 @@ +import { strict as assert } from 'node:assert'; +import { test } from './harness.mjs'; +import { encodeGraph, decodeGraph } from '../src/graph.ts'; +import { landmarkTables } from '../src/landmark.ts'; +import { routeDirectionSplit } from '../scripts/experiment-alt-direction-split.mjs'; + +function fixture(n, edges, anchors) { + const q = 1; + const tables = landmarkTables({ nodeCount: n, edges: edges.map(e => ({ ...e, flags: e.flags ?? 0 })), quantumS: q }, Uint32Array.from(anchors)); + const lat = Array(n).fill(3.14), lng = Array(n).fill(101.68); + return decodeGraph(encodeGraph({ lat, lng, edges, names: [], maxSpeedKmh: 120, landmarks: { nodes: anchors, quantumS: q, ...tables } })); +} + +// Independent O(V²) oracle, not the experiment's heap-based Dijkstra. +function oracle(g, o, target) { + const d = Array(g.nodeCount).fill(Infinity), seen = new Set(); d[o] = 0; + for (let k = 0; k < g.nodeCount; k++) { + let u = -1; + for (let v = 0; v < g.nodeCount; v++) if (!seen.has(v) && (u < 0 || d[v] < d[u])) u = v; + if (u < 0 || d[u] === Infinity) break; + if (u === target) return d[u]; + seen.add(u); + for (let e = g.edgeStart[u]; e < g.edgeStart[u + 1]; e++) if (!(g.edgeFlags[e] & 1)) d[g.edgeTarget[e]] = Math.min(d[g.edgeTarget[e]], d[u] + g.edgeSeconds[e]); + } + return Infinity; +} + +function checkAll(graph) { + assert.ok(graph); + for (let o = 0; o < graph.nodeCount; o++) for (let g = 0; g < graph.nodeCount; g++) { + const actual = routeDirectionSplit(graph, o, g), expected = oracle(graph, o, g); + assert.equal(actual === null, expected === Infinity, `${o}→${g} reachability`); + if (!actual) continue; + assert.ok(Math.abs(actual.seconds - expected) <= 1e-6, `${o}→${g} cost ${actual.seconds} != ${expected}`); + assert.equal(actual.nodes[0], o); assert.equal(actual.nodes.at(-1), g); + let sum = 0; + for (let i = 1; i < actual.nodes.length; i++) { + const a = actual.nodes[i - 1], b = actual.nodes[i]; + const edgeIds = []; + for (let e = graph.edgeStart[a]; e < graph.edgeStart[a + 1]; e++) if (graph.edgeTarget[e] === b && !(graph.edgeFlags[e] & 1)) edgeIds.push(e); + assert.equal(edgeIds.length, 1, `illegal ${a}→${b}`); + sum += graph.edgeSeconds[edgeIds[0]]; + } + assert.ok(Math.abs(sum - actual.seconds) <= 1e-6); + } +} + +test('direction split: directed ring, blocked mirrors, incoming spur and equal alternatives', () => { + checkAll(fixture(6, [ + ...[0, 1, 2, 3].map(u => ({ from: u, to: (u + 1) % 4, seconds: 2 })), + { from: 1, to: 0, seconds: 1, flags: 1 }, { from: 0, to: 3, seconds: 1, flags: 1 }, + { from: 4, to: 1, seconds: 3 }, { from: 4, to: 2, seconds: 5 }, + { from: 0, to: 5, seconds: 2 }, { from: 3, to: 5, seconds: 2 }, + ], [0, 2, 4])); +}); + +test('direction split: one-way bridge both travel directions, anchored and unanchored islands', () => { + checkAll(fixture(9, [ + { from: 0, to: 1, seconds: 1 }, { from: 1, to: 0, seconds: 1 }, + { from: 1, to: 2, seconds: 2 }, { from: 2, to: 3, seconds: 1 }, { from: 3, to: 2, seconds: 1 }, + { from: 3, to: 2, seconds: 1, flags: 1 }, + { from: 4, to: 5, seconds: 1 }, { from: 5, to: 4, seconds: 1 }, + { from: 6, to: 7, seconds: 1 }, { from: 7, to: 6, seconds: 1 }, + ], [0, 3, 4])); +}); + +test('direction split: per-node skip adversary on finite→sentinel boundary and mixed clean/dirty columns', () => { + // At edge 1→2, to(anchor 0) at 1 is finite only via 1→0, while 2 cannot reach 0. + // At edge 1→2, from(anchor 2) at 1 is sentinel and at 2 finite. + const graph = fixture(5, [ + { from: 1, to: 0, seconds: 100 }, { from: 1, to: 2, seconds: 1 }, + { from: 2, to: 3, seconds: 1 }, { from: 3, to: 2, seconds: 1 }, + { from: 3, to: 4, seconds: 1 }, { from: 4, to: 3, seconds: 1 }, + ], [0, 2, 3]); + assert.equal(graph.landmarkTo[2], 0xffff); + assert.equal(graph.landmarkFrom[2 * 5 + 1], 0xffff); + checkAll(graph); +});