Skip to content

Commit e1c092b

Browse files
authored
fix(stats): measure accuracy over auto-actioned PRs, not every reviewed PR (#9793)
Live after both #9718 and #9768 deployed: byProject still published 100% for all three repos, on 2377/602/508 reviewed PRs with zero reversals. Neither earlier fix could have caught it. Both addressed the WINDOW the denominator covers -- #9718 withheld the ratio where a reversal was unobservable, #9768 bounded an immortal denominator to its numerator's retention -- and the defect is WHICH PRs belong in it. A reversal is by definition a human overturning an engine auto-action; recordReversalSignals only records one against a PR the engine merged or closed. The denominator was `github_app.pr_public_surface_published`: every PR that got a review surface, including the many the engine only commented on, held, or advised. None of those could produce a reversal, so each one pushes 1 - reversed/decided toward 100% for reasons unrelated to gate quality. Both surfaces now divide by the distinct PRs this deployment auto-actioned in the window -- same event types, same outcome filter, same dry-run exclusion that loadReversalDayRows already applies when anchoring the numerator, so the two halves of the ratio are finally drawn from one population. The weekly trend had the same mismatch, masked only because its own-ledger series currently reports null for recent weeks; fixing one and not the other would have left two definitions of "decided" on one page. Volume columns are untouched: reviewed/merged/closed keep publishing lifetime and fleet-folded counts, which are measured and correct. Only the ratio's denominator narrows. #9718's observability probe is REMOVED rather than left beside the thing that supersedes it. No auto-actions now means decided is 0 and the answer is null by construction -- a structural guarantee, where the probe was a heuristic that could disagree with the denominator sitting next to it. Six fixtures were made faithful rather than the assertion relaxed: each modelled a decided PR with no auto-action, which is not a reachable state. The clearest is the revert-PR regression, which recorded a reversal_reverted with no agent.action.merge -- a PR cannot be reverted unless the engine merged it. Closes #9792
1 parent ec8a1d3 commit e1c092b

5 files changed

Lines changed: 195 additions & 121 deletions

File tree

src/review/public-stats.ts

Lines changed: 39 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -165,47 +165,19 @@ function filteredPct(reviewed: number, merged: number): number | null {
165165
return Math.round(((reviewed - merged) / reviewed) * 1000) / 10;
166166
}
167167

168-
/** The auto-actions a reversal is recorded against. `recordReversalSignals` only ever fires for a PR this
169-
* deployment itself auto-merged or auto-closed, so with zero of these rows in the retained window a
170-
* `reversal_*` event cannot exist — and `1 - 0/N` is then a structural zero, not a measurement. */
171-
export const AUTO_ACTION_EVENT_TYPES = ["agent.action.close", "agent.action.merge"] as const;
172-
173-
/**
174-
* Whether a reversal is OBSERVABLE on this deployment: did it record any terminal auto-action in the window a
175-
* reversal could still be attributed to? Reversal signals are written only by the review-execution pipeline
176-
* (`recordReversalSignals`, reached via the `github-webhook` job), which a runtime that does not execute
177-
* reviews acks-and-drops — so on such a runtime `reversed` is pinned at 0 forever while the merged/closed
178-
* denominator keeps growing, and every reversal-grounded percentage converges on a fake 100%.
168+
/** Reversal-grounded accuracy over the AUTO-ACTIONED merged + closed; null until there is signal.
179169
*
180-
* Publishing that as accuracy is the exact failure #8820 called out for the fleet number ("the old formula
181-
* overstated accuracy") and #7449 fixed for the global one. This is the same discipline the rest of this
182-
* surface already applies — a sparse rule's precision is null, `gamingFlagsCaught` is null below three
183-
* instances — extended to the case where the numerator's WRITER, not just its sample, is absent.
184-
*/
185-
export async function loadReversalObservability(env: Env, knownReversals = 0): Promise<boolean> {
186-
// A recorded reversal is itself proof the pipeline that records them runs here -- no probe needed, and no
187-
// query on the hot path for any deployment that has ever overturned an auto-action.
188-
if (knownReversals > 0) return true;
189-
const rows = await safeAll<{ n: number }>(
190-
env,
191-
`SELECT COUNT(*) AS n FROM audit_events
192-
WHERE event_type IN (${AUTO_ACTION_EVENT_TYPES.map((type) => `'${type}'`).join(", ")})
193-
AND created_at >= ?`,
194-
retentionCutoffIsoForTable("audit_events"),
195-
);
196-
return (rows[0]?.n ?? 0) > 0;
197-
}
198-
199-
/** Reversal-grounded accuracy over the irreversible auto-actions (merged + closed); null until there is signal,
200-
* and null when a reversal is not observable at all on this deployment (see {@link loadReversalObservability}) —
201-
* an unmeasurable quantity stays unknown rather than rendering as a perfect score. */
170+
* `merged`/`closed` must come from the auto-action denominator (#9792), never the published-surface counts:
171+
* a reversal only ever exists for a PR the engine itself merged or closed, so a wider denominator counts PRs
172+
* whose reversal was never possible and drifts the ratio toward 100%. That also makes the separate
173+
* observability probe #9718 added redundant and it has been removed — no auto-actions means `decided` is 0
174+
* and the answer is null by construction, which is a structural guarantee rather than a heuristic that
175+
* could disagree with the denominator beside it. */
202176
function accuracyPct(
203177
merged: number,
204178
closed: number,
205179
reversed: number,
206-
reversalObservable: boolean,
207180
): number | null {
208-
if (!reversalObservable) return null;
209181
const decided = merged + closed;
210182
if (decided <= 0) return null;
211183
// `reversed` counts engine auto-actions regardless of a PR's CURRENT disposition, so a reopened
@@ -378,22 +350,39 @@ export async function getPublicStats(
378350
GROUP BY ev.repo`,
379351
...projects,
380352
),
381-
// The ACCURACY denominator, bounded to the same window its numerator can survive in. `reviewed`/
382-
// `merged`/`closed` above are lifetime: `github_app.pr_public_surface_published` is the single
383-
// retention-EXEMPT audit event type (src/db/retention.ts's DURABLE_AUDIT_EVENT_TYPES), so those
384-
// counts never shrink. `reversal_*` rows are pruned with the rest of audit_events at 90 days. Pairing
385-
// an immortal denominator with a 90-day numerator makes `1 - reversed/decided` drift toward 100% as
386-
// the ledger ages, independent of real reversal behavior -- the same shape #7449 fixed for the
387-
// Orb-folded denominator, and confirmed live (2377/602/508 reviewed, 0 reversals, 100% each).
353+
// The ACCURACY denominator: the PRs this deployment AUTO-ACTIONED in the window, not the PRs it
354+
// published a surface for.
355+
//
356+
// A reversal is by definition a human overturning an engine auto-action -- outcomes-wire.ts only records
357+
// one against a PR the engine merged or closed. So the population the numerator can draw from is
358+
// auto-actions, and any denominator wider than that counts PRs whose reversal was never possible.
359+
// `github_app.pr_public_surface_published` is wider in two independent ways: it is the single
360+
// retention-EXEMPT event type (retention.ts's DURABLE_AUDIT_EVENT_TYPES) so it never shrinks, and it
361+
// covers every reviewed PR including the ones the engine only commented on. #9768 fixed the first by
362+
// windowing it; this fixes the second, which was why the published figure was STILL 100% for all three
363+
// repos on 2377/602/508 reviewed with 0 reversals after that shipped.
364+
//
365+
// This is the pairing public-accuracy-trend.ts's loadReversalDayRows already uses for the weekly series
366+
// -- it anchors reversals on `agent.action.*` rows, dry-runs excluded -- so the two surfaces now agree on
367+
// what "decided" means instead of each choosing its own denominator.
388368
safeAll<{ project: string; merged: number; closed: number }>(
389369
env,
390-
`SELECT ev.repo AS project,
370+
`SELECT act.project AS project,
391371
SUM(CASE WHEN pr.merged_at IS NOT NULL THEN 1 ELSE 0 END) AS merged,
392372
SUM(CASE WHEN pr.state = 'closed' AND pr.merged_at IS NULL THEN 1 ELSE 0 END) AS closed
393-
FROM (SELECT DISTINCT repo, number FROM (${PUBLISHED_PR_KEYS}) WHERE created_at >= ?) ev
394-
LEFT JOIN pull_requests pr ON pr.repo_full_name = ev.repo AND pr.number = ev.number
395-
WHERE LOWER(ev.repo) IN (${inList})
396-
GROUP BY ev.repo`,
373+
FROM (
374+
SELECT DISTINCT substr(target_key, 1, instr(target_key, '#') - 1) AS project,
375+
CAST(substr(target_key, instr(target_key, '#') + 1) AS INTEGER) AS pr_number
376+
FROM audit_events
377+
WHERE event_type IN ('agent.action.close', 'agent.action.merge')
378+
AND outcome = 'completed' AND instr(target_key, '#') > 0
379+
AND length(target_key) - length(replace(target_key, '#', '')) = 1
380+
AND COALESCE(json_extract(metadata_json, '$.mode'), 'live') <> 'dry_run'
381+
AND created_at >= ?
382+
) act
383+
LEFT JOIN pull_requests pr ON pr.repo_full_name = act.project AND pr.number = act.pr_number
384+
WHERE LOWER(act.project) IN (${inList})
385+
GROUP BY act.project`,
397386
retentionCutoffIsoForTable("audit_events"),
398387
...projects,
399388
),
@@ -484,9 +473,7 @@ export async function getPublicStats(
484473
};
485474
// Resolved before byProject so every published accuracy figure -- per repo and global -- answers the same
486475
// question about the same deployment, rather than one of them silently disagreeing with the others.
487-
const totalReversals = [...reversedByProject.values()].reduce((sum, n) => sum + n, 0);
488-
const reversalObservable = await loadReversalObservability(env, totalReversals);
489-
// project -> the retention-windowed merged/closed pairing for `reversed`. See the query's own comment.
476+
// project -> the auto-actioned merged/closed pairing for `reversed`. See the query's own comment.
490477
const windowedByProject = new Map(windowedDispositions.map((row) => [String(row.project).toLowerCase(), row]));
491478
let windowedMerged = 0;
492479
let windowedClosed = 0;
@@ -514,7 +501,7 @@ export async function getPublicStats(
514501
reviewed,
515502
merged,
516503
closed,
517-
accuracyPct: accuracyPct(windowedRepoMerged, windowedRepoClosed, reversed, reversalObservable),
504+
accuracyPct: accuracyPct(windowedRepoMerged, windowedRepoClosed, reversed),
518505
};
519506
})
520507
.filter((r) => r.reviewed > 0)
@@ -643,7 +630,7 @@ export async function getPublicStats(
643630
// byProject fold above), never the fleet-folded lifetime totals.merged/closed, so the numerator
644631
// (own-ledger `reversed`) and the denominator are drawn from the same population AND the same
645632
// retention window. See the windowed disposition query's own comment for both halves of that pairing.
646-
accuracyPct: accuracyPct(windowedMerged, windowedClosed, totals.reversed, reversalObservable),
633+
accuracyPct: accuracyPct(windowedMerged, windowedClosed, totals.reversed),
647634
minutesSaved,
648635
},
649636
weekly: { reviewed: w.reviewed ?? 0, merged: w.merged ?? 0 },

src/services/public-accuracy-trend.ts

Lines changed: 55 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// #2568 pattern for the sibling per-repo quality trend) can recompute any historical week correctly on every
99
// request -- no cron-miss gap risk, no second copy of the number to keep in sync, and the SAME formula as the
1010
// live figure by construction, so the two can never silently diverge or read as inconsistent to a public viewer.
11-
import { loadReversalObservability, PUBLISHED_PR_KEYS, publicStatsProjects, safeAll } from "../review/public-stats";
11+
import { PUBLISHED_PR_KEYS, publicStatsProjects, safeAll } from "../review/public-stats";
1212
import { isoWeekStart } from "./public-quality-metrics";
1313

1414
export const PUBLIC_ACCURACY_TREND_WEEKS = 8;
@@ -24,13 +24,16 @@ export type PublicAccuracyTrendWeek = {
2424
accuracyPct: number | null;
2525
};
2626

27-
/** `merged`/`closed` are the DISPLAYED volume (own ledger + registered Orb fleet). `ownMerged`/`ownClosed` are
28-
* the own-ledger-only pairing for `reversed`, which is own-ledger-only by construction (the Orb aggregate has
29-
* no reversal concept). Accuracy divides the own-ledger numbers ONLY -- #7449 fixed exactly this asymmetry for
30-
* the lifetime figure in public-stats.ts and it was never carried across to this trend, so the denominator grew
31-
* with every newly registered install while the numerator stayed own-ledger-scoped, trending every week toward
32-
* 100% independent of real reversal behavior. */
33-
type DayRow = { day: string; merged: number; closed: number; ownMerged: number; ownClosed: number; reversed: number };
27+
/** `merged`/`closed` are the DISPLAYED volume (own ledger + registered Orb fleet). `autoActioned` is the
28+
* ACCURACY denominator: the PRs this deployment actually merged or closed itself that day.
29+
*
30+
* Two separate reasons it cannot be the displayed volume. #7449: the Orb aggregate has no reversal concept, so
31+
* folding it in grows the denominator with every newly registered install while `reversed` stays own-ledger
32+
* scoped. And #9792: `reversed` is only ever recorded against an engine AUTO-ACTION (loadReversalDayRows
33+
* anchors on `agent.action.*`), so even the own-ledger published-PR count is wider than the population the
34+
* numerator can draw from -- it includes every PR the engine merely commented on. Both make the ratio drift
35+
* toward 100% for reasons that have nothing to do with reversal behavior. */
36+
type DayRow = { day: string; merged: number; closed: number; autoActioned: number; reversed: number };
3437

3538
const MS_PER_WEEK = 7 * 86_400_000;
3639

@@ -39,21 +42,17 @@ function roundPct(value: number): number {
3942
}
4043

4144
/** Same formula as public-stats.ts's accuracyPct, reused so the trend and the live number can never drift
42-
* apart into two competing definitions of "accuracy". `reversalObservable` false means this deployment records
43-
* no terminal auto-actions for a reversal to be attributed to, so the week's accuracy is unknown, not perfect --
44-
* the volume columns still publish, since those ARE measured. */
45-
function publicBucketOf(
46-
bucket: { merged: number; closed: number; ownMerged: number; ownClosed: number; reversed: number },
47-
reversalObservable: boolean,
48-
): Omit<PublicAccuracyTrendWeek, "weekStart"> {
45+
* apart into two competing definitions of "accuracy". The volume columns still publish when accuracy cannot:
46+
* those ARE measured. */
47+
function publicBucketOf(bucket: { merged: number; closed: number; autoActioned: number; reversed: number }): Omit<PublicAccuracyTrendWeek, "weekStart"> {
4948
const decided = bucket.merged + bucket.closed;
5049
if (decided < MIN_ACCURACY_TREND_SAMPLE) return { merged: null, closed: null, reversed: null, accuracyPct: null };
5150
const observed = { merged: bucket.merged, closed: bucket.closed, reversed: bucket.reversed };
52-
if (!reversalObservable) return { ...observed, accuracyPct: null };
53-
// Own-ledger-only denominator: `reversed` can only ever be attributed to own-ledger PRs (see DayRow).
54-
const ownDecided = bucket.ownMerged + bucket.ownClosed;
55-
if (ownDecided <= 0) return { ...observed, accuracyPct: null };
56-
const reversalRate = Math.min(1, bucket.reversed / ownDecided);
51+
// No auto-actions that week means no PR a reversal could have been recorded against, so accuracy is
52+
// unknown rather than perfect. This replaces the separate observability probe (#9718): the denominator IS
53+
// the signal now, and one structural guarantee beats a heuristic that could disagree with it.
54+
if (bucket.autoActioned <= 0) return { ...observed, accuracyPct: null };
55+
const reversalRate = Math.min(1, bucket.reversed / bucket.autoActioned);
5756
return { ...observed, accuracyPct: roundPct(1 - reversalRate) };
5857
}
5958

@@ -63,11 +62,10 @@ export function buildPublicAccuracyTrend(
6362
dayRows: DayRow[],
6463
nowMs: number,
6564
weeks: number = PUBLIC_ACCURACY_TREND_WEEKS,
66-
reversalObservable = true,
6765
): PublicAccuracyTrendWeek[] {
6866
const currentStartMs = Date.parse(isoWeekStart(nowMs));
6967
const oldestStartMs = currentStartMs - (weeks - 1) * MS_PER_WEEK;
70-
const buckets = Array.from({ length: weeks }, () => ({ merged: 0, closed: 0, ownMerged: 0, ownClosed: 0, reversed: 0 }));
68+
const buckets = Array.from({ length: weeks }, () => ({ merged: 0, closed: 0, autoActioned: 0, reversed: 0 }));
7169

7270
for (const row of dayRows) {
7371
const dayMs = Date.parse(`${row.day}T00:00:00.000Z`);
@@ -77,14 +75,13 @@ export function buildPublicAccuracyTrend(
7775
const bucket = buckets[weekOffset]!;
7876
bucket.merged += row.merged;
7977
bucket.closed += row.closed;
80-
bucket.ownMerged += row.ownMerged;
81-
bucket.ownClosed += row.ownClosed;
78+
bucket.autoActioned += row.autoActioned;
8279
bucket.reversed += row.reversed;
8380
}
8481

8582
return buckets.map((bucket, offset) => ({
8683
weekStart: isoWeekStart(oldestStartMs + offset * MS_PER_WEEK),
87-
...publicBucketOf(bucket, reversalObservable),
84+
...publicBucketOf(bucket),
8885
}));
8986
}
9087

@@ -122,6 +119,34 @@ async function loadOwnLedgerDayRows(env: Env, projects: string[], sinceIso: stri
122119
return map;
123120
}
124121

122+
/** Distinct PRs this deployment AUTO-ACTIONED per day -- the accuracy denominator. Deliberately the same
123+
* population, filters and dry-run exclusion as loadReversalDayRows' inner `orig` query below, because the
124+
* numerator is drawn from exactly these rows; any divergence between the two would silently reintroduce a
125+
* ratio over mismatched populations. */
126+
async function loadAutoActionDayRows(env: Env, projects: string[], sinceIso: string): Promise<Map<string, number>> {
127+
const map = new Map<string, number>();
128+
if (projects.length === 0) return map;
129+
const inList = projects.map(() => "?").join(", ");
130+
const rows = await safeAll<{ day: string; n: number }>(
131+
env,
132+
`SELECT date(act.created_at) AS day, COUNT(DISTINCT act.target_key) AS n FROM (
133+
SELECT substr(target_key, 1, instr(target_key, '#') - 1) AS project, target_key, created_at
134+
FROM audit_events
135+
WHERE event_type IN ('agent.action.close', 'agent.action.merge')
136+
AND outcome = 'completed' AND instr(target_key, '#') > 0
137+
AND length(target_key) - length(replace(target_key, '#', '')) = 1
138+
AND COALESCE(json_extract(metadata_json, '$.mode'), 'live') <> 'dry_run'
139+
AND created_at >= ?
140+
) act
141+
WHERE LOWER(act.project) IN (${inList})
142+
GROUP BY day`,
143+
sinceIso,
144+
...projects,
145+
);
146+
for (const row of rows) map.set(row.day, row.n);
147+
return map;
148+
}
149+
125150
/** Day-bucketed reversal count, bucketed by the ORIGINAL auto-action's own created_at (not the later reversal's
126151
* timestamp) so a reversal always credits the week the decision was actually made, and never retroactively
127152
* shifts a past week's published trend. Detection matches public-stats.ts's `reversalRows` fix (#fairness-
@@ -192,22 +217,21 @@ export async function loadPublicAccuracyTrend(env: Env, nowMs: number = Date.now
192217
const projects = publicStatsProjects(env);
193218
const sinceIso = new Date(Date.parse(isoWeekStart(nowMs)) - (PUBLIC_ACCURACY_TREND_WEEKS - 1) * MS_PER_WEEK).toISOString();
194219

195-
const [ownLedger, reversals, orb, reversalObservable] = await Promise.all([
220+
const [ownLedger, reversals, orb, autoActions] = await Promise.all([
196221
loadOwnLedgerDayRows(env, projects, sinceIso),
197222
loadReversalDayRows(env, projects, sinceIso),
198223
loadOrbDayRows(env, sinceIso),
199-
loadReversalObservability(env),
224+
loadAutoActionDayRows(env, projects, sinceIso),
200225
]);
201226

202-
const days = new Set([...ownLedger.keys(), ...reversals.keys(), ...orb.keys()]);
227+
const days = new Set([...ownLedger.keys(), ...reversals.keys(), ...orb.keys(), ...autoActions.keys()]);
203228
const dayRows: DayRow[] = [...days].map((day) => ({
204229
day,
205230
merged: (ownLedger.get(day)?.merged ?? 0) + (orb.get(day)?.merged ?? 0),
206231
closed: (ownLedger.get(day)?.closed ?? 0) + (orb.get(day)?.closed ?? 0),
207-
ownMerged: ownLedger.get(day)?.merged ?? 0,
208-
ownClosed: ownLedger.get(day)?.closed ?? 0,
232+
autoActioned: autoActions.get(day) ?? 0,
209233
reversed: reversals.get(day) ?? 0,
210234
}));
211235

212-
return buildPublicAccuracyTrend(dayRows, nowMs, PUBLIC_ACCURACY_TREND_WEEKS, reversalObservable);
236+
return buildPublicAccuracyTrend(dayRows, nowMs);
213237
}

test/integration/public-stats-route.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ async function seed(env: Env) {
2424
)
2525
.bind(`ae-${repo}-${number}`, `${repo}#${number}`)
2626
.run();
27+
// #9792: the accuracy denominator reads engine AUTO-ACTIONS, not published surfaces, so a decided PR
28+
// needs the action that decided it. The still-open one (loopover#3) gets its own agent.action.close
29+
// below, since it was auto-closed and then reopened -- that is what makes it a reversal.
30+
if (state === "closed") {
31+
await env.DB.prepare(
32+
`INSERT INTO audit_events (id, event_type, target_key, outcome) VALUES (?, ?, ?, 'completed')`,
33+
)
34+
.bind(`act-${repo}-${number}`, mergedAt ? "agent.action.merge" : "agent.action.close", `${repo}#${number}`)
35+
.run();
36+
}
2737
await env.DB.prepare(
2838
`INSERT INTO pull_requests (id, repo_full_name, number, title, state, merged_at) VALUES (?, ?, ?, ?, ?, ?)`,
2939
)

0 commit comments

Comments
 (0)