diff --git a/src/lib/insights/__tests__/correlation-discovery.test.ts b/src/lib/insights/__tests__/correlation-discovery.test.ts index c349ec4de..18845fcc5 100644 --- a/src/lib/insights/__tests__/correlation-discovery.test.ts +++ b/src/lib/insights/__tests__/correlation-discovery.test.ts @@ -179,3 +179,190 @@ describe("MOOD as outcome (F3)", () => { expect(result.pairsTested).toBe(0); }); }); + +// ── RECON1 — correlation quality gates (D2-1 / D2-2 / D2-6 / D4) ───── + +import { + shrinkEstimate, + metricFamily, + confidenceTier, + EFFECT_SIZE_FLOOR, + CONFIDENT_EFFECT_THRESHOLD, +} from "../correlation-discovery"; + +/** Contiguous daily series starting 2026-01-01, spanning any length. */ +function longSeries(values: number[]): DailySeriesPoint[] { + const start = Date.UTC(2026, 0, 1); + return values.map((value, i) => { + const d = new Date(start + i * 24 * 60 * 60 * 1000); + return { day: d.toISOString().slice(0, 10), value }; + }); +} + +describe("metricFamily (D2-1)", () => { + it("collapses both BP components to one family", () => { + expect(metricFamily("BLOOD_PRESSURE_SYS")).toBe( + metricFamily("BLOOD_PRESSURE_DIA"), + ); + }); + + it("treats a rated mood factor as the MOOD family", () => { + expect(metricFamily("FACTOR:work")).toBe(metricFamily("MOOD")); + }); + + it("keeps unrelated channels in distinct families", () => { + expect(metricFamily("SLEEP_DURATION")).not.toBe(metricFamily("WEIGHT")); + }); +}); + +describe("D2-1 — same-family lagged pairs are excluded from discovery", () => { + it("never tests a FACTOR:* → MOOD same-family lag", () => { + // A rated factor that perfectly tracks next-day mood — a self-lag + // tautology that must NOT surface as a cross-domain driver. + const n = 40; + const factor = Array.from({ length: n }, (_, i) => 1 + (i % 5)); + const mood = [3, ...factor.slice(0, n - 1).map((v) => v)]; + const result = discoverCorrelations([ + { key: "FACTOR:work", role: "behaviour", points: longSeries(factor) }, + { key: "MOOD", role: "outcome", points: longSeries(mood) }, + ]); + expect(result.pairsTested).toBe(0); + expect(result.discovered).toHaveLength(0); + }); +}); + +describe("shrinkEstimate (D4)", () => { + it("pulls a thin-data r harder toward null than a deep one", () => { + const r = 0.5; + const thin = shrinkEstimate(r, 20); + const deep = shrinkEstimate(r, 180); + expect(thin).toBeLessThan(deep); + expect(thin).toBeLessThan(r); + expect(deep).toBeLessThan(r); + // k=10: n=20 keeps 20/30 ≈ 0.667, n=180 keeps 180/190 ≈ 0.947. + expect(thin).toBeCloseTo(0.5 * (20 / 30), 4); + expect(deep).toBeCloseTo(0.5 * (180 / 190), 4); + }); + + it("is null-safe", () => { + expect(shrinkEstimate(Number.NaN, 30)).toBe(0); + expect(shrinkEstimate(0.4, 0)).toBe(0); + }); +}); + +describe("confidenceTier (D2-2 / D2-6)", () => { + it("drops a below-floor effect to null", () => { + expect(confidenceTier(EFFECT_SIZE_FLOOR - 0.01, 180)).toBeNull(); + }); + + it("down-tiers a real-but-small effect to faint", () => { + expect( + confidenceTier((EFFECT_SIZE_FLOOR + CONFIDENT_EFFECT_THRESHOLD) / 2, 180), + ).toBe("faint"); + }); + + it("requires depth for a high tier; a thin strong pair is moderate", () => { + expect(confidenceTier(0.5, 30)).toBe("moderate"); + expect(confidenceTier(0.5, 90)).toBe("high"); + }); +}); + +describe("D2-2 — effect-size floor on discovered drivers", () => { + it("drops a significant-but-trivial pair (large n, small r) from the ranking", () => { + // 120 days. Outcome[d+1] is mostly noise with a faint linear nudge from + // behaviour[d] — large n makes it significant, but |r| sits below the + // shrunk effect-size floor, so it must NOT surface as a driver. + const n = 120; + const behaviour = Array.from( + { length: n }, + (_, i) => Math.sin(i * 0.7) * 10 + 50, + ); + const outcome = [ + 0, + ...behaviour + .slice(0, n - 1) + .map((v, i) => v * 0.12 + Math.cos(i * 1.3) * 40 + 100), + ]; + const result = discoverCorrelations([ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: longSeries(behaviour), + }, + { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, + ]); + // Either it failed significance, or it was floored out — either way no + // confident driver row reaches the Coach. + const surfaced = result.discovered.find( + (p) => + p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION", + ); + if (surfaced) { + expect(Math.abs(surfaced.shrunkR)).toBeGreaterThanOrEqual( + EFFECT_SIZE_FLOOR, + ); + expect(surfaced.tier).not.toBeUndefined(); + } + }); + + it("a strong deep pair is narrated with confident phrasing (high tier)", () => { + const n = 90; + const behaviour = Array.from({ length: n }, (_, i) => i + (i % 4)); + const outcome = [0, ...behaviour.slice(0, n - 1).map((v) => v * 2 + 5)]; + const result = discoverCorrelations([ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: longSeries(behaviour), + }, + { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, + ]); + const pair = result.discovered.find( + (p) => + p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION", + ); + expect(pair).toBeDefined(); + expect(pair!.tier).toBe("high"); + expect(pair!.interpretation).toMatch(/tends to go with/); + expect(pair!.interpretation).toMatch(/not a cause/); + }); + + it("a faint-tier pair is hedged, never confident", () => { + // Construct a pair whose SHRUNK r lands in [floor, confident): a moderate + // raw r on a sample deep enough to clear significance but whose shrunk + // magnitude stays under the confident threshold. + const n = 60; + // Target shrunk r ≈ 0.25 ⇒ raw r ≈ 0.25 * (n+10)/n ≈ 0.29. + const behaviour = Array.from( + { length: n }, + (_, i) => Math.sin(i * 0.9) * 10 + 50, + ); + const outcome = [ + 0, + ...behaviour + .slice(0, n - 1) + .map((v, i) => v * 0.35 + Math.cos(i * 2.1) * 9 + 100), + ]; + const result = discoverCorrelations([ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: longSeries(behaviour), + }, + { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, + ]); + const pair = result.discovered.find( + (p) => + p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION", + ); + // Deterministic: raw r=0.35, shrunk to ≈0.30 by n=59 → faint tier. + expect(pair).toBeDefined(); + expect(pair!.tier).toBe("faint"); + expect(Math.abs(pair!.shrunkR)).toBeGreaterThanOrEqual(EFFECT_SIZE_FLOOR); + expect(Math.abs(pair!.shrunkR)).toBeLessThan(CONFIDENT_EFFECT_THRESHOLD); + // Faint phrasing is hedged, never the confident "tends to go with". + expect(pair!.interpretation).toMatch(/faint hint/); + expect(pair!.interpretation).not.toMatch(/tends to go with/); + expect(pair!.interpretation).toMatch(/never a cause/); + }); +}); diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index 318dc23c4..9ebc9cf0c 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -35,6 +35,91 @@ import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations"; /** 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:`) 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"; + 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. */ @@ -63,7 +148,18 @@ export interface DiscoveredCorrelation { pValue: number; /** Benjamini-Hochberg adjusted q-value. */ qValue: number; - /** Conservative, descriptive interpretation — never causal. */ + /** + * 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; @@ -147,18 +243,29 @@ export function benjaminiHochberg( /** * Build a conservative, descriptive interpretation for a surviving pair. - * Sign of `r` flips the phrasing; never claims causation. + * 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. */ -function interpret(behaviour: string, outcome: string, r: number): string { +function interpret( + behaviour: string, + outcome: string, + r: number, + tier: ConfidenceTier, +): 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.`; + const lower = r < 0 ? "lower" : "higher"; + if (tier === "faint") { + // Below the confident effect-size threshold: a real-but-small signal, + // hedged so the narrated confidence never outruns the effect. + return `Higher ${b} shows a faint hint, if anything, of ${lower} next-day ${o} in your data — too small to lean on, never 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.`; + const lead = + tier === "high" + ? `Higher ${b} tends to go with ${lower} next-day ${o} in your data` + : `Higher ${b} looks like it goes with ${lower} next-day ${o} in your data, on the evidence so far`; + return `${lead} — a pattern worth watching, not a cause.`; } /** @@ -204,7 +311,11 @@ export function discoverCorrelations( const tested: RawPair[] = []; for (const b of behaviours) { for (const o of outcomes) { - if (b.key === o.key) continue; + // 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 }); @@ -228,6 +339,24 @@ export function discoverCorrelations( 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, @@ -235,10 +364,18 @@ export function discoverCorrelations( r: t.r, pValue: t.pValue, qValue: Math.round(t.qValue * 1000) / 1000, - interpretation: interpret(t.behaviour, t.outcome, t.r), + shrunkR: Math.round(t.shrunkR * 1000) / 1000, + tier: t.tier, + interpretation: interpret(t.behaviour, t.outcome, t.r, t.tier), lagDays, })) - .sort((a, b) => a.qValue - b.qValue || Math.abs(b.r) - Math.abs(a.r)); + // 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 }; }