|
| 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 | +} |
0 commit comments