|
| 1 | +// AMS-side calibration corpus (#8183, epic #8172): pair the miner's own predicted gate verdicts |
| 2 | +// (prediction-ledger rows) with the realized PR outcomes it later observed (event-ledger `pr_outcome` |
| 3 | +// events) into the SAME labeled BacktestCase shape the ORB calibration primitives consume. Cases are built |
| 4 | +// directly (each prediction IS the decision instance and the outcome IS its verdict — there is no |
| 5 | +// nearest-following-override ambiguity to resolve, so routing through buildBacktestCorpus would only |
| 6 | +// launder per-prediction labels through a per-target event pairing that can mislabel multi-head PRs); |
| 7 | +// everything downstream — scoreBacktest, compareBacktestScores, splitBacktestCorpus, the renderer — is |
| 8 | +// reused untouched, which is the reuse boundary #8172 draws: adapters over the miner ledger, not new math. |
| 9 | +// |
| 10 | +// CLASS MAPPING (the miner's reversal analog): a prediction is one directional call — `merge` or `close` — |
| 11 | +// and the realized outcome either agrees (label `confirmed`, the prediction was right) or contradicts it |
| 12 | +// (label `reversed`, the prediction was wrong: a merge-shaped prediction whose PR was CLOSED, or a |
| 13 | +// close-shaped prediction whose PR was MERGED). `hold`-shaped and unrecognized predictions carry no |
| 14 | +// direction a realized outcome can confirm or reverse, so they are skipped — the same "only the decided |
| 15 | +// ones count" posture buildCalibrationReport (packages/loopover-miner/lib/calibration.ts) takes. |
| 16 | +// |
| 17 | +// Fully local by design: both inputs come from a single node's own ledgers, and nothing here performs IO. |
| 18 | +import type { BacktestCase } from "./backtest-corpus.js"; |
| 19 | + |
| 20 | +/** The synthetic rule id AMS prediction cases are labeled under — one rule, mirroring how #8157 mapped |
| 21 | + * ORB's decision-level history onto `ai_consensus_defect`. Never reuse an ORB rule id here: the two |
| 22 | + * deployments' corpora must stay distinguishable at a glance. */ |
| 23 | +export const AMS_GATE_PREDICTION_RULE_ID = "ams_gate_prediction"; |
| 24 | + |
| 25 | +/** A prediction-ledger row, as the miner's thin reader projects it (lib/prediction-ledger.ts's entries). */ |
| 26 | +export type AmsPredictionRecord = { |
| 27 | + repoFullName: string; |
| 28 | + /** The PR number the prediction targeted. */ |
| 29 | + targetId: number; |
| 30 | + headSha: string | null; |
| 31 | + /** The predicted gate verdict (`merge`/`close`/`hold`/…). */ |
| 32 | + conclusion: string; |
| 33 | + readinessScore: number | null; |
| 34 | + /** Which engine build produced the prediction — carried into case metadata so a corpus can be filtered |
| 35 | + * to comparable builds ({@link filterCasesByEngineVersion}). */ |
| 36 | + engineVersion: string; |
| 37 | + /** ISO timestamp the prediction was recorded. */ |
| 38 | + ts: string; |
| 39 | +}; |
| 40 | + |
| 41 | +/** The latest realized outcome for one PR, as readPrOutcomes (lib/pr-outcome.ts) reduces the event |
| 42 | + * stream: `merged` or `closed` (anything else is not terminal and never labels a case). */ |
| 43 | +export type AmsRealizedOutcome = { |
| 44 | + repoFullName: string; |
| 45 | + prNumber: number; |
| 46 | + decision: string; |
| 47 | + /** ISO timestamp the outcome was observed. */ |
| 48 | + recordedAt: string; |
| 49 | +}; |
| 50 | + |
| 51 | +/** |
| 52 | + * Build the labeled AMS prediction corpus. Deterministic join key: repo + PR number. Re-predictions of the |
| 53 | + * SAME (repo, PR, headSha) collapse to the LATEST by timestamp — one decision instance per head, matching |
| 54 | + * the precision join's per-decision counting — while predictions for DIFFERENT heads of one PR each stand |
| 55 | + * as their own case (each was a real call the node made). Malformed rows on either side are skipped, never |
| 56 | + * guessed at. Pure and deterministic: same ledgers in, same corpus out. |
| 57 | + */ |
| 58 | +export function buildAmsPredictionCorpus( |
| 59 | + predictions: readonly AmsPredictionRecord[], |
| 60 | + outcomes: readonly AmsRealizedOutcome[], |
| 61 | +): BacktestCase[] { |
| 62 | + const outcomeByTarget = new Map<string, { direction: "merge" | "close"; recordedAt: string }>(); |
| 63 | + for (const outcome of outcomes) { |
| 64 | + const direction = outcome.decision === "merged" ? "merge" : outcome.decision === "closed" ? "close" : null; |
| 65 | + if (!direction || !outcome.repoFullName.trim() || !Number.isInteger(outcome.prNumber)) continue; |
| 66 | + if (!Number.isFinite(Date.parse(outcome.recordedAt))) continue; |
| 67 | + // Inputs come from readPrOutcomes' latest-per-PR reduction already; when a caller hands raw duplicates |
| 68 | + // anyway, the last entry wins — the same "a later event supersedes" contract that reducer documents. |
| 69 | + outcomeByTarget.set(`${outcome.repoFullName}#${outcome.prNumber}`, { direction, recordedAt: outcome.recordedAt }); |
| 70 | + } |
| 71 | + |
| 72 | + // Latest prediction per (repo, PR, headSha). |
| 73 | + const latestPerHead = new Map<string, AmsPredictionRecord>(); |
| 74 | + for (const prediction of predictions) { |
| 75 | + if (!prediction.repoFullName.trim() || !Number.isInteger(prediction.targetId)) continue; |
| 76 | + if (!Number.isFinite(Date.parse(prediction.ts))) continue; |
| 77 | + const direction = predictionDirection(prediction.conclusion); |
| 78 | + if (!direction) continue; // hold-shaped or unrecognized: no direction to confirm/reverse |
| 79 | + const key = `${prediction.repoFullName}#${prediction.targetId}@${prediction.headSha ?? ""}`; |
| 80 | + const existing = latestPerHead.get(key); |
| 81 | + if (!existing || existing.ts < prediction.ts) latestPerHead.set(key, prediction); |
| 82 | + } |
| 83 | + |
| 84 | + const cases: BacktestCase[] = []; |
| 85 | + for (const prediction of latestPerHead.values()) { |
| 86 | + const targetKey = `${prediction.repoFullName}#${prediction.targetId}`; |
| 87 | + const outcome = outcomeByTarget.get(targetKey); |
| 88 | + if (!outcome) continue; // still pending: undecided predictions never enter the corpus |
| 89 | + const direction = predictionDirection(prediction.conclusion)!; |
| 90 | + const metadata: Record<string, unknown> = { engineVersion: prediction.engineVersion }; |
| 91 | + if (prediction.headSha !== null) metadata.headSha = prediction.headSha; |
| 92 | + // The threshold classifier replays against `confidence` on the [0, 1] scale ORB's confidences use; |
| 93 | + // the readiness score is the miner's native confidence signal on a 0-100 scale (gate-advisory.ts |
| 94 | + // renders it as "N/100"), so it is normalized here. Out-of-range/absent stays unset — never guessed. |
| 95 | + if (prediction.readinessScore !== null && Number.isFinite(prediction.readinessScore) && prediction.readinessScore >= 0 && prediction.readinessScore <= 100) { |
| 96 | + metadata.confidence = prediction.readinessScore / 100; |
| 97 | + } |
| 98 | + // Each surviving prediction labels against ITS OWN direction — a multi-head PR whose heads predicted |
| 99 | + // opposite directions yields one confirmed and one reversed case, both correct. |
| 100 | + cases.push({ |
| 101 | + ruleId: AMS_GATE_PREDICTION_RULE_ID, |
| 102 | + targetKey, |
| 103 | + outcome: direction, |
| 104 | + label: outcome.direction === direction ? "confirmed" : "reversed", |
| 105 | + firedAt: prediction.ts, |
| 106 | + decidedAt: outcome.recordedAt, |
| 107 | + metadata, |
| 108 | + }); |
| 109 | + } |
| 110 | + // Deterministic output order regardless of Map iteration: by target, then firedAt, then head — one |
| 111 | + // composite key so the comparator has no order-of-evaluation-dependent arms (keys are unique: the |
| 112 | + // latest-per-head collapse above removed any (target, head) duplicate). |
| 113 | + const sortKey = (backtestCase: BacktestCase) => `${backtestCase.targetKey}\u0000${backtestCase.firedAt}\u0000${String(backtestCase.metadata?.headSha ?? "")}`; |
| 114 | + return cases.sort((a, b) => (sortKey(a) < sortKey(b) ? -1 : 1)); |
| 115 | +} |
| 116 | + |
| 117 | +/** Restrict a corpus to the cases one engine build produced — comparable-build filtering (#8183). */ |
| 118 | +export function filterCasesByEngineVersion(cases: readonly BacktestCase[], engineVersion: string): BacktestCase[] { |
| 119 | + return cases.filter((backtestCase) => backtestCase.metadata?.engineVersion === engineVersion); |
| 120 | +} |
| 121 | + |
| 122 | +export type AmsCorpusStats = { |
| 123 | + cases: number; |
| 124 | + confirmed: number; |
| 125 | + reversed: number; |
| 126 | + /** Distinct engine builds present, ascending — the filter axis {@link filterCasesByEngineVersion} serves. */ |
| 127 | + engineVersions: string[]; |
| 128 | +}; |
| 129 | + |
| 130 | +/** Aggregate numbers only — the shape the calibration CLI prints (#8183's read surface). */ |
| 131 | +export function computeAmsCorpusStats(cases: readonly BacktestCase[]): AmsCorpusStats { |
| 132 | + const engineVersions = new Set<string>(); |
| 133 | + let confirmed = 0; |
| 134 | + let reversed = 0; |
| 135 | + for (const backtestCase of cases) { |
| 136 | + if (backtestCase.label === "confirmed") confirmed += 1; |
| 137 | + else reversed += 1; |
| 138 | + const version = backtestCase.metadata?.engineVersion; |
| 139 | + if (typeof version === "string" && version !== "") engineVersions.add(version); |
| 140 | + } |
| 141 | + return { cases: cases.length, confirmed, reversed, engineVersions: [...engineVersions].sort() }; |
| 142 | +} |
| 143 | + |
| 144 | +function predictionDirection(conclusion: string): "merge" | "close" | null { |
| 145 | + const normalized = conclusion.trim().toLowerCase(); |
| 146 | + return normalized === "merge" || normalized === "close" ? normalized : null; |
| 147 | +} |
0 commit comments