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
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Gate-precision analytics card model (#2191). UI-side mirror of the GateEvalReport/GateEvalRow shape produced
// by computeGateEval (src/review/parity.ts) and surfaced on the operator-dashboard payload — plus the pure fold
// that turns the per-project rows into a single 2x2 confusion matrix + merge precision for the card. Types +
// pure helper live here (not in the .tsx) so the component file exports only components
// (react-refresh/only-export-components).

/** One project's gate confusion matrix + precisions (mirror of src/review/parity.ts GateEvalRow). */
export interface GateEvalRow {
project: string;
wouldMerge: number;
mergeConfirmed: number; // predicted merge AND human merged (true positive)
mergeFalse: number; // predicted merge BUT human closed (false positive)
wouldClose: number;
closeConfirmed: number; // predicted close AND human closed (true negative)
closeFalse: number; // predicted close BUT human merged (false negative)
hold: number;
decided: number;
mergePrecision: number | null;
closePrecision: number | null;
}

/** The gate-eval report as delivered on the operator-dashboard payload (mirror of parity.ts GateEvalReport). */
export interface GateEvalReport {
rows: GateEvalRow[];
/** True once at least one project has enough decided samples to read meaningfully (parity.ts's floor). */
hasSignal: boolean;
}

/** Aggregated 2x2 confusion matrix across every project row, with overall merge precision. */
export interface GateConfusionMatrix {
truePositive: number; // merge predicted → merged
falsePositive: number; // merge predicted → closed
falseNegative: number; // close predicted → merged
trueNegative: number; // close predicted → closed
decided: number;
/** TP / (TP + FP); null when the gate made no merge predictions (empty denominator). */
mergePrecision: number | null;
}

/** Fold the per-project rows into one confusion matrix. Pure; an empty report yields all-zero counts and a
* null precision (no merge predictions ⇒ nothing to be precise about). */
export function aggregateGateEval(report: GateEvalReport): GateConfusionMatrix {
const totals = report.rows.reduce(
(acc, row) => ({
truePositive: acc.truePositive + row.mergeConfirmed,
falsePositive: acc.falsePositive + row.mergeFalse,
falseNegative: acc.falseNegative + row.closeFalse,
trueNegative: acc.trueNegative + row.closeConfirmed,
decided: acc.decided + row.decided,
}),
{ truePositive: 0, falsePositive: 0, falseNegative: 0, trueNegative: 0, decided: 0 },
);
const mergePredictions = totals.truePositive + totals.falsePositive;
return {
...totals,
mergePrecision: mergePredictions > 0 ? totals.truePositive / mergePredictions : null,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { GatePrecisionCard } from "@/components/site/app-panels/gate-precision-card";
import {
aggregateGateEval,
type GateEvalReport,
} from "@/components/site/app-panels/gate-precision-card-model";

function row(overrides: Partial<GateEvalReport["rows"][number]> = {}) {
return {
project: "acme/widgets",
wouldMerge: 0,
mergeConfirmed: 0,
mergeFalse: 0,
wouldClose: 0,
closeConfirmed: 0,
closeFalse: 0,
hold: 0,
decided: 0,
mergePrecision: null,
closePrecision: null,
...overrides,
};
}

describe("aggregateGateEval", () => {
it("folds multiple project rows into one confusion matrix + merge precision", () => {
const report: GateEvalReport = {
hasSignal: true,
rows: [
row({ mergeConfirmed: 6, mergeFalse: 2, closeConfirmed: 3, closeFalse: 1, decided: 12 }),
row({
project: "acme/other",
mergeConfirmed: 2,
mergeFalse: 0,
closeConfirmed: 1,
closeFalse: 0,
decided: 3,
}),
],
};
expect(aggregateGateEval(report)).toEqual({
truePositive: 8,
falsePositive: 2,
falseNegative: 1,
trueNegative: 4,
decided: 15,
mergePrecision: 8 / 10,
});
});

it("returns all-zero counts and null precision for an empty report (no merge predictions ⇒ empty denominator)", () => {
expect(aggregateGateEval({ hasSignal: false, rows: [] })).toEqual({
truePositive: 0,
falsePositive: 0,
falseNegative: 0,
trueNegative: 0,
decided: 0,
mergePrecision: null,
});
});
});

describe("GatePrecisionCard", () => {
it("renders precision, decided count, and the 2x2 confusion matrix for a populated report", () => {
const report: GateEvalReport = {
hasSignal: true,
rows: [
row({ mergeConfirmed: 6, mergeFalse: 2, closeConfirmed: 3, closeFalse: 1, decided: 12 }),
],
};
render(<GatePrecisionCard report={report} />);
expect(screen.getByText("Gate precision")).toBeTruthy();
// 6/(6+2) = 75%
expect(screen.getByText("75%")).toBeTruthy();
expect(screen.getByText("12 decided")).toBeTruthy();
expect(screen.getByText("True positive")).toBeTruthy();
expect(screen.getByText("False negative")).toBeTruthy();
});

it("renders '—' for precision when the gate made no merge predictions (null-precision arm)", () => {
const report: GateEvalReport = {
hasSignal: true,
rows: [row({ closeConfirmed: 4, decided: 4 })], // only close predictions → merge denominator is 0
};
render(<GatePrecisionCard report={report} />);
expect(screen.getByText("—")).toBeTruthy();
});

it("flags the below-floor state via the StatusPill when hasSignal is false", () => {
const report: GateEvalReport = {
hasSignal: false,
rows: [row({ mergeConfirmed: 1, decided: 1 })],
};
render(<GatePrecisionCard report={report} />);
expect(screen.getByText("below 10-sample floor")).toBeTruthy();
});

it("renders nothing when there are no evaluated project rows at all", () => {
const { container } = render(<GatePrecisionCard report={{ hasSignal: false, rows: [] }} />);
expect(container.firstChild).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { cn } from "@/lib/utils";
import { Stat, StatusPill } from "@/components/site/control-primitives";
import { aggregateGateEval, type GateEvalReport } from "./gate-precision-card-model";

// Documented sample-size floor: below this many decided predictions the confusion matrix is too noisy to read,
// so the card flags it via the StatusPill. Mirrors parity.ts's MIN_DECIDED_FOR_SIGNAL (which sets `hasSignal`).
const MIN_DECIDED_FLOOR = 10;

/** Self-host maintainer analytics card (#2191): gate merge-precision + the TP/FP/FN/TN confusion matrix from
* computeGateEval, read-only over the operator-dashboard payload. Renders nothing when there are no evaluated
* projects at all (keeps the analytics page clean until the gate has produced eval rows). */
export function GatePrecisionCard({ report }: { report: GateEvalReport }) {
if (report.rows.length === 0) return null;
const matrix = aggregateGateEval(report);
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">Gate precision</h2>
<p className="mt-1 text-token-xs text-muted-foreground">
The gate's merge/close predictions scored against realized PR outcomes. Public-safe
counts only.
</p>
</div>
<StatusPill status={report.hasSignal ? "ready" : "warn"}>
{report.hasSignal
? `${matrix.decided} decided`
: `below ${MIN_DECIDED_FLOOR}-sample floor`}
</StatusPill>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<Stat
label="Merge precision"
value={
matrix.mergePrecision !== null ? `${Math.round(matrix.mergePrecision * 100)}%` : "—"
}
hint={<span className="text-muted-foreground">P(merged | gate predicted merge)</span>}
/>
<Stat
label="Decided predictions"
value={String(matrix.decided)}
hint={<span className="text-muted-foreground">predictions with a known outcome</span>}
/>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<ConfusionCell
label="True positive"
detail="predicted merge → merged"
value={matrix.truePositive}
tone="text-success"
/>
<ConfusionCell
label="False positive"
detail="predicted merge → closed"
value={matrix.falsePositive}
tone="text-danger"
/>
<ConfusionCell
label="False negative"
detail="predicted close → merged"
value={matrix.falseNegative}
tone="text-warning"
/>
<ConfusionCell
label="True negative"
detail="predicted close → closed"
value={matrix.trueNegative}
tone="text-success"
/>
</div>
</section>
);
}

function ConfusionCell({
label,
detail,
value,
tone,
}: {
label: string;
detail: string;
value: number;
tone: string;
}) {
return (
<div className="rounded-token border border-border p-3">
<div className={cn("font-mono text-token-lg font-medium", tone)}>{value}</div>
<div className="text-token-xs text-foreground">{label}</div>
<div className="text-token-2xs text-muted-foreground">{detail}</div>
</div>
);
}
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 @@ -9,6 +9,8 @@
ProductUsageBreakdownPanel,
WeeklyValueMetricsPanel,
} 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 { useApiResource } from "@/lib/api/use-api-resource";

export const Route = createFileRoute("/app/analytics")({
Expand Down Expand Up @@ -98,9 +100,10 @@
}>;
};
upstreamDrift?: { status?: string; openReportCount?: number } | null;
gateEval?: GateEvalReport;
};

function ProductAnalytics() {

Check warning on line 106 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 @@ -178,6 +181,8 @@
/>
) : null}

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

{data.usageSummary ? (
<ProductUsageBreakdownPanel
byEvent={data.usageSummary.byEvent}
Expand Down
8 changes: 8 additions & 0 deletions src/services/operator-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
WeeklyValueReport,
} from "../types";
import { computeFleetAnalytics, type FleetAnalytics } from "../orb/analytics";
import { computeGateEval, type GateEvalReport } from "../review/parity";
import { loadUpstreamStatus, type UpstreamStatus } from "../upstream/ruleset";
import { nowIso } from "../utils/json";
import { buildRecommendationQualityReport, type RecommendationQualityReport } from "./recommendation-quality-report";
Expand Down Expand Up @@ -59,6 +60,9 @@ export type OperatorDashboardPayload = {
scoringModel: ScoringModelSnapshotRecord | null;
upstreamDrift: UpstreamStatus;
fleetMetrics: FleetAnalytics;
// Gate-precision eval (#2191): the per-project confusion matrix + precisions from computeGateEval, surfaced
// read-only for the maintainer analytics card. Fail-safe empty report when there is no review_audit signal.
gateEval: GateEvalReport;
};

const USAGE_WINDOW_DAYS = 7;
Expand All @@ -82,6 +86,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
commandUsefulness,
recommendationQuality,
fleetMetrics,
gateEval,
] = await Promise.all([
listRepositories(env),
listInstallations(env),
Expand All @@ -99,6 +104,8 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
getCommandUsefulnessSummary(env),
buildRecommendationQualityReport(env, { windowDays: 90 }),
computeFleetAnalytics(env, { windowDays: 90 }),
// #2191: reuse the existing eval (no new compute); it fails safe to an empty report on any read error.
computeGateEval(env, { days: 90, nowMs: Date.now() }),
]);
const weeklyValueReport = buildWeeklyValueReport({
generatedAt: nowIso(),
Expand Down Expand Up @@ -192,6 +199,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise<OperatorD
scoringModel: scoring,
upstreamDrift,
fleetMetrics,
gateEval,
};
}

Expand Down
3 changes: 3 additions & 0 deletions test/unit/operator-dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ describe("operator dashboard payload", () => {
expect(payload.usageSummary).toMatchObject({ totalEvents: expect.any(Number), activeActors: expect.any(Number) });
expect(payload.commandUsefulness.totals).toMatchObject({ feedbackCount: expect.any(Number) });
expect(serialized).not.toMatch(FORBIDDEN_EXPORT_TERMS);
// #2191: gate-eval report is surfaced read-only; with no review_audit signal it fails safe to an empty
// report (no rows, no signal) rather than being absent.
expect(payload.gateEval).toEqual({ rows: [], hasSignal: false });
// Empty fleet → instanceCount 0, null precision card ("—"), no-outlier delta.
expect(payload.fleetMetrics.instanceCount).toBe(0);
expect(payload.metrics).toEqual(
Expand Down
Loading