Files
HealthLog/src/lib/ai/confidence.ts
T
Marc-André BombeckandClaude Opus 4.7 363e73cbf2 chore: voice + filename hygiene + wave-4 state wrap
Maintainer-voice sweep across `src/`. ~190 references to the
maintainer by first name in code comments and JSDoc neutralised to
"the maintainer", "the live tenant", "users", or "the user" depending
on context. Three sed passes covering ~50 distinct patterns
(`Marc reported`, `Marc wants`, `Marc kept`, `Marc rolled back`, etc.).
Test fixtures (`displayName: "Marc B."`, `username: "marc"`,
`<HeroStrip userName="Marc">`) kept as opaque test data per the brief.

CHANGELOG normalisation. The v1.4.14 + v1.4.15 entries paired a
German bullet with its English translation by historical design — the
in-app copy is English-default and the user docs are English, so the
mixed-language style was a holdover from the early-marathon notes.
A one-off node script extracted the `_..._` English paragraph from
each bilingual bullet and rewrote the entry as that text. ~44 bullets
reduced; one Docs bullet that lost a leading word in the regex
fixed by hand.

CLAUDE.md → CONTRIBUTING-AI.md rename so the filename is no longer
AI-vendor-specific. `CONTRIBUTING.md` reference updated. `AGENTS.md`
stays in place for multi-agent compatibility (the conventions doc is
shared with Codex / Cursor / etc.).

Wave-4 state wrap. STATE.md now records C1–C5 + D + D2 as complete;
v1421-backlog.md grew a "Closed in v1.4.22" header listing the 20
items the marathon landed; `.planning/phase-W4-report.md` documents
the per-commit decisions, test deltas, and the 23 items deferred to
v1.4.23 with reasoning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:46:32 +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);
}