feat(simulation): MiroFish Phase 2 — theater-limited simulation runner (#2220)

* feat(simulation): MiroFish Phase 2 — theater-limited simulation runner

Adds the simulation execution layer that consumes simulation-package.json
and produces simulation-outcome.json for maritime chokepoint + energy/logistics
theaters, closing the WorldMonitor → MiroFish handoff loop.

Changes:
- scripts/seed-forecasts.mjs: 2-round LLM simulation runner (prompt builders,
  JSON extractor, runTheaterSimulation, writeSimulationOutcome, task queue
  with NX dedup lock, runSimulationWorker poll loop)
- scripts/process-simulation-tasks.mjs: standalone worker entry point
- proto: GetSimulationOutcome RPC + make generate
- server/worldmonitor/forecast/v1/get-simulation-outcome.ts: RPC handler
- server/gateway.ts: slow tier for get-simulation-outcome
- api/health.js: simulationOutcomeLatest in STANDALONE + ON_DEMAND keys
- tests: 14 new tests for simulation runner functions

* fix(simulation): address P1/P2 code review findings from PR #2220

Security (P1 #018):
- sanitizeForPrompt() applied to all entity/seed fields interpolated into
  Round 1 prompt (entityId, class, stance, seedId, type, timing)
- sanitizeForPrompt() applied to actorId and entityIds in Round 2 prompt
- sanitizeForPrompt() + length caps applied to all LLM array fields written
  to R2 (dominantReactions, stabilizers, invalidators, keyActors, timingMarkers)

Validation (P1 #019):
- Added validateRunId() regex guard
- Applied in enqueueSimulationTask() and processNextSimulationTask() loop

Type safety (P1 #020):
- Added isOutcomePointer() and isPackagePointer() type guards in TS handlers
- Replaced unsafe as-casts with runtime-validated guards in both handlers

Correctness (P2 #022):
- Log warning when pkgPointer.runId does not match task runId

Architecture (P2 #024):
- isMaritimeChokeEnergyCandidate() accepts both flat and nested topBucketId
- Call site simplified to pass theater directly

Performance (P2 #025):
- SIMULATION_ROUND1_MAX_TOKENS raised 1800 to 2200
- Added max 3 initialReactions instruction to Round 1 prompt

Maintainability (P2 #026):
- Simulation pointer keys exported from server/_shared/cache-keys.ts
- Both TS handlers import from shared location

Documentation (P2 #027):
- Strengthened runId no-op description in proto and OpenAPI spec

* fix(todos): add blank lines around lists in markdown todo files

* style(api): reformat openapi yaml to match linter output

* test(simulation): add flat-shape filter test + getSimulationOutcome handler coverage

Two tests identified as missing during PR #2220 review:

1. isMaritimeChokeEnergyCandidate flat-shape tests — covers the || candidate.topBucketId
   normalization added in the P1/P2 review pass. The existing tests only used the nested
   marketContext.topBucketId shape; this adds the flat root-field shape that arrives from
   the simulation-package.json JSON (selectedTheaters entries have topBucketId at root).

2. getSimulationOutcome handler structural tests — verifies the isOutcomePointer guard,
   found:false NOT_FOUND return, found:true success path, note population on runId mismatch,
   and redis_unavailable error string. Follows the readSrc static-analysis pattern used
   elsewhere in server-handlers.test.mjs (handler imports Redis so full integration test
   would require a test Redis instance).
This commit is contained in:
Elie Habib
2026-03-25 13:55:59 +04:00
committed by GitHub
parent 8f27a871f5
commit 01f6057389
28 changed files with 1811 additions and 7 deletions

View File

@@ -55,6 +55,13 @@ import {
SIMULATION_PACKAGE_SCHEMA_VERSION,
SIMULATION_PACKAGE_LATEST_KEY,
writeSimulationPackage,
SIMULATION_OUTCOME_LATEST_KEY,
SIMULATION_OUTCOME_SCHEMA_VERSION,
buildSimulationOutcomeKey,
writeSimulationOutcome,
buildSimulationRound1SystemPrompt,
buildSimulationRound2SystemPrompt,
extractSimulationRoundPayload,
} from '../scripts/seed-forecasts.mjs';
import {
@@ -5528,6 +5535,24 @@ describe('simulation package export', () => {
})), true);
});
it('isMaritimeChokeEnergyCandidate accepts candidate with energy bucket on root (flat shape, no marketContext)', () => {
// Flat shape: topBucketId is on the candidate root, no marketContext object.
// This is the package JSON shape written by buildSimulationPackageFromDeepSnapshot.
assert.equal(isMaritimeChokeEnergyCandidate(makeCandidate({
marketContext: undefined,
topBucketId: 'energy',
})), true);
});
it('isMaritimeChokeEnergyCandidate rejects flat shape with non-energy bucket and no energy commodity', () => {
assert.equal(isMaritimeChokeEnergyCandidate(makeCandidate({
marketContext: undefined,
topBucketId: 'semis',
commodityKey: '',
marketBucketIds: ['semis'],
})), false);
});
it('buildSimulationPackageFromDeepSnapshot returns null when no qualifying candidates', () => {
const pkg = buildSimulationPackageFromDeepSnapshot(makeSnapshot([
makeCandidate({ routeFacilityKey: '' }),
@@ -5705,3 +5730,197 @@ describe('simulation package export', () => {
assert.equal(result, null);
});
});
// ---------------------------------------------------------------------------
// MiroFish Phase 2 — Simulation Runner
// ---------------------------------------------------------------------------
const minimalTheater = {
theaterId: 'test-theater-1',
theaterRegion: 'Red Sea',
theaterLabel: 'Red Sea / Bab-el-Mandeb',
candidateStateId: 'state-001',
routeFacilityKey: 'Red Sea',
dominantRegion: 'Middle East',
macroRegions: ['MENA'],
topBucketId: 'energy',
topChannel: 'price_spike',
marketBucketIds: ['energy', 'freight'],
};
const minimalPkg = {
runId: 'run-001',
generatedAt: 1711234567000,
selectedTheaters: [minimalTheater],
entities: [
{ entityId: 'houthi-forces', name: 'Houthi Forces', class: 'military_or_security_actor', region: 'Yemen', stance: 'active', objectives: [], constraints: [], relevanceToTheater: 'test-theater-1' },
{ entityId: 'aramco-exports', name: 'Saudi Aramco', class: 'exporter_or_importer', region: 'Saudi Arabia', stance: 'stressed', objectives: [], constraints: [], relevanceToTheater: 'test-theater-1' },
],
eventSeeds: [
{ seedId: 'seed-1', theaterId: 'test-theater-1', type: 'live_news', summary: 'Houthi missile attack on Red Sea shipping', evidenceRefs: ['E1'], timing: 'T+0h' },
{ seedId: 'seed-2', theaterId: 'test-theater-1', type: 'state_signal', summary: 'Oil tanker rerouting Cape of Good Hope', evidenceRefs: ['E2'], timing: 'T+12h' },
],
constraints: { 'test-theater-1': ['No actor may unilaterally close the Strait of Bab-el-Mandeb'] },
evaluationTargets: { 'test-theater-1': ['Oil price trajectory over 72h', 'Shipping diversion extent'] },
simulationRequirement: { 'test-theater-1': 'Simulate how a Red Sea disruption propagates through energy and logistics markets' },
};
describe('simulation runner — prompt builders', () => {
it('Round 1 prompt contains theater label and region', () => {
const prompt = buildSimulationRound1SystemPrompt(minimalTheater, minimalPkg);
assert.ok(prompt.includes('Red Sea / Bab-el-Mandeb'), 'should include theater label');
assert.ok(prompt.includes('Red Sea'), 'should include theater region');
});
it('Round 1 prompt contains all 3 required path IDs', () => {
const prompt = buildSimulationRound1SystemPrompt(minimalTheater, minimalPkg);
assert.ok(prompt.includes('"escalation"'), 'should mention escalation path');
assert.ok(prompt.includes('"containment"'), 'should mention containment path');
assert.ok(prompt.includes('"spillover"'), 'should mention spillover path');
});
it('Round 1 prompt lists entity IDs', () => {
const prompt = buildSimulationRound1SystemPrompt(minimalTheater, minimalPkg);
assert.ok(prompt.includes('houthi-forces'), 'should include entity entityId');
assert.ok(prompt.includes('aramco-exports'), 'should include entity entityId');
});
it('Round 1 prompt lists event seed IDs', () => {
const prompt = buildSimulationRound1SystemPrompt(minimalTheater, minimalPkg);
assert.ok(prompt.includes('seed-1'), 'should include seed-1');
assert.ok(prompt.includes('seed-2'), 'should include seed-2');
});
it('Round 1 prompt includes simulation requirement', () => {
const prompt = buildSimulationRound1SystemPrompt(minimalTheater, minimalPkg);
assert.ok(prompt.includes('Red Sea disruption'), 'should include simulationRequirement text');
});
it('Round 2 prompt contains Round 1 path summaries', () => {
const round1 = {
paths: [
{ pathId: 'escalation', summary: 'Escalation path summary', initialReactions: [{ actorId: 'houthi-forces' }] },
{ pathId: 'containment', summary: 'Containment path summary', initialReactions: [] },
{ pathId: 'spillover', summary: 'Spillover path summary', initialReactions: [] },
],
};
const prompt = buildSimulationRound2SystemPrompt(minimalTheater, minimalPkg, round1);
assert.ok(prompt.includes('Escalation path summary'), 'should include round 1 escalation summary');
assert.ok(prompt.includes('Containment path summary'), 'should include round 1 containment summary');
assert.ok(prompt.includes('ROUND 2'), 'should indicate this is round 2');
});
it('Round 2 prompt includes valid actor IDs list', () => {
const round1 = { paths: [] };
const prompt = buildSimulationRound2SystemPrompt(minimalTheater, minimalPkg, round1);
assert.ok(prompt.includes('houthi-forces'), 'should include valid actor IDs');
});
});
describe('simulation runner — extractSimulationRoundPayload', () => {
const r1Payload = JSON.stringify({
paths: [
{ pathId: 'escalation', label: 'Escalate', summary: 'Forces escalate', initialReactions: [] },
{ pathId: 'containment', label: 'Contain', summary: 'Forces contained', initialReactions: [] },
{ pathId: 'spillover', label: 'Spill', summary: 'Spillover effect', initialReactions: [] },
],
dominantReactions: ['Actor A: escalates'],
note: 'Three divergent paths',
});
const r2Payload = JSON.stringify({
paths: [
{ pathId: 'escalation', label: 'Full Escalation', summary: 'Escalated 72h', keyActors: ['houthi-forces'], roundByRoundEvolution: [{ round: 1, summary: 'Round 1' }, { round: 2, summary: 'Round 2' }], confidence: 0.75, timingMarkers: [{ event: 'First strike', timing: 'T+6h' }] },
{ pathId: 'containment', label: 'Contained', summary: 'Contained 72h', keyActors: [], roundByRoundEvolution: [], confidence: 0.6, timingMarkers: [] },
{ pathId: 'spillover', label: 'Spilled', summary: 'Spillover 72h', keyActors: [], roundByRoundEvolution: [], confidence: 0.4, timingMarkers: [] },
],
stabilizers: ['International pressure'],
invalidators: ['New attack'],
globalObservations: 'Cross-theater ripple effects expected',
confidenceNotes: 'Moderate confidence overall',
});
it('parses valid Round 1 JSON directly', () => {
const result = extractSimulationRoundPayload(r1Payload, 1);
assert.ok(Array.isArray(result.paths), 'should return paths array');
assert.equal(result.paths.length, 3, 'should have 3 paths');
assert.equal(result.paths[0].pathId, 'escalation');
assert.ok(Array.isArray(result.dominantReactions), 'should include dominantReactions');
assert.equal(result.diagnostics.stage, 'direct');
});
it('parses valid Round 2 JSON directly', () => {
const result = extractSimulationRoundPayload(r2Payload, 2);
assert.ok(Array.isArray(result.paths), 'should return paths array');
assert.equal(result.paths.length, 3);
assert.ok(Array.isArray(result.stabilizers), 'should include stabilizers');
assert.ok(Array.isArray(result.invalidators), 'should include invalidators');
assert.ok(typeof result.globalObservations === 'string');
});
it('strips fenced code blocks and parses Round 1', () => {
const fenced = `\`\`\`json\n${r1Payload}\n\`\`\``;
const result = extractSimulationRoundPayload(fenced, 1);
assert.ok(Array.isArray(result.paths), 'should parse fenced JSON');
assert.equal(result.paths.length, 3);
});
it('strips <think> tags before parsing', () => {
const withThink = `<think>internal reasoning here</think>\n${r1Payload}`;
const result = extractSimulationRoundPayload(withThink, 1);
assert.ok(Array.isArray(result.paths), 'should parse after stripping think tags');
});
it('returns null paths on invalid JSON', () => {
const result = extractSimulationRoundPayload('not valid json', 1);
assert.equal(result.paths, null);
assert.equal(result.diagnostics.stage, 'no_json');
});
it('returns null paths when paths array is missing', () => {
const result = extractSimulationRoundPayload('{"no_paths": true}', 1);
assert.equal(result.paths, null);
});
it('returns null paths when no valid pathId present', () => {
const badPaths = JSON.stringify({ paths: [{ pathId: 'unknown', summary: 'x' }] });
const result = extractSimulationRoundPayload(badPaths, 1);
assert.equal(result.paths, null);
});
it('uses extractFirstJsonObject fallback for prefix text', () => {
const withPrefix = `Here is the result:\n${r1Payload}\nEnd.`;
const result = extractSimulationRoundPayload(withPrefix, 1);
assert.ok(Array.isArray(result.paths), 'should parse via extractFirstJsonObject fallback');
});
});
describe('simulation runner — outcome key builder', () => {
it('buildSimulationOutcomeKey produces a key ending in simulation-outcome.json', () => {
const key = buildSimulationOutcomeKey('run-123', 1711234567000);
assert.ok(key.endsWith('/simulation-outcome.json'), `unexpected key: ${key}`);
assert.ok(key.includes('run-123'), 'should include runId');
});
it('SIMULATION_OUTCOME_LATEST_KEY is the canonical Redis pointer key', () => {
assert.equal(SIMULATION_OUTCOME_LATEST_KEY, 'forecast:simulation-outcome:latest');
});
it('SIMULATION_OUTCOME_SCHEMA_VERSION is v1', () => {
assert.equal(SIMULATION_OUTCOME_SCHEMA_VERSION, 'v1');
});
});
describe('simulation runner — writeSimulationOutcome', () => {
it('returns null when R2 storage is not configured', async () => {
const outcome = { theaterResults: [], failedTheaters: [], runId: 'run-001', generatedAt: Date.now() };
const result = await writeSimulationOutcome(minimalPkg, outcome, { storageConfig: null });
assert.equal(result, null);
});
it('returns null when pkg has no runId', async () => {
const outcome = { theaterResults: [], failedTheaters: [] };
const result = await writeSimulationOutcome({ generatedAt: Date.now() }, outcome, { storageConfig: null });
assert.equal(result, null);
});
});