|
| 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