Skip to content

fix(forecast): resolve judged forecast outcomes#5087

Merged
koala73 merged 4 commits into
mainfrom
codex/issue-5007-judged-resolution
Jul 9, 2026
Merged

fix(forecast): resolve judged forecast outcomes#5087
koala73 merged 4 commits into
mainfrom
codex/issue-5007-judged-resolution

Conversation

@koala73

@koala73 koala73 commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Summary

Forecast resolution now handles due judged forecasts instead of leaving them permanently in pending-judge. The resolver uses two LLM judges over digest-archive evidence, seals YES/NO only when both models agree with grounded citations, and writes the result through the existing ledger, receipt, and scorecard flow.

The failure modes are deliberately conservative: model disagreement becomes VOID, missing/malformed judge output stays pending for retry, complete archive reads with no relevant evidence become VOID, and incomplete or truncated archive reads stay pending instead of creating permanent false VOID receipts.

Related: #5007

Validation

  • node --test tests/forecast-resolutions-seeder.test.mjs tests/forecast-scorecard.test.mjs tests/forecast-resolution-eval.test.mjs tests/scripts-railway-nixpacks-no-escape-import.test.mts
  • Pre-push hook passed: frontend typecheck, API typecheck, Convex check, CJS syntax, Unicode safety, boundary lint, safe HTML guard, Sentry coverage gate, rate-limit policy lint, premium-fetch parity, edge bundle check, changed resolver tests, and version sync.

Post-Deploy Monitoring & Validation

  • Watch Railway logs for forecast-resolutions, [LLM:forecast_resolution_judge_openrouter], [LLM:forecast_resolution_judge_groq], judged archive unavailable, Terminal receipts resolved this cycle, and R2 receipts archived.
  • Check /api/health and seed-meta:forecast:scorecard for fresh successful runs; inspect forecast:scorecard:v1 for judged entries moving from pending to scored or VOID without a sudden VOID-rate spike.
  • Sample new R2 forecast-resolutions receipts and confirm judged evidence includes dual_model_agreement, judge_disagreement, all_judges_void, or no_archive_evidence with citations only when excerpts match the archive item.
  • Healthy signal: due judged forecasts resolve gradually, judge_unavailable/archive_unavailable attempts are transient, and LLM telemetry shows successful calls from both judge providers.
  • Failure signal: repeated archive_unavailable, same provider failing every run, zero judged receipts after due forecasts exist, or a sharp increase in no_archive_evidence/VOID rate. Roll back by reverting this PR and redeploying the previous Railway worker image.

Compound Engineering
GPT-5

@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Preview Jul 9, 2026 11:15am

Request Review

@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds LLM-judge-based resolution for forecasts that cannot be auto-resolved by metric feeds, routing due pending-judge entries through two competing models (OpenRouter + Groq) over a Redis-backed news archive. Sealed outcomes require dual-model agreement with grounded citations; disagreement, missing citations, or archive gaps conservatively produce VOID or leave entries pending for retry.

  • New processResolutionCycleWithJudges orchestrates ingestion, hard-spec resolution, LLM judging, pruning, and scorecard computation in a single async cycle, replacing processResolutionCycle in the live run path.
  • readDigestAccumulatorArchive fetches the relevant window of story-track hashes via ZRANGEBYSCORE and pipelines HGETALL lookups, throwing on any empty or errored row (fail-closed design) and flagging truncated reads to prevent false VOIDs.
  • Run configuration now includes an explicit lockTtlMs: 180_000 and a widened fetchPhaseTimeoutMs: 150_000 to accommodate the additional LLM latency budget.

Confidence Score: 3/5

Safe to merge with the understanding that any story:track:v1 hash key expiring before its ZSET entry is pruned will silently block all judged resolutions for every subsequent run until the stale ZSET members age out.

The HGETALL fail-closed behavior in readDigestAccumulatorArchive turns a missing Redis key into a blanket archive-unavailable signal that freezes every pending-judge entry indefinitely. The remaining findings are self-contained and do not affect production resolution correctness.

scripts/seed-forecast-resolutions.mjs — specifically the HGETALL empty-result handling in readDigestAccumulatorArchive and whether story track key TTLs are guaranteed to be at least as long as the ZSET accumulator retention window.

Important Files Changed

Filename Overview
scripts/seed-forecast-resolutions.mjs Adds ~530 lines implementing LLM-judge resolution for pending-judge forecasts; the fail-closed HGETALL check throws on empty rows (non-existent keys), which can block all judged resolutions when ZSET members outlive story track hash TTLs.
tests/forecast-resolutions-seeder.test.mjs Adds comprehensive tests for the new judge cycle: dual-model agreement, disagreement → VOID, no evidence (full/incomplete/truncated archive), fabricated-citation downgrade, judge error → pending, and archive truncation detection.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Worker as Railway Worker
    participant Redis as Redis (Upstash)
    participant OR as OpenRouter Judge
    participant Groq as Groq Judge
    participant R2 as Cloudflare R2

    Worker->>Redis: LRANGE forecast:predictions:history:v1
    Redis-->>Worker: history snapshots
    Worker->>Worker: ingestHistory / samplePendingEntries / resolveDueEntries
    Worker->>Redis: ZRANGEBYSCORE digest:accumulator:v1:full:en [window]
    Redis-->>Worker: story hashes
    Worker->>Redis: HGETALL pipeline (story:track:v1:hash x N)
    Redis-->>Worker: story track rows (throws on empty/error row)
    loop Each due pending-judge entry
        Worker->>Worker: selectJudgedArchiveItems
        alt No relevant archive items
            Worker->>Worker: archiveCoversEntryWindow check
        else Relevant items found
            Worker->>OR: callForecastLLM
            OR-->>Worker: outcome + citations
            Worker->>Groq: callForecastLLM
            Groq-->>Worker: outcome + citations
            Worker->>Worker: normalizeJudgment x 2
        end
    end
    Worker->>Redis: SET forecast:resolutions:v1
    Worker->>Redis: SET forecast:scorecard:v1
    Worker->>R2: PUT receipt JSON
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Worker as Railway Worker
    participant Redis as Redis (Upstash)
    participant OR as OpenRouter Judge
    participant Groq as Groq Judge
    participant R2 as Cloudflare R2

    Worker->>Redis: LRANGE forecast:predictions:history:v1
    Redis-->>Worker: history snapshots
    Worker->>Worker: ingestHistory / samplePendingEntries / resolveDueEntries
    Worker->>Redis: ZRANGEBYSCORE digest:accumulator:v1:full:en [window]
    Redis-->>Worker: story hashes
    Worker->>Redis: HGETALL pipeline (story:track:v1:hash x N)
    Redis-->>Worker: story track rows (throws on empty/error row)
    loop Each due pending-judge entry
        Worker->>Worker: selectJudgedArchiveItems
        alt No relevant archive items
            Worker->>Worker: archiveCoversEntryWindow check
        else Relevant items found
            Worker->>OR: callForecastLLM
            OR-->>Worker: outcome + citations
            Worker->>Groq: callForecastLLM
            Groq-->>Worker: outcome + citations
            Worker->>Worker: normalizeJudgment x 2
        end
    end
    Worker->>Redis: SET forecast:resolutions:v1
    Worker->>Redis: SET forecast:scorecard:v1
    Worker->>R2: PUT receipt JSON
Loading

Comments Outside Diff (2)

  1. scripts/seed-forecast-resolutions.mjs, line 629-631 (link)

    P1 Empty HGETALL result throws and blocks all judged resolutions

    HGETALL on a non-existent Redis key returns an empty array ([]), which triggers the raw.length === 0 branch and throws. This throw propagates out of readDigestAccumulatorArchive, is caught by readJudgedNewsArchiveForLedger, and causes the entire archive to be returned as available: false — meaning every pending-judge entry stays pending for the whole run. If any story:track:v1:${hash} key expires before the ZSET accumulator entry is cleaned up (e.g., if story track TTL < 48 h), this failure will recur on every run and permanently block all judged resolutions. Skipping empty HGETALL rows rather than throwing would be the safer path: a missing story is just a gap in evidence, not a corrupted data structure.

  2. scripts/seed-forecast-resolutions.mjs, line 977 (link)

    P2 The --dry-run path still calls processResolutionCycle rather than processResolutionCycleWithJudges, so local dry runs will never attempt LLM judging, never update judgeLastAttempt, and will keep reporting pendingJudge counts that the live path would have cleared. Switching to the judged variant (with an empty or mocked archive) would make dry-run output reflect actual production behavior.

Reviews (1): Last reviewed commit: "fix(forecast): resolve judged forecast o..." | Re-trigger Greptile

Comment thread scripts/seed-forecast-resolutions.mjs Outdated
@koala73

koala73 commented Jul 9, 2026

Copy link
Copy Markdown
Owner Author

Empty HGETALL result throws and blocks all judged resolutions.
The --dry-run path still calls processResolutionCycle rather than processResolutionCycleWithJudges.

Addressed in b926bb2dd and pushed in 7cfbae48a:

  • missing/empty story-track rows are skipped and mark archive coverage incomplete, with regressions for stale ZSET rows
  • dry-run now exercises processResolutionCycleWithJudges using no-LLM judge models
  • single injected judge lists now fail closed with fewer_than_two_models instead of falling through to live judges

Validation: pre-push hook passed, and the merged-tree focused forecast/script sweep passed.

@koala73
koala73 merged commit 5ccd0fa into main Jul 9, 2026
23 checks passed
@koala73
koala73 deleted the codex/issue-5007-judged-resolution branch July 9, 2026 11:46
koala73 added a commit that referenced this pull request Jul 10, 2026
…5136) (#5138)

* fix(forecast): reclassify conflict/ucdp_zone count specs to judged (#5136)

Conflict/ucdp_zone hard-count specs resolve against CONFLICT_COUNT_SOURCE_FEED
(conflict:acled-resolution:v1), which only populates with ACLED credentials we
don't have -> EXISTS=0 in prod -> every conflict count spec is unresolvable
(sits pending/VOID forever). Route these families to judged resolution (live
via #5087) instead of emitting a dead hard spec.

- deriveHardMetrics: conflict/ucdp_zone return null when the count feed is
  unavailable -> buildHardSpec falls back to buildJudgedSpec (LLM judge over the
  news archive). Gated by CONFLICT_COUNT_FEED_AVAILABLE (default false); flip to
  re-enable hard-count once a populated event-count feed exists.
- buildQuestion: family-aware, escalation-framed question for conflict forecasts
  (resolves more reliably against the archive than the generic phrasing).
- Threaded an optional `conflictCountFeedAvailable` override through
  buildResolutionSpec/attachResolutionSpec so the preserved #5010
  horizon-commensurable threshold logic stays under test (the tests that lock it
  force the hard path); production emits judged by default.
- GDELT article-volume is NOT a substitute: its scale is article count, not
  event count, so the thresholds would mis-resolve.

Tests: conflict now judged with an escalation question; scoped-regression that
unrest still emits hard; existing conflict-hard tests force the feed-available
path (preserves #5010 coverage). 405 forecast tests green.

Related: #5136, #5097, #5087 (judged resolver), #5091 (KPI reframe follow-up)

Claude-Session: https://claude.ai/code/session_01StNurp4TGC3JLHbTtKJhbp

* fix(forecast): migrate pending conflict counts to judged
koala73 added a commit that referenced this pull request Jul 10, 2026
* fix(forecast): reclassify unrest count specs to judged (#5091)

The twin of #5136/#5138: unrest hard-count specs resolve against
UNREST_COUNT_SOURCE_FEED (unrest:events-resolution:v1), which is EMPTY in prod —
seed-unrest-events only writes it from an ACLED resolution fetch, and we have no
ACLED credentials. So unrest counts were unresolvable, sitting pending/VOID
forever, while inflating the nominal hard-ratio.

- deriveHardMetrics: the `unrest` family returns null when the count feed is
  unavailable -> buildJudgedSpec (LLM judge, #5087). Gated by the new
  UNREST_COUNT_FEED_AVAILABLE flag (default false) + an `unrestCountFeedAvailable`
  override; the horizon-scaled threshold logic is preserved for re-enable.
- buildQuestion: family-aware, instability-framed question for the political
  (unrest) domain, mirroring the conflict question.
- Not GDELT-for-count: article volume != event count, so the thresholds would
  mis-resolve (same reason it was rejected for conflict).

Tests: unrest now judged by default with an instability question; the scoped
regression now uses cyber (a populated feed) as the still-hard exemplar; the
unrest feed-mapping + R4 fixtures force the feed-available path. 404 forecast
tests green.

Related: #5091 (KPI reframe + empty-feed audit), #5136/#5138 (conflict template),
#5087 (judged resolver), #4930

Claude-Session: https://claude.ai/code/session_01StNurp4TGC3JLHbTtKJhbp

* test(forecast): address Greptile — assert horizon in unrest question + cover tally-null guard under override (#5142)

- unrest judged test now asserts the horizon (7d) appears in the question,
  matching the conflict test's rigor (Greptile P2).
- add a test for the unrest tally-null path under unrestCountFeedAvailable:true
  (a non-numeric count signal still falls back to judged) — the path the flag
  early-return shadowed by default (Greptile P2).

Claude-Session: https://claude.ai/code/session_01StNurp4TGC3JLHbTtKJhbp

* fix(forecast): migrate existing pending unrest count entries to judged (#5091)

#5138 added a pending-ledger migration for conflict, but the unrest
reclassification (this PR) only changed new-forecast emission — existing pending
unrest count rows would stay stuck as unresolvable hard specs. Generalize the
migration to cover both families.

- migratePendingConflictCountEntryToJudged -> table-driven
  migratePendingCountEntryToJudged over UNAVAILABLE_COUNT_FEED_MIGRATIONS
  (conflict + unrest), each with its own flag gate + judged-question builder.
- unrest question mirrors the generator-side political/instability phrasing.
- Tests: new unrest-migration test; the two existing conflict+unrest ledger
  tests now assert unrest rows migrate to judged (they encoded the old
  unrest-resolves premise). 150 forecast tests green.

Related: #5091, #5138

Claude-Session: https://claude.ai/code/session_01StNurp4TGC3JLHbTtKJhbp

* test(forecast): lock unrest threshold scaling
@koala73

koala73 commented Jul 10, 2026

Copy link
Copy Markdown
Owner Author

🔧 Fix plan — judged forecasts perpetual-pend / forced-VOID (48h archive window) — subagent-ready

Now urgent: conflict + unrest were reclassified to judged (#5138/#5142, deployed 07-10). They're 7d/30d-horizon, so they hit this trap head-on. First judged deadlines mature ~2026-07-14 — fix before then or the whole reclassification yields VOIDs, not YES/NO.

The bug (verified against origin/main, prod data)

A judged forecast (gen T0, deadline D=T0+horizon, resolved at now≈D):

  1. judgedArchiveWindowForEntry (seed-forecast-resolutions.mjs:408) anchors the required evidence window on min(generatedAt, firstSeenAt, deadline, now) = generatedAt[T0−6h, now] (the entire 7–30d horizon).
  2. readDigestAccumulatorArchive (:1040) clamps the read to coverageStartMs = max(windowStart, now − JUDGED_ARCHIVE_RETENTION_MS) where JUDGED_ARCHIVE_RETENTION_MS = 48h (:34).
  3. archiveCoversEntryWindow (:398) requires coverageStart(now−48h) ≤ requiredStart(T0−6h)false for any horizon > ~48h; it also returns false whenever archiveInput.truncated (the 3,000 hash-cap, :1070).
  4. resolveJudgedEntry (:222): dual-agreement (:271) seals YES/NO ignoring completeness — the ONLY resolve path. Otherwise if (!archiveComplete) return pending (:274) → sits pending-judge until maybeExpireJudgedEntry (:197) VOIDs it after 14 attempts + 14d (judge_retry_exhausted).

Net: long-horizon judged forecasts resolve only on dual-agreement from the last 48h; everything else → 14d limbo → VOID. No real Brier signal.

Data (prod, 2026-07-10): accumulator digest:accumulator:v1:full:en retains 93 days (oldest 2026-04-08) — widening is feasible. Story volume: 48h=2,341 (fits 3k cap); 7d=6,989 / 14d=14,398 / 30d=31,577 all exceed the 3,000 hash captruncated=true on any window >48h → the completeness gate is guaranteed to fail once widened. So widening the window and fixing the truncated conflation must happen together.

Root cause (two coupled defects)

  • R1 — wrong required window: anchored on generation, not the deadline. "Did X happen by D" is answered by news in the run-up to / just after D, not the entire horizon.
  • R2 — completeness conflates "reached the window" with "read every story": truncated (hit hash cap) is treated as "incomplete window," so any window with >3k stories can never be "complete," forcing pending → forced-VOID.

The fix (do all three together)

Part 1 — Deadline-centric required window. Rewrite judgedArchiveWindowForEntry:

const deadline = Number(entry?.deadline ?? entry?.spec?.deadline);
const anchor = Number.isFinite(deadline) ? deadline : nowMs;
return { startMs: Math.max(0, anchor - JUDGED_EVIDENCE_LOOKBACK_MS), endMs: nowMs };

New const JUDGED_EVIDENCE_LOOKBACK_MS (default 7d, env FORECAST_RESOLUTION_JUDGE_EVIDENCE_LOOKBACK_MS). Window is now [D−7d, now] — bounded (~8–9d incl. settlement) regardless of horizon. JUDGED_ARCHIVE_LOOKBACK_PAD_MS (6h) is subsumed; keep or fold in.

Part 2 — Read the deadline-centric window; stop clamping to 48h. In readDigestAccumulatorArchive:

  • coverageStartMs = max(windowStartMs, nowMs − JUDGED_EVIDENCE_MAX_LOOKBACK_MS) — replace the 48h clamp with a max lookback cap (default 14d, env-tunable) that is ≥ the 7d lookback. The accumulator's 93d retention supports it.
  • Raise DEFAULT_JUDGED_ARCHIVE_HASH_LIMIT so a 7–14d window fits without recency-truncation: 15,000 (14d≈14.4k). Pipeline of ~15k HGETALLs is bounded and daily; verify it stays within archiveTimeoutMs (bump to 20–25s) — if too heavy, page the ZSET in chunks. ZREVRANGEBYSCORE is recency-ordered, so if the cap is hit the retained set is the most-recent (deadline neighborhood) — acceptable, but do NOT mark it incomplete (Part 3).

Part 3 — Decouple truncated from completeness; seal matured entries. In archiveCoversEntryWindow:

  • Remove if (archiveInput.truncated) return false. Completeness = did the read reach the required deadline-centric start: coverageStartMs ≤ startMs && coverageEndMs ≥ endMs. Hitting the hash cap within the window = sampled-most-recent, not incomplete.
  • Keep truncated only as a logged sampling note (already warned at :1072).
  • Consequence in resolveJudgedEntry: for any matured entry the window is now covered → agreement seals YES/NO; disagreement / all-VOID / no-evidence → VOID immediately (:277, honest terminal, Brier-excluded) instead of 14d→judge_retry_exhausted. maybeExpireJudgedEntry becomes a rare safety net for genuinely archive-unavailable runs only.

Tests (RED-first, tests/forecast-resolutions-seeder.test.mjs / forecast-resolution-eval — use the judgeModels doubles + injected archive)

  1. The trap, fixed: a 30d-horizon judged entry, now = deadline+1h, archive spanning [deadline−7d, now] with truncated:truearchiveCoversEntryWindow is true; disagreeing judges → VOID (judge_disagreement), NOT pending. (Before: pending.)
  2. Deadline-centric window: judgedArchiveWindowForEntry for a generatedAt=T0, deadline=T0+30d entry returns startMs = deadline − 7d (not T0−6h).
  3. Resolution still works: agreeing judges with a matching citation → YES/NO sealed (unchanged).
  4. Genuinely-unavailable still pends: archiveInput.available === falsepending (archive_unavailable), untouched.
  5. No-evidence on a covered window → VOID (no_archive_evidence), not pending.
  6. readDigestAccumulatorArchive unit: coverageStartMs = deadline-lookback (capped at max), not now−48h.

Validation (post-deploy, before/around 07-14)

  • Watch forecast:scorecard:v1: pendingJudge should start falling as due judged entries resolve/VOID; scored/void rise. Scorecard-watch M2 fires on the first judged resolution.
  • VOID-rate guardrail: immediate-VOID-on-disagreement can raise VOID rate. If voidRate on judged spikes (>~40%), the wider window isn't producing agreement — consider a 3rd tiebreak judge or majority-lean (follow-up, not v1).
  • Confirm judge cost/latency stays within the run budget (FORECAST_RESOLUTION_JUDGE_RUN_BUDGET_MS) with the larger evidence pool + 15k-hash reads.

Config knobs added

FORECAST_RESOLUTION_JUDGE_EVIDENCE_LOOKBACK_MS (7d), FORECAST_RESOLUTION_JUDGE_EVIDENCE_MAX_LOOKBACK_MS (14d), FORECAST_RESOLUTION_JUDGE_ARCHIVE_HASH_LIMIT (→15000). All default-safe; no schema/proto change.

Deploy note

seed-forecast-resolutions auto-deploy is blocked by the checkSuites skip — deploy manually via serviceInstanceDeployV2(serviceId, environmentId, commitSha) then deploymentInstanceExecutionCreate(input:{serviceInstanceId}) (see #5062).

Related: #5091 (this is the open verification gate), #5138/#5142 (conflict/unrest now judged), #5062 (deploy method).

@koala73

koala73 commented Jul 10, 2026

Copy link
Copy Markdown
Owner Author

☝️ Tracked as a live work item in #5183 (this is a merged PR — the fix lives there for a subagent to own).

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