diff --git a/routing/src/landmark.ts b/routing/src/landmark.ts index b55d6fc..887081e 100644 --- a/routing/src/landmark.ts +++ b/routing/src/landmark.ts @@ -45,6 +45,16 @@ export const LANDMARK_MAX_QUANTA = 65000; * all-ones u16 pattern 0xffff is every table cell's out-of-domain value; * consumers check against this constant, never a per-callsite literal. */ export const LANDMARK_SENTINEL = 0xffff; +/** The i32 weight-domain ceiling: qWeight is an Int32Array, so a quantized + * edge at or past 2^31 would WRAP NEGATIVE on store — silently bypassing + * the LANDMARK_MAX_QUANTA throw in dijkstraPass (a negative contribution + * never exceeds it), poisoning the Dijkstras, and at finish() a wrapped + * −1 label would BECOME the 0xFFFF sentinel. Any raw seconds/quantumS + * quotient at or past this ceiling throws at prepare time, BEFORE the + * store — nothing is clamped, ever. (Exactly 2^31−1 already cannot + * survive dijkstraPass's ceiling either, so the strict `>=` loses + * nothing and fails one boundary earlier, at the table-build entry.) */ +const LANDMARK_I32_WEIGHT_CEILING = 0x7fffffff; /** The landmark compute input — the shape build scripts assemble. */ export interface LandmarkInput { @@ -111,7 +121,20 @@ function prepare(input: LandmarkInput): Prepared { } // THE ĉ RULE (Pattern 2): floor keeps the surrogate at or below the // live edge — the exactness hinge for admissibility downstream. - qWeight[e] = Math.floor(edge.seconds / input.quantumS); + const q = Math.floor(edge.seconds / input.quantumS); + // RANGE LAW at the storage boundary (08-review WR-04): the quotient is + // validated BEFORE the Int32Array store — at or past the i32 ceiling + // the store wraps NEGATIVE, silently bypassing the dijkstraPass ceiling + // (a negative contribution never exceeds LANDMARK_MAX_QUANTA) and + // corrupting the tables. This is a per-edge check at the table-build + // entry: the search hot path stays allocation-free and validation-free. + if (q >= LANDMARK_I32_WEIGHT_CEILING) { + fail( + context, + `edge ${edge.from}->${edge.to} quantizes to ${q} quanta, at or past the i32 weight ceiling ${LANDMARK_I32_WEIGHT_CEILING} (raise landmarkQuantumS)`, + ); + } + qWeight[e] = q; flags[e] = edge.flags & 1; target[e] = edge.to; source[e] = edge.from; diff --git a/routing/test/landmark.test.mjs b/routing/test/landmark.test.mjs index c7cd249..dfd420b 100644 --- a/routing/test/landmark.test.mjs +++ b/routing/test/landmark.test.mjs @@ -197,6 +197,58 @@ test("L5 overflow throws — any label > 65000 quanta fails loud, naming the fix ); }); +test("L5b i32 wrap throws — an edge quantizing to >= 2^31 fails at prepare, before the Int32Array store", async () => { + const { landmarkTables, farthestPointAnchors } = await loadLandmark(); + // seconds 3e10 at the 0.1 s quantum = 3e11 quanta — an absurd but FINITE + // positive weight. Stored unchecked it would wrap NEGATIVE in the + // Int32Array (-64771072 for 3e10), silently bypassing the LANDMARK_MAX_QUANTA + // throw (a negative contribution never exceeds it) and poisoning the + // tables. The wrap must be caught at the table-build entry instead. + const input = { + nodeCount: 3, + edges: [ + { from: 0, to: 1, seconds: 3e10, flags: 0 }, // floor(3e10/0.1) = 3e11 quanta >= 2^31 + { from: 1, to: 0, seconds: 10, flags: 0 }, + { from: 1, to: 2, seconds: 10, flags: 0 }, + { from: 2, to: 1, seconds: 10, flags: 0 }, + ], + quantumS: 0.1, + }; + assert.throws( + () => landmarkTables(input, Uint32Array.of(0)), + /i32 weight ceiling 2147483647 \(raise landmarkQuantumS\)/, + "landmarkTables throws at the storage boundary, naming the edge, the ceiling and the fix", + ); + assert.throws( + () => farthestPointAnchors(input, 1), + /i32 weight ceiling 2147483647/, + "the anchor-selection entry shares the same prepare-time law", + ); + // The ceiling itself stays inside the legal domain: 2147483646 quanta on + // one edge does NOT throw here (the dijkstraPass 65000 ceiling is the law + // that fires later — prepare only rejects the i32 WRAP class). + const justUnder = { + nodeCount: 2, + edges: [ + { from: 0, to: 1, seconds: 214748364.6, flags: 0 }, // floor(/0.1) = 2147483646 < 2^31 + { from: 1, to: 0, seconds: 10, flags: 0 }, + ], + quantumS: 0.1, + }; + assert.throws( + () => landmarkTables(justUnder, Uint32Array.of(0)), + /exceeds 65000/, + "just-under-ceiling still fails — via the documented 65000 law, never silently", + ); + assert.doesNotThrow( + () => landmarkTables( + { nodeCount: 2, edges: [{ from: 0, to: 1, seconds: 25.5, flags: 0 }, { from: 1, to: 0, seconds: 10, flags: 0 }], quantumS: 0.1 }, + Uint32Array.of(0), + ), + "ordinary weights are untouched by the new check", + ); +}); + test("L6 5x5 grid shape — anchors [0,18]; farthest 297 quanta; coverage 1.0", async () => { const { farthestPointAnchors, landmarkTables, landmarkCoverage } = await loadLandmark(); const input = await gridInput();