From 23766f09e113accff797cfe74be30fb7891f59c5 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:03:19 -0700 Subject: [PATCH] =?UTF-8?q?feat(engine):=20AMS=20calibration=20corpus=20?= =?UTF-8?q?=E2=80=94=20pair=20predicted=20gate=20verdicts=20with=20realize?= =?UTF-8?q?d=20PR=20outcomes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #8172 epic's foundation (#8183): buildAmsPredictionCorpus turns the miner's own prediction-ledger rows + pr_outcome events into the exact labeled BacktestCase shape the shared calibration primitives consume. Class mapping: agreement between a directional prediction (merge/close) and the realized outcome labels confirmed; contradiction labels reversed — the miner's reversal analog. Hold-shaped/unrecognized predictions, pending PRs, non-terminal outcomes, and malformed rows all skip conservatively. Re-predictions of one head collapse to the latest; distinct heads each stand as their own correctly-labeled case. The readiness score (0-100) normalizes into the [0,1] confidence field the threshold classifier replays against, and engineVersion rides case metadata with a filterCasesByEngineVersion helper for comparable-build slicing. Everything downstream (score/compare/split/render) is reused untouched — adapters over the miner ledger, not new math. Miner side: two thin projections in calibration-cli.ts and a corpus stats line (aggregates only, corpus content never prints) in both text and --json output — the local evidence base #8184's backtest will replay against. Fully local: a node's corpus never leaves the node. Closes #8183 --- .../src/calibration/ams-prediction-corpus.ts | 147 +++++++++++++++++ packages/loopover-engine/src/index.ts | 1 + .../loopover-miner/lib/calibration-cli.ts | 57 ++++++- test/unit/ams-prediction-corpus.test.ts | 150 ++++++++++++++++++ test/unit/miner-calibration-cli.test.ts | 41 ++++- 5 files changed, 389 insertions(+), 7 deletions(-) create mode 100644 packages/loopover-engine/src/calibration/ams-prediction-corpus.ts create mode 100644 test/unit/ams-prediction-corpus.test.ts diff --git a/packages/loopover-engine/src/calibration/ams-prediction-corpus.ts b/packages/loopover-engine/src/calibration/ams-prediction-corpus.ts new file mode 100644 index 0000000000..dc61ce6b73 --- /dev/null +++ b/packages/loopover-engine/src/calibration/ams-prediction-corpus.ts @@ -0,0 +1,147 @@ +// AMS-side calibration corpus (#8183, epic #8172): pair the miner's own predicted gate verdicts +// (prediction-ledger rows) with the realized PR outcomes it later observed (event-ledger `pr_outcome` +// events) into the SAME labeled BacktestCase shape the ORB calibration primitives consume. Cases are built +// directly (each prediction IS the decision instance and the outcome IS its verdict — there is no +// nearest-following-override ambiguity to resolve, so routing through buildBacktestCorpus would only +// launder per-prediction labels through a per-target event pairing that can mislabel multi-head PRs); +// everything downstream — scoreBacktest, compareBacktestScores, splitBacktestCorpus, the renderer — is +// reused untouched, which is the reuse boundary #8172 draws: adapters over the miner ledger, not new math. +// +// CLASS MAPPING (the miner's reversal analog): a prediction is one directional call — `merge` or `close` — +// and the realized outcome either agrees (label `confirmed`, the prediction was right) or contradicts it +// (label `reversed`, the prediction was wrong: a merge-shaped prediction whose PR was CLOSED, or a +// close-shaped prediction whose PR was MERGED). `hold`-shaped and unrecognized predictions carry no +// direction a realized outcome can confirm or reverse, so they are skipped — the same "only the decided +// ones count" posture buildCalibrationReport (packages/loopover-miner/lib/calibration.ts) takes. +// +// Fully local by design: both inputs come from a single node's own ledgers, and nothing here performs IO. +import type { BacktestCase } from "./backtest-corpus.js"; + +/** The synthetic rule id AMS prediction cases are labeled under — one rule, mirroring how #8157 mapped + * ORB's decision-level history onto `ai_consensus_defect`. Never reuse an ORB rule id here: the two + * deployments' corpora must stay distinguishable at a glance. */ +export const AMS_GATE_PREDICTION_RULE_ID = "ams_gate_prediction"; + +/** A prediction-ledger row, as the miner's thin reader projects it (lib/prediction-ledger.ts's entries). */ +export type AmsPredictionRecord = { + repoFullName: string; + /** The PR number the prediction targeted. */ + targetId: number; + headSha: string | null; + /** The predicted gate verdict (`merge`/`close`/`hold`/…). */ + conclusion: string; + readinessScore: number | null; + /** Which engine build produced the prediction — carried into case metadata so a corpus can be filtered + * to comparable builds ({@link filterCasesByEngineVersion}). */ + engineVersion: string; + /** ISO timestamp the prediction was recorded. */ + ts: string; +}; + +/** The latest realized outcome for one PR, as readPrOutcomes (lib/pr-outcome.ts) reduces the event + * stream: `merged` or `closed` (anything else is not terminal and never labels a case). */ +export type AmsRealizedOutcome = { + repoFullName: string; + prNumber: number; + decision: string; + /** ISO timestamp the outcome was observed. */ + recordedAt: string; +}; + +/** + * Build the labeled AMS prediction corpus. Deterministic join key: repo + PR number. Re-predictions of the + * SAME (repo, PR, headSha) collapse to the LATEST by timestamp — one decision instance per head, matching + * the precision join's per-decision counting — while predictions for DIFFERENT heads of one PR each stand + * as their own case (each was a real call the node made). Malformed rows on either side are skipped, never + * guessed at. Pure and deterministic: same ledgers in, same corpus out. + */ +export function buildAmsPredictionCorpus( + predictions: readonly AmsPredictionRecord[], + outcomes: readonly AmsRealizedOutcome[], +): BacktestCase[] { + const outcomeByTarget = new Map(); + for (const outcome of outcomes) { + const direction = outcome.decision === "merged" ? "merge" : outcome.decision === "closed" ? "close" : null; + if (!direction || !outcome.repoFullName.trim() || !Number.isInteger(outcome.prNumber)) continue; + if (!Number.isFinite(Date.parse(outcome.recordedAt))) continue; + // Inputs come from readPrOutcomes' latest-per-PR reduction already; when a caller hands raw duplicates + // anyway, the last entry wins — the same "a later event supersedes" contract that reducer documents. + outcomeByTarget.set(`${outcome.repoFullName}#${outcome.prNumber}`, { direction, recordedAt: outcome.recordedAt }); + } + + // Latest prediction per (repo, PR, headSha). + const latestPerHead = new Map(); + for (const prediction of predictions) { + if (!prediction.repoFullName.trim() || !Number.isInteger(prediction.targetId)) continue; + if (!Number.isFinite(Date.parse(prediction.ts))) continue; + const direction = predictionDirection(prediction.conclusion); + if (!direction) continue; // hold-shaped or unrecognized: no direction to confirm/reverse + const key = `${prediction.repoFullName}#${prediction.targetId}@${prediction.headSha ?? ""}`; + const existing = latestPerHead.get(key); + if (!existing || existing.ts < prediction.ts) latestPerHead.set(key, prediction); + } + + const cases: BacktestCase[] = []; + for (const prediction of latestPerHead.values()) { + const targetKey = `${prediction.repoFullName}#${prediction.targetId}`; + const outcome = outcomeByTarget.get(targetKey); + if (!outcome) continue; // still pending: undecided predictions never enter the corpus + const direction = predictionDirection(prediction.conclusion)!; + const metadata: Record = { engineVersion: prediction.engineVersion }; + if (prediction.headSha !== null) metadata.headSha = prediction.headSha; + // The threshold classifier replays against `confidence` on the [0, 1] scale ORB's confidences use; + // the readiness score is the miner's native confidence signal on a 0-100 scale (gate-advisory.ts + // renders it as "N/100"), so it is normalized here. Out-of-range/absent stays unset — never guessed. + if (prediction.readinessScore !== null && Number.isFinite(prediction.readinessScore) && prediction.readinessScore >= 0 && prediction.readinessScore <= 100) { + metadata.confidence = prediction.readinessScore / 100; + } + // Each surviving prediction labels against ITS OWN direction — a multi-head PR whose heads predicted + // opposite directions yields one confirmed and one reversed case, both correct. + cases.push({ + ruleId: AMS_GATE_PREDICTION_RULE_ID, + targetKey, + outcome: direction, + label: outcome.direction === direction ? "confirmed" : "reversed", + firedAt: prediction.ts, + decidedAt: outcome.recordedAt, + metadata, + }); + } + // Deterministic output order regardless of Map iteration: by target, then firedAt, then head — one + // composite key so the comparator has no order-of-evaluation-dependent arms (keys are unique: the + // latest-per-head collapse above removed any (target, head) duplicate). + const sortKey = (backtestCase: BacktestCase) => `${backtestCase.targetKey}\u0000${backtestCase.firedAt}\u0000${String(backtestCase.metadata?.headSha ?? "")}`; + return cases.sort((a, b) => (sortKey(a) < sortKey(b) ? -1 : 1)); +} + +/** Restrict a corpus to the cases one engine build produced — comparable-build filtering (#8183). */ +export function filterCasesByEngineVersion(cases: readonly BacktestCase[], engineVersion: string): BacktestCase[] { + return cases.filter((backtestCase) => backtestCase.metadata?.engineVersion === engineVersion); +} + +export type AmsCorpusStats = { + cases: number; + confirmed: number; + reversed: number; + /** Distinct engine builds present, ascending — the filter axis {@link filterCasesByEngineVersion} serves. */ + engineVersions: string[]; +}; + +/** Aggregate numbers only — the shape the calibration CLI prints (#8183's read surface). */ +export function computeAmsCorpusStats(cases: readonly BacktestCase[]): AmsCorpusStats { + const engineVersions = new Set(); + let confirmed = 0; + let reversed = 0; + for (const backtestCase of cases) { + if (backtestCase.label === "confirmed") confirmed += 1; + else reversed += 1; + const version = backtestCase.metadata?.engineVersion; + if (typeof version === "string" && version !== "") engineVersions.add(version); + } + return { cases: cases.length, confirmed, reversed, engineVersions: [...engineVersions].sort() }; +} + +function predictionDirection(conclusion: string): "merge" | "close" | null { + const normalized = conclusion.trim().toLowerCase(); + return normalized === "merge" || normalized === "close" ? normalized : null; +} diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index 40566f3b27..b20dffd62c 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -164,6 +164,7 @@ export * from "./governor/action-mode.js"; export * from "./governor/chokepoint.js"; export * from "./calibration/signal-tracking.js"; export * from "./calibration/backtest-corpus.js"; +export * from "./calibration/ams-prediction-corpus.js"; export * from "./calibration/backtest-score.js"; export * from "./calibration/backtest-compare.js"; export * from "./calibration/backtest-report.js"; diff --git a/packages/loopover-miner/lib/calibration-cli.ts b/packages/loopover-miner/lib/calibration-cli.ts index 8721121d6b..c1e885f999 100644 --- a/packages/loopover-miner/lib/calibration-cli.ts +++ b/packages/loopover-miner/lib/calibration-cli.ts @@ -2,6 +2,8 @@ // verdicts (prediction-ledger) with the realized PR outcomes it later observed (event-ledger `pr_outcome` // events), via the pure buildCalibrationReport join. Opens both local stores, maps their rows to the // calibration record shapes, renders, and closes. Never modifies the live scoring/calibration logic. +import { AMS_GATE_PREDICTION_RULE_ID, buildAmsPredictionCorpus, computeAmsCorpusStats } from "@loopover/engine"; +import type { AmsPredictionRecord, AmsRealizedOutcome } from "@loopover/engine"; import { buildCalibrationReport } from "./calibration.js"; import { initEventLedger, resolveEventLedgerDbPath } from "./event-ledger.js"; import type { LedgerEntry } from "./event-ledger.js"; @@ -48,6 +50,37 @@ export function toOutcomeRecords(events: LedgerEntry[]): ObservedOutcomeRecord[] return [...latest.values()]; } +/** Project prediction-ledger rows into the engine adapter's record shape (#8183). Exported for the same + * reuse reason as {@link toPredictionRecords}. */ +export function toAmsPredictionRecords(rows: PredictionLedgerEntry[]): AmsPredictionRecord[] { + return rows.map((row) => ({ + repoFullName: row.repoFullName, + targetId: row.targetId, + headSha: row.headSha, + conclusion: row.conclusion, + readinessScore: row.readinessScore, + engineVersion: row.engineVersion, + ts: row.ts, + })); +} + +/** Reduce pr_outcome events to the engine adapter's realized-outcome shape — latest per (repo, PR), the + * same reduction contract readPrOutcomes documents (#8183). */ +export function toAmsRealizedOutcomes(events: LedgerEntry[]): AmsRealizedOutcome[] { + const latest = new Map(); + for (const event of events) { + if (event?.type !== MINER_PR_OUTCOME_EVENT) continue; + const prNumber = event.payload?.prNumber; + const decision = event.payload?.decision; + if (typeof prNumber !== "number" || !Number.isInteger(prNumber) || typeof decision !== "string") continue; + if (typeof event.repoFullName !== "string" || !event.repoFullName.trim()) continue; + const key = `${event.repoFullName}:${prNumber}`; + latest.delete(key); // re-key so a later outcome supersedes in iteration order too (mirrors readPrOutcomes) + latest.set(key, { repoFullName: event.repoFullName, prNumber, decision, recordedAt: event.createdAt }); + } + return [...latest.values()]; +} + function renderReportText(report: CalibrationReport): void { if (!report.hasSignal) { console.log("calibration: no decided predictions yet (predictions need a realized merge/close outcome)."); @@ -84,12 +117,24 @@ export function runCalibrationCli(args: string[] = [], env: Record 0: appendPrediction refuses a blank + // engineVersion at the ledger boundary (normalizePredictionInput's invalid_engine_version). + `corpus (${AMS_GATE_PREDICTION_RULE_ID}): ${corpusStats.cases} case(s) | confirmed ${corpusStats.confirmed} | reversed ${corpusStats.reversed} | engine build(s): ${corpusStats.engineVersions.join(", ")}`, + ); + } return 0; } catch (error) { return reportCliFailure(json, describeCliError(error)); diff --git a/test/unit/ams-prediction-corpus.test.ts b/test/unit/ams-prediction-corpus.test.ts new file mode 100644 index 0000000000..732c379d79 --- /dev/null +++ b/test/unit/ams-prediction-corpus.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +// Direct src-path import (not the `@loopover/engine` package barrel, which resolves to dist and is NOT in +// Codecov's measured set) — the engine-package blind-spot rule every engine mirror suite follows. +import { + AMS_GATE_PREDICTION_RULE_ID, + buildAmsPredictionCorpus, + computeAmsCorpusStats, + filterCasesByEngineVersion, + type AmsPredictionRecord, + type AmsRealizedOutcome, +} from "../../packages/loopover-engine/src/calibration/ams-prediction-corpus.js"; +import { splitBacktestCorpus } from "../../packages/loopover-engine/src/calibration/backtest-split.js"; + +// #8183: the AMS corpus adapter. The load-bearing properties: the class mapping (agreement = confirmed, +// contradiction = reversed), the latest-per-head collapse, conservative skipping of everything +// directionless or malformed, and compatibility with the shared split primitive. + +function prediction(over: Partial = {}): AmsPredictionRecord { + return { + repoFullName: "acme/widgets", + targetId: 7, + headSha: "head-1", + conclusion: "merge", + readinessScore: 82, + engineVersion: "1.4.0", + ts: "2026-07-01T00:00:00.000Z", + ...over, + }; +} + +function outcome(over: Partial = {}): AmsRealizedOutcome { + return { + repoFullName: "acme/widgets", + prNumber: 7, + decision: "merged", + recordedAt: "2026-07-02T00:00:00.000Z", + ...over, + }; +} + +describe("buildAmsPredictionCorpus (#8183)", () => { + it("labels agreement confirmed and contradiction reversed — both directions", () => { + const cases = buildAmsPredictionCorpus( + [ + prediction(), // merge-pred, merged -> confirmed + prediction({ targetId: 8, conclusion: "close", headSha: "head-8" }), // close-pred, closed -> confirmed + prediction({ targetId: 9, conclusion: "merge", headSha: "head-9" }), // merge-pred, closed -> reversed + prediction({ targetId: 10, conclusion: "close", headSha: "head-10" }), // close-pred, merged -> reversed + ], + [outcome(), outcome({ prNumber: 8, decision: "closed" }), outcome({ prNumber: 9, decision: "closed" }), outcome({ prNumber: 10, decision: "merged" })], + ); + expect(cases.map((c) => [c.targetKey, c.outcome, c.label])).toEqual([ + ["acme/widgets#10", "close", "reversed"], + ["acme/widgets#7", "merge", "confirmed"], + ["acme/widgets#8", "close", "confirmed"], + ["acme/widgets#9", "merge", "reversed"], + ]); + expect(cases.every((c) => c.ruleId === AMS_GATE_PREDICTION_RULE_ID)).toBe(true); + expect(cases[1]!.metadata).toMatchObject({ engineVersion: "1.4.0", headSha: "head-1", confidence: 0.82 }); // 82/100 normalized + expect(cases[1]!).toMatchObject({ firedAt: "2026-07-01T00:00:00.000Z", decidedAt: "2026-07-02T00:00:00.000Z" }); + }); + + it("collapses re-predictions of the same head to the LATEST; different heads each stand as their own correctly-labeled case", () => { + const cases = buildAmsPredictionCorpus( + [ + prediction({ conclusion: "close", ts: "2026-07-01T00:00:00.000Z" }), // superseded re-prediction of head-1 + prediction({ conclusion: "merge", ts: "2026-07-01T06:00:00.000Z" }), // latest for head-1 -> confirmed vs merged + prediction({ headSha: "head-2", conclusion: "close", ts: "2026-07-01T12:00:00.000Z" }), // other head -> reversed vs merged + ], + [outcome()], + ); + expect(cases).toHaveLength(2); + expect(cases.map((c) => [c.metadata?.headSha, c.outcome, c.label])).toEqual([ + ["head-1", "merge", "confirmed"], + ["head-2", "close", "reversed"], + ]); + }); + + it("orders deterministically through full ties: same PR, same timestamp, different heads (both comparator arms)", () => { + const simultaneous = [ + prediction({ headSha: "head-b", ts: "2026-07-01T00:00:00.000Z" }), + prediction({ headSha: "head-a", ts: "2026-07-01T00:00:00.000Z" }), + ]; + const forward = buildAmsPredictionCorpus(simultaneous, [outcome()]); + const reversedInput = buildAmsPredictionCorpus([...simultaneous].reverse(), [outcome()]); + expect(forward.map((c) => c.metadata?.headSha)).toEqual(["head-a", "head-b"]); + expect(reversedInput.map((c) => c.metadata?.headSha)).toEqual(["head-a", "head-b"]); // input order never leaks + }); + + it("skips everything conservative labeling demands: pending PRs, hold/unknown predictions, non-terminal outcomes, malformed rows", () => { + const cases = buildAmsPredictionCorpus( + [ + prediction({ targetId: 50 }), // no outcome at all -> pending + prediction({ conclusion: "hold" }), // directionless + prediction({ conclusion: "escalate" }), // unrecognized + prediction({ repoFullName: " " }), // malformed repo + prediction({ targetId: 7.5 }), // non-integer target + prediction({ ts: "not-a-date" }), // unparseable timestamp + ], + [ + outcome(), + outcome({ prNumber: 51, decision: "reopened" }), // non-terminal decision + outcome({ repoFullName: "" }), // malformed repo + outcome({ prNumber: 52, recordedAt: "garbage" }), // unparseable timestamp + ], + ); + expect(cases).toEqual([]); + }); + + it("keeps null headSha and out-of-range/absent readiness out of metadata, and the corpus splits deterministically with the shared primitive", () => { + const predictions = Array.from({ length: 40 }, (_, i) => + prediction({ targetId: i + 1, headSha: null, readinessScore: i % 3 === 0 ? null : i % 3 === 1 ? 170 : 50 }), + ); + const outcomes = Array.from({ length: 40 }, (_, i) => outcome({ prNumber: i + 1 })); + const cases = buildAmsPredictionCorpus(predictions, outcomes); + expect(cases).toHaveLength(40); + expect(cases.every((c) => !("headSha" in c.metadata!))).toBe(true); + expect(cases.filter((c) => "confidence" in c.metadata!)).toHaveLength(13); // only the in-range third + const { visible, heldOut } = splitBacktestCorpus(cases, 0.25, "ams-corpus-test-seed"); + expect(visible.length + heldOut.length).toBe(40); + const again = splitBacktestCorpus(buildAmsPredictionCorpus(predictions, outcomes), 0.25, "ams-corpus-test-seed"); + expect(again.heldOut.map((c) => c.targetKey)).toEqual(heldOut.map((c) => c.targetKey)); // held-out membership stable + }); +}); + +describe("filterCasesByEngineVersion / computeAmsCorpusStats (#8183)", () => { + it("filters to one engine build and aggregates numbers only", () => { + const cases = buildAmsPredictionCorpus( + [ + prediction(), + prediction({ targetId: 9, headSha: "head-9", engineVersion: "1.5.0" }), + prediction({ targetId: 10, headSha: "head-10", conclusion: "close", engineVersion: "1.5.0" }), + ], + [outcome(), outcome({ prNumber: 9, decision: "closed" }), outcome({ prNumber: 10, decision: "closed" })], + ); + expect(filterCasesByEngineVersion(cases, "1.5.0").map((c) => c.targetKey)).toEqual(["acme/widgets#10", "acme/widgets#9"]); + expect(filterCasesByEngineVersion(cases, "0.0.1")).toEqual([]); + + const stats = computeAmsCorpusStats(cases); + expect(stats).toEqual({ cases: 3, confirmed: 2, reversed: 1, engineVersions: ["1.4.0", "1.5.0"] }); + expect(JSON.stringify(stats)).not.toMatch(/acme|head-|targetKey/); // aggregates only, no corpus content + }); + + it("handles the empty corpus and metadata-free cases (blank/missing engine version)", () => { + expect(computeAmsCorpusStats([])).toEqual({ cases: 0, confirmed: 0, reversed: 0, engineVersions: [] }); + const blankVersion = buildAmsPredictionCorpus([prediction({ engineVersion: "" })], [outcome()]); + expect(computeAmsCorpusStats(blankVersion).engineVersions).toEqual([]); + expect(filterCasesByEngineVersion([{ ruleId: "x", targetKey: "a#1", outcome: "merge", label: "confirmed", firedAt: "t", decidedAt: "t" }], "1.0.0")).toEqual([]); + }); +}); diff --git a/test/unit/miner-calibration-cli.test.ts b/test/unit/miner-calibration-cli.test.ts index 8245ffecb7..0bca476d98 100644 --- a/test/unit/miner-calibration-cli.test.ts +++ b/test/unit/miner-calibration-cli.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { runCalibrationCli } from "../../packages/loopover-miner/lib/calibration-cli.js"; +import { runCalibrationCli, toAmsRealizedOutcomes } from "../../packages/loopover-miner/lib/calibration-cli.js"; import { initEventLedger, resolveEventLedgerDbPath } from "../../packages/loopover-miner/lib/event-ledger.js"; import { initPredictionLedger, @@ -108,6 +108,45 @@ describe("loopover-miner calibration CLI (#4849)", () => { expect(report.rows[0]).toMatchObject({ mergeConfirmed: 1, mergeFalse: 0 }); // latest "merged" confirmed the merge }); + it("prints the #8183 corpus stats line: labeled cases with class counts + engine build, aggregates only", () => { + const env = envForTempStores(); + seedPrediction(env, 42, "merge"); // merged -> confirmed + seedPrediction(env, 43, "merge"); // closed -> reversed + seedOutcomeEvent(env, { prNumber: 42, decision: "merged" }); + seedOutcomeEvent(env, { prNumber: 43, decision: "closed" }); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + expect(runCalibrationCli([], env)).toBe(0); + const output = log.mock.calls.map((call) => String(call[0])).join("\n"); + expect(output).toContain("corpus (ams_gate_prediction): 2 case(s) | confirmed 1 | reversed 1 | engine build(s): 1.0.0"); + expect(output).not.toContain("#42"); // corpus CONTENT never prints -- aggregates only + }); + + it("renders the explicit empty-corpus line and embeds corpus stats under --json (#8183)", () => { + const env = envForTempStores(); + seedPrediction(env, 1, "merge"); // pending: no outcome -> zero labeled cases + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + expect(runCalibrationCli([], env)).toBe(0); + expect(log.mock.calls.map((c) => String(c[0])).join("\n")).toContain("corpus: no labeled cases yet"); + + log.mockClear(); + seedOutcomeEvent(env, { prNumber: 1, decision: "merged" }); + expect(runCalibrationCli(["--json"], env)).toBe(0); + const report = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(report.corpus).toEqual({ ruleId: "ams_gate_prediction", cases: 1, confirmed: 1, reversed: 0, engineVersions: ["1.0.0"] }); + }); + + it("toAmsRealizedOutcomes drops events without a usable repoFullName (#8183)", () => { + // Direct mapper call: LedgerEntry.repoFullName is nullable for other event kinds, and the mapper must + // never fabricate a repo for a malformed pr_outcome row. + const rows = toAmsRealizedOutcomes([ + { id: 1, seq: 1, type: "pr_outcome", repoFullName: null, payload: { prNumber: 5, decision: "merged" }, createdAt: "2026-07-01T00:00:00.000Z" }, + { id: 2, seq: 2, type: "pr_outcome", repoFullName: " ", payload: { prNumber: 6, decision: "merged" }, createdAt: "2026-07-01T00:00:00.000Z" }, + { id: 3, seq: 3, type: "pr_outcome", repoFullName: "acme/widgets", payload: { prNumber: 7, decision: "closed" }, createdAt: "2026-07-01T00:00:00.000Z" }, + ] as never); + expect(rows).toEqual([{ repoFullName: "acme/widgets", prNumber: 7, decision: "closed", recordedAt: "2026-07-01T00:00:00.000Z" }]); + }); + it("rejects an unknown option with exit code 1", () => { const err = vi.spyOn(console, "error").mockImplementation(() => {}); expect(runCalibrationCli(["--bogus"], envForTempStores())).toBe(1);