mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
The generated-insight narrative layer built its user-facing sentences by
string interpolation and never routed through the i18n bundle, so a
non-English UI rendered English text spliced into localised copy — e.g.
"Higher activity steps tends to go with lower next-day weight" surfacing
mid-German on the Weight insight. Because those strings bypassed t(), the
locale-integrity guard could not catch them.
Route every correlation narrative through the server translator:
- Correlation discovery (daily lagged pairs + labs-vs-outcome) now takes
the reader's locale, resolved from the correlations route the same way
other insight generators resolve it. Sentence templates, the direction
word, and every channel label live under a new insights.correlation.*
namespace, translated in de/en/es/fr/it/pl. The three fixed hypotheses
(BP-compliance, mood-pulse, weight-weekday) and their weekday labels are
localised the same way; the analytics cache key now includes the locale
so a language switch cannot serve another locale's cached text.
- Metric labels resolve to stable localised channel labels; the never-
causal framing ("not a cause" / "an association, never a cause") and the
faint/moderate/high hedging tiers are preserved in every language.
English output is byte-for-byte unchanged. Adds a regression test pinning
German narration and asserting it is not the English string.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
993 lines
40 KiB
TypeScript
993 lines
40 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.
|
||
*
|
||
* v1.21.0 (FDREXTEND) — medication compliance (a daily adherence rate from the
|
||
* compliance engine's dose-history ledger) and symptom severity (the illness
|
||
* day-log functional-impact / symptom-burden track) are now first-class daily
|
||
* channels in the matrix, so the high-value "adherence dip → symptom flare" and
|
||
* "compliance↓ → a vital drifting" links can finally be discovered. They are
|
||
* the sparsest, noisiest inputs in the system — they flow through the SAME
|
||
* n ≥ 20 / p < 0.05 / BH-FDR / effect-size-floor / shrinkage gates as every
|
||
* other channel, so a thin series degrades to absent rather than to a spurious
|
||
* link. The series builders live in `correlation-series-builders.ts`.
|
||
*/
|
||
import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations";
|
||
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||
|
||
/**
|
||
* Locale-aware string builder — the same signature the server translator
|
||
* exposes. Every user-facing narration in this module routes through one of
|
||
* these so the sentence is emitted in the reader's language, not hardcoded
|
||
* English. Falls back to English (then the raw key) inside the translator.
|
||
*/
|
||
type Translate = (
|
||
key: string,
|
||
params?: Record<string, string | number>,
|
||
) => string;
|
||
|
||
/** Default Benjamini-Hochberg target false-discovery rate. */
|
||
export const FDR_Q = 0.1;
|
||
|
||
/**
|
||
* RECON1 (D2-2) — effect-size floor on the SHRUNK |r|. A pair can be
|
||
* statistically significant (p < 0.05, FDR-clean) yet explain a trivial slice
|
||
* of variance — e.g. n=180, r=0.16 explains ~2.5% and is noise-floor signal a
|
||
* human should not chase. Below this floor we DROP the pair from the discovered
|
||
* ranking entirely, so the Coach never narrates a significant-but-trivial pair
|
||
* as a confident "tends to go with" driver. The floor is applied AFTER the D4
|
||
* shrinkage (below), so a thin-data estimate is pulled toward null first.
|
||
*/
|
||
export const EFFECT_SIZE_FLOOR = 0.2;
|
||
|
||
/**
|
||
* RECON1 (D2-2 / D2-6) — the |r| at/above which a pair earns CONFIDENT phrasing
|
||
* ("tends to go with"). Between `EFFECT_SIZE_FLOOR` and this, the pair survives
|
||
* but is down-tiered to a hedged "faint hint" so the narrated confidence never
|
||
* outruns the effect.
|
||
*/
|
||
export const CONFIDENT_EFFECT_THRESHOLD = 0.3;
|
||
|
||
/**
|
||
* RECON1 (D4) — James-Stein / regression-to-the-mean shrinkage constant. A
|
||
* sparse n-of-1 Pearson estimate is noisy and over-states the effect; we pull
|
||
* it toward null by the factor n/(n+SHRINKAGE_K) before ranking + tiering, so a
|
||
* thin-data correlation cannot out-rank a deep, well-sampled one purely on an
|
||
* inflated point estimate. k=10 means a pair at the n≥20 floor keeps ~67% of
|
||
* its raw r, a 60-day pair keeps ~86%, and a 180-day pair keeps ~95% — light,
|
||
* documented, and reusing nothing more exotic than the paired-day count the
|
||
* MAD-robust baseline infra already surfaces. The shrunk r is used ONLY for
|
||
* ranking, the effect-size floor, and the phrasing tier; the reported `r` and
|
||
* `pValue` stay the honest raw statistics.
|
||
*/
|
||
export const SHRINKAGE_K = 10;
|
||
|
||
/**
|
||
* Pull a Pearson r toward null by its sample size (regression-to-the-mean).
|
||
* Pure. Larger n → less shrinkage; a thin pair is discounted toward 0.
|
||
*/
|
||
export function shrinkEstimate(r: number, n: number): number {
|
||
if (!Number.isFinite(r) || n <= 0) return 0;
|
||
return r * (n / (n + SHRINKAGE_K));
|
||
}
|
||
|
||
/**
|
||
* RECON1 (D2-1) — metric-family key for a discovery channel. Two channels in
|
||
* the same family (e.g. both blood-pressure components) lag-correlate trivially
|
||
* (serial auto-correlation of one physiological signal), so a same-family
|
||
* lagged pair is a near-tautology, not a cross-domain insight. The discovery
|
||
* loop already skips the exact self-pair (`b.key === o.key`); this widens that
|
||
* to the whole family so MOOD→MOOD-class self-lags and any future same-family
|
||
* pairing are excluded from the ranking before they can crowd out genuine
|
||
* cross-domain links. Channels with no shared family return their own key, so
|
||
* only deliberate families collapse.
|
||
*/
|
||
export function metricFamily(key: string): string {
|
||
// A RATED mood factor (`FACTOR:<name>`) is a sub-component of overall mood, so
|
||
// it shares MOOD's family — a `FACTOR:* → MOOD` lag is the same self-lag
|
||
// tautology as MOOD→MOOD and is excluded too.
|
||
if (key.startsWith(FACTOR_CHANNEL_PREFIX) || key === "MOOD") return "MOOD";
|
||
if (key.startsWith("BLOOD_PRESSURE")) return "BLOOD_PRESSURE";
|
||
// v1.25 (W-ENV) — every environmental-exposure channel (ENV_TEMP_MEAN,
|
||
// ENV_DAYLIGHT, …) shares ONE family so the same-family guard never
|
||
// lag-correlates two same-day weather fields against each other (a
|
||
// near-tautology). They stay free to pair cross-domain against mood / sleep /
|
||
// vitals — the whole point of the module.
|
||
if (key.startsWith("ENV_")) return "ENVIRONMENT";
|
||
// v1.21.0 (FDREXTEND) — medication compliance and symptom severity each form
|
||
// their OWN single-channel family, so the loop's same-family guard collapses
|
||
// only the self-lag (compliance→compliance, symptom→symptom). The returned
|
||
// key equals the channel key for both, so neither shares a family with any
|
||
// vital / sleep / mood channel — they remain free to pair cross-domain
|
||
// (the whole point: adherence↓ → next-day symptom↑, or compliance↓ → a vital
|
||
// drifting). No special-case branch is needed; the `return key` fall-through
|
||
// already isolates them. This comment documents the deliberate decision.
|
||
return key;
|
||
}
|
||
|
||
/** RECON1 (D2-6) — explicit phrasing tier a low-confidence signal must honour. */
|
||
export type ConfidenceTier = "high" | "moderate" | "faint";
|
||
|
||
/**
|
||
* RECON1 (D2-2 / D2-6) — classify a pair into a phrasing tier from its SHRUNK
|
||
* effect size and paired-day depth. The tier is a contract the narration (and
|
||
* the hallucination tests) can check, so a thin / weak signal is hedged
|
||
* differently from a deep / strong one rather than both reading "tends to go
|
||
* with" at full confidence. `null` means below the effect-size floor — the
|
||
* caller drops the pair entirely.
|
||
*/
|
||
export function confidenceTier(
|
||
shrunkR: number,
|
||
n: number,
|
||
): ConfidenceTier | null {
|
||
const mag = Math.abs(shrunkR);
|
||
if (mag < EFFECT_SIZE_FLOOR) return null;
|
||
if (mag < CONFIDENT_EFFECT_THRESHOLD) return "faint";
|
||
// A confident effect still needs depth to be narrated plainly; a strong r on
|
||
// a barely-qualifying sample is "moderate", not "high".
|
||
return n >= 60 ? "high" : "moderate";
|
||
}
|
||
|
||
/** 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;
|
||
/**
|
||
* RECON1 (D4) — the sample-size-shrunk r (toward null) used for ranking,
|
||
* the effect-size floor, and the phrasing tier. Display still uses raw `r`.
|
||
*/
|
||
shrunkR: number;
|
||
/**
|
||
* RECON1 (D2-6) — phrasing tier the narration must honour: a `faint` signal
|
||
* is hedged, a `high` signal stated plainly. Never `null` here (a below-floor
|
||
* pair is dropped before this list).
|
||
*/
|
||
tier: ConfidenceTier;
|
||
/** Conservative, descriptive interpretation — never causal, tier-hedged. */
|
||
interpretation: string;
|
||
/** Lag in days applied (always 1 here). */
|
||
lagDays: number;
|
||
/**
|
||
* v1.22 — which window surfaced the pair. `retrospective` = the standard
|
||
* 180-day scan (the established pattern). `recent` = the rolling
|
||
* early-detection pass over the trailing {@link EARLY_WINDOW_DAYS} days,
|
||
* surfaced ONLY when the pair is not already established retrospectively.
|
||
* Absent on the legacy path so existing consumers read it as the
|
||
* retrospective default.
|
||
*/
|
||
window?: "retrospective" | "recent";
|
||
/**
|
||
* v1.22 — true for a `recent`-window emerging pair: it cleared a tighter
|
||
* FDR and a no-faint effect bar over a short window, but rests on fewer days
|
||
* than the retrospective scan, so the narration must hedge it as emerging /
|
||
* provisional rather than established.
|
||
*/
|
||
provisional?: boolean;
|
||
}
|
||
|
||
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 direction; the `tier` (RECON1 D2-2 / D2-6) flips the
|
||
* CONFIDENCE of the phrasing so a faint effect reads as "a faint hint, if
|
||
* anything" rather than a confident "tends to go with". Never claims causation.
|
||
*
|
||
* The sentence is assembled from locale-keyed templates under
|
||
* `insights.correlation.daily.*` (+ a per-locale direction word) so the calm,
|
||
* never-causal framing survives verbatim in every language rather than leaking
|
||
* English into a non-English UI.
|
||
*/
|
||
function interpret(
|
||
behaviour: string,
|
||
outcome: string,
|
||
r: number,
|
||
tier: ConfidenceTier,
|
||
t: Translate,
|
||
): string {
|
||
const params = {
|
||
behaviour: humanise(behaviour, t),
|
||
outcome: humanise(outcome, t),
|
||
direction: t(
|
||
`insights.correlation.direction.${r < 0 ? "lower" : "higher"}`,
|
||
),
|
||
};
|
||
// faint = below the confident effect-size threshold (a real-but-small signal,
|
||
// hedged); high = confident "tends to go with"; moderate = the in-between
|
||
// "on the evidence so far" phrasing. Each tier keeps its own template.
|
||
const tierKey =
|
||
tier === "faint" ? "faint" : tier === "high" ? "high" : "moderate";
|
||
return t(`insights.correlation.daily.${tierKey}`, params);
|
||
}
|
||
|
||
/**
|
||
* Localised, human-readable label for a channel key.
|
||
*
|
||
* v1.14.0 — a `FACTOR:<key>` channel (a RATED mood factor folded into the
|
||
* matrix) strips its namespace prefix so the phrasing reads "rated <factor>"
|
||
* rather than leaking the raw key. The prefix exists only to keep a factor
|
||
* key from colliding with a `MeasurementType` in the channel set. The factor
|
||
* name itself is user data, kept verbatim (lower-cased) inside the localised
|
||
* `channelFactor` frame.
|
||
*
|
||
* Every other channel (measurement types, MOOD, medication compliance, symptom
|
||
* severity, and the ENV_* exposure channels) resolves through a stable
|
||
* `insights.correlation.channel.<KEY>` label, translated in all six locales.
|
||
* An unknown key falls back to its prettified form so a newly-added channel
|
||
* never blanks.
|
||
*/
|
||
function humanise(key: string, t: Translate): string {
|
||
if (key.startsWith(FACTOR_CHANNEL_PREFIX)) {
|
||
const factor = key
|
||
.slice(FACTOR_CHANNEL_PREFIX.length)
|
||
.replace(/[_-]/g, " ")
|
||
.toLowerCase();
|
||
return t("insights.correlation.channelFactor", { factor });
|
||
}
|
||
const labelKey = `insights.correlation.channel.${key}`;
|
||
const label = t(labelKey);
|
||
// The server translator echoes the key on a miss; fall back to a readable
|
||
// prettified form so an unmapped channel degrades gracefully, never English-
|
||
// leaks a raw key.
|
||
return label === labelKey ? key.replace(/_/g, " ").toLowerCase() : label;
|
||
}
|
||
|
||
/** Namespace prefix for a RATED-mood-factor discovery channel (v1.14.0). */
|
||
export const FACTOR_CHANNEL_PREFIX = "FACTOR:";
|
||
|
||
/**
|
||
* v1.21.0 (FDREXTEND) — stable channel key for the daily medication-compliance
|
||
* adherence rate (per-day taken/scheduled, 0–100). Sourced from the compliance
|
||
* engine's unified dose-history ledger, NOT a `MeasurementType`, so the caller
|
||
* builds its series separately (the way MOOD is read from MoodEntry) and folds
|
||
* it in. A BEHAVIOUR channel: the actionable, high-value direction is
|
||
* "adherence dip today → a worse outcome tomorrow" (next-day symptom flare or a
|
||
* vital drifting), so compliance lags the outcome by a day like every other
|
||
* behaviour. It forms its own `metricFamily`, so the only pair the loop skips is
|
||
* the compliance→compliance self-lag.
|
||
*/
|
||
export const MEDICATION_COMPLIANCE_CHANNEL_KEY = "MEDICATION_COMPLIANCE";
|
||
|
||
/**
|
||
* v1.21.0 (FDREXTEND) — stable channel key for the daily symptom-severity /
|
||
* functional-impact burden (0–3; 0 = healthy, 3 = bedbound). Sourced from the
|
||
* illness day-log (`functionalImpact`, else max linked symptom severity), NOT a
|
||
* `MeasurementType`. It rides BOTH roles like MOOD: as an OUTCOME it surfaces
|
||
* "adherence dip today → symptom flare tomorrow"; as a BEHAVIOUR it surfaces
|
||
* "symptom burden today → a vital drifting tomorrow". The same-family guard
|
||
* skips only the symptom→symptom self-lag. The series is built only across the
|
||
* span the user actually logs illness (healthy days inside that span = 0); a
|
||
* user who never logs illness yields an EMPTY series that degrades to absent —
|
||
* never a spurious all-zero constant that could fabricate a link.
|
||
*/
|
||
export const SYMPTOM_SEVERITY_CHANNEL_KEY = "SYMPTOM_SEVERITY";
|
||
|
||
/**
|
||
* v1.21.0 (FDREXTEND) — discovery channels that are NOT `MeasurementType` enum
|
||
* values: each is backed by a different model (MOOD → MoodEntry,
|
||
* MEDICATION_COMPLIANCE → the dose-history ledger, SYMPTOM_SEVERITY → the
|
||
* illness day-log). EVERY caller that derives a `MeasurementType[]` list from
|
||
* `DISCOVERY_BEHAVIOURS` / `DISCOVERY_OUTCOMES` to feed a Prisma
|
||
* `measurement.findMany({ where: { type: { in } } })` MUST exclude these — a
|
||
* non-enum string in the `IN (...)` list errors the Postgres enum cast. Use
|
||
* {@link discoveryMeasurementTypes} (or this set) rather than re-spelling the
|
||
* `k !== "MOOD"` filter, so a future non-measurement channel cannot be missed at
|
||
* one call site and crash its query.
|
||
*/
|
||
export const NON_MEASUREMENT_DISCOVERY_CHANNELS: ReadonlySet<string> = new Set([
|
||
"MOOD",
|
||
MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||
SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||
]);
|
||
|
||
/**
|
||
* v1.21.0 (FDREXTEND) — the subset of `keys` that ARE real `MeasurementType`
|
||
* enum values (i.e. excluding every {@link NON_MEASUREMENT_DISCOVERY_CHANNELS}
|
||
* channel), safe to splice into a Prisma `type IN (...)` filter. The cast is the
|
||
* caller's existing `as MeasurementType[]` assertion — this only drops the
|
||
* non-enum keys first.
|
||
*/
|
||
export function discoveryMeasurementTypes(keys: readonly string[]): string[] {
|
||
return keys.filter((k) => !NON_MEASUREMENT_DISCOVERY_CHANNELS.has(k));
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
/**
|
||
* Reader's locale for the narrated `interpretation`. Defaults to English
|
||
* so the many server callers that feed the string into an LLM prompt (which
|
||
* re-localises) keep their existing behaviour; the user-facing correlations
|
||
* route passes the resolved locale so the card renders in the reader's
|
||
* language.
|
||
*/
|
||
locale?: Locale;
|
||
} = {},
|
||
): CorrelationDiscoveryResult {
|
||
const lagDays = opts.lagDays ?? 1;
|
||
const minPairs = opts.minPairs ?? MIN_PAIRED_N;
|
||
const fdrQ = opts.fdrQ ?? FDR_Q;
|
||
// Named `translate` (not `t`) — the pair-mapping callbacks below bind `t` to
|
||
// the RawPair element, so the translator must not shadow-collide with them.
|
||
const { t: translate } = getServerTranslator(opts.locale ?? defaultLocale);
|
||
|
||
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) {
|
||
// RECON1 (D2-1) — skip same-metric-FAMILY lagged pairs, not just the exact
|
||
// self-pair. A same-family lag (mood→mood, sys→any-BP) is a serial
|
||
// auto-correlation tautology, not a cross-domain insight; excluding it
|
||
// before testing stops it crowding out genuine cross-metric links.
|
||
if (metricFamily(b.key) === metricFamily(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)
|
||
// RECON1 (D4) — shrink each estimate toward null by its sample depth, then
|
||
// (D2-2) classify into a phrasing tier. A pair below the effect-size floor
|
||
// tiers to `null` and is dropped: it is statistically real but explains too
|
||
// little variance to narrate as a driver.
|
||
.map((t) => {
|
||
const shrunkR = shrinkEstimate(t.r, t.n);
|
||
const tier = confidenceTier(shrunkR, t.n);
|
||
return { ...t, shrunkR, tier };
|
||
})
|
||
.filter(
|
||
(
|
||
t,
|
||
): t is RawPair & {
|
||
qValue: number;
|
||
shrunkR: number;
|
||
tier: ConfidenceTier;
|
||
} => t.tier !== null,
|
||
)
|
||
.map((t) => ({
|
||
behaviour: t.behaviour,
|
||
outcome: t.outcome,
|
||
n: t.n,
|
||
r: t.r,
|
||
pValue: t.pValue,
|
||
qValue: Math.round(t.qValue * 1000) / 1000,
|
||
shrunkR: Math.round(t.shrunkR * 1000) / 1000,
|
||
tier: t.tier,
|
||
interpretation: interpret(t.behaviour, t.outcome, t.r, t.tier, translate),
|
||
lagDays,
|
||
}))
|
||
// RECON1 (D2-2 / D4) — rank by the SHRUNK effect magnitude (a deep, strong
|
||
// pair leads a thin one even if the thin one's raw r is higher), with q as
|
||
// the tie-break so a tighter FDR still wins on equal effect.
|
||
.sort(
|
||
(a, b) =>
|
||
Math.abs(b.shrunkR) - Math.abs(a.shrunkR) || a.qValue - b.qValue,
|
||
);
|
||
|
||
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.
|
||
*
|
||
* v1.21.0 (FDREXTEND) — medication compliance is NOW a behaviour channel (its
|
||
* cadence-aware per-day rate comes from the compliance engine's dose-history
|
||
* ledger; the caller builds the series and folds it in, the way MOOD is read
|
||
* from MoodEntry). Symptom severity rides both roles (see the OUTCOMES note).
|
||
* Both are sparse, noisy inputs — the engine's existing n ≥ 20 / p < 0.05 /
|
||
* BH-FDR / effect-size-floor / shrinkage gates apply UNCHANGED, so a thin
|
||
* compliance or symptom series cannot surface a confident driver.
|
||
*/
|
||
export const DISCOVERY_BEHAVIOURS = [
|
||
"TIME_IN_DAYLIGHT",
|
||
"MOOD",
|
||
"BLOOD_GLUCOSE",
|
||
"BLOOD_PRESSURE_SYS",
|
||
"ACTIVITY_STEPS",
|
||
// v1.22 — sleep duration as a lag SOURCE too (it was outcome-only). This
|
||
// opens the clinically-obvious "poor sleep last night → next-day mood / HRV /
|
||
// a vital drifting" direction. The same-family guard collapses only the
|
||
// sleep→sleep self-lag (the key is its own family), so sleep stays free to
|
||
// pair cross-domain. The daily series is the same per-day collapse the outcome
|
||
// role already uses, so neither role sees a fabricated value.
|
||
"SLEEP_DURATION",
|
||
// v1.21.0 (FDREXTEND) — daily adherence rate as a lag source: the high-value
|
||
// "adherence dip today → a worse outcome tomorrow" direction.
|
||
MEDICATION_COMPLIANCE_CHANNEL_KEY,
|
||
// v1.21.0 (FDREXTEND) — symptom burden as a lag source too: "more symptomatic
|
||
// today → a vital drifting tomorrow". Mirrored as an outcome below.
|
||
SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||
] as const;
|
||
|
||
export const DISCOVERY_OUTCOMES = [
|
||
"SLEEP_DURATION",
|
||
"HEART_RATE_VARIABILITY",
|
||
"RESTING_HEART_RATE",
|
||
"WEIGHT",
|
||
// v1.22 — blood pressure as an OUTCOME (it was behaviour-only). This makes
|
||
// the clinically-obvious "medication adherence → next-day BP control" link
|
||
// discoverable (compliance reached HRV / RHR / weight but never BP). Both
|
||
// components are added so diastolic — previously never a candidate channel at
|
||
// all — can correlate too. The same-family guard skips every
|
||
// BP-behaviour → BP-outcome self/cross-component lag (serial autocorrelation),
|
||
// so only genuine cross-domain behaviours (compliance, mood, daylight, …) →
|
||
// BP survive. BH-FDR controls the wider pair family this opens up.
|
||
"BLOOD_PRESSURE_SYS",
|
||
"BLOOD_PRESSURE_DIA",
|
||
// v1.11.5 (F3) — mood is now an OUTCOME channel too, not only a behaviour.
|
||
// The discovery loop skips the MOOD→MOOD self-pair (`b.key === o.key`), so
|
||
// promoting it lets the FDR scan surface "behaviour today → next-day mood"
|
||
// relations (e.g. more daylight today → better mood tomorrow) in addition
|
||
// to the existing "mood today → next-day outcome" direction. BH-FDR
|
||
// already controls the larger pair family this opens up.
|
||
"MOOD",
|
||
// v1.21.0 (FDREXTEND) — symptom severity as an OUTCOME: the flagship
|
||
// "adherence dip today → symptom flare tomorrow" link (compliance behaviour →
|
||
// symptom outcome). The same-family guard skips the symptom→symptom self-lag;
|
||
// BH-FDR controls the wider family the dual-role channel opens up.
|
||
SYMPTOM_SEVERITY_CHANNEL_KEY,
|
||
// 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;
|
||
|
||
// ─── v1.22 — rolling early-detection window ───────────────────────────────
|
||
|
||
/**
|
||
* v1.22 — trailing window (days) for the rolling EARLY-detection pass.
|
||
*
|
||
* The standard discovery scan is retrospective: it needs n ≥ 20 paired days, so
|
||
* a pattern only surfaces once ~3 weeks of overlap have accrued INSIDE the
|
||
* 180-day window — it explains the past well but reacts slowly to an emerging
|
||
* shift (an illness pulling HRV + sleep down together over the last fortnight).
|
||
* This shorter window re-runs the SAME engine over only the trailing days so a
|
||
* fresh, concentrated pattern can surface before the long window dilutes it.
|
||
*/
|
||
export const EARLY_WINDOW_DAYS = 21;
|
||
|
||
/**
|
||
* v1.22 — paired-day floor for the early window. Lower than the retrospective
|
||
* `MIN_PAIRED_N` (20) because 21 days cannot reach 20 lagged pairs, but still
|
||
* high enough that Pearson is defensible. The shorter, noisier series is
|
||
* counter-balanced by a TIGHTER FDR ({@link EARLY_FDR_Q}) and a no-faint effect
|
||
* bar (see {@link discoverEmergingCorrelations}), so the early pass keeps a HIGH
|
||
* firing bar despite the smaller n.
|
||
*/
|
||
export const EARLY_MIN_PAIRS = 12;
|
||
|
||
/**
|
||
* v1.22 — tighter Benjamini-Hochberg target for the early window (vs the
|
||
* retrospective {@link FDR_Q} = 0.10). A short window is noisier, so the early
|
||
* pass demands a stricter false-discovery rate before it will narrate an
|
||
* emerging pattern.
|
||
*/
|
||
export const EARLY_FDR_Q = 0.05;
|
||
|
||
/** Pure: keep only the points on/after `fromDayKey` (inclusive). */
|
||
export function filterSeriesToWindow(
|
||
series: NamedSeries[],
|
||
fromDayKey: string,
|
||
): NamedSeries[] {
|
||
return series.map((s) => ({
|
||
...s,
|
||
points: s.points.filter((p) => p.day >= fromDayKey),
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Separator joining the two channel names into a stable pair key. The
|
||
* unit-separator character (U+241F) cannot appear in a correlation-channel
|
||
* name, so two distinct pairs never collide. The key is used only for Set
|
||
* membership (never split back), and lives for one discovery pass.
|
||
*/
|
||
const PAIR_KEY_SEP = "\u241F";
|
||
|
||
/** Stable pair identity for double-count exclusion. */
|
||
function pairKey(behaviour: string, outcome: string): string {
|
||
return `${behaviour}${PAIR_KEY_SEP}${outcome}`;
|
||
}
|
||
|
||
export interface EmergingCorrelationResult {
|
||
/**
|
||
* Recent-window pairs that are NOT already established by the retrospective
|
||
* scan — the genuinely emerging signals. Each is flagged `window: "recent"`
|
||
* and `provisional: true`.
|
||
*/
|
||
emerging: DiscoveredCorrelation[];
|
||
/** Trailing window (days) the early pass scanned. */
|
||
windowDays: number;
|
||
/** Paired-day floor enforced for the early pass. */
|
||
minPairs: number;
|
||
/** FDR target the early pass used. */
|
||
fdrQ: number;
|
||
/** How many pairs were tested in the early window (honest footer). */
|
||
pairsTested: number;
|
||
}
|
||
|
||
/**
|
||
* v1.22 — rolling early-detection pass.
|
||
*
|
||
* Re-runs {@link discoverCorrelations} over only the trailing
|
||
* {@link EARLY_WINDOW_DAYS} days (a lower n floor, a tighter FDR), then keeps
|
||
* ONLY pairs that:
|
||
* 1. are not already in the retrospective `discovered` set (no double-count —
|
||
* an established pattern is reported once, retrospectively), and
|
||
* 2. clear a no-`faint` effect bar — over a short, noisy window a faint signal
|
||
* is too likely to be transient noise to narrate as "emerging".
|
||
*
|
||
* The early window runs its OWN Benjamini-Hochberg control across its OWN tested
|
||
* family, so FDR discipline holds independently in each pass. Pure — the caller
|
||
* passes the full series and the day key of the early-window start.
|
||
*/
|
||
export function discoverEmergingCorrelations(
|
||
fullSeries: NamedSeries[],
|
||
retrospective: CorrelationDiscoveryResult,
|
||
opts: {
|
||
recentFromDayKey: string;
|
||
windowDays?: number;
|
||
lagDays?: number;
|
||
minPairs?: number;
|
||
fdrQ?: number;
|
||
/** Reader's locale, threaded to the inner scan's narration. */
|
||
locale?: Locale;
|
||
},
|
||
): EmergingCorrelationResult {
|
||
const windowDays = opts.windowDays ?? EARLY_WINDOW_DAYS;
|
||
const minPairs = opts.minPairs ?? EARLY_MIN_PAIRS;
|
||
const fdrQ = opts.fdrQ ?? EARLY_FDR_Q;
|
||
|
||
const recentSeries = filterSeriesToWindow(fullSeries, opts.recentFromDayKey);
|
||
const recent = discoverCorrelations(recentSeries, {
|
||
lagDays: opts.lagDays,
|
||
minPairs,
|
||
fdrQ,
|
||
locale: opts.locale,
|
||
});
|
||
|
||
const established = new Set(
|
||
retrospective.discovered.map((d) => pairKey(d.behaviour, d.outcome)),
|
||
);
|
||
|
||
const emerging = recent.discovered
|
||
// High firing bar for the noisier short window: drop faint-tier signals.
|
||
.filter((d) => d.tier !== "faint")
|
||
// No double-count: an already-established retrospective pair is reported
|
||
// once (retrospectively), never re-surfaced as "emerging".
|
||
.filter((d) => !established.has(pairKey(d.behaviour, d.outcome)))
|
||
.map((d) => ({
|
||
...d,
|
||
window: "recent" as const,
|
||
provisional: true,
|
||
}));
|
||
|
||
return {
|
||
emerging,
|
||
windowDays,
|
||
minPairs,
|
||
fdrQ,
|
||
pairsTested: recent.pairsTested,
|
||
};
|
||
}
|
||
|
||
// ─── v1.22 — labs ↔ outcome correlation ───────────────────────────────────
|
||
|
||
/**
|
||
* v1.22 — why labs are NOT a daily-lag channel in the matrix above.
|
||
*
|
||
* Lab draws are sparse and irregular (a panel every few months), so a day-D →
|
||
* day-D+1 lagged Pearson with an n ≥ 20 paired-DAY floor is structurally
|
||
* unreachable for a biomarker — folding `LAB:*` into `DISCOVERY_*` would only
|
||
* add channels that always degrade to absent (dead candidates). The clinically
|
||
* meaningful object for a sparse marker is different: each draw vs the
|
||
* CONTEMPORANEOUS windowed mean of an outcome the marker plausibly reflects
|
||
* (HbA1c ↔ the period's mean glucose; lipids ↔ weight). That is a point-vs-window
|
||
* statistic over the DRAWS (n = number of draws), not a daily lag — so it gets
|
||
* its own FDR-controlled pass here rather than a forced, never-firing channel.
|
||
*/
|
||
|
||
/** A single lab draw, day-keyed in the user's tz. */
|
||
export interface LabDrawPoint {
|
||
/** Stable channel key — `LAB:<analyte>`. */
|
||
key: string;
|
||
/** Day key YYYY-MM-DD of the draw. */
|
||
day: string;
|
||
/** Numeric reading (qualitative rows are excluded by the caller). */
|
||
value: number;
|
||
}
|
||
|
||
/** One discovered lab ↔ outcome association — descriptive, never causal. */
|
||
export interface DiscoveredLabCorrelation {
|
||
/** `LAB:<analyte>` channel key (display strips the prefix). */
|
||
lab: string;
|
||
/** Outcome channel key it tracks with. */
|
||
outcome: string;
|
||
/** Number of draws paired with a usable contemporaneous outcome window. */
|
||
n: number;
|
||
/** Pearson r across (draw value, contemporaneous outcome window-mean). */
|
||
r: number;
|
||
pValue: number;
|
||
qValue: number;
|
||
/** Trailing days each draw's outcome window spanned. */
|
||
windowDays: number;
|
||
/** Conservative, descriptive interpretation — never causal. */
|
||
interpretation: string;
|
||
}
|
||
|
||
export interface LabCorrelationResult {
|
||
discovered: DiscoveredLabCorrelation[];
|
||
/** Lab × outcome pairs assessed (honest footer). */
|
||
pairsTested: number;
|
||
fdrQ: number;
|
||
/** Minimum paired-draw count enforced per pair. */
|
||
minDraws: number;
|
||
}
|
||
|
||
/**
|
||
* v1.22 — curated outcome channels a lab biomarker is paired against. Kept
|
||
* small and physiologically broad (the flagship clinical wins the integration
|
||
* audit names) so the lab × outcome family stays tight and BH-FDR stays honest:
|
||
* a biomarker that tracks the period's mean weight, glucose, or systolic BP.
|
||
*/
|
||
export const LAB_OUTCOME_TARGETS: readonly string[] = [
|
||
"WEIGHT",
|
||
"BLOOD_GLUCOSE",
|
||
"BLOOD_PRESSURE_SYS",
|
||
];
|
||
|
||
/** Default trailing window (days) each draw's outcome mean is taken over. */
|
||
export const LAB_OUTCOME_WINDOW_DAYS = 28;
|
||
|
||
/** Default minimum draws (paired with a usable outcome window) per lab pair. */
|
||
export const LAB_MIN_DRAWS = 5;
|
||
|
||
/** Default minimum outcome points inside a draw's window for it to count. */
|
||
export const LAB_MIN_WINDOW_POINTS = 5;
|
||
|
||
/** YYYY-MM-DD `daysBack` days before `day` (inclusive lower bound). */
|
||
function shiftDayBack(day: string, daysBack: number): string {
|
||
return shiftDay(day, -daysBack);
|
||
}
|
||
|
||
/**
|
||
* Mean of an outcome series over `(drawDay - windowDays, drawDay]` — the period
|
||
* the marker physiologically reflects. Returns `null` when too few outcome
|
||
* points fall in the window for a defensible mean. Pure.
|
||
*/
|
||
function outcomeWindowMean(
|
||
outcome: DailySeriesPoint[],
|
||
drawDay: string,
|
||
windowDays: number,
|
||
minPoints: number,
|
||
): number | null {
|
||
const lower = shiftDayBack(drawDay, windowDays);
|
||
let sum = 0;
|
||
let count = 0;
|
||
for (const p of outcome) {
|
||
if (p.day > lower && p.day <= drawDay && Number.isFinite(p.value)) {
|
||
sum += p.value;
|
||
count += 1;
|
||
}
|
||
}
|
||
return count >= minPoints ? sum / count : null;
|
||
}
|
||
|
||
/**
|
||
* v1.22 — FDR-controlled labs ↔ outcome discovery.
|
||
*
|
||
* For each biomarker (grouped by `LabDrawPoint.key`) and each
|
||
* {@link LAB_OUTCOME_TARGETS} outcome, pair every draw value with the
|
||
* contemporaneous trailing-window mean of that outcome, then run Pearson + the
|
||
* exact p-value across the draws. Benjamini-Hochberg controls the lab × outcome
|
||
* family; the same effect-size floor + James-Stein shrinkage as the daily engine
|
||
* gate the survivors. A sparse marker simply fails the `minDraws` floor and
|
||
* degrades to absent — it never fabricates a link. Pure over already-fetched
|
||
* draws + outcome series.
|
||
*/
|
||
export function discoverLabOutcomeCorrelations(
|
||
labDraws: LabDrawPoint[],
|
||
series: NamedSeries[],
|
||
opts: {
|
||
windowDays?: number;
|
||
minDraws?: number;
|
||
minWindowPoints?: number;
|
||
fdrQ?: number;
|
||
/** Reader's locale for the narrated `interpretation`. Defaults to English. */
|
||
locale?: Locale;
|
||
} = {},
|
||
): LabCorrelationResult {
|
||
const windowDays = opts.windowDays ?? LAB_OUTCOME_WINDOW_DAYS;
|
||
const minDraws = opts.minDraws ?? LAB_MIN_DRAWS;
|
||
const minWindowPoints = opts.minWindowPoints ?? LAB_MIN_WINDOW_POINTS;
|
||
const fdrQ = opts.fdrQ ?? FDR_Q;
|
||
// Named `translate` — the pair-mapping callback below binds `t` to its element.
|
||
const { t: translate } = getServerTranslator(opts.locale ?? defaultLocale);
|
||
|
||
// Group draws by biomarker key (newest-irrelevant; Pearson is order-free).
|
||
const drawsByLab = new Map<string, LabDrawPoint[]>();
|
||
for (const d of labDraws) {
|
||
if (!Number.isFinite(d.value)) continue;
|
||
const list = drawsByLab.get(d.key) ?? [];
|
||
list.push(d);
|
||
drawsByLab.set(d.key, list);
|
||
}
|
||
|
||
// Index the curated outcome series by key (last-write keeps a single series
|
||
// per key even when a channel appears in both roles).
|
||
const outcomeByKey = new Map<string, DailySeriesPoint[]>();
|
||
for (const s of series) {
|
||
if (LAB_OUTCOME_TARGETS.includes(s.key)) outcomeByKey.set(s.key, s.points);
|
||
}
|
||
|
||
interface RawLabPair {
|
||
lab: string;
|
||
outcome: string;
|
||
n: number;
|
||
r: number;
|
||
pValue: number;
|
||
}
|
||
const tested: RawLabPair[] = [];
|
||
for (const [lab, draws] of drawsByLab) {
|
||
for (const outcomeKey of LAB_OUTCOME_TARGETS) {
|
||
const outcome = outcomeByKey.get(outcomeKey);
|
||
if (!outcome || outcome.length === 0) continue;
|
||
const xs: number[] = [];
|
||
const ys: number[] = [];
|
||
for (const draw of draws) {
|
||
const mean = outcomeWindowMean(
|
||
outcome,
|
||
draw.day,
|
||
windowDays,
|
||
minWindowPoints,
|
||
);
|
||
if (mean === null) continue;
|
||
xs.push(draw.value);
|
||
ys.push(mean);
|
||
}
|
||
if (xs.length < minDraws) continue;
|
||
const result = pearson({ xs, ys, minPairs: minDraws });
|
||
if (result.status !== "ok") continue;
|
||
tested.push({
|
||
lab,
|
||
outcome: outcomeKey,
|
||
n: result.n,
|
||
r: result.r,
|
||
pValue: result.pValue,
|
||
});
|
||
}
|
||
}
|
||
|
||
const pairsTested = tested.length;
|
||
if (pairsTested === 0) {
|
||
return { discovered: [], pairsTested: 0, fdrQ, minDraws };
|
||
}
|
||
|
||
const qValues = benjaminiHochberg(tested.map((t) => t.pValue));
|
||
const discovered: DiscoveredLabCorrelation[] = tested
|
||
.map((t, i) => ({ ...t, qValue: qValues[i] }))
|
||
.filter((t) => t.pValue < 0.05 && t.qValue <= fdrQ)
|
||
.map((t) => {
|
||
const shrunkR = shrinkEstimate(t.r, t.n);
|
||
const tier = confidenceTier(shrunkR, t.n);
|
||
return { ...t, shrunkR, tier };
|
||
})
|
||
.filter(
|
||
(
|
||
t,
|
||
): t is RawLabPair & {
|
||
qValue: number;
|
||
shrunkR: number;
|
||
tier: ConfidenceTier;
|
||
} => t.tier !== null,
|
||
)
|
||
.map((t) => ({
|
||
lab: t.lab,
|
||
outcome: t.outcome,
|
||
n: t.n,
|
||
r: t.r,
|
||
pValue: t.pValue,
|
||
qValue: Math.round(t.qValue * 1000) / 1000,
|
||
windowDays,
|
||
interpretation: interpretLab(t.lab, t.outcome, t.r, t.tier, translate),
|
||
}))
|
||
.sort((a, b) => Math.abs(b.r) - Math.abs(a.r) || a.qValue - b.qValue);
|
||
|
||
return { discovered, pairsTested, fdrQ, minDraws };
|
||
}
|
||
|
||
/** Strip the `LAB:` prefix for display. */
|
||
function humaniseLab(key: string): string {
|
||
return key.startsWith("LAB:") ? key.slice("LAB:".length) : key;
|
||
}
|
||
|
||
/**
|
||
* Descriptive, never-causal interpretation for a lab ↔ outcome pair.
|
||
*
|
||
* v1.22 — honour the confidence tier the way the daily engine's `interpret`
|
||
* does: a surviving `faint`-tier pair (real, but below the confident
|
||
* effect-size bar) reads as "a faint hint, if anything" rather than the
|
||
* confident "line up with", so the narrated confidence never outruns the
|
||
* effect. The clinician caveat stays on every tier.
|
||
*/
|
||
function interpretLab(
|
||
lab: string,
|
||
outcome: string,
|
||
r: number,
|
||
tier: ConfidenceTier,
|
||
t: Translate,
|
||
): string {
|
||
const params = {
|
||
// The analyte name is user data — kept verbatim inside the localised frame.
|
||
lab: humaniseLab(lab),
|
||
outcome: humanise(outcome, t),
|
||
direction: t(
|
||
`insights.correlation.direction.${r < 0 ? "lower" : "higher"}`,
|
||
),
|
||
};
|
||
const tierKey = tier === "faint" ? "faint" : "default";
|
||
return t(`insights.correlation.lab.${tierKey}`, params);
|
||
}
|