Skip to content

Commit f366b1d

Browse files
authored
feat(review): record MCP predicted-gate verdicts and measure predicted-vs-live gate agreement (#4558)
Records every gittensory_predict_gate/explain_gate_disposition call into a new local-only, login-keyed predicted_gate_calls table (mirroring contributor_gate_history's 0126 precedent -- review_audit stays deliberately actor-login-free, per its own migration's privacy design). A new sibling to computeGateEval/computeGateParity, computePredictedGateAgreement, joins predicted_gate_calls against the already-existing contributor_gate_history by (project, login, timestamp) -- the correlation key predict-time calls can actually provide, since predictGateShape has no PR-number field pre-submission -- and folds the result into a per-project agreement/disagreement confusion matrix, surfaced via a new GET /v1/internal/predicted-agreement endpoint alongside the existing /v1/internal/parity one. Fixes #4516
1 parent aacfc3b commit f366b1d

9 files changed

Lines changed: 545 additions & 0 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
-- #predicted-live-gate-agreement (maintainer review-stack x AMS integration audit, 2026-07-09): the data
2+
-- substrate for measuring how often the MCP `gittensory_predict_gate`/`gittensory_explain_gate_disposition`
3+
-- verdict agrees with the REAL gate decision the same contributor's PR later receives.
4+
--
5+
-- WHY A NEW TABLE, NOT A `review_audit` ROW: `predictGateShape` has no PR-number field (it is an explicit
6+
-- pre-PR-existence dry run), so a predicted call cannot be keyed `project#pr` the way recordNativeGateDecision
7+
-- keys a real gate_decision -- there is no PR yet to key against. The only reliable correlation key available
8+
-- at predict-time is (project, login, timestamp), which means CORRELATING a predicted call to its eventual
9+
-- real PR requires a login-keyed join. `review_audit` (migrations/0049) is DELIBERATELY actor-login-free
10+
-- ("No actor logins... ONLY") specifically because it feeds the anonymized cross-instance orb-collector export
11+
-- (src/selfhost/orb-collector.ts) -- exactly the same reason migrations/0126 (contributor_gate_history) is its
12+
-- own separate, local-only table rather than a review_audit column. This table follows that identical
13+
-- precedent: a SEPARATE, LOCAL-ONLY, login-keyed substrate, never wired into exportOrbBatch or any other
14+
-- cross-instance/public export path. See src/review/predicted-gate-calls.ts for the writer and
15+
-- src/review/predicted-gate-agreement.ts for the reader, which joins this table against the ALREADY-EXISTING
16+
-- contributor_gate_history (0126) -- the login-keyed real-decision data that table already records -- rather
17+
-- than duplicating the real side of the comparison into a second copy.
18+
--
19+
-- Privacy: this table is per-login by design (see migrations/0126's identical rationale for why login, not a
20+
-- hash, is fine for a LOCAL-ONLY table). Any output DERIVED from it (the agreement-rate metric) must remain
21+
-- aggregated -- never render which login contributed which paired row on any public/contributor-facing surface.
22+
CREATE TABLE IF NOT EXISTS predicted_gate_calls (
23+
id TEXT PRIMARY KEY NOT NULL,
24+
-- The GitHub login the prediction was requested for (the miner's own `login` input to predict_gate).
25+
login TEXT NOT NULL,
26+
-- Which repo the prediction is for.
27+
project TEXT NOT NULL,
28+
-- The predicted gate action: 'merge' | 'hold' (nativeGateActionFromConclusion's mapping -- the predicted-gate
29+
-- engine never predicts 'close', mirroring the live gate: it is a CHECK that passes or blocks, never closes).
30+
predicted_action TEXT NOT NULL,
31+
-- The raw predicted verdict conclusion (success/failure/action_required/neutral), kept alongside the
32+
-- collapsed predicted_action for observability -- e.g. distinguishing a hard blocker from an inconclusive hold.
33+
conclusion TEXT NOT NULL,
34+
-- Bounded reason-class code for the predicted verdict (mirrors review_audit.summary / neutralHoldReasonCode),
35+
-- never a raw finding title/detail (which can embed contributor- or per-repo-controlled text).
36+
reason_code TEXT,
37+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
38+
);
39+
40+
-- The read side (computePredictedGateAgreement) scans "this project's predicted calls in a recency window,
41+
-- grouped by login" to pair each against contributor_gate_history's real decisions.
42+
CREATE INDEX IF NOT EXISTS predicted_gate_calls_project_login_idx
43+
ON predicted_gate_calls(project, login, created_at);

scripts/check-schema-drift.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const RAW_SQL_ONLY_TABLES = new Set([
5050
"orb_signals",
5151
"orb_webhook_events",
5252
"override_audit",
53+
"predicted_gate_calls",
5354
"repo_chunks",
5455
"review_audit",
5556
"review_targets",

src/api/routes.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ import { buildRepoOutcomeCalibration } from "../services/outcome-calibration";
255255
import { loadGatePrecisionReport } from "../services/gate-precision";
256256
import { computeOpsStats, isOpsEnabled } from "../review/ops-wire";
257257
import { computeParityReadiness, isParityAuditEnabled } from "../review/parity-wire";
258+
import { computePredictedGateAgreement } from "../review/predicted-gate-agreement";
258259
import { isRagEnabled } from "../review/rag-wire";
259260
import { getPublicStats, isPublicStatsEnabled } from "../review/public-stats";
260261
import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from "../services/maintainer-quality-dashboard";
@@ -3498,6 +3499,18 @@ export function createApp() {
34983499
return c.json(await computeParityReadiness(c.env));
34993500
});
35003501

3502+
// #predicted-live-gate-agreement (maintainer review-stack x AMS integration audit, 2026-07-09): how often the
3503+
// MCP predict_gate/explain_gate_disposition verdict agrees with the REAL gate decision a contributor's PR
3504+
// later receives -- a DIFFERENT question than /v1/internal/parity's reviewbot-vs-gittensory migration parity
3505+
// (see src/review/predicted-gate-agreement.ts's module header). Same gate/auth contract as /v1/internal/parity:
3506+
// bearer-gated by the `/v1/internal/*` middleware, 404 when GITTENSORY_REVIEW_PARITY_AUDIT is off so the
3507+
// endpoint does not exist on a deploy not running this telemetry family. Aggregate counts only — no PR
3508+
// content / actor logins (see that module's privacy note on why a per-login breakdown never belongs here).
3509+
app.get("/v1/internal/predicted-agreement", async (c) => {
3510+
if (!isParityAuditEnabled(c.env)) return c.json({ error: "not_found" }, 404);
3511+
return c.json(await computePredictedGateAgreement(c.env, { days: 90, nowMs: Date.now() }));
3512+
});
3513+
35013514
app.post("/v1/internal/jobs/refresh-registry", async (c) => {
35023515
const message: JobMessage = { type: "refresh-registry", requestedBy: "api" };
35033516
await c.env.JOBS.send(message);

src/mcp/server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ import { loadUpstreamStatus } from "../upstream/ruleset";
162162
import { simulateOpenPrPressure, type OpenPrPressureInput } from "../services/open-pr-pressure-scenarios";
163163
import { buildFindingTaxonomyDocument, FINDING_TAXONOMY_URI } from "../review/finding-taxonomy";
164164
import { buildEnrichmentAnalyzersTaxonomyDocument, ENRICHMENT_ANALYZERS_URI } from "../review/enrichment-analyzers-taxonomy";
165+
import { recordPredictedGateCall } from "../review/predicted-gate-calls";
165166

166167
type AppContext = Context<{ Bindings: Env }>;
167168
type ToolPayload = {
@@ -2957,6 +2958,13 @@ export class GittensoryMcp {
29572958
confirmedContributor,
29582959
...(input.changedPaths === undefined ? {} : { changedPaths: input.changedPaths }),
29592960
});
2961+
// #predicted-live-gate-agreement: record this call so a later real gate decision for the same
2962+
// (repo, login) can be paired against it (src/review/predicted-gate-agreement.ts). Shared by BOTH
2963+
// predictGate and explainGateDisposition (this function backs both tools) -- a caller that invokes both
2964+
// for what is really one logical check records two rows, a small, acceptable volume over-count rather
2965+
// than threading a request-scoped dedup key through a read-only prediction path. Best-effort; never
2966+
// blocks or fails the tool response.
2967+
await recordPredictedGateCall(this.env, { login: input.login, project: repoFullName, verdict });
29602968
return { repoFullName, verdict };
29612969
}
29622970

9.59 KB
Binary file not shown.

src/review/predicted-gate-calls.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Predicted-gate call history (#predicted-live-gate-agreement, maintainer review-stack x AMS integration
2+
// audit 2026-07-09) -- records EVERY MCP `gittensory_predict_gate`/`gittensory_explain_gate_disposition` call,
3+
// so a later real gate decision for the same (project, login) can be paired against it (see
4+
// src/review/predicted-gate-agreement.ts for the read/join side). Structurally a sibling of
5+
// src/review/contributor-calibration.ts: `review_audit` (migrations/0049) is DELIBERATELY actor-login-free
6+
// (feeds the anonymized orb-collector export), so this is its own separate, LOCAL-ONLY table
7+
// (migrations/0132) -- never wired into exportOrbBatch or any other cross-instance/public export path.
8+
//
9+
// UNLIKE contributor-calibration.ts's per-commit dedup (a re-run at the same head_sha replaces its prior row),
10+
// every predict_gate call gets its OWN row here: there is no commit to dedup against pre-submission, and a
11+
// miner iterating on the same repo (tweaking a title, retrying after a blocker) makes a genuinely new inquiry
12+
// each time -- collapsing them would undercount how often the tool was actually consulted.
13+
14+
import { isParityAuditEnabled, nativeGateActionFromConclusion } from "./parity-wire";
15+
import type { GateCheckConclusion } from "../rules/advisory";
16+
import { isSelfHostedReviewRuntime } from "../selfhost/review-runtime";
17+
import { errorMessage, nowIso } from "../utils/json";
18+
19+
/** The minimal env shape the recorder needs -- mirrors parity-wire.ts's ParityRecorderEnv / contributor-
20+
* calibration.ts's ContributorCalibrationEnv exactly, since this records under the identical self-hosted/
21+
* parity-flag gate (one flag controls the whole gate-accuracy telemetry family). */
22+
type PredictedGateCallEnv = {
23+
DB: D1Database;
24+
GITTENSORY_REVIEW_PARITY_AUDIT?: string | undefined;
25+
SELFHOST_TRANSIENT_CACHE?: NonNullable<Env["SELFHOST_TRANSIENT_CACHE"]>;
26+
};
27+
28+
/** The minimal verdict shape this recorder needs -- structurally compatible with PredictedGateVerdict
29+
* (packages/gittensory-engine), whose `blockers` entries are the public-safe shape (no `severity`), unlike
30+
* the real gate's AdvisoryFinding -- so this reads only `.code`, never reusing neutralHoldReasonCode's
31+
* stricter AdvisoryFinding-typed signature (see the reasonCode comment below for why that's an acceptable,
32+
* deliberately coarser fallback on the predicted side). */
33+
type RecordablePredictedVerdict = {
34+
conclusion: GateCheckConclusion;
35+
blockers: Array<{ code: string }>;
36+
};
37+
38+
/**
39+
* Record one MCP predict_gate/explain_gate_disposition call into `predicted_gate_calls`, keyed by the
40+
* requested contributor's login. Gated identically to {@link recordNativeGateDecision} in parity-wire.ts (same
41+
* self-hosted-always-records / cloud-flag-gated contract) -- this is additive telemetry alongside the same
42+
* gate-accuracy measurement family, not a separate feature with its own on/off knob.
43+
*
44+
* Best-effort: a write failure is swallowed (telemetry must never break the MCP tool response). A missing/
45+
* empty login records nothing -- there is no meaningful per-actor row to write without one.
46+
*/
47+
export async function recordPredictedGateCall(
48+
env: PredictedGateCallEnv,
49+
input: { login: string | null | undefined; project: string; verdict: RecordablePredictedVerdict },
50+
): Promise<void> {
51+
if (!isSelfHostedReviewRuntime(env) && !isParityAuditEnabled(env)) return;
52+
const login = input.login?.trim();
53+
if (!login) return;
54+
const action = nativeGateActionFromConclusion(input.verdict.conclusion);
55+
if (action === null) return; // "skipped" -- not a comparable prediction (mirrors recordNativeGateDecision)
56+
const project = input.project.slice(0, 200);
57+
// Coarser than the real gate_decision's summary (which recovers a specific neutral-hold sub-code via
58+
// neutralHoldReasonCode): the predicted-gate engine's public verdict shape carries no `severity` on its
59+
// findings, so it isn't AdvisoryFinding-shaped and can't reuse that stricter-typed helper. reason_code here
60+
// is an observability aid only (not read by computePredictedGateAgreement's core comparison), so the bare
61+
// conclusion string is an acceptable fallback for every non-failure case.
62+
const reasonCode = input.verdict.conclusion === "failure" ? (input.verdict.blockers[0]?.code ?? input.verdict.conclusion) : input.verdict.conclusion;
63+
try {
64+
// Every call gets its own row (no dedup key) -- see the module header for why, unlike
65+
// recordContributorGateDecision's per-commit replace.
66+
await env.DB.prepare(
67+
`INSERT INTO predicted_gate_calls (id, login, project, predicted_action, conclusion, reason_code, created_at)
68+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
69+
)
70+
.bind(`predicted:${login}:${project}:${nowIso()}:${Math.random().toString(36).slice(2, 8)}`, login, project, action, input.verdict.conclusion, reasonCode.slice(0, 200), nowIso())
71+
.run();
72+
} catch (error) {
73+
console.warn(JSON.stringify({ event: "predicted_gate_calls_record_error", project, message: errorMessage(error).slice(0, 200) }));
74+
}
75+
}

test/unit/mcp-predict-gate.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,50 @@ testExpectations:
228228
expect(result.isError).toBe(true);
229229
expect(JSON.stringify(result.content)).toContain("authenticated GitHub login");
230230
});
231+
232+
describe("records the call for predicted-vs-live agreement measurement (#predicted-live-gate-agreement)", () => {
233+
async function rawAll(env: Env, sql: string): Promise<Record<string, unknown>[]> {
234+
const res = await (env.DB as unknown as { prepare: (s: string) => { all: <T>() => Promise<{ results: T[] }> } }).prepare(sql).all<Record<string, unknown>>();
235+
return res.results;
236+
}
237+
238+
it("SELF-HOSTED instances record a predicted_gate_calls row on a successful predict_gate call", async () => {
239+
const env = createTestEnv(); // SELFHOST_TRANSIENT_CACHE present by default → self-hosted
240+
const client = await connect(env);
241+
242+
await client.callTool({
243+
name: "gittensory_predict_gate",
244+
arguments: { login: "miner1", owner: "acme", repo: "widgets", title: "Add retry to upload client" },
245+
});
246+
247+
const rows = await rawAll(env, "SELECT * FROM predicted_gate_calls");
248+
expect(rows).toHaveLength(1);
249+
expect(rows[0]).toMatchObject({ login: "miner1", project: "acme/widgets" });
250+
});
251+
252+
it("also records from gittensory_explain_gate_disposition (both tools share computePredictedGateVerdict)", async () => {
253+
const env = createTestEnv();
254+
const client = await connect(env);
255+
256+
await client.callTool({
257+
name: "gittensory_explain_gate_disposition",
258+
arguments: { login: "miner1", owner: "acme", repo: "widgets", title: "Add retry to upload client" },
259+
});
260+
261+
expect(await rawAll(env, "SELECT * FROM predicted_gate_calls")).toHaveLength(1);
262+
});
263+
264+
it("records NOTHING on the CLOUD WORKER when GITTENSORY_REVIEW_PARITY_AUDIT is unset (byte-identical default)", async () => {
265+
const env = createTestEnv();
266+
delete env.SELFHOST_TRANSIENT_CACHE; // simulate the cloud worker
267+
const client = await connect(env);
268+
269+
await client.callTool({
270+
name: "gittensory_predict_gate",
271+
arguments: { login: "miner1", owner: "acme", repo: "widgets", title: "Add retry to upload client" },
272+
});
273+
274+
expect(await rawAll(env, "SELECT * FROM predicted_gate_calls")).toHaveLength(0);
275+
});
276+
});
231277
});

0 commit comments

Comments
 (0)