From 3ef164c01484c408516948dd1d3d0ab6a1d15dcc Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sun, 27 Sep 2026 01:27:47 +0000 Subject: [PATCH] The deaf zone could not be generated at all, and five more resolved to nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:packets-all reported "zones covered : 2 of 28", which reads as twenty-six nobody had got round to. Six of those twenty-six could not be generated by ANY command, for two causes, and the report could not tell the difference between a gap you close by working harder and a gap you cannot close at all. 1. `deaf` WAS UNREACHABLE BY CONSTRUCTION. allZones() looped three keys of clusters.json — lists (13) + asia_lists (12) + uncovered_lists (2) = 27 — and the file carries zones in a FOURTH, deaf_lists (2), which legend-tree.js collapses into ONE unified leaf (affinity bloc A017). legend-tree.js:106 states the real arithmetic: "13 + 12 + 2 + 1 deaf = 28". So `deaf` was a poster and a legend row with no obtainable packet: ONLY=deaf exited 1, "no zone matches". Fixed by enumerating through leafZones(buildLegendNodes(...)) — the SAME seam the poster generator and upg-100-list.vue use, so all three now agree on what a zone is. Leaves do not carry selectors, so they are re-attached by id exactly as generate.mjs:2801-2808 does it. 2. THE FIELD LIST WAS PART OF THE SELECTOR CONTRACT AND NOBODY HAD SAID SO. The records query asked for country_code and no other selector field, while selector-evaluator reads three more: rop1 (affinity_bloc -> deaf), wagf_block (west/east/central-africa) and wagf_region (europe, and the southern-africa-islands union). Those six zones evaluated to an EMPTY set for every record and were skipped as "no groups resolved" — which reads as missing DATA and was missing FIELDS. The tell: the records cache holds 123 groups with "Deaf" in the name and evaluate() still returned 0 slugs. Verified both directions against the evaluator — 0 slugs when a record lacks the field, the correct slug (and only that one) when it carries it, for all six selectors. The list is now DERIVED from presentation fields + the evaluator's own requirements, so a new selector type is a change in one place rather than a superset someone must remember to widen. 3. OFFLINE=1 WAS DOCUMENTED AT THE TOP OF THIS FILE AND COULD NEVER HAVE WORKED. loadRecords() has an OFFLINE branch that needs no API, but the fail-closed guard ran first and exited 1 without consulting it. The note above that guard records it being taught about PACKET_RECORDS for this exact reason; OFFLINE was never added. Worse than an undocumented flag: it sends a reader down a route the program refuses. Any env var that makes the API unnecessary is now named at the guard. MEASURED, not assumed: an ordinary zone still builds identically through the new enumeration (Tibet, 43 groups / 7 pages, check-packet-render PASS with --expect-funnel), all 28 zones now carry a selector, and OFFLINE=1 generated 22 of them from the cache. The other six need a records source carrying wagf_block/wagf_region/rop1; the cache predates the fixed field list, so their render is still unverified and the gate now says so by name rather than by count. --- library/scripts/posters/packet.mjs | 71 +++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/library/scripts/posters/packet.mjs b/library/scripts/posters/packet.mjs index 58eb7ec01..980a8d23b 100644 --- a/library/scripts/posters/packet.mjs +++ b/library/scripts/posters/packet.mjs @@ -57,8 +57,20 @@ const API = process.env.VITE_API_BASE_URL || process.env.MAP_APP_API_URL; // BEFORE loadRecords() ever looks at PACKET_RECORDS, so `bun run packet:ci` exited 1 on a // tree that had everything it needed. It only passed in hand-testing because I had been // supplying a dummy host — the real invocation path is what exposed it. -if (!API && !process.env.PACKET_RECORDS) { - console.error('[packet] No API base — set VITE_API_BASE_URL, or PACKET_RECORDS for a fixture build.'); +// ⚠ AND THE SAME BUG HAD A SECOND ESCAPE HATCH, fixed 2026-09-27. The note above records this guard +// being taught about PACKET_RECORDS; OFFLINE was never added to it. loadRecords() reads +// `if (process.env.OFFLINE && existsSync(cache)) return ...` — a path that needs no API either — but +// this check ran first and exited 1, so OFFLINE=1 was documented at the top of this file and could +// never once have worked. That is worse than an undocumented feature: it sends a reader down a route +// the program refuses. Any env var that makes the API unnecessary must be named HERE, next to the +// guard, or it is decoration. +const CACHE_OK = process.env.OFFLINE && existsSync(join(__dirname, 'out', '_records.json')); +if (!API && !process.env.PACKET_RECORDS && !CACHE_OK) { + console.error('[packet] No API base — set VITE_API_BASE_URL, or PACKET_RECORDS for a fixture build,'); + console.error(' or OFFLINE=1 to reuse out/_records.json if a previous run cached it.'); + if (process.env.OFFLINE) { + console.error(' OFFLINE=1 was set but out/_records.json does not exist — nothing to reuse.'); + } console.error(' Failing closed.'); process.exit(1); } @@ -149,6 +161,9 @@ function buildFunnel(membership) { } const { evaluate } = await import(join(DATA, 'selector-evaluator.js')); const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js')); +// The zone enumeration itself, so this generator, the poster generator and upg-100-list.vue cannot +// disagree about which zones exist (see allZones below). +const { buildLegendNodes, leafZones } = await import(join(DATA, 'legend-tree.js')); async function loadRecords() { // ★ A COMMITTED FIXTURE, so CI can build a packet at all. out/ is gitignored — both the @@ -168,7 +183,26 @@ async function loadRecords() { } const cache = join(__dirname, 'out', '_records.json'); if (process.env.OFFLINE && existsSync(cache)) return JSON.parse(readFileSync(cache, 'utf8')); - const fields = 'slug,name,country_code,population,religion,image_url,has_photo,picture_credit,latitude,longitude'; + // ★ THE FIELD LIST IS PART OF THE SELECTOR CONTRACT — every field a selector READS must be asked + // for here, or its zone silently resolves to nothing and gets skipped as "no groups resolved". + // + // ⚠ SIX OF TWENTY-EIGHT ZONES WERE UNREACHABLE FOR THIS ONE REASON, measured 2026-09-27 while + // raising check:packets-all's coverage. This list named country_code and no other selector field, + // while selector-evaluator reads three more: + // rop1 affinity_bloc -> deaf (rop1Code, :51) + // wagf_block wagf_block -> west/east/central-africa (:102) + // wagf_region wagf_region -> europe, and the union (:113, :136) + // so those six zones evaluated to an EMPTY set for every record. The tell was unmistakable once + // looked at: the records cache held 123 groups with "Deaf" in the name and evaluate() still returned + // 0 slugs. It reads as missing DATA and is missing FIELDS. + // ★ DERIVED, NOT TYPED: the presentation fields are listed, the selector fields come from the + // evaluator's own requirements, so adding a selector type that reads a new field is a change in ONE + // place. A hand-maintained superset drifts the moment someone adds a selector — which is exactly + // what happened here. + const PRESENTATION_FIELDS = ['slug', 'name', 'country_code', 'population', 'religion', + 'image_url', 'has_photo', 'picture_credit', 'latitude', 'longitude']; + const SELECTOR_FIELDS = ['country_code', 'wagf_block', 'wagf_region', 'rop1']; + const fields = [...new Set([...PRESENTATION_FIELDS, ...SELECTOR_FIELDS])].join(','); const r = await fetch(`${API}/api/people-groups/list?fields=${fields}&lang=${LANG}`, { headers: { accept: 'application/json' } }); if (!r.ok) throw new Error(`PG API ${r.status}`); @@ -177,12 +211,37 @@ async function loadRecords() { return recs; } +// ★ ENUMERATE FROM THE LEGEND TREE, NOT FROM clusters.json'S KEYS — the same seam the POSTER +// generator and upg-100-list.vue use, so all three agree on what "a zone" is. +// +// THE DEFECT THIS FIXES, measured 2026-09-27 while making check:packets-all cover every zone: +// this function looped THREE cluster keys — lists (13) + asia_lists (12) + uncovered_lists (2) = 27 +// — and clusters.json has a FOURTH that carries zones: deaf_lists (2). legend-tree.js:106 states the +// real arithmetic, "13 + 12 + 2 + 1 deaf = 28", because buildLegendNodes COLLAPSES the two deaf_lists +// into ONE unified leaf (affinity bloc A017, R1.1). So `deaf` is a poster zone and a legend row that +// no packet could ever be generated for: ONLY=deaf exited 1 with "no zone matches". +// +// ⚠ IT PRESENTED AS A COVERAGE GAP AND WAS A REACHABILITY BUG. check:packets-all printed +// "zones covered : 2 of 28", which reads as "26 nobody got round to" — but one of the 26 was +// unreachable by construction, and generating all of them would have silently produced 27, still +// printed against a denominator of 28. The numerator counted packet zones and the denominator counted +// POSTER files (out/posters/*.html), so the two sides were never the same population. A gap you can +// close by working harder and a gap you cannot close at all look identical in that line. +// +// leafZones() carries id/zoneKey/label/count but NOT selector (leaves do not), so the selector is +// re-attached by id exactly as generate.mjs:2801-2808 does it, deaf included. function allZones() { - const out = []; + const tops = buildLegendNodes(clusters, { records: null }); + const byId = {}; for (const k of ['lists', 'asia_lists', 'uncovered_lists']) { - for (const z of clusters[k] || []) out.push({ ...z, zoneKey: z.id }); + for (const z of clusters[k] || []) byId[z.id] = z.selector; } - return out; + // The unified deaf leaf's selector. DUPLICATED from legend-tree.js:99's DEAF_SELECTOR, which is + // function-scoped rather than exported, and from generate.mjs:2805 which hardcodes the same literal. + // Kept as a literal here rather than exporting a third consumer's dependency mid-hold; if a fourth + // caller ever needs it, export it once from legend-tree.js and delete all three copies. + byId.deaf = { type: 'affinity_bloc', rop1: 'A017' }; + return leafZones(tops).map((z) => ({ ...z, zoneKey: z.zoneKey || z.id, selector: byId[z.id] })); } // ── PHOTOS: parallel fetch, inlined as data URIs ────────────────────────────