Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Original file line number Diff line number Diff line change
@@ -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);
});
});
97 changes: 97 additions & 0 deletions apps/api/src/modules/appeal-decisions/score-calibration.service.ts
Original file line number Diff line number Diff line change
@@ -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<CalibrationPolicy> = {}): 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";
}
}
47 changes: 47 additions & 0 deletions docs/ai-206-score-calibration.md
Original file line number Diff line number Diff line change
@@ -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<CalibrationPolicy>` — 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`
19 changes: 19 additions & 0 deletions packages/api-contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down