From 7dfdc819a9d4ebaaf2e9c635107e0608e840dfa4 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Sat, 11 Apr 2026 17:55:39 +0400 Subject: [PATCH] Phase 0: Regional Intelligence snapshot writer foundation (#2940) --- api/health.js | 2 + scripts/jsconfig.json | 9 +- scripts/regional-snapshot/_helpers.mjs | 45 ++ scripts/regional-snapshot/actor-scoring.mjs | 95 +++ scripts/regional-snapshot/balance-vector.mjs | 382 +++++++++++ scripts/regional-snapshot/diff-snapshot.mjs | 126 ++++ .../regional-snapshot/evidence-collector.mjs | 105 +++ scripts/regional-snapshot/freshness.mjs | 88 +++ .../regional-snapshot/persist-snapshot.mjs | 114 ++++ .../regional-snapshot/regime-derivation.mjs | 40 ++ .../regional-snapshot/scenario-builder.mjs | 93 +++ scripts/regional-snapshot/snapshot-meta.mjs | 85 +++ .../transmission-templates.mjs | 129 ++++ .../regional-snapshot/trigger-evaluator.mjs | 134 ++++ scripts/regional-snapshot/triggers.config.mjs | 121 ++++ scripts/seed-bundle-derived-signals.mjs | 3 +- scripts/seed-regional-snapshots.mjs | 266 ++++++++ scripts/shared/iso2-to-region.json | 220 +++++++ shared/geography.d.ts | 50 ++ shared/geography.js | 279 ++++++++ shared/iso2-to-region.json | 220 +++++++ shared/regions.types.d.ts | 267 ++++++++ tests/regional-snapshot.test.mjs | 599 ++++++++++++++++++ ...e-p1-health-seed-meta-not-in-keys-loops.md | 61 ++ ...f-trigger-key-not-in-freshness-registry.md | 62 ++ ...plete-p1-zombie-freshness-registry-keys.md | 66 ++ ...iff-field-leaks-into-persisted-snapshot.md | 67 ++ ...doc-types-not-enforced-jsconfig-missing.md | 67 ++ ...losethreshold-inverted-for-lt-operators.md | 64 ++ ...tial-readlatestsnapshot-1600ms-overhead.md | 62 ++ ...p2-sequential-per-region-persist-1600ms.md | 62 ++ ...2-seeder-bypasses-runseed-gold-standard.md | 76 +++ ...g-p2-region-taxonomy-3-sources-of-truth.md | 67 ++ ...s-coupled-to-compute-modules-no-adapter.md | 90 +++ ...from-redis-unsanitized-xss-risk-phase-1.md | 77 +++ ...orontroller-on-rapid-region-pill-clicks.md | 72 +++ ...getforecasts-handler-no-cachedfetchjson.md | 58 ++ ...ns-find-duplicated-use-getregion-helper.md | 60 ++ ...-inputs-treated-as-fresh-confidence-bug.md | 61 ++ ...onsistent-unknown-region-error-handling.md | 57 ++ ...xtrakeywithmeta-positional-args-fragile.md | 52 ++ ...-p2-pipeline-non-atomic-partial-persist.md | 59 ++ ...rigger-watching-runs-on-delta-operators.md | 46 ++ ...2-regime-transition-driver-always-empty.md | 54 ++ ...ng-p2-orphan-scripts-shared-iso2-mirror.md | 58 ++ ...ngling-docs-internal-references-in-code.md | 65 ++ ...efetchforregion-silent-error-swallowing.md | 62 ++ ...-redundant-jsonstringify-casefile-loops.md | 56 ++ ...g-p3-various-helper-and-config-cleanups.md | 81 +++ todos/192-pending-p3-perf-micro-cleanups.md | 78 +++ 50 files changed, 5210 insertions(+), 2 deletions(-) create mode 100644 scripts/regional-snapshot/_helpers.mjs create mode 100644 scripts/regional-snapshot/actor-scoring.mjs create mode 100644 scripts/regional-snapshot/balance-vector.mjs create mode 100644 scripts/regional-snapshot/diff-snapshot.mjs create mode 100644 scripts/regional-snapshot/evidence-collector.mjs create mode 100644 scripts/regional-snapshot/freshness.mjs create mode 100644 scripts/regional-snapshot/persist-snapshot.mjs create mode 100644 scripts/regional-snapshot/regime-derivation.mjs create mode 100644 scripts/regional-snapshot/scenario-builder.mjs create mode 100644 scripts/regional-snapshot/snapshot-meta.mjs create mode 100644 scripts/regional-snapshot/transmission-templates.mjs create mode 100644 scripts/regional-snapshot/trigger-evaluator.mjs create mode 100644 scripts/regional-snapshot/triggers.config.mjs create mode 100644 scripts/seed-regional-snapshots.mjs create mode 100644 scripts/shared/iso2-to-region.json create mode 100644 shared/geography.d.ts create mode 100644 shared/geography.js create mode 100644 shared/iso2-to-region.json create mode 100644 shared/regions.types.d.ts create mode 100644 tests/regional-snapshot.test.mjs create mode 100644 todos/166-complete-p1-health-seed-meta-not-in-keys-loops.md create mode 100644 todos/167-complete-p1-oref-trigger-key-not-in-freshness-registry.md create mode 100644 todos/168-complete-p1-zombie-freshness-registry-keys.md create mode 100644 todos/169-complete-p1-diff-field-leaks-into-persisted-snapshot.md create mode 100644 todos/170-complete-p1-jsdoc-types-not-enforced-jsconfig-missing.md create mode 100644 todos/171-pending-p2-iscclosethreshold-inverted-for-lt-operators.md create mode 100644 todos/172-pending-p2-sequential-readlatestsnapshot-1600ms-overhead.md create mode 100644 todos/173-pending-p2-sequential-per-region-persist-1600ms.md create mode 100644 todos/174-pending-p2-seeder-bypasses-runseed-gold-standard.md create mode 100644 todos/175-pending-p2-region-taxonomy-3-sources-of-truth.md create mode 100644 todos/176-pending-p2-redis-keys-coupled-to-compute-modules-no-adapter.md create mode 100644 todos/177-pending-p2-stored-strings-from-redis-unsanitized-xss-risk-phase-1.md create mode 100644 todos/178-pending-p2-aborontroller-on-rapid-region-pill-clicks.md create mode 100644 todos/179-pending-p2-getforecasts-handler-no-cachedfetchjson.md create mode 100644 todos/180-pending-p2-regions-find-duplicated-use-getregion-helper.md create mode 100644 todos/181-pending-p2-undated-inputs-treated-as-fresh-confidence-bug.md create mode 100644 todos/182-pending-p2-inconsistent-unknown-region-error-handling.md create mode 100644 todos/183-pending-p2-writeextrakeywithmeta-positional-args-fragile.md create mode 100644 todos/184-pending-p2-pipeline-non-atomic-partial-persist.md create mode 100644 todos/185-pending-p2-trigger-watching-runs-on-delta-operators.md create mode 100644 todos/186-pending-p2-regime-transition-driver-always-empty.md create mode 100644 todos/187-pending-p2-orphan-scripts-shared-iso2-mirror.md create mode 100644 todos/188-pending-p2-dangling-docs-internal-references-in-code.md create mode 100644 todos/189-pending-p2-refetchforregion-silent-error-swallowing.md create mode 100644 todos/190-pending-p3-many-redundant-jsonstringify-casefile-loops.md create mode 100644 todos/191-pending-p3-various-helper-and-config-cleanups.md create mode 100644 todos/192-pending-p3-perf-micro-cleanups.md diff --git a/api/health.js b/api/health.js index 4eeb16651..97bd904a1 100644 --- a/api/health.js +++ b/api/health.js @@ -157,6 +157,7 @@ const STANDALONE_KEYS = { emberElectricity: 'energy:ember:v1:_all', resilienceIntervals: 'resilience:intervals:v1:US', sprPolicies: 'energy:spr-policies:v1', + regionalSnapshots: 'intelligence:regional-snapshots:summary:v1', }; const SEED_META = { @@ -225,6 +226,7 @@ const SEED_META = { blsSeries: { key: 'seed-meta:economic:bls-series', maxStaleMin: 2880 }, // daily seed; 2880min = 48h = 2x interval sanctionsPressure: { key: 'seed-meta:sanctions:pressure', maxStaleMin: 720 }, crossSourceSignals: { key: 'seed-meta:intelligence:cross-source-signals', maxStaleMin: 30 }, // 15min cron; 30min = 2x interval + regionalSnapshots: { key: 'seed-meta:intelligence:regional-snapshots', maxStaleMin: 720 }, // 6h cron via seed-bundle-derived-signals; 720min = 12h = 2x interval sanctionsEntities: { key: 'seed-meta:sanctions:entities', maxStaleMin: 1440 }, // 12h cron; 1440min = 24h = 2x interval radiationWatch: { key: 'seed-meta:radiation:observations', maxStaleMin: 30 }, groceryBasket: { key: 'seed-meta:economic:grocery-basket', maxStaleMin: 10080 }, // weekly seed; 10080 = 7 days diff --git a/scripts/jsconfig.json b/scripts/jsconfig.json index adf4c9484..1da870833 100644 --- a/scripts/jsconfig.json +++ b/scripts/jsconfig.json @@ -10,6 +10,13 @@ "maxNodeModuleJsDepth": 0, "skipLibCheck": true }, - "include": ["seed-forecasts.mjs", "seed-forecasts.types.d.ts"], + "include": [ + "seed-forecasts.mjs", + "seed-forecasts.types.d.ts", + "seed-regional-snapshots.mjs", + "regional-snapshot/**/*.mjs", + "../shared/regions.types.d.ts", + "../shared/geography.d.ts" + ], "exclude": [] } diff --git a/scripts/regional-snapshot/_helpers.mjs b/scripts/regional-snapshot/_helpers.mjs new file mode 100644 index 000000000..033df692b --- /dev/null +++ b/scripts/regional-snapshot/_helpers.mjs @@ -0,0 +1,45 @@ +// @ts-check +// Shared helpers for snapshot compute modules. + +/** Clamp a number to the [lo, hi] range. */ +export function clip(value, lo, hi) { + if (Number.isNaN(value) || !Number.isFinite(value)) return lo; + return Math.min(hi, Math.max(lo, value)); +} + +/** Safe numeric coercion with default fallback. */ +export function num(value, fallback = 0) { + const n = typeof value === 'string' ? parseFloat(value) : Number(value); + return Number.isFinite(n) ? n : fallback; +} + +/** Weighted average. Returns 0 if all weights are zero. */ +export function weightedAverage(items, valueFn, weightFn) { + let weighted = 0; + let total = 0; + for (const item of items) { + const w = weightFn(item); + weighted += valueFn(item) * w; + total += w; + } + return total > 0 ? weighted / total : 0; +} + +/** Percentile (0-100) of a numeric array. */ +export function percentile(values, p) { + if (!values.length) return 0; + const sorted = [...values].sort((a, b) => a - b); + const idx = (p / 100) * (sorted.length - 1); + const lo = Math.floor(idx); + const hi = Math.ceil(idx); + if (lo === hi) return sorted[lo]; + const frac = idx - lo; + return sorted[lo] * (1 - frac) + sorted[hi] * frac; +} + +/** Simple UUID v7-ish: time-ordered, sortable, no external deps. */ +export function generateSnapshotId() { + const t = Date.now().toString(16).padStart(12, '0'); + const r = Math.random().toString(16).slice(2, 14).padStart(12, '0'); + return `${t}-${r}`; +} diff --git a/scripts/regional-snapshot/actor-scoring.mjs b/scripts/regional-snapshot/actor-scoring.mjs new file mode 100644 index 000000000..852e611d5 --- /dev/null +++ b/scripts/regional-snapshot/actor-scoring.mjs @@ -0,0 +1,95 @@ +// @ts-check +// Actor scoring extracts ActorState entries from forecast case files. +// Phase 0: lightweight extraction. Phase 1+ adds dedicated actor tracking. + +import { clip, num } from './_helpers.mjs'; +import { REGIONS } from '../../shared/geography.js'; + +const ALIASES = { + iran: 'Iran', irgc: 'IRGC', tehran: 'Iran', + russia: 'Russia', kremlin: 'Russia', moscow: 'Russia', + china: 'China', prc: 'China', beijing: 'China', + 'united states': 'United States', usa: 'United States', us: 'United States', washington: 'United States', + israel: 'Israel', idf: 'Israel', + 'saudi arabia': 'Saudi Arabia', riyadh: 'Saudi Arabia', + nato: 'NATO', + hezbollah: 'Hezbollah', + hamas: 'Hamas', + houthis: 'Houthis', houthi: 'Houthis', ansarallah: 'Houthis', +}; + +/** + * @param {string} regionId + * @param {Record} sources + * @returns {{ actors: import('../../shared/regions.types.js').ActorState[]; edges: import('../../shared/regions.types.js').LeverageEdge[] }} + */ +export function scoreActors(regionId, sources) { + const region = REGIONS.find((r) => r.id === regionId); + if (!region) return { actors: [], edges: [] }; + + const fc = sources['forecast:predictions:v2']; + const forecasts = Array.isArray(fc?.predictions) ? fc.predictions : []; + const inRegion = forecasts.filter((f) => { + const fRegion = String(f?.region ?? '').toLowerCase(); + return fRegion.includes(region.forecastLabel.toLowerCase()); + }); + + const counts = new Map(); // canonical name -> { mentions, leverageDomains, evidenceIds } + for (const f of inRegion) { + const text = JSON.stringify(f?.caseFile ?? f?.signals ?? {}).toLowerCase(); + for (const [needle, canonical] of Object.entries(ALIASES)) { + if (text.includes(needle)) { + const entry = counts.get(canonical) ?? { mentions: 0, domains: new Set(), evidence: [] }; + entry.mentions += 1; + if (/sanction|trade/.test(text)) entry.domains.add('economic'); + if (/naval|missile|strike|military|fleet/.test(text)) entry.domains.add('military'); + if (/oil|gas|pipeline|energy|opec/.test(text)) entry.domains.add('energy'); + if (/diplomat|alliance|treaty|summit/.test(text)) entry.domains.add('diplomatic'); + if (/strait|chokepoint|maritime|shipping|naval/.test(text)) entry.domains.add('maritime'); + if (entry.evidence.length < 5 && f?.id) entry.evidence.push(`forecast:${f.id}`); + counts.set(canonical, entry); + } + } + } + + /** @type {import('../../shared/regions.types.js').ActorState[]} */ + const actors = []; + const totalMentions = [...counts.values()].reduce((s, e) => s + e.mentions, 0) || 1; + for (const [name, entry] of counts.entries()) { + const leverageScore = clip(entry.mentions / Math.max(5, totalMentions / 2), 0, 1); + const role = inferRole(name, entry); + actors.push({ + actor_id: name.toLowerCase().replace(/\s+/g, '-'), + name, + role, + leverage_domains: /** @type {import('../../shared/regions.types.js').ActorLeverageDomain[]} */ ([...entry.domains]), + leverage_score: round(leverageScore), + delta: 0, // No history in Phase 0 + evidence_ids: entry.evidence, + }); + } + + // Phase 0: no leverage edges (requires actor pair detection across forecasts) + return { + actors: actors.sort((a, b) => b.leverage_score - a.leverage_score).slice(0, 10), + edges: [], + }; +} + +/** + * @param {string} name + * @param {{ domains: Set }} entry + * @returns {import('../../shared/regions.types.js').ActorRole} + */ +function inferRole(name, entry) { + const aggressors = new Set(['Iran', 'IRGC', 'Russia', 'Houthis', 'Hamas', 'Hezbollah', 'China']); + const stabilizers = new Set(['United States', 'NATO', 'Saudi Arabia']); + if (aggressors.has(name) && entry.domains.has('military')) return 'aggressor'; + if (stabilizers.has(name)) return 'stabilizer'; + if (entry.domains.has('diplomatic')) return 'broker'; + return 'swing'; +} + +function round(n) { + return Math.round(n * 1000) / 1000; +} diff --git a/scripts/regional-snapshot/balance-vector.mjs b/scripts/regional-snapshot/balance-vector.mjs new file mode 100644 index 000000000..e7352eb1f --- /dev/null +++ b/scripts/regional-snapshot/balance-vector.mjs @@ -0,0 +1,382 @@ +// @ts-check +// Balance vector computation. Deterministic, no LLM. +// Mirrors the per-axis formulas in +// docs/internal/pro-regional-intelligence-appendix-scoring.md. + +import { clip, num, weightedAverage, percentile } from './_helpers.mjs'; +import { getRegionCountries, getRegionCorridors, countryCriticality, REGIONS } from '../../shared/geography.js'; +import iso3ToIso2Raw from '../../shared/iso3-to-iso2.json' with { type: 'json' }; + +/** @type {Record} */ +const ISO3_TO_ISO2 = iso3ToIso2Raw; + +const SCORING_VERSION = '1.0.0'; + +export { SCORING_VERSION }; + +/** + * @param {string} regionId + * @param {Record} sources - keyed by Redis key, see freshness.mjs + * @returns {{ vector: import('../../shared/regions.types.js').BalanceVector }} + */ +export function computeBalanceVector(regionId, sources) { + const region = REGIONS.find((r) => r.id === regionId); + if (!region) throw new Error(`Unknown region: ${regionId}`); + const countries = new Set(getRegionCountries(regionId)); + const corridors = getRegionCorridors(regionId); + + const pressuresOut = []; + const buffersOut = []; + + // ── Pressures ──────────────────────────────────────────────────────────── + const coercive = computeCoercivePressure(region, sources, pressuresOut); + const fragility = computeDomesticFragility(countries, sources, pressuresOut); + const capital = computeCapitalStress(countries, sources, pressuresOut); + const energyVuln = computeEnergyVulnerability(countries, sources, pressuresOut); + + // ── Buffers ────────────────────────────────────────────────────────────── + const alliance = computeAllianceCohesion(regionId, sources, buffersOut); + const maritime = computeMaritimeAccess(corridors, sources, buffersOut); + const energyLev = computeEnergyLeverage(countries, buffersOut); + + const pressureMean = (coercive + fragility + capital + energyVuln) / 4; + const bufferMean = (alliance + maritime + energyLev) / 3; + const netBalance = bufferMean - pressureMean; + + return { + vector: { + coercive_pressure: round(coercive), + domestic_fragility: round(fragility), + capital_stress: round(capital), + energy_vulnerability: round(energyVuln), + alliance_cohesion: round(alliance), + maritime_access: round(maritime), + energy_leverage: round(energyLev), + net_balance: round(netBalance), + pressures: pressuresOut, + buffers: buffersOut, + }, + }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Per-axis computations +// ──────────────────────────────────────────────────────────────────────────── + +function computeCoercivePressure(region, sources, drivers) { + // Cross-source signals scoped by theater label substring matching + const xss = sources['intelligence:cross-source-signals:v1']; + const signals = Array.isArray(xss?.signals) ? xss.signals : []; + const theaterLabels = region.theaters; // theater IDs are kebab-case; cross-source uses display names + const inRegion = signals.filter((s) => { + const t = String(s?.theater ?? '').toLowerCase(); + return theaterLabels.some((label) => t.includes(label.replace(/-/g, ' '))); + }); + const criticalCount = inRegion.filter((s) => /CRITICAL/i.test(String(s?.severity ?? ''))).length; + const highCount = inRegion.filter((s) => /HIGH/i.test(String(s?.severity ?? ''))).length; + + // ACLED conflict counts (region-scoped via country bbox - approximate via signal theater) + // For Phase 0 we approximate via cross-source signal counts since ACLED key not directly fetched. + const cSignal = clip(criticalCount * 0.4 + highCount * 0.15, 0, 1); + + // Forecast 'rising' count for military/conflict in region + const fc = sources['forecast:predictions:v2']; + const forecasts = Array.isArray(fc?.predictions) ? fc.predictions : []; + const risingMilitary = forecasts.filter((f) => { + const trend = String(f?.trend ?? '').toLowerCase(); + const domain = String(f?.domain ?? '').toLowerCase(); + const fRegion = String(f?.region ?? '').toLowerCase(); + return (trend === 'rising' || trend === 'escalating') && + (domain === 'military' || domain === 'conflict') && + fRegion.includes(region.forecastLabel.toLowerCase()); + }).length; + const cForecast = clip(risingMilitary / 5, 0, 1); + + // Vessel surge and conflict event surrogates default to mid-low when no data + const cVessel = 0; + const cConflict = clip(inRegion.length / 50, 0, 1); + + const score = 0.30 * cVessel + 0.30 * cSignal + 0.25 * cConflict + 0.15 * cForecast; + + if (cSignal > 0.05) { + drivers.push({ + axis: 'coercive_pressure', + description: `${criticalCount} critical, ${highCount} high cross-source signals in region`, + magnitude: round(cSignal), + evidence_ids: inRegion.slice(0, 5).map((s) => String(s?.id ?? `xss:${s?.type ?? 'unknown'}`)), + orientation: 'pressure', + }); + } + if (cForecast > 0) { + drivers.push({ + axis: 'coercive_pressure', + description: `${risingMilitary} rising military/conflict forecasts in region`, + magnitude: round(cForecast), + evidence_ids: [], + orientation: 'pressure', + }); + } + + return clip(score, 0, 1); +} + +function computeDomesticFragility(countries, sources, drivers) { + const cii = sources['risk:scores:sebuf:stale:v1']; + const ciiScores = Array.isArray(cii?.ciiScores) ? cii.ciiScores : []; + const inRegion = ciiScores.filter((s) => countries.has(String(s?.region ?? ''))); + if (!inRegion.length) return 0; + + // Per-country normalized score + const normPerCountry = inRegion.map((s) => ({ + iso: String(s.region), + norm: clip(num(s.combinedScore) / 100, 0, 1), + })); + + // Weighted base average + const baseAvg = weightedAverage( + normPerCountry, + (item) => item.norm, + (item) => countryCriticality(item.iso), + ); + // Tail amplification + const values = normPerCountry.map((c) => c.norm); + const tailP90 = percentile(values, 90); + const tailMax = Math.max(...values); + + const score = 0.4 * baseAvg + 0.4 * tailP90 + 0.2 * tailMax; + + // Top driver: the country with the highest weighted contribution + const top = normPerCountry + .map((c) => ({ ...c, contribution: c.norm * countryCriticality(c.iso) })) + .sort((a, b) => b.contribution - a.contribution)[0]; + if (top && top.norm > 0.3) { + drivers.push({ + axis: 'domestic_fragility', + description: `${top.iso} CII ${(top.norm * 100).toFixed(0)} (criticality ${countryCriticality(top.iso).toFixed(1)})`, + magnitude: round(top.contribution), + evidence_ids: [`cii:${top.iso}`], + orientation: 'pressure', + }); + } + + return clip(score, 0, 1); +} + +function computeCapitalStress(countries, sources, drivers) { + // economic:macro-signals:v1 — seed-economy.mjs emits verdict: 'BUY' | 'CASH' | 'UNKNOWN' + // BUY = bullish signals dominate (low stress) + // CASH = bearish, rotate to cash (high stress) + // UNKNOWN = missing/stale (treat as neutral) + const macro = sources['economic:macro-signals:v1']; + const verdict = String(macro?.verdict ?? '').toUpperCase(); + const cMacro = verdict === 'CASH' ? 1 : verdict === 'BUY' ? 0 : 0.5; + + // economic:national-debt:v1 shape: { entries: [{ iso3, debtToGdp, ... }] } + // debtToGdp is a PERCENTAGE (e.g., 110 for 110% of GDP), not a 0-1 fraction. + // Filter to region via iso3 -> iso2 lookup, then compute average debt percentage. + const debt = sources['economic:national-debt:v1']; + const debtEntries = Array.isArray(debt?.entries) ? debt.entries : []; + const inRegionDebt = debtEntries.filter((e) => { + const iso2 = ISO3_TO_ISO2[String(e?.iso3 ?? '')]; + return iso2 && countries.has(iso2); + }); + // Neutral baseline: 60%. Saturate at 140%+ (80 pct points above neutral). + const avgDebtPct = inRegionDebt.length + ? inRegionDebt.reduce((sum, e) => sum + num(e.debtToGdp), 0) / inRegionDebt.length + : 60; + const cDebt = clip((avgDebtPct - 60) / 80, 0, 1); + + // economic:stress-index:v1 shape: { compositeScore, label, components, ... } + // Single global object (US-based FRED composite on 0-100 scale), NOT per-country. + // Apply as a global overlay that scales every region's capital_stress equally. + const stress = sources['economic:stress-index:v1']; + const stressComposite = num(stress?.compositeScore); + const cStress = clip(stressComposite / 100, 0, 1); + + // Sanctions count proxy: not in default sources, leave at 0 for Phase 0. + const cSanctions = 0; + + const score = 0.25 * cMacro + 0.20 * cDebt + 0.30 * cStress + 0.25 * cSanctions; + + if (cMacro > 0.6) { + drivers.push({ + axis: 'capital_stress', + description: `Macro signals verdict: ${verdict}`, + magnitude: round(cMacro), + evidence_ids: ['macro:verdict'], + orientation: 'pressure', + }); + } + if (cDebt > 0.4) { + drivers.push({ + axis: 'capital_stress', + description: `Regional debt/GDP average: ${avgDebtPct.toFixed(0)}% across ${inRegionDebt.length} countries`, + magnitude: round(cDebt), + evidence_ids: ['debt:region-avg'], + orientation: 'pressure', + }); + } + if (cStress > 0.5) { + drivers.push({ + axis: 'capital_stress', + description: `Global stress index: ${stressComposite.toFixed(0)} (${stress?.label ?? 'n/a'})`, + magnitude: round(cStress), + evidence_ids: ['stress:composite'], + orientation: 'pressure', + }); + } + + return clip(score, 0, 1); +} + +function computeEnergyVulnerability(countries, sources, drivers) { + // energy:mix:v1:_all shape: Record + // Values are OWID PERCENTAGES (0-100), not 0-1 fractions. Field is + // `importShare`, not `imported`. Countries with null importShare are + // excluded from the average (not treated as zero). + const mix = sources['energy:mix:v1:_all']; + if (!mix || typeof mix !== 'object') return 0; + const entries = Object.entries(mix).filter(([iso]) => countries.has(iso)); + if (!entries.length) return 0; + + // Vulnerability = 0.5 * import share + 0.25 * (1 - storage proxy) + 0.25 * (1 - SPR proxy) + // Phase 0: only import share is reliably present per-country. + let totalImport = 0; + let validCount = 0; + for (const [, m] of entries) { + if (m == null || m.importShare == null) continue; + totalImport += clip(num(m.importShare) / 100, 0, 1); + validCount += 1; + } + const avgImport = validCount > 0 ? totalImport / validCount : 0; + + // Storage proxy from EU gas storage (single number for EU region) + const euGas = sources['economic:eu-gas-storage:v1']; + const months = Array.isArray(euGas?.months) ? euGas.months : []; + const latestFill = months.length ? num(months[months.length - 1]?.fillPct) / 100 : null; + const cStorage = latestFill !== null ? 1 - clip(latestFill / 0.8, 0, 1) : 0.5; + + // SPR proxy + const spr = sources['economic:spr:v1']; + const sprDays = num(spr?.daysOfCover, 90); + const cSpr = 1 - clip(sprDays / 90, 0, 1); + + const score = 0.5 * avgImport + 0.25 * cStorage + 0.25 * cSpr; + + if (avgImport > 0.4 && validCount > 0) { + drivers.push({ + axis: 'energy_vulnerability', + description: `Average import dependency ${(avgImport * 100).toFixed(0)}% across ${validCount} countries`, + magnitude: round(avgImport), + evidence_ids: ['energy:mix'], + orientation: 'pressure', + }); + } + + return clip(score, 0, 1); +} + +function computeAllianceCohesion(regionId, sources, drivers) { + // Phase 0: rough alliance signal from forecast actor lenses. + // No headline classification yet (deferred to Phase 1 LLM batch tagging). + const fc = sources['forecast:predictions:v2']; + const forecasts = Array.isArray(fc?.predictions) ? fc.predictions : []; + const region = REGIONS.find((r) => r.id === regionId); + const inRegion = forecasts.filter((f) => { + const fRegion = String(f?.region ?? '').toLowerCase(); + return fRegion.includes(String(region?.forecastLabel ?? '').toLowerCase()); + }); + const allianceRefs = inRegion.filter((f) => { + const cf = JSON.stringify(f?.caseFile ?? {}).toLowerCase(); + return /alliance|treaty|coordination|coalition|nato|gcc/.test(cf); + }).length; + + const cActor = clip(allianceRefs / 5, 0, 1); + // Baseline: assume neutral cohesion if no data + const score = 0.4 * cActor + 0.6 * 0.5; + + if (cActor > 0.2) { + drivers.push({ + axis: 'alliance_cohesion', + description: `${allianceRefs} forecast actor lenses reference alliance dynamics`, + magnitude: round(cActor), + evidence_ids: [], + orientation: 'buffer', + }); + } + + return clip(score, 0, 1); +} + +function computeMaritimeAccess(corridors, sources, drivers) { + if (!corridors.length) return 0.7; // Inland regions: assume neutral-good + + const chokepointData = sources['supply_chain:chokepoints:v4']; + const allCps = Array.isArray(chokepointData?.chokepoints) ? chokepointData.chokepoints : []; + const cpById = new Map(); + for (const cp of allCps) cpById.set(String(cp?.id ?? ''), cp); + + const transitData = sources['supply_chain:transit-summaries:v1']; + const summaries = transitData?.summaries ?? {}; + + let weightedSum = 0; + let totalWeight = 0; + + for (const corridor of corridors) { + if (!corridor.chokepointId) continue; + const cp = cpById.get(corridor.chokepointId); + if (!cp) continue; + + const threatLevel = String(cp?.threatLevel ?? cp?.status ?? 'normal').toLowerCase(); + const threatMap = { war_zone: 0.0, critical: 0.2, high: 0.4, elevated: 0.6, normal: 1.0 }; + const mThreat = threatMap[threatLevel] ?? 0.7; + + const summary = summaries[corridor.chokepointId]; + const wow = num(summary?.wowChangePct, 0); + const mThroughput = clip((100 + wow) / 120, 0, 1); // 0% change -> 0.83, +20% -> 1.0 + + const mCorridor = 0.6 * mThreat + 0.4 * mThroughput; + weightedSum += mCorridor * corridor.weight; + totalWeight += corridor.weight; + + if (mThreat < 0.6) { + drivers.push({ + axis: 'maritime_access', + description: `${corridor.label} threat level: ${threatLevel}`, + magnitude: round(1 - mThreat), + evidence_ids: [`chokepoint:${corridor.chokepointId}`], + orientation: 'buffer', + }); + } + } + + return totalWeight > 0 ? clip(weightedSum / totalWeight, 0, 1) : 0.7; +} + +function computeEnergyLeverage(countries, drivers) { + // Producer leverage = max across region's producers + const TOP_PRODUCERS = new Set(['SA', 'RU', 'US', 'IR', 'IQ', 'AE', 'CA', 'KW', 'QA', 'NG', 'NO', 'BR', 'MX', 'VE', 'AU']); + const inRegion = [...countries].filter((c) => TOP_PRODUCERS.has(c)); + if (!inRegion.length) return 0; + + // Phase 0: simple presence-based leverage. Sophistication arrives in Phase 1. + const baseLeverage = 0.6; + const score = inRegion.length >= 3 ? Math.min(1, baseLeverage + 0.1 * inRegion.length) : baseLeverage; + + drivers.push({ + axis: 'energy_leverage', + description: `${inRegion.length} top-15 producers in region: ${inRegion.join(', ')}`, + magnitude: round(score), + evidence_ids: inRegion.map((iso) => `producer:${iso}`), + orientation: 'buffer', + }); + + return clip(score, 0, 1); +} + +function round(n) { + return Math.round(n * 1000) / 1000; +} diff --git a/scripts/regional-snapshot/diff-snapshot.mjs b/scripts/regional-snapshot/diff-snapshot.mjs new file mode 100644 index 000000000..a05acf0ae --- /dev/null +++ b/scripts/regional-snapshot/diff-snapshot.mjs @@ -0,0 +1,126 @@ +// @ts-check +// Diff engine. Compares prev vs curr snapshot and returns a SnapshotDiff +// that drives all alert types. Single source of truth for state changes. + +const SCENARIO_JUMP_THRESHOLD = 0.15; +const LEVERAGE_SHIFT_THRESHOLD = 0.15; +const BUFFER_FAILURE_THRESHOLD = 0.20; + +/** + * @param {import('../../shared/regions.types.js').RegionalSnapshot | null} prev + * @param {import('../../shared/regions.types.js').RegionalSnapshot} curr + * @returns {import('../../shared/regions.types.js').SnapshotDiff} + */ +export function diffRegionalSnapshot(prev, curr) { + const diff = { + regime_changed: null, + scenario_jumps: [], + trigger_activations: [], + trigger_deactivations: [], + corridor_breaks: [], + leverage_shifts: [], + buffer_failures: [], + reroute_waves: null, + mobility_disruptions: [], + }; + + if (!prev) { + // First snapshot ever for this region: anything notable counts as a one-time mark. + if (curr.regime?.label && curr.regime.label !== 'calm') { + diff.regime_changed = { from: '', to: curr.regime.label }; + } + diff.trigger_activations = curr.triggers.active.map((t) => ({ id: t.id, description: t.description })); + return diff; + } + + // ── Regime ── + if (prev.regime?.label !== curr.regime?.label) { + diff.regime_changed = { from: prev.regime?.label ?? '', to: curr.regime.label }; + } + + // ── Scenario probability jumps (per horizon) ── + for (const currSet of curr.scenario_sets) { + const prevSet = prev.scenario_sets?.find((s) => s.horizon === currSet.horizon); + if (!prevSet) continue; + for (const currLane of currSet.lanes) { + const prevLane = prevSet.lanes.find((l) => l.name === currLane.name); + if (!prevLane) continue; + const delta = Math.abs(currLane.probability - prevLane.probability); + if (delta > SCENARIO_JUMP_THRESHOLD) { + diff.scenario_jumps.push({ + horizon: currSet.horizon, + lane: currLane.name, + from: prevLane.probability, + to: currLane.probability, + }); + } + } + } + + // ── Trigger activations / deactivations ── + const prevActive = new Set(prev.triggers.active.map((t) => t.id)); + const currActive = new Set(curr.triggers.active.map((t) => t.id)); + for (const t of curr.triggers.active) { + if (!prevActive.has(t.id)) diff.trigger_activations.push({ id: t.id, description: t.description }); + } + for (const t of prev.triggers.active) { + if (!currActive.has(t.id)) diff.trigger_deactivations.push({ id: t.id }); + } + + // ── Corridor breaks (severity escalation in transmission paths) ── + // Compared by chokepoint state via the maritime_access driver descriptions. + // Phase 0: detect via balance.maritime_access drop. + const prevMaritime = prev.balance?.maritime_access ?? 1; + const currMaritime = curr.balance?.maritime_access ?? 1; + if (prevMaritime - currMaritime > 0.3) { + diff.corridor_breaks.push({ + corridor_id: 'aggregate', + from: prevMaritime.toFixed(2), + to: currMaritime.toFixed(2), + }); + } + + // ── Leverage shifts ── + const prevActors = new Map((prev.actors ?? []).map((a) => [a.actor_id, a.leverage_score])); + for (const a of curr.actors ?? []) { + const prevScore = prevActors.get(a.actor_id) ?? 0; + const delta = a.leverage_score - prevScore; + if (Math.abs(delta) > LEVERAGE_SHIFT_THRESHOLD) { + diff.leverage_shifts.push({ + actor_id: a.actor_id, + from: prevScore, + to: a.leverage_score, + delta, + }); + } + } + + // ── Buffer failures ── + const bufferAxes = ['alliance_cohesion', 'maritime_access', 'energy_leverage']; + for (const axis of bufferAxes) { + const prevVal = prev.balance?.[axis] ?? 1; + const currVal = curr.balance?.[axis] ?? 1; + if (prevVal - currVal > BUFFER_FAILURE_THRESHOLD) { + diff.buffer_failures.push({ axis, from: prevVal, to: currVal }); + } + } + + // ── Reroute waves and mobility disruptions ── + // Phase 0: empty (mobility lane and corridor reroute tracking are Phase 2) + + return diff; +} + +/** + * Pick a trigger_reason from a SnapshotDiff in priority order. + * + * @param {import('../../shared/regions.types.js').SnapshotDiff} diff + * @returns {import('../../shared/regions.types.js').TriggerReason} + */ +export function inferTriggerReason(diff) { + if (diff.regime_changed) return 'regime_shift'; + if (diff.trigger_activations.length > 0) return 'trigger_activation'; + if (diff.corridor_breaks.length > 0) return 'corridor_break'; + if (diff.leverage_shifts.length > 0) return 'leverage_shift'; + return 'scheduled_6h'; +} diff --git a/scripts/regional-snapshot/evidence-collector.mjs b/scripts/regional-snapshot/evidence-collector.mjs new file mode 100644 index 000000000..435b31779 --- /dev/null +++ b/scripts/regional-snapshot/evidence-collector.mjs @@ -0,0 +1,105 @@ +// @ts-check +// Builds the evidence chain for a snapshot. Each evidence item is attributed +// to a theater (and corridor where applicable) and is referenced by ID from +// balance drivers, narrative sections, and triggers. + +import { num } from './_helpers.mjs'; +import { REGIONS } from '../../shared/geography.js'; + +const MAX_EVIDENCE_PER_SNAPSHOT = 30; + +/** + * @param {string} regionId + * @param {Record} sources + * @returns {import('../../shared/regions.types.js').EvidenceItem[]} + */ +export function collectEvidence(regionId, sources) { + const region = REGIONS.find((r) => r.id === regionId); + if (!region) return []; + + /** @type {import('../../shared/regions.types.js').EvidenceItem[]} */ + const out = []; + + // Cross-source signals + const xss = sources['intelligence:cross-source-signals:v1']?.signals; + if (Array.isArray(xss)) { + for (const s of xss) { + const theater = String(s?.theater ?? '').toLowerCase(); + if (!region.theaters.some((t) => theater.includes(t.replace(/-/g, ' ')))) continue; + out.push({ + id: String(s?.id ?? `xss:${out.length}`), + type: 'market_signal', + source: 'cross-source', + summary: String(s?.summary ?? s?.type ?? 'cross-source signal'), + confidence: num(s?.severityScore, 50) / 100, + observed_at: num(s?.detectedAt, Date.now()), + theater: String(s?.theater ?? ''), + corridor: '', + }); + } + } + + // CII spikes for region countries + const cii = sources['risk:scores:sebuf:stale:v1']?.ciiScores; + if (Array.isArray(cii)) { + const regionCountries = new Set(region.keyCountries); + for (const c of cii) { + if (!regionCountries.has(String(c?.region ?? ''))) continue; + if (num(c?.combinedScore) < 50) continue; + out.push({ + id: `cii:${c.region}`, + type: 'cii_spike', + source: 'risk-scores', + summary: `${c.region} CII ${num(c.combinedScore).toFixed(0)} (trend ${c.trend ?? 'STABLE'})`, + confidence: 0.9, + observed_at: num(c?.computedAt, Date.now()), + theater: '', + corridor: '', + }); + } + } + + // Chokepoint status changes for region's corridors + const cps = sources['supply_chain:chokepoints:v4']?.chokepoints; + if (Array.isArray(cps)) { + for (const cp of cps) { + const threat = String(cp?.threatLevel ?? '').toLowerCase(); + if (threat === 'normal' || threat === '') continue; + out.push({ + id: `chokepoint:${cp.id}`, + type: 'chokepoint_status', + source: 'supply-chain', + summary: `${cp?.name ?? cp?.id}: ${threat}`, + confidence: 0.95, + observed_at: Date.now(), + theater: '', + corridor: String(cp?.id ?? ''), + }); + } + } + + // Forecasts in region + const fc = sources['forecast:predictions:v2']?.predictions; + if (Array.isArray(fc)) { + for (const f of fc) { + const fRegion = String(f?.region ?? '').toLowerCase(); + if (!fRegion.includes(region.forecastLabel.toLowerCase())) continue; + if (num(f?.probability) < 0.3) continue; + out.push({ + id: `forecast:${f.id}`, + type: 'news_headline', + source: 'forecast', + summary: String(f?.title ?? 'forecast'), + confidence: num(f?.confidence, 0.5), + observed_at: num(f?.updatedAt, Date.now()), + theater: '', + corridor: '', + }); + } + } + + // Sort by recency, cap to limit + return out + .sort((a, b) => b.observed_at - a.observed_at) + .slice(0, MAX_EVIDENCE_PER_SNAPSHOT); +} diff --git a/scripts/regional-snapshot/freshness.mjs b/scripts/regional-snapshot/freshness.mjs new file mode 100644 index 000000000..6a5576313 --- /dev/null +++ b/scripts/regional-snapshot/freshness.mjs @@ -0,0 +1,88 @@ +// @ts-check +// Source freshness registry. Mirrors the table in +// docs/internal/pro-regional-intelligence-appendix-scoring.md "Source Freshness Registry". +// +// Each entry maps a Redis key (or key prefix) to its expected max-age. +// The snapshot writer marks inputs as stale or missing based on this table +// and feeds those flags into SnapshotMeta.snapshot_confidence. + +/** + * @typedef {object} SourceFreshnessSpec + * @property {string} key - Redis key (literal, no template variables) + * @property {number} maxAgeMin - Maximum acceptable age in minutes + * @property {string[]} feedsAxes - Which balance axes / sections this input drives + */ + +/** + * Only keys that compute modules actually consume via sources['...']. + * Keys must be added here in lockstep with new compute consumers, never + * speculatively. Drift between this list and the consumers is an alerting + * blind spot (a missing key drags down snapshot_confidence and a present + * key with no consumer wastes a Redis read). + * + * @type {SourceFreshnessSpec[]} + */ +export const FRESHNESS_REGISTRY = [ + { key: 'risk:scores:sebuf:stale:v1', maxAgeMin: 30, feedsAxes: ['domestic_fragility', 'coercive_pressure'] }, + { key: 'forecast:predictions:v2', maxAgeMin: 180, feedsAxes: ['scenarios', 'actors'] }, + { key: 'supply_chain:chokepoints:v4', maxAgeMin: 30, feedsAxes: ['maritime_access', 'corridors'] }, + { key: 'supply_chain:transit-summaries:v1', maxAgeMin: 30, feedsAxes: ['maritime_access'] }, + { key: 'intelligence:cross-source-signals:v1', maxAgeMin: 45, feedsAxes: ['coercive_pressure', 'evidence'] }, + { key: 'relay:oref:history:v1', maxAgeMin: 15, feedsAxes: ['coercive_pressure', 'triggers'] }, + { key: 'economic:macro-signals:v1', maxAgeMin: 60, feedsAxes: ['capital_stress'] }, + { key: 'economic:national-debt:v1', maxAgeMin: 10080, feedsAxes: ['capital_stress'] }, + { key: 'economic:stress-index:v1', maxAgeMin: 120, feedsAxes: ['capital_stress'] }, + { key: 'energy:mix:v1:_all', maxAgeMin: 50400, feedsAxes: ['energy_vulnerability'] }, + { key: 'economic:eu-gas-storage:v1', maxAgeMin: 2880, feedsAxes: ['energy_vulnerability'] }, + { key: 'economic:spr:v1', maxAgeMin: 10080, feedsAxes: ['energy_buffer'] }, +]; + +export const ALL_INPUT_KEYS = FRESHNESS_REGISTRY.map((s) => s.key); + +/** + * Classify each input as fresh, stale, or missing. + * @param {Record} payloads - Map of key -> raw value (or null) + * @returns {{ fresh: string[]; stale: string[]; missing: string[] }} + */ +export function classifyInputs(payloads) { + const fresh = []; + const stale = []; + const missing = []; + const now = Date.now(); + + for (const spec of FRESHNESS_REGISTRY) { + const payload = payloads[spec.key]; + if (payload === null || payload === undefined) { + missing.push(spec.key); + continue; + } + // Try to extract a timestamp from common shapes. + const ts = extractTimestamp(payload); + if (ts === null) { + // Present but undated -- treat as fresh (we cannot prove staleness). + fresh.push(spec.key); + continue; + } + const ageMin = (now - ts) / 60_000; + if (ageMin > spec.maxAgeMin) { + stale.push(spec.key); + } else { + fresh.push(spec.key); + } + } + return { fresh, stale, missing }; +} + +/** Pull a timestamp out of common payload shapes; null if none found. */ +function extractTimestamp(payload) { + if (typeof payload !== 'object' || payload === null) return null; + const obj = payload; + for (const field of ['fetchedAt', 'generatedAt', 'timestamp', 'updatedAt', 'lastUpdate']) { + if (typeof obj[field] === 'number') return obj[field]; + if (typeof obj[field] === 'string') { + const parsed = Date.parse(obj[field]); + if (Number.isFinite(parsed)) return parsed; + } + } + return null; +} diff --git a/scripts/regional-snapshot/persist-snapshot.mjs b/scripts/regional-snapshot/persist-snapshot.mjs new file mode 100644 index 000000000..c447b28e1 --- /dev/null +++ b/scripts/regional-snapshot/persist-snapshot.mjs @@ -0,0 +1,114 @@ +// @ts-check +// Idempotent persistence + index pruning + dedup guard. +// Implements the persist step of the seed pipeline. + +import { getRedisCredentials } from '../_seed-utils.mjs'; + +const SNAPSHOT_TTL_SECONDS = 90 * 24 * 60 * 60; // 90 days +const DEDUP_TTL_SECONDS = 900; // 15 min +const PRUNE_AGE_MS = 90 * 24 * 60 * 60 * 1000; + +/** + * Persist a snapshot atomically with idempotency guard and index pruning. + * Returns whether the persist actually happened (false = dedupe skip). + * + * @param {import('../../shared/regions.types.js').RegionalSnapshot} snapshot + * @returns {Promise<{ persisted: boolean; reason: string }>} + */ +export async function persistSnapshot(snapshot) { + const { url, token } = getRedisCredentials(); + if (!url || !token) { + return { persisted: false, reason: 'no-redis-credentials' }; + } + + const region = snapshot.region_id; + const snapshotId = snapshot.meta.snapshot_id; + const triggerReason = snapshot.meta.trigger_reason; + const timestamp = snapshot.generated_at; + const bucket = Math.floor(timestamp / (15 * 60_000)); // 15-min bucket + + // 1. Idempotency check via atomic SET NX EX (single round-trip; SETNX + EXPIRE + // would be a race that leaks a permanent dedup key on EXPIRE failure). + const dedupKey = `dedup:snapshot:v1:${region}:${triggerReason}:${bucket}`; + const dedupRes = await fetch(`${url}/pipeline`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify([['SET', dedupKey, snapshotId, 'EX', String(DEDUP_TTL_SECONDS), 'NX']]), + signal: AbortSignal.timeout(5_000), + }); + if (!dedupRes.ok) { + return { persisted: false, reason: `dedup-http-${dedupRes.status}` }; + } + const dedupJson = await dedupRes.json(); + // SET ... NX returns 'OK' on success, null when the key already exists. + if (!dedupJson?.[0] || dedupJson[0].result == null) { + return { persisted: false, reason: 'duplicate-bucket' }; + } + + // 2. Persist (single pipeline) + const json = JSON.stringify(snapshot); + const tsKey = `intelligence:snapshot:v1:${region}:${timestamp}`; + const idKey = `intelligence:snapshot-by-id:v1:${snapshotId}`; + const latestKey = `intelligence:snapshot:v1:${region}:latest`; + const indexKey = `intelligence:snapshot-index:v1:${region}`; + const liveKey = `intelligence:snapshot:v1:${region}:live`; + const pruneCutoff = Date.now() - PRUNE_AGE_MS; + + const pipeline = [ + ['SET', tsKey, json, 'EX', String(SNAPSHOT_TTL_SECONDS)], + ['SET', idKey, json, 'EX', String(SNAPSHOT_TTL_SECONDS)], + ['SET', latestKey, snapshotId, 'EX', String(SNAPSHOT_TTL_SECONDS)], + ['ZADD', indexKey, String(timestamp), snapshotId], + ['ZREMRANGEBYSCORE', indexKey, '-inf', `(${pruneCutoff}`], + ['DEL', liveKey], + ]; + + const pipeRes = await fetch(`${url}/pipeline`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(pipeline), + signal: AbortSignal.timeout(15_000), + }); + if (!pipeRes.ok) { + return { persisted: false, reason: `pipeline-http-${pipeRes.status}` }; + } + + return { persisted: true, reason: 'ok' }; +} + +/** + * Read the latest persisted snapshot for a region. Used by the diff engine + * (compares prev vs curr) and by tests. + * + * @param {string} regionId + * @returns {Promise} + */ +export async function readLatestSnapshot(regionId) { + const { url, token } = getRedisCredentials(); + if (!url || !token) return null; + + const latestKey = `intelligence:snapshot:v1:${regionId}:latest`; + const idRes = await fetch(`${url}/get/${encodeURIComponent(latestKey)}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(5_000), + }); + if (!idRes.ok) return null; + const idJson = await idRes.json(); + const snapshotId = idJson.result; + if (!snapshotId) return null; + + const snapKey = `intelligence:snapshot-by-id:v1:${snapshotId}`; + const snapRes = await fetch(`${url}/get/${encodeURIComponent(snapKey)}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(5_000), + }); + if (!snapRes.ok) return null; + const snapJson = await snapRes.json(); + if (!snapJson.result) return null; + + try { + return JSON.parse(snapJson.result); + } catch { + return null; + } +} diff --git a/scripts/regional-snapshot/regime-derivation.mjs b/scripts/regional-snapshot/regime-derivation.mjs new file mode 100644 index 000000000..9612f7e9e --- /dev/null +++ b/scripts/regional-snapshot/regime-derivation.mjs @@ -0,0 +1,40 @@ +// @ts-check +// Rule-based regime derivation. Mirrors the rule table in +// docs/internal/pro-regional-intelligence-upgrade.md. +// Pure function: takes a balance vector, returns a regime label. + +/** + * @param {import('../../shared/regions.types.js').BalanceVector} balance + * @returns {import('../../shared/regions.types.js').RegimeLabel} + */ +export function deriveRegime(balance) { + const coercive = balance.coercive_pressure; + const alliance = balance.alliance_cohesion; + const net = balance.net_balance; + + if (coercive > 0.8 && net < -0.4) return 'escalation_ladder'; + if (coercive > 0.6 && alliance < 0.3) return 'fragmentation_risk'; + if (coercive > 0.5 && net > -0.1) return 'coercive_stalemate'; + if (net > 0.1 && coercive > 0.3) return 'managed_deescalation'; + if (net < -0.1) return 'stressed_equilibrium'; + return 'calm'; +} + +/** + * Build a RegimeState by comparing the new balance to a previous regime label. + * + * @param {import('../../shared/regions.types.js').BalanceVector} balance + * @param {import('../../shared/regions.types.js').RegimeLabel | ''} previousLabel + * @param {string} transitionDriver + * @returns {import('../../shared/regions.types.js').RegimeState} + */ +export function buildRegimeState(balance, previousLabel, transitionDriver = '') { + const label = deriveRegime(balance); + const transitioned = label !== previousLabel; + return { + label, + previous_label: previousLabel, + transitioned_at: transitioned ? Date.now() : 0, + transition_driver: transitioned ? transitionDriver : '', + }; +} diff --git a/scripts/regional-snapshot/scenario-builder.mjs b/scripts/regional-snapshot/scenario-builder.mjs new file mode 100644 index 000000000..8175a8290 --- /dev/null +++ b/scripts/regional-snapshot/scenario-builder.mjs @@ -0,0 +1,93 @@ +// @ts-check +// Builds scenario sets per horizon, normalized so lane probabilities sum to 1.0. +// See docs/internal/pro-regional-intelligence-appendix-scoring.md +// "Scenario Set Normalization". + +import { num } from './_helpers.mjs'; +import { REGIONS } from '../../shared/geography.js'; + +/** @type {import('../../shared/regions.types.js').ScenarioHorizon[]} */ +const HORIZONS = ['24h', '7d', '30d']; +/** @type {import('../../shared/regions.types.js').ScenarioName[]} */ +const LANE_NAMES = ['base', 'escalation', 'containment', 'fragmentation']; + +/** + * @param {string} regionId + * @param {Record} sources + * @param {import('../../shared/regions.types.js').TriggerLadder} triggers + * @returns {import('../../shared/regions.types.js').ScenarioSet[]} + */ +export function buildScenarioSets(regionId, sources, triggers) { + const region = REGIONS.find((r) => r.id === regionId); + if (!region) return []; + + const fc = sources['forecast:predictions:v2']; + const forecasts = Array.isArray(fc?.predictions) ? fc.predictions : []; + const inRegion = forecasts.filter((f) => { + const fRegion = String(f?.region ?? '').toLowerCase(); + return fRegion.includes(region.forecastLabel.toLowerCase()); + }); + + return HORIZONS.map((horizon) => { + const lanes = LANE_NAMES.map((name) => buildLane(name, horizon, inRegion, triggers)); + return { horizon, lanes: normalize(lanes) }; + }); +} + +function buildLane(name, horizon, forecasts, triggers) { + // Raw score sources: + // 1. Forecasts whose trend matches the lane direction in this horizon + // 2. Active trigger count for this lane (each adds 0.1 boost) + // 3. Default base case score for stability + let rawScore = name === 'base' ? 0.4 : 0.1; + + for (const f of forecasts) { + const fHorizon = String(f?.timeHorizon ?? '').toLowerCase(); + if (!matchesHorizon(fHorizon, horizon)) continue; + const trend = String(f?.trend ?? '').toLowerCase(); + const prob = num(f?.probability, 0); + if (name === 'escalation' && (trend === 'rising' || trend === 'escalating')) rawScore += prob * 0.5; + if (name === 'containment' && (trend === 'falling' || trend === 'de-escalating')) rawScore += prob * 0.5; + if (name === 'base' && trend === 'stable') rawScore += prob * 0.3; + if (name === 'fragmentation') { + const cf = JSON.stringify(f?.caseFile ?? {}).toLowerCase(); + if (/fragment|collapse|breakdown/.test(cf)) rawScore += prob * 0.4; + } + } + + const activeForLane = triggers.active.filter((t) => t.scenario_lane === name).length; + rawScore += activeForLane * 0.1; + + const triggerIds = [ + ...triggers.active.filter((t) => t.scenario_lane === name).map((t) => t.id), + ...triggers.watching.filter((t) => t.scenario_lane === name).map((t) => t.id), + ]; + + return { + name, + probability: Math.max(0, rawScore), + trigger_ids: triggerIds, + consequences: [], + transmissions: [], + }; +} + +function matchesHorizon(forecastHorizon, targetHorizon) { + if (!forecastHorizon) return targetHorizon === '7d'; + if (targetHorizon === '24h') return /h24|24h|day|24h/.test(forecastHorizon); + if (targetHorizon === '7d') return /d7|7d|week|d7/.test(forecastHorizon); + if (targetHorizon === '30d') return /d30|30d|month|d30/.test(forecastHorizon); + return false; +} + +function normalize(lanes) { + const total = lanes.reduce((sum, l) => sum + l.probability, 0); + if (total === 0) { + return lanes.map((l) => ({ ...l, probability: l.name === 'base' ? 1.0 : 0.0 })); + } + return lanes.map((l) => ({ ...l, probability: round(l.probability / total) })); +} + +function round(n) { + return Math.round(n * 1000) / 1000; +} diff --git a/scripts/regional-snapshot/snapshot-meta.mjs b/scripts/regional-snapshot/snapshot-meta.mjs new file mode 100644 index 000000000..1e2954be9 --- /dev/null +++ b/scripts/regional-snapshot/snapshot-meta.mjs @@ -0,0 +1,85 @@ +// @ts-check +// Builds SnapshotMeta. Confidence is computed from input freshness + +// completeness, then merged with model/scoring/geography versions. +// +// Phase 0 builds pre-meta (no narrative, no snapshot_id) and the seed entry +// fills in the final fields after compute completes. + +import { classifyInputs } from './freshness.mjs'; + +export const MODEL_VERSION = '0.1.0'; + +/** + * @param {Record} sources + * @returns {{ + * pre: { + * model_version: string; + * scoring_version: string; + * geography_version: string; + * snapshot_confidence: number; + * missing_inputs: string[]; + * stale_inputs: string[]; + * valid_until: number; + * trigger_reason: 'scheduled_6h'; + * }; + * classification: { fresh: string[]; stale: string[]; missing: string[] }; + * }} + */ +export function buildPreMeta(sources, scoringVersion, geographyVersion) { + const classification = classifyInputs(sources); + const totalInputs = classification.fresh.length + classification.stale.length + classification.missing.length; + const cCompleteness = totalInputs > 0 + ? (totalInputs - classification.missing.length) / totalInputs + : 0; + const presentInputs = totalInputs - classification.missing.length; + const cFreshness = presentInputs > 0 + ? (presentInputs - classification.stale.length) / presentInputs + : 0; + const snapshot_confidence = round(0.6 * cCompleteness + 0.4 * cFreshness); + + return { + pre: { + model_version: MODEL_VERSION, + scoring_version: scoringVersion, + geography_version: geographyVersion, + snapshot_confidence, + missing_inputs: classification.missing, + stale_inputs: classification.stale, + valid_until: Date.now() + 6 * 60 * 60 * 1000, // 6h + trigger_reason: 'scheduled_6h', + }, + classification, + }; +} + +/** + * Merge pre-meta with the fields that only become available after compute. + * + * @param {ReturnType['pre']} preMeta + * @param {{ + * snapshot_id: string; + * trigger_reason: import('../../shared/regions.types.js').TriggerReason; + * narrative_provider?: string; + * narrative_model?: string; + * }} finalFields + * @returns {import('../../shared/regions.types.js').SnapshotMeta} + */ +export function buildFinalMeta(preMeta, finalFields) { + return { + snapshot_id: finalFields.snapshot_id, + model_version: preMeta.model_version, + scoring_version: preMeta.scoring_version, + geography_version: preMeta.geography_version, + snapshot_confidence: preMeta.snapshot_confidence, + missing_inputs: preMeta.missing_inputs, + stale_inputs: preMeta.stale_inputs, + valid_until: preMeta.valid_until, + trigger_reason: finalFields.trigger_reason, + narrative_provider: finalFields.narrative_provider ?? '', + narrative_model: finalFields.narrative_model ?? '', + }; +} + +function round(n) { + return Math.round(n * 1000) / 1000; +} diff --git a/scripts/regional-snapshot/transmission-templates.mjs b/scripts/regional-snapshot/transmission-templates.mjs new file mode 100644 index 000000000..6e3e03d11 --- /dev/null +++ b/scripts/regional-snapshot/transmission-templates.mjs @@ -0,0 +1,129 @@ +// @ts-check +// Pre-built transmission path templates. ~15 covering major corridors. +// Each template is matched at runtime against active triggers and corridor +// status, then enriched with live data (current rates, prices) when available. + +const TEMPLATE_VERSION = '1.0.0'; + +export { TEMPLATE_VERSION }; + +/** @type {Array<{ + * id: string; + * trigger: string; + * corridorId: string; + * steps: Array<{ start: string; mechanism: string; end: string; severity: 'critical'|'high'|'medium'|'low'; latencyHours: number; assetClass: string; magnitudeLow: number; magnitudeHigh: number; magnitudeUnit: string; confidence: number }>; + * affectedRegions: string[]; + * }>} + */ +export const TRANSMISSION_TEMPLATES = [ + { + id: 'hormuz_blockade', + trigger: 'hormuz_transit_drop', + corridorId: 'hormuz', + affectedRegions: ['mena', 'east-asia', 'south-asia', 'europe'], + steps: [ + { start: 'Hormuz transit drop', mechanism: 'tanker insurance premiums spike', end: 'tanker rates +200-400%', severity: 'critical', latencyHours: 12, assetClass: 'shipping', magnitudeLow: 200, magnitudeHigh: 400, magnitudeUnit: 'pct', confidence: 0.90 }, + { start: 'Tanker rates spike', mechanism: 'crude risk premium widens', end: 'Brent +$10-25/bbl', severity: 'critical', latencyHours: 24, assetClass: 'crude', magnitudeLow: 10, magnitudeHigh: 25, magnitudeUnit: 'usd_bbl', confidence: 0.85 }, + { start: 'Brent spikes', mechanism: 'refinery input costs rise', end: 'Asian gasoline/diesel margins +15-30%', severity: 'high', latencyHours: 72, assetClass: 'refined_products', magnitudeLow: 15, magnitudeHigh: 30, magnitudeUnit: 'pct', confidence: 0.70 }, + { start: 'Crude spikes', mechanism: 'SPR coordinated release absorbs demand', end: 'price ceiling for ~30d', severity: 'medium', latencyHours: 96, assetClass: 'crude', magnitudeLow: -10, magnitudeHigh: 0, magnitudeUnit: 'usd_bbl', confidence: 0.60 }, + ], + }, + { + id: 'red_sea_rerouting', + trigger: 'red_sea_critical', + corridorId: 'babelm', + affectedRegions: ['mena', 'europe', 'east-asia', 'sub-saharan-africa'], + steps: [ + { start: 'Bab el-Mandeb threat critical', mechanism: 'shipping diverts around Cape of Good Hope', end: 'Asia-EU transit +10-14 days', severity: 'high', latencyHours: 24, assetClass: 'container', magnitudeLow: 10, magnitudeHigh: 14, magnitudeUnit: 'days', confidence: 0.85 }, + { start: 'Container rerouting', mechanism: 'spot rates spike', end: 'Asia-EU container rates +$2000-4000/TEU', severity: 'high', latencyHours: 72, assetClass: 'container', magnitudeLow: 2000, magnitudeHigh: 4000, magnitudeUnit: 'usd_teu', confidence: 0.85 }, + { start: 'Longer voyages', mechanism: 'bunker fuel demand rises', end: 'bunker prices +8-12%', severity: 'medium', latencyHours: 96, assetClass: 'bunker', magnitudeLow: 8, magnitudeHigh: 12, magnitudeUnit: 'pct', confidence: 0.75 }, + { start: 'Transit cost increase', mechanism: 'EU consumer goods margin pressure', end: 'EU retail inflation +0.3-0.5pp', severity: 'medium', latencyHours: 720, assetClass: 'cpi', magnitudeLow: 0.3, magnitudeHigh: 0.5, magnitudeUnit: 'pp', confidence: 0.50 }, + ], + }, + { + id: 'taiwan_strait_tension', + trigger: 'taiwan_tension_high', + corridorId: 'taiwan-strait', + affectedRegions: ['east-asia', 'north-america', 'europe'], + steps: [ + { start: 'Taiwan Strait threat elevated', mechanism: 'semiconductor supply risk re-priced', end: 'Asian semiconductor stocks -5% to -12%', severity: 'high', latencyHours: 12, assetClass: 'equity', magnitudeLow: -12, magnitudeHigh: -5, magnitudeUnit: 'pct', confidence: 0.75 }, + { start: 'Supply chain de-risking accelerates', mechanism: 'global capex revision toward diversification', end: 'tech sector capex +$20-50B annualized', severity: 'medium', latencyHours: 720, assetClass: 'capex', magnitudeLow: 20, magnitudeHigh: 50, magnitudeUnit: 'usd_b', confidence: 0.60 }, + { start: 'Strait tensions', mechanism: 'East Asia container rates spike', end: 'TPEB rates +30-60%', severity: 'high', latencyHours: 48, assetClass: 'container', magnitudeLow: 30, magnitudeHigh: 60, magnitudeUnit: 'pct', confidence: 0.70 }, + ], + }, + { + id: 'iran_cii_escalation', + trigger: 'iran_cii_spike', + corridorId: 'hormuz', + affectedRegions: ['mena', 'east-asia', 'europe'], + steps: [ + { start: 'Iran instability spike', mechanism: 'regional risk premium widens', end: 'Brent +$3-8/bbl', severity: 'high', latencyHours: 6, assetClass: 'crude', magnitudeLow: 3, magnitudeHigh: 8, magnitudeUnit: 'usd_bbl', confidence: 0.80 }, + { start: 'Iran instability spike', mechanism: 'gold safe-haven bid', end: 'gold +1-3%', severity: 'medium', latencyHours: 6, assetClass: 'metals', magnitudeLow: 1, magnitudeHigh: 3, magnitudeUnit: 'pct', confidence: 0.70 }, + ], + }, + { + id: 'russia_naval_baltic', + trigger: 'russia_naval_buildup', + corridorId: 'danish', + affectedRegions: ['europe'], + steps: [ + { start: 'Russian naval buildup in Baltic', mechanism: 'NATO defense spending re-rated', end: 'EU defense stocks +5-15%', severity: 'medium', latencyHours: 48, assetClass: 'equity', magnitudeLow: 5, magnitudeHigh: 15, magnitudeUnit: 'pct', confidence: 0.65 }, + { start: 'Baltic tension', mechanism: 'gas pipeline risk premium rises', end: 'TTF +5-15%', severity: 'high', latencyHours: 24, assetClass: 'gas', magnitudeLow: 5, magnitudeHigh: 15, magnitudeUnit: 'pct', confidence: 0.70 }, + ], + }, + { + id: 'european_capital_stress', + trigger: 'european_capital_stress', + corridorId: 'bosphorus', + affectedRegions: ['europe'], + steps: [ + { start: 'European capital stress breaches threshold', mechanism: 'sovereign spreads widen', end: 'periphery vs Bund +30-80bp', severity: 'high', latencyHours: 48, assetClass: 'fx', magnitudeLow: 30, magnitudeHigh: 80, magnitudeUnit: 'basis_points', confidence: 0.75 }, + { start: 'Sovereign stress', mechanism: 'EUR weakens vs USD', end: 'EUR/USD -2% to -5%', severity: 'medium', latencyHours: 72, assetClass: 'fx', magnitudeLow: -5, magnitudeHigh: -2, magnitudeUnit: 'pct', confidence: 0.65 }, + ], + }, + { + id: 'mena_coercive_general', + trigger: 'mena_coercive_high', + corridorId: 'hormuz', + affectedRegions: ['mena', 'east-asia', 'europe'], + steps: [ + { start: 'MENA coercive pressure spikes', mechanism: 'broad regional risk-off', end: 'Brent +$5-12/bbl', severity: 'high', latencyHours: 12, assetClass: 'crude', magnitudeLow: 5, magnitudeHigh: 12, magnitudeUnit: 'usd_bbl', confidence: 0.75 }, + ], + }, +]; + +/** + * Filter templates that should activate given the active triggers and region. + * + * @param {string} regionId + * @param {import('../../shared/regions.types.js').TriggerLadder} triggers + * @returns {import('../../shared/regions.types.js').TransmissionPath[]} + */ +export function resolveTransmissions(regionId, triggers) { + const activeIds = new Set(triggers.active.map((t) => t.id)); + /** @type {import('../../shared/regions.types.js').TransmissionPath[]} */ + const out = []; + for (const tpl of TRANSMISSION_TEMPLATES) { + if (!activeIds.has(tpl.trigger)) continue; + if (!tpl.affectedRegions.includes(regionId)) continue; + for (const step of tpl.steps) { + out.push({ + start: step.start, + mechanism: step.mechanism, + end: step.end, + severity: step.severity, + corridor_id: tpl.corridorId, + confidence: step.confidence, + latency_hours: step.latencyHours, + impacted_asset_class: step.assetClass, + impacted_regions: /** @type {import('../../shared/regions.types.js').RegionId[]} */ (tpl.affectedRegions), + magnitude_low: step.magnitudeLow, + magnitude_high: step.magnitudeHigh, + magnitude_unit: step.magnitudeUnit, + template_id: tpl.id, + template_version: TEMPLATE_VERSION, + }); + } + } + return out; +} diff --git a/scripts/regional-snapshot/trigger-evaluator.mjs b/scripts/regional-snapshot/trigger-evaluator.mjs new file mode 100644 index 000000000..71252be8b --- /dev/null +++ b/scripts/regional-snapshot/trigger-evaluator.mjs @@ -0,0 +1,134 @@ +// @ts-check +// Evaluates structured trigger thresholds against current snapshot inputs. +// Each trigger maps to one of three states: active, watching, or dormant. + +import { num } from './_helpers.mjs'; +import { TRIGGER_DEFS } from './triggers.config.mjs'; + +/** + * @param {string} regionId + * @param {Record} sources + * @param {import('../../shared/regions.types.js').BalanceVector} balance + * @returns {import('../../shared/regions.types.js').TriggerLadder} + */ +export function evaluateTriggers(regionId, sources, balance) { + const active = []; + const watching = []; + const dormant = []; + + for (const def of TRIGGER_DEFS) { + if (def.regionId !== regionId) continue; + + const metricValue = resolveMetric(def.threshold.metric, sources, balance, regionId); + if (metricValue === null) { + dormant.push(buildTrigger(def, false)); + continue; + } + + const passes = evaluateThreshold(metricValue, def.threshold); + if (passes === true) { + active.push(buildTrigger(def, true)); + } else if (isCloseToThreshold(metricValue, def.threshold)) { + watching.push(buildTrigger(def, false)); + } else { + dormant.push(buildTrigger(def, false)); + } + } + + return { active, watching, dormant }; +} + +function buildTrigger(def, activated) { + return { + id: def.id, + description: def.description, + threshold: def.threshold, + activated, + activated_at: activated ? Date.now() : 0, + scenario_lane: def.scenario_lane, + evidence_ids: [], + }; +} + +/** + * Resolve a metric reference like `chokepoint:hormuz:threat_level` against + * the current snapshot inputs. Returns null if the metric is unavailable. + */ +function resolveMetric(metric, sources, balance, regionId) { + // balance:{region}:{axis} + if (metric.startsWith('balance:')) { + const parts = metric.split(':'); + if (parts.length !== 3) return null; + const [, mRegion, axis] = parts; + if (mRegion !== regionId) return null; + const v = balance[axis]; + return typeof v === 'number' ? v : null; + } + + // chokepoint:{id}:{field} + if (metric.startsWith('chokepoint:')) { + const parts = metric.split(':'); + const [, cpId, field] = parts; + const cps = sources['supply_chain:chokepoints:v4']?.chokepoints; + const cp = Array.isArray(cps) ? cps.find((c) => c?.id === cpId) : null; + if (!cp) return null; + if (field === 'threat_level') { + const map = { war_zone: 1.0, critical: 0.8, high: 0.6, elevated: 0.4, normal: 0.0 }; + return map[String(cp.threatLevel ?? 'normal').toLowerCase()] ?? 0; + } + if (field === 'transit_count') { + const summaries = sources['supply_chain:transit-summaries:v1']?.summaries ?? {}; + return num(summaries[cpId]?.todayTotal, 0); + } + return null; + } + + // cii:{iso2}:{field} + if (metric.startsWith('cii:')) { + const parts = metric.split(':'); + const [, iso] = parts; + const cii = sources['risk:scores:sebuf:stale:v1']?.ciiScores; + if (!Array.isArray(cii)) return null; + const entry = cii.find((s) => s?.region === iso); + return entry ? num(entry.combinedScore) : null; + } + + // oref:active_alerts_count + // Reads the canonical relay:oref:history:v1 key shape: + // { history, historyCount24h, totalHistoryCount, activeAlertCount, persistedAt } + // Prefer activeAlertCount when present (live count), fall back to historyCount24h + // (rolling 24h window) so the trigger still fires after the relay restarts. + if (metric === 'oref:active_alerts_count') { + const oref = sources['relay:oref:history:v1']; + if (!oref || typeof oref !== 'object') return 0; + if (typeof oref.activeAlertCount === 'number') return oref.activeAlertCount; + if (typeof oref.historyCount24h === 'number') return oref.historyCount24h; + return 0; + } + + // theater:* metrics not yet implemented in Phase 0 + return null; +} + +function evaluateThreshold(value, threshold) { + switch (threshold.operator) { + case 'gt': return value > threshold.value; + case 'gte': return value >= threshold.value; + case 'lt': return value < threshold.value; + case 'lte': return value <= threshold.value; + // delta_gt and delta_lt require historical snapshots. Phase 0 has no + // history yet, so these operators are dormant by design. Phase 1 + // populates a baseline reader and re-enables them. + case 'delta_gt': return false; + case 'delta_lt': return false; + default: return false; + } +} + +function isCloseToThreshold(value, threshold) { + // 80% of the threshold counts as "watching" + const target = threshold.value; + if (target === 0) return false; + const ratio = value / target; + return ratio > 0.8 && ratio < 1.0; +} diff --git a/scripts/regional-snapshot/triggers.config.mjs b/scripts/regional-snapshot/triggers.config.mjs new file mode 100644 index 000000000..d6c393fce --- /dev/null +++ b/scripts/regional-snapshot/triggers.config.mjs @@ -0,0 +1,121 @@ +// @ts-check +// Trigger threshold configuration. Each trigger is a structured assertion +// against a metric, evaluated by trigger-evaluator.mjs. +// +// See docs/internal/pro-regional-intelligence-appendix-scoring.md +// "Trigger Threshold Examples" for the canonical table. + +/** @type {Array<{ + * id: string; + * description: string; + * regionId: string; + * scenario_lane: 'base' | 'escalation' | 'containment' | 'fragmentation'; + * threshold: import('../../shared/regions.types.js').TriggerThreshold; + * }>} + */ +export const TRIGGER_DEFS = [ + { + id: 'hormuz_transit_drop', + description: 'Hormuz transit count drops sharply vs 7d trailing avg', + regionId: 'mena', + scenario_lane: 'escalation', + threshold: { + metric: 'chokepoint:hormuz:transit_count', + operator: 'delta_lt', + value: -0.20, + window_minutes: 1440, + baseline: 'trailing_7d', + }, + }, + { + id: 'iran_cii_spike', + description: 'Iran CII jumps significantly vs 7d trailing avg', + regionId: 'mena', + scenario_lane: 'escalation', + threshold: { + metric: 'cii:IR:combined_score', + operator: 'delta_gt', + value: 15, + window_minutes: 720, + baseline: 'trailing_7d', + }, + }, + { + id: 'red_sea_critical', + description: 'Bab el-Mandeb threat level reaches critical', + regionId: 'mena', + scenario_lane: 'escalation', + threshold: { + metric: 'chokepoint:babelm:threat_level', + operator: 'gte', + value: 0.8, + window_minutes: 60, + baseline: 'fixed', + }, + }, + { + id: 'mena_coercive_high', + description: 'MENA coercive pressure breaches threshold', + regionId: 'mena', + scenario_lane: 'escalation', + threshold: { + metric: 'balance:mena:coercive_pressure', + operator: 'gte', + value: 0.7, + window_minutes: 360, + baseline: 'fixed', + }, + }, + { + id: 'oref_cluster', + description: 'OREF active alert cluster', + regionId: 'mena', + scenario_lane: 'escalation', + threshold: { + metric: 'oref:active_alerts_count', + operator: 'gt', + value: 10, + window_minutes: 60, + baseline: 'fixed', + }, + }, + { + id: 'taiwan_tension_high', + description: 'Taiwan Strait threat level elevated', + regionId: 'east-asia', + scenario_lane: 'escalation', + threshold: { + metric: 'chokepoint:taiwan_strait:threat_level', + operator: 'gte', + value: 0.6, + window_minutes: 120, + baseline: 'fixed', + }, + }, + { + id: 'russia_naval_buildup', + description: 'Russian fleet concentration in Eastern Europe theater', + regionId: 'europe', + scenario_lane: 'escalation', + threshold: { + metric: 'theater:eastern_europe:russia_vessel_count', + operator: 'delta_gt', + value: 5, + window_minutes: 1440, + baseline: 'trailing_30d', + }, + }, + { + id: 'european_capital_stress', + description: 'European capital stress axis breaches threshold', + regionId: 'europe', + scenario_lane: 'fragmentation', + threshold: { + metric: 'balance:europe:capital_stress', + operator: 'gte', + value: 0.7, + window_minutes: 360, + baseline: 'fixed', + }, + }, +]; diff --git a/scripts/seed-bundle-derived-signals.mjs b/scripts/seed-bundle-derived-signals.mjs index 4a483f7aa..20a794829 100644 --- a/scripts/seed-bundle-derived-signals.mjs +++ b/scripts/seed-bundle-derived-signals.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node -import { runBundle, MIN } from './_bundle-runner.mjs'; +import { runBundle, MIN, HOUR } from './_bundle-runner.mjs'; await runBundle('derived-signals', [ { label: 'Correlation', script: 'seed-correlation.mjs', seedMetaKey: 'correlation:cards', intervalMs: 5 * MIN, timeoutMs: 60_000 }, { label: 'Cross-Source-Signals', script: 'seed-cross-source-signals.mjs', seedMetaKey: 'intelligence:cross-source-signals', intervalMs: 15 * MIN, timeoutMs: 120_000 }, + { label: 'Regional-Snapshots', script: 'seed-regional-snapshots.mjs', seedMetaKey: 'intelligence:regional-snapshots', intervalMs: 6 * HOUR, timeoutMs: 180_000 }, ]); diff --git a/scripts/seed-regional-snapshots.mjs b/scripts/seed-regional-snapshots.mjs new file mode 100644 index 000000000..160085333 --- /dev/null +++ b/scripts/seed-regional-snapshots.mjs @@ -0,0 +1,266 @@ +#!/usr/bin/env node +// @ts-check +/** + * Regional Intelligence snapshot seeder. + * + * Computes a RegionalSnapshot per region using deterministic scoring across + * seven balance axes, derives a regime label, scores actors, evaluates + * structured trigger thresholds, builds normalized scenario sets, resolves + * pre-built transmission templates, and persists to Redis with idempotency. + * + * Phase 0: NO LLM narrative call. Phase 1+ adds the narrative layer. + * + * Architecture: docs/internal/pro-regional-intelligence-upgrade.md + * Engineering: docs/internal/pro-regional-intelligence-appendix-engineering.md + * Scoring: docs/internal/pro-regional-intelligence-appendix-scoring.md + * + * Run via the seed bundle (recommended) or directly: + * node scripts/seed-regional-snapshots.mjs + */ + +import { pathToFileURL } from 'node:url'; + +import { loadEnvFile, getRedisCredentials, writeExtraKeyWithMeta } from './_seed-utils.mjs'; +import { REGIONS, GEOGRAPHY_VERSION } from '../shared/geography.js'; + +import { computeBalanceVector, SCORING_VERSION } from './regional-snapshot/balance-vector.mjs'; +import { buildRegimeState } from './regional-snapshot/regime-derivation.mjs'; +import { scoreActors } from './regional-snapshot/actor-scoring.mjs'; +import { evaluateTriggers } from './regional-snapshot/trigger-evaluator.mjs'; +import { buildScenarioSets } from './regional-snapshot/scenario-builder.mjs'; +import { resolveTransmissions } from './regional-snapshot/transmission-templates.mjs'; +import { collectEvidence } from './regional-snapshot/evidence-collector.mjs'; +import { buildPreMeta, buildFinalMeta } from './regional-snapshot/snapshot-meta.mjs'; +import { diffRegionalSnapshot, inferTriggerReason } from './regional-snapshot/diff-snapshot.mjs'; +import { persistSnapshot, readLatestSnapshot } from './regional-snapshot/persist-snapshot.mjs'; +import { ALL_INPUT_KEYS } from './regional-snapshot/freshness.mjs'; +import { generateSnapshotId } from './regional-snapshot/_helpers.mjs'; + +loadEnvFile(import.meta.url); + +const SEED_META_KEY = 'intelligence:regional-snapshots'; + +/** @returns {Promise>} */ +async function readAllInputs() { + const { url, token } = getRedisCredentials(); + const pipeline = ALL_INPUT_KEYS.map((k) => ['GET', k]); + const resp = await fetch(`${url}/pipeline`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(pipeline), + signal: AbortSignal.timeout(15_000), + }); + if (!resp.ok) throw new Error(`Redis pipeline read: HTTP ${resp.status}`); + const results = await resp.json(); + /** @type {Record} */ + const data = {}; + for (let i = 0; i < ALL_INPUT_KEYS.length; i++) { + const key = ALL_INPUT_KEYS[i]; + const raw = results[i]?.result; + if (raw === null || raw === undefined) { + data[key] = null; + continue; + } + try { + data[key] = JSON.parse(raw); + } catch { + data[key] = null; + } + } + return data; +} + +/** + * Run the full compute pipeline for one region in the canonical order. + * + * 1. (sources already read by caller) + * 2. pre_meta + * 3. balance vector + * 4. actors + * 5. triggers (BEFORE scenarios) + * 6. scenarios (normalized) + * 7. transmissions + * 8. mobility (empty in Phase 0) + * 9. evidence + * 10. (skip narrative LLM call in Phase 0) + * 11. snapshot_id + * 12. read previous + diff + * 13. final_meta + */ +async function computeSnapshot(regionId, sources) { + // Step 2: pre-meta + const { pre } = buildPreMeta(sources, SCORING_VERSION, GEOGRAPHY_VERSION); + + // Step 3: balance vector + const { vector: balance } = computeBalanceVector(regionId, sources); + + // Step 4: actors + const { actors, edges } = scoreActors(regionId, sources); + + // Step 5: triggers (before scenarios) + const triggers = evaluateTriggers(regionId, sources, balance); + + // Step 6: scenarios (normalized to 1.0 per horizon) + const scenarioSets = buildScenarioSets(regionId, sources, triggers); + + // Step 7: transmissions (matched to active triggers) + const transmissionPaths = resolveTransmissions(regionId, triggers); + + // Step 8: mobility (empty in Phase 0 - see appendix Mobility Input Keys) + const mobility = { + airspace: [], + flight_corridors: [], + airports: [], + reroute_intensity: 0, + notam_closures: [], + }; + + // Step 9: evidence chain + const evidence = collectEvidence(regionId, sources); + + // Step 10: SKIPPED in Phase 0 (no narrative LLM call) + /** @type {import('../shared/regions.types.js').RegionalNarrative} */ + const narrative = { + situation: { text: '', evidence_ids: [] }, + balance_assessment: { text: '', evidence_ids: [] }, + outlook_24h: { text: '', evidence_ids: [] }, + outlook_7d: { text: '', evidence_ids: [] }, + outlook_30d: { text: '', evidence_ids: [] }, + watch_items: [], + }; + + // Step 11: snapshot_id + const snapshotId = generateSnapshotId(); + + // Step 12: read previous, run diff + const previous = await readLatestSnapshot(regionId).catch(() => null); + const previousLabel = previous?.regime?.label ?? ''; + const regime = buildRegimeState(balance, previousLabel, ''); + + // Build a tentative snapshot purely so the diff engine can compare against + // the previously-persisted snapshot. The tentative snapshot's meta is a + // throwaway placeholder; the real meta is built after the diff so trigger_reason + // can be derived from the diff result. + const tentativeSnapshot = { + region_id: regionId, + generated_at: Date.now(), + meta: buildFinalMeta(pre, { snapshot_id: snapshotId, trigger_reason: 'scheduled_6h' }), + regime, + balance, + actors, + leverage_edges: edges, + scenario_sets: scenarioSets, + transmission_paths: transmissionPaths, + triggers, + mobility, + evidence, + narrative, + }; + + const diff = diffRegionalSnapshot(previous, tentativeSnapshot); + const triggerReason = inferTriggerReason(diff); + + // Step 13: final_meta with diff-derived trigger_reason + const finalMeta = buildFinalMeta(pre, { + snapshot_id: snapshotId, + trigger_reason: triggerReason, + }); + + // Return the snapshot WITHOUT the diff. The diff is a runtime artifact for + // alert emission; persisting it would leak a non-RegionalSnapshot field into + // Redis and break Phase 1 proto codegen consumers. + /** @type {import('../shared/regions.types.js').RegionalSnapshot} */ + const snapshot = { ...tentativeSnapshot, meta: finalMeta }; + return { snapshot, diff }; +} + +async function main() { + const t0 = Date.now(); + console.log(`[regional-snapshots] Starting compute for ${REGIONS.length} regions`); + + // Step 1: read all inputs once (shared across regions) + const sources = await readAllInputs(); + const presentKeys = Object.entries(sources).filter(([, v]) => v !== null).length; + console.log(`[regional-snapshots] Read inputs: ${presentKeys}/${ALL_INPUT_KEYS.length} keys present`); + + let persisted = 0; + let skipped = 0; + let failed = 0; + const summary = []; + const failedRegions = []; + + for (const region of REGIONS) { + try { + const { snapshot } = await computeSnapshot(region.id, sources); + const result = await persistSnapshot(snapshot); + if (result.persisted) { + persisted += 1; + summary.push({ + region: region.id, + regime: snapshot.regime.label, + confidence: snapshot.meta.snapshot_confidence, + active_triggers: snapshot.triggers.active.length, + trigger_reason: snapshot.meta.trigger_reason, + }); + console.log(`[${region.id}] persisted regime=${snapshot.regime.label} confidence=${snapshot.meta.snapshot_confidence} triggers=${snapshot.triggers.active.length} reason=${snapshot.meta.trigger_reason}`); + } else { + skipped += 1; + console.log(`[${region.id}] skipped: ${result.reason}`); + } + } catch (err) { + failed += 1; + failedRegions.push({ region: region.id, error: String(/** @type {any} */ (err)?.message ?? err) }); + console.error(`[${region.id}] FAILED: ${/** @type {any} */ (err)?.message ?? err}`); + } + } + + // Health policy: + // 1. persisted > 0 && failed === 0: write the fresh summary + seed-meta. + // 2. persisted === 0 && failed === 0: all regions dedup-skipped (e.g., a + // retry within the 15min idempotency bucket). Preserve the prior good + // summary by skipping the write entirely. api/health.js classifies an + // empty `regions: []` + `recordCount: 0` as EMPTY_DATA which flips the + // overall health to red, so overwriting on a no-op retry is actively + // harmful. The 12h maxStaleMin budget lets the next full run refresh + // the payload naturally. + // 3. failed > 0: skip the meta write so /api/health flips to STALE after + // the maxStaleMin budget on persistent degradation instead of silently + // reporting OK. The bundle runner's freshness gate retries next cycle. + const elapsed = ((Date.now() - t0) / 1000).toFixed(1); + if (failed === 0 && persisted > 0) { + const ttlSec = 12 * 60 * 60; // 12h, 2x the 6h cron cadence + await writeExtraKeyWithMeta( + `intelligence:regional-snapshots:summary:v1`, + { regions: summary, generatedAt: Date.now() }, + ttlSec, + persisted, + `seed-meta:${SEED_META_KEY}`, + ttlSec, + ); + console.log(`[regional-snapshots] Done in ${elapsed}s: persisted=${persisted} skipped=${skipped} failed=0`); + return; + } + + if (failed === 0) { + // All regions dedup-skipped. Preserve the prior summary and return cleanly. + console.log(`[regional-snapshots] Done in ${elapsed}s: persisted=0 skipped=${skipped} failed=0 (all dedup-skipped, prior summary preserved)`); + return; + } + + console.error(`[regional-snapshots] Done in ${elapsed}s: persisted=${persisted} skipped=${skipped} failed=${failed}`); + for (const f of failedRegions) { + console.error(` [${f.region}] ${f.error}`); + } + console.error('[regional-snapshots] Skipping seed-meta write due to partial failure. /api/health will reflect degradation after 12h.'); + process.exit(1); +} + +const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isDirectRun) { + main().catch((err) => { + console.error(`PUBLISH FAILED: ${err?.message || err}`); + process.exit(1); + }); +} + +export { main, computeSnapshot, readAllInputs }; diff --git a/scripts/shared/iso2-to-region.json b/scripts/shared/iso2-to-region.json new file mode 100644 index 000000000..d9ba7625f --- /dev/null +++ b/scripts/shared/iso2-to-region.json @@ -0,0 +1,220 @@ +{ + "AD": "europe", + "AE": "mena", + "AF": "south-asia", + "AG": "latam", + "AL": "europe", + "AM": "europe", + "AO": "sub-saharan-africa", + "AR": "latam", + "AS": "east-asia", + "AT": "europe", + "AU": "east-asia", + "AW": "latam", + "AZ": "europe", + "BA": "europe", + "BB": "latam", + "BD": "south-asia", + "BE": "europe", + "BF": "sub-saharan-africa", + "BG": "europe", + "BH": "mena", + "BI": "sub-saharan-africa", + "BJ": "sub-saharan-africa", + "BM": "north-america", + "BN": "east-asia", + "BO": "latam", + "BR": "latam", + "BS": "latam", + "BT": "south-asia", + "BW": "sub-saharan-africa", + "BY": "europe", + "BZ": "latam", + "CA": "north-america", + "CD": "sub-saharan-africa", + "CF": "sub-saharan-africa", + "CG": "sub-saharan-africa", + "CH": "europe", + "CI": "sub-saharan-africa", + "CL": "latam", + "CM": "sub-saharan-africa", + "CN": "east-asia", + "CO": "latam", + "CR": "latam", + "CU": "latam", + "CV": "sub-saharan-africa", + "CW": "latam", + "CY": "europe", + "CZ": "europe", + "DE": "europe", + "DJ": "mena", + "DK": "europe", + "DM": "latam", + "DO": "latam", + "DZ": "mena", + "EC": "latam", + "EE": "europe", + "EG": "mena", + "ER": "sub-saharan-africa", + "ES": "europe", + "ET": "sub-saharan-africa", + "FI": "europe", + "FJ": "east-asia", + "FM": "east-asia", + "FO": "europe", + "FR": "europe", + "GA": "sub-saharan-africa", + "GB": "europe", + "GD": "latam", + "GE": "europe", + "GH": "sub-saharan-africa", + "GI": "europe", + "GL": "europe", + "GM": "sub-saharan-africa", + "GN": "sub-saharan-africa", + "GQ": "sub-saharan-africa", + "GR": "europe", + "GT": "latam", + "GU": "east-asia", + "GW": "sub-saharan-africa", + "GY": "latam", + "HK": "east-asia", + "HN": "latam", + "HR": "europe", + "HT": "latam", + "HU": "europe", + "ID": "east-asia", + "IE": "europe", + "IL": "mena", + "IM": "europe", + "IN": "south-asia", + "IQ": "mena", + "IR": "mena", + "IS": "europe", + "IT": "europe", + "JG": "europe", + "JM": "latam", + "JO": "mena", + "JP": "east-asia", + "KE": "sub-saharan-africa", + "KG": "europe", + "KH": "east-asia", + "KI": "east-asia", + "KM": "sub-saharan-africa", + "KN": "latam", + "KP": "east-asia", + "KR": "east-asia", + "KW": "mena", + "KY": "latam", + "KZ": "europe", + "LA": "east-asia", + "LB": "mena", + "LC": "latam", + "LI": "europe", + "LK": "south-asia", + "LR": "sub-saharan-africa", + "LS": "sub-saharan-africa", + "LT": "europe", + "LU": "europe", + "LV": "europe", + "LY": "mena", + "MA": "mena", + "MC": "europe", + "MD": "europe", + "ME": "europe", + "MF": "latam", + "MG": "sub-saharan-africa", + "MH": "east-asia", + "MK": "europe", + "ML": "sub-saharan-africa", + "MM": "east-asia", + "MN": "east-asia", + "MO": "east-asia", + "MP": "east-asia", + "MR": "sub-saharan-africa", + "MT": "mena", + "MU": "sub-saharan-africa", + "MV": "south-asia", + "MW": "sub-saharan-africa", + "MX": "north-america", + "MY": "east-asia", + "MZ": "sub-saharan-africa", + "NA": "sub-saharan-africa", + "NC": "east-asia", + "NE": "sub-saharan-africa", + "NG": "sub-saharan-africa", + "NI": "latam", + "NL": "europe", + "NO": "europe", + "NP": "south-asia", + "NR": "east-asia", + "NZ": "east-asia", + "OM": "mena", + "PA": "latam", + "PE": "latam", + "PF": "east-asia", + "PG": "east-asia", + "PH": "east-asia", + "PK": "south-asia", + "PL": "europe", + "PR": "latam", + "PS": "mena", + "PT": "europe", + "PW": "east-asia", + "PY": "latam", + "QA": "mena", + "RO": "europe", + "RS": "europe", + "RU": "europe", + "RW": "sub-saharan-africa", + "SA": "mena", + "SB": "east-asia", + "SC": "sub-saharan-africa", + "SD": "sub-saharan-africa", + "SE": "europe", + "SG": "east-asia", + "SI": "europe", + "SK": "europe", + "SL": "sub-saharan-africa", + "SM": "europe", + "SN": "sub-saharan-africa", + "SO": "sub-saharan-africa", + "SR": "latam", + "SS": "sub-saharan-africa", + "ST": "sub-saharan-africa", + "SV": "latam", + "SX": "latam", + "SY": "mena", + "SZ": "sub-saharan-africa", + "TC": "latam", + "TD": "sub-saharan-africa", + "TG": "sub-saharan-africa", + "TH": "east-asia", + "TJ": "europe", + "TL": "east-asia", + "TM": "europe", + "TN": "mena", + "TO": "east-asia", + "TR": "mena", + "TT": "latam", + "TV": "east-asia", + "TW": "east-asia", + "TZ": "sub-saharan-africa", + "UA": "europe", + "UG": "sub-saharan-africa", + "US": "north-america", + "UY": "latam", + "UZ": "europe", + "VC": "latam", + "VE": "latam", + "VG": "latam", + "VI": "latam", + "VN": "east-asia", + "VU": "east-asia", + "WS": "east-asia", + "XK": "europe", + "YE": "mena", + "ZA": "sub-saharan-africa", + "ZM": "sub-saharan-africa", + "ZW": "sub-saharan-africa" +} diff --git a/shared/geography.d.ts b/shared/geography.d.ts new file mode 100644 index 000000000..efb110f92 --- /dev/null +++ b/shared/geography.d.ts @@ -0,0 +1,50 @@ +// Type declarations for shared/geography.js. +// See shared/regions.types.d.ts for the snapshot model types. + +import type { RegionId } from './regions.types.js'; + +export interface RegionDef { + id: RegionId; + label: string; + forecastLabel: string; + wbCode: string; + theaters: string[]; + feedRegion: string; + mapView: string; + keyCountries: string[]; +} + +export interface TheaterDef { + id: string; + label: string; + regionId: RegionId; + corridorIds: string[]; +} + +export interface CorridorDef { + id: string; + label: string; + theaterId: string; + /** Maps to existing chokepoint IDs in supply_chain:chokepoints:v4. Null for non-chokepoint corridors (Cape route, English Channel). */ + chokepointId: string | null; + /** 1 = critical global, 2 = major regional, 3 = secondary/reroute */ + tier: 1 | 2 | 3; + /** 0-1 normalized weight for maritime_access scoring */ + weight: number; +} + +export const REGION_IDS: RegionId[]; +export const GEOGRAPHY_VERSION: string; +export const REGIONS: readonly RegionDef[]; +export const THEATERS: readonly TheaterDef[]; +export const CORRIDORS: readonly CorridorDef[]; +export const COUNTRY_CRITICALITY: Record; +export const DEFAULT_COUNTRY_CRITICALITY: number; + +export function getRegion(regionId: string): RegionDef | null; +export function getRegionCountries(regionId: string): string[]; +export function regionForCountry(iso2: string): RegionId | null; +export function getRegionTheaters(regionId: string): TheaterDef[]; +export function getTheaterCorridors(theaterId: string): CorridorDef[]; +export function getRegionCorridors(regionId: string): CorridorDef[]; +export function countryCriticality(iso2: string): number; diff --git a/shared/geography.js b/shared/geography.js new file mode 100644 index 000000000..a21a7a7f7 --- /dev/null +++ b/shared/geography.js @@ -0,0 +1,279 @@ +// Hierarchical geography for the Regional Intelligence Model. +// Layers (aggregate upward): Display Region -> Theater -> Corridor -> Node. +// +// Used by: +// - scripts/seed-regional-snapshots.mjs (snapshot writer) +// - scripts/regional-snapshot/* (compute modules) +// - server/worldmonitor/intelligence/v1/* (Phase 1+ RPC handlers) +// - src/components/RegionalIntelligenceBoard (Phase 1 UI) +// +// Region taxonomy is anchored to World Bank region codes (EAS/ECS/LCN/MEA/NAC/SAS/SSF) +// with strategic overrides applied to shared/iso2-to-region.json (built once from +// https://api.worldbank.org/v2/country?format=json&per_page=300). +// +// Strategic overrides (deviations from raw WB classification): +// - AF, PK: WB has them in MEA; we put them in south-asia (geographic/strategic) +// - TR: WB has it in ECS; we put it in mena (Levant/Syria/refugee policy frame) +// - MX: WB has it in LCN; we put it in north-america (USMCA strategic frame) +// - TW: WB does not list Taiwan; manually added to east-asia +// +// Country and corridor criticality weights match the formulas in +// docs/internal/pro-regional-intelligence-appendix-scoring.md. + +import iso2ToRegionData from './iso2-to-region.json' with { type: 'json' }; + +/** @type {import('./regions.types.js').RegionId[]} */ +export const REGION_IDS = [ + 'mena', + 'east-asia', + 'europe', + 'north-america', + 'south-asia', + 'latam', + 'sub-saharan-africa', + 'global', +]; + +export const GEOGRAPHY_VERSION = '1.0.0'; + +/** + * Eight display regions. `forecastLabel` matches the free-text region strings + * the existing forecast handler does substring matching against, so the same + * label flows end-to-end without taxonomy mismatch. + */ +export const REGIONS = [ + { + id: 'mena', + label: 'Middle East & North Africa', + forecastLabel: 'Middle East', + wbCode: 'MEA', + theaters: ['levant', 'persian-gulf', 'red-sea', 'north-africa'], + feedRegion: 'middleeast', + mapView: 'mena', + keyCountries: ['SA', 'IR', 'IL', 'AE', 'EG', 'IQ', 'TR'], + }, + { + id: 'east-asia', + label: 'East Asia & Pacific', + forecastLabel: 'East Asia', + wbCode: 'EAS', + theaters: ['east-asia', 'southeast-asia'], + feedRegion: 'asia', + mapView: 'asia', + keyCountries: ['CN', 'JP', 'KR', 'TW', 'AU', 'SG', 'ID'], + }, + { + id: 'europe', + label: 'Europe & Central Asia', + forecastLabel: 'Europe', + wbCode: 'ECS', + theaters: ['eastern-europe', 'western-europe', 'baltic', 'arctic'], + feedRegion: 'europe', + mapView: 'eu', + keyCountries: ['DE', 'FR', 'GB', 'UA', 'RU', 'PL', 'IT'], + }, + { + id: 'north-america', + label: 'North America', + forecastLabel: 'North America', + wbCode: 'NAC', + theaters: ['north-america'], + feedRegion: 'us', + mapView: 'america', + keyCountries: ['US', 'CA', 'MX'], + }, + { + id: 'south-asia', + label: 'South Asia', + forecastLabel: 'South Asia', + wbCode: 'SAS', + theaters: ['south-asia'], + feedRegion: 'asia', + mapView: 'asia', + keyCountries: ['IN', 'PK', 'BD', 'LK', 'AF'], + }, + { + id: 'latam', + label: 'Latin America & Caribbean', + forecastLabel: 'Latin America', + wbCode: 'LCN', + theaters: ['latin-america', 'caribbean'], + feedRegion: 'latam', + mapView: 'latam', + keyCountries: ['BR', 'AR', 'CO', 'CL', 'VE', 'PE'], + }, + { + id: 'sub-saharan-africa', + label: 'Sub-Saharan Africa', + forecastLabel: 'Africa', + wbCode: 'SSF', + theaters: ['horn-of-africa', 'sahel', 'southern-africa', 'central-africa'], + feedRegion: 'africa', + mapView: 'africa', + keyCountries: ['NG', 'ZA', 'KE', 'ET', 'SD', 'CD'], + }, + { + id: 'global', + label: 'Global', + forecastLabel: '', + wbCode: '1W', + theaters: ['global-markets'], + feedRegion: 'worldwide', + mapView: 'global', + keyCountries: ['US', 'CN', 'RU', 'DE', 'JP', 'IN', 'GB', 'SA'], + }, +]; + +/** + * Theaters group countries into geopolitical-strategic units smaller than + * regions. Cross-source signals and military posture data already use these + * theater names where applicable. + */ +export const THEATERS = [ + // MENA + { id: 'levant', label: 'Levant', regionId: 'mena', corridorIds: [] }, + { id: 'persian-gulf', label: 'Persian Gulf', regionId: 'mena', corridorIds: ['hormuz'] }, + { id: 'red-sea', label: 'Red Sea', regionId: 'mena', corridorIds: ['babelm', 'suez'] }, + { id: 'north-africa', label: 'North Africa', regionId: 'mena', corridorIds: [] }, + // East Asia + { id: 'east-asia', label: 'East Asia', regionId: 'east-asia', corridorIds: ['taiwan-strait'] }, + { id: 'southeast-asia', label: 'Southeast Asia', regionId: 'east-asia', corridorIds: ['malacca', 'south-china-sea'] }, + // Europe + { id: 'eastern-europe', label: 'Eastern Europe', regionId: 'europe', corridorIds: ['bosphorus'] }, + { id: 'western-europe', label: 'Western Europe', regionId: 'europe', corridorIds: ['english-channel'] }, + { id: 'baltic', label: 'Baltic', regionId: 'europe', corridorIds: ['danish'] }, + { id: 'arctic', label: 'Arctic', regionId: 'europe', corridorIds: [] }, + // North America + { id: 'north-america', label: 'North America', regionId: 'north-america', corridorIds: ['panama'] }, + // South Asia + { id: 'south-asia', label: 'South Asia', regionId: 'south-asia', corridorIds: [] }, + // LatAm + { id: 'latin-america', label: 'Latin America', regionId: 'latam', corridorIds: [] }, + { id: 'caribbean', label: 'Caribbean', regionId: 'latam', corridorIds: ['panama'] }, + // SSA + { id: 'horn-of-africa', label: 'Horn of Africa', regionId: 'sub-saharan-africa', corridorIds: ['babelm'] }, + { id: 'sahel', label: 'Sahel', regionId: 'sub-saharan-africa', corridorIds: [] }, + { id: 'southern-africa', label: 'Southern Africa', regionId: 'sub-saharan-africa', corridorIds: ['cape-of-good-hope'] }, + { id: 'central-africa', label: 'Central Africa', regionId: 'sub-saharan-africa', corridorIds: [] }, + // Global + { id: 'global-markets', label: 'Global Markets', regionId: 'global', corridorIds: [] }, +]; + +/** + * Corridors are the chokepoint and trade-route layer where transmission + * mechanics actually live. `chokepointId` links to the existing seeded data + * at `supply_chain:chokepoints:v4` and `scripts/seed-chokepoint-baselines.mjs`. + * + * Tier and weight match the criticality table in the scoring appendix. + */ +export const CORRIDORS = [ + // Tier 1 (~20% of global oil transit, top trade volume) + { id: 'hormuz', label: 'Strait of Hormuz', theaterId: 'persian-gulf', chokepointId: 'hormuz', tier: 1, weight: 1.0 }, + { id: 'suez', label: 'Suez Canal', theaterId: 'red-sea', chokepointId: 'suez', tier: 1, weight: 1.0 }, + { id: 'babelm', label: 'Bab el-Mandeb', theaterId: 'red-sea', chokepointId: 'babelm', tier: 1, weight: 0.9 }, + { id: 'taiwan-strait', label: 'Taiwan Strait', theaterId: 'east-asia', chokepointId: 'taiwan_strait',tier: 1, weight: 0.9 }, + { id: 'bosphorus', label: 'Bosphorus', theaterId: 'eastern-europe', chokepointId: 'bosphorus', tier: 1, weight: 0.7 }, + // Tier 2 + { id: 'malacca', label: 'Strait of Malacca', theaterId: 'southeast-asia', chokepointId: 'malacca', tier: 2, weight: 0.8 }, + { id: 'panama', label: 'Panama Canal', theaterId: 'north-america', chokepointId: 'panama', tier: 2, weight: 0.6 }, + { id: 'danish', label: 'Danish Straits', theaterId: 'baltic', chokepointId: 'danish', tier: 2, weight: 0.5 }, + // Tier 3 (reroute paths and secondary) + { id: 'cape-of-good-hope', label: 'Cape of Good Hope', theaterId: 'southern-africa', chokepointId: null, tier: 3, weight: 0.4 }, + { id: 'south-china-sea', label: 'South China Sea', theaterId: 'southeast-asia', chokepointId: null, tier: 3, weight: 0.6 }, + { id: 'english-channel', label: 'English Channel', theaterId: 'western-europe', chokepointId: null, tier: 3, weight: 0.4 }, +]; + +/** + * Country criticality weights for the weighted-tail domestic fragility score. + * Higher weight = country dominates region risk. + * + * Methodology (per scoring appendix): + * 1.0: controls a tier-1 corridor, OR top-10 oil/gas producer, OR top-5 region GDP + * 0.6: controls a tier-2 corridor, OR top-20 oil/gas producer, OR top-10 region GDP + * 0.3: default for other countries + */ +export const COUNTRY_CRITICALITY = { + // Tier-1 corridor controllers + top-10 producers + top-5 region GDP + IR: 1.0, // Hormuz controller + OM: 1.0, // Hormuz controller (other side) + AE: 1.0, // Persian Gulf, top oil producer + SA: 1.0, // Top oil producer + EG: 1.0, // Suez controller + YE: 1.0, // Bab el-Mandeb controller + CN: 1.0, // Taiwan Strait, top region GDP + TW: 1.0, // Taiwan Strait + TR: 1.0, // Bosphorus controller + US: 1.0, // Top-10 producer, dominant region GDP + RU: 1.0, // Top oil/gas producer + CA: 1.0, // Top oil producer + // Tier-2 corridor controllers + top-20 producers + top-10 region GDP + MY: 0.6, // Malacca + SG: 0.6, // Malacca + ID: 0.6, // Malacca, regional GDP + PA: 0.6, // Panama + DK: 0.6, // Danish Straits + DE: 0.6, // Top region GDP + FR: 0.6, + GB: 0.6, + JP: 0.6, + IN: 0.6, // Top region GDP, growing producer + BR: 0.6, // Top region GDP, top-20 producer + MX: 0.6, + KR: 0.6, + IL: 0.6, // Strategic significance in MENA + IQ: 0.6, // Top-20 producer + KW: 0.6, // Top-20 producer + QA: 0.6, // Top-20 LNG producer + NG: 0.6, // Top-20 oil producer + AU: 0.6, // Top LNG producer, regional GDP + // Everything else defaults to 0.3 +}; + +export const DEFAULT_COUNTRY_CRITICALITY = 0.3; + +// ──────────────────────────────────────────────────────────────────────────── +// Helper functions +// ──────────────────────────────────────────────────────────────────────────── + +/** @type {Record} */ +const ISO2_TO_REGION = iso2ToRegionData; + +/** @param {string} regionId */ +export function getRegion(regionId) { + return REGIONS.find((r) => r.id === regionId) ?? null; +} + +/** @param {string} regionId */ +export function getRegionCountries(regionId) { + const out = []; + for (const [iso, rid] of Object.entries(ISO2_TO_REGION)) { + if (rid === regionId) out.push(iso); + } + return out; +} + +/** @param {string} iso2 */ +export function regionForCountry(iso2) { + return ISO2_TO_REGION[iso2] ?? null; +} + +/** @param {string} regionId */ +export function getRegionTheaters(regionId) { + return THEATERS.filter((t) => t.regionId === regionId); +} + +/** @param {string} theaterId */ +export function getTheaterCorridors(theaterId) { + return CORRIDORS.filter((c) => c.theaterId === theaterId); +} + +/** @param {string} regionId */ +export function getRegionCorridors(regionId) { + const theaterIds = new Set(getRegionTheaters(regionId).map((t) => t.id)); + return CORRIDORS.filter((c) => theaterIds.has(c.theaterId)); +} + +/** @param {string} iso2 */ +export function countryCriticality(iso2) { + return COUNTRY_CRITICALITY[iso2] ?? DEFAULT_COUNTRY_CRITICALITY; +} diff --git a/shared/iso2-to-region.json b/shared/iso2-to-region.json new file mode 100644 index 000000000..d9ba7625f --- /dev/null +++ b/shared/iso2-to-region.json @@ -0,0 +1,220 @@ +{ + "AD": "europe", + "AE": "mena", + "AF": "south-asia", + "AG": "latam", + "AL": "europe", + "AM": "europe", + "AO": "sub-saharan-africa", + "AR": "latam", + "AS": "east-asia", + "AT": "europe", + "AU": "east-asia", + "AW": "latam", + "AZ": "europe", + "BA": "europe", + "BB": "latam", + "BD": "south-asia", + "BE": "europe", + "BF": "sub-saharan-africa", + "BG": "europe", + "BH": "mena", + "BI": "sub-saharan-africa", + "BJ": "sub-saharan-africa", + "BM": "north-america", + "BN": "east-asia", + "BO": "latam", + "BR": "latam", + "BS": "latam", + "BT": "south-asia", + "BW": "sub-saharan-africa", + "BY": "europe", + "BZ": "latam", + "CA": "north-america", + "CD": "sub-saharan-africa", + "CF": "sub-saharan-africa", + "CG": "sub-saharan-africa", + "CH": "europe", + "CI": "sub-saharan-africa", + "CL": "latam", + "CM": "sub-saharan-africa", + "CN": "east-asia", + "CO": "latam", + "CR": "latam", + "CU": "latam", + "CV": "sub-saharan-africa", + "CW": "latam", + "CY": "europe", + "CZ": "europe", + "DE": "europe", + "DJ": "mena", + "DK": "europe", + "DM": "latam", + "DO": "latam", + "DZ": "mena", + "EC": "latam", + "EE": "europe", + "EG": "mena", + "ER": "sub-saharan-africa", + "ES": "europe", + "ET": "sub-saharan-africa", + "FI": "europe", + "FJ": "east-asia", + "FM": "east-asia", + "FO": "europe", + "FR": "europe", + "GA": "sub-saharan-africa", + "GB": "europe", + "GD": "latam", + "GE": "europe", + "GH": "sub-saharan-africa", + "GI": "europe", + "GL": "europe", + "GM": "sub-saharan-africa", + "GN": "sub-saharan-africa", + "GQ": "sub-saharan-africa", + "GR": "europe", + "GT": "latam", + "GU": "east-asia", + "GW": "sub-saharan-africa", + "GY": "latam", + "HK": "east-asia", + "HN": "latam", + "HR": "europe", + "HT": "latam", + "HU": "europe", + "ID": "east-asia", + "IE": "europe", + "IL": "mena", + "IM": "europe", + "IN": "south-asia", + "IQ": "mena", + "IR": "mena", + "IS": "europe", + "IT": "europe", + "JG": "europe", + "JM": "latam", + "JO": "mena", + "JP": "east-asia", + "KE": "sub-saharan-africa", + "KG": "europe", + "KH": "east-asia", + "KI": "east-asia", + "KM": "sub-saharan-africa", + "KN": "latam", + "KP": "east-asia", + "KR": "east-asia", + "KW": "mena", + "KY": "latam", + "KZ": "europe", + "LA": "east-asia", + "LB": "mena", + "LC": "latam", + "LI": "europe", + "LK": "south-asia", + "LR": "sub-saharan-africa", + "LS": "sub-saharan-africa", + "LT": "europe", + "LU": "europe", + "LV": "europe", + "LY": "mena", + "MA": "mena", + "MC": "europe", + "MD": "europe", + "ME": "europe", + "MF": "latam", + "MG": "sub-saharan-africa", + "MH": "east-asia", + "MK": "europe", + "ML": "sub-saharan-africa", + "MM": "east-asia", + "MN": "east-asia", + "MO": "east-asia", + "MP": "east-asia", + "MR": "sub-saharan-africa", + "MT": "mena", + "MU": "sub-saharan-africa", + "MV": "south-asia", + "MW": "sub-saharan-africa", + "MX": "north-america", + "MY": "east-asia", + "MZ": "sub-saharan-africa", + "NA": "sub-saharan-africa", + "NC": "east-asia", + "NE": "sub-saharan-africa", + "NG": "sub-saharan-africa", + "NI": "latam", + "NL": "europe", + "NO": "europe", + "NP": "south-asia", + "NR": "east-asia", + "NZ": "east-asia", + "OM": "mena", + "PA": "latam", + "PE": "latam", + "PF": "east-asia", + "PG": "east-asia", + "PH": "east-asia", + "PK": "south-asia", + "PL": "europe", + "PR": "latam", + "PS": "mena", + "PT": "europe", + "PW": "east-asia", + "PY": "latam", + "QA": "mena", + "RO": "europe", + "RS": "europe", + "RU": "europe", + "RW": "sub-saharan-africa", + "SA": "mena", + "SB": "east-asia", + "SC": "sub-saharan-africa", + "SD": "sub-saharan-africa", + "SE": "europe", + "SG": "east-asia", + "SI": "europe", + "SK": "europe", + "SL": "sub-saharan-africa", + "SM": "europe", + "SN": "sub-saharan-africa", + "SO": "sub-saharan-africa", + "SR": "latam", + "SS": "sub-saharan-africa", + "ST": "sub-saharan-africa", + "SV": "latam", + "SX": "latam", + "SY": "mena", + "SZ": "sub-saharan-africa", + "TC": "latam", + "TD": "sub-saharan-africa", + "TG": "sub-saharan-africa", + "TH": "east-asia", + "TJ": "europe", + "TL": "east-asia", + "TM": "europe", + "TN": "mena", + "TO": "east-asia", + "TR": "mena", + "TT": "latam", + "TV": "east-asia", + "TW": "east-asia", + "TZ": "sub-saharan-africa", + "UA": "europe", + "UG": "sub-saharan-africa", + "US": "north-america", + "UY": "latam", + "UZ": "europe", + "VC": "latam", + "VE": "latam", + "VG": "latam", + "VI": "latam", + "VN": "east-asia", + "VU": "east-asia", + "WS": "east-asia", + "XK": "europe", + "YE": "mena", + "ZA": "sub-saharan-africa", + "ZM": "sub-saharan-africa", + "ZW": "sub-saharan-africa" +} diff --git a/shared/regions.types.d.ts b/shared/regions.types.d.ts new file mode 100644 index 000000000..fa1e156e3 --- /dev/null +++ b/shared/regions.types.d.ts @@ -0,0 +1,267 @@ +// Type definitions for the Regional Intelligence Model. +// Mirrors the future proto shape (proto/worldmonitor/intelligence/v1/service.proto) +// so the Phase 1 codegen lands as a drop-in replacement. +// +// See docs/internal/pro-regional-intelligence-upgrade.md for the full spec. + +export type RegionId = + | 'mena' + | 'east-asia' + | 'europe' + | 'north-america' + | 'south-asia' + | 'latam' + | 'sub-saharan-africa' + | 'global'; + +export type RegimeLabel = + | 'calm' + | 'stressed_equilibrium' + | 'coercive_stalemate' + | 'fragmentation_risk' + | 'managed_deescalation' + | 'escalation_ladder'; + +export type ScenarioName = 'base' | 'escalation' | 'containment' | 'fragmentation'; +export type ScenarioHorizon = '24h' | '7d' | '30d'; + +export type AlertSeverity = 'critical' | 'high' | 'medium' | 'low'; + +export type ActorRole = 'aggressor' | 'stabilizer' | 'swing' | 'broker'; +export type ActorLeverageDomain = + | 'energy' + | 'military' + | 'diplomatic' + | 'economic' + | 'maritime'; + +export type LeverageMechanism = + | 'sanctions' + | 'naval_posture' + | 'energy_supply' + | 'alliance_shift' + | 'trade_friction'; + +export type EvidenceType = + | 'vessel_track' + | 'flight_surge' + | 'news_headline' + | 'cii_spike' + | 'chokepoint_status' + | 'sanctions_move' + | 'market_signal' + | 'mobility_disruption'; + +export type DriverOrientation = 'pressure' | 'buffer'; + +export type TriggerOperator = 'gt' | 'gte' | 'lt' | 'lte' | 'delta_gt' | 'delta_lt'; +export type TriggerBaseline = 'trailing_7d' | 'trailing_30d' | 'fixed'; + +export type TriggerReason = + | 'scheduled_6h' + | 'regime_shift' + | 'trigger_activation' + | 'corridor_break' + | 'leverage_shift'; + +export interface SnapshotMeta { + snapshot_id: string; + model_version: string; + scoring_version: string; + geography_version: string; + snapshot_confidence: number; + missing_inputs: string[]; + stale_inputs: string[]; + valid_until: number; + trigger_reason: TriggerReason; + narrative_provider: string; + narrative_model: string; +} + +export interface RegimeState { + label: RegimeLabel; + previous_label: RegimeLabel | ''; + transitioned_at: number; + transition_driver: string; +} + +export interface BalanceDriver { + axis: keyof Omit; + description: string; + magnitude: number; + evidence_ids: string[]; + orientation: DriverOrientation; +} + +export interface BalanceVector { + // Pressures (high = bad) + coercive_pressure: number; + domestic_fragility: number; + capital_stress: number; + energy_vulnerability: number; + // Buffers (high = good) + alliance_cohesion: number; + maritime_access: number; + energy_leverage: number; + // Derived + net_balance: number; + // Decomposition + pressures: BalanceDriver[]; + buffers: BalanceDriver[]; +} + +export interface ActorState { + actor_id: string; + name: string; + role: ActorRole; + leverage_domains: ActorLeverageDomain[]; + leverage_score: number; + delta: number; + evidence_ids: string[]; +} + +export interface LeverageEdge { + from_actor_id: string; + to_actor_id: string; + mechanism: LeverageMechanism; + strength: number; + evidence_ids: string[]; +} + +export interface TransmissionPath { + start: string; + mechanism: string; + end: string; + severity: AlertSeverity; + corridor_id: string; + confidence: number; + latency_hours: number; + impacted_asset_class: string; + impacted_regions: RegionId[]; + magnitude_low: number; + magnitude_high: number; + magnitude_unit: string; + template_id: string; + template_version: string; +} + +export interface ScenarioLane { + name: ScenarioName; + probability: number; + trigger_ids: string[]; + consequences: string[]; + transmissions: TransmissionPath[]; +} + +export interface ScenarioSet { + horizon: ScenarioHorizon; + lanes: ScenarioLane[]; +} + +export interface TriggerThreshold { + metric: string; + operator: TriggerOperator; + value: number; + window_minutes: number; + baseline: TriggerBaseline; +} + +export interface Trigger { + id: string; + description: string; + threshold: TriggerThreshold; + activated: boolean; + activated_at: number; + scenario_lane: ScenarioName; + evidence_ids: string[]; +} + +export interface TriggerLadder { + active: Trigger[]; + watching: Trigger[]; + dormant: Trigger[]; +} + +export interface AirspaceStatus { + airspace_id: string; + status: 'open' | 'restricted' | 'closed'; + reason: string; +} + +export interface FlightCorridorStress { + corridor: string; + stress_level: number; + rerouted_flights_24h: number; +} + +export interface AirportNodeStatus { + icao: string; + name: string; + status: 'normal' | 'disrupted' | 'closed'; + disruption_reason: string; +} + +export interface MobilityState { + airspace: AirspaceStatus[]; + flight_corridors: FlightCorridorStress[]; + airports: AirportNodeStatus[]; + reroute_intensity: number; + notam_closures: string[]; +} + +export interface EvidenceItem { + id: string; + type: EvidenceType; + source: string; + summary: string; + confidence: number; + observed_at: number; + theater: string; + corridor: string; +} + +export interface NarrativeSection { + text: string; + evidence_ids: string[]; +} + +export interface RegionalNarrative { + situation: NarrativeSection; + balance_assessment: NarrativeSection; + outlook_24h: NarrativeSection; + outlook_7d: NarrativeSection; + outlook_30d: NarrativeSection; + watch_items: NarrativeSection[]; +} + +export interface RegionalSnapshot { + region_id: RegionId; + generated_at: number; + meta: SnapshotMeta; + regime: RegimeState; + balance: BalanceVector; + actors: ActorState[]; + leverage_edges: LeverageEdge[]; + scenario_sets: ScenarioSet[]; + transmission_paths: TransmissionPath[]; + triggers: TriggerLadder; + mobility: MobilityState; + evidence: EvidenceItem[]; + narrative: RegionalNarrative; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Diff output (used by alert layer) +// ──────────────────────────────────────────────────────────────────────────── + +export interface SnapshotDiff { + regime_changed: { from: RegimeLabel | ''; to: RegimeLabel } | null; + scenario_jumps: { horizon: ScenarioHorizon; lane: ScenarioName; from: number; to: number }[]; + trigger_activations: { id: string; description: string }[]; + trigger_deactivations: { id: string }[]; + corridor_breaks: { corridor_id: string; from: string; to: string }[]; + leverage_shifts: { actor_id: string; from: number; to: number; delta: number }[]; + buffer_failures: { axis: string; from: number; to: number }[]; + reroute_waves: { affected_corridors: string[] } | null; + mobility_disruptions: { airspace?: string; reroute_intensity?: number }[]; +} diff --git a/tests/regional-snapshot.test.mjs b/tests/regional-snapshot.test.mjs new file mode 100644 index 000000000..a3c3cead5 --- /dev/null +++ b/tests/regional-snapshot.test.mjs @@ -0,0 +1,599 @@ +// Tests for the Regional Intelligence snapshot pipeline. +// Pure-function unit tests; no Redis dependency. Run via: +// npm run test:data + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + REGIONS, + REGION_IDS, + THEATERS, + CORRIDORS, + GEOGRAPHY_VERSION, + getRegion, + getRegionCountries, + regionForCountry, + getRegionCorridors, + countryCriticality, +} from '../shared/geography.js'; + +import { computeBalanceVector, SCORING_VERSION } from '../scripts/regional-snapshot/balance-vector.mjs'; +import { deriveRegime, buildRegimeState } from '../scripts/regional-snapshot/regime-derivation.mjs'; +import { scoreActors } from '../scripts/regional-snapshot/actor-scoring.mjs'; +import { evaluateTriggers } from '../scripts/regional-snapshot/trigger-evaluator.mjs'; +import { buildScenarioSets } from '../scripts/regional-snapshot/scenario-builder.mjs'; +import { resolveTransmissions, TEMPLATE_VERSION } from '../scripts/regional-snapshot/transmission-templates.mjs'; +import { collectEvidence } from '../scripts/regional-snapshot/evidence-collector.mjs'; +import { buildPreMeta, buildFinalMeta, MODEL_VERSION } from '../scripts/regional-snapshot/snapshot-meta.mjs'; +import { diffRegionalSnapshot, inferTriggerReason } from '../scripts/regional-snapshot/diff-snapshot.mjs'; +import { generateSnapshotId, clip, percentile } from '../scripts/regional-snapshot/_helpers.mjs'; +import { classifyInputs, FRESHNESS_REGISTRY } from '../scripts/regional-snapshot/freshness.mjs'; + +// ──────────────────────────────────────────────────────────────────────────── +// Geography +// ──────────────────────────────────────────────────────────────────────────── + +describe('shared/geography', () => { + it('exports 8 regions with the expected IDs', () => { + assert.equal(REGIONS.length, 8); + assert.deepEqual(REGION_IDS.sort(), [ + 'east-asia', + 'europe', + 'global', + 'latam', + 'mena', + 'north-america', + 'south-asia', + 'sub-saharan-africa', + ]); + }); + + it('every region has a non-empty forecastLabel except global', () => { + for (const r of REGIONS) { + if (r.id === 'global') continue; + assert.ok(r.forecastLabel.length > 0, `${r.id} missing forecastLabel`); + } + }); + + it('every theater belongs to a defined region', () => { + const regionIds = new Set(REGIONS.map((r) => r.id)); + for (const t of THEATERS) { + assert.ok(regionIds.has(t.regionId), `Theater ${t.id} -> unknown region ${t.regionId}`); + } + }); + + it('every corridor belongs to a defined theater and has a valid weight', () => { + const theaterIds = new Set(THEATERS.map((t) => t.id)); + for (const c of CORRIDORS) { + assert.ok(theaterIds.has(c.theaterId), `Corridor ${c.id} -> unknown theater ${c.theaterId}`); + assert.ok(c.weight > 0 && c.weight <= 1, `Corridor ${c.id} weight out of range: ${c.weight}`); + assert.ok([1, 2, 3].includes(c.tier), `Corridor ${c.id} bad tier ${c.tier}`); + } + }); + + it('regionForCountry resolves correctly with overrides', () => { + assert.equal(regionForCountry('AF'), 'south-asia'); // override (WB has it in MEA) + assert.equal(regionForCountry('PK'), 'south-asia'); // override + assert.equal(regionForCountry('IR'), 'mena'); + assert.equal(regionForCountry('TW'), 'east-asia'); // manually added + assert.equal(regionForCountry('US'), 'north-america'); + assert.equal(regionForCountry('DE'), 'europe'); + assert.equal(regionForCountry('NG'), 'sub-saharan-africa'); + assert.equal(regionForCountry('ZZ'), null); // unknown + }); + + it('getRegionCountries returns at least the keyCountries for each region', () => { + for (const r of REGIONS) { + if (r.id === 'global') continue; + const countries = getRegionCountries(r.id); + for (const key of r.keyCountries) { + assert.ok(countries.includes(key), `${r.id} keyCountry ${key} missing from ISO2 mapping`); + } + } + }); + + it('countryCriticality returns 1.0 for tier-1 corridor controllers', () => { + assert.equal(countryCriticality('IR'), 1.0); // Hormuz + assert.equal(countryCriticality('EG'), 1.0); // Suez + assert.equal(countryCriticality('TR'), 1.0); // Bosphorus + assert.equal(countryCriticality('XX'), 0.3); // default + }); + + it('GEOGRAPHY_VERSION follows semver', () => { + assert.match(GEOGRAPHY_VERSION, /^\d+\.\d+\.\d+$/); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────────────── + +describe('helpers', () => { + it('clip clamps values to range', () => { + assert.equal(clip(0.5, 0, 1), 0.5); + assert.equal(clip(-0.1, 0, 1), 0); + assert.equal(clip(1.5, 0, 1), 1); + assert.equal(clip(NaN, 0, 1), 0); + }); + + it('percentile interpolates linearly', () => { + assert.equal(percentile([0, 1, 2, 3, 4], 0), 0); + assert.equal(percentile([0, 1, 2, 3, 4], 100), 4); + assert.equal(percentile([0, 1, 2, 3, 4], 50), 2); + assert.equal(percentile([], 50), 0); + }); + + it('generateSnapshotId is unique and time-ordered', () => { + const a = generateSnapshotId(); + const b = generateSnapshotId(); + assert.notEqual(a, b); + assert.match(a, /^[0-9a-f]+-[0-9a-f]+$/); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Balance vector +// ──────────────────────────────────────────────────────────────────────────── + +const baseSources = () => ({ + 'risk:scores:sebuf:stale:v1': { + ciiScores: [ + { region: 'IR', combinedScore: 65, trend: 'TREND_DIRECTION_UP' }, + { region: 'IL', combinedScore: 55, trend: 'TREND_DIRECTION_STABLE' }, + { region: 'SA', combinedScore: 30, trend: 'TREND_DIRECTION_STABLE' }, + ], + }, + 'forecast:predictions:v2': { + predictions: [ + { + id: 'f1', + region: 'Middle East', + trend: 'rising', + domain: 'military', + probability: 0.6, + confidence: 0.7, + timeHorizon: 'h24', + caseFile: { actors: [{ name: 'Iran' }] }, + }, + ], + }, + 'supply_chain:chokepoints:v4': { + chokepoints: [ + { id: 'hormuz', name: 'Strait of Hormuz', threatLevel: 'elevated' }, + { id: 'babelm', name: 'Bab el-Mandeb', threatLevel: 'high' }, + { id: 'suez', name: 'Suez', threatLevel: 'normal' }, + ], + }, + 'supply_chain:transit-summaries:v1': { + summaries: { hormuz: { todayTotal: 25, wowChangePct: -12 } }, + }, + 'intelligence:cross-source-signals:v1': { + signals: [ + { id: 's1', type: 'COERCIVE', theater: 'Middle East', severity: 'HIGH', severityScore: 75 }, + ], + }, + 'economic:macro-signals:v1': { verdict: 'NEUTRAL' }, + 'energy:mix:v1:_all': { + IR: { imported: 0.1 }, + SA: { imported: 0.05 }, + AE: { imported: 0.2 }, + EG: { imported: 0.4 }, + IL: { imported: 0.85 }, + }, +}); + +describe('computeBalanceVector', () => { + it('returns all 7 axes with values in [0, 1] except net_balance', () => { + const { vector } = computeBalanceVector('mena', baseSources()); + const axes = [ + 'coercive_pressure', + 'domestic_fragility', + 'capital_stress', + 'energy_vulnerability', + 'alliance_cohesion', + 'maritime_access', + 'energy_leverage', + ]; + for (const axis of axes) { + assert.ok(vector[axis] >= 0 && vector[axis] <= 1, `${axis} out of [0,1]: ${vector[axis]}`); + } + assert.ok(vector.net_balance >= -1 && vector.net_balance <= 1); + }); + + it('decomposes net_balance correctly', () => { + const { vector } = computeBalanceVector('mena', baseSources()); + const pressureMean = (vector.coercive_pressure + vector.domestic_fragility + vector.capital_stress + vector.energy_vulnerability) / 4; + const bufferMean = (vector.alliance_cohesion + vector.maritime_access + vector.energy_leverage) / 3; + const expected = bufferMean - pressureMean; + assert.ok(Math.abs(vector.net_balance - expected) < 0.01, `net_balance=${vector.net_balance} expected≈${expected}`); + }); + + it('always returns at least one driver when there is signal', () => { + const { vector } = computeBalanceVector('mena', baseSources()); + assert.ok(vector.pressures.length + vector.buffers.length > 0); + }); + + it('weighted-tail domestic fragility amplifies high-criticality countries', () => { + const sources = { + 'risk:scores:sebuf:stale:v1': { + ciiScores: [ + // Low CII for low-criticality countries + { region: 'JO', combinedScore: 10 }, + { region: 'BH', combinedScore: 10 }, + // High CII for tier-1 country + { region: 'IR', combinedScore: 90 }, + ], + }, + }; + const { vector } = computeBalanceVector('mena', sources); + // Should be weighted toward IR's 90 score, not the average of 36 + assert.ok(vector.domestic_fragility > 0.4, `expected fragility > 0.4, got ${vector.domestic_fragility}`); + }); + + it('returns zeros gracefully when no inputs available', () => { + const { vector } = computeBalanceVector('mena', {}); + assert.equal(vector.coercive_pressure, 0); + assert.equal(vector.domestic_fragility, 0); + }); + + it('SCORING_VERSION follows semver', () => { + assert.match(SCORING_VERSION, /^\d+\.\d+\.\d+$/); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Regime derivation +// ──────────────────────────────────────────────────────────────────────────── + +describe('deriveRegime', () => { + const base = () => ({ + coercive_pressure: 0, + domestic_fragility: 0, + capital_stress: 0, + energy_vulnerability: 0, + alliance_cohesion: 0.5, + maritime_access: 0.5, + energy_leverage: 0.5, + net_balance: 0, + pressures: [], + buffers: [], + }); + + it('returns calm by default', () => { + assert.equal(deriveRegime(base()), 'calm'); + }); + + it('returns escalation_ladder when coercive > 0.8 and net < -0.4', () => { + const v = { ...base(), coercive_pressure: 0.85, net_balance: -0.5 }; + assert.equal(deriveRegime(v), 'escalation_ladder'); + }); + + it('returns fragmentation_risk when coercive > 0.6 and alliance < 0.3', () => { + const v = { ...base(), coercive_pressure: 0.7, alliance_cohesion: 0.2 }; + assert.equal(deriveRegime(v), 'fragmentation_risk'); + }); + + it('returns coercive_stalemate when coercive > 0.5 and net > -0.1', () => { + const v = { ...base(), coercive_pressure: 0.6, net_balance: 0 }; + assert.equal(deriveRegime(v), 'coercive_stalemate'); + }); + + it('returns managed_deescalation when net > 0.1 and coercive > 0.3', () => { + const v = { ...base(), coercive_pressure: 0.4, net_balance: 0.3 }; + assert.equal(deriveRegime(v), 'managed_deescalation'); + }); + + it('returns stressed_equilibrium when net < -0.1', () => { + const v = { ...base(), net_balance: -0.2 }; + assert.equal(deriveRegime(v), 'stressed_equilibrium'); + }); + + it('buildRegimeState records transition timestamp on label change', () => { + const v = { ...base(), net_balance: -0.2 }; + const r = buildRegimeState(v, 'calm', 'test'); + assert.equal(r.label, 'stressed_equilibrium'); + assert.equal(r.previous_label, 'calm'); + assert.ok(r.transitioned_at > 0); + assert.equal(r.transition_driver, 'test'); + }); + + it('buildRegimeState leaves transitioned_at zero when label unchanged', () => { + const v = base(); + const r = buildRegimeState(v, 'calm'); + assert.equal(r.transitioned_at, 0); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Triggers +// ──────────────────────────────────────────────────────────────────────────── + +describe('evaluateTriggers', () => { + it('returns active/watching/dormant arrays', () => { + const sources = baseSources(); + const { vector } = computeBalanceVector('mena', sources); + const tl = evaluateTriggers('mena', sources, vector); + assert.ok(Array.isArray(tl.active)); + assert.ok(Array.isArray(tl.watching)); + assert.ok(Array.isArray(tl.dormant)); + }); + + it('mena_coercive_high fires when coercive_pressure >= 0.7', () => { + const sources = baseSources(); + const vector = { ...computeBalanceVector('mena', sources).vector, coercive_pressure: 0.75 }; + const tl = evaluateTriggers('mena', sources, vector); + assert.ok(tl.active.some((t) => t.id === 'mena_coercive_high')); + }); + + it('delta operators are dormant in Phase 0 (no historical baseline)', () => { + const sources = baseSources(); + const { vector } = computeBalanceVector('mena', sources); + const tl = evaluateTriggers('mena', sources, vector); + // hormuz_transit_drop and iran_cii_spike use delta operators + assert.ok(!tl.active.some((t) => t.id === 'hormuz_transit_drop')); + assert.ok(!tl.active.some((t) => t.id === 'iran_cii_spike')); + }); + + it('only returns triggers for the requested region', () => { + const sources = baseSources(); + const { vector } = computeBalanceVector('mena', sources); + const tl = evaluateTriggers('east-asia', sources, vector); + // No mena_* triggers should appear + const all = [...tl.active, ...tl.watching, ...tl.dormant]; + assert.ok(!all.some((t) => t.id.startsWith('mena_'))); + assert.ok(!all.some((t) => t.id === 'hormuz_transit_drop')); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Scenario builder +// ──────────────────────────────────────────────────────────────────────────── + +describe('buildScenarioSets', () => { + it('returns one set per horizon (24h, 7d, 30d)', () => { + const sources = baseSources(); + const { vector } = computeBalanceVector('mena', sources); + const triggers = evaluateTriggers('mena', sources, vector); + const sets = buildScenarioSets('mena', sources, triggers); + assert.equal(sets.length, 3); + assert.deepEqual(sets.map((s) => s.horizon).sort(), ['24h', '30d', '7d']); + }); + + it('lane probabilities sum to 1.0 within each set', () => { + const sources = baseSources(); + const { vector } = computeBalanceVector('mena', sources); + const triggers = evaluateTriggers('mena', sources, vector); + const sets = buildScenarioSets('mena', sources, triggers); + for (const set of sets) { + const total = set.lanes.reduce((s, l) => s + l.probability, 0); + assert.ok(Math.abs(total - 1.0) < 0.005, `${set.horizon} lanes sum ${total}, not 1.0`); + } + }); + + it('every lane has the four canonical names', () => { + const sources = baseSources(); + const { vector } = computeBalanceVector('mena', sources); + const triggers = evaluateTriggers('mena', sources, vector); + const sets = buildScenarioSets('mena', sources, triggers); + for (const set of sets) { + assert.deepEqual( + set.lanes.map((l) => l.name).sort(), + ['base', 'containment', 'escalation', 'fragmentation'], + ); + } + }); + + it('keeps base lane dominant when no forecast or trigger data', () => { + const sets = buildScenarioSets('global', {}, { active: [], watching: [], dormant: [] }); + for (const set of sets) { + const base = set.lanes.find((l) => l.name === 'base'); + // With no inputs, base should dominate (initial seed score is 0.4 vs 0.1 for others) + assert.ok(base.probability > 0.5, `expected base > 0.5, got ${base.probability}`); + } + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Transmission templates +// ──────────────────────────────────────────────────────────────────────────── + +describe('resolveTransmissions', () => { + it('returns empty list when no triggers active', () => { + const out = resolveTransmissions('mena', { active: [], watching: [], dormant: [] }); + assert.equal(out.length, 0); + }); + + it('matches transmission templates to active triggers', () => { + const triggers = { + active: [{ id: 'mena_coercive_high', description: '', threshold: {}, activated: true, activated_at: 0, scenario_lane: 'escalation', evidence_ids: [] }], + watching: [], + dormant: [], + }; + const out = resolveTransmissions('mena', triggers); + assert.ok(out.length > 0); + for (const t of out) { + assert.ok(t.template_id); + assert.equal(t.template_version, TEMPLATE_VERSION); + assert.ok(t.confidence >= 0 && t.confidence <= 1); + } + }); + + it('only emits transmissions for the requested region', () => { + const triggers = { + active: [{ id: 'taiwan_tension_high', description: '', threshold: {}, activated: true, activated_at: 0, scenario_lane: 'escalation', evidence_ids: [] }], + watching: [], + dormant: [], + }; + const out = resolveTransmissions('mena', triggers); + assert.equal(out.length, 0); // Taiwan template doesn't list MENA in affected regions... let's check actual output + + const eastAsia = resolveTransmissions('east-asia', triggers); + assert.ok(eastAsia.length > 0); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Snapshot meta +// ──────────────────────────────────────────────────────────────────────────── + +describe('snapshot meta', () => { + it('buildPreMeta computes confidence from completeness and freshness', () => { + const allKeys = {}; + for (const s of FRESHNESS_REGISTRY) allKeys[s.key] = { fetchedAt: Date.now() }; + const { pre } = buildPreMeta(allKeys, '1.0.0', '1.0.0'); + assert.equal(pre.snapshot_confidence, 1); + assert.equal(pre.missing_inputs.length, 0); + assert.equal(pre.stale_inputs.length, 0); + }); + + it('buildPreMeta marks missing inputs', () => { + const { pre } = buildPreMeta({}, '1.0.0', '1.0.0'); + assert.ok(pre.snapshot_confidence < 1); + assert.ok(pre.missing_inputs.length > 0); + }); + + it('buildPreMeta marks stale inputs based on max-age', () => { + const old = { fetchedAt: Date.now() - 999_999_999 }; + const sources = { 'risk:scores:sebuf:stale:v1': old }; + const { pre } = buildPreMeta(sources, '1.0.0', '1.0.0'); + assert.ok(pre.stale_inputs.includes('risk:scores:sebuf:stale:v1')); + }); + + it('buildFinalMeta merges pre + finalFields preserving snapshot_id', () => { + const { pre } = buildPreMeta({}, '1.0.0', '1.0.0'); + const final = buildFinalMeta(pre, { + snapshot_id: 'abc-123', + trigger_reason: 'regime_shift', + narrative_provider: 'groq', + narrative_model: 'mixtral', + }); + assert.equal(final.snapshot_id, 'abc-123'); + assert.equal(final.trigger_reason, 'regime_shift'); + assert.equal(final.narrative_provider, 'groq'); + assert.equal(final.model_version, MODEL_VERSION); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Diff engine +// ──────────────────────────────────────────────────────────────────────────── + +describe('diffRegionalSnapshot', () => { + function makeSnapshot(overrides = {}) { + return { + region_id: 'mena', + generated_at: Date.now(), + meta: { snapshot_id: 'x', model_version: '0.1.0', scoring_version: '1.0.0', geography_version: '1.0.0', snapshot_confidence: 1, missing_inputs: [], stale_inputs: [], valid_until: 0, trigger_reason: 'scheduled_6h', narrative_provider: '', narrative_model: '' }, + regime: { label: 'calm', previous_label: '', transitioned_at: 0, transition_driver: '' }, + balance: { coercive_pressure: 0, domestic_fragility: 0, capital_stress: 0, energy_vulnerability: 0, alliance_cohesion: 0.5, maritime_access: 0.7, energy_leverage: 0.5, net_balance: 0, pressures: [], buffers: [] }, + actors: [], + leverage_edges: [], + scenario_sets: [{ horizon: '24h', lanes: [{ name: 'base', probability: 1, trigger_ids: [], consequences: [], transmissions: [] }] }], + transmission_paths: [], + triggers: { active: [], watching: [], dormant: [] }, + mobility: { airspace: [], flight_corridors: [], airports: [], reroute_intensity: 0, notam_closures: [] }, + evidence: [], + narrative: { situation: { text: '', evidence_ids: [] }, balance_assessment: { text: '', evidence_ids: [] }, outlook_24h: { text: '', evidence_ids: [] }, outlook_7d: { text: '', evidence_ids: [] }, outlook_30d: { text: '', evidence_ids: [] }, watch_items: [] }, + ...overrides, + }; + } + + it('returns no diffs for identical snapshots', () => { + const s = makeSnapshot(); + const diff = diffRegionalSnapshot(s, s); + assert.equal(diff.regime_changed, null); + assert.equal(diff.scenario_jumps.length, 0); + assert.equal(diff.trigger_activations.length, 0); + }); + + it('detects regime change', () => { + const a = makeSnapshot(); + const b = makeSnapshot({ regime: { ...a.regime, label: 'coercive_stalemate' } }); + const diff = diffRegionalSnapshot(a, b); + assert.deepEqual(diff.regime_changed, { from: 'calm', to: 'coercive_stalemate' }); + }); + + it('detects scenario probability jumps > 15%', () => { + const a = makeSnapshot(); + const b = makeSnapshot({ + scenario_sets: [{ horizon: '24h', lanes: [{ name: 'base', probability: 0.8, trigger_ids: [], consequences: [], transmissions: [] }] }], + }); + const diff = diffRegionalSnapshot(a, b); + assert.equal(diff.scenario_jumps.length, 1); + assert.equal(diff.scenario_jumps[0].lane, 'base'); + }); + + it('detects new trigger activations', () => { + const a = makeSnapshot(); + const b = makeSnapshot({ + triggers: { active: [{ id: 't1', description: 'New trigger', threshold: {}, activated: true, activated_at: 0, scenario_lane: 'escalation', evidence_ids: [] }], watching: [], dormant: [] }, + }); + const diff = diffRegionalSnapshot(a, b); + assert.equal(diff.trigger_activations.length, 1); + assert.equal(diff.trigger_activations[0].id, 't1'); + }); + + it('detects buffer failures (> 0.20 drop)', () => { + const a = makeSnapshot(); + const b = makeSnapshot({ balance: { ...a.balance, alliance_cohesion: 0.2 } }); + const diff = diffRegionalSnapshot(a, b); + assert.ok(diff.buffer_failures.some((f) => f.axis === 'alliance_cohesion')); + }); + + it('handles null prev (first snapshot ever) gracefully', () => { + const b = makeSnapshot({ regime: { label: 'coercive_stalemate', previous_label: '', transitioned_at: 0, transition_driver: '' } }); + const diff = diffRegionalSnapshot(null, b); + assert.deepEqual(diff.regime_changed, { from: '', to: 'coercive_stalemate' }); + }); + + it('inferTriggerReason picks regime_shift first', () => { + const diff = { regime_changed: { from: 'calm', to: 'escalation_ladder' }, scenario_jumps: [], trigger_activations: [{ id: 't1' }], trigger_deactivations: [], corridor_breaks: [], leverage_shifts: [], buffer_failures: [], reroute_waves: null, mobility_disruptions: [] }; + assert.equal(inferTriggerReason(diff), 'regime_shift'); + }); + + it('inferTriggerReason falls back to scheduled_6h when nothing changed', () => { + const diff = { regime_changed: null, scenario_jumps: [], trigger_activations: [], trigger_deactivations: [], corridor_breaks: [], leverage_shifts: [], buffer_failures: [], reroute_waves: null, mobility_disruptions: [] }; + assert.equal(inferTriggerReason(diff), 'scheduled_6h'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// End-to-end pipeline (no Redis) +// ──────────────────────────────────────────────────────────────────────────── + +describe('end-to-end pipeline', () => { + it('runs the full compute order without throwing', () => { + const sources = baseSources(); + const { vector: balance } = computeBalanceVector('mena', sources); + const { actors } = scoreActors('mena', sources); + const triggers = evaluateTriggers('mena', sources, balance); + const scenarios = buildScenarioSets('mena', sources, triggers); + const transmissions = resolveTransmissions('mena', triggers); + const evidence = collectEvidence('mena', sources); + const { pre } = buildPreMeta(sources, SCORING_VERSION, GEOGRAPHY_VERSION); + const snapshotId = generateSnapshotId(); + const meta = buildFinalMeta(pre, { snapshot_id: snapshotId, trigger_reason: 'scheduled_6h' }); + + assert.ok(balance); + assert.ok(Array.isArray(actors)); + assert.ok(triggers); + assert.equal(scenarios.length, 3); + assert.ok(Array.isArray(transmissions)); + assert.ok(Array.isArray(evidence)); + assert.ok(meta.snapshot_id); + }); + + it('produces a snapshot for every region without throwing', () => { + const sources = baseSources(); + for (const region of REGIONS) { + const { vector } = computeBalanceVector(region.id, sources); + const triggers = evaluateTriggers(region.id, sources, vector); + const scenarios = buildScenarioSets(region.id, sources, triggers); + const transmissions = resolveTransmissions(region.id, triggers); + const evidence = collectEvidence(region.id, sources); + assert.ok(vector, `${region.id}: balance computed`); + assert.equal(scenarios.length, 3, `${region.id}: 3 scenario sets`); + } + }); +}); diff --git a/todos/166-complete-p1-health-seed-meta-not-in-keys-loops.md b/todos/166-complete-p1-health-seed-meta-not-in-keys-loops.md new file mode 100644 index 000000000..6a646b4ad --- /dev/null +++ b/todos/166-complete-p1-health-seed-meta-not-in-keys-loops.md @@ -0,0 +1,61 @@ +--- +status: complete +priority: p1 +issue_id: 166 +tags: [code-review, phase-0, regional-intelligence, health, monitoring] +dependencies: [] +--- + +# Health check for regionalSnapshots is dead wiring (not in BOOTSTRAP_KEYS or STANDALONE_KEYS) + +## Problem Statement +PR #2940 added `SEED_META.regionalSnapshots` to `api/health.js:225` but the entry is never read. `health.js` only dereferences `SEED_META[name]` inside two loops that iterate `BOOTSTRAP_KEYS` (line ~402) and `STANDALONE_KEYS` (line ~474). There is no `regionalSnapshots` key in either map, so the freshness check silently no-ops. The 12h staleness budget is unobservable: if the seeder falls behind, nothing alerts. + +Same failure mode as `feedback_empty_data_ok_keys_bootstrap_blind_spot.md` and the `health-maxstalemin-write-cadence` skill. + +## Findings +- `api/health.js:225` has the `SEED_META` entry for `regionalSnapshots`. +- Neither `BOOTSTRAP_KEYS` nor `STANDALONE_KEYS` contains `regionalSnapshots`. +- Verified by grepping for `regionalSnapshots` across `api/health.js`: only one match (the `SEED_META` entry itself). +- Net effect: the health endpoint never exercises the freshness budget for this seed. + +## Proposed Solutions + +### Option 1: Add to STANDALONE_KEYS +Add `regionalSnapshots: 'intelligence:regional-snapshots:summary:v1'` to `STANDALONE_KEYS`. This is the seeded summary key written by `seed-regional-snapshots.mjs:208`. + +**Pros:** Minimal change, matches current phase (Phase 0 seeds but no panel consumer yet). +**Cons:** Will need to move it to `BOOTSTRAP_KEYS` when Phase 1 panel bootstrap lands. +**Effort:** Small +**Risk:** Low + +### Option 2: Add to BOOTSTRAP_KEYS now +Add to `BOOTSTRAP_KEYS` directly since Phase 1 will consume this in the panel bootstrap. + +**Pros:** Cleaner forward compat, avoids a second migration. +**Cons:** Premature coupling; bootstrap payload grows before consumer exists. +**Effort:** Small +**Risk:** Medium (risks shipping bootstrap field with no consumer) + +## Recommended Action +(leave blank for triage) + +## Technical Details +- Affected files: `api/health.js` +- Components: health endpoint, seed freshness monitoring +- Related seed: `scripts/seed-regional-snapshots.mjs:208` +- Related key: `intelligence:regional-snapshots:summary:v1` + +## Acceptance Criteria +- [ ] `regionalSnapshots` key appears in either `BOOTSTRAP_KEYS` or `STANDALONE_KEYS` in `api/health.js` +- [ ] Hitting `/api/health` on production after first cron cycle returns OK status for `regionalSnapshots` +- [ ] If seeder fails for >720 min, `/api/health` goes red + +## Work Log +(empty) + +## Resources +- PR #2940 +- Spec: docs/internal/pro-regional-intelligence-upgrade.md +- Related memory: `feedback_empty_data_ok_keys_bootstrap_blind_spot.md` +- Related skill: `health-maxstalemin-write-cadence` diff --git a/todos/167-complete-p1-oref-trigger-key-not-in-freshness-registry.md b/todos/167-complete-p1-oref-trigger-key-not-in-freshness-registry.md new file mode 100644 index 000000000..acfcb84f5 --- /dev/null +++ b/todos/167-complete-p1-oref-trigger-key-not-in-freshness-registry.md @@ -0,0 +1,62 @@ +--- +status: complete +priority: p1 +issue_id: 167 +tags: [code-review, phase-0, regional-intelligence, triggers, dead-code] +dependencies: [] +--- + +# OREF cluster trigger reads Redis key not in freshness registry (silently always-dormant) + +## Problem Statement +The `oref_cluster` trigger in `triggers.config.mjs:68-80` resolves the metric `oref:active_alerts_count` by reading `sources['intelligence:oref-alerts:v1']` in `trigger-evaluator.mjs:97`. But that key is NOT in `FRESHNESS_REGISTRY` (`scripts/regional-snapshot/freshness.mjs:16-32`) and therefore not in `ALL_INPUT_KEYS` consumed by `readAllInputs()` in the seed entry. The trigger always returns 0 from undefined data, fails the `value > 10` threshold, and is permanently dormant. + +This is a silent dead trigger that ships as part of the Phase 0 trigger set but never fires. Same class of bug as `feedback_empty_data_ok_keys_bootstrap_blind_spot.md`. + +## Findings +- `scripts/regional-snapshot/trigger-evaluator.mjs:95-99` reads `sources['intelligence:oref-alerts:v1']`. +- `scripts/regional-snapshot/freshness.mjs:16-32` does NOT include this key. +- `scripts/regional-snapshot/triggers.config.mjs:68-80` declares the `oref_cluster` trigger expecting this metric. +- Because the key is not in `ALL_INPUT_KEYS`, `readAllInputs()` never fetches it, `sources[...]` is undefined, metric resolves to 0, threshold never trips. + +## Proposed Solutions + +### Option 1: Wire the input +Add `intelligence:oref-alerts:v1` to `FRESHNESS_REGISTRY` with `maxAgeMin` matching the OREF relay cadence (~5 min). + +**Pros:** OREF data is genuinely useful for MENA coercive pressure scoring. Minimal change, enables the trigger as designed. +**Cons:** Adds one more key to the input pipeline. +**Effort:** Small +**Risk:** Low + +### Option 2: Remove the dead trigger +Delete the `oref_cluster` trigger from `triggers.config.mjs` until Phase 1 wires the input. + +**Pros:** Fewer moving parts shipped in Phase 0. +**Cons:** Loses MENA signal; trigger has to be reintroduced later. +**Effort:** Small +**Risk:** Low + +## Recommended Action +(leave blank for triage) + +## Technical Details +- Affected files: + - `scripts/regional-snapshot/freshness.mjs` + - `scripts/regional-snapshot/trigger-evaluator.mjs` + - `scripts/regional-snapshot/triggers.config.mjs` +- Components: regional snapshot trigger evaluation, freshness registry, OREF source wiring +- Related key: `intelligence:oref-alerts:v1` + +## Acceptance Criteria +- [ ] `FRESHNESS_REGISTRY` includes `intelligence:oref-alerts:v1` (or the trigger is removed) +- [ ] Test added that verifies `oref_cluster` fires when `sources` contains a payload with >10 active alerts +- [ ] Phase 0 snapshot for MENA region includes `oref_cluster` in active or watching list when synthetic data is provided + +## Work Log +(empty) + +## Resources +- PR #2940 +- Spec: docs/internal/pro-regional-intelligence-upgrade.md +- Related memory: `feedback_empty_data_ok_keys_bootstrap_blind_spot.md` diff --git a/todos/168-complete-p1-zombie-freshness-registry-keys.md b/todos/168-complete-p1-zombie-freshness-registry-keys.md new file mode 100644 index 000000000..965bb72f8 --- /dev/null +++ b/todos/168-complete-p1-zombie-freshness-registry-keys.md @@ -0,0 +1,66 @@ +--- +status: complete +priority: p1 +issue_id: 168 +tags: [code-review, phase-0, regional-intelligence, freshness, dead-code] +dependencies: [] +--- + +# Four freshness registry keys have no compute consumer (zombie keys drag down confidence score) + +## Problem Statement +`scripts/regional-snapshot/freshness.mjs:16-32` lists four keys that the seed script reads on every run but no compute module ever consumes via `sources['...']`: +1. `supply_chain:shipping_stress:v1` +2. `energy:chokepoint-flows:v1` +3. `intelligence:advisories-bootstrap:v2` +4. `market:commodities-bootstrap:v1` + +These keys waste a Redis pipeline GET each run, pollute `missing_inputs` when absent, and drag down `snapshot_confidence` for no reason. They also create the false impression that the compute modules consume them, misleading Phase 1 engineers who add new axes. + +## Findings +- Verified via grep for each key string across `scripts/regional-snapshot/*.mjs`: zero consumers. +- The keys appear only in `freshness.mjs`. +- Each run still pipelines a GET for these keys (wasted Redis round-trip bytes). +- When any of them is missing, `snapshot_confidence` drops and `missing_inputs` grows without the compute pipeline ever having needed the data. + +## Proposed Solutions + +### Option 1: Prune to real consumers +Remove all 4 keys from `FRESHNESS_REGISTRY` until they have a real consumer. + +**Pros:** Eliminates wasted GETs, prevents confidence-score false negatives, prevents engineer confusion. +**Cons:** Keys must be re-added when genuinely wired. +**Effort:** Small +**Risk:** Low + +### Option 2: Wire each into a compute module +Wire each into a specific compute module (e.g., `shipping_stress` into a new transmission enrichment, `advisories` into mobility). + +**Pros:** Actually uses the signals that are being fetched. +**Cons:** Large cross-cutting work; out of Phase 0 scope. +**Effort:** Medium per key (4 × Medium) +**Risk:** Medium + +## Recommended Action +(leave blank for triage) + +## Technical Details +- Affected file: `scripts/regional-snapshot/freshness.mjs` +- Components: freshness registry, snapshot confidence computation, missing_inputs reporting +- Zombie keys: + - `supply_chain:shipping_stress:v1` + - `energy:chokepoint-flows:v1` + - `intelligence:advisories-bootstrap:v2` + - `market:commodities-bootstrap:v1` + +## Acceptance Criteria +- [ ] `FRESHNESS_REGISTRY` has only the keys that compute modules actually read +- [ ] `snapshot_confidence` reaches 1.0 when all remaining keys are present +- [ ] Test added that asserts every key in `FRESHNESS_REGISTRY` appears in at least one `sources['...']` reference (static analysis or convention) + +## Work Log +(empty) + +## Resources +- PR #2940 +- Spec: docs/internal/pro-regional-intelligence-upgrade.md diff --git a/todos/169-complete-p1-diff-field-leaks-into-persisted-snapshot.md b/todos/169-complete-p1-diff-field-leaks-into-persisted-snapshot.md new file mode 100644 index 000000000..281f52d33 --- /dev/null +++ b/todos/169-complete-p1-diff-field-leaks-into-persisted-snapshot.md @@ -0,0 +1,67 @@ +--- +status: complete +priority: p1 +issue_id: 169 +tags: [code-review, phase-0, regional-intelligence, types, persistence] +dependencies: [] +--- + +# SnapshotDiff field serialized into persisted snapshot (proto contract leak) + +## Problem Statement +`scripts/seed-regional-snapshots.mjs:164` returns `{...tentativeSnapshot, meta: finalMeta, diff}` from `computeSnapshot`. The `diff` field is NOT part of the `RegionalSnapshot` type defined in `shared/regions.types.d.ts`. The persist layer in `persist-snapshot.mjs:48` then `JSON.stringifies` the full object including `diff` and writes it to 3 Redis keys. + +When Phase 1 generates the proto from `RegionalSnapshot` and server handlers deserialize Redis values, they will either: +1. Error on the unknown `diff` field with strict parsers (Buf/Connect-ES is strict) +2. Silently drop it with permissive parsers +3. Serialize it back out, propagating the contract leak to clients + +The architectural commitment is "the persisted snapshot is canonical". The disk shape must match the typed shape. + +## Findings +- `scripts/seed-regional-snapshots.mjs:164`: `return { ...tentativeSnapshot, meta: finalMeta, diff }`. +- `shared/regions.types.d.ts` has no `diff` field on `RegionalSnapshot`. +- `scripts/regional-snapshot/persist-snapshot.mjs:47-48` stringifies the full object and writes to 3 Redis keys. +- Phase 1 proto generation will pull from `RegionalSnapshot`, creating a drift between the runtime value and the typed contract. + +## Proposed Solutions + +### Option 1: Return diff as sibling, not spread +Change `computeSnapshot` to return `{snapshot, diff}` separately. Persist only `snapshot`. Use `diff` for `inferTriggerReason` and for Phase 2 alert emission. + +**Pros:** Keeps persisted shape aligned to type; diff remains available for runtime use. +**Cons:** Small refactor of the call site and any consumers. +**Effort:** Small +**Risk:** Low + +### Option 2: Add diff to the type +Add `diff` as an optional field in `RegionalSnapshot` type. + +**Pros:** Zero runtime change. +**Cons:** Couples persistent shape to a runtime detail; grows the proto surface area; violates the "persisted = canonical" commitment. +**Effort:** Small +**Risk:** Medium + +## Recommended Action +(leave blank for triage) + +## Technical Details +- Affected files: + - `scripts/seed-regional-snapshots.mjs:164` + - `scripts/regional-snapshot/persist-snapshot.mjs:47-48` + - `shared/regions.types.d.ts` +- Components: snapshot compute, persist layer, type contract, future proto generation +- Touched Redis keys: 3 keys written by `persistSnapshot` + +## Acceptance Criteria +- [ ] `computeSnapshot` returns `{snapshot, diff}` as separate fields +- [ ] `persistSnapshot` receives only the snapshot, not the wrapper +- [ ] JSON inspection of any persisted `intelligence:snapshot:*` key has no `diff` property +- [ ] Test added: `JSON.parse(persistedSnapshot)` matches the `RegionalSnapshot` type exactly + +## Work Log +(empty) + +## Resources +- PR #2940 +- Spec: docs/internal/pro-regional-intelligence-upgrade.md diff --git a/todos/170-complete-p1-jsdoc-types-not-enforced-jsconfig-missing.md b/todos/170-complete-p1-jsdoc-types-not-enforced-jsconfig-missing.md new file mode 100644 index 000000000..e0472be3b --- /dev/null +++ b/todos/170-complete-p1-jsdoc-types-not-enforced-jsconfig-missing.md @@ -0,0 +1,67 @@ +--- +status: complete +priority: p1 +issue_id: 170 +tags: [code-review, phase-0, regional-intelligence, type-safety, build] +dependencies: [] +--- + +# JSDoc @type annotations in scripts/regional-snapshot/*.mjs are decorative - tsc --checkJs not configured + +## Problem Statement +All 13 modules under `scripts/regional-snapshot/*.mjs` use JSDoc annotations like `@type {import('../../shared/regions.types.js').BalanceVector}`. But: +1. `shared/regions.types.js` does not exist: only `shared/regions.types.d.ts` +2. `scripts/jsconfig.json` include array does NOT contain `seed-regional-snapshots.mjs` or `scripts/regional-snapshot/*.mjs` +3. Therefore `tsc --checkJs` never validates these annotations +4. The types are pure decoration that mislead reviewers into thinking the code is type-safe + +This directly violates the user-mandated rule from `/Users/eliehabib/.claude/projects/-Users-eliehabib-Documents-GitHub-worldmonitor/memory/feedback_type_safety_always.md`: "ALWAYS write type-safe code: JSDoc+@ts-check for .mjs, .types.d.ts for data structures, strict TS for .ts files. Non-negotiable." + +## Findings +- `scripts/jsconfig.json` includes only specific files; grep for "regional-snapshot" or "seed-regional-snapshots" in `jsconfig.json` returns nothing. +- `shared/regions.types.js` does not exist (only the `.d.ts`). +- No `// @ts-check` directive at the top of the regional-snapshot modules. +- Net effect: reviewers see JSDoc `@type` annotations and assume the code is checked, but tsc never visits these files, so any type drift goes unreported. + +## Proposed Solutions + +### Option 1: Wire into existing jsconfig +Add `scripts/seed-regional-snapshots.mjs` and `scripts/regional-snapshot/*.mjs` to `scripts/jsconfig.json`'s include array. Drop the `.js` extension from JSDoc imports (`import('../../shared/regions.types')` resolves to the `.d.ts`). Add `// @ts-check` directive at the top of each `.mjs`. + +**Pros:** Honors the "type safety always" rule with minimum file churn. Surfaces real type errors. +**Cons:** Will surface real type errors that need fixing before merge. +**Effort:** Small +**Risk:** Low (risk is surfaced errors, not runtime regressions) + +### Option 2: Convert to TypeScript +Convert the `.mjs` files to `.ts` and put them under `scripts/jsconfig.json` or a new tsconfig. + +**Pros:** Strongest type safety. +**Cons:** Much larger change; touches the Railway seed runtime, requires compile or tsx loader, out of Phase 0 scope. +**Effort:** Large +**Risk:** Medium (runtime/loader changes) + +## Recommended Action +(leave blank for triage) + +## Technical Details +- Affected files: + - `scripts/jsconfig.json` (add includes) + - `scripts/seed-regional-snapshots.mjs` + - `scripts/regional-snapshot/*.mjs` (13 modules, add `// @ts-check`, fix import paths) +- Components: build-time type checking, regional snapshot seed modules, CI pre-push hook +- Related rule: `/Users/eliehabib/.claude/projects/-Users-eliehabib-Documents-GitHub-worldmonitor/memory/feedback_type_safety_always.md` + +## Acceptance Criteria +- [ ] `npx tsc --noEmit -p scripts/jsconfig.json` runs and validates regional-snapshot modules +- [ ] All `@type` annotations resolve correctly +- [ ] Each `.mjs` has `// @ts-check` at the top +- [ ] Type errors surfaced by Option 1 are fixed before merge + +## Work Log +(empty) + +## Resources +- PR #2940 +- Spec: docs/internal/pro-regional-intelligence-upgrade.md +- Rule: `feedback_type_safety_always.md` diff --git a/todos/171-pending-p2-iscclosethreshold-inverted-for-lt-operators.md b/todos/171-pending-p2-iscclosethreshold-inverted-for-lt-operators.md new file mode 100644 index 000000000..541df0caf --- /dev/null +++ b/todos/171-pending-p2-iscclosethreshold-inverted-for-lt-operators.md @@ -0,0 +1,64 @@ +--- +status: pending +priority: p2 +issue_id: 171 +tags: [code-review, phase-0, regional-intelligence, trigger-evaluator, bug] +dependencies: [] +--- + +# isCloseToThreshold watching band inverted for lt/lte operators + +## Problem Statement + +`scripts/regional-snapshot/trigger-evaluator.mjs:120-126` uses `ratio = value / target; return ratio > 0.8 && ratio < 1.0`. This is only correct for gt/gte triggers with positive thresholds. For lt with threshold 0.3, value 0.32 has ratio 1.07 which returns false (dormant), while value 0.28 has ratio 0.93 which returns true (watching). But 0.28 already PASSES the "less than" check (should be active), and 0.32 is what's actually close to breaching. The watching band is inverted for lt/lte operators. Also broken for negative thresholds (delta_lt with -0.20). Phase 1 will unstub delta ops and surface this bug. + +## Findings + +- `scripts/regional-snapshot/trigger-evaluator.mjs:120-126` — current ratio-based implementation only handles gt/gte with positive thresholds correctly. + +## Proposed Solutions + +### Option 1: Branch on threshold.operator + +Implement explicit operator-aware watching bands: +- For `gt` / `gte`: `value >= t * 0.8 && value < t` +- For `lt` / `lte`: `value > t && value <= t * 1.2` +- For `delta_*` operators: return `false` (Phase 0 stub) + +**Pros:** Correct semantics per operator; fits current stub-until-Phase-1 posture for delta ops; simple to test. +**Cons:** None significant. +**Effort:** Small. +**Risk:** Low. + +### Option 2: Distance-from-threshold formulation + +Normalize via absolute distance: `Math.abs(value - t) / Math.abs(t) <= 0.2 && !isActive(value, t, op)`. + +**Pros:** Single formula, no operator branching. +**Cons:** Requires access to `isActive` result; denominator zero-guard needed; less readable than explicit branching. +**Effort:** Small. +**Risk:** Medium (zero-threshold division, subtle negative-threshold cases). + +## Recommended Action + +Option 1 — explicit operator branching. Small surface area, clear semantics, easy unit tests. + +## Technical Details + +Current buggy code at `trigger-evaluator.mjs:120-126` treats `ratio < 1.0` as universally meaning "not yet breached." That is only true when the trigger is `gt/gte` and the threshold is positive. For `lt` triggers the inequality is reversed: a value above threshold is the dormant/approaching side, while a value below is already-active. The fix partitions the computation by operator and returns `false` for all `delta_*` operators while Phase 0 still stubs them. + +Negative thresholds (e.g. `delta_lt: -0.20`) are also silently wrong under the current formulation because `value / target` has the wrong sign semantics. + +## Acceptance Criteria + +- [ ] Test: `isCloseToThreshold(0.28, { operator: 'lt', value: 0.3 })` returns `false` (it's already active). +- [ ] Test: `isCloseToThreshold(0.32, { operator: 'lt', value: 0.3 })` returns `true` (close to breaching). +- [ ] Test: `isCloseToThreshold(0.85, { operator: 'gte', value: 1.0 })` returns `true`. +- [ ] Test: `delta_*` operators always return `false` in Phase 0. + +## Work Log + +## Resources + +- PR #2940 +- Spec: `docs/internal/pro-regional-intelligence-upgrade.md` diff --git a/todos/172-pending-p2-sequential-readlatestsnapshot-1600ms-overhead.md b/todos/172-pending-p2-sequential-readlatestsnapshot-1600ms-overhead.md new file mode 100644 index 000000000..4d3324f9f --- /dev/null +++ b/todos/172-pending-p2-sequential-readlatestsnapshot-1600ms-overhead.md @@ -0,0 +1,62 @@ +--- +status: pending +priority: p2 +issue_id: 172 +tags: [code-review, phase-0, regional-intelligence, performance, redis] +dependencies: [] +--- + +# Sequential per-region readLatestSnapshot wastes ~1600ms wall-clock per cron run + +## Problem Statement + +Each region in the snapshot writer calls `readLatestSnapshot()`, which issues 2 sequential round-trip GETs (latest pointer → ID, then snapshot-by-id). With 8 regions inside the sequential `for` loop in `seed-regional-snapshots.mjs:181`, that's 16 serial HTTP round-trips. At ~100ms Upstash latency that's ~1600ms wall-clock for reads alone on every cron invocation. + +## Findings + +- `scripts/regional-snapshot/persist-snapshot.mjs:85-113` implements the two-step read. +- `scripts/seed-regional-snapshots.mjs:135` calls `readLatestSnapshot` from inside `computeSnapshot`. +- `scripts/seed-regional-snapshots.mjs:181` wraps `computeSnapshot` in a sequential `for` loop over all 8 regions. + +## Proposed Solutions + +### Option 1: Hoist readLatestSnapshot out of computeSnapshot + +Move `readLatestSnapshot` into `main()`. Issue all 8 `:latest` GETs as one pipeline, then all 8 `snapshot-by-id` GETs as one pipeline. Two round-trips total instead of 16. + +**Pros:** Preserves existing pointer-indirection schema; minimal churn; ~1400ms saved. +**Cons:** `computeSnapshot` signature grows to accept `prevSnapshot`. +**Effort:** Medium. +**Risk:** Low. + +### Option 2: Inline the full snapshot in the `:latest` key + +Drop the indirection — store the full snapshot JSON directly in `:latest`. Then 8 GETs become a single pipeline. ~1500ms saved. + +**Pros:** Simplest read path; one pipeline call total. +**Cons:** Writer must keep `:by-id` and `:latest` in sync with the same payload; roughly doubles write-side storage; migration needed. +**Effort:** Medium. +**Risk:** Medium (write-side invariant must hold). + +## Recommended Action + +Option 2 if simplicity matters more than storage; Option 1 if minimal change matters more. Either choice combined with #173 drops total runtime from ~3.4s to <800ms. + +## Technical Details + +Upstash REST latency per call is ~100ms on the current Railway region. The reads are fully independent per region (dedup keys are region-scoped and the snapshot schema is isolated). Both options are safe to parallelize because no read depends on another region's write. + +Pipeline API on the existing `redis.ts` helper supports batched GETs, so Option 1 is a mechanical transformation. + +## Acceptance Criteria + +- [ ] All 8 region prev snapshots are fetched in <300ms total wall-clock. +- [ ] Existing tests still pass. +- [ ] No regression in dedup/write semantics. + +## Work Log + +## Resources + +- PR #2940 +- Spec: `docs/internal/pro-regional-intelligence-upgrade.md` diff --git a/todos/173-pending-p2-sequential-per-region-persist-1600ms.md b/todos/173-pending-p2-sequential-per-region-persist-1600ms.md new file mode 100644 index 000000000..9857084a6 --- /dev/null +++ b/todos/173-pending-p2-sequential-per-region-persist-1600ms.md @@ -0,0 +1,62 @@ +--- +status: pending +priority: p2 +issue_id: 173 +tags: [code-review, phase-0, regional-intelligence, performance, redis] +dependencies: [] +--- + +# Sequential per-region persist pipelines waste ~1600ms wall-clock + +## Problem Statement + +`scripts/seed-regional-snapshots.mjs:181-203` awaits `persistSnapshot(snapshot)` per region in a sequential `for` loop. Each call issues 2 round-trips (dedup SETNX + data pipeline). 8 regions × 2 round-trips = 16 serial round-trips = ~1600ms wall-clock. Regions are fully independent (dedup keys are region-scoped) so this is safe to parallelize. + +## Findings + +- `scripts/seed-regional-snapshots.mjs:181-203` — sequential for-loop over regions with `await persistSnapshot` inside. + +## Proposed Solutions + +### Option 1: Parallelize with Promise.all + +Replace the sequential for-loop with `Promise.all(regions.map(async region => ...))`. Saves ~1400ms wall-clock. + +**Pros:** Trivial change; region-independent writes make this safe; failures are isolated per region via `Promise.allSettled` pattern. +**Cons:** Partial failure handling needs explicit `allSettled` to not block other regions. +**Effort:** Small. +**Risk:** Low. + +### Option 2: Batch pipeline all 8 regions into one redis.pipeline() call + +Collect all writes, emit a single multi-region pipeline. Saves ~1500ms. + +**Pros:** Single round-trip for writes. +**Cons:** Dedup SETNX results need to be checked before emitting the data writes, so you still need two phases; more invasive refactor. +**Effort:** Medium. +**Risk:** Medium. + +## Recommended Action + +Option 1 with `Promise.allSettled`. Combined with #172, drops total runtime from ~3.4s to <800ms. + +## Technical Details + +`persistSnapshot` currently does: +1. `SETNX dedup:{region}:{hash} 1` (1 round-trip) +2. `pipeline: SET by-id, SET latest, EXPIRE...` (1 round-trip) + +Each region's dedup key is namespaced by region, so there is no cross-region write ordering constraint. Switching from `for (const r of regions) await persist(r)` to `Promise.allSettled(regions.map(persist))` preserves per-region dedup guarantees while removing the inter-region serialization. + +## Acceptance Criteria + +- [ ] All 8 regions persisted in parallel. +- [ ] Failed regions don't block other regions (use `Promise.allSettled`). +- [ ] Existing tests still pass. + +## Work Log + +## Resources + +- PR #2940 +- Spec: `docs/internal/pro-regional-intelligence-upgrade.md` diff --git a/todos/174-pending-p2-seeder-bypasses-runseed-gold-standard.md b/todos/174-pending-p2-seeder-bypasses-runseed-gold-standard.md new file mode 100644 index 000000000..ad766a8dc --- /dev/null +++ b/todos/174-pending-p2-seeder-bypasses-runseed-gold-standard.md @@ -0,0 +1,76 @@ +--- +status: pending +priority: p2 +issue_id: 174 +tags: [code-review, phase-0, regional-intelligence, seeder, redis, gold-standard] +dependencies: [] +--- + +# Snapshot seeder bypasses runSeed gold-standard (no lock, no TTL extension, summary TTL only 2x) + +## Problem Statement + +`scripts/seed-regional-snapshots.mjs:167-230` reimplements its own `main()` instead of using `runSeed()` from `_seed-utils.mjs`. It loses several gold-standard guarantees per `feedback_seeder_gold_standard.md`: + +1. **No distributed lock** via `acquireLockSafely` — two Railway container restarts could double-execute. +2. **`writeExtraKeyWithMeta` called UNCONDITIONALLY** at line 207 even when `persisted=0` — that overwrites a healthy seed-meta with `recordCount=0`. +3. **No `extendExistingTtl`** on transient Redis failures — a 20-minute outage surfaces as `STALE_SEED` even though good last-known snapshots exist. +4. **Summary key TTL is 12h** = 2x the 6h interval. Gold standard says 3x (18h+). + +## Findings + +- `scripts/seed-regional-snapshots.mjs:181` — no lock acquired before the work loop. +- `scripts/seed-regional-snapshots.mjs:207` — unconditional `writeExtraKeyWithMeta` call; runs even when 0 regions succeeded. +- `scripts/seed-regional-snapshots.mjs:206` — summary TTL of 12h (= 2x cron interval), should be ≥3x. +- `scripts/_seed-utils.mjs:606` — `runSeed` signature showing canonical lock + TTL-extension + empty-data-guard pattern. + +## Proposed Solutions + +### Option 1: Retrofit gold-standard guarantees directly + +Acquire a lock keyed `regional-snapshots` at the start of `main()`; only call `writeExtraKeyWithMeta` when `persisted > 0`; bump summary TTL to 18h or 24h (3x); add `extendExistingTtl` on partial failure paths. + +**Pros:** Surgical; doesn't fight the multi-key architecture; preserves existing per-region logic. +**Cons:** Still not using the shared `runSeed` harness, so future gold-standard updates need to be mirrored. +**Effort:** Medium. +**Risk:** Low-medium (lock contention edge cases). + +### Option 2: Refactor to use runSeed() pattern entirely + +Rewrite the seeder as a `runSeed()` consumer. + +**Pros:** Full alignment with the rest of the fleet. +**Cons:** Doesn't fit the architecture — `runSeed` is built for "fetch+publish one canonical key" but this seeder writes 8 regional + 1 summary key. Would require extending `runSeed` itself. +**Effort:** Large. +**Risk:** Medium-high (changes shared utility). + +## Recommended Action + +Option 1. Retrofit lock, guard the meta write, bump TTL to 24h, add TTL extension on partial failure. Defer Option 2 until we have a second multi-key seeder that would justify extending `runSeed`. + +## Technical Details + +Gold standard pattern from `feedback_seeder_gold_standard.md`: +- TTL ≥ 3× cron interval (so a missed run still leaves data within `maxStaleMin`). +- Retry in 20 min on failure (prevents rapid re-attempt storms). +- `upstashExpire` on both failure paths (preserve existing good data during outages). +- Clear retry timer on success. +- Health `maxStaleMin = 2× interval` (alert only after 2 missed cycles). + +The current seeder fails 1, 2, 3 above. The summary key at line 206 violates #1 with a 12h TTL against a 6h cron — a single missed run blows past `maxStaleMin`. + +## Acceptance Criteria + +- [ ] Distributed lock prevents double-execution across parallel Railway container restarts. +- [ ] Summary key TTL = 18h or 24h (≥3× cron interval). +- [ ] When all regions dedup-skipped, existing seed-meta is preserved (recordCount not overwritten with 0). +- [ ] Per-region failures trigger `extendExistingTtl` on the `:latest` key for that region. +- [ ] Health check still reports OK after a partial failure. + +## Work Log + +## Resources + +- PR #2940 +- Spec: `docs/internal/pro-regional-intelligence-upgrade.md` +- Feedback: `feedback_seeder_gold_standard.md` diff --git a/todos/175-pending-p2-region-taxonomy-3-sources-of-truth.md b/todos/175-pending-p2-region-taxonomy-3-sources-of-truth.md new file mode 100644 index 000000000..ae976e9e4 --- /dev/null +++ b/todos/175-pending-p2-region-taxonomy-3-sources-of-truth.md @@ -0,0 +1,67 @@ +--- +status: pending +priority: p2 +issue_id: 175 +tags: [code-review, phase-0, regional-intelligence, dry, taxonomy] +dependencies: [] +--- + +# Region taxonomy has 3 independent sources of truth (PR #2942 should import REGIONS from geography.js) + +## Problem Statement + +PR #2940 ships `shared/geography.js` with `REGIONS` and `forecastLabel` fields. PR #2942 ships `ForecastPanel.ts:10-19` with a hardcoded `FORECAST_REGIONS` constant duplicating the same labels. Plus `api/mcp.ts:556` enumerates DIFFERENT region examples ("Asia Pacific" not "East Asia"), breaking agent-native parity. Plus `scripts/seed-forecasts.mjs` writes `f.region` strings via `MACRO_REGION_MAP` that are a fourth implicit source. + +Adding a region requires editing 3-4 files. They will drift. + +## Findings + +- `src/components/ForecastPanel.ts:10-19` — hardcoded `FORECAST_REGIONS` constant. +- `shared/geography.js:46-124` — canonical `REGIONS` with `forecastLabel` field. +- `api/mcp.ts:556-577` — generate_forecasts tool description enumerates "Asia Pacific" etc. (different from geography). +- `scripts/seed-forecasts.mjs` — `MACRO_REGION_MAP` as implicit fourth source. + +## Proposed Solutions + +### Option 1: ForecastPanel.ts imports REGIONS from shared/geography + +Derive `FORECAST_REGIONS` via `REGIONS.map(r => ({ id: r.id, label: r.forecastLabel }))`. Update `api/mcp.ts` to enumerate the same labels (also via import from geography). + +**Pros:** Zero new files; uses the module already designated as canonical; straight deletion of duplicates. +**Cons:** Minor — TS consumers need to deal with a `.js` import, but that's already a pattern elsewhere. +**Effort:** Small. +**Risk:** Low. + +### Option 2: Extract a shared/forecast-regions.ts module + +Create a dedicated `shared/forecast-regions.ts` module exporting the canonical list. ForecastPanel + MCP both import. + +**Pros:** Explicit module for this concern. +**Cons:** Creates a second layer of indirection when geography.js is already the intended source of truth. +**Effort:** Small. +**Risk:** Low. + +## Recommended Action + +Option 1 — `geography.js` is already the canonical source; the panel and MCP description should consume it directly. Delete `FORECAST_REGIONS` and the ad-hoc MCP examples. + +## Technical Details + +`shared/geography.js` already has the `forecastLabel` field on every `REGIONS` entry specifically for this use case (added in PR #2940). The panel duplication in #2942 defeats the purpose. The MCP description drift ("Asia Pacific" vs "East Asia") is an agent-native correctness bug — MCP clients generate structured calls against the documented examples, so mismatched labels become unresolvable filters at runtime. + +The `scripts/seed-forecasts.mjs` `MACRO_REGION_MAP` is a fourth source but its job is ISO2 → region mapping, not label authority. It should still derive its region IDs from `REGIONS`. + +## Acceptance Criteria + +- [ ] `ForecastPanel.ts` imports `REGIONS` from `shared/geography.js`. +- [ ] `api/mcp.ts` `generate_forecasts` tool description enumerates exactly the same labels. +- [ ] Adding a region in `geography.js` automatically updates UI pills and MCP description. +- [ ] `MACRO_REGION_MAP` in `seed-forecasts.mjs` references `REGIONS` for the region IDs. + +## Work Log + +## Resources + +- PR #2940 +- PR #2942 +- Spec: `docs/internal/pro-regional-intelligence-upgrade.md` diff --git a/todos/176-pending-p2-redis-keys-coupled-to-compute-modules-no-adapter.md b/todos/176-pending-p2-redis-keys-coupled-to-compute-modules-no-adapter.md new file mode 100644 index 000000000..19604eb79 --- /dev/null +++ b/todos/176-pending-p2-redis-keys-coupled-to-compute-modules-no-adapter.md @@ -0,0 +1,90 @@ +--- +status: pending +priority: p2 +issue_id: 176 +tags: [code-review, phase-0, regional-intelligence, architecture, redis] +dependencies: [] +--- + +# Compute modules tightly coupled to raw Redis key strings - no adapter or invariant test + +## Problem Statement + +Each compute module under `scripts/regional-snapshot/*.mjs` has inline string literals like `sources['risk:scores:sebuf:stale:v1']`. The same key strings live in `freshness.mjs:FRESHNESS_REGISTRY`. When a data seed bumps its key version (v2 → v3), the update has to happen in multiple modules plus the registry. This drift has ALREADY caused two bugs (issues #167 OREF zombie, #168 4 unused keys). + +Nothing enforces that the registry and consumers stay in sync. + +## Findings + +- `scripts/regional-snapshot/balance-vector.mjs` — hardcodes source key strings. +- `scripts/regional-snapshot/trigger-evaluator.mjs` — hardcodes source key strings. +- `scripts/regional-snapshot/evidence-collector.mjs` — hardcodes source key strings. +- `scripts/regional-snapshot/actor-scoring.mjs` — hardcodes source key strings. +- `scripts/regional-snapshot/scenario-builder.mjs` — hardcodes source key strings. +- `scripts/regional-snapshot/freshness.mjs:FRESHNESS_REGISTRY` — parallel, drift-prone list. +- Related already-landed bugs: issue #167 (OREF zombie), issue #168 (4 unused keys). + +## Proposed Solutions + +### Option 1 (minimal): Static-analysis invariant test + +Write a test that uses regex extraction of all `sources['...']` literals from compute modules and asserts each appears in `FRESHNESS_REGISTRY`. + +**Pros:** Zero refactor; pure test addition. +**Cons:** Static-analysis tests reading source as strings are notoriously fragile — see the `static-analysis-test-fragility` skill. They catch one class of drift but miss aliasing, template literals, computed indexers. +**Effort:** Small. +**Risk:** Medium (brittle; false positives on refactor). + +### Option 2 (proper): Extract sources-adapter.mjs + +Create `sources-adapter.mjs` with typed accessors (`getCiiScores(sources)`, `getForecasts(sources)`, etc.). Compute modules depend only on the adapter. The adapter is the single place where keys are referenced and enforces registry ↔ accessor match at module load. + +**Pros:** Correct architectural boundary; future key bumps are a one-file change; unit-testable; loss of string-literal surface area eliminates drift classes entirely. +**Cons:** Touch every compute module. +**Effort:** Medium. +**Risk:** Low (mechanical substitution, tests cover behavior). + +## Recommended Action + +Option 2. The fragile static-test approach has caused real maintenance burden elsewhere (see skill doc), and we already have two shipped bugs from drift. Fix the structure. + +## Technical Details + +Current drift surface: +```js +// in balance-vector.mjs +const cii = sources['risk:scores:sebuf:stale:v1']; +// in freshness.mjs +FRESHNESS_REGISTRY = { 'risk:scores:sebuf:stale:v1': {...} }; +``` + +Target: +```js +// sources-adapter.mjs +export const KEYS = { + ciiScores: 'risk:scores:sebuf:stale:v1', + forecasts: 'forecast:predictions:v2', + // ... +}; +export const getCiiScores = (sources) => sources[KEYS.ciiScores]; +// freshness.mjs imports KEYS, builds registry from it +// compute modules import getCiiScores, never see the string +``` + +At module load, `freshness.mjs` can assert `Object.keys(FRESHNESS_REGISTRY)` equals `Object.values(KEYS)`, giving a load-time guarantee. + +## Acceptance Criteria + +- [ ] Compute modules contain zero string literals matching `sources\['[^']+'\]`. +- [ ] Adapter module is the only place where Redis key constants live. +- [ ] Load-time assertion verifies `FRESHNESS_REGISTRY` and adapter keys match. +- [ ] Unit tests for each accessor. + +## Work Log + +## Resources + +- PR #2940 +- Spec: `docs/internal/pro-regional-intelligence-upgrade.md` +- Skill: `static-analysis-test-fragility` +- Related: issue #167, issue #168 diff --git a/todos/177-pending-p2-stored-strings-from-redis-unsanitized-xss-risk-phase-1.md b/todos/177-pending-p2-stored-strings-from-redis-unsanitized-xss-risk-phase-1.md new file mode 100644 index 000000000..43e54c2c4 --- /dev/null +++ b/todos/177-pending-p2-stored-strings-from-redis-unsanitized-xss-risk-phase-1.md @@ -0,0 +1,77 @@ +--- +status: pending +priority: p2 +issue_id: 177 +tags: [code-review, phase-0, regional-intelligence, security, xss] +dependencies: [] +--- + +# Snapshot string fields interpolate upstream Redis data without sanitization (Phase 1 stored XSS risk) + +## Problem Statement + +`scripts/regional-snapshot/evidence-collector.mjs:31, 51, 70, 90` and `balance-vector.mjs:150` build free-form description strings by interpolating upstream Redis fields directly: + +- `String(s?.summary ?? s?.type)` from cross-source signals. +- `` `${c.region} CII ... (trend ${c.trend})` `` from CII scores. +- `` `${cp?.name ?? cp?.id}: ${threat}` `` from chokepoints. +- `String(f?.title)` from forecasts. +- `` `${top.iso} CII ${...}` `` from balance drivers. + +These strings are `JSON.stringify`'d into the snapshot, persisted to Redis, then read back in Phase 1 by a new UI consumer. If any upstream seeder has a validation gap (or accepts a hostile third-party feed), a string like `` propagates through to stored XSS at render time. + +Existing panels (SignalModal, StrategicRiskPanel, CrossSourceSignalsPanel) DO escape via `escapeHtml` when rendering. So if Phase 1 follows convention, this is fine. But the writer provides no guard rail. + +## Findings + +- `scripts/regional-snapshot/evidence-collector.mjs:27-97` — interpolates upstream strings without sanitization. +- `scripts/regional-snapshot/balance-vector.mjs:96-109, 144-155, 195-203, 232-243, 261-276, 310-319, 334-343` — builds driver description strings from upstream fields. + +## Proposed Solutions + +### Option 1: sanitizeEvidenceString helper at writer boundary + +Add a `sanitizeEvidenceString()` helper. Strip `<>`, collapse whitespace, cap length to ~200 chars. Apply to every interpolated upstream field. + +**Pros:** Defense in depth; doesn't rely on Phase 1 convention; handles future Phase 2 consumers; bounded payload size. +**Cons:** May clip legitimate punctuation edge cases (unlikely given the field shapes). +**Effort:** Small. +**Risk:** Low. + +### Option 2: Document Phase 1 requirement + +Add a spec checklist item: every renderer of snapshot string fields MUST use `escapeHtml`. + +**Pros:** Zero code change. +**Cons:** Relies entirely on convention; every new Phase 1/2 consumer is an opportunity to forget. +**Effort:** Small. +**Risk:** Medium. + +## Recommended Action + +Both. Option 1 is cheap and gives defense in depth at the writer boundary. Option 2 ensures the rendering convention holds so double-sanitization doesn't create escape artifacts. + +## Technical Details + +Writer-side sanitization complements render-side escaping. The goal at the writer is: +1. Strip structural HTML markers (`<`, `>`) that serve no legitimate purpose in these description fields. +2. Collapse runs of whitespace to prevent layout-breaking inputs. +3. Cap length to a reasonable bound (~200 chars) to contain payload-size attacks. + +Render-side `escapeHtml` remains the primary defense. The writer guard is the backstop for any panel that forgets. + +Existing convention: `SignalModal`, `StrategicRiskPanel`, `CrossSourceSignalsPanel` all use `escapeHtml` from `src/utils/escape.ts`. + +## Acceptance Criteria + +- [ ] `sanitizeEvidenceString` helper exists and is applied to every upstream string field in the snapshot. +- [ ] Phase 1 checklist in the spec explicitly requires `escapeHtml` on snapshot string fields. +- [ ] Test: snapshot containing `` in a CII region name produces a stripped/escaped string. +- [ ] Test: 10KB input is truncated to ≤200 chars. + +## Work Log + +## Resources + +- PR #2940 +- Spec: `docs/internal/pro-regional-intelligence-upgrade.md` diff --git a/todos/178-pending-p2-aborontroller-on-rapid-region-pill-clicks.md b/todos/178-pending-p2-aborontroller-on-rapid-region-pill-clicks.md new file mode 100644 index 000000000..1de784504 --- /dev/null +++ b/todos/178-pending-p2-aborontroller-on-rapid-region-pill-clicks.md @@ -0,0 +1,72 @@ +--- +status: pending +priority: p2 +issue_id: 178 +tags: [code-review, phase-0, regional-intelligence, performance, frontend] +dependencies: [179] +--- + +# refetchForRegion missing AbortController - rapid clicks fire wasted RPCs + +## Problem Statement + +`src/components/ForecastPanel.ts:318-331` uses a sequence counter to discard stale UI updates, but every click still fires a full RPC. Clicking MENA → East Asia → Europe issues 3 full HTTP calls. Each hits `getCachedJson(REDIS_KEY)` in `get-forecasts.ts:17` with no in-process memoization, so each is a fresh Redis GET + filter + ~50KB response. The 1st-2nd responses are silently discarded. + +Compounded by P2 #179 (server has no `cachedFetchJson` coalescing), the user can trigger an order of magnitude more Redis traffic than necessary. + +## Findings + +- `src/components/ForecastPanel.ts:318-331` — `regionFetchSeq` guard discards stale results but does not abort in-flight requests. + +## Proposed Solutions + +### Option 1: AbortController + +Thread `signal` through `fetchForecasts` → `IntelligenceServiceClient.getForecasts`. Verify the generated proto-ts client supports `options.abort`. + +**Pros:** Explicit cancellation; reduces server work; idiomatic fetch pattern. +**Cons:** Requires verifying proto-ts client signal support and threading it through. +**Effort:** Small. +**Risk:** Low. + +### Option 2: Debounce 150ms before issuing the RPC + +Wait 150ms after last click before fetching. + +**Pros:** Single-line change. +**Cons:** Adds delay on first click; doesn't help the already-fired request; degrades perceived responsiveness. +**Effort:** Small. +**Risk:** Low. + +### Option 3: Client-side cache + +Add a `Map` keyed on region. Instant back-navigation. + +**Pros:** Zero server trips for revisits; instant UI. +**Cons:** Stale until panel re-opens; needs a TTL or invalidation trigger. +**Effort:** Small. +**Risk:** Low. + +## Recommended Action + +Option 1 + Option 3. Together they prevent server work and give instant return navigation. Option 2 is a fallback if proto-ts signal support is absent. + +## Technical Details + +Proto-ts clients generated by `make generate` typically accept an `AbortSignal` through an options bag — confirm via the generated `IntelligenceServiceClient` interface before wiring. If the signal path isn't plumbed, wrap the call in a native `fetch`-level abort at the client edge. + +Cache shape: `Map` with a 60-second TTL matching server cache. Invalidate on visibility change + explicit refresh button. + +## Acceptance Criteria + +- [ ] Clicking 5 region pills rapidly issues at most 1 in-flight RPC. +- [ ] Stale RPCs are explicitly aborted (not just UI-discarded). +- [ ] Returning to a previously-fetched region serves from client cache. +- [ ] Cache entries honor a TTL and refresh on visibility change. + +## Work Log + +## Resources + +- PR #2942 +- Spec: `docs/internal/pro-regional-intelligence-upgrade.md` diff --git a/todos/179-pending-p2-getforecasts-handler-no-cachedfetchjson.md b/todos/179-pending-p2-getforecasts-handler-no-cachedfetchjson.md new file mode 100644 index 000000000..bf69e10d9 --- /dev/null +++ b/todos/179-pending-p2-getforecasts-handler-no-cachedfetchjson.md @@ -0,0 +1,58 @@ +--- +status: pending +priority: p2 +issue_id: 179 +tags: [code-review, phase-0, regional-intelligence, performance, redis, cache-stampede] +dependencies: [] +--- + +# getForecasts RPC handler lacks cachedFetchJson coalescing (cache stampede risk) + +## Problem Statement + +`server/worldmonitor/forecast/v1/get-forecasts.ts:17` calls `getCachedJson(REDIS_KEY)` directly. Per CLAUDE.md ("Cache Stampede: Use cachedFetchJson"), RPC handlers with shared cache should use `cachedFetchJson` to coalesce concurrent misses. With 8 region pills and a user clicking quickly, multiple concurrent edge function invocations could each miss the in-process cache and hit Upstash with the same key. + +## Findings + +- `server/worldmonitor/forecast/v1/get-forecasts.ts:17` — uses `getCachedJson` directly without `cachedFetchJson` wrapper. +- CLAUDE.md "Cache Stampede: Use cachedFetchJson (Critical Pattern)" — established rule for all RPC handlers with shared cache. + +## Proposed Solutions + +### Option 1: Wrap in cachedFetchJson + +Wrap the read in `cachedFetchJson` with a 30-60s in-process TTL keyed by `forecast:predictions:v2`. + +**Pros:** Matches project convention; concurrent identical requests share one Redis round-trip; documented pattern; minimal diff. +**Cons:** None significant. +**Effort:** Small. +**Risk:** Low. + +## Recommended Action + +Option 1. Separate from PR #2942 scope but highlighted by it — the forecast panel's region pills (see #178) multiply concurrent reads, making the stampede window measurably hit. + +## Technical Details + +`cachedFetchJson` (in `server/_shared/redis.ts`) coalesces concurrent cache misses via an in-process `Map`. The first request issues the Redis GET; parallel requests await the same promise. When the promise resolves, all waiters receive the result without additional Redis traffic. + +Per CLAUDE.md: +1. Wrap in try-catch for stale/backup fallback. +2. Await stale/backup cache writes (Edge runtimes may terminate isolate). + +Cache key: `forecast:predictions:v2` (match the Redis key). In-process TTL: 30-60s is the canonical window used by other handlers in this directory. + +## Acceptance Criteria + +- [ ] `get-forecasts.ts` uses `cachedFetchJson` per the CLAUDE.md cache stampede rule. +- [ ] Concurrent identical RPC requests share a single in-flight Redis read. +- [ ] Stale/backup fallback path is exercised via try-catch. +- [ ] Test: 10 parallel identical RPC calls produce 1 Redis GET. + +## Work Log + +## Resources + +- PR #2942 +- Spec: `docs/internal/pro-regional-intelligence-upgrade.md` +- CLAUDE.md: "Cache Stampede: Use cachedFetchJson (Critical Pattern)" diff --git a/todos/180-pending-p2-regions-find-duplicated-use-getregion-helper.md b/todos/180-pending-p2-regions-find-duplicated-use-getregion-helper.md new file mode 100644 index 000000000..3a9d45713 --- /dev/null +++ b/todos/180-pending-p2-regions-find-duplicated-use-getregion-helper.md @@ -0,0 +1,60 @@ +--- +status: pending +priority: p2 +issue_id: 180 +tags: [code-review, phase-0, regional-intelligence, refactor, dry] +dependencies: [] +--- + +# REGIONS.find duplicated 5 times instead of using getRegion helper + +## Problem Statement +`shared/geography.js:242` exports `getRegion(regionId)` but 5 compute modules use inline `REGIONS.find((r) => r.id === regionId)` instead: +- `balance-vector.mjs:18` and `balance-vector.mjs:253` +- `evidence-collector.mjs:16` +- `actor-scoring.mjs:26` +- `scenario-builder.mjs:18` + +Duplication will cause inconsistency when validation logic is added (e.g., warning on unknown region, caching, normalization). + +## Findings +- `getRegion` helper already exists and is the canonical accessor +- 5 callsites use inline `REGIONS.find` instead +- 4 of the 5 files only need `getRegion` but currently import the raw `REGIONS` array +- No tests enforce use of the helper + +## Proposed Solutions + +### Option 1: Mass-replace with getRegion +Replace all inline `REGIONS.find` calls with `getRegion(regionId)` and drop `REGIONS` from imports where only `getRegion` is needed. + +**Pros:** Centralizes region lookup; future validation lives in one place; smaller imports +**Cons:** Touches 5 files +**Effort:** Small +**Risk:** Low + +## Recommended Action + + +## Technical Details +Files to update: +- `scripts/regional-snapshot/balance-vector.mjs` (lines 18, 253) +- `scripts/regional-snapshot/evidence-collector.mjs` (line 16) +- `scripts/regional-snapshot/actor-scoring.mjs` (line 26) +- `scripts/regional-snapshot/scenario-builder.mjs` (line 18) + +`shared/geography.js:242` defines: +```js +export function getRegion(regionId) { ... } +``` + +## Acceptance Criteria +- [ ] Mass-replace REGIONS.find with getRegion calls in 5 modules +- [ ] Drop REGIONS from imports in 4 files where only getRegion is needed +- [ ] Tests still pass + +## Work Log + +## Resources +- PR #2940 +- PR #2942 diff --git a/todos/181-pending-p2-undated-inputs-treated-as-fresh-confidence-bug.md b/todos/181-pending-p2-undated-inputs-treated-as-fresh-confidence-bug.md new file mode 100644 index 000000000..b62256870 --- /dev/null +++ b/todos/181-pending-p2-undated-inputs-treated-as-fresh-confidence-bug.md @@ -0,0 +1,61 @@ +--- +status: pending +priority: p2 +issue_id: 181 +tags: [code-review, phase-0, regional-intelligence, freshness, confidence] +dependencies: [] +--- + +# Present-but-undated inputs treated as fresh - inflates snapshot_confidence on stale data + +## Problem Statement +`scripts/regional-snapshot/freshness.mjs:56-59` returns "fresh" when an input payload is present but has no extractable timestamp. This is the wrong default for a confidence-scoring system. If an upstream seeder crashes and leaves an old payload with no timestamp, the snapshot will silently score it as fresh and produce high-confidence snapshots from stale data. + +## Findings +- Freshness evaluation defaults to "fresh" for undated-but-present inputs +- snapshot_confidence depends on input freshness +- No observability on how often inputs arrive undated +- Upstream seeder crash → stale payload with missing timestamp → scored fresh + +## Proposed Solutions + +### Option 1: Flip default to stale +Return "stale" when a payload is present but undated. + +**Pros:** Safer default; forces timestamp discipline upstream +**Cons:** May initially flag legitimate inputs as stale if some seeders never emit timestamps +**Effort:** Small +**Risk:** Low + +### Option 2: Log a warning on first undated input per run +Keep "fresh" default but emit a warning. + +**Pros:** Visibility without behavior change +**Cons:** Doesn't solve the underlying confidence inflation +**Effort:** Small +**Risk:** Low + +### Option 3: Metric/counter for undated proportion +Track `undated_inputs / total_inputs` per cron run and surface in health. + +**Pros:** Data-driven signal +**Cons:** Requires additional wiring; still doesn't fix the default +**Effort:** Small +**Risk:** Low + +## Recommended Action + + +## Technical Details +`scripts/regional-snapshot/freshness.mjs:56-59` - current behavior returns fresh on missing timestamp. + +## Acceptance Criteria +- [ ] Default for present-but-undated inputs is "stale" (not fresh) +- [ ] OR a warning is logged the first time an undated input is observed +- [ ] OR a counter tracks the proportion of undated inputs per cron run + +## Work Log + +## Resources +- PR #2940 +- PR #2942 diff --git a/todos/182-pending-p2-inconsistent-unknown-region-error-handling.md b/todos/182-pending-p2-inconsistent-unknown-region-error-handling.md new file mode 100644 index 000000000..e7f75cb58 --- /dev/null +++ b/todos/182-pending-p2-inconsistent-unknown-region-error-handling.md @@ -0,0 +1,57 @@ +--- +status: pending +priority: p2 +issue_id: 182 +tags: [code-review, phase-0, regional-intelligence, error-handling, consistency] +dependencies: [] +--- + +# Compute modules return different things for unknown region (some throw, some return empty) + +## Problem Statement +`balance-vector.mjs:18` throws on unknown region. `scoreActors:26`, `buildScenarioSets:18`, `collectEvidence:16` silently return empty. Inconsistent contracts. The seed orchestrator should pick one. + +## Findings +- 4 compute modules, 2 different error strategies +- Caller cannot rely on a uniform contract +- A typo'd region id will crash one path, silently empty-render another + +## Proposed Solutions + +### Option 1: Validate region in orchestrator +Fail-fast in `seed-regional-snapshots.mjs` before calling any compute module. Compute modules can assume a valid region. + +**Pros:** Single validation point; compute modules simplify +**Cons:** Orchestrator must know region invariants +**Effort:** Small +**Risk:** Low + +### Option 2: All modules throw on unknown region +Normalize every compute module to throw. + +**Pros:** Loud failures at call site; easy debugging +**Cons:** Requires each compute module to handle the error +**Effort:** Small +**Risk:** Low + +## Recommended Action + + +## Technical Details +Files involved: +- `scripts/regional-snapshot/balance-vector.mjs:18` (throws) +- `scripts/regional-snapshot/actor-scoring.mjs:26` (returns empty) +- `scripts/regional-snapshot/scenario-builder.mjs:18` (returns empty) +- `scripts/regional-snapshot/evidence-collector.mjs:16` (returns empty) +- `scripts/seed-regional-snapshots.mjs` - orchestrator + +## Acceptance Criteria +- [ ] All compute modules use consistent error semantics +- [ ] Either: orchestrator validates region before calling any compute module +- [ ] Or: every module throws on unknown region + +## Work Log + +## Resources +- PR #2940 +- PR #2942 diff --git a/todos/183-pending-p2-writeextrakeywithmeta-positional-args-fragile.md b/todos/183-pending-p2-writeextrakeywithmeta-positional-args-fragile.md new file mode 100644 index 000000000..434d83ccb --- /dev/null +++ b/todos/183-pending-p2-writeextrakeywithmeta-positional-args-fragile.md @@ -0,0 +1,52 @@ +--- +status: pending +priority: p2 +issue_id: 183 +tags: [code-review, phase-0, regional-intelligence, api-design, refactor] +dependencies: [] +--- + +# writeExtraKeyWithMeta call site uses 6 positional args including ttl repeated twice + +## Problem Statement +`seed-regional-snapshots.mjs:207-214` passes 6 positional args to `writeExtraKeyWithMeta`: `(canonicalKey, payload, ttlSec, persisted, metaKey, ttlSec)`. `ttlSec` appears in slots 3 and 6. A future refactor swapping slots 4/5 silently corrupts. The helper signature is pre-existing in `_seed-utils.mjs` but this is the most complex invocation in the repo. + +## Findings +- Positional signature with duplicated value is a foot-gun +- 6-arg positional calls are hard to audit +- No test asserts which slot receives what + +## Proposed Solutions + +### Option 1: Wrapper helper in seed-regional-snapshots +Add a named-argument wrapper in this file only; leaves the shared helper signature alone. + +**Pros:** Smallest change; isolates risk +**Cons:** Shared helper remains fragile for other callers +**Effort:** Small +**Risk:** Low + +### Option 2: Refactor writeExtraKeyWithMeta to options-object signature +Change `_seed-utils.mjs` to accept `{ canonicalKey, payload, ttlSec, persisted, metaKey }`. + +**Pros:** Fixes all current and future callers +**Cons:** Touches more files; needs audit of every call site +**Effort:** Medium +**Risk:** Low + +## Recommended Action + + +## Technical Details +`scripts/seed-regional-snapshots.mjs:207-214` - the 6-arg call site. +`scripts/_seed-utils.mjs` - defines `writeExtraKeyWithMeta`. + +## Acceptance Criteria +- [ ] File a follow-up issue to refactor writeExtraKeyWithMeta to options-object signature +- [ ] OR add a wrapper helper in seed-regional-snapshots that names the arguments + +## Work Log + +## Resources +- PR #2940 +- PR #2942 diff --git a/todos/184-pending-p2-pipeline-non-atomic-partial-persist.md b/todos/184-pending-p2-pipeline-non-atomic-partial-persist.md new file mode 100644 index 000000000..60fbcf21a --- /dev/null +++ b/todos/184-pending-p2-pipeline-non-atomic-partial-persist.md @@ -0,0 +1,59 @@ +--- +status: pending +priority: p2 +issue_id: 184 +tags: [code-review, phase-0, regional-intelligence, persistence, consistency, redis] +dependencies: [] +--- + +# Upstash /pipeline is not transactional - partial failure leaves inconsistent snapshot state + +## Problem Statement +`scripts/regional-snapshot/persist-snapshot.mjs:56-73` issues 6 commands via `/pipeline` (SET timestamp, SET by-id, SET latest, ZADD index, ZREMRANGEBYSCORE, DEL live). Pipeline executes sequentially but not atomically. Partial failure leaves inconsistent state (latest pointer to a snapshot not in the index, or vice versa). Phase 1 may rely on the index to enumerate accessible snapshots and trip on the inconsistency. + +## Findings +- `/pipeline` is batched but not transactional on Upstash +- 6 writes cover 3 state shapes: timestamp view, id view, latest pointer, index +- Partial failure surfaces as silent drift between pointer and index +- No repair job exists + +## Proposed Solutions + +### Option 1: Use /multi-exec for atomic persistence +Upstash supports MULTI/EXEC transactions via `/multi-exec` endpoint. + +**Pros:** All-or-nothing guarantee; no inconsistent state +**Cons:** Slightly more expensive; needs a small client change +**Effort:** Medium +**Risk:** Low + +### Option 2: Document partial-persist contract + repair job +Accept non-atomicity; write a repair job that reconciles latest/index on next run. + +**Pros:** Keeps existing pipeline code +**Cons:** Runtime readers still see drift until repair fires; more complex +**Effort:** Medium +**Risk:** Low + +## Recommended Action + + +## Technical Details +File: `scripts/regional-snapshot/persist-snapshot.mjs:56-73` +Commands in the current pipeline: +1. SET snapshot:ts:{region}:{ts} +2. SET snapshot:id:{region}:{id} +3. SET snapshot:latest:{region} +4. ZADD snapshot:index:{region} +5. ZREMRANGEBYSCORE snapshot:index:{region} +6. DEL snapshot:live:{region} + +## Acceptance Criteria +- [ ] Use /multi-exec for atomic persistence OR +- [ ] Document the partial-persist contract and add a repair job + +## Work Log + +## Resources +- PR #2940 +- PR #2942 diff --git a/todos/185-pending-p2-trigger-watching-runs-on-delta-operators.md b/todos/185-pending-p2-trigger-watching-runs-on-delta-operators.md new file mode 100644 index 000000000..6df97d0be --- /dev/null +++ b/todos/185-pending-p2-trigger-watching-runs-on-delta-operators.md @@ -0,0 +1,46 @@ +--- +status: pending +priority: p2 +issue_id: 185 +tags: [code-review, phase-0, regional-intelligence, triggers] +dependencies: [171] +--- + +# trigger-evaluator runs isCloseToThreshold on delta operators that are unconditionally false + +## Problem Statement +`scripts/regional-snapshot/trigger-evaluator.mjs:18-35`. `evaluateThreshold` returns false for two reasons: (a) threshold not breached, (b) operator is `delta_*` (Phase 0 stub). In case (b), `isCloseToThreshold` STILL runs and could elevate dormant triggers to "watching" based on misleading math. + +## Findings +- `delta_*` operators are stubbed to return false in Phase 0 +- `isCloseToThreshold` has no knowledge of the stub +- Watching-state elevation for delta-gated triggers is semantically wrong +- Downstream Phase 1 readers will surface these as near-triggers incorrectly + +## Proposed Solutions + +### Option 1: Skip isCloseToThreshold for delta_* operators +Guard at the top of the watching branch. + +**Pros:** Minimal change; correct semantics +**Cons:** Adds one branch +**Effort:** Small +**Risk:** Low + +## Recommended Action + + +## Technical Details +File: `scripts/regional-snapshot/trigger-evaluator.mjs:18-35` +Related issue: #171 (isCloseToThreshold inverted for lt operators) + +## Acceptance Criteria +- [ ] Skip isCloseToThreshold for delta_* operators +- [ ] Test: delta_gt trigger never appears in watching list during Phase 0 + +## Work Log + +## Resources +- PR #2940 +- PR #2942 +- Related: issue #171 diff --git a/todos/186-pending-p2-regime-transition-driver-always-empty.md b/todos/186-pending-p2-regime-transition-driver-always-empty.md new file mode 100644 index 000000000..2d00ed0f7 --- /dev/null +++ b/todos/186-pending-p2-regime-transition-driver-always-empty.md @@ -0,0 +1,54 @@ +--- +status: pending +priority: p2 +issue_id: 186 +tags: [code-review, phase-0, regional-intelligence, dead-code, schema] +dependencies: [] +--- + +# regime.transition_driver field is dead weight (always empty in Phase 0) + +## Problem Statement +`scripts/seed-regional-snapshots.mjs:137` passes empty string as the driver to `buildRegimeState` because the diff isn't computed yet at that point in the pipeline. Field exists in type, is serialized, never populated. Phase 1 readers will find always-empty. + +## Findings +- `regime.transition_driver` is part of the persisted snapshot shape +- Value is always `""` because diff is computed later in the pipeline +- Phase 1 consumers will be forced to ignore the field or special-case it +- Options: populate after diff step, or defer the field to Phase 2 + +## Proposed Solutions + +### Option 1: Populate transition_driver from inferTriggerReason(diff) after the diff step +Rewire the pipeline so regime state is built after the diff is known. + +**Pros:** Field carries real information +**Cons:** Requires reordering steps in the orchestrator +**Effort:** Small +**Risk:** Low + +### Option 2: Document as Phase 2 and keep empty +Add a comment noting the field is intentionally deferred. + +**Pros:** No behavioral change +**Cons:** Field remains dead weight +**Effort:** Small +**Risk:** Low + +## Recommended Action + + +## Technical Details +File: `scripts/seed-regional-snapshots.mjs:137` - call site with empty driver. +Function: `buildRegimeState` - consumer. +Function: `inferTriggerReason(diff)` - would populate the field. + +## Acceptance Criteria +- [ ] Populate regime.transition_driver from inferTriggerReason(diff) after the diff step +- [ ] OR document the field as Phase 2 and keep empty + +## Work Log + +## Resources +- PR #2940 +- PR #2942 diff --git a/todos/187-pending-p2-orphan-scripts-shared-iso2-mirror.md b/todos/187-pending-p2-orphan-scripts-shared-iso2-mirror.md new file mode 100644 index 000000000..af8954984 --- /dev/null +++ b/todos/187-pending-p2-orphan-scripts-shared-iso2-mirror.md @@ -0,0 +1,58 @@ +--- +status: pending +priority: p2 +issue_id: 187 +tags: [code-review, phase-0, regional-intelligence, dead-code, convention] +dependencies: [] +--- + +# scripts/shared/iso2-to-region.json is orphaned (no consumer in Phase 0) + +## Problem Statement +PR #2940 mirrors `shared/iso2-to-region.json` to `scripts/shared/iso2-to-region.json` per the convention enforced by `tests/edge-functions.test.mjs`. But Phase 0 only imports from `shared/geography.js` which reads `./iso2-to-region.json` (the `shared/` original). The mirror has zero runtime consumers. + +The convention exists for files imported by both Vite (src/) and Node ESM (scripts/) directly. Phase 0 doesn't import directly from scripts/. + +## Findings +- `scripts/shared/iso2-to-region.json` has no runtime consumer in Phase 0 +- The mirror convention targets files imported directly by scripts/ Node ESM paths +- Phase 0 geography lookups go through `shared/geography.js` +- `tests/edge-functions.test.mjs` enforces the mirror regardless of whether there's a consumer + +## Proposed Solutions + +### Option 1: Delete the mirror +Remove `scripts/shared/iso2-to-region.json` and update `tests/edge-functions.test.mjs` to skip `iso2-to-region` on the mirror check. + +**Pros:** No dead JSON file; test doesn't mislead +**Cons:** Future scripts/-side consumers will need to re-add it +**Effort:** Small +**Risk:** Low + +### Option 2: Document why the mirror must exist forward-compatibly +Leave the mirror, add a comment in the JSON file or a nearby README explaining the convention. + +**Pros:** Keeps infra simple; future-proof +**Cons:** Looks unused to reviewers +**Effort:** Small +**Risk:** Low + +## Recommended Action + + +## Technical Details +Files: +- `shared/iso2-to-region.json` - canonical source +- `scripts/shared/iso2-to-region.json` - mirror (orphan in Phase 0) +- `shared/geography.js` - the only Phase 0 consumer (reads shared/) +- `tests/edge-functions.test.mjs` - enforces mirror + +## Acceptance Criteria +- [ ] Either delete the mirror (and update tests/edge-functions.test.mjs to skip iso2-to-region) +- [ ] Or document why the mirror must exist forward-compatibly + +## Work Log + +## Resources +- PR #2940 +- PR #2942 diff --git a/todos/188-pending-p2-dangling-docs-internal-references-in-code.md b/todos/188-pending-p2-dangling-docs-internal-references-in-code.md new file mode 100644 index 000000000..332397baf --- /dev/null +++ b/todos/188-pending-p2-dangling-docs-internal-references-in-code.md @@ -0,0 +1,65 @@ +--- +status: pending +priority: p2 +issue_id: 188 +tags: [code-review, phase-0, regional-intelligence, docs, stale-references] +dependencies: [] +--- + +# Code comments reference docs/internal/ paths that don't exist in this worktree + +## Problem Statement +Multiple files reference `docs/internal/pro-regional-intelligence-{appendix-engineering, appendix-scoring}.md` in code comments: +- `scripts/seed-regional-snapshots.mjs:12-14` +- `scripts/regional-snapshot/freshness.mjs:2` +- `scripts/regional-snapshot/triggers.config.mjs:4` +- `scripts/regional-snapshot/scenario-builder.mjs:2` +- `scripts/regional-snapshot/balance-vector.mjs:3` +- `shared/geography.js:21` + +But only `plans/pro-regional-intelligence-upgrade.md` exists in this worktree. The appendix files ship to `docs/internal/` in the main repo. + +## Findings +- 6 files reference appendix docs not present in the worktree +- Reviewers in the worktree cannot follow the references +- Breaks reviewer navigation; misleading for anyone reading this code outside main + +## Proposed Solutions + +### Option 1: Copy the appendix files into the worktree +Mirror `docs/internal/pro-regional-intelligence-appendix-engineering.md` and `docs/internal/pro-regional-intelligence-appendix-scoring.md` into the worktree. + +**Pros:** Everything is self-contained; comments work +**Cons:** Duplicated content; possible drift from main +**Effort:** Small +**Risk:** Low + +### Option 2: Update code comments to reference the actual location +Point comments at `plans/pro-regional-intelligence-upgrade.md` or the real location. + +**Pros:** Single source of truth; no duplication +**Cons:** Comments may lose specificity if the appendix had more detail +**Effort:** Small +**Risk:** Low + +## Recommended Action + + +## Technical Details +Files with dangling references: +- `scripts/seed-regional-snapshots.mjs` (lines 12-14) +- `scripts/regional-snapshot/freshness.mjs` (line 2) +- `scripts/regional-snapshot/triggers.config.mjs` (line 4) +- `scripts/regional-snapshot/scenario-builder.mjs` (line 2) +- `scripts/regional-snapshot/balance-vector.mjs` (line 3) +- `shared/geography.js` (line 21) + +## Acceptance Criteria +- [ ] Either copy the appendix files into the worktree under docs/internal/ +- [ ] Or update code comments to reference the actual location + +## Work Log + +## Resources +- PR #2940 +- PR #2942 diff --git a/todos/189-pending-p2-refetchforregion-silent-error-swallowing.md b/todos/189-pending-p2-refetchforregion-silent-error-swallowing.md new file mode 100644 index 000000000..623e82ca1 --- /dev/null +++ b/todos/189-pending-p2-refetchforregion-silent-error-swallowing.md @@ -0,0 +1,62 @@ +--- +status: pending +priority: p2 +issue_id: 189 +tags: [code-review, phase-0, regional-intelligence, error-handling, ui] +dependencies: [] +--- + +# ForecastPanel refetchForRegion catches errors silently - UI shows stale data with no badge + +## Problem Statement +`src/components/ForecastPanel.ts:326-336` has try/catch with empty catch. On failure, the panel shows the previous region's data (or stays empty) with no indication that the refetch failed. Comment says "same pattern as the initial load" but the initial load actually reports failures via the data badge. + +## Findings +- Empty catch block swallows all errors +- User has no way to know the refetch failed +- Comment claims parity with initial load, but initial load sets the data badge on failure +- Stale data + no indicator is worse than a clear error state + +## Proposed Solutions + +### Option 1: Add setDataBadge('unavailable') in catch +Mirror the initial load's failure path. + +**Pros:** Consistent UX; clear signal to user +**Cons:** Needs verification that initial load actually does this +**Effort:** Small +**Risk:** Low + +### Option 2: Log errors via console.error +Sentry breadcrumbs will capture them; adds observability. + +**Pros:** Dev visibility; triage signal +**Cons:** Doesn't improve user experience alone +**Effort:** Small +**Risk:** Low + +### Option 3: Combined - badge + log +Do both: set badge to 'unavailable' AND log via console.error. + +**Pros:** Full coverage (UX + observability) +**Cons:** None material +**Effort:** Small +**Risk:** Low + +## Recommended Action + + +## Technical Details +File: `src/components/ForecastPanel.ts:326-336` +Pattern to mirror: the initial load's setDataBadge call on the same file. + +## Acceptance Criteria +- [ ] On RPC failure, setDataBadge('unavailable') is called +- [ ] Errors are logged via console.error (Sentry breadcrumbs capture them) +- [ ] User sees a clear failed-to-load indicator + +## Work Log + +## Resources +- PR #2940 +- PR #2942 diff --git a/todos/190-pending-p3-many-redundant-jsonstringify-casefile-loops.md b/todos/190-pending-p3-many-redundant-jsonstringify-casefile-loops.md new file mode 100644 index 000000000..03dc0360b --- /dev/null +++ b/todos/190-pending-p3-many-redundant-jsonstringify-casefile-loops.md @@ -0,0 +1,56 @@ +--- +status: pending +priority: p3 +issue_id: 190 +tags: [code-review, phase-0, regional-intelligence, performance, dry] +dependencies: [] +--- + +# Hot-loop JSON.stringify(caseFile) duplicated across modules - precompute once + +## Problem Statement +`actor-scoring.mjs:38`, `balance-vector.mjs:259` (`computeAllianceCohesion`), `scenario-builder.mjs:50` all call `JSON.stringify(f?.caseFile ?? ...).toLowerCase()` per forecast per region. For 14 forecasts x 8 regions x 5 callsites = 560 calls producing identical strings. Also creates inconsistency: actor-scoring checks `caseFile ?? signals`, alliance-cohesion checks only `caseFile`. + +## Findings +- 5 call sites stringify `caseFile` per-forecast-per-region +- ~560 redundant stringifies per seed run at current scale +- Inconsistent fallback: some use `caseFile ?? signals`, others use `caseFile` only +- Text is identical across callsites for the same forecast - prime memoization target + +## Proposed Solutions + +### Option 1: Precompute caseFileText once in main() +Attach `_caseFileText` to each forecast before the region loop; all modules read it. + +**Pros:** Single source of truth for what counts as searchable text; ~560 stringifies become 14; removes inconsistency +**Cons:** Adds a non-schema field to the forecast object (prefix with `_` to signal internal) +**Effort:** Small +**Risk:** Low + +## Recommended Action + + +## Technical Details +Affected files: +- `scripts/regional-snapshot/actor-scoring.mjs:38` +- `scripts/regional-snapshot/balance-vector.mjs:259` (computeAllianceCohesion) +- `scripts/regional-snapshot/scenario-builder.mjs:50` +- `scripts/seed-regional-snapshots.mjs` - main() where precomputation would land + +The current code pattern is roughly: +```js +JSON.stringify(f?.caseFile ?? f?.signals ?? {}).toLowerCase() +``` + +The fallback chain must be normalized across all callers. + +## Acceptance Criteria +- [ ] Precompute `_caseFileText: string` per forecast once before the region loop in main() +- [ ] All modules read `f._caseFileText` instead of re-stringifying +- [ ] Single consistent definition of what fields contribute to the searchable text + +## Work Log + +## Resources +- PR #2940 +- PR #2942 diff --git a/todos/191-pending-p3-various-helper-and-config-cleanups.md b/todos/191-pending-p3-various-helper-and-config-cleanups.md new file mode 100644 index 000000000..4f82f7014 --- /dev/null +++ b/todos/191-pending-p3-various-helper-and-config-cleanups.md @@ -0,0 +1,81 @@ +--- +status: pending +priority: p3 +issue_id: 191 +tags: [code-review, phase-0, regional-intelligence, cleanup, helpers] +dependencies: [] +--- + +# Helper, freshness, and config cleanups (round helper duplication, num() over-coercion, generateSnapshotId entropy, matchesHorizon regex, getRegionCountries scan) + +## Problem Statement +Several minor cleanups across helpers and config: + +1. `_helpers.mjs:num()` uses `parseFloat` for strings, accepts `"42abc"` as `42`. Just use `Number(value)`. +2. `_helpers.mjs:clip()` checks `Number.isNaN` AND `!Number.isFinite` - the latter handles NaN. +3. `_helpers.mjs:generateSnapshotId()` uses `Math.random` (not CSPRNG). Use `crypto.randomUUID()` instead. Comment claims "UUID v7-ish" - it's not. +4. `round()` helper duplicated in 3+ modules (balance-vector, actor-scoring, scenario-builder, snapshot-meta). Move to `_helpers.mjs`. +5. `scenario-builder.mjs:matchesHorizon()` regex has duplicate alternatives (`/h24|24h|day|24h/`). +6. `geography.js:getRegionCountries()` does `Object.entries` scan per call. Precompute `COUNTRIES_BY_REGION` at module load. +7. `balance-vector.mjs:cVessel = 0` hardcoded with 0.30 weight - dead weight that caps `coercive_pressure` at 0.70, making `escalation_ladder` regime structurally unreachable. +8. `_helpers.mjs:percentile()` doesn't clamp `p` to `[0,100]`. +9. `triggers.config.mjs:russia_naval_buildup` uses `theater:eastern_europe:...` (snake_case) but other theater IDs use kebab-case (`eastern-europe`). +10. Test comment at `tests/regional-snapshot.test.mjs:429` has trailing "let's check actual output" exploration text. + +## Findings +- Batch of minor cleanups, each independently safe +- #7 is structurally important: `cVessel = 0` forces coercive_pressure below the threshold that would trigger escalation_ladder regime - Phase 0 can never produce that regime even on extreme input +- #9 creates ID drift that may break theater lookups silently +- Others are quality-of-life but reduce bug surface + +## Proposed Solutions + +### Option 1: Do all 10 in one PR +Small and mechanical; review them together. + +**Pros:** Single follow-up eliminates a category of debt +**Cons:** Mix of concerns in one change +**Effort:** Small (each item) +**Risk:** Low + +### Option 2: Split into 3 PRs +(a) helper fixes (num/clip/generateSnapshotId/round/percentile), (b) perf+config (getRegionCountries/matchesHorizon/triggers.config/test comment), (c) cVessel renormalization. + +**Pros:** Cleaner history; smaller blast radius per change +**Cons:** More PR overhead +**Effort:** Small +**Risk:** Low + +## Recommended Action + + +## Technical Details +Files touched: +- `scripts/regional-snapshot/_helpers.mjs` - num, clip, generateSnapshotId, round, percentile +- `scripts/regional-snapshot/balance-vector.mjs` - cVessel weight, duplicated round +- `scripts/regional-snapshot/actor-scoring.mjs` - duplicated round +- `scripts/regional-snapshot/scenario-builder.mjs` - duplicated round, matchesHorizon regex +- `scripts/regional-snapshot/snapshot-meta.mjs` - duplicated round +- `shared/geography.js` - getRegionCountries scan +- `scripts/regional-snapshot/triggers.config.mjs` - russia_naval_buildup theater ID +- `tests/regional-snapshot.test.mjs:429` - stray comment + +For #7: renormalize the `coercive_pressure` formula so that with `cVessel` stubbed at 0 the remaining weights sum to 1.0 (or drop the cVessel term entirely until Phase 1 provides data). + +## Acceptance Criteria +- [ ] num() drops parseFloat branch (use Number) +- [ ] clip() drops redundant isNaN check +- [ ] generateSnapshotId uses crypto.randomUUID +- [ ] round() centralized in _helpers +- [ ] matchesHorizon regex deduplicated +- [ ] getRegionCountries precomputed +- [ ] coercive_pressure renormalized so escalation_ladder is reachable in Phase 0 +- [ ] percentile clamps p +- [ ] triggers.config theater names consistent +- [ ] Test comment cleaned up + +## Work Log + +## Resources +- PR #2940 +- PR #2942 diff --git a/todos/192-pending-p3-perf-micro-cleanups.md b/todos/192-pending-p3-perf-micro-cleanups.md new file mode 100644 index 000000000..deae98423 --- /dev/null +++ b/todos/192-pending-p3-perf-micro-cleanups.md @@ -0,0 +1,78 @@ +--- +status: pending +priority: p3 +issue_id: 192 +tags: [code-review, phase-0, regional-intelligence, performance, safety] +dependencies: [] +--- + +# Performance micro-cleanups (buildPreMeta x8, signal indexing, evidence chokepoint filter, prototype-pollution guards) + +## Problem Statement +Several minor perf and safety cleanups: + +1. `buildPreMeta(sources)` is called 8x with identical results (only depends on sources, not regionId). Hoist out of `computeSnapshot` into `main()`. +2. `signals.filter(theater substring)` rebuilds per region in `balance-vector.mjs` and `evidence-collector.mjs`. Precompute `signalsByRegion` Map once in `main()`. +3. `evidence-collector.mjs:62-77` iterates ALL chokepoints regardless of region. Filter by `getRegionCorridors(regionId).map(c => c.chokepointId)`. +4. `geography.js:countryCriticality()` and `regionForCountry()` use bracket access on plain objects - prototype pollution risk if `iso2` is `__proto__`. Use `Object.hasOwn()` guard. +5. `JSON.stringify(snapshot)` happens twice in `persist-snapshot.mjs` (for tsKey and idKey). Stringify once, reuse. +6. `actor-scoring.mjs`, `balance-vector.mjs`, `scenario-builder.mjs` `JSON.stringify` on `caseFile` not wrapped in try/catch. Circular references in upstream payload would crash the seed for all 8 regions. + +## Findings +- #1-3, #5 are pure perf: redundant work per region +- #4 is a safety issue (bracket access on user-supplied string keys) +- #6 is a reliability issue (one bad forecast crashes the whole seed) +- All items small, independent, safe + +## Proposed Solutions + +### Option 1: Do all 6 in one PR +Small mechanical cleanups; low risk. + +**Pros:** Single follow-up +**Cons:** Mix of concerns +**Effort:** Small (each item) +**Risk:** Low + +### Option 2: Split perf vs safety +(a) perf micros (#1, #2, #3, #5), (b) safety (#4, #6). + +**Pros:** Each PR has a clean theme +**Cons:** More overhead +**Effort:** Small +**Risk:** Low + +## Recommended Action + + +## Technical Details +Affected files: +- `scripts/seed-regional-snapshots.mjs` - main(), computeSnapshot, buildPreMeta call site +- `scripts/regional-snapshot/balance-vector.mjs` - signals.filter, caseFile stringify +- `scripts/regional-snapshot/evidence-collector.mjs:62-77` - chokepoint iteration +- `scripts/regional-snapshot/actor-scoring.mjs` - caseFile stringify +- `scripts/regional-snapshot/scenario-builder.mjs` - caseFile stringify +- `scripts/regional-snapshot/persist-snapshot.mjs` - double stringify +- `shared/geography.js` - countryCriticality, regionForCountry + +For #4, pattern: +```js +if (!Object.hasOwn(table, iso2)) return fallback; +return table[iso2]; +``` + +For #6, wrap each `JSON.stringify(f?.caseFile ?? ...)` in try/catch and fall back to `"{}"` (ties in naturally with issue #190's precompute-once). + +## Acceptance Criteria +- [ ] buildPreMeta hoisted to main() +- [ ] signalsByRegion indexed once +- [ ] Chokepoint evidence filtered by region corridors +- [ ] Object.hasOwn guards on geography lookups +- [ ] JSON.stringify(snapshot) called once per region +- [ ] caseFile JSON.stringify wrapped in try/catch with fallback to {} + +## Work Log + +## Resources +- PR #2940 +- PR #2942