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:
parent
d17950086a
commit
3ef164c014
1 changed files with 65 additions and 6 deletions
|
|
@ -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
|
// 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
|
// 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.
|
// supplying a dummy host — the real invocation path is what exposed it.
|
||||||
if (!API && !process.env.PACKET_RECORDS) {
|
// ⚠ AND THE SAME BUG HAD A SECOND ESCAPE HATCH, fixed 2026-09-27. The note above records this guard
|
||||||
console.error('[packet] No API base — set VITE_API_BASE_URL, or PACKET_RECORDS for a fixture build.');
|
// 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.');
|
console.error(' Failing closed.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
@ -149,6 +161,9 @@ function buildFunnel(membership) {
|
||||||
}
|
}
|
||||||
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
|
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
|
||||||
const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.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() {
|
async function loadRecords() {
|
||||||
// ★ A COMMITTED FIXTURE, so CI can build a packet at all. out/ is gitignored — both the
|
// ★ 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');
|
const cache = join(__dirname, 'out', '_records.json');
|
||||||
if (process.env.OFFLINE && existsSync(cache)) return JSON.parse(readFileSync(cache, 'utf8'));
|
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}`,
|
const r = await fetch(`${API}/api/people-groups/list?fields=${fields}&lang=${LANG}`,
|
||||||
{ headers: { accept: 'application/json' } });
|
{ headers: { accept: 'application/json' } });
|
||||||
if (!r.ok) throw new Error(`PG API ${r.status}`);
|
if (!r.ok) throw new Error(`PG API ${r.status}`);
|
||||||
|
|
@ -177,12 +211,37 @@ async function loadRecords() {
|
||||||
return recs;
|
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() {
|
function allZones() {
|
||||||
const out = [];
|
const tops = buildLegendNodes(clusters, { records: null });
|
||||||
|
const byId = {};
|
||||||
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) {
|
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 ────────────────────────────
|
// ── PHOTOS: parallel fetch, inlined as data URIs ────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue