feat(forecast): add resolution ledger and scorecard#5024
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Greptile SummaryThis PR wires up a full forecast-resolution lifecycle: a daily Railway seeder ingests published forecast specs into a durable Redis ledger, samples hard-spec source feeds, resolves terminal entries, archives R2 receipts, and publishes a Brier/log-score scorecard. A new
Confidence Score: 4/5Safe to merge; the resolution engine, scoring math, and handler are all well-tested with no correctness issues found. The new seeder, evaluator, and scorecard modules are cleanly separated, pure-function exported helpers are fully unit-tested, and the handler covers all three response paths. The only findings are: a redundant computeScorecard call inside buildLedgerForRun whose result is discarded, a misleading publicationCoverage field name (always 0 or 1, not a ratio), and a missing optional chain on entry.spec.sourceFeed in samplePendingEntries that is safe today but inconsistent with the defensive style used elsewhere. The three concerns are all in scripts/seed-forecast-resolutions.mjs and scripts/_forecast-scorecard.mjs; everything else is straightforward. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Railway as Railway Cron
participant Seeder as seed-forecast-resolutions.mjs
participant Redis as Redis (Upstash)
participant R2 as Cloudflare R2
participant Gateway as Vercel Gateway
participant Client as Client / MCP
Railway->>Seeder: daily trigger
Seeder->>Redis: LRANGE forecast:predictions:history:v1
Redis-->>Seeder: history snapshots
Seeder->>Seeder: ingestHistory
Seeder->>Redis: GET per-sourceFeed keys
Redis-->>Seeder: feed data
Seeder->>Seeder: samplePendingEntries
Seeder->>Seeder: resolveDueEntries (YES/NO/VOID)
Seeder->>R2: PUT receipt JSON (best-effort per-object)
Seeder->>Seeder: markReceiptsArchived
Seeder->>Redis: SET forecast:resolutions:v1
Seeder->>Seeder: computeScorecard
Seeder->>Redis: SET forecast:scorecard:v1
Seeder->>Redis: SET seed-meta keys
Client->>Gateway: GET /api/forecast/v1/get-forecast-scorecard
Gateway->>Redis: GET forecast:scorecard:v1
Redis-->>Gateway: scorecard envelope
Gateway-->>Client: GetForecastScorecardResponse
%%{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 Railway as Railway Cron
participant Seeder as seed-forecast-resolutions.mjs
participant Redis as Redis (Upstash)
participant R2 as Cloudflare R2
participant Gateway as Vercel Gateway
participant Client as Client / MCP
Railway->>Seeder: daily trigger
Seeder->>Redis: LRANGE forecast:predictions:history:v1
Redis-->>Seeder: history snapshots
Seeder->>Seeder: ingestHistory
Seeder->>Redis: GET per-sourceFeed keys
Redis-->>Seeder: feed data
Seeder->>Seeder: samplePendingEntries
Seeder->>Seeder: resolveDueEntries (YES/NO/VOID)
Seeder->>R2: PUT receipt JSON (best-effort per-object)
Seeder->>Seeder: markReceiptsArchived
Seeder->>Redis: SET forecast:resolutions:v1
Seeder->>Seeder: computeScorecard
Seeder->>Redis: SET forecast:scorecard:v1
Seeder->>Redis: SET seed-meta keys
Client->>Gateway: GET /api/forecast/v1/get-forecast-scorecard
Gateway->>Redis: GET forecast:scorecard:v1
Redis-->>Gateway: scorecard envelope
Gateway-->>Client: GetForecastScorecardResponse
Reviews (1): Last reviewed commit: "fix(review): harden forecast resolution ..." | Re-trigger Greptile |
| const result = processResolutionCycle(preLedger, [], feeds, nowMs); | ||
| const archivedReceipts = await appendR2Receipts(collectUnarchivedReceipts(result.ledger)); |
There was a problem hiding this comment.
The scorecard computed inside
processResolutionCycle (assigned to result.scorecard) is immediately discarded — buildLedgerForRun returns only result.ledger, and the actual scorecard written to Redis comes from the extraKeys.transform lambda which calls computeScorecard a second time. This means every production run pays the cost of two full scorecard computations. The least-invasive path is a clarifying comment, since processResolutionCycle is a shared export used in tests that do inspect .scorecard.
| const result = processResolutionCycle(preLedger, [], feeds, nowMs); | |
| const archivedReceipts = await appendR2Receipts(collectUnarchivedReceipts(result.ledger)); | |
| // processResolutionCycle also computes result.scorecard, but buildLedgerForRun | |
| // discards it — the authoritative scorecard is written by runSeed's extraKeys | |
| // transform after markReceiptsArchived. No callers need result.scorecard here. | |
| const result = processResolutionCycle(preLedger, [], feeds, nowMs); | |
| const archivedReceipts = await appendR2Receipts(collectUnarchivedReceipts(result.ledger)); |
| scored: scored.length, | ||
| void: voided.length, | ||
| voidRate: resolved.length ? round(voided.length / resolved.length) : 0, | ||
| publicationCoverage: entries.length ? 1 : 0, |
There was a problem hiding this comment.
publicationCoverage is always 0 or 1 — it's a presence flag, not a rate. Consumers expecting a ratio will find the field misleading. Consider renaming it to hasEntries or documenting it explicitly in the methodology string.
| publicationCoverage: entries.length ? 1 : 0, | |
| publicationCoverage: entries.length ? 1 : 0, // presence flag: 1 = at least one entry tracked, 0 = no entries yet |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| const parsed = parseMetricKey(entry.spec?.metricKey); | ||
| if (!parsed || parsed.fn === 'count') continue; | ||
| if (nowMs > Number(entry.deadline ?? entry.spec?.deadline)) continue; | ||
| const feedData = feedsByKey?.[entry.spec.sourceFeed] ?? feedsByKey?.[parsed.feedKey]; |
There was a problem hiding this comment.
samplePendingEntries accesses entry.spec.sourceFeed without optional chaining on .spec, while resolveDueEntries defensively uses entry.spec?.sourceFeed. The !parsed guard makes this safe today, but the inconsistency is a maintenance trap if the pattern is copied into a context where the invariant doesn't hold.
| const feedData = feedsByKey?.[entry.spec.sourceFeed] ?? feedsByKey?.[parsed.feedKey]; | |
| const feedData = feedsByKey?.[entry.spec?.sourceFeed] ?? feedsByKey?.[parsed.feedKey]; |
|
Review at head The architecture is faithful in most places — the (id, deadline-window) model is correctly implemented (rollover, probability-only pre-deadline updates, one-open-window), the retryable R2 receipt marking is better than the plan asked for, the scorecard math is clean, and the ops wiring (health 36h, railway-services, envelope-aware handler) is right. But two P0s in resolution semantics produce wrong outcomes that freeze permanently into the audit ledger — the worst failure mode this product can have. P0-1 — Every production prediction-market resolution is inverted
The at-endDate test passes only because its fixture uses P0-2 — at-deadline/at-endDate never uses an at-or-after-deadline read (plan D3 inverted); no-sample entries VOIDPlan D3: resolve as "the first sample/read at-or-after the deadline." The implementation does the opposite, in four mutually-reinforcing places: Both P0s compound with D6 flip-resistance + R2 receipts: once written, wrong outcomes are terminal and archived. On the current schedule the first inverted receipts would be written ~day 2 after deploy. P1-3 — Plan-mandated adversarial tests missingNo blowup-bound test (many hourly re-emissions of one id → exactly one open entry), no flip-resistance test (terminal entry + drifted feed fixture → unchanged outcome). The rollover/probability-only test exists and is good; the two missing ones are precisely the class that would have caught P0-1's fixture problem. P2
P3
Verified good (no action)Window model incl. multi-pending-window routing; ingest freeze rules; count skips sampling; count settlement lag; omission meta-test; scorecard determinism/nowMs injection/decile buckets/market-skill exclusions; proto |
|
Full review at head New findings this roundP1 — a
|
Summary
Forecasts can now enter an auditable resolution loop: published specs are pre-registered into a durable pending ledger, sampled against their source feeds until the authoritative deadline, resolved into scored outcomes, and rolled up into a scorecard that future track-record surfaces can read.
This starts the Bet 2 verification clock without pretending every future public-track-record feature is done. The first scorer is intentionally conservative: unresolved
countfamilies and judged specs stay pending or VOID instead of poisoning Brier math, feed gaps are recorded rather than guessed through, and the scorecard reports coverage/VOID rates alongside Brier and log score.What Changed
get-forecast-scorecardexposes the current scorecard, health now tracks the resolution ledger and scorecard metadata, and gateway caching keeps the freshness flag meaningful.get_forecast_scorecard; raw resolution ledger access is deliberately deferred until a filtered tool exists.Validation
node --test tests/forecast-resolution-eval.test.mjs tests/forecast-resolutions-seeder.test.mjs tests/forecast-get-scorecard.test.mts tests/forecast-health-registration.test.mjs tests/forecast-history.test.mjs tests/forecast-scorecard.test.mjs tests/route-cache-tier.test.mjs./node_modules/.bin/tsx --test tests/mcp-api-parity.test.mjs tests/mcp-bootstrap-parity.test.mjs tests/mcp-tools-reference-docs.test.mjsnpm run typecheck:apinpm run typechecknpm run lint:api-contractnode scripts/docs-stats.mjs --checknpm run lint:mintlify-slugsgit diff --checkResidual Risk
Related
Related: #5007
Related: #4930