The browser/Node sheet equivalence check can measure on a fresh checkout

check:poster-browser-equiv is chained in gates:pre and exited 75 NOT MEASURED
every run. Its input, out/_records.json, is gitignored and written only by
generate.mjs against a live API, so on CI and on any box without credentials the
gate never looked at anything. The runner reported that honestly rather than as
a pass, but an identical NOT MEASURED every run is indistinguishable from an
unwired gate -- and this is the gate that proves a sheet generated in the browser
is byte-identical to the one Node writes.

An equivalence check needs the SAME input on both sides, not fresh input, so a
committed fixture loses nothing it was measuring. The fixture is the 124 public
records the two arms actually consume: Nepal's 34 for the country arm, and the
90 slugs the Gujarat zone selects for the zone arm. Same fields the served maps
show; no other data.

Verified equivalent to the full 2,106-record cache: identical zone pins in the
same order, identical Nepal set, count 90, pop 3,164,770, series {2209, 28, 1}.

Record ORDER is load-bearing -- it drives draw and label order -- so the fixture
keeps the cache's order. A first cut sorted it: every aggregate matched and the
pin SET matched, while the order differed. Sorting a fixture would have quietly
changed the sheet it produces.

generate.mjs gains RECORDS_FILE so a caller can name the offline input instead of
only the API-written cache. It still fails closed without an API when no offline
file is given. The gate passes it the very file the browser arm read, so both
sides are fed the same records by construction.

The gate now says which input it measured, because the full cache widens coverage
to all 175 countries where it exists and the fixture is the floor everywhere else.

Two-sided, on this tree: with the fixture it PASSES byte-identical on both arms
(834,174 and 921,522 bytes). Dropping the province boundaries at
posterSheets.js:206 -- the defect this gate's header records, where a country
beside India or China printed without them -- makes the country arm 251,576
against Node's 921,522 and the gate exits 1. Restored, it passes again.
This commit is contained in:
Doxa Maps 2026-09-29 12:07:09 +00:00
parent 9061065558
commit 6a0aade5c1
4 changed files with 59 additions and 10 deletions

View file

@ -21,19 +21,50 @@
* is absent the check cannot run: NOT MEASURED (75). If it is present and the Node generator fails or
* writes nothing, that is a FAILURE — a broken driver must not read as "not measured".
*/
import { readFileSync, existsSync, rmSync } from 'node:fs'
import { readFileSync, existsSync, rmSync, mkdtempSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { pathToFileURL } from 'node:url'
const REPO = new URL('..', import.meta.url).pathname
const P = join(REPO, 'library/scripts/posters')
const CACHE = join(P, 'out/_records.json')
// ★ A COMMITTED FIXTURE SO THIS GATE CAN MEASURE ON A FRESH CHECKOUT. out/_records.json is
// gitignored (library/scripts/*/out/**) and is written only by `generate.mjs` against a live API,
// so on CI and on any box without credentials this gate exited 75 EVERY run — chained in gates:pre
// and inert. The runner reported that honestly as NOT MEASURED rather than as a pass, but an
// identical ⊘ every run is indistinguishable from an unwired gate.
// AN EQUIVALENCE CHECK NEEDS THE SAME INPUT ON BOTH SIDES, NOT FRESH INPUT, so a fixed fixture
// loses nothing it was measuring. The fixture is the 124 PUBLIC records the two arms actually
// consume — Nepal's 34 (the country arm) and the 90 slugs the Gujarat zone selects (the zone arm) —
// carrying the same fields the served maps show.
// VERIFIED against the full 2,106-record cache: identical zone pins IN THE SAME ORDER, identical
// Nepal set, count 90, pop 3,164,770, series {2209, 28, 1}. Record ORDER is load-bearing (it drives
// draw and label order), so the fixture keeps the cache's order and is deliberately not sorted.
// NAMED equiv-*, NOT records-*: internal/build-packet-fixtures.mjs walks this directory for
// /^records-.*\.json$/ and builds EVERY match as a packet against one fixed zone (Tibet), so that
// shape is the only variable. A records-* file with no Tibet groups therefore fails that build by
// construction — which is exactly what this fixture did until it was renamed. Two different kinds
// of fixture share one directory; the prefix is what keeps them apart.
const FIXTURE = join(P, 'fixtures/equiv-records-npl-ind.json')
const SOURCE = existsSync(CACHE) ? CACHE : FIXTURE
const ASSETS = join(REPO, 'public/dx-maps/assets')
const DATE = '2026-09-29'
if (!existsSync(CACHE)) {
console.log('POSTER-BROWSER-EQUIV: NOT MEASURED (exit 75) — no offline records cache at library/scripts/posters/out/_records.json;')
console.log(' run `node generate.mjs` once with an API configured to write it.')
// ★ THIS GATE WRITES NOTHING SHARED. generate.mjs's default out/ is gitignored but SHARED, and
// check:zone-census counts the sheets in out/posters/ to decide whether every selectable zone has
// one. Running the generator here for a byte comparison left asia-india-gujarat.packet.html and
// country-npl.html behind, and zone-census read those two as a half-finished poster build: it went
// from NOT MEASURED to FAIL, naming 28 zones with no poster. A checker must not change what the
// next checker measures, so the generator is pointed at a private temp dir that is removed below.
const WORK = mkdtempSync(join(tmpdir(), 'equiv-'))
// Registered on 'exit' rather than written at the end: this gate exits 1 on a mismatch and 75 on a
// missing precondition, so a trailing cleanup line would be skipped on exactly the runs that ran the
// generator. 'exit' fires for all of them, and for a throw.
process.on('exit', () => { try { rmSync(WORK, { recursive: true, force: true }) } catch { /* best effort */ } })
if (!existsSync(SOURCE)) {
console.log('POSTER-BROWSER-EQUIV: NOT MEASURED (exit 75) — no records to measure with: neither the offline cache')
console.log(' (library/scripts/posters/out/_records.json) nor the committed fixture (library/scripts/posters/fixtures/).')
process.exit(75)
}
if (!existsSync(join(ASSETS, 'boundaries/world-adm0.geojson'))) {
@ -52,10 +83,13 @@ let failed = false
if (!same) failed = true
}
function nodeSheet(env, file) {
const out = join(P, 'out/posters', file)
const out = join(WORK, 'posters', file)
rmSync(out, { force: true })
const r = spawnSync('node', ['generate.mjs'], { cwd: P, encoding: 'utf8', timeout: 240000,
env: { PATH: process.env.PATH, HOME: process.env.HOME, OFFLINE: '1', VITE_API_BASE_URL: 'http://offline.invalid', POSTER_DATE: DATE, ...env } })
// RECORDS_FILE is SOURCE, the very file the browser arm read. Feeding the two sides from the
// same records is what makes this an equivalence check rather than two independent renders —
// and it is what lets the Node side run at all without a live API having written the cache.
env: { PATH: process.env.PATH, HOME: process.env.HOME, OFFLINE: '1', VITE_API_BASE_URL: 'http://offline.invalid', POSTER_DATE: DATE, RECORDS_FILE: SOURCE, OUT_DIR: WORK, ...env } })
if (r.status !== 0 || !existsSync(out)) {
console.log(` ✗ the Node generator did not write ${file} (exit ${r.status})\n${(r.stderr || r.stdout || '').slice(-400)}`)
failed = true
@ -64,7 +98,11 @@ function nodeSheet(env, file) {
return readFileSync(out, 'utf8')
}
const json = (p) => JSON.parse(readFileSync(p, 'utf8'))
const records = json(CACHE)
const records = json(SOURCE)
// SAY WHICH INPUT WAS MEASURED. The full cache widens coverage to all 175 countries where it
// exists; the fixture is the floor that makes the gate mean something everywhere else. A reader
// comparing two runs has to know which one produced the numbers below.
console.log(`POSTER-BROWSER-EQUIV: measuring with ${SOURCE === CACHE ? 'the offline cache out/_records.json' : 'the committed fixture fixtures/equiv-records-npl-ind.json'} — ${records.length} record(s).`)
const assetsBase = pathToFileURL(ASSETS).href
const results = []

File diff suppressed because one or more lines are too long

View file

@ -34,7 +34,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA = process.env.UPG100_DATA_PATH
? resolve(process.env.UPG100_DATA_PATH)
: resolve(__dirname, '..', '..', '..', 'plugin-designs', 'my-upg-100-list', 'data');
const OUT = join(__dirname, 'out');
// OUT_DIR lets a caller write somewhere private instead of the shared out/. out/posters/ is
// gitignored but SHARED, and other gates read it: check:zone-census counts the sheets there to
// decide whether every selectable zone has a poster. A checker that runs this generator for its own
// comparison therefore has to write elsewhere, or it leaves two real sheets behind and the next gate
// reads them as a half-finished poster build. Measured: exactly that turned check:zone-census from
// NOT MEASURED into FAIL ("28 selectable zone(s) have NO poster") on a fresh checkout.
const OUT = process.env.OUT_DIR ? resolve(process.env.OUT_DIR) : join(__dirname, 'out');
const POSTERS = join(OUT, 'posters');
mkdirSync(POSTERS, { recursive: true });
@ -116,7 +122,11 @@ const FIELDS = 'slug,name,country_code,wagf_region,wagf_block,rop1,population,la
// ── fetch the live pins (the only network call; happens at GENERATE time only) ─
async function loadRecords() {
const cache = join(__dirname, 'out', '_records.json');
// RECORDS_FILE lets a caller name the offline input instead of only the API-written cache. The
// equivalence check needs BOTH sides fed from the same records, and out/_records.json is
// gitignored — so without this the Node side could only ever run where a live API had already
// written that file, which is what left that gate inert on every fresh checkout.
const cache = process.env.RECORDS_FILE || join(__dirname, 'out', '_records.json');
if (process.env.OFFLINE) { try { return readJSON(cache); } catch { /* fall through */ } }
const url = `${API}/api/people-groups/list?fields=${FIELDS}&lang=en`;
const resp = await fetch(url, { headers: { accept: 'application/json' } });

View file

@ -619,7 +619,7 @@
"check:gospel-set-fit": "the set charts FIT the canvas they are given. renderWorldCopies:false puts a hard floor under the zoom at log2(canvasWidth/512) — one world must fill the canvas WIDTH — and on a wide, short figure that floor is too close for the drawing's own height, so the outermost groups fall outside the frame (measured at 1440: 54 outside in ar, 97 in en, which is what proved it was the width and not RTL). Pure arithmetic against the components' own projection, so no browser and no drift. Negative control: the old 0.17 extentFraction still fails 796x249 at 271px of 169, so a pass cannot be vacuous. Also asserts both charts share one footprint, or the same population reads as a different size when a reader switches tabs.",
"check:sentence-slots": "a translated SENTENCE that carries values stays ONE key, through library/composables/useSentence.js. Four properties that all fail silently: a translator can REORDER the clauses (the slots follow the message, not the call); a missing slot leaves its placeholder VISIBLE rather than rendering null or an empty gap, while 0 and '' are real values; slotted markup (a bold count, a dir=ltr island) goes in as a VALUE and is never translated around; and English renders as it did before the migration. Exists because the pattern it replaces built sentences by concatenating translated fragments around untranslated text — 'languages have', 'people.' — which in Arabic gives Arabic words in English clause order: grammatically broken, and worse than leaving it English because it looks translated. Sentinels are private-use (U+E000+n) so nothing in the i18n pipeline may rewrite them, and any sentinel already in a message is stripped before ours are placed — without that a stray swallowed a slot and the arm still passed, which is how this gate found its own weakness.",
"check:people-group-records": "the records half of the print standard selects EXACTLY the set it is asked for, two-sided: a country selector returns that country and not another, an unknown selector returns nothing rather than every group on earth, one fetch per language serves every item, and a failed fetch is not cached so the next click retries.",
"check:poster-browser-equiv": "a map sheet GENERATED IN THE BROWSER (library/packet/posterSheets.js, the print standard: generate at the click) is byte-identical to the one the Node generator writes, given the same records: it runs the real generate.mjs for one UPG-100 zone (the map-only packet sheet, an India zone so the ADM1 path runs) and one country, draws both through the browser feed (the SERVED boundaries, data/zone-sheet.js, the library name table) and compares bytes. The drawing code is one factory (poster-render.js); this guards the FEED, which is what can still drift. NOT MEASURED without the generator's offline records cache.",
"check:poster-browser-equiv": "a map sheet GENERATED IN THE BROWSER (library/packet/posterSheets.js, the print standard: generate at the click) is byte-identical to the one the Node generator writes, given the same records: it runs the real generate.mjs for one UPG-100 zone (the map-only packet sheet, an India zone so the ADM1 path runs) and one country, draws both through the browser feed (the SERVED boundaries, data/zone-sheet.js, the library name table) and compares bytes. The drawing code is one factory (poster-render.js); this guards the FEED, which is what can still drift. Measures on a fresh checkout: it reads the API-written cache (out/_records.json) when present and otherwise the committed fixture (library/scripts/posters/fixtures/equiv-records-npl-ind.json, the 124 public records the two arms consume), feeds BOTH sides from that same file via RECORDS_FILE, and says which input it measured.",
"check:poster-render-pinned": "the map-sheet generator a browser imports at print time (served at assets/lib/poster-render.js, not bundled) is integrity-pinned like every embed, because it executes on partner pages: the pin in library/packet/poster-render.integrity.js equals the sha384 of the source and of the served copy (a stale pin names `bun run pin:poster-render`), a TAMPERED copy is refused and its code never runs (a planted side effect stays unset), and the genuine copy loads and draws.",
"pin:poster-render": "rewrite library/packet/poster-render.integrity.js from library/packet/poster-render.js — run after any change to the generator; check:poster-render-pinned fails until it is.",
"check:board-map-options": "the GEOGRAPHIC map caller must state whiteboard/boundaries EXPLICITLY, never inherit them. library/maps/whiteboard-map.js defaults to whiteboard:true/boundaries:false (it was extracted for the whiteboard case), the inverse of what a real map needs, so a caller relying on defaults renders a blank basemap. check:gospel-whiteboard cannot see this — it asserts chart geometry and imports no component."