Files
accounted/lib/agent/categorize/__tests__/calibration.test.ts
T
Jakob Wennberg 704bf93e08 feat(categorize): confidence calibration engine + measurement loop (cascade step 4) (#1784)
Turns the selector's raw confidence into a score that means what it says.

- lib/agent/categorize/calibration.ts: the engine. Isotonic regression
  (pool-adjacent-violators, distribution-free + monotonic) over
  (confidence, was_correct) samples → a calibrator; plus reliabilityByBucket,
  ECE, and bandFor(). bandFor NEVER returns 'auto' without a fitted calibrator
  (no silent booking on an unproven score) and never auto-books above an amount
  cap. 12 engine tests (overconfidence pulled down, underconfidence lifted,
  monotonicity, ECE, band gating).
- Measurement loop: migration categorize_calibration_samples (append-only,
  company-scoped RLS, confidence CHECK [0,1]) + POST /api/agent/categorize/
  outcome logging one sample (proposed vs actually booked) fire-and-forget from
  QuickReviewDialog on a successful book (sandbox skipped). AiCategorizeProposal
  surfaces the proposal metadata via onProposal.
- scripts/fit-categorize-calibration.ts (read-only): prints the reliability
  diagram + ECE + fitted calibrator once data has accumulated.

Fitting needs a few hundred real outcomes, so nothing calibrates today — the
loop starts collecting, and "säker" stays uncalibrated (no auto-book) until the
data proves it. 131 unit tests green; RLS covered by a pg-real test.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 15:55:26 +02:00

105 lines
4.0 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
reliabilityByBucket,
expectedCalibrationError,
fitIsotonic,
calibrate,
bandFor,
type Sample,
} from '../calibration'
// Build N samples at a given confidence with a given true accuracy.
function samplesAt(confidence: number, accuracy: number, n: number): Sample[] {
const correct = Math.round(accuracy * n)
return Array.from({ length: n }, (_, i) => ({ confidence, correct: i < correct }))
}
describe('reliabilityByBucket', () => {
it('reports empirical accuracy per confidence bucket', () => {
const samples = [...samplesAt(0.95, 0.7, 100), ...samplesAt(0.55, 0.5, 100)]
const buckets = reliabilityByBucket(samples, 10)
const high = buckets.find((b) => b.lo === 0.9)!
const mid = buckets.find((b) => b.lo === 0.5)!
expect(high.n).toBe(100)
expect(high.accuracy).toBeCloseTo(0.7, 2)
expect(mid.accuracy).toBeCloseTo(0.5, 2)
})
})
describe('expectedCalibrationError', () => {
it('is ~0 for a well-calibrated corpus', () => {
const samples = [...samplesAt(0.9, 0.9, 100), ...samplesAt(0.5, 0.5, 100)]
expect(expectedCalibrationError(samples)).toBeLessThan(0.02)
})
it('is large for an overconfident corpus', () => {
// Model says 0.95 but is only right 60% of the time.
const samples = samplesAt(0.95, 0.6, 200)
expect(expectedCalibrationError(samples)).toBeGreaterThan(0.3)
})
})
describe('fitIsotonic', () => {
it('returns null below the minimum sample count (stay uncalibrated)', () => {
expect(fitIsotonic(samplesAt(0.9, 0.9, 50), 200)).toBeNull()
})
it('learns to pull an overconfident score down', () => {
// 0.95 raw but only 60% correct → calibrated should be ~0.6, not ~0.95.
const samples = [...samplesAt(0.95, 0.6, 300), ...samplesAt(0.5, 0.5, 300)]
const cal = fitIsotonic(samples, 200)!
expect(cal.fittedOn).toBe(600)
expect(calibrate(0.95, cal)).toBeLessThan(0.7)
expect(calibrate(0.95, cal)).toBeGreaterThan(0.5)
})
it('is monotonic non-decreasing (PAV guarantee)', () => {
// Deliberately non-monotonic raw→accuracy; PAV must pool the violation.
const samples = [
...samplesAt(0.4, 0.8, 200), // low conf but high accuracy
...samplesAt(0.7, 0.5, 200), // higher conf but lower accuracy (violation)
...samplesAt(0.9, 0.9, 200),
]
const cal = fitIsotonic(samples, 200)!
const grid = [0.3, 0.5, 0.7, 0.9]
const vals = grid.map((r) => calibrate(r, cal))
for (let i = 1; i < vals.length; i++) {
expect(vals[i]).toBeGreaterThanOrEqual(vals[i - 1])
}
})
it('lifts an underconfident score up', () => {
// 0.55 raw but 90% correct → calibrated should be well above 0.55.
const samples = [...samplesAt(0.55, 0.9, 300), ...samplesAt(0.2, 0.2, 300)]
const cal = fitIsotonic(samples, 200)!
expect(calibrate(0.55, cal)).toBeGreaterThan(0.8)
})
})
describe('bandFor', () => {
it('never auto-books without a calibrator, even at high raw confidence', () => {
expect(bandFor(0.99, null, { amount: 100 })).toBe('suggest')
expect(bandFor(0.6, null)).toBe('review')
})
it('auto-books a small amount at high calibrated probability with a calibrator', () => {
const cal = fitIsotonic(samplesAt(0.96, 0.99, 400), 200)!
expect(bandFor(0.96, cal, { amount: 499 })).toBe('auto')
})
it('never auto-books a large amount, however confident', () => {
const cal = fitIsotonic(samplesAt(0.96, 0.99, 400), 200)!
expect(bandFor(0.96, cal, { amount: 50000 })).toBe('suggest')
})
it('drops to review when the calibrated probability is low even if raw was high', () => {
// raw 0.95 but the calibrator learned it is really ~0.6 → not auto, not even suggest at 0.7.
const cal = fitIsotonic([...samplesAt(0.95, 0.6, 300), ...samplesAt(0.5, 0.5, 300)], 200)!
expect(bandFor(0.95, cal, { amount: 100 })).toBe('review')
})
it('honours custom thresholds', () => {
const cal = fitIsotonic(samplesAt(0.8, 0.85, 400), 200)!
expect(bandFor(0.8, cal, { amount: 100, autoThreshold: 0.8 })).toBe('auto')
})
})