doxa-maps/internal/check-gospel-coverage-consistent.mjs
doxa-maps-88655c70 101653e3fd Verify all 3,603 links: 2 were dead, and the map showed "unchecked" on the rest
The brief's rule is to verify a URL resolves before shipping it. The table that did
that was written in August against a corpus of 426 links, while the map now ships
3,603 - and the UI matched exactly TWO of them. Two silent causes: its eBible keys
were http:// while every shipped URL now passes through httpsify, and its 306 film
keys were the old ?languageId= URLs that were later replaced wholesale by Arclight
shareUrl deep links. Links that HAD been verified displayed as "unchecked", which a
reader cannot tell apart from "nobody looked".

The old note said the film page was unverifiable by fetch, and for the URL shape it
was written against that was right: ?languageId=13562 and ?languageId=99999999 return
byte-identical HTML, so a status check there is a false green. The shareUrl migration
changed that. A path discriminates:
  /watch/jesus/german-standard      -> 200, 944,516 bytes
  /watch/jesus/pashto-yusufzai      -> 200, 810,741 bytes (distinct body)
  /watch/jesus/definitely-not-xyzzy -> 404
So 2,049 film links became checkable for the first time. The 5 remaining ?languageId=
URLs stay "unknown" with that reason - never verified, never broken.

Every host was probed with a known-good and a known-bad before being trusted, and all
answer HEAD and discriminate: ebible.org, png.bible, alkitab.pw, jesusfilm.org,
arc.gt, git.door43.org, zume.training. HEAD is deliberate - a GET over 2,049 film
pages is ~1.6GB of someone else's bandwidth to learn a status code. Five negative and
five positive controls run in band and the script aborts rather than write a table if
either arm misbehaves.

Result over 3,603 unique URLs in 365s: 3,596 verified, 5 unknown, and 2 genuinely
dead - arc.gt/092vn (Yakpa, 2 pins) and /watch/jesus/4101 (Zhuang Liujiang, 1 pin),
both re-confirmed 404 with a browser UA and a GET. The builder now drops any link
verification proved dead BEFORE tiers are computed, so a code whose only film is a 404
has no film and its tier says so. The gap filter moves 3,457 -> 3,460, which is
exactly those three pins.

Also fixed: the verification fetch hung off openPin's country branch, so a tap landing
before the country index had loaded left the whole session reading "unchecked". It is
now fetched unconditionally.

Lookup coverage goes from 2/3,605 to 3,603/3,603. New gate clause K fails below a 95%
coverage floor or if any shipped URL is recorded broken; poison self-test now 11/11.
Verified in a browser on the exact case that failed - one immediate pin tap - which
now fetches the table and renders "Scripture - link checked" and "Film - link checked";
screenshot read to confirm both chips are green where they were grey.
2026-09-24 09:58:47 +00:00

232 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* check-gospel-coverage-consistent.mjs — the gospel-resources surfaces must agree.
*
* WHY THIS EXISTS. The same defect was found by hand THREE times in one flight, and each
* time it was invisible until someone compared two surfaces:
* 1. the country sheet listed 36 groups for Indonesia while the map drew 669 pins there,
* because the sheet read a curated 2,106-record subset and the map read the pins;
* 2. the resource table silently dropped 506 real resources across 377 languages,
* because its generator skipped any code no pin speaks — in a table titled
* "every link";
* 3. resources attached to `und` ("Undetermined") told 14 people groups there was
* Scripture in a language the record explicitly does not identify.
* None of these threw. Every page rendered. Only a comparison caught them, so the
* comparison is now a gate rather than a habit.
*
* WHAT IT ASSERTS, all static — no browser, no server, runs in well under a second:
* A. no ISO 639-3 special/reserved code carries a resource (und/mul/mis/zxx/qaa-qtz);
* B. every code carrying a resource has a language NAME — an unnamed code renders as a
* bare ISO in the table and tells a reader nothing;
* C. the resource table's reach columns match an independent recount off the pins;
* D. the table covers EVERY code that has a resource — the "every link" claim;
* E. every resource_url in the table exists in the pin index, so the table cannot
* invent a link the map does not hold;
* F. each pin's tier agrees with the URLs its code actually carries;
* I. a row's TYPE agrees with the host it points at — Film only on jesusfilm.org or
* arc.gt, Bible Stories only on door43 and only under that name, Scripture never on
* a film host. Clause H catches the one mislabel that actually shipped; this catches
* the general shape, because the 70-row defect was invisible for seven ticks
* precisely because NOTHING compared a row's label to where it points. Audited at
* the time of writing: 3,550 rows, 0 violations — so this is a guard, not a fix.
* H. nothing named "Open Bible Stories" is TYPED as Scripture. OBS is 50 illustrated
* narratives drawn from the canon, not a translation, and this repo's standing rule
* is that the two are never blurred. It broke silently: owner disambiguation changed
* the label to "Open Bible Stories (OBS-TLF)" and the type test was an exact ===
* match, so 70 story sets were typed Scripture and stayed that way for seven ticks.
* An exact match against a label that can grow a suffix is the whole defect.
* G. no URL is plain http. Every host we ship serves https directly and redirects
* http there anyway, so a plain link only buys a redirect hop and a downgraded
* scheme in a field worker's browser. 1,237 of them shipped that way until the
* normalisation moved to the point where a URL ENTERS the index — doing it
* per-source had left the 120 curated links behind, since fill-never-overwrite
* preserves those and they never passed through a fetcher.
*
* ★ SELF-TEST: run with --poison to inject each defect in memory and confirm the clause
* catches it. A check that has never been shown to fail proves nothing, and five of the
* six clauses here are asserting a NEGATIVE, which is exactly the shape that passes
* vacuously when the data it reads is empty or mis-keyed.
*/
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const POISON = process.argv.includes('--poison')
const read = (p) => JSON.parse(readFileSync(resolve(REPO, p), 'utf8'))
let pins, table, verif
try {
pins = read('data/tables/people-group-pins.json')
table = read('data/tables/gospel-resource-links.json')
try { verif = read('data/tables/gospel-link-verification.json') } catch { verif = null }
} catch (e) {
console.error(`COVERAGE-CONSISTENT: SKIP — inputs missing (${e.message}).`)
console.error(' Run: node plugin-designs/gospel-resources/scripts/build-people-group-pins.mjs')
process.exit(0)
}
const codes = { ...pins.codes }
let rows = pins.pins
let trows = table.rows
// A gate that reads an empty corpus passes every negative clause while measuring nothing.
if (!rows?.length || !trows?.length || !Object.keys(codes).length) {
console.error('COVERAGE-CONSISTENT: FAIL — an input is empty; every clause below would pass vacuously.')
process.exit(1)
}
if (POISON) {
// one injected defect per clause, in memory only
codes.und = { l: 'Undetermined', d: [{ s: 'Open Bible Stories', u: 'https://example.invalid/und' }] } // A
codes.zzq = { l: '', s: 'http://ebible.org/zzq/' } // B + G
trows = [{ ...trows[0], people_groups: (trows[0].people_groups || 0) + 999 }, // C
{ ...trows[0], resource: 'Open Bible Stories (X)', type: 'Scripture' }, // H
{ ...trows[0], type: 'Film', resource_url: 'https://ebible.org/xyz/' }, // I
...trows.slice(1, -1),
{ ...trows[trows.length - 1], resource_url: 'https://example.invalid/invented' }] // E
rows = [[...rows[0].slice(0, 6), 2], ...rows.slice(1)] // F (forced tier 2)
codes.zzj = { l: 'Both at once', z: 'https://zume.training/en', za: { u: 'https://zume.training/pus', l: 'Pashto', n: 'x' } } // J
codes.zzk = { l: 'Ships a known-dead link', f: 'https://jesusfilm.org/watch/jesus/4101' } // K
}
const SPECIAL = (c) => c === 'und' || c === 'mul' || c === 'mis' || c === 'zxx' || (c >= 'qaa' && c <= 'qtz')
const hasRes = (v) => !!(v && (v.s || v.f || (v.d && v.d.length)))
const fails = []
const add = (clause, msg) => fails.push(` [${clause}] ${msg}`)
// A — no resource on a code that is not a language
for (const [c, v] of Object.entries(codes)) {
if (SPECIAL(c) && hasRes(v)) add('A', `${c} is an ISO reserved code and carries a resource`)
}
// B — a code with a resource must be named
for (const [c, v] of Object.entries(codes)) {
if (hasRes(v) && !String(v.l || '').trim()) add('B', `${c} carries a resource but has no language name`)
}
// C — reach columns recounted off the pins
const groups = {}, speakers = {}
for (const r of rows) { groups[r[4]] = (groups[r[4]] || 0) + 1; speakers[r[4]] = (speakers[r[4]] || 0) + (r[5] || 0) }
let cBad = 0
for (const t of trows) {
if ((groups[t.iso639_3] || 0) !== t.people_groups) cBad++
else if ((speakers[t.iso639_3] || 0) !== t.speakers) cBad++
}
if (cBad) add('C', `${cBad} table row(s) disagree with a pin recount of people_groups/speakers`)
// D — every code with a resource is represented in the table
const inTable = new Set(trows.map((t) => t.iso639_3))
const missing = Object.keys(codes).filter((c) => hasRes(codes[c]) && !inTable.has(c))
if (missing.length) add('D', `${missing.length} code(s) carry a resource but appear in no table row (e.g. ${missing.slice(0, 3).join(', ')})`)
// E — the table cannot invent a URL
const known = new Set()
for (const v of Object.values(codes)) {
if (v.s) known.add(v.s); if (v.f) known.add(v.f); if (v.z) known.add(v.z); if (v.za) known.add(v.za.u)
for (const d of v.d || []) known.add(d.u)
}
const invented = trows.filter((t) => t.resource_url && !known.has(t.resource_url))
if (invented.length) add('E', `${invented.length} table URL(s) exist in no pin record (e.g. ${invented[0].resource_url})`)
// I — type must agree with the host
const FILM_HOST = /(^|\.)(jesusfilm\.org|arc\.gt)$/i
const hostOf = (u) => (/^https?:\/\/([^/]+)/i.exec(u || '') || [, ''])[1].toLowerCase()
const typeBad = []
for (const t of trows) {
const h = hostOf(t.resource_url)
if (!h) continue
const film = FILM_HOST.test(h)
if (t.type === 'Film' && !film) typeBad.push(`Film on ${h}`)
else if (t.type === 'Scripture' && film) typeBad.push(`Scripture on film host ${h}`)
else if (t.type === 'Bible Stories' && !/(^|\.)git\.door43\.org$/i.test(h)) typeBad.push(`Bible Stories on ${h}`)
else if (t.type === 'Bible Stories' && !/^Open Bible Stories/.test(t.resource || '')) typeBad.push(`Bible Stories named "${t.resource}"`)
// Training is a COURSE, and the only publisher of it here is Zume. Without this the
// type fell through every branch above and was checked by nothing at all.
else if (t.type === 'Discipleship training' && !/(^|\.)zume\.training$/i.test(h)) typeBad.push(`Discipleship training on ${h}`)
else if (/(^|\.)zume\.training$/i.test(h) && t.type !== 'Discipleship training') typeBad.push(`${t.type} on zume.training`)
}
if (typeBad.length) add('I', `${typeBad.length} row(s) whose type disagrees with their host (e.g. ${typeBad[0]})`)
// H — a story set must never be typed as Scripture
const misTyped = trows.filter((t) => /open bible stories/i.test(t.resource || '') && /^scripture$/i.test(t.type || ''))
if (misTyped.length) add('H', `${misTyped.length} row(s) named "Open Bible Stories" are typed as Scripture (e.g. ${misTyped[0].language})`)
// G — no plain-http URL
const httpUrls = []
for (const [c, v] of Object.entries(codes)) {
for (const u of [v.s, v.f, v.z, v.za && v.za.u, ...(v.d || []).map((d) => d.u)]) {
if (u && /^http:\/\//i.test(u)) httpUrls.push(`${c} ${u}`)
}
}
if (httpUrls.length) add('G', `${httpUrls.length} plain-http URL(s) (e.g. ${httpUrls[0]})`)
// F — tier agrees with the URLs the code holds
let fBad = 0
for (const r of rows) {
const v = codes[r[4]] || {}
const want = v.s && v.f ? 2 : (v.s || v.f || (v.d && v.d.length)) ? 1 : 0
if (want !== r[6]) fBad++
}
if (fBad) add('F', `${fBad} pin(s) carry a tier that disagrees with their code's URLs`)
// J — an approximate match must never sit alongside a confirmed one on the same code.
// The map styles them differently and says different things, so a code holding both would
// render the qualified claim and the confirmed one as if they were the same resource, and
// the qualification is the entire point of keeping them apart.
const both = Object.entries(codes).filter(([, v]) => v.z && v.za).map(([c]) => c)
if (both.length) add('J', `${both.length} code(s) carry BOTH a confirmed and an approximate training link (e.g. ${both[0]})`)
// An approximate entry must still be a real, complete record rather than a bare URL.
const thin = Object.entries(codes).filter(([, v]) => v.za && !(v.za.u && v.za.l && v.za.n)).map(([c]) => c)
if (thin.length) add('J', `${thin.length} approximate entr(ies) missing a url, language or note (e.g. ${thin[0]})`)
// K — verification must actually cover what is shipped, and nothing shipped may be known
// dead. The previous table matched TWO of 3,605 links because its keys were written http://
// and against a URL shape the index had since replaced; the map showed "unchecked" on links
// that had in fact been verified and nobody could tell that from never-looked-at. A coverage
// floor is the only thing that makes that failure loud.
if (verif && verif.links) {
const V = verif.links
const shipped = new Set()
for (const v of Object.values(codes)) {
if (v.s) shipped.add(v.s)
if (v.f) shipped.add(v.f)
if (v.z) shipped.add(v.z)
if (v.za && v.za.u) shipped.add(v.za.u)
for (const d of v.d || []) if (d.u) shipped.add(d.u)
}
const known = [...shipped].filter((u) => V[u])
const cover = shipped.size ? known.length / shipped.size : 0
if (cover < 0.95) {
add('K', `only ${known.length}/${shipped.size} shipped URL(s) (${(100 * cover).toFixed(1)}%) appear in the verification table — below the 95% floor`)
}
const dead = [...shipped].filter((u) => V[u] && V[u].state === 'broken')
if (dead.length) add('K', `${dead.length} shipped URL(s) are recorded BROKEN and must not ship (e.g. ${dead[0]})`)
}
const clauses = new Set(fails.map((f) => f.slice(3, 4)))
if (POISON) {
// D is included deliberately: it is the clause that would have caught the 506
// resources dropped from a table titled "every link", and the most valuable clause is
// the worst one to leave unproven. The injected codes above (und, zzq) are absent from
// the table, so they poison D as well as A and B.
const want = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K']
const caught = want.filter((c) => clauses.has(c))
console.log(`COVERAGE-CONSISTENT --poison: ${caught.length}/${want.length} clause(s) caught their injected defect (${caught.join(',')})`)
if (caught.length !== want.length) {
console.error(` MISSED: ${want.filter((c) => !clauses.has(c)).join(',')} — those clauses cannot fail and prove nothing.`)
process.exit(1)
}
console.log('COVERAGE-CONSISTENT: SELF-TEST PASS — every poisoned clause fails as designed.')
process.exit(0)
}
if (fails.length) {
console.error(`COVERAGE-CONSISTENT: FAIL — ${fails.length} problem(s):`)
for (const f of fails.slice(0, 20)) console.error(f)
process.exit(1)
}
console.log(`COVERAGE-CONSISTENT: PASS — ${rows.length} pins, ${Object.keys(codes).length} codes, ${trows.length} table rows agree.`)
// Name every clause. A summary that lists four of nine reads as a narrower check than it
// is, and the next reader calibrates their trust on this line rather than the source.
console.log(' A no resource on an ISO reserved code B every resourced code is named')
console.log(' C reach recounted off the pins D every resourced code is in the table')
console.log(' E no table URL the pins do not hold F pin tiers match their code\'s URLs')
console.log(' G no plain-http URL H no story set typed as Scripture')
console.log(' I row type agrees with its host J approximate never reads as confirmed')
console.log(' K links are verified, and none known-broken ships')