diff --git a/routing/test/astar.test.mjs b/routing/test/astar.test.mjs index 1103286..ab5db0d 100644 --- a/routing/test/astar.test.mjs +++ b/routing/test/astar.test.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /* - * astar.test.mjs — typed-array A* + turn classifier + step derivation (A1–A11). + * astar.test.mjs — typed-array A* + turn classifier + step derivation (A1–A13). * ----------------------------------------------------------------------------- * RED first (plan 06-01 task 3): behavior cases fail while * routing/src/astar.ts does not exist (lazy-import pattern). @@ -32,22 +32,37 @@ * original pin named this plan as the owner) * A8 structural — no `new Map(` in astar.ts (allocation-free * hot loop: typed arrays only) - * A9 ALT island (08-03) — two-component synthetic, anchors HAND-PINNED - * inside component A only: pairs inside A route - * Dijkstra-equal with real ALT bounds live, - * pairs inside B route Dijkstra-equal on the - * haversine fallback (per-node sentinel skip), - * cross-component pairs resolve null + * A9 ALT island (08-03, F1) — two-component synthetic, anchors HAND-PINNED + * inside component A only: every to-column + * holds a sentinel (B nodes reach no anchor), + * so the F1 clean-columns law drops EVERY + * landmark search-scoped and the WHOLE + * fixture runs the haversine fallback — + * every pair still Dijkstra-equal, cross + * pairs resolve null * A10 ALT search-scoped skip — origin AND goal inside the anchorless - * component: every l's goal/origin-side - * constant is sentinel, all families drop for - * the WHOLE search (constants dropped at - * search scope, never per-node l-masking), + * component: with all columns dirty the + * landmarks drop at SEARCH scope (the active + * list is empty — never a per-node l-mask), * results still Dijkstra-equal - * A11 structural (ALT) — comment-stripped astar.ts consults the + * A11 structural (ALT, F1) — comment-stripped astar.ts consults the * ARG2 landmark tables (landmarkTo/ - * landmarkFrom/landmarkQuantumS in CODE, not - * comments); `new Map(` still absent + * landmarkQuantumS in CODE, not comments) AND + * the F1 clean-column machinery (per-graph + * memo + per-search active list; the + * per-node sentinel compare is BANNED); + * landmarkFrom still absent; `new Map(` + * still absent + * + * A12 oneway ring (08-05) — directed ring + spur, STRONGLY connected: + * every to-column is CLEAN, so the fixture + * runs its FULL active list — the ALT-active + * directed-asymmetry seal; every pair + * Dijkstra-equal + * A13 mixed columns (F1) — one-way-bottleneck twin rings, anchors in + * BOTH parts: some columns clean, some dirty + * — the active list is a strict SUBSET and + * every pair stays Dijkstra-equal * * Run: npm run routing:test (exit 0 = all green) */ @@ -379,7 +394,9 @@ test("A8 structural — no `new Map(` in astar.ts (typed arrays only in the hot * anchor reaches a node far faster than its upstream neighbour) is exactly * what made the four-family potential inconsistent on the real graph — the * D-03 gate caught it as x038/x045 suboptimality. Under the amended - * toTable-only potential EVERY pair must stay Dijkstra-equal. + * toTable-only potential EVERY pair must stay Dijkstra-equal. The fixture + * is STRONGLY connected, so under the F1 clean-columns law it runs its + * FULL active list — the ALT-active counterpart to A9's all-dirty island. */ test("A12 oneway ring under ALT — directed reach asymmetry stays Dijkstra-equal on every pair", async () => { const { encodeGraph, decodeGraph } = await loadGraph(); @@ -420,6 +437,21 @@ test("A12 oneway ring under ALT — directed reach asymmetry stays Dijkstra-equa ); assert.ok(g, "the oneway-ring synthetic decodes"); assert.equal(g.nodeCount, 9, "9 nodes: ring + spur head"); + // The F1 ALT-active seal: the fixture is STRONGLY connected — the ring + // reaches itself, and the spur head rides 8->3 into it — so every + // to-column is sentinel-FREE. The clean-columns law therefore keeps the + // FULL active list here: the directed reach asymmetry lives inside the + // triangle-exact tables (never inside a per-node skip), which is exactly + // why the sweep below stays Dijkstra-equal. + for (let l = 0; l < g.landmarkCount; l++) { + for (let v = 0; v < g.nodeCount; v++) { + assert.notEqual( + g.landmarkTo[l * g.nodeCount + v], + 0xffff, + `column ${l} is clean: node ${v} reaches anchor ${g.landmarkNodes[l]}`, + ); + } + } let pairs = 0; let reachable = 0; for (let o = 0; o < g.nodeCount; o++) { @@ -455,10 +487,25 @@ test("A9 ALT island — anchors pinned in component A only; EVERY pair still Dij assert.equal(g.landmarkFrom[l * n + v], 0xffff, `anchor ${l} cannot reach B node ${v}`); } } - // The sweep: A-internal pairs run with real ALT bounds live, B-internal - // pairs run on the haversine fallback (per-node sentinel skip), cross - // pairs resolve the honest miss. Seconds-only assertions — never - // node-path equality (D-03's tie law). + // The F1 containment corollary (debug alt-potential-4x-slowdown): every + // to-column holds a B-node sentinel, so EVERY column is dirty — the same + // shape as the current real binary (24/24 dirty). The clean-columns law + // drops every landmark search-scoped; the WHOLE fixture (A-internal pairs + // included) runs the haversine fallback — the designed sound degradation, + // never a per-node skip. + for (let l = 0; l < g.landmarkCount; l++) { + let clean = true; + for (let v = 0; v < n; v++) { + if (g.landmarkTo[l * n + v] === 0xffff) { + clean = false; + break; + } + } + assert.equal(clean, false, `column ${l} is dirty (a B-node sentinel sits in it)`); + } + // The sweep: with every column dirty, ALL pairs — A-internal included — + // route on the haversine fallback, cross pairs resolve the honest miss. + // Seconds-only assertions — never node-path equality (D-03's tie law). let pairs = 0; let crossNulls = 0; for (let o = 0; o < n; o++) { @@ -490,10 +537,11 @@ test("A10 ALT search-scoped skip — origin AND goal in the anchorless component const g = await buildIslandFixture(Uint32Array.of(0, 8)); const n = g.nodeCount; // With origin and goal inside B (9..12), every l's goal/origin-side - // constant is the 0xFFFF sentinel — so ALL families drop for the WHOLE - // search (the skip lives in the per-search constant tables, never a - // per-node l-mask mid-max) and the run is pure-haversine. The skip law: - // results stay exactly optimal regardless. + // constant is the 0xFFFF sentinel — and every COLUMN is dirty anyway + // (A9's corollary), so the F1 active list is empty and the landmarks + // drop at SEARCH scope (never a per-node l-mask mid-max): the run is + // pure-haversine. The containment law: results stay exactly optimal + // regardless. let pairs = 0; for (let o = 9; o < n; o++) { for (let t = 9; t < n; t++) { @@ -510,7 +558,7 @@ test("A10 ALT search-scoped skip — origin AND goal in the anchorless component assert.equal(pairs, 16, "the 4x4 anchorless sweep ran"); }); -test("A11 structural (ALT) — astar.ts consults the landmark tables in CODE, not comments", () => { +test("A11 structural (ALT, F1) — astar.ts consults the landmark tables + the clean-column law in CODE, not comments", () => { const src = readFileSync(path.join(here, "..", "src", "astar.ts"), "utf8"); const code = src .replace(/\/\*[\s\S]*?\*\//g, " ") @@ -527,10 +575,122 @@ test("A11 structural (ALT) — astar.ts consults the landmark tables in CODE, no // routes (the D-03 gate's x038/x045 catch). Re-adding landmarkFrom to the // search trips this pin and re-opens that bug. assert.ok(!code.includes("landmarkFrom"), "comment-stripped astar.ts must NOT consult landmarkFrom (08-05 consistency law)"); - // Raw u16 sentinel handling on the table families — one spelling of the - // sentinel (0xffff in code, never the decimal 65535). - assert.ok(code.includes("0xffff"), "the sentinel is compared raw (u16, before float conversion)"); + // The F1 shape (debug alt-potential-4x-slowdown): the per-graph + // clean-column memo and the per-search active list exist in CODE — dirty + // columns drop landmarks at SEARCH scope ("clean columns only"), the + // containment law that keeps the max-of-families cross-edge consistent. + assert.ok(code.includes("cleanColumnsFor"), "the per-graph clean-column memo must exist in code (F1)"); + assert.ok(code.includes("activeL"), "the per-search active-landmark list must exist in code (F1)"); + // NO per-node sentinel branch survives anywhere: the RC-1 bug's exact + // shape was an inline per-cell compare+skip inside bound(), which varied + // the active family set PER NODE and broke the bound across edges + // leaving the disconnected island (1/194 fresh pairs suboptimal). Every + // sentinel compare must ride the one named constant, at setup scope + // only — the literal survives only in its declaration. + assert.ok(!code.includes("=== 0xffff"), "no inline sentinel compare — the per-node skip shape is banned (F1)"); + assert.ok(code.includes("LANDMARK_SENTINEL_U16"), "the sentinel reads through the one named constant"); + // Raw u16 sentinel spelling — one spelling of the sentinel (0xffff in + // code, never the decimal 65535). + assert.ok(code.includes("0xffff"), "the sentinel is spelled 0xffff (u16, before float conversion)"); assert.ok(!code.includes("65535"), "one spelling of the sentinel: 0xffff"); // A8's law is unchanged under ALT: the hot loop stays typed arrays. assert.ok(!code.includes("new Map("), "no Map allocation — the hot loop is typed arrays"); }); + +/* ————— A13: mixed clean/dirty columns under the F1 law ————— */ + +/** + * Two directed 4-rings joined by a ONE-WAY bridge (x0 -> y0; nothing + * returns): the Y-anchor's to-column is CLEAN — every node reaches y0 (the + * X side rides x0->y0, the Y side goes around its ring) — while the + * X-anchor's column is DIRTY (the Y side can never reach back). This is + * the branch neither A9 (all-dirty) nor A12 (all-clean) exercises: the F1 + * active list is a strict SUBSET of the landmarks, and the shared Ikeda + * average stays consistent because every family inside it is a clean + * (two-sided) column. EVERY pair must stay Dijkstra-equal. + */ +test("A13 mixed columns — a one-way bridge leaves some columns clean, some dirty; the subset active list stays optimal", async () => { + const { encodeGraph, decodeGraph } = await loadGraph(); + const { routeAstar } = await loadAstar(); + const { LANDMARK_QUANTUM_S, landmarkTables } = await import("../src/landmark.ts"); + const lat = []; + const lng = []; + // X ring: nodes 0-3 around (3.12, 101.68); Y ring: nodes 4-7 around + // (3.12, 101.72) — ~4 km apart, integer / 1e6 grid (the codec's + // exact-decimal law). + for (let i = 0; i < 4; i++) { + lat.push((3120000 + Math.round(300 * Math.sin((i / 4) * 2 * Math.PI))) / 1e6); + lng.push((101680000 + Math.round(300 * Math.cos((i / 4) * 2 * Math.PI))) / 1e6); + } + for (let i = 0; i < 4; i++) { + lat.push((3120000 + Math.round(300 * Math.sin((i / 4) * 2 * Math.PI))) / 1e6); + lng.push((101720000 + Math.round(300 * Math.cos((i / 4) * 2 * Math.PI))) / 1e6); + } + const edges = []; + const ring = (base) => { + for (let i = 0; i < 4; i++) { + edges.push({ from: base + i, to: base + ((i + 1) % 4), seconds: 10, flags: 0, nameId: 0 }); // the one-way hop + edges.push({ from: base + ((i + 1) % 4), to: base + i, seconds: 10, flags: 1, nameId: 0 }); // bit0: stored, never relaxed + } + }; + ring(0); // X: one-way ring + ring(4); // Y: one-way ring + edges.push({ from: 0, to: 4, seconds: 10, flags: 0, nameId: 0 }); // the one-way bridge x0 -> y0 + const anchors = new Uint32Array([0, 4]); // hand-pinned: one anchor per part + const { to, from } = landmarkTables( + { nodeCount: 8, edges, quantumS: LANDMARK_QUANTUM_S }, + anchors, + ); + const g = decodeGraph( + encodeGraph({ + lat, + lng, + edges, + names: ["Bridge Road"], + maxSpeedKmh: 60, + landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from }, + }), + ); + assert.ok(g, "the twin-ring synthetic decodes"); + assert.equal(g.nodeCount, 8, "8 nodes: two directed rings"); + assert.equal(g.landmarkNodes[0], 0, "landmark 0 is x0"); + assert.equal(g.landmarkNodes[1], 4, "landmark 1 is y0"); + // The fixture's MIXED law: the X-anchor to-column is dirty (Y-node + // sentinels — no way back across the bridge), the Y-anchor to-column is + // clean (every node reaches y0). The active list must therefore be the + // strict subset {y0}. + let xColumnClean = true; + let yColumnClean = true; + for (let v = 0; v < g.nodeCount; v++) { + if (g.landmarkTo[v] === 0xffff) xColumnClean = false; // column 0: anchor x0 + if (g.landmarkTo[g.nodeCount + v] === 0xffff) yColumnClean = false; // column 1: anchor y0 + } + assert.equal(xColumnClean, false, "the X-anchor column is dirty (Y nodes sentinel behind the one-way bridge)"); + assert.equal(yColumnClean, true, "the Y-anchor column is clean (every node reaches y0)"); + // The full 8x8 sweep: every reachable pair Dijkstra-equal (X->Y crosses + // the bridge under the subset active list), Y->X the honest null. + let pairs = 0; + let reachable = 0; + let crossNulls = 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`); + crossNulls++; + continue; + } + reachable++; + assert.ok(result, `pair ${o}->${t}: reachable must route`); + assert.ok( + Math.abs(result.seconds - optimal) < 1e-6, + `pair ${o}->${t}: subset-ALT cost ${result.seconds} vs Dijkstra ${optimal}`, + ); + } + } + assert.equal(pairs, 64, "the full 8x8 sweep ran"); + assert.equal(reachable, 48, "48 reachable pairs: 12 per ring + 16 over the bridge"); + assert.equal(crossNulls, 16, "every Y->X pair is a null (the bridge is one-way)"); +});