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
30 changes: 29 additions & 1 deletion apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,33 @@
"accuracyPct"
]
}
},
"reuseRateTrend": {
"type": "array",
"items": {
"type": "object",
"properties": {
"weekStart": {
"type": "string"
},
"hits": {
"type": "number"
},
"misses": {
"type": "number"
},
"reuseRatePct": {
"type": "number",
"nullable": true
}
},
"required": [
"weekStart",
"hits",
"misses",
"reuseRatePct"
]
}
}
},
"required": [
Expand All @@ -503,7 +530,8 @@
"totals",
"weekly",
"byProject",
"accuracyTrend"
"accuracyTrend",
"reuseRateTrend"
]
},
"PublicQualityMetrics": {
Expand Down
5 changes: 3 additions & 2 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ import { computePredictedGateAgreement } from "../review/predicted-gate-agreemen
import { isRagEnabled } from "../review/rag-wire";
import { getPublicStats, isPublicStatsEnabled } from "../review/public-stats";
import { loadPublicAccuracyTrend } from "../services/public-accuracy-trend";
import { loadPublicReuseRateTrend } from "../services/public-reuse-rate-trend";
import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from "../services/maintainer-quality-dashboard";
import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics";
import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES, normalizeReadinessGateMode } from "../signals/focus-manifest";
Expand Down Expand Up @@ -958,9 +959,9 @@ export function createApp() {
app.get("/v1/public/stats", async (c) => {
if (!isPublicStatsEnabled(c.env)) return c.json({ error: "not_found" }, 404);
try {
const [stats, accuracyTrend] = await Promise.all([getPublicStats(c.env), loadPublicAccuracyTrend(c.env)]);
const [stats, accuracyTrend, reuseRateTrend] = await Promise.all([getPublicStats(c.env), loadPublicAccuracyTrend(c.env), loadPublicReuseRateTrend(c.env)]);
c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=300");
return c.json({ ...stats, accuracyTrend });
return c.json({ ...stats, accuracyTrend, reuseRateTrend });
} catch {
return c.json({ error: "public_stats_unavailable" }, 503);
}
Expand Down
13 changes: 13 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ export const PublicStatsSchema = z
accuracyPct: z.number().nullable(),
}),
),
/** Trailing weekly "how often we avoid redoing AI work" trend (#4448) -- a competence signal, not a cost
* claim. Counts cache hits/misses across every instrumented AI-touching capability (grounding,
* review-memory, impact-map, repo-culture-profile, ai_review, ai_slop, linked_issue_satisfaction,
* miner_detection). null reuseRatePct on a week means too few total attempts to publish a meaningful
* percentage, not zero reuse. */
reuseRateTrend: z.array(
z.object({
weekStart: z.string(),
hits: z.number(),
misses: z.number(),
reuseRatePct: z.number().nullable(),
}),
),
})
.openapi("PublicStats");

Expand Down
2 changes: 1 addition & 1 deletion src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7546,7 +7546,7 @@ export async function runAiReviewForAdvisory(
patch: typeof file.payload?.patch === "string" ? file.payload.patch : undefined,
})),
);
impactMapEntries = await computeImpactMap(changedSymbols, {
impactMapEntries = await computeImpactMap(env, changedSymbols, {
infra: createReviewAdapters(env),
project: impactMapProject,
repo: impactMapRepo,
Expand Down
22 changes: 22 additions & 0 deletions src/review/impact-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
// FAIL-SAFE (mirrors rag.ts's own guarantee): a missing/cold RAG index, no changed symbols, or any retrieval
// error degrades to an EMPTY impact map — this computation can never break or block a review.

import { recordAuditEvent } from "../db/repositories";
import { incr } from "../selfhost/metrics";
import { sha256Hex } from "../utils/crypto";
import { nowIso } from "../utils/json";
import type { FileChangedSymbols } from "./impact-symbols";
Expand Down Expand Up @@ -129,10 +131,12 @@ async function putCachedImpactMapQuery(
* retrieval error yields an EMPTY impact map, never a throw.
*/
export async function computeImpactMap(
env: Env,
symbols: FileChangedSymbols[],
ragContext: { infra: RagInfra; project: string; repo: string },
): Promise<ImpactMapEntry[]> {
const out: ImpactMapEntry[] = [];
const targetKey = ragContext.project ? `${ragContext.project}/${ragContext.repo}` : ragContext.repo;
// Symbol-less files never query (nothing to look up) and so never count against the cap below -- filter
// them out first so the cap applies to the actual query budget, not a raw slice of the input.
const queryableFiles = symbols.filter((file) => file.symbols.length > 0).slice(0, MAX_IMPACT_MAP_INPUT_FILES);
Expand All @@ -148,8 +152,26 @@ export async function computeImpactMap(
const cached = await getCachedImpactMapQuery(ragContext.infra.storage, ragContext.project, ragContext.repo, fingerprint);
let result: RagRetrievalResult;
if (cached !== null) {
// #4448: mirrors repo-culture-profile's #4509 cache hit/miss instrumentation exactly -- one of the six
// AI-touching capabilities that had no reuse-rate signal at all before this.
incr("gittensory_impact_map_cache_hit_total");
await recordAuditEvent(env, {
eventType: "github_app.impact_map_cache_hit",
targetKey,
outcome: "completed",
detail: "reused a cached impact-map query result instead of re-querying the vector index",
metadata: { repoFullName: targetKey },
}).catch(() => undefined);
result = cached;
} else {
incr("gittensory_impact_map_cache_miss_total");
await recordAuditEvent(env, {
eventType: "github_app.impact_map_cache_miss",
targetKey,
outcome: "completed",
detail: "no reusable cached impact-map query result; querying the vector index fresh",
metadata: { repoFullName: targetKey },
}).catch(() => undefined);
result = await retrieveContextWithMetrics(ragContext.infra, {
project: ragContext.project,
repo: ragContext.repo,
Expand Down
111 changes: 111 additions & 0 deletions src/services/public-reuse-rate-trend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Public "AI-work reuse rate" weekly trend (#4448, part of epic #4445). An honest engineering-competence
// number, not a cost claim: how often the review engine correctly reused a prior result instead of redoing the
// same work -- across every AI-touching capability that has a cache to hit or miss (grounding, review-memory,
// impact-map, repo-culture-profile, ai_review, ai_slop, linked_issue_satisfaction, miner_detection). Deliberately
// NOT a cost/token-rate metric (out of scope per the parent epic).
//
// DELIBERATELY NOT a persisted/cron rollup, mirroring #4447's own public-accuracy-trend.ts design: audit_events
// is already durable, so a live weekly re-bucketing of the SAME rows can recompute any historical week correctly
// on every request -- no cron-miss gap risk, and no second copy of the number to keep in sync.
//
// DELIBERATELY GLOBAL, not scoped to the public-stats repo allowlist: unlike accuracy/handled-PR counts, a
// cache-hit/miss event carries no PR content, author, or repo-specific outcome -- the aggregate reuse rate
// doesn't reveal anything about any one repo's activity, and target_key isn't uniformly shaped across all eight
// capabilities (some key by bare repoFullName, others by repoFullName#prNumber), so allowlist-filtering it would
// need a fragile per-capability parser for no real privacy benefit.
//
// NAMING CONVENTION, not a hardcoded capability list: every instrumented capability already follows
// `github_app.<name>_cache_hit` / `github_app.<name>_cache_miss` (confirmed via a full-repo grep before writing
// this), so a single LIKE-pattern query picks up all eight today AND any future capability that follows the
// same convention, with zero code change here. ai_review's three additional REUSE variants (frozen/paused/
// one-shot) don't fit that exact suffix -- each is a genuine "skipped a redundant AI call" event, so they're
// folded into "hit" alongside the plain ai_review_cache_hit.
import { safeAll } from "../review/public-stats";
import { isoWeekStart } from "./public-quality-metrics";

export const PUBLIC_REUSE_RATE_TREND_WEEKS = 8;
/** Below this many total attempts (hits+misses) in a week, that week's reuse rate is too noisy to publish. */
export const MIN_REUSE_RATE_TREND_SAMPLE = 5;

/** ai_review reuse events that don't follow the `_cache_hit` suffix convention but are the SAME "avoided a
* redundant AI call" signal -- each one means the review pass reused a prior state instead of re-running. */
const AI_REVIEW_REUSE_EVENT_TYPES = ["github_app.ai_review_frozen_reuse", "github_app.ai_review_paused_reuse", "github_app.ai_review_one_shot_reuse"] as const;

export type PublicReuseRateTrendWeek = {
/** UTC Monday (YYYY-MM-DD) that starts the bucket. */
weekStart: string;
hits: number;
misses: number;
reuseRatePct: number | null;
};

type DayRow = { day: string; hits: number; misses: number };

const MS_PER_WEEK = 7 * 86_400_000;

function roundPct(value: number): number {
return Math.round(value * 1000) / 10;
}

function reuseRatePctOf(hits: number, misses: number): number | null {
const attempts = hits + misses;
if (attempts < MIN_REUSE_RATE_TREND_SAMPLE) return null;
return roundPct(hits / attempts);
}

/** Fold day-granularity rows into `weeks` trailing UTC-Monday buckets ending in the week containing `nowMs`.
* Pure -- mirrors buildPublicAccuracyTrend's own bucketing shape (public-accuracy-trend.ts, #4447). */
export function buildPublicReuseRateTrend(dayRows: DayRow[], nowMs: number, weeks: number = PUBLIC_REUSE_RATE_TREND_WEEKS): PublicReuseRateTrendWeek[] {
const currentStartMs = Date.parse(isoWeekStart(nowMs));
const oldestStartMs = currentStartMs - (weeks - 1) * MS_PER_WEEK;
const buckets = Array.from({ length: weeks }, () => ({ hits: 0, misses: 0 }));

for (const row of dayRows) {
const dayMs = Date.parse(`${row.day}T00:00:00.000Z`);
if (!Number.isFinite(dayMs)) continue;
const weekOffset = Math.floor((dayMs - oldestStartMs) / MS_PER_WEEK);
if (weekOffset < 0 || weekOffset >= weeks) continue;
const bucket = buckets[weekOffset]!;
bucket.hits += row.hits;
bucket.misses += row.misses;
}

return buckets.map((bucket, offset) => ({
weekStart: isoWeekStart(oldestStartMs + offset * MS_PER_WEEK),
hits: bucket.hits,
misses: bucket.misses,
reuseRatePct: reuseRatePctOf(bucket.hits, bucket.misses),
}));
}

/** Day-bucketed hit/miss counts across every `github_app.<name>_cache_hit` / `_cache_miss` event, plus
* ai_review's three non-suffix-conforming reuse variants (see file header). Fail-safe: degrades to [] on any
* query error (safeAll), yielding under-counted weeks rather than throwing the whole public stats payload. */
async function loadReuseRateDayRows(env: Env, sinceIso: string): Promise<DayRow[]> {
const reuseTypePlaceholders = AI_REVIEW_REUSE_EVENT_TYPES.map(() => "?").join(", ");
const rows = await safeAll<{ day: string; hits: number; misses: number }>(
env,
`SELECT date(created_at) AS day,
SUM(CASE WHEN event_type LIKE 'github_app.%cache_hit' OR event_type IN (${reuseTypePlaceholders}) THEN 1 ELSE 0 END) AS hits,
SUM(CASE WHEN event_type LIKE 'github_app.%cache_miss' THEN 1 ELSE 0 END) AS misses
FROM audit_events
WHERE (event_type LIKE 'github_app.%cache_hit' OR event_type LIKE 'github_app.%cache_miss' OR event_type IN (${reuseTypePlaceholders}))
AND created_at >= ?
GROUP BY day`,
...AI_REVIEW_REUSE_EVENT_TYPES,
...AI_REVIEW_REUSE_EVENT_TYPES,
sinceIso,
);
/* v8 ignore next -- SUM(CASE WHEN ... THEN 1 ELSE 0 END) over an existing GROUP BY day always yields a
* defined integer (0 or more), never SQL NULL, so the ?? 0 fallback can't currently be exercised; kept for
* defense against a future query-shape change (mirrors public-accuracy-trend.ts's identical guard). */
return rows.map((row) => ({ day: row.day, hits: row.hits ?? 0, misses: row.misses ?? 0 }));
}

/** Assemble the public reuse-rate trend from the SAME live audit_events ledger every instrumented capability
* already writes to. */
export async function loadPublicReuseRateTrend(env: Env, nowMs: number = Date.now()): Promise<PublicReuseRateTrendWeek[]> {
const sinceIso = new Date(Date.parse(isoWeekStart(nowMs)) - (PUBLIC_REUSE_RATE_TREND_WEEKS - 1) * MS_PER_WEEK).toISOString();
const dayRows = await loadReuseRateDayRows(env, sinceIso);
return buildPublicReuseRateTrend(dayRows, nowMs);
}
5 changes: 5 additions & 0 deletions test/integration/public-stats-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { createApp } from "../../src/api/routes";
import { createTestEnv } from "../helpers/d1";
import { PUBLIC_ACCURACY_TREND_WEEKS } from "../../src/services/public-accuracy-trend";
import { PUBLIC_REUSE_RATE_TREND_WEEKS } from "../../src/services/public-reuse-rate-trend";

/** Seed the LIVE ledger: a published-review surface per reviewed PR (audit_events) + each PR's terminal
* disposition (pull_requests state/merged_at), plus one live reversal (an engine close on a now-reopened PR). */
Expand Down Expand Up @@ -62,6 +63,7 @@ describe("GET /v1/public/stats (#1059)", () => {
weekly: { reviewed: number; merged: number };
byProject: Array<{ project: string; reviewed: number }>;
accuracyTrend: Array<{ weekStart: string; merged: number; closed: number; reversed: number; accuracyPct: number | null }>;
reuseRateTrend: Array<{ weekStart: string; hits: number; misses: number; reuseRatePct: number | null }>;
};
expect(body.totals.handled).toBe(5); // distinct reviewed PRs
expect(body.totals.merged).toBe(3);
Expand All @@ -81,5 +83,8 @@ describe("GET /v1/public/stats (#1059)", () => {
// #4447: the weekly accuracy trend rides along on the SAME response, one entry per trailing week.
expect(body.accuracyTrend).toHaveLength(PUBLIC_ACCURACY_TREND_WEEKS);
for (const week of body.accuracyTrend) expect(typeof week.weekStart).toBe("string");
// #4448: the weekly AI-work reuse-rate trend rides along on the SAME response too.
expect(body.reuseRateTrend).toHaveLength(PUBLIC_REUSE_RATE_TREND_WEEKS);
for (const week of body.reuseRateTrend) expect(typeof week.weekStart).toBe("string");
});
});
Loading
Loading