circularLayout gate: close two tautological checks (5B P2/P3)

P2: test fourSides numbering against GEOMETRY per side (L cy↓, R y↑,
T x↑, B x↓) with a de-overlap drift tolerance, not label-emission order
(which numbering walks, so it could never fail). P3: assert every
pairedRows leader carries polys.length===2. Both mutation-tested: a
reversed side sort and a dropped polys each make the gate FAIL.
This commit is contained in:
Doxa Maps 2026-10-02 12:35:00 +00:00
parent 00db24ed0c
commit 7f272a4cd4

View file

@ -128,15 +128,27 @@ if (four.stats.placed < 800) fails.push(`fourSides: only ${four.stats.placed} pi
for (const n of nums) if (n <= lo) seqOk = false;
if (nums.length) lo = Math.max(lo, ...nums);
}
// monotone WITHIN a side = numbers increase in LABEL EMISSION order (ring order),
// which is the real placement order (four.pins is input order, so it cannot test this).
let monoOk = true;
// monotone WITHIN a side = tested against GEOMETRY, not the emission arrays (which
// numbering walks, so an emission-order check cannot fail — studio P2). Per classic4:
// L cy DESC (num↑ ⇒ screen y DOWN), R cy ASC (y UP), T cx ASC (x UP), B cx DESC (x DOWN).
// Pins are post-spread, so allow a drift tolerance = 60% of the side's mean step; a
// reversed sort (studio's poison) still fails, normal de-overlap drift does not.
const AXIS = { L: ['y', -1], R: ['y', +1], T: ['x', +1], B: ['x', -1] };
let monoOk = true, monoWhy = '';
for (const sd of SEQ) {
const seq = four.labels.filter((l) => l.side === sd).map((l) => l.num);
for (let i = 1; i < seq.length; i++) if (seq[i] <= seq[i - 1]) { monoOk = false; break; }
const ps = four.pins.filter((p) => p.side === sd).sort((a, b) => a.num - b.num);
if (ps.length < 3) continue;
const [axis, dir] = AXIS[sd];
const coords = ps.map((p) => p[axis] * dir); // expected non-decreasing
const span = coords[coords.length - 1] - coords[0];
const tol = 0.6 * Math.abs(span) / (coords.length - 1); // 60% of mean step
let inversions = 0;
for (let i = 1; i < coords.length; i++) if (coords[i] < coords[i - 1] - tol) inversions++;
// a correct side has only small local drift inversions; a reversed sort inverts ~everything
if (inversions > coords.length * 0.2) { monoOk = false; monoWhy = `${sd}:${inversions}/${coords.length}`; }
}
if (!seqOk) fails.push('fourSides: numbers not in side sequence L<T<R<B');
if (!monoOk) fails.push('fourSides: numbers not monotone within a side');
if (!monoOk) fails.push(`fourSides: numbering not geometrically monotone within a side (${monoWhy})`);
// (4) RECOUNT outside-state from the OUTPUT (pins vs state rings), not from stats.
const outFx = recountOutside(four.pins, feats);
if (outFx !== 0) fails.push(`fourSides: recount found ${outFx} pins outside their state (stats said ${four.stats.pinsOutsideState})`);
@ -163,7 +175,10 @@ if (paired.stats.placed < 800) fails.push(`pairedRows: only ${paired.stats.place
if (xr !== 0) fails.push(`pairedRows: recount found ${xr} crossings (stats said ${paired.stats.crossings})`);
const outPr = recountOutside(paired.pins, feats);
if (outPr !== 0) fails.push(`pairedRows: recount found ${outPr} pins outside their state`);
note.push(`pairedRows check: crossings(recount) ${xr}, outside(recount) ${outPr}`);
// (P3) every leader must actually carry its two polylines — a dropped polys passed before.
const badPolys = paired.leaders.filter((l) => !Array.isArray(l.polys) || l.polys.length !== 2).length;
if (badPolys !== 0) fails.push(`pairedRows: ${badPolys} leaders missing polys.length===2`);
note.push(`pairedRows check: crossings(recount) ${xr}, outside(recount) ${outPr}, leaders-with-2-polys ${paired.leaders.length - badPolys}/${paired.leaders.length}`);
}
// ── 3. sideLanes ──────────────────────────────────────────────────────────────