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:
araharah-executor
2026-10-25 22:05:55 +08:00
parent 8aa9f489ec
commit c071e205e4
+187 -27
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/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 * RED first (plan 06-01 task 3): behavior cases fail while
* routing/src/astar.ts does not exist (lazy-import pattern). * routing/src/astar.ts does not exist (lazy-import pattern).
@@ -32,22 +32,37 @@
* original pin named this plan as the owner) * original pin named this plan as the owner)
* A8 structural — no `new Map(` in astar.ts (allocation-free * A8 structural — no `new Map(` in astar.ts (allocation-free
* hot loop: typed arrays only) * hot loop: typed arrays only)
* A9 ALT island (08-03) — two-component synthetic, anchors HAND-PINNED * A9 ALT island (08-03, F1) — two-component synthetic, anchors HAND-PINNED
* inside component A only: pairs inside A route * inside component A only: every to-column
* Dijkstra-equal with real ALT bounds live, * holds a sentinel (B nodes reach no anchor),
* pairs inside B route Dijkstra-equal on the * so the F1 clean-columns law drops EVERY
* haversine fallback (per-node sentinel skip), * landmark search-scoped and the WHOLE
* cross-component pairs resolve null * 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 * A10 ALT search-scoped skip — origin AND goal inside the anchorless
* component: every l's goal/origin-side * component: with all columns dirty the
* constant is sentinel, all families drop for * landmarks drop at SEARCH scope (the active
* the WHOLE search (constants dropped at * list is empty — never a per-node l-mask),
* search scope, never per-node l-masking),
* results still Dijkstra-equal * 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/ * ARG2 landmark tables (landmarkTo/
* landmarkFrom/landmarkQuantumS in CODE, not * landmarkQuantumS in CODE, not comments) AND
* comments); `new Map(` still absent * 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) * 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 * anchor reaches a node far faster than its upstream neighbour) is exactly
* what made the four-family potential inconsistent on the real graph — the * what made the four-family potential inconsistent on the real graph — the
* D-03 gate caught it as x038/x045 suboptimality. Under the amended * 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 () => { test("A12 oneway ring under ALT — directed reach asymmetry stays Dijkstra-equal on every pair", async () => {
const { encodeGraph, decodeGraph } = await loadGraph(); 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.ok(g, "the oneway-ring synthetic decodes");
assert.equal(g.nodeCount, 9, "9 nodes: ring + spur head"); 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 pairs = 0;
let reachable = 0; let reachable = 0;
for (let o = 0; o < g.nodeCount; o++) { 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}`); 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 // The F1 containment corollary (debug alt-potential-4x-slowdown): every
// pairs run on the haversine fallback (per-node sentinel skip), cross // to-column holds a B-node sentinel, so EVERY column is dirty — the same
// pairs resolve the honest miss. Seconds-only assertions — never // shape as the current real binary (24/24 dirty). The clean-columns law
// node-path equality (D-03's tie 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 pairs = 0;
let crossNulls = 0; let crossNulls = 0;
for (let o = 0; o < n; o++) { 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 g = await buildIslandFixture(Uint32Array.of(0, 8));
const n = g.nodeCount; const n = g.nodeCount;
// With origin and goal inside B (9..12), every l's goal/origin-side // 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 // constant is the 0xFFFF sentinel — and every COLUMN is dirty anyway
// search (the skip lives in the per-search constant tables, never a // (A9's corollary), so the F1 active list is empty and the landmarks
// per-node l-mask mid-max) and the run is pure-haversine. The skip law: // drop at SEARCH scope (never a per-node l-mask mid-max): the run is
// results stay exactly optimal regardless. // pure-haversine. The containment law: results stay exactly optimal
// regardless.
let pairs = 0; let pairs = 0;
for (let o = 9; o < n; o++) { for (let o = 9; o < n; o++) {
for (let t = 9; t < n; t++) { 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"); 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 src = readFileSync(path.join(here, "..", "src", "astar.ts"), "utf8");
const code = src const code = src
.replace(/\/\*[\s\S]*?\*\//g, " ") .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 // routes (the D-03 gate's x038/x045 catch). Re-adding landmarkFrom to the
// search trips this pin and re-opens that bug. // 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)"); 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 // The F1 shape (debug alt-potential-4x-slowdown): the per-graph
// sentinel (0xffff in code, never the decimal 65535). // clean-column memo and the per-search active list exist in CODE — dirty
assert.ok(code.includes("0xffff"), "the sentinel is compared raw (u16, before float conversion)"); // 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"); assert.ok(!code.includes("65535"), "one spelling of the sentinel: 0xffff");
// A8's law is unchanged under ALT: the hot loop stays typed arrays. // 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"); 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)");
});