doxa-maps/library/scripts/research/verify-iso2-map.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

86 lines
4.8 KiB
JavaScript

// verify-iso2-map.mjs — assert the ISO3->ISO2 lookup is INJECTIVE and correct.
//
// node verify-iso2-map.mjs # exits 1 on any failure
//
// ═══ WHY THIS EXISTS ════════════════════════════════════════════════════════
// out/iso3-to-iso2.json feeds FIVE consumers — fetch-trending, fetch-operators,
// fetch-social-platforms, fetch-top-apps, and build-tabulator-tables. Nothing in
// this repo WRITES it, so it is an unversioned artefact that every country-keyed
// fetch silently trusts.
//
// It was wrong for 21 of 175 codes. The damaging one was NER -> NG: Niger is a
// sovereign country holding 2 DX people-group clusters, and it was being given
// NIGERIA's alpha-2. Measured before the fix, Niger and Nigeria had BYTE-IDENTICAL
// trending rows — Lionel Messi, Lamine Yamal, Gianni Infantino for both. That is
// not a gap in the data, it is FABRICATED data: a row that looks fully populated
// and is about the wrong country. PSE -> IL was the same class (Palestine given
// Israel's code), and 19 territories were mapped to their sovereign parent.
//
// A wrong-but-plausible row is worse than a missing one. A missing row is visibly
// unknown; a fabricated row gets used.
//
// THE CORE INVARIANT IS INJECTIVITY. Two ISO3 codes sharing one ISO2 means at
// least one of them is being told about a country that is not itself. That single
// check catches the whole class without needing a hand-maintained table of every
// correct pair.
//
// THE GUARD IS ONLY WORTH ITS GREEN IF IT CAN GO RED: set one code to a duplicate
// and re-run — it must fail.
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const F = join(__dirname, 'out', 'iso3-to-iso2.json');
if (!existsSync(F)) { console.error('FATAL: out/iso3-to-iso2.json missing'); process.exit(1); }
const map = JSON.parse(readFileSync(F, 'utf8'));
let failed = 0;
const fail = (m) => { console.error(` ✗ ${m}`); failed++; };
const ok = (m) => console.log(` ✓ ${m}`);
console.log('ISO3->ISO2 MAP VERIFICATION');
// 1. INJECTIVE. The check that catches NER->NG and PSE->IL without a lookup table.
const byIso2 = {};
for (const [iso3, iso2] of Object.entries(map)) (byIso2[iso2] ||= []).push(iso3);
const shared = Object.entries(byIso2).filter(([, v]) => v.length > 1);
if (shared.length) {
for (const [iso2, iso3s] of shared) {
fail(`ISO2 "${iso2}" claimed by ${iso3s.length} countries: ${iso3s.join(', ')} `
+ '— at least one will receive another country\'s data');
}
} else ok(`all ${Object.keys(map).length} codes map to a DISTINCT ISO2`);
// 2. WELL-FORMED. A lowercase or 3-letter value silently misses every API lookup.
const malformed = Object.entries(map).filter(([, v]) => !/^[A-Z]{2}$/.test(String(v)));
if (malformed.length) fail(`${malformed.length} malformed ISO2 value(s): e.g. ${malformed[0].join(' -> ')}`);
else ok('every value is a well-formed 2-letter uppercase code');
// 3. SPOT-CHECK the codes this corpus actually got wrong, so a regression is named
// rather than merely counted. These are the sovereign states whose alpha-2 was
// previously their neighbour's or their former administrator's.
const MUST = { NER: 'NE', NGA: 'NG', PSE: 'PS', ISR: 'IL', HKG: 'HK', MAC: 'MO', ABW: 'AW' };
// ABSENT MUST FAIL, NOT PASS. The first version read `map[k] && map[k] !== v`, so a
// code MISSING ENTIRELY was falsey and slipped through — deleting NER outright passed
// all three checks and this line still printed "NER … all correct" about a code that
// no longer existed. Niger would then silently vanish from every downstream table,
// which is the same disappearance the injectivity check exists to prevent, arriving
// by the opposite route. Missing is not clean; it is unverified.
const missing = Object.keys(MUST).filter((k) => !map[k]);
const wrong = Object.entries(MUST).filter(([k, v]) => map[k] && map[k] !== v);
for (const k of missing) fail(`${k} is ABSENT from the map — that country drops out of every consumer`);
for (const [k, v] of wrong) fail(`${k} -> ${map[k]}, expected ${v}`);
if (!missing.length && !wrong.length) ok('previously-wrong codes (NER, PSE, HKG, MAC, ABW …) all present and correct');
// The map must also stay COMPLETE. A silent shrink (175 -> 174) means a country
// stopped being fetched at all, which no per-code check can see.
const EXPECTED_MIN = 175;
if (Object.keys(map).length < EXPECTED_MIN) {
fail(`map has ${Object.keys(map).length} codes, expected at least ${EXPECTED_MIN} — a country has DISAPPEARED`);
} else ok(`${Object.keys(map).length} codes present (>= ${EXPECTED_MIN})`);
console.log(`\n${failed ? `FAILED — ${failed} problem(s)` : 'ISO2 MAP VALID'}`);
process.exit(failed ? 1 : 0);