From 1e0293399ea3ada807014d71bcaa5671fa5a0097 Mon Sep 17 00:00:00 2001 From: Khadija Date: Wed, 24 Jun 2026 11:49:32 +0000 Subject: [PATCH] feat(ai-206): add policy-aware score calibration for AI appeal outputs - ScoreCalibrationService decouples raw model confidence from policy thresholds - CalibrationPolicy exposes approveThreshold, rejectThreshold, humanReviewThreshold, biasCorrectionFactor - needsHumanReview=true whenever confidence < humanReviewThreshold (explicit review boundary) - appliedPolicy + rawScore preserved in every output for audit/replay - Corresponding types exported from @devconsole/api-contracts - Spec covers approve/escalate/reject bands, bias correction, clamping, and traceability Closes #423 --- .../appeal-decisions.module.ts | 5 +- .../score-calibration.service.spec.ts | 62 ++++++++++++ .../score-calibration.service.ts | 97 +++++++++++++++++++ docs/ai-206-score-calibration.md | 47 +++++++++ packages/api-contracts/src/index.ts | 19 ++++ 5 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/modules/appeal-decisions/score-calibration.service.spec.ts create mode 100644 apps/api/src/modules/appeal-decisions/score-calibration.service.ts create mode 100644 docs/ai-206-score-calibration.md diff --git a/apps/api/src/modules/appeal-decisions/appeal-decisions.module.ts b/apps/api/src/modules/appeal-decisions/appeal-decisions.module.ts index 521ab22..65dd17e 100644 --- a/apps/api/src/modules/appeal-decisions/appeal-decisions.module.ts +++ b/apps/api/src/modules/appeal-decisions/appeal-decisions.module.ts @@ -4,10 +4,11 @@ import { AuditService } from "../../lib/audit.service.js"; import { AppealDecisionsController } from "./appeal-decisions.controller.js"; import { AppealDecisionsService } from "./appeal-decisions.service.js"; import { AppealDecisionsRepository } from "./appeal-decisions.repository.js"; +import { ScoreCalibrationService } from "./score-calibration.service.js"; @Module({ controllers: [AppealDecisionsController], - providers: [AppealDecisionsService, AppealDecisionsRepository, PrismaService, AuditService], - exports: [AppealDecisionsService], + providers: [AppealDecisionsService, AppealDecisionsRepository, PrismaService, AuditService, ScoreCalibrationService], + exports: [AppealDecisionsService, ScoreCalibrationService], }) export class AppealDecisionsModule {} diff --git a/apps/api/src/modules/appeal-decisions/score-calibration.service.spec.ts b/apps/api/src/modules/appeal-decisions/score-calibration.service.spec.ts new file mode 100644 index 0000000..16ee9d3 --- /dev/null +++ b/apps/api/src/modules/appeal-decisions/score-calibration.service.spec.ts @@ -0,0 +1,62 @@ +import { ScoreCalibrationService, DEFAULT_CALIBRATION_POLICY } from "./score-calibration.service.js"; + +describe("ScoreCalibrationService", () => { + let svc: ScoreCalibrationService; + + beforeEach(() => { + svc = new ScoreCalibrationService(); + }); + + it("auto-approves high-confidence scores", () => { + const result = svc.calibrate(0.9); + expect(result.band).toBe("auto_approve"); + expect(result.action).toBe("approve"); + expect(result.needsHumanReview).toBe(false); + }); + + it("escalates mid-range scores and requires human review below threshold", () => { + const result = svc.calibrate(0.5); + expect(result.band).toBe("review"); + expect(result.action).toBe("escalate"); + expect(result.needsHumanReview).toBe(true); + }); + + it("auto-rejects low-confidence scores", () => { + const result = svc.calibrate(0.1); + expect(result.band).toBe("auto_reject"); + expect(result.action).toBe("reject"); + expect(result.needsHumanReview).toBe(true); + }); + + it("applies bias correction factor to shift scores upward", () => { + // raw 0.7 * factor 1.2 = 0.84 → auto_approve + const result = svc.calibrate(0.7, { biasCorrectionFactor: 1.2 }); + expect(result.band).toBe("auto_approve"); + expect(result.confidence).toBeCloseTo(0.84); + }); + + it("clamps corrected confidence to [0, 1]", () => { + const result = svc.calibrate(0.95, { biasCorrectionFactor: 2.0 }); + expect(result.confidence).toBe(1); + }); + + it("preserves rawScore in output for audit trail", () => { + const result = svc.calibrate(0.65, { biasCorrectionFactor: 1.1 }); + expect(result.rawScore).toBe(0.65); + }); + + it("includes the resolved policy in output for traceability", () => { + const result = svc.calibrate(0.5); + expect(result.appliedPolicy).toMatchObject(DEFAULT_CALIBRATION_POLICY); + }); + + it("respects custom thresholds", () => { + const result = svc.calibrate(0.6, { approveThreshold: 0.55 }); + expect(result.band).toBe("auto_approve"); + }); + + it("marks high-confidence mid-range score as not needing human review when threshold is low", () => { + const result = svc.calibrate(0.75, { humanReviewThreshold: 0.5 }); + expect(result.needsHumanReview).toBe(false); + }); +}); diff --git a/apps/api/src/modules/appeal-decisions/score-calibration.service.ts b/apps/api/src/modules/appeal-decisions/score-calibration.service.ts new file mode 100644 index 0000000..8527396 --- /dev/null +++ b/apps/api/src/modules/appeal-decisions/score-calibration.service.ts @@ -0,0 +1,97 @@ +/** + * AI-206: Policy-aware score calibration for AI appeal outputs. + * + * Separates raw model confidence (0–1) from policy thresholds so fairness, + * review timing, and risk tolerances can be tuned independently of the model. + * + * Explicit inputs → calibrate(rawScore, policy) + * Explicit outputs → CalibratedScore (band, action, confidence, needsHumanReview) + * Review boundary → needsHumanReview=true whenever confidence is below the + * policy's humanReviewThreshold + */ + +import { Injectable } from "@nestjs/common"; + +export type PolicyBand = "auto_approve" | "review" | "auto_reject"; + +export interface CalibrationPolicy { + /** Raw model score above which we auto-approve (default 0.80). */ + approveThreshold: number; + /** Raw model score below which we auto-reject (default 0.25). */ + rejectThreshold: number; + /** + * Calibrated confidence below which a human reviewer must inspect the case. + * Expressed as a ratio of the output confidence (default 0.70). + */ + humanReviewThreshold: number; + /** + * Optional bias-correction factor applied before band assignment. + * Values > 1 shift scores upward (lenient); < 1 shift downward (strict). + * Default 1.0 (no correction). + */ + biasCorrectionFactor?: number; +} + +export interface CalibratedScore { + /** Policy band derived from the calibrated score. */ + band: PolicyBand; + /** Recommended action for the appeal pipeline. */ + action: "approve" | "escalate" | "reject"; + /** Calibrated confidence in 0–1 range after bias correction. */ + confidence: number; + /** True when the system confidence is below the policy review threshold. */ + needsHumanReview: boolean; + /** The raw model score that was passed in (preserved for audit). */ + rawScore: number; + /** The policy snapshot used for this calibration (for traceability). */ + appliedPolicy: CalibrationPolicy; +} + +export const DEFAULT_CALIBRATION_POLICY: CalibrationPolicy = { + approveThreshold: 0.8, + rejectThreshold: 0.25, + humanReviewThreshold: 0.7, + biasCorrectionFactor: 1.0, +}; + +@Injectable() +export class ScoreCalibrationService { + /** + * Calibrate a raw model score against the given policy. + * + * @param rawScore Model output in [0, 1]. + * @param policy Policy thresholds; falls back to DEFAULT_CALIBRATION_POLICY. + */ + calibrate(rawScore: number, policy: Partial = {}): CalibratedScore { + const resolved: CalibrationPolicy = { ...DEFAULT_CALIBRATION_POLICY, ...policy }; + const factor = resolved.biasCorrectionFactor ?? 1.0; + + // Clamp raw score to [0, 1] then apply bias correction (clamp result too). + const confidence = Math.min(1, Math.max(0, rawScore * factor)); + + const band = this.toBand(confidence, resolved); + const action = this.toAction(band); + const needsHumanReview = confidence < resolved.humanReviewThreshold; + + return { + band, + action, + confidence, + needsHumanReview, + rawScore, + appliedPolicy: resolved, + }; + } + + private toBand(score: number, policy: CalibrationPolicy): PolicyBand { + if (score >= policy.approveThreshold) return "auto_approve"; + if (score <= policy.rejectThreshold) return "auto_reject"; + return "review"; + } + + private toAction(band: PolicyBand): CalibratedScore["action"] { + if (band === "auto_approve") return "approve"; + if (band === "auto_reject") return "reject"; + return "escalate"; + } +} diff --git a/docs/ai-206-score-calibration.md b/docs/ai-206-score-calibration.md new file mode 100644 index 0000000..9f955d0 --- /dev/null +++ b/docs/ai-206-score-calibration.md @@ -0,0 +1,47 @@ +# AI-206: Policy-Aware Score Calibration for AI Appeal Outputs + +## Overview + +`ScoreCalibrationService` decouples raw model confidence (a float in `[0, 1]`) +from the policy thresholds that govern what happens next. This means fairness +parameters, review timing, and risk tolerances can be adjusted without touching +the model or rewriting the AI pipeline. + +## Explicit Inputs / Outputs + +| Direction | What it is | +|-----------|-----------| +| **Input** | `rawScore: number` — model confidence in `[0, 1]` | +| **Input** | `policy: Partial` — operator-tunable thresholds | +| **Output** | `CalibratedScore.band` — `auto_approve \| review \| auto_reject` | +| **Output** | `CalibratedScore.action` — recommended pipeline action | +| **Output** | `CalibratedScore.needsHumanReview` — human gate flag | +| **Output** | `CalibratedScore.appliedPolicy` — policy snapshot for audit trail | + +## Policy Knobs (`CalibrationPolicy`) + +| Field | Default | Purpose | +|-------|---------|---------| +| `approveThreshold` | `0.80` | Score at or above → `auto_approve` | +| `rejectThreshold` | `0.25` | Score at or below → `auto_reject` | +| `humanReviewThreshold` | `0.70` | Confidence below this → `needsHumanReview=true` | +| `biasCorrectionFactor` | `1.0` | Multiply score before band assignment (> 1 = lenient) | + +## Review Boundary + +`needsHumanReview` is always `true` when `confidence < humanReviewThreshold`. +The caller must honour this flag before acting on `action`. The pipeline must +**not** auto-act when `needsHumanReview` is true. + +## Measurability + +Every `CalibratedScore` includes: +- `rawScore` — the unmodified model output (audit trail) +- `appliedPolicy` — the full policy snapshot used (reproducibility) + +Operators can replay any historical score against updated policy values to +measure the effect of a threshold change before promoting it to production. + +## File Location + +`apps/api/src/modules/appeal-decisions/score-calibration.service.ts` diff --git a/packages/api-contracts/src/index.ts b/packages/api-contracts/src/index.ts index ced574f..7310b3c 100644 --- a/packages/api-contracts/src/index.ts +++ b/packages/api-contracts/src/index.ts @@ -403,6 +403,25 @@ export interface AppealTimingResult { appealDeadline: string; } +// ── AI-206: Policy-aware score calibration ──────────────────────────────────── + +export type PolicyBand = "auto_approve" | "review" | "auto_reject"; + +export interface CalibrationPolicy { + approveThreshold: number; + rejectThreshold: number; + humanReviewThreshold: number; + biasCorrectionFactor?: number; +} + +export interface CalibratedScoreResponse { + band: PolicyBand; + action: "approve" | "escalate" | "reject"; + confidence: number; + needsHumanReview: boolean; + rawScore: number; +} + // ── Budget Accounting (BE-201, BE-202, BE-203, BE-204) ─────────────────────────── export type BudgetEventType =