Files
worldmonitor/tests/forecast-detectors.test.mjs
Elie Habib 226cebf9bc feat(deep-forecast): Phase 2+3 scoring recalibration + autoresearch prompt self-improvement (#2178)
* fix(deep-forecast): lower acceptance threshold 0.60→0.50 to match real score distribution

computeDeepPathAcceptanceScore formula: pathScore×0.55 + quality×0.20 + coherence×0.15
With pathScore≈0.65, quality≈0.30, coherence≈0.55:
  0.358 + 0.060 + 0.083 = 0.50

The 0.60 threshold was calibrated before understanding that reportableQualityScore
is constrained by world-state simulation geometry (not hypothesis quality), and
coherence loses 0.15 for generic candidates without routeFacilityKey. The threshold
was structurally unreachable with typical expanded paths.

Verified end-to-end: deep worker now returns [DeepForecast] completed.

Also updates T6 gateDetails assertion and renames the rejection-floor test to
correctly describe the new behavior (strong inputs should be accepted).

111/111 tests pass.

* feat(deep-forecast): autoresearch prompt self-improvement loop + T9/T10 tests

- Add scoreImpactExpansionQuality() locked scorer: commodity rate (35%),
  variable diversity (35%), chain coverage (20%), mapped rate (10%)
- Add runImpactExpansionPromptRefinement(): rate-limited LLM critic loop
  (30min cooldown) that reads learned section from Redis, scores current
  run, generates critique if composite < 0.62, tests on same candidates,
  commits to forecast:prompt:impact-expansion:learned if score improves
- buildImpactExpansionSystemPrompt() now accepts learnedSection param,
  appends it after core rules with separator so model sees prior examples
- buildImpactExpansionCandidateHash() includes learnedFingerprint to
  bust cache when learned section changes
- processDeepForecastTask reads learnedSection from Redis before LLM
  call, runs refinement after both completed and no_material_change paths
- Export scoreImpactExpansionQuality + runImpactExpansionPromptRefinement
- T9: high commodity rate + chain coverage → composite > 0.70
- T10: no commodity + no chain coverage → composite < 0.40
- 113/113 tests pass

* fix(deep-forecast): raise autoresearch threshold 0.62→0.80 + fix JSON parse

- Threshold 0.62 was too low: commodity=1.00 + chain=1.00 compensated for
  diversity=0.50 (all same chains), keeping composite at 0.775 → no critique
- Raise to 0.80 so diversity<0.70 triggers critique even with good commodity/chain
- Fix JSON parser to extract first {…} block (handles Gemini code-fence wrapping)
- Add per-hypothesis log in refinement breakdown for observability
- Add refinementQualityThreshold to gateDetails for self-documenting artifacts
- Verified: critique fires on diversity=0.50 run, committed Hormuz/Baltic/Suez
  region-specific chain examples (score 0.592→0.650)

* feat(deep-forecast): per-candidate parallel LLM calls replace batch expansion

Previously: all candidates → one batch LLM call → LLM averages context →
identical route_disruption → inflation_pass_through chains for all candidates.

Now: each candidate → its own focused LLM call (parallel Promise.all) →
LLM reasons about specific stateKind/region/routeFacility for that candidate.

Results (3 candidates, 3 parallel calls):
- composite: 0.592 → 0.831 (+0.24)
- commodity: 0.17 → 1.00 (all mapped have specific commodity)
- diversity: 0.50 → 0.83 (energy_export_stress, importer_balance_stress
  appearing alongside route_disruption — genuinely different chains)
- Baseline updated: 0.831 (above 0.80 threshold → no critique needed)

Also threads learnedSection through extractSingleImpactExpansionCandidate
so the learned examples from autoresearch apply to each focused call.
Per-candidate cache keys (already existed) now serve as primary cache.

* fix(tests): update recovery test for per-candidate LLM call flow

- Change stage mock from impact_expansion → impact_expansion_single
  (batch primary path removed, per-candidate is now primary)
- Assert parseMode === per_candidate instead of parseStage /^recovered_/
  (recovered_ prefix was only set by old batch_repair path)
- 2257/2257 tests pass

* fix(deep-forecast): add Red Sea, Persian Gulf, South China Sea to chokepoint map

Candidate packets had routeFacilityKey=none for Red Sea / Persian Gulf /
Baltic Sea signals because prediction titles say "Red Sea maritime disruption"
not "Bab el-Mandeb" or "Strait of Hormuz". CHOKEPOINT_MARKET_REGIONS only
had sub-facility names (Bab el-Mandeb, Suez Canal) as keys, not the sea
regions themselves.

Fix: add Red Sea, Persian Gulf, Arabian Sea, Black Sea, South China Sea,
Mediterranean Sea as direct keys so region-level candidate titles resolve.

Result: LLM user prompt now shows routeFacilityKey=Red Sea / Persian Gulf /
Baltic Sea per candidate — giving each focused call the geographic context
needed to generate route-specific chains.

- Autoresearch baseline updated 0.932→0.965 on this run
- T8 extended with Red Sea, Persian Gulf, South China Sea assertions
- 2257/2257 tests pass

* feat(deep-forecast): free-form hypothesis schema + remove registry constraint

- Bump IMPACT_EXPANSION_REGISTRY_VERSION to v4
- Add hypothesisKey, description, geography, affectedAssets, marketImpact, causalLink fields to normalizeImpactHypothesisDraft (keep legacy fields for backward compat)
- Rewrite buildImpactExpansionSystemPrompt: remove IMPACT_VARIABLE_REGISTRY constraint table, use free-form ImpactHypothesis schema with geographic/commodity specificity rules
- Rewrite evaluateImpactHypothesisRejection: use effective key (hypothesisKey || variableKey) for dedup; legacy registry check only for old cached responses without hypothesisKey
- Update validateImpactHypotheses scoring: add geographyScore, commodityScore, causalLinkScore, assetScore terms; channelCoherence/bucketCoherence only apply to legacy responses
- Update parent-must-be-mapped invariant to use hypothesisKey || variableKey as effective key
- Update mapImpactHypothesesToWorldSignals: use effective key for dedup and sourceKey; prefer description/geography over legacy fields
- Update buildImpactPathsForCandidate: match on hypothesisKey || variableKey for parent lookup
- Update buildImpactPathId: use hypothesisKey || variableKey for hash inputs
- Rewrite scoreImpactExpansionQuality: add geographyRate and assetRate metrics; update composite weights
- Update buildImpactPromptCritiqueSystemPrompt/UserPrompt: use hypothesisKey-based chain format in examples
- Add new fields to buildImpactExpansionBundleFromPaths push calls
- Update T7 test assertion: MUST be the exact hypothesisKey instead of variableKey string

* fix(deep-forecast): update breakdown log to show free-form hypothesis fields

* feat(deep-forecast): add commodityDiversity metric to autoresearch scorer

- commodityDiversity = unique commodities / nCandidates (weight 0.35)
  Penalizes runs where all candidates default to same commodity.
  3 candidates all crude_oil → diversity=0.33 → composite ~0.76 → critique fires.
- Rebalanced composite weights: comDiversity 0.35, geo 0.20, keyDiversity 0.15, chain 0.10, commodityRate 0.10, asset 0.05, mappedRate 0.05
- Breakdown log now shows comDiversity + geo + keyDiversity
- Critique prompt updated: commodity_monoculture failure mode, diagnosis targets commodity homogeneity
- T9: added commodityDiversity=1.0 assertion (2 unique commodities across 2 candidates)

* refactor(deep-forecast): replace commodityDiversity with directCommodityDiversity + directGeoDiversity + candidateSpreadScore

Problem: measuring diversity on all mapped hypotheses misses the case where
one candidate generates 10 implications while others generate 0, or where
all candidates converge on the same commodity due to dominating signals.

Fix: score at the DIRECT hypothesis level (root causes only) and add
a candidate-spread metric:

- directCommodityDiversity: unique commodities among direct hypotheses /
  nCandidates. Measures breadth at the root-cause level. 3 candidates all
  crude_oil → 0.33 → composite ~0.77 → critique fires.

- directGeoDiversity: unique primary geographies among direct hypotheses /
  nCandidates. First segment of compound geography strings (e.g.
  'Red Sea, Suez Canal' → 'red sea') to avoid double-counting.

- candidateSpreadScore: normalized inverse-HHI. 1.0 = perfectly even
  distribution across candidates. One candidate with 10 implications and
  others with 0 → scores near 0 → critique fires.

Weight rationale: comDiversity 0.35, geoDiversity 0.20, spread 0.15,
chain 0.15, comRate 0.08, assetRate 0.04, mappedRate 0.03.

Verified: Run 2 Baltic/Hormuz/Brazil → freight/crude_oil/USD spread=0.98 ✓

* feat(deep-forecast): add convergence object to R2 debug artifact

Surface autoresearch loop outcome per run: converged (bool), finalComposite,
critiqueIterations (0 or 1), refinementCommitted, and perCandidateMappedCount
(candidateStateId → count). After 5+ runs the artifact alone answers whether
the pipeline is improving.

Architectural changes:
- runImpactExpansionPromptRefinement now returns { iterationCount, committed }
  at all exit paths instead of undefined
- Call hoisted before writeForecastTraceArtifacts so the result flows into the
  debug payload via dataForWrite.refinementResult
- buildImpactExpansionDebugPayload assembles convergence from validation +
  refinementResult; exported for direct testing
- Fix: stale diversityScore reference replaced with directCommodityDiversity

Tests: T-conv-1 (converged=true), T-conv-2 (converged=false + iterations=1),
T-conv-3 (perCandidateMappedCount grouping) — 116/116 pass

* fix(deep-forecast): address P1+P2 review issues from convergence observability PR

P1-A: sanitize LLM-returned proposed_addition before Redis write (prompt injection
      guard via sanitizeProposedLlmAddition — strips directive-phrase lines)
P1-B: restore fire-and-forget for runImpactExpansionPromptRefinement; compute
      critiqueIterations from quality score (predicted) instead of awaiting result,
      eliminating 15-30s critical-path latency on poor-quality runs
P1-C: processDeepForecastTask now returns convergence object to callers; add
      convergence_quality_met warn check to evaluateForecastRunArtifacts
P1-D: cap concurrent LLM calls in extractImpactExpansionBundle to 3 (manual
      batching — no p-limit) to respect provider rate limits

P2-1: hash full learnedSection in buildImpactExpansionCandidateHash (was sliced
      to 80 chars, causing cache collisions on long learned sections)
P2-2: add exitReason field to all runImpactExpansionPromptRefinement return paths
P2-3: sanitizeForPrompt strips directive injection phrases; new
      sanitizeProposedLlmAddition applies line-level filtering before Redis write
P2-4: add comment explaining intentional bidirectional affectedAssets/assetsOrSectors
      coalescing in normalizeImpactHypothesisDraft
P2-5: extract makeConvTestData helper in T-conv tests; remove refinementCommitted
      assertions (field removed from convergence shape)
P2-6: convergence_quality_met check added to evaluateForecastRunArtifacts (warn)

🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.ai/claude-code) + Compound Engineering v2.49.0

Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>

* fix(docs): add blank lines around lists in plan (MD032)

* fix(deep-forecast): address P1+P2 reviewer issues in convergence observability

P1-1: mapImpactHypothesesToWorldSignals used free-form marketImpact values
(price_spike, shortage, credit_stress, risk_off) verbatim as signal channel
types, producing unknown types that buildMarketTransmissionGraph cannot
consume. Add IMPACT_SIGNAL_CHANNELS set + resolveImpactChannel() to map
free-form strings to the nearest valid channel before signal materialization.

P1-2: sanitizeForPrompt had directive-phrase stripping added that was too
broad for a function called on headlines, evidence tables, case files, and
geopolitical summaries. Reverted to original safe sanitizer (newline/control
char removal only). Directive stripping remains in sanitizeProposedLlmAddition
where it is scoped to Redis-bound LLM-generated additions only.

P2: Renamed convergence.critiqueIterations to predictedCritiqueIterations to
make clear this is a prediction from the quality score, not a measured count
from actual refinement behavior (refinement is fire-and-forget after artifact
write). Updated T-conv-1/2 test assertions to match.

* feat(deep-forecast): inject live news headlines into evidence table

Wire inputs.newsInsights / inputs.newsDigest through the candidate
selection pipeline so buildImpactExpansionEvidenceTable receives up to
3 commodity-relevant live headlines as 'live_news' evidence entries.

Changes:
- IMPACT_COMMODITY_LEXICON: extend fertilizer pattern (fertiliser,
  nitrogen, phosphate, npk); add food_grains and shipping_freight entries
- filterNewsHeadlinesByState: new pure helper that scores headlines by
  alert status, LNG/energy/route/sanctions signal match, lexicon commodity
  match, and source corroboration count (min score 2 to include)
- buildImpactExpansionEvidenceTable: add newsItems param, inject
  live_news entries, raise cap 8→11
- buildImpactExpansionCandidate: add newsInsights/newsDigest params,
  compute newsItems via filterNewsHeadlinesByState
- selectImpactExpansionCandidates: add newsInsights/newsDigest to options
- Call site: pass inputs.newsInsights/newsDigest at seed time
- Export filterNewsHeadlinesByState, buildImpactExpansionEvidenceTable
- 9 new tests (T-news-1 through T-lex-3): all pass, 125 total pass

🤖 Generated with Claude Sonnet 4.6 (200K context) via Claude Code (https://claude.ai/claude-code) + Compound Engineering v2.49.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(deep-forecast): remove hardcoded LNG boost from filterNewsHeadlinesByState

The LNG+2 score was commodity-specific and inconsistent with the
intent: headline scoring should be generic, not biased toward any
named commodity. The function already handles the state's detected
commodity dynamically via lexEntry.pattern (IMPACT_COMMODITY_LEXICON).
LNG headlines still score via CRITICAL_NEWS_ENERGY_RE (+1) and
CRITICAL_NEWS_ROUTE_RE (+1) when relevant to the state's region.
All 125 tests pass.

* fix(deep-forecast): address all P1+P2 code review findings from PR #2178

P1 fixes (block-merge):
- Lower third_order mapped floor 0.74→0.70 (max achievable via 0.72 multiplier was 0.72)
- Guard runImpactExpansionPromptRefinement against empty validation (no_mapped exit)
- Replace block-list sanitizeProposedLlmAddition with pattern-based allowlist (HTML/JS/directive takeover)
- Fix TOCTOU on PROMPT_LAST_ATTEMPT_KEY: claim slot before quality check, not after LLM call

P2 fixes:
- Fix learned section overflow: use slice(-MAX) to preserve tail, not discard all prior content
- Add safe_haven_bid and global_crude_spread_stress branches to resolveImpactChannel
- quality_met path now sets rate-limit key (prevents 3 Redis GETs per good run)
- Hoist extractNewsClusterItems outside stateUnit map in selectImpactExpansionCandidates
- Export PROMPT_LEARNED_KEY, PROMPT_BASELINE_KEY, PROMPT_LAST_ATTEMPT_KEY + read/clear helpers

All 125 tests pass.

* fix(todos): add blank lines around lists/headings in todo files (markdownlint)

* fix(todos): fix markdownlint blanks-around-headings/lists in all todo files

---------

Co-authored-by: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
2026-03-24 18:52:02 +04:00

2613 lines
115 KiB
JavaScript

import assert from 'node:assert/strict';
import { afterEach, describe, it } from 'node:test';
import {
forecastId,
normalize,
makePrediction,
resolveCascades,
calibrateWithMarkets,
computeTrends,
detectConflictScenarios,
detectMarketScenarios,
detectSupplyChainScenarios,
detectPoliticalScenarios,
detectMilitaryScenarios,
detectInfraScenarios,
detectUcdpConflictZones,
detectCyberScenarios,
detectGpsJammingScenarios,
detectFromPredictionMarkets,
getFreshMilitaryForecastInputs,
normalizeChokepoints,
normalizeGpsJamming,
loadEntityGraph,
discoverGraphCascades,
attachNewsContext,
computeConfidence,
computeHeadlineRelevance,
computeMarketMatchScore,
sanitizeForPrompt,
parseLLMScenarios,
validateScenarios,
validatePerspectives,
validateCaseNarratives,
computeProjections,
buildUserPrompt,
buildForecastCase,
buildForecastCases,
buildPriorForecastSnapshot,
buildPublishedForecastPayload,
buildPublishedSeedPayload,
buildChangeItems,
buildChangeSummary,
annotateForecastChanges,
buildCounterEvidence,
buildCaseTriggers,
buildForecastActors,
buildForecastWorldState,
buildForecastRunWorldState,
buildForecastBranches,
buildActorLenses,
scoreForecastReadiness,
computeAnalysisPriority,
rankForecastsForAnalysis,
selectPublishedForecastPool,
buildPublishedForecastArtifacts,
filterPublishedForecasts,
applySituationFamilyCaps,
selectForecastsForEnrichment,
parseForecastProviderOrder,
getForecastLlmCallOptions,
resolveForecastLlmProviders,
buildFallbackScenario,
buildFallbackBaseCase,
buildFallbackEscalatoryCase,
buildFallbackContrarianCase,
buildFeedSummary,
buildFallbackPerspectives,
populateFallbackNarratives,
refreshPublishedNarratives,
extractImpactExpansionBundle,
loadCascadeRules,
evaluateRuleConditions,
summarizePublishFiltering,
SIGNAL_TO_SOURCE,
PREDICATE_EVALUATORS,
DEFAULT_CASCADE_RULES,
PROJECTION_CURVES,
__setForecastLlmCallOverrideForTests,
} from '../scripts/seed-forecasts.mjs';
const originalForecastEnv = {
FORECAST_LLM_PROVIDER_ORDER: process.env.FORECAST_LLM_PROVIDER_ORDER,
FORECAST_LLM_COMBINED_PROVIDER_ORDER: process.env.FORECAST_LLM_COMBINED_PROVIDER_ORDER,
FORECAST_LLM_MODEL_OPENROUTER: process.env.FORECAST_LLM_MODEL_OPENROUTER,
FORECAST_LLM_COMBINED_MODEL_OPENROUTER: process.env.FORECAST_LLM_COMBINED_MODEL_OPENROUTER,
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
};
afterEach(() => {
__setForecastLlmCallOverrideForTests(null);
for (const [key, value] of Object.entries(originalForecastEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
describe('forecastId', () => {
it('same inputs produce same ID', () => {
const a = forecastId('conflict', 'Iran', 'Escalation risk');
const b = forecastId('conflict', 'Iran', 'Escalation risk');
assert.equal(a, b);
});
it('different inputs produce different IDs', () => {
const a = forecastId('conflict', 'Iran', 'Escalation risk');
const b = forecastId('market', 'Iran', 'Oil price shock');
assert.notEqual(a, b);
});
it('ID format is fc-{domain}-{8char_hex}', () => {
const id = forecastId('conflict', 'Middle East', 'Theater escalation');
assert.match(id, /^fc-conflict-[0-9a-f]{8}$/);
});
it('domain is embedded in the ID', () => {
const id = forecastId('market', 'Red Sea', 'Oil disruption');
assert.ok(id.startsWith('fc-market-'));
});
});
describe('normalize', () => {
it('value at min returns 0', () => {
assert.equal(normalize(50, 50, 100), 0);
});
it('value at max returns 1', () => {
assert.equal(normalize(100, 50, 100), 1);
});
it('midpoint returns 0.5', () => {
assert.equal(normalize(75, 50, 100), 0.5);
});
it('value below min clamps to 0', () => {
assert.equal(normalize(10, 50, 100), 0);
});
it('value above max clamps to 1', () => {
assert.equal(normalize(200, 50, 100), 1);
});
it('min === max returns 0', () => {
assert.equal(normalize(50, 50, 50), 0);
});
it('min > max returns 0', () => {
assert.equal(normalize(50, 100, 50), 0);
});
});
describe('resolveCascades', () => {
it('conflict near chokepoint creates supply_chain and market cascades', () => {
const pred = makePrediction(
'conflict', 'Middle East', 'Escalation risk: Iran',
0.7, 0.6, '7d', [{ type: 'cii', value: 'Iran CII 85', weight: 0.4 }],
);
const predictions = [pred];
resolveCascades(predictions, DEFAULT_CASCADE_RULES);
const domains = pred.cascades.map(c => c.domain);
assert.ok(domains.includes('supply_chain'), 'should have supply_chain cascade');
assert.ok(domains.includes('market'), 'should have market cascade');
});
it('cascade probabilities capped at 0.8', () => {
const pred = makePrediction(
'conflict', 'Middle East', 'Escalation risk: Iran',
0.99, 0.9, '7d', [{ type: 'cii', value: 'high', weight: 0.4 }],
);
resolveCascades([pred], DEFAULT_CASCADE_RULES);
for (const c of pred.cascades) {
assert.ok(c.probability <= 0.8, `cascade probability ${c.probability} should be <= 0.8`);
}
});
it('deduplication within a single call: same rule does not fire twice for same source', () => {
const pred = makePrediction(
'conflict', 'Middle East', 'Escalation risk: Iran',
0.7, 0.6, '7d', [{ type: 'cii', value: 'test', weight: 0.4 }],
);
resolveCascades([pred], DEFAULT_CASCADE_RULES);
const keys = pred.cascades.map(c => `${c.domain}:${c.effect}`);
const unique = new Set(keys);
assert.equal(keys.length, unique.size, 'no duplicate cascade entries within one resolution');
});
it('no self-edges: cascade domain differs from source domain', () => {
const pred = makePrediction(
'conflict', 'Middle East', 'Escalation',
0.7, 0.6, '7d', [{ type: 'cii', value: 'test', weight: 0.4 }],
);
resolveCascades([pred], DEFAULT_CASCADE_RULES);
for (const c of pred.cascades) {
assert.notEqual(c.domain, pred.domain, `cascade domain ${c.domain} should differ from source ${pred.domain}`);
}
});
it('political > 0.6 creates conflict cascade', () => {
const pred = makePrediction(
'political', 'Iran', 'Political instability',
0.65, 0.5, '30d', [{ type: 'unrest', value: 'unrest', weight: 0.4 }],
);
resolveCascades([pred], DEFAULT_CASCADE_RULES);
const domains = pred.cascades.map(c => c.domain);
assert.ok(domains.includes('conflict'), 'political instability should cascade to conflict');
});
it('political <= 0.6 does not cascade to conflict', () => {
const pred = makePrediction(
'political', 'Iran', 'Political instability',
0.5, 0.5, '30d', [{ type: 'unrest', value: 'unrest', weight: 0.4 }],
);
resolveCascades([pred], DEFAULT_CASCADE_RULES);
assert.equal(pred.cascades.length, 0);
});
});
describe('calibrateWithMarkets', () => {
it('matching market adjusts probability with 40/60 blend', () => {
const pred = makePrediction(
'conflict', 'Middle East', 'Escalation',
0.7, 0.6, '7d', [],
);
pred.region = 'Middle East';
const markets = {
geopolitical: [{ title: 'Will Iran conflict escalate in MENA?', yesPrice: 30, source: 'polymarket' }],
};
calibrateWithMarkets([pred], markets);
const expected = +(0.4 * 0.3 + 0.6 * 0.7).toFixed(3);
assert.equal(pred.probability, expected);
assert.ok(pred.calibration !== null);
assert.equal(pred.calibration.source, 'polymarket');
});
it('no match leaves probability unchanged', () => {
const pred = makePrediction(
'conflict', 'Korean Peninsula', 'Korea escalation',
0.6, 0.5, '7d', [],
);
const originalProb = pred.probability;
const markets = {
geopolitical: [{ title: 'Will EU inflation drop?', yesPrice: 50 }],
};
calibrateWithMarkets([pred], markets);
assert.equal(pred.probability, originalProb);
assert.equal(pred.calibration, null);
});
it('drift calculated correctly', () => {
const pred = makePrediction(
'conflict', 'Middle East', 'Escalation',
0.7, 0.6, '7d', [],
);
const markets = {
geopolitical: [{ title: 'Iran MENA conflict?', yesPrice: 40 }],
};
calibrateWithMarkets([pred], markets);
assert.equal(pred.calibration.drift, +(0.7 - 0.4).toFixed(3));
});
it('null markets handled gracefully', () => {
const pred = makePrediction('conflict', 'Middle East', 'Test', 0.5, 0.5, '7d', []);
calibrateWithMarkets([pred], null);
assert.equal(pred.calibration, null);
});
it('empty markets handled gracefully', () => {
const pred = makePrediction('conflict', 'Middle East', 'Test', 0.5, 0.5, '7d', []);
calibrateWithMarkets([pred], {});
assert.equal(pred.calibration, null);
});
it('markets without geopolitical key handled gracefully', () => {
const pred = makePrediction('conflict', 'Middle East', 'Test', 0.5, 0.5, '7d', []);
calibrateWithMarkets([pred], { crypto: [] });
assert.equal(pred.calibration, null);
});
it('does not calibrate from unrelated same-region macro market', () => {
const pred = makePrediction(
'conflict', 'Middle East', 'Escalation risk: Iran',
0.7, 0.6, '7d', [],
);
const markets = {
geopolitical: [{ title: 'Will Netanyahu remain prime minister through 2026?', yesPrice: 20, source: 'polymarket', volume: 100000 }],
};
calibrateWithMarkets([pred], markets);
assert.equal(pred.calibration, null);
assert.equal(pred.probability, 0.7);
});
it('does not calibrate commodity forecasts from loosely related regional conflict markets', () => {
const pred = makePrediction(
'market', 'Middle East', 'Oil price impact from Strait of Hormuz disruption',
0.668, 0.58, '30d', [],
);
const markets = {
geopolitical: [{ title: 'Will Israel launch a major ground offensive in Lebanon by March 31?', yesPrice: 57, source: 'polymarket', volume: 100000 }],
};
calibrateWithMarkets([pred], markets);
assert.equal(pred.calibration, null);
assert.equal(pred.probability, 0.668);
});
});
describe('computeTrends', () => {
it('no prior: all trends set to stable', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.6, 0.5, '7d', []);
computeTrends([pred], null);
assert.equal(pred.trend, 'stable');
assert.equal(pred.priorProbability, pred.probability);
});
it('rising: delta > 0.05', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.7, 0.5, '7d', []);
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
computeTrends([pred], prior);
assert.equal(pred.trend, 'rising');
assert.equal(pred.priorProbability, 0.5);
});
it('falling: delta < -0.05', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.3, 0.5, '7d', []);
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
computeTrends([pred], prior);
assert.equal(pred.trend, 'falling');
});
it('stable: delta within +/- 0.05', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.52, 0.5, '7d', []);
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
computeTrends([pred], prior);
assert.equal(pred.trend, 'stable');
});
it('new prediction (no prior match): stable', () => {
const pred = makePrediction('conflict', 'Iran', 'Brand new', 0.6, 0.5, '7d', []);
const prior = { predictions: [{ id: 'fc-conflict-00000000', probability: 0.5 }] };
computeTrends([pred], prior);
assert.equal(pred.trend, 'stable');
assert.equal(pred.priorProbability, pred.probability);
});
it('prior with empty predictions array: all stable', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.6, 0.5, '7d', []);
computeTrends([pred], { predictions: [] });
assert.equal(pred.trend, 'stable');
});
it('just above +0.05 threshold: rising', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.56, 0.5, '7d', []);
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
computeTrends([pred], prior);
assert.equal(pred.trend, 'rising');
});
it('just below -0.05 threshold: falling', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.44, 0.5, '7d', []);
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
computeTrends([pred], prior);
assert.equal(pred.trend, 'falling');
});
it('delta exactly at boundary: uses strict comparison (> 0.05)', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.549, 0.5, '7d', []);
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
computeTrends([pred], prior);
assert.equal(pred.trend, 'stable');
});
});
describe('detector smoke tests: null/empty inputs', () => {
it('detectConflictScenarios({}) returns []', () => {
assert.deepEqual(detectConflictScenarios({}), []);
});
it('detectMarketScenarios({}) returns []', () => {
assert.deepEqual(detectMarketScenarios({}), []);
});
it('detectSupplyChainScenarios({}) returns []', () => {
assert.deepEqual(detectSupplyChainScenarios({}), []);
});
it('detectPoliticalScenarios({}) returns []', () => {
assert.deepEqual(detectPoliticalScenarios({}), []);
});
it('detectMilitaryScenarios({}) returns []', () => {
assert.deepEqual(detectMilitaryScenarios({}), []);
});
it('detectInfraScenarios({}) returns []', () => {
assert.deepEqual(detectInfraScenarios({}), []);
});
it('detectors handle null arrays gracefully', () => {
const inputs = {
ciiScores: null,
temporalAnomalies: null,
theaterPosture: null,
chokepoints: null,
iranEvents: null,
ucdpEvents: null,
unrestEvents: null,
outages: null,
cyberThreats: null,
gpsJamming: null,
};
assert.deepEqual(detectConflictScenarios(inputs), []);
assert.deepEqual(detectMarketScenarios(inputs), []);
assert.deepEqual(detectSupplyChainScenarios(inputs), []);
assert.deepEqual(detectPoliticalScenarios(inputs), []);
assert.deepEqual(detectMilitaryScenarios(inputs), []);
assert.deepEqual(detectInfraScenarios(inputs), []);
});
});
describe('detectConflictScenarios', () => {
it('high CII rising score produces conflict prediction', () => {
const inputs = {
ciiScores: [{ code: 'IRN', name: 'Iran', score: 85, level: 'high', trend: 'rising' }],
theaterPosture: { theaters: [] },
iranEvents: [],
ucdpEvents: [],
};
const result = detectConflictScenarios(inputs);
assert.ok(result.length >= 1);
assert.equal(result[0].domain, 'conflict');
assert.ok(result[0].probability > 0);
assert.ok(result[0].probability <= 0.9);
});
it('low CII score is ignored', () => {
const inputs = {
ciiScores: [{ code: 'CHE', name: 'Switzerland', score: 30, level: 'low', trend: 'stable' }],
theaterPosture: { theaters: [] },
iranEvents: [],
ucdpEvents: [],
};
assert.deepEqual(detectConflictScenarios(inputs), []);
});
it('critical theater posture produces prediction', () => {
const inputs = {
ciiScores: [],
theaterPosture: { theaters: [{ id: 'iran-theater', name: 'Iran Theater', postureLevel: 'critical' }] },
iranEvents: [],
ucdpEvents: [],
};
const result = detectConflictScenarios(inputs);
assert.ok(result.length >= 1);
assert.equal(result[0].region, 'Middle East');
});
it('accepts theater posture entries that use theater instead of id', () => {
const inputs = {
ciiScores: [],
theaterPosture: { theaters: [{ theater: 'taiwan-theater', name: 'Taiwan Theater', postureLevel: 'elevated' }] },
iranEvents: [],
ucdpEvents: [],
};
const result = detectConflictScenarios(inputs);
assert.ok(result.length >= 1);
assert.equal(result[0].region, 'Western Pacific');
});
});
describe('detectMarketScenarios', () => {
it('high-risk chokepoint with known commodity produces market prediction', () => {
const inputs = {
chokepoints: { routes: [{ region: 'Middle East', riskLevel: 'critical', riskScore: 85 }] },
ciiScores: [],
};
const result = detectMarketScenarios(inputs);
assert.ok(result.length >= 1);
assert.equal(result[0].domain, 'market');
assert.ok(result[0].title.includes('Oil'));
});
it('maps live chokepoint names to market-sensitive regions', () => {
const inputs = {
chokepoints: { chokepoints: [{ name: 'Strait of Hormuz', region: 'Strait of Hormuz', riskLevel: 'critical', riskScore: 80 }] },
ciiScores: [],
};
const result = detectMarketScenarios(inputs);
assert.equal(result.length, 1);
assert.equal(result[0].domain, 'market');
assert.equal(result[0].region, 'Middle East');
assert.match(result[0].title, /Hormuz/);
});
it('low-risk chokepoint is ignored', () => {
const inputs = {
chokepoints: { routes: [{ region: 'Middle East', riskLevel: 'low', riskScore: 30 }] },
ciiScores: [],
};
assert.deepEqual(detectMarketScenarios(inputs), []);
});
});
describe('detectInfraScenarios', () => {
it('major outage produces infra prediction', () => {
const inputs = {
outages: [{ country: 'Syria', severity: 'major' }],
cyberThreats: [],
gpsJamming: [],
};
const result = detectInfraScenarios(inputs);
assert.ok(result.length >= 1);
assert.equal(result[0].domain, 'infrastructure');
assert.ok(result[0].title.includes('Syria'));
});
it('minor outage is ignored', () => {
const inputs = {
outages: [{ country: 'Test', severity: 'minor' }],
cyberThreats: [],
gpsJamming: [],
};
assert.deepEqual(detectInfraScenarios(inputs), []);
});
it('cyber threats boost probability', () => {
const base = {
outages: [{ country: 'Syria', severity: 'total' }],
cyberThreats: [],
gpsJamming: [],
};
const withCyber = {
outages: [{ country: 'Syria', severity: 'total' }],
cyberThreats: [{ country: 'Syria', type: 'ddos' }],
gpsJamming: [],
};
const baseResult = detectInfraScenarios(base);
const cyberResult = detectInfraScenarios(withCyber);
assert.ok(cyberResult[0].probability > baseResult[0].probability,
'cyber threats should boost probability');
});
});
describe('detectPoliticalScenarios', () => {
it('uses geoConvergence when unrest-specific fields are absent or zero', () => {
const inputs = {
ciiScores: {
ciiScores: [{
region: 'IL',
combinedScore: 69,
trend: 'TREND_DIRECTION_STABLE',
components: { ciiContribution: 0, geoConvergence: 63, militaryActivity: 35 },
}],
},
temporalAnomalies: { anomalies: [] },
unrestEvents: { events: [] },
};
const result = detectPoliticalScenarios(inputs);
assert.equal(result.length, 1);
assert.equal(result[0].domain, 'political');
assert.equal(result[0].region, 'Israel');
});
it('can generate from unrest event counts even when CII unrest is weak', () => {
const inputs = {
ciiScores: {
ciiScores: [{
region: 'IN',
combinedScore: 62,
trend: 'TREND_DIRECTION_STABLE',
components: { ciiContribution: 0, geoConvergence: 0 },
}],
},
temporalAnomalies: { anomalies: [] },
unrestEvents: { events: [{ country: 'India' }, { country: 'India' }, { country: 'India' }] },
};
const result = detectPoliticalScenarios(inputs);
assert.equal(result.length, 1);
assert.equal(result[0].domain, 'political');
assert.equal(result[0].region, 'India');
});
});
describe('detectMilitaryScenarios', () => {
it('accepts live theater entries that use theater instead of id', () => {
const inputs = {
militaryForecastInputs: { fetchedAt: Date.now(), theaters: [{ theater: 'baltic-theater', postureLevel: 'critical', activeFlights: 12 }] },
temporalAnomalies: { anomalies: [] },
};
const result = detectMilitaryScenarios(inputs);
assert.equal(result.length, 1);
assert.equal(result[0].domain, 'military');
assert.equal(result[0].region, 'Northern Europe');
});
it('creates a military forecast from theater surge data even before posture turns elevated', () => {
const inputs = {
temporalAnomalies: { anomalies: [] },
militaryForecastInputs: {
fetchedAt: Date.now(),
theaters: [{ theater: 'taiwan-theater', postureLevel: 'normal', activeFlights: 5 }],
surges: [{
theaterId: 'taiwan-theater',
surgeType: 'fighter',
currentCount: 8,
baselineCount: 2,
surgeMultiple: 4,
persistent: true,
persistenceCount: 2,
postureLevel: 'normal',
strikeCapable: true,
fighters: 8,
tankers: 1,
awacs: 1,
dominantCountry: 'China',
dominantCountryCount: 6,
dominantOperator: 'plaaf',
}],
},
};
const result = detectMilitaryScenarios(inputs);
assert.equal(result.length, 1);
assert.equal(result[0].title, 'China-linked fighter surge near Taiwan Strait');
assert.ok(result[0].probability >= 0.7);
assert.ok(result[0].signals.some((signal) => signal.type === 'mil_surge'));
assert.ok(result[0].signals.some((signal) => signal.type === 'operator'));
assert.ok(result[0].signals.some((signal) => signal.type === 'persistence'));
assert.ok(result[0].signals.some((signal) => signal.type === 'theater_actor_fit'));
});
it('ignores stale military surge payloads', () => {
const inputs = {
temporalAnomalies: { anomalies: [] },
militaryForecastInputs: {
fetchedAt: Date.now() - (4 * 60 * 60 * 1000),
theaters: [{ theater: 'taiwan-theater', postureLevel: 'normal', activeFlights: 5 }],
surges: [{
theaterId: 'taiwan-theater',
surgeType: 'fighter',
currentCount: 8,
baselineCount: 2,
surgeMultiple: 4,
postureLevel: 'normal',
strikeCapable: true,
fighters: 8,
tankers: 1,
awacs: 1,
dominantCountry: 'China',
dominantCountryCount: 6,
}],
},
};
const result = detectMilitaryScenarios(inputs);
assert.equal(result.length, 0);
});
it('rejects military bundles whose theater timestamps drift from fetchedAt', () => {
const bundle = getFreshMilitaryForecastInputs({
militaryForecastInputs: {
fetchedAt: Date.now(),
theaters: [{ theater: 'taiwan-theater', postureLevel: 'elevated', assessedAt: Date.now() - (6 * 60 * 1000) }],
surges: [],
},
});
assert.equal(bundle, null);
});
it('suppresses one-off generic air activity when it lacks persistence and theater-relevant actors', () => {
const inputs = {
temporalAnomalies: { anomalies: [] },
militaryForecastInputs: {
fetchedAt: Date.now(),
theaters: [{ theater: 'iran-theater', postureLevel: 'normal', activeFlights: 6 }],
surges: [{
theaterId: 'iran-theater',
surgeType: 'air_activity',
currentCount: 6,
baselineCount: 2.7,
surgeMultiple: 2.22,
persistent: false,
persistenceCount: 0,
postureLevel: 'normal',
strikeCapable: false,
fighters: 0,
tankers: 0,
awacs: 0,
dominantCountry: 'Qatar',
dominantCountryCount: 4,
dominantOperator: 'other',
}],
},
};
const result = detectMilitaryScenarios(inputs);
assert.equal(result.length, 0);
});
});
// ── Phase 2 Tests ──────────────────────────────────────────
describe('attachNewsContext', () => {
it('matches headlines mentioning prediction region and scenario context', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
const news = { topStories: [
{ primaryTitle: 'Iran tensions escalate after military action' },
{ primaryTitle: 'Stock market rallies on tech earnings' },
{ primaryTitle: 'Iran nuclear deal negotiations resume' },
]};
attachNewsContext(preds, news);
assert.equal(preds[0].newsContext.length, 1);
assert.ok(preds[0].newsContext[0].includes('Iran'));
});
it('adds news_corroboration signal when headlines match', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
const news = { topStories: [{ primaryTitle: 'Iran military strikes reported' }] };
attachNewsContext(preds, news);
const corr = preds[0].signals.find(s => s.type === 'news_corroboration');
assert.ok(corr, 'should have news_corroboration signal');
assert.equal(corr.weight, 0.15);
});
it('does NOT add signal when no headlines match', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
const news = { topStories: [{ primaryTitle: 'Local weather forecast sunny' }] };
attachNewsContext(preds, news);
const corr = preds[0].signals.find(s => s.type === 'news_corroboration');
assert.equal(corr, undefined);
});
it('does not attach unrelated generic headlines when no match', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
const news = { topStories: [
{ primaryTitle: 'Unrelated headline about sports' },
{ primaryTitle: 'Another unrelated story' },
{ primaryTitle: 'Third unrelated story' },
{ primaryTitle: 'Fourth unrelated story' },
]};
attachNewsContext(preds, news);
assert.deepEqual(preds[0].newsContext, []);
});
it('excludes commodity node names from matching (no false positives)', () => {
// Iran links to "Oil" in entity graph, but "Oil" should NOT match headlines
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
const news = { topStories: [{ primaryTitle: 'Oil prices rise on global demand' }] };
attachNewsContext(preds, news);
// "Oil" is a commodity node, not country/theater, so should NOT match
const corr = preds[0].signals.find(s => s.type === 'news_corroboration');
assert.equal(corr, undefined, 'commodity names should not trigger corroboration');
});
it('reads headlines from digest categories (primary path)', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
const digest = { categories: {
middleeast: { items: [{ title: 'Iran launches missile test' }, { title: 'Saudi oil output stable' }] },
europe: { items: [{ title: 'EU summit concludes' }] },
}};
attachNewsContext(preds, null, digest);
assert.ok(preds[0].newsContext.length >= 1);
assert.ok(preds[0].newsContext[0].includes('Iran'));
const corr = preds[0].signals.find(s => s.type === 'news_corroboration');
assert.ok(corr, 'should have corroboration from digest headlines');
});
it('handles null newsInsights and null digest', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
attachNewsContext(preds, null, null);
assert.equal(preds[0].newsContext, undefined);
});
it('handles empty topStories with no digest', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
attachNewsContext(preds, { topStories: [] }, null);
assert.equal(preds[0].newsContext, undefined);
});
it('prefers region-relevant headlines over generic domain-only matches', () => {
const preds = [makePrediction('supply_chain', 'Red Sea', 'Shipping disruption: Red Sea', 0.6, 0.4, '7d', [])];
const news = { topStories: [
{ primaryTitle: 'Global shipping stocks rise despite broader market weakness' },
{ primaryTitle: 'Red Sea shipping disruption worsens after new attacks' },
{ primaryTitle: 'Freight rates react to Red Sea rerouting' },
]};
attachNewsContext(preds, news);
assert.ok(preds[0].newsContext[0].includes('Red Sea'));
assert.ok(preds[0].newsContext.every(h => /Red Sea|rerouting/i.test(h)));
});
it('rejects domain-only headlines with no geographic grounding', () => {
const preds = [makePrediction('military', 'Northern Europe', 'Military posture escalation: Northern Europe', 0.6, 0.4, '7d', [])];
const news = { topStories: [
{ primaryTitle: 'Kenya minister flies to Russia to halt illegal army hiring' },
{ primaryTitle: 'Army reshuffle rattles coalition government in Nairobi' },
]};
attachNewsContext(preds, news);
assert.deepEqual(preds[0].newsContext, []);
});
});
describe('headline and market relevance helpers', () => {
it('scores region-specific headlines above generic domain headlines', () => {
const terms = ['Red Sea', 'Yemen'];
const specific = computeHeadlineRelevance('Red Sea shipping disruption worsens after new attacks', terms, 'supply_chain');
const generic = computeHeadlineRelevance('Global shipping shares rise in New York trading', terms, 'supply_chain');
assert.ok(specific > generic);
});
it('scores semantically aligned markets above broad regional ones', () => {
const pred = makePrediction('conflict', 'Middle East', 'Escalation risk: Iran', 0.7, 0.5, '7d', []);
const targeted = computeMarketMatchScore(pred, 'Will Iran conflict escalate before July?', ['Iran', 'Middle East']);
const broad = computeMarketMatchScore(pred, 'Will Netanyahu remain prime minister through 2026?', ['Iran', 'Middle East']);
assert.ok(targeted.score > broad.score);
});
it('penalizes mismatched regional headlines and markets', () => {
const terms = ['Northern Europe', 'Baltic'];
const headlineScore = computeHeadlineRelevance(
'Kenya minister flies to Russia to halt illegal army hiring',
terms,
'military',
{ region: 'Northern Europe', requireRegion: true, requireSemantic: true },
);
assert.equal(headlineScore, 0);
const pred = makePrediction('market', 'Middle East', 'Oil price impact from Strait of Hormuz disruption', 0.66, 0.5, '30d', []);
const market = computeMarketMatchScore(
pred,
'Will Israel launch a major ground offensive in Lebanon by March 31?',
['Middle East', 'Strait of Hormuz', 'Iran'],
);
assert.ok(market.score < 7);
});
});
describe('forecast case assembly', () => {
it('buildForecastCase assembles evidence, triggers, and actors from current forecast data', () => {
const pred = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.42, '7d', [
{ type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 },
{ type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 },
]);
pred.newsContext = ['Iran military drills intensify after border incident'];
pred.calibration = { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.58, drift: 0.12, source: 'polymarket' };
pred.cascades = [{ domain: 'market', effect: 'commodity price shock', probability: 0.41 }];
pred.trend = 'falling';
pred.priorProbability = 0.78;
const caseFile = buildForecastCase(pred);
assert.ok(caseFile.supportingEvidence.some(item => item.type === 'cii'));
assert.ok(caseFile.supportingEvidence.some(item => item.type === 'headline'));
assert.ok(caseFile.supportingEvidence.some(item => item.type === 'market_calibration'));
assert.ok(caseFile.supportingEvidence.some(item => item.type === 'cascade'));
assert.ok(caseFile.counterEvidence.length >= 1);
assert.ok(caseFile.triggers.length >= 1);
assert.ok(caseFile.actorLenses.length >= 1);
assert.ok(caseFile.actors.length >= 1);
assert.ok(caseFile.worldState.summary.includes('Iran'));
assert.ok(caseFile.worldState.activePressures.length >= 1);
assert.equal(caseFile.branches.length, 3);
});
it('buildForecastCases populates the case file for every forecast', () => {
const a = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.6, '7d', [
{ type: 'cii', value: 'Iran CII 87', weight: 0.4 },
]);
const b = makePrediction('market', 'Red Sea', 'Shipping price shock', 0.55, 0.5, '30d', [
{ type: 'chokepoint', value: 'Red Sea risk: high', weight: 0.5 },
]);
buildForecastCases([a, b]);
assert.ok(a.caseFile);
assert.ok(b.caseFile);
});
it('helper functions return structured case ingredients', () => {
const pred = makePrediction('supply_chain', 'Red Sea', 'Supply chain disruption: Red Sea', 0.64, 0.35, '7d', [
{ type: 'chokepoint', value: 'Red Sea disruption detected', weight: 0.5 },
{ type: 'gps_jamming', value: 'GPS interference near Red Sea', weight: 0.2 },
]);
pred.trend = 'rising';
pred.cascades = [{ domain: 'market', effect: 'supply shortage pricing', probability: 0.38 }];
const counter = buildCounterEvidence(pred);
const triggers = buildCaseTriggers(pred);
const structuredActors = buildForecastActors(pred);
const worldState = buildForecastWorldState(pred, structuredActors, triggers, counter);
const branches = buildForecastBranches(pred, {
actors: structuredActors,
triggers,
counterEvidence: counter,
worldState,
});
const actorLenses = buildActorLenses(pred);
assert.ok(Array.isArray(counter));
assert.ok(triggers.length >= 1);
assert.ok(structuredActors.length >= 1);
assert.ok(worldState.summary.includes('Red Sea'));
assert.ok(worldState.activePressures.length >= 1);
assert.equal(branches.length, 3);
assert.ok(branches[0].rounds.length >= 3);
assert.ok(actorLenses.length >= 1);
});
});
describe('forecast evaluation and ranking', () => {
it('scores evidence-rich forecasts above thin forecasts', () => {
const rich = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.62, '7d', [
{ type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 },
{ type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 },
{ type: 'theater', value: 'Middle East theater posture elevated', weight: 0.2 },
]);
rich.newsContext = ['Iran military drills intensify after border incident'];
rich.calibration = { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.58, drift: 0.04, source: 'polymarket' };
rich.cascades = [{ domain: 'market', effect: 'commodity price shock', probability: 0.41 }];
rich.trend = 'rising';
buildForecastCase(rich);
const thin = makePrediction('market', 'Europe', 'Energy stress: Europe', 0.7, 0.62, '7d', [
{ type: 'prediction_market', value: 'Broad market stress chatter', weight: 0.2 },
]);
thin.trend = 'stable';
buildForecastCase(thin);
const richScore = scoreForecastReadiness(rich);
const thinScore = scoreForecastReadiness(thin);
assert.ok(richScore.overall > thinScore.overall);
assert.ok(richScore.groundingScore > thinScore.groundingScore);
});
it('uses readiness to rank better-grounded forecasts ahead of thinner peers', () => {
const rich = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.66, 0.58, '7d', [
{ type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 },
{ type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 },
]);
rich.newsContext = ['Iran military drills intensify after border incident'];
rich.calibration = { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.57, drift: 0.03, source: 'polymarket' };
rich.trend = 'rising';
buildForecastCase(rich);
const thin = makePrediction('market', 'Europe', 'Energy stress: Europe', 0.69, 0.58, '7d', [
{ type: 'prediction_market', value: 'Broad market stress chatter', weight: 0.2 },
]);
thin.trend = 'stable';
buildForecastCase(thin);
assert.ok(computeAnalysisPriority(rich) > computeAnalysisPriority(thin));
const ranked = [thin, rich];
rankForecastsForAnalysis(ranked);
assert.equal(ranked[0].title, rich.title);
});
it('penalizes thin forecasts with weak grounding even at similar base probability', () => {
const grounded = makePrediction('political', 'France', 'Political instability: France', 0.64, 0.57, '7d', [
{ type: 'unrest', value: 'France protest intensity remains elevated', weight: 0.3 },
{ type: 'cii', value: 'France institutional stress index 68', weight: 0.25 },
]);
grounded.newsContext = ['French unions warn of a broader escalation in strikes'];
grounded.trend = 'rising';
buildForecastCase(grounded);
const thin = makePrediction('conflict', 'Brazil', 'Active armed conflict: Brazil', 0.65, 0.57, '7d', [
{ type: 'conflict_events', value: 'Localized violence persists', weight: 0.15 },
]);
thin.trend = 'stable';
buildForecastCase(thin);
assert.ok(computeAnalysisPriority(grounded) > computeAnalysisPriority(thin));
});
it('filters non-positive forecasts before publish while keeping positive probabilities', () => {
const dropped = makePrediction('market', 'Red Sea', 'Shipping/Oil price impact from Suez Canal disruption', 0, 0.58, '30d', []);
const kept = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.12, 0.58, '7d', []);
const ranked = [dropped, kept];
const published = filterPublishedForecasts(ranked);
assert.equal(published.length, 1);
assert.equal(published[0].id, kept.id);
});
it('selects enrichment targets from a broader, domain-balanced top slice', () => {
const conflictA = makePrediction('conflict', 'Iran', 'Conflict A', 0.72, 0.61, '7d', [
{ type: 'cii', value: 'Iran CII 87', weight: 0.4 },
{ type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 },
]);
conflictA.newsContext = ['Iran military drills intensify after border incident'];
conflictA.trend = 'rising';
buildForecastCase(conflictA);
const conflictB = makePrediction('conflict', 'Israel', 'Conflict B', 0.71, 0.6, '7d', [
{ type: 'ucdp', value: '4 UCDP conflict events', weight: 0.35 },
{ type: 'theater', value: 'Eastern Mediterranean posture elevated', weight: 0.25 },
]);
conflictB.newsContext = ['Regional officials warn of retaliation risk'];
conflictB.trend = 'rising';
buildForecastCase(conflictB);
const conflictC = makePrediction('conflict', 'Mexico', 'Conflict C', 0.7, 0.59, '7d', [
{ type: 'conflict_events', value: 'Violence persists across multiple states', weight: 0.2 },
]);
conflictC.trend = 'stable';
buildForecastCase(conflictC);
const cyberA = makePrediction('cyber', 'China', 'Cyber A', 0.69, 0.58, '7d', [
{ type: 'cyber', value: 'Hostile malware hosting remains elevated', weight: 0.4 },
{ type: 'news_corroboration', value: 'Security firms warn of sustained activity', weight: 0.2 },
]);
cyberA.newsContext = ['Security researchers warn of renewed malware coordination'];
cyberA.trend = 'rising';
buildForecastCase(cyberA);
const cyberB = makePrediction('cyber', 'Russia', 'Cyber B', 0.67, 0.56, '7d', [
{ type: 'cyber', value: 'C2 server concentration remains high', weight: 0.35 },
{ type: 'news_corroboration', value: 'Government agencies issue new advisories', weight: 0.2 },
]);
cyberB.newsContext = ['Authorities publish a fresh advisory on state-linked activity'];
cyberB.trend = 'rising';
buildForecastCase(cyberB);
const supplyChain = makePrediction('supply_chain', 'Red Sea', 'Shipping disruption: Red Sea', 0.68, 0.59, '7d', [
{ type: 'chokepoint', value: 'Red Sea disruption detected', weight: 0.5 },
{ type: 'gps_jamming', value: 'GPS interference near Red Sea', weight: 0.2 },
]);
supplyChain.newsContext = ['Freight rates react to Red Sea rerouting'];
supplyChain.trend = 'rising';
buildForecastCase(supplyChain);
const market = makePrediction('market', 'Middle East', 'Oil price impact from Strait of Hormuz disruption', 0.73, 0.58, '30d', [
{ type: 'chokepoint', value: 'Hormuz transit risk rises', weight: 0.5 },
{ type: 'prediction_market', value: 'Oil breakout chatter increases', weight: 0.2 },
]);
market.newsContext = ['Analysts warn of renewed stress in the Strait of Hormuz'];
market.calibration = { marketTitle: 'Will oil close above $90?', marketPrice: 0.65, drift: 0.05, source: 'polymarket' };
market.trend = 'rising';
buildForecastCase(market);
const selected = selectForecastsForEnrichment([
conflictA,
conflictB,
conflictC,
cyberA,
cyberB,
supplyChain,
market,
]);
const enriched = [...selected.combined, ...selected.scenarioOnly];
assert.equal(enriched.length, 6);
assert.ok(enriched.some(pred => pred.domain === 'supply_chain'));
assert.ok(enriched.some(pred => pred.domain === 'market'));
assert.ok(enriched.filter(pred => pred.domain === 'conflict').length <= 2);
assert.ok(enriched.filter(pred => pred.domain === 'cyber').length <= 2);
});
});
describe('forecast change tracking', () => {
it('builds prior snapshots with enough context for evidence diffs', () => {
const pred = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.6, '7d', [
{ type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 },
]);
pred.newsContext = ['Iran military drills intensify after border incident'];
pred.calibration = { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.58, drift: 0.04, source: 'polymarket' };
const snapshot = buildPriorForecastSnapshot(pred);
assert.equal(snapshot.id, pred.id);
assert.deepEqual(snapshot.signals, ['Iran CII 87 (critical)']);
assert.deepEqual(snapshot.newsContext, ['Iran military drills intensify after border incident']);
assert.equal(snapshot.calibration.marketTitle, 'Will Iran conflict escalate before July?');
});
it('buildPublishedSeedPayload strips simulation-only forecast bulk from the canonical payload', () => {
const pred = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.72, 0.6, '7d', [
{ type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 },
]);
buildForecastCase(pred);
pred.caseFile.situationContext = { id: 'sit-1', label: 'Iran conflict situation', forecastCount: 3 };
pred.caseFile.familyContext = { id: 'fam-1', label: 'War theater family', forecastCount: 6 };
pred.caseFile.worldState = {
...pred.caseFile.worldState,
situationId: 'sit-1',
familyId: 'fam-1',
familyLabel: 'War theater family',
simulationSummary: 'Heavy simulation linkage that should stay out of the canonical Redis payload.',
simulationPosture: 'escalatory',
simulationPostureScore: 0.88,
};
pred.readiness = { overall: 0.82, explanation: 'heavy' };
pred.analysisPriority = 42;
pred.traceMeta = { narrativeSource: 'llm_combined', llmProvider: 'openrouter' };
const slimForecast = buildPublishedForecastPayload(pred);
assert.equal(slimForecast.caseFile.worldState.summary, pred.caseFile.worldState.summary);
assert.equal(slimForecast.caseFile.worldState.situationId, undefined);
assert.equal(slimForecast.caseFile.situationContext, undefined);
assert.equal(slimForecast.caseFile.familyContext, undefined);
assert.equal(slimForecast.readiness, undefined);
assert.equal(slimForecast.analysisPriority, undefined);
assert.equal(slimForecast.traceMeta, undefined);
const payload = buildPublishedSeedPayload({ generatedAt: 123, predictions: [pred] });
assert.equal(payload.generatedAt, 123);
assert.equal(payload.predictions.length, 1);
assert.equal(payload.predictions[0].caseFile.worldState.familyId, undefined);
});
it('keeps full canonical narrative fields and emits separate compact summary fields for publish payloads', () => {
const pred = makePrediction('market', 'Strait of Hormuz', 'Energy repricing risk: Strait of Hormuz', 0.71, 0.64, '30d', [
{ type: 'shipping_cost_shock', value: 'Strait of Hormuz rerouting is keeping freight costs elevated.', weight: 0.38 },
]);
buildForecastCase(pred);
pred.scenario = 'Strait of Hormuz shipping disruption keeps freight and energy repricing active across the Gulf over the next 30d while LNG routes, tanker insurance costs, and importer hedging behavior continue to amplify the base path across multiple downstream markets and policy-sensitive sectors.';
pred.feedSummary = 'Strait of Hormuz disruption is still anchoring the main market path through higher freight, wider energy premia, and persistent rerouting pressure across Gulf-linked trade flows, even as participants avoid assuming a full corridor closure.';
const payload = buildPublishedForecastPayload(pred);
assert.ok(payload.scenario.length > 220);
assert.ok(payload.feedSummary.length > 220);
assert.ok(payload.scenarioShort.length < payload.scenario.length);
assert.ok(payload.feedSummaryShort.length < payload.feedSummary.length);
assert.match(payload.scenarioShort, /\.\.\.$/);
assert.match(payload.feedSummaryShort, /\.\.\.$/);
});
it('annotates what changed versus the prior run', () => {
const pred = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.72, 0.6, '7d', [
{ type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 },
{ type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 },
]);
pred.newsContext = [
'Iran military drills intensify after border incident',
'Regional officials warn of retaliation risk',
];
pred.calibration = { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.64, drift: 0.04, source: 'polymarket' };
buildForecastCase(pred);
const prior = {
predictions: [{
id: pred.id,
probability: 0.58,
signals: ['Iran CII 87 (critical)'],
newsContext: ['Iran military drills intensify after border incident'],
calibration: { marketTitle: 'Will Iran conflict escalate before July?', marketPrice: 0.53 },
}],
};
annotateForecastChanges([pred], prior);
assert.match(pred.caseFile.changeSummary, /Probability rose from 58% to 72%/);
assert.ok(pred.caseFile.changeItems.some(item => item.includes('New signal: 3 UCDP conflict events')));
assert.ok(pred.caseFile.changeItems.some(item => item.includes('New reporting: Regional officials warn of retaliation risk')));
assert.ok(pred.caseFile.changeItems.some(item => item.includes('Market moved from 53% to 64%')));
});
it('marks newly surfaced forecasts clearly', () => {
const pred = makePrediction('market', 'Europe', 'Energy stress: Europe', 0.55, 0.5, '30d', [
{ type: 'prediction_market', value: 'Broad market stress chatter', weight: 0.2 },
]);
buildForecastCase(pred);
const items = buildChangeItems(pred, null);
const summary = buildChangeSummary(pred, null, items);
assert.match(summary, /new in the current run/i);
assert.ok(items[0].includes('New forecast surfaced'));
});
});
describe('forecast llm overrides', () => {
it('parses provider order safely', () => {
assert.equal(parseForecastProviderOrder(''), null);
assert.deepEqual(parseForecastProviderOrder('openrouter, groq, openrouter, invalid'), ['openrouter', 'groq']);
});
it('keeps default provider order when no override is set', () => {
delete process.env.FORECAST_LLM_PROVIDER_ORDER;
delete process.env.FORECAST_LLM_COMBINED_PROVIDER_ORDER;
delete process.env.FORECAST_LLM_MODEL_OPENROUTER;
delete process.env.FORECAST_LLM_COMBINED_MODEL_OPENROUTER;
const options = getForecastLlmCallOptions('combined');
const providers = resolveForecastLlmProviders(options);
assert.deepEqual(options.providerOrder, ['groq', 'openrouter']);
assert.equal(providers[0]?.name, 'groq');
assert.equal(providers[0]?.model, 'llama-3.1-8b-instant');
assert.equal(providers[1]?.name, 'openrouter');
assert.equal(providers[1]?.model, 'google/gemini-2.5-flash');
});
it('supports a stronger combined-model override without changing scenario defaults', () => {
process.env.FORECAST_LLM_COMBINED_PROVIDER_ORDER = 'openrouter';
process.env.FORECAST_LLM_COMBINED_MODEL_OPENROUTER = 'google/gemini-2.5-pro';
const combinedOptions = getForecastLlmCallOptions('combined');
const combinedProviders = resolveForecastLlmProviders(combinedOptions);
const scenarioOptions = getForecastLlmCallOptions('scenario');
const scenarioProviders = resolveForecastLlmProviders(scenarioOptions);
assert.deepEqual(combinedOptions.providerOrder, ['openrouter']);
assert.equal(combinedProviders.length, 1);
assert.equal(combinedProviders[0]?.name, 'openrouter');
assert.equal(combinedProviders[0]?.model, 'google/gemini-2.5-pro');
assert.deepEqual(scenarioOptions.providerOrder, ['groq', 'openrouter']);
assert.equal(scenarioProviders[0]?.name, 'groq');
assert.equal(scenarioProviders[1]?.model, 'google/gemini-2.5-flash');
});
it('lets a global provider order and openrouter model apply to non-combined stages', () => {
process.env.FORECAST_LLM_PROVIDER_ORDER = 'openrouter';
process.env.FORECAST_LLM_MODEL_OPENROUTER = 'google/gemini-2.5-flash-lite-preview';
const options = getForecastLlmCallOptions('scenario');
const providers = resolveForecastLlmProviders(options);
assert.deepEqual(options.providerOrder, ['openrouter']);
assert.equal(providers.length, 1);
assert.equal(providers[0]?.name, 'openrouter');
assert.equal(providers[0]?.model, 'google/gemini-2.5-flash-lite-preview');
});
it('recovers impact expansion output after an initial invalid parse', async () => {
process.env.UPSTASH_REDIS_REST_URL = 'https://redis.example.test';
process.env.UPSTASH_REDIS_REST_TOKEN = 'test-token';
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const href = String(url);
if (href.includes('/get/')) {
return {
ok: false,
json: async () => ({}),
text: async () => '',
};
}
return {
ok: true,
json: async () => ({ result: null }),
text: async () => '',
};
};
const prediction = makePrediction('supply_chain', 'Strait of Hormuz', 'Shipping disruption: Strait of Hormuz', 0.68, 0.6, '7d', [
{ type: 'shipping_cost_shock', value: 'Shipping costs are rising around Strait of Hormuz rerouting.', weight: 0.5 },
{ type: 'energy_supply_shock', value: 'Energy transit pressure is building around Qatar LNG flows.', weight: 0.32 },
]);
prediction.newsContext = ['Tanker rerouting is amplifying LNG and freight pressure around the Gulf.'];
buildForecastCase(prediction);
populateFallbackNarratives([prediction]);
const baseState = buildForecastRunWorldState({
generatedAt: Date.parse('2026-03-23T10:00:00Z'),
predictions: [prediction],
});
const candidateStateId = baseState.stateUnits[0]?.id || 'state-0';
__setForecastLlmCallOverrideForTests(async (_systemPrompt, _userPrompt, options = {}) => {
if (options.stage === 'impact_expansion_single') {
return {
provider: 'test',
model: 'impact-model',
text: 'not valid json',
};
}
if (options.stage === 'impact_expansion_recovery') {
return {
provider: 'test',
model: 'impact-model',
text: JSON.stringify({
candidates: [
{
candidateIndex: 0,
candidateStateId,
directHypotheses: [
{
variableKey: 'route_disruption',
channel: 'shipping_cost_shock',
targetBucket: 'freight',
region: 'Strait of Hormuz',
macroRegion: 'EMEA',
countries: ['Qatar'],
assetsOrSectors: ['Shipping'],
commodity: 'lng',
dependsOnKey: '',
strength: 0.9,
confidence: 0.88,
analogTag: 'energy_corridor_blockage',
summary: 'Route disruption persists through the Strait of Hormuz corridor.',
evidenceRefs: ['E1', 'E2'],
},
],
secondOrderHypotheses: [],
thirdOrderHypotheses: [],
},
],
}),
};
}
return null;
});
try {
const bundle = await extractImpactExpansionBundle({
stateUnits: baseState.stateUnits,
worldSignals: baseState.worldSignals,
marketTransmission: baseState.marketTransmission,
marketState: baseState.marketState,
marketInputCoverage: baseState.marketInputCoverage,
});
assert.equal(bundle.source, 'live');
assert.equal(bundle.failureReason, '');
assert.equal(bundle.extractedCandidateCount, 1);
assert.equal(bundle.extractedHypothesisCount, 1);
assert.equal(bundle.parseMode, 'per_candidate');
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe('forecast narrative fallbacks', () => {
it('buildUserPrompt keeps headlines scoped to each prediction', () => {
const a = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.6, '7d', [
{ type: 'cii', value: 'Iran CII 87', weight: 0.4 },
]);
a.newsContext = ['Iran military drills intensify'];
a.projections = { h24: 0.6, d7: 0.7, d30: 0.5 };
buildForecastCase(a);
const b = makePrediction('market', 'Europe', 'Gas price shock in Europe', 0.55, 0.5, '30d', [
{ type: 'market', value: 'EU gas futures spike', weight: 0.3 },
]);
b.newsContext = ['European gas storage draw accelerates'];
b.projections = { h24: 0.5, d7: 0.55, d30: 0.6 };
buildForecastCase(b);
const prompt = buildUserPrompt([a, b]);
assert.match(prompt, /\[0\][\s\S]*Iran military drills intensify/);
assert.match(prompt, /\[1\][\s\S]*European gas storage draw accelerates/);
assert.ok(!prompt.includes('Current top headlines:'));
assert.match(prompt, /\[SUPPORTING_EVIDENCE\]/);
assert.match(prompt, /\[ACTORS\]/);
assert.match(prompt, /\[WORLD_STATE\]/);
assert.match(prompt, /\[SIMULATED_BRANCHES\]/);
});
it('populateFallbackNarratives fills missing scenario, perspectives, and case narratives', () => {
const pred = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.6, '7d', [
{ type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 },
{ type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 },
]);
pred.trend = 'rising';
populateFallbackNarratives([pred]);
assert.match(pred.scenario, /Iran CII 87|central path/i);
assert.ok(pred.perspectives?.strategic);
assert.ok(pred.perspectives?.regional);
assert.ok(pred.perspectives?.contrarian);
assert.ok(pred.caseFile?.baseCase);
assert.ok(pred.caseFile?.escalatoryCase);
assert.ok(pred.caseFile?.contrarianCase);
assert.equal(pred.caseFile?.branches?.length, 3);
assert.ok(pred.feedSummary);
});
it('fallback perspective references calibration when present', () => {
const pred = makePrediction('market', 'Middle East', 'Oil price impact', 0.65, 0.5, '30d', [
{ type: 'chokepoint', value: 'Hormuz disruption detected', weight: 0.5 },
]);
pred.calibration = { marketTitle: 'Will oil close above $90?', marketPrice: 0.62, drift: 0.03, source: 'polymarket' };
const perspectives = buildFallbackPerspectives(pred);
assert.match(perspectives.contrarian, /Will oil close above \$90/);
});
it('fallback scenario stays concise and evidence-led', () => {
const pred = makePrediction('infrastructure', 'France', 'Infrastructure cascade risk: France', 0.48, 0.4, '24h', [
{ type: 'outage', value: 'France major outage', weight: 0.4 },
]);
const scenario = buildFallbackScenario(pred);
assert.match(scenario, /France major outage/);
assert.ok(scenario.length <= 500);
});
it('fallback case narratives stay evidence-led and concise', () => {
const pred = makePrediction('infrastructure', 'France', 'Infrastructure cascade risk: France', 0.48, 0.4, '24h', [
{ type: 'outage', value: 'France major outage', weight: 0.4 },
]);
buildForecastCase(pred);
const baseCase = buildFallbackBaseCase(pred);
const escalatoryCase = buildFallbackEscalatoryCase(pred);
const contrarianCase = buildFallbackContrarianCase(pred);
assert.match(baseCase, /France major outage/);
assert.ok(escalatoryCase.length <= 500);
assert.ok(contrarianCase.length <= 500);
});
it('fallback narratives keep situation context without broader-cluster filler', () => {
const pred = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.63, 0.48, '7d', [
{ type: 'ucdp', value: '27 conflict events in Iran', weight: 0.5 },
]);
buildForecastCase(pred);
pred.caseFile.situationContext = {
id: 'sit-1',
label: 'Iran conflict pressure',
forecastCount: 4,
topSignals: [{ type: 'ucdp', count: 4 }],
};
pred.situationContext = pred.caseFile.situationContext;
const scenario = buildFallbackScenario(pred);
const baseCase = buildFallbackBaseCase(pred);
const summary = buildFeedSummary(pred);
assert.match(baseCase, /27 conflict events in Iran/i);
assert.ok(!scenario.match(/broader|cluster/i));
assert.ok(!summary.match(/broader|cluster/i));
});
it('buildFeedSummary preserves the full narrative without server-side clipping', () => {
const pred = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.6, '7d', [
{ type: 'cii', value: 'Iran CII 87 (critical)', weight: 0.4 },
{ type: 'ucdp', value: '3 UCDP conflict events', weight: 0.3 },
]);
buildForecastCase(pred);
pred.caseFile.baseCase = 'Iran CII 87 (critical) and 3 UCDP conflict events keep the base path elevated over the next 7d with persistent force pressure and increasingly visible cross-border signaling, while regional actors still avoid a decisive break into a wider confrontation.';
const summary = buildFeedSummary(pred);
assert.ok(summary.length > 180);
assert.ok(!summary.endsWith('...'));
assert.match(summary, /Iran CII 87/);
});
it('refreshPublishedNarratives preserves validated llm narratives and only fills gaps', () => {
const pred = makePrediction('market', 'Strait of Hormuz', 'Inflation and rates pressure from Strait of Hormuz maritime disruption state', 0.69, 0.64, '30d', [
{ type: 'shipping_cost_shock', value: 'Strait of Hormuz shipping costs remain elevated', weight: 0.42 },
]);
buildForecastCase(pred);
pred.traceMeta = { narrativeSource: 'llm_combined', llmProvider: 'openrouter' };
pred.caseFile.baseCase = 'LLM base case keeps Hormuz freight and energy repricing tied to persistent shipping disruption over the next 30d.';
pred.caseFile.escalatoryCase = 'LLM escalatory case sees a sharper repricing if maritime insurance and rerouting costs jump again.';
pred.caseFile.contrarianCase = 'LLM contrarian case assumes corridor access stabilizes before the freight shock spreads further.';
pred.scenario = 'LLM scenario keeps Hormuz inflation pressure elevated while the corridor remains contested.';
pred.feedSummary = '';
refreshPublishedNarratives([pred]);
assert.equal(pred.caseFile.baseCase, 'LLM base case keeps Hormuz freight and energy repricing tied to persistent shipping disruption over the next 30d.');
assert.equal(pred.caseFile.escalatoryCase, 'LLM escalatory case sees a sharper repricing if maritime insurance and rerouting costs jump again.');
assert.equal(pred.caseFile.contrarianCase, 'LLM contrarian case assumes corridor access stabilizes before the freight shock spreads further.');
assert.equal(pred.scenario, 'LLM scenario keeps Hormuz inflation pressure elevated while the corridor remains contested.');
assert.equal(pred.feedSummary, 'LLM base case keeps Hormuz freight and energy repricing tied to persistent shipping disruption over the next 30d.');
});
it('rebuilds deterministic feed summaries from enriched scenarios instead of leaving fallback phrasing in place', () => {
const pred = makePrediction('market', 'Strait of Hormuz', 'Energy repricing risk from Strait of Hormuz maritime disruption state', 0.68, 0.63, '30d', [
{ type: 'shipping_cost_shock', value: 'Hormuz freight costs remain elevated.', weight: 0.4 },
]);
buildForecastCase(pred);
pred.traceMeta = { narrativeSource: 'llm_scenario', llmProvider: 'openrouter' };
pred.caseFile.baseCase = buildFallbackBaseCase(pred);
pred.scenario = 'LLM scenario keeps Hormuz energy and freight stress elevated as the corridor stays contested and downstream importers continue to hedge against extended rerouting pressure.';
pred.feedSummary = buildFallbackBaseCase(pred);
refreshPublishedNarratives([pred]);
assert.equal(pred.feedSummary, pred.scenario);
assert.doesNotMatch(pred.feedSummary, /For now, the base case stays near/i);
});
});
describe('validateCaseNarratives', () => {
it('accepts valid case narratives', () => {
const pred = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.7, 0.6, '7d', [
{ type: 'cii', value: 'Iran CII 87', weight: 0.4 },
]);
const valid = validateCaseNarratives([{
index: 0,
baseCase: 'Iran CII 87 remains the main anchor for the base path in the next 7d.',
escalatoryCase: 'A further rise in Iran CII 87 and added conflict-event reporting would move risk materially higher.',
contrarianCase: 'If no new corroborating headlines appear, the current path would lose support and flatten out.',
}], [pred]);
assert.equal(valid.length, 1);
});
it('accepts partial case narratives when at least one branch is substantive', () => {
const pred = makePrediction('market', 'India', 'FX stress from India cyber pressure state', 0.68, 0.61, '30d', [
{ type: 'fx_stress', value: 'India cyber pressure state is keeping FX stress active', weight: 0.42 },
]);
const valid = validateCaseNarratives([{
index: 0,
baseCase: 'India cyber pressure state remains the clearest anchor for the current FX stress base case over the next 30d.',
}], [pred]);
assert.equal(valid.length, 1);
assert.match(valid[0].baseCase, /India cyber pressure state/);
assert.equal(valid[0].escalatoryCase, undefined);
});
});
describe('computeConfidence', () => {
it('higher source diversity = higher confidence', () => {
const p1 = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', [
{ type: 'cii', value: 'test', weight: 0.4 },
]);
const p2 = makePrediction('conflict', 'Iran', 'b', 0.5, 0, '7d', [
{ type: 'cii', value: 'test', weight: 0.4 },
{ type: 'theater', value: 'test', weight: 0.3 },
{ type: 'ucdp', value: 'test', weight: 0.2 },
]);
computeConfidence([p1, p2]);
assert.ok(p2.confidence > p1.confidence);
});
it('cii and cii_delta count as one source', () => {
const p = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', [
{ type: 'cii', value: 'test', weight: 0.4 },
{ type: 'cii_delta', value: 'test', weight: 0.2 },
]);
const pSingle = makePrediction('conflict', 'Iran', 'b', 0.5, 0, '7d', [
{ type: 'cii', value: 'test', weight: 0.4 },
]);
computeConfidence([p, pSingle]);
assert.equal(p.confidence, pSingle.confidence);
});
it('low calibration drift = higher confidence than high drift', () => {
const pLow = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', [
{ type: 'cii', value: 'test', weight: 0.4 },
]);
pLow.calibration = { marketTitle: 'test', marketPrice: 0.5, drift: 0.01, source: 'polymarket' };
const pHigh = makePrediction('conflict', 'Iran', 'b', 0.5, 0, '7d', [
{ type: 'cii', value: 'test', weight: 0.4 },
]);
pHigh.calibration = { marketTitle: 'test', marketPrice: 0.5, drift: 0.4, source: 'polymarket' };
computeConfidence([pLow, pHigh]);
assert.ok(pLow.confidence > pHigh.confidence);
});
it('high calibration drift = lower confidence', () => {
const p = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', [
{ type: 'cii', value: 'test', weight: 0.4 },
]);
p.calibration = { marketTitle: 'test', marketPrice: 0.5, drift: 0.4, source: 'polymarket' };
computeConfidence([p]);
assert.ok(p.confidence <= 0.5);
});
it('floors at 0.2', () => {
const p = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', []);
p.calibration = { marketTitle: 'test', marketPrice: 0.5, drift: 0.5, source: 'polymarket' };
computeConfidence([p]);
assert.ok(p.confidence >= 0.2);
});
});
describe('sanitizeForPrompt', () => {
it('strips HTML tags', () => {
assert.equal(sanitizeForPrompt('<script>alert("xss")</script>hello'), 'scriptalert("xss")/scripthello');
});
it('strips newlines', () => {
assert.equal(sanitizeForPrompt('line1\nline2\rline3'), 'line1 line2 line3');
});
it('truncates to 200 chars', () => {
const long = 'x'.repeat(300);
assert.equal(sanitizeForPrompt(long).length, 200);
});
it('handles null/undefined', () => {
assert.equal(sanitizeForPrompt(null), '');
assert.equal(sanitizeForPrompt(undefined), '');
});
});
describe('parseLLMScenarios', () => {
it('parses valid JSON array', () => {
const result = parseLLMScenarios('[{"index": 0, "scenario": "Test scenario"}]');
assert.equal(result.length, 1);
assert.equal(result[0].index, 0);
});
it('returns null for invalid JSON', () => {
assert.equal(parseLLMScenarios('not json at all'), null);
});
it('strips thinking tags before parsing', () => {
const result = parseLLMScenarios('<think>reasoning here</think>[{"index": 0, "scenario": "Test"}]');
assert.equal(result.length, 1);
});
it('repairs truncated JSON array', () => {
const result = parseLLMScenarios('[{"index": 0, "scenario": "Test scenario"');
assert.ok(result !== null);
assert.equal(result[0].index, 0);
});
it('extracts JSON from surrounding text', () => {
const result = parseLLMScenarios('Here is my analysis:\n[{"index": 0, "scenario": "Test"}]\nDone.');
assert.equal(result.length, 1);
});
it('extracts scenarios from fenced object wrappers', () => {
const result = parseLLMScenarios('```json\n{"scenarios":[{"index":0,"scenario":"Test scenario"}]}\n```');
assert.equal(result.length, 1);
assert.equal(result[0].index, 0);
});
});
describe('validateScenarios', () => {
const preds = [
makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [
{ type: 'cii', value: 'Iran CII 87 critical', weight: 0.4 },
]),
];
it('accepts scenario with signal reference', () => {
const scenarios = [{ index: 0, scenario: 'The Iran CII score of 87 indicates critical instability in the region, driven by ongoing military activity.' }];
const valid = validateScenarios(scenarios, preds);
assert.equal(valid.length, 1);
});
it('accepts scenario with headline reference', () => {
preds[0].newsContext = ['Iran military drills intensify after border incident'];
const scenarios = [{ index: 0, scenario: 'Iran military drills intensify after border incident, keeping escalation pressure elevated over the next 7d.' }];
const valid = validateScenarios(scenarios, preds);
assert.equal(valid.length, 1);
delete preds[0].newsContext;
});
it('accepts scenario with market cue and trigger reference', () => {
preds[0].calibration = { marketTitle: 'Will oil close above $90?', marketPrice: 0.62, drift: 0.03, source: 'polymarket' };
preds[0].caseFile = {
supportingEvidence: [],
counterEvidence: [],
triggers: ['A market repricing of 8-10 points would be a meaningful confirmation or rejection signal.'],
actorLenses: [],
baseCase: '',
escalatoryCase: '',
contrarianCase: '',
};
const scenarios = [{ index: 0, scenario: 'Will oil close above $90? remains a live market cue, and a market repricing of 8-10 points would confirm the current path.' }];
const valid = validateScenarios(scenarios, preds);
assert.equal(valid.length, 1);
delete preds[0].calibration;
delete preds[0].caseFile;
});
it('accepts scenario with state-label evidence for state-derived forecasts', () => {
preds[0].stateContext = {
id: 'state-india-fx',
label: 'India cyber pressure state',
sampleTitles: ['FX stress from India cyber pressure state'],
};
const scenarios = [{ index: 0, scenario: 'India cyber pressure state remains the clearest anchor for the current FX stress path over the next 30d.' }];
const valid = validateScenarios(scenarios, preds);
assert.equal(valid.length, 1);
delete preds[0].stateContext;
});
it('rejects scenario without any evidence reference', () => {
const scenarios = [{ index: 0, scenario: 'Tensions continue to rise in the region due to various geopolitical factors and ongoing disputes.' }];
const valid = validateScenarios(scenarios, preds);
assert.equal(valid.length, 0);
});
it('rejects too-short scenario', () => {
const scenarios = [{ index: 0, scenario: 'Short.' }];
const valid = validateScenarios(scenarios, preds);
assert.equal(valid.length, 0);
});
it('rejects out-of-bounds index', () => {
const scenarios = [{ index: 5, scenario: 'Iran CII 87 indicates critical instability in the region.' }];
const valid = validateScenarios(scenarios, preds);
assert.equal(valid.length, 0);
});
it('strips HTML from scenario', () => {
const scenarios = [{ index: 0, scenario: 'The Iran CII score of 87 <b>critical</b> indicates instability in the conflict zone region.' }];
const valid = validateScenarios(scenarios, preds);
assert.equal(valid.length, 1);
assert.ok(!valid[0].scenario.includes('<b>'));
});
it('handles null/non-array input', () => {
assert.deepEqual(validateScenarios(null, preds), []);
assert.deepEqual(validateScenarios('not array', preds), []);
});
});
// ── Phase 3 Tests ──────────────────────────────────────────
describe('computeProjections', () => {
it('anchors projection to timeHorizon', () => {
const p = makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', []);
computeProjections([p]);
assert.ok(p.projections);
// probability should equal the d7 projection (anchored to 7d)
assert.equal(p.projections.d7, p.probability);
});
it('different domains produce different curves', () => {
const conflict = makePrediction('conflict', 'A', 'a', 0.5, 0.5, '7d', []);
const infra = makePrediction('infrastructure', 'B', 'b', 0.5, 0.5, '24h', []);
computeProjections([conflict, infra]);
assert.notEqual(conflict.projections.d30, infra.projections.d30);
});
it('caps at 0.95', () => {
const p = makePrediction('conflict', 'Iran', 'test', 0.9, 0.5, '7d', []);
computeProjections([p]);
assert.ok(p.projections.h24 <= 0.95);
assert.ok(p.projections.d7 <= 0.95);
assert.ok(p.projections.d30 <= 0.95);
});
it('floors at 0.01', () => {
const p = makePrediction('infrastructure', 'A', 'test', 0.02, 0.5, '24h', []);
computeProjections([p]);
assert.ok(p.projections.d30 >= 0.01);
});
it('unknown domain defaults to multiplier 1', () => {
const p = makePrediction('unknown_domain', 'X', 'test', 0.5, 0.5, '7d', []);
computeProjections([p]);
assert.equal(p.projections.h24, 0.5);
assert.equal(p.projections.d7, 0.5);
assert.equal(p.projections.d30, 0.5);
});
});
describe('validatePerspectives', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [
{ type: 'cii', value: 'Iran CII 87', weight: 0.4 },
])];
it('accepts valid perspectives', () => {
const items = [{
index: 0,
strategic: 'The CII data shows critical instability with a score of 87 in the conflict region.',
regional: 'Regional actors face mounting pressure from the elevated CII threat level.',
contrarian: 'Despite CII readings, diplomatic channels remain open and could defuse tensions.',
}];
const valid = validatePerspectives(items, preds);
assert.equal(valid.length, 1);
});
it('rejects too-short perspectives', () => {
const items = [{ index: 0, strategic: 'Short.', regional: 'Also short.', contrarian: 'Nope.' }];
assert.equal(validatePerspectives(items, preds).length, 0);
});
it('strips HTML before length check', () => {
const items = [{
index: 0,
strategic: '<b><i><span>x</span></i></b>',
regional: 'Valid regional perspective with enough characters here.',
contrarian: 'Valid contrarian perspective with enough characters here.',
}];
assert.equal(validatePerspectives(items, preds).length, 0);
});
it('handles null input', () => {
assert.deepEqual(validatePerspectives(null, preds), []);
});
it('rejects out-of-bounds index', () => {
const items = [{
index: 5,
strategic: 'Valid strategic perspective with sufficient length.',
regional: 'Valid regional perspective with sufficient length too.',
contrarian: 'Valid contrarian perspective with sufficient length too.',
}];
assert.equal(validatePerspectives(items, preds).length, 0);
});
});
describe('loadCascadeRules', () => {
it('loads rules from JSON file', () => {
const rules = loadCascadeRules();
assert.ok(Array.isArray(rules));
assert.ok(rules.length >= 5);
});
it('each rule has required fields', () => {
const rules = loadCascadeRules();
for (const r of rules) {
assert.ok(r.from, 'missing from');
assert.ok(r.to, 'missing to');
assert.ok(typeof r.coupling === 'number', 'coupling must be number');
assert.ok(r.mechanism, 'missing mechanism');
}
});
it('includes new Phase 3 rules', () => {
const rules = loadCascadeRules();
const infraToSupply = rules.find(r => r.from === 'infrastructure' && r.to === 'supply_chain');
assert.ok(infraToSupply, 'infrastructure -> supply_chain rule missing');
assert.equal(infraToSupply.requiresSeverity, 'total');
});
});
describe('evaluateRuleConditions', () => {
it('requiresChokepoint passes for chokepoint region', () => {
const pred = makePrediction('conflict', 'Middle East', 'test', 0.5, 0.5, '7d', []);
assert.ok(evaluateRuleConditions({ requiresChokepoint: true }, pred));
});
it('requiresChokepoint fails for non-chokepoint region', () => {
const pred = makePrediction('conflict', 'Northern Europe', 'test', 0.5, 0.5, '7d', []);
assert.ok(!evaluateRuleConditions({ requiresChokepoint: true }, pred));
});
it('minProbability passes when above threshold', () => {
const pred = makePrediction('political', 'Iran', 'test', 0.7, 0.5, '7d', []);
assert.ok(evaluateRuleConditions({ minProbability: 0.6 }, pred));
});
it('minProbability fails when below threshold', () => {
const pred = makePrediction('political', 'Iran', 'test', 0.3, 0.5, '7d', []);
assert.ok(!evaluateRuleConditions({ minProbability: 0.6 }, pred));
});
it('requiresSeverity checks outage signal value', () => {
const pred = makePrediction('infrastructure', 'Iran', 'test', 0.5, 0.5, '24h', [
{ type: 'outage', value: 'Iran total outage', weight: 0.4 },
]);
assert.ok(evaluateRuleConditions({ requiresSeverity: 'total' }, pred));
});
it('requiresSeverity fails for non-matching severity', () => {
const pred = makePrediction('infrastructure', 'Iran', 'test', 0.5, 0.5, '24h', [
{ type: 'outage', value: 'Iran minor outage', weight: 0.4 },
]);
assert.ok(!evaluateRuleConditions({ requiresSeverity: 'total' }, pred));
});
});
// ── Phase 4 Tests ──────────────────────────────────────────
describe('normalizeChokepoints', () => {
it('maps v4 shape to v2 fields', () => {
const v4 = { chokepoints: [{ name: 'Suez Canal', disruptionScore: 75, status: 'yellow' }] };
const result = normalizeChokepoints(v4);
assert.equal(result.chokepoints[0].region, 'Suez Canal');
assert.equal(result.chokepoints[0].riskScore, 75);
assert.equal(result.chokepoints[0].riskLevel, 'high');
assert.equal(result.chokepoints[0].disrupted, false);
});
it('maps red status to critical + disrupted', () => {
const v4 = { chokepoints: [{ name: 'Hormuz', status: 'red' }] };
const result = normalizeChokepoints(v4);
assert.equal(result.chokepoints[0].riskLevel, 'critical');
assert.equal(result.chokepoints[0].disrupted, true);
});
it('handles null', () => {
assert.equal(normalizeChokepoints(null), null);
});
});
describe('normalizeGpsJamming', () => {
it('maps hexes to zones', () => {
const raw = { hexes: [{ lat: 35, lon: 30 }] };
const result = normalizeGpsJamming(raw);
assert.ok(result.zones);
assert.equal(result.zones[0].lat, 35);
});
it('preserves existing zones', () => {
const raw = { zones: [{ lat: 10, lon: 20 }] };
const result = normalizeGpsJamming(raw);
assert.equal(result.zones[0].lat, 10);
});
it('handles null', () => {
assert.equal(normalizeGpsJamming(null), null);
});
});
describe('detectUcdpConflictZones', () => {
it('generates prediction for 10+ events in one country', () => {
const events = Array.from({ length: 15 }, () => ({ country: 'Syria' }));
const result = detectUcdpConflictZones({ ucdpEvents: { events } });
assert.equal(result.length, 1);
assert.equal(result[0].domain, 'conflict');
assert.equal(result[0].region, 'Syria');
});
it('skips countries with < 10 events', () => {
const events = Array.from({ length: 5 }, () => ({ country: 'Jordan' }));
assert.equal(detectUcdpConflictZones({ ucdpEvents: { events } }).length, 0);
});
it('handles empty input', () => {
assert.equal(detectUcdpConflictZones({}).length, 0);
});
});
describe('detectCyberScenarios', () => {
it('generates prediction for 5+ threats in one country', () => {
const threats = Array.from({ length: 8 }, () => ({ country: 'US', type: 'malware' }));
const result = detectCyberScenarios({ cyberThreats: { threats } });
assert.equal(result.length, 1);
assert.equal(result[0].domain, 'cyber');
});
it('skips countries with < 5 threats', () => {
const threats = Array.from({ length: 3 }, () => ({ country: 'CH', type: 'phishing' }));
assert.equal(detectCyberScenarios({ cyberThreats: { threats } }).length, 0);
});
it('handles empty input', () => {
assert.equal(detectCyberScenarios({}).length, 0);
});
it('caps broad cyber output to the top-ranked countries', () => {
const threats = [];
for (let i = 0; i < 20; i++) {
const country = `Country-${i}`;
for (let j = 0; j < 5; j++) threats.push({ country, type: 'phishing' });
}
const result = detectCyberScenarios({ cyberThreats: { threats } });
assert.equal(result.length, 12);
});
});
describe('detectGpsJammingScenarios', () => {
it('generates prediction for hexes in maritime region', () => {
const zones = Array.from({ length: 5 }, () => ({ lat: 35, lon: 30 })); // Eastern Med
const result = detectGpsJammingScenarios({ gpsJamming: { zones } });
assert.equal(result.length, 1);
assert.equal(result[0].domain, 'supply_chain');
assert.equal(result[0].region, 'Eastern Mediterranean');
});
it('skips hexes outside maritime regions', () => {
const zones = [{ lat: 0, lon: 0 }, { lat: 1, lon: 1 }, { lat: 2, lon: 2 }];
assert.equal(detectGpsJammingScenarios({ gpsJamming: { zones } }).length, 0);
});
});
describe('detectFromPredictionMarkets', () => {
it('generates from 60-90% markets with region', () => {
const markets = { geopolitical: [{ title: 'Will Iran strike Israel?', yesPrice: 70, source: 'polymarket' }] };
const result = detectFromPredictionMarkets({ predictionMarkets: markets });
assert.equal(result.length, 1);
assert.equal(result[0].domain, 'conflict');
assert.equal(result[0].region, 'Middle East');
});
it('skips markets below 60%', () => {
const markets = { geopolitical: [{ title: 'Will US enter recession?', yesPrice: 30 }] };
assert.equal(detectFromPredictionMarkets({ predictionMarkets: markets }).length, 0);
});
it('caps at 5 predictions', () => {
const markets = { geopolitical: Array.from({ length: 10 }, (_, i) => ({
title: `Will Europe face crisis ${i}?`, yesPrice: 70,
})) };
assert.ok(detectFromPredictionMarkets({ predictionMarkets: markets }).length <= 5);
});
});
describe('lowered CII conflict threshold', () => {
it('CII score 67 (high level) now triggers conflict', () => {
const result = detectConflictScenarios({
ciiScores: { ciiScores: [{ region: 'IL', combinedScore: 67, trend: 'TREND_DIRECTION_STABLE', components: {} }] },
theaterPosture: { theaters: [] },
iranEvents: { events: [] },
ucdpEvents: { events: [] },
});
assert.ok(result.length >= 1, 'should trigger at score 67');
});
it('CII score 62 (elevated level) does NOT trigger conflict', () => {
const result = detectConflictScenarios({
ciiScores: { ciiScores: [{ region: 'JO', combinedScore: 62, trend: 'TREND_DIRECTION_RISING', components: {} }] },
theaterPosture: { theaters: [] },
iranEvents: { events: [] },
ucdpEvents: { events: [] },
});
assert.equal(result.length, 0, 'should NOT trigger at score 62 (elevated)');
});
});
describe('loadEntityGraph', () => {
it('loads graph from JSON', () => {
const graph = loadEntityGraph();
assert.ok(graph.nodes);
assert.ok(graph.aliases);
assert.ok(graph.edges);
assert.ok(Object.keys(graph.nodes).length > 10);
});
it('aliases resolve country codes', () => {
const graph = loadEntityGraph();
assert.equal(graph.aliases['IR'], 'IR');
assert.equal(graph.aliases['Iran'], 'IR');
assert.equal(graph.aliases['Middle East'], 'middle-east');
});
});
describe('discoverGraphCascades', () => {
it('finds linked predictions via graph', () => {
const graph = loadEntityGraph();
const preds = [
makePrediction('conflict', 'IR', 'Iran conflict', 0.6, 0.5, '7d', []),
makePrediction('market', 'Middle East', 'Oil impact', 0.4, 0.5, '30d', []),
];
discoverGraphCascades(preds, graph);
// IR links to middle-east theater, which has Oil impact prediction
const irCascades = preds[0].cascades.filter(c => c.effect.includes('graph:'));
assert.ok(irCascades.length > 0 || preds[1].cascades.length > 0, 'should find graph cascade between Iran and Middle East');
});
it('skips same-domain predictions', () => {
const graph = loadEntityGraph();
const preds = [
makePrediction('conflict', 'IR', 'a', 0.6, 0.5, '7d', []),
makePrediction('conflict', 'Middle East', 'b', 0.5, 0.5, '7d', []),
];
discoverGraphCascades(preds, graph);
const graphCascades = preds[0].cascades.filter(c => c.effect.includes('graph:'));
assert.equal(graphCascades.length, 0, 'same domain should not cascade');
});
});
describe('forecast quality gating', () => {
it('reserves scenario enrichment slots for scarce market and military forecasts', () => {
const predictions = [
makePrediction('cyber', 'A', 'Cyber A', 0.7, 0.55, '7d', [{ type: 'cyber', value: '8 threats', weight: 0.5 }]),
makePrediction('cyber', 'B', 'Cyber B', 0.68, 0.55, '7d', [{ type: 'cyber', value: '7 threats', weight: 0.5 }]),
makePrediction('conflict', 'C', 'Conflict C', 0.66, 0.6, '7d', [{ type: 'ucdp', value: '12 events', weight: 0.5 }]),
makePrediction('market', 'Middle East', 'Oil price impact', 0.4, 0.5, '30d', [{ type: 'news_corroboration', value: 'Oil traders react', weight: 0.3 }]),
makePrediction('military', 'Korean Peninsula', 'Elevated military air activity', 0.34, 0.5, '7d', [{ type: 'mil_surge', value: 'fighter surge', weight: 0.4 }]),
];
buildForecastCases(predictions);
const selected = selectForecastsForEnrichment(predictions, { maxCombined: 2, maxScenario: 2, maxPerDomain: 2, minReadiness: 0 });
assert.equal(selected.combined.length, 2);
assert.equal(selected.scenarioOnly.length, 2);
assert.ok(selected.scenarioOnly.some(item => item.domain === 'market'));
assert.ok(selected.scenarioOnly.some(item => item.domain === 'military'));
assert.deepEqual(selected.telemetry.reservedScenarioDomains.sort(), ['market', 'military']);
});
it('filters only the weakest fallback forecasts from publish output', () => {
const weak = makePrediction('cyber', 'Thinland', 'Cyber threat concentration: Thinland', 0.11, 0.32, '7d', [
{ type: 'cyber', value: '5 threats (phishing)', weight: 0.5 },
]);
buildForecastCases([weak]);
weak.traceMeta = { narrativeSource: 'fallback' };
weak.readiness = { overall: 0.28 };
weak.analysisPriority = 0.05;
const strong = makePrediction('market', 'Middle East', 'Oil price impact from Strait of Hormuz disruption', 0.22, 0.48, '7d', [
{ type: 'news_corroboration', value: 'Oil prices moved on shipping risk', weight: 0.4 },
]);
buildForecastCases([strong]);
strong.traceMeta = { narrativeSource: 'fallback' };
strong.readiness = { overall: 0.52 };
strong.analysisPriority = 0.11;
const published = filterPublishedForecasts([weak, strong]);
assert.equal(published.length, 1);
assert.equal(published[0].id, strong.id);
});
it('suppresses weaker duplicate-like conflict forecasts while preserving distinct consequences', () => {
const primary = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.64, 0.58, '7d', [
{ type: 'ucdp', value: '27 conflict events in Iran', weight: 0.5 },
{ type: 'news_corroboration', value: 'Iran strike exchange intensifies', weight: 0.3 },
]);
const duplicate = makePrediction('conflict', 'Iran', 'Active armed conflict: Iran', 0.52, 0.42, '7d', [
{ type: 'ucdp', value: '27 conflict events in Iran', weight: 0.5 },
{ type: 'news_corroboration', value: 'Iran strike exchange intensifies', weight: 0.3 },
]);
const consequence = makePrediction('market', 'Middle East', 'Oil price impact from Strait of Hormuz disruption', 0.41, 0.51, '30d', [
{ type: 'news_corroboration', value: 'Oil traders react to Hormuz risk', weight: 0.4 },
]);
const distinctConflict = makePrediction('conflict', 'Gulf', 'Spillover conflict risk: Gulf shipping corridor', 0.47, 0.53, '14d', [
{ type: 'news_corroboration', value: 'Gulf states prepare for possible spillover', weight: 0.35 },
]);
buildForecastCases([primary, duplicate, consequence, distinctConflict]);
for (const pred of [primary, duplicate, consequence, distinctConflict]) {
pred.traceMeta = { narrativeSource: 'fallback' };
}
primary.caseFile.situationContext = { id: 'sit-1', label: 'Iran conflict pressure', forecastCount: 3, topSignals: [{ type: 'ucdp', count: 2 }] };
duplicate.caseFile.situationContext = { id: 'sit-1', label: 'Iran conflict pressure', forecastCount: 3, topSignals: [{ type: 'ucdp', count: 2 }] };
consequence.caseFile.situationContext = { id: 'sit-1', label: 'Iran conflict pressure', forecastCount: 3, topSignals: [{ type: 'ucdp', count: 2 }] };
distinctConflict.caseFile.situationContext = { id: 'sit-2', label: 'Gulf spillover pressure', forecastCount: 1, topSignals: [{ type: 'news_corroboration', count: 1 }] };
primary.situationContext = primary.caseFile.situationContext;
duplicate.situationContext = duplicate.caseFile.situationContext;
consequence.situationContext = consequence.caseFile.situationContext;
distinctConflict.situationContext = distinctConflict.caseFile.situationContext;
primary.readiness = { overall: 0.63 };
duplicate.readiness = { overall: 0.44 };
consequence.readiness = { overall: 0.54 };
distinctConflict.readiness = { overall: 0.49 };
primary.analysisPriority = 0.19;
duplicate.analysisPriority = 0.09;
consequence.analysisPriority = 0.12;
distinctConflict.analysisPriority = 0.11;
const published = filterPublishedForecasts([primary, duplicate, consequence, distinctConflict]);
assert.equal(published.length, 3);
assert.ok(published.some((item) => item.id === primary.id));
assert.ok(!published.some((item) => item.id === duplicate.id));
assert.ok(published.some((item) => item.id === consequence.id));
assert.ok(published.some((item) => item.id === distinctConflict.id));
const telemetry = summarizePublishFiltering([primary, duplicate, consequence, distinctConflict]);
assert.equal(telemetry.suppressedSituationOverlap, 1);
});
it('caps dominant same-domain situation output while preserving cross-domain consequences', () => {
const conflictA = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.66, 0.61, '7d', [
{ type: 'ucdp', value: '27 conflict events in Iran', weight: 0.5 },
]);
const conflictB = makePrediction('conflict', 'Gulf', 'Spillover conflict risk: Gulf shipping corridor', 0.61, 0.57, '7d', [
{ type: 'news_corroboration', value: 'Gulf states prepare for spillover', weight: 0.45 },
]);
const conflictC = makePrediction('conflict', 'Israel', 'Retaliatory conflict risk: Israel', 0.58, 0.53, '14d', [
{ type: 'news_corroboration', value: 'Retaliatory pressure remains elevated around Israel', weight: 0.42 },
]);
const consequence = makePrediction('market', 'Middle East', 'Oil price impact from Strait of Hormuz disruption', 0.49, 0.55, '30d', [
{ type: 'news_corroboration', value: 'Oil traders react to Hormuz risk', weight: 0.4 },
]);
buildForecastCases([conflictA, conflictB, conflictC, consequence]);
for (const pred of [conflictA, conflictB, conflictC, consequence]) {
pred.traceMeta = { narrativeSource: 'fallback' };
pred.situationContext = {
id: 'sit-iran',
label: 'Iran conflict and market situation',
forecastCount: 4,
topSignals: [{ type: 'ucdp', count: 3 }],
};
pred.caseFile.situationContext = pred.situationContext;
}
conflictA.readiness = { overall: 0.64 };
conflictB.readiness = { overall: 0.59 };
conflictC.readiness = { overall: 0.51 };
consequence.readiness = { overall: 0.56 };
conflictA.analysisPriority = 0.22;
conflictB.analysisPriority = 0.19;
conflictC.analysisPriority = 0.15;
consequence.analysisPriority = 0.17;
const published = filterPublishedForecasts([conflictA, conflictB, conflictC, consequence]);
assert.equal(published.length, 3);
assert.ok(published.some((item) => item.id === consequence.id));
assert.ok(!published.some((item) => item.id === conflictC.id));
const telemetry = summarizePublishFiltering([conflictA, conflictB, conflictC, consequence]);
assert.equal(telemetry.suppressedSituationDomainCap, 1);
assert.equal(telemetry.cappedSituations, 0);
});
it('does not suppress same-domain forecasts as duplicates when they belong to different situation families', () => {
const iranConflict = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.67, 0.61, '7d', [
{ type: 'ucdp', value: 'Iran conflict intensity remains elevated', weight: 0.45 },
]);
const brazilConflict = makePrediction('conflict', 'Brazil', 'Escalation risk: Brazil', 0.62, 0.58, '7d', [
{ type: 'ucdp', value: 'Brazil conflict intensity remains elevated', weight: 0.42 },
]);
buildForecastCases([iranConflict, brazilConflict]);
for (const pred of [iranConflict, brazilConflict]) {
pred.traceMeta = { narrativeSource: 'fallback' };
pred.readiness = { overall: 0.58 };
pred.analysisPriority = 0.16;
}
iranConflict.situationContext = { id: 'sit-iran', label: 'Iran conflict situation', forecastCount: 1, topSignals: [{ type: 'ucdp', count: 1 }] };
brazilConflict.situationContext = { id: 'sit-brazil', label: 'Brazil conflict situation', forecastCount: 1, topSignals: [{ type: 'ucdp', count: 1 }] };
iranConflict.caseFile.situationContext = iranConflict.situationContext;
brazilConflict.caseFile.situationContext = brazilConflict.situationContext;
iranConflict.familyContext = { id: 'fam-middle-east', label: 'Middle East conflict pressure family', situationCount: 1, forecastCount: 1 };
brazilConflict.familyContext = { id: 'fam-brazil', label: 'Brazil conflict pressure family', situationCount: 1, forecastCount: 1 };
iranConflict.caseFile.familyContext = iranConflict.familyContext;
brazilConflict.caseFile.familyContext = brazilConflict.familyContext;
const published = filterPublishedForecasts([iranConflict, brazilConflict]);
assert.equal(published.length, 2);
const telemetry = summarizePublishFiltering([iranConflict, brazilConflict]);
assert.equal(telemetry.suppressedSituationOverlap, 0);
});
it('caps dominant family output while preserving family diversity', () => {
const preds = [
makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.69, 0.62, '7d', [{ type: 'ucdp', value: 'Iran events remain elevated', weight: 0.4 }]),
makePrediction('political', 'Iran', 'Political instability: Iran', 0.56, 0.56, '14d', [{ type: 'news_corroboration', value: 'Emergency cabinet talks continue', weight: 0.35 }]),
makePrediction('market', 'Middle East', 'Oil price impact: Middle East', 0.53, 0.55, '30d', [{ type: 'prediction_market', value: 'Oil repricing persists', weight: 0.3 }]),
makePrediction('supply_chain', 'Persian Gulf', 'Shipping disruption: Persian Gulf', 0.51, 0.54, '14d', [{ type: 'chokepoint', value: 'Shipping reroutes persist', weight: 0.35 }]),
makePrediction('infrastructure', 'Iran', 'Infrastructure strain: Iran', 0.49, 0.53, '14d', [{ type: 'news_corroboration', value: 'Grid strain and outages remain elevated', weight: 0.32 }]),
makePrediction('conflict', 'Brazil', 'Escalation risk: Brazil', 0.63, 0.58, '7d', [{ type: 'ucdp', value: 'Brazil conflict remains active', weight: 0.42 }]),
];
buildForecastCases(preds);
for (const [index, pred] of preds.entries()) {
pred.traceMeta = { narrativeSource: 'fallback' };
pred.readiness = { overall: 0.68 - (index * 0.03) };
pred.analysisPriority = 0.24 - (index * 0.02);
}
const familyA = {
id: 'fam-middle-east',
label: 'Middle East pressure family',
situationCount: 5,
forecastCount: 5,
situationIds: ['sit-iran-conflict', 'sit-iran-political', 'sit-middleeast-market', 'sit-gulf-shipping', 'sit-iran-infra'],
};
const familyB = {
id: 'fam-brazil',
label: 'Brazil pressure family',
situationCount: 1,
forecastCount: 1,
situationIds: ['sit-brazil-conflict'],
};
preds[0].situationContext = { id: 'sit-iran-conflict', label: 'Iran conflict situation', forecastCount: 1, topSignals: [{ type: 'ucdp', count: 1 }] };
preds[1].situationContext = { id: 'sit-iran-political', label: 'Iran political situation', forecastCount: 1, topSignals: [{ type: 'news_corroboration', count: 1 }] };
preds[2].situationContext = { id: 'sit-middleeast-market', label: 'Middle East market situation', forecastCount: 1, topSignals: [{ type: 'prediction_market', count: 1 }] };
preds[3].situationContext = { id: 'sit-gulf-shipping', label: 'Persian Gulf supply chain situation', forecastCount: 1, topSignals: [{ type: 'chokepoint', count: 1 }] };
preds[4].situationContext = { id: 'sit-iran-infra', label: 'Iran infrastructure situation', forecastCount: 1, topSignals: [{ type: 'news_corroboration', count: 1 }] };
preds[5].situationContext = { id: 'sit-brazil-conflict', label: 'Brazil conflict situation', forecastCount: 1, topSignals: [{ type: 'ucdp', count: 1 }] };
for (const pred of preds.slice(0, 5)) {
pred.familyContext = familyA;
pred.caseFile.situationContext = pred.situationContext;
pred.caseFile.familyContext = familyA;
}
preds[5].familyContext = familyB;
preds[5].caseFile.situationContext = preds[5].situationContext;
preds[5].caseFile.familyContext = familyB;
const published = applySituationFamilyCaps(preds, [familyA, familyB]);
assert.equal(published.length, 5);
assert.ok(published.some((item) => item.id === preds[5].id));
const telemetry = summarizePublishFiltering(preds);
assert.equal(telemetry.suppressedSituationFamilyCap, 1);
assert.equal(telemetry.cappedFamilies, 1);
});
it('preselects published forecasts across families before overlap suppression', () => {
const preds = [
makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.72, 0.65, '7d', [{ type: 'ucdp', value: 'Iran events elevated', weight: 0.4 }]),
makePrediction('political', 'Iran', 'Political instability: Iran', 0.58, 0.59, '14d', [{ type: 'news_corroboration', value: 'Emergency meetings continue', weight: 0.35 }]),
makePrediction('market', 'Middle East', 'Oil repricing risk: Gulf', 0.55, 0.57, '30d', [{ type: 'prediction_market', value: 'Oil reprices higher', weight: 0.3 }]),
makePrediction('supply_chain', 'Persian Gulf', 'Shipping disruption: Persian Gulf', 0.53, 0.56, '14d', [{ type: 'chokepoint', value: 'Routing delays persist', weight: 0.35 }]),
makePrediction('conflict', 'Ukraine', 'Escalation risk: Ukraine', 0.64, 0.61, '7d', [{ type: 'ucdp', value: 'Ukraine conflict remains active', weight: 0.42 }]),
makePrediction('market', 'Black Sea', 'Grain pricing pressure: Black Sea', 0.5, 0.54, '30d', [{ type: 'prediction_market', value: 'Grain risk premium widens', weight: 0.28 }]),
];
buildForecastCases(preds);
for (const [index, pred] of preds.entries()) {
pred.traceMeta = { narrativeSource: index < 2 ? 'llm_combined' : 'fallback' };
pred.readiness = { overall: 0.7 - (index * 0.04) };
pred.analysisPriority = 0.24 - (index * 0.02);
}
const familyA = { id: 'fam-middle-east', label: 'Middle East pressure family', forecastCount: 4, situationCount: 4, situationIds: ['sit-iran-conflict', 'sit-iran-political', 'sit-gulf-market', 'sit-gulf-shipping'] };
const familyB = { id: 'fam-black-sea', label: 'Black Sea pressure family', forecastCount: 2, situationCount: 2, situationIds: ['sit-ukraine-conflict', 'sit-blacksea-market'] };
const contexts = [
['sit-iran-conflict', 'Iran conflict situation', familyA],
['sit-iran-political', 'Iran political situation', familyA],
['sit-gulf-market', 'Gulf market situation', familyA],
['sit-gulf-shipping', 'Persian Gulf shipping situation', familyA],
['sit-ukraine-conflict', 'Ukraine conflict situation', familyB],
['sit-blacksea-market', 'Black Sea market situation', familyB],
];
for (const [index, pred] of preds.entries()) {
const [id, label, family] = contexts[index];
pred.situationContext = { id, label, forecastCount: 1, topSignals: [{ type: 'news_corroboration', count: 1 }] };
pred.caseFile.situationContext = pred.situationContext;
pred.familyContext = family;
pred.caseFile.familyContext = family;
}
const selected = selectPublishedForecastPool(preds);
assert.ok(selected.some((pred) => pred.familyContext?.id === familyA.id));
assert.ok(selected.some((pred) => pred.familyContext?.id === familyB.id));
assert.ok(selected.some((pred) => pred.domain === 'market'));
assert.ok((selected.deferredCandidates || []).length >= 1);
});
it('backfills deferred forecasts when filtering drops a preselected duplicate', () => {
const primary = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.74, 0.66, '7d', [{ type: 'ucdp', value: 'Iran events elevated', weight: 0.4 }]);
const duplicate = makePrediction('conflict', 'Iran', 'Retaliatory conflict risk: Iran', 0.69, 0.58, '7d', [{ type: 'ucdp', value: 'Iran events elevated', weight: 0.36 }]);
const political = makePrediction('political', 'Iran', 'Political instability: Iran', 0.59, 0.57, '14d', [{ type: 'news_corroboration', value: 'Emergency cabinet meetings continue', weight: 0.35 }]);
const supply = makePrediction('supply_chain', 'Persian Gulf', 'Shipping disruption: Persian Gulf', 0.54, 0.56, '14d', [{ type: 'chokepoint', value: 'Routing delays persist', weight: 0.34 }]);
buildForecastCases([primary, duplicate, political, supply]);
const fullRunSituationClusters = [
{ id: 'sit-iran-conflict', label: 'Iran conflict situation', dominantRegion: 'Iran', dominantDomain: 'conflict', regions: ['Iran'], domains: ['conflict'], actors: ['Iran'], branchKinds: ['base'], forecastIds: [primary.id, duplicate.id], forecastCount: 2, avgProbability: 0.715, avgConfidence: 0.62, topSignals: [{ type: 'ucdp', count: 2 }], sampleTitles: [primary.title, duplicate.title] },
{ id: 'sit-iran-political', label: 'Iran political situation', dominantRegion: 'Iran', dominantDomain: 'political', regions: ['Iran'], domains: ['political'], actors: ['Iran'], branchKinds: ['base'], forecastIds: [political.id], forecastCount: 1, avgProbability: 0.59, avgConfidence: 0.57, topSignals: [{ type: 'news_corroboration', count: 1 }], sampleTitles: [political.title] },
{ id: 'sit-gulf-shipping', label: 'Persian Gulf shipping situation', dominantRegion: 'Persian Gulf', dominantDomain: 'supply_chain', regions: ['Persian Gulf'], domains: ['supply_chain'], actors: ['Shipping'], branchKinds: ['base'], forecastIds: [supply.id], forecastCount: 1, avgProbability: 0.54, avgConfidence: 0.56, topSignals: [{ type: 'chokepoint', count: 1 }], sampleTitles: [supply.title] },
];
const familyA = { id: 'fam-middle-east', label: 'Middle East pressure family', forecastCount: 3, situationCount: 2, situationIds: ['sit-iran-conflict', 'sit-iran-political'] };
const familyB = { id: 'fam-gulf', label: 'Persian Gulf pressure family', forecastCount: 1, situationCount: 1, situationIds: ['sit-gulf-shipping'] };
for (const pred of [primary, duplicate, political, supply]) {
pred.traceMeta = { narrativeSource: 'fallback' };
pred.readiness = { overall: 0.7 };
}
primary.analysisPriority = 0.25;
duplicate.analysisPriority = 0.2;
political.analysisPriority = 0.18;
supply.analysisPriority = 0.14;
primary.situationContext = fullRunSituationClusters[0];
duplicate.situationContext = fullRunSituationClusters[0];
political.situationContext = fullRunSituationClusters[1];
supply.situationContext = fullRunSituationClusters[2];
primary.caseFile.situationContext = primary.situationContext;
duplicate.caseFile.situationContext = duplicate.situationContext;
political.caseFile.situationContext = political.situationContext;
supply.caseFile.situationContext = supply.situationContext;
primary.familyContext = familyA;
duplicate.familyContext = familyA;
political.familyContext = familyA;
supply.familyContext = familyB;
primary.caseFile.familyContext = familyA;
duplicate.caseFile.familyContext = familyA;
political.caseFile.familyContext = familyA;
supply.caseFile.familyContext = familyB;
const pool = selectPublishedForecastPool([primary, duplicate, political], { targetCount: 3 });
assert.equal(pool.length, 3);
assert.equal(pool.deferredCandidates.length, 0);
const expandedPool = selectPublishedForecastPool([primary, duplicate, political, supply], { targetCount: 3 });
const candidatePool = [...expandedPool];
const deferred = [...expandedPool.deferredCandidates];
let artifacts = buildPublishedForecastArtifacts(candidatePool, fullRunSituationClusters);
while (artifacts.publishedPredictions.length < expandedPool.targetCount && deferred.length > 0) {
candidatePool.push(deferred.shift());
artifacts = buildPublishedForecastArtifacts(candidatePool, fullRunSituationClusters);
}
assert.equal(artifacts.publishedPredictions.length, 3);
assert.ok(artifacts.publishedPredictions.some((pred) => pred.id === supply.id));
assert.ok(!artifacts.publishedPredictions.some((pred) => pred.id === duplicate.id));
});
it('boosts memory-backed situations during publish selection', () => {
const persistent = makePrediction('political', 'Iran', 'Political pressure: Iran', 0.53, 0.5, '14d', [{ type: 'news_corroboration', value: 'Iran unrest persists', weight: 0.34 }]);
const fresh = makePrediction('political', 'India', 'Political pressure: India', 0.54, 0.5, '14d', [{ type: 'news_corroboration', value: 'India coalition talks continue', weight: 0.34 }]);
buildForecastCases([persistent, fresh]);
for (const pred of [persistent, fresh]) {
pred.traceMeta = { narrativeSource: 'fallback' };
pred.readiness = { overall: 0.55 };
pred.analysisPriority = 0.12;
}
persistent.situationContext = { id: 'sit-iran-political', label: 'Iran political situation', forecastCount: 1, topSignals: [{ type: 'news_corroboration', count: 1 }] };
fresh.situationContext = { id: 'sit-india-political', label: 'India political situation', forecastCount: 1, topSignals: [{ type: 'news_corroboration', count: 1 }] };
persistent.caseFile.situationContext = persistent.situationContext;
fresh.caseFile.situationContext = fresh.situationContext;
persistent.familyContext = { id: 'fam-mena-political', label: 'MENA political family', forecastCount: 1, situationCount: 1, situationIds: ['sit-iran-political'] };
fresh.familyContext = { id: 'fam-asia-political', label: 'Asia political family', forecastCount: 1, situationCount: 1, situationIds: ['sit-india-political'] };
persistent.caseFile.familyContext = persistent.familyContext;
fresh.caseFile.familyContext = fresh.familyContext;
const pool = selectPublishedForecastPool([fresh, persistent], {
targetCount: 1,
memoryIndex: {
bySituationLabel: new Map([['iran political situation', {
situationId: 'sit-iran-political',
label: 'Iran political situation',
dominantRegion: 'Iran',
dominantDomain: 'political',
pressureMemory: 0.82,
memoryDelta: 0.14,
}]]),
byRegionDomain: new Map(),
edgeCounts: new Map([['sit-iran-political', 2]]),
},
});
assert.equal(pool.length, 1);
assert.equal(pool[0].id, persistent.id);
assert.equal(pool[0].publishSelectionMemory?.matchedBy, 'label');
});
it('boosts market-confirmed situations during publish selection', () => {
const confirmed = makePrediction('market', 'Middle East', 'Oil repricing: Strait of Hormuz', 0.51, 0.48, '30d', [
{ type: 'prediction_market', value: 'Oil contracts reprice on Hormuz stress', weight: 0.3 },
]);
const unconfirmed = makePrediction('political', 'India', 'Political pressure: India', 0.54, 0.49, '14d', [
{ type: 'news_corroboration', value: 'Coalition bargaining remains active', weight: 0.32 },
]);
buildForecastCases([confirmed, unconfirmed]);
for (const pred of [confirmed, unconfirmed]) {
pred.traceMeta = { narrativeSource: 'fallback' };
pred.readiness = { overall: 0.55 };
pred.analysisPriority = 0.12;
pred.situationContext = { id: `sit-${pred.region}`, label: `${pred.region} situation`, forecastCount: 1, topSignals: [{ type: 'news_corroboration', count: 1 }] };
pred.familyContext = { id: `fam-${pred.region}`, label: `${pred.region} family`, forecastCount: 1, situationCount: 1, situationIds: [pred.situationContext.id] };
pred.caseFile.situationContext = pred.situationContext;
pred.caseFile.familyContext = pred.familyContext;
}
confirmed.marketSelectionContext = {
confirmationScore: 0.74,
contradictionScore: 0.04,
topBucketId: 'energy',
topBucketLabel: 'Energy',
topBucketPressure: 0.66,
transmissionEdgeCount: 2,
topTransmissionStrength: 0.63,
topTransmissionConfidence: 0.68,
consequenceSummary: 'Hormuz risk is transmitting into Energy.',
};
unconfirmed.marketSelectionContext = {
confirmationScore: 0.08,
contradictionScore: 0.22,
topBucketId: '',
topBucketLabel: '',
topBucketPressure: 0,
transmissionEdgeCount: 0,
topTransmissionStrength: 0,
topTransmissionConfidence: 0,
consequenceSummary: '',
};
const pool = selectPublishedForecastPool([unconfirmed, confirmed], { targetCount: 1 });
assert.equal(pool.length, 1);
assert.equal(pool[0].id, confirmed.id);
assert.ok((pool[0].publishSelectionMarket?.confirmationScore || 0) > 0.7);
});
it('keeps strategic supply-chain forecasts alive alongside same-state market repricing and reports survival telemetry', () => {
const market = makePrediction('market', 'Strait of Hormuz', 'Energy repricing risk: Strait of Hormuz', 0.66, 0.61, '30d', [
{ type: 'energy_supply_shock', value: 'Energy repricing persists around Hormuz shipping stress.', weight: 0.36 },
]);
const supply = makePrediction('supply_chain', 'Strait of Hormuz', 'Shipping disruption: Strait of Hormuz', 0.59, 0.57, '14d', [
{ type: 'shipping_cost_shock', value: 'Shipping reroutes persist through the Hormuz corridor.', weight: 0.35 },
]);
const conflict = makePrediction('conflict', 'Brazil', 'Escalation risk: Brazil', 0.67, 0.62, '7d', [
{ type: 'ucdp', value: 'Brazil conflict pressure remains active.', weight: 0.4 },
]);
buildForecastCases([market, supply, conflict]);
for (const [index, pred] of [market, supply, conflict].entries()) {
pred.traceMeta = { narrativeSource: 'fallback' };
pred.readiness = { overall: 0.7 - (index * 0.04) };
pred.analysisPriority = 0.26 - (index * 0.02);
}
const hormuzSituation = {
id: 'sit-hormuz',
label: 'Hormuz maritime disruption situation',
dominantRegion: 'Strait of Hormuz',
dominantDomain: 'market',
regions: ['Strait of Hormuz'],
domains: ['market', 'supply_chain'],
actors: ['Shipping operator'],
branchKinds: ['base'],
forecastIds: [market.id, supply.id],
forecastCount: 2,
avgProbability: 0.625,
avgConfidence: 0.59,
topSignals: [{ type: 'shipping_cost_shock', count: 1 }, { type: 'energy_supply_shock', count: 1 }],
sampleTitles: [market.title, supply.title],
};
const brazilSituation = {
id: 'sit-brazil',
label: 'Brazil escalation situation',
dominantRegion: 'Brazil',
dominantDomain: 'conflict',
regions: ['Brazil'],
domains: ['conflict'],
actors: ['Regional forces'],
branchKinds: ['base'],
forecastIds: [conflict.id],
forecastCount: 1,
avgProbability: 0.67,
avgConfidence: 0.62,
topSignals: [{ type: 'ucdp', count: 1 }],
sampleTitles: [conflict.title],
};
const hormuzState = {
id: 'state-hormuz',
label: 'Strait of Hormuz maritime disruption state',
dominantRegion: 'Strait of Hormuz',
dominantDomain: 'market',
forecastCount: 2,
familyId: 'fam-hormuz',
topSignals: [{ type: 'shipping_cost_shock' }, { type: 'energy_supply_shock' }],
};
const hormuzFamily = { id: 'fam-hormuz', label: 'Hormuz maritime pressure family', forecastCount: 2, situationCount: 1, situationIds: ['sit-hormuz'] };
const brazilFamily = { id: 'fam-brazil', label: 'Brazil escalation family', forecastCount: 1, situationCount: 1, situationIds: ['sit-brazil'] };
market.stateContext = hormuzState;
supply.stateContext = { ...hormuzState, dominantDomain: 'supply_chain' };
conflict.stateContext = {
id: 'state-brazil',
label: 'Brazil security escalation state',
dominantRegion: 'Brazil',
dominantDomain: 'conflict',
forecastCount: 1,
familyId: 'fam-brazil',
topSignals: [{ type: 'ucdp' }],
};
market.situationContext = hormuzSituation;
supply.situationContext = hormuzSituation;
conflict.situationContext = brazilSituation;
market.familyContext = hormuzFamily;
supply.familyContext = hormuzFamily;
conflict.familyContext = brazilFamily;
market.caseFile.situationContext = market.situationContext;
supply.caseFile.situationContext = supply.situationContext;
conflict.caseFile.situationContext = conflict.situationContext;
market.caseFile.familyContext = hormuzFamily;
supply.caseFile.familyContext = hormuzFamily;
conflict.caseFile.familyContext = brazilFamily;
market.marketSelectionContext = {
confirmationScore: 0.66,
contradictionScore: 0.04,
topBucketId: 'energy',
topBucketLabel: 'Energy',
topBucketPressure: 0.71,
transmissionEdgeCount: 3,
criticalSignalLift: 0.6,
topChannel: 'energy_supply_shock',
linkedBucketIds: ['energy', 'freight'],
};
supply.marketSelectionContext = {
confirmationScore: 0.62,
contradictionScore: 0.04,
topBucketId: 'freight',
topBucketLabel: 'Freight',
topBucketPressure: 0.67,
transmissionEdgeCount: 3,
criticalSignalLift: 0.58,
topChannel: 'shipping_cost_shock',
linkedBucketIds: ['freight', 'energy'],
};
conflict.marketSelectionContext = {
confirmationScore: 0.28,
contradictionScore: 0.1,
topBucketId: 'sovereign_risk',
topBucketLabel: 'Sovereign Risk',
topBucketPressure: 0.39,
transmissionEdgeCount: 1,
criticalSignalLift: 0.18,
topChannel: 'security_spillover',
linkedBucketIds: ['sovereign_risk'],
};
const selected = selectPublishedForecastPool([market, supply, conflict], { targetCount: 2 });
assert.ok(selected.some((pred) => pred.id === market.id));
assert.ok(selected.some((pred) => pred.id === supply.id));
const telemetry = summarizePublishFiltering([market, supply, conflict], selected, selected);
assert.equal(telemetry.candidateSupplyChainCount, 1);
assert.equal(telemetry.selectedSupplyChainCount, 1);
assert.equal(telemetry.publishedSupplyChainCount, 1);
});
it('does not report capped situations when a situation only reaches the cap without dropping anything', () => {
const preds = [
makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.66, 0.6, '7d', [
{ type: 'ucdp', value: '27 conflict events in Iran', weight: 0.5 },
]),
makePrediction('political', 'Iran', 'Political instability: Iran', 0.55, 0.54, '14d', [
{ type: 'news_corroboration', value: 'Emergency cabinet meetings continue', weight: 0.35 },
]),
makePrediction('market', 'Middle East', 'Oil price impact from Strait of Hormuz disruption', 0.48, 0.52, '30d', [
{ type: 'news_corroboration', value: 'Oil traders react to Hormuz risk', weight: 0.4 },
]),
];
buildForecastCases(preds);
for (const [index, pred] of preds.entries()) {
pred.traceMeta = { narrativeSource: 'fallback' };
pred.situationContext = {
id: 'sit-iran-gulf',
label: 'Iran Gulf pressure',
forecastCount: 3,
topSignals: [{ type: 'news_corroboration', count: 2 }],
};
pred.caseFile.situationContext = pred.situationContext;
pred.readiness = { overall: 0.65 - (index * 0.05) };
pred.analysisPriority = 0.22 - (index * 0.03);
}
const published = filterPublishedForecasts(preds);
assert.equal(published.length, 3);
const telemetry = summarizePublishFiltering(preds);
assert.equal(telemetry.suppressedSituationCap, 0);
assert.equal(telemetry.cappedSituations, 0);
});
it('keeps unrelated forecasts in separate situations instead of token-only over-merging', () => {
const conflict = makePrediction('conflict', 'Iran', 'Escalation risk: Iran', 0.65, 0.58, '7d', [
{ type: 'ucdp', value: '27 conflict events in Iran', weight: 0.5 },
]);
const cyber = makePrediction('cyber', 'Estonia', 'Cyber disruption risk: Estonia', 0.43, 0.52, '7d', [
{ type: 'news_corroboration', value: 'Estonia reports sustained cyber probing', weight: 0.35 },
]);
buildForecastCases([conflict, cyber]);
const worldState = buildForecastRunWorldState({ predictions: [conflict, cyber] });
assert.equal(worldState.situationClusters.length, 2);
assert.ok(worldState.situationClusters.every((cluster) => cluster.label.endsWith('situation')));
assert.ok(worldState.situationClusters.every((cluster) => !/fc-[a-z]+-[0-9a-f]{8}/.test(cluster.label)));
});
});