The record · scoring model v2.10.0
Last updated July 23, 2026
Public, dated record of every change to how NationsHelm computes scores. Versioned
independently of the app (lib/scoringModel.ts → shown on /methodology). MAJOR =
reorders headline rankings; MINOR = new/removed metric or floor; PATCH = fix/label.
The Geopolitics "blocs" signal no longer scores a hand-picked list of ~30 orgs. That list was arbitrary, silently incomplete (it omitted Council of Europe / OSCE / WTO, so Ukraine read "No bloc memberships on record" despite deep Euro-Atlantic integration), and its gaps fed the 2.9.0 fake-zero. The taxon is now open and derived from the source:
scripts/fetch-country-blocs.mjs): take all current Wikidata P463 memberships
for every nation (deduped by entity), not a 30-org allowlist. globalInfluence.blocs becomes
the nation's memberships that are geopolitical/economic alignment blocs;
globalInfluence.blocMembershipsTotal records the full count.data/bloc-registry.json
records every org, its member count, and its scored flag.)lib/blocs.ts): the count of scored blocs on a diminishing curve,
100·(1−e^(−n/4)) — never saturates (n=1→22, 4→63, 9→90), replacing the old count×20 (which
capped at 5 blocs). Null only when a nation has no membership data at all
(blocMembershipsTotal === 0, a territory Wikidata doesn't cover) — renormalised out. A nation
in memberships but no alignment bloc (North Korea: UN-system only) scores a real 0.docs/data-quality-fix/snapshots/<published-id>-diff.md.Supersedes 2.9.0 (the fake-zero null-fix): the null-vs-0 handling is now expressed through
blocMembershipsTotal, and the empty-list ambiguity is gone because the taxon is comprehensive.
An empty tracked-bloc list is no longer scored as a real 0 inside the Strategic
Position Geopolitics component (lib/strategicPosition.ts) or the Global Influence
metric-category (lib/insights/metricCategories.ts). Both now route the bloc signal through
one blocMembershipScore(blocs) helper that returns null for an empty list, and
renormalise over the signals that are actually present:
blocsScore = blocs.length × 20 turned an empty list into a hard 0 that
entered Geopolitics at 40% weight. Because [] is a structurally-valid value (not
null), it slipped past every missing-data guard — the AGENTS.md "missing is null, never a
fabricated fallback" rule was violated in silence. Ukraine (in no tracked exclusive bloc —
its Council of Europe / OSCE / WTO memberships are out of the tracked scope) scored
Geopolitics 13, dragging Strategic Position to 46.null → renormalised out. Geopolitics still requires ≥1 sourced
quantitative signal (soft power or GDP share) — a bloc list alone can't assert a score
(Taiwan: no formal bloc, real weight), so the "no quantitative signal → null" guard is
unchanged. The weight ladder (0.4/0.3/0.3, with the 0.57/0.43 two-signal fallbacks) is kept
verbatim — only a null signal now drops out — so every present-bloc nation is byte-identical
to before and the diff is confined to the empty-bloc set.MANUAL_ADDITIONS (scripts/fetch-country-blocs.mjs) and backfilled in
data/nation-profile.json; they now carry PIF instead of "No bloc memberships on record".Not changed (documented follow-ups): the country-blocs ingest contract still measures an
aggregate coverage floor and excludes zero-bloc nations from its payload, so it remains blind
to a single nation dropping to zero — a per-entity/known-member guard is the recurrence fix. The
tracked-org set (30 exclusive blocs) still excludes integration bodies (Council of Europe, OSCE,
WTO); "No bloc memberships on record" copy is scope-accurate only for exclusive blocs. Snapshot
diff: docs/data-quality-fix/snapshots/<published-id>-diff.md (0 blocking / 0 advisory — no Overall
or Risk-board movement; the 14 empty-bloc nations with a quantitative signal get a renormalised
Geopolitics, e.g. Ukraine 13→21 / Strategic Position 46→48).
The Stability Violence & Safety sub-signal no longer inverts per-capita deaths against the
worst country in the cohort. It is now min(perCapita, bandCap) (scripts/lib/safetyScore.mjs):
Impact: stability drops for conflict-affected nations (Iran 44→31, Syria 34→20, Russia 48→34,
Mexico 53→39, CAR 45→23…); nations with zero organized-violence deaths are unchanged (safety 100).
Overall headline board is stable (only RK 36–40 shuffle ≤4); Risk-top-15 gains Togo and Niger. The
QoL active-conflict cap (D6) is unaffected — it still reads the raw per-million counts. Snapshot
diff: docs/data-quality-fix/snapshots/snap-20260723-*-diff.md.
A leader's Diplomacy dimension (dip) now overlays from Diplomatic Conduct, a
performance-under-test model (docs/diplomacy-conduct-v1.md) replacing the volume-dominated
Diplomatic Signal. Two axes like Crisis Response: Test Severity (context — hostility of the
external environment, from UCDP active-conflict intensity + conflictual event share + sanctions
received) and Conduct (the scored blend), with severity amplifying the conduct deviation
(k=0.4) so strong conduct under fire is worth more.
scripts/generate-gdelt-conduct-bq-query.mjs.diplomaticSignal is retained on the record but deprecated.Quality of Life is hard-capped at 40 for any nation that is an active organized-violence party, correcting a structural blind spot: the Safety group's conflict metrics are population-scaled (deaths per million), so a large nation at war reads "safe" (Iran's armed- conflict deaths scored 93; Mexico's an average that didn't tank QoL), and where the primary homicide series is absent the Safety group nulls out entirely, leaving QoL as Healthcare + Education only (Iran QoL 80). Neither reflects that a war zone is not a high-quality-of-life place.
isActiveConflictParty, lib/indicators/scoringPolicy.ts): still active this
year — UCDP-CED current-year deaths per million > 0 — AND at meaningful annual scale
— UCDP-GED finalized deaths per million ≥ 10. Both readings are freshness-gated, so a
resolved conflict cannot trip it. Requiring both keeps genuine wars (Ukraine, Russia, Iran,
Israel, Syria, Mexico, Colombia, Myanmar, Pakistan, Lebanon, Cameroon) while sparing one-off
localized violence a bare "any death this year" rule over-caps (Brazil 20 deaths, Thailand 7,
Guatemala 1).min(), not an average — high Healthcare/Education cannot renormalise it away.
The category carries conflictCapped + cappedFrom so the UI shows "80 → 40, capped: active
conflict" rather than an unexplained number.qolActiveConflictCap
semantics flag (Group D), shipped ON after the diff review.conflictCapped + cappedFrom are
now threaded through the snapshot projection (lib/snapshot/build.ts) and rendered on the
category card as a "capped — active conflict" chip with the pre-cap number ("80 → 40"), so the
score never reads as an unexplained contradiction of the visible group scores. The two UCDP
conflict metrics' registry entries document the cap; the methodology page gains a
"Why is a country's Quality of Life capped when it's at war?" entry.applyConflictPenalty,
scripts/lib/diplomaticSignalScore.mjs) is implemented in code but NOT yet activated in data —
it is being reworked so leader-level violence is captured even when a war is fought off the
leader's own soil (location-based UCDP misses it). Will land in a later version once settled.Two Group-D semantics options, signed off after review and shipped ON (the flags in
lib/indicators/semanticsFlags.ts were inert stubs — this release actually implements
them in buildCategories). D1 (public-spending band-optimum) stays OFF — a contestable
normative band, not an error correction.
debtToGdp now scores from IMF
general-government net debt (data/imf-net-debt.json, series GGXWDN_G01_GDP_PT,
2024, 84 nations) where available, else keeps the World Bank gross reading. Net uses
a domain sharing the gross 150%-of-GDP worst-case ceiling but floored at 0, so net-creditor
states clamp to 100 (Norway net −149% → 100; Australia gross 58 → net 32; Brazil 82 → 61).
Nations the IMF does not net (Singapore — sovereign assets in GIC/Temasek) keep gross with
the "Debt / GDP" label; net-scored nations render "Net debt / GDP". Flat-mean Fiscal Health
(3 metrics) already caps any single reading's weight. 25 nations swap gross→net, 59 gain
net where they had no gross, 43 keep gross.ranking-diff-report.md; rationale + per-nation evidence in
metric-semantics-decision.md.militaryScore
= 0: no-army microstates and force-count data gaps) no longer scores the War
scenario 0. War is null (renormalised out) below a measurable-military floor —
absence is not "certain defeat". Saint Vincent fell from the #1 riskiest nation to
#5; the homepage Risk Signal top-3 is now Yemen/Haiti/Syria. Every conflict state
scores militaryScore ≥ 8 and is unaffected.Aggregation integrity (A)
Formula & consistency (B)
Coverage (C) — Taiwan V-Dem/V-Party join repaired via the canonical crosswalk (data lands on next ingest); dead GDELT media modules collapse and the false "~173 tracked hourly" claim retracted; leaked military honorifics stripped from leader names.
Infrastructure (F) — declarative registry fields (weight/status/band); canonical entity crosswalk + audit; leader-page ISR vintage aligned; data-quality CI gate.
Deferred (documented, not shipped): D1–D3 metric-semantics flags (OFF, pending sign-off); F3 ingest contracts / F4 versioned snapshot / F6 anomaly detection (designed).
Continuous difficulty multiplier (was tiered); FSI dropped from Government Stability; frozen-indicator gating; momentum rebuilt on real history; RNG purged. See git history.