Files
HealthLog/src/lib/ai/confidence.ts
T
Marc-André Bombeck 9445d89b76 refactor(api,tz): one typed client for every call and one home for wall-clock math
A typed apiFetch wrapper owns the envelope unwrap, the ok-check and the
error read for every client call — two hundred call sites across the
app migrate onto it and a lint rule keeps raw api fetches out for good.
The legacy timezone helpers fold into the tz module and test fixtures
drop personal names.
2026-06-11 02:19:27 +02:00

103 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.4.16 phase B5d — deterministic confidence-score computation per
* recommendation.
*
* The maintainer's mandate: the model's self-reported confidence is unreliable
* (calibrated probabilities are not a small-LLM strength), so the
* wrapper OVERRIDES with a server-computed value derived from three
* inputs that the cited data window actually exposes:
*
* - n sample count behind the cited data window
* (`metricSource.n`).
* - recencyDays days since the most recent sample in the window;
* a 7-day rec built off data 25 days old should
* not feel as confident as the same rec built off
* data from this morning.
* - deviationStdRatio |deviation| / stdev of the user's 90-day
* baseline. `null` when the baseline is too thin
* to compute a stdev — we fall back to a neutral
* mid-band score rather than zero.
*
* Score components (sum, max 100):
*
* - n contribution: log-saturating curve, capped at 40.
* n<3 hard-caps the whole score at ≤15 (5 * n) — three samples is
* the hard floor for any quantitative claim per research §2.A
* (Apple's "be quiet when unsure" stance).
*
* - recency contribution: 30 at <2d old, decays linearly to 0 at
* 30d, floored at 0. The shape matches the Oura "Pay Attention"
* band — old data is still data, just stale.
*
* - signal contribution: 30 at |z|≥1.5 (clearly out-of-band),
* scaled linearly down to 0 at z=0. When ratio is null (no
* baseline yet), we award 15 — neither punishing the user for
* thin history nor over-claiming a strong deviation we can't
* measure.
*
* The formula is pre-feedback. v1.4.17 will fit weights to thumbs-up
* rates (B5e feedback ratchet); for v1.4.16 the formula is fixed and
* deterministic so admin observability + the "draft pill below 25"
* UI affordance are reproducible across runs.
*/
export interface ConfidenceInputs {
/** Sample count behind the cited data window (from metricSource.n). */
n: number;
/** Days since the most recent sample in the window. */
recencyDays: number;
/** |deviation| / stdev of the user's 90-day baseline, when computable. */
deviationStdRatio: number | null;
}
const HARD_CAP_FLOOR = 10;
const N_CONTRIBUTION_MAX = 40;
const RECENCY_CONTRIBUTION_MAX = 30;
const RECENCY_DECAY_DAYS = 30;
const SIGNAL_CONTRIBUTION_MAX = 30;
const SIGNAL_SATURATION_RATIO = 1.5;
const SIGNAL_NULL_NEUTRAL = 15;
/**
* Compute the deterministic confidence score (0..100) for a single
* recommendation. Pure function — no I/O, no clock; safe to call from
* test fixtures and from `generateInsight()` post-validation alike.
*/
export function computeConfidence(inputs: ConfidenceInputs): number {
const { n, recencyDays, deviationStdRatio } = inputs;
// n hard cap: <3 samples means we cannot make a quantitative claim,
// so the whole score is bounded at 5*n (max 15 at n=2). Floor at
// 10 so n=0/n=1 still produce a non-zero "draft" signal — the UI
// shows the rec but tags it as low-confidence rather than hiding it.
if (n < 3) {
return Math.max(HARD_CAP_FLOOR, 5 * n);
}
// n contribution: log10 saturates so a 100-sample window doesn't
// outweigh a 30-sample window by 3×; both are "enough data" for the
// user-level signal we care about.
const nScore = Math.min(N_CONTRIBUTION_MAX, 10 + 10 * Math.log10(n));
// Recency contribution: <2d ≈ full score (30 * (1 - 0/30) = 30,
// 30 * (1 - 1/30) = 29; both round into the meter's top band).
// 30d+ floors to 0 so an ancient window cannot inflate the score.
const recencyScore = Math.max(
0,
RECENCY_CONTRIBUTION_MAX * (1 - recencyDays / RECENCY_DECAY_DAYS),
);
// Signal contribution: null ratio (no baseline) → neutral 15.
// Otherwise scale |z| linearly to the SIGNAL_SATURATION_RATIO cap.
const signalScore =
deviationStdRatio === null
? SIGNAL_NULL_NEUTRAL
: Math.min(
SIGNAL_CONTRIBUTION_MAX,
SIGNAL_CONTRIBUTION_MAX *
(Math.abs(deviationStdRatio) / SIGNAL_SATURATION_RATIO),
);
return Math.round(nScore + recencyScore + signalScore);
}