Found by running the SHIPPED table through the layout instead of the uniform sample the module was built against. The real near-neighbours data is heavily skewed — ring 8 carries 1,932 groups, ring 5 carries 100 — and against it bandFill placed 962 of 7,296. It dropped 87% of the map, and every drop was faithfully reported, which is exactly why no assertion caught it: all 16 asked "is the overflow reported?" and none asked "is the CAPACITY right?". The capacity model counted one circle of pins at the band's innermost radius. A band has depth, so it holds several concentric rows, each with its own circumference. Counting them is a ~2x correction at ringGap 24 and more at larger gaps; on the real table it takes placement from 962 to 1,851 on the gate's geometry and every under-full ring now places everything it is given. Placement had to follow: a hashed radius with one angular step was fine while a ring held a dozen pins and wrong once it held hundreds, because two pins could take nearly the same angle at nearly the same radius while the capacity sum insisted there was room. Pins are now dealt round-robin into rows, each row evenly spaced across the usable arc, staggered a quarter slot so adjacent rows do not line up radially and read as spokes. Two bugs found on the way, both by the gate failing honestly: - the stagger first added half a step to every odd row, which pushed that row's last pin past the end of the arc and into the label's keep-out — 7 overlapping. Staggering WITHIN the slot keeps every pin inside its own slot. - the shrink arm tested at 200 groups, which only crowded a ring while capacity was undercounted. It now tests at 2,000, the honest pressure for a 7,296-row table, and still reaches the pin floor and reports. The new capacity arm asserts a RATIO against the single-row count, not a magic number: its first version hardcoded ">400" from the component's ringGap of 22 while the gate builds radii at 24 — the code was right and my threshold was guessed. Poison-tested: forcing rows back to 1 gives 1.05x and fails both arms.
195 lines
11 KiB
JavaScript
195 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-ring-bands.mjs — the ring band layout keeps the promises the ring target rests on.
|
|
*
|
|
* Four properties, each of which fails SILENTLY if it breaks — the drawing still renders, it is
|
|
* just wrong, and nobody can tell by looking at one sheet:
|
|
* 1. every pin sits inside its own ring's band (not on the old single circle, not in the gap)
|
|
* 2. no two pins on a ring are closer than minSep
|
|
* 3. no pin sits inside a ring label's keep-out arc
|
|
* 4. the same input twice gives byte-identical output
|
|
*
|
|
* Every arm has a NEGATIVE CONTROL: a deliberately broken input that must fail it. An assertion
|
|
* that cannot fail is not evidence, and three of the bugs this module exists to prevent were
|
|
* originally found only because a check was made to fail on purpose first.
|
|
*/
|
|
import { bandFill, curvedRingLabels, inBand, inArc, hash01 } from '../library/components/charts/ringBands.js'
|
|
import { ringRadii, RING_MAX } from '../library/components/charts/centeredSet.js'
|
|
|
|
const pass = []
|
|
const fail = []
|
|
const ok = (label, cond, detail = '') => (cond ? pass : fail).push(detail ? `${label} — ${detail}` : label)
|
|
|
|
const RING_GAP = 24
|
|
const radii = ringRadii({ innerR: 26, ringGap: RING_GAP })
|
|
const labels = curvedRingLabels(radii, (r) => `Ring ${r} — same language family`, { ringGap: RING_GAP })
|
|
|
|
/** A reproducible population: n groups spread across the rings. */
|
|
const makeItems = (n, tag = 'g') =>
|
|
Array.from({ length: n }, (_, i) => ({ id: `${tag}${i}`, ring: (i % RING_MAX) + 1 }))
|
|
|
|
const items = makeItems(120)
|
|
const out = bandFill(items, radii, { ringGap: RING_GAP, labels })
|
|
|
|
// ── 1. every pin inside its band ────────────────────────────────────────────────────────────────
|
|
const strays = out.placed.filter((p) => !inBand(p, radii, { ringGap: RING_GAP }))
|
|
ok('every pin sits inside its own ring band', strays.length === 0, `${strays.length} outside`)
|
|
|
|
// NEG CONTROL: a pin pushed out of its band must be caught, or arm 1 proves nothing.
|
|
const moved = { ...out.placed[0], r: out.placed[0].r + RING_GAP }
|
|
ok('NEG CONTROL: a pin moved a full ringGap is detected as outside', !inBand(moved, radii, { ringGap: RING_GAP }))
|
|
|
|
// ── 2. pins on a ring are at least minSep apart ─────────────────────────────────────────────────
|
|
// Measured along the arc at each pin's own radius, which is how a reader sees the gap.
|
|
const tooClose = []
|
|
const byRing = new Map()
|
|
for (const p of out.placed) {
|
|
if (!byRing.has(p.ring)) byRing.set(p.ring, [])
|
|
byRing.get(p.ring).push(p)
|
|
}
|
|
for (const [ring, ps] of byRing) {
|
|
const sorted = [...ps].sort((a, b) => a.angle - b.angle)
|
|
for (let i = 1; i < sorted.length; i++) {
|
|
const dx = sorted[i].x - sorted[i - 1].x
|
|
const dy = sorted[i].y - sorted[i - 1].y
|
|
const d = Math.hypot(dx, dy)
|
|
// Two pins may be close in ANGLE and far apart in radius — that is the band doing its job — so
|
|
// the test is euclidean distance, not angular distance.
|
|
if (d < Math.min(out.minSep, 2 * sorted[i].pin) - 1e-9) tooClose.push({ ring, d: d.toFixed(2) })
|
|
}
|
|
}
|
|
ok('no two pins on a ring are closer than the pin diameter', tooClose.length === 0, `${tooClose.length} pair(s)`)
|
|
|
|
// NEG CONTROL: at an absurd minSep the ring cannot hold its items, so the module must REPORT
|
|
// unplaced rather than quietly overlapping them.
|
|
const crowded = bandFill(makeItems(400), radii, { ringGap: RING_GAP, labels, minSep: 60 })
|
|
ok(
|
|
'NEG CONTROL: an impossible minSep reports unplaced rather than overlapping',
|
|
crowded.unplaced.length > 0 && crowded.unplaced.every((u) => /full at the pin floor/.test(u.reason)),
|
|
`${crowded.unplaced.length} reported`,
|
|
)
|
|
|
|
// And it must SHRINK before it reports: a visible pin beats an unplaced one.
|
|
// ★ 2000, NOT 200 — and the change of number is itself the record of a bug. This arm used 200,
|
|
// which crowded a ring only while capacity was counted as a SINGLE row of pins. Once capacity was
|
|
// counted over the rows a band's depth actually allows (the real fix), 200 groups fit at full size
|
|
// and nothing shrank, so the arm failed while the module was working better than before. The
|
|
// shipped table carries 7,296 placeable groups, so the honest pressure to test at is thousands.
|
|
const tight = bandFill(makeItems(2000), radii, { ringGap: RING_GAP, labels })
|
|
ok(
|
|
'a crowded ring shrinks its pins before reporting anything unplaced',
|
|
tight.rings.some((r) => r.shrunk) && tight.rings.every((r) => r.pin >= 2.6 - 1e-9),
|
|
`min pin ${Math.min(...tight.rings.map((r) => r.pin)).toFixed(2)}, ${tight.rings.filter((r) => r.shrunk).length} ring(s) shrank`,
|
|
)
|
|
|
|
// ★ THE REAL TABLE'S SHAPE, because a uniform sample hid a 4.7x undercount. The shipped
|
|
// near-neighbours table is heavily skewed — ring 8 carries 1,932 groups and ring 5 carries 100 —
|
|
// and against a single-row capacity model bandFill placed 962 of 7,296, dropping 87% of the map
|
|
// with every drop faithfully reported. No assertion here caught it, because they all asked "is the
|
|
// overflow reported?" and none asked "is the capacity RIGHT?". This one does.
|
|
const skewed = [
|
|
...Array.from({ length: 1190 }, (_, i) => ({ id: `r1-${i}`, ring: 1 })),
|
|
...Array.from({ length: 1932 }, (_, i) => ({ id: `r8-${i}`, ring: 8 })),
|
|
...Array.from({ length: 100 }, (_, i) => ({ id: `r5-${i}`, ring: 5 })),
|
|
]
|
|
const real = bandFill(skewed, radii, { ringGap: RING_GAP, labels })
|
|
const r8 = real.rings.find((r) => r.ring === 8)
|
|
const r5 = real.rings.find((r) => r.ring === 5)
|
|
// Asserted as a RATIO against the single-row count this band would give, not a magic number: the
|
|
// first version of this arm hardcoded ">400", which came from the component's ringGap of 22 while
|
|
// the gate builds its radii at 24. The code was right and the threshold was guessed. A ratio stays
|
|
// true when the geometry changes, which is the only way this arm keeps meaning something.
|
|
const singleRowCapacity = (() => {
|
|
const half = (RING_GAP / 2) * (1 - 0.16)
|
|
const base = radii.find((x) => x.ring === 8).r
|
|
const sep = Math.max(7, 2 * 3.4)
|
|
const label = labels.find((l) => l.ring === 8)
|
|
const usable = Math.PI * 2 - Math.min(label.keepOut.span, Math.PI * 2)
|
|
return Math.floor((usable * (base - half)) / sep)
|
|
})()
|
|
ok(
|
|
'a band counts the concentric rows its depth allows, not one circle of pins',
|
|
r8.capacity > singleRowCapacity * 1.5,
|
|
`ring 8 capacity ${r8.capacity} vs ${singleRowCapacity} for a single row (${(r8.capacity / singleRowCapacity).toFixed(2)}x)`,
|
|
)
|
|
ok(
|
|
'a ring that is not over-full places everything, even when a sibling ring overflows',
|
|
r5.fitted === 100 && r5.wanted === 100,
|
|
`ring 5 ${r5.fitted}/${r5.wanted}`,
|
|
)
|
|
|
|
// ── 3. no pin inside a label's keep-out arc ─────────────────────────────────────────────────────
|
|
const inLabel = out.placed.filter((p) => {
|
|
const l = labels.find((x) => x.ring === p.ring)
|
|
return l && inArc(p.angle, l.keepOut.start, l.keepOut.span)
|
|
})
|
|
ok('no pin sits inside a ring label keep-out arc', inLabel.length === 0, `${inLabel.length} overlapping`)
|
|
|
|
// NEG CONTROL: the arc test must actually detect a point inside it — including the WRAPPING case,
|
|
// where a naive start<=a<=end comparison is wrong for exactly one label position.
|
|
const wrapStart = 6.0 // radians, so start+span crosses 0
|
|
ok('NEG CONTROL: inArc detects a point inside a wrapping arc', inArc(0.1, wrapStart, 0.6))
|
|
ok('NEG CONTROL: inArc rejects a point outside a wrapping arc', !inArc(3.0, wrapStart, 0.6))
|
|
|
|
// ── 4. determinism ──────────────────────────────────────────────────────────────────────────────
|
|
const again = bandFill(makeItems(120), radii, { ringGap: RING_GAP, labels })
|
|
ok(
|
|
'the same input twice gives byte-identical output',
|
|
JSON.stringify(again.placed) === JSON.stringify(out.placed),
|
|
'two runs agree',
|
|
)
|
|
// Order of the INPUT must not change the output either: a caller that filters or re-sorts its rows
|
|
// must not move every dot on the chart.
|
|
const shuffled = bandFill([...items].reverse(), radii, { ringGap: RING_GAP, labels })
|
|
const key = (ps) => JSON.stringify([...ps].sort((a, b) => String(a.id).localeCompare(String(b.id))).map((p) => [p.id, p.x.toFixed(9), p.y.toFixed(9)]))
|
|
ok('input order does not change any position', key(shuffled.placed) === key(out.placed))
|
|
|
|
// NEG CONTROL: the hash really does vary per id, or "deterministic" would be trivially true because
|
|
// everything landed in the same place.
|
|
ok('NEG CONTROL: hash01 separates ids', hash01('a') !== hash01('b') && hash01('a') === hash01('a'))
|
|
|
|
// ── no label may starve its own ring ────────────────────────────────────────────────────────────
|
|
// ★ THIS ARM EXISTS BECAUSE THE GATE ONCE PASSED THE BUG. Uncapped, a long name on ring 1 claimed
|
|
// 5.65 rad — 96% of that small circle — so capacity was ZERO, all 12 groups were reported unplaced,
|
|
// and the innermost ring drew empty. Every other arm was green: the items WERE reported rather than
|
|
// silently dropped, which is what they assert. "Reported, not dropped" is not the same promise as
|
|
// "the ring draws". A long label now truncates instead of eating its circle.
|
|
const longLabels = curvedRingLabels(radii, () => 'A very much longer ring label indeed', { ringGap: RING_GAP })
|
|
const starved = bandFill(makeItems(120), radii, { ringGap: RING_GAP, labels: longLabels })
|
|
ok(
|
|
'a long label never starves its ring: every ring still places something',
|
|
starved.rings.every((r) => r.fitted > 0),
|
|
`empty rings: ${starved.rings.filter((r) => r.fitted === 0).map((r) => r.ring).join(',') || 'none'}`,
|
|
)
|
|
ok(
|
|
'no label takes more than its share of its own circle',
|
|
longLabels.every((l) => l.keepOut.span <= Math.PI * 2 * 0.45 + 2 * 0.18 + 1e-9),
|
|
`max ${(Math.max(...longLabels.map((l) => l.keepOut.span)) / (Math.PI * 2) * 100).toFixed(0)}%`,
|
|
)
|
|
ok(
|
|
'a truncated label says so, and says how many characters fit',
|
|
longLabels[0].truncated === true && longLabels[0].fitChars > 0 && longLabels[0].fitChars < 36,
|
|
`ring 1 fits ${longLabels[0].fitChars} chars`,
|
|
)
|
|
|
|
// ── the label geometry is derived, not guessed ──────────────────────────────────────────────────
|
|
const shortL = curvedRingLabels(radii, () => 'A', { ringGap: RING_GAP })
|
|
const longL = curvedRingLabels(radii, () => 'A much longer ring label', { ringGap: RING_GAP })
|
|
ok(
|
|
'a longer label claims a wider keep-out arc',
|
|
longL[0].keepOut.span > shortL[0].keepOut.span,
|
|
`${longL[0].keepOut.span.toFixed(3)} > ${shortL[0].keepOut.span.toFixed(3)}`,
|
|
)
|
|
ok(
|
|
'the same text needs a SMALLER arc on a larger ring',
|
|
longL[RING_MAX - 1].span < longL[0].span,
|
|
'arc length = r * angle',
|
|
)
|
|
|
|
for (const p of pass) console.log(` ✓ ${p}`)
|
|
for (const f of fail) console.log(` ✗ ${f}`)
|
|
if (fail.length) {
|
|
console.log(`\nRING-BANDS: FAIL — ${fail.length} of ${pass.length + fail.length}`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`\nRING-BANDS: PASS — ${pass.length} assertions, every arm with a negative control`)
|