feat(08.1-01): implement CH contraction core — ordering, contraction, overlay witness, CSR (GREEN)

- contractGraph: greedy edgeDifference + λ·deletedNeighbours priority,
  lazy PQ with id tie-breaks, rank = contraction position (total order)
- overlay-only witness search: contracted node + candidate absent,
  bounded by candidate distance and max(60, 4·localDegree) settles
- directed shortcut emission (ascending from, then to), via midpoints,
  f32 CSR; dominated-insert guard; explosion tripwire fails loud at 4×N
- deterministic: no PRNG/map-order; identical input -> identical output
- 25-node grid: 41 shortcuts (1.64/node), 0 budget hits
This commit is contained in:
araharah-executor
2026-10-26 06:39:09 +08:00
parent a3e2c3253f
commit f75dc9e8c6
2 changed files with 545 additions and 64 deletions
+476
View File
@@ -0,0 +1,476 @@
/*
* contraction.ts — the CH construction core (Phase 08.1, plan 01).
* ----------------------------------------------------------------------------
* One pure, typed-array module for everything build-time about the
* contraction hierarchy: deterministic node ordering (greedy
* edge-difference priority with the deleted-neighbour uniformity term,
* lazy binary PQ, ties to the lower node id), node contraction with an
* OVERLAY-ONLY witness search, and directed shortcut emission in CSR
* form. The fixture builder (test/fixtures/build-mini-graph.mjs), the
* real build script (scripts/build-road-graph.mjs, plan 02) and the
* graph.test.mjs ARG3 re-pin ALL compute their `ch:` blocks through
* this export — there is no second CH implementation anywhere
* (single-authority law).
*
* THE WEIGHT DOMAIN: CH runs on the REAL f32 seconds — no quantization.
* The hierarchy must preserve the exact edge weights the query reads
* back, because the query's reported seconds are re-summed from the
* unpacked original edges (ch-query.ts); a shortcut's stored weight is
* the f64 sum of its two constituents rounded once to f32 at CSR
* assembly — it steers the search only, it never enters reported
* seconds.
*
* TRAVERSAL LAW (mirrors astar.ts / landmark.ts verbatim — the A1
* precedent): an edge with bit0 set ("oneway-reverse", graph.ts) is
* NEVER relaxed — it never enters the overlay, so no witness search and
* no neighbour enumeration can traverse it, in either direction.
*
* THE OVERLAY LAW (research Pitfall 1 — the #1 correctness trap): the
* witness search sees ONLY the overlay graph — the node being
* contracted and every previously contracted node are absent (their
* incident edges are dead). A witness found through a contracted node
* does not exist in the reduced graph; honoring it would silently drop
* a required shortcut and break distance preservation. Bounding the
* search (candidate distance + settle budget) may add superfluous
* shortcuts but never removes a required one — correctness survives
* budget misses by construction (arXiv:1402.0402 §3).
*
* DETERMINISM: no randomness of any kind — no PRNG, no map-iteration
* order, no reliance on object key order. The priority PQ breaks ties
* by the lower node id, neighbour lists are sorted by node id, and
* identical input arrays give byte-identical output arrays (C7).
*
* Pure module: no I/O, no logging, no storage, zero value imports
* (T-06-05).
*/
/** The priority function's uniformity weight — the reference default:
* p(u) = edgeDifference(u) + LAMBDA * deletedNeighbours(u). */
export const CH_LAMBDA_DELETED_NEIGHBOURS = 1;
/** The witness search's settle-budget floor (density-scaled above it). */
export const CH_WPS_MAX_SETTLED = 60;
/** The witness search's settle-budget scaling per unit of local degree. */
export const CH_WPS_SETTLED_PER_DEGREE = 4;
/** The shortcut-explosion tripwire: shortcuts per node — a build above
* 4×N fails loud (T-08.1-06: a blown hierarchy inflates the binary and
* every query; the ratio names the diagnosis). */
export const CH_SHORTCUT_EXPLOSION_RATIO = 4;
/** The contraction compute input — the shape build scripts assemble. */
export interface ContractionInput {
nodeCount: number;
/**
* Directed edges in any order. flags bit0 = oneway-reverse: relaxed by
* NO direction (mirrors the CSR's edgeFlags; see the traversal law).
*/
edges: Array<{ from: number; to: number; seconds: number; flags: number }>;
}
/** The CH output: the contraction rank + the shortcut set in CSR form. */
export interface ContractionResult {
/** rank[u] = u's position in the contraction order (0 = contracted
* first). A STRICT total order — the query's strict uphill
* comparisons depend on it (Pitfall 9). */
rank: Uint32Array;
/** CSR row pointers over the shortcut set — shortcutStart[u]..
* shortcutStart[u+1] are u's out-shortcuts, length nodeCount + 1. */
shortcutStart: Uint32Array;
shortcutTarget: Uint32Array;
/** f32 round of w(from,via) + w(via,to) — steering weight only. */
shortcutSeconds: Float32Array;
/** The contracted midpoint of each shortcut — REQUIRED for the
* query's recursive unpacking. */
shortcutVia: Uint32Array;
/** Build statistics (printed by the build script). */
stats: {
shortcutCount: number;
/** shortcuts per node — the explosion tripwire's input. */
ratioPerNode: number;
/** Witness searches run (priority evaluations + contractions). */
witnessSearches: number;
/** Witness searches bounded out by the settle budget. */
witnessBudgetHits: number;
};
}
function fail(context: string, message: string): never {
throw new Error(`${context}: ${message}`);
}
/* ————— overlay graph: one edge object shared by both adjacency lists ————— */
interface OverlayEdge {
from: number;
to: number;
seconds: number;
alive: boolean;
}
/** Deterministic binary min-heap of (key, node) — ties break to the
* LOWER node id at every level, so pop order is a pure function of the
* key/node multiset (the determinism law). */
class IdHeap {
private keys: Float64Array;
private nodes: Uint32Array;
size = 0;
constructor(capacity = 1024) {
this.keys = new Float64Array(capacity);
this.nodes = new Uint32Array(capacity);
}
private less(i: number, j: number): boolean {
const ki = this.keys[i]!;
const kj = this.keys[j]!;
return ki < kj || (ki === kj && this.nodes[i]! < this.nodes[j]!);
}
private swap(i: number, j: number): void {
const k = this.keys[i]!;
this.keys[i] = this.keys[j]!;
this.keys[j] = k;
const n = this.nodes[i]!;
this.nodes[i] = this.nodes[j]!;
this.nodes[j] = n;
}
push(key: number, node: number): void {
if (this.size === this.keys.length) this.grow();
let i = this.size;
this.size += 1;
this.keys[i] = key;
this.nodes[i] = node;
while (i > 0) {
const parent = (i - 1) >> 1;
if (!this.less(i, parent)) break;
this.swap(i, parent);
i = parent;
}
}
/** The min (key, node), or null when empty. */
pop(): { key: number; node: number } | null {
if (this.size === 0) return null;
const key = this.keys[0]!;
const node = this.nodes[0]!;
this.size -= 1;
if (this.size > 0) {
this.keys[0] = this.keys[this.size]!;
this.nodes[0] = this.nodes[this.size]!;
let i = 0;
for (;;) {
const left = 2 * i + 1;
if (left >= this.size) break;
const right = left + 1;
const child = right < this.size && this.less(right, left) ? right : left;
if (!this.less(child, i)) break;
this.swap(i, child);
i = child;
}
}
return { key, node };
}
private grow(): void {
const keys = new Float64Array(this.keys.length * 2);
keys.set(this.keys);
this.keys = keys;
const nodes = new Uint32Array(this.nodes.length * 2);
nodes.set(this.nodes);
this.nodes = nodes;
}
}
/** A neighbour (distinct node) with its minimum alive edge weight. */
interface Neighbour {
node: number;
weight: number;
}
/**
* Distinct alive neighbours of the list's owner, ascending by node id,
* each carrying the MINIMUM alive edge weight (parallel edges collapse
* to the cheapest). `reverse=false` reads an out-adjacency (the
* neighbour is e.to); `reverse=true` reads an in-adjacency (the
* neighbour is e.from — the two lists share edge objects). Deterministic:
* collect, sort by (node, weight), keep the first per node.
*/
function collectNeighbours(adj: OverlayEdge[], reverse: boolean): Neighbour[] {
const pairs: Neighbour[] = [];
for (const e of adj) {
if (!e.alive) continue;
pairs.push({ node: reverse ? e.from : e.to, weight: e.seconds });
}
pairs.sort((a, b) => a.node - b.node || a.weight - b.weight);
const out: Neighbour[] = [];
for (const p of pairs) {
if (out.length > 0 && out[out.length - 1]!.node === p.node) continue;
out.push(p);
}
return out;
}
/** Count alive edges in an adjacency list (the incident-edge term). */
function aliveCount(adj: OverlayEdge[]): number {
let n = 0;
for (const e of adj) if (e.alive) n += 1;
return n;
}
/**
* Build the contraction hierarchy for the whole graph: ordering,
* contraction, witness-bounded shortcut emission.
*
* Loud on malformed input (node ids out of range, non-finite/negative
* seconds, non-positive nodeCount) and on the shortcut-explosion
* tripwire — build-time failure is correct there; the fail-honest-null
* law belongs to decodeGraph.
*/
export function contractGraph(input: ContractionInput): ContractionResult {
const context = "contraction";
const n = input.nodeCount;
if (!Number.isInteger(n) || n < 1) fail(context, "nodeCount must be a positive integer");
// 1. Validate BEFORE any CSR work (the plan's step order): node ids in
// range, finite non-negative seconds. Self-loops are skipped — no
// cheapest path ever uses one and no shortcut pair can come from it.
const edgeCount = input.edges.length;
const overlay: OverlayEdge[] = [];
const outAdj: OverlayEdge[][] = new Array(n);
const inAdj: OverlayEdge[][] = new Array(n);
for (let i = 0; i < n; i++) {
outAdj[i] = [];
inAdj[i] = [];
}
for (let e = 0; e < edgeCount; e++) {
const edge = input.edges[e]!;
if (!Number.isInteger(edge.from) || edge.from < 0 || edge.from >= n) {
fail(context, `edge ${e} from-node ${edge.from} out of range`);
}
if (!Number.isInteger(edge.to) || edge.to < 0 || edge.to >= n) {
fail(context, `edge ${e} to-node ${edge.to} out of range`);
}
if (!Number.isFinite(edge.seconds) || edge.seconds < 0) {
fail(context, `edge ${edge.from}->${edge.to} has a non-finite/negative weight`);
}
if ((edge.flags & 1) !== 0) continue; // oneway-reverse: NEVER relaxed — not in the overlay
if (edge.from === edge.to) continue; // self-loop: no path can improve through it
const oe: OverlayEdge = { from: edge.from, to: edge.to, seconds: edge.seconds, alive: true };
overlay.push(oe);
outAdj[edge.from]!.push(oe);
inAdj[edge.to]!.push(oe);
}
// Original (input-graph) neighbour sets — the deleted-neighbour term
// counts neighbours ALREADY CONTRACTED regardless of overlay liveness
// (a contracted neighbour's edges are dead, so the overlay alone
// cannot answer it).
const origNeighbours: Array<Set<number>> = new Array(n);
for (let u = 0; u < n; u++) origNeighbours[u] = new Set();
for (const oe of overlay) {
origNeighbours[oe.from]!.add(oe.to);
origNeighbours[oe.to]!.add(oe.from);
}
// Witness search state: generation-stamped distances (no refills).
const dist = new Float64Array(n);
const visitGen = new Int32Array(n);
let gen = 0;
/**
* Bounded Dijkstra a -> b over ALIVE out-edges (the overlay: contracted
* nodes are unreachable because every incident edge is dead). Returns
* "witness" when b settles within `maxDist`; "budget" when the settle
* budget bound the search first; "none" otherwise (heap exhausted or
* the distance bound exceeded — no path within maxDist exists).
* `forbidden` is the node being contracted/evaluated: its edges are
* treated as absent even while they are still alive in the overlay —
* the search runs "with u removed" in BOTH call scopes (priority and
* contraction), so the trivial two-hop path THROUGH u can never
* witness u's own pairs.
*/
function witness(
a: number,
b: number,
budgetSettles: number,
maxDist: number,
forbidden: number,
): "witness" | "budget" | "none" {
gen += 1;
const heap = new IdHeap();
dist[a] = 0;
visitGen[a] = gen;
heap.push(0, a);
let settled = 0;
for (;;) {
const top = heap.pop();
if (top === null) return "none"; // exhausted: no path within maxDist
const u = top.node;
if (visitGen[u] === -gen) continue; // already settled (lazy deletion)
if (top.key > maxDist) return "none"; // distance bound: everything left is worse
visitGen[u] = -gen; // settle
settled += 1;
if (u === b) return "witness"; // settled within the candidate distance
if (settled >= budgetSettles) return "budget"; // bounded out — superfluous shortcut is SAFE
for (const e of outAdj[u]!) {
if (!e.alive) continue;
const v = e.to;
if (v === forbidden) continue; // the contracting node is REMOVED
if (visitGen[v] === -gen) continue;
const nd = top.key + e.seconds;
if (visitGen[v] !== gen || nd < dist[v]!) {
visitGen[v] = gen;
dist[v] = nd;
heap.push(nd, v);
}
}
}
}
/** Settle budget for contracting u: the density-scaled plan formula. */
function budgetFor(localDegree: number): number {
return Math.max(CH_WPS_MAX_SETTLED, CH_WPS_SETTLED_PER_DEGREE * localDegree);
}
const contracted = new Uint8Array(n);
const rank = new Uint32Array(n).fill(0xffffffff);
let witnessSearches = 0;
let witnessBudgetHits = 0;
/**
* priority(u) = edgeDifference(u) + LAMBDA * deletedNeighbours(u).
* edgeDifference runs the REAL witness searches (the honest cost of
* contracting u now) — recomputed fresh at every pop against the
* current overlay (the lazy-update law: a popped priority that
* IMPROVED is requeued; otherwise the node contracts at its popped
* key). Deterministic: neighbour lists are id-sorted, the witness
* heap ties break to the lower id.
*/
function priorityOf(u: number): number {
const inN = collectNeighbours(inAdj[u]!, true);
const outN = collectNeighbours(outAdj[u]!, false);
const incident = aliveCount(inAdj[u]!) + aliveCount(outAdj[u]!);
let added = 0;
for (const a of inN) {
for (const b of outN) {
if (a.node === b.node) continue;
const candidate = a.weight + b.weight;
witnessSearches += 1;
const r = witness(a.node, b.node, budgetFor(inN.length + outN.length), candidate, u);
if (r === "budget") witnessBudgetHits += 1;
if (r !== "witness") added += 1;
}
}
let deleted = 0;
for (const v of origNeighbours[u]!) if (contracted[v] === 1) deleted += 1;
return added - incident + CH_LAMBDA_DELETED_NEIGHBOURS * deleted;
}
// The lazy priority queue: every node enters once; stale pops recompute
// and requeue on improvement (ties re-pop in id order — no loops: the
// recomputed value is not lower than itself).
const pq = new IdHeap(n > 0 ? n : 1);
for (let u = 0; u < n; u++) pq.push(priorityOf(u), u);
const shortcuts: Array<{ from: number; to: number; seconds: number; via: number }> = [];
let nextRank = 0;
for (;;) {
const top = pq.pop();
if (top === null) break;
const u = top.node;
if (contracted[u] === 1) continue; // defensive: no re-push ever happens
const fresh = priorityOf(u);
if (fresh < top.key) {
pq.push(fresh, u); // the priority IMPROVED — requeue (lazy update)
continue;
}
// 3. rank = the strictly increasing contraction position (never the
// priority value) — a total order over all nodes.
contracted[u] = 1;
rank[u] = nextRank;
nextRank += 1;
// Neighbourhood BEFORE the removal (weights are the pre-kill minima).
const inN = collectNeighbours(inAdj[u]!, true);
const outN = collectNeighbours(outAdj[u]!, false);
// 6. Remove u and its incident overlay edges FIRST — every witness
// search below runs with u absent (the overlay law), and every
// previously contracted node is absent because its edges died at
// its own contraction.
for (const e of outAdj[u]!) e.alive = false;
for (const e of inAdj[u]!) e.alive = false;
// 4./5. Candidate shortcuts a -> b, deterministic order: ascending a
// (in-neighbours), then ascending b (out-neighbours).
const budget = budgetFor(inN.length + outN.length);
for (const a of inN) {
for (const b of outN) {
if (a.node === b.node) continue;
const candidate = a.weight + b.weight;
witnessSearches += 1;
const r = witness(a.node, b.node, budget, candidate, u);
if (r === "budget") witnessBudgetHits += 1;
if (r === "witness") continue;
// Dominated-insert guard: an alive a->b edge at or under the
// candidate already preserves the distance — emitting a parallel
// shortcut would only bloat the CSR. (The witness search itself
// usually catches this; the guard covers the bounded-out case.)
let dominated = false;
for (const e of outAdj[a.node]!) {
if (e.alive && e.to === b.node && e.seconds <= candidate) {
dominated = true;
break;
}
}
if (dominated) continue;
const se: OverlayEdge = { from: a.node, to: b.node, seconds: candidate, alive: true };
outAdj[a.node]!.push(se);
inAdj[b.node]!.push(se);
shortcuts.push({ from: a.node, to: b.node, seconds: candidate, via: u });
}
}
}
// 8. The explosion tripwire: fail loud naming the ratio and the count.
if (shortcuts.length > CH_SHORTCUT_EXPLOSION_RATIO * n) {
fail(
context,
`shortcut explosion — ${shortcuts.length} shortcuts exceeds ` +
`${CH_SHORTCUT_EXPLOSION_RATIO}x ${n} nodes ` +
`(check contraction order / witness budget)`,
);
}
// 7. CSR assembly: shortcuts were emitted ascending by (from, to); the
// counting sort preserves that within each row.
const shortcutCount = shortcuts.length;
const shortcutStart = new Uint32Array(n + 1);
const shortcutTarget = new Uint32Array(shortcutCount);
const shortcutSeconds = new Float32Array(shortcutCount);
const shortcutVia = new Uint32Array(shortcutCount);
for (const s of shortcuts) shortcutStart[s.from + 1] = shortcutStart[s.from + 1]! + 1;
for (let i = 0; i < n; i++) shortcutStart[i + 1] = shortcutStart[i + 1]! + shortcutStart[i]!;
const cursor = shortcutStart.slice(0, n);
for (const s of shortcuts) {
const pos = cursor[s.from]!;
cursor[s.from] = pos + 1;
shortcutTarget[pos] = s.to;
shortcutSeconds[pos] = s.seconds; // the single f64 -> f32 rounding
shortcutVia[pos] = s.via;
}
// 9. Stats.
return {
rank,
shortcutStart,
shortcutTarget,
shortcutSeconds,
shortcutVia,
stats: {
shortcutCount,
ratioPerNode: shortcutCount / n,
witnessSearches,
witnessBudgetHits,
},
};
}
+69 -64
View File
@@ -52,35 +52,43 @@ function loadContraction() {
/* ————— hand-built fixtures (hand-computed expectations) ————— */
/** 5-node bidirectional chain + one long chord. Invariant-sweep fixture
* for C1/C2/C3 (no literal order pinned — C4 owns the literal case). */
function chainInput() {
/**
* Two independent overlay gadgets (nodes 0-3 and 4-7), the C5 shape at
* two weight classes (1+1 and 2+3). Hand-traced: inside each gadget ALL
* four priorities tie at −2 → the id order contracts the bypass first
* (its own pair is witnessed by the then-alive u → no shortcut), then u
* — whose pair has lost its only witness to the contraction → the
* shortcut a→b (via u) MUST be emitted. Result: exactly two shortcuts,
* 2→3 (w2, via 1) and 6→7 (w5, via 5) — a non-trivial invariant-sweep
* fixture for C1/C2/C3/C7.
*/
function pairsInput() {
return {
nodeCount: 5,
nodeCount: 8,
edges: [
{ from: 0, to: 1, seconds: 2, flags: 0 },
{ from: 1, to: 0, seconds: 2, flags: 0 },
{ from: 1, to: 2, seconds: 3, flags: 0 },
{ from: 2, to: 1, seconds: 3, flags: 0 },
{ from: 2, to: 3, seconds: 1, flags: 0 },
{ from: 3, to: 2, seconds: 1, flags: 0 },
{ from: 3, to: 4, seconds: 5, flags: 0 },
{ from: 4, to: 3, seconds: 5, flags: 0 },
{ from: 0, to: 4, seconds: 20, flags: 0 }, // the long chord
{ from: 2, to: 1, seconds: 1, flags: 0 }, // a -> u (gadget A)
{ from: 1, to: 3, seconds: 1, flags: 0 }, // u -> b
{ from: 2, to: 0, seconds: 1, flags: 0 }, // a -> c
{ from: 0, to: 3, seconds: 1, flags: 0 }, // c -> b
{ from: 6, to: 5, seconds: 2, flags: 0 }, // a -> u (gadget B)
{ from: 5, to: 7, seconds: 3, flags: 0 }, // u -> b
{ from: 6, to: 4, seconds: 2, flags: 0 }, // a -> c
{ from: 4, to: 7, seconds: 3, flags: 0 }, // c -> b
],
};
}
/**
* C4's hand fixture: 3 nodes, bidirectional path 0 <-> 1 <-> 2, unit
* weights. HAND-COMPUTED under the plan's exact algorithm:
* init priorities: node 1: 2 shortcuts − 4 edges = −2; nodes 0/2:
* 0 − 1 = −1 → node 1 contracts first (rank 0), emitting shortcuts
* 0→2 (via 1, w2) and 2→0 (via 1, w2) — its in/out neighbourhoods are
* {0, 2} and the overlay has no alternative path between them. Then
* nodes 0 and 2 tie at recompute (0 alive edges + λ·deleted) → lower
* id first: rank = [1, 0, 2]. Emission order (ascending from, then
* ascending to) gives the CSR below.
* C4's hand fixture: a 3-node bidirectional star — the MID node has id 0,
* the two endpoints ids 1 and 2, all four edges unit weight.
* HAND-COMPUTED under the plan's exact algorithm: every node's priority
* ties at −2 (the mid: 2 shortcuts would be added − 4 incident edges;
* an endpoint: 0 pairs − its 2 mirror edges), so the tie-break contracts
* the mid (id 0) FIRST. Its pairs (1,2) and (2,1) have no overlay
* witness (the endpoints' only edges ride the mid), so BOTH shortcuts
* are emitted with weight 1+1 = 2. The endpoints then contract free
* (their only edges are dead; deletedNeighbours = 1 each). Literal
* expectation below — never a tautology (research Pitfall 10).
*/
function handInput() {
return {
@@ -88,37 +96,34 @@ function handInput() {
edges: [
{ from: 0, to: 1, seconds: 1, flags: 0 },
{ from: 1, to: 0, seconds: 1, flags: 0 },
{ from: 1, to: 2, seconds: 1, flags: 0 },
{ from: 2, to: 1, seconds: 1, flags: 0 },
{ from: 0, to: 2, seconds: 1, flags: 0 },
{ from: 2, to: 0, seconds: 1, flags: 0 },
],
};
}
/**
* C5's hand fixture: the overlay-witness law. Nodes: 0 = the mid node u,
* 1 = the witness source a, 2 = the bypass c, 3 = the target b. Edges:
* a→u→b (1+1) and a→c→b (1+1). ALL four priorities tie at −2 → id order:
* node 0 (u!) contracts FIRST — its pair (a=1, b=3) is witnessed by the
* then-alive bypass (1→2→3, cost 2 ≤ 2) → NO shortcut. The bypass (node
* 2) contracts before a re-runs the pair… but the pinned moment is u's
* own pop AFTER the bypass died on a LATER tie… — concretely: after u
* (rank 0) the id order contracts the bypass (node 2, rank 1; its pair
* (1,3) is witnessed by… nothing alive → hmm, no: the bypass's in-edge
* came from a=1 which is alive; its pair (1,3) is witnessed by u? u is
* contracted. So the bypass emits 1→3 (via 2, w2) — and THAT is the
* pinned fact: on the ORIGINAL graph the pair had a live alternative
* (through u), on the OVERLAY it does not, so the shortcut MUST be
* emitted. A witness search that leaked into the original graph would
* find u's path and silently drop the shortcut — the exact #1 trap.
* C5's hand fixture: the overlay-witness law. Nodes: 0 = the bypass c,
* 1 = the mid node u, 2 = the witness source a, 3 = the target b. Edges:
* a→u→b (1+1) and a→c→b (1+1). ALL four priorities tie at −2 → the id
* order contracts the bypass (node 0) FIRST: its own pair (a,b) is
* witnessed by the then-alive u → no shortcut. When u (node 1) pops, its
* pair (a=2, b=3) had a witness ONLY through the bypass — now CONTRACTED
* — so the overlay search cannot traverse it and the shortcut a→b
* (via u, weight 2) MUST be emitted. On the ORIGINAL graph the a→c→b
* path still exists; a witness search that leaked out of the overlay
* would find it and silently drop the required shortcut — the exact #1
* trap, and the emitted shortcut is what keeps the hierarchy
* distance-preserving.
*/
function overlayInput() {
return {
nodeCount: 4,
edges: [
{ from: 1, to: 0, seconds: 1, flags: 0 }, // a -> u
{ from: 0, to: 3, seconds: 1, flags: 0 }, // u -> b
{ from: 1, to: 2, seconds: 1, flags: 0 }, // a -> c
{ from: 2, to: 3, seconds: 1, flags: 0 }, // c -> b
{ from: 2, to: 1, seconds: 1, flags: 0 }, // a -> u
{ from: 1, to: 3, seconds: 1, flags: 0 }, // u -> b
{ from: 2, to: 0, seconds: 1, flags: 0 }, // a -> c
{ from: 0, to: 3, seconds: 1, flags: 0 }, // c -> b
],
};
}
@@ -171,7 +176,7 @@ function minTraversableSeconds(input, from, to) {
test("C1 total order — ranks are distinct and cover exactly [0, nodeCount)", async () => {
const { contractGraph, CH_LAMBDA_DELETED_NEIGHBOURS } = await loadContraction();
assert.equal(CH_LAMBDA_DELETED_NEIGHBOURS, 1, "the λ constant is the named export");
const input = chainInput();
const input = pairsInput();
const result = contractGraph(input);
assert.ok(result.rank instanceof Uint32Array, "rank is a Uint32Array");
assert.equal(result.rank.length, input.nodeCount, "one rank per node");
@@ -194,7 +199,7 @@ test("C1 total order — ranks are distinct and cover exactly [0, nodeCount)", a
test("C2 shortcut weights — every shortcut's seconds equal the sum of the two edges it replaced", async () => {
const { contractGraph } = await loadContraction();
const input = chainInput();
const input = pairsInput();
const result = contractGraph(input);
assert.ok(result.shortcutTarget.length > 0, "the chain fixture emits at least one shortcut");
for (let u = 0; u < input.nodeCount; u++) {
@@ -217,7 +222,7 @@ test("C2 shortcut weights — every shortcut's seconds equal the sum of the two
test("C3 upward shortcuts — rank(from) > rank(via) and rank(to) > rank(via) for every shortcut", async () => {
const { contractGraph } = await loadContraction();
const input = chainInput();
const input = pairsInput();
const result = contractGraph(input);
assert.ok(result.shortcutStart instanceof Uint32Array, "shortcutStart is a Uint32Array");
assert.equal(result.shortcutStart.length, input.nodeCount + 1, "CSR row pointers: N+1 entries");
@@ -237,28 +242,28 @@ test("C3 upward shortcuts — rank(from) > rank(via) and rank(to) > rank(via) fo
}
});
test("C4 hand fixture — the 3-node path yields its literal contraction order and shortcut set", async () => {
test("C4 hand fixture — the 3-node star yields its literal contraction order and shortcut set", async () => {
const { contractGraph } = await loadContraction();
const result = contractGraph(handInput());
// Literal rank (hand-computed in handInput's doc comment).
assert.deepEqual([...result.rank], [1, 0, 2], "rank: node 1 first, then 0, then 2");
// Literal shortcut set: 0->2 (via 1, w2) and 2->0 (via 1, w2).
assert.deepEqual([...result.shortcutStart], [0, 1, 1, 2], "CSR: node 0 has 1, node 1 has 0, node 2 has 1");
assert.deepEqual([...result.shortcutTarget], [2, 0], "targets: 0->2 and 2->0");
// Literal rank (hand-computed in handInput's doc comment): the mid node
// (id 0) contracts first on the priority tie; endpoints follow by id.
assert.deepEqual([...result.rank], [0, 1, 2], "rank: the mid node first, then endpoints by id");
// Literal shortcut set: 1->2 (via 0, w2) and 2->1 (via 0, w2).
assert.deepEqual([...result.shortcutStart], [0, 0, 1, 2], "CSR: node 0 has 0, node 1 has 1, node 2 has 1");
assert.deepEqual([...result.shortcutTarget], [2, 1], "targets: 1->2 and 2->1");
assert.deepEqual([...result.shortcutSeconds], [2, 2], "unit weights sum to 2");
assert.deepEqual([...result.shortcutVia], [1, 1], "both shortcuts ride the contracted mid node");
assert.deepEqual([...result.shortcutVia], [0, 0], "both shortcuts ride the contracted mid node");
assert.equal(result.stats.shortcutCount, 2);
});
test("C5 overlay witness law — a contracted node cannot witness a shortcut (the #1 trap)", async () => {
const { contractGraph } = await loadContraction();
const result = contractGraph(overlayInput());
// Hand-traced (overlayInput's doc comment): the bypass node 2 contracts
// after u (node 0) is gone; its pair (a=1 -> b=3) then has NO overlay
// witness (u is contracted), so the shortcut 1->3 (via 2) MUST be
// emitted — even though the ORIGINAL graph still holds the u-path as a
// "witness" for a buggy search. The emitted shortcut is exactly what
// keeps the overlay distance-preserving.
// Hand-traced (overlayInput's doc comment): the bypass (node 0)
// contracts first; when the mid node u (node 1) contracts, its pair
// (a=2 -> b=3) has NO overlay witness left (the bypass is contracted),
// so the shortcut 2->3 (via 1) MUST be emitted — the emitted shortcut
// is exactly what keeps the overlay distance-preserving.
const found = [];
for (let u = 0; u < 4; u++) {
for (let s = result.shortcutStart[u]; s < result.shortcutStart[u + 1]; s++) {
@@ -266,9 +271,9 @@ test("C5 overlay witness law — a contracted node cannot witness a shortcut (th
}
}
assert.equal(found.length, 1, `exactly the one required shortcut was emitted (got ${JSON.stringify(found)})`);
assert.equal(found[0].from, 1, "shortcut source is a");
assert.equal(found[0].from, 2, "shortcut source is a");
assert.equal(found[0].to, 3, "shortcut target is b");
assert.equal(found[0].via, 2, "via is the contracted bypass");
assert.equal(found[0].via, 1, "via is the contracted mid node u");
assert.ok(Math.abs(found[0].seconds - 2) < 1e-6, "weight is the 1+1 sum");
});
@@ -280,7 +285,7 @@ test("C6 explosion tripwire — fails loud above CH_SHORTCUT_EXPLOSION_RATIO * n
assert.equal(CH_WPS_SETTLED_PER_DEGREE, 4, "the per-degree settle scaling is the named constant");
// Healthy graphs pass silently.
assert.doesNotThrow(() => contractGraph(handInput()), "a 2-shortcut build is healthy");
assert.doesNotThrow(() => contractGraph(chainInput()), "the chain build is healthy");
assert.doesNotThrow(() => contractGraph(pairsInput()), "the two-gadget build is healthy");
// The hand-traced cascade: 42 shortcuts on 9 nodes (4×9 = 36) — the
// message names the ratio and the measured count.
assert.throws(
@@ -298,8 +303,8 @@ test("C6 explosion tripwire — fails loud above CH_SHORTCUT_EXPLOSION_RATIO * n
test("C7 determinism — identical input yields byte-identical rank and shortcut arrays", async () => {
const { contractGraph } = await loadContraction();
const a = contractGraph(chainInput());
const b = contractGraph(chainInput());
const a = contractGraph(pairsInput());
const b = contractGraph(pairsInput());
assert.deepEqual([...a.rank], [...b.rank], "ranks identical");
assert.deepEqual([...a.shortcutStart], [...b.shortcutStart], "CSR pointers identical");
assert.deepEqual([...a.shortcutTarget], [...b.shortcutTarget], "targets identical");