feat(mood-insights): promote mood to a discovery outcome and surface it

Two changes that turn the descriptive relations surfaces into a
statistically-gated discovery view on the mood page.

Mood as an outcome: the FDR-controlled all-pairs correlation discovery
engine treated mood only as a behaviour (a lag source). Promote it to an
outcome channel too, so the scan also surfaces "behaviour today → next-day
mood" relations (e.g. more daylight today → better mood tomorrow) alongside
the existing "mood today → next-day outcome" direction. The self-pair is
skipped and Benjamini-Hochberg already controls the larger pair family this
opens up. The discovery route now feeds the shared mood daily-mean series
into both roles.

Surface the discovery slice: the engine was computed but never rendered.
The mood Insights page now shows the mood-involving pairs that survive the
false-discovery correction, each phrased observationally with its paired-day
count, r, and BH q-value, plus the honest "X of N tested" footer. The
surface self-fetches the correlation route and degrades to nothing when the
operator has disabled it. Observational only, with the standing disclaimer.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 18:09:07 +02:00
1 parent f1edbd17d1
commit 4e6599d85f
6 files changed
+339 -13

No files matched your search

@@ -95,3 +95,48 @@ describe("discoverCorrelations", () => {
expect(result.discovered).toHaveLength(0);
});
});
// ── F3 — MOOD as a discoverable OUTCOME channel ─────────────────────
import { DISCOVERY_OUTCOMES } from "../correlation-discovery";
describe("MOOD as outcome (F3)", () => {
it("registers MOOD in the outcome matrix", () => {
expect(DISCOVERY_OUTCOMES).toContain("MOOD");
});
it("surfaces a behaviour → next-day MOOD relation", () => {
// 30 contiguous days; daylight on day D drives mood on day D+1.
const n = 30;
const daylight = Array.from({ length: n }, (_, i) => 60 + (i % 10) * 5);
// mood[D+1] tracks daylight[D] with light noise → strong lagged r.
const mood = Array.from({ length: n }, (_, i) => {
const driver = i === 0 ? 60 : 60 + ((i - 1) % 10) * 5;
return 1 + (driver - 60) / 45 + (i % 2 === 0 ? 0.02 : -0.02);
});
const result = discoverCorrelations([
{ key: "TIME_IN_DAYLIGHT", role: "behaviour", points: series(daylight) },
{ key: "MOOD", role: "outcome", points: series(mood) },
]);
const pair = result.discovered.find(
(p) => p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "MOOD",
);
expect(pair).toBeDefined();
expect(pair!.r).toBeGreaterThan(0.5);
});
it("never tests the MOOD → MOOD self-pair", () => {
const n = 30;
const mood = Array.from({ length: n }, (_, i) => 3 + (i % 3));
const result = discoverCorrelations([
{ key: "MOOD", role: "behaviour", points: series(mood) },
{ key: "MOOD", role: "outcome", points: series(mood) },
]);
expect(
result.discovered.find(
(p) => p.behaviour === "MOOD" && p.outcome === "MOOD",
),
).toBeUndefined();
expect(result.pairsTested).toBe(0);
});
});
@@ -249,6 +249,13 @@ export const DISCOVERY_OUTCOMES = [
"HEART_RATE_VARIABILITY",
"RESTING_HEART_RATE",
"WEIGHT",
// 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",
// 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