feat(insights): gate discovered correlations on effect size and confidence

Significance alone surfaced trivial drivers: a large-n pair with r=0.16
narrated as a confident "tends to go with" link while explaining ~2.5%
of variance. Add three gates before a pair is ranked as a driver:

- exclude same-metric-family lagged pairs (mood->mood, BP component
  self-lag) as serial-autocorrelation tautologies, widening the existing
  exact self-pair skip to the whole family.
- shrink each Pearson estimate toward null by n/(n+10) so a thin-data
  correlation cannot out-rank a deep one on an inflated point estimate.
- floor the shrunk effect at 0.2 (drop below) and bind 0.2-0.3 to a
  hedged "faint hint" phrasing tier, 0.3+ to confident phrasing scaled
  by sample depth. The reported r and p stay the honest raw statistics.

Rank by shrunk effect magnitude, q as tie-break.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:10:29 +02:00
1 parent b75cef9190
commit 99a6163343
2 files changed
+336 -12

No files matched your search

@@ -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/);
});
});
+149 -12
View File
@@ -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:<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";
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 };
}