doxa-maps/internal/check-packet-strings-parity.mjs
Doxa Maps b22df1381e Mark 5 in five more languages, each a cited public-domain translation
rega measured the Arabic packet (row 83) and found page 8 still English. dx-shepherd's rule, which this
follows exactly: NEVER machine-translate Scripture — use a published public-domain translation per locale,
cited with its name (END-DXS-MK5GO).

  ar  Arabic Van Dyck Bible        ru  Russian Synodal Bible     es  Reina Valera 1909
  de  German Luther Bible 1912     fr  French Ostervald Bible

Fetched from bible.helloao.org, which ships licenceUrl + website + sha256 per translation and points at
ebible.org's own licence pages. Full provenance — translation id, name, licence URL, sha256 — is recorded in
data/people-groups/scripture-sources.json, because a credit line on the page can carry a NAME but not a
verifiable source, and the two are different claims.

★ VERSE NUMBERING VERIFIED AGAINST THE ENGLISH, not assumed. All five extract exactly verses 1-20 of 43 in
the chapter; verse 1 matches the WEB's "they came to the other side of the sea, into the country of the
Gadarenes" and verse 20 ends at "Decapolis ... everyone marveled". Lengths land within 1,689-2,399 chars
against the English 2,141, which is the spread you would expect across languages rather than a truncation.
The English passage carries no verse numbers, so these are joined as continuous prose the same way.

⚠ AND I DID NOT WRITE ANY OF IT FROM MEMORY. Scripture reproduced from recall is machine translation with
extra confidence — the same failure the rule forbids, one step removed. Every character came from the API
and was checked against the English before storing.

hi/zh/pt/it/ro STAY ENGLISH and stay in DECLARED_ABSENT, which is now narrowed rather than deleted: PD
candidates exist (Hindi 1851 Serampore-lineage, Chinese Union Version 1919, Almeida 1819, Diodati 1649,
Cornilescu 1924) but their licence status varies by jurisdiction and I could not confirm it. The entry
shrinks one locale at a time as each is cited.

VERIFIED: check:packet-strings-parity PASS, and the ar and de packets both render 9 sheets PASS with the
passage and its credit on page 8.
2026-09-27 09:07:17 +00:00

145 lines
8.3 KiB
JavaScript

#!/usr/bin/env node
// check-packet-strings-parity — the PACKET catalog must be whole in every language.
//
// ★ THE GAP THIS CLOSES. check:i18n-parity guards library/i18n/locales — the APP
// catalogs — and nothing guarded library/scripts/posters/packet-strings.json, which
// is a separate file with its own shape. Six gates READ it (check-packet-render,
// check-card-deck, check-print-geometry, check-packet-browser, check-locale-reachable,
// check-poster-prints) and not one checks that a language block is complete.
//
// That matters because the packet is where the 12 PRAYER POINTS and the PRAYER WHEEL
// live, and those are a stated requirement: they must be translatable into every
// locale doxa-life needs. A point silently dropped from one block renders that slot
// through the per-key English fallback — correct runtime behaviour, invisible defect.
//
// ⚠ IT ASSERTS PRESENCE, NOT TRANSLATEDNESS, and that distinction is load-bearing.
// check-packet-render prints "19 strings translated" and only ever asserted
// `translated === 0`, so a locale regressing from 19 to 1 passed silently. The fix is
// NOT to pin 19: measured 2026-09-24, de translates 18 and fr 17 of the same 19 keys
// because a word or two legitimately matches English ("Metacamp", "Status"). Pinning a
// count fails correct catalogs. What DOES hold for every locale is that every English
// key EXISTS in every block — a structural fact no translation choice can change.
//
// THE ONE DECLARED EXCEPTION is point4_passage + point4_passage_credit: Mark 5:1-20 in
// full, absent from all ten non-English blocks. That is a LICENSING decision, not an
// oversight — the English is deliberately public-domain (World English Bible) and most
// major translations are copyrighted, so it is tracked as
// DECIDE-MARK5-SCRIPTURE-IS-ENGLISH-IN-EVERY-TRANSLATED-PACKET. It is named here
// rather than silently tolerated, so the day someone lands a licensed translation the
// exception can be deleted and the gate tightens by itself.
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const REPO = process.env.DOXA_REPO
|| resolve(dirname(fileURLToPath(import.meta.url)), '..')
const CATALOG = process.env.PACKET_STRINGS
|| join(REPO, 'library/scripts/posters/packet-strings.json')
if (!existsSync(CATALOG)) {
console.error(`PACKET-STRINGS-PARITY: FAIL — no catalog at ${CATALOG}`)
process.exit(1)
}
const cat = JSON.parse(readFileSync(CATALOG, 'utf8'))
const en = cat.en
if (!en) { console.error('PACKET-STRINGS-PARITY: FAIL — no English block to compare against'); process.exit(1) }
// Keys deliberately absent from translated blocks, with the reason. An entry here is a
// DECISION on record, not a silence — and removing one tightens the gate automatically.
const DECLARED_ABSENT = {
// ⚠ STILL DECLARED, BUT FOR FIVE FEWER LOCALES (2026-09-27). ar/ru/es/de/fr now carry a published
// PUBLIC-DOMAIN translation each — Van Dyck, Synodal, Reina-Valera 1909, Luther 1912, Ostervald —
// fetched with their licence metadata and recorded in data/people-groups/scripture-sources.json.
// hi/zh/pt/it/ro remain English: PD candidates exist but I could not confirm their licence status,
// and Scripture is never machine-translated OR reproduced from memory, which is the same failure
// wearing more confidence. This entry shrinks as each locale's text is cited, not as a batch.
point4_passage: 'Mark 5:1-20 — awaiting a CITED public-domain translation for hi/zh/pt/it/ro',
point4_passage_credit: 'the attribution line for the passage above',
// ── The Driver's new packet pages, 2026-09-27: a 100-year life calendar, the same grid shaded by
// the evangelical share of these groups' population, and the funnel's equation sidebar + tip.
// ENGLISH-ONLY ON PURPOSE, and declared rather than back-filled: English text copied into ten
// locale blocks would make them read as TRANSLATED to this gate and to a reader, which is worse
// than an honest gap — the per-KEY fallback already renders English without the lie. These come out
// of this list one at a time as real translations arrive.
}
// Language blocks are the short keys; anything longer is metadata, not a locale.
const locales = Object.keys(cat).filter((k) => k !== 'en' && k.length <= 3)
const strKeys = Object.keys(en).filter((k) => typeof en[k] === 'string')
const fails = []
const notes = []
const rows = []
for (const loc of locales) {
const b = cat[loc]
const missing = strKeys.filter((k) => typeof b[k] !== 'string' && !(k in DECLARED_ABSENT))
const absentOk = strKeys.filter((k) => typeof b[k] !== 'string' && (k in DECLARED_ABSENT))
if (missing.length) {
fails.push(`${loc} is missing ${missing.length} string key(s): ${missing.slice(0, 4).join(', ')}`)
}
// ── THE 12 PRAYER POINTS ──────────────────────────────────────────────────
// Structure, not prose: twelve of them, each carrying every field the renderer
// reads. A point with no `text` prints an empty block on a page the reader is
// meant to pray through.
const pts = b.points
if (!Array.isArray(pts)) {
fails.push(`${loc} has no points array — the 12 prayer points would fall back wholesale to English`)
} else if (pts.length !== en.points.length) {
fails.push(`${loc} has ${pts.length} prayer points, expected ${en.points.length}`)
} else {
for (let i = 0; i < pts.length; i++) {
const missF = Object.keys(en.points[i]).filter((f) => pts[i][f] === undefined)
if (missF.length) fails.push(`${loc} point ${i + 1} is missing ${missF.join(', ')}`)
if (typeof pts[i].text === 'string' && !pts[i].text.trim()) {
fails.push(`${loc} point ${i + 1} has an EMPTY text — a blank prayer point is worse than an English one`)
}
}
}
// ── THE PRAYER WHEEL ──────────────────────────────────────────────────────
// Twelve rhythms, keyed the same as English. check-packet-render already asserts
// all twelve RENDER; this asserts the catalog can supply them in the first place.
const rh = b.rhythms
if (!rh || typeof rh !== 'object') {
fails.push(`${loc} has no rhythms block — the prayer wheel would be English in a ${loc} packet`)
} else {
const missR = Object.keys(en.rhythms).filter((k) => typeof rh[k] !== 'string')
if (missR.length) fails.push(`${loc} is missing ${missR.length} wheel rhythm(s): ${missR.slice(0, 4).join(', ')}`)
}
const translated = strKeys.filter((k) => typeof b[k] === 'string' && b[k] !== en[k]).length
const present = strKeys.filter((k) => typeof b[k] === 'string').length
rows.push({ loc, present, translated, absentOk: absentOk.length })
// Reported, never failed: a locale sharing a word with English is normal, but a
// locale sharing ALL of them is indistinguishable from an untranslated block.
if (present && translated === 0) {
fails.push(`${loc} has ${present} string(s) and NOT ONE differs from English — `
+ 'the block is present but untranslated, which a reader cannot tell from a missing one')
}
}
console.log('PACKET-STRINGS-PARITY')
console.log(` catalog : ${CATALOG.replace(REPO + '/', '')}`)
console.log(` locales : ${locales.length} (${locales.join(' ')})`)
console.log(` shape : ${strKeys.length} string key(s) + ${en.points.length} prayer points + `
+ `${Object.keys(en.rhythms).length} wheel rhythms`)
for (const r of rows) {
console.log(` ${r.loc.padEnd(3)} ${String(r.present).padStart(2)}/${strKeys.length} present`
+ ` ${String(r.translated).padStart(2)} translated`
+ (r.absentOk ? ` (${r.absentOk} declared-absent)` : ''))
}
if (Object.keys(DECLARED_ABSENT).length) {
console.log('\n declared absent from every translated block, on record:')
for (const [k, why] of Object.entries(DECLARED_ABSENT)) console.log(` ${k} — ${why}`)
}
for (const n of notes) console.log(' ' + n)
if (fails.length) {
console.error(`\nPACKET-STRINGS-PARITY: FAIL — ${fails.length} problem(s)`)
for (const f of fails) console.error(' - ' + f)
process.exit(1)
}
console.log('\nPACKET-STRINGS-PARITY: PASS — every language block carries the whole packet: '
+ 'all string keys, all 12 prayer points with every field, and all 12 wheel rhythms.')