doxa-maps/library/scripts/research/build-tabulator-tables.mjs
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +00:00

1363 lines
77 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// build-tabulator-tables.mjs — the VALIDATION + MODELING stage of IEMV.
//
// node build-tabulator-tables.mjs -> out/tables/*.json (+ app data folders)
//
// ═══ WHY THIS FILE EXISTS ═══════════════════════════════════════════════════
// The research stages produced NESTED research objects — good for capture, wrong
// for a list view. Tabulator wants FLAT ROWS: one record per fact, every column a
// scalar, so it can sort, filter, group and export without the app reshaping data
// at render time.
//
// design note 2026-08-01: "store all your findings into tabulator tables for the list
// view of these apps… all organized in list format with full granularity… in a
// format that actually works, a format that is tabulator."
//
// So this is the M and V of IEMV:
// I/E extraction — done by derive-city-candidates / fetch-* / name-clusters
// M modeling — HERE: nested research objects -> flat typed rows
// V validation — HERE: every table is asserted before it is written
//
// VALIDATION IS FAIL-LOUD. A table that fails its assertions throws rather than
// writing a broken file, because a silently-wrong table is worse than no table:
// the app renders it, it looks authoritative, and nobody checks. Same reasoning as
// the poster generator's fail-loud guards.
//
// EVERY ROW CARRIES ITS PROVENANCE — source, resolution, and (where relevant) what
// an absence means. A list view strips context, so the context has to be IN the row.
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = join(__dirname, 'out');
const TABLES = join(OUT, 'tables');
// Tables that must NEVER ship publicly. They are never written inside this repo: set
// DOXA_PRIVATE_DIR to a folder OUTSIDE the checkout to keep them for internal analysis, or
// leave it unset and they are skipped. (They used to live in a tracked data-private/ folder;
// a tracked folder in a public repo is not private.)
const PRIVATE_TABLES = new Set(['field-access-visas']);
const PRIVATE_DIR = process.env.DOXA_PRIVATE_DIR ? resolve(process.env.DOXA_PRIVATE_DIR) : null;
const PROFILES = resolve(__dirname, '..', '..', '..', 'plugin-designs');
const read = (f) => JSON.parse(readFileSync(join(OUT, f), 'utf8'));
const has = (f) => existsSync(join(OUT, f));
/**
* Write a table only if it passes its own assertions.
* @param {string} name
* @param {Array} rows
* @param {Array<{id,title,field,type}>} columns the Tabulator column model
* @param {Function} assert throws on invalid
*/
// Groups whose UPSTREAM population is an outlier against comparable groups in this
// same corpus. Derived from the data — every other Deaf group sits near 1% of its
// national population (Iran 0.98%, Myanmar 0.97%) while Deaf Pakistanis is 3.98% —
// NOT from an outside prior. Same in-corpus comparison that caught the ISO2
// fabrication. An explicit list, so adding one is a deliberate reviewable act rather
// than a heuristic silently reclassifying real data. We do NOT overwrite the value:
// it is the upstream API's, and flagging is honest where editing would not be.
const POP_OUTLIERS = new Set(['Deaf Pakistanis']);
function emit(name, rows, columns, meta, assert) {
if (!rows.length) throw new Error(`[tables] ${name}: EMPTY — refusing to write an empty table`);
// Every declared column must exist on every row: a column Tabulator cannot find
// renders as a silently blank cell, which reads as "no data" rather than "bug".
const fields = columns.map((c) => c.field);
for (const r of rows.slice(0, 50)) {
for (const f of fields) {
if (!(f in r)) throw new Error(`[tables] ${name}: row missing declared column '${f}'`);
}
}
if (assert) assert(rows);
// RELEASE GOVERNANCE: tables in PRIVATE_TABLES never land in this repo — only in
// DOXA_PRIVATE_DIR (outside the checkout), or nowhere.
if (PRIVATE_TABLES.has(name)) {
if (!PRIVATE_DIR) {
console.log(`[tables] ${name.padEnd(26)} ${String(rows.length).padStart(5)} rows PRIVATE — skipped (set DOXA_PRIVATE_DIR to keep it outside the repo)`);
return { name, rows: rows.length, private: true };
}
const priv = join(PRIVATE_DIR, 'tables');
if (!existsSync(priv)) mkdirSync(priv, { recursive: true });
writeFileSync(join(priv, `${name}.json`), JSON.stringify({ meta, columns, rows }, null, 1));
console.log(`[tables] ${name.padEnd(26)} ${String(rows.length).padStart(5)} rows PRIVATE → ${priv}`);
return { name, rows: rows.length, private: true };
}
if (!existsSync(TABLES)) mkdirSync(TABLES, { recursive: true });
writeFileSync(join(TABLES, `${name}.json`), JSON.stringify({ meta, columns, rows }, null, 1));
console.log(`[tables] ${name.padEnd(26)} ${String(rows.length).padStart(5)} rows ${columns.length} cols`);
return { name, rows: rows.length };
}
const built = [];
// ═══ TABLE 1 — PEOPLE GROUPS × RESOURCES (the bridge) ═══════════════════════
// One row per PEOPLE GROUP, not per language: the list view is browsed by group,
// and flattening here means Tabulator never has to expand a nested array.
if (has('language-resources.json')) {
// OPEN-POOL LAYER (keyless pools probed 2026-08-02 — fetch-open-pools.mjs).
// Kept SEPARATE from scripture/film on purpose. Open Bible Stories is 50 illustrated
// Bible narratives — a real gospel resource, strong for oral-preference and
// low-literacy communities, and NOT a Bible translation. Folding it into
// has_scripture would overstate TRANSLATION coverage, which is the single number
// that misdirects real translation effort.
const OPEN_POOLS = (() => {
const f = join(OUT, 'open-pools.json');
if (!existsSync(f)) return {};
return JSON.parse(readFileSync(f, 'utf8')).languages || {};
})();
const obsFor = (iso) => {
const d = OPEN_POOLS[iso]?.pools?.door43;
if (!d) return { has: false, note: '' };
if (d.roster_only) {
// The trap this whole layer exists to avoid: a catalogue LISTING a language is
// not the catalogue HAVING a resource for it. Measured: 4 of 44 DX overlaps.
return { has: false, note: 'Door43 lists this language but hosts NO resources for it — roster membership is not coverage' };
}
return { has: !!d.has_open_bible_stories, note: d.has_open_bible_stories ? 'Open Bible Stories (50 illustrated narratives) — NOT a Bible translation' : '' };
};
const src = read('language-resources.json');
const rows = [];
for (const L of src.languages) {
for (const pg of L.people_groups) {
rows.push({
people_group: pg,
language: L.language,
iso639_3: L.iso639_3,
countries: L.countries.join(', '),
country_count: L.countries.length,
population: L.population,
has_scripture: !!L.has_scripture,
full_bible: !!L.full_bible,
new_testament: !!L.new_testament,
has_film: !!L.has_film,
jesus_film: !!L.jesus_film,
// STRING not boolean, deliberately: 'unknown' is a THIRD state and a
// boolean column would collapse it to false — telling a reader a language
// is unserved when the pool that would answer was never checked.
has_audio: String(L.has_audio),
// OPEN BIBLE STORIES — its OWN field, never folded into has_scripture.
has_open_bible_stories: obsFor(L.iso639_3).has,
obs_note: obsFor(L.iso639_3).note,
// The headline: nothing found in ANY checked pool. DELIBERATELY still
// scripture+film only — OBS is a real resource but not a translation, and
// this flag drives translation prioritisation. any_resource_found below
// carries the wider reading so both questions can be asked separately.
no_resource_found: !L.has_scripture && !L.has_film,
any_resource_found: !!(L.has_scripture || L.has_film || obsFor(L.iso639_3).has),
scripture_url: (L.resources && L.resources[0] && L.resources[0].url) || '',
film_url: L.film_url || '',
// Structural unmatchability is NOT the same as a measured absence.
code_unmatchable: !!L.code_unmatchable,
code_note: L.code_note || '',
checked_pools: (L.checked_pools || []).join(', '),
unchecked_pools: (L.unchecked_pools || []).join(', '),
});
}
}
rows.sort((a, b) => (a.no_resource_found === b.no_resource_found)
? b.population - a.population
: (a.no_resource_found ? -1 : 1));
built.push(emit('people-group-resources', rows, [
{ title: 'People Group', field: 'people_group', type: 'string' },
{ title: 'Language', field: 'language', type: 'string' },
{ title: 'ISO 639-3', field: 'iso639_3', type: 'string' },
{ title: 'Countries', field: 'countries', type: 'string' },
{ title: 'Population', field: 'population', type: 'number' },
{ title: 'Scripture', field: 'has_scripture', type: 'boolean' },
{ title: 'Full Bible', field: 'full_bible', type: 'boolean' },
{ title: 'NT', field: 'new_testament', type: 'boolean' },
{ title: 'Film', field: 'has_film', type: 'boolean' },
{ title: 'JESUS Film', field: 'jesus_film', type: 'boolean' },
{ title: 'Audio', field: 'has_audio', type: 'string' },
{ title: 'NO RESOURCE', field: 'no_resource_found', type: 'boolean' },
{ title: 'Scripture link', field: 'scripture_url', type: 'string' },
{ title: 'Film link', field: 'film_url', type: 'string' },
{ title: 'Code unmatchable', field: 'code_unmatchable', type: 'boolean' },
{ title: 'Code note', field: 'code_note', type: 'string' },
{ title: 'Pools checked', field: 'checked_pools', type: 'string' },
{ title: 'Pools NOT checked', field: 'unchecked_pools', type: 'string' },
], {
title: 'People Groups × Gospel Resources',
grain: 'one row per people group',
sources: src.stats.pools_checked,
not_included: src.stats.pools_unchecked,
absence_meaning: 'FALSE means NOT FOUND in the pools checked — never proof that no resource exists.',
unmatchable_note: '33 of 862 DX language codes are OUTSIDE the ISO 639-3 registry (31 private-use q*, almost all SIGN LANGUAGES). No pool can index those codes, so their resource status is UNRESOLVABLE BY JOIN — structurally false, not measured. Filter on code_unmatchable to separate them.',
boundary: 'Resource availability only. No church, believer or meeting-point data.',
}, (r) => {
// The join must not have silently emptied: major languages MUST show scripture.
const tel = r.find((x) => x.iso639_3 === 'tel');
if (tel && !tel.has_scripture) throw new Error('validation: Telugu shows no scripture — the ISO join is broken');
const bad = r.find((x) => x.has_audio !== 'unknown' && x.has_audio !== 'true' && x.has_audio !== 'false');
if (bad) throw new Error(`validation: has_audio has an unexpected value '${bad.has_audio}'`);
}));
}
// ═══ TABLE 1b — GOSPEL ACCESS vs POPULATION GROWTH ═════════════════════════
// The strategic question: is gospel access keeping pace with the population it has
// to reach? A group growing 2.5%/yr whose language still has no scripture falls
// further behind every year — invisible in a static "unreached" list.
if (has('language-resources.json') && has('demographics-by-country.json')) {
const src = read('language-resources.json');
const demo = read('demographics-by-country.json').countries;
const recPath = resolve(__dirname, '..', 'posters', 'out', '_records.json');
const rawRec = JSON.parse(readFileSync(recPath, 'utf8'));
const records = Array.isArray(rawRec) ? rawRec : (rawRec.records || []);
const byName = new Map();
for (const r of records) byName.set(r.name, r);
const rows = [];
for (const L of src.languages) {
for (const pg of L.people_groups) {
const r = byName.get(pg) || {};
const iso3 = r.country_code || '';
const d = demo[iso3] || {};
const pop = parseInt(r.population, 10) || 0;
const dr = d.death_rate_per_1000;
// ORDER-OF-MAGNITUDE: a NATIONAL crude rate applied to a sub-population.
// Unengaged groups skew rural and poorer, so the real figure is plausibly
// HIGHER. `estimate_method` travels with the number in every row.
const deathsDay = (dr != null && pop) ? +((pop * (dr / 1000)) / 365.25).toFixed(2) : null;
const growth = d.pop_growth_pct;
const noRes = !L.has_scripture && !L.has_film;
rows.push({
people_group: pg,
language: L.language,
iso639_3: L.iso639_3,
country: r.country_code_label || iso3,
iso3,
primary_religion: r.religion_label || 'Unknown',
// Named clumsily on purpose: this is an INFERENCE from the group's primary
// religion field, not a measurement of individual belief. Nobody counted.
primary_religion_not_christian: !/christ/i.test(r.religion_label || ''),
population: pop,
population_growth_pct: growth ?? null,
growth_year: d.pop_growth_pct_year || '',
death_rate_per_1000: dr ?? null,
death_rate_year: d.death_rate_per_1000_year || '',
est_deaths_per_day: deathsDay,
est_deaths_per_year: deathsDay != null ? +(deathsDay * 365.25).toFixed(0) : null,
has_scripture: !!L.has_scripture,
has_film: !!L.has_film,
has_audio: String(L.has_audio),
no_resource_found: noRes,
// The headline signal: growing AND unreached by any checked resource.
growing_without_resource: !!(noRes && growth != null && growth > 0),
urban_pct: d.urban_pct ?? null,
// POPULATION OUTLIER FLAG. Not a correction — the population comes from the
// upstream people-group API and is not ours to overwrite — but a number that
// disagrees with the corpus's OWN pattern must travel with that fact, because
// est_deaths_per_day is computed straight from it and feeds the headline.
// Measured: every other Deaf group sits near 1% of its national population
// (Iran 0.98%, Myanmar 0.97%), while Deaf Pakistanis is 3.98% — a 4x outlier
// against the corpus itself, not against an outside assumption. That single
// row carries ~292 of the ~3,480 total deaths/day.
population_outlier: POP_OUTLIERS.has(pg) ? 'population disagrees with comparable groups in this corpus — treat the derived rate as UNVERIFIED' : '',
estimate_method: 'deaths/day = population x (national crude death rate / 1000) / 365.25 — ORDER-OF-MAGNITUDE; a NATIONAL rate applied to a sub-population',
source: 'World Bank (demographics) + eBible/Arclight (resources)',
});
}
}
rows.sort((a, b) => (b.est_deaths_per_day || 0) - (a.est_deaths_per_day || 0));
built.push(emit('gospel-access-vs-growth', rows, [
{ title: 'People Group', field: 'people_group', type: 'string' },
{ title: 'Language', field: 'language', type: 'string' },
{ title: 'ISO 639-3', field: 'iso639_3', type: 'string' },
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'Primary religion', field: 'primary_religion', type: 'string' },
{ title: 'Population', field: 'population', type: 'number' },
{ title: 'Growth %/yr', field: 'population_growth_pct', type: 'number' },
{ title: 'Death rate /1000', field: 'death_rate_per_1000', type: 'number' },
{ title: 'Est. deaths/day', field: 'est_deaths_per_day', type: 'number' },
{ title: 'Est. deaths/yr', field: 'est_deaths_per_year', type: 'number' },
{ title: 'Scripture', field: 'has_scripture', type: 'boolean' },
{ title: 'Film', field: 'has_film', type: 'boolean' },
{ title: 'Audio', field: 'has_audio', type: 'string' },
{ title: 'NO RESOURCE', field: 'no_resource_found', type: 'boolean' },
{ title: 'GROWING + UNREACHED', field: 'growing_without_resource', type: 'boolean' },
{ title: 'Urban %', field: 'urban_pct', type: 'number' },
{ title: 'Method', field: 'estimate_method', type: 'string' },
], {
title: 'Gospel Access vs Population Growth',
grain: 'one row per people group',
the_question: 'Is gospel access keeping pace with the population it must reach?',
mortality_caveat: 'est_deaths_per_day applies a NATIONAL crude death rate to a SUB-POPULATION. It is an ORDER-OF-MAGNITUDE ESTIMATE, not a count. Crude death rate is not age-standardised. Unengaged groups skew rural and poorer, so the true figure is plausibly HIGHER.',
religion_caveat: 'primary_religion_not_christian is INFERRED from the group primary-religion field, not measured individual belief. 1 of 2,106 DX groups has a Christian primary religion.',
boundary: 'No church, believer or meeting-point data.',
}, (r) => {
const withEst = r.filter((x) => x.est_deaths_per_day != null);
if (withEst.length < 1000) throw new Error(`validation: only ${withEst.length} rows have a mortality estimate`);
const neg = r.find((x) => x.est_deaths_per_day != null && x.est_deaths_per_day < 0);
if (neg) throw new Error('validation: negative deaths/day — the arithmetic is wrong');
}));
}
// ═══ TABLE 2 — KEY CITIES (named population centres) ════════════════════════
if (has('city-candidates.json')) {
const spine = read('city-candidates.json');
const named = has('named-places.json') ? read('named-places.json').places : {};
const conn = has('connectivity-by-country.json') ? read('connectivity-by-country.json').countries : {};
const rows = spine.clusters.map((c) => {
const n = named[c.cluster_id] || {};
const k = conn[c.iso3] || {};
return {
place_name: n.place_name || '',
place_type: n.place_type || '',
admin_state: n.admin_state || '',
country: c.country,
iso3: c.iso3,
region: c.region,
lat: c.lat,
lng: c.lng,
people_group_count: c.people_group_count,
people_groups: (c.people_groups || []).join(', '),
languages: (c.languages || []).join(', '),
upg_population: c.upg_population_sum,
is_population_centre: !!c.shared,
named: !!n.place_name,
// Country-level context, LABELLED as such so a reader cannot mistake it for
// a city measurement.
country_internet_pct: k.internet_pct ?? null,
country_internet_year: k.internet_year ?? '',
country_internet_access: k.internet_access || 'unknown',
internet_resolution: k.internet_pct != null ? 'country' : 'unknown',
name_source: n.source || '',
name_licence: n.licence || '',
};
});
rows.sort((a, b) => b.people_group_count - a.people_group_count);
built.push(emit('key-cities', rows, [
{ title: 'Place', field: 'place_name', type: 'string' },
{ title: 'Type', field: 'place_type', type: 'string' },
{ title: 'Admin area', field: 'admin_state', type: 'string' },
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'ISO3', field: 'iso3', type: 'string' },
{ title: 'Region', field: 'region', type: 'string' },
{ title: 'Lat', field: 'lat', type: 'number' },
{ title: 'Lng', field: 'lng', type: 'number' },
{ title: 'People groups', field: 'people_group_count', type: 'number' },
{ title: 'Group names', field: 'people_groups', type: 'string' },
{ title: 'Languages', field: 'languages', type: 'string' },
{ title: 'UPG population', field: 'upg_population', type: 'number' },
{ title: 'Population centre', field: 'is_population_centre', type: 'boolean' },
{ title: 'Named', field: 'named', type: 'boolean' },
{ title: 'Country internet %', field: 'country_internet_pct', type: 'number' },
{ title: 'Year', field: 'country_internet_year', type: 'string' },
{ title: 'Access band', field: 'country_internet_access', type: 'string' },
{ title: 'Internet resolution', field: 'internet_resolution', type: 'string' },
{ title: 'Name source', field: 'name_source', type: 'string' },
], {
title: 'Key Cities / Location Clusters',
grain: 'one row per ~25km location cluster',
caveat: 'A cluster is people-group CENTROIDS, not a settlement. place_name comes from a cited gazetteer (Nominatim/OSM, ODbL); an unnamed row is honest, not missing.',
internet_caveat: 'Internet figures are NATIONAL. Per-city connectivity is not publicly available at this scale.',
}, (r) => {
const namedRows = r.filter((x) => x.named);
if (namedRows.length < 200) throw new Error(`validation: only ${namedRows.length} named cities — expected the 277 population centres`);
}));
}
// ASN holder strings are REGISTRY-formatted, not human names:
// 'AIRTEL TCHAD - AIRTEL TCHAD' (doubled)
// 'TELECOM-LA-AS-AP - Lao Telecom Communication, LTC' (handle prefix)
// A list view showing the raw string is technically correct and practically
// unreadable. So `operator` is CLEANED for reading and `operator_raw` keeps the
// exact registry value — the cleaned name is a label, the raw one is the fact.
function cleanOperator(name) {
const s = String(name || '').trim();
// RIPE genuinely publishes "-" as the holder name for some ASNs (measured: 5 rows,
// AS37110/MOZ, AS7901/NZL, AS22354/TZA, AS36997/UGA, AS11157/ZAF). That is real
// upstream absence, not a parse failure — but rendering "-" in an Operator column
// reads as if the operator were NAMED "-". Say what is true instead: the name is
// unknown. The honesty rule's own convention — 'unknown' is a STRING, never a
// silent blank or a false value.
if (!s || /^[-–—]+$/.test(s)) return 'unknown — no holder name published by the registry';
if (!s.includes(' - ')) return s;
const [left, right] = [s.slice(0, s.indexOf(' - ')).trim(), s.slice(s.indexOf(' - ') + 3).trim()];
if (left.toLowerCase() === right.toLowerCase()) return left;
// A registry HANDLE is all-caps with -AS/-AP style suffixes; prefer the human half.
//
// KNOWN LIMIT, left visible rather than hidden: some ASN records put a POSTAL
// ADDRESS in the holder field. Measured: 'ROSHAN-AF - Main Street, House No. 13
// Wazir Akbar' — the real carrier is Roshan, and the human half is its address.
// A cleaner cannot reliably tell an address from a company name, so when the
// right half LOOKS like an address we keep the handle instead: 'ROSHAN-AF' is
// ugly but true, whereas 'Main Street, House No. 13' is confidently wrong.
// Guessing the pretty answer is how a dataset starts lying.
const looksLikeAddress = /\b(street|road|house no|floor|building|p\.?o\.? box|avenue|blvd|suite)\b/i.test(right);
if (looksLikeAddress) return left;
return (/^[A-Z0-9-]+$/.test(left) && right) ? right : left;
}
// ═══ TABLE 3 — NETWORK OPERATORS (ISPs + carriers, by name) ═════════════════
if (has('operators-by-country.json')) {
const ops = read('operators-by-country.json');
const rows = [];
for (const c of Object.values(ops.countries)) {
for (const o of (c.operators || [])) {
rows.push({
operator: cleanOperator(o.name),
operator_raw: o.name,
asn: o.asn,
country_iso3: c.iso3,
country_iso2: c.iso2,
kind: o.kind,
likely_carrier: o.kind !== 'unclassified',
classification: o.classification_method,
resolution: c.resolution,
source: c.source,
});
}
}
if (rows.length) {
built.push(emit('network-operators', rows, [
{ title: 'Operator', field: 'operator', type: 'string' },
{ title: 'Registry name', field: 'operator_raw', type: 'string' },
{ title: 'ASN', field: 'asn', type: 'string' },
{ title: 'Country', field: 'country_iso3', type: 'string' },
{ title: 'ISO2', field: 'country_iso2', type: 'string' },
{ title: 'Kind', field: 'kind', type: 'string' },
{ title: 'Likely carrier/ISP', field: 'likely_carrier', type: 'boolean' },
{ title: 'How classified', field: 'classification', type: 'string' },
{ title: 'Resolution', field: 'resolution', type: 'string' },
{ title: 'Source', field: 'source', type: 'string' },
], {
title: 'Network Operators (ISPs + cell carriers)',
grain: 'one row per ASN holder',
caveat: 'An ASN holder announces IP space in that country. That set includes ISPs and carriers BUT ALSO banks, universities and hosting resellers. `kind` is a NAME-PATTERN HEURISTIC, not a verified classification.',
resolution_caveat: 'ASN registration is NATIONAL. No public dataset maps operators to individual cities at this scale.',
source: ops.source,
}));
}
}
// ═══ TABLE 4 — COUNTRY CONNECTIVITY ════════════════════════════════════════
if (has('connectivity-by-country.json')) {
const conn = read('connectivity-by-country.json');
const spine = has('city-candidates.json') ? read('city-candidates.json') : { clusters: [] };
const byIso = {};
for (const c of spine.clusters) {
byIso[c.iso3] = byIso[c.iso3] || { groups: 0, clusters: 0, country: c.country, region: c.region };
byIso[c.iso3].groups += c.people_group_count;
byIso[c.iso3].clusters++;
}
const rows = Object.values(conn.countries).map((k) => {
const s = byIso[k.iso3] || {};
return {
country: s.country || k.iso3,
iso3: k.iso3,
region: s.region || '',
internet_access: k.internet_access,
internet_pct: k.internet_pct,
internet_year: k.internet_year || '',
mobile_per_100: k.mobile_per_100,
mobile_year: k.mobile_year || '',
national_population: k.national_population,
people_groups: s.groups || 0,
locations: s.clusters || 0,
censorship: k.censorship,
resolution: k.resolution,
banding: k.banding,
source: k.source?.name || '',
};
}).sort((a, b) => b.people_groups - a.people_groups);
built.push(emit('country-connectivity', rows, [
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'ISO3', field: 'iso3', type: 'string' },
{ title: 'Region', field: 'region', type: 'string' },
{ title: 'Access', field: 'internet_access', type: 'string' },
{ title: 'Internet %', field: 'internet_pct', type: 'number' },
{ title: 'Year', field: 'internet_year', type: 'string' },
{ title: 'Mobile/100', field: 'mobile_per_100', type: 'number' },
{ title: 'Mobile year', field: 'mobile_year', type: 'string' },
{ title: 'National population', field: 'national_population', type: 'number' },
{ title: 'People groups', field: 'people_groups', type: 'number' },
{ title: 'Locations', field: 'locations', type: 'number' },
{ title: 'Censorship', field: 'censorship', type: 'string' },
{ title: 'Resolution', field: 'resolution', type: 'string' },
{ title: 'Source', field: 'source', type: 'string' },
], {
title: 'Country Connectivity',
grain: 'one row per country',
caveat: 'National figures. `censorship` is `unknown` throughout — it lives in OONI / Access Now / Freedom on the Net reports that are not machine-readable per-country APIs, and approximating it would be a guess wearing a citation.',
source: conn.source,
}));
}
// ═══ TABLE 6 — SOCIAL PLATFORMS (where people actually are) ════════════════
// A people group with internet is reachable on SOME platform — but not the same
// one everywhere. Measured: Sudan is Twitter-dominant (50.6%), Laos YouTube
// (77.3%), Pakistan Facebook (91.7%), India Instagram (45.4%). Assuming a global
// default is how digital outreach misses an entire country.
if (has('social-platforms-by-country.json')) {
const soc = read('social-platforms-by-country.json');
const spine2 = has('city-candidates.json') ? read('city-candidates.json') : { clusters: [] };
const groupsBy = {};
for (const c of spine2.clusters) {
groupsBy[c.iso3] = groupsBy[c.iso3] || { groups: 0, country: c.country, region: c.region };
groupsBy[c.iso3].groups += c.people_group_count;
}
const rows = [];
for (const c of Object.values(soc.countries)) {
const g = groupsBy[c.iso3] || {};
// One row per COUNTRY×PLATFORM — the grain that lets a list view sort
// "where is YouTube dominant" without unpacking a nested array.
for (const p of (c.platforms || [])) {
rows.push({
country: g.country || c.iso3,
iso3: c.iso3,
region: g.region || '',
platform: p.name,
share_pct: p.pct,
is_top: p.name === c.top_platform,
month: c.month || '',
people_groups: g.groups || 0,
messaging_apps: c.messaging_apps,
resolution: c.resolution,
source: c.source,
});
}
}
if (rows.length) {
rows.sort((a, b) => b.people_groups - a.people_groups || b.share_pct - a.share_pct);
built.push(emit('social-platforms', rows, [
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'ISO3', field: 'iso3', type: 'string' },
{ title: 'Region', field: 'region', type: 'string' },
{ title: 'Platform', field: 'platform', type: 'string' },
{ title: 'Share %', field: 'share_pct', type: 'number' },
{ title: 'Dominant', field: 'is_top', type: 'boolean' },
{ title: 'Month', field: 'month', type: 'string' },
{ title: 'People groups', field: 'people_groups', type: 'number' },
{ title: 'Messaging apps', field: 'messaging_apps', type: 'string' },
{ title: 'Resolution', field: 'resolution', type: 'string' },
{ title: 'Source', field: 'source', type: 'string' },
], {
title: 'Social Platforms by Country',
grain: 'one row per country x platform',
measures: 'share of page-view referrals among tracked social platforms',
critical_caveat: 'MESSAGING APPS ARE NOT MEASURED. WhatsApp/Telegram/WeChat/Signal/Viber generate few web referrals but may be THE dominant channel in several of these countries. A low total here means little web-referral traffic, NOT that people are offline.',
empty_month_note: 'The CURRENT month publishes as all-zeros before StatCounter finalises it; the parser walks BACK to the last month with real data and records which month that was.',
source: soc.source,
}, (r) => {
const zero = r.filter((x) => x.share_pct === 0);
if (zero.length) throw new Error(`validation: ${zero.length} rows with 0% share — the empty-month trap was not handled`);
}));
}
}
// ═══ TABLE 7 — MESSAGING & APP CHANNELS (what referral stats cannot see) ═══
// StatCounter's ENTIRE tracked universe is Western referral platforms — measured:
// Facebook/Instagram/Twitter/YouTube/Pinterest/reddit/LinkedIn/Tumblr/HN/VK/Fark.
// It has NO WhatsApp, WeChat, Telegram, TikTok, LINE, Viber, imo or Zalo, because
// messaging apps generate almost no web referrals. Reading "Facebook 91.7%" as
// "this is where Pakistanis are" would be exactly wrong.
// Brief: "look for other social media platforms than the typical ones."
if (has('top-apps-by-country.json')) {
const apps = read('top-apps-by-country.json');
const spine3 = has('city-candidates.json') ? read('city-candidates.json') : { clusters: [] };
const g = {};
for (const c of spine3.clusters) {
g[c.iso3] = g[c.iso3] || { groups: 0, country: c.country, region: c.region };
g[c.iso3].groups += c.people_group_count;
}
const rows = [];
for (const c of Object.values(apps.countries)) {
const meta = g[c.iso3] || {};
// One row per COUNTRY x APP so a list view can sort "where does Telegram rank".
for (const a of (c.comms_apps || [])) {
rows.push({
country: meta.country || c.iso3,
iso3: c.iso3,
region: meta.region || '',
app: a.name,
kind: a.kind,
rank: a.rank,
is_top_messaging: a.name === c.top_messaging,
people_groups: meta.groups || 0,
platform_bias: c.platform_bias,
classification: c.classification_method,
source: c.source,
});
}
}
if (rows.length) {
rows.sort((a, b) => b.people_groups - a.people_groups || a.rank - b.rank);
built.push(emit('messaging-channels', rows, [
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'ISO3', field: 'iso3', type: 'string' },
{ title: 'Region', field: 'region', type: 'string' },
{ title: 'App', field: 'app', type: 'string' },
{ title: 'Kind', field: 'kind', type: 'string' },
{ title: 'Store rank', field: 'rank', type: 'number' },
{ title: 'Top messaging', field: 'is_top_messaging', type: 'boolean' },
{ title: 'People groups', field: 'people_groups', type: 'number' },
{ title: 'Platform bias', field: 'platform_bias', type: 'string' },
{ title: 'How classified', field: 'classification', type: 'string' },
{ title: 'Source', field: 'source', type: 'string' },
], {
title: 'Messaging & App Channels',
grain: 'one row per country x communication app',
why: 'Referral stats (StatCounter) structurally cannot see messaging apps. Measured here: WhatsApp is the top messaging app in 54 countries, Telegram in 38 — neither appears in referral share at all.',
ios_caveat: 'iOS ONLY. Most of these countries are Android-majority and Google publishes no keyless equivalent, so this is one eye open. An app absent here is NOT absent in-country. 50 of 175 countries have no Apple storefront.',
ranking_caveat: 'A top-free RANK is most-DOWNLOADED recently, not most-used.',
classification_caveat: 'kind is a name-keyword HEURISTIC over a known list, not a verified taxonomy.',
source: apps.source,
}));
}
}
// ═══ TABLE 8 — LANGUAGES BY COUNTRY, ranked, joined to resources ════════════
if (has('trade-languages-by-country.json')) {
const tl = read('trade-languages-by-country.json');
const rows = [];
for (const c of tl.countries) {
for (const [i, l] of (c.languages_ranked || []).entries()) {
rows.push({
country: c.country,
iso3: c.iso3,
language: l.label,
iso639_3: l.iso639_3,
prevalence_rank: i + 1,
groups_speaking: l.groups,
population: l.population,
// A language spoken by >1 group crosses group boundaries in that country.
bridge_candidate: l.groups > 1,
has_scripture: !!l.has_scripture,
full_bible: !!l.full_bible,
has_film: !!l.has_film,
carries_resource: !!l.carries_resource,
country_people_groups: c.people_groups,
});
}
}
if (rows.length) {
rows.sort((a, b) => b.country_people_groups - a.country_people_groups || a.prevalence_rank - b.prevalence_rank);
built.push(emit('languages-by-country', rows, [
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'ISO3', field: 'iso3', type: 'string' },
{ title: 'Language', field: 'language', type: 'string' },
{ title: 'ISO 639-3', field: 'iso639_3', type: 'string' },
{ title: 'Rank', field: 'prevalence_rank', type: 'number' },
{ title: 'Groups speaking', field: 'groups_speaking', type: 'number' },
{ title: 'Population', field: 'population', type: 'number' },
{ title: 'Bridge candidate', field: 'bridge_candidate', type: 'boolean' },
{ title: 'Scripture', field: 'has_scripture', type: 'boolean' },
{ title: 'Full Bible', field: 'full_bible', type: 'boolean' },
{ title: 'Film', field: 'has_film', type: 'boolean' },
{ title: 'Carries a resource', field: 'carries_resource', type: 'boolean' },
], {
title: 'Languages by Country (ranked) x Resources',
grain: 'one row per country x language',
ranking: 'By how many DX people groups claim it as their PRIMARY language in that country.',
bridge_caveat: 'bridge_candidate = primary to MORE THAN ONE group here. That is NOT a national trade language: a lingua franca no unengaged group claims as primary is invisible to this method (Urdu in Pakistan is the clearest case). See gaps-and-opportunities.bridge_strength before acting.',
source: tl.stats,
}));
}
}
// ═══ TABLE 9 — LANGUAGES PER CITY x RESOURCES (the per-city half) ══════════
// Brief: "trade languages for each country AND CITY… key languages in order of
// most prevalent, and map that to gospel."
// The country-level view (TABLE 8) answers "what is spoken in this country".
// This answers the sharper question: AT THIS PLACE, which languages are spoken,
// and does each one already carry a resource — so a city can be read as reachable
// or not without aggregating up to its country.
//
// GRAIN is one row per LOCATION x LANGUAGE. 399 of 1,646 clusters are
// multi-language, which is exactly where a city-level bridge could exist that the
// country view averages away.
if (has('city-candidates.json') && has('language-resources.json')) {
const spine4 = read('city-candidates.json');
const lr = read('language-resources.json');
const named4 = has('named-places.json') ? read('named-places.json').places : {};
const byL = new Map();
for (const l of lr.languages) byL.set((l.language || '').toLowerCase(), l);
// languages on a cluster are LABELS; language-resources is keyed by iso639_3, so
// match on the label and record when it does not resolve rather than dropping it.
const rows = [];
for (const c of spine4.clusters) {
const n = named4[c.cluster_id] || {};
const langs = c.languages || [];
for (const [i, label] of langs.entries()) {
const L = byL.get(String(label).toLowerCase());
rows.push({
place_name: n.place_name || '',
country: c.country,
iso3: c.iso3,
region: c.region,
lat: c.lat,
lng: c.lng,
language: label,
iso639_3: L ? L.iso639_3 : '',
// Order within THIS place, by how the cluster lists them.
rank_in_place: i + 1,
languages_here: langs.length,
people_groups_here: c.people_group_count,
upg_population: c.upg_population_sum,
has_scripture: L ? !!L.has_scripture : false,
has_film: L ? !!L.has_film : false,
carries_resource: L ? !!(L.has_scripture || L.has_film) : false,
// Explicit third state: a label that did not resolve is NOT a language
// without resources — it is a language we could not look up.
resolved: !!L,
note: L ? '' : 'language label did not resolve to an ISO 639-3 record — status UNKNOWN, not absent',
});
}
}
if (rows.length) {
rows.sort((a, b) => b.people_groups_here - a.people_groups_here || a.rank_in_place - b.rank_in_place);
built.push(emit('languages-per-city', rows, [
{ title: 'Place', field: 'place_name', type: 'string' },
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'Region', field: 'region', type: 'string' },
{ title: 'Language', field: 'language', type: 'string' },
{ title: 'ISO 639-3', field: 'iso639_3', type: 'string' },
{ title: 'Rank here', field: 'rank_in_place', type: 'number' },
{ title: 'Languages here', field: 'languages_here', type: 'number' },
{ title: 'People groups here', field: 'people_groups_here', type: 'number' },
{ title: 'UPG population', field: 'upg_population', type: 'number' },
{ title: 'Scripture', field: 'has_scripture', type: 'boolean' },
{ title: 'Film', field: 'has_film', type: 'boolean' },
{ title: 'Carries a resource', field: 'carries_resource', type: 'boolean' },
{ title: 'Resolved', field: 'resolved', type: 'boolean' },
{ title: 'Note', field: 'note', type: 'string' },
{ title: 'Lat', field: 'lat', type: 'number' },
{ title: 'Lng', field: 'lng', type: 'number' },
], {
title: 'Languages per City x Resources',
grain: 'one row per location cluster x language spoken there',
why: '399 of 1,646 clusters are multi-language — a city-level bridge can exist that a country average hides.',
place_caveat: 'A location is a cluster of people-group centroids within ~25km, NOT a municipal boundary. An empty place_name is an unnamed cluster, never an invented one.',
resolution_caveat: 'resolved:false means the language LABEL did not match an ISO 639-3 record here — its resource status is UNKNOWN, not absent.',
pools_caveat: 'Resource status is from eBible + JESUS Film only. GRN and FCBH audio are key-blocked.',
}, (r) => {
const unres = r.filter((x) => !x.resolved).length;
if (unres === r.length) throw new Error('validation: NO language label resolved — the label join is broken');
}));
}
}
// ═══ TABLE 10 — CITY BRIDGE OPPORTUNITIES (the actionable shortlist) ═══════
// The sharpest output of the whole corpus, promoted out of the 1,866-row per-city
// table so it is not buried: places where a people group's OWN language carries
// nothing, but ANOTHER language spoken in that same ~25km cluster already does.
//
// Measured: 30 such places. Coimbatore — Muthuvan has nothing, Tamil is right
// there. Port Blair — Andaman Hindi Creole -> Telugu. Jinghong — Kon Keu -> Hani.
//
// WHY IT IS NOT VISIBLE ANYWHERE ELSE: a country-level view averages this away.
// 399 of 1,646 clusters are multi-language, and the bridge exists at CITY scale.
//
// HONEST LIMIT, in every row: co-location inside a 25km cluster is NOT proof the
// group speaks the neighbouring language. This is a LEAD TO VERIFY, not a route to
// assume — which is why the column is named `lead`, not `route`.
if (existsSync(join(TABLES, 'languages-per-city.json'))) {
const lpc = JSON.parse(readFileSync(join(TABLES, 'languages-per-city.json'), 'utf8'));
const byPlace = new Map();
for (const r of lpc.rows) {
const k = `${r.lat},${r.lng}`;
if (!byPlace.has(k)) byPlace.set(k, []);
byPlace.get(k).push(r);
}
const rows = [];
for (const [, v] of byPlace) {
const top = v[0];
if (!top || top.carries_resource) continue; // top language already served
// A SIGN LANGUAGE CANNOT BRIDGE TO A SPOKEN ONE. Caught before shipping:
// 'Yemeni Sign Language -> Hebrew (modern)' and 'Angolan Sign Language ->
// Portuguese'. Scripture text and audio do not reach a Deaf community through
// a spoken language; the bridge for a sign language is ANOTHER SIGN LANGUAGE.
// Presenting those as opportunities would misdirect effort away from the
// population most likely to be overlooked — so they are excluded here and
// surfaced as their own category instead.
const isSign = (x) => /\bsign language\b/i.test(x.language || '');
const bridge = v.find((x) => x !== top && x.carries_resource && isSign(x) === isSign(top));
if (!bridge) continue; // nothing here to bridge to
rows.push({
place_name: top.place_name || '',
country: top.country,
region: top.region,
lat: top.lat,
lng: top.lng,
primary_language: top.language,
primary_iso639_3: top.iso639_3,
primary_has_scripture: top.has_scripture,
primary_has_film: top.has_film,
bridge_language: bridge.language,
bridge_iso639_3: bridge.iso639_3,
bridge_has_scripture: bridge.has_scripture,
bridge_has_film: bridge.has_film,
languages_here: v.length,
people_groups_here: top.people_groups_here,
upg_population: top.upg_population,
lead: 'co-located within ~25km — VERIFY that this group speaks the bridge language before acting',
});
}
if (rows.length) {
rows.sort((a, b) => b.upg_population - a.upg_population);
built.push(emit('city-bridge-opportunities', rows, [
{ title: 'Place', field: 'place_name', type: 'string' },
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'Region', field: 'region', type: 'string' },
{ title: 'Their language', field: 'primary_language', type: 'string' },
{ title: 'ISO', field: 'primary_iso639_3', type: 'string' },
{ title: 'Bridge language', field: 'bridge_language', type: 'string' },
{ title: 'Bridge ISO', field: 'bridge_iso639_3', type: 'string' },
{ title: 'Bridge scripture', field: 'bridge_has_scripture', type: 'boolean' },
{ title: 'Bridge film', field: 'bridge_has_film', type: 'boolean' },
{ title: 'Languages here', field: 'languages_here', type: 'number' },
{ title: 'People groups here', field: 'people_groups_here', type: 'number' },
{ title: 'UPG population', field: 'upg_population', type: 'number' },
{ title: 'Lead status', field: 'lead', type: 'string' },
], {
title: 'City Bridge Opportunities',
grain: 'one row per location where a co-located language already carries a resource',
the_finding: 'A people group whose own language has nothing, in a place where another spoken language already has scripture or film. Reachable with what exists TODAY.',
why_only_here: 'A country-level average hides this. The bridge exists at CITY scale — 399 of 1,646 clusters are multi-language.',
sign_language_rule: 'A SIGN language is only ever bridged by another SIGN language. Two rows were excluded for this (Yemeni Sign Language -> Hebrew, Angolan Sign Language -> Portuguese): text and audio do not reach a Deaf community through a spoken language, and offering them as opportunities would misdirect effort away from the population most likely to be missed. Sign languages appear in the DEAF GAP list instead.',
hard_caveat: 'CO-LOCATION IS NOT COMPREHENSION. Sharing a 25km cluster does not prove this group speaks that language. Every row is a LEAD TO VERIFY, never a route to assume.',
pools_caveat: 'Resource status is eBible + JESUS Film only; GRN and FCBH audio are key-blocked.',
}, (r) => {
const bad = r.find((x) => x.primary_has_scripture || x.primary_has_film);
if (bad) throw new Error('validation: a row whose primary language ALREADY has a resource is not an opportunity');
}));
}
}
// ═══ TABLE 11 — THE DEAF GAP ═══════════════════════════════════════════════
// Surfaced because the city-bridge work exposed it: two rows tried to bridge a
// Deaf community to a SPOKEN language, and excluding them raised the question of
// how large this population actually is.
//
// MEASURED: 73 sign languages across 120 DX people groups, 19.35M people.
// 68 languages / 90 groups / 17.53M people have NOTHING found in the pools checked.
// And 31 carry PRIVATE-USE (q*) codes that are outside ISO 639-3 entirely, so no
// resource pool can index them — their status is not "absent", it is UNLOOKUPABLE.
//
// This is the largest structurally-invisible bloc in the corpus, and it is invisible
// for a compounding reason: a sign language cannot be reached by a text or audio
// resource in a spoken language, AND a third of them cannot even be queried. A
// dataset that quietly folds them into "no resource found" hides both facts.
if (has('language-resources.json')) {
const lr2 = read('language-resources.json');
const sign = lr2.languages.filter((l) => /\bsign language\b/i.test(l.language || ''));
const rows = [];
for (const l of sign) {
for (const pg of l.people_groups) {
rows.push({
people_group: pg,
sign_language: l.language,
iso639_3: l.iso639_3,
countries: (l.countries || []).join(', '),
population: l.population,
has_scripture: !!l.has_scripture,
has_film: !!l.has_film,
has_any: !!(l.has_scripture || l.has_film),
// The compounding problem: a private-use code cannot be looked up at all.
code_unmatchable: !!l.code_unmatchable,
status: l.code_unmatchable
? 'UNLOOKUPABLE — private-use code outside ISO 639-3; no pool can index it'
: (l.has_scripture || l.has_film) ? 'has a resource'
: 'nothing found in the pools checked',
bridge_rule: 'A sign language is bridged ONLY by another sign language. Text and audio in a spoken language do not reach a Deaf community.',
});
}
}
if (rows.length) {
rows.sort((a, b) => (a.has_any === b.has_any ? b.population - a.population : (a.has_any ? 1 : -1)));
built.push(emit('deaf-gap', rows, [
{ title: 'People Group', field: 'people_group', type: 'string' },
{ title: 'Sign language', field: 'sign_language', type: 'string' },
{ title: 'ISO 639-3', field: 'iso639_3', type: 'string' },
{ title: 'Countries', field: 'countries', type: 'string' },
{ title: 'Population', field: 'population', type: 'number' },
{ title: 'Scripture', field: 'has_scripture', type: 'boolean' },
{ title: 'Film', field: 'has_film', type: 'boolean' },
{ title: 'Has anything', field: 'has_any', type: 'boolean' },
{ title: 'Code unlookupable', field: 'code_unmatchable', type: 'boolean' },
{ title: 'Status', field: 'status', type: 'string' },
{ title: 'Bridge rule', field: 'bridge_rule', type: 'string' },
], {
title: 'The Deaf Gap',
grain: 'one row per Deaf people group',
measured: '73 sign languages · 120 people groups · 19.35M people. 68 languages / 90 groups / 17.53M with NOTHING found.',
compounding_problem: '31 of these carry PRIVATE-USE (q*) codes outside ISO 639-3. No resource pool indexes a private code, so their status is UNLOOKUPABLE — not measured absence. Folding them into "no resource" hides that the query itself is impossible.',
bridge_rule: 'A sign language is bridged ONLY by another sign language. This is why Deaf groups are excluded from city-bridge-opportunities rather than matched to a co-located spoken language.',
pools_caveat: 'eBible + JESUS Film only. GRN and FCBH audio are key-blocked — and audio would not serve this population regardless.',
}));
}
}
// ═══ TABLE 16 — OPEN DOORS: is the PATH clear, not just the SUPPLY present ══
// THE PROBLEM THIS ANSWERS (the-way-truth-life-problem.md):
// Every other table in this corpus answers a SUPPLY question — does a resource
// exist, is there internet, which platform is popular. NONE answers the PATH
// question the mission is actually for: could a person in this place reach the
// gospel in their own language TODAY?
//
// A path needs THREE legs joined, which until now sat in three separate tables:
// 1. a resource EXISTS in their language (gospel-access-vs-growth)
// 2. the country can reach the network (internet-by-country)
// 3. the channel is not WALLED OFF (platform-reachability)
//
// There is a FOURTH leg — is anyone there — and it is deliberately ABSENT and must
// stay absent. NEVER-DO #6: pin resource AVAILABILITY, never believer/church/
// meeting-point locations. So this table can never say "who will go". It says only
// WHERE THE PATH IS ALREADY OPEN, which is the honest and safe half of the question.
//
// NO NEW RESEARCH. This is a DERIVATION over tables already built and already
// verified — which is why it can be trusted at all.
{
const spine = JSON.parse(readFileSync(join(TABLES, 'gospel-access-vs-growth.json'), 'utf8')).rows;
const reachRows = JSON.parse(readFileSync(join(TABLES, 'platform-reachability.json'), 'utf8')).rows;
const inet = JSON.parse(readFileSync(join(OUT, 'connectivity-by-country.json'), 'utf8')).countries || {};
// Reachability, collapsed per country: how many platforms are usable at all.
// 'accessible' and 'interference-suspected' count as reachable-with-friction;
// 'blocked' / 'interference-widespread' do not. 'not-measured' is counted
// SEPARATELY and never as either — absence of measurement is not accessibility.
const byCountry = {};
for (const r of reachRows) {
const b = byCountry[r.iso3] || (byCountry[r.iso3] = { open: [], shut: [], unmeasured: 0 });
if (r.status === 'accessible' || r.status === 'interference-suspected') b.open.push(r.platform);
else if (r.status === 'not-measured') b.unmeasured += 1;
else b.shut.push(r.platform);
}
const rows = spine.map((r) => {
const c = byCountry[r.iso3] || { open: [], shut: [], unmeasured: 0 };
const net = inet[r.iso3] || {};
const hasResource = String(r.has_scripture) === 'true' || String(r.has_scripture) === 'True'
|| String(r.has_film) === 'true' || String(r.has_film) === 'True';
const netBand = net.band || net.internet_access || 'unknown';
const netOk = netBand === 'yes' || netBand === 'partial';
const channelOpen = c.open.length > 0;
// THE VERDICT — deliberately NOT a boolean. Each value names WHICH LEG fails,
// because "closed" for three different reasons needs three different responses:
// a translation, a distribution route, or neither.
// NO MEASUREMENT IS NOT A FINDING. If every platform in this country is
// 'not-measured', channelOpen is false for a reason that has NOTHING to do with
// censorship — OONI simply has no volunteer probe there. My first version called
// that 'resource-exists-channel-shut' (a DISTRIBUTION problem) for all 15 rows
// it produced, every one of them shut=0 / unmeasured=15: Eritrea, Western
// Sahara, Palau, Niue, Tokelau. That is absence dressed as a finding — the same
// class this corpus keeps catching — and it would have sent someone chasing a
// censorship problem that was never measured. Unmeasured gets its OWN verdict.
const noChannelData = c.open.length === 0 && c.shut.length === 0;
let verdict, action;
if (noChannelData) {
verdict = 'channel-UNMEASURED';
action = hasResource
? 'A resource exists, but NO platform reachability was measured here (no OONI probe coverage). This is an UNKNOWN, not a blockage — do not read it as censorship.'
: 'No resource found AND no channel data. Both legs unknown; treat as unassessed, not as closed.';
} else if (!hasResource && !channelOpen) {
verdict = 'no-resource-and-no-channel';
action = 'Lowest immediate leverage: a translation here would land where nothing can carry it. Fund the channel question first, or reach by non-internet means.';
} else if (!hasResource) {
verdict = 'channel-open-no-resource';
action = 'HIGHEST TRANSLATION LEVERAGE: the road is open and there is nothing to send. A resource made here can actually arrive.';
} else if (!channelOpen) {
verdict = 'resource-exists-channel-shut';
action = 'NOT a translation problem — a DISTRIBUTION problem. The work is done and cannot travel. Different ask entirely.';
} else if (!netOk) {
verdict = 'resource-and-channel-but-network-poor';
action = 'Resource and platforms exist; national connectivity is the constraint. Offline/physical distribution may beat digital here.';
} else {
verdict = 'path-open';
action = 'Resource exists in-language, network reachable, at least one channel open. The road is clear — this says NOTHING about whether anyone has walked it.';
}
return {
people_group: r.people_group,
language: r.language,
iso639_3: r.iso639_3,
country: r.country,
iso3: r.iso3,
region: '',
population: r.population,
verdict,
// The three legs, each auditable on its own so a reader can see WHY.
leg1_resource_exists: hasResource,
leg2_network_band: netBand,
leg3_channels_open: c.open.length,
channels_shut: c.shut.length,
channels_not_measured: c.unmeasured,
open_channels: c.open.slice(0, 6).join(', '),
shut_channels: c.shut.slice(0, 6).join(', '),
est_deaths_per_day: r.est_deaths_per_day,
growing_without_resource: r.growing_without_resource,
action,
// THE BOUNDARY, on every row, so it cannot be quoted out of context.
what_this_does_NOT_say: 'This is a SUPPLY-and-ROUTE reading only. It never indicates believers, churches, meeting points, or that anyone has received anything. path-open means a route is technically clear, NOT that the gospel has arrived.',
confidence_note: c.unmeasured > 0 ? `${c.unmeasured} of 15 platforms UNMEASURED here — verdict rests on partial channel data` : 'all 15 platforms measured',
source: 'DERIVED — gospel-access-vs-growth + platform-reachability + connectivity-by-country. No new research.',
};
});
// Rank by the thing that most deserves attention: an open road with nothing on it,
// weighted by how many die daily. NOT by population — the mission is about reach.
const order = { 'channel-open-no-resource': 0, 'resource-exists-channel-shut': 1, 'no-resource-and-no-channel': 2, 'resource-and-channel-but-network-poor': 3, 'channel-UNMEASURED': 4, 'path-open': 5 };
rows.sort((a, b) => (order[a.verdict] - order[b.verdict])
|| (Number(b.est_deaths_per_day) || 0) - (Number(a.est_deaths_per_day) || 0));
built.push(emit('open-doors', rows, [
{ title: 'People group', field: 'people_group', type: 'string' },
{ title: 'Language', field: 'language', type: 'string' },
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'Verdict', field: 'verdict', type: 'string' },
{ title: 'Resource?', field: 'leg1_resource_exists', type: 'string' },
{ title: 'Network', field: 'leg2_network_band', type: 'string' },
{ title: 'Channels open', field: 'leg3_channels_open', type: 'number' },
{ title: 'Deaths/day', field: 'est_deaths_per_day', type: 'number' },
{ title: 'Population', field: 'population', type: 'number' },
], {
title: 'Open Doors — where the path is clear, and where it is not',
grain: 'one row per people group x country',
the_question: 'Every other table answers SUPPLY (does a resource exist, is there internet). This answers ROUTE: could a person here reach the gospel in their own language today?',
boundary: 'This table records resource AVAILABILITY and network ROUTE only. It never indicates believers, churches or meeting points, and path-open never means the gospel has arrived.',
verdicts: 'channel-open-no-resource (highest translation leverage) · resource-exists-channel-shut (a DISTRIBUTION problem, not a translation one) · no-resource-and-no-channel · resource-and-channel-but-network-poor · channel-UNMEASURED (no probe coverage — an UNKNOWN, never read as censorship) · path-open.',
honesty: 'not-measured platforms are counted SEPARATELY and never as open or shut. Rows carry channels_not_measured + a confidence_note so a verdict resting on partial data says so.',
derived_from: 'gospel-access-vs-growth + platform-reachability + connectivity-by-country. NO new research — a join over already-verified tables.',
}, (r) => {
const bad = r.filter((x) => !x.verdict);
if (bad.length) throw new Error(`validation: ${bad.length} rows with no verdict`);
const leak = r.filter((x) => /believer|church|congregation|meeting.?point/i.test(JSON.stringify(x).replace(/what_this_does_NOT_say":"[^"]*"/, '')));
if (leak.length) throw new Error(`SAFETY: ${leak.length} rows mention believer/church/meeting-point data`);
}));
}
// ═══ TABLE 15 — KEY CITIES UNDER ADMIN-N (the derivation, honestly tiered) ══
// Phase 2: "under admin-n, derive CITIES -> KEY CITIES (pin-density, online-
// reference cross-check, long-running verify -> a VERIFIED key-cities list).
// Cities that have no polygon get a CUSTOM CITY PIN so they're visually distinct."
//
// WHAT 'VERIFIED' CAN AND CANNOT MEAN TODAY — this is the load-bearing honesty.
// Every named place has exactly ONE name source (Nominatim/OSM). A single source
// cannot cross-check itself, so NOTHING here may be called "cross-referenced".
// Instead each row carries an explicit verification_tier naming what was actually
// established, and the highest tier available today is 'gazetteer-named'. Claiming
// a verified list on one source would be the confident-wrong failure this corpus
// keeps catching.
{
const src = JSON.parse(readFileSync(join(TABLES, 'key-cities.json'), 'utf8')).rows;
const named = src.filter((r) => r.place_name);
// PIN DENSITY is the one derivation signal we genuinely have: how many distinct
// people groups cluster at this place. It is a real, computed measure — not a
// proxy for importance, and the caveat says so.
const counts = named.map((r) => Number(r.people_group_count) || 0).sort((a, b) => b - a);
const p90 = counts[Math.floor(counts.length * 0.10)] || 0;
const p75 = counts[Math.floor(counts.length * 0.25)] || 0;
const rows = named.map((r) => {
const g = Number(r.people_group_count) || 0;
// KEY-ness is a BAND over pin density, computed from this corpus's own
// distribution rather than an invented absolute threshold.
const key_tier = g >= p90 ? 'key-high' : g >= p75 ? 'key' : 'candidate';
// ADMIN-N: admin_state is the sub-country unit OSM reports. 276 of 277 named
// places carry one. Whether that unit is a State, Province, Wilayat or Region
// is country-specific — geoBoundaries' boundaryCanonical names it, and that
// lookup is a separate (already-researched) step, so the level is labelled
// GENERICALLY here rather than mislabelled 'province' for everyone.
return {
place_name: r.place_name,
place_type: r.place_type,
admin_n: r.admin_state || '',
admin_n_level: r.admin_state ? 'ADM1-equivalent (country-specific name — see boundaryCanonical)' : 'none reported',
country: r.country,
iso3: r.iso3,
region: r.region,
lat: r.lat,
lng: r.lng,
people_group_count: g,
upg_population: r.upg_population,
languages: r.languages,
key_tier,
// THE CUSTOM CITY PIN the phase asks for: a place with no polygon must render
// visually DISTINCT from a people-group pin. We hold no city polygons (ADM2 is
// 7.6MB for India alone and stays API-on-demand), so EVERY row is pin-rendered.
render_hint: 'city-pin-star',
has_polygon: false,
polygon_note: 'No city polygon shipped — ADM2 is fetched per-country on demand (India ADM2 alone = 7.6MB simplified). Render as a STAR pin, distinct from people-group pins.',
verification_tier: 'gazetteer-named',
verification_meaning: 'Name resolved from a CITED gazetteer (Nominatim/OSM) at these coordinates. NOT cross-referenced against a second independent source — only one name source exists in this corpus, and a single source cannot cross-check itself.',
country_internet_pct: r.country_internet_pct,
internet_resolution: r.internet_resolution,
name_source: r.name_source,
name_licence: r.name_licence,
};
}).sort((a, b) => b.people_group_count - a.people_group_count);
built.push(emit('key-cities-admin-n', rows, [
{ title: 'Place', field: 'place_name', type: 'string' },
{ title: 'Admin-n', field: 'admin_n', type: 'string' },
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'Key tier', field: 'key_tier', type: 'string' },
{ title: 'People groups', field: 'people_group_count', type: 'number' },
{ title: 'UPG population', field: 'upg_population', type: 'number' },
{ title: 'Type', field: 'place_type', type: 'string' },
{ title: 'Verification', field: 'verification_tier', type: 'string' },
{ title: 'Region', field: 'region', type: 'string' },
], {
title: 'Key Cities under Admin-N',
grain: 'one row per NAMED location cluster, with its sub-country admin unit',
derivation: `key_tier is a band over PIN DENSITY computed from this corpus: key-high >= ${p90} groups (top decile), key >= ${p75} (top quartile), else candidate.`,
verification_limit: 'Place names are single-source: Nominatim/OSM, not cross-checked against a second gazetteer. verification_tier says exactly what was established.',
admin_n_note: 'admin_n is the sub-country unit OSM reports (276 of 277 named places have one, 81 distinct values). What that level is CALLED varies by country — geoBoundaries boundaryCanonical gives the native term (State/Union Territory, Province, Wilayat, Region...). Not mislabelled "province" for everyone.',
rendering: 'No city polygons are shipped; every row renders as a STAR pin, visually distinct from people-group pins.',
source: 'Nominatim/OpenStreetMap (ODbL 1.0) for names; people-group counts from the DX spine.',
}, (r) => {
const bad = r.filter((x) => x.verification_tier !== 'gazetteer-named');
if (bad.length) throw new Error(`validation: ${bad.length} rows claim a verification tier this corpus cannot support`);
const nogeo = r.filter((x) => !Number.isFinite(Number(x.lat)) || !Number.isFinite(Number(x.lng)));
if (nogeo.length) throw new Error(`validation: ${nogeo.length} rows lack usable coordinates`);
}));
}
// ═══ TABLE 14 — PLATFORM REACHABILITY: is it walled off, or open here? ═════
// design note 2026-08-01: "we don't only need to track social media platforms but top
// websites and apps like WeChat or WhatsApp ... so we can know whether they are
// closed off or globally accessible."
//
// This is the ONLY table that answers REACHABILITY. social-platforms answers
// POPULARITY (StatCounter web referrals — WhatsApp/WeChat are invisible to it) and
// messaging-channels answers iOS DOWNLOAD RANK. Neither can say whether a platform
// is blocked, which is the question that decides whether a channel is usable at all.
{
const f = join(OUT, 'reachability-by-country.json');
if (!existsSync(f)) {
console.log('[tables] SKIP platform-reachability — run fetch-reachability.mjs first');
} else {
const reach = JSON.parse(readFileSync(f, 'utf8')).countries || {};
// Build country metadata LOCALLY. `groups` is defined further down the file
// (the visa table), so referencing it here is a temporal-dead-zone error — the
// build threw ReferenceError on first run. Derive from the same spine instead of
// depending on declaration order.
const spineR = JSON.parse(readFileSync(join(OUT, 'city-candidates.json'), 'utf8'));
const groups = {};
for (const c of spineR.clusters) {
groups[c.iso3] = groups[c.iso3] || { g: 0, country: c.country, region: c.region };
groups[c.iso3].g += c.people_group_count;
}
const rows = [];
for (const [iso3, c] of Object.entries(reach)) {
const meta = groups[iso3] || {};
for (const [platform, p] of Object.entries(c.platforms || {})) {
rows.push({
country: meta.country || iso3,
iso3,
region: meta.region || '',
platform,
status: p.status,
measurements: p.measurements || 0,
confirmed_blocked: p.confirmed_blocked ?? null,
block_pct: p.block_pct ?? null,
// anomaly is kept as its OWN column and never folded into block_pct.
// CHINA is the proof: YouTube 84.9% anomaly with ZERO confirmed, because
// the Great Firewall resets TCP / poisons DNS rather than serving a block
// page. Reading "0 confirmed" as "not blocked" would be exactly wrong on
// the largest censored population in this corpus.
anomaly: p.anomaly ?? null,
anomaly_pct: p.anomaly_pct ?? null,
people_groups: meta.g || 0,
probe_coverage: c.probe_coverage,
domains_measured: (p.domains || []).join(', '),
absence_meaning: p.absence_meaning || '',
window: c.window,
source: c.source,
});
}
}
rows.sort((a, b) => (b.confirmed_blocked || 0) - (a.confirmed_blocked || 0));
built.push(emit('platform-reachability', rows, [
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'Platform', field: 'platform', type: 'string' },
{ title: 'Status', field: 'status', type: 'string' },
{ title: 'Blocked %', field: 'block_pct', type: 'number' },
{ title: 'Anomaly %', field: 'anomaly_pct', type: 'number' },
{ title: 'Measurements', field: 'measurements', type: 'number' },
{ title: 'People groups', field: 'people_groups', type: 'number' },
{ title: 'Probe coverage', field: 'probe_coverage', type: 'string' },
{ title: 'Region', field: 'region', type: 'string' },
], {
title: 'Platform Reachability — blocked or open?',
grain: 'one row per country x platform',
status_scale: 'accessible < interference-suspected (>=30% anomaly) < interference-widespread (>=70% anomaly) < partially-blocked (>=5% confirmed) < blocked (>=20% confirmed). not-measured is a STRING and is NOT evidence of accessibility.',
hard_caveat: 'OONI runs on VOLUNTEER PROBES: coverage is uneven and NOT population-weighted. confirmed = proven blocked; anomaly = PROBABLE interference. They are never summed.',
safety: 'NETWORK data only. It says what a network does, NEVER who used what. Must not be joined to anything identifying a person or a fellowship.',
source: 'OONI — Open Observatory of Network Interference (api.ooni.io, open, no key)',
}, (r) => {
const bad = r.filter((x) => x.status === 'not-measured' && x.measurements > 0);
if (bad.length) throw new Error(`validation: ${bad.length} not-measured rows carry measurements`);
}));
}
}
// ═══ TABLE 12 — FIELD ACCESS: visa requirements into DX countries ══════════
// design note (via the reverse-engineer-1 template): "laws · VISA restrictions
// (nomadic travelers + tourists)" per place.
//
// SOURCE: the passport-index open dataset (ilyankou/passport-index-dataset on
// GitHub) — 39,601 passport x destination rows, PUBLIC, no key. A numeric value is
// the visa-free stay in DAYS; the rest are visa-required / e-visa / visa-on-arrival
// / eta / visa-free.
//
// WHY THIS IS SHAPED AROUND A FEW PASSPORTS AND NOT ALL 199:
// The question is not "the full 199x199 matrix" — that is 39,601 rows of mostly
// irrelevance. It is "can a labourer holding a common sending passport REACH the
// places where these people groups live?" So this emits one row per
// DX-COUNTRY x SENDING-PASSPORT for a small set of widely-held passports, which is
// the shape the question actually has.
//
// ═══ WHAT THIS IS NOT — READ BEFORE ACTING ═════════════════════════════════
// This is TOURIST/VISITOR entry policy. It is NOT permission to do religious work,
// NOT residency, and NOT a safety assessment. A country can be visa-free and still
// be somewhere this work carries real risk — those are orthogonal facts and this
// table only carries the first. Anyone reading "visa free" as "open" would be
// making exactly the inference this dataset cannot support.
//
// LAWS per city remain NOT DONE. No free, keyless, machine-readable source for
// religious-freedom or assembly law was found; the honest sources (USCIRF, Open
// Doors, Freedom House) publish PDF/HTML reports requiring a cited human pass.
if (existsSync(join(OUT, 'passport-index.csv'))) {
const csv = readFileSync(join(OUT, 'passport-index.csv'), 'utf8').trim().split('\n');
const iso2to3 = {};
try {
const m = JSON.parse(readFileSync(join(OUT, 'iso3-to-iso2.json'), 'utf8'));
for (const [i3, i2] of Object.entries(m)) iso2to3[i2] = i3;
} catch (_) { /* no map = no table */ }
// Widely-held sending passports. Named explicitly so the choice is visible and
// arguable, rather than an unexplained subset.
const SENDING = { US: 'United States', GB: 'United Kingdom', BR: 'Brazil', KR: 'South Korea',
NG: 'Nigeria', IN: 'India', PH: 'Philippines', ZA: 'South Africa' };
const spine5 = has('city-candidates.json') ? read('city-candidates.json') : { clusters: [] };
const dxIso3 = new Set(spine5.clusters.map((c) => c.iso3));
const groups = {};
for (const c of spine5.clusters) {
groups[c.iso3] = groups[c.iso3] || { g: 0, country: c.country, region: c.region };
groups[c.iso3].g += c.people_group_count;
}
const rows = [];
for (let i = 1; i < csv.length; i++) {
const [pass, dest, req] = csv[i].split(',').map((x) => x.trim());
if (!SENDING[pass]) continue;
const dIso3 = iso2to3[dest];
if (!dIso3 || !dxIso3.has(dIso3)) continue;
const meta = groups[dIso3] || {};
// "-1" is passport-index's sentinel for SAME COUNTRY — a national entering their
// own country. It is not a visa requirement and there is no field-access question
// to answer, but it was rendering in the table as though "-1" were a requirement
// (6 rows: India<-India, Brazil<-Brazil, Nigeria<-Nigeria, USA, South Africa,
// Philippines). Same class as a wrong-but-populated row: a meaningless value
// presented as a real one gets read as one. Drop the row rather than surface it.
if (req === '-1' || req === -1) continue;
const days = /^\d+$/.test(req) ? Number(req) : null;
rows.push({
destination: meta.country || dIso3,
dest_iso3: dIso3,
region: meta.region || '',
passport: SENDING[pass],
passport_iso2: pass,
requirement: days != null ? `visa free ${days} days` : req,
visa_free: days != null || req === 'visa free',
visa_free_days: days,
needs_visa_in_advance: req === 'visa required',
on_arrival_or_evisa: req === 'visa on arrival' || req === 'e-visa' || req === 'eta',
people_groups: meta.g || 0,
scope_note: 'TOURIST/VISITOR entry only — NOT permission for religious work, NOT residency, NOT a safety assessment',
source: 'passport-index open dataset (public, no key)',
});
}
if (rows.length) {
rows.sort((a, b) => b.people_groups - a.people_groups || a.destination.localeCompare(b.destination));
built.push(emit('field-access-visas', rows, [
{ title: 'Destination', field: 'destination', type: 'string' },
{ title: 'ISO3', field: 'dest_iso3', type: 'string' },
{ title: 'Region', field: 'region', type: 'string' },
{ title: 'Passport', field: 'passport', type: 'string' },
{ title: 'Requirement', field: 'requirement', type: 'string' },
{ title: 'Visa free', field: 'visa_free', type: 'boolean' },
{ title: 'Visa-free days', field: 'visa_free_days', type: 'number' },
{ title: 'Visa in advance', field: 'needs_visa_in_advance', type: 'boolean' },
{ title: 'On arrival / e-visa', field: 'on_arrival_or_evisa', type: 'boolean' },
{ title: 'People groups', field: 'people_groups', type: 'number' },
{ title: 'Scope', field: 'scope_note', type: 'string' },
], {
title: 'Field Access — Visa Requirements',
grain: 'one row per DX destination x sending passport',
passports_chosen: Object.values(SENDING).join(', ') + ' — a deliberate subset of widely-held sending passports, not the full 199x199 matrix (39,601 rows of mostly irrelevance).',
HARD_SCOPE_LIMIT: 'TOURIST/VISITOR entry policy ONLY. NOT permission to do religious work, NOT residency, and NOT a safety assessment. A country can be visa-free AND a place where this work carries real risk — orthogonal facts, and only the first is here.',
laws_not_done: 'Religious-freedom / assembly LAW per country is NOT in this corpus. No free keyless machine-readable source was found; USCIRF, Open Doors and Freedom House publish reports that need a cited human pass.',
source: 'https://github.com/ilyankou/passport-index-dataset (public, no key)',
}, (r) => {
const bad = r.find((x) => x.visa_free && x.needs_visa_in_advance);
if (bad) throw new Error('validation: a row is both visa-free and visa-required');
}));
}
}
// ── Distribute to the apps that read them ───────────────────────────────────
const ROUTE = {
'people-group-resources': 'gospel-resources',
'gospel-access-vs-growth': 'gospel-resources',
'key-cities': 'cities-internet',
'network-operators': 'cities-internet',
'country-connectivity': 'cities-internet',
'social-platforms': 'cities-internet',
'messaging-channels': 'cities-internet',
'languages-by-country': 'gospel-resources',
'languages-per-city': 'key-cities',
'city-bridge-opportunities': 'gospel-resources',
'deaf-gap': 'gospel-resources',
'field-access-visas': 'key-cities',
};
for (const b of built) {
const app = ROUTE[b.name];
if (!app) continue;
// ★ THE LEAK THIS LOOP CAUSED (found live 2026-09-24, the agent-critical): emit() already
// respects PRIVATE_TABLES and keeps those out of the repo -- but THIS loop copied from
// out/tables into the BUNDLE's data dir without the same check, so field-access-visas.json
// landed in plugin-designs/key-cities/data/tables/, flowed into the public snapshot AND the
// served catalog, and was fetchable by anonymous clone + live 200. A privacy rule enforced
// at ONE of two writers is not a rule. Same check, both writers:
if (PRIVATE_TABLES.has(b.name)) continue;
const dir = join(PROFILES, app, 'data', 'tables');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${b.name}.json`), readFileSync(join(TABLES, `${b.name}.json`)));
}
// DECLARE TABLES WRITTEN BY OTHER SCRIPTS TOO. index.json is the completeness
// contract, but only THIS script writes it — so a table produced elsewhere was
// silently absent from it. gaps-and-opportunities.json (2,106 rows, written by
// build-trade-languages.mjs) shipped this whole flight undeclared, which put it
// outside every completeness check and made the headline count wrong in every
// summary. Two producers, one index: the index has to cover both, or the contract
// only describes part of the corpus.
const EXTERNAL = ['gaps-and-opportunities'];
for (const name of EXTERNAL) {
const f = join(TABLES, `${name}.json`);
if (!existsSync(f)) { console.error(`[tables] WARNING: ${name}.json not found — run build-trade-languages.mjs`); continue; }
const rows = (JSON.parse(readFileSync(f, 'utf8')).rows || []).length;
if (!built.some((b) => b.name === name)) built.push({ name, rows });
}
writeFileSync(join(TABLES, 'index.json'), JSON.stringify({
generated: '2026-08-01',
method: 'extracted from the cited sources, then modelled and validated into one table per question',
tables: built.filter((b) => !b.private),
}, null, 1));
console.log(`\n[tables] ${built.length} tables, ${built.reduce((n, b) => n + b.rows, 0)} rows total`);
console.log('[tables] all assertions passed');