feat(261026-d95): isolate directional ALT search and adversarial oracle tests
This commit is contained in:
@@ -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)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user