Skip to content

Commit d8bcb56

Browse files
authored
fix(stats): bound the accuracy denominator to the window its numerator survives in (#9768)
* fix(stats): bound the accuracy denominator to the window its numerator survives in #9718 gated accuracy on whether a reversal is observable at all, and fixed the weekly trend's Orb-folded denominator. It did not fix the third asymmetry in #9676: `merged`/`closed` come from `github_app.pr_public_surface_published`, the single retention-EXEMPT audit event type, so they are lifetime and never shrink -- while `reversal_*` rows prune with the rest of audit_events at 90 days. Pairing an immortal denominator with a 90-day numerator makes `1 - reversed/decided` drift toward 100% as the ledger ages, independent of real reversal behavior. Confirmed live after #9718 deployed: byProject still published 100% for all three repos on 2377/602/508 reviewed with 0 reversals, because the reversal signal IS observable on that deployment -- the gate passed and the ratio was still meaningless. The accuracy denominator is now a second, retention-windowed disposition query that shares BOTH of the numerator's bounds: own-ledger only, and inside audit_events' retention window. `reviewed`/`merged`/`closed` keep publishing lifetime volume, which is measured and correct. That display-vs-denominator split is the same shape #7449 established for the Orb fold and #9718 carried into the weekly trend; the #7449 snapshot it supersedes is removed rather than left dead, with its reasoning folded into the new comment. Boundary case, stated rather than hidden: a PR published before the window but reversed inside it contributes to the numerator and not the denominator. accuracyPct's existing clamp already handles that (it is the same shape as the reopened-auto-close case its comment describes), and the alternative -- an unbounded denominator -- is the bug being fixed. Refs #9676 * docs(stats): retarget the global accuracyPct comment at the variables that still exist Review catch on #9768. The comment above the global accuracyPct call site still pointed at "the ownLedgerMerged/ownLedgerClosed snapshot above the Orb fold" -- variables this PR removed and replaced with windowedMerged/windowedClosed. A comment referring a reader to a symbol that no longer exists is worse than none: it sends them looking for context that was deliberately relocated. Retargeted at the windowed disposition query, and widened while there. The old text described ONE of the two bounds the pairing needs (same population, so the fleet fold cannot inflate it); it now names both, since this PR added the second (same retention window, so an immortal denominator cannot outlive its numerator). `ownLedgerReviewed`/`ownLedgerMinutes` are untouched -- different variables, both still live, feeding the minutes-saved calculation.
1 parent 8618983 commit d8bcb56

2 files changed

Lines changed: 79 additions & 13 deletions

File tree

src/review/public-stats.ts

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -356,9 +356,10 @@ export async function getPublicStats(
356356
// The own-ledger side needs at least one allowlisted project to query; an empty allowlist skips these three
357357
// queries entirely (own-ledger totals stay zero) but still lets the Orb aggregate below run.
358358
const inList = projects.map(() => "?").join(", ");
359-
const [dispositions, reversalRows, weeklyRows, effortRows] = projects.length === 0
359+
const [dispositions, windowedDispositions, reversalRows, weeklyRows, effortRows] = projects.length === 0
360360
? await Promise.all([
361361
Promise.resolve<DispositionRow[]>([]),
362+
Promise.resolve<{ project: string; merged: number; closed: number }[]>([]),
362363
Promise.resolve<{ project: string; reversed: number }[]>([]),
363364
Promise.resolve<{ reviewed: number; merged: number }[]>([]),
364365
Promise.resolve<{ totalMinutes: number | null }[]>([]),
@@ -377,6 +378,25 @@ export async function getPublicStats(
377378
GROUP BY ev.repo`,
378379
...projects,
379380
),
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).
388+
safeAll<{ project: string; merged: number; closed: number }>(
389+
env,
390+
`SELECT ev.repo AS project,
391+
SUM(CASE WHEN pr.merged_at IS NOT NULL THEN 1 ELSE 0 END) AS merged,
392+
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`,
397+
retentionCutoffIsoForTable("audit_events"),
398+
...projects,
399+
),
380400
safeAll<{ project: string; reversed: number }>(
381401
env,
382402
// A "reversal" = a human overturning a terminal engine auto-action, already detected and recorded by
@@ -466,6 +486,10 @@ export async function getPublicStats(
466486
// question about the same deployment, rather than one of them silently disagreeing with the others.
467487
const totalReversals = [...reversedByProject.values()].reduce((sum, n) => sum + n, 0);
468488
const reversalObservable = await loadReversalObservability(env, totalReversals);
489+
// project -> the retention-windowed merged/closed pairing for `reversed`. See the query's own comment.
490+
const windowedByProject = new Map(windowedDispositions.map((row) => [String(row.project).toLowerCase(), row]));
491+
let windowedMerged = 0;
492+
let windowedClosed = 0;
469493
const byProject = dispositions
470494
.map((d) => {
471495
const merged = d.merged ?? 0;
@@ -474,6 +498,11 @@ export async function getPublicStats(
474498
const reversed =
475499
reversedByProject.get(String(d.project).toLowerCase()) ?? 0;
476500
const reviewed = merged + closed + inReview;
501+
const windowed = windowedByProject.get(String(d.project).toLowerCase());
502+
const windowedRepoMerged = windowed?.merged ?? 0;
503+
const windowedRepoClosed = windowed?.closed ?? 0;
504+
windowedMerged += windowedRepoMerged;
505+
windowedClosed += windowedRepoClosed;
477506
totals.handled += reviewed;
478507
totals.merged += merged;
479508
totals.closed += closed;
@@ -485,7 +514,7 @@ export async function getPublicStats(
485514
reviewed,
486515
merged,
487516
closed,
488-
accuracyPct: accuracyPct(merged, closed, reversed, reversalObservable),
517+
accuracyPct: accuracyPct(windowedRepoMerged, windowedRepoClosed, reversed, reversalObservable),
489518
};
490519
})
491520
.filter((r) => r.reviewed > 0)
@@ -501,13 +530,13 @@ export async function getPublicStats(
501530
// Snapshot before Orb merge: effort SQL only covers allowlisted own-ledger publishes, while `reviewed`
502531
// below includes Orb fleet outcomes folded into totals.merged/closed.
503532
const ownLedgerReviewed = reviewedOf(totals);
504-
// #7449: also snapshot the pre-fold own-ledger merged/closed. totals.reversed stays own-ledger-only (the Orb
505-
// aggregate has no reversal concept), so the published global accuracyPct below is computed from THESE, not the
506-
// fleet-folded totals.merged/closed -- otherwise the denominator would grow with every newly registered install
507-
// while the numerator stayed own-ledger-scoped, trending the percentage toward 100 independent of real reversal
508-
// behavior. The fleet fold still (correctly) inflates reviewed/handled/minutesSaved, which have no such pairing.
509-
const ownLedgerMerged = totals.merged;
510-
const ownLedgerClosed = totals.closed;
533+
// #7449 (superseded, same reasoning): the published global accuracyPct is NOT computed from
534+
// totals.merged/closed. Those are lifetime AND fleet-folded, while totals.reversed is own-ledger-only and
535+
// 90-day-pruned, so pairing them lets the denominator grow -- with every newly registered install, and with
536+
// every additional day of immortal publish events -- while the numerator cannot, trending the percentage
537+
// toward 100 independent of real reversal behavior. windowedMerged/windowedClosed (accumulated above) is the
538+
// pairing that shares BOTH of the numerator's bounds: own-ledger only, and inside audit_events' retention
539+
// window. The fleet fold still correctly inflates reviewed/handled/minutesSaved, which have no such pairing.
511540
// Fleet accuracy (bugfix, #fairness-analytics): independent of the own-ledger allowlist above, so it's fetched
512541
// unconditionally alongside the Orb global fold, matching that fold's own unscoped-regardless-of-allowlist
513542
// behavior (see the "skips the own-ledger queries but still queries the Orb aggregate" test).
@@ -610,10 +639,11 @@ export async function getPublicStats(
610639
...totals,
611640
reviewed,
612641
filteredPct: filteredPct(reviewed, totals.merged),
613-
// Option 1 of #7449: compute the global accuracy from the OWN-LEDGER merged/closed snapshot (not the
614-
// fleet-folded totals.merged/closed), so its numerator (own-ledger reversed) and denominator are drawn
615-
// from the same population. See the ownLedgerMerged/ownLedgerClosed snapshot above the Orb fold for why.
616-
accuracyPct: accuracyPct(ownLedgerMerged, ownLedgerClosed, totals.reversed, reversalObservable),
642+
// #7449 extended: compute the global accuracy from windowedMerged/windowedClosed (accumulated in the
643+
// byProject fold above), never the fleet-folded lifetime totals.merged/closed, so the numerator
644+
// (own-ledger `reversed`) and the denominator are drawn from the same population AND the same
645+
// retention window. See the windowed disposition query's own comment for both halves of that pairing.
646+
accuracyPct: accuracyPct(windowedMerged, windowedClosed, totals.reversed, reversalObservable),
617647
minutesSaved,
618648
},
619649
weekly: { reviewed: w.reviewed ?? 0, merged: w.merged ?? 0 },

test/unit/public-stats.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,42 @@ describe("getPublicStats — live aggregate over the review ledger", () => {
737737
expect(out.byProject.map((row) => row.accuracyPct)).toEqual([null]);
738738
});
739739

740+
it("REGRESSION: the accuracy denominator is bounded to audit_events' retention window, not lifetime (real D1)", async () => {
741+
// `github_app.pr_public_surface_published` is the ONE retention-exempt event type, so reviewed/merged/
742+
// closed are immortal, while `reversal_*` rows prune at 90 days. Pairing them made 1 - reversed/decided
743+
// drift toward 100% as the ledger aged -- live: 2377/602/508 reviewed, 0 reversals, 100% each.
744+
const env = createTestEnv({ LOOPOVER_PUBLIC_STATS_REPOS: "JSONbored/loopover" });
745+
const old = new Date(NOW - 200 * 86_400_000).toISOString(); // outside the 90-day window
746+
const recent = new Date(NOW - 5 * 86_400_000).toISOString();
747+
await upsertRepositoryFromGitHub(env, { name: "loopover", full_name: "JSONbored/loopover", private: false, owner: { login: "JSONbored" } }, 1);
748+
749+
// Four ancient merged PRs -- they keep counting toward `reviewed`, but must NOT pad the accuracy
750+
// denominator, because a reversal of any of them would long since have been pruned.
751+
for (const number of [1, 2, 3, 4]) {
752+
await upsertPullRequestFromGitHub(env, "JSONbored/loopover", { number, title: `old ${number}`, state: "closed", merged_at: old, user: { login: "a" }, head: { sha: `s${number}` }, labels: [] });
753+
await recordAuditEvent(env, { eventType: "github_app.pr_public_surface_published", targetKey: `JSONbored/loopover#${number}`, outcome: "completed", createdAt: old });
754+
}
755+
// One recent auto-closed PR, reversed by a human.
756+
await upsertPullRequestFromGitHub(env, "JSONbored/loopover", { number: 5, title: "recent", state: "open", user: { login: "b" }, head: { sha: "s5" }, labels: [] });
757+
await recordAuditEvent(env, { eventType: "github_app.pr_public_surface_published", targetKey: "JSONbored/loopover#5", outcome: "completed", createdAt: recent });
758+
await recordAuditEvent(env, { eventType: "agent.action.close", targetKey: "JSONbored/loopover#5", outcome: "completed", createdAt: recent });
759+
await recordAuditEvent(env, { eventType: "reversal_reopened", targetKey: "JSONbored/loopover#5", outcome: "completed", createdAt: recent });
760+
// ...and one recent merged PR that stands, so the windowed denominator is 1 merged + 0 closed... plus
761+
// PR#5 which is currently `open` and so counts as inReview, not decided.
762+
await upsertPullRequestFromGitHub(env, "JSONbored/loopover", { number: 6, title: "recent ok", state: "closed", merged_at: recent, user: { login: "c" }, head: { sha: "s6" }, labels: [] });
763+
await recordAuditEvent(env, { eventType: "github_app.pr_public_surface_published", targetKey: "JSONbored/loopover#6", outcome: "completed", createdAt: recent });
764+
765+
const out = await getPublicStats(env, NOW);
766+
// Lifetime volume still reports every PR ever published -- that part is measured and unaffected.
767+
expect(out.totals.reviewed).toBe(6);
768+
expect(out.totals.merged).toBe(5);
769+
expect(out.totals.reversed).toBe(1);
770+
// Accuracy divides by the WINDOWED denominator (PR#6 merged inside the window), not the lifetime 5.
771+
// 1 - 1/1 = 0%, not the 1 - 1/5 = 80% the old lifetime pairing would have published.
772+
expect(out.byProject[0]?.accuracyPct).toBe(0);
773+
expect(out.totals.accuracyPct).toBe(0);
774+
});
775+
740776
it("publishes a real accuracy once the deployment records the auto-actions a reversal attaches to (real D1)", async () => {
741777
const env = createTestEnv({ LOOPOVER_PUBLIC_STATS_REPOS: "JSONbored/loopover" });
742778
await upsertRepositoryFromGitHub(env, { name: "loopover", full_name: "JSONbored/loopover", private: false, owner: { login: "JSONbored" } }, 1);

0 commit comments

Comments
 (0)