Skip to content

Commit 5cc403c

Browse files
committed
feat(api): weekly rule-calibration trend endpoint over fired/override history and backtest runs (#8113)
The #8101/#8104 fired+override history and the #8138/#8139 persisted backtest runs had no aggregate view -- answering 'is precision for rule X trending up or down' meant manually re-running CLIs. Adds rule-calibration-trend.ts (the maintainer-facing sibling of public-accuracy-trend.ts: same live no-cron re-bucketing posture, same null-below-min-sample discipline) aggregating per-rule fired/confirmed/reversed/precisionPct per UTC week plus backtest-run verdict counts across both sibling event types, served at /v1/internal/calibration-trend next to the existing operator calibration endpoint (same INTERNAL_JOB_TOKEN gate). Trend-grained precision semantics (decision-week bucketing, confirmed/decided) are documented as deliberately distinct from computeRulePrecision's per-target pairing. Aggregate counts and rule ids only; the leak-guard test pins that no metadata raw context escapes. 100% line+branch coverage on the service.
1 parent ac2190f commit 5cc403c

3 files changed

Lines changed: 414 additions & 0 deletions

File tree

src/api/routes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ import { isFairnessAnalyticsEnabled, resolveFairnessAnalyticsManifestOverride }
319319
import { isRagEnabled } from "../review/rag-wire";
320320
import { getPublicStats, isPublicStatsEnabled, resolvePublicStatsManifestOverride } from "../review/public-stats";
321321
import { loadPublicAccuracyTrend } from "../services/public-accuracy-trend";
322+
import { loadCalibrationTrend } from "../services/rule-calibration-trend";
322323
import { loadPublicReuseRateTrend } from "../services/public-reuse-rate-trend";
323324
import { loadPublicReviewVolumeTrend } from "../services/public-review-volume-trend";
324325
import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from "../services/maintainer-quality-dashboard";
@@ -4806,6 +4807,12 @@ export function createApp() {
48064807
// Fails safe to an empty-but-shaped report when there is no review signal yet. Aggregate counts only.
48074808
app.get("/v1/internal/calibration", (c) => handleInternalCalibration(c.req.raw, c.env, internalOpsAgentConfig(c.env)));
48084809

4810+
// Operator calibration trend (#8113): weekly per-rule fired/decided/precision plus backtest-run verdict
4811+
// counts, re-bucketed live from audit_events (no cron rollup — see rule-calibration-trend.ts's header). Sibling
4812+
// of /v1/internal/calibration above, same INTERNAL_JOB_TOKEN gate via the /v1/internal/* middleware.
4813+
// Aggregate counts and rule ids only — no PR content, no raw context.
4814+
app.get("/v1/internal/calibration-trend", async (c) => c.json(await loadCalibrationTrend(c.env)));
4815+
48094816
app.post("/v1/internal/jobs/refresh-registry", async (c) => {
48104817
const message: JobMessage = { type: "refresh-registry", requestedBy: "api" };
48114818
await c.env.JOBS.send(message);
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Rule/AI-judgment calibration trend (#8113, epic #8082). The fired+override history (#8101/#8104) and the
2+
// persisted backtest runs (#8138/#8139) previously had no aggregate view — only per-PR advisory comments —
3+
// so "is precision for rule X trending up or down" meant manually re-running CLIs. This is the maintainer-
4+
// facing sibling of public-accuracy-trend.ts (#4447): the SAME deliberate no-cron posture (audit_events is
5+
// already durable, so a live weekly re-bucketing recomputes any historical week correctly on every request —
6+
// no rollup copy to drift), served from the /v1/internal/* operator surface, NOT the public stats payload
7+
// (rule-level precision is operator observability, not homepage material).
8+
//
9+
// Precision semantics, deliberately trend-grained: a week's `decided` counts the human override events whose
10+
// own created_at falls in that week (the DECISION week), and precisionPct = confirmed/decided over them.
11+
// This is intentionally NOT computeRulePrecision's per-target fired↔override pairing (that corpus-exact
12+
// pairing needs full event metadata, not day rollups) — the two answer different questions ("how are humans
13+
// judging this rule's calls lately" vs "score this exact corpus") and must not be conflated.
14+
import { safeAll } from "../review/public-stats";
15+
import { isoWeekStart } from "./public-quality-metrics";
16+
17+
export const CALIBRATION_TREND_WEEKS = 8;
18+
/** Below this many decided (confirmed+reversed) verdicts in a week, that week's precision is too noisy to
19+
* report — mirrors MIN_ACCURACY_TREND_SAMPLE's role in the public trend. */
20+
export const MIN_CALIBRATION_TREND_SAMPLE = 3;
21+
22+
const RULE_FIRED_EVENT_TYPE_PREFIX = "signal.rule_fired:";
23+
const HUMAN_OVERRIDE_EVENT_TYPE_PREFIX = "signal.human_override:";
24+
// Mirrors THRESHOLD_BACKTEST_EVENT_TYPE (src/services/threshold-backtest-run.ts) and the CI writer's
25+
// LOGIC_BACKTEST_EVENT_TYPE (scripts/backtest-logic-check-core.ts) — the same hand-mirrored posture
26+
// scripts/backtest-track-record.ts documents for why the scripts-side constant isn't imported here.
27+
const BACKTEST_RUN_EVENT_TYPES = ["calibration.threshold_backtest_run", "calibration.logic_backtest_run"] as const;
28+
29+
export type CalibrationRuleTrendWeek = {
30+
/** UTC Monday (YYYY-MM-DD) that starts the bucket. */
31+
weekStart: string;
32+
fired: number;
33+
confirmed: number | null;
34+
reversed: number | null;
35+
precisionPct: number | null;
36+
};
37+
38+
export type CalibrationRuleTrend = { ruleId: string; weeks: CalibrationRuleTrendWeek[] };
39+
40+
export type BacktestRunTrendWeek = {
41+
weekStart: string;
42+
runs: number;
43+
regressed: number;
44+
improved: number;
45+
unchanged: number;
46+
};
47+
48+
export type CalibrationTrendReport = {
49+
rules: CalibrationRuleTrend[];
50+
backtestRuns: BacktestRunTrendWeek[];
51+
};
52+
53+
export type FiredDayRow = { ruleId: string; day: string; fired: number };
54+
export type OverrideDayRow = { ruleId: string; day: string; confirmed: number; reversed: number };
55+
export type BacktestRunDayRow = { day: string; regressed: number; improved: number; unchanged: number };
56+
57+
const MS_PER_WEEK = 7 * 86_400_000;
58+
59+
function roundPct(value: number): number {
60+
return Math.round(value * 1000) / 10;
61+
}
62+
63+
/** Week offset of a day row inside the trailing window, or null when the day is unparseable or outside it. */
64+
function weekOffsetOf(day: string, oldestStartMs: number, weeks: number): number | null {
65+
const dayMs = Date.parse(`${day}T00:00:00.000Z`);
66+
if (!Number.isFinite(dayMs)) return null;
67+
const offset = Math.floor((dayMs - oldestStartMs) / MS_PER_WEEK);
68+
return offset < 0 || offset >= weeks ? null : offset;
69+
}
70+
71+
/**
72+
* Fold day-granularity calibration rows into `weeks` trailing UTC-Monday buckets ending in the week
73+
* containing `nowMs`. Pure — mirrors buildPublicAccuracyTrend's bucketing shape exactly. Rules are sorted
74+
* by ruleId for byte-stable output; a week with fewer than {@link MIN_CALIBRATION_TREND_SAMPLE} decided
75+
* verdicts reports null confirmed/reversed/precisionPct (unknown stays unknown, never a fake 0 or 100).
76+
*/
77+
export function buildCalibrationTrend(
78+
firedRows: readonly FiredDayRow[],
79+
overrideRows: readonly OverrideDayRow[],
80+
runRows: readonly BacktestRunDayRow[],
81+
nowMs: number,
82+
weeks: number = CALIBRATION_TREND_WEEKS,
83+
): CalibrationTrendReport {
84+
const currentStartMs = Date.parse(isoWeekStart(nowMs));
85+
const oldestStartMs = currentStartMs - (weeks - 1) * MS_PER_WEEK;
86+
87+
const ruleBuckets = new Map<string, Array<{ fired: number; confirmed: number; reversed: number }>>();
88+
const bucketsFor = (ruleId: string) => {
89+
const existing = ruleBuckets.get(ruleId);
90+
if (existing) return existing;
91+
const created = Array.from({ length: weeks }, () => ({ fired: 0, confirmed: 0, reversed: 0 }));
92+
ruleBuckets.set(ruleId, created);
93+
return created;
94+
};
95+
for (const row of firedRows) {
96+
const offset = weekOffsetOf(row.day, oldestStartMs, weeks);
97+
if (offset === null) continue;
98+
bucketsFor(row.ruleId)[offset]!.fired += row.fired;
99+
}
100+
for (const row of overrideRows) {
101+
const offset = weekOffsetOf(row.day, oldestStartMs, weeks);
102+
if (offset === null) continue;
103+
const bucket = bucketsFor(row.ruleId)[offset]!;
104+
bucket.confirmed += row.confirmed;
105+
bucket.reversed += row.reversed;
106+
}
107+
108+
const runBuckets = Array.from({ length: weeks }, () => ({ regressed: 0, improved: 0, unchanged: 0 }));
109+
for (const row of runRows) {
110+
const offset = weekOffsetOf(row.day, oldestStartMs, weeks);
111+
if (offset === null) continue;
112+
const bucket = runBuckets[offset]!;
113+
bucket.regressed += row.regressed;
114+
bucket.improved += row.improved;
115+
bucket.unchanged += row.unchanged;
116+
}
117+
118+
const rules: CalibrationRuleTrend[] = [...ruleBuckets.entries()]
119+
.sort(([a], [b]) => a.localeCompare(b))
120+
.map(([ruleId, buckets]) => ({
121+
ruleId,
122+
weeks: buckets.map((bucket, offset) => {
123+
const decided = bucket.confirmed + bucket.reversed;
124+
const publishable = decided >= MIN_CALIBRATION_TREND_SAMPLE;
125+
return {
126+
weekStart: isoWeekStart(oldestStartMs + offset * MS_PER_WEEK),
127+
fired: bucket.fired,
128+
confirmed: publishable ? bucket.confirmed : null,
129+
reversed: publishable ? bucket.reversed : null,
130+
precisionPct: publishable ? roundPct(bucket.confirmed / decided) : null,
131+
};
132+
}),
133+
}));
134+
135+
const backtestRuns: BacktestRunTrendWeek[] = runBuckets.map((bucket, offset) => ({
136+
weekStart: isoWeekStart(oldestStartMs + offset * MS_PER_WEEK),
137+
runs: bucket.regressed + bucket.improved + bucket.unchanged,
138+
regressed: bucket.regressed,
139+
improved: bucket.improved,
140+
unchanged: bucket.unchanged,
141+
}));
142+
143+
return { rules, backtestRuns };
144+
}
145+
146+
/** Day-bucketed rule firings — the ruleId is recovered from the event_type suffix (signal-tracking-wire.ts
147+
* folds it into the type: `signal.rule_fired:<ruleId>`). */
148+
async function loadFiredDayRows(env: Env, sinceIso: string): Promise<FiredDayRow[]> {
149+
const rows = await safeAll<{ rule_id: string; day: string; n: number }>(
150+
env,
151+
`SELECT substr(event_type, ${RULE_FIRED_EVENT_TYPE_PREFIX.length + 1}) AS rule_id, date(created_at) AS day, COUNT(*) AS n
152+
FROM audit_events
153+
WHERE event_type LIKE '${RULE_FIRED_EVENT_TYPE_PREFIX}%' AND created_at >= ?
154+
GROUP BY rule_id, day`,
155+
sinceIso,
156+
);
157+
return rows.map((row) => ({ ruleId: row.rule_id, day: row.day, fired: row.n }));
158+
}
159+
160+
/** Day-bucketed human verdicts, split confirmed/reversed via the recorded `$.verdict` (signal-tracking-wire's
161+
* recordHumanOverride writes it) — bucketed by the override's OWN created_at: this trend reports how humans
162+
* are judging a rule's calls per decision week (see the module doc's precision-semantics note). */
163+
async function loadOverrideDayRows(env: Env, sinceIso: string): Promise<OverrideDayRow[]> {
164+
const rows = await safeAll<{ rule_id: string; day: string; confirmed: number; reversed: number }>(
165+
env,
166+
`SELECT substr(event_type, ${HUMAN_OVERRIDE_EVENT_TYPE_PREFIX.length + 1}) AS rule_id, date(created_at) AS day,
167+
SUM(CASE WHEN json_extract(metadata_json, '$.verdict') = 'reversed' THEN 0 ELSE 1 END) AS confirmed,
168+
SUM(CASE WHEN json_extract(metadata_json, '$.verdict') = 'reversed' THEN 1 ELSE 0 END) AS reversed
169+
FROM audit_events
170+
WHERE event_type LIKE '${HUMAN_OVERRIDE_EVENT_TYPE_PREFIX}%' AND created_at >= ?
171+
GROUP BY rule_id, day`,
172+
sinceIso,
173+
);
174+
/* v8 ignore next 2 -- SUM(CASE ...) over a GROUP BY always yields a defined integer, never SQL NULL; the ?? 0
175+
* fallbacks guard a future query-shape change, mirroring loadOrbDayRows' identical note. */
176+
return rows.map((row) => ({ ruleId: row.rule_id, day: row.day, confirmed: row.confirmed ?? 0, reversed: row.reversed ?? 0 }));
177+
}
178+
179+
/** Day-bucketed backtest runs across BOTH sibling event types, verdict read from the persisted
180+
* `$.comparison.verdict` (the field backtest-track-record.ts's reader also anchors on). A row whose verdict
181+
* is missing/unrecognized counts as `unchanged` — a malformed run must not vanish from `runs` entirely. */
182+
async function loadBacktestRunDayRows(env: Env, sinceIso: string): Promise<BacktestRunDayRow[]> {
183+
const inList = BACKTEST_RUN_EVENT_TYPES.map((eventType) => `'${eventType}'`).join(", ");
184+
const rows = await safeAll<{ day: string; regressed: number; improved: number; unchanged: number }>(
185+
env,
186+
`SELECT date(created_at) AS day,
187+
SUM(CASE WHEN json_extract(metadata_json, '$.comparison.verdict') = 'regressed' THEN 1 ELSE 0 END) AS regressed,
188+
SUM(CASE WHEN json_extract(metadata_json, '$.comparison.verdict') = 'improved' THEN 1 ELSE 0 END) AS improved,
189+
SUM(CASE WHEN json_extract(metadata_json, '$.comparison.verdict') NOT IN ('regressed', 'improved') OR json_extract(metadata_json, '$.comparison.verdict') IS NULL THEN 1 ELSE 0 END) AS unchanged
190+
FROM audit_events
191+
WHERE event_type IN (${inList}) AND created_at >= ?
192+
GROUP BY day`,
193+
sinceIso,
194+
);
195+
/* v8 ignore next 2 -- same SUM(CASE)-never-NULL note as loadOverrideDayRows above. */
196+
return rows.map((row) => ({ day: row.day, regressed: row.regressed ?? 0, improved: row.improved ?? 0, unchanged: row.unchanged ?? 0 }));
197+
}
198+
199+
/** Assemble the calibration trend live from audit_events. Fail-safe: each query degrades to [] on error
200+
* (safeAll), so a single bad query yields under-counted weeks rather than a thrown operator endpoint. */
201+
export async function loadCalibrationTrend(env: Env, nowMs: number = Date.now()): Promise<CalibrationTrendReport> {
202+
const sinceIso = new Date(Date.parse(isoWeekStart(nowMs)) - (CALIBRATION_TREND_WEEKS - 1) * MS_PER_WEEK).toISOString();
203+
const [firedRows, overrideRows, runRows] = await Promise.all([
204+
loadFiredDayRows(env, sinceIso),
205+
loadOverrideDayRows(env, sinceIso),
206+
loadBacktestRunDayRows(env, sinceIso),
207+
]);
208+
return buildCalibrationTrend(firedRows, overrideRows, runRows, nowMs);
209+
}

0 commit comments

Comments
 (0)