The six-row funnel can finally be rendered

ITS ONLY WITNESS WAS MY OWN HARNESS. The browser path honours the universal
`list` prop — upg-100-list.vue calls applyPeopleGroupList() at every fetch site —
but library/scripts/posters/packet.mjs had NO list support at all. So the
non-default-list branch of funnelPage, including the six-row shape where a list's
own total is inserted before the reader's row, could not be produced by the only
pipeline rega can drive. I had unit-tested it through a script I wrote about it,
which is exactly the state I have been objecting to in other people's gates.
rega offered to shoot a ?list= packet and there was no command to give them.

PACKET_LIST=<id> closes that. Rendered for the first time, and it is right:
  rows   12,379 -> 7,341 -> 4,754 -> 2,107 -> 1,745 -> 37
  bars   1080 / 640 / 415 / 184 / 152 / 26      (six rows, six bars)
  label  "Project 4700: frontier people groups" between the network's list and
         the reader's 37
  share  "This packet is 2.1% of the 1,745"     (no false "1 of 27 lists")
  foot   provenance only, no coverage claim
  row gap auto-adjusted 218 -> 147px for the extra row; last row stays 14px so
  the share still reads as its conclusion.

IT READS THE MEMBERSHIP FILE FROM DISK, not through loadListMembership(), which
fetches from the asset pool over HTTP — there is no server in a Node build. Same
file, same slugs, same counts.doxa.

AND IT FAILS CLOSED: an unknown or empty list exits non-zero rather than quietly
producing a packet of every group. peopleGroupList.js makes the same choice for
the same reason ("a map that silently showed all 2,107 while an embed asked for
Project 700 would be showing the wrong list with no sign of it"), and a packet is
worse than a map — it is printed and handed to someone.

⚠ buildFunnel() IS NOW A FUNCTION, NOT A CONSTANT, because it depends on a list
resolved after the records load. It mirrors upg-100-list.vue's packetFunnel()
exactly: the default list is the only one the zone catalogue describes, so it is
the only one that may claim "1 of K lists" and an unassigned count.

── AND A CORRECTION TO MY OWN VERIFICATION ─────────────────────────────────
"Numerals untouched" is about DIGITS, not GROUPING, and I verified the digits and
assumed the rest. rega measured the rendered hi sheet: Indian grouping,
"8,28,85,63,969 लोग" (lakh/crore 2-2-3) rather than Western 3-3-3. That is
CORRECT hi-IN formatting, so changing it would be worse — the real risk was never
the grouping but INCONSISTENCY, the same number printed two ways in one document.
Measured and it is consistent: num() is the only formatter in the renderer, so the
funnel's populations and the card pages' populations group identically — confirmed
against the rendered hi packet (card populations 1,70,000 / 3,37,000 / 8,10,000,
Indian-grouped like the funnel's). Only values at or above 100,000 can distinguish
the systems at all; my first check classified "1,400" and read a mismatch that did
not exist.

Verified: default path byte-identical (43 groups, 7 pages, 5 rows, 5 bars); all
four fixtures PASS with rows==bars; PACKET_LIST=frontier gives 37 of 43 records
and 6 pages; check:packet-strings-parity PASS; vite build clean, bundle unchanged
(the change is Node-side).
This commit is contained in:
Doxa Maps 2026-09-26 07:22:26 +00:00
parent 373fdd2595
commit 6fc6f2017b
3 changed files with 60 additions and 4 deletions

View file

@ -237,6 +237,17 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null })
// never claims that band; it states the share the data can defend. Shepherd approved exactly this
// (ENDTOKEN-DXS-UPG-KDECIDE), K=27 including the two residual buckets until the Driver rules.
//
// ⚠ "NUMERALS UNTOUCHED" IS ABOUT DIGITS, NOT GROUPING, and those are two properties. I verified the
// digits and assumed the rest. rega measured the rendered hi sheet and found Indian grouping —
// "8,28,85,63,969 लोग" (lakh/crore, 2-2-3) rather than Western 3-3-3. That is CORRECT hi-IN
// formatting, so changing it would be worse than leaving it, and the real risk was never the
// grouping itself but INCONSISTENCY: the same number printed two ways in one document.
// Measured 2026-09-26, and it is consistent: num() is the ONLY formatter in this renderer, so the
// funnel's populations and the people-group cards' populations group identically. Confirmed against
// the rendered hi packet — card populations 1,70,000 / 3,37,000 / 8,10,000 are Indian-grouped like
// the funnel's. (Only values at or above 100,000 can distinguish the two systems at all; my first
// check classified "1,400" and read a mismatch that did not exist.)
//
// ⚠ NUMERALS STAY WESTERN. num() is toLocaleString(lang), and for the bare `ar`/`hi` tags CLDR's
// default numbering system is `latn` — verified: 12,379 and 2,107 in both. Arabic-Indic digits
// need an explicit -u-nu-arab, which nothing here asks for. The brief requires numerals untouched,

View file

@ -123,17 +123,24 @@ const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'));
// them, at build time, so no figure on the page is a typed constant.
// A missing file yields null, and buildPacket omits page 2 rather than printing numbers it could
// not read.
const FUNNEL = (() => {
// ⚠ A FUNCTION, NOT A CONSTANT, because it depends on PACKET_LIST — which is resolved further down,
// after the records load. It mirrors upg-100-list.vue's packetFunnel() exactly: the DEFAULT list is the
// only one the zone catalogue describes, so it is the only one that may claim "1 of K lists" and an
// unassigned count; a non-default list states its OWN total and goes quiet on the rest.
function buildFunnel(membership) {
try {
const raw = JSON.parse(readFileSync(join(DATA, '../../../data/people-groups/vision-funnel.json'), 'utf8'));
if (!raw || !Array.isArray(raw.steps) || !raw.steps.length) return null;
if (membership) {
return { ...raw, listTotal: membership.total, listId: LIST, listLabel: membership.label };
}
const lists = [...(clusters.lists || []), ...(clusters.asia_lists || []),
...(clusters.uncovered_lists || [])];
const listTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0;
const assigned = lists.reduce((s, l) => s + (Number(l.count) || 0), 0);
return { ...raw, listCount: lists.length, listTotal, unassigned: Math.max(0, listTotal - assigned) };
} catch (_) { return null; }
})();
}
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js'));
@ -229,7 +236,45 @@ async function buildPacket(zone, groups, mapSvg) {
}
// ── MAIN ────────────────────────────────────────────────────────────────────
const records = await loadRecords();
const allRecords = await loadRecords();
// ★ PACKET_LIST MAKES THE NON-DEFAULT-LIST PATH REACHABLE FROM THIS GENERATOR, and it needed to be.
// The BROWSER path honours the universal `list` prop — upg-100-list.vue calls applyPeopleGroupList()
// at every fetch site — but this generator had no list support at all, so the SIX-ROW funnel (a list's
// own total inserted before the reader's row) could not be rendered by the only pipeline rega can
// drive. Its only evidence was a harness I wrote about it, which is the state I have been objecting to
// elsewhere: a code path whose sole witness is its author's own test.
//
// ⚠ IT READS THE MEMBERSHIP FILE FROM DISK rather than through loadListMembership(), which fetches
// from the asset pool over HTTP — there is no server here. Same file, same `slugs`, same counts.doxa.
// ⚠ AND IT FAILS CLOSED. An unknown or unreadable list exits non-zero instead of quietly producing a
// packet of every group: peopleGroupList.js makes the same choice for the same reason ("a map that
// silently showed all 2,107 while an embed asked for Project 700 would be showing the wrong list with
// no sign of it"). A packet is worse — it is printed and handed to someone.
const LIST = process.env.PACKET_LIST || '';
let LIST_MEMBERSHIP = null;
if (LIST) {
const mf = join(__dirname, `../../../data/people-groups/lists/${LIST}.json`);
if (!existsSync(mf)) {
console.error(`[packet] PACKET_LIST=${LIST}: no membership file at ${mf}`);
console.error(' Run library/scripts/people-groups/build-lists.mjs, or use the default list.');
process.exit(1);
}
const m = JSON.parse(readFileSync(mf, 'utf8'));
LIST_MEMBERSHIP = { slugs: new Set(m.slugs || []), total: Number(m?.counts?.doxa) || (m.slugs || []).length, label: m.label || LIST };
if (!LIST_MEMBERSHIP.slugs.size) {
console.error(`[packet] PACKET_LIST=${LIST}: membership file lists no slugs. Failing closed.`);
process.exit(1);
}
}
const records = LIST_MEMBERSHIP
? allRecords.filter((r) => LIST_MEMBERSHIP.slugs.has(r.slug))
: allRecords;
if (LIST_MEMBERSHIP) {
console.log(`[packet] list=${LIST} — ${records.length} of ${allRecords.length} record(s) `
+ `belong to it (list total ${LIST_MEMBERSHIP.total})`);
}
const FUNNEL = buildFunnel(LIST_MEMBERSHIP);
const bySlug = new Map(records.map((r) => [r.slug, r]));
const zones = allZones().filter((z) => !ONLY || z.id === ONLY);
if (!zones.length) { console.error(`[packet] no zone matches ONLY=${ONLY}`); process.exit(1); }

View file

@ -361,7 +361,7 @@
"lists": "regenerate data/people-groups/lists/<id>.json, the DOXA slugs in each people-group list of library/lists/lists.json, from the IMB CSV and the DOXA snapshot. Re-run after either changes.",
"packet-fixture": "regenerate the committed records subset (smallest zone, image_url stripped) that lets CI build a packet at all — out/ is gitignored, so a clean checkout has neither the records cache nor any packet.",
"packet:ci": "build EVERY committed fixture, no network, each renamed to its own output so they coexist. Was one fixture (tibet); packet.mjs names its output for the ZONE not the fixture, so sequential builds overwrote each other and check:packets-all saw a single SHAPE while reporting success. The fixtures are chosen at the PER_PAGE=20 page boundary — exactly 20, 21, and 1 group — because laos (95) and tibet (43) are both mid-range remainder cases and the boundary itself had never rendered.",
"packets": "the prayer packets. READS out/posters/<zone>.html for page 1's map, which `bun run posters` writes — without it the packet still builds and page 1 is simply BLANK, so the script now warns per-zone AND in its summary. PACKET_LANG=<loc> for one language, ONLY=<zoneKey> for one zone, NOPHOTOS=1 to skip the photo fetch.",
"packets": "the prayer packets. READS out/posters/<zone>.html for page 1's map, which `bun run posters` writes — without it the packet still builds and page 1 is simply BLANK, so the script now warns per-zone AND in its summary. PACKET_LANG=<loc> for one language, ONLY=<zoneKey> for one zone, NOPHOTOS=1 to skip the photo fetch. ENV: ONLY=<zone> one zone · PACKET_LANG=<locale> · PACKET_RECORDS=<file> a committed fixture instead of the live API · PACKET_LIST=<list-id> the universal list prop for this generator (doxa default; unreached/frontier/frontier-500k/unengaged read data/people-groups/lists/<id>.json from disk, since there is no server to fetch the asset pool from). PACKET_LIST FAILS CLOSED on an unknown or empty list rather than silently printing every group — a packet that showed the wrong list is printed and handed to someone. It exists because the browser path honours ?list= while this generator did not, so the six-row funnel (a list's own total inserted before the reader's row) was unreachable by the only pipeline that can render it.",
"posters:one": "ONLY=<zoneKey> to build one",
"serve": "serves public/ the way PRODUCTION does: a DIRECTORY request returns 404, no index fallback. This was python3 -m http.server, which DID resolve a directory to index.html while the live static layer does not — so gates passed on URLs that 404 live (measured 2026-09-23; it cost a false \"publish failed\" reading and hid a bad URL in verify-shell-scroll for a whole flight). Loopback only, same as before. Override port/host with PORT/HOST.",
"serve:permissive": "LOOPBACK ONLY. This was --bind 0.0.0.0, which published the built tree on every interface of whatever machine ran a gate; measured on the build VPS it answered HTTP 200 on the box's PUBLIC IP while a browser gate was running. Every consumer (verify-browser-all, check:a11y-button-names, check:nav-reachable, smoke:share-all) already defaults to 127.0.0.1, so nothing needed the wider bind. Override with BASE_URL/BASE if you deliberately want to reach it from another host.",