doxa-maps/internal/check-legend-matches-source.mjs
doxa-maps-88655c70 bfc1e0029b
Some checks are pending
build-gate / build (push) Waiting to run
gates: two unwired checks were red on main, and nothing could tell us
Censused the 67 gate scripts that are in no wave. The framing I had been using —
"coverage we are not getting" — was wrong in the more serious direction. Two of
them were FAILING on main right now. The gates exist, work, and had found
something; because nothing ran them the findings sat silently.

check:legend-source, a real product finding: the live corpus moved 2108 -> 2107
and one group crossed Unengaged -> Engaged, so the legend baseline was
describing a dataset that no longer exists. Confirmed 2107 independently against
the live endpoint rather than trusting the gate's own reading.

Updated the baseline following the procedure its header requires, repeated
rather than assumed: grepping the six figures across plugin-designs/ and
library/ finds them only in explanatory comments, so no shipping code hardcodes
a corpus total and only the baseline lagged. Mutation-tested — an off-by-8
baseline fails. Recorded that this is the third reading, that it drifts in both
directions, and that it must NOT be widened into a range: the noise is the
signal that the numbers on the page are live rather than asserted.

check:verification-tags asks whether a human ever looked at a done card. 98
could not answer. 24 of those were mine, and they are now tagged agent/state —
honestly agent rather than human, and state rather than visual, because every
one was verified by measuring rendered DOM, PDF bytes and request tallies, never
by a human looking at pixels. The other 74 belong to other agents and the
Driver; guessing their provenance would defeat the gate, so it stays unwired.

Six further gates were green and self-contained and are now wired: locale
reachability, url resolution, manifest coverage, the palette registry, and the
legend/source comparison. gates:post 37 -> 42; wave membership 43 -> 50 of 106.

One I nearly wired wrong. check:dxlife passes standalone but exits 2 by
documented contract — 0 unchanged, 1 drift, 2 clone not found — and the wave
runner grades any non-zero as failure, so a checkout without the sibling clone
would go red on a precondition. Caught in the fresh-checkout run, unwired, with
the reason recorded on the script so the next census does not re-add it. "Can it
run" and "is it wave-safe" are different questions.

42/42 with servers up; 31/42 passed and 7 SKIPPED on a fresh checkout, every
skip named.
2026-09-24 15:53:58 +00:00

164 lines
9.2 KiB
JavaScript

#!/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 re-measured 2026-09-23 (second reading) against the live endpoint. See the header: these
// move with the data.
//
// ★ WHY THIS UPDATE WAS SAFE, and what was checked before touching it. The gate's own failure text
// forbids updating the baseline alone, because that silences the check while leaving the maps wrong.
// So the maps were checked FIRST: grep for 2107/2,107/2080 across plugin-designs/ and library/ found
// the number ONLY inside explanatory comments — no shipping code hardcodes a corpus total. Every
// legend and KPI computes from the rows it loaded. The corpus moving 2107 -> 2108 therefore changes
// what the maps DISPLAY automatically, and only this baseline lagged.
// Re-measured 2026-09-24 (THIRD reading). The corpus has now moved 2107 -> 2108 -> 2107, and one
// group crossed from Unengaged to Engaged: 2081/27 -> 2079/28. Sums check (2079 + 28 = 2107).
//
// ★ THE SAME SAFETY CHECK WAS REPEATED, not assumed from last time. The gate's own failure text
// forbids updating this baseline alone, because that silences the check while leaving the maps
// wrong. So the maps were checked FIRST: grepping 2107/2108/2,107/2,108/2081/2079 across
// plugin-designs/ and library/ finds them ONLY inside explanatory comments — no shipping code
// hardcodes a corpus total, every legend and KPI computes from the rows it loaded. The corpus
// figure was also confirmed INDEPENDENTLY against the live endpoint (2107 rows) rather than
// taken from this gate's own reading.
//
// ⚠ THIS DRIFTS, AND THAT IS THE POINT. Three readings in two days, moving in both directions.
// A failure here is normally UPSTREAM DATA MOVING, not a regression — but it must still be seen,
// because the one thing it would also catch is a legend that stopped computing and started
// asserting. Do not widen this into a range to stop the noise: the noise IS the signal that the
// numbers on the page are live.
const EXPECT = {
total: 2107,
engagement: { Unengaged: 2079, Engaged: 28 },
adoptedPct: 1,
}
// ── THE TWO LOSTNESS AXES, AND THE FACT THAT THEY DISAGREE ──────────────────────────────────
// Two independent measurements of "who has nothing" now ship in this product, and a reader who
// meets both needs to know they are not the same question:
//
// imb_gsec — evangelical presence + church-planting activity (the Gospel Access filter)
// the six imb_*_available flags — whether a RESOURCE exists (Bible, recordings, JESUS Film,
// audio scripture, radio, Bible stories)
//
// MEASURED 2026-09-23 over the live corpus:
// gsec rung 0 "No Evangelicals, Churches or Resources" .... 225
// zero of the six resource flags ......................... 285
// BOTH (no presence AND no resource) ..................... 209
// rung 0 yet HAS at least one resource .................... 16 <- the label overstates itself
// zero resources yet NOT rung 0 ........................... 76
//
// ★ Of the 225 groups filed under "No Evangelicals, Churches OR RESOURCES", 11 have one of the six
// and 5 have FOUR each. The "or Resources" clause is not literally true in the data, so that phrase
// must never be repeated to a mission team as fact.
//
// NOT asserted as a gate condition here, deliberately: which figure is OFFICIAL is a Driver decision
// (DRIVER-DECISIONS-gospel-resources.md, Decision 1), and a gate must not quietly canonise one answer
// to a question that is still open. Recorded so the next reader inherits the measurement, not the
// task of re-deriving it.
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.')