Unify the CII — one engine, server-canonical
Origin: Issue #3738 ("Security dimension is 100% GPS jamming"). Investigation showed the mislabel is a symptom, not the bug. The bug: WorldMonitor runs two CII engines that diverge in formula, inputs, and output shape. This plan unifies them.
Reviewed 2026-05-22 by three parallel reviewers (feasibility / adversarial / coherence). Their verified findings are folded in. Corrections to the first draft are marked
[rev].
Problem
| Frontend engine | Server engine | |
|---|---|---|
| File | src/services/country-instability.ts |
server/worldmonitor/intelligence/v1/get-risk-scores.ts |
| Scoring fns | calcUnrestScore, calcConflictScore, calcSecurityScore, calcInformationScore, calcNewsConflictFloor |
4 inline component blocks in computeCIIScores |
| Inputs | ~20 ingest*ForCII functions |
~12 AuxiliarySources (post-Phase-1: ~17) |
| Output consumers | calculateCII() / getCountryScore() |
RPC getRiskScores → MCP get_country_risk, API, panels |
| Cadence | recomputed live as browser streams tick | cached risk:scores:sebuf:v2, TTL 600 s |
Three independent divergences, not one:
- Input divergence. The server's
computeCIIScoresdoes not score 8 signal families the frontend scores (military flights, military vessels, aviation disruptions, AIS disruptions, earthquakes, sanctions, temporal anomalies, HAPI).[rev]"Does not score" ≠ "cannot see" — 6 of the 8 already have a usable server source (audit table ✅), AIS disruptions has a relay source needing only geo-attribution, and only military vessels need a genuinely new classifier. See the audit table. - Formula divergence. Beyond inputs, the blend formulas differ structurally. Server
get-risk-scores.ts:549-556: `baseline·0.4 + eventScore·0.6 + climateBoost + cyberBoost- fireBoost + advisoryBoost + orefBlendBoost + displacementBoost
. Frontendcountry-instability.ts:~1014: addshotspotBoost,newsUrgencyBoost,focalBoost,supplementalSignalBoost,getEarthquakeBoost,getSanctionsBoost, and uses a *different*displacementBoostshape (server =log10ramp cap 20; frontend = step function cap 8). The component sub-formulas (securityGPS-only vs 4-input) also differ. And the *frontend itself* carries two divergent blends:calculateCII(definedcountry-instability.ts:975, blend at:1014) includes the earthquake/sanctions/supplemental boosts;getCountryScore(:1042) omits all three. Phase 3a's canonical-decision list must pick one blend — Phase 4 then silently changes scores forgetCountryScore` consumers (an expected, intended change).
- fireBoost + advisoryBoost + orefBlendBoost + displacementBoost
- Output-shape divergence. The
CiiComponentsproto fields arenewsActivity / ciiContribution / geoConvergence / militaryActivity— positionally aliased toinformation / unrest / conflict / securitybycached-risk-scores.ts:92-97andget-risk-scores.ts:572-577. There is nosecurityproto field.
Because both engines are user-facing (MCP/API serve the server score; the UI's
calculateCII() / getCountryScore() serve the frontend score), the MCP tool and the
on-screen CII can disagree for the same country.
Scope boundary — "unify" is not "correct"
This plan makes the two engines produce one score. It does not fix whether that
score is right. Specifically: calcSecurityScore (flights + vessels + aviation + GPS) is
military activity, not "security" in the terrorism sense (issue #3738's actual complaint).
Unifying on it freezes that model. Correcting the security model is out of scope here,
but must not be silently dropped: filing the follow-up issue — a real security/terrorism
signal, with a named owner — is a Phase 0 deliverable, and #3738 is closed against that
follow-up, not against this plan's rename. Phase 0's label rename ("Security" → "Mil. Activity") is the
honest stopgap that makes the frozen model's name accurate; it is a deliberate, lasting
decision, not a placeholder. [rev] This is why Phase 0 is safe to ship early: the plan
commits to "the dimension stays military-activity-only," so the label will not need
re-reverting later.
Target architecture
The server get-risk-scores.ts is the single CII engine. The browser renders its output.
- Delete
calculateCII(),getCountryScore(),calc*Score,calcNewsConflictFloor, and the CII-scoring state fromcountry-instability.ts. - All frontend CII consumers read the server
RiskScoresproto via the already-builtsrc/services/cached-risk-scores.tsadapter (getCachedScores,toCountryScore,fetchCachedRiskScores, circuit breaker, localStorage persistence).[rev]This module already exists and several call sites already dual-path through it — Phase 4 is "remove the frontend-engine arm," not "build a consumption layer." - Accepted tradeoff: the CII number freshness becomes the server cadence (~10 min), not
live. Map layers stay live; only the index number follows server cadence. For a
baseline·0.4 + eventScore·0.6slow index this is acceptable — but the acceptance is per-consumer, not blanket. Live-CII-dependent surfaces and their verdict:checkCIIChangesinstability alerts (cross-module-integration.ts:561) — delta detection moves from live ticks to 10-min snapshots; needs retuning (see Risk 4).getCountryScoremap tint (DeckGLMap.ts/Map.tsviasetCIIGetter) — 10-min staleness acceptable for a country-level choropleth.story-data.ts,military-surge.ts— consume CII for context, not real time; 10-min acceptable. Phase 4 confirms each consumer owner signs off; the alerts surface is the one with a real behavior change, not a pure latency change.
A shared-formula module ("one formula, two engines") is rejected — the user's decision is one engine. Note: a shared module would eliminate formula divergence but not input or country-set divergence, so it does not by itself deliver one CII.
Per-signal source audit
The 8 families the server CII does not currently score, and what each needs:
| Signal | Frontend ingest | Server source today | Work |
|---|---|---|---|
| Aviation disruptions | ingestAviationForCII |
aviation:delays-bootstrap:v2 (Redis — pre-merged FAA + intl + NOTAM by seed-aviation.mjs) ✅ |
Plumb into AuxiliarySources |
| AIS disruptions | ingestAisDisruptionsForCII |
get-vessel-snapshot.ts emits AisDisruption (live relay HTTP, not a Redis key) ⚠️ |
Phase 2 — cached to a Redis key by Phase 2's scheduled relay job, then plumbed + geo→country |
| Earthquakes | ingestEarthquakesForCII |
seismology:earthquakes:v1 (Redis) ✅ |
Plumb + geo→country |
| Sanctions | ingestSanctionsForCII |
sanctions/v1/list-sanctions-pressure.ts (per-country) ✅ |
Plumb |
| Temporal anomalies | ingestTemporalAnomaliesForCII |
server-detected (TemporalAnomalyProto, temporal-baseline.ts consumeServerAnomalies()) ✅ |
Plumb |
| Military flights | ingestMilitaryForCII |
military:flights:v1 (Redis) — already operator-classified by scripts/seed-military-flights.mjs ✅ |
Per-country count + location/foreign-presence attribution |
| Military vessels | ingestMilitaryForCII |
relay get-vessel-snapshot.ts has raw vessels; no military classification/attribution ⚠️ |
Port classifier (MILITARY_VESSEL_PATTERNS, KNOWN_NAVAL_VESSELS in src/config/military.ts) + attribution |
| HAPI conflict | ingestHapiForCII |
conflict/v1/get-humanitarian-summary-batch.ts → conflict:humanitarian:v1:{ISO3} ✅ |
Phase 1 plumb — or take the drop decision in Phase 3a (frontend uses it only as ACLED-empty fallback) |
[rev] Corrections from the first draft: only military vessels need genuinely new
classification work — military flights are already classified in military:flights:v1;
the relay is a process (scripts/ais-relay.cjs) reached by per-request HTTP, not a Redis
key; AIS disruptions and earthquakes both need a geo→country step. No new persistent-
WebSocket worker is needed (the relay process already holds the streams), but "plumbing"
for AIS still means a live relay HTTP call with a timeout, not a cheap cache read.
Additional frontend-only inputs (feed calculateCII but not in the 8 families):
focalPointDetector.getCountryUrgencyMap() (country-instability.ts:977), hotspot boost
(getHotspotBoost), learning-mode gating (isInLearningMode). Decide per Phase 3a.
Country-set divergence [rev]
Server computeCIIScores iterates Object.keys(TIER1_COUNTRIES) — 31 countries
(_shared.ts:35, includes KR/IQ/AF/LB/EG/JP/QA). Frontend calculateCII() iterates
new Set([...countryDataMap.keys(), ...Object.keys(CURATED_COUNTRIES)]) — the same 31
curated plus any country that received ingested data (dynamic). So after Phase 4,
non-curated countries with events lose their CII. This is a smaller gap than a reviewer
first claimed (it is not 31→24), but it is real: decide in Phase 3a whether to expand the
server set or accept that CII is curated-countries-only (those dynamic scores were thin —
baseline 20 + events — anyway).
Phases
Phases 0, 1, 2, 3b, and 4 are each an independently shippable PR; Phase 3a is a design-note decision table, not a PR. The Guardrails section below is not a phase — each test ships inside an earlier phase's PR (noted per test).
Phase 0 — Label stopgap (ships immediately, independent)
- Rename the CII component label
"Security"→"Mil. Activity"in all three component blocks ofsrc/locales/en.json(lines 101, 745, 3170) and the 20 non-English locale files insrc/locales/*.json(regenerate any.d.tscompanions). - Internal dimension key stays
security; only the display string changes. - File the security-model follow-up issue (#3738's actual complaint — a real security/terrorism signal) and link #3738 to it. Owner: TBD — assign before Phase 0 ships; Phase 0 is not complete with only the rename.
- No engine change. Safe given the Scope-boundary commitment above.
Phase 1 — Server acquires the cheap signals
AuxiliarySources+fetchAuxiliarySources(): add aviation (aviation:delays-bootstrap:v2— the pre-merged FAA + intl + NOTAM source), earthquakes (seismology:earthquakes:v1, with geo→country), sanctions (sanctions:pressure:v1), temporal anomalies (temporal:anomalies:v1). All four are backed by an existing Redis key.CountrySignals+emptySignals(): add the count fields (aviation{Closure,Severe,Major,Moderate}Count,earthquake{Significant,Major,Severe}Count,sanctions{Entry,NewEntry}Count,temporalAnomaly{,Critical}Count).- Wire each into the per-country accumulation loop. No scoring change — signals gathered but unused. Additive and safe.
- AIS disruptions moved to Phase 2. Unlike the four above, AIS disruptions have no Redis
key —
get-vessel-snapshot.tsfetches them from the Railway relay per request. Adding a live relay HTTP call into the 600 s scoring path is the anti-pattern the feasibility review flagged; instead AIS rides on the scheduled relay job Phase 2 already stands up.
Phase 2 — Server military flights, vessels + AIS disruptions
- Per-country aggregation of
military:flights:v1(already operator-classified) — add location-code attribution for foreign presence. - New server-side military vessel classifier: port
MILITARY_VESSEL_PATTERNS/KNOWN_NAVAL_VESSELSand apply to the relay vessel snapshot. This is the single largest new-build item in the plan — before committing, Phase 3a must check the vessel signal's weight in the CII blend; if it is marginal, drop military vessels and document the accepted input gap is a valid Phase 3a decision (mirroring HAPI's "or drop"). - Port the intent of
ingestMilitaryForCII's foreign-presence weighting — not the representation hack. The frontend fakes the ×2 weight by pushing synthetic{}objects soarray.lengthinflates (country-instability.ts:447-456); the server must store honest counts and apply the ×2 in the formula, or any future reader of the new key gets a ~3× inflated aircraft count. - Write per-country counts to a Redis key (e.g.
intelligence:military-cii:v1). - Decision: aggregation runs as a scheduled server job writing the Redis key (not a
relay extension) — keeps
get-risk-scores.tsreading Redis uniformly, matches the 600 s cadence, and avoids coupling scoring to relay request latency. - AIS disruptions (moved from Phase 1). AIS disruptions have no Redis key —
get-vessel-snapshot.tsfetches them from the relay per request. The same scheduled job caches them to a Redis key with geo→country attribution (AisDisruptioncarries{lat,lon}+ a free-textregion, nocountryCode), sofetchAuxiliarySources()reads them uniformly like the Phase 1 signals — no live relay call in the scoring path. AddaisDisruption{High,Elevated,Low}CounttoCountrySignals. - Attribution caveat: the server's
geoToCountry(get-risk-scores.ts:154) is a bounding-box scan; the frontend uses polygon containment. For straits/borders (Hormuz, Taiwan Strait, Black Sea — the high-signal cases) bbox will mis-attribute. Either port polygon containment server-side or accept and document the precision loss.
Phase 3a — Reconciliation decisions (design note, no code)
"Port verbatim" is not sufficient — the formulas diverge structurally and at least one divergence is intentional. Before the behavior-changing PR opens, settle every decision below and record it in a signed-off table. Phase 3b executes this table; it does not make decisions inside the PR.
- For each of the 4 component formulas and each blend boost
(
hotspotBoost,newsUrgencyBoost,focalBoost,supplementalSignalBoost,earthquakeBoost,sanctionsBoost,displacementBoost), a canonical decision: which engine's formula wins. Default = frontend, except where the server intentionally diverged — e.g. theinformationcap was deliberately raised 20→100 in issue #3739 (get-risk-scores.ts:518-521); the server version wins there. - The
BASELINE_RISK/EVENT_MULTIPLIERscalar-table reconciliation. - The
getScoreLevelthreshold reconciliation — frontendgetLevel(≥81 / ≥66 / ≥51 / ≥31) vs server adaptercached-risk-scores.ts getScoreLevel(≥70 / ≥55 / ≥40 / ≥25). Even identical scores render differentlevelbadges until this is unified. - focal-urgency / hotspot-boost / learning-mode: port if reproducible from server-available signals, drop otherwise.
- The country-set question (Risk 6: expand server set, or accept curated-only).
- The Phase 3-proto field-naming choice (below).
- The Risk 3 cold-cache decision.
- Signal→component mapping: for each newly-plumbed signal (Phase 1 — aviation, AIS disruptions, earthquakes, sanctions, temporal anomalies; Phase 2 — military flights/vessels), which component it scores into and with what sub-formula. The boost list above does not cover signal→component mapping; an unmapped signal gets silently dropped or invented inside Phase 3b.
- The
ingest*ForCIIside-effect decomposition for Phase 4: enumerate which non-CII side effects (e.g.trackHotspotActivity→hotspotActivityMap) survive the deletion and which feature consumes each. Phase 4's irreversible deletion is gated on this table.
Phase 3b — Server computes the full CII (implementation)
This is the behavior-changing PR. It executes the Phase 3a decision table — it makes no
decisions. Implement each canonical formula choice, the scalar tables, the getScoreLevel
thresholds, and the proto change. Pair with the Guardrails equality/level/attribution tests
below; the PR is not done until the equality test is green.
Phase 3-proto — CiiComponents field naming
Folded into the Phase 3b PR (same cache-key bump); the keep-vs-rename choice is made in
Phase 3a. Decide: keep abusing the positional
aliases (militaryActivity≡security, …), or rename the .proto fields to
unrest/conflict/security/information and regenerate
src/generated/server/worldmonitor/intelligence/v1/service_server.ts +
src/generated/client/worldmonitor/intelligence/v1/service_client.ts. Either way the
CiiScore shape changes → mandatory bump of
RISK_CACHE_KEY (v2→v3) propagated to every reader listed at get-risk-scores.ts:626-631.
Phase 4 — Frontend becomes a renderer
- Repoint every CII consumer to
cached-risk-scores.ts— bothcalculateCII()andgetCountryScore()consumers. Therg 'calculateCII|getCountryScore' src/sweep is authoritative and must be run before starting; the known sites are:country-intel.ts:218,740,data-loader.ts:348,search-manager.ts:710,CIIPanel.ts:134,story-data.ts:65,src/services/cross-module-integration.ts:561,630, the internalcountry-instability.ts:1038(getTopUnstableCountries), and thegetCountryScorepath:DeckGLMap.ts/Map.ts(viasetCIIGetter),InsightsPanel.ts,military-surge.ts. search-manager.ts:710(panelScores.length > 0 ? panelScores : calculateCII()): the fallback arm is removed, the line keeps thepanelScoresarm. It is not "converted to a proto consumer" —panelScoresalready is one.- Delete
calculateCII,getCountryScore,calc*Score,calcNewsConflictFloor. - Surgical: the
ingest*ForCIIfunctions have non-CII side effects (see Risk 1) — keep the ingestion, delete only the CII scoring state. Use the side-effect decomposition table produced in Phase 3a; do not leave it to per-engineer judgment.
Guardrails (not a standalone phase — each test ships in the phase noted)
These are not an independently shippable phase; each test lands inside an earlier phase's PR.
- Equality test (lands with Phase 3b): feed identical fixture component-input signals
to the server formulas and the frontend
calc*Scorefunctions; assert byte-identical component scores. This is formula parity only — a green equality test is necessary but not sufficient for UI parity (see Principle). - Level-parity test (lands with Phase 3b): assert the rendered
levelbadge matches across engines for the same score, gated on the Phase 3agetScoreLevelreconciliation. Identical component scores still render different badges until thresholds are unified — the equality test does not catch this. - Attribution-parity test (lands with Phase 3b, one-shot while both engines coexist): feed real coordinates (straits, borders) through both the server bbox and frontend polygon attribution; assert the country assignment matches, or document the accepted deltas. The equality test alone is blind to this (Risk 2). The frontend attribution is deleted in Phase 4, so this is a transition check, not an ongoing guardrail.
- Standing attribution regression test (permanent — survives Phase 4): a fixed
coordinate-set → expected-country fixture for the server's
geoToCountry, independent of the frontend. The one-shot attribution-parity test validates the port; this guards against future bbox drift once the frontend polygon attribution is deleted. - Source-grep test (lands with Phase 4): fails if
calculateCII/getCountryScore/calc*Scorereappear insrc/.
Principle
The server CII and the frontend CII are one metric — after Phase 4 only the server
engine exists, so equality is structural. During the transition (Phases 1–3) "equal"
means formula parity given identical inputs, verified by the Guardrails equality test.
Full production equality additionally requires input parity and attribution parity, which
the Guardrails attribution-parity test covers. Note: byte-equality cannot be asserted against the
current server information block — it intentionally diverged (#3739); Phase 3a resolves
which side is canonical before the equality test can pass.
Risks / gotchas
ingest*ForCIIhave non-CII side effects.ingestMilitaryForCII,ingestProtestsForCII,ingestConflictsForCIIcalltrackHotspotActivity(country-instability.ts:258,270,424,444), feedinghotspotActivityMapconsumed bygetHotspotBoost. Phase 4 deletes scoring, not ingestion — decompose each function.- Formula parity needs input + attribution parity. Porting
calcSecurityScorebyte-for-byte while feeding differently-attributed arrays still drifts. The Guardrails attribution-parity test guards this; the equality test alone does not. - Cold-cache failure mode. On a cold Redis cache + upstream failure, the server
returns 31 countries scored on baseline only (
get-risk-scores.ts:694-700:computeCIIScores([], emptyAux)). Today the frontend fallback masks this by recomputing from live in-browser streams. Phase 4 removes that mask. Decision required before Phase 4: either keep a thin client-side fallback for the empty-result case, or accept a degraded baseline-only CII on cold start. Do not delete the fallback silently. cross-module-integration.tsis stateful.checkCIIChanges()(line 561) keeps alert history in a module-levelpreviousCIIScoresMap and gates onisInLearningMode()(frontend-only state). Moving to a 600 s server cadence changes delta-detection semantics (two snapshots 10 min apart, not live ticks). This is a behavior change, not just an availability concern — re-validate against the learning-mode decision in Phase 3a.- Cache key bump is mandatory, not conditional — see Phase 3-proto.
- Country-set shrink — see "Country-set divergence"; decide in Phase 3a.
Sequencing notes
- Phase 0 ships today; safe given the Scope-boundary commitment.
- Phases 1–2 are additive (signals gathered, not scored) — safe, provided no proto change ships with them (it ships with Phase 3b).
- Phase 3a is a design note (decision table), not code; it is authored after Phases 1–2 land so the vessel-weight check has real data, and its cited formula line numbers are re-verified against current source at the start of Phase 3b. Phase 3b (executing that table, + Phase 3-proto + the Guardrails equality/level/attribution tests) is one PR — the behavior-changing one. It is not done until the equality test is green and every per-component canonical decision from 3a is recorded.
- Phase 4 is the deletion PR — largest blast radius. Gate it on the Risk 3 (cold-cache), Risk 4 (alert retuning), and Risk 6 (country-set) items being resolved and signed off.
- Revertability: Phases 0–3 revert cleanly. Phase 4 deletes code — reverting it means
restoring the deleted engine and the
ingest*ForCIIdecomposition. Treat Phase 4 as the point of no return.
Deferred / Open Questions
From 2026-05-22 review
- Cold-cache fallback decision (Risk 3). Phase 4 deletes the frontend engine that today
masks the cold-cache failure mode — on cold Redis + upstream failure the server returns
31 baseline-only scores (
get-risk-scores.ts:694-700). Decide before Phase 4: keep a thin client-side fallback for the empty-result case only, or accept a degraded baseline-only CII on cold start (and decide whether a degraded score needs a UI staleness indicator). This is an architecture/UX call that depends on tolerance for a cold-start baseline-only CII — it cannot be auto-resolved. Blocks Phase 4. (adversarial, product-lens)