Skip to content

Commit f799c2c

Browse files
authored
feat(analytics): surface org-wide slop-band calibration + add the dashboard card (#2196) (#5126)
Wire the existing, unit-tested buildSlopOutcomeCalibration over listAllPullRequests into the operator-dashboard payload as slopCalibration: org-wide per-band merge/close rates over resolved PRs carrying a persisted slop band, plus whether the deterministic slop score discriminates. The read is wrapped in a buildOrgSlopCalibration() helper that fails safe to an empty calibration on any read error (listAllPullRequests can throw, unlike the sibling reads) so one DB hiccup never fails the whole dashboard — with both the success and fail-safe branches unit-tested. Adds SlopBandCalibrationCard (per-band merge-rate bars + predictive/inverted/insufficient verdict, bands only, never raw scores) across all-bands, empty-band, inverted, no-data, and absent-field branches.
1 parent a416632 commit f799c2c

5 files changed

Lines changed: 235 additions & 1 deletion

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { SlopBandCalibrationCard } from "@/components/site/app-panels/slop-band-calibration-card";
5+
6+
describe("SlopBandCalibrationCard", () => {
7+
it("renders a row per band with merge rate + counts and the discrimination verdict when all bands have data", () => {
8+
render(
9+
<SlopBandCalibrationCard
10+
calibration={{
11+
totalResolved: 40,
12+
overallMergeRate: 0.6,
13+
discriminates: true,
14+
bands: [
15+
{ band: "clean", sampleSize: 20, merged: 18, closed: 2, mergeRate: 0.9 },
16+
{ band: "low", sampleSize: 10, merged: 6, closed: 4, mergeRate: 0.6 },
17+
{ band: "elevated", sampleSize: 6, merged: 2, closed: 4, mergeRate: 0.333 },
18+
{ band: "high", sampleSize: 4, merged: 0, closed: 4, mergeRate: 0 },
19+
],
20+
}}
21+
/>,
22+
);
23+
expect(screen.getByText("clean")).toBeTruthy();
24+
expect(screen.getByText("high")).toBeTruthy();
25+
expect(screen.getByText("90% merged")).toBeTruthy();
26+
expect(screen.getByText("predictive")).toBeTruthy();
27+
expect(screen.getByText(/40 resolved · 60% merged overall/)).toBeTruthy();
28+
});
29+
30+
it("shows the '— no samples' state for an empty band and marks insufficient data", () => {
31+
render(
32+
<SlopBandCalibrationCard
33+
calibration={{
34+
totalResolved: 3,
35+
overallMergeRate: 1,
36+
discriminates: null,
37+
bands: [
38+
{ band: "clean", sampleSize: 3, merged: 3, closed: 0, mergeRate: 1 },
39+
{ band: "high", sampleSize: 0, merged: 0, closed: 0, mergeRate: 0 },
40+
],
41+
}}
42+
/>,
43+
);
44+
expect(screen.getByText("— no samples")).toBeTruthy();
45+
expect(screen.getByText("insufficient data")).toBeTruthy();
46+
});
47+
48+
it("flags an inverted (non-predictive) score", () => {
49+
render(
50+
<SlopBandCalibrationCard
51+
calibration={{
52+
totalResolved: 20,
53+
overallMergeRate: 0.5,
54+
discriminates: false,
55+
bands: [{ band: "clean", sampleSize: 20, merged: 10, closed: 10, mergeRate: 0.5 }],
56+
}}
57+
/>,
58+
);
59+
expect(screen.getByText("inverted")).toBeTruthy();
60+
});
61+
62+
it("shows the 'no resolved PRs' empty state when the calibration has no resolved data", () => {
63+
render(
64+
<SlopBandCalibrationCard
65+
calibration={{ totalResolved: 0, overallMergeRate: null, discriminates: null, bands: [] }}
66+
/>,
67+
);
68+
expect(screen.getByText("No resolved PRs with a slop band")).toBeTruthy();
69+
expect(screen.queryByText("predictive")).toBeNull();
70+
});
71+
72+
it("shows the 'not yet available' empty state when the calibration field is absent", () => {
73+
render(<SlopBandCalibrationCard />);
74+
expect(screen.getByText("Not yet available")).toBeTruthy();
75+
});
76+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { AnalyticsCardShell } from "@/components/site/app-panels/analytics-card-shell";
2+
import { StatusPill, type Status } from "@/components/site/control-primitives";
3+
import { cn } from "@/lib/utils";
4+
5+
/** Slop-band calibration (#2196): per-band merge/close rates over resolved PRs that carry a persisted slop band —
6+
* is the deterministic slop score predictive (do higher-slop bands merge less)? Display slice over the
7+
* operator-dashboard's `slopCalibration` payload. Bands only, never raw scores (public/private boundary). */
8+
export type SlopBand = "clean" | "low" | "elevated" | "high";
9+
10+
export type SlopBandCalibration = {
11+
band: SlopBand;
12+
sampleSize: number;
13+
merged: number;
14+
closed: number;
15+
mergeRate: number;
16+
};
17+
18+
export type SlopOutcomeCalibration = {
19+
totalResolved: number;
20+
bands: SlopBandCalibration[];
21+
overallMergeRate: number | null;
22+
discriminates: boolean | null;
23+
};
24+
25+
/** clean → high reads low-to-high severity; the bar color tracks band severity. */
26+
const BAND_BAR: Record<SlopBand, string> = {
27+
clean: "bg-success",
28+
low: "bg-mint",
29+
elevated: "bg-warning",
30+
high: "bg-danger",
31+
};
32+
33+
function discriminationPill(discriminates: boolean | null): { status: Status; label: string } {
34+
if (discriminates === true) return { status: "ready", label: "predictive" };
35+
if (discriminates === false) return { status: "degraded", label: "inverted" };
36+
return { status: "info", label: "insufficient data" };
37+
}
38+
39+
function BandRow({ row }: { row: SlopBandCalibration }) {
40+
const hasSamples = row.sampleSize > 0;
41+
const pct = hasSamples ? Math.round(row.mergeRate * 100) : null;
42+
return (
43+
<div className="space-y-1.5">
44+
<div className="flex items-center justify-between gap-3 text-token-sm">
45+
<span className="font-medium capitalize text-foreground">{row.band}</span>
46+
<span className="font-mono text-token-xs text-muted-foreground">
47+
{pct === null ? "— no samples" : `${pct}% merged`}
48+
</span>
49+
</div>
50+
<div className="h-2 overflow-hidden rounded-full bg-border" aria-hidden>
51+
{hasSamples ? (
52+
<div className={cn("h-full", BAND_BAR[row.band])} style={{ width: `${pct}%` }} />
53+
) : null}
54+
</div>
55+
<div className="font-mono text-token-2xs text-muted-foreground">
56+
{row.sampleSize} resolved · {row.merged} merged · {row.closed} closed
57+
</div>
58+
</div>
59+
);
60+
}
61+
62+
export function SlopBandCalibrationCard({ calibration }: { calibration?: SlopOutcomeCalibration }) {
63+
const hasData =
64+
calibration != null && calibration.totalResolved > 0 && calibration.bands.length > 0;
65+
66+
if (!hasData) {
67+
return (
68+
<AnalyticsCardShell
69+
title="Slop-band calibration"
70+
description="Predicted slop band vs realized merge/close outcome, across resolved PRs."
71+
state="empty"
72+
emptyTitle={calibration ? "No resolved PRs with a slop band" : "Not yet available"}
73+
emptyHint={
74+
calibration
75+
? "Calibration appears once resolved PRs carry a persisted slop band in the window."
76+
: "Slop-band calibration appears once the payload includes resolved-PR slop bands."
77+
}
78+
/>
79+
);
80+
}
81+
82+
const pill = discriminationPill(calibration.discriminates);
83+
const overall =
84+
calibration.overallMergeRate === null ? null : Math.round(calibration.overallMergeRate * 100);
85+
86+
return (
87+
<AnalyticsCardShell
88+
title="Slop-band calibration"
89+
description="Predicted slop band vs realized merge/close outcome, across resolved PRs."
90+
state="ready"
91+
>
92+
<div className="flex flex-wrap items-center justify-between gap-2">
93+
<span className="font-mono text-token-2xs text-muted-foreground">
94+
{calibration.totalResolved} resolved
95+
{overall === null ? "" : ` · ${overall}% merged overall`}
96+
</span>
97+
<StatusPill status={pill.status}>{pill.label}</StatusPill>
98+
</div>
99+
<div className="mt-3 space-y-4">
100+
{calibration.bands.map((row) => (
101+
<BandRow key={row.band} row={row} />
102+
))}
103+
</div>
104+
</AnalyticsCardShell>
105+
);
106+
}

apps/gittensory-ui/src/routes/app.analytics.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import {
2828
FindingsBreakdownCard,
2929
type FindingsBreakdown,
3030
} from "@/components/site/app-panels/findings-breakdown-card";
31+
import {
32+
SlopBandCalibrationCard,
33+
type SlopOutcomeCalibration,
34+
} from "@/components/site/app-panels/slop-band-calibration-card";
3135
import { useApiResource } from "@/lib/api/use-api-resource";
3236
import { exportOperatorDashboardCsv } from "@/lib/csv-export";
3337

@@ -124,6 +128,7 @@ type OperatorDashboard = {
124128
agentHealth?: ReversalHealth;
125129
acceptance?: FindingAcceptance;
126130
findingsBreakdown?: FindingsBreakdown;
131+
slopCalibration?: SlopOutcomeCalibration;
127132
};
128133

129134
function ProductAnalytics() {
@@ -232,6 +237,8 @@ function ProductAnalytics() {
232237

233238
<FindingsBreakdownCard findings={data.findingsBreakdown} />
234239

240+
<SlopBandCalibrationCard calibration={data.slopCalibration} />
241+
235242
{data.usageSummary ? (
236243
<ProductUsageBreakdownPanel
237244
byEvent={data.usageSummary.byEvent}

src/services/operator-dashboard.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getCommandUsefulnessSummary,
55
getLatestScoringModelSnapshot,
66
getProductUsageRollupStatus,
7+
listAllPullRequests,
78
listInstallationHealth,
89
listInstallations,
910
listLatestGitHubRateLimitObservations,
@@ -32,6 +33,7 @@ import { computeCycleTimeAggregate, type CycleTimeAggregate } from "../review/st
3233
import { loadUpstreamStatus, type UpstreamStatus } from "../upstream/ruleset";
3334
import { nowIso } from "../utils/json";
3435
import { buildRecommendationQualityReport, type RecommendationQualityReport } from "./recommendation-quality-report";
36+
import { buildSlopOutcomeCalibration, type SlopOutcomeCalibration } from "./outcome-calibration";
3537
import { buildWeeklyValueReport } from "./weekly-value-report";
3638

3739
export type OperatorDashboardMetric = {
@@ -71,6 +73,9 @@ export type OperatorDashboardPayload = {
7173
calibration: Calibration;
7274
// Agent reversal health (#2193): how often humans reopened/reverted bot auto-actions (ops.ts AgentHealth).
7375
agentHealth: AgentHealth;
76+
// Slop-band calibration (#2196): org-wide per-band merge/close rates over resolved PRs carrying a persisted
77+
// slop band — is the deterministic slop score predictive? Bands only, never raw scores. Fails safe to empty.
78+
slopCalibration: SlopOutcomeCalibration;
7479
};
7580

7681
const USAGE_WINDOW_DAYS = 7;
@@ -98,6 +103,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
98103
cycleTime,
99104
calibration,
100105
agentHealth,
106+
slopCalibration,
101107
] = await Promise.all([
102108
listRepositories(env),
103109
listInstallations(env),
@@ -121,6 +127,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
121127
computeCycleTimeAggregate(env, { days: 90, nowMs: Date.now() }),
122128
computeCalibration(env, operatorAgentConfig(env)),
123129
computeAgentHealth(env, operatorAgentConfig(env)),
130+
buildOrgSlopCalibration(env),
124131
]);
125132
const weeklyValueReport = buildWeeklyValueReport({
126133
generatedAt: nowIso(),
@@ -224,9 +231,21 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
224231
cycleTime,
225232
calibration,
226233
agentHealth,
234+
slopCalibration,
227235
};
228236
}
229237

238+
/** #2196: org-wide slop-band calibration from persisted slop bands on resolved PRs. `listAllPullRequests` can
239+
* throw (unlike the sibling reads, which fail safe internally), so this wraps it and degrades to an empty
240+
* calibration on any read error — one DB hiccup must never fail the whole dashboard build. */
241+
async function buildOrgSlopCalibration(env: Env): Promise<SlopOutcomeCalibration> {
242+
try {
243+
return buildSlopOutcomeCalibration(await listAllPullRequests(env));
244+
} catch {
245+
return buildSlopOutcomeCalibration([]);
246+
}
247+
}
248+
230249
function operatorAgentConfig(env: Env): { slug: string; secrets: Record<string, never> } {
231250
const slug =
232251
typeof env.GITHUB_APP_SLUG === "string" && env.GITHUB_APP_SLUG.trim()
@@ -235,7 +254,7 @@ function operatorAgentConfig(env: Env): { slug: string; secrets: Record<string,
235254
return { slug, secrets: {} };
236255
}
237256

238-
export const __operatorDashboardInternals = { operatorAgentConfig };
257+
export const __operatorDashboardInternals = { operatorAgentConfig, buildOrgSlopCalibration };
239258

240259
export function latestUsageRollup(rollups: ProductUsageDailyRollupRecord[]): ProductUsageDailyRollupRecord | null {
241260
if (rollups.length === 0) return null;

test/unit/operator-dashboard.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ describe("operator dashboard payload", () => {
5050
recentAutoActions: 0,
5151
reversedTargets: [],
5252
});
53+
// #2196: org-wide slop-band calibration fails safe to an empty calibration when no resolved PR carries a band.
54+
expect(payload.slopCalibration).toMatchObject({
55+
totalResolved: 0,
56+
overallMergeRate: null,
57+
discriminates: null,
58+
});
5359
// Empty fleet → instanceCount 0, null precision card ("—"), no-outlier delta.
5460
expect(payload.fleetMetrics.instanceCount).toBe(0);
5561
expect(payload.metrics).toEqual(
@@ -103,6 +109,26 @@ describe("operator dashboard payload", () => {
103109
});
104110
});
105111

112+
it("buildOrgSlopCalibration (#2196) degrades to an empty calibration when the PR read throws", async () => {
113+
const { buildOrgSlopCalibration } = __operatorDashboardInternals;
114+
// A DB whose every access throws forces listAllPullRequests to reject; the fail-safe must swallow it.
115+
const brokenDb = new Proxy(
116+
{},
117+
{
118+
get() {
119+
throw new Error("DB unavailable");
120+
},
121+
},
122+
);
123+
const brokenEnv = { ...createTestEnv(), DB: brokenDb as unknown as D1Database };
124+
const calibration = await buildOrgSlopCalibration(brokenEnv);
125+
expect(calibration).toMatchObject({
126+
totalResolved: 0,
127+
overallMergeRate: null,
128+
discriminates: null,
129+
});
130+
});
131+
106132
it("surfaces populated fleet metrics + outliers from orb_signals", async () => {
107133
const env = createTestEnv();
108134
let n = 0;

0 commit comments

Comments
 (0)