feat(insights): correlate rated mood factors against vitals

Rated mood factors (work / sleep-quality / stress, scored per entry) now
participate in the cross-metric correlation surface. Until now their score
series was captured but read by zero analytics.

Two paths, both on the existing FDR-controlled engine:

- A factor x vital crosstab on the mood insights page and the mood
  snapshot: for each factor the user scores, split its days low vs high by
  the factor's own median, then run the same Welch test + day floors +
  Benjamini-Hochberg correction the tag crosstab uses. Surfaces lines like
  "sleep runs ~38 min below baseline on the days you rated work low"
  (n-gated, q <= 0.10, never fabricated on thin data).
- Factor channels (FACTOR:<key>) fold into the period-narrative discovery
  matrix as both lag source and lag target, so the drivers the coach and
  narrative reference can cite a factor relationship.

An inverse factor (higher rating = worse day) gets the documented sign-flip
once, at the series boundary, so "up" always reads as a better day and the
low/high split and phrasing stay honest. Strings land in all six locales;
the surface carries the standing "association, not cause" caption.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 23:58:40 +02:00
1 parent 30a7437f4d
commit dfda2a1c7e
15 files changed
+1133 -17

No files matched your search

+20
View File
@@ -2645,6 +2645,26 @@
"detailSameDay": "Durchschnittlich {withAvg} {unit} an den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} ohne.",
"detailNextDay": "Durchschnittlich {withAvg} {unit} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} sonst."
},
"factorCrosstab": {
"title": "Deine bewerteten Faktoren und deine Vitalwerte",
"description": "Wie sich ein Vitalwert an den Tagen unterscheidet, an denen du einen Faktor niedrig bewertet hast, verglichen mit den Tagen mit hoher Bewertung.",
"pairLabel": "{factor} · {metric}",
"metricSleepDuration": "Schlaf",
"metricSteps": "Schritte",
"metricRestingHeartRate": "Ruhepuls",
"metricHrv": "Herzfrequenzvariabilität",
"metricWeight": "Gewicht",
"metricBloodPressureSys": "Systolisch",
"unitHours": "Std.",
"unitScore": "Pkt.",
"unitSteps": "Schritte",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitMmHg": "mmHg",
"detail": "Durchschnittlich {lowAvg} {unit} an den {lowDays} Tagen mit niedriger {factor}-Bewertung gegenüber {highAvg} {unit} an den Tagen mit hoher Bewertung.",
"detailInverse": "Durchschnittlich {lowAvg} {unit} an deinen {lowDays} schlechteren {factor}-Tagen gegenüber {highAvg} {unit} an deinen besseren."
},
"discovery": {
"title": "Was auffällt",
"subheading": "Entdeckte Zusammenhänge",
+20
View File
@@ -2645,6 +2645,26 @@
"detailSameDay": "Averages {withAvg} {unit} on the {withDays} days you tagged this vs {withoutAvg} {unit} without it.",
"detailNextDay": "Averages {withAvg} {unit} the day after the {withDays} days you tagged this vs {withoutAvg} {unit} otherwise."
},
"factorCrosstab": {
"title": "Your rated factors and your vitals",
"description": "How a vital differs on the days you rated each factor low compared with the days you rated it high.",
"pairLabel": "{factor} · {metric}",
"metricSleepDuration": "Sleep",
"metricSteps": "Steps",
"metricRestingHeartRate": "Resting heart rate",
"metricHrv": "Heart-rate variability",
"metricWeight": "Weight",
"metricBloodPressureSys": "Systolic",
"unitHours": "h",
"unitScore": "pts",
"unitSteps": "steps",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitMmHg": "mmHg",
"detail": "Averages {lowAvg} {unit} on the {lowDays} days you rated {factor} low vs {highAvg} {unit} on the days you rated it high.",
"detailInverse": "Averages {lowAvg} {unit} on your {lowDays} worse {factor} days vs {highAvg} {unit} on your better ones."
},
"discovery": {
"title": "What stands out",
"subheading": "Discovered relationships",
+20
View File
@@ -2645,6 +2645,26 @@
"detailSameDay": "Promedia {withAvg} {unit} en los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} sin él.",
"detailNextDay": "Promedia {withAvg} {unit} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} el resto."
},
"factorCrosstab": {
"title": "Tus factores valorados y tus constantes",
"description": "Cómo difiere una constante en los días en que valoraste bajo cada factor frente a los días en que lo valoraste alto.",
"pairLabel": "{factor} · {metric}",
"metricSleepDuration": "Sueño",
"metricSteps": "Pasos",
"metricRestingHeartRate": "Frecuencia cardíaca en reposo",
"metricHrv": "Variabilidad de la frecuencia cardíaca",
"metricWeight": "Peso",
"metricBloodPressureSys": "Sistólica",
"unitHours": "h",
"unitScore": "pts",
"unitSteps": "pasos",
"unitBpm": "ppm",
"unitMs": "ms",
"unitKg": "kg",
"unitMmHg": "mmHg",
"detail": "Promedia {lowAvg} {unit} en los {lowDays} días que valoraste {factor} bajo frente a {highAvg} {unit} en los días que lo valoraste alto.",
"detailInverse": "Promedia {lowAvg} {unit} en tus {lowDays} peores días de {factor} frente a {highAvg} {unit} en los mejores."
},
"discovery": {
"title": "Lo que destaca",
"subheading": "Relaciones descubiertas",
+20
View File
@@ -2645,6 +2645,26 @@
"detailSameDay": "En moyenne {withAvg} {unit} les {withDays} jours avec ce tag contre {withoutAvg} {unit} sans.",
"detailNextDay": "En moyenne {withAvg} {unit} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} {unit} sinon."
},
"factorCrosstab": {
"title": "Vos facteurs notés et vos constantes",
"description": "Comment une constante diffère les jours où vous avez noté un facteur bas par rapport aux jours où vous l'avez noté haut.",
"pairLabel": "{factor} · {metric}",
"metricSleepDuration": "Sommeil",
"metricSteps": "Pas",
"metricRestingHeartRate": "Fréquence cardiaque au repos",
"metricHrv": "Variabilité de la fréquence cardiaque",
"metricWeight": "Poids",
"metricBloodPressureSys": "Systolique",
"unitHours": "h",
"unitScore": "pts",
"unitSteps": "pas",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitMmHg": "mmHg",
"detail": "En moyenne {lowAvg} {unit} les {lowDays} jours où vous avez noté {factor} bas contre {highAvg} {unit} les jours où vous l'avez noté haut.",
"detailInverse": "En moyenne {lowAvg} {unit} sur vos {lowDays} pires jours de {factor} contre {highAvg} {unit} sur les meilleurs."
},
"discovery": {
"title": "Ce qui ressort",
"subheading": "Relations découvertes",
+20
View File
@@ -2645,6 +2645,26 @@
"detailSameDay": "In media {withAvg} {unit} nei {withDays} giorni con questo tag contro {withoutAvg} {unit} senza.",
"detailNextDay": "In media {withAvg} {unit} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} {unit} altrimenti."
},
"factorCrosstab": {
"title": "I tuoi fattori valutati e i tuoi parametri vitali",
"description": "Come cambia un parametro vitale nei giorni in cui hai valutato basso un fattore rispetto ai giorni in cui lo hai valutato alto.",
"pairLabel": "{factor} · {metric}",
"metricSleepDuration": "Sonno",
"metricSteps": "Passi",
"metricRestingHeartRate": "Frequenza cardiaca a riposo",
"metricHrv": "Variabilità della frequenza cardiaca",
"metricWeight": "Peso",
"metricBloodPressureSys": "Sistolica",
"unitHours": "h",
"unitScore": "pti",
"unitSteps": "passi",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitMmHg": "mmHg",
"detail": "In media {lowAvg} {unit} nei {lowDays} giorni in cui hai valutato basso {factor} contro {highAvg} {unit} nei giorni in cui lo hai valutato alto.",
"detailInverse": "In media {lowAvg} {unit} nei tuoi {lowDays} giorni peggiori di {factor} contro {highAvg} {unit} in quelli migliori."
},
"discovery": {
"title": "Cosa risalta",
"subheading": "Relazioni scoperte",
+20
View File
@@ -2645,6 +2645,26 @@
"detailSameDay": "Średnio {withAvg} {unit} w {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} bez niego.",
"detailNextDay": "Średnio {withAvg} {unit} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} w pozostałe dni."
},
"factorCrosstab": {
"title": "Twoje ocenione czynniki i parametry życiowe",
"description": "Jak parametr życiowy różni się w dniach, w których oceniłeś dany czynnik nisko, w porównaniu z dniami, w których oceniłeś go wysoko.",
"pairLabel": "{factor} · {metric}",
"metricSleepDuration": "Sen",
"metricSteps": "Kroki",
"metricRestingHeartRate": "Tętno spoczynkowe",
"metricHrv": "Zmienność rytmu serca",
"metricWeight": "Waga",
"metricBloodPressureSys": "Skurczowe",
"unitHours": "godz.",
"unitScore": "pkt",
"unitSteps": "kroki",
"unitBpm": "bpm",
"unitMs": "ms",
"unitKg": "kg",
"unitMmHg": "mmHg",
"detail": "Średnio {lowAvg} {unit} w {lowDays} dniach z niską oceną {factor} wobec {highAvg} {unit} w dniach z wysoką oceną.",
"detailInverse": "Średnio {lowAvg} {unit} w Twoich {lowDays} gorszych dniach {factor} wobec {highAvg} {unit} w lepszych."
},
"discovery": {
"title": "Co się wyróżnia",
"subheading": "Wykryte relacje",
@@ -0,0 +1,175 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { cn } from "@/lib/utils";
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
/**
* v1.14.0 — RATED-factor × vital crosstab card.
*
* The cross-domain bridge: for each factor the user scores per entry (work /
* sleep-quality / stress …), a vital's mean on the days the factor was rated
* LOW (below its median) vs HIGH, the delta, and a confidence chip. The math
* is pre-computed in `mood-aggregates.ts` (`computeFactorMetricCrosstab`) —
* the same Welch t-test + day floors + Benjamini-Hochberg FDR the rest of the
* relations surface uses; only FDR-surviving rows reach here. An inverse
* factor (stress / conflict) already runs its split on the flipped series, so
* "low" always means a worse day; the phrasing reads "your worse <factor>
* days". Observational only: the page-level "associations, not causes" footer
* carries the caveat once.
*/
export type MoodFactorCrosstabDisplay =
| "hours"
| "score"
| "steps"
| "bpm"
| "ms"
| "kg"
| "mmHg";
export type MoodFactorCrosstabMode = "sameDay" | "nextDay";
export interface MoodFactorMetricCrosstabRow {
factor: string;
labelKey: string;
categoryKey: string;
icon: string | null;
inverse: boolean;
metricKey: string;
display: MoodFactorCrosstabDisplay;
mode: MoodFactorCrosstabMode;
lowDays: number;
highDays: number;
lowAvg: number;
highAvg: number;
delta: number;
pValue: number;
qValue: number;
confidence: MoodInfluenceConfidence;
}
const METRIC_LABEL_KEY: Record<string, string> = {
sleepDuration: "insights.mood.factorCrosstab.metricSleepDuration",
steps: "insights.mood.factorCrosstab.metricSteps",
restingHeartRate: "insights.mood.factorCrosstab.metricRestingHeartRate",
heartRateVariability: "insights.mood.factorCrosstab.metricHrv",
weight: "insights.mood.factorCrosstab.metricWeight",
bloodPressureSystolic: "insights.mood.factorCrosstab.metricBloodPressureSys",
};
const UNIT_KEY: Record<MoodFactorCrosstabDisplay, string> = {
hours: "insights.mood.factorCrosstab.unitHours",
score: "insights.mood.factorCrosstab.unitScore",
steps: "insights.mood.factorCrosstab.unitSteps",
bpm: "insights.mood.factorCrosstab.unitBpm",
ms: "insights.mood.factorCrosstab.unitMs",
kg: "insights.mood.factorCrosstab.unitKg",
mmHg: "insights.mood.factorCrosstab.unitMmHg",
};
const CONFIDENCE_KEY: Record<MoodInfluenceConfidence, string> = {
low: "insights.mood.influence.confidenceLow",
medium: "insights.mood.influence.confidenceMedium",
high: "insights.mood.influence.confidenceHigh",
};
const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
low: "bg-secondary text-muted-foreground",
medium: "bg-info/15 text-info",
high: "bg-success/15 text-success",
};
/** Whole numbers for steps; one decimal otherwise. */
function fmt(value: number, display: MoodFactorCrosstabDisplay): string {
return display === "steps" ? Math.round(value).toString() : value.toFixed(1);
}
export function MoodFactorMetricCrosstab({
rows,
}: {
rows: MoodFactorMetricCrosstabRow[];
}) {
const { t } = useTranslations();
if (rows.length === 0) return null;
return (
<div data-slot="mood-factor-metric-crosstab">
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.factorCrosstab.description")}
</p>
<ul className="divide-border divide-y">
{rows.map((row) => {
const Icon = moodTagIcon(row.icon);
const factorLabel = t(row.labelKey);
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
const unit = t(UNIT_KEY[row.display]);
// `delta` = lowAvg − highAvg: negative = vital runs lower on the
// low-factor (worse) days. The read-out is colored by sign.
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
const deltaColor = up ? "var(--success)" : "var(--destructive)";
return (
<li
key={`${row.metricKey}:${row.factor}`}
className="flex flex-col gap-1.5 py-2"
data-slot="mood-factor-crosstab-row"
data-metric={row.metricKey}
data-direction={up ? "up" : "down"}
data-confidence={row.confidence}
>
<div className="flex items-center gap-2 text-sm">
{Icon && (
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
)}
<span
className="text-foreground min-w-0 flex-1 truncate"
title={`${factorLabel} · ${metricLabel}`}
>
{t("insights.mood.factorCrosstab.pairLabel", {
factor: factorLabel,
metric: metricLabel,
})}
</span>
<span
className="shrink-0 text-sm font-semibold tabular-nums"
style={{ color: deltaColor }}
>
{deltaText}
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
CONFIDENCE_CLASS[row.confidence],
)}
>
{t(CONFIDENCE_KEY[row.confidence])}
</span>
</div>
<p className="text-muted-foreground text-xs">
{t(
row.inverse
? "insights.mood.factorCrosstab.detailInverse"
: "insights.mood.factorCrosstab.detail",
{
factor: factorLabel,
metric: metricLabel,
lowAvg: fmt(row.lowAvg, row.display),
highAvg: fmt(row.highAvg, row.display),
unit,
lowDays: row.lowDays,
},
)}
</p>
</li>
);
})}
</ul>
</div>
);
}
@@ -9,6 +9,7 @@ import {
CalendarDays,
CalendarRange,
Clock,
Gauge,
Grid3x3,
Link2,
Sparkles,
@@ -94,6 +95,10 @@ import {
MoodTagMetricCrosstab,
type MoodTagMetricCrosstabRow,
} from "./mood-tag-metric-crosstab";
import {
MoodFactorMetricCrosstab,
type MoodFactorMetricCrosstabRow,
} from "./mood-factor-metric-crosstab";
/**
* v1.8.5 — additional Mood Insights sections.
@@ -130,6 +135,9 @@ interface MoodInsightsResponse {
// Optional only to tolerate a stale pre-v1.12.0 cached payload during a
// rollout; the live endpoint always populates it.
tagMetricCrosstab?: MoodTagMetricCrosstabRow[];
// Optional only to tolerate a stale pre-v1.14.0 cached payload during a
// rollout; the live endpoint always populates it.
factorCrosstab?: MoodFactorMetricCrosstabRow[];
narratives: MoodNarrativeItem[];
correlations: {
sleep: MoodMetricCorrelationData;
@@ -244,6 +252,8 @@ export function MoodInsightsSections({
const hasBetterDays = betterDays.length > 0;
const crosstabRows = data.tagMetricCrosstab ?? [];
const hasCrosstab = crosstabRows.length > 0;
const factorCrosstabRows = data.factorCrosstab ?? [];
const hasFactorCrosstab = factorCrosstabRows.length > 0;
// v1.12.7 — the Stimmungskalender is lifted above the line chart on the page,
// so it renders as its own region here.
@@ -368,7 +378,17 @@ export function MoodInsightsSections({
</SectionCard>
)}
{hasFactorCrosstab && (
<SectionCard
title={t("insights.mood.factorCrosstab.title")}
icon={Gauge}
>
<MoodFactorMetricCrosstab rows={factorCrosstabRows} />
</SectionCard>
)}
<SectionCard title={t("insights.mood.correlationsTitle")} icon={Link2}>
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.correlationsDescription")}
</p>
@@ -94,6 +94,28 @@ describe("discoverCorrelations", () => {
expect(result.pairsTested).toBe(1);
expect(result.discovered).toHaveLength(0);
});
it("surfaces a RATED-factor channel and humanises its namespace prefix", () => {
// A `FACTOR:work` behaviour whose next-day sleep tracks it linearly.
const n = 30;
const factorVals = Array.from({ length: n }, (_, i) => (i % 5) + 1);
const sleepVals = [400, ...factorVals.slice(0, n - 1).map((v) => v * 30 + 300)];
const result = discoverCorrelations(
[
{ key: "FACTOR:work", role: "behaviour", points: series(factorVals) },
{ key: "SLEEP_DURATION", role: "outcome", points: series(sleepVals) },
],
{ fdrQ: 0.1 },
);
expect(result.discovered).toHaveLength(1);
const pair = result.discovered[0];
expect(pair.behaviour).toBe("FACTOR:work");
// The interpretation strips the namespace and reads "rated work", never
// the raw key, and stays causation-banned.
expect(pair.interpretation).toContain("rated work");
expect(pair.interpretation).not.toContain("FACTOR:");
expect(pair.interpretation).toMatch(/not a cause/);
});
});
// ── F3 — MOOD as a discoverable OUTCOME channel ─────────────────────
@@ -16,6 +16,8 @@ import {
computeTagInfluence,
computeBetterDays,
computeTagMetricCrosstab,
computeFactorMetricCrosstab,
buildFactorDailySeries,
CROSSTAB_MIN_PRESENT_DAYS,
CROSSTAB_MIN_ABSENT_DAYS,
CROSSTAB_MAX_ROWS,
@@ -29,6 +31,7 @@ import {
type CrossMetricMeasurement,
type MoodAggregateEntry,
type MoodMetricCorrelation,
type RatedFactorScore,
type StructuredTagRef,
type TagInfluence,
} from "../mood-aggregates";
@@ -1094,3 +1097,158 @@ describe("computeTagMetricCrosstab", () => {
expect(row!.withoutAvg).toBeCloseTo(6, 1); // 3 × 120 = 360 min = 6 h
});
});
describe("buildFactorDailySeries", () => {
function factorEntry(
offset: number,
score: number,
factors: RatedFactorScore[],
): MoodAggregateEntry {
return { ...entry(offset, score, null), ratedFactors: factors };
}
const WORK = (rating: number, inverse = false): RatedFactorScore => ({
key: "work",
categoryKey: "life",
labelKey: "mood.tag.work",
icon: "Briefcase",
rating,
scaleMin: 1,
scaleMax: 5,
inverse,
});
it("means a factor's ratings per day", () => {
const entries = [
// two entries same day → mean of (4, 2) = 3.
{ ...factorEntry(0, 4, [WORK(4)]) },
{ ...factorEntry(0, 2, [WORK(2)]) },
factorEntry(1, 3, [WORK(5)]),
];
const series = buildFactorDailySeries(entries, NOW, 365);
const work = series.get("work");
expect(work).toBeDefined();
expect(work!.byDay.get(dayKey(0))).toBeCloseTo(3, 5);
expect(work!.byDay.get(dayKey(1))).toBeCloseTo(5, 5);
expect(work!.ref.inverse).toBe(false);
});
it("flips an inverse factor across its scale midpoint so up = better", () => {
// inverse stress rated 5 (worst) maps to (1+5)-5 = 1 (worst on the
// flipped axis); rated 1 (best) maps to 5.
const entries = [
factorEntry(0, 2, [WORK(5, true)]),
factorEntry(1, 4, [WORK(1, true)]),
];
const series = buildFactorDailySeries(entries, NOW, 365);
const work = series.get("work")!;
expect(work.byDay.get(dayKey(0))).toBeCloseTo(1, 5);
expect(work.byDay.get(dayKey(1))).toBeCloseTo(5, 5);
expect(work.ref.inverse).toBe(true);
});
});
describe("computeFactorMetricCrosstab", () => {
function factorEntry(
offset: number,
score: number,
factors: RatedFactorScore[],
): MoodAggregateEntry {
return { ...entry(offset, score, null), ratedFactors: factors };
}
function meas(offset: number, type: string, value: number): CrossMetricMeasurement {
return { type, value, measuredAt: new Date(NOW.getTime() - offset * dayMs) };
}
const work = (rating: number, inverse = false): RatedFactorScore => ({
key: "work",
categoryKey: "life",
labelKey: "mood.tag.work",
icon: "Briefcase",
rating,
scaleMin: 1,
scaleMax: 5,
inverse,
});
it("surfaces a low-factor-day vital deviation via the median split", () => {
const entries: MoodAggregateEntry[] = [];
const measurements: CrossMetricMeasurement[] = [];
// 12 LOW-work days (rating 2, below the median of 3.5) with short sleep
// (~6 h = 360 min); 12 HIGH-work days (rating 5) with long sleep (~8 h).
for (let i = 0; i < 12; i++) {
entries.push(factorEntry(i, 3, [work(2)]));
measurements.push(meas(i, "SLEEP_DURATION", 360 + (i % 2 === 0 ? 6 : -6)));
}
for (let i = 12; i < 24; i++) {
entries.push(factorEntry(i, 4, [work(5)]));
measurements.push(meas(i, "SLEEP_DURATION", 480 + (i % 2 === 0 ? 6 : -6)));
}
const rows = computeFactorMetricCrosstab({ entries, measurements, now: NOW });
const row = rows.find((r) => r.factor === "work" && r.metricKey === "sleepDuration");
expect(row).toBeDefined();
expect(row!.display).toBe("hours");
expect(row!.lowDays).toBe(12);
expect(row!.highDays).toBe(12);
expect(row!.lowAvg).toBeCloseTo(6, 1);
expect(row!.highAvg).toBeCloseTo(8, 1);
// delta = lowAvg − highAvg → negative: sleep runs lower on low-work days.
expect(row!.delta).toBeLessThan(0);
expect(row!.delta).toBeCloseTo(-2, 1);
expect(row!.confidence).toBe("high");
expect(row!.qValue).toBeLessThanOrEqual(0.1);
expect(row!.inverse).toBe(false);
});
it("carries the inverse flag through and still splits worse-vs-better on the flipped axis", () => {
const entries: MoodAggregateEntry[] = [];
const measurements: CrossMetricMeasurement[] = [];
// stress is inverse: rating 5 = worst day. On the flipped axis it becomes
// 1 (low/worse). Worse-stress days carry higher next-day RHR (D → D+1).
for (let i = 1; i <= 12; i++) {
entries.push(factorEntry(i, 3, [work(5, true)])); // worse → flipped low
measurements.push(meas(i - 1, "RESTING_HEART_RATE", 64 + (i % 2 === 0 ? 1 : -1)));
}
for (let i = 13; i <= 24; i++) {
entries.push(factorEntry(i, 4, [work(1, true)])); // better → flipped high
measurements.push(meas(i - 1, "RESTING_HEART_RATE", 56 + (i % 2 === 0 ? 1 : -1)));
}
const rows = computeFactorMetricCrosstab({ entries, measurements, now: NOW });
const row = rows.find((r) => r.metricKey === "restingHeartRate");
expect(row).toBeDefined();
expect(row!.mode).toBe("nextDay");
expect(row!.inverse).toBe(true);
// worse-stress (low flipped) days → higher RHR; delta = low − high > 0.
expect(row!.lowAvg).toBeCloseTo(64, 0);
expect(row!.highAvg).toBeCloseTo(56, 0);
expect(row!.delta).toBeGreaterThan(0);
});
it("drops a factor with too few rated days to split defensibly", () => {
const entries: MoodAggregateEntry[] = [];
const measurements: CrossMetricMeasurement[] = [];
// only 6 rated days < 5 + 5 floor for a clean median split.
for (let i = 0; i < 6; i++) {
entries.push(factorEntry(i, 3, [work(i < 3 ? 2 : 5)]));
measurements.push(meas(i, "SLEEP_DURATION", i < 3 ? 360 : 480));
}
expect(CROSSTAB_MIN_PRESENT_DAYS + CROSSTAB_MIN_ABSENT_DAYS).toBeGreaterThan(6);
const rows = computeFactorMetricCrosstab({ entries, measurements, now: NOW });
expect(rows.find((r) => r.factor === "work")).toBeUndefined();
});
it("returns nothing when there are no rated factors at all", () => {
const entries: MoodAggregateEntry[] = [];
const measurements: CrossMetricMeasurement[] = [];
for (let i = 0; i < 24; i++) {
entries.push(entry(i, 3, null));
measurements.push(meas(i, "SLEEP_DURATION", 420));
}
expect(computeFactorMetricCrosstab({ entries, measurements, now: NOW })).toEqual([]);
});
it("caps the surface at CROSSTAB_MAX_ROWS", () => {
expect(CROSSTAB_MAX_ROWS).toBeGreaterThan(0);
});
});
+14 -1
View File
@@ -161,11 +161,24 @@ function interpret(behaviour: string, outcome: string, r: number): string {
return `${b} and next-day ${o} move together only weakly in this window.`;
}
/** Lower-case, space-separated label from a channel key. */
/**
* Lower-case, space-separated label from a channel key.
*
* v1.14.0 — a `FACTOR:<key>` channel (a RATED mood factor folded into the
* matrix) strips its namespace prefix so the phrasing reads "rated <factor>"
* rather than leaking the raw key. The prefix exists only to keep a factor
* key from colliding with a `MeasurementType` in the channel set.
*/
function humanise(key: string): string {
if (key.startsWith("FACTOR:")) {
return `rated ${key.slice("FACTOR:".length).replace(/[_-]/g, " ").toLowerCase()}`;
}
return key.replace(/_/g, " ").toLowerCase();
}
/** Namespace prefix for a RATED-mood-factor discovery channel (v1.14.0). */
export const FACTOR_CHANNEL_PREFIX = "FACTOR:";
/**
* Run the FDR-controlled discovery over the behaviour × outcome matrix.
*
+397 -6
View File
@@ -109,6 +109,44 @@ export interface MoodAggregateEntry {
* aggregate tests (flat tags only) keep their narrow fixtures.
*/
structuredTags?: StructuredTagRef[];
/**
* v1.14.0 — per-entry RATED factor scores (work / sleep-quality /
* stress …) from the same `mood_entry_tag_links` join, but carrying the
* numeric `rating` instead of a present/absent flag. Optional so legacy
* fixtures keep their narrow shape. A RATED factor is a *continuous*
* daily signal, fed into the factor crosstab (low-vs-high-day vital
* deviation) and the discovery matrix — distinct from BINARY structured
* tags, which carry no `rating`.
*/
ratedFactors?: RatedFactorScore[];
}
/**
* A RATED mood factor's score on a single entry, resolved from the
* catalog. Carries the scale + `inverse` flag so the analytics layer can
* apply the documented sign-flip ("higher rating = worse day" → flip so
* "up" always reads as better) once, at the series boundary.
*/
export interface RatedFactorScore {
/** Stable factor key (e.g. `work`). */
key: string;
/** Parent category key. */
categoryKey: string;
/** i18n message key for the factor label. */
labelKey: string;
/** Lucide icon name, or null. */
icon: string | null;
/** The per-entry rating, bounded to `scaleMin..scaleMax` at ingest. */
rating: number;
/** Inclusive scale bounds (default 1..5). */
scaleMin: number;
scaleMax: number;
/**
* `true` when a higher rating means a WORSE day (stress / conflict).
* The series builder flips an inverse factor's rating to
* `(scaleMin + scaleMax) - rating` so "up" always reads as better.
*/
inverse: boolean;
}
/**
@@ -1043,6 +1081,313 @@ export function computeTagMetricCrosstab(args: {
.slice(0, CROSSTAB_MAX_ROWS);
}
// --- RATED factor × health-metric crosstab (low- vs high-factor days) ---
/**
* v1.14.0 — the flagship cross-domain insight: "on days you rated <factor>
* low, your <vital> ran X below baseline".
*
* A RATED mood factor (work / sleep-quality / stress …) is a CONTINUOUS
* per-day score, unlike the BINARY structured tags `computeTagMetricCrosstab`
* handles. To reuse the EXACT same Welch + FDR engine, this thresholds the
* factor's daily mean into a binary membership — a day is "low" when its
* factor mean sits BELOW the factor's own median over the window, "high" at
* or above. The median split is robust + self-calibrating (always two
* non-empty groups when the factor has spread), and invents no fixed cutoff.
*
* `inverse` is applied at the SERIES boundary (the factor's per-day mean is
* flipped to `(scaleMin + scaleMax) - raw` for an inverse factor like
* stress), so the median split — and therefore the "low day" label — always
* means "a worse day for this factor", honestly, without per-call casing.
*
* Same honesty discipline as the tag crosstab: both sides need
* `CROSSTAB_MIN_PRESENT_DAYS` / `CROSSTAB_MIN_ABSENT_DAYS` paired metric
* days, the family is BH-FDR corrected as one (across every factor × metric
* × direction pair), survivors clear p < 0.05 AND q ≤ `CROSSTAB_FDR_Q`, and
* the surface is capped + ranked. Observational only — the card renders the
* standing "association, not cause" caption.
*/
/**
* The metric channels the factor crosstab pairs each RATED factor against.
* Broader than the tag crosstab (which is activity-focused) because the
* value of a RATED factor is its bridge from a SUBJECTIVE score to an
* OBJECTIVE vital. `sameDay` for same-night/same-day metrics (sleep,
* steps); `nextDay` (D → D+1) for overnight-recovery metrics (RHR / HRV),
* matching the plausible "today's factor → tomorrow's body" direction.
*/
export const FACTOR_CROSSTAB_METRICS: Record<
string,
{
type: MeasurementType;
mode: CrosstabMode;
display: "hours" | "score" | "steps" | "bpm" | "ms" | "kg" | "mmHg";
}
> = {
sleepDuration: { type: "SLEEP_DURATION", mode: "sameDay", display: "hours" },
steps: { type: "ACTIVITY_STEPS", mode: "sameDay", display: "steps" },
restingHeartRate: {
type: "RESTING_HEART_RATE",
mode: "nextDay",
display: "bpm",
},
heartRateVariability: {
type: "HEART_RATE_VARIABILITY",
mode: "nextDay",
display: "ms",
},
weight: { type: "WEIGHT", mode: "sameDay", display: "kg" },
bloodPressureSystolic: {
type: "BLOOD_PRESSURE_SYS",
mode: "sameDay",
display: "mmHg",
},
} as const;
export type FactorCrosstabMetricKey = keyof typeof FACTOR_CROSSTAB_METRICS;
/** Distinct measurement types the factor crosstab reads — single-sourced. */
export const FACTOR_CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from(
new Set(Object.values(FACTOR_CROSSTAB_METRICS).map((m) => m.type)),
);
export interface FactorMetricCrosstabRow {
/** Stable RATED-factor key. */
factor: string;
/** i18n label key for the factor. */
labelKey: string;
/** Parent category key, for grouping/icon. */
categoryKey: string;
/** Lucide icon name, or null. */
icon: string | null;
/**
* `true` when the factor is inverse-scaled (stress / conflict). The
* UI flips the phrasing — "your worse <factor> days" — but the split
* itself already runs on the flipped series, so a "low" row always
* means a worse day regardless.
*/
inverse: boolean;
/** Which metric channel this row compares against. */
metricKey: FactorCrosstabMetricKey;
/** Display unit hint for the client formatter. */
display: FactorMetricDisplay;
/** Pairing mode used (echoed so the UI can caption "next-day"). */
mode: CrosstabMode;
/** Days the factor was rated LOW (below its median) with a paired metric. */
lowDays: number;
/** Days the factor was rated HIGH (at/above its median) with a paired metric. */
highDays: number;
/** Mean metric on low-factor days (display unit). */
lowAvg: number;
/** Mean metric on high-factor days (display unit). */
highAvg: number;
/** lowAvg − highAvg (display unit). Negative = vital runs lower on low days. */
delta: number;
/** Welch two-sided p-value for the difference of means. */
pValue: number;
/** Benjamini-Hochberg adjusted q-value across the tested family. */
qValue: number;
/** Discrete confidence band (p + min per-group day count). */
confidence: InfluenceConfidence;
}
type FactorMetricDisplay = (typeof FACTOR_CROSSTAB_METRICS)[FactorCrosstabMetricKey]["display"];
/** Convert a raw metric value to its factor-crosstab display unit. */
function toFactorDisplayUnit(value: number, display: FactorMetricDisplay): number {
return display === "hours" ? value / 60 : value;
}
/** Median of a numeric array (sorted-copy, mean-of-two for even length). */
function median(values: number[]): number {
if (values.length === 0) return Number.NaN;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
}
/** Reference data for one RATED factor's daily series + its meta. */
interface FactorDailySeries {
ref: Pick<RatedFactorScore, "key" | "labelKey" | "categoryKey" | "icon" | "inverse">;
/** Day key → factor's daily-mean score, inverse-flipped if needed. */
byDay: Map<string, number>;
}
/**
* Build a per-factor daily-mean series, applying the `inverse` sign-flip
* once. For each tz-anchored day, the mean of the factor's ratings across
* the day's entries; an inverse factor's rating `r` maps to
* `(scaleMin + scaleMax) - r` BEFORE averaging so "up" always reads as a
* better day. Pure. Exported for the discovery-channel path (period
* narrative) so the two consumers can never diverge on the flip.
*/
export function buildFactorDailySeries(
entries: MoodAggregateEntry[],
now: Date,
windowDays: number,
): Map<string, FactorDailySeries> {
const cutoff = now.getTime() - windowDays * MS_PER_DAY;
const acc = new Map<
string,
{
ref: FactorDailySeries["ref"];
byDay: Map<string, { sum: number; count: number }>;
}
>();
for (const entry of entries) {
if (entry.moodLoggedAt.getTime() < cutoff) continue;
if (!entry.ratedFactors) continue;
for (const f of entry.ratedFactors) {
if (!Number.isFinite(f.rating)) continue;
// The documented sign-flip: an inverse factor's rating is mirrored
// across its scale midpoint so a higher series value always means a
// better day. Done here, once, at the series boundary.
const value = f.inverse
? f.scaleMin + f.scaleMax - f.rating
: f.rating;
const slot =
acc.get(f.key) ??
{
ref: {
key: f.key,
labelKey: f.labelKey,
categoryKey: f.categoryKey,
icon: f.icon,
inverse: f.inverse,
},
byDay: new Map<string, { sum: number; count: number }>(),
};
const cur = slot.byDay.get(entry.date) ?? { sum: 0, count: 0 };
cur.sum += value;
cur.count += 1;
slot.byDay.set(entry.date, cur);
acc.set(f.key, slot);
}
}
const out = new Map<string, FactorDailySeries>();
for (const [key, slot] of acc) {
const byDay = new Map<string, number>();
for (const [day, agg] of slot.byDay) byDay.set(day, agg.sum / agg.count);
out.set(key, { ref: slot.ref, byDay });
}
return out;
}
interface FactorCrosstabCandidate {
row: Omit<FactorMetricCrosstabRow, "qValue">;
}
/**
* Compute the RATED-factor × metric crosstab. Pure over already-fetched
* rows. Mirrors `computeTagMetricCrosstab` but splits each factor's
* continuous daily score into low/high by its own median, then runs the
* same Welch + FDR engine.
*/
export function computeFactorMetricCrosstab(args: {
entries: MoodAggregateEntry[];
measurements: CrossMetricMeasurement[];
now: Date;
windowDays?: number;
userPriorityJson?: unknown;
}): FactorMetricCrosstabRow[] {
const { entries, measurements, now } = args;
const windowDays = args.windowDays ?? 365;
const userPriorityJson = args.userPriorityJson ?? null;
const factorSeries = buildFactorDailySeries(entries, now, windowDays);
if (factorSeries.size === 0) return [];
const candidates: FactorCrosstabCandidate[] = [];
for (const [metricKey, cfg] of Object.entries(FACTOR_CROSSTAB_METRICS) as Array<
[FactorCrosstabMetricKey, (typeof FACTOR_CROSSTAB_METRICS)[FactorCrosstabMetricKey]]
>) {
const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson);
if (metricByDay.size === 0) continue;
for (const [factorKey, series] of factorSeries) {
// The median split is over the factor's own rated days only, so a
// sparse factor never borrows another's threshold.
const ratedDays = [...series.byDay.values()];
if (ratedDays.length < CROSSTAB_MIN_PRESENT_DAYS + CROSSTAB_MIN_ABSENT_DAYS) {
continue;
}
const split = median(ratedDays);
const lowVals: number[] = [];
const highVals: number[] = [];
for (const [dayKey, score] of series.byDay) {
const metricDayKey =
cfg.mode === "nextDay" ? shiftDayKey(dayKey, 1) : dayKey;
const metricValue = metricByDay.get(metricDayKey);
if (metricValue == null || !Number.isFinite(metricValue)) continue;
const display = toFactorDisplayUnit(metricValue, cfg.display);
// Below the median is a "low" (worse, after the inverse flip) day;
// at or above is "high". A perfectly bimodal factor with all days
// exactly on the median falls entirely into "high" and fails the
// low-side floor below — honest: no contrast, no row.
if (score < split) lowVals.push(display);
else highVals.push(display);
}
if (
lowVals.length < CROSSTAB_MIN_PRESENT_DAYS ||
highVals.length < CROSSTAB_MIN_ABSENT_DAYS
) {
continue;
}
const welch = welchTTest(lowVals, highVals);
const lowAvg = lowVals.reduce((s, v) => s + v, 0) / lowVals.length;
const highAvg = highVals.reduce((s, v) => s + v, 0) / highVals.length;
const delta = lowAvg - highAvg;
if (delta === 0) continue;
const pValue = welch.status === "ok" ? welch.pValue : 1;
const minGroupDays = Math.min(lowVals.length, highVals.length);
candidates.push({
row: {
factor: factorKey,
labelKey: series.ref.labelKey,
categoryKey: series.ref.categoryKey,
icon: series.ref.icon,
inverse: series.ref.inverse,
metricKey,
display: cfg.display,
mode: cfg.mode,
lowDays: lowVals.length,
highDays: highVals.length,
lowAvg: round(lowAvg, 2),
highAvg: round(highAvg, 2),
delta: round(delta, 2),
pValue,
confidence: influenceConfidence(pValue, minGroupDays),
},
});
}
}
if (candidates.length === 0) return [];
// One BH family across every factor × metric pair tested — the same
// step-up the tag crosstab + discovery engine run.
const qValues = fdrAdjust(candidates.map((c) => c.row.pValue));
return candidates
.map((c, i) => ({ ...c.row, qValue: Math.round(qValues[i] * 1000) / 1000 }))
.filter((row) => row.pValue < 0.05 && row.qValue <= CROSSTAB_FDR_Q)
.sort(
(a, b) =>
a.qValue - b.qValue ||
Math.abs(b.delta) - Math.abs(a.delta) ||
a.factor.localeCompare(b.factor),
)
.slice(0, CROSSTAB_MAX_ROWS);
}
// --- In-target % over the last 30 daily points (lifted from mood-status) ---
/**
@@ -1486,6 +1831,15 @@ export interface MoodAggregates {
* renders the standing "association, not cause" caption.
*/
tagMetricCrosstab: TagMetricCrosstabRow[];
/**
* v1.14.0 — per-RATED-factor × health-metric crosstab: for each factor
* the user scores per entry (work / sleep-quality / stress …), a vital's
* mean on the days the factor was rated LOW vs HIGH (median split, same-day
* or D→D+1), Welch-tested + FDR-corrected. The cross-domain bridge from a
* subjective score to an objective vital. Observational; the card renders
* the standing "association, not cause" caption.
*/
factorCrosstab: FactorMetricCrosstabRow[];
/**
* v1.8.6 — ranked, threshold-gated narrative takeaways. The "read
* this first" layer above the charts; the same array feeds the LLM
@@ -1572,6 +1926,12 @@ export function computeMoodAggregates(args: {
now,
userPriorityJson,
});
const factorCrosstab = computeFactorMetricCrosstab({
entries,
measurements,
now,
userPriorityJson,
});
// Distinct day keys the user logged on — drives the streak takeaway.
const loggedDayKeys = Array.from(new Set(entries.map((entry) => entry.date)));
@@ -1603,6 +1963,7 @@ export function computeMoodAggregates(args: {
tagInfluence,
betterDays,
tagMetricCrosstab,
factorCrosstab,
narratives: computeMoodNarratives({
daily: moodDaily,
weekday,
@@ -1646,13 +2007,20 @@ export async function fetchMoodAggregates(
tz: true,
// v1.8.5 — pull the structured-tag links + their catalog rows so
// the breakdown can fold structured tags next to the flat ones.
// v1.14.0 — also pull the per-link `rating` + the factor's
// kind/scale/inverse so RATED factors feed the factor crosstab.
tagLinks: {
select: {
rating: true,
moodTag: {
select: {
key: true,
labelKey: true,
icon: true,
kind: true,
scaleMin: true,
scaleMax: true,
inverse: true,
category: { select: { key: true } },
},
},
@@ -1667,12 +2035,31 @@ export async function fetchMoodAggregates(
tags: parseTags(row.tags),
moodLoggedAt: row.moodLoggedAt,
tz: row.tz,
structuredTags: row.tagLinks.map((link) => ({
key: link.moodTag.key,
categoryKey: link.moodTag.category.key,
labelKey: link.moodTag.labelKey,
icon: link.moodTag.icon,
})),
// BINARY links carry the structured-tag breakdown; RATED links
// (a non-null `rating`) carry the factor score. One join, two axes.
structuredTags: row.tagLinks
.filter((link) => link.moodTag.kind !== "RATED")
.map((link) => ({
key: link.moodTag.key,
categoryKey: link.moodTag.category.key,
labelKey: link.moodTag.labelKey,
icon: link.moodTag.icon,
})),
ratedFactors: row.tagLinks
.filter(
(link): link is typeof link & { rating: number } =>
link.moodTag.kind === "RATED" && link.rating != null,
)
.map((link) => ({
key: link.moodTag.key,
categoryKey: link.moodTag.category.key,
labelKey: link.moodTag.labelKey,
icon: link.moodTag.icon,
rating: link.rating,
scaleMin: link.moodTag.scaleMin,
scaleMax: link.moodTag.scaleMax,
inverse: link.moodTag.inverse,
})),
})),
);
@@ -1691,6 +2078,10 @@ export async function fetchMoodAggregates(
new Set<MeasurementType>([
...(Object.values(CORRELATION_METRICS) as MeasurementType[]),
...CROSSTAB_METRIC_TYPES,
// v1.14.0 — union the factor-crosstab vital channels (RHR /
// HRV / steps / sleep / weight / BP-sys) so the factor board
// has its measurements without a second query.
...FACTOR_CROSSTAB_METRIC_TYPES,
]),
),
},
+89 -9
View File
@@ -18,7 +18,9 @@ import {
MOOD_GREEN_MIN,
MOOD_ORANGE_MAX,
MOOD_ORANGE_MIN,
type FactorMetricCrosstabRow,
computeBetterDays,
computeFactorMetricCrosstab,
computeInTargetPct,
computeMoodMetricCorrelation,
computeMoodStability,
@@ -64,6 +66,21 @@ function stripRankingOnly(row: TagInfluenceRow): Omit<TagInfluenceRow, "pooledSd
return rest;
}
/**
* Trim a factor-crosstab row to the load-bearing stats for the prompt — drop
* the UI-only `icon`. The factor key + metric + low/high averages + delta + n
* + confidence + inverse flag are what the model reasons over; the Lucide icon
* is chrome and has no place in the prompt payload (shown == sent for the
* statistics, not the glyph).
*/
function stripFactorCrosstab(
row: FactorMetricCrosstabRow,
): Omit<FactorMetricCrosstabRow, "icon"> {
const { icon: _icon, ...rest } = row;
void _icon;
return rest;
}
export async function generateMoodStatusForUser(
userId: string,
options?: {
@@ -151,13 +168,20 @@ export async function generateMoodStatusForUser(
// v1.8.6 — pull the structured-tag links so the snapshot feeds
// `computeMoodNarratives` the same structured-tag pool the visible
// mood page does (shown == sent for the tag→mood takeaways).
// v1.14.0 — also pull the per-link `rating` + factor kind/scale/inverse
// so RATED factors feed the factor crosstab in the snapshot.
tagLinks: {
select: {
rating: true,
moodTag: {
select: {
key: true,
labelKey: true,
icon: true,
kind: true,
scaleMin: true,
scaleMax: true,
inverse: true,
category: { select: { key: true } },
},
},
@@ -172,12 +196,29 @@ export async function generateMoodStatusForUser(
tags: row.tags,
moodLoggedAt: row.moodLoggedAt,
tz: row.tz,
structuredTags: (row.tagLinks ?? []).map((link) => ({
key: link.moodTag.key,
categoryKey: link.moodTag.category.key,
labelKey: link.moodTag.labelKey,
icon: link.moodTag.icon,
})),
structuredTags: (row.tagLinks ?? [])
.filter((link) => link.moodTag.kind !== "RATED")
.map((link) => ({
key: link.moodTag.key,
categoryKey: link.moodTag.category.key,
labelKey: link.moodTag.labelKey,
icon: link.moodTag.icon,
})),
ratedFactors: (row.tagLinks ?? [])
.filter(
(link): link is typeof link & { rating: number } =>
link.moodTag.kind === "RATED" && link.rating != null,
)
.map((link) => ({
key: link.moodTag.key,
categoryKey: link.moodTag.category.key,
labelKey: link.moodTag.labelKey,
icon: link.moodTag.icon,
rating: link.rating,
scaleMin: link.moodTag.scaleMin,
scaleMax: link.moodTag.scaleMax,
inverse: link.moodTag.inverse,
})),
})),
);
@@ -225,15 +266,36 @@ export async function generateMoodStatusForUser(
// 1095 = 365 d × 3 channels so power users do not pull unbounded
// rows. Order desc + reverse so the downstream filters see oldest-
// first as before.
// v1.14.0 — union the factor-crosstab vital channels (RHR / HRV / steps /
// sleep, on top of weight / BP-sys) so the factor board the snapshot carries
// has its measurements; cap lifted to 365 d × 8 channels. `source` /
// `deviceType` ride along so the crosstab's per-day canonical-source pick
// de-dups a cumulative metric reported by two sources on the same day.
const measurements = await prisma.measurement
.findMany({
where: {
userId,
type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"] },
type: {
in: [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"PULSE",
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"ACTIVITY_STEPS",
"SLEEP_DURATION",
],
},
},
orderBy: { measuredAt: "desc" },
take: 1095,
select: { type: true, value: true, measuredAt: true },
take: 50_000,
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
},
})
.then((rows) => rows.reverse());
@@ -312,6 +374,17 @@ export async function generateMoodStatusForUser(
),
});
// v1.14.0 — RATED-factor × vital crosstab: "on days you rated <factor> low,
// your <vital> ran X below baseline". Computed from the same entries +
// measurements the snapshot already holds so the model's prose matches the
// visible factor board (shown == sent). FDR-controlled + min-N-gated inside
// `computeFactorMetricCrosstab` — never fabricated on thin data.
const factorCrosstab = computeFactorMetricCrosstab({
entries,
measurements,
now,
});
// v1.8.6 — the same threshold-gated narrative feed the user sees on
// the mood page, computed from the same aggregates so the prose the
// model writes never contradicts the takeaways on screen (shown ==
@@ -382,6 +455,13 @@ export async function generateMoodStatusForUser(
// v1.11.5 (F2) — the ranked "better days" board the user sees, so the
// model's relations prose matches the on-screen list (shown == sent).
betterDays: betterDays.length > 0 ? betterDays : null,
// v1.14.0 — RATED-factor → vital deviation rows (low-vs-high-day Welch
// delta, FDR-gated). Lets the model say "your data shows sleep runs ~X
// shorter on days you rate work low (n=N, association not cause)". Only
// the surviving rows reach the prompt; an empty board emits null.
factorCrosstab: factorCrosstab.length > 0
? factorCrosstab.map(stripFactorCrosstab)
: null,
narratives: narratives.length > 0 ? narratives : null,
// v1.9.0 — only emit the daypart pattern when it cleared its
// spread/sample floors, so the model never reasons over a once-a-day
@@ -213,6 +213,41 @@ describe("assemblePeriodNarrativeContext — drivers (FDR-controlled)", () => {
const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m)));
expect(ctx.drivers).toEqual([]);
});
it("surfaces a RATED-factor channel as a driver with humanised prose", () => {
// A `FACTOR:work` daily score whose next-day sleep tracks it linearly.
const fVals = Array.from({ length: 31 }, (_, i) => (i % 5) + 1);
const sVals = Array.from({ length: 31 }, (_, i) =>
i === 0 ? 400 : ((i - 1) % 5) * 40 + 360 + (i % 2 === 0 ? 1 : -1),
);
const factor = seriesEndingAt(fVals, "2026-04-30");
const sleep = seriesEndingAt(sVals, "2026-04-30");
// Two real vitals with current-period coverage so the availability gate
// (≥ 2 DELTA metrics covered) is satisfied — a factor channel is not a
// vital delta and does not count toward coverage by design.
const weight = seriesEndingAt(Array.from({ length: 31 }, () => 80), "2026-04-30");
const steps = seriesEndingAt(Array.from({ length: 31 }, (_, i) => 8000 + i), "2026-04-30");
const discoverySeries: NamedSeries[] = [
{ key: "FACTOR:work", role: "behaviour", points: factor },
{ key: "SLEEP_DURATION", role: "outcome", points: sleep },
];
const m = new Map<string, DailySeriesPoint[]>();
m.set("FACTOR:work", factor);
m.set("SLEEP_DURATION", sleep);
m.set("WEIGHT", weight);
m.set("ACTIVITY_STEPS", steps);
const ctx = assertReady(
assemblePeriodNarrativeContext(monthInput(m, discoverySeries)),
);
const driver = ctx.drivers.find((d) => d.behaviour === "FACTOR:work");
expect(driver).toBeDefined();
// The prose strips the namespace prefix and reads "rated work", stays
// causation-banned.
expect(driver!.interpretation).toContain("rated work");
expect(driver!.interpretation).not.toContain("FACTOR:");
expect(driver!.interpretation.toLowerCase()).toContain("not a cause");
});
});
describe("assemblePeriodNarrativeContext — coincident flags", () => {
+103 -1
View File
@@ -36,6 +36,7 @@ import {
discoverCorrelations,
DISCOVERY_BEHAVIOURS,
DISCOVERY_OUTCOMES,
FACTOR_CHANNEL_PREFIX,
type DailySeriesPoint,
type NamedSeries,
} from "@/lib/insights/correlation-discovery";
@@ -199,6 +200,57 @@ function toDailyMeans(
.sort((a, b) => (a.day < b.day ? -1 : 1));
}
/**
* A RATED mood factor link the period read pulls alongside the score.
* Carries the scale + `inverse` so the series build can apply the
* documented sign-flip once, at the boundary.
*/
interface FactorLink {
key: string;
rating: number;
scaleMin: number;
scaleMax: number;
inverse: boolean;
at: Date;
}
/**
* v1.14.0 — collapse RATED-factor links to one inverse-flipped daily-mean
* series per factor, tz-day-keyed exactly like `toDailyMeans` so a factor
* channel joins the discovery matrix on the same day grid as every vital.
* An inverse factor's rating `r` maps to `(scaleMin + scaleMax) - r` BEFORE
* averaging so "up" always reads as a better day — the same flip the mood
* aggregates apply, kept in lock-step. Returns one `FACTOR:<key>` series per
* factor the user actually rated. Pure.
*/
function factorDailyMeans(
links: FactorLink[],
tz: string,
): Map<string, DailySeriesPoint[]> {
const byFactor = new Map<string, Map<string, { sum: number; count: number }>>();
for (const l of links) {
if (!Number.isFinite(l.rating)) continue;
const value = l.inverse ? l.scaleMin + l.scaleMax - l.rating : l.rating;
const day = tzDayKey(l.at, tz);
const days = byFactor.get(l.key) ?? new Map<string, { sum: number; count: number }>();
const acc = days.get(day) ?? { sum: 0, count: 0 };
acc.sum += value;
acc.count += 1;
days.set(day, acc);
byFactor.set(l.key, days);
}
const out = new Map<string, DailySeriesPoint[]>();
for (const [key, days] of byFactor) {
out.set(
`${FACTOR_CHANNEL_PREFIX}${key}`,
[...days.entries()]
.map(([day, acc]) => ({ day, value: acc.sum / acc.count }))
.sort((a, b) => (a.day < b.day ? -1 : 1)),
);
}
return out;
}
/** Round to 2 decimals. */
function round2(n: number): number {
return Math.round(n * 100) / 100;
@@ -501,7 +553,29 @@ export async function buildPeriodNarrativeContext(
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "asc" },
take: 5000,
select: { score: true, moodLoggedAt: true },
select: {
score: true,
moodLoggedAt: true,
// v1.14.0 — pull RATED-factor links so each factor the user scores
// (work / sleep-quality / stress …) joins the discovery matrix as a
// `FACTOR:<key>` channel. One extra select on the existing mood read
// — no new round-trip. BINARY links carry a null `rating` and are
// dropped below.
tagLinks: {
where: { moodTag: { kind: "RATED" }, rating: { not: null } },
select: {
rating: true,
moodTag: {
select: {
key: true,
scaleMin: true,
scaleMax: true,
inverse: true,
},
},
},
},
},
}),
]);
@@ -522,6 +596,25 @@ export async function buildPeriodNarrativeContext(
);
if (moodPoints.length > 0) seriesByMetric.set("MOOD", moodPoints);
// v1.14.0 — RATED-factor channels. Flatten every entry's RATED links into
// one inverse-flipped daily-mean series per factor, keyed `FACTOR:<key>`.
const factorLinks: FactorLink[] = [];
for (const e of moodEntries) {
for (const link of e.tagLinks) {
if (link.rating == null) continue;
factorLinks.push({
key: link.moodTag.key,
rating: link.rating,
scaleMin: link.moodTag.scaleMin,
scaleMax: link.moodTag.scaleMax,
inverse: link.moodTag.inverse,
at: e.moodLoggedAt,
});
}
}
const factorSeries = factorDailyMeans(factorLinks, tz);
for (const [key, points] of factorSeries) seriesByMetric.set(key, points);
// Discovery matrix over the same window.
const discoverySeries: NamedSeries[] = [];
for (const key of DISCOVERY_BEHAVIOURS) {
@@ -538,6 +631,15 @@ export async function buildPeriodNarrativeContext(
points: seriesByMetric.get(key) ?? [],
});
}
// A factor is plausibly both a lag source ("rated work today → next-day
// sleep") and a lag target ("steps today → next-day rated energy"), so it
// enters as both roles. The engine skips self-pairs and BH-FDR-controls the
// wider family this opens. Factors the user rated on < the paired-day floor
// simply never get tested — no new statistical machinery, same honesty.
for (const [key, points] of factorSeries) {
discoverySeries.push({ key, role: "behaviour", points });
discoverySeries.push({ key, role: "outcome", points });
}
return assemblePeriodNarrativeContext({
period,