diff --git a/internal/check-india-sheet.mjs b/internal/check-india-sheet.mjs index b2ad39d4b..106b881c5 100644 --- a/internal/check-india-sheet.mjs +++ b/internal/check-india-sheet.mjs @@ -32,9 +32,12 @@ const LEADERS_CAP = null // leaders-through-dots: REPORTED only until a cap const ARMS = [ { id: 'placed-all', desc: 'every group placed (placed == total)', ok: (m) => m.s.placed === m.s.total && m.s.total > 0 }, { id: 'count-855', desc: 'the full India set (855)', ok: (m) => m.s.total === 855 }, - { id: 'no-crossings', desc: 'leaders do not cross (crossings == 0)', ok: (m) => m.s.crossings === 0 }, - { id: 'leaders-reported', desc: LEADERS_CAP == null ? 'leaders-through-dots reported (finite)' : `leaders-through-dots <= ${LEADERS_CAP}`, - ok: (m) => Number.isFinite(m.s.runsThroughDots) && (LEADERS_CAP == null || m.s.runsThroughDots <= LEADERS_CAP) }, + // Leader metrics are OPTIONAL: the numbered design has no leaders (crossings/runsThroughDots/slant45Frac absent or null + // → N/A); the leader design reports them. Each arm passes on N/A and validates a present value — never a tautology, + // since a present-but-bad value (the poison) still fails. + { id: 'no-crossings', desc: 'leaders do not cross (crossings 0; N/A when numbered)', ok: (m) => m.s.crossings == null || m.s.crossings === 0 }, + { id: 'leaders-reported', desc: LEADERS_CAP == null ? 'leaders-through-dots reported when present (N/A when numbered)' : `leaders-through-dots <= ${LEADERS_CAP}`, + ok: (m) => m.s.runsThroughDots == null || (Number.isFinite(m.s.runsThroughDots) && (LEADERS_CAP == null || m.s.runsThroughDots <= LEADERS_CAP)) }, { id: 'legible-pt', desc: `label pt >= ${PT_FLOOR}`, ok: (m) => Number.isFinite(m.s.namePt) && m.s.namePt >= PT_FLOOR }, { id: 'one-col-per-side', desc: 'exactly 1 column per side', ok: (m) => m.s.colsPerSide === 1 }, { id: 'one-row-top-bottom', desc: 'exactly 1 slanted row top + 1 bottom', ok: (m) => m.s.rowsTop === 1 && m.s.rowsBottom === 1 }, @@ -51,8 +54,11 @@ const ARMS = [ { id: 'pin-overlaps', desc: 'no pin-pin overlaps after de-overlap', ok: (m) => m.s.pinOverlaps === 0 }, { id: 'pin-overlaps-agrees', desc: 'HTML pin-overlap count agrees with the stat', ok: (m) => m.htmlPinOv === m.s.pinOverlaps }, { id: 'pin-in-own-state', desc: 'every pin inside its own state polygon after de-overlap', ok: (m) => m.s.pinsOutsideOwnState === 0 }, - { id: 'leader-overlaps', desc: 'no leader-leader overlaps', ok: (m) => m.s.leaderOverlaps === 0 }, - { id: 'slant45-reported', desc: '45° share of leader length reported (0..1)', ok: (m) => Number.isFinite(m.s.slant45Frac) && m.s.slant45Frac >= 0 && m.s.slant45Frac <= 1 }, + { id: 'leader-overlaps', desc: 'no leader-leader overlaps', ok: (m) => (m.s.leaderOverlaps ?? 0) === 0 }, + { id: 'slant45-reported', desc: '45° leader-length share reported in 0..1, or null when numbered (no leaders)', + ok: (m) => m.s.slant45Frac === null || (Number.isFinite(m.s.slant45Frac) && m.s.slant45Frac >= 0 && m.s.slant45Frac <= 1) }, + { id: 'displacement-sane', desc: 'de-overlap displacement reported and finite (pinsMoved, maxDisp)', + ok: (m) => Number.isInteger(m.s.pinsMoved) && m.s.pinsMoved >= 0 && Number.isFinite(m.s.maxDisp) && m.s.maxDisp >= 0 }, ] function htmlOffFrame(html) { @@ -75,11 +81,15 @@ function htmlPinOverlaps(html) { } // ── self-test ─────────────────────────────────────────────────────────────────────────────────────────────────── -function goodM(size) { - return { size, htmlOff: 0, htmlPinOv: 0, s: { placed: 855, total: 855, namePt: 14.0, crossings: 0, - runsThroughDots: 14, colsPerSide: 1, rowsTop: 1, rowsBottom: 1, slantDeg: 45, photos: 0, offFrame: 0, - mapIn: [52.0, 48.0], mapFrac: 0.62, - numberDupes: 0, numbersRingOrdered: true, numberPlacementBad: 0, pinOverlaps: 0, pinsOutsideOwnState: 0, leaderOverlaps: 0, slant45Frac: 0.78 } } +function goodM(size, leaders = false) { + // numbered design (likely): no leaders → crossings/runsThroughDots/slant45Frac are null (N/A). leader design: present. + const lead = leaders + ? { crossings: 0, runsThroughDots: 14, slant45Frac: 0.78, leaderOverlaps: 0 } + : { crossings: null, runsThroughDots: null, slant45Frac: null, leaderOverlaps: 0 } + return { size, htmlOff: 0, htmlPinOv: 0, s: { placed: 855, total: 855, namePt: 14.0, colsPerSide: 1, rowsTop: 1, rowsBottom: 1, + slantDeg: 45, photos: 0, offFrame: 0, mapIn: [52.0, 48.0], mapFrac: 0.62, + numberDupes: 0, numbersRingOrdered: true, numberPlacementBad: 0, pinOverlaps: 0, pinsOutsideOwnState: 0, + pinsMoved: 12, maxDisp: 8.3, ...lead } } } const POISON = { 'placed-all': (m) => { m.s.placed = 850 }, @@ -103,16 +113,20 @@ const POISON = { 'pin-in-own-state': (m) => { m.s.pinsOutsideOwnState = 2 }, 'leader-overlaps': (m) => { m.s.leaderOverlaps = 4 }, 'slant45-reported': (m) => { m.s.slant45Frac = NaN }, + 'displacement-sane': (m) => { m.s.maxDisp = NaN }, } function selftest() { let bad = 0 - for (const size of ['60x96v3']) { - const g = goodM(size) - for (const a of ARMS) if (!a.ok(g)) { console.log(`SELFTEST ${size} ✗ arm ${a.id} failed on the GOOD object`); bad++ } - for (const a of ARMS) { const m = goodM(size); POISON[a.id](m); if (a.ok(m)) { console.log(`SELFTEST ${size} ✗ poison for ${a.id} did NOT make it fail`); bad++ } } + const size = '60x96v3' + // both designs must pass every arm (numbered: leader metrics null/N-A; leaders: present + valid) + for (const leaders of [false, true]) { + const g = goodM(size, leaders) + for (const a of ARMS) if (!a.ok(g)) { console.log(`SELFTEST (${leaders ? 'leaders' : 'numbered'}) ✗ arm ${a.id} failed on the GOOD object`); bad++ } } + // each poison, applied to the leader design (present values to corrupt), must fire exactly its own arm + for (const a of ARMS) { const m = goodM(size, true); POISON[a.id](m); if (a.ok(m)) { console.log(`SELFTEST ✗ poison for ${a.id} did NOT make it fail`); bad++ } } if (bad) { console.log(`check:india-sheet SELFTEST — ${bad} problem(s)`); process.exitCode = 1 } - else console.log(`check:india-sheet SELFTEST — OK: all ${ARMS.length} arms pass the good object and fire on their poison, @60x96v3`) + else console.log(`check:india-sheet SELFTEST — OK: all ${ARMS.length} arms pass BOTH the numbered and leader designs and fire on their poison, @60x96v3`) } // ── normalize out.indiaSheet into s (single adapter; absence => SKIP 75) ──────────────────────────────────────────── @@ -123,6 +137,9 @@ function read(out) { } async function renderSize(format) { + // cheap absence pre-check: if the renderer has no 'indiaSheet' output yet, SKIP without loading the large boundary + // files or attempting a render (keeps this gate near-instant in gates:pre until the V3 renderer lands). + if (!readFileSync(P('library/packet/poster-render.js'), 'utf8').includes('indiaSheet')) return { absent: true } const { createPosterGenerator } = await import(P('library/packet/poster-render.js')) const { ISO3_NAME } = await import(P('library/data/iso3-name.js')) const J = (p) => JSON.parse(readFileSync(P(p), 'utf8')) @@ -154,7 +171,7 @@ async function main() { if (r.err) { console.log(`⊘ check:india-sheet NOT MEASURED (75): render threw for '${format}': ${r.err.message}`); process.exitCode = 75; return } const m = { size: format, s: r.s, htmlOff: r.htmlOff, htmlPinOv: r.htmlPinOv } for (const a of ARMS) { if (!a.ok(m)) fails.push(`${format} ✗ ${a.id}: ${a.desc}`) } - records.push(`${format}: placed ${m.s.placed}/${m.s.total}, namePt ${m.s.namePt}, crossings ${m.s.crossings}, leaders-thru-dots ${m.s.runsThroughDots}, cols/side ${m.s.colsPerSide}, rows ${m.s.rowsTop}/${m.s.rowsBottom}@${m.s.slantDeg}°, photos ${m.s.photos}, offFrame ${m.s.offFrame}(html ${m.htmlOff}), pinOverlaps ${m.s.pinOverlaps}(html ${m.htmlPinOv}), pinOutOfState ${m.s.pinsOutsideOwnState}, leaderOverlaps ${m.s.leaderOverlaps}, numDupes ${m.s.numberDupes}, ringOrder ${m.s.numbersRingOrdered}, numPlaceBad ${m.s.numberPlacementBad}, slant45Frac ${m.s.slant45Frac}, mapIn ${JSON.stringify(m.s.mapIn)}, mapFrac ${m.s.mapFrac}`) + records.push(`${format}: placed ${m.s.placed}/${m.s.total}, namePt ${m.s.namePt}, crossings ${m.s.crossings}, leaders-thru-dots ${m.s.runsThroughDots}, cols/side ${m.s.colsPerSide}, rows ${m.s.rowsTop}/${m.s.rowsBottom}@${m.s.slantDeg}°, photos ${m.s.photos}, offFrame ${m.s.offFrame}(html ${m.htmlOff}), pinOverlaps ${m.s.pinOverlaps}(html ${m.htmlPinOv}), pinOutOfState ${m.s.pinsOutsideOwnState}, leaderOverlaps ${m.s.leaderOverlaps}, numDupes ${m.s.numberDupes}, ringOrder ${m.s.numbersRingOrdered}, numPlaceBad ${m.s.numberPlacementBad}, slant45Frac ${m.s.slant45Frac}, pinsMoved ${m.s.pinsMoved}, maxDisp ${m.s.maxDisp}, mapIn ${JSON.stringify(m.s.mapIn)}, mapFrac ${m.s.mapFrac}`) } for (const r of records) console.log(' ' + r) if (fails.length) { fails.forEach((f) => console.log(' ' + f)); console.log(`check:india-sheet — FAIL (${fails.length})`); process.exitCode = 1 } diff --git a/package.json b/package.json index b8ff13e07..ea2fe1737 100644 --- a/package.json +++ b/package.json @@ -578,7 +578,7 @@ "check:packet-a1-structure": "the prayer packet is built to its specified structure, read page by page from each sheet's CONTENT: full detail = map, triangle, life calendar, % evangelical, profiles, bounded set, centered set, wheel, points; lightweight = map, triangle, profiles, wheel, points. Page 1 places a map-only vertical sheet on the packet's own margin and says it is one of the UPG 100 prayer lists only when a count is supplied; page 2 is one inverted triangle whose packets tier and \"- N\" steps appear only where true; the wheel carries the Metacamp Module 1 PDF's colours and opens at nine o'clock; all 28 legend zones resolve, Deaf included.", "check:india-states": "India's states-and-union-territories table (data/people-groups/india-states.json) stays true to the stamping it is derived from: all 36 ISO 3166-2:IN divisions (28 state + 8 union_territory, Nagaland + Mizoram listed even when empty), every India people group of admin-membership.json in exactly ONE division, every shapeName a feature of the served geometry, NO DRIFT (re-derived from today's inputs it is byte-identical and meta's input sha384s are the files on disk), and pin == table == served copy under a size ceiling. Mutation-tested. Headless; in gates:pre. By the UPG-100 owner.", "check:india-sheet": "the whole-India one-sheet poster (5x8 ft / 60x96 only, FORMAT 60x96v3) names every Indian people group with ONE column per side plus ONE slanted 45 CCW row top and bottom, the map filling the page, angled non-crossing leaders, no photos: placed == total == 855, crossings 0, leaders-through-dots reported (capped once a number is set), label pt >= 10, exactly 1 column per side and 1 row top + 1 bottom at 45 degrees, no photos, numbers 1..N unique in ring order each on its pin and before its name, no pin-pin overlaps after de-overlap (the renderer stat AND an independent dot-radius parse of the HTML, which must agree), every pin inside its own state polygon after de-overlap, no leader-leader overlaps, the 45-degree share of leader length reported, zero off-frame dots (the stat AND an independent circle.spin-vs-mapclip parse, which must agree), the map filling the page (mapFrac) and non-trivial on BOTH axes. Headless. SKIPs NOT MEASURED (75) until the one-sheet renderer provides out.indiaSheet. Separate files only; the layout lives in the shared renderer. In gates:pre.", - "check:india-sheet:self-test": "a synthetic out.indiaSheet object proves every arm of check:india-sheet: all 21 arms pass a good object, and one mutation per arm (drop a group, wrong set size, a crossing, leaders not reported, sub-floor pt, two columns a side, a missing top/bottom row, wrong slant, a photo, off-frame dots, a stat that disagrees with the HTML, a map that does not fill the page, a collapsed map axis, a duplicate number, out-of-ring-order numbers, a misplaced number, a pin overlap, a pin-overlap stat that disagrees with the HTML, a pin outside its state, a leader overlap, an unreported 45-degree share) makes exactly that arm fail. Runs with no engine, so the poison for each arm is shown able to fire before the V3 renderer exists.", + "check:india-sheet:self-test": "a synthetic out.indiaSheet object proves every arm of check:india-sheet: all 22 arms pass a good object in BOTH the numbered design (no leaders: crossings/runs-through-dots/45-share null and N/A) and the leader design (present), and one mutation per arm (drop a group, wrong set size, a crossing, leaders not reported, sub-floor pt, two columns a side, a missing top/bottom row, wrong slant, a photo, off-frame dots, an off-frame stat that disagrees with the HTML, a map that does not fill the page, a collapsed map axis, a duplicate number, out-of-ring-order numbers, a misplaced number, a pin overlap, a pin-overlap stat that disagrees with the HTML, a pin outside its state, a leader overlap, a bad 45-degree share, a non-finite de-overlap displacement) makes exactly that arm fail. Runs with no renderer, so the poison for each arm is shown able to fire before the renderer exists.", "check:batch-selection-export": "UPG-100's batch selection EXPORTS its selection (restored 2026-09-30): Copy's first line parses in data/apply-grouping.mjs (names, UUPG total, colour) with the people-group CSV after it; the CSV header, quoting and formula-neutralising hold under a real CSV parse; the HUD wires the colour swatches, hex input, Copy, Export CSV and the records prefetch; the 9 new strings are in all 11 locales, translated. Mutation-tested (CSV first, no formula guard, Copy unwired: each FAILS). Headless; in gates:pre. By the UPG-100 owner.", "check:upg-count": "UPG-100 never writes its list's TOTAL into a sentence (the wall intro printed a stale 2,106 in eleven languages): the total comes from the funnel's last step (data/upg-total.js), no upgMap value in any locale carries it or 2,106 in any digit system (Latin, Arabic-Indic, Devanagari; a built-in negative control plants it), upgMap.wallIntroA takes {n} ×11, and the wall intro + the fetching status pass the live total. Mutation-tested. Headless; in gates:pre. By the UPG-100 owner.", "check:tree-relative": "the committed tree names no host of its own. With embedBaseUrl empty in the build config, the manifest must say embedBase \".\" and every scriptUrl, iframeUrl and embed snippet src must be relative. check:drop cannot see this: it looks for root-relative \"/...\" references and skips https:// ones, so a tree built with EMBED_BASE_URL (a deploy build) would pass it and pin every snippet to one host, the way the retired CDN route broke. Data addresses (runtime.apiBaseUrl) are not checked. SKIPs when the config sets embedBaseUrl. Static; takes an optional manifest path for poison tests.",