Skip to content

Commit ee5e724

Browse files
authored
fix(orb): score enforcement closes as their own class, not quality mispredictions (#8827)
A policy close (contributor cap, blacklist, copycat, review-nag, screenshot-table, linked-issue hard rule) carries no gate blocker, so blockerClass was 'none' and the recorded reason fell back to the gate's own conclusion -- writing 'success' on a PR the bot deliberately closed. Calibration then scored every one as a quality misprediction ('said merge, ended closed') when the gate never made a quality claim at all. On the live fleet all 210 rows in that class carried the bare summary 'success', indistinguishable from a real verdict. Closes #8825. An enforcement close is a deliberate decision, not an error, and can be neither confirmed nor disconfirmed as a quality prediction -- so it is excluded from precision/accuracy scoring entirely rather than counted on either side, and reported separately as policyActions so the volume stays visible. - processors: name the closeKind in the recorded reason (policy_close:<kind>) instead of falling back to the conclusion - orb-collector: bucket that prefix as policy_action, checked before the substring rules so linked-issue-hard-rule doesn't flatten into issue_policy - analytics: carry the bucket through the confusion matrix; exclude policy_action cells from decisionAccuracy/precision while still counting them in decided and reversalRate Rows exported before the bucket existed carry null and keep scoring as ordinary quality verdicts, so historical data is unchanged.
1 parent 6c1870c commit ee5e724

5 files changed

Lines changed: 72 additions & 8 deletions

File tree

src/orb/analytics.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export interface Cell {
4141
verdict: string | null;
4242
outcome: string;
4343
reversal_flag: string;
44+
/** #8825: `policy_action` marks a deliberate enforcement close (contributor cap, blacklist, copycat,
45+
* review-nag, screenshot-table, linked-issue hard rule) rather than a claim about code quality. Null on
46+
* rows exported before the bucket existed — treated as a normal quality verdict, matching prior behavior. */
47+
gate_reasoncode_bucket?: string | null;
4448
n: number;
4549
}
4650

@@ -68,6 +72,10 @@ export interface InstanceMetrics {
6872
* Measured on the live fleet the two differ by ~6 points (93.6% vs 99.6%) — the gap is real errors, not
6973
* rounding. null when the instance made no merge/close verdicts at all (holds only). */
7074
decisionAccuracy: number | null;
75+
/** #8825: enforcement closes excluded from the precision/accuracy scoring above (contributor cap, blacklist,
76+
* copycat, review-nag, screenshot-table, linked-issue hard rule). Reported so the volume of policy actions
77+
* stays visible instead of vanishing from every metric. */
78+
policyActions: number;
7179
}
7280

7381
/** #2350: one self-hosted instance whose combined volume/precision/reversal-rate pattern looks like it is
@@ -133,10 +141,18 @@ export function percentile(sorted: number[], p: number): number | null {
133141
export function foldInstance(instanceId: string, cells: Cell[]): InstanceMetrics {
134142
let wouldMerge = 0, mergeConfirmed = 0, mergeFalse = 0;
135143
let wouldClose = 0, closeConfirmed = 0, closeFalse = 0;
136-
let reversals = 0, decided = 0;
144+
let reversals = 0, decided = 0, policyActions = 0;
137145
for (const c of cells) {
138146
decided += c.n;
139147
if (c.reversal_flag !== "none") reversals += c.n;
148+
// #8825: a deliberate enforcement close is not a quality prediction and can be neither confirmed nor
149+
// disconfirmed as one, so it is excluded from the precision/accuracy scoring entirely — counted on its own
150+
// (policyActions) rather than silently inflating either side. It still contributes to `decided` and
151+
// reversalRate, which measure activity and human overrides rather than gate correctness.
152+
if (c.gate_reasoncode_bucket === "policy_action") {
153+
policyActions += c.n;
154+
continue;
155+
}
140156
if (c.verdict === "merge") {
141157
wouldMerge += c.n;
142158
if (c.outcome === "merged" && c.reversal_flag !== "reverted") mergeConfirmed += c.n;
@@ -157,6 +173,7 @@ export function foldInstance(instanceId: string, cells: Cell[]): InstanceMetrics
157173
fnRate: wouldClose > 0 ? closeFalse / wouldClose : null,
158174
reversalRate: reversals / decided, // decided ≥ 1 (the instance has at least one cell)
159175
decisionAccuracy: verdicts > 0 ? (mergeConfirmed + closeConfirmed) / verdicts : null,
176+
policyActions,
160177
};
161178
}
162179

@@ -173,9 +190,9 @@ export async function computeFleetAnalytics(env: Env, opts: { windowDays?: numbe
173190
try {
174191
const matrix = await env.DB
175192
.prepare(
176-
`SELECT instance_id, gate_verdict AS verdict, outcome, reversal_flag, COUNT(*) AS n
193+
`SELECT instance_id, gate_verdict AS verdict, outcome, reversal_flag, gate_reasoncode_bucket, COUNT(*) AS n
177194
FROM orb_signals WHERE received_at >= ?
178-
GROUP BY instance_id, gate_verdict, outcome, reversal_flag`,
195+
GROUP BY instance_id, gate_verdict, outcome, reversal_flag, gate_reasoncode_bucket`,
179196
)
180197
.bind(cutoff)
181198
.all<Cell>();

src/queue/processors.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3275,13 +3275,28 @@ async function runAgentMaintenancePlanAndExecute(
32753275
// downstream autonomous disposition (merge/close/hold), not only the gate check conclusion. In particular, a
32763276
// failing gate can still become a concrete auto-close after CI/conflict/duplicate/linked-issue planning; recording
32773277
// only the gate's hold-shaped conclusion would blind the close-precision breaker to those live closes.
3278+
// #8825: a POLICY close (contributor cap, blacklist, copycat, review-nag, screenshot-table, linked-issue
3279+
// hard rule) carries no gate blocker, so blockerClass is "none" and the reason used to fall back to the
3280+
// gate's own conclusion -- recording "success" on a PR the bot deliberately closed. Every one of those rows
3281+
// then scored as a quality misprediction ("said merge, ended closed") when the gate never made a quality
3282+
// claim at all; measured on the live fleet, all 210 rows in that class carried the bare summary "success".
3283+
// Naming the closeKind makes the policy action distinguishable from a quality verdict at scoring time.
3284+
const policyCloseKind =
3285+
disposition.actionClass === "close"
3286+
? breakerOnPlan.find((planned) => planned.actionClass === "close" && planned.closeKind !== undefined)?.closeKind
3287+
: undefined;
32783288
await recordNativeGateDecision(env, {
32793289
project: repoFullName,
32803290
pullNumber: pr.number,
32813291
headSha: pr.headSha,
32823292
conclusion: gate.conclusion,
32833293
action: disposition.actionClass,
3284-
reasonCode: disposition.blockerClass === "none" ? gate.conclusion : disposition.blockerClass,
3294+
reasonCode:
3295+
disposition.blockerClass !== "none"
3296+
? disposition.blockerClass
3297+
: policyCloseKind !== undefined
3298+
? `policy_close:${policyCloseKind}`
3299+
: gate.conclusion,
32853300
// #2352: this row is the ACTUAL autonomous disposition that the precision breaker evaluates, so preserve
32863301
// the same miner-authored scope as the gate-check audit row below. Omitting it defaults to non-miner and can
32873302
// erase a prior miner-authored prediction for the same head.

src/selfhost/orb-collector.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@ export async function getOrCreateAnonSecret(db: D1Database): Promise<string> {
138138
export function bucketReasonCode(summary: string | null | undefined): string {
139139
if (!summary) return "none";
140140
const s = summary.toLowerCase();
141+
// #8825: a POLICY close (contributor cap, blacklist, copycat, review-nag, screenshot-table, linked-issue
142+
// hard rule) is a deliberate enforcement action, NOT a claim about code quality — scoring it as a quality
143+
// prediction distorts precision in both directions. Checked FIRST so the prefix wins over the substring
144+
// rules below (e.g. `policy_close:linked-issue-hard-rule` must not bucket as plain issue_policy).
145+
if (s.startsWith("policy_close:")) return "policy_action";
141146
if (s.includes("linked_issue") || s.includes("linked issue")) return "issue_policy";
142147
if (s.includes("duplicate")) return "duplicate_risk";
143148
if (s.includes("slop")) return "slop_advisory";

test/unit/orb-analytics.test.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ async function signals(
88
env: Env,
99
instance: string,
1010
n: number,
11-
o: { verdict?: string | null; outcome?: string; reversal?: string; ms?: number | null } = {},
11+
o: { verdict?: string | null; outcome?: string; reversal?: string; ms?: number | null; bucket?: string | null } = {},
1212
): Promise<void> {
1313
for (let i = 0; i < n; i++) {
1414
await env.DB
1515
.prepare(
16-
`INSERT INTO orb_signals (instance_id, repo_hash, pr_hash, gate_verdict, outcome, reversal_flag, time_to_close_ms)
17-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
16+
`INSERT INTO orb_signals (instance_id, repo_hash, pr_hash, gate_verdict, outcome, reversal_flag, time_to_close_ms, gate_reasoncode_bucket)
17+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1818
)
19-
.bind(instance, `repo${seq}`, `pr${seq++}`, o.verdict ?? "merge", o.outcome ?? "merged", o.reversal ?? "none", o.ms ?? null)
19+
.bind(instance, `repo${seq}`, `pr${seq++}`, o.verdict ?? "merge", o.outcome ?? "merged", o.reversal ?? "none", o.ms ?? null, o.bucket ?? null)
2020
.run();
2121
}
2222
}
@@ -103,6 +103,28 @@ describe("computeFleetAnalytics()", () => {
103103
expect(1 - inst.reversalRate).toBe(1);
104104
});
105105

106+
it("#8825: a POLICY close is excluded from accuracy scoring — it is enforcement, not a quality prediction", async () => {
107+
const env = createTestEnv();
108+
await signals(env, "i", 8, { verdict: "close", outcome: "closed" }); // real quality closes, all confirmed
109+
await signals(env, "i", 2, { verdict: "close", outcome: "merged" }); // real quality misses
110+
// Enforcement closes: the gate made no quality claim, so they must not count either way.
111+
await signals(env, "i", 40, { verdict: "close", outcome: "closed", bucket: "policy_action" });
112+
const inst = (await computeFleetAnalytics(env)).instances[0]!;
113+
expect(inst.decisionAccuracy).toBeCloseTo(8 / 10); // NOT 48/50 — the 40 enforcement closes are excluded
114+
expect(inst.closePrecision).toBeCloseTo(8 / 10);
115+
expect(inst.policyActions).toBe(40);
116+
expect(inst.decided).toBe(50); // still counted as activity
117+
});
118+
119+
it("#8825: rows exported BEFORE the bucket existed (null) still score as ordinary quality verdicts", async () => {
120+
const env = createTestEnv();
121+
await signals(env, "i", 4, { verdict: "close", outcome: "closed", bucket: null });
122+
await signals(env, "i", 1, { verdict: "close", outcome: "merged", bucket: "other" });
123+
const inst = (await computeFleetAnalytics(env)).instances[0]!;
124+
expect(inst.decisionAccuracy).toBeCloseTo(4 / 5);
125+
expect(inst.policyActions).toBe(0);
126+
});
127+
106128
it("decisionAccuracy is null for a holds-only instance (no decision to score) and drives the fleet median", async () => {
107129
const env = createTestEnv();
108130
await signals(env, "holds-only", 6, { verdict: "hold", outcome: "merged" });

test/unit/selfhost-orb-collector.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ describe("bucketReasonCode()", () => {
5959
expect(bucketReasonCode("self_authored_with_maintainer_cut")).toBe("author_policy");
6060
expect(bucketReasonCode("ci_state failing")).toBe("ci_readiness");
6161
expect(bucketReasonCode("something_unmapped")).toBe("other");
62+
// #8825: an enforcement close buckets as policy_action, and the prefix wins over the substring rules
63+
// below it -- `policy_close:linked-issue-hard-rule` must NOT flatten into plain issue_policy.
64+
expect(bucketReasonCode("policy_close:contributor_cap")).toBe("policy_action");
65+
expect(bucketReasonCode("policy_close:linked-issue-hard-rule")).toBe("policy_action");
66+
expect(bucketReasonCode("policy_close:blacklist")).toBe("policy_action");
6267
});
6368
});
6469

0 commit comments

Comments
 (0)