Files
HealthLog/src/lib/analytics/correlations.ts
T
Marc-André Bombeck a827fe2ca7 fix(insights): correct medication compliance and per-user insight bucketing
A cluster of related correctness gaps in the compliance denominator and the
insight surfaces that read it:

- Paused medications. A doctor-ordered pause flipped `active` off and stamped
  `pausedAt`, but resume nulled `pausedAt`, so the interval was irrecoverable
  and every paused day became a missed dose after resume — collapsing
  adherence across the Health Score, the BP gate, the Coach snapshot and the
  report. Persist pause intervals as durable eras (new `MedicationPauseEra`,
  migration 0211), close the open era on resume, thread the eras through the
  compliance context and drop slots inside a paused interval from the
  denominator. Every compliance call site now fetches and passes the eras.

- Health Score eras. The Health-Score fast path was the only compliance
  caller that skipped the shared schedule select and schedule revisions, so
  after a schedule edit it scored past days against the current schedule
  while every other surface used the historical era. Switch it to the shared
  select and thread revisions (and pause eras) like the canonical path.

- Insight bucketing timezone. The per-metric status series bucketed every
  user in Europe/Berlin, so a near-midnight reading landed in the wrong day
  and the relative-day phrasing was off for non-Berlin self-hosters. Thread
  the resolved user timezone and key off `userDayKey`.

- Correlation significance. User-facing correlations surfaced from five
  paired points with strength read straight off the coefficient. Route them
  through the rigorous engine (at least twenty pairs, Student-t p below 0.05)
  so small-N coincidences no longer render as confident findings.

- Resting-pulse input. The mood-versus-"resting pulse" correlation fed the
  raw PULSE day-mean, which folds in workout HR, under a "Resting pulse"
  label. Feed `resolveRestingPulseSeries` (clean RESTING_HEART_RATE, else a
  daily low-percentile proxy) so the label matches the input.
2026-06-28 03:28:37 +02:00

154 lines
4.8 KiB
TypeScript

/**
* Correlation analysis between health metrics.
* Pure functions — no DB access.
*/
import type { DataPoint } from "./trends";
import { pearson, MAX_P_VALUE } from "@/lib/insights/correlations";
export interface PairedPoint {
a: number;
b: number;
date: Date;
}
export interface CorrelationResult {
r: number;
strength: "stark" | "moderat" | "schwach" | "keine";
n: number;
}
/**
* Pair two time-series by matching timestamps within a maximum gap.
* Each point is used at most once (greedy nearest-match).
*
* NOTE: This is a greedy heuristic, not the bipartite-minimum-weight
* optimum. For sparse health data the difference is negligible — well
* under 1% in the v1.4 charts auditor's stress sample — but adversarial
* inputs (alternating 1ms-apart points) can produce sub-optimal
* pairings. If exact pairing matters, swap in a Hungarian-style match.
*/
export function pairByTimestamp(
seriesA: DataPoint[],
seriesB: DataPoint[],
maxGapMs: number = 24 * 60 * 60 * 1000, // default 1 day
): PairedPoint[] {
if (seriesA.length === 0 || seriesB.length === 0) return [];
const sortedA = [...seriesA].sort(
(a, b) => a.date.getTime() - b.date.getTime(),
);
const sortedB = [...seriesB].sort(
(a, b) => a.date.getTime() - b.date.getTime(),
);
const usedB = new Set<number>();
const pairs: PairedPoint[] = [];
for (const a of sortedA) {
let bestIdx = -1;
let bestGap = Infinity;
for (let i = 0; i < sortedB.length; i++) {
if (usedB.has(i)) continue;
const gap = Math.abs(a.date.getTime() - sortedB[i].date.getTime());
if (gap <= maxGapMs && gap < bestGap) {
bestGap = gap;
bestIdx = i;
}
}
if (bestIdx >= 0) {
usedB.add(bestIdx);
pairs.push({
a: a.value,
b: sortedB[bestIdx].value,
date: a.date,
});
}
}
return pairs.sort((a, b) => a.date.getTime() - b.date.getTime());
}
/**
* Pearson correlation coefficient.
* Returns null if fewer than minPairs data points.
*/
export function pearsonCorrelation(
pairs: PairedPoint[],
minPairs: number = 5,
): CorrelationResult | null {
if (pairs.length < minPairs) return null;
const n = pairs.length;
const sumA = pairs.reduce((s, p) => s + p.a, 0);
const sumB = pairs.reduce((s, p) => s + p.b, 0);
const sumAB = pairs.reduce((s, p) => s + p.a * p.b, 0);
const sumAA = pairs.reduce((s, p) => s + p.a * p.a, 0);
const sumBB = pairs.reduce((s, p) => s + p.b * p.b, 0);
const denominator = Math.sqrt(
(n * sumAA - sumA * sumA) * (n * sumBB - sumB * sumB),
);
if (denominator === 0) return { r: 0, strength: "keine", n };
const r = Math.round(((n * sumAB - sumA * sumB) / denominator) * 1000) / 1000;
const absR = Math.abs(r);
let strength: CorrelationResult["strength"];
if (absR >= 0.7) strength = "stark";
else if (absR >= 0.4) strength = "moderat";
else if (absR >= 0.2) strength = "schwach";
else strength = "keine";
return { r, strength, n };
}
/**
* v1.2.5 (M-CS3) — significance-gated Pearson for USER-FACING surfaces.
*
* The plain `pearsonCorrelation` above assigns a strength band from |r|
* alone with no significance test and a default floor of only 5 pairs —
* so a 5-point r ≈ 0.7 fluke surfaces as a "stark" correlation. Every
* surfaced insight-card / status-snapshot correlation must instead clear
* the same rigorous bar the `/insights` correlation engine enforces:
*
* - n >= 20 paired points (the engine's `MIN_PAIRED_N`), AND
* - a two-sided Student-t p-value < 0.05 (`MAX_P_VALUE`).
*
* Below either bar this returns `null` so the caller suppresses the
* correlation exactly as it already does for the < minPairs case — no
* call-site shape change. The returned `{ r, strength, n }` keeps the
* legacy shape (German strength band) so the snapshot byte-shape is
* unchanged on the happy path.
*
* This routes through the engine's `pearson` (exact regularised-
* incomplete-beta p-value) rather than re-deriving the t-test, so the
* surface decision shares ONE definition with the dedicated cards.
*/
export function significantPearsonCorrelation(
pairs: PairedPoint[],
): CorrelationResult | null {
const result = pearson({
xs: pairs.map((p) => p.a),
ys: pairs.map((p) => p.b),
});
// `pearson` defaults `minPairs` to MIN_PAIRED_N (20); below it (or with
// zero variance) it reports `insufficient` and we suppress the card.
if (result.status !== "ok") return null;
// Significance gate — a non-significant coefficient never surfaces.
if (result.pValue >= MAX_P_VALUE) return null;
const r = Math.round(result.r * 1000) / 1000;
const absR = Math.abs(r);
let strength: CorrelationResult["strength"];
if (absR >= 0.7) strength = "stark";
else if (absR >= 0.4) strength = "moderat";
else if (absR >= 0.2) strength = "schwach";
else strength = "keine";
return { r, strength, n: result.n };
}