mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
30a7437f4d
commit
dfda2a1c7e
15 files changed
+1133
-17
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user