mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
fix(insights): gate the mood correlation cards on significance
The mood page called the plain `pearsonCorrelation` — strength banded from |r| alone above a floor of five pairs, no significance test — so five days of noise sloping the right way arrived at the reader as a "Strong" badge, with n and r behind an info icon. The guarded twin was written for exactly that failure and names it in its own comment, and the weight, blood-pressure and mood-status surfaces had already moved onto it. The mood page had not. Route the page's five cross-metric channels through `significantPearsonCorrelation` (n >= 20 AND p < 0.05) and carry the reason a pairing was refused, so the card can distinguish "still building" from "twenty days and nothing in them" instead of showing the same blank for both. New copy in all seven locales. Add a structural guard that fails when a new caller of the unguarded helper appears outside a written allowlist. Two callers remain on it, each named as open work rather than as a blessing: the /insights envelope and the AI feature payload both still band at n >= 5, and moving them changes payload shapes their own tests pin.
This commit is contained in:
1 parent
214b268b41
commit
f72e236b8a
13 files changed
+416
-16
No files matched your search
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 `<ScatterCorrelationChart>` 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 ---
|
||||
|
||||
Reference in new issue
Block a user