Skip to content

Commit b2e59d1

Browse files
committed
feat(calibration): backfill the corpus from historical review_targets decisions (#8157 phase 1)
The calibration corpus only accumulated from the #8101 capture writers' ship date, while review_targets held 3,233 historical decisions. The pure core synthesizes the fired/override pairs those writers would have produced: close-verdict decisions with decision-level confidence, labeled by terminal outcome (closed = confirmed; a close-verdict PR that ended merged = reversed), mapped per #8157's ratified option (a) onto ai_consensus_defect — one rule id, never duplicated across siblings — with backfilled+provenance tags on every row so consumers can include or exclude the era explicitly. Never fabricates: non-close verdicts, missing confidence, and non-terminal rows are counted and skipped; modelResponseText is never synthesized. Idempotent by construction (deterministic ids + INSERT OR IGNORE, chunked statements). Applied to production after the dry-run report on the issue: 460 eligible decisions -> 920 rows; verified round-tripping through backtest-corpus-export into 460 labeled BacktestCases. 100% line+branch coverage on the core, including a buildBacktestCorpus round-trip test. Advances #8157.
1 parent 87e8a89 commit b2e59d1

3 files changed

Lines changed: 419 additions & 0 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Pure core for the calibration-corpus backfill (#8157 phase 1, epic #8082). Transforms historical
2+
// review_targets decisions (decision-level AI verdict + confidence, terminal outcome) into the synthesized
3+
// signal.rule_fired / signal.human_override audit rows the live capture writers (#8101) would have produced
4+
// had they existed then — so the shipped threshold backtest (#8138) and trend view (#8113) start from the
5+
// ledger's real history instead of an empty corpus. No IO here — the CLI (backfill-calibration-corpus.ts)
6+
// does the D1 reads/writes — mirrors backtest-corpus-export-core.ts's identical pure-core / thin-IO split.
7+
//
8+
// Integrity rules (#8157's own Requirements, plus the mapping decision ratified on the issue):
9+
// • Mapping (a): decision-level CLOSE verdicts synthesize firings for `ai_consensus_defect` — ONE rule id
10+
// (the close-authority consensus code KNOWN_THRESHOLDS maps to DEFAULT_AI_REVIEW_CLOSE_CONFIDENCE),
11+
// never duplicated across sibling ids, and every synthesized row carries `backfilled: true` +
12+
// `provenance` so consumers can include/exclude the era explicitly.
13+
// • Never fabricate: a row without a close verdict, a numeric confidence, or a terminal outcome yields
14+
// NOTHING (counted, not guessed). modelResponseText is never synthesized.
15+
// • Idempotent by construction: deterministic ids + INSERT OR IGNORE, so re-runs are no-ops.
16+
export const BACKFILL_PROVENANCE = "review_targets_decision_level";
17+
/** Mapping (a) — see #8157. The single rule id historical close decisions are synthesized under. */
18+
export const BACKFILL_RULE_ID = "ai_consensus_defect";
19+
20+
const FIRED_EVENT_TYPE = `signal.rule_fired:${BACKFILL_RULE_ID}`;
21+
const OVERRIDE_EVENT_TYPE = `signal.human_override:${BACKFILL_RULE_ID}`;
22+
23+
/** The projection of a review_targets row this transform reads. `confidence` is the decision-level value
24+
* json-extracted by the CLI's query; null when absent from decision_json. */
25+
export type ReviewTargetDecisionRow = {
26+
repo: string;
27+
number: number;
28+
verdict: string | null;
29+
status: string | null;
30+
confidence: number | null;
31+
terminalAt: string | null;
32+
};
33+
34+
export type SynthesizedAuditRow = {
35+
id: string;
36+
eventType: string;
37+
actor: string;
38+
targetKey: string;
39+
outcome: string;
40+
detail: string;
41+
metadataJson: string;
42+
createdAt: string;
43+
};
44+
45+
export type BackfillReport = {
46+
eligible: number;
47+
reversed: number;
48+
confirmed: number;
49+
skippedWrongVerdict: number;
50+
skippedNoConfidence: number;
51+
skippedNotTerminal: number;
52+
skippedDuplicateTarget: number;
53+
rows: SynthesizedAuditRow[];
54+
};
55+
56+
/** SQLite "YYYY-MM-DD HH:MM:SS" (no zone) normalized to ISO-8601 UTC; already-ISO strings pass through.
57+
* Returns null for a blank value so eligibility can fail closed on it. */
58+
function normalizeLedgerTimestamp(value: string | null): string | null {
59+
if (!value || !value.trim()) return null;
60+
const t = value.includes("T") ? value : value.replace(" ", "T");
61+
const hasZone = t.endsWith("Z") || /[+-]\d\d:?\d\d$/.test(t);
62+
const ms = Date.parse(hasZone ? t : `${t}Z`);
63+
if (!Number.isFinite(ms)) return null;
64+
return new Date(ms).toISOString();
65+
}
66+
67+
/**
68+
* Synthesize the backfill's fired + override audit rows from historical decisions. Eligibility (all
69+
* required, each miss counted separately, priority in the listed order): verdict `close`, a numeric
70+
* decision-level confidence, a parseable terminal timestamp, and a terminal `closed`/`merged` status.
71+
* Label: `closed` (the close stood) ⇒ `confirmed`; `merged` (a closed-verdict PR that ended MERGED — the
72+
* decision was wrong) ⇒ `reversed`. One synthesized pair per targetKey (a re-reviewed target keeps its
73+
* LATEST terminal decision; earlier ones count as duplicates). The override's createdAt sits 1s after the
74+
* firing's so buildBacktestCorpus's strictly-after pairing always matches. Deterministic output for
75+
* deterministic input — ids derive from the targetKey alone.
76+
*/
77+
export function synthesizeBackfillRows(rows: readonly ReviewTargetDecisionRow[]): BackfillReport {
78+
const report: BackfillReport = {
79+
eligible: 0,
80+
reversed: 0,
81+
confirmed: 0,
82+
skippedWrongVerdict: 0,
83+
skippedNoConfidence: 0,
84+
skippedNotTerminal: 0,
85+
skippedDuplicateTarget: 0,
86+
rows: [],
87+
};
88+
89+
// Latest terminal decision wins per target — sort desc by normalized terminal time, first seen kept.
90+
const eligible: Array<{ row: ReviewTargetDecisionRow; terminalIso: string }> = [];
91+
for (const row of rows) {
92+
if (row.verdict !== "close") {
93+
report.skippedWrongVerdict += 1;
94+
continue;
95+
}
96+
if (typeof row.confidence !== "number" || !Number.isFinite(row.confidence)) {
97+
report.skippedNoConfidence += 1;
98+
continue;
99+
}
100+
const terminalIso = normalizeLedgerTimestamp(row.terminalAt);
101+
if (!terminalIso || (row.status !== "closed" && row.status !== "merged")) {
102+
report.skippedNotTerminal += 1;
103+
continue;
104+
}
105+
eligible.push({ row, terminalIso });
106+
}
107+
eligible.sort((a, b) => (a.terminalIso < b.terminalIso ? 1 : a.terminalIso > b.terminalIso ? -1 : 0));
108+
109+
const seen = new Set<string>();
110+
for (const { row, terminalIso } of eligible) {
111+
const targetKey = `${row.repo}#${row.number}`;
112+
if (seen.has(targetKey)) {
113+
report.skippedDuplicateTarget += 1;
114+
continue;
115+
}
116+
seen.add(targetKey);
117+
const label = row.status === "merged" ? "reversed" : "confirmed";
118+
report.eligible += 1;
119+
if (label === "reversed") report.reversed += 1;
120+
else report.confirmed += 1;
121+
122+
const overrideIso = new Date(Date.parse(terminalIso) + 1000).toISOString();
123+
report.rows.push(
124+
{
125+
id: `backfill:${BACKFILL_RULE_ID}:${targetKey}:fired`,
126+
eventType: FIRED_EVENT_TYPE,
127+
actor: "loopover",
128+
targetKey,
129+
outcome: "completed",
130+
detail: `rule ${BACKFILL_RULE_ID} fired (close) against ${targetKey} [backfilled]`,
131+
metadataJson: JSON.stringify({ outcome: "close", confidence: row.confidence, backfilled: true, provenance: BACKFILL_PROVENANCE }),
132+
createdAt: terminalIso,
133+
},
134+
{
135+
id: `backfill:${BACKFILL_RULE_ID}:${targetKey}:override`,
136+
eventType: OVERRIDE_EVENT_TYPE,
137+
actor: "human",
138+
targetKey,
139+
outcome: "completed",
140+
detail: `human ${label} rule ${BACKFILL_RULE_ID} against ${targetKey} [backfilled]`,
141+
metadataJson: JSON.stringify({ verdict: label, backfilled: true, provenance: BACKFILL_PROVENANCE }),
142+
createdAt: overrideIso,
143+
},
144+
);
145+
}
146+
return report;
147+
}
148+
149+
/** Single-quoted SQL string literal — mirrors backtest-corpus-export.ts's sqlStringLiteral exactly. */
150+
export function sqlStringLiteral(value: string): string {
151+
return `'${value.replace(/'/g, "''")}'`;
152+
}
153+
154+
/**
155+
* Render the synthesized rows as chunked `INSERT OR IGNORE` statements (idempotency comes from the
156+
* deterministic ids: a re-run, or an overlap with a prior partial apply, silently no-ops instead of
157+
* double-writing). Chunked so a statement never grows past what `wrangler d1 execute --command` sanely
158+
* carries. Returns [] for an empty report.
159+
*/
160+
export function buildBackfillInsertStatements(rows: readonly SynthesizedAuditRow[], chunkSize = 50): string[] {
161+
const statements: string[] = [];
162+
for (let start = 0; start < rows.length; start += Math.max(1, chunkSize)) {
163+
const chunk = rows.slice(start, start + Math.max(1, chunkSize));
164+
const values = chunk
165+
.map(
166+
(row) =>
167+
`(${[row.id, row.eventType, row.actor, row.targetKey, row.outcome, row.detail, row.metadataJson, row.createdAt]
168+
.map(sqlStringLiteral)
169+
.join(", ")})`,
170+
)
171+
.join(", ");
172+
statements.push(`INSERT OR IGNORE INTO audit_events (id, event_type, actor, target_key, outcome, detail, metadata_json, created_at) VALUES ${values}`);
173+
}
174+
return statements;
175+
}
176+
177+
/** The human-readable dry-run/apply summary the CLI prints and #8157's report requires. Pure string build. */
178+
export function renderBackfillReport(report: BackfillReport, mode: "dry-run" | "apply"): string {
179+
return [
180+
`Calibration corpus backfill (${mode}) — mapping (a), rule ${BACKFILL_RULE_ID}, provenance ${BACKFILL_PROVENANCE}`,
181+
` eligible decisions: ${report.eligible} (confirmed ${report.confirmed}, reversed ${report.reversed})`,
182+
` synthesized audit rows: ${report.rows.length} (fired + override pairs)`,
183+
` skipped: wrong-verdict ${report.skippedWrongVerdict}, no-confidence ${report.skippedNoConfidence}, not-terminal ${report.skippedNotTerminal}, duplicate-target ${report.skippedDuplicateTarget}`,
184+
].join("\n");
185+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/env node
2+
// Calibration-corpus backfill CLI (#8157 phase 1, epic #8082). Reads historical review_targets decisions
3+
// out of D1 via `wrangler d1 execute --json`, synthesizes the fired/override pairs the live capture
4+
// writers (#8101) would have produced, and — ONLY with --apply — writes them back as idempotent
5+
// `INSERT OR IGNORE` rows. Dry-run is the default and prints the report #8157 requires before any apply.
6+
// All transform logic lives in backfill-calibration-corpus-core.ts (unit-tested); this file is the thin IO
7+
// wrapper — mirrors backtest-corpus-export.ts's identical split.
8+
//
9+
// tsx scripts/backfill-calibration-corpus.ts --db loopover [--remote] [--apply]
10+
//
11+
// Deployment note (#8157): source AND destination are the same D1 — the ledger of record for this
12+
// deployment. Self-host operators' corpora live in their own Postgres; a pg-driver variant is an explicit
13+
// non-goal here.
14+
import { spawnSync } from "node:child_process";
15+
import {
16+
buildBackfillInsertStatements,
17+
renderBackfillReport,
18+
synthesizeBackfillRows,
19+
type ReviewTargetDecisionRow,
20+
} from "./backfill-calibration-corpus-core.js";
21+
22+
type Args = { db: string; remote: boolean; apply: boolean };
23+
24+
function parseArgs(argv: string[]): Args {
25+
const args: Args = { db: "loopover", remote: false, apply: false };
26+
for (let i = 0; i < argv.length; i += 1) {
27+
const flag = argv[i];
28+
if (flag === "--remote") args.remote = true;
29+
else if (flag === "--apply") args.apply = true;
30+
else if (flag === "--db") args.db = argv[++i]!;
31+
}
32+
return args;
33+
}
34+
35+
// Mirrors export-d1-data.ts's d1Query: fail-loud so a partial read/write never passes silently.
36+
function d1Execute(db: string, remote: boolean, sql: string): Array<Record<string, unknown>> {
37+
const result = spawnSync("npx", ["wrangler", "d1", "execute", db, remote ? "--remote" : "--local", "--json", "--command", sql], {
38+
encoding: "utf8",
39+
maxBuffer: 256 * 1024 * 1024,
40+
});
41+
if (result.status !== 0) {
42+
throw new Error(`wrangler d1 execute failed (${result.status}): ${(result.stderr || result.stdout || "").slice(0, 500)}`);
43+
}
44+
const parsed = JSON.parse(result.stdout);
45+
const first = Array.isArray(parsed) ? parsed[0] : parsed;
46+
return first?.results ?? [];
47+
}
48+
49+
function main() {
50+
const args = parseArgs(process.argv.slice(2));
51+
52+
const rows = d1Execute(
53+
args.db,
54+
args.remote,
55+
`SELECT repo, number, verdict, status, json_extract(decision_json, '$.confidence') AS confidence, terminal_at
56+
FROM review_targets WHERE kind = 'pull_request'`,
57+
);
58+
const projected: ReviewTargetDecisionRow[] = rows.map((row) => ({
59+
repo: typeof row.repo === "string" ? row.repo : "",
60+
number: typeof row.number === "number" ? row.number : Number(row.number ?? 0),
61+
verdict: typeof row.verdict === "string" ? row.verdict : null,
62+
status: typeof row.status === "string" ? row.status : null,
63+
confidence: typeof row.confidence === "number" ? row.confidence : null,
64+
terminalAt: typeof row.terminal_at === "string" ? row.terminal_at : null,
65+
}));
66+
67+
const report = synthesizeBackfillRows(projected);
68+
console.log(renderBackfillReport(report, args.apply ? "apply" : "dry-run"));
69+
70+
if (!args.apply) {
71+
console.error("dry-run only — re-run with --apply to write. Rows are INSERT OR IGNORE with deterministic ids (idempotent).");
72+
return;
73+
}
74+
const statements = buildBackfillInsertStatements(report.rows);
75+
let written = 0;
76+
for (const statement of statements) {
77+
d1Execute(args.db, args.remote, statement);
78+
written += 1;
79+
console.error(`applied statement ${written}/${statements.length}`);
80+
}
81+
console.error(`backfill applied: ${report.rows.length} row(s) across ${statements.length} statement(s) (re-runs are no-ops).`);
82+
}
83+
84+
main();

0 commit comments

Comments
 (0)