The funnel stops claiming a coverage gap it cannot measure
I PUT A CONFIDENT WRONG NUMBER ON THE PAGE AND AM TAKING IT OFF. The footnote read "21 of the 2,107 are not yet in any list". That 21 is the total minus the sum of the DECLARED `count` fields in clusters.json — a claim about that file's bookkeeping, not about membership. I wrote it, asserted nothing about it, and shipped it twice. TRYING TO VERIFY IT PRODUCED A SECOND WRONG NUMBER, which is what settled the question. Resolving every selector against the packet records gives 418 in no geographic list, not 21. But that cache carries only TWELVE fields — no wagf_region, no wagf_block — so 13 of the 27 selectors cannot resolve against it at all, and deaf_predicate matches 0 of the 123 deaf records that ARE in it. Both figures describe the FIXTURE, not the product. Nothing on disk can answer this. AND THE QUESTION IS ILL-POSED BESIDES. clusters.json has FOUR buckets, and allLists() reads three: the two DEAF lists (61 + 62 groups, with their own poster) are excluded. Summing all 29 gives 2,209 against a source total of 2,106, so the deaf lists OVERLAP the geographic ones — a deaf group in West Africa is in both. They are a second, cross-cutting partition, organised by disability rather than geography, which is very likely WHY allLists() leaves them out: including them in a zone-to-list map would double-count. So "in no list" has no single answer until someone says which partition is meant. funnelPage already falls back to the provenance-only footnote when `unassigned` is absent, so removing it is the whole fix: the page now says "Counts from IMB / peoplegroups.org, captured 2026-07-16." and stops there. The share line is untouched — "1 of 27 lists · 2% of the 2,107" is provable. BOTH PATHS CHANGED TOGETHER. The Node generator computed the same figure; leaving it would have made the two packets disagree about page 2. CARDED FOR THE DRIVER, because two of these are product questions and not mine: whether a packet counts 27 geographic lists or 29 including the deaf ones, and whether "Central Asia + Pakistan" is a prayer list rather than a residual bucket. It has 55 groups, contiguous=true with contiguityExempt=false, a country-code selector, and the poster wall already files it under "Central Asia" — it is larger than four accepted lists (Europe 55, Oceania 53, SE Asia 50, Southern Africa 39) and reads exactly like them. Its neighbour uncovered-scatter is the genuine catch-all: 8 groups, contiguous=FALSE with contiguityExempt=TRUE, and that exemption flag is the tell. So "27 or 25" was a false binary and 26 is live. Verified: all 18 packets in out/ pass sheets, rows==bars, zero-population and the script check; the footnote no longer contains "not yet in any list"; the share line is unchanged. ⚠ NO BUNDLE REBUILD IN THIS COMMIT — dx-shepherd's shot window forbids public/ churn while rega reshoots the README screenshots. This is source only; whoever builds after the all-clear produces the matching artefact.
This commit is contained in:
parent
ba030370d3
commit
0952006343
2 changed files with 31 additions and 13 deletions
|
|
@ -119,14 +119,14 @@ const clusters = JSON.parse(readFileSync(join(DATA, 'clusters.json'), 'utf8'));
|
|||
//
|
||||
// ★ READ, NEVER COUNTED. The four steps come verbatim from vision-funnel.json, generated by
|
||||
// build-vision-funnel.mjs from the IMB CSV and the live network list, and already driving the
|
||||
// Vision Card. K and the unassigned count ARE measured here — but from the catalogue that defines
|
||||
// Vision Card. K IS measured here — from the catalogue that defines
|
||||
// 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.
|
||||
// ⚠ 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.
|
||||
// claim; 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'));
|
||||
|
|
@ -134,11 +134,17 @@ function buildFunnel(membership) {
|
|||
if (membership) {
|
||||
return { ...raw, listTotal: membership.total, listId: LIST, listLabel: membership.label };
|
||||
}
|
||||
// ⚠ NO `unassigned` — see upg-100-list.vue's packetFunnel() for the full measurement. Short
|
||||
// version: it printed "21 of the 2,107 are not yet in any list", derived from summing DECLARED
|
||||
// count fields, and neither that figure nor the 418 I got by resolving the selectors survives
|
||||
// scrutiny — the records cache lacks wagf_region/wagf_block, so 13 of the 27 selectors cannot
|
||||
// resolve, and clusters.json's two DEAF lists are a second, overlapping partition that
|
||||
// allLists() excludes. "In no list" has no single answer until someone says which partition is
|
||||
// meant. Both paths must agree, so this one stops claiming it too.
|
||||
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) };
|
||||
return { ...raw, listCount: lists.length, listTotal };
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
|
||||
|
|
|
|||
|
|
@ -1356,16 +1356,28 @@ async function packetFunnel() {
|
|||
const doxaTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0;
|
||||
|
||||
// THE DEFAULT LIST is the one the zone catalogue actually describes, so it is the only one that
|
||||
// can honestly claim "1 of K lists" and an unassigned count.
|
||||
// can honestly claim "1 of K lists".
|
||||
//
|
||||
// ⚠ `unassigned` IS DELIBERATELY NOT SUPPLIED ANY MORE, and taking it out is the point. It printed
|
||||
// "21 of the 2,107 are not yet in any list", derived as doxaTotal minus the sum of the DECLARED
|
||||
// `count` fields — a claim about clusters.json's BOOKKEEPING, not about membership. Measured
|
||||
// 2026-09-26 while trying to verify it, and neither available figure survives:
|
||||
// · resolving every selector against the packet records gives 418 in no geographic list, not 21;
|
||||
// · but that cache carries only 12 fields (no wagf_region, no wagf_block), so 13 of the 27
|
||||
// selectors cannot resolve against it at all, and deaf_predicate matches 0 of the 123 deaf
|
||||
// records that ARE there.
|
||||
// Both numbers describe the FIXTURE, not the product. And the question is ill-posed besides:
|
||||
// clusters.json has FOUR buckets — the two deaf lists (61 + 62 groups) are excluded from
|
||||
// allLists(), and summing all 29 gives 2,209 against a total of 2,106, so the deaf lists OVERLAP
|
||||
// the geographic ones. They are a second, cross-cutting partition, which means "in no list" has no
|
||||
// single answer until someone says which partition is meant.
|
||||
// A confident figure I cannot defend is exactly the defect class I have been removing from other
|
||||
// surfaces all day; this one I put here myself. funnelPage already falls back to the
|
||||
// provenance-only footnote when unassigned is absent, so the page simply stops claiming it.
|
||||
// Carded for the Driver: 27 geographic / 29 counting deaf, and whether "Central Asia + Pakistan"
|
||||
// (55 groups, contiguous, poster-walled as a real region) is a prayer list rather than a residual.
|
||||
if (listId === LIST_DEFAULT) {
|
||||
const lists = allLists();
|
||||
const assigned = lists.reduce((sum, l) => sum + (Number(l.count) || 0), 0);
|
||||
return {
|
||||
...raw,
|
||||
listCount: lists.length,
|
||||
listTotal: doxaTotal,
|
||||
unassigned: Math.max(0, doxaTotal - assigned),
|
||||
};
|
||||
return { ...raw, listCount: allLists().length, listTotal: doxaTotal };
|
||||
}
|
||||
|
||||
// A NON-DEFAULT LIST: read its own DOXA-side total from the membership file the build writes.
|
||||
|
|
|
|||
Loading…
Reference in a new issue