From 718ad723dccd7289fef97966a9943efbaa2ba40b Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:54:59 +0000 Subject: [PATCH] Check that the numbers the maps show match the numbers the source has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every gate in this repo verifies INTERNAL consistency. check:facet-changes-map proves the dots change and that the categories sum to 2,107 — but it asserts that inside the page, against data the page already loaded. If the upstream corpus shifted, the page would stay perfectly self-consistent while its legend quietly stopped describing reality. Nothing fetched the live endpoint: not the six data guards, not check:label-joins, not any browser gate. That is the worst failure this product can ship, because it is invisible to every structural check. The element mounts, the canvas paints, the counts are formatted correctly, the partition sums. Only comparison against the SOURCE catches a legend that is beautifully wrong — and these maps exist to say where the gospel has not reached, so a wrong number is not a cosmetic defect. Measured by hand first, then encoded. The cap-2 engagement legend shows Unengaged 2,080 / Engaged 27; the live API returns 2,107 rows splitting exactly 2,080 / 27, with no third bucket and no remainder. The cap-4 dashboard KPI strip prints "1% ADOPTED"; the live API gives 21 of 2,107, which is 1.0%. Three independent surfaces agreeing to the unit. The gate also asserts the partition is COMPLETE and that no category exists which the two-row legend does not show — a third engagement value would hide rows from the reader while every count still looked right. The expected values are a baseline, not a law, and the failure message says so: the corpus is living data and these numbers will move. A mismatch is a prompt to update the legend, the KPI and the baseline TOGETHER, which is the coupling that was missing. Updating the baseline alone would silence the gate and leave the maps wrong. Offline SKIPs at exit 0 rather than failing, because reporting a mismatch that may not exist is the fail-open mistake in reverse. Negative-tested both ways: drifting the baseline fails naming the exact category and both numbers; an unreachable host skips and says why. The browser user-agent is deliberate — pray.doxa.life answers 403 to a default script UA, a trap already recorded here, and one that presents as "the data is gone". --- internal/check-legend-matches-source.mjs | 116 +++++++++++++++++++++++ package.json | 3 +- 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 internal/check-legend-matches-source.mjs diff --git a/internal/check-legend-matches-source.mjs b/internal/check-legend-matches-source.mjs new file mode 100644 index 000000000..443b30ca8 --- /dev/null +++ b/internal/check-legend-matches-source.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** + * check-legend-matches-source.mjs — the numbers the maps SHOW must match the numbers the SOURCE HAS. + * + * THE GAP THIS FILLS. Every existing gate verifies INTERNAL consistency. check:facet-changes-map + * proves the dots change and that the categories sum to 2,107 — but it asserts that inside the + * page, against data the page already loaded. If the upstream corpus shifted (a new engagement + * category, a reclassification, a renamed field) the page would stay perfectly self-consistent + * while its legend quietly stopped describing reality. Nothing fetched the live endpoint. + * + * That is the worst failure this product can ship, because it is invisible to every structural + * check: the element mounts, the canvas paints, the counts are formatted correctly, the partition + * sums. Only comparison against the SOURCE catches a legend that is beautifully wrong — and these + * maps exist to tell people where the gospel has not reached, so a wrong number is not a cosmetic + * defect. + * + * WHAT IT ASSERTS, measured by hand first on 2026-09-23 and encoded here: + * - the corpus is still 2,107 rows; + * - engagement_status partitions it EXACTLY as the cap-2 legend claims (Unengaged 2,080 / + * Engaged 27) with no third bucket and no remainder; + * - adopted_by_churches is 21 of 2,107 = 1%, which is what the cap-4 dashboard KPI strip prints. + * + * THE EXPECTED VALUES ARE A BASELINE, NOT A LAW. The corpus is living data and these numbers WILL + * move. A change here is not automatically a bug — it is a prompt to update the legend, the KPI and + * this baseline together, which is exactly the coupling that was missing. The failure message says + * so, so a future reader does not "fix" the data to please the gate. + * + * OFFLINE IS A SKIP, NOT A FAILURE. This box cannot always reach pray.doxa.life, and reporting a + * mismatch that may not exist is the fail-open/fail-closed mistake in reverse. A network error + * SKIPs with exit 0 and says why; only a reachable endpoint that DISAGREES fails. + * + * NOTE the browser UA. pray.doxa.life answers 403 to a default script user-agent — a trap already + * recorded in this repo's notes, and one that presents as "the data is gone". + */ +import process from 'node:process' + +const API = process.env.PRAY_BASE || 'https://pray.doxa.life' +const UA = 'Mozilla/5.0 (compatible; doxa-maps-gate/1.0)' + +// Baseline measured 2026-09-23 against the live endpoint. See the header: these move with the data. +const EXPECT = { + total: 2107, + engagement: { Unengaged: 2080, Engaged: 27 }, + adoptedPct: 1, +} + +async function get(fields) { + const url = `${API}/api/people-groups/list?fields=${fields}&lang=en` + const r = await fetch(url, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(30000) }) + if (!r.ok) throw new Error(`HTTP ${r.status} for ${fields}`) + const d = await r.json() + if (!Array.isArray(d.posts)) throw new Error(`no posts[] in the response for ${fields}`) + return d.posts +} + +// A field may arrive as a bare string or as a {value,label} pair — the DX-life shape. Read the +// label when present, so a change in envelope shape is not mistaken for a change in the data. +const labelOf = (v) => (v && typeof v === 'object' ? (v.label ?? v.value) : v) ?? '(none)' + +let posts +try { + posts = await get('name,engagement_status') +} catch (e) { + console.log(`LEGEND-SOURCE: SKIP — could not reach ${API} (${e.message}).`) + console.log(' Offline is not a mismatch. Re-run where the endpoint is reachable.') + process.exit(0) +} + +const problems = [] + +if (posts.length !== EXPECT.total) { + problems.push( + `corpus size moved: ${posts.length} rows, baseline ${EXPECT.total}.\n` + + ' Every legend and KPI derived from the corpus should be re-read before updating this baseline.') +} + +const counts = {} +for (const p of posts) counts[labelOf(p.engagement_status)] = (counts[labelOf(p.engagement_status)] || 0) + 1 + +for (const [k, n] of Object.entries(EXPECT.engagement)) { + if (counts[k] !== n) problems.push(`engagement "${k}": ${counts[k] ?? 0} rows, legend claims ${n}.`) +} +const extra = Object.keys(counts).filter((k) => !(k in EXPECT.engagement)) +if (extra.length) { + problems.push( + `engagement has ${extra.length} category(ies) the legend does not show: ${extra.join(', ')}.\n` + + ' A two-row legend over a three-value field hides rows from the reader.') +} +const sum = Object.values(counts).reduce((a, b) => a + b, 0) +if (sum !== posts.length) problems.push(`engagement partition is incomplete: ${sum} of ${posts.length} rows carry a value.`) + +try { + const ad = await get('name,adopted_by_churches') + const n = ad.filter((p) => p.adopted_by_churches).length + const pct = Math.round((n / ad.length) * 100) + if (pct !== EXPECT.adoptedPct) problems.push(`adopted: ${n}/${ad.length} = ${pct}%, KPI strip prints ${EXPECT.adoptedPct}%.`) + else console.log(`LEGEND-SOURCE: adopted ${n}/${ad.length} = ${pct}% — matches the KPI strip.`) +} catch (e) { + console.log(`LEGEND-SOURCE: adoption check skipped (${e.message}).`) +} + +console.log(`LEGEND-SOURCE: ${posts.length} rows · ` + + Object.entries(counts).map(([k, n]) => `${k} ${n}`).join(' · ')) + +if (problems.length) { + console.error(`\nLEGEND-SOURCE: FAIL — ${problems.length} mismatch(es) between what the maps SHOW and what the source HAS.\n`) + for (const p of problems) console.error(' ✗ ' + p + '\n') + console.error(' This is a PROMPT, not a verdict: the corpus is living data. Update the legend, the') + console.error(' KPI strip and the EXPECT baseline in this file TOGETHER — never the baseline alone,') + console.error(' which would silence the gate while leaving the maps wrong.') + process.exit(1) +} + +console.log('LEGEND-SOURCE: PASS — every rendered figure matches the live source.') +console.log(' Internal consistency is already covered by check:facet-changes-map; this is the') +console.log(' EXTERNAL check — the page agreeing with itself proves nothing about the world.') diff --git a/package.json b/package.json index f90aad217..fb0eb263d 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,8 @@ "check:synced-css:self-test": "node internal/check-synced-css-copies.mjs --self-test", "serve:permissive": "python3 -m http.server 4173 --bind 127.0.0.1 --directory public # 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.", "check:doc-scripts": "node internal/check-doc-quotes-scripts.mjs", - "check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test" + "check:doc-scripts:self-test": "node internal/check-doc-quotes-scripts.mjs --self-test", + "check:legend-source": "node internal/check-legend-matches-source.mjs # the numbers the maps SHOW must match the numbers the live SOURCE has. Every other gate checks INTERNAL consistency — a page can agree with itself perfectly while its legend has stopped describing reality. Offline SKIPs (exit 0); only a reachable endpoint that disagrees fails." }, "devDependencies": { "@capacitor/android": "^7.6.9",