|
| 1 | +// Pure core for the slop-corpus replay backfill (#8277) — phase 3 of the calibration backfill family. |
| 2 | +// The slop scorer is deterministic and in-repo, so the #8224 report-only knob's evidence can be |
| 3 | +// manufactured honestly: replay `buildSlopAssessment` over each archived PR diff (the #8130/#8170 |
| 4 | +// raw-context corpus) and synthesize provenance-tagged `slop_gate_score` fired/override pairs labeled by |
| 5 | +// what humans actually did — the same counterfactual "mapping a" framing the phase-1 close-confidence |
| 6 | +// backfill documented. This core is transform-only (no IO), mirroring backfill-calibration-corpus-core.ts; |
| 7 | +// the CLI wrapper owns the manifest read and the wrangler/pg writes. |
| 8 | +// |
| 9 | +// SIGNAL-SUBSET HONESTY: only the diff-derivable signals can replay (trivialWhitespaceChurn, |
| 10 | +// missingTestEvidence, nonSubstantivePadding — changedFiles parse out of the archived unified diff). The |
| 11 | +// unarchived inputs (description, commit messages, duplicate-cluster membership, linked-issue state) are |
| 12 | +// passed UNDEFINED so their signals SKIP rather than fire spuriously — a replayed score is therefore a |
| 13 | +// LOWER BOUND on what live scoring would have produced. Every synthesized row records the computed signal |
| 14 | +// codes plus the provenance tag so the eventual flip-to-live decision can weigh exactly that. |
| 15 | + |
| 16 | +import { buildSlopAssessment, SLOP_WEIGHTS, slopBandFor, type SlopChangedFile } from "../packages/loopover-engine/src/signals/slop"; |
| 17 | +import type { SynthesizedAuditRow } from "./backfill-calibration-corpus-core.js"; |
| 18 | + |
| 19 | +export const SLOP_BACKFILL_RULE_ID = "slop_gate_score"; |
| 20 | +export const SLOP_BACKFILL_PROVENANCE = "slop_replay_backfill_v1"; |
| 21 | +const FIRED_EVENT_TYPE = `signal.rule_fired:${SLOP_BACKFILL_RULE_ID}`; |
| 22 | +const OVERRIDE_EVENT_TYPE = `signal.human_override:${SLOP_BACKFILL_RULE_ID}`; |
| 23 | + |
| 24 | +/** One replayable source case, projected from a backtest-corpus manifest (backtest-corpus-export.ts): |
| 25 | + * the archived bounded diff plus the human label the original rule's history already established. */ |
| 26 | +/** The diff-derivable signal codes the replay may score, mapped to the scorer's own weights. Everything |
| 27 | + * else needs unarchived inputs and is EXCLUDED even if the scorer fires it on an undefined field. */ |
| 28 | +const REPLAYABLE_SIGNAL_WEIGHTS: Record<string, number | undefined> = { |
| 29 | + trivial_whitespace_churn: SLOP_WEIGHTS.trivialWhitespaceChurn, |
| 30 | + missing_test_evidence: SLOP_WEIGHTS.missingTestEvidence, |
| 31 | + non_substantive_padding: SLOP_WEIGHTS.nonSubstantivePadding, |
| 32 | +}; |
| 33 | + |
| 34 | +export type SlopReplaySourceCase = { |
| 35 | + targetKey: string; |
| 36 | + label: "confirmed" | "reversed"; |
| 37 | + firedAt: string; |
| 38 | + decidedAt: string; |
| 39 | + diff: string; |
| 40 | +}; |
| 41 | + |
| 42 | +/** |
| 43 | + * Parse a unified diff into the scorer's {@link SlopChangedFile} shape: one entry per `diff --git` block |
| 44 | + * (b-side path — the post-change name), with per-file added/deleted line counts (`+`/`-` bodies only, |
| 45 | + * never the `+++`/`---` headers). Tolerant of the 45KB truncation marker the phase-2 apply path appends: |
| 46 | + * a truncated tail simply yields fewer counted lines — the parse never throws on any string input. |
| 47 | + */ |
| 48 | +export function parseDiffChangedFiles(diff: string): SlopChangedFile[] { |
| 49 | + const files: SlopChangedFile[] = []; |
| 50 | + let current: { path: string; additions: number; deletions: number } | null = null; |
| 51 | + for (const line of diff.split("\n")) { |
| 52 | + const header = /^diff --git a\/.+ b\/(.+)$/.exec(line); |
| 53 | + if (header) { |
| 54 | + if (current) files.push(current); |
| 55 | + current = { path: header[1]!, additions: 0, deletions: 0 }; |
| 56 | + continue; |
| 57 | + } |
| 58 | + if (!current) continue; |
| 59 | + if (line.startsWith("+++") || line.startsWith("---")) continue; |
| 60 | + if (line.startsWith("+")) current.additions += 1; |
| 61 | + else if (line.startsWith("-")) current.deletions += 1; |
| 62 | + } |
| 63 | + if (current) files.push(current); |
| 64 | + return files; |
| 65 | +} |
| 66 | + |
| 67 | +export type SlopReplayReport = { |
| 68 | + replayed: number; |
| 69 | + skippedEmptyDiff: number; |
| 70 | + skippedNoFiles: number; |
| 71 | + skippedDuplicateTarget: number; |
| 72 | + /** Histogram of replayed risks by band edge — the evidence summary the issue asks for. */ |
| 73 | + riskCounts: { zero: number; low: number; elevated: number; high: number }; |
| 74 | + reversed: number; |
| 75 | + confirmed: number; |
| 76 | + rows: SynthesizedAuditRow[]; |
| 77 | +}; |
| 78 | + |
| 79 | +/** |
| 80 | + * Replay the deterministic slop scorer over each source case and synthesize the provenance-tagged |
| 81 | + * fired/override pair (ids derive from the targetKey alone, so re-runs upsert idempotently — the phase-1 |
| 82 | + * insert builder's ON CONFLICT discipline applies unchanged). The fired event's `occurredAt` reuses the |
| 83 | + * source case's own firedAt and the override sits at its decidedAt (floored to 1s after the firing when |
| 84 | + * the archive's timestamps collide), so buildBacktestCorpus's strictly-after pairing always matches. |
| 85 | + * Deterministic output for deterministic input. |
| 86 | + */ |
| 87 | +export function replaySlopCorpus(cases: readonly SlopReplaySourceCase[]): SlopReplayReport { |
| 88 | + const report: SlopReplayReport = { |
| 89 | + replayed: 0, |
| 90 | + skippedEmptyDiff: 0, |
| 91 | + skippedNoFiles: 0, |
| 92 | + skippedDuplicateTarget: 0, |
| 93 | + riskCounts: { zero: 0, low: 0, elevated: 0, high: 0 }, |
| 94 | + reversed: 0, |
| 95 | + confirmed: 0, |
| 96 | + rows: [], |
| 97 | + }; |
| 98 | + const seen = new Set<string>(); |
| 99 | + for (const sourceCase of cases) { |
| 100 | + if (!sourceCase.diff || !sourceCase.diff.trim()) { |
| 101 | + report.skippedEmptyDiff += 1; |
| 102 | + continue; |
| 103 | + } |
| 104 | + if (seen.has(sourceCase.targetKey)) { |
| 105 | + report.skippedDuplicateTarget += 1; |
| 106 | + continue; |
| 107 | + } |
| 108 | + const changedFiles = parseDiffChangedFiles(sourceCase.diff); |
| 109 | + if (changedFiles.length === 0) { |
| 110 | + report.skippedNoFiles += 1; |
| 111 | + continue; |
| 112 | + } |
| 113 | + seen.add(sourceCase.targetKey); |
| 114 | + // Signal-subset honesty, enforced by ALLOWLIST rather than trusting undefined-skipping: the scorer |
| 115 | + // treats a missing description as an empty one (buildEmptyDescriptionFinding fires on undefined — |
| 116 | + // correct live, where absence IS emptiness, but inflating here, where the field simply wasn't |
| 117 | + // archived). Only the three diff-derivable signals may contribute; risk and band recompute from |
| 118 | + // exactly their weights via the scorer's own exported constants. |
| 119 | + const assessment = buildSlopAssessment({ changedFiles }); |
| 120 | + const replayableFindings = assessment.findings.filter((finding) => REPLAYABLE_SIGNAL_WEIGHTS[finding.code] !== undefined); |
| 121 | + const slopRisk = Math.min( |
| 122 | + 100, |
| 123 | + replayableFindings.reduce((sum, finding) => sum + REPLAYABLE_SIGNAL_WEIGHTS[finding.code]!, 0), |
| 124 | + ); |
| 125 | + const band = slopBandFor(slopRisk); |
| 126 | + const computedSignals = replayableFindings.map((finding) => finding.code).sort(); |
| 127 | + |
| 128 | + report.replayed += 1; |
| 129 | + if (slopRisk >= 60) report.riskCounts.high += 1; |
| 130 | + else if (slopRisk >= 30) report.riskCounts.elevated += 1; |
| 131 | + else if (slopRisk > 0) report.riskCounts.low += 1; |
| 132 | + else report.riskCounts.zero += 1; |
| 133 | + if (sourceCase.label === "reversed") report.reversed += 1; |
| 134 | + else report.confirmed += 1; |
| 135 | + |
| 136 | + const firedMs = Date.parse(sourceCase.firedAt); |
| 137 | + const decidedMs = Date.parse(sourceCase.decidedAt); |
| 138 | + const overrideIso = new Date( |
| 139 | + Number.isFinite(decidedMs) && Number.isFinite(firedMs) && decidedMs > firedMs ? decidedMs : (Number.isFinite(firedMs) ? firedMs : 0) + 1000, |
| 140 | + ).toISOString(); |
| 141 | + report.rows.push( |
| 142 | + { |
| 143 | + id: `backfill:${SLOP_BACKFILL_RULE_ID}:${sourceCase.targetKey}:fired`, |
| 144 | + eventType: FIRED_EVENT_TYPE, |
| 145 | + actor: "loopover", |
| 146 | + targetKey: sourceCase.targetKey, |
| 147 | + outcome: slopRisk >= 60 ? "above_threshold" : "below_threshold", |
| 148 | + detail: `rule ${SLOP_BACKFILL_RULE_ID} replayed against ${sourceCase.targetKey} [backfilled]`, |
| 149 | + metadataJson: JSON.stringify({ |
| 150 | + confidence: slopRisk / 100, |
| 151 | + band, |
| 152 | + computedSignals, |
| 153 | + backfilled: true, |
| 154 | + provenance: SLOP_BACKFILL_PROVENANCE, |
| 155 | + }), |
| 156 | + createdAt: sourceCase.firedAt, |
| 157 | + }, |
| 158 | + { |
| 159 | + id: `backfill:${SLOP_BACKFILL_RULE_ID}:${sourceCase.targetKey}:override`, |
| 160 | + eventType: OVERRIDE_EVENT_TYPE, |
| 161 | + actor: "human", |
| 162 | + targetKey: sourceCase.targetKey, |
| 163 | + outcome: "completed", |
| 164 | + detail: `human ${sourceCase.label} rule ${SLOP_BACKFILL_RULE_ID} against ${sourceCase.targetKey} [backfilled]`, |
| 165 | + metadataJson: JSON.stringify({ verdict: sourceCase.label, backfilled: true, provenance: SLOP_BACKFILL_PROVENANCE }), |
| 166 | + createdAt: overrideIso, |
| 167 | + }, |
| 168 | + ); |
| 169 | + } |
| 170 | + return report; |
| 171 | +} |
| 172 | + |
| 173 | +/** Render the dry-run/apply summary — the shape the #8277 evidence comment quotes. */ |
| 174 | +export function renderSlopReplayReport(report: SlopReplayReport, mode: "dry-run" | "apply"): string { |
| 175 | + return [ |
| 176 | + `Slop corpus replay backfill (${mode}) — provenance ${SLOP_BACKFILL_PROVENANCE}`, |
| 177 | + ` replayed: ${report.replayed} (confirmed ${report.confirmed}, reversed ${report.reversed})`, |
| 178 | + ` risk bands: zero ${report.riskCounts.zero} | low ${report.riskCounts.low} | elevated ${report.riskCounts.elevated} | high ${report.riskCounts.high}`, |
| 179 | + ` skipped: empty-diff ${report.skippedEmptyDiff}, no-files ${report.skippedNoFiles}, duplicate-target ${report.skippedDuplicateTarget}`, |
| 180 | + mode === "dry-run" ? "dry-run only — re-run with --apply to write. Rows upsert idempotently by id." : ` rows written: ${report.rows.length}`, |
| 181 | + ].join("\n"); |
| 182 | +} |
0 commit comments