mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Register four new derived metrics for the additive HealthKit signals: - WRIST_TEMPERATURE_BASELINE, STAIR_ASCENT_SPEED_BASELINE, STAIR_DESCENT_SPEED_BASELINE — any-user-baseline personal bands reusing the type-generic median +/- k*MAD engine via a new BASELINE_CAPABLE_TYPES allowlist. Wrist temperature is a personal-deviation band only (no illness or cycle inference); stair speeds are personal trend bands (no population cutoff, geometry-confounded). - SIX_MINUTE_WALK_BAND — passthrough re-frame placing the device-estimated distance against the Enright & Sherrill 1998 predicted 6MWD (ATS 2002 test standard), as percent-of-predicted with a green/yellow/red band; band null when age/height/weight/sex are incomplete. Fall count and the breathing-disturbance index stay trend-only by design, documented in the registry: fall count is a zero-inflated discrete safety event, the breathing index is a regulated screening signal where a derived band would imply a diagnosis. Note wrist temperature as a deferred correlation outcome channel held pending a privacy review (cycle-phase inference risk), mirroring the medication-compliance omission note. Add provenance + standards entries, method/caveat copy across all six locales, and tests for the engine routing, the Enright equation, and the percent-of-predicted band.
261 lines
9.4 KiB
TypeScript
261 lines
9.4 KiB
TypeScript
/**
|
||
* v1.10.0 — FDR-controlled correlation discovery engine.
|
||
*
|
||
* Promotes the legacy three-fixed-hypothesis `correlations.ts` to a bounded
|
||
* ALL-PAIRS discovery over a curated behaviour × outcome matrix, lagged
|
||
* (a behaviour on day D vs an outcome on day D+1), Benjamini-Hochberg
|
||
* FDR-controlled on top of the existing exact Student-t / n ≥ 20 / p < 0.05
|
||
* gates. Only statistically-defensible pairs surface, each with an honest
|
||
* "descriptive, not causal" caveat.
|
||
*
|
||
* Why FDR: scanning many pairs inflates the family-wise false-discovery
|
||
* rate — at p < 0.05 alone, 1-in-20 noise pairs surface as "significant".
|
||
* Benjamini & Hochberg (1995), J. R. Stat. Soc. B 57(1):289–300 controls
|
||
* the expected proportion of false discoveries among the surfaced pairs to
|
||
* `FDR_Q` (default 0.10). A pair surfaces only when its rank-adjusted q
|
||
* clears the threshold — so the card count stays honest as the matrix grows.
|
||
*
|
||
* Lagging: a behaviour (today's daylight, mood, glucose, BP) is paired with
|
||
* the NEXT day's outcome (sleep, HRV, resting HR, weight). The lag encodes
|
||
* the plausible direction ("more daylight today → better sleep tonight")
|
||
* without claiming causation — the caveat is displayed verbatim.
|
||
*
|
||
* Pearson + the exact incomplete-beta p-value are reused from
|
||
* `src/lib/insights/correlations.ts` (its docstring explicitly anticipates
|
||
* this auto-discovery widening the pair grid). This module is PURE over
|
||
* already-fetched daily series — the DB read lives in the route.
|
||
*
|
||
* Framing discipline: descriptive, never causal; every pair carries n, r,
|
||
* p, and the BH-adjusted q; medication compliance is NOT yet a behaviour
|
||
* channel (the cadence-aware per-day rate needs the compliance engine — a
|
||
* later wave), documented here so the omission is intentional, not a gap.
|
||
*/
|
||
import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations";
|
||
|
||
/** Default Benjamini-Hochberg target false-discovery rate. */
|
||
export const FDR_Q = 0.1;
|
||
|
||
/** A single day's value for one metric (daily mean / sum). */
|
||
export interface DailySeriesPoint {
|
||
/** Day key YYYY-MM-DD. */
|
||
day: string;
|
||
value: number;
|
||
}
|
||
|
||
/** A named daily series feeding the discovery matrix. */
|
||
export interface NamedSeries {
|
||
/** Stable channel key (e.g. "TIME_IN_DAYLIGHT", "MOOD"). */
|
||
key: string;
|
||
/** Whether the channel is a behaviour (lag source) or an outcome (lag target). */
|
||
role: "behaviour" | "outcome";
|
||
points: DailySeriesPoint[];
|
||
}
|
||
|
||
/** One discovered, FDR-surviving correlation pair. */
|
||
export interface DiscoveredCorrelation {
|
||
behaviour: string;
|
||
outcome: string;
|
||
/** Paired-day count after the day+1 lag join. */
|
||
n: number;
|
||
/** Pearson r (lag-joined). */
|
||
r: number;
|
||
/** Two-sided exact Student-t p-value. */
|
||
pValue: number;
|
||
/** Benjamini-Hochberg adjusted q-value. */
|
||
qValue: number;
|
||
/** Conservative, descriptive interpretation — never causal. */
|
||
interpretation: string;
|
||
/** Lag in days applied (always 1 here). */
|
||
lagDays: number;
|
||
}
|
||
|
||
export interface CorrelationDiscoveryResult {
|
||
/** Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control. */
|
||
discovered: DiscoveredCorrelation[];
|
||
/** How many behaviour × outcome pairs were tested (for the honest footer). */
|
||
pairsTested: number;
|
||
/** The FDR target the surface used. */
|
||
fdrQ: number;
|
||
/** Minimum paired-day count enforced per pair. */
|
||
minPairs: number;
|
||
}
|
||
|
||
/** Add `lagDays` to a YYYY-MM-DD day key, returning the shifted key. */
|
||
function shiftDay(day: string, lagDays: number): string {
|
||
const [y, m, d] = day.split("-").map(Number);
|
||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||
dt.setUTCDate(dt.getUTCDate() + lagDays);
|
||
return dt.toISOString().slice(0, 10);
|
||
}
|
||
|
||
/**
|
||
* Lag-join a behaviour series (day D) against an outcome series (day D+lag).
|
||
* Returns the paired (behaviour, outcome) value arrays. Pure.
|
||
*/
|
||
export function lagJoin(
|
||
behaviour: DailySeriesPoint[],
|
||
outcome: DailySeriesPoint[],
|
||
lagDays: number,
|
||
): { xs: number[]; ys: number[] } {
|
||
const outcomeByDay = new Map(outcome.map((p) => [p.day, p.value]));
|
||
const xs: number[] = [];
|
||
const ys: number[] = [];
|
||
for (const b of behaviour) {
|
||
const target = outcomeByDay.get(shiftDay(b.day, lagDays));
|
||
if (target != null && Number.isFinite(b.value) && Number.isFinite(target)) {
|
||
xs.push(b.value);
|
||
ys.push(target);
|
||
}
|
||
}
|
||
return { xs, ys };
|
||
}
|
||
|
||
interface RawPair {
|
||
behaviour: string;
|
||
outcome: string;
|
||
n: number;
|
||
r: number;
|
||
pValue: number;
|
||
}
|
||
|
||
/**
|
||
* Apply the Benjamini-Hochberg step-up procedure to a set of p-values.
|
||
* Returns the BH-adjusted q-value per input index (monotone-enforced from
|
||
* the largest rank down), so a caller can surface pairs whose q ≤ `fdrQ`.
|
||
* Pure. `m` is the number of tests (the full family), which can exceed
|
||
* `pValues.length` if some pairs were dropped before ranking — but here we
|
||
* pass every tested pair, so `m === pValues.length`.
|
||
*/
|
||
export function benjaminiHochberg(
|
||
pValues: number[],
|
||
m: number = pValues.length,
|
||
): number[] {
|
||
const indexed = pValues.map((p, i) => ({ p, i }));
|
||
indexed.sort((a, b) => a.p - b.p);
|
||
const q = new Array<number>(pValues.length);
|
||
// Walk from the largest p (rank m) down, enforcing monotonicity so a
|
||
// smaller p never gets a larger q than a larger p.
|
||
let prev = 1;
|
||
for (let rank = indexed.length; rank >= 1; rank--) {
|
||
const { p, i } = indexed[rank - 1];
|
||
const raw = (p * m) / rank;
|
||
prev = Math.min(prev, raw);
|
||
q[i] = Math.min(1, prev);
|
||
}
|
||
return q;
|
||
}
|
||
|
||
/**
|
||
* Build a conservative, descriptive interpretation for a surviving pair.
|
||
* Sign of `r` flips the phrasing; never claims causation.
|
||
*/
|
||
function interpret(behaviour: string, outcome: string, r: number): string {
|
||
const b = humanise(behaviour);
|
||
const o = humanise(outcome);
|
||
if (r <= -0.1) {
|
||
return `Higher ${b} tends to go with lower next-day ${o} in your data — a pattern worth watching, not a cause.`;
|
||
}
|
||
if (r >= 0.1) {
|
||
return `Higher ${b} tends to go with higher next-day ${o} in your data — a pattern worth watching, not a cause.`;
|
||
}
|
||
return `${b} and next-day ${o} move together only weakly in this window.`;
|
||
}
|
||
|
||
/** Lower-case, space-separated label from a channel key. */
|
||
function humanise(key: string): string {
|
||
return key.replace(/_/g, " ").toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* Run the FDR-controlled discovery over the behaviour × outcome matrix.
|
||
*
|
||
* 1. For every (behaviour, outcome) pair, lag-join (D → D+1) and run
|
||
* Pearson with the exact p-value. Pairs below `minPairs` paired days
|
||
* are dropped (not tested — they cannot be defensibly assessed).
|
||
* 2. Across the tested pairs, compute BH q-values.
|
||
* 3. Surface pairs with p < 0.05 AND q ≤ `fdrQ`, ranked by q then |r|.
|
||
*
|
||
* Pure — the caller fetches the daily series.
|
||
*/
|
||
export function discoverCorrelations(
|
||
series: NamedSeries[],
|
||
opts: { lagDays?: number; minPairs?: number; fdrQ?: number } = {},
|
||
): CorrelationDiscoveryResult {
|
||
const lagDays = opts.lagDays ?? 1;
|
||
const minPairs = opts.minPairs ?? MIN_PAIRED_N;
|
||
const fdrQ = opts.fdrQ ?? FDR_Q;
|
||
|
||
const behaviours = series.filter((s) => s.role === "behaviour");
|
||
const outcomes = series.filter((s) => s.role === "outcome");
|
||
|
||
const tested: RawPair[] = [];
|
||
for (const b of behaviours) {
|
||
for (const o of outcomes) {
|
||
if (b.key === o.key) continue;
|
||
const { xs, ys } = lagJoin(b.points, o.points, lagDays);
|
||
if (xs.length < minPairs) continue;
|
||
const result = pearson({ xs, ys, minPairs });
|
||
if (result.status !== "ok") continue;
|
||
tested.push({
|
||
behaviour: b.key,
|
||
outcome: o.key,
|
||
n: result.n,
|
||
r: result.r,
|
||
pValue: result.pValue,
|
||
});
|
||
}
|
||
}
|
||
|
||
const pairsTested = tested.length;
|
||
if (pairsTested === 0) {
|
||
return { discovered: [], pairsTested: 0, fdrQ, minPairs };
|
||
}
|
||
|
||
const qValues = benjaminiHochberg(tested.map((t) => t.pValue));
|
||
const discovered: DiscoveredCorrelation[] = tested
|
||
.map((t, i) => ({ ...t, qValue: qValues[i] }))
|
||
.filter((t) => t.pValue < 0.05 && t.qValue <= fdrQ)
|
||
.map((t) => ({
|
||
behaviour: t.behaviour,
|
||
outcome: t.outcome,
|
||
n: t.n,
|
||
r: t.r,
|
||
pValue: t.pValue,
|
||
qValue: Math.round(t.qValue * 1000) / 1000,
|
||
interpretation: interpret(t.behaviour, t.outcome, t.r),
|
||
lagDays,
|
||
}))
|
||
.sort((a, b) => a.qValue - b.qValue || Math.abs(b.r) - Math.abs(a.r));
|
||
|
||
return { discovered, pairsTested, fdrQ, minPairs };
|
||
}
|
||
|
||
/**
|
||
* The curated discovery matrix — the channels the engine pairs. Behaviours
|
||
* (lag sources) on the left, outcomes (lag targets) on the right. Medication
|
||
* compliance is deliberately NOT yet a behaviour channel — its cadence-aware
|
||
* per-day rate needs the compliance engine; folding it in is a later wave.
|
||
*/
|
||
export const DISCOVERY_BEHAVIOURS = [
|
||
"TIME_IN_DAYLIGHT",
|
||
"MOOD",
|
||
"BLOOD_GLUCOSE",
|
||
"BLOOD_PRESSURE_SYS",
|
||
"ACTIVITY_STEPS",
|
||
] as const;
|
||
|
||
export const DISCOVERY_OUTCOMES = [
|
||
"SLEEP_DURATION",
|
||
"HEART_RATE_VARIABILITY",
|
||
"RESTING_HEART_RATE",
|
||
"WEIGHT",
|
||
// Wrist temperature is a credible future OUTCOME channel (near-daily, so
|
||
// n ≥ 20 is reachable; "did a hard workout / late alcohol raise next-night
|
||
// temperature?"), but it is deliberately NOT a channel yet: it is
|
||
// privacy-sensitive and deviation-framed, and folding it into the FDR
|
||
// matrix risks surfacing a cycle-phase correlation that strays into
|
||
// reproductive inference. Held pending a deliberate privacy review —
|
||
// documented here so the omission is intentional, not a gap (same posture
|
||
// as the medication-compliance omission above).
|
||
] as const;
|