docs/methodology/cohort-sanity-release-gate.md
Operational procedure for the resilience cohort-sanity audit. This is a release gate, not a merge gate. The audit tells release review what to look at before publishing a ranking; it does not block a PR from merging.
A composite resilience score can be mathematically correct yet produce rankings that contradict first-principles domain judgment — usually because ONE input has a coverage gap, a saturated goalpost, or a denominator that's structurally wrong for one sub-class of entities (re-export hubs, single-sector states, SWF-parked-reserve designs).
Cohort-sanity is the test the codebase can't run on its own. It says: "given these cohorts, does the ranking match the construct each cohort is defined to probe?" Not "does country A rank above country B" — see the anti-pattern section below.
Relevant public background:
cohort-ranking-sanity-surfaces-hidden-data-gaps — the general
diagnostic protocol (data bug / methodology bug / construct limitation
/ value judgment), including the anti-pattern note on rank-targeted
acceptance criteria.tests/resilience-construct-invariants.test.mts — formula-level
invariants with synthetic inputs. These test the SCORING MATH; they
don't flip to fail on a live-ranking change.scripts/audit-resilience-cohorts.mjs — emits a structured
Markdown report with:
score × coverage × dimWeight × domainWeight contribution to
overalltests/resilience-construct-invariants.test.mts — formula-level
anchor-value assertions. Part of npm run test:data. Failing means
the scorer formula drifted; investigate before editing the test.
docs/snapshots/resilience-ranking-live-pre-cohort-audit-YYYY-MM-DD.json —
the baseline snapshot for movers comparison. Refresh before each
methodology change.
server/worldmonitor/resilience/v1/_dimension-scorers.ts,
server/worldmonitor/resilience/v1/_shared.ts, or a scorer-feeding
seeder in scripts/seed-recovery-*.mjs, scripts/seed-bundle-resilience-*.mjs.RESILIENCE_ENERGY_V2_ENABLED, RESILIENCE_PILLAR_COMBINE_ENABLED,
RESILIENCE_SCHEMA_V2_ENABLED).resilience:score:vN,
resilience:ranking:vN, resilience:history:vN) — once the new
prefix has warmed up, rerun the audit so the movers table reflects
the new values and nothing else.# Online (hits the live API; requires WORLDMONITOR_API_KEY)
WORLDMONITOR_API_KEY=wm_xxx \
API_BASE=https://api.worldmonitor.app \
BASELINE=docs/snapshots/resilience-ranking-live-pre-cohort-audit-2026-04-24.json \
OUT=/tmp/cohort-audit-$(date +%Y-%m-%d).md \
node scripts/audit-resilience-cohorts.mjs
# Offline (fixture mode — for CI / dry-run / regression comparison)
FIXTURE=tests/fixtures/resilience-audit-fixture.json \
OUT=/tmp/cohort-audit-fixture.md \
node scripts/audit-resilience-cohorts.mjs
Recommended environment variables:
| Var | Default | Notes |
|---|---|---|
API_BASE | (required unless FIXTURE set) | e.g. https://api.worldmonitor.app |
WORLDMONITOR_API_KEY | (required unless FIXTURE set) | resilience RPCs are in PREMIUM_RPC_PATHS |
FIXTURE | (empty) | JSON fixture with { ranking, scores } shape — skips all network calls |
BASELINE | (empty) | Path to a frozen ranking JSON for movers comparison |
OUT | (stdout) | Path for the Markdown report |
TOP_N | 60 | Rows to render in the full-ranking table |
MOVERS_N | 30 | Rows to render in the movers table |
CONCURRENCY | 6 | Parallel score-endpoint fetches |
STRICT | unset | 1 = fail-closed. Report still writes, then exit 3 on fetch failures/missing members, exit 4 on formula-mode drift, exit 0 otherwise. Recommended for release-gate automation. |
CONTRIB_TOLERANCE | 1.5 | Points of drift tolerated between Σ contributions and overallScore before formula-mode drift is declared. |
The audit is fail-closed on two axes. Both are implemented in
scripts/audit-resilience-cohorts.mjs and documented here so that a
release-gate operator cannot shortcut them by reading only the
rendered tables.
Fetch failures / missing cohort members. When a per-country score
fetch fails (HTTP 4xx/5xx, timeout, DNS), the country is NOT silently
dropped. The failure is recorded in the run's failures map, banner'd
as a ⛔ block at the top of the report, and rendered in a dedicated
"Fetch failures / missing members" section that is ALWAYS present
(even when empty, so an operator learns to look for it). Fixture mode
uses the same mechanism for cohort members absent from the fixture.
Formula-mode mismatch (RESILIENCE_PILLAR_COMBINE_ENABLED). The
contribution decomposition is a domain-weighted roll-up that is ONLY
mathematically valid when overallScore is computed via the legacy
sum(domain.score * domain.weight) path. Once pillar combine is on,
overallScore = penalizedPillarScore(pillars) — a non-linear
function of the dim scores — and the decomposition rows no longer
sum to overall. The harness detects this by taking any country with:
sum(domain.weight) within 0.05 of 1.0 (complete response)coverage ≥ 0.9 (stable share math)and checking |Σ contributions - overallScore| ≤ CONTRIB_TOLERANCE.
If more than 50% of ≥ 3 eligible countries drift beyond the
tolerance, a ⛔ blocker banner fires at report top AND a
"Formula-mode diagnostic" section prints the first three offenders
with their Σ vs overall numbers. Until the harness grows a
pillar-aware decomposition, the contribution tables under pillar
mode must be treated as "legacy-formula reference only".
The operator guide for what to do when the formula-mode banner fires:
scripts/audit-resilience-cohorts.mjs
DIM_WEIGHTS is stale): update the mirror, re-run. This is the
production-logic-mirror-silent-divergence pattern — the mirror
must move with the scorer.STRICT=1: 3 = fetch/missing, 4 = formula
mode, 0 = all clear. These are distinct so automation can
differentiate "the infra is broken" from "the code path is no
longer decomposable."The report surfaces five categories of signal. Treat each as a prompt for investigation, not a merge gate.
Read across rows. If one country has IMPUTED / unmonitored /
coverage < 0.5 where peers have full coverage, that's a seed-level
gap — probably a late-reporter window or a missing manifest entry.
Fix the seed, not the score.
Each cell shows how many overall-score points that dimension
contributes to that country. If the row sum doesn't match overall
score (not within ~0.5 points), the scorer is using a composition
formula the audit script doesn't understand — investigate
_shared.ts's coverageWeightedMean + penalizedPillarScore
branches and update the decomposition accordingly.
known-limitations.md), or the goalpost is too generous (re-anchor).coverage < 0.5 while peers
are ≥ 0.9. This is almost always the ranking-inversion smoking gun.Expected movers post-methodology-PR are construct-consistent: a re-export-hub PR should move re-export hubs, not SWF-heavy exporters. Surprise movers trigger investigation before publication.
Run npx tsx --test tests/resilience-construct-invariants.test.mts.
An anchor drift > 1 point on score(ratio=1.0)=50 or
score(em=12)≈63 means someone silently re-goalposted or rewrote a
saturating transform. This is a bug until proven otherwise.
Never put "ENTITY A > ENTITY B" as a merge gate in this workflow. Once a review commits to producing a specific ranking, every construct / manifest / goalpost knob becomes a lever to tune toward that outcome — even subconsciously — and the methodology loses its construct integrity.
Use instead:
score(HHI=0.05) > score(HHI=0.20),
score(ratio=1.0) = 50 ± 1. These fail when the MATH breaks, not
when the RANKING changes.If a release reviewer asks "will this make A rank above B", the correct answer is: "A will move by the amount the construct predicts. Where it ends up relative to B is an outcome."
docs/methodology/known-limitations.md so future reviewers see the
diagnosis trail.