The 32 are scattered through the life now, not stacked into a bar

rega's contact sheet showed what no gate could: page 3 read as ONE SOLID GREEN BAR. 32 contiguous cells
fill most of row 1 of 52, so the page said "a big green block" where the Driver's intent is "32 boxes lost
in 5,200". Rarity is the whole message and a bar cannot carry it.

⚠ AND THE COMMENT I DELETED SAID THE OPPOSITE, WHICH IS THE PART WORTH KEEPING. It read: "Row-major, so the
shaded run reads as a contiguous block from the top — a scattered shading of the same COUNT would be honest
arithmetic and an unreadable picture." I wrote that reasoning about a picture I had never looked at, and it
was exactly backwards. Scattering is what makes the eye hunt for them, which IS the point.

DETERMINISTIC, NEVER Math.random(): a 32-bit FNV-1a hash of the ZONE ID feeds a mulberry32 PRNG, so the
same packet prints identically every time — two copies of one zone must not disagree — while different
zones get different constellations, so a print run is not 28 sheets wearing the same pattern. Verified: two
consecutive renders produce byte-identical files (md5 febafc1813c6 both times).
Partial Fisher-Yates over the index space rather than shuffling all 5,200, so a large share degrades into
an even spread rather than clumping.

★ AND THE NEW ASSERTION IS THE ONE THAT WOULD HAVE CAUGHT THIS. check-packet-render now measures the shaded
cells' VERTICAL SPREAD as a fraction of the grid height, at the RENDERED layer via getBoundingClientRect —
because a COUNT cannot tell a scatter from a bar. 32 shaded is 32 shaded either way, which is precisely why
every existing assertion stayed green on a broken page.
POISON-TESTED rather than trusted: rebuilding the same 32 cells as a contiguous run makes it FAIL with
"span only 0% of the grid height". The real packet measures 95% (32 cells across 26 distinct rows). The 50%
floor is deliberately loose — a genuine scatter spans nearly the whole page, a bar spans ~1%, and anything
between deserves a human look.
⚠ The poison test first ran on a copy in /tmp and the gate REFUSED it — "outside any checkout, so which
build produced it cannot be checked". A sensible guard, and it meant my first poison run proved nothing;
re-ran inside the tree.

en and ar both PASS at 9 sheets. Page 2 (the empty life grid reading as blank graph paper) is UNTOUCHED and
held for the Driver with the page-order question — shading a reader's life we do not know would be a guess
presented as a fact, which is a product decision, not mine.
This commit is contained in:
Doxa Maps 2026-09-28 02:14:15 +00:00
parent 3bb5fd7b86
commit 35f682f7cb
2 changed files with 65 additions and 5 deletions

View file

@ -247,6 +247,19 @@ const r = await page.evaluate(() => {
prayBoxes: document.querySelectorAll('.pray-box').length,
funnelTip: document.querySelectorAll('.funnel-tip').length,
lifeShaded: document.querySelectorAll('.lc-grid .lc-reached').length,
// ★ THE SHADED CELLS' VERTICAL SPREAD, as a fraction of the grid's own height. A COUNT cannot tell a
// scatter from a bar — 32 shaded is 32 shaded whether they are spread through a life or stacked in
// row 1 — so this measures the SHAPE, which is the thing the page is actually claiming.
lifeSpread: (() => {
const grid = document.querySelector('.sheet-christian .lc-grid')
if (!grid) return null
const on = [...grid.querySelectorAll('.lc-reached')]
if (on.length < 2) return null
const gb = grid.getBoundingClientRect()
if (!(gb.height > 0)) return null
const tops = on.map((el) => el.getBoundingClientRect().top)
return (Math.max(...tops) - Math.min(...tops)) / gb.height
})(),
funnelEq: document.querySelectorAll('.funnel-eq').length,
funnelSteps: document.querySelectorAll('.funnel-mine').length,
// ★ MEASURED SO THE GATE CAN REPORT WHAT IT ASSERTED. rega: "the gate prints no funnel line, even
@ -672,6 +685,18 @@ if (r.lifeSheets) {
+ `but rendered ${perGrid} — the grid changed size, so the page no longer shows a human life`)
}
}
// ⚠ AND THE SHADE MUST BE SCATTERED, NOT STACKED — the assertion that would have caught what three gates
// and a passing suite did not. rega's contact sheet (2026-09-28) showed page 3 reading as ONE SOLID BAR:
// 32 contiguous cells fill most of row 1 of 52, so the page said "a big green block" where the Driver's
// intent is "32 boxes lost in 5,200". Every assertion passed, because they all measured the COUNT.
// Half the grid height is a deliberately loose floor: a genuine scatter of even a few cells spans most of
// the page (measured: 32 cells over 26 rows, 95% of the height), and a bar spans ~1%. Anything in between
// is worth a human look.
if (r.christianSheets && r.lifeSpread !== null && r.lifeSpread !== undefined && r.lifeSpread < 0.5) {
fails.push(`the christian-share page's shaded cells span only ${(r.lifeSpread * 100).toFixed(0)}% of the `
+ 'grid height — they are stacked, not scattered, so the page reads as a bar rather than as rare marks '
+ 'in a whole life. Shade by a deterministic scatter, not a contiguous run.')
}
// ★ THE SHADED SHARE MUST BE NON-ZERO AND NOT EVERYTHING. 0 shaded means the percentage never
// reached the page (a null christian-share file renders the page with an empty grid, which reads as
// "nobody is reached" — a false claim rather than missing data); 5,200 means the maths inverted.

View file

@ -518,14 +518,49 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
// ★ 5,200 IS NOT ROUNDED OR CHOSEN FOR LAYOUT: 100 years x 52 weeks, the Driver's own numbers
// ("a 100 year life in weeks"). Everything else here is derived from it, so the two pages cannot
// drift apart into two grids that merely look alike.
function lifeGrid({ shaded = 0, shadeClass = 'lc-on' } = {}) {
function lifeGrid({ shaded = 0, shadeClass = 'lc-on', seed = '' } = {}) {
const YEARS = 100, WEEKS = 52, TOTAL = YEARS * WEEKS;
const n = Math.max(0, Math.min(TOTAL, Math.round(Number(shaded) || 0)));
// Row-major, so the shaded run reads as a contiguous block from the top — a scattered
// shading of the same COUNT would be honest arithmetic and an unreadable picture.
// ⚠ SCATTERED, NOT STACKED — and the comment that used to sit here said the opposite. It read:
// "Row-major, so the shaded run reads as a contiguous block from the top — a scattered shading of the
// same COUNT would be honest arithmetic and an unreadable picture." That was reasoning about a picture
// I had never looked at, and it was exactly backwards.
// ★ WHAT THE RENDER ACTUALLY SHOWED (rega's contact sheet, 2026-09-28): 32 contiguous cells fill most
// of row 1 of 52, so the page reads as ONE SOLID GREEN BAR across the header — the ordinary chart the
// whole life-grid idea exists to avoid. The Driver's intent is "32 boxes lost in 5,200"; rarity is the
// message, and a bar cannot say it. Scattering makes the eye hunt for them, which IS the point.
// ⚠ AND NO GATE COULD SEE IT. check-packet-render asserts the shaded COUNT is strictly between 0 and
// 5,200 — true of a scatter and equally true of a bar. A count cannot distinguish a distribution.
//
// DETERMINISTIC, NEVER Math.random(): the same packet must print identically every time, or two copies
// of one zone disagree and neither is reproducible. A 32-bit FNV-1a hash of the seed (the zone id) feeds
// a mulberry32 PRNG, so the pattern is stable per zone and DIFFERENT between zones — which also avoids
// every packet in a print run carrying the same constellation.
let h = 2166136261;
for (let i = 0; i < String(seed).length; i++) {
h ^= String(seed).charCodeAt(i); h = Math.imul(h, 16777619);
}
const rand = () => {
h |= 0; h = (h + 0x6D2B79F5) | 0;
let t = Math.imul(h ^ (h >>> 15), 1 | h);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
// Partial Fisher-Yates over the index space: pick n distinct cells without materialising a shuffle of
// all 5,200. Sparse by construction, so a large n degrades gracefully rather than clumping.
const picked = new Set();
const idx = [];
for (let i = 0; i < TOTAL; i++) idx.push(i);
for (let k = 0; k < n; k++) {
const j = k + Math.floor(rand() * (TOTAL - k));
const t = idx[k]; idx[k] = idx[j]; idx[j] = t;
picked.add(idx[k]);
}
const cells = [];
for (let i = 0; i < TOTAL; i++) {
cells.push(`<i class="lc-c${i < n ? ' ' + shadeClass : ''}"></i>`);
cells.push(`<i class="lc-c${picked.has(i) ? ' ' + shadeClass : ''}"></i>`);
}
return { TOTAL, YEARS, WEEKS, shadedCount: n, html: `<div class="lc-grid">${cells.join('')}</div>` };
}
@ -560,7 +595,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
function christianPage(zone, christian) {
if (!christian || !Number.isFinite(Number(christian.pctEvangelical))) return '';
const pct = Number(christian.pctEvangelical);
const g = lifeGrid({ shaded: (pct / 100) * 5200, shadeClass: 'lc-reached' });
const g = lifeGrid({ shaded: (pct / 100) * 5200, shadeClass: 'lc-reached', seed: zone.id || zone.zoneKey || 'upg-100' });
const lostPct = 100 - pct;
// ⚠ TWO DECIMALS ON BOTH SIDES, not rounded above 10. The reached share is 0.61% and rounding the
// other side to "99%" loses the asymmetry that is the whole point: 99.39 and 0.61 sum to 100, while