Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { Status } from "@/components/site/control-primitives";

// UI-side mirror of FleetOutcomeCalibration (src/services/outcome-calibration.ts), delivered on the
// operator-dashboard payload's `calibration` field (#2192, part of #1967). "Bins" here are slop-severity
// bands (clean/low/elevated/high) — the deterministic PREDICTED-risk signal gittensory actually computes
// — each carrying its REALIZED merge rate (the "kept rate"). This intentionally does not mirror #2192's
// own reference (src/review/ops.ts's `Calibration`/`computeCalibration`): that module is ported-but-unwired
// "reviewbot" code whose `review_targets`/`review_audit` tables are never populated in gittensory (see
// src/review/ops-wire.ts's header comment) — nothing constructs its config anywhere in this codebase, and
// its handlers are registered on no route. This mirrors the live native equivalent instead.
export type SlopBand = "clean" | "low" | "elevated" | "high";

export type SlopBandCalibration = {
band: SlopBand;
sampleSize: number;
merged: number;
closed: number;
mergeRate: number;
};

export type SlopOutcomeCalibration = {
totalResolved: number;
bands: SlopBandCalibration[];
overallMergeRate: number | null;
discriminates: boolean | null;
};

export type RecommendationOutcomeCalibration = {
total: number;
positive: number;
negative: number;
pending: number;
positiveRate: number | null;
};

export type FleetOutcomeCalibration = {
generatedAt: string;
slop: SlopOutcomeCalibration;
recommendations: RecommendationOutcomeCalibration;
signals: string[];
};

export const SLOP_BAND_LABEL: Record<SlopBand, string> = {
clean: "Clean",
low: "Low",
elevated: "Elevated",
high: "High",
};

export type CalibrationBandRow = {
band: SlopBand;
label: string;
sampleSize: number;
/** 0-100, or null when there's no sample to show a bar for (distinct from a real 0% merge rate). */
mergeRatePercent: number | null;
};

/** Pure: shape each slop band into a display-ready row. */
export function calibrationBandRows(slop: SlopOutcomeCalibration): CalibrationBandRow[] {
return slop.bands.map((entry) => ({
band: entry.band,
label: SLOP_BAND_LABEL[entry.band],
sampleSize: entry.sampleSize,
mergeRatePercent: entry.sampleSize > 0 ? Math.round(entry.mergeRate * 100) : null,
}));
}

/** Pure: pill tone for the discrimination verdict. */
export function calibrationVerdictTone(discriminates: boolean | null): Status {
if (discriminates === true) return "ready";
if (discriminates === false) return "blocked";
return "warn";
}

/** Pure: human label for the discrimination verdict. */
export function calibrationVerdictLabel(discriminates: boolean | null): string {
if (discriminates === true) return "Predictive";
if (discriminates === false) return "Not discriminating";
return "Insufficient data";
}
164 changes: 164 additions & 0 deletions apps/gittensory-ui/src/components/site/calibration-card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { CalibrationCard } from "@/components/site/calibration-card";
import {
calibrationBandRows,
calibrationVerdictLabel,
calibrationVerdictTone,
type FleetOutcomeCalibration,
type SlopBandCalibration,
} from "@/components/site/calibration-card-model";

function band(overrides: Partial<SlopBandCalibration> = {}): SlopBandCalibration {
return { band: "clean", sampleSize: 0, merged: 0, closed: 0, mergeRate: 0, ...overrides };
}

function calibration(overrides: Partial<FleetOutcomeCalibration> = {}): FleetOutcomeCalibration {
return {
generatedAt: "2026-07-10T00:00:00.000Z",
slop: {
totalResolved: 0,
bands: [
band({ band: "clean" }),
band({ band: "low" }),
band({ band: "elevated" }),
band({ band: "high" }),
],
overallMergeRate: null,
discriminates: null,
},
recommendations: { total: 0, positive: 0, negative: 0, pending: 0, positiveRate: null },
signals: [],
...overrides,
};
}

describe("calibrationBandRows", () => {
it("marks a zero-sample band's merge rate as null (no bar), not a real 0%", () => {
const rows = calibrationBandRows({
totalResolved: 0,
bands: [band({ band: "clean", sampleSize: 0 })],
overallMergeRate: null,
discriminates: null,
});
expect(rows).toEqual([
{ band: "clean", label: "Clean", sampleSize: 0, mergeRatePercent: null },
]);
});

it("converts a populated band's merge rate to a rounded 0-100 percent", () => {
const rows = calibrationBandRows({
totalResolved: 6,
bands: [band({ band: "high", sampleSize: 6, merged: 1, closed: 5, mergeRate: 0.167 })],
overallMergeRate: 0.167,
discriminates: null,
});
expect(rows).toEqual([{ band: "high", label: "High", sampleSize: 6, mergeRatePercent: 17 }]);
});
});

describe("calibrationVerdictTone / calibrationVerdictLabel", () => {
it("reports a ready/predictive verdict when the score discriminates", () => {
expect(calibrationVerdictTone(true)).toBe("ready");
expect(calibrationVerdictLabel(true)).toBe("Predictive");
});
it("reports a blocked/not-discriminating verdict when the score inverts", () => {
expect(calibrationVerdictTone(false)).toBe("blocked");
expect(calibrationVerdictLabel(false)).toBe("Not discriminating");
});
it("reports a warn/insufficient-data verdict when there isn't enough signal to judge", () => {
expect(calibrationVerdictTone(null)).toBe("warn");
expect(calibrationVerdictLabel(null)).toBe("Insufficient data");
});
});

describe("CalibrationCard", () => {
it("renders nothing when there is no resolved-PR or recommendation signal at all (empty bins)", () => {
const { container } = render(<CalibrationCard calibration={calibration()} />);
expect(container.firstChild).toBeNull();
});

it("renders a single populated band alongside the rest as 'no data'", () => {
render(
<CalibrationCard
calibration={calibration({
slop: {
totalResolved: 6,
bands: [
band({ band: "clean", sampleSize: 6, merged: 5, closed: 1, mergeRate: 0.833 }),
band({ band: "low" }),
band({ band: "elevated" }),
band({ band: "high" }),
],
overallMergeRate: 0.833,
discriminates: null,
},
signals: ["Not enough resolved PRs per band to judge slop calibration yet (6 resolved)."],
})}
/>,
);
expect(screen.getByText("Confidence calibration")).toBeTruthy();
expect(screen.getByText("83% · n=6")).toBeTruthy();
expect(screen.getAllByText("no data")).toHaveLength(3);
expect(screen.getByText("Insufficient data")).toBeTruthy();
expect(screen.getByText(/Not enough resolved PRs per band/)).toBeTruthy();
});

it("renders the full curve across every band plus the recommendation-outcome split", () => {
render(
<CalibrationCard
calibration={calibration({
slop: {
totalResolved: 24,
bands: [
band({ band: "clean", sampleSize: 6, merged: 5, closed: 1, mergeRate: 0.833 }),
band({ band: "low", sampleSize: 6, merged: 3, closed: 3, mergeRate: 0.5 }),
band({ band: "elevated", sampleSize: 6, merged: 2, closed: 4, mergeRate: 0.333 }),
band({ band: "high", sampleSize: 6, merged: 1, closed: 5, mergeRate: 0.167 }),
],
overallMergeRate: 0.458,
discriminates: true,
},
recommendations: { total: 5, positive: 3, negative: 1, pending: 1, positiveRate: 0.75 },
signals: [
"Slop score is predictive: merge rate falls as the band rises (24 resolved PRs).",
],
})}
/>,
);
expect(screen.getByText("Predictive")).toBeTruthy();
expect(screen.getByText("24 resolved")).toBeTruthy();
expect(screen.getByText("83% · n=6")).toBeTruthy();
expect(screen.getByText("50% · n=6")).toBeTruthy();
expect(screen.getByText("33% · n=6")).toBeTruthy();
expect(screen.getByText("17% · n=6")).toBeTruthy();
expect(screen.queryByText("no data")).toBeNull();
expect(screen.getByText("75%")).toBeTruthy();
expect(screen.getByText(/3\/4 positive/)).toBeTruthy();
expect(screen.getByText(/1 pending/)).toBeTruthy();
});

it("shows a '—' recommendation rate when nothing is resolved yet, even with slop signal present", () => {
render(
<CalibrationCard
calibration={calibration({
slop: {
totalResolved: 6,
bands: [
band({ band: "clean", sampleSize: 6, merged: 5, closed: 1, mergeRate: 0.833 }),
band({ band: "low" }),
band({ band: "elevated" }),
band({ band: "high" }),
],
overallMergeRate: 0.833,
discriminates: null,
},
recommendations: { total: 2, positive: 0, negative: 0, pending: 2, positiveRate: null },
})}
/>,
);
expect(screen.getByText("—")).toBeTruthy();
expect(screen.getByText(/0\/0 positive/)).toBeTruthy();
});
});
91 changes: 91 additions & 0 deletions apps/gittensory-ui/src/components/site/calibration-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { BoundaryBadge, Stat, StatusPill } from "@/components/site/control-primitives";
import {
calibrationBandRows,
calibrationVerdictLabel,
calibrationVerdictTone,
type FleetOutcomeCalibration,
} from "@/components/site/calibration-card-model";

/**
* Confidence-calibration card (#2192, part of #1967): predicted slop-severity band vs. realized merge
* rate per bucket, plus the recommendation-outcome split, over the existing FleetOutcomeCalibration
* payload. Renders nothing when there's no resolved-PR or recommendation signal yet (keeps the analytics
* page clean until calibration data exists), matching GatePrecisionCard's convention on this same page.
*/
export function CalibrationCard({ calibration }: { calibration: FleetOutcomeCalibration }) {
if (calibration.slop.totalResolved === 0 && calibration.recommendations.total === 0) return null;
const rows = calibrationBandRows(calibration.slop);
const resolvedRecommendations =
calibration.recommendations.positive + calibration.recommendations.negative;

return (
<section className="rounded-token border border-border bg-transparent p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-display text-token-lg font-semibold">Confidence calibration</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
Predicted slop severity vs. realized merge rate per band — public-safe counts only.
</p>
</div>
<BoundaryBadge boundary="private-api" />
</div>

<div className="mt-4 grid gap-3 sm:grid-cols-2">
<Stat
label="Calibration signal"
value={calibrationVerdictLabel(calibration.slop.discriminates)}
hint={
<StatusPill status={calibrationVerdictTone(calibration.slop.discriminates)}>
{calibration.slop.totalResolved} resolved
</StatusPill>
}
/>
<Stat
label="Recommendation outcomes"
value={
calibration.recommendations.positiveRate !== null
? `${Math.round(calibration.recommendations.positiveRate * 100)}%`
: "—"
}
hint={
<span className="text-muted-foreground">
{calibration.recommendations.positive}/{resolvedRecommendations} positive ·{" "}
{calibration.recommendations.pending} pending
</span>
}
/>
</div>

<div className="mt-4 space-y-2">
{rows.map((row) => (
<div key={row.band} className="flex items-center gap-3">
<span className="w-20 shrink-0 font-mono text-token-2xs uppercase tracking-wider text-muted-foreground">
{row.label}
</span>
<div className="h-2 flex-1 overflow-hidden rounded-token bg-background/40">
{row.mergeRatePercent !== null ? (
<div
className="h-full rounded-token bg-mint/60"
style={{ width: `${row.mergeRatePercent}%` }}
/>
) : null}
</div>
<span className="w-24 shrink-0 text-right font-mono text-token-2xs text-muted-foreground">
{row.mergeRatePercent !== null
? `${row.mergeRatePercent}% · n=${row.sampleSize}`
: "no data"}
</span>
</div>
))}
</div>

{calibration.signals.length > 0 ? (
<ul className="mt-4 space-y-1 text-token-xs text-muted-foreground">
{calibration.signals.map((signal) => (
<li key={signal}>{signal}</li>
))}
</ul>
) : null}
</section>
);
}
5 changes: 5 additions & 0 deletions apps/gittensory-ui/src/routes/app.analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
} from "@/components/site/usage-analytics-panels";
import { GatePrecisionCard } from "@/components/site/app-panels/gate-precision-card";
import type { GateEvalReport } from "@/components/site/app-panels/gate-precision-card-model";
import { CalibrationCard } from "@/components/site/calibration-card";
import type { FleetOutcomeCalibration } from "@/components/site/calibration-card-model";
import { CycleTimeCard } from "@/components/site/app-panels/cycle-time-card";
import type { CycleTimeAggregate } from "@/components/site/app-panels/cycle-time-card-model";
import { useApiResource } from "@/lib/api/use-api-resource";
Expand Down Expand Up @@ -104,10 +106,11 @@
};
upstreamDrift?: { status?: string; openReportCount?: number } | null;
gateEval?: GateEvalReport;
calibration?: FleetOutcomeCalibration;
cycleTime?: CycleTimeAggregate;
};

function ProductAnalytics() {

Check warning on line 113 in apps/gittensory-ui/src/routes/app.analytics.tsx

View workflow job for this annotation

GitHub Actions / validate-code

Fast refresh only works when a file only exports components. Move your component(s) to a separate file. If all exports are HOCs, add them to the `extraHOCs` option
const dashboard = useApiResource<OperatorDashboard>(
"/v1/app/operator-dashboard",
"Product analytics",
Expand Down Expand Up @@ -188,6 +191,8 @@

{data.gateEval ? <GatePrecisionCard report={data.gateEval} /> : null}

{data.calibration ? <CalibrationCard calibration={data.calibration} /> : null}

{data.cycleTime ? <CycleTimeCard cycleTime={data.cycleTime} /> : null}

{data.usageSummary ? (
Expand Down
Loading
Loading