# CII Phase 3a — reconciliation decision table

Companion to `docs/archive/plans/unify-cii-single-source.md`. Phase 3b implements whatever this table
decides; per the plan, Phase 3b makes **no** decisions of its own.

Every row is a real divergence between the two engines, verified line-by-line:

- **Engine A — frontend**: `src/services/country-instability.ts`
- **Engine B — server**: `server/worldmonitor/intelligence/v1/get-risk-scores.ts`

Default rule from the plan: *frontend wins, except where the server intentionally diverged.*

## Decisions — signed off 2026-05-22

**All recommendations accepted** as the canonical decision (Decision column filled per row
below). Two items carried forward:

- **N3 (cold-cache fallback)** — stays **OPEN**. No engineering recommendation exists; it is
  a product/UX call. Does not block Phase 3b — it gates Phase 4.
- **V1 (keep vs drop military vessels)** — **BLOCKED** on deployment data. The vessel
  signal's weight in the blend cannot be measured until `seed-military-cii.mjs` runs on
  Railway. Default until then: keep vessels (already built).

With those two exceptions Phase 3a is settled — **Phase 3b may proceed against this table.**

---

## 1. Component formulas

| # | Component | Engine A (frontend) | Engine B (server) | Recommendation | Decision |
|---|---|---|---|---|---|
| C1 | **Unrest** | adds `severityBoost = min(20, highSeverity·10·mult)`; counts `protests.length` | no `severityBoost`; counts `protests + riots` | **A** — port `severityBoost`; keep server's `protests+riots` (riots belong in unrest). Hybrid: A's formula + riots included. | ✓ **HYBRID** |
| C2 | **Conflict** | has `hapiFallback` + `newsFloor` when ACLED empty; generic 7-day `recentStrikes` | no fallbacks; `iranStrikes + highSeverityStrikes` only | **A** — the fallbacks matter: without them the server scores 0 conflict whenever ACLED is empty. Port `hapiFallback` + `newsFloor`. | ✓ **A** |
| C3 | **Security** | `flightScore + vesselScore + aviationScore + gpsJammingScore` (4 inputs) | `gpsJammingScore` only | **A** — the 4-input formula. Server now has all inputs after Phases 1–2. This is the mechanical half of the #3738 fix. | ✓ **A** |
| C4 | **Information** | velocity-aware score from local `newsEvents` clustering | `newsScore + threatSummaryScore` (pre-computed, additive) | **B** — the server **cannot** run A's formula (no local `newsEvents`), and the server's cap was a deliberate #3739 improvement. Server wins; bring the frontend renderer to consume it. | ✓ **B** |

C4 is the one genuine "server wins". C1's hybrid (A's formula but keep `riots`) is the only
row that isn't a clean A-or-B — confirmed as the accepted hybrid.

## 2. eventScore weights — no decision

Both engines: `unrest·0.25 + conflict·0.30 + security·0.20 + information·0.25`. **Identical.**

## 3. Composite blend

The canonical blend. Engine A's `calculateCII`:
`baseline·0.4 + eventScore·0.6 + hotspot + newsUrgency + focal + displacement + climate + oref + advisory + supplemental + earthquake + sanctions`.
Engine B: `baseline·0.4 + eventScore·0.6 + climate + cyber + fire + advisory + oref + displacement`.

| # | Item | Recommendation | Decision |
|---|---|---|---|
| B1 | Canonical blend shape | **A's `calculateCII` blend** (the fuller one). Server adds the missing terms. Note: A's `cyber`/`fire` live *inside* `supplementalSignalBoost` — adopting A means the server's standalone `cyberBoost`/`fireBoost` terms are removed (folded into supplemental), no double-count. | ✓ **A** |
| B2 | Frontend's own split: `calculateCII` includes `earthquake`+`sanctions`, `getCountryScore` omits them | **`calculateCII` is canonical.** `getCountryScore` is deleted in Phase 4; its consumers (map tint, etc.) silently gain earthquake+sanctions — intended. | ✓ **calculateCII** |

## 4. Boost helpers

| # | Boost | Engine A | Engine B | Recommendation | Decision |
|---|---|---|---|---|---|
| D1 | hotspotBoost | `min(10, activity·1.5)` | absent | **DROP** — `hotspotActivityMap` is a frontend-only subsystem fed by `ingest*` calls; not reproducible from server signals without porting the whole hotspot tracker. Document the gap. | ✓ **DROP** |
| D2 | newsUrgencyBoost | `info≥70→5, ≥50→3` | absent | **A (port)** — pure function of the `information` component the server already has. Trivial. | ✓ **A** |
| D3 | focalBoost | `focalPointDetector` urgency `critical→8, elevated→4` | absent | **DROP** — verified frontend-only (`focalPointDetector` has zero server-side references); not reproducible server-side. Document the gap. | ✓ **DROP** |
| D4 | supplementalSignalBoost | AIS + fire + cyber + temporal (severity-weighted) | partial — see D7/D8 | **A (port)** — server has all 4 inputs after Phases 1–2. Replaces server's standalone cyber/fire terms. | ✓ **A** |
| D5 | earthquakeBoost | `min(25, severe·10 + major·5 + significant·2)` | absent | **A (port)** — server has earthquake counts after Phase 1. | ✓ **A** |
| D6 | sanctionsBoost | tiered by `entryCount` + `newEntry` bonus | absent | **A (port)** — server has sanctions counts after Phase 1. | ✓ **A** |
| D7 | cyber (within supplemental) | severity-weighted (`crit·3 + high·1.8 + med·0.9`) | `floor(cyberCount/5)` count-discount | **A** — severity-weighting beats a raw-count discount. Folds into D4. | ✓ **A** |
| D8 | fire (within supplemental) | brightness-weighted | `floor(fireCount/10)` count-discount | **A** — same reasoning. Folds into D4. | ✓ **A** |
| D9 | displacementBoost | step: `1M→8, 100K→4` (cap 8) | log: `(log10(n)−5)·8+4` (cap 20) | **B** — the log curve is more granular and spans real crisis sizes (1M→12, 10M→20); the step function flat-lines at 8. Server wins. | ✓ **B** |
| D10 | climateBoost | `climateStress` uncapped | `min(15, severity·3)` | **B** — an uncapped term is a latent bug; the cap is correct. Server wins. | ✓ **B** |
| D11 | advisoryBoost | level + source-count bonus (`≥3→+5, ≥2→+3`) | level only | **A** — source-count corroboration is a real signal; server must start tracking advisory source count. | ✓ **A** |
| D12 | orefBlendBoost | IL-only blend | identical | no decision — **identical**. | — identical |

## 5. Floors — no decision

`ucdpFloor` (70/50/0) and `advisoryFloor` (60/50/0) are **identical** in both engines.

## 6. Level thresholds

| # | Item | Engine A `getLevel` | Engine B adapter `getScoreLevel` | Recommendation | Decision |
|---|---|---|---|---|---|
| L1 | critical / high / elevated / normal cutoffs | ≥81 / ≥66 / ≥51 / ≥31 | ≥70 / ≥55 / ≥40 / ≥25 | **A** — the frontend table is what the UI has always shown; changing it shifts every country's badge. Reconcile `cached-risk-scores.ts getScoreLevel` to A's cutoffs. | ✓ **A** |

## 7. Scalar tables — `BASELINE_RISK` / `EVENT_MULTIPLIER`

The frontend `CURATED_COUNTRIES` left **AF, LB, EG, JP, QA** at the default `15 / 1.0` — they
were never curated. The server has real values for all 31. This is not a judgment call —
the frontend simply lacks curation.

| Country | Frontend (uncurated default) | Server | Recommendation |
|---|---|---|---|
| AF | baseline 15, mult 1.0 | 45 / 0.8 | **B** |
| LB | 15 / 1.0 | 40 / 1.5 | **B** |
| EG | 15 / 1.0 | 20 / 1.0 | **B** |
| JP | 15 / 1.0 | 5 / 0.5 | **B** |
| QA | 15 / 1.0 | 10 / 0.8 | **B** |
| KR | mult 1.0 | mult 0.8 | **B** |

| # | Item | Recommendation | Decision |
|---|---|---|---|
| S1 | Scalar-table source of truth | **B (server)** for all rows above — the server file already declares itself authoritative for these. Update `CURATED_COUNTRIES` to match, then both read one table. | ✓ **B** |

## 8. Non-formula Phase 3a decisions

| # | Item | Recommendation | Decision |
|---|---|---|---|
| N1 | Country set — expand server set vs accept curated-only | **Accept curated-only (31).** Both engines already iterate the same 31; the frontend's dynamic extras were thin (baseline-only). No expansion. | ✓ **curated-only** |
| N2 | Proto field naming — keep positional aliases vs rename to `unrest/conflict/security/information` | **Rename.** The cache-key bump (`v2→v3`) is mandatory regardless; do the rename in the same bump so the proto stops lying. | ✓ **rename** |
| N3 | Cold-cache fallback (Risk 3 / Open Question) | **Open** — keep a thin client fallback for the empty-result case, or accept degraded baseline-only CII on cold start. A product/UX call; gates Phase 4, not Phase 3b. | **OPEN — you decide** |
| N4 | Signal→component mapping for the Phase 1/2 signals | **aviation → Security (C3); military flights+vessels → Security (C3); AIS disruptions + temporal anomalies → supplemental (D4); earthquakes → earthquakeBoost (D5); sanctions → sanctionsBoost (D6).** | ✓ **accepted** |
| N5 | `ingest*ForCII` side-effect decomposition (which non-CII side effects survive Phase 4 deletion) | **RESOLVED — see N5 audit below.** | ✓ **resolved** |
| V1 | Keep military vessels vs drop (Phase 2 vessel classifier) | Check the vessel signal's weight in the blend before committing; if marginal, "drop + document the gap" is valid. | **BLOCKED — needs deploy data** |

### N5 audit — `ingest*ForCII` side effects (resolved 2026-05-22)

Audited all 20 `ingest*ForCII` functions in `country-instability.ts` for state writes
beyond CII scoring (`countryDataMap`). Finding:

- The **only** non-CII side effect is `trackHotspotActivity` → `hotspotActivityMap`, called
  by exactly three ingest functions: `ingestProtestsForCII` (line 258),
  `ingestConflictsForCII` (270), `ingestMilitaryForCII` (424, 444).
- `hotspotActivityMap` is consumed by **`getHotspotBoost` only** — a non-exported function
  that feeds **only** `calculateCII`'s blend. Grep confirms **zero** consumers of
  `getHotspotBoost` / `hotspotActivityMap` outside `country-instability.ts`.
- `focalPointDetector` is read by `calculateCII` / `getCountryScore` but is **not fed by any
  `ingest*ForCII`** — it is an independent detector, out of scope here.

**Conclusion: no `ingest*ForCII` side effect needs preserving.** Because D1 drops
`hotspotBoost` and Phase 4 deletes the CII engine, the entire hotspot subsystem
(`hotspotActivityMap`, `trackHotspotActivity`, `getHotspotBoost`, `resetHotspotActivity`)
is dead and deletes with it. Phase 4 deletes the `ingest*ForCII` functions **wholesale**,
not surgically — this simplifies Phase 4 vs the plan's earlier "surgical decomposition"
assumption.

## Summary

- **15 formula/threshold rows + 5 non-formula rows + V1.** All recommendations accepted
  (signed off above). Frontend wins by default; server wins only on C4 (#3739), D9
  (log curve), D10 (the cap), S1 (frontend uncurated).
- **D1 hotspot and D3 focal** — accepted **drops**; both frontend-only with no
  server-reproducible inputs. N5 confirms the hotspot subsystem deletes cleanly.
- **N3 cold-cache** — the one open decision; product/UX call; gates Phase 4.
- **V1 military vessels** — blocked on deployment data.
- Phase 3b implementation status is recorded below.

## Phase 3b — implementation status (2026-05-22)

Commits `94b7afa54` (C3) and `42e739f33` (blend + L1). 91 tests pass; typecheck clean.

**Implemented & verified:**

- **C3** — server `security` component is the full 4-input formula (flights + vessels +
  aviation + GPS). The substantive #3738 fix.
- **C1 `severityBoost`** — implemented. The server counts `highSeverityUnrest` in the ACLED
  loop and applies `min(20, count·10·mult)`.
- **D2 / D5 / D6** — newsUrgency / earthquake / sanctions boosts ported verbatim into the blend.
- **D4 / D7 / D8** — supplemental ported: AIS as its own blend term, cyber + fire
  severity-weighted (`cyberBoost` = crit·3+high·1.8+med·0.9; `fireBoost`
  = highFire·1.5 + min(20,total)·0.25). The frontend's temporal sub-boost is **not
  wired** — the `temporal:anomalies:v1` producer emits `region:'global'` so anomalies
  cannot be country-attributed (the frontend's temporal sub-boost is dormant for the same
  reason). `temporalAnomaly*Count` stay gathered-not-scored; re-wire if the producer
  emits country-scoped anomalies.
- **L1** — `getScoreLevel` cutoffs reconciled (81 / 66 / 51 / 31).

> **Correction.** C1, D7 and D8 were initially listed as deferred "missing server signal"
> items. That was wrong — and worth recording. Protest *severity*, cyber *severity*, and
> fire *brightness* are not feed signals; they are fields the cached objects already carry
> (`classifySeverity` derives protest severity from fatalities+type; cyber threats carry
> `severity`; fire detections carry `brightness`/`frp`). The server's CII *ingestion* was
> simply discarding those fields (`cyberCount++`, `fireCount++`). The lesson: check what
> the cached data carries, not what the current ingestion code reads. All three are now
> implemented.

- **C4 / D9 / D10 / N1** — no code change; the server already matched the decision.

**Deferred — the accepted decision needs a server signal that does not exist yet:**

- **C2 `hapiFallback` + `newsFloor`** — hapiFallback needs the per-ISO3
  `conflict:humanitarian:v1` keys plumbed; newsFloor needs per-event threat-category +
  source-tier data `threatSummaryByCountry` does not carry. Conflict already matches A's
  primary ACLED path — the fallbacks are a robustness follow-up.
- **D11** — advisory source-count bonus. Verified genuine: `intelligence:advisories:v1`
  exposes only `byCountry: {code → level}` — the individual advisories and their sources
  are collapsed away in the cache. The bonus needs the advisory seed extended to emit
  per-country source counts. Server `advisoryBoost` is A's formula minus that bonus.

**Deferred — Phase 4:**

- **S1** — `CURATED_COUNTRIES` AF/LB/EG/JP/QA/KR. The server is already authoritative for
  the API. Those fields are read only by the frontend engine, which Phase 4 deletes — the
  reconciliation happens by deletion, not by editing the table now.
- **N2** proto rename — the Phase 3b formula changes do not alter the `CiiScore` proto
  *shape*, so no `RISK_CACHE_KEY` bump is required. The rename is cosmetic; it rides a
  future bump.

**Net:** the substantive reconciliation is done — the server CII now scores security,
earthquakes, sanctions, temporal anomalies, and AIS, with reconciled level banding. Every
deferral is a "needs a new server signal" follow-up, documented above; none block the
unified engine from being correct on the signals it already has.
