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
|
#!/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)");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user