Skip to content

Commit 23766f0

Browse files
committed
feat(engine): AMS calibration corpus — pair predicted gate verdicts with realized PR outcomes
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
1 parent 9d021ad commit 23766f0

5 files changed

Lines changed: 389 additions & 7 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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+
}

packages/loopover-engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ export * from "./governor/action-mode.js";
164164
export * from "./governor/chokepoint.js";
165165
export * from "./calibration/signal-tracking.js";
166166
export * from "./calibration/backtest-corpus.js";
167+
export * from "./calibration/ams-prediction-corpus.js";
167168
export * from "./calibration/backtest-score.js";
168169
export * from "./calibration/backtest-compare.js";
169170
export * from "./calibration/backtest-report.js";

packages/loopover-miner/lib/calibration-cli.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// verdicts (prediction-ledger) with the realized PR outcomes it later observed (event-ledger `pr_outcome`
33
// events), via the pure buildCalibrationReport join. Opens both local stores, maps their rows to the
44
// calibration record shapes, renders, and closes. Never modifies the live scoring/calibration logic.
5+
import { AMS_GATE_PREDICTION_RULE_ID, buildAmsPredictionCorpus, computeAmsCorpusStats } from "@loopover/engine";
6+
import type { AmsPredictionRecord, AmsRealizedOutcome } from "@loopover/engine";
57
import { buildCalibrationReport } from "./calibration.js";
68
import { initEventLedger, resolveEventLedgerDbPath } from "./event-ledger.js";
79
import type { LedgerEntry } from "./event-ledger.js";
@@ -48,6 +50,37 @@ export function toOutcomeRecords(events: LedgerEntry[]): ObservedOutcomeRecord[]
4850
return [...latest.values()];
4951
}
5052

53+
/** Project prediction-ledger rows into the engine adapter's record shape (#8183). Exported for the same
54+
* reuse reason as {@link toPredictionRecords}. */
55+
export function toAmsPredictionRecords(rows: PredictionLedgerEntry[]): AmsPredictionRecord[] {
56+
return rows.map((row) => ({
57+
repoFullName: row.repoFullName,
58+
targetId: row.targetId,
59+
headSha: row.headSha,
60+
conclusion: row.conclusion,
61+
readinessScore: row.readinessScore,
62+
engineVersion: row.engineVersion,
63+
ts: row.ts,
64+
}));
65+
}
66+
67+
/** Reduce pr_outcome events to the engine adapter's realized-outcome shape — latest per (repo, PR), the
68+
* same reduction contract readPrOutcomes documents (#8183). */
69+
export function toAmsRealizedOutcomes(events: LedgerEntry[]): AmsRealizedOutcome[] {
70+
const latest = new Map<string, AmsRealizedOutcome>();
71+
for (const event of events) {
72+
if (event?.type !== MINER_PR_OUTCOME_EVENT) continue;
73+
const prNumber = event.payload?.prNumber;
74+
const decision = event.payload?.decision;
75+
if (typeof prNumber !== "number" || !Number.isInteger(prNumber) || typeof decision !== "string") continue;
76+
if (typeof event.repoFullName !== "string" || !event.repoFullName.trim()) continue;
77+
const key = `${event.repoFullName}:${prNumber}`;
78+
latest.delete(key); // re-key so a later outcome supersedes in iteration order too (mirrors readPrOutcomes)
79+
latest.set(key, { repoFullName: event.repoFullName, prNumber, decision, recordedAt: event.createdAt });
80+
}
81+
return [...latest.values()];
82+
}
83+
5184
function renderReportText(report: CalibrationReport): void {
5285
if (!report.hasSignal) {
5386
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<string, strin
84117
try {
85118
predictionStore = initPredictionLedger(resolvePredictionLedgerDbPath(env));
86119
eventLedger = initEventLedger(resolveEventLedgerDbPath(env));
87-
const report = buildCalibrationReport(
88-
toPredictionRecords(predictionStore.readPredictions()),
89-
toOutcomeRecords(eventLedger.readEvents()),
90-
);
91-
if (json) console.log(JSON.stringify(report, null, 2));
92-
else renderReportText(report);
120+
const predictionRows = predictionStore.readPredictions();
121+
const events = eventLedger.readEvents();
122+
const report = buildCalibrationReport(toPredictionRecords(predictionRows), toOutcomeRecords(events));
123+
// #8183: the labeled backtest corpus over the same two ledgers — aggregate numbers only, the local
124+
// evidence base every later AMS backtest (#8184+) replays against. Corpus content never prints.
125+
const corpusStats = computeAmsCorpusStats(buildAmsPredictionCorpus(toAmsPredictionRecords(predictionRows), toAmsRealizedOutcomes(events)));
126+
if (json) {
127+
console.log(JSON.stringify({ ...report, corpus: { ruleId: AMS_GATE_PREDICTION_RULE_ID, ...corpusStats } }, null, 2));
128+
} else {
129+
renderReportText(report);
130+
console.log(
131+
corpusStats.cases === 0
132+
? "corpus: no labeled cases yet (a case needs a directional prediction AND a realized merge/close outcome)."
133+
: // engineVersions is never empty alongside cases > 0: appendPrediction refuses a blank
134+
// engineVersion at the ledger boundary (normalizePredictionInput's invalid_engine_version).
135+
`corpus (${AMS_GATE_PREDICTION_RULE_ID}): ${corpusStats.cases} case(s) | confirmed ${corpusStats.confirmed} | reversed ${corpusStats.reversed} | engine build(s): ${corpusStats.engineVersions.join(", ")}`,
136+
);
137+
}
93138
return 0;
94139
} catch (error) {
95140
return reportCliFailure(json, describeCliError(error));

0 commit comments

Comments
 (0)