diff --git a/messages/de.json b/messages/de.json index ae260abd6..bb25c8cf7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3707,6 +3707,9 @@ "source": "{n} gepaarte Tage · r = {r}", "emptyTitle": "Noch nicht genug gepaarte Daten", "emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen.", + "pendingDescription": "{n} von {needed} gepaarten Tagen. Ein Zusammenhang erscheint, sobald genug Tage da sind, um Muster von Zufall zu unterscheiden.", + "noLinkTitle": "Kein klarer Zusammenhang", + "noLinkDescription": "{n} gepaarte Tage, und nichts hebt sich vom Zufall ab.", "weightTitle": "Stimmung vs. Gewicht", "bloodPressureTitle": "Stimmung vs. Blutdruck", "weightAxis": "Gewicht", diff --git a/messages/en.json b/messages/en.json index bfbb0ab32..f8f3c5599 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3708,6 +3708,9 @@ "sourceLabel": "How this was computed", "emptyTitle": "Not enough paired data yet", "emptyDescription": "Log mood and this metric on the same days to see a correlation.", + "pendingDescription": "{n} of {needed} paired days so far. A correlation appears once there are enough days to tell a pattern from chance.", + "noLinkTitle": "No clear link", + "noLinkDescription": "{n} paired days, and nothing stands out beyond what chance would produce.", "weightTitle": "Mood vs. weight", "bloodPressureTitle": "Mood vs. blood pressure", "weightAxis": "Weight", diff --git a/messages/es.json b/messages/es.json index 2ecddf478..b3f1b33e6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3707,6 +3707,9 @@ "source": "{n} días emparejados · r = {r}", "emptyTitle": "Aún no hay suficientes datos emparejados", "emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación.", + "pendingDescription": "{n} de {needed} días emparejados. La correlación aparece cuando hay días suficientes para distinguir un patrón del azar.", + "noLinkTitle": "Sin relación clara", + "noLinkDescription": "{n} días emparejados, y nada destaca más allá de lo que produciría el azar.", "weightTitle": "Ánimo vs. peso", "bloodPressureTitle": "Ánimo vs. presión arterial", "weightAxis": "Peso", diff --git a/messages/fr.json b/messages/fr.json index 63b62b5ff..c982dd5fa 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3707,6 +3707,9 @@ "source": "{n} jours appariés · r = {r}", "emptyTitle": "Pas encore assez de données appariées", "emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation.", + "pendingDescription": "{n} jours appariés sur {needed}. Une corrélation apparaît dès qu'il y a assez de jours pour distinguer un motif du hasard.", + "noLinkTitle": "Aucun lien net", + "noLinkDescription": "{n} jours appariés, et rien ne se démarque de ce que le hasard produirait.", "weightTitle": "Humeur vs. poids", "bloodPressureTitle": "Humeur vs. tension artérielle", "weightAxis": "Poids", diff --git a/messages/it.json b/messages/it.json index 3cc1dd08e..17cbbc0f3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3707,6 +3707,9 @@ "source": "{n} giorni appaiati · r = {r}", "emptyTitle": "Non ci sono ancora abbastanza dati appaiati", "emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione.", + "pendingDescription": "{n} di {needed} giorni appaiati. La correlazione compare quando i giorni bastano a distinguere un pattern dal caso.", + "noLinkTitle": "Nessun legame chiaro", + "noLinkDescription": "{n} giorni appaiati, e nulla emerge oltre a quanto produrrebbe il caso.", "weightTitle": "Umore vs. peso", "bloodPressureTitle": "Umore vs. pressione sanguigna", "weightAxis": "Peso", diff --git a/messages/ko.json b/messages/ko.json index 062b3e109..6c9a29048 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3708,6 +3708,9 @@ "sourceLabel": "계산 방법", "emptyTitle": "아직 짝지을 데이터가 부족해요", "emptyDescription": "같은 날에 기분과 이 지표를 함께 기록하면 상관관계를 볼 수 있어요.", + "pendingDescription": "짝지은 {n}일 / {needed}일. 우연과 패턴을 구분할 만큼 날이 쌓이면 상관관계가 나타나요.", + "noLinkTitle": "뚜렷한 관계 없음", + "noLinkDescription": "짝지은 {n}일 동안 우연으로 볼 수 있는 범위를 넘는 건 없었어요.", "weightTitle": "기분 대 체중", "bloodPressureTitle": "기분 대 혈압", "weightAxis": "체중", diff --git a/messages/pl.json b/messages/pl.json index de783600b..08b955f02 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3707,6 +3707,9 @@ "source": "{n} sparowanych dni · r = {r}", "emptyTitle": "Za mało sparowanych danych", "emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację.", + "pendingDescription": "{n} z {needed} sparowanych dni. Korelacja pojawi się, gdy dni wystarczy, aby odróżnić wzorzec od przypadku.", + "noLinkTitle": "Brak wyraźnego związku", + "noLinkDescription": "{n} sparowanych dni i nic nie wykracza poza to, co dałby przypadek.", "weightTitle": "Nastrój vs. waga", "bloodPressureTitle": "Nastrój vs. ciśnienie krwi", "weightAxis": "Waga", diff --git a/src/__tests__/unguarded-correlation-guard.test.ts b/src/__tests__/unguarded-correlation-guard.test.ts new file mode 100644 index 000000000..96f0993d4 --- /dev/null +++ b/src/__tests__/unguarded-correlation-guard.test.ts @@ -0,0 +1,117 @@ +/** + * Structural guard on the unguarded Pearson helper. + * + * `pearsonCorrelation` bands a coefficient from |r| alone above a floor of + * five pairs, with no significance test. Its guarded twin + * `significantPearsonCorrelation` sits directly beneath it in the same file + * and exists because of exactly one failure: five days of noise sloping the + * right way reaching a reader labelled "strong". The comment above the twin + * says so in as many words. + * + * The twin was written once and adopted one surface at a time, which is how + * the mood page kept the naive call for four minor lines after the surfaces + * either side of it had moved. Nothing failed when it was skipped, because + * nothing was watching. This is what watches: a new caller of the unguarded + * helper has to be added to the list below, in a diff a reviewer reads, next + * to a written reason. + * + * It is a tripwire, not a proof. It cannot say the remaining entries are + * right — only that the set has not grown without someone editing this file. + * The two entries it carries today are named as open work, not as blessings. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** + * Files that may still call `pearsonCorrelation` directly, and why. + * + * Neither entry is settled. Both are the same migration the user-facing + * surfaces already made, still owed by two payload builders whose consumers + * pin the n >= 5 shape; moving them changes what those payloads contain and + * belongs in its own change with its own tests, not smuggled into this one. + */ +const ALLOWED: Record = { + "app/api/insights/comprehensive/route.ts": + "Five correlations on the /insights envelope (weight x BP, mood x BP, " + + "mood x weight, mood x pulse, BP-medication continuity). Open: they " + + "carry the same small-n exposure the mood page had, and the page's " + + "cards read `n` straight off the envelope.", + "lib/insights/features.ts": + "Eight cross-metric coefficients on the AI feature payload. Open: the " + + "prompt reads them as findings, so the same significance bar applies; " + + "the snapshot shape and its budget tests move with it.", +}; + +/** Every non-test, non-generated source file under `src/`. */ +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +/** + * A call to the unguarded helper. Whitespace-tolerant and anchored on a word + * boundary so `significantPearsonCorrelation(` cannot match: a matcher that + * quietly matches nothing is the failure mode this repository has shipped + * before, so the count is asserted below as well. + */ +const CALL = /(? { + it("finds callers at all, so an empty result cannot read as a pass", () => { + const definition = read("lib/analytics/correlations.ts"); + expect(definition).toMatch(/export function pearsonCorrelation\s*\(/); + expect( + definition, + "the guarded twin must exist for this guard to mean anything", + ).toMatch(/export function significantPearsonCorrelation\s*\(/); + + const callers = sourceFiles().filter( + (rel) => rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)), + ); + expect(callers.length).toBeGreaterThan(0); + }); + + it("names every caller outside the allowlist", () => { + const callers = sourceFiles().filter( + (rel) => rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)), + ); + + expect( + callers.filter((rel) => !(rel in ALLOWED)), + "A new caller of the unguarded `pearsonCorrelation`. A surface a " + + "person reads must use `significantPearsonCorrelation` (n >= 20 " + + "AND p < 0.05). If this one genuinely cannot, add it to ALLOWED " + + "in this file with the reason, so the exception is reviewed rather " + + "than assumed.", + ).toEqual([]); + }); + + it("keeps the allowlist honest by refusing stale entries", () => { + const callers = new Set( + sourceFiles().filter( + (rel) => + rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)), + ), + ); + + // An entry whose file no longer calls the helper is a licence nobody + // needs any more; leaving it standing is how the next one gets waved in. + expect( + Object.keys(ALLOWED).filter((rel) => !callers.has(rel)), + "this file is on the allowlist but no longer calls the unguarded " + + "helper — drop the entry", + ).toEqual([]); + }); +}); diff --git a/src/components/insights/mood/__tests__/mood-correlation-cards.test.tsx b/src/components/insights/mood/__tests__/mood-correlation-cards.test.tsx new file mode 100644 index 000000000..102e6a467 --- /dev/null +++ b/src/components/insights/mood/__tests__/mood-correlation-cards.test.tsx @@ -0,0 +1,104 @@ +/** + * What the mood correlation card is allowed to claim. + * + * The card used to paint a "Strong" badge from any coefficient it was handed, + * as long as five days had been paired. The coefficient itself now clears the + * significance bar before it leaves the server, so the card's job here is the + * other half: when the bar refuses, say which bar and how far off the reader + * is, rather than falling back to the same "not enough paired data" blank that + * a reader with no data at all sees. + */ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + MoodCorrelationCards, + type MoodMetricCorrelationData, +} from "../mood-correlation-cards"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const NOTHING: MoodMetricCorrelationData = { + result: null, + points: [], + n: 0, + suppressed: "insufficientPairs", +}; + +/** The sleep card's own markup — the four siblings carry their own copy. */ +function renderSleep(sleep: MoodMetricCorrelationData) { + const html = render( + , + ); + const start = html.indexOf('data-kind="sleep"'); + const end = html.indexOf('data-kind="steps"'); + return html.slice(start, end); +} + +describe("", () => { + it("shows no strength badge for a refused correlation", () => { + const html = renderSleep({ + result: null, + points: Array.from({ length: 5 }, (_, i) => ({ x: i, y: i })), + n: 5, + suppressed: "insufficientPairs", + }); + + expect(html).not.toContain("Strong"); + expect(html).not.toContain("Moderate"); + }); + + it("names how many paired days are still missing instead of a bare blank", () => { + const html = renderSleep({ + result: null, + points: Array.from({ length: 5 }, (_, i) => ({ x: i, y: i })), + n: 5, + suppressed: "insufficientPairs", + }); + + // The reader is told where they stand, not only that something is absent. + expect(html).toContain("5 of 20"); + }); + + it("keeps the plain empty copy when nothing has been paired at all", () => { + const html = renderSleep(NOTHING); + + expect(html).toContain("Not enough paired data yet"); + expect(html).not.toContain("0 of 20"); + }); + + it("says a twenty-day pairing showed no reliable link", () => { + const html = renderSleep({ + result: null, + points: Array.from({ length: 20 }, (_, i) => ({ x: i, y: i })), + n: 20, + suppressed: "notSignificant", + }); + + expect(html).toContain("No clear link"); + expect(html).not.toContain("Not enough paired data yet"); + expect(html).not.toContain("Weak"); + }); + + it("still shows the badge and the n · r caption for a correlation that cleared the bar", () => { + const html = renderSleep({ + result: { r: 0.82, strength: "stark", n: 24 }, + points: Array.from({ length: 24 }, (_, i) => ({ x: i, y: i })), + n: 24, + }); + + expect(html).toContain("Strong"); + expect(html).toContain("24 paired days"); + }); +}); diff --git a/src/components/insights/mood/mood-correlation-cards.tsx b/src/components/insights/mood/mood-correlation-cards.tsx index d01a7265c..804bf52ff 100644 --- a/src/components/insights/mood/mood-correlation-cards.tsx +++ b/src/components/insights/mood/mood-correlation-cards.tsx @@ -20,7 +20,9 @@ import { MoodExplainerIcon } from "./mood-explainer-icon"; * pulse) pair. The Pearson math comes pre-computed from * `/api/mood/insights` (single source of truth with the LLM snapshot). * Below-threshold pairs render an EmptyState rather than a misleading - * near-zero scatter. + * near-zero scatter — and the EmptyState names which threshold, because a + * pairing that is still building and one that found nothing are different + * answers and used to read as the same blank. */ const ScatterCorrelationChart = dynamic( @@ -46,8 +48,17 @@ export interface MoodMetricCorrelationData { result: CorrelationResult | null; points: MoodCorrelationPoint[]; n: number; + /** Which bar the pairing fell short of, when there is no coefficient. */ + suppressed?: "insufficientPairs" | "notSignificant"; } +/** + * The paired-day floor the server's significance gate enforces. Repeated + * here only to tell the reader how far off they are; the decision itself is + * the server's and arrives as `suppressed`. + */ +const MIN_PAIRED_DAYS = 20; + type MetricKind = "sleep" | "steps" | "pulse" | "weight" | "bloodPressure"; const TITLE_KEY: Record = { @@ -95,7 +106,33 @@ function MoodCorrelationCard({ className?: string; }) { const { t } = useTranslations(); - const hasResult = data.result != null && data.n >= 5; + const hasResult = data.result != null; + + // Three different silences, and the reader deserves to know which one they + // are looking at: nothing paired yet, some days paired but not enough to + // separate a pattern from chance, or enough days and no pattern in them. + // The last one is a finding, not a gap, and used to render as the same + // "not enough data" blank as the first. + const emptyCopy = + data.suppressed === "notSignificant" + ? { + title: t("insights.mood.correlation.noLinkTitle"), + description: t("insights.mood.correlation.noLinkDescription", { + n: data.n, + }), + } + : data.n > 0 + ? { + title: t("insights.mood.correlation.emptyTitle"), + description: t("insights.mood.correlation.pendingDescription", { + n: data.n, + needed: MIN_PAIRED_DAYS, + }), + } + : { + title: t("insights.mood.correlation.emptyTitle"), + description: t("insights.mood.correlation.emptyDescription"), + }; return ( } - title={t("insights.mood.correlation.emptyTitle")} - description={t("insights.mood.correlation.emptyDescription")} + title={emptyCopy.title} + description={emptyCopy.description} /> )} diff --git a/src/lib/insights/__tests__/mood-aggregates.test.ts b/src/lib/insights/__tests__/mood-aggregates.test.ts index 50d04848f..05a3bc8de 100644 --- a/src/lib/insights/__tests__/mood-aggregates.test.ts +++ b/src/lib/insights/__tests__/mood-aggregates.test.ts @@ -292,25 +292,43 @@ describe("computeHeatmapCells", () => { describe("computeMoodMetricCorrelation", () => { it("pairs on dayOffset and returns scatter points + a coefficient", () => { - const moodDaily = [ - { dayOffset: 0, value: 5 }, - { dayOffset: 1, value: 4 }, - { dayOffset: 2, value: 3 }, - { dayOffset: 3, value: 2 }, - { dayOffset: 4, value: 1 }, - ]; + // Twenty days, not five. This test used to run on five and assert a + // coefficient came back, which pinned the very thing the significance + // gate exists to refuse: five points that happen to line up are not a + // finding. The pairing and the scatter projection are what it is asking + // about, and they are unchanged — it just has to ask above the bar. + const moodDaily = Array.from({ length: 20 }, (_, i) => ({ + dayOffset: i, + value: 5 - (i % 5), + })); // Perfectly positively correlated metric. const metricDaily = moodDaily.map((b) => ({ dayOffset: b.dayOffset, value: b.value * 10, })); const corr = computeMoodMetricCorrelation(moodDaily, metricDaily, NOW); - expect(corr.n).toBe(5); - expect(corr.points).toHaveLength(5); + expect(corr.n).toBe(20); + expect(corr.points).toHaveLength(20); expect(corr.result?.r).toBe(1); + expect(corr.suppressed).toBeUndefined(); expect(corr.points[0]).toEqual({ x: 5, y: 50 }); }); + it("refuses the same shape at five days", () => { + const moodDaily = Array.from({ length: 5 }, (_, i) => ({ + dayOffset: i, + value: 5 - i, + })); + const metricDaily = moodDaily.map((b) => ({ + dayOffset: b.dayOffset, + value: b.value * 10, + })); + const corr = computeMoodMetricCorrelation(moodDaily, metricDaily, NOW); + expect(corr.n).toBe(5); + expect(corr.result).toBeNull(); + expect(corr.suppressed).toBe("insufficientPairs"); + }); + it("returns a null coefficient below the minimum pair count", () => { const moodDaily = [ { dayOffset: 0, value: 5 }, diff --git a/src/lib/insights/__tests__/mood-metric-correlation-significance.test.ts b/src/lib/insights/__tests__/mood-metric-correlation-significance.test.ts new file mode 100644 index 000000000..7f4456c77 --- /dev/null +++ b/src/lib/insights/__tests__/mood-metric-correlation-significance.test.ts @@ -0,0 +1,78 @@ +/** + * The mood page's cross-metric correlations clear the same bar as every + * other user-facing correlation surface. + * + * `significantPearsonCorrelation` was written for exactly this failure — its + * own comment names a five-point r ≈ 0.7 fluke rendering as "stark" — and the + * weight, blood-pressure and mood-status surfaces were moved onto it. The + * mood page stayed on the plain `pearsonCorrelation` with its floor of five + * pairs and no significance test, so the same five points that the other + * surfaces refuse still painted a "strong" badge here. + * + * These assertions are about what the reader is told, not about which helper + * is called: below the bar there is no coefficient to show, and the card is + * handed a reason it can name instead of a blank. + */ +import { describe, expect, it } from "vitest"; + +import { + computeMoodMetricCorrelation, + type DailyPoint, +} from "@/lib/insights/mood-aggregates"; + +const NOW = new Date("2026-06-01T12:00:00.000Z"); + +/** One point per day-offset, values taken from the list in order. */ +function series(values: number[]): DailyPoint[] { + return values.map((value, i) => ({ dayOffset: i, value })); +} + +describe("computeMoodMetricCorrelation — significance gate", () => { + it("refuses a five-day strong-looking slope and says the pairs are too few", () => { + const mood = series([1, 2, 3, 4, 5]); + const metric = series([1, 2, 2, 4, 3]); + + const out = computeMoodMetricCorrelation(mood, metric, NOW); + + expect(out.n).toBe(5); + // No coefficient reaches the card, so no strength band can be painted. + expect(out.result).toBeNull(); + // ... and the card is told WHY, so it can say so rather than showing an + // unexplained blank. + expect(out.suppressed).toBe("insufficientPairs"); + }); + + it("refuses twenty noisy days as not significant rather than as too few", () => { + const ys = [5, 2, 7, 1, 6, 3, 8, 2, 5, 4, 6, 1, 7, 3, 5, 2, 6, 4, 5, 3]; + const mood = series(ys.map((_, i) => i)); + const metric = series(ys); + + const out = computeMoodMetricCorrelation(mood, metric, NOW); + + expect(out.n).toBe(20); + expect(out.result).toBeNull(); + expect(out.suppressed).toBe("notSignificant"); + }); + + it("surfaces twenty days of a real relationship", () => { + const mood = series(Array.from({ length: 20 }, (_, i) => i)); + const metric = series( + Array.from({ length: 20 }, (_, i) => i * 2 + (i % 2 === 0 ? 0.3 : -0.3)), + ); + + const out = computeMoodMetricCorrelation(mood, metric, NOW); + + expect(out.suppressed).toBeUndefined(); + expect(out.result).not.toBeNull(); + expect(out.result!.n).toBe(20); + expect(out.result!.strength).toBe("stark"); + }); + + it("reports no overlap as too few pairs, not as an insignificant finding", () => { + const out = computeMoodMetricCorrelation(series([3, 4]), [], NOW); + + expect(out.n).toBe(0); + expect(out.result).toBeNull(); + expect(out.suppressed).toBe("insufficientPairs"); + }); +}); diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index f829df5f6..ed2c0a633 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -20,10 +20,11 @@ import { prisma } from "@/lib/db"; import { - pearsonCorrelation, + significantPearsonCorrelation, type CorrelationResult, type PairedPoint, } from "@/lib/analytics/correlations"; +import { MIN_PAIRED_N } from "@/lib/insights/correlations"; import { applyPayloadBudget, dayOffsetToBerlinDayKey, @@ -384,11 +385,20 @@ export interface CorrelationScatterPoint { [key: string]: number; } +/** Why a pairing produced no coefficient. */ +export type MoodCorrelationSuppression = "insufficientPairs" | "notSignificant"; + export interface MoodMetricCorrelation { result: CorrelationResult | null; /** Mood (x) vs metric (y) scatter points, paired by day. */ points: CorrelationScatterPoint[]; n: number; + /** + * Which bar the pairing fell short of, so the card can name it rather + * than showing a blank that reads the same whether nothing was logged or + * a full year of days simply did not line up. Absent when `result` is set. + */ + suppressed?: MoodCorrelationSuppression; } /** @@ -396,6 +406,14 @@ export interface MoodMetricCorrelation { * series, paired on `dayOffset`. Returns the scatter points so the * caller can paint a `` from the same pairing * the coefficient was computed over. + * + * This routes through `significantPearsonCorrelation` — n >= 20 AND + * p < 0.05 — for the same reason the weight, blood-pressure and + * mood-status surfaces do: the plain `pearsonCorrelation` bands from |r| + * alone above a floor of five pairs, so five days of noise sloping the + * right way arrive at the reader labelled "strong". A mood page is where + * that costs the most, because the reader is looking for something to + * change about their week. */ export function computeMoodMetricCorrelation( moodDaily: DailyPoint[], @@ -404,9 +422,16 @@ export function computeMoodMetricCorrelation( tz: string = DEFAULT_TIMEZONE, ): MoodMetricCorrelation { const pairs = pairDailyBuckets(moodDaily, metricDaily, now, tz); - const result = pearsonCorrelation(pairs); const points = pairs.map((p) => ({ x: p.a, y: p.b })); - return { result, points, n: pairs.length }; + const result = significantPearsonCorrelation(pairs); + if (result) return { result, points, n: pairs.length }; + return { + result: null, + points, + n: pairs.length, + suppressed: + pairs.length < MIN_PAIRED_N ? "insufficientPairs" : "notSignificant", + }; } // --- Orchestrated aggregate shape ---