Skip to content

Commit 80455e1

Browse files
authored
feat(calibration): phase-2 backfill — retro reversal labels + raw-context re-fetch (GitHub truth) (#8207)
* feat(calibration): phase-2 backfill CLI — retro successor labels + raw-context re-fetch (GitHub truth) Pass A runs #8166's evaluateSuccessorMatch retrospectively over the phase-1 backfilled close decisions: a bot-closed PR with a merged successor (shared linked issue, or same author reworking a majority of its files, merged inside the 30-day lookback) gets its override row's verdict flipped to reversed with distinct github_successor_scan provenance — the corpus's first organic-shaped negative labels. Pass B re-fetches PR diffs (public repos only) into the phase-1 fired rows' metadata.diff, the exact field the live #8130 capture records, bounded by the same cap. Same discipline as phase 1: pure core + thin IO wrapper, deterministic backfill: ids only (live rows unreachable by construction), idempotent patchers (already-patched rows return null), dry-run default, hard per-run GitHub request budget with a resumable state-file cursor. Advances #8170 * fix(calibration): budget-bound the phase-2 successor listing instead of a 10-page cap The first full production dry-run returned zero matches because page 10 of the sort=updated listing only reached back to July 5 while the closes under scan ended June 22 — a silently truncated listing is indistinguishable from 'no successors'. Depth is now bounded by the run's request budget (hard page ceiling 200 as a backstop), with the boundary condition unchanged. Advances #8170 * fix(calibration): time-bound the phase-2 wrapper's wrangler calls so a hung remote execute fails loud Advances #8170 * feat(calibration): per-heuristic breakdown in the pass-A report 295/460 matches on the first full scan is too many to all be reversals: in a duplicate-competition culture a shared-issue match by a different author is usually the gate correctly closing a losing duplicate. The apply decision needs the same-author-rework vs shared-issue-only split. Advances #8170 * feat(calibration): capture same-PR reopened+merged reversals in pass A — the definitive label class The operator's correction to the zero-reversals reading: bot/AI-closed PRs HAVE been reopened and merged, but latest-decision-wins erased the earlier close verdicts and the scan skipped merged PRs as 'not a standing close'. A close-verdict PR that itself shows merged_at is a definitive same-PR reversal — no heuristics — labeled under github_same_pr_merged provenance, counted separately from the successor classes in the report. Advances #8170 * feat(calibration): plan-file applies, class-gated policy, and burst-limit resilience for phase 2 Production hardening from running the passes for real: - Scan-once/apply-from-plan: the dry-run emits its matches as a plan file (persisted even on mid-run abort), and --apply --plan-in replays it against any store with ZERO GitHub requests — the cloud D1 and selfhost Postgres applies share one scan instead of re-spending ~2.7k requests each. --pg rides #8171's driver seam for the store side. - Apply policy is class-gated: same_pr_merged + same_author apply by default; shared_issue_only (128 of the 301 production matches — routine duplicate competition, not reversal evidence) is counted and planned but never applied without an explicit --include-shared-issue-only. - GitHub IO survives reality: bounded retries on thrown fetches/5xx, Retry-After honored on 403/429 burst limits (90s default, 5m cap) with ~4 req/s pacing so a stall costs minutes instead of the whole scan, and the wrangler reads are time-bounded so a hung remote execute fails loud. Advances #8170 * feat(calibration): default the phase-2 scan to ~2.4k req/hr — the operator's token pool is shared Advances #8170
1 parent c26fe94 commit 80455e1

3 files changed

Lines changed: 816 additions & 0 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Pure core for the phase-2 calibration backfill (#8170, epic #8082): the two GitHub-truth passes phase 1
2+
// (#8157, backfill-calibration-corpus-core.ts) deliberately deferred. The thin IO wrapper
3+
// (backfill-calibration-corpus-phase2.ts) does every DB/GitHub read and write; everything here is pure.
4+
//
5+
// • Pass A — retro successor scan: run #8166's `evaluateSuccessorMatch` (imported, never re-implemented)
6+
// over historical bot-close decisions vs the merged PRs that followed them. A confirmed match flips the
7+
// phase-1 override row's verdict to `reversed` — the ledger's first organic-shaped negative labels.
8+
// • Pass B — raw-context re-fetch: patch the phase-1 fired rows with the PR diff the live capture (#8130)
9+
// would have recorded (`metadata.diff`, bounded to RAW_CONTEXT_MAX_DIFF_CHARS), public repos only.
10+
// • Conservative + idempotent: borderline successor matches record NOTHING; both patchers return null on
11+
// an already-patched row, so re-runs are no-ops; every patched row carries a distinct provenance tag.
12+
import { evaluateSuccessorMatch, SUPERSEDED_LOOKBACK_MS, type SupersededHeuristics } from "../src/review/reversal-superseded.js";
13+
import { RAW_CONTEXT_MAX_DIFF_CHARS } from "../src/rules/advisory.js";
14+
import { BACKFILL_RULE_ID } from "./backfill-calibration-corpus-core.js";
15+
16+
/** Distinct provenance for pass A's retro labels — never confusable with phase 1's decision-level rows. */
17+
export const RETRO_SUCCESSOR_PROVENANCE = "github_successor_scan";
18+
/** Provenance for the strongest retro label: the close-verdict PR ITSELF later merged (the operator
19+
* reopened + merged it) — a definitive same-PR reversal needing no successor heuristics at all. */
20+
export const RETRO_SAME_PR_MERGED_PROVENANCE = "github_same_pr_merged";
21+
/** Distinct provenance for pass B's re-fetched raw context. */
22+
export const RAW_CONTEXT_REFETCH_PROVENANCE = "github_raw_context_refetch";
23+
24+
/** A phase-1 backfilled close decision, hydrated with the GitHub truth the wrapper fetched. */
25+
export type HistoricalCloseSide = {
26+
targetKey: string;
27+
repo: string;
28+
number: number;
29+
/** ISO close time (phase 1's terminal_at) — successors must merge within the #8166 lookback AFTER it. */
30+
closedAt: string;
31+
authorLogin: string | null;
32+
linkedIssues: readonly number[];
33+
files: readonly string[];
34+
};
35+
36+
/** A candidate successor: a PR in the same repo that actually merged. */
37+
export type SuccessorSide = {
38+
number: number;
39+
mergedAt: string;
40+
authorLogin: string | null;
41+
linkedIssues: readonly number[];
42+
files: readonly string[];
43+
};
44+
45+
export type RetroSuccessorMatch = {
46+
targetKey: string;
47+
supersededBy: number;
48+
heuristics: SupersededHeuristics;
49+
};
50+
51+
/**
52+
* Decide which historical closes were superseded by a later merge. Pure: both sides arrive pre-fetched.
53+
* The window is directional — a successor must merge AFTER the close and within {@link SUPERSEDED_LOOKBACK_MS}
54+
* (#8166's own bound) — and the EARLIEST qualifying merge wins so re-runs with more candidates stay stable.
55+
*/
56+
export function matchRetroSuccessors(close: HistoricalCloseSide, successors: readonly SuccessorSide[]): RetroSuccessorMatch | null {
57+
const closedAtMs = Date.parse(close.closedAt);
58+
if (!Number.isFinite(closedAtMs)) return null;
59+
const eligible = successors
60+
.filter((successor) => {
61+
if (successor.number === close.number) return false;
62+
const mergedAtMs = Date.parse(successor.mergedAt);
63+
return Number.isFinite(mergedAtMs) && mergedAtMs > closedAtMs && mergedAtMs - closedAtMs <= SUPERSEDED_LOOKBACK_MS;
64+
})
65+
.sort((a, b) => (a.mergedAt < b.mergedAt ? -1 : a.mergedAt > b.mergedAt ? 1 : a.number - b.number));
66+
for (const successor of eligible) {
67+
const heuristics = evaluateSuccessorMatch(
68+
{ authorLogin: successor.authorLogin, linkedIssues: successor.linkedIssues, files: successor.files },
69+
{ authorLogin: close.authorLogin, linkedIssues: close.linkedIssues, files: close.files },
70+
);
71+
if (heuristics) return { targetKey: close.targetKey, supersededBy: successor.number, heuristics };
72+
}
73+
return null;
74+
}
75+
76+
/** The deterministic phase-1 row ids this pass is allowed to touch — live capture rows are never patched. */
77+
export function backfillOverrideId(targetKey: string): string {
78+
return `backfill:${BACKFILL_RULE_ID}:${targetKey}:override`;
79+
}
80+
export function backfillFiredId(targetKey: string): string {
81+
return `backfill:${BACKFILL_RULE_ID}:${targetKey}:fired`;
82+
}
83+
84+
/**
85+
* Patch a phase-1 override row's metadata to the retro `reversed` verdict. Returns the new JSON, or null
86+
* when the row is already reversed (idempotent re-run) or does not parse as an object (never guess).
87+
*/
88+
export function patchOverrideMetadataToReversed(metadataJson: string, match: RetroSuccessorMatch): string | null {
89+
const metadata = parseObject(metadataJson);
90+
if (!metadata) return null;
91+
if (metadata.verdict === "reversed") return null;
92+
return JSON.stringify({
93+
...metadata,
94+
verdict: "reversed",
95+
retroLabel: {
96+
provenance: RETRO_SUCCESSOR_PROVENANCE,
97+
supersededBy: match.supersededBy,
98+
heuristics: match.heuristics,
99+
},
100+
});
101+
}
102+
103+
/**
104+
* Patch a phase-1 override row for the same-PR reversal: GitHub says the close-verdict PR itself MERGED
105+
* (the operator reopened + merged it) — the decision was overridden on its own target, no heuristics
106+
* involved. Same idempotency contract as {@link patchOverrideMetadataToReversed}.
107+
*/
108+
export function patchOverrideMetadataToSamePrMerged(metadataJson: string, mergedAt: string): string | null {
109+
const metadata = parseObject(metadataJson);
110+
if (!metadata) return null;
111+
if (metadata.verdict === "reversed") return null;
112+
return JSON.stringify({
113+
...metadata,
114+
verdict: "reversed",
115+
retroLabel: { provenance: RETRO_SAME_PR_MERGED_PROVENANCE, mergedAt },
116+
});
117+
}
118+
119+
/**
120+
* Patch a phase-1 fired row's metadata with the re-fetched PR diff — the field the live #8130 capture
121+
* records for this rule (`metadata.diff`, same bound). Returns null when raw context is already present
122+
* (either captured live or patched by an earlier run), when the diff is empty, or on unparseable metadata.
123+
*/
124+
export function patchFiredMetadataWithDiff(metadataJson: string, diff: string): string | null {
125+
const metadata = parseObject(metadataJson);
126+
if (!metadata) return null;
127+
if (typeof metadata.diff === "string") return null;
128+
const bounded = diff.slice(0, RAW_CONTEXT_MAX_DIFF_CHARS);
129+
if (bounded === "") return null;
130+
return JSON.stringify({ ...metadata, diff: bounded, rawContextProvenance: RAW_CONTEXT_REFETCH_PROVENANCE });
131+
}
132+
133+
export type Phase2Report = {
134+
pass: "successors" | "raw-context";
135+
scanned: number;
136+
patched: number;
137+
alreadyPatched: number;
138+
noMatch: number;
139+
/** Pass-A heuristic breakdown (#8170's apply decision hinges on it): a SAME-AUTHOR rework merging is
140+
* strong bot-was-wrong evidence; a shared-issue match by a DIFFERENT author is routine duplicate
141+
* competition in this culture — the winner merging does not make closing the loser wrong. */
142+
matchedSameAuthor: number;
143+
matchedSharedIssueOnly: number;
144+
/** The close-verdict PR itself later merged — definitive reversals, no heuristics (see the operator's
145+
* own reopen-and-merge history; the strongest label class this pass produces). */
146+
matchedSamePrMerged: number;
147+
requestsUsed: number;
148+
exhaustedBudget: boolean;
149+
resumeFrom: string | null;
150+
};
151+
152+
/** Render the dry-run/apply report #8170 requires before any apply. */
153+
export function renderPhase2Report(report: Phase2Report, mode: "dry-run" | "apply"): string {
154+
const lines = [
155+
`Calibration corpus backfill phase 2 (${mode}) — pass ${report.pass}, provenance ${
156+
report.pass === "successors" ? RETRO_SUCCESSOR_PROVENANCE : RAW_CONTEXT_REFETCH_PROVENANCE
157+
}`,
158+
` scanned: ${report.scanned} patched: ${report.patched} already-patched: ${report.alreadyPatched} no-match/skipped: ${report.noMatch}`,
159+
...(report.pass === "successors"
160+
? [
161+
` match classes: same-PR reopened+merged ${report.matchedSamePrMerged} (definitive), same-author rework ${report.matchedSameAuthor}, shared-issue-only (different author) ${report.matchedSharedIssueOnly}`,
162+
]
163+
: []),
164+
` GitHub requests used: ${report.requestsUsed}${report.exhaustedBudget ? " (budget exhausted — resumable)" : ""}`,
165+
];
166+
if (report.resumeFrom) lines.push(` resume from: ${report.resumeFrom} (state file updated)`);
167+
return lines.join("\n");
168+
}
169+
170+
function parseObject(json: string): Record<string, unknown> | null {
171+
try {
172+
const parsed: unknown = JSON.parse(json);
173+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>;
174+
} catch {
175+
/* corrupt row -- treat as unpatchable, mirroring phase 1's fail-open metadata parse */
176+
}
177+
return null;
178+
}

0 commit comments

Comments
 (0)