mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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/);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user