A band holds concentric rows, so it holds four times what I was counting

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.
This commit is contained in:
Doxa Maps 2026-09-29 01:25:22 +00:00
parent 819051c08e
commit b3238db361
2 changed files with 91 additions and 12 deletions

View file

@ -70,11 +70,52 @@ ok(
)
// And it must SHRINK before it reports: a visible pin beats an unplaced one.
const tight = bandFill(makeItems(200), radii, { ringGap: RING_GAP, labels })
// ★ 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)}`,
`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 ─────────────────────────────────────────────────────

View file

@ -149,13 +149,26 @@ export function bandFill(items, radii, {
// clumping together, while staying a pure function of the data.
const sorted = [...group].sort((a, b) => hash01(a.id) - hash01(b.id) || String(a.id).localeCompare(String(b.id)))
// How many fit at this pin size? Two pins must be minSep apart along the arc at the band's
// INNERMOST usable radius, where the circumference is smallest and crowding is worst.
// ★ A BAND HAS DEPTH, SO IT HOLDS CONCENTRIC ROWS — counting one circle of pins was a 4.7x
// undercount and it only showed on the real data. On the uniform 12-per-ring sample this module
// was built against, every ring fitted and the model looked right. The shipped table is heavily
// skewed (ring 8 carries 1,932 groups, ring 5 carries 100), and there the single-row count
// placed 962 of 7,296 — it dropped 87% of the map, every drop faithfully REPORTED, which is why
// no assertion caught it. Capacity is now summed over the rows the band's depth actually allows.
const innerUsableR = Math.max(baseR - half, 1e-6)
let pin = pinRadius
const rowsAt = (p) => Math.max(Math.floor((2 * half) / Math.max(minSep, 2 * p)), 1)
const capacityAt = (p) => {
const sep = Math.max(minSep, 2 * p)
return Math.max(Math.floor((usable * innerUsableR) / sep), 0)
const rows = rowsAt(p)
let total = 0
for (let k = 0; k < rows; k++) {
// Each row sits at its own radius, so each has its own circumference — an outer row of the
// same band legitimately holds more pins than an inner one.
const r = Math.max(baseR - half + ((k + 0.5) * (2 * half)) / rows, 1e-6)
total += Math.floor((usable * r) / sep)
}
return Math.max(total, 0)
}
let capacity = capacityAt(pin)
let shrunk = false
@ -169,18 +182,43 @@ export function bandFill(items, radii, {
const fitting = sorted.slice(0, capacity)
const overflow = sorted.slice(capacity)
// Spread the ones that fit evenly through the usable arc, stepping past the label.
// Spread the ones that fit through the usable arc, stepping past the label.
//
// ★ PLACED ROW BY ROW, because capacity is counted row by row. A hashed radius plus one angular
// step used to decide position; that was fine while a ring held a dozen pins and wrong as soon
// as 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. Rows are assigned deterministically
// from the item's own id, so a group still lands in the same place on every render.
const startA = keepOut ? norm(keepOut.start + keepOut.span) : 0
const step = fitting.length > 0 ? usable / fitting.length : 0
const rows = rowsAt(pin)
const rowOf = [] // how many pins ended up in each row
for (let k = 0; k < rows; k++) rowOf.push(0)
// Deal round-robin by the item's hashed order so every row fills evenly rather than the first
// row taking everything and the outer rows staying empty.
fitting.forEach((it, i) => {
const a = norm(startA + step * (i + 0.5))
// Radius WITHIN the band, deterministic per item: this is what turns a line of dots into a
// filled band. 0.5 would be the old single-circle behaviour.
const t = hash01(`${it.id}:r`)
const r = baseR - half + t * (2 * half)
const row = i % rows
rowOf[row]++
})
const rowCursor = rowOf.map(() => 0)
fitting.forEach((it, i) => {
const row = i % rows
const perRow = Math.max(rowOf[row], 1)
const r = Math.max(baseR - half + ((row + 0.5) * (2 * half)) / rows, 1e-6)
// Each ROW gets its own even spacing across the usable arc. A half-step offset per row keeps
// adjacent rows from lining their pins up radially, which would read as spokes rather than a
// filled band.
const idx = rowCursor[row]++
// ★ THE STAGGER MUST STAY INSIDE THE USABLE ARC. Adding half a step to every odd row pushed
// that row's last pin past the end of the arc and into the label's keep-out — measured at 7
// pins overlapping. Staggering WITHIN the slot keeps every pin inside its own slot, so the
// row still ends before the label begins however many pins it holds.
const slot = usable / perRow
const stagger = row % 2 ? 0.25 : -0.25 // a quarter slot either side of its centre
const a = norm(startA + slot * (idx + 0.5 + stagger))
placed.push({
...it,
ring,
row,
angle: a,
r,
pin,