Skip to content

feat(forecast): plumb simulation decorations from ExpandedPath to Forecast Redis key#2528

Open
koala73 wants to merge 3 commits intomainfrom
feat/forecast-sim-decorations-plumbing
Open

feat(forecast): plumb simulation decorations from ExpandedPath to Forecast Redis key#2528
koala73 wants to merge 3 commits intomainfrom
feat/forecast-sim-decorations-plumbing

Conversation

@koala73
Copy link
Copy Markdown
Owner

@koala73 koala73 commented Mar 29, 2026

Why this PR?

The simulation confidence sub-bar added in #2526 renders in `ForecastPanel` but had no data: `simulationAdjustment`, `simPathConfidence`, and `demotedBySimulation` only lived on `ExpandedPath` trace artifacts in R2. They never reached the `forecast:predictions:v2` Redis key that the panel reads.

What changed

Proto + generated client

  • Added fields 20-22 to `Forecast` message: `simulation_adjustment`, `sim_path_confidence`, `demoted_by_simulation`
  • Regenerated `src/generated/client/worldmonitor/forecast/v1/service_client.ts`

`scripts/seed-forecasts.mjs`

  • `buildPublishedForecastPayload`: passes sim fields through to the published Redis payload
  • `writeSimulationDecorations`: called fire-and-forget from `applyPostSimulationRescore` after merge; builds `candidateStateId → forecastIds` map from `snapshot.fullRunStateUnits`, picks strongest signal per candidate, writes `forecast:sim-decorations:v1` (3-day TTL)
  • `applySimulationDecorationsToForecasts`: called at the top of `fetchForecasts()`, reads the decoration key, mutates matching predictions in-place; non-fatal on failure
  • `__setRedisStoreForTests` + `_testRedisStore` bypass in `getRedisCredentials`: in-memory injection for unit tests

Staleness fix (P1 review finding)

Without the fix, `writeSimulationDecorations` returned early on empty adjustments or zero `decorationCount`, leaving the prior run's key intact for the full 3-day TTL. Old simulation flags would persist across multiple runs.

Fix: split the first guard — only skip when `simulationEvidence` is entirely absent (bogus data); always write even when `adjustments` is `[]` or no forecast IDs matched. Removed the `decorationCount === 0` early return for the same reason.

Also added `SIMULATION_DECORATIONS_MAX_AGE_MS` (48h) guard in `applySimulationDecorationsToForecasts` as defense-in-depth for the edge case where no simulation has run recently (e.g., outage).

`tests/forecast-trace-export.test.mjs`

  • 15 WD-* tests covering early-exit paths, empty-write clears stale data, strongest-signal selection, candidate-to-forecast mapping, demotion flag, max-age skip, non-fatal error handling, and a full write→apply round-trip

Test results

287/287 passing. `npm run typecheck` clean.

Post-Deploy Monitoring & Validation

  • Logs to watch: `[SimulationDecorations] Written N decorations` in Railway simulation worker; `[SimulationDecorations] Applied to N/M forecasts` in Railway fast-path seeder; `Skipping stale decorations (age=Xh)` as warning if no simulation ran for >48h
  • Expected healthy behavior: After the next simulation run completes, `forecast:sim-decorations:v1` is present with `byForecastId` entries (or empty `{}` if nothing crossed thresholds); subsequent fast-path seeds log "Applied to N forecasts"
  • Failure signals: `[SimulationDecorations] Write failed` or `Apply failed (non-fatal)` — sub-bar shows nothing; stale-flag scenario no longer possible since empty write overwrites
  • Validation window: First simulation run post-deploy

…ecast Redis key

- Add `simulation_adjustment`, `sim_path_confidence`, `demoted_by_simulation` fields
  to `Forecast` proto (fields 20-22) and regenerate service client
- `writeSimulationDecorations`: after simulation rescore, builds candidateStateId →
  forecastIds map from snapshot.fullRunStateUnits, picks strongest signal per candidate,
  writes `forecast:sim-decorations:v1` to Redis (3-day TTL)
- `applySimulationDecorationsToForecasts`: reads decorations at start of fast-path seed,
  mutates predictions in-place (stale-by-one-run, non-fatal on failure)
- `buildPublishedForecastPayload`: passes sim fields through to published Redis payload
- Add `__setRedisStoreForTests` / test-store bypass in `getRedisCredentials` for unit tests
- 12 new WD-* tests covering early-exits, strongest-signal selection, candidate conflict
  resolution, demotion flag, non-fatal error handling, and full round-trip
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Preview Mar 29, 2026 7:30pm

Request Review

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 29, 2026

Greptile Summary

This PR plumbs three simulation decoration fields (simulationAdjustment, simPathConfidence, demotedBySimulation) from the existing ExpandedPath R2 artifacts all the way into the forecast:sim-decorations:v1 Redis key, so the ForecastPanel sub-bar introduced in #2526 finally has real data to render.

Key changes:

  • writeSimulationDecorations: groups simulation adjustments by candidateStateId, picks the strongest signal (highest |adj|) per candidate, resolves multi-candidate → single-forecast conflicts, and writes to a 3-day TTL Redis key — called fire-and-forget from applyPostSimulationRescore
  • applySimulationDecorationsToForecasts: mutates matching predictions in-place before buildPublishedForecastPayload serializes them; failures are fully non-fatal
  • buildPublishedForecastPayload already serializes all three fields (|| 0 / ?? 1.0 fallbacks are self-consistent across layers)
  • 12 new unit tests cover all early-exit paths, selection/conflict logic, demotion flag, and the full round-trip via the in-memory Redis bypass
  • One AGENTS.md convention is not followed: writeSimulationDecorations writes forecast:sim-decorations:v1 without a companion seed-meta:forecast:sim-decorations:v1 key, leaving health monitoring blind to this new key

Confidence Score: 4/5

Safe to merge after the seed-meta companion key is added; all failures are non-fatal and test coverage is thorough.

The only finding is a P2 AGENTS.md convention gap (missing seed-meta write), but the convention uses the word MUST and health-monitoring tooling actively relies on those keys. Bumping to 4/5 to surface this before merge; all other logic, error handling, and test coverage is solid.

scripts/seed-forecasts.mjs — add seed-meta:forecast:sim-decorations:v1 companion write inside writeSimulationDecorations

Important Files Changed

Filename Overview
scripts/seed-forecasts.mjs Adds writeSimulationDecorations and applySimulationDecorationsToForecasts, plus Redis test-bypass infrastructure; logic is sound but missing a seed-meta companion write per AGENTS.md convention.
tests/forecast-trace-export.test.mjs Adds 12 well-structured WD-* tests covering early-exit paths, strongest-signal selection, demotion flag, cross-candidate conflict resolution, non-fatal error handling, and a full write→apply round-trip; afterEach cleanup is correct.

Sequence Diagram

sequenceDiagram
    participant SW as Simulation Worker
    participant FF as fetchForecasts (Fast-Path Seeder)
    participant R2 as Cloudflare R2
    participant Redis as Upstash Redis

    SW->>R2: writeSimulationOutcome (forecast-eval.json + snapshot)
    SW->>SW: applyPostSimulationRescore(runId, freshOutcome, storageConfig)
    SW->>R2: getR2JsonObject(evalKey + snapshotKey)
    R2-->>SW: evalData, snapshot (with fullRunStateUnits)
    SW->>SW: applySimulationMerge → mergeResult
    SW-->>Redis: writeSimulationDecorations (fire-and-forget) writes forecast:sim-decorations:v1 (3-day TTL)
    Note over Redis: { byForecastId: { [id]: { simulationAdjustment, simPathConfidence, demotedBySimulation } } }

    FF->>FF: fetchForecasts()
    FF->>Redis: applySimulationDecorationsToForecasts(predictions)
    Redis-->>FF: forecast:sim-decorations:v1
    FF->>FF: mutate pred.simulationAdjustment / simPathConfidence / demotedBySimulation in-place
    FF->>FF: buildPublishedForecastPayload(pred)
    FF->>Redis: write forecast:predictions:v2 (with sim fields)
    Note over FF: ForecastPanel sub-bar now has data
Loading

Reviews (1): Last reviewed commit: "feat(forecast): plumb simulation decorat..." | Re-trigger Greptile

Comment on lines +16362 to +16368
await redisSet(url, token, SIMULATION_DECORATIONS_KEY, {
runId: snapshot?.runId || '',
generatedAt: Date.now(),
byForecastId,
}, SIMULATION_DECORATIONS_TTL_SECONDS);
console.log(` [SimulationDecorations] Written ${decorationCount} decorations from ${byCandidateId.size} candidates`);
} catch (err) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing seed-meta key write

AGENTS.md states: "Redis seed scripts MUST write seed-meta:<key> for health monitoring". Other persistent writes in this file follow the convention (e.g. seed-meta:conflict:ema-windows:v1 at line 14865 and seed-meta:intelligence:market-implications at line 15936), but writeSimulationDecorations only writes forecast:sim-decorations:v1 without a companion seed-meta:forecast:sim-decorations:v1 key.

Without this, health-monitoring endpoints that scan seed-meta:* keys will be unaware that the simulation decorations key exists, making it invisible to on-call dashboards after deploy.

Suggested fix — add just after the redisSet call inside the try block:

await redisSet(url, token, `seed-meta:${SIMULATION_DECORATIONS_KEY}`, {
  writtenAt: new Date().toISOString(),
  decorationCount,
  runId: snapshot?.runId || '',
}, SIMULATION_DECORATIONS_TTL_SECONDS);

Context Used: AGENTS.md (source)

…ay staleness

Without this fix, writeSimulationDecorations() returned early on empty adjustments
or zero decorationCount, leaving the prior run's forecast:sim-decorations:v1 intact
for the full 3-day TTL. Subsequent fast-path seeds would blindly reapply old flags.

Fixes:
- Split the `!adjustments?.length` guard: only skip on missing simulationEvidence
  (bogus data); write an empty byForecastId map when adjustments is [] so later
  runs always overwrite stale entries
- Remove the `decorationCount === 0` early return for the same reason
- Add SIMULATION_DECORATIONS_MAX_AGE_MS (48h) guard in applySimulationDecorationsToForecasts
  as defense-in-depth for the edge case where no simulation has run recently

Tests: WD-1, WD-3 updated; WD-2b, WD-13, WD-14 added (287 total, all pass)
…ast path

processDeepForecastTask called applySimulationMerge but only extracted
simulationEvidence, discarding mergeResult without writing decorations.
Forecasts processed through the inline deep path (the common case) never
got their forecast:sim-decorations:v1 key populated.

Fix: fire-and-forget writeSimulationDecorations(mergeResult, snapshot)
immediately after applySimulationMerge in processDeepForecastTask, same
pattern as applyPostSimulationRescore. The deep path now writes on the
SAME run (no stale-by-one lag).

Test WD-15: exercises applySimulationMerge → writeSimulationDecorations
with snapshot.fullRunStateUnits, verifying both mapped forecast IDs are
decorated via the full chain used by processDeepForecastTask (288 total).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant