Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/loopover-ui/content/docs/verify-this-review.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,30 @@ A rate over an empty window is `null`, never `0` — "nothing was held" and "not
different claims, and the fairness report renders the second as *measured zero* with its window rather
than as a reassuring number.

### Automation rate

`reviewParity`'s sibling, from the same ledger (#9727):

```bash
curl -s "https://api.loopover.ai/v1/public/stats" | jq '.automationRate'
```

A pull request counts as **automated** only if every verdict for it was a merge or close with no human in
the path. Three things mark a human:

- `action = 'hold'` — the gate declined to decide and handed it to a person,
- a `reevaluation_actor` — a named person caused a re-evaluation,
- `reevaluation_reason = 'maintainer_request'` — a human asked for the re-run.

So a PR that was **held and later merged is manual**, not automated. Counting the final disposition instead
would let the rate be inflated by holding everything and then merging it by hand — which is exactly the
failure mode this number exists to make visible.

Each week carries a `basis`. `holds_only` weeks start before `provenanceHorizon`, the date the bot began
recording who caused a re-evaluation; those weeks can only detect manual work that took the form of a hold,
so they **under-count** it. A week straddling that date is labelled `holds_only` too — understating
confidence rather than overstating it.

The same per-rule numbers are also published as digest-committed
[EvalScoreRecords](/docs/what-you-can-verify), each independently re-derivable without trusting the
transport — recompute `recordDigest` over the record's own remaining fields and compare:
Expand Down
65 changes: 65 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,70 @@
"byAuthorClass",
"byProject"
]
},
"automationRate": {
"type": "object",
"properties": {
"weeks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"weekStart": {
"type": "string"
},
"decided": {
"type": "number"
},
"automated": {
"type": "number"
},
"manual": {
"type": "number"
},
"automationRatePct": {
"type": "number",
"nullable": true
},
"basis": {
"type": "string",
"enum": [
"full",
"holds_only"
]
}
},
"required": [
"weekStart",
"decided",
"automated",
"manual",
"automationRatePct",
"basis"
]
}
},
"decided": {
"type": "number"
},
"automated": {
"type": "number"
},
"automationRatePct": {
"type": "number",
"nullable": true
},
"provenanceHorizon": {
"type": "string"
}
},
"required": [
"weeks",
"decided",
"automated",
"automationRatePct",
"provenanceHorizon"
]
}
},
"required": [
Expand All @@ -1008,6 +1072,7 @@
"weekly",
"rulePrecision",
"reviewParity",
"automationRate",
"byProject",
"fleetAccuracy",
"accuracyTrend",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ function renderWithClient(ui: ReactNode) {
const FIXTURE: PublicStats = {
// The wire always carries rulePrecision (#8230/#8231). A fixture without it is not a payload the
// current backend can produce -- the one test that needs that shape strips it explicitly.
automationRate: {
weeks: [],
decided: 0,
automated: 0,
automationRatePct: null,
provenanceHorizon: "2026-07-29T00:00:00.000Z",
},
reviewParity: {
windowStart: "2026-07-22T00:00:00.000Z",
windowEnd: "2026-07-29T00:00:00.000Z",
Expand Down Expand Up @@ -509,4 +516,86 @@ describe("FairnessReportPage (#fairness-analytics)", () => {
expect(await screen.findByText(/No re-evaluations recorded/i)).toBeTruthy();
expect(screen.queryByText(/No verdicts recorded/i)).toBeNull();
});

// #9728: the automation-rate surface and its zero/reduced-basis states.
function automationFixture(over: Record<string, unknown>) {
return {
ok: true,
durationMs: 10,
data: {
...FIXTURE,
automationRate: {
weeks: [],
decided: 0,
automated: 0,
automationRatePct: null,
provenanceHorizon: "2026-07-29T00:00:00.000Z",
...over,
},
},
};
}

it("renders the headline rate, the weekly table, and the definition", async () => {
apiFetch.mockResolvedValue(
automationFixture({
decided: 10,
automated: 7,
automationRatePct: 70,
weeks: [
{
weekStart: "2026-07-27T00:00:00.000Z",
decided: 10,
automated: 7,
manual: 3,
automationRatePct: 70,
basis: "full",
},
],
}),
);
renderWithClient(<FairnessReportPage />);

expect(await screen.findByText(/Automation rate/i)).toBeTruthy();
expect(screen.getByText(/70% automated/)).toBeTruthy();
expect(screen.getByText(/7 of 10 pull requests/)).toBeTruthy();
// The definition must be readable without opening source -- that is #9728's acceptance.
expect(screen.getByText(/no human action/i)).toBeTruthy();
expect(screen.getByText(/counts as manual even if it later merged/i)).toBeTruthy();
});

it("labels reduced-basis weeks and explains that they UNDER-count manual work", async () => {
apiFetch.mockResolvedValue(
automationFixture({
decided: 4,
automated: 4,
automationRatePct: 100,
weeks: [
{
weekStart: "2026-07-06T00:00:00.000Z",
decided: 4,
automated: 4,
manual: 0,
automationRatePct: 100,
basis: "holds_only",
},
],
}),
);
renderWithClient(<FairnessReportPage />);

expect(
(await screen.findAllByText(/reduced basis/i)).length,
"the row badge and the footnote both say it",
).toBeGreaterThanOrEqual(2);
expect(screen.getByText(/under-count it rather than over-count it/i)).toBeTruthy();
});

it("renders an empty window as a measured zero, not as missing data", async () => {
apiFetch.mockResolvedValue(automationFixture({}));
renderWithClient(<FairnessReportPage />);

expect(await screen.findByText(/No pull requests decided/i)).toBeTruthy();
expect(screen.getByText(/measured zero, not missing data/i)).toBeTruthy();
});
});
97 changes: 97 additions & 0 deletions apps/loopover-ui/src/components/site/fairness-report-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,103 @@ export function FairnessReportPage() {
</div>
) : null}

{/* #9728: the automation rate — the share of PRs decided with no human in the path. Rendered
unconditionally when present: a window that decided nothing is a MEASURED zero and says so,
which is a different claim from "we did not compute this". */}
{data.automationRate ? (
<div className="mt-10">
<h2 className="text-token-lg font-medium">Automation rate</h2>
<p className="mt-2 text-token-sm text-muted-foreground">
The share of pull requests decided and enacted with{" "}
<span className="font-medium text-foreground">no human action</span> between open
and disposition. A PR that was held for a person — or that a maintainer asked to
be re-run — counts as manual even if it later merged: the question is whether
someone had to act, not how it ended. Computed from the ledger alone, so you can
recompute it:{" "}
<Link
to="/docs/$slug"
params={{ slug: "verify-this-review" }}
className="underline underline-offset-2"
>
verify this review
</Link>
.
</p>

{data.automationRate.decided === 0 ? (
<p className="mt-4 text-token-sm text-muted-foreground">
<span className="font-medium text-foreground">No pull requests decided</span> in
this window — a measured zero, not missing data.
</p>
) : (
<>
<p className="mt-4 text-token-sm">
{data.automationRate.automationRatePct != null
? `${pctFmt.format(data.automationRate.automationRatePct)}% automated`
: "Rate unavailable"}{" "}
<span className="text-muted-foreground">
({intFmt.format(data.automationRate.automated)} of{" "}
{intFmt.format(data.automationRate.decided)} pull requests)
</span>
</p>
<TableScroll className="mt-4" label="Weekly automation rate">
<table className="w-full min-w-[30rem] text-left text-token-sm">
<caption className="sr-only">
Automated and manual pull requests per week, with each week&apos;s
measurement basis.
</caption>
<thead className="text-token-xs text-muted-foreground">
<tr>
<th scope="col" className="pb-2 pr-4 font-medium">
Week
</th>
<th scope="col" className="pb-2 pr-4 font-medium">
Decided
</th>
<th scope="col" className="pb-2 pr-4 font-medium">
Automated
</th>
<th scope="col" className="pb-2 font-medium">
Rate
</th>
</tr>
</thead>
<tbody>
{data.automationRate.weeks.map((week) => (
<tr key={week.weekStart} className="border-t border-hairline">
<td className="py-2 pr-4">
{new Date(week.weekStart).toLocaleDateString()}
{week.basis === "holds_only" ? (
<span className="ml-2 text-token-xs text-muted-foreground">
reduced basis
</span>
) : null}
</td>
<td className="py-2 pr-4">{intFmt.format(week.decided)}</td>
<td className="py-2 pr-4">{intFmt.format(week.automated)}</td>
<td className="py-2">
{week.automationRatePct != null
? `${pctFmt.format(week.automationRatePct)}%`
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</TableScroll>
<p className="mt-3 text-token-xs text-muted-foreground">
Weeks marked{" "}
<span className="font-medium text-foreground">reduced basis</span> start
before {new Date(data.automationRate.provenanceHorizon).toLocaleDateString()},
when the bot began recording who caused a re-evaluation. Those weeks can only
detect manual work that took the form of a hold, so they under-count it rather
than over-count it.
</p>
</>
)}
</div>
) : null}

{/* #9744: re-evaluation rate + author-class parity. Rendered UNCONDITIONALLY when the block is
present -- a window with no verdicts is a MEASURED zero and says so with its own bounds,
which is a different claim from "we did not compute this" and must not look identical. */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ import {
const PAYLOAD: PublicStats = {
// The wire always carries rulePrecision (#8230/#8231). A fixture without it is not a payload the
// current backend can produce -- the one test that needs that shape strips it explicitly.
automationRate: {
weeks: [],
decided: 0,
automated: 0,
automationRatePct: null,
provenanceHorizon: "2026-07-29T00:00:00.000Z",
},
reviewParity: {
windowStart: "2026-07-22T00:00:00.000Z",
windowEnd: "2026-07-29T00:00:00.000Z",
Expand Down
30 changes: 30 additions & 0 deletions packages/loopover-contract/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,34 @@ export const ReviewParitySchema = z.object({
byProject: z.array(z.object({ project: z.string(), byAuthorClass: z.array(ParityRollupSchema) })),
});

/**
* Weekly automation-rate series (#9727): the share of pull requests decided with no human in the path.
* Computed from `decision_records` alone; definitions live beside the implementation in
* src/review/automation-rate.ts and are restated in the verifier walkthrough.
*/
export const AutomationRateWeekSchema = z.object({
/** ISO date-time of the week's UTC Monday. */
weekStart: z.string(),
/** Distinct pull requests with at least one verdict that week, bucketed by their FIRST verdict. */
decided: z.number(),
automated: z.number(),
manual: z.number(),
/** Null when the week decided nothing -- an undefined ratio, never a reassuring 100%. */
automationRatePct: z.number().nullable(),
/** `holds_only` weeks predate the re-evaluation provenance fields and can only UNDER-count manual work.
* A week straddling the horizon is labelled `holds_only` too: understating confidence, not overstating. */
basis: z.enum(["full", "holds_only"]),
});

export const AutomationRateSchema = z.object({
weeks: z.array(AutomationRateWeekSchema),
decided: z.number(),
automated: z.number(),
automationRatePct: z.number().nullable(),
/** The date the provenance fields began being written, so a reader can see which weeks are reduced-basis. */
provenanceHorizon: z.string(),
});

export const PublicStatsSchema = z.object({
generatedAt: z.string(),
updatedAt: z.string(),
Expand All @@ -94,6 +122,7 @@ export const PublicStatsSchema = z.object({
weekly: z.object({ reviewed: z.number(), merged: z.number() }),
rulePrecision: PublicRulePrecisionSchema,
reviewParity: ReviewParitySchema,
automationRate: AutomationRateSchema,
byProject: z.array(
z.object({
project: z.string(),
Expand Down Expand Up @@ -186,4 +215,5 @@ export const PublicStatsSchema = z.object({

export type PublicStats = z.infer<typeof PublicStatsSchema>;
export type ReviewParity = z.infer<typeof ReviewParitySchema>;
export type AutomationRate = z.infer<typeof AutomationRateSchema>;
export type ParityRollup = z.infer<typeof ParityRollupSchema>;
Loading
Loading