Files
HealthLog/src/lib/ai/__tests__/confidence.test.ts
T
Marc-André BombeckandClaude Opus 4.7 0cb0373e59 feat(ai): deterministic confidence-score computation per recommendation
Each recommendation now carries an optional `confidence: 0..100`
integer in `aiRecommendationSchema`. New `src/lib/ai/confidence.ts`
exposes `computeConfidence({ n, recencyDays, deviationStdRatio })`,
the deterministic formula the wrapper will use to override whatever
number the model picks.

Score breakdown (sum, max 100):
  - n contribution: log10-saturating curve, capped at 40. n<3 hard-
    caps the whole score at 5*n (max 15) so a three-sample window
    never produces a confident-looking meter.
  - recency contribution: 30 at <2d, decays linearly to 0 at 30d.
  - signal contribution: 30 at |z|>=1.5, scaled linearly to 0 at
    z=0; null ratio (no baseline yet) returns a neutral 15.

Schema is `.optional()` so:
  - The wrapper can fill it post-validation regardless of what the
    LLM emitted — the model's calibrated probability is unreliable.
  - Legacy cached payloads from before B5d landed still parse.

Integer-only and bounded so the meter component renders without
clamping; a renegade provider that emits 101 or 67.5 fails parse and
triggers the wrapper's corrective retry.

15 fixtures cover the input matrix: n hard-cap (n=0..2), n=3 floor,
mid-range (n=14, n=20), saturation (n=100 plateau, n=10000 cap),
recency clamp (45d -> 0), null-ratio neutral, negative-z magnitude,
plus monotonicity-in-n + integer-in-bounds invariants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:40:29 +02:00

151 lines
5.0 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { computeConfidence, type ConfidenceInputs } from "../confidence";
/**
* v1.4.16 phase B5d — `computeConfidence()` deterministic scorer.
*
* The wrapper feeds three inputs:
* - n: sample count behind the cited data window
* (from `metricSource.n` when available; 0 otherwise).
* - recencyDays: days since the most recent sample in the window.
* - deviationStdRatio: |deviation| / stdev of the user's 90-day
* baseline. `null` when the baseline is too thin to compute.
*
* Score breakdown (max 100):
* - n contribution: log-saturating curve, max 40 at n=30 ish.
* n<3 hard-caps the whole score at ≤15 (5 * n).
* - recencyScore: 30 at <2d, decays linearly to 0 at 30d, floored at 0.
* - signalScore: 30 at |z|≥1.5, 0 at z=0, 15 when ratio is null.
*
* 12 fixtures cover the input matrix per research §2.C: small-n hard
* cap, mid-n + recency interplay, saturated-n + signal extremes,
* null-ratio neutral path.
*/
interface Fixture {
name: string;
inputs: ConfidenceInputs;
expected: number;
}
const FIXTURES: Fixture[] = [
// n hard-cap: n<3 always returns max(10, 5*n)
{
name: "n=0 → hard-cap 10 floor",
inputs: { n: 0, recencyDays: 0, deviationStdRatio: 2 },
expected: 10,
},
{
name: "n=1 → hard-cap 10 floor",
inputs: { n: 1, recencyDays: 0, deviationStdRatio: 2 },
expected: 10,
},
{
name: "n=2 → hard-cap 10 (5*2)",
inputs: { n: 2, recencyDays: 0, deviationStdRatio: 2 },
expected: 10,
},
// n=3: nScore = 10 + 10*log10(3) ≈ 14.77;
// recency=1 → 30 * (1 - 1/30) = 29; signal=null → 15
// total ≈ 14.77 + 29 + 15 = 58.77 → 59
{
name: "n=3 fresh sample (1d old), null ratio → 59",
inputs: { n: 3, recencyDays: 1, deviationStdRatio: null },
expected: 59,
},
// n=14, recencyDays=1, ratio=2 → research-§2C calibration target ~94
// nScore = 10 + 10*log10(14) ≈ 21.46
// recencyScore = 30 * (1 - 1/30) = 29
// signalScore = min(30, 30 * 2/1.5) = 30
// total ≈ 80.46 → 80
{
name: "n=14, fresh, strong signal → ~80",
inputs: { n: 14, recencyDays: 1, deviationStdRatio: 2 },
expected: 80,
},
// n=30, recencyDays=20, null ratio → research-§2C calibration target ~52
// nScore = min(40, 10 + 10*log10(30)) = min(40, 24.77) = 24.77
// recencyScore = max(0, 30 * (1 - 20/30)) = 10
// signalScore = 15 (null)
// total ≈ 49.77 → 50
{
name: "n=30, stale (20d), null ratio → ~50",
inputs: { n: 30, recencyDays: 20, deviationStdRatio: null },
expected: 50,
},
// n=100 → nScore = min(40, 10 + 10*log10(100)) = 30 (curve, not cap)
// recency=0 → 30, signal=z=1.5 → 30, total 90
{
name: "n=100, fresh, strong signal → 90",
inputs: { n: 100, recencyDays: 0, deviationStdRatio: 1.5 },
expected: 90,
},
// n=10000 → log10 saturates at the 40 cap → max possible total 100
{
name: "n=10000 saturates the n curve at 40 → 100",
inputs: { n: 10000, recencyDays: 0, deviationStdRatio: 2 },
expected: 100,
},
// n=10, recency=30d → recency floors at 0; ratio=0 → signal=0
// nScore = 10 + 10*log10(10) = 20; recency=0; signal=0
// total = 20
{
name: "n=10, ancient (30d), zero deviation → 20",
inputs: { n: 10, recencyDays: 30, deviationStdRatio: 0 },
expected: 20,
},
// n=10, recency=45d → recencyScore floor 0 (negative clamped)
{
name: "n=10, very ancient (45d) clamps recency to 0",
inputs: { n: 10, recencyDays: 45, deviationStdRatio: null },
expected: 35, // 20 + 0 + 15
},
// n=20, fresh, mild signal (z=0.75 → half of cap)
// nScore = 10 + 10*log10(20) ≈ 23.01
// recency=0 → 30
// signal = 30 * 0.75/1.5 = 15
// total ≈ 68.01 → 68
{
name: "n=20, fresh, mild signal → ~68",
inputs: { n: 20, recencyDays: 0, deviationStdRatio: 0.75 },
expected: 68,
},
// Negative ratio: |z|=2 still hits cap of 30
{
name: "n=14, fresh, strong negative signal (z=-2) → 80",
inputs: { n: 14, recencyDays: 1, deviationStdRatio: -2 },
expected: 80,
},
// Edge: n=2, fresh, strong signal — hard-cap still applies
{
name: "n=2 hard-cap survives even with strong signal + fresh",
inputs: { n: 2, recencyDays: 0, deviationStdRatio: 2 },
expected: 10,
},
];
describe("computeConfidence()", () => {
it.each(FIXTURES)("$name", ({ inputs, expected }) => {
expect(computeConfidence(inputs)).toBe(expected);
});
it("returns an integer in [0, 100] for all fixtures", () => {
for (const f of FIXTURES) {
const v = computeConfidence(f.inputs);
expect(Number.isInteger(v)).toBe(true);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(100);
}
});
it("is monotonic in n when other inputs fixed (more data → never lower)", () => {
const baseInputs = { recencyDays: 1, deviationStdRatio: 1 };
let prev = -Infinity;
for (const n of [5, 10, 20, 50, 100]) {
const v = computeConfidence({ ...baseInputs, n });
expect(v).toBeGreaterThanOrEqual(prev);
prev = v;
}
});
});