# 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: 1. **Input divergence.** The server's `computeCIIScores` does 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. 2. **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`. Frontend `country-instability.ts:~1014`: adds `hotspotBoost`, `newsUrgencyBoost`, `focalBoost`, `supplementalSignalBoost`, `getEarthquakeBoost`, `getSanctionsBoost`, and uses a *different* `displacementBoost` shape (server = `log10` ramp cap 20; frontend = step function cap 8). The component sub-formulas (`security` GPS-only vs 4-input) also differ. And the *frontend itself* carries two divergent blends: `calculateCII` (defined `country-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 for `getCountryScore` consumers (an expected, intended change). 3. **Output-shape divergence.** The `CiiComponents` proto fields are `newsActivity / ciiContribution / geoConvergence / militaryActivity` — *positionally aliased* to `information / unrest / conflict / security` by `cached-risk-scores.ts:92-97` and `get-risk-scores.ts:572-577`. There is no `security` proto 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 from `country-instability.ts`. - All frontend CII consumers read the server `RiskScores` proto via the **already-built** `src/services/cached-risk-scores.ts` adapter (`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.6` slow index this is acceptable — but the acceptance is **per-consumer, not blanket**. Live-CII-dependent surfaces and their verdict: - `checkCIIChanges` instability alerts (`cross-module-integration.ts:561`) — delta detection moves from live ticks to 10-min snapshots; **needs retuning** (see Risk 4). - `getCountryScore` map tint (`DeckGLMap.ts` / `Map.ts` via `setCIIGetter`) — 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 of `src/locales/en.json` (lines 101, 745, 3170) and the **20 non-English locale files** in `src/locales/*.json` (regenerate any `.d.ts` companions). - 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.ts` fetches 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_VESSELS` and 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 so `array.length` inflates (`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.ts` reading 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.ts` fetches them from the relay per request. The same scheduled job caches them to a Redis key with geo→country attribution (`AisDisruption` carries `{lat,lon}` + a free-text `region`, no `countryCode`), so `fetchAuxiliarySources()` reads them uniformly like the Phase 1 signals — no live relay call in the scoring path. Add `aisDisruption{High,Elevated,Low}Count` to `CountrySignals`. - **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. the `information` cap was deliberately raised 20→100 in issue #3739 (`get-risk-scores.ts:518-521`); the server version wins there. - The `BASELINE_RISK` / `EVENT_MULTIPLIER` scalar-table reconciliation. - The `getScoreLevel` threshold reconciliation — frontend `getLevel` (≥81 / ≥66 / ≥51 / ≥31) vs server adapter `cached-risk-scores.ts getScoreLevel` (≥70 / ≥55 / ≥40 / ≥25). Even identical scores render different `level` badges 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*ForCII` side-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` — both `calculateCII()` and `getCountryScore()` consumers. The `rg '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 internal `country-instability.ts:1038` (`getTopUnstableCountries`), and the `getCountryScore` path: `DeckGLMap.ts` / `Map.ts` (via `setCIIGetter`), `InsightsPanel.ts`, `military-surge.ts`. - `search-manager.ts:710` (`panelScores.length > 0 ? panelScores : calculateCII()`): the fallback arm is **removed**, the line keeps the `panelScores` arm. It is not "converted to a proto consumer" — `panelScores` already is one. - Delete `calculateCII`, `getCountryScore`, `calc*Score`, `calcNewsConflictFloor`. - **Surgical:** the `ingest*ForCII` functions 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*Score` functions; 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 `level` badge matches across engines for the same score, gated on the Phase 3a `getScoreLevel` reconciliation. 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*Score` reappear in `src/`. ## 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 1. **`ingest*ForCII` have non-CII side effects.** `ingestMilitaryForCII`, `ingestProtestsForCII`, `ingestConflictsForCII` call `trackHotspotActivity` (`country-instability.ts:258,270,424,444`), feeding `hotspotActivityMap` consumed by `getHotspotBoost`. Phase 4 deletes scoring, not ingestion — decompose each function. 2. **Formula parity needs input + attribution parity.** Porting `calcSecurityScore` byte-for-byte while feeding differently-attributed arrays still drifts. The Guardrails attribution-parity test guards this; the equality test alone does not. 3. **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. 4. **`cross-module-integration.ts` is stateful.** `checkCIIChanges()` (line 561) keeps alert history in a module-level `previousCIIScores` Map and gates on `isInLearningMode()` (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. 5. **Cache key bump is mandatory, not conditional** — see Phase 3-proto. 6. **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*ForCII` decomposition. 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)