Skip to content

Commit e87c70d

Browse files
committed
feat(replay): re-query action-match mode — the honest metric for the one nondeterministic stage (#9028)
Completes #9028. Requirement 2 (wall-clock capture) was verified already landed: staleness-clock.ts cites this issue in its header, decision_replay_inputs.replay_json carries the captured instant, and decision-replay.test.ts pins both arms per rule plus the CLI's clock-divergence exit. This PR is requirement 1, previously deferred on the prompt-persistence decision -- which is made here, not deferred again. WHAT IT IS. Everything downstream of the model is a pure function the replay harness pins bit-exactly, including time. The model call is the one stage that cannot be: hosted inference is not bit-deterministic even at temperature 0 (batching, kernel scheduling, silent model revisions). So `replay-decision.ts --requery <n>` re-runs the model n times against the EXACT persisted prompt and reports an ACTION-MATCH RATE -- across fresh runs, how often the verdict lands in the same CLASS the recorded decision acted on (defect vs clean, through the same parseModelReview the live pipeline uses, with the same ai_consensus_defect/ai_review_split boundary the gate acts on). The word "reproducibility" appears only inside the metric's own label as a negation -- "action-match-rate (NOT reproducibility)" -- so a pasted report cannot shed the caveat. THE PERSISTENCE DECISION. decision_replay_prompts (migration 0200): a private sibling of decision_replay_inputs with the identical posture and a deliberately SHORTER 30-day retention -- the prompt embeds the full diff plus contributor content (the largest, most sensitive artifact in the replay family), requery is a debugging tool for recent decisions, and the public promptDigest commitment outlives the text forever. Keyed by the BASE record id so a supersession's :rev<N> row still finds its head's prompt. Oversize prompts are SKIPPED, never truncated: a truncated prompt re-queried would report a rate for a prompt that was never sent. BOTH TURNS, because the first honest test caught the design flaw: the system prompt carries the rubric + config suffixes (what promptDigest commits to), but the DIFF travels in the USER turn -- re-querying with the system prompt alone would ask the model to review nothing. Captured at the orchestration site, never through findings (findings reach public render surfaces). The CLI replays the live call's own shape: a system turn plus a user turn at temperature 0, so the rate is a statement about the decision, not about a different way of asking. Denominator honesty: a transport failure is an UNUSABLE run counted in the denominator, never a silent skip -- shrinking it would inflate the rate exactly when the provider is flakiest. Unusable matches neither class, mirroring the live pipeline's fail-closed inconclusive routing. Provider config is explicit env only (REPLAY_AI_BASE_URL/REPLAY_AI_MODEL or ANTHROPIC_API_KEY/REPLAY_AI_MODEL) -- a tool that spends tokens never guesses which model to spend against. Exit 0 on a produced report (a low rate is a FINDING); 2 on unusable input or missing config. Pure replay mode remains network-free by construction, untouched. Tests: 21 across four suites -- the class boundaries through the live parser, both recorded-class codes, denominator invariants, zero-run degenerate arm, verbatim two-turn passthrough, bundle/env parsing with every named error (including the 30-day retention hint), base-id upsert semantics, oversize skip, swallowed persist failure, the retention rule at its own window, and an end-to-end pass proving a real AI review persists both turns with the diff in the user one. New module: 100% statements, branches, and functions. Full suite: 24,045 green.
1 parent 643383c commit e87c70d

11 files changed

Lines changed: 517 additions & 0 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- #9028 (epic #8828, Replay v2): the exact system prompt sent to the model, for re-query action matching.
2+
--
3+
-- The public decision record commits to the prompt via `promptDigest` (sha256 of the ACTUAL
4+
-- buildSystemPrompt output for that call, #9124) -- a contributor can verify the commitment, but a digest
5+
-- cannot be re-queried. `scripts/replay-decision.ts --requery` needs the text itself to re-run the model for
6+
-- the same target and report an ACTION-MATCH RATE (never "reproducibility": hosted inference is not
7+
-- bit-deterministic even at temperature 0, and the docs say so).
8+
--
9+
-- DELIBERATELY A PRIVATE SIBLING TABLE, mirroring decision_replay_inputs' posture exactly (#8838): the
10+
-- prompt embeds the full diff plus contributor content, so it must never live in the public record. Row-size
11+
-- is why it is ALSO not a decision_replay_inputs column: prompts run to hundreds of KB, and pinning them to
12+
-- the replay-input row would drag that table's every read through the blob.
13+
--
14+
-- Retention is 30 days (src/db/retention.ts), deliberately SHORTER than decision_replay_inputs' 180: the
15+
-- re-query mode is an operator debugging tool for RECENT decisions, the blob is the largest and most
16+
-- sensitive artifact in the replay family, and the public promptDigest commitment outlives the text forever.
17+
CREATE TABLE IF NOT EXISTS decision_replay_prompts (
18+
record_id TEXT PRIMARY KEY, -- decision_records.id (record:<owner/repo>#<pr>@<head sha>)
19+
prompt_json TEXT NOT NULL, -- { systemPrompt } -- exactly what was sent, nothing derived
20+
created_at TEXT NOT NULL
21+
);
22+
CREATE INDEX IF NOT EXISTS idx_decision_replay_prompts_created_at ON decision_replay_prompts(created_at);

scripts/check-schema-drift.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export const RAW_SQL_ONLY_TABLES: Set<string> = new Set([
4646
"decision_ledger_anchors",
4747
"decision_records",
4848
"decision_replay_inputs",
49+
// #9028: same raw-SQL-only posture as its sibling above — an operator-private blob table the drizzle
50+
// schema never queries (writes go through persistDecisionReplayPrompt's raw statement; reads happen only
51+
// in the operator's own extract SQL for the replay CLI).
52+
"decision_replay_prompts",
4953
"ai_review_verdict_flips",
5054
"global_agent_controls",
5155
"global_contributor_blacklist",

scripts/replay-decision.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,38 @@
2727
// Exit codes: 0 = replayed, same verdict ("here is the clause"); 1 = DIVERGENCE — a bug by definition,
2828
// file it with the printed stage diff; 2 = unusable input. Replay mode cannot re-query the model or touch
2929
// any network/DB by construction: replayDecision is a pure function of the two JSON values.
30+
//
31+
// #9028: `--requery <n>` is the ONE deliberately-networked mode, for the one stage pure replay cannot cover.
32+
// It re-runs the model n times against the EXACT persisted prompt and reports an ACTION-MATCH RATE — never
33+
// "reproducibility": hosted inference is not bit-deterministic even at temperature 0 (batching, kernel
34+
// scheduling, silent model revisions), so bit-comparing outputs would measure the provider's scheduler, not
35+
// the decision. What CAN be honestly measured is whether fresh runs land in the same verdict CLASS the
36+
// recorded decision acted on (defect vs clean, through the same parseModelReview the live pipeline uses).
37+
//
38+
// The bundle gains an optional `prompt` for this mode. EXTRACT (joins the private prompts sibling by the
39+
// BASE record id, so a supersession's `:rev<N>` row still finds its head's prompt):
40+
// SELECT json_build_object(
41+
// 'record', to_jsonb(dr) || dr.record_json::jsonb,
42+
// 'replayInput', dri.replay_json::jsonb,
43+
// 'prompt', drp.prompt_json::jsonb
44+
// )
45+
// FROM decision_records dr
46+
// JOIN decision_replay_inputs dri ON dri.record_id = dr.id
47+
// LEFT JOIN decision_replay_prompts drp
48+
// ON drp.record_id = 'record:' || dr.repo_full_name || '#' || dr.pull_number || '@' || dr.head_sha
49+
// WHERE dr.id = 'record:<owner/repo>#<pr>@<head sha>';
50+
//
51+
// Provider config (explicit env, no defaults — this is a debugging tool, not a service):
52+
// REPLAY_AI_BASE_URL + REPLAY_AI_MODEL [+ REPLAY_AI_API_KEY] -> any OpenAI-compatible endpoint (Ollama etc.)
53+
// ANTHROPIC_API_KEY + REPLAY_AI_MODEL -> Anthropic
54+
//
55+
// Requery exit codes: 0 = report produced (a low rate is a FINDING, not a failure); 2 = unusable input or
56+
// missing provider config. Prompts above the persistence cap were skipped at capture time, never truncated —
57+
// a truncated prompt re-queried would report a rate for a prompt that was never sent.
3058
import { readFileSync } from "node:fs";
3159
import { replayDecision, type DecisionReplayInput, type ReplayableRecord } from "../src/review/decision-replay";
60+
import { recordedJudgmentClass, runRequery } from "../src/review/decision-requery";
61+
import { createAnthropicAi, createOpenAiCompatibleAi, type SelfHostAi } from "../src/selfhost/ai";
3262

3363
/** Parse + normalize a bundle (snake_case SQL rows accepted) and replay it. Exported for tests.
3464
*
@@ -61,9 +91,86 @@ export function runReplayBundle(raw: string, atMs?: number): { outcome: ReturnTy
6191
return { outcome: replayDecision(record, replayInput, atMs === undefined ? {} : { nowMs: atMs }) };
6292
}
6393

94+
/** Parse the bundle's optional prompt + replay input for requery. Exported for tests. */
95+
export function parseRequeryBundle(raw: string): { systemPrompt: string; userPrompt: string; recordedClass: "defect" | "clean" } | { error: string } {
96+
let bundle: { replayInput?: DecisionReplayInput; prompt?: { systemPrompt?: unknown; userPrompt?: unknown } };
97+
try {
98+
bundle = JSON.parse(raw) as never;
99+
} catch (error) {
100+
return { error: `unparseable bundle JSON: ${error instanceof Error ? error.message : String(error)}` };
101+
}
102+
const systemPrompt = bundle.prompt?.systemPrompt;
103+
const userPrompt = bundle.prompt?.userPrompt;
104+
if (typeof systemPrompt !== "string" || systemPrompt.length === 0 || typeof userPrompt !== "string" || userPrompt.length === 0) {
105+
return { error: "requery needs bundle.prompt.{systemPrompt,userPrompt} — extract with the prompts LEFT JOIN in this file's header (rows age out after 30 days; older decisions cannot be re-queried, only replayed)" };
106+
}
107+
if (!bundle.replayInput || !Array.isArray(bundle.replayInput.findings)) {
108+
return { error: "requery needs bundle.replayInput.findings to derive the recorded verdict class" };
109+
}
110+
return { systemPrompt, userPrompt, recordedClass: recordedJudgmentClass(bundle.replayInput) };
111+
}
112+
113+
/** Build the provider client from explicit env. Exported for tests; returns an error string when unconfigured. */
114+
export function requeryClientFromEnv(env: Record<string, string | undefined>): { ai: SelfHostAi; model: string } | { error: string } {
115+
const model = env.REPLAY_AI_MODEL;
116+
if (!model) return { error: "requery needs REPLAY_AI_MODEL (explicit — this tool never guesses which model to spend against)" };
117+
if (env.REPLAY_AI_BASE_URL) {
118+
return { ai: createOpenAiCompatibleAi({ baseUrl: env.REPLAY_AI_BASE_URL, apiKey: env.REPLAY_AI_API_KEY, model }), model };
119+
}
120+
if (env.ANTHROPIC_API_KEY) {
121+
return { ai: createAnthropicAi({ apiKey: env.ANTHROPIC_API_KEY, model }), model };
122+
}
123+
return { error: "requery needs REPLAY_AI_BASE_URL (OpenAI-compatible) or ANTHROPIC_API_KEY" };
124+
}
125+
64126
const invokedDirectly = process.argv[1]?.endsWith("replay-decision.ts") === true;
65127
if (invokedDirectly) {
66128
const argv = process.argv.slice(2);
129+
const requeryIndex = argv.indexOf("--requery");
130+
if (requeryIndex !== -1) {
131+
const runsRaw = argv[requeryIndex + 1];
132+
const runs = Number(runsRaw);
133+
if (!Number.isInteger(runs) || runs < 1 || runs > 25) {
134+
console.error("replay-decision: --requery requires a run count between 1 and 25");
135+
process.exit(2);
136+
}
137+
const requerySource = argv.filter((_arg, index) => index !== requeryIndex && index !== requeryIndex + 1)[0];
138+
if (!requerySource) {
139+
console.error("usage: replay-decision.ts <bundle.json | -> --requery <n>");
140+
process.exit(2);
141+
}
142+
const rawBundle = requerySource === "-" ? readFileSync(0, "utf8") : readFileSync(requerySource, "utf8");
143+
const parsed = parseRequeryBundle(rawBundle);
144+
if ("error" in parsed) {
145+
console.error(`replay-decision: ${parsed.error}`);
146+
process.exit(2);
147+
}
148+
const client = requeryClientFromEnv(process.env);
149+
if ("error" in client) {
150+
console.error(`replay-decision: ${client.error}`);
151+
process.exit(2);
152+
}
153+
const report = await runRequery({
154+
systemPrompt: parsed.systemPrompt,
155+
userPrompt: parsed.userPrompt,
156+
runs,
157+
recordedClass: parsed.recordedClass,
158+
// The live call's own shape (ai-review.ts): a system turn plus a user turn, temperature 0. Matching it
159+
// is what makes the rate a statement about the DECISION and not about a different way of asking.
160+
callModel: async (systemPrompt, userPrompt) => {
161+
const result = await client.ai.run(client.model, {
162+
temperature: 0,
163+
messages: [
164+
{ role: "system", content: systemPrompt },
165+
{ role: "user", content: userPrompt },
166+
],
167+
});
168+
return result.response ?? "";
169+
},
170+
});
171+
console.log(JSON.stringify(report, null, 2));
172+
process.exit(0);
173+
}
67174
const atIndex = argv.indexOf("--at");
68175
const atRaw = atIndex === -1 ? undefined : argv[atIndex + 1];
69176
if (atIndex !== -1 && (atRaw === undefined || !Number.isFinite(Number(atRaw)))) {

src/db/retention.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ export const RETENTION_POLICY: readonly RetentionRule[] = [
101101
{ table: "predicted_gate_calibration_ledger", column: "created_at", days: 90 },
102102
{ table: "contributor_gate_history", column: "created_at", days: 90 },
103103
{ table: "decision_replay_inputs", column: "created_at", days: 180 },
104+
// #9028: deliberately SHORTER than decision_replay_inputs' 180d -- the prompt blob embeds the full diff
105+
// plus contributor content (the largest, most sensitive artifact in the replay family), the re-query mode
106+
// is a debugging tool for RECENT decisions, and the public promptDigest commitment outlives the text.
107+
{ table: "decision_replay_prompts", column: "created_at", days: 30 },
104108
];
105109

106110
// #9083: a real, single-column, indexable primary key for the ordered-range delete below, keyed by table
@@ -142,6 +146,8 @@ export const RETENTION_PK_COLUMN: Readonly<Record<string, string>> = {
142146
contributor_gate_history: "id",
143147
// decision_replay_inputs keys on record_id (decision_records.id), not an `id` column.
144148
decision_replay_inputs: "record_id",
149+
// Same key shape as decision_replay_inputs above, for the same reason.
150+
decision_replay_prompts: "record_id",
145151
};
146152

147153
/**

src/queue/ai-review-orchestration.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { recordAuditEvent, getDecryptedRepositoryAiKey, getRepository, listCheck
2626
import { registerHeldLock, unregisterHeldLock } from "./held-lock-registry";
2727
import { recordRoutingShadow } from "../services/reviewer-routing";
2828
import { scoreJudgmentAgreement } from "../review/judgment-agreement";
29+
import { persistDecisionReplayPrompt } from "../review/decision-replay";
2930
import { createInstallationToken } from "../github/app";
3031
import type { AgentActionMode } from "../settings/agent-execution";
3132
import { buildAiReviewDiff } from "../review/review-diff";
@@ -927,6 +928,24 @@ export async function runAiReviewForAdvisory(
927928
// the REAL reviewer identities and the REAL system prompt into DecisionRecord instead of hardcoding null.
928929
const aiJudgmentModelIds = parsedReviewModelIds(result.reviewDiagnostics ?? []);
929930
const aiJudgmentPromptDigest = result.systemPromptDigest;
931+
// #9028: capture the ACTUAL prompt text the digest above commits to, so the replay harness's re-query
932+
// mode can re-run the model for this exact target. Written here (not at the decision-record persist)
933+
// because the text must never travel through findings -- findings reach public render surfaces, and the
934+
// prompt embeds the full diff. Keyed by the derivable base record id; an orphan row from a pass that
935+
// never finalizes a decision ages out with the table's 30-day retention. Every verdict class is captured
936+
// -- a CLEAN decision's action-match rate matters exactly as much as a defect's.
937+
/* v8 ignore next -- the no-head arm is a plain skip, not a protection: without a SHA there is nothing to
938+
* key the prompt row by, and a ghost PR's decision record is head-keyed too, so requery would have no row
939+
* to join even if one were written. */
940+
if (args.advisory.headSha) {
941+
await persistDecisionReplayPrompt(env, {
942+
repoFullName: args.repoFullName,
943+
pullNumber: args.pr.number,
944+
headSha: args.advisory.headSha,
945+
systemPrompt: result.systemPrompt,
946+
userPrompt: result.userPrompt,
947+
});
948+
}
930949
// #8834: inter-run agreement over the stances this review ALREADY produced (#8229's reviewerVotes) —
931950
// zero additional AI spend. Computed once and attached to whichever AI-judgment finding is built below,
932951
// so the decision record carries a per-decision confidence signal for the calibration set (#8835).

src/review/decision-replay.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,50 @@ export async function persistDecisionReplayInputForGate(
214214
});
215215
}
216216

217+
/** #9028: prompts above this size are SKIPPED, never truncated -- a truncated prompt re-queried against the
218+
* model would report an action-match rate for a prompt that was never sent, which is worse than reporting
219+
* nothing. The public promptDigest commitment is unaffected either way. Sized well under D1's row ceiling. */
220+
export const DECISION_REPLAY_PROMPT_MAX_CHARS = 900_000;
221+
222+
/**
223+
* #9028: persist the EXACT system prompt sent to the model, keyed by the BASE record id
224+
* (`record:<repo>#<pr>@<head sha>`) rather than a supersession's `:rev<N>` id -- the prompt is a property of
225+
* the (target, head, resolved-config) triple, not of which revision row happened to land, and upsert-last-wins
226+
* means a superseding pass that rebuilt the prompt (config changed between passes) leaves the one that
227+
* matches the LATEST decision. Private sibling of decision_replay_inputs with the same posture and a shorter
228+
* (30-day) retention; the text must never reach the public record or any rendered surface.
229+
*
230+
* Best-effort like every persist in this family: prompt capture must never break the review pass that
231+
* produced it.
232+
*/
233+
export async function persistDecisionReplayPrompt(
234+
env: Env,
235+
args: { repoFullName: string; pullNumber: number; headSha: string; systemPrompt: string; userPrompt: string },
236+
): Promise<void> {
237+
if (args.systemPrompt.length + args.userPrompt.length > DECISION_REPLAY_PROMPT_MAX_CHARS) {
238+
console.warn(
239+
JSON.stringify({
240+
event: "decision_replay_prompt_skipped_oversize",
241+
repoFullName: args.repoFullName,
242+
pullNumber: args.pullNumber,
243+
chars: args.systemPrompt.length + args.userPrompt.length,
244+
}),
245+
);
246+
return;
247+
}
248+
const recordId = `record:${args.repoFullName}#${args.pullNumber}@${args.headSha}`.slice(0, 250);
249+
try {
250+
await env.DB.prepare(
251+
`INSERT INTO decision_replay_prompts (record_id, prompt_json, created_at) VALUES (?, ?, ?)
252+
ON CONFLICT(record_id) DO UPDATE SET prompt_json = excluded.prompt_json, created_at = excluded.created_at`,
253+
)
254+
.bind(recordId, JSON.stringify({ systemPrompt: args.systemPrompt, userPrompt: args.userPrompt }), nowIso())
255+
.run();
256+
} catch (error) {
257+
console.warn(JSON.stringify({ event: "decision_replay_prompt_persist_error", recordId: recordId.slice(0, 120), message: errorMessage(error).slice(0, 160) }));
258+
}
259+
}
260+
217261
export async function persistDecisionReplayInput(env: Env, recordId: string, input: DecisionReplayInput): Promise<void> {
218262
try {
219263
await env.DB.prepare(

0 commit comments

Comments
 (0)