Publish which province each group falls in

gospel-internet was running the same point-in-polygon over the same two geometry
files and refusing to write unless its per-province counts matched this table's —
which is good discipline and should not be necessary. The assignment ships now: one
row per placed group, 1,005 of them, with the language code, population and tier.

The two files are reconciled BEFORE either is written — per-province counts and the
assigned total must agree or the build refuses — because they are published
separately (the map fetches only the aggregate, lazily, and has no use for a
thousand rows) and separate files can drift. The failing direction of that guard is
not proven, only the passing one: 46 provinces agree, 1,005 = 1,005.
This commit is contained in:
Doxa Maps 2026-09-27 01:26:31 +00:00
parent 78a37c1b5f
commit 3179c24561
5 changed files with 20254 additions and 2 deletions

File diff suppressed because it is too large Load diff

View file

@ -2,11 +2,12 @@
"meta": {
"title": "Gospel access by province — India and China",
"why": "These two countries hold 47.9% of Doxa's roster (855 + 154 of 2,107). No other country is close; the third is Sudan at 105. A national fill cannot answer \"where would a team go\" for half the map.",
"generated": "2026-09-26",
"generated": "2026-09-27",
"provinces_with_groups": 46,
"provinces_available": 48,
"groups_assigned": 1005,
"groups_unassigned": 4,
"per_group_file": "gospel-by-province-groups.json — one row per placed group, reconciled with these counts at build time",
"india_note": "India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is the UPG-100 list's curated 34-unit partition, and eleven groups came out with no province because they stood in states that partition does not draw. Switching the file moved 998 assigned to 1,005 and 44 province rows to 46, and turned the province names into India's own states.",
"unassigned_note": "Four remain, each one legible rather than lost. Great Andamanese and the Sentinelese sit on small islands whose simplified coastline the point falls just outside — Andaman and Nicobar IS in the file, so this is geometry precision, not a missing province. Jing and Pana are on China's Vietnam and Laos borders, outside the twelve-province subset. They are listed in `unassigned` with their coordinates.",
"china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.",

View file

@ -30,6 +30,7 @@ import { writeSnapshot } from './_snapshot-guard.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = resolve(HERE, '../../..')
const OUT = resolve(REPO, 'data/tables/gospel-by-province.json')
const OUT_GROUPS = resolve(REPO, 'data/tables/gospel-by-province-groups.json')
const T = (f) => JSON.parse(readFileSync(resolve(REPO, 'data/tables', f), 'utf8'))
const BOUND = (f) => JSON.parse(readFileSync(resolve(REPO, 'public/dx-maps/assets/boundaries', f), 'utf8'))
@ -110,6 +111,7 @@ const COUNTRIES = [
const rows = []
const unassigned = []
const assignments = []
let provincesAvailable = 0
for (const c of COUNTRIES) {
const feats = BOUND(c.file).features.map((f) => ({ f, b: bboxOf(f), name: f.properties.shapeName }))
@ -124,6 +126,20 @@ for (const c of COUNTRIES) {
if (featHas(cand.f, x, y)) { hit = cand.name; break }
}
if (!hit) { unassigned.push({ country: c.country, iso3: c.iso3, people_group: r.name, lat: r.lat, lng: r.lng }); continue }
/* THE PER-GROUP ROW, published beside the aggregate so no other build has to repeat this
point-in-polygon. gospel-internet was running the identical ray-casting over the identical
two geometry files and refusing to write unless its counts matched these; a second
implementation of the same geometry is a second place for it to drift. */
assignments.push({
people_group: r.name,
country: c.country,
iso3: c.iso3,
province: hit,
iso639_3: r.iso639_3 || '',
population: r.population || 0,
tier: tiers[r.iso639_3] ?? r.tier ?? 0,
approximate: true,
})
if (!by.has(hit)) by.set(hit, [])
by.get(hit).push(r)
}
@ -212,6 +228,49 @@ if (!assigned || assigned < 900) {
console.error(' geometry or the coordinates changed shape, not that the answer moved.')
process.exitCode = 1
} else {
/* ═══ THE TWO FILES MUST AGREE, ASSERTED HERE ════════════════════════════════════════════
* The aggregate says a province holds N groups; the per-group file lists them. Published
* separately (the map fetches only the aggregate, lazily, and has no use for 1,005 rows), which
* means they CAN disagree — so they are reconciled before either is written. gospel-internet
* built its own copy of this point-in-polygon and refused to write unless its counts matched
* ours; it should not have to, and it should not have to trust us either.
*/
const perProvince = new Map()
for (const a of assignments) perProvince.set(`${a.iso3}|${a.province}`, (perProvince.get(`${a.iso3}|${a.province}`) || 0) + 1)
const disagree = rows.filter((r) => (perProvince.get(`${r.iso3}|${r.province}`) || 0) !== r.groups)
if (disagree.length || assignments.length !== assigned) {
console.error(`GOSPEL-BY-PROVINCE: REFUSING TO WRITE — the per-group file and the aggregate disagree on ${disagree.length} province(s); ${assignments.length} assignments vs ${assigned} counted.`)
process.exit(1)
}
writeSnapshot(
OUT_GROUPS,
`${JSON.stringify({
meta: {
title: 'Which province each people group falls in — India and China',
why: 'Published so no other build has to repeat this point-in-polygon. gospel-internet ran ' +
'the identical ray-casting over the identical geometry and could only check itself against ' +
'our aggregates; a second implementation of the same geometry is a second place for it to drift.',
generated: new Date().toISOString().slice(0, 10),
grain: 'one row per PLACED people group; the four that fall outside every polygon are in the aggregate file\'s `unassigned`',
rows: assignments.length,
agrees_with: 'gospel-by-province.json — per-province counts and the assigned total are reconciled at build time, and neither file is written if they differ',
approximate: 'Every row is approximate. The coordinate marks where a PEOPLE is found, not a ' +
'settlement, and near a border it can fall on the wrong side. No surface may present a ' +
'province as a fact about a group.',
},
columns: [
{ title: 'People group', field: 'people_group', type: 'string' },
{ title: 'Country', field: 'country', type: 'string' },
{ title: 'Province', field: 'province', type: 'string' },
{ title: 'Language code', field: 'iso639_3', type: 'string' },
{ title: 'People', field: 'population', type: 'number' },
{ title: 'Tier', field: 'tier', type: 'number' },
],
rows: assignments,
}, null, 1)}\n`,
(j) => (j.rows || []).length,
'province assignment(s)',
)
writeSnapshot(
OUT,
`${JSON.stringify({
@ -225,6 +284,7 @@ if (!assigned || assigned < 900) {
provinces_available: provincesAvailable,
groups_assigned: assigned,
groups_unassigned: unassigned.length,
per_group_file: 'gospel-by-province-groups.json — one row per placed group, reconciled with these counts at build time',
india_note: 'India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet ' +
'India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is ' +
'the UPG-100 list\'s curated 34-unit partition, and eleven groups came out with no ' +

File diff suppressed because it is too large Load diff

View file

@ -2,11 +2,12 @@
"meta": {
"title": "Gospel access by province — India and China",
"why": "These two countries hold 47.9% of Doxa's roster (855 + 154 of 2,107). No other country is close; the third is Sudan at 105. A national fill cannot answer \"where would a team go\" for half the map.",
"generated": "2026-09-26",
"generated": "2026-09-27",
"provinces_with_groups": 46,
"provinces_available": 48,
"groups_assigned": 1005,
"groups_unassigned": 4,
"per_group_file": "gospel-by-province-groups.json — one row per placed group, reconciled with these counts at build time",
"india_note": "India is read from the COMPLETE ADM1 (36 states, geoBoundaries via DataMeet India, CC BY 2.5 IN). The first build of this table read gb-IND-ADM1.geojson, which is the UPG-100 list's curated 34-unit partition, and eleven groups came out with no province because they stood in states that partition does not draw. Switching the file moved 998 assigned to 1,005 and 44 province rows to 46, and turned the province names into India's own states.",
"unassigned_note": "Four remain, each one legible rather than lost. Great Andamanese and the Sentinelese sit on small islands whose simplified coastline the point falls just outside — Andaman and Nicobar IS in the file, so this is geometry precision, not a missing province. Jing and Pana are on China's Vietnam and Laos borders, outside the twelve-province subset. They are listed in `unassigned` with their coordinates.",
"china_note": "China's ADM1 file carries 12 provinces, not 31+. Measured rather than assumed: 152 of 154 Chinese groups fall inside them and all twelve are used, so the file is cut to the provinces where the unreached peoples are.",