The deaf zone could not be generated at all, and five more resolved to nothing

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.
This commit is contained in:
Doxa Maps 2026-09-27 01:27:47 +00:00
parent d17950086a
commit 3ef164c014

View file

@ -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 ────────────────────────────