test(08): RED — re-spec ALT suite to the F1 clean-columns law (debug alt-potential-4x-slowdown)
- A9: island fixture re-pinned as ALL-DIRTY (every to-column holds a B-node sentinel -> every landmark drops search-scoped -> the whole fixture runs the haversine fallback); the old 'real ALT bounds live in A' claim is gone — it described the per-node sentinel skip RC-1 removed - A10: mechanism comment updated to the containment law (active list empty at search scope, never a per-node l-mask) - A11: structural pin rewritten to the F1 bound() shape — cleanColumnsFor memo + activeL list must exist in code; inline '=== 0xffff' sentinel compares BANNED (RC-1's exact shape); named-constant law pinned — FAILS ON SHIPPED CODE (the RED gate) - A12: re-sealed as the ALT-ACTIVE counterpart — all to-columns clean (strongly connected ring + spur), pinned by table scan - A13 (new): mixed clean/dirty columns via a one-way-bottleneck twin-ring fixture — the subset-active-list branch neither A9 nor A12 exercises; 64-pair Dijkstra equivalence + honest nulls Fails today by design: A11 (4 pins) against the shipped per-node skip.
This commit is contained in:
+187
-27
@@ -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)");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user