Packet chart arithmetic, and the three ways it was quietly wrong
These numbers print. A packet is handed to a reader on paper: no hover, no tooltip, no correction, no redeploy. So the arithmetic lives in its own module and is asserted against the shipped data rather than a fixture. DOXA IS NOT A SLICE. Of its 2,107 groups, 2,082 are unreached and 1,745 are frontier, so 25 are not unreached at all and 362 are not frontier. That is one group in six. A chart drawing DOXA as a fourth band nested inside frontier misstates 17.2% of the list with a clean shape and no error of any kind, which is why steps and overlays are different props and an overlay reports whether it is a slice rather than being assumed to be one. upg100 asked for the numbers to reach the caption as text too, because a printed band nobody can hover over explains nothing. Three bugs I wrote, each caught by an assertion rather than by review: The publisher warning sat in the wrong function. I put "Joshua Project counts 16,426 where IMB counts 12,379" into the nesting error, and the test proved the claim false: 16,426 on top of 12,379 is perfectly monotonic. The two are not the same kind of count, and no comparison of magnitudes can see that, so it needed a separate check reading what the numbers are OF. An error message naming a cause its own code cannot detect sends the next reader to look for the wrong thing. A percent read as a fraction filled 3,189 cells of 5,200 instead of 32. My heuristic was "above 1 means percent", and pctEvangelical is 0.6133, which is a valid fraction and a valid percent with nothing in the value to separate them. The refusal I added catches a percent of 1 or more and provably cannot catch ours, and every share this packet prints is below 1% because the subject is unreached peoples. So the guard is not the protection, the name is: fractionFromPercent states the unit in the source line. Both halves are asserted, including the half that does not work, because a guard believed to cover more than it does is the worse failure. And the rounding flags were untested until the test used values that could reach them. 0.6133% of 5,200 is 31.9 cells; the reader sees 32 and cannot see 31.9, so the exact value goes in the caption and a share too small to fill one cell is flagged rather than drawn as one.
This commit is contained in:
parent
53d3862627
commit
4e2ea5ed2c
3 changed files with 308 additions and 0 deletions
115
internal/check-packet-charts.mjs
Normal file
115
internal/check-packet-charts.mjs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-packet-charts — the arithmetic printed on a UPG-100 packet page. Headless, no mount.
|
||||
*
|
||||
* ★ WHY THIS ONE MATTERS MORE THAN A SCREEN CHART. These figures print. A packet is handed to a
|
||||
* reader on paper: no hover, no tooltip, no correction, no redeploy. Every number the triangle and
|
||||
* the two calendars show passes through packetCharts.js, so it is asserted here against the SHIPPED
|
||||
* data rather than a fixture — the gap arms in check-list-selection taught that a fixture proves the
|
||||
* module behaves and says nothing about what we print.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
assertNested, assertOnePublisher, fractionFromPercent, overlayPlacement, overlayCaption, cellCount, shareCells, peoplePerCell, shareOfLists,
|
||||
} from '../library/components/charts/packetCharts.js'
|
||||
|
||||
const DATA = join(dirname(fileURLToPath(import.meta.url)), '..', 'data', 'people-groups')
|
||||
const readJson = (f) => JSON.parse(readFileSync(join(DATA, f), 'utf8'))
|
||||
let fails = 0
|
||||
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
|
||||
|
||||
const funnel = readJson('vision-funnel.json')
|
||||
const byId = Object.fromEntries(funnel.steps.map((s) => [s.id, s]))
|
||||
const STEPS = ['all', 'unreached', 'frontier'].map((id) => ({ id, ...byId[id] }))
|
||||
const DOXA = { id: 'doxa', ...byId.doxa, within: { unreached: byId.doxa.unreached, frontier: byId.doxa.frontier } }
|
||||
|
||||
// ── the steps nest, and a mixed publisher is refused ────────────────────────────────────────────
|
||||
ok(assertNested(STEPS) === STEPS, 'the shipped steps nest: all 12,379 > unreached 7,341 > frontier 4,754')
|
||||
let threw = null
|
||||
try { assertNested([{ id: 'frontier', groups: 4754 }, { id: 'unreached', groups: 7341 }]) } catch (e) { threw = e.message }
|
||||
ok(!!threw && /do not nest/.test(threw) && /4754|7341/.test(threw),
|
||||
'a step LARGER than the one above it throws, naming both counts')
|
||||
|
||||
// ★ THE PUBLISHER HAZARD NEEDS ITS OWN DETECTOR, and writing this test is what proved it.
|
||||
// I first put the Joshua-Project warning in assertNested's message. Then the assertion failed:
|
||||
// 16,426 stacked on 12,379 is perfectly monotonic, so nesting arithmetic accepts it. The two are
|
||||
// not the same KIND of count — peoples-by-country vs peoples — and no comparison of magnitudes can
|
||||
// see that. An error message naming a cause its own code cannot detect is worse than none.
|
||||
let mixed = null
|
||||
try {
|
||||
assertOnePublisher([{ id: 'all', groups: 16426, source: 'joshua-project' }, { id: 'unreached', groups: 7341, source: 'imb' }])
|
||||
} catch (e) { mixed = e.message }
|
||||
ok(!!mixed && /mix publishers/.test(mixed) && /joshua-project/.test(mixed) && /imb/.test(mixed),
|
||||
'mixing Joshua Project and IMB steps throws and NAMES both, which nesting arithmetic cannot catch')
|
||||
ok(assertNested([{ id: 'all', groups: 16426 }, ...STEPS]).length === 4,
|
||||
'CONTROL: the same mixed pair passes NESTING — proving the two checks measure different things')
|
||||
|
||||
// ── DOXA IS NOT A SLICE, and this is the assertion upg100 asked to be un-simplifiable ───────────
|
||||
// Measured from the shipped file, not quoted: of 2,107 DOXA groups, 2,082 are unreached and 1,745
|
||||
// are frontier. So 25 are not unreached at all and 362 — 17.2%, ONE GROUP IN SIX — are not frontier.
|
||||
// A chart drawing DOXA as a fourth nested band inside frontier misstates one group in six, with a
|
||||
// clean shape and no error of any kind. That is the whole reason overlays exist; if anyone later
|
||||
// "simplifies" this back to four slices, these three assertions are what stops them.
|
||||
const place = overlayPlacement(DOXA, STEPS)
|
||||
ok(place.isSlice === false, 'DOXA is NOT a slice of any single step, so it must never be drawn as one')
|
||||
ok(DOXA.groups - DOXA.within.frontier === 362 && Math.round(1000 * (DOXA.groups - DOXA.within.frontier) / DOXA.groups) / 10 === 17.2,
|
||||
'362 of its 2,107 groups — 17.2%, one in six — are NOT frontier, which is what a nested chart would misstate')
|
||||
ok(DOXA.groups - DOXA.within.unreached === 25, 'and 25 are not unreached at all, so it is not a slice of unreached either')
|
||||
ok(place.spans.join(',') === 'unreached,frontier', 'it spans unreached AND frontier, in step order, so a legend can say so')
|
||||
|
||||
// ── the caption carries the parts as TEXT (upg100: a printed page has no hover) ─────────────────
|
||||
const cap = overlayCaption(DOXA, STEPS)
|
||||
ok(cap.total === 2107 && cap.parts.length === 2, 'the caption exposes the within numbers, not only the geometry')
|
||||
ok(cap.parts.map((p) => `${p.stepId}:${p.groups}`).join(' ') === 'unreached:2082 frontier:1745',
|
||||
'as inkable parts in step order — an unexplained band on paper tells a reader nothing')
|
||||
ok(cap.isSlice === false, 'and the caption says it is not a slice, so the component need not decide that itself')
|
||||
|
||||
// ── the calendars ───────────────────────────────────────────────────────────────────────────────
|
||||
ok(cellCount(100, 52) === 5200, 'a 100-year life in weeks is 5,200 cells')
|
||||
const share = readJson('christian-share.json')
|
||||
const pct = Number(share.pctEvangelical)
|
||||
ok(Number.isFinite(pct) && pct > 0, `the shipped evangelical share is a usable number (${pct})`)
|
||||
// pctEvangelical is a PERCENT (0.6133 means 0.6133%), so the conversion happens HERE, at the only
|
||||
// place that can see which the source meant. See the refusal in shareCells.
|
||||
const cells = shareCells(100, 52, fractionFromPercent(pct))
|
||||
ok(cells.filled === 32 && Math.abs(cells.exact - 31.9) < 0.15,
|
||||
`${cells.filled} cells of 5,200 are filled, from an exact ${cells.exact.toFixed(2)} — the drawn integer is a ROUNDING`)
|
||||
ok(cells.exact !== cells.filled,
|
||||
'exact and filled differ, which is why the caption must print the unrounded value: 32 reads as precise when it is not')
|
||||
const tiny = shareCells(100, 52, 0.00015) // 0.78 of a cell
|
||||
ok(tiny.exact < 1 && tiny.filled === 1 && tiny.roundedUpFromBelowOne === true,
|
||||
'a share too small to fill one cell is FLAGGED — drawing it as one cell says "at least one" where the data said "almost none"')
|
||||
const vanishing = shareCells(100, 52, 0.00001) // 0.05 of a cell
|
||||
ok(vanishing.filled === 0 && vanishing.roundedToZero === true,
|
||||
'and a share that rounds away to nothing is flagged too, so "no cells" is never silently read as "none"')
|
||||
|
||||
// ★ THE PERCENT/FRACTION CONFUSION, AND THE HONEST LIMIT OF GUARDING IT. I wrote this bug: a
|
||||
// `share > 1 ? share/100 : share` heuristic filled 3,189 cells of 5,200 instead of 32, a
|
||||
// hundredfold error whose only symptom is a darker chart. The >1 refusal catches a percent of 1 or
|
||||
// more — and CANNOT catch ours, because 0.6133 is a valid fraction AND a valid percent. Every share
|
||||
// this packet prints is below 1%, since the subject is unreached peoples, so the guard is provably
|
||||
// useless for the real case. It is the NAME that carries it. Both halves asserted, including the
|
||||
// half that does not work, because a guard believed to cover more than it does is the worse failure.
|
||||
let pctErr = null
|
||||
try { shareCells(100, 52, 61.33) } catch (e) { pctErr = e.message }
|
||||
ok(!!pctErr && /FRACTION/.test(pctErr), 'a percent of 1 or more is refused outright')
|
||||
ok(shareCells(100, 52, pct).filled === 3189,
|
||||
'but OUR percent passes the guard silently and fills 3,189 cells — the guard cannot see this case, so the call site must name the unit')
|
||||
ok(Math.abs(fractionFromPercent(pct) - 0.006133) < 1e-12 && shareCells(100, 52, fractionFromPercent(pct)).filled === 32,
|
||||
'fractionFromPercent states the unit in the source line, which is the only thing that actually prevents it')
|
||||
|
||||
const doxaPop = byId.doxa.population
|
||||
const per = peoplePerCell(doxaPop, 100, 52)
|
||||
ok(Math.round(per / 100) * 100 === 40200,
|
||||
`each cell stands for about ${Math.round(per).toLocaleString('en')} people — the figure the brief asks to be stated`)
|
||||
|
||||
// ── "1 of K lists", where K is unsettled and this asserts nothing about it ──────────────────────
|
||||
ok(shareOfLists(1, 27).percent.toFixed(2) === '3.70' && shareOfLists(1, 29).percent.toFixed(2) === '3.45',
|
||||
'K is a prop: 27 and 29 both compute, and this check takes no side on DECISION-SHEET row 41')
|
||||
ok(shareOfLists(1, 0) === null && shareOfLists(1, undefined) === null,
|
||||
'and a missing K returns null rather than printing Infinity on a page nobody can recall')
|
||||
|
||||
console.log(fails ? `\nPACKET-CHARTS: ${fails} FAIL(s)` : '\nPACKET-CHARTS: the printed numbers add up, and DOXA is not drawn as a slice')
|
||||
process.exit(fails ? 1 : 0)
|
||||
192
library/components/charts/packetCharts.js
Normal file
192
library/components/charts/packetCharts.js
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* packetCharts.js — the arithmetic behind the packet's inverted triangle and its two calendars.
|
||||
*
|
||||
* Split out of the components so the numbers can be tested without mounting anything, the same way
|
||||
* listSelection.js sits beside ListSelectorLegend.vue. Every figure a reader sees on a printed
|
||||
* packet passes through here, and a printed page cannot be corrected after it is handed out.
|
||||
*/
|
||||
|
||||
/**
|
||||
* ★ THE STEPS MUST NEST, AND THIS REFUSES THEM WHEN THEY DO NOT.
|
||||
*
|
||||
* The triangle's whole rhetoric is containment: of all peoples, these are unreached; of those,
|
||||
* these are frontier. That reading is only honest while each step is a SUBSET of the one above,
|
||||
* and the research names a live way to break it — Joshua Project counts 16,426 peoples-by-country
|
||||
* where IMB counts 12,379, and putting JP's number on top of IMB's steps produces a funnel whose
|
||||
* first step does not contain its second (UPG100-RESEARCH-FINDINGS.md §3).
|
||||
*
|
||||
* That failure has no visual symptom. The triangle still draws, still narrows, still looks correct;
|
||||
* only the claim is wrong. So this throws rather than clamping or sorting: given a shape someone
|
||||
* will print and hand to a reader, a loud refusal beats a plausible picture.
|
||||
*/
|
||||
export function assertNested(steps) {
|
||||
const s = steps || []
|
||||
for (let i = 1; i < s.length; i++) {
|
||||
const prev = s[i - 1]
|
||||
const cur = s[i]
|
||||
if (!(cur.groups <= prev.groups)) {
|
||||
throw new Error(
|
||||
`funnel steps do not nest: "${cur.id}" has ${cur.groups} groups but sits inside "${prev.id}" with ${prev.groups}. ` +
|
||||
'A funnel claims containment, so a step can never be larger than the one above it.')
|
||||
}
|
||||
if (cur.population != null && prev.population != null && !(cur.population <= prev.population)) {
|
||||
throw new Error(
|
||||
`funnel populations do not nest: "${cur.id}" has ${cur.population} people inside "${prev.id}" with ${prev.population}.`)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ ONE PUBLISHER PER FUNNEL, and this is a SEPARATE check from nesting because the numbers alone
|
||||
* cannot catch it.
|
||||
*
|
||||
* The research names mixing publishers as the live hazard: Joshua Project counts 16,426
|
||||
* peoples-by-country where IMB counts 12,379 (UPG100-RESEARCH-FINDINGS.md §3). I first wrote that
|
||||
* warning into assertNested's error message and the test proved the claim false — 16,426 on top of
|
||||
* 12,379 IS monotonic, so nesting arithmetic accepts it happily. The two counts are not the same
|
||||
* KIND of thing (peoples-by-country vs peoples), and no comparison of magnitudes can tell you that.
|
||||
*
|
||||
* So the detector has to read what the numbers are OF, not what they are. Each step declares its
|
||||
* source; if they disagree, the funnel is comparing two universes and is refused. A message that
|
||||
* names a cause its own code cannot detect is worse than no message: it sends the next reader to
|
||||
* look for the wrong thing.
|
||||
*/
|
||||
export function assertOnePublisher(steps) {
|
||||
const named = (steps || []).filter((s) => s && s.source)
|
||||
const sources = [...new Set(named.map((s) => String(s.source)))]
|
||||
if (sources.length > 1) {
|
||||
throw new Error(
|
||||
`funnel steps mix publishers: ${sources.join(' and ')}. A funnel claims containment, and two ` +
|
||||
'publishers count different things — Joshua Project counts peoples-by-country, IMB counts peoples — ' +
|
||||
'so one cannot be a subset of the other however the totals happen to compare. Use one source for every step.')
|
||||
}
|
||||
return steps || []
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ AN OVERLAY IS NOT A STEP, and conflating them is the error this file exists to prevent.
|
||||
*
|
||||
* DOXA is not a subset of frontier: of its 2,107 groups, 1,745 are frontier and 2,082 unreached
|
||||
* (vision-funnel.json records both). So drawing DOXA as a fourth nested band asserts a containment
|
||||
* that is false. An overlay therefore carries `within` — how much of it falls inside each step —
|
||||
* and is drawn crossing the steps rather than under them.
|
||||
*
|
||||
* Returns the overlay's position WITHOUT ever implying containment: the caller gets the parts, and
|
||||
* a `spans` list naming every step the overlay reaches into, so a legend can say so in words.
|
||||
*/
|
||||
export function overlayPlacement(overlay, steps) {
|
||||
const within = (overlay && overlay.within) || {}
|
||||
const ids = (steps || []).map((s) => s.id)
|
||||
const spans = ids.filter((id) => Number(within[id]) > 0)
|
||||
const inside = spans.reduce((n, id) => Math.max(n, Number(within[id]) || 0), 0)
|
||||
const total = Number(overlay && overlay.groups) || 0
|
||||
return {
|
||||
id: overlay && overlay.id,
|
||||
groups: total,
|
||||
spans, // every step it reaches into, in step order
|
||||
outside: Math.max(0, total - inside), // the part no step contains
|
||||
// True only when the overlay falls entirely inside ONE step. Anything else must not be drawn
|
||||
// as a slice, and the component asks this rather than deciding for itself.
|
||||
isSlice: spans.length === 1 && inside === total,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlay's numbers as TEXT, because a printed page has no hover.
|
||||
*
|
||||
* upg100's constraint, and it is the one that decides whether the overlay is honest or merely
|
||||
* pretty: an unexplained band crossing the triangle tells a reader nothing. The parts have to be
|
||||
* inked — "2,107 in this network — 2,082 unreached, 1,745 frontier" — or the overlay is decoration.
|
||||
* Returns the parts in step order with the labels the caller supplies; it never writes prose itself,
|
||||
* because the packet prints in every locale we ship.
|
||||
*/
|
||||
export function overlayCaption(overlay, steps) {
|
||||
const place = overlayPlacement(overlay, steps)
|
||||
const within = (overlay && overlay.within) || {}
|
||||
return {
|
||||
id: place.id,
|
||||
total: place.groups,
|
||||
parts: (steps || [])
|
||||
.filter((s) => Number(within[s.id]) > 0)
|
||||
.map((s) => ({ stepId: s.id, groups: Number(within[s.id]) })),
|
||||
// The part of the overlay that NO step contains, which is the whole reason it is not a slice.
|
||||
outside: place.outside,
|
||||
isSlice: place.isSlice,
|
||||
}
|
||||
}
|
||||
|
||||
/** Cells in a calendar grid. 100 years x 52 weeks is 5,200. */
|
||||
export function cellCount(rows, columns) {
|
||||
return (Number(rows) || 0) * (Number(columns) || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many people one cell stands for, and how many cells a share fills.
|
||||
*
|
||||
* ★ THE ROUNDED CELL COUNT IS NOT THE MEASUREMENT, so both come back. At 0.6133% of 5,200 the
|
||||
* honest number is 31.9 cells; a reader sees 32 squares and there is no way to draw 31.9. The
|
||||
* caption carries `exact` so the drawn integer never reads as precise, and `roundedUpFromBelowOne`
|
||||
* flags the one case where rounding changes the KIND of statement being made: a share small enough
|
||||
* to fill less than a single cell, drawn as one cell, says "at least one" where the data said
|
||||
* "almost none".
|
||||
*/
|
||||
export function shareCells(rows, columns, share) {
|
||||
const total = cellCount(rows, columns)
|
||||
const s = Number(share) || 0
|
||||
// ★ A FRACTION, NEVER A PERCENT, and it refuses rather than guessing. The shipped field is named
|
||||
// pctEvangelical and holds 0.6133 meaning 0.6133% — so the same numeral is a plausible FRACTION
|
||||
// (61.33%) and a plausible PERCENT (0.61%), and nothing in the value says which. I wrote a
|
||||
// `share > 1 ? share/100 : share` heuristic here and it silently produced 3,189 filled cells
|
||||
// instead of 32: a hundredfold error, on a printed page, with no symptom but a darker chart.
|
||||
// No heuristic can tell these apart, so the caller converts and this refuses the impossible half.
|
||||
if (s > 1) {
|
||||
throw new Error(
|
||||
`share must be a FRACTION, got ${s}. A share above 1 would be more than 100%. ` +
|
||||
'If this came from a field named pct… it is a percent — use fractionFromPercent().')
|
||||
}
|
||||
const exact = total * s
|
||||
const filled = Math.round(exact)
|
||||
return {
|
||||
total,
|
||||
exact,
|
||||
filled,
|
||||
roundedUpFromBelowOne: exact > 0 && exact < 1 && filled >= 1,
|
||||
roundedToZero: exact > 0 && filled === 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ THE UNIT LIVES IN THE FUNCTION NAME, because for our actual numbers no check can recover it.
|
||||
*
|
||||
* The refusal above catches a percent of 1 or more. It CANNOT catch ours: pctEvangelical is 0.6133,
|
||||
* which is a perfectly valid fraction meaning 61.33%, and a perfectly valid percent meaning 0.61%.
|
||||
* Nothing in the value distinguishes them, so every share below 1% — which is every share this
|
||||
* packet will ever print, since the whole point is unreached peoples — passes any guard silently
|
||||
* and draws a chart that is a hundred times too dark.
|
||||
*
|
||||
* So the guard is not the protection; the NAME is. Call sites read
|
||||
* `shareCells(100, 52, fractionFromPercent(data.pctEvangelical))`, where the unit is stated twice
|
||||
* and a mismatch is visible in the source line rather than in a printed packet.
|
||||
*/
|
||||
export function fractionFromPercent(percent) {
|
||||
return (Number(percent) || 0) / 100
|
||||
}
|
||||
|
||||
/** People per cell, for the caption the brief asks for explicitly. */
|
||||
export function peoplePerCell(population, rows, columns) {
|
||||
const total = cellCount(rows, columns)
|
||||
if (!total) return 0
|
||||
return (Number(population) || 0) / total
|
||||
}
|
||||
|
||||
/**
|
||||
* "1 of K lists = N%". K is unsettled (27 or 29, DECISION-SHEET row 41), so it is a prop and this
|
||||
* asserts nothing about its value — it only refuses a division that would print Infinity or NaN on
|
||||
* a page nobody can recall.
|
||||
*/
|
||||
export function shareOfLists(index, total) {
|
||||
const k = Number(total) || 0
|
||||
if (k <= 0) return null
|
||||
return { index: Number(index) || 0, total: k, percent: 100 / k }
|
||||
}
|
||||
|
|
@ -179,6 +179,7 @@
|
|||
"check:optional-t-equivalence": "node internal/check-optional-t-equivalence.mjs",
|
||||
"check:package-json-unique-keys": "node internal/check-package-json-unique-keys.mjs",
|
||||
"check:no-repo-pb-hooks": "node internal/check-no-repo-pb-hooks.mjs",
|
||||
"check:packet-charts": "node internal/check-packet-charts.mjs",
|
||||
"check:packet-browser": "node internal/check-packet-browser.mjs",
|
||||
"check:packet-fixture": "node internal/generate-packet-fixture.mjs --check",
|
||||
"check:packet-render": "node internal/check-packet-render.mjs",
|
||||
|
|
|
|||
Loading…
Reference in a new issue