doxa-maps/library/packet/scripts/check-fork-leaders.mjs
Doxa Maps d12d926870 packet: fork-leaders — octilinear forked leaders for paired rows
New library/packet/fork-leaders.js: a pure, deterministic router for one
paired-row leader, forking a stem at a 45° chamfer into two short legs
(one per pin), in row and trunk modes. All segments H/V/45°. Returns
polylines for drawing and a deduped segment list (stem once) so a fork
counts as one polyline for crossings, plus octilinear and through-pin
reports. Imitates the poster-render octilinear lane routing (read, not
called). Adds check:fork-leaders (both modes, 0 crossings on a 220-row
monotone set, through-pin + poison arms).
2026-10-02 09:07:49 +00:00

143 lines
8.2 KiB
JavaScript

#!/usr/bin/env node
/**
* check-fork-leaders.mjs — unit check for library/packet/fork-leaders.js.
*
* Asserts, for both modes ('row' and 'trunk'), at the whole-India 60x96 sheet
* scale (2500x4000px), that forkRoute():
* 1. emits OCTILINEAR polylines (H/V/45° only) and ends its two legs exactly on
* the two pins;
* 2. produces 0 crossings over a clean monotone paired set, batched through the
* SAME crossing rule the engine uses (countPolyCrossings: polylines, skip
* same-polyline pairs) — with a floor on rows so the pass is not vacuous;
* 3. reports a pin a leg passes through in `throughPins` (and reports none when
* obstacles are clear);
* 4. is DETERMINISTIC (two runs identical);
* 5. POISON: a leg forced to a non-octilinear angle must make octilinear=false,
* and an obstacle planted on a leg must appear in throughPins — never silent.
*
* No browser, no network. Exit 0 PASS, 1 FAIL, 75 NOT MEASURED if the module
* cannot be imported.
*/
let forkRoute;
try { ({ forkRoute } = await import('../fork-leaders.js')); }
catch (e) { console.error(`NOT MEASURED: cannot import fork-leaders.js — ${e.message}`); process.exitCode = 75; }
if (forkRoute) {
const fails = [], note = [];
// Crossing counter — the engine's rule, inlined so the check is self-contained.
const segCross = (p, q) => {
const o = (ax, ay, bx, by, cx, cy) => Math.sign((bx - ax) * (cy - ay) - (by - ay) * (cx - ax));
const [a, b] = p, [c, d] = q;
return o(a[0], a[1], b[0], b[1], c[0], c[1]) !== o(a[0], a[1], b[0], b[1], d[0], d[1]) &&
o(c[0], c[1], d[0], d[1], a[0], a[1]) !== o(c[0], c[1], d[0], d[1], b[0], b[1]);
};
// Count crossings BETWEEN forks: each fork contributes its unique `segs` under
// ONE polyline id (li). Segments of the same fork are never paired — a fork's
// own stem+legs sharing the fork point is not a crossing.
const countForkCrossings = (forks) => {
const segs = [];
forks.forEach((f, li) => { for (const s of f.segs) segs.push({ s, li }); });
let n = 0;
for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) {
if (segs[i].li === segs[j].li) continue;
if (segCross(segs[i].s, segs[j].s)) n++;
}
return n;
};
const isOcti = (pts, eps = 0.6) => {
for (let i = 0; i < pts.length - 1; i++) {
const dx = Math.abs(pts[i + 1][0] - pts[i][0]), dy = Math.abs(pts[i + 1][1] - pts[i][1]);
if (dx < eps || dy < eps) continue;
if (Math.abs(dx - dy) < eps) continue;
return false;
}
return true;
};
const endsAt = (pts, pin) => Math.hypot(pts[pts.length - 1][0] - pin.x, pts[pts.length - 1][1] - pin.y) < 0.6;
// ── A clean monotone paired set on the LEFT margin (dirOut +1 → toward map). ──
// Lanes stacked down the margin; each row's two pins sit to the right, in the
// row's own latitude band, left-pin above right-pin, bands disjoint and y-sorted
// → the classical non-crossing arrangement. 220 rows (floor 200).
const ROWS = 220;
const LANE_X = 572, MAP_X = 572, BAND = 14;
const rowsData = [];
for (let i = 0; i < ROWS; i++) {
const y = 400 + i * BAND;
rowsData.push({
lane: { x: LANE_X, y },
a: { id: `r${i}a`, x: MAP_X + 120, y: y - 3 },
b: { id: `r${i}b`, x: MAP_X + 180, y: y + 3 },
});
}
for (const mode of ['row', 'trunk']) {
let endOk = 0, octiOk = 0;
const forks = [];
for (const rw of rowsData) {
const res = forkRoute({ lane: rw.lane, a: rw.a, b: rw.b, dirOut: 1, mode, forkFrac: 0.5 });
if (res.octilinear && isOcti(res.polys[0]) && isOcti(res.polys[1])) octiOk++;
if (endsAt(res.legA, rw.a) && endsAt(res.legB, rw.b)) endOk++;
forks.push(res);
}
const crossings = countForkCrossings(forks);
note.push(`[${mode}] octilinear ${octiOk}/${ROWS}, legs-end-on-pin ${endOk}/${ROWS}, crossings ${crossings}`);
if (octiOk !== ROWS) fails.push(`[${mode}] ${ROWS - octiOk} forks not octilinear`);
if (endOk !== ROWS) fails.push(`[${mode}] ${ROWS - endOk} forks whose legs miss their pin`);
if (crossings !== 0) fails.push(`[${mode}] ${crossings} crossings on a clean monotone set (expected 0)`);
}
if (rowsData.length < 200) fails.push(`only ${rowsData.length} rows (floor 200) — vacuous`);
// ── 3: through-pin report, both directions ───────────────────────────────────
const base = { lane: { x: 572, y: 1000 }, a: { id: 'A', x: 760, y: 980 }, b: { id: 'B', x: 760, y: 1020 }, dirOut: 1, mode: 'row' };
const clear = forkRoute({ ...base, obstacles: [{ id: 'X', x: 1500, y: 3000 }], r: 8 });
if (clear.throughPins.length !== 0) fails.push(`through-pin false positive: ${JSON.stringify(clear.throughPins)}`);
// Plant an obstacle ON legA by sampling a midpoint of it.
const probe = forkRoute({ ...base });
const mid = probe.legA[Math.max(1, probe.legA.length - 1)];
const onLeg = forkRoute({ ...base, obstacles: [{ id: 'HIT', x: mid[0], y: mid[1] }], r: 6 });
if (!onLeg.throughPins.includes('HIT')) fails.push('through-pin MISS: an obstacle on legA was not reported');
note.push(`through-pin: clear=${clear.throughPins.length}, planted reported=${onLeg.throughPins.includes('HIT')}`);
// ── 4: determinism ────────────────────────────────────────────────────────────
const d1 = forkRoute({ ...base, mode: 'trunk', forkFrac: 0.37 });
const d2 = forkRoute({ ...base, mode: 'trunk', forkFrac: 0.37 });
if (JSON.stringify(d1.polys) !== JSON.stringify(d2.polys)) fails.push('NON-DETERMINISTIC output');
// ── 5: poison — a non-octilinear leg must be flagged, not passed ──────────────
// Build a poly with a 30° segment and confirm the module's own octilinear test rejects it.
const bad = [[0, 0], [100, 58]]; // slope ~30°, neither H/V nor 45°
if (isOcti(bad)) fails.push('POISON: the octilinear test accepted a 30° segment');
// And a real forkRoute whose pins force a long non-diagonal stays octilinear (sanity that we did not over-trigger):
const far = forkRoute({ lane: { x: 572, y: 500 }, a: { id: 'P', x: 1800, y: 505 }, b: { id: 'Q', x: 900, y: 1200 }, dirOut: 1, mode: 'trunk', forkFrac: 1 });
if (!far.octilinear) fails.push('a legitimate far-pin fork was wrongly non-octilinear');
note.push(`poison: 30deg rejected=${!isOcti(bad)}; far fork octilinear=${far.octilinear}`);
// ── 6: edge cases + a loud guard on non-finite input ─────────────────────────
const okShape = (res) => res.octilinear && isOcti(res.polys[0]) && isOcti(res.polys[1]) &&
res.polys.flat(2).every(Number.isFinite);
const edge = [
['a==b', { lane: { x: 572, y: 800 }, a: { id: 'x', x: 760, y: 900 }, b: { id: 'x', x: 760, y: 900 }, dirOut: 1, mode: 'row' }],
['lane-level', { lane: { x: 572, y: 900 }, a: { id: 'a', x: 760, y: 900 }, b: { id: 'b', x: 760, y: 900 }, dirOut: 1, mode: 'trunk', forkFrac: 0.5 }],
['pins-behind', { lane: { x: 760, y: 900 }, a: { id: 'a', x: 572, y: 880 }, b: { id: 'b', x: 572, y: 920 }, dirOut: 1, mode: 'row' }],
['trunk-neg', { lane: { x: 1800, y: 900 }, a: { id: 'a', x: 1600, y: 880 }, b: { id: 'b', x: 1600, y: 920 }, dirOut: -1, mode: 'trunk', forkFrac: 0.6 }],
];
for (const [name, args] of edge) {
if (!okShape(forkRoute(args))) fails.push(`edge '${name}': not octilinear/finite`);
}
let guardThrew = false;
try { forkRoute({ lane: { x: NaN, y: 900 }, a: { id: 'a', x: 760, y: 880 }, b: { id: 'b', x: 760, y: 920 }, dirOut: 1, mode: 'row' }); }
catch (e) { guardThrew = /non-finite/.test(e.message); }
if (!guardThrew) fails.push('non-finite input did NOT throw (silent NaN path)');
note.push(`edge cases ${edge.length}/${edge.length} octilinear; non-finite guard throws=${guardThrew}`);
for (const n of note) console.log(` · ${n}`);
if (fails.length) {
console.error('FAIL check:fork-leaders');
for (const f of fails) console.error(` ✗ ${f}`);
process.exitCode = 1;
} else {
console.log(`PASS check:fork-leaders — both modes octilinear, legs land on pins, 0 crossings on ${ROWS} clean rows, through-pins caught, deterministic, poison rejected`);
}
}