From 77a652a47cee7a64dc85997308d285a19056bf14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 09:58:13 +0200 Subject: [PATCH] feat(cycle): cycle-phase correlation insight + Coach phase dimension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the menstrual-cycle phase as a channel in the existing FDR-guarded correlation machinery, the same way rated mood factors were folded in — no new statistics, the same Welch + Benjamini-Hochberg pipeline, the same day floors and q = 0.10 gate. Two complementary mechanisms, both confined to a gender-gated surface: - a categorical luteal-vs-follicular contrast per outcome metric (resting heart rate, HRV, sleep, steps, weight, basal/wrist/skin temperature), surfacing one headline finding (resting-heart-rate-by-phase, falling back to HRV); and - the continuous phase ordinal folded into the lagged-Pearson matrix. Phase never reaches the general correlations route — the temperature channels held out of the general matrix are un-blocked only here, behind the cycle gate, so a reproductive inference can never leak onto a non-gated surface. Every row shows n, effect size, p and q; nothing is fabricated on thin data and nothing is framed as causal. Surface it through a new gated GET /api/cycle/insights and a drop-in card that mirrors the mood-factor crosstab. Add a cycle dimension to the Coach snapshot: current phase + day-of-cycle, the next predicted event (period range + confidence + method, fertile window goal-gated), and the headline phase finding — present only for a cycle-enabled account, so a non-cycle snapshot is unchanged. A new ground rule keeps the Coach descriptive: never contraception-grade, never a "safe day" claim. OpenAPI regenerated; phase-correlation + snapshot-inclusion tests added; copy localised across all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/api/openapi.yaml | 195 +++++++++++ messages/de.json | 34 ++ messages/en.json | 34 ++ messages/es.json | 34 ++ messages/fr.json | 34 ++ messages/it.json | 34 ++ messages/pl.json | 34 ++ src/app/api/cycle/insights/route.ts | 192 +++++++++++ src/components/cycle/cycle-phase-crosstab.tsx | 202 +++++++++++ .../ai/coach/__tests__/cycle-snapshot.test.ts | 155 +++++++++ src/lib/ai/coach/cycle-snapshot.ts | 321 ++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 37 ++ src/lib/ai/coach/system-prompt.ts | 30 ++ .../cycle/__tests__/phase-crosstab.test.ts | 274 +++++++++++++++ src/lib/cycle/engine-adapter.ts | 63 +++- src/lib/cycle/phase-crosstab.ts | 302 ++++++++++++++++ src/lib/insights/mood-aggregates.ts | 4 +- src/lib/openapi/routes.ts | 82 +++++ 18 files changed, 2057 insertions(+), 4 deletions(-) create mode 100644 src/app/api/cycle/insights/route.ts create mode 100644 src/components/cycle/cycle-phase-crosstab.tsx create mode 100644 src/lib/ai/coach/__tests__/cycle-snapshot.test.ts create mode 100644 src/lib/ai/coach/cycle-snapshot.ts create mode 100644 src/lib/cycle/__tests__/phase-crosstab.test.ts create mode 100644 src/lib/cycle/phase-crosstab.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6ae909ef1..53bb940db 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -578,6 +578,27 @@ paths: "403": *a2 "422": *a3 "429": *a4 + /api/cycle/insights: + get: + tags: + - Cycle + summary: Cycle-phase correlation insights (v1.15.0) + description: "FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / + temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same + Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 + surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. + Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off." + responses: + "200": + description: Phase-correlation rows + headline. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleInsightsEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 /api/cycle/cycles/{id}: delete: tags: @@ -5289,6 +5310,180 @@ components: - data - error additionalProperties: false + CycleInsightsEnvelope: + type: object + properties: + data: + type: object + properties: + rows: + type: array + items: + $ref: "#/components/schemas/CyclePhaseCrosstabRow" + headline: + anyOf: + - $ref: "#/components/schemas/CyclePhaseCrosstabRow" + - type: "null" + lagged: + type: object + properties: + discovered: + type: array + items: + $ref: "#/components/schemas/CyclePhaseLaggedPair" + pairsTested: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + fdrQ: + type: number + minPairs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - discovered + - pairsTested + - fdrQ + - minPairs + additionalProperties: false + contrast: + type: object + properties: + high: + type: string + const: LUTEAL + low: + type: string + const: FOLLICULAR + required: + - high + - low + additionalProperties: false + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + cyclesObserved: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - rows + - headline + - lagged + - contrast + - windowDays + - cyclesObserved + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CyclePhaseCrosstabRow: + type: object + properties: + metricKey: + type: string + enum: + - restingHeartRate + - heartRateVariability + - sleepDuration + - steps + - weight + - basalBodyTemp + - wristTemperature + - skinTemperature + display: + type: string + enum: + - hours + - steps + - bpm + - ms + - kg + - celsius + lutealDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + follicularDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + lutealAvg: + type: number + follicularAvg: + type: number + delta: + type: number + pValue: + type: number + qValue: + type: number + confidence: + type: string + enum: + - low + - medium + - high + required: + - metricKey + - display + - lutealDays + - follicularDays + - lutealAvg + - follicularAvg + - delta + - pValue + - qValue + - confidence + additionalProperties: false + description: One FDR-surviving luteal-vs-follicular contrast for an outcome metric. `delta = lutealAvg − follicularAvg`. + Observational, never causal. + CyclePhaseLaggedPair: + type: object + properties: + behaviour: + type: string + outcome: + type: string + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + r: + type: number + pValue: + type: number + qValue: + type: number + interpretation: + type: string + lagDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - behaviour + - outcome + - n + - r + - pValue + - qValue + - interpretation + - lagDays + additionalProperties: false + description: One FDR-surviving lagged-Pearson pair from the continuous CYCLE_PHASE ordinal × outcome matrix (mechanism + B). Descriptive, never causal. CycleProfileEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index d0a4d488b..eb288c858 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4893,6 +4893,40 @@ }, "prediction": { "disclaimer": "Vorhersagen sind Schätzungen aus deinen erfassten Daten, keine medizinische Beratung und keine Verhütung. Die Genauigkeit steigt, je mehr Zyklen du erfasst." + }, + "insights": { + "headline": { + "title": "Dein Zyklus und dein Körper", + "restingHeartRate": "Dein Ruhepuls liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.", + "heartRateVariability": "Deine Herzfrequenzvariabilität liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.", + "generic": "Dein/e {metric} liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.", + "dirHigher": "höher", + "dirLower": "niedriger", + "empty": "Noch nicht genug Zyklen und Messwerte für ein Phasenmuster. Logge weiter — das füllt sich, je länger deine Historie wird." + }, + "crosstab": { + "description": "Wie sich jeder Messwert zwischen deiner Luteal- und Follikelphase vergleicht – auf deinen eigenen Daten.", + "pairLabel": "{metric} · Luteal vs. Follikel", + "detail": "Luteal {lutealAvg} {unit} ({lutealDays} Tage) vs. Follikel {follicularAvg} {unit} ({follicularDays} Tage).", + "footer": "Das sind Zusammenhänge in deinen eigenen Daten, keine Ursachen. Nur Muster, die einen Signifikanztest bestehen, werden gezeigt.", + "metricRestingHeartRate": "Ruhepuls", + "metricHrv": "Herzfrequenzvariabilität", + "metricSleepDuration": "Schlafdauer", + "metricSteps": "Schritte", + "metricWeight": "Gewicht", + "metricBasalBodyTemp": "Basaltemperatur", + "metricWristTemperature": "Handgelenkstemperatur nachts", + "metricSkinTemperature": "Hauttemperatur", + "unitHours": "Std.", + "unitSteps": "Schritte", + "unitBpm": "S/min", + "unitMs": "ms", + "unitKg": "kg", + "unitCelsius": "°C", + "confidenceLow": "Geringe Sicherheit", + "confidenceMedium": "Mittlere Sicherheit", + "confidenceHigh": "Hohe Sicherheit" + } } } } diff --git a/messages/en.json b/messages/en.json index c46256bba..585a03828 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4893,6 +4893,40 @@ }, "prediction": { "disclaimer": "Predictions are estimates from your logged data, not medical advice or contraception. Accuracy improves as you log more cycles." + }, + "insights": { + "headline": { + "title": "Your cycle and your body", + "restingHeartRate": "Your resting heart rate runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.", + "heartRateVariability": "Your heart-rate variability runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.", + "generic": "Your {metric} runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.", + "dirHigher": "higher", + "dirLower": "lower", + "empty": "Not enough cycles and readings yet to surface a phase pattern. Keep logging — this fills in as your history grows." + }, + "crosstab": { + "description": "How each measurement compares between your luteal and follicular phases, on your own data.", + "pairLabel": "{metric} · luteal vs follicular", + "detail": "Luteal {lutealAvg} {unit} ({lutealDays} days) vs follicular {follicularAvg} {unit} ({follicularDays} days).", + "footer": "These are associations in your own data, not causes. Only patterns that clear a significance check are shown.", + "metricRestingHeartRate": "Resting heart rate", + "metricHrv": "Heart-rate variability", + "metricSleepDuration": "Sleep duration", + "metricSteps": "Steps", + "metricWeight": "Weight", + "metricBasalBodyTemp": "Basal body temperature", + "metricWristTemperature": "Overnight wrist temperature", + "metricSkinTemperature": "Skin temperature", + "unitHours": "h", + "unitSteps": "steps", + "unitBpm": "bpm", + "unitMs": "ms", + "unitKg": "kg", + "unitCelsius": "°C", + "confidenceLow": "Low confidence", + "confidenceMedium": "Medium confidence", + "confidenceHigh": "High confidence" + } } } } diff --git a/messages/es.json b/messages/es.json index 3df96040b..c08a026c9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4893,6 +4893,40 @@ }, "prediction": { "disclaimer": "Las predicciones son estimaciones basadas en tus datos registrados, no son consejo médico ni un método anticonceptivo. La precisión mejora a medida que registras más ciclos." + }, + "insights": { + "headline": { + "title": "Tu ciclo y tu cuerpo", + "restingHeartRate": "Tu frecuencia cardíaca en reposo es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.", + "heartRateVariability": "Tu variabilidad de la frecuencia cardíaca es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.", + "generic": "Tu {metric} es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.", + "dirHigher": "más alta", + "dirLower": "más baja", + "empty": "Aún no hay suficientes ciclos ni registros para mostrar un patrón de fase. Sigue registrando: se completará a medida que crezca tu historial." + }, + "crosstab": { + "description": "Cómo se compara cada medición entre tus fases lútea y folicular, con tus propios datos.", + "pairLabel": "{metric} · lútea vs. folicular", + "detail": "Lútea {lutealAvg} {unit} ({lutealDays} días) vs. folicular {follicularAvg} {unit} ({follicularDays} días).", + "footer": "Son asociaciones en tus propios datos, no causas. Solo se muestran los patrones que superan una prueba de significación.", + "metricRestingHeartRate": "Frecuencia cardíaca en reposo", + "metricHrv": "Variabilidad de la frecuencia cardíaca", + "metricSleepDuration": "Duración del sueño", + "metricSteps": "Pasos", + "metricWeight": "Peso", + "metricBasalBodyTemp": "Temperatura basal", + "metricWristTemperature": "Temperatura de la muñeca nocturna", + "metricSkinTemperature": "Temperatura de la piel", + "unitHours": "h", + "unitSteps": "pasos", + "unitBpm": "lpm", + "unitMs": "ms", + "unitKg": "kg", + "unitCelsius": "°C", + "confidenceLow": "Confianza baja", + "confidenceMedium": "Confianza media", + "confidenceHigh": "Confianza alta" + } } } } diff --git a/messages/fr.json b/messages/fr.json index 85653fb21..1f56a7aba 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4893,6 +4893,40 @@ }, "prediction": { "disclaimer": "Les prévisions sont des estimations issues de tes données enregistrées, et non un avis médical ou un moyen de contraception. La précision s'améliore à mesure que tu enregistres des cycles." + }, + "insights": { + "headline": { + "title": "Ton cycle et ton corps", + "restingHeartRate": "Ta fréquence cardiaque au repos est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.", + "heartRateVariability": "Ta variabilité de la fréquence cardiaque est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.", + "generic": "Ton/ta {metric} est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.", + "dirHigher": "plus élevée", + "dirLower": "plus basse", + "empty": "Pas encore assez de cycles et de relevés pour faire ressortir un schéma de phase. Continue à enregistrer : cela se complétera à mesure que ton historique grandit." + }, + "crosstab": { + "description": "Comment chaque mesure se compare entre tes phases lutéale et folliculaire, sur tes propres données.", + "pairLabel": "{metric} · lutéale vs folliculaire", + "detail": "Lutéale {lutealAvg} {unit} ({lutealDays} jours) vs folliculaire {follicularAvg} {unit} ({follicularDays} jours).", + "footer": "Ce sont des associations dans tes propres données, pas des causes. Seuls les schémas qui passent un test de significativité sont affichés.", + "metricRestingHeartRate": "Fréquence cardiaque au repos", + "metricHrv": "Variabilité de la fréquence cardiaque", + "metricSleepDuration": "Durée du sommeil", + "metricSteps": "Pas", + "metricWeight": "Poids", + "metricBasalBodyTemp": "Température basale", + "metricWristTemperature": "Température du poignet la nuit", + "metricSkinTemperature": "Température de la peau", + "unitHours": "h", + "unitSteps": "pas", + "unitBpm": "bpm", + "unitMs": "ms", + "unitKg": "kg", + "unitCelsius": "°C", + "confidenceLow": "Confiance faible", + "confidenceMedium": "Confiance moyenne", + "confidenceHigh": "Confiance élevée" + } } } } diff --git a/messages/it.json b/messages/it.json index ebdcd177d..5114e3a61 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4893,6 +4893,40 @@ }, "prediction": { "disclaimer": "Le previsioni sono stime basate sui dati registrati, non sono un consiglio medico né un metodo contraccettivo. La precisione migliora man mano che registri più cicli." + }, + "insights": { + "headline": { + "title": "Il tuo ciclo e il tuo corpo", + "restingHeartRate": "La tua frequenza cardiaca a riposo è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.", + "heartRateVariability": "La tua variabilità della frequenza cardiaca è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.", + "generic": "Il/la tuo/a {metric} è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.", + "dirHigher": "più alta", + "dirLower": "più bassa", + "empty": "Non ci sono ancora abbastanza cicli e rilevazioni per mostrare uno schema di fase. Continua a registrare: si completerà man mano che cresce il tuo storico." + }, + "crosstab": { + "description": "Come si confronta ogni misurazione tra la fase luteale e quella follicolare, sui tuoi dati.", + "pairLabel": "{metric} · luteale vs follicolare", + "detail": "Luteale {lutealAvg} {unit} ({lutealDays} giorni) vs follicolare {follicularAvg} {unit} ({follicularDays} giorni).", + "footer": "Sono associazioni nei tuoi dati, non cause. Vengono mostrati solo gli schemi che superano un test di significatività.", + "metricRestingHeartRate": "Frequenza cardiaca a riposo", + "metricHrv": "Variabilità della frequenza cardiaca", + "metricSleepDuration": "Durata del sonno", + "metricSteps": "Passi", + "metricWeight": "Peso", + "metricBasalBodyTemp": "Temperatura basale", + "metricWristTemperature": "Temperatura del polso notturna", + "metricSkinTemperature": "Temperatura della pelle", + "unitHours": "h", + "unitSteps": "passi", + "unitBpm": "bpm", + "unitMs": "ms", + "unitKg": "kg", + "unitCelsius": "°C", + "confidenceLow": "Affidabilità bassa", + "confidenceMedium": "Affidabilità media", + "confidenceHigh": "Affidabilità alta" + } } } } diff --git a/messages/pl.json b/messages/pl.json index 2b1eea8b6..c10fdef07 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4893,6 +4893,40 @@ }, "prediction": { "disclaimer": "Prognozy to szacunki na podstawie zapisanych danych, a nie porada medyczna ani metoda antykoncepcji. Dokładność rośnie wraz z liczbą zapisanych cykli." + }, + "insights": { + "headline": { + "title": "Twój cykl i Twoje ciało", + "restingHeartRate": "Twoje tętno spoczynkowe jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.", + "heartRateVariability": "Twoja zmienność rytmu serca jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.", + "generic": "Twoje {metric} jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.", + "dirHigher": "wyższe", + "dirLower": "niższe", + "empty": "Za mało cykli i pomiarów, aby pokazać wzorzec fazy. Rejestruj dalej — uzupełni się to wraz z historią." + }, + "crosstab": { + "description": "Jak każdy pomiar wypada między fazą lutealną a folikularną, na Twoich danych.", + "pairLabel": "{metric} · lutealna vs folikularna", + "detail": "Lutealna {lutealAvg} {unit} ({lutealDays} dni) vs folikularna {follicularAvg} {unit} ({follicularDays} dni).", + "footer": "To powiązania w Twoich danych, nie przyczyny. Pokazywane są tylko wzorce, które przechodzą test istotności.", + "metricRestingHeartRate": "Tętno spoczynkowe", + "metricHrv": "Zmienność rytmu serca", + "metricSleepDuration": "Czas snu", + "metricSteps": "Kroki", + "metricWeight": "Waga", + "metricBasalBodyTemp": "Temperatura podstawowa", + "metricWristTemperature": "Nocna temperatura nadgarstka", + "metricSkinTemperature": "Temperatura skóry", + "unitHours": "godz.", + "unitSteps": "kroki", + "unitBpm": "ud./min", + "unitMs": "ms", + "unitKg": "kg", + "unitCelsius": "°C", + "confidenceLow": "Niska pewność", + "confidenceMedium": "Średnia pewność", + "confidenceHigh": "Wysoka pewność" + } } } } diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts new file mode 100644 index 000000000..e6bd24db4 --- /dev/null +++ b/src/app/api/cycle/insights/route.ts @@ -0,0 +1,192 @@ +/** + * `GET /api/cycle/insights` — cycle-phase × vital correlation insights + * (integration-audit §1 + §3 "INSIGHTS"). + * + * The gender-gated home for the CYCLE_PHASE correlation. The general + * `/api/insights/correlations` route is NOT gender-gated, so phase — and the + * temperature channels a phase relation rides on — must never surface there; + * this route is the deliberate, gated place they do. + * + * Surfaces the FDR-guarded LUTEAL-vs-FOLLICULAR phase contrast per outcome + * metric (the same Welch + Benjamini-Hochberg machinery the mood-factor + * crosstab runs) plus the ONE headline insight (resting-heart-rate-by-phase, + * falling back to HRV) for the v1.15.0 card. Honest: n / effect size / q are + * shown, every row is FDR-gated, nothing is fabricated on thin data, nothing + * is causal. + */ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { + predictCycle, + type NightlyTempInput, +} from "@/lib/cycle"; +import { LUTEAL_DEFAULT } from "@/lib/cycle/types"; +import { + buildPhaseDayMap, + toCycleInputs, + toDayLogInputs, + toProfileInput, +} from "@/lib/cycle/engine-adapter"; +import { + computePhaseMetricCrosstab, + discoverPhaseCorrelations, + selectHeadlinePhaseRow, + PHASE_CROSSTAB_METRIC_TYPES, +} from "@/lib/cycle/phase-crosstab"; +import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates"; +import { addDays } from "@/lib/cycle/day-math"; +import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; + +/** Trailing window the phase-contrast walks (days). */ +const WINDOW_DAYS = 365; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + const profile = gate.profile; + + const tz = user.timezone ?? DEFAULT_TIMEZONE; + const today = moodDateKey(new Date(), tz); + const from = addDays(today, -WINDOW_DAYS); + + const [cycles, dayLogRows, nightlyTempRows, measurementRows, prefsRow] = + await Promise.all([ + prisma.menstrualCycle.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { startDate: "asc" }, + }), + prisma.cycleDayLog.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { date: "asc" }, + select: { + date: true, + flow: true, + basalBodyTempC: true, + ovulationTest: true, + cervicalMucus: true, + }, + }), + // Passive wrist temperature feeds the temperature-trend ovulation layer + // (used only to sharpen the prediction's next-start the phase-day map + // extends to). Not a crosstab input. + prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + type: "WRIST_TEMPERATURE", + measuredAt: { + gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { measuredAt: true, value: true }, + }), + // The outcome metrics the phase contrast compares — soft-delete-scoped, + // canonical-source deduped per day inside `metricDayMap`. + prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + type: { in: PHASE_CROSSTAB_METRIC_TYPES }, + measuredAt: { + gte: new Date(Date.parse(`${from}T00:00:00Z`)), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + }, + }), + prisma.user.findUnique({ + where: { id: user.id }, + select: { sourcePriorityJson: true }, + }), + ]); + + // The latest open cycle runs to the predicted next-period start so the + // trailing window is phase-labelled even before the next period is logged. + const nights: NightlyTempInput[] = nightlyTempRows.map((m) => ({ + date: moodDateKey(m.measuredAt, tz), + valueC: m.value, + })); + const prediction = + profile.predictionEnabled && !profile.rawChartMode + ? predictCycle( + toCycleInputs(cycles), + toDayLogInputs(dayLogRows), + toProfileInput(profile), + today, + nights, + ) + : null; + + const phaseByDay = buildPhaseDayMap( + cycles, + prediction?.nextPeriodStart ?? null, + profile.lutealPhaseLength ?? LUTEAL_DEFAULT, + from, + today, + ); + + const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({ + type: m.type, + value: m.value, + measuredAt: m.measuredAt, + source: m.source, + deviceType: m.deviceType, + })); + + const userPriorityJson = prefsRow?.sourcePriorityJson ?? null; + + // Mechanism A — the categorical luteal-vs-follicular contrast (the headline + // surface). Mechanism B — the continuous CYCLE_PHASE ordinal folded into the + // lagged-Pearson FDR matrix. Both are FDR-gated; both live only here. + const rows = computePhaseMetricCrosstab({ + phaseByDay, + measurements, + userPriorityJson, + }); + const headline = selectHeadlinePhaseRow(rows); + const lagged = discoverPhaseCorrelations({ + phaseByDay, + measurements, + userPriorityJson, + }); + + annotate({ + action: { name: "cycle.insights.read" }, + meta: { + labelled_days: phaseByDay.size, + cycles_observed: cycles.length, + rows: rows.length, + has_headline: headline !== null, + lagged_discovered: lagged.discovered.length, + lagged_pairs_tested: lagged.pairsTested, + }, + }); + + return apiSuccess({ + rows, + headline, + lagged: { + discovered: lagged.discovered, + pairsTested: lagged.pairsTested, + fdrQ: lagged.fdrQ, + minPairs: lagged.minPairs, + }, + contrast: { high: "LUTEAL", low: "FOLLICULAR" }, + windowDays: WINDOW_DAYS, + cyclesObserved: cycles.length, + }); +}); diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx new file mode 100644 index 000000000..f2b52ee55 --- /dev/null +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * v1.15 — CYCLE_PHASE × vital crosstab card. + * + * Mirrors the mood-factor × vital crosstab (`mood-factor-metric-crosstab.tsx`): + * for each outcome metric, the vital's mean on LUTEAL-phase days vs + * FOLLICULAR-phase days, the signed delta, n per group, q, and a confidence + * chip. The math is pre-computed in `src/lib/cycle/phase-crosstab.ts` + * (`computePhaseMetricCrosstab`) — the same Welch t-test + day floors + + * Benjamini-Hochberg FDR the rest of the relations surface uses; only + * FDR-surviving rows reach here. Observational only — the page-level + * "associations, not causes" footer carries the caveat once. This surface is + * gender-gated by its `/api/cycle/insights` route; phase never renders on the + * general correlations surface. + */ + +export type CyclePhaseCrosstabDisplay = + | "hours" + | "steps" + | "bpm" + | "ms" + | "kg" + | "celsius"; + +export type CyclePhaseCrosstabConfidence = "low" | "medium" | "high"; + +export interface CyclePhaseCrosstabRow { + metricKey: string; + display: CyclePhaseCrosstabDisplay; + lutealDays: number; + follicularDays: number; + lutealAvg: number; + follicularAvg: number; + delta: number; + pValue: number; + qValue: number; + confidence: CyclePhaseCrosstabConfidence; +} + +const METRIC_LABEL_KEY: Record = { + restingHeartRate: "cycle.insights.crosstab.metricRestingHeartRate", + heartRateVariability: "cycle.insights.crosstab.metricHrv", + sleepDuration: "cycle.insights.crosstab.metricSleepDuration", + steps: "cycle.insights.crosstab.metricSteps", + weight: "cycle.insights.crosstab.metricWeight", + basalBodyTemp: "cycle.insights.crosstab.metricBasalBodyTemp", + wristTemperature: "cycle.insights.crosstab.metricWristTemperature", + skinTemperature: "cycle.insights.crosstab.metricSkinTemperature", +}; + +const UNIT_KEY: Record = { + hours: "cycle.insights.crosstab.unitHours", + steps: "cycle.insights.crosstab.unitSteps", + bpm: "cycle.insights.crosstab.unitBpm", + ms: "cycle.insights.crosstab.unitMs", + kg: "cycle.insights.crosstab.unitKg", + celsius: "cycle.insights.crosstab.unitCelsius", +}; + +const CONFIDENCE_KEY: Record = { + low: "cycle.insights.crosstab.confidenceLow", + medium: "cycle.insights.crosstab.confidenceMedium", + high: "cycle.insights.crosstab.confidenceHigh", +}; + +const CONFIDENCE_CLASS: Record = { + 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: CyclePhaseCrosstabDisplay): string { + return display === "steps" ? Math.round(value).toString() : value.toFixed(1); +} + +const HEADLINE_KEY: Record = { + restingHeartRate: "cycle.insights.headline.restingHeartRate", + heartRateVariability: "cycle.insights.headline.heartRateVariability", +}; + +/** + * The single headline finding rendered prominently above the grid. Resting + * heart rate / HRV get bespoke phrasing; every other metric uses the generic + * line. Returns null when nothing cleared the FDR gate — an honest empty state. + */ +export function CyclePhaseHeadline({ + headline, +}: { + headline: CyclePhaseCrosstabRow | null; +}) { + const { t } = useTranslations(); + if (!headline) { + return ( +

+ {t("cycle.insights.headline.empty")} +

+ ); + } + const metricLabel = t(METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey); + const unit = t(UNIT_KEY[headline.display]); + const up = headline.delta >= 0; + const dir = t( + up ? "cycle.insights.headline.dirHigher" : "cycle.insights.headline.dirLower", + ); + const params = { + metric: metricLabel, + delta: fmt(Math.abs(headline.delta), headline.display), + unit, + dir, + }; + const key = HEADLINE_KEY[headline.metricKey] ?? "cycle.insights.headline.generic"; + return ( +

+ {t(key, params)} +

+ ); +} + +export function CyclePhaseCrosstab({ + rows, +}: { + rows: CyclePhaseCrosstabRow[]; +}) { + const { t } = useTranslations(); + if (rows.length === 0) return null; + + return ( +
+

+ {t("cycle.insights.crosstab.description")} +

+
    + {rows.map((row) => { + const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey); + const unit = t(UNIT_KEY[row.display]); + // `delta` = lutealAvg − follicularAvg: positive = the vital runs + // higher in the luteal phase. The number stays NEUTRAL — higher is + // good for steps but bad for resting HR, and this board is + // observational, so colouring it would imply a verdict the data + // doesn't support. The sign prefix + `data-direction` carry it. + const up = row.delta >= 0; + const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`; + return ( +
  • +
    + + {t("cycle.insights.crosstab.pairLabel", { metric: metricLabel })} + + + {deltaText} + + + {t(CONFIDENCE_KEY[row.confidence])} + +
    +

    + {t("cycle.insights.crosstab.detail", { + metric: metricLabel, + lutealAvg: fmt(row.lutealAvg, row.display), + follicularAvg: fmt(row.follicularAvg, row.display), + unit, + lutealDays: row.lutealDays, + follicularDays: row.follicularDays, + })} +

    +
  • + ); + })} +
+

+ {t("cycle.insights.crosstab.footer")} +

+
+ ); +} diff --git a/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts new file mode 100644 index 000000000..586004099 --- /dev/null +++ b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + cycleProfile: { findUnique: vi.fn() }, + menstrualCycle: { findMany: vi.fn() }, + cycleDayLog: { findMany: vi.fn() }, + measurement: { findMany: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); + +import { buildCycleSnapshotBlock } from "../cycle-snapshot"; +import { prisma } from "@/lib/db"; + +const prismaMock = prisma as unknown as { + cycleProfile: { findUnique: ReturnType }; + menstrualCycle: { findMany: ReturnType }; + cycleDayLog: { findMany: ReturnType }; + measurement: { findMany: ReturnType }; + user: { findUnique: ReturnType }; +}; + +const NOW = new Date("2026-03-22T12:00:00.000Z"); + +function profile(overrides: Record = {}) { + return { + goal: "GENERAL_HEALTH", + predictionEnabled: true, + rawChartMode: false, + lutealPhaseLength: 14, + typicalCycleLength: 28, + typicalPeriodLength: 5, + ...overrides, + }; +} + +/** Three completed 28-day cycles ending just before NOW. */ +function cycles() { + return [ + { + startDate: "2026-01-05", + endDate: "2026-02-02", + periodEndDate: "2026-01-09", + ovulationDate: null, + ovulationConfirmed: false, + }, + { + startDate: "2026-02-02", + endDate: "2026-03-02", + periodEndDate: "2026-02-06", + ovulationDate: null, + ovulationConfirmed: false, + }, + { + startDate: "2026-03-02", + endDate: null, + periodEndDate: "2026-03-06", + ovulationDate: null, + ovulationConfirmed: false, + }, + ]; +} + +/** + * RHR rows: luteal days clearly higher than follicular days. Both windows land + * within `[cycle start (2026-03-02), today (2026-03-22)]` so the phase-day map + * (which only runs up to `today`) labels every one. + */ +function rhrMeasurements() { + const out: Array> = []; + const day = (i: number, v: number) => + out.push({ + type: "RESTING_HEART_RATE", + value: v + (i % 3) * 0.5, + measuredAt: new Date(`2026-03-${String(i).padStart(2, "0")}T12:00:00.000Z`), + source: "APPLE_HEALTH", + deviceType: null, + }); + // Follicular window ~ cycle days 6..12 (2026-03-07..03-13). + for (let i = 7; i <= 13; i++) day(i, 57); + // Luteal window ~ cycle days 16..21 (2026-03-17..03-22). + for (let i = 17; i <= 22; i++) day(i, 63); + return out; +} + +beforeEach(() => { + vi.clearAllMocks(); + prismaMock.cycleDayLog.findMany.mockResolvedValue([]); + prismaMock.user.findUnique.mockResolvedValue({ sourcePriorityJson: null }); + // The wrist-temperature read + the outcome-metric read share the same mock; + // default to empty, override per-test for the metric read. + prismaMock.measurement.findMany.mockResolvedValue([]); +}); + +describe("buildCycleSnapshotBlock", () => { + it("returns null when the account has no cycle profile", async () => { + prismaMock.cycleProfile.findUnique.mockResolvedValue(null); + expect(await buildCycleSnapshotBlock("u1", "FEMALE", NOW)).toBeNull(); + }); + + it("returns null when the account has no observed cycles", async () => { + prismaMock.cycleProfile.findUnique.mockResolvedValue(profile()); + prismaMock.menstrualCycle.findMany.mockResolvedValue([]); + expect(await buildCycleSnapshotBlock("u1", "FEMALE", NOW)).toBeNull(); + }); + + it("reports the current phase, day-of-cycle and next predicted event", async () => { + prismaMock.cycleProfile.findUnique.mockResolvedValue(profile()); + prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles()); + const block = await buildCycleSnapshotBlock("u1", "FEMALE", NOW); + expect(block).not.toBeNull(); + // 2026-03-22 is day 21 of a cycle that started 2026-03-02 → luteal. + expect(block!.phase).toBe("LUTEAL"); + expect(block!.dayOfCycle).toBe(21); + expect(block!.cyclesObserved).toBe(3); + expect(block!.nextEvent).not.toBeNull(); + expect(block!.nextEvent!.nextPeriodStart).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(block!.nextEvent!.method).toBeTruthy(); + }); + + it("suppresses the fertile window unless the goal surfaces it", async () => { + prismaMock.cycleProfile.findUnique.mockResolvedValue( + profile({ goal: "GENERAL_HEALTH" }), + ); + prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles()); + const block = await buildCycleSnapshotBlock("u1", "FEMALE", NOW); + expect(block!.nextEvent!.fertileWindowStart).toBeUndefined(); + + prismaMock.cycleProfile.findUnique.mockResolvedValue( + profile({ goal: "TRYING_TO_CONCEIVE" }), + ); + const ttc = await buildCycleSnapshotBlock("u1", "FEMALE", NOW); + // The conception goal may surface the window (present when the engine + // computes one) — the key invariant is the GENERAL_HEALTH path never does. + expect( + ttc!.nextEvent!.fertileWindowStart === undefined || + typeof ttc!.nextEvent!.fertileWindowStart === "string", + ).toBe(true); + }); + + it("includes the headline phase insight when a contrast clears FDR", async () => { + prismaMock.cycleProfile.findUnique.mockResolvedValue(profile()); + prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles()); + // First measurement read = WRIST_TEMPERATURE (empty), second = outcomes. + prismaMock.measurement.findMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(rhrMeasurements()); + const block = await buildCycleSnapshotBlock("u1", "FEMALE", NOW); + expect(block!.phaseInsight).not.toBeNull(); + expect(block!.phaseInsight!.metric).toBe("resting heart rate"); + expect(block!.phaseInsight!.delta).toBeGreaterThan(0); + expect(block!.phaseInsight!.interpretation).toMatch(/descriptive pattern, not a cause/); + }); +}); diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts new file mode 100644 index 000000000..ad090cc06 --- /dev/null +++ b/src/lib/ai/coach/cycle-snapshot.ts @@ -0,0 +1,321 @@ +/** + * Cycle/phase snapshot block builder for the Coach prompt (v1.15). + * + * Adds a cycle dimension to the Coach snapshot so the model can say "you are on + * day 22, luteal phase" and reference the user's own phase-correlation finding, + * grounded in the same engine the calendar + insights surface use — never + * re-derived. The block carries three things, each present only when actually + * computed (shown == sent): + * + * 1. current phase + 1-based day-of-cycle (from the deterministic phase + * engine), + * 2. the next predicted event — period range + confidence + method, plus the + * fertile window ONLY when the goal surfaces it (TRYING_TO_CONCEIVE), + * 3. the ONE headline phase insight — the FDR-surviving luteal-vs-follicular + * contrast (the comparison already finished, the metric-signal posture: + * hand the model the numbers, do not make it re-derive them). + * + * Gated entirely on `isCycleEnabled` by the caller, so a non-cycle account's + * snapshot is byte-for-byte unchanged. The Coach system prompt's cycle ground + * rule keeps this descriptive only — never contraception-grade, never a "safe + * day" claim. + */ +import { prisma } from "@/lib/db"; +import { + predictCycle, + type NightlyTempInput, +} from "@/lib/cycle"; +import { LUTEAL_DEFAULT, type CyclePhase } from "@/lib/cycle/types"; +import { + phaseForDate, + buildPhaseDayMap, + toCycleInputs, + toDayLogInputs, +} from "@/lib/cycle/engine-adapter"; +import type { CycleProfileInput } from "@/lib/cycle/types"; +import { goalAllowsFertileWindow } from "@/lib/cycle/dto"; +import { + computePhaseMetricCrosstab, + selectHeadlinePhaseRow, + PHASE_CROSSTAB_METRIC_TYPES, + PHASE_CROSSTAB_METRICS, + type PhaseMetricCrosstabRow, +} from "@/lib/cycle/phase-crosstab"; +import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates"; +import { addDays } from "@/lib/cycle/day-math"; +import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; + +/** Trailing window the phase contrast walks (days). Mirrors the read route. */ +const WINDOW_DAYS = 365; + +/** Natural-language metric labels (no enum leak into the prompt). */ +const METRIC_LABEL: Record = { + restingHeartRate: "resting heart rate", + heartRateVariability: "heart-rate variability", + sleepDuration: "sleep duration", + steps: "steps", + weight: "weight", + basalBodyTemp: "basal body temperature", + wristTemperature: "overnight wrist temperature", + skinTemperature: "skin temperature", +}; + +const UNIT_LABEL: Record = { + hours: "h", + steps: "steps", + bpm: "bpm", + ms: "ms", + kg: "kg", + celsius: "°C", +}; + +interface PhaseInsightBlock { + metric: string; + unit: string; + lutealAvg: number; + follicularAvg: number; + /** lutealAvg − follicularAvg; positive = higher in the luteal phase. */ + delta: number; + /** The finding's confidence band (p + per-group day count). */ + confidence: PhaseMetricCrosstabRow["confidence"]; + /** Benjamini-Hochberg q-value across the tested family. */ + qValue: number; + lutealDays: number; + follicularDays: number; + /** One-line descriptive interpretation — never causal. */ + interpretation: string; +} + +interface NextEventBlock { + nextPeriodStart: string; + nextPeriodStartLow: string; + nextPeriodStartHigh: string; + daysUntilPeriod: number; + method: string; + confidence: number; + stillLearning: boolean; + /** Goal-gated — present only when the goal surfaces the fertile window. */ + fertileWindowStart?: string; + fertileWindowEnd?: string; +} + +export interface CycleSnapshotBlock { + phase: CyclePhase | null; + dayOfCycle: number | null; + goal: string; + cyclesObserved: number; + nextEvent: NextEventBlock | null; + phaseInsight: PhaseInsightBlock | null; +} + +/** Descriptive, never-causal interpretation of the headline contrast. */ +function interpretHeadline(row: PhaseMetricCrosstabRow): string { + const metric = METRIC_LABEL[row.metricKey] ?? row.metricKey; + const dir = row.delta >= 0 ? "higher" : "lower"; + const mag = Math.abs(row.delta); + const unit = UNIT_LABEL[row.display] ?? ""; + return `Your ${metric} runs about ${mag}${unit ? " " + unit : ""} ${dir} on luteal-phase days than follicular-phase days in your own data — a descriptive pattern, not a cause.`; +} + +/** + * Build the cycle snapshot block. Returns `null` when there is nothing to say + * (no observed cycle → no phase, no prediction, no insight) so the caller omits + * the field entirely. The caller has already confirmed `isCycleEnabled`. + */ +export async function buildCycleSnapshotBlock( + userId: string, + gender: string | null | undefined, + now: Date = new Date(), +): Promise { + // Test environments may mock only part of prisma — bow out silently if the + // cycle models are absent (matches "no cycle data" production behaviour). + if (typeof prisma?.menstrualCycle?.findMany !== "function") return null; + if (typeof prisma?.cycleProfile?.findUnique !== "function") return null; + + const profile = await prisma.cycleProfile.findUnique({ + where: { userId }, + select: { + goal: true, + predictionEnabled: true, + rawChartMode: true, + lutealPhaseLength: true, + typicalCycleLength: true, + typicalPeriodLength: true, + }, + }); + if (!profile) return null; + + const profileInput: CycleProfileInput = { + goal: profile.goal, + typicalCycleLength: profile.typicalCycleLength, + typicalPeriodLength: profile.typicalPeriodLength, + lutealPhaseLength: profile.lutealPhaseLength, + predictionEnabled: profile.predictionEnabled, + rawChartMode: profile.rawChartMode, + }; + + const tz = DEFAULT_TIMEZONE; + const today = moodDateKey(now, tz); + const from = addDays(today, -WINDOW_DAYS); + + const [cycles, dayLogRows, nightlyTempRows, measurementRows, userRow] = + await Promise.all([ + prisma.menstrualCycle.findMany({ + where: { userId, deletedAt: null }, + orderBy: { startDate: "asc" }, + }), + prisma.cycleDayLog.findMany({ + where: { userId, deletedAt: null }, + orderBy: { date: "asc" }, + select: { + date: true, + flow: true, + basalBodyTempC: true, + ovulationTest: true, + cervicalMucus: true, + }, + }), + prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + type: "WRIST_TEMPERATURE", + measuredAt: { + gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { measuredAt: true, value: true }, + }), + prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + type: { in: PHASE_CROSSTAB_METRIC_TYPES }, + measuredAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) }, + }, + orderBy: { measuredAt: "asc" }, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { sourcePriorityJson: true }, + }), + ]); + + if (cycles.length === 0) return null; + + const lutealLength = profile.lutealPhaseLength ?? LUTEAL_DEFAULT; + const goalAllowsFertile = goalAllowsFertileWindow(profile.goal); + + const nights: NightlyTempInput[] = nightlyTempRows.map((m) => ({ + date: moodDateKey(m.measuredAt, tz), + valueC: m.value, + })); + const prediction = + profile.predictionEnabled && !profile.rawChartMode + ? predictCycle( + toCycleInputs(cycles), + toDayLogInputs(dayLogRows), + profileInput, + today, + nights, + ) + : null; + + const { phase, dayOfCycle } = phaseForDate( + today, + cycles, + prediction?.nextPeriodStart ?? null, + lutealLength, + ); + + // Next predicted event — period range + confidence + method, fertile window + // goal-gated. + let nextEvent: NextEventBlock | null = null; + if (prediction) { + const daysUntilPeriod = Math.max( + 0, + Math.round( + (Date.parse(`${prediction.nextPeriodStart}T12:00:00Z`) - + Date.parse(`${today}T12:00:00Z`)) / + 86_400_000, + ), + ); + nextEvent = { + nextPeriodStart: prediction.nextPeriodStart, + nextPeriodStartLow: prediction.nextPeriodStartLow, + nextPeriodStartHigh: prediction.nextPeriodStartHigh, + daysUntilPeriod, + method: prediction.method, + confidence: prediction.confidence, + stillLearning: prediction.stillLearning, + ...(goalAllowsFertile && prediction.fertileWindowStart + ? { fertileWindowStart: prediction.fertileWindowStart } + : {}), + ...(goalAllowsFertile && prediction.fertileWindowEnd + ? { fertileWindowEnd: prediction.fertileWindowEnd } + : {}), + }; + } + + // Headline phase insight — the FDR-surviving luteal-vs-follicular contrast. + const phaseByDay = buildPhaseDayMap( + cycles, + prediction?.nextPeriodStart ?? null, + lutealLength, + from, + today, + ); + const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({ + type: m.type, + value: m.value, + measuredAt: m.measuredAt, + source: m.source, + deviceType: m.deviceType, + })); + const headlineRow = selectHeadlinePhaseRow( + computePhaseMetricCrosstab({ + phaseByDay, + measurements, + userPriorityJson: userRow?.sourcePriorityJson ?? null, + }), + ); + + let phaseInsight: PhaseInsightBlock | null = null; + if (headlineRow) { + const cfg = PHASE_CROSSTAB_METRICS[headlineRow.metricKey]; + phaseInsight = { + metric: METRIC_LABEL[headlineRow.metricKey] ?? headlineRow.metricKey, + unit: UNIT_LABEL[cfg.display] ?? "", + lutealAvg: headlineRow.lutealAvg, + follicularAvg: headlineRow.follicularAvg, + delta: headlineRow.delta, + confidence: headlineRow.confidence, + qValue: headlineRow.qValue, + lutealDays: headlineRow.lutealDays, + follicularDays: headlineRow.follicularDays, + interpretation: interpretHeadline(headlineRow), + }; + } + + // Nothing worth saying → omit the block. + if (phase === null && nextEvent === null && phaseInsight === null) { + return null; + } + + return { + phase, + dayOfCycle, + goal: profile.goal, + cyclesObserved: cycles.length, + nextEvent, + phaseInsight, + }; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index ea68dc0e0..78e42ca89 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -33,6 +33,8 @@ import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; import { buildDerivedSnapshotBlock } from "./derived-snapshot"; import { buildCoachMemoryBlock } from "./memory-snapshot"; import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot"; +import { buildCycleSnapshotBlock } from "./cycle-snapshot"; +import { isCycleEnabled } from "@/lib/cycle/gate"; import type { BaselineProfile } from "@/lib/insights/derived"; import { CLUSTER_PRIORITY, @@ -466,6 +468,11 @@ async function buildCoachSnapshotImpl( // v1.11.5 — needed to collapse a dual-source sleep night to one // canonical source before reconstructing per-night asleep totals. sourcePriorityJson: true, + // v1.15 — the cycle snapshot block is gated on the resolved cycle + // toggle (an explicit opt-in/out overrides the gender default). Read + // both columns here so a non-cycle account pays no extra round-trip. + gender: true, + cycleProfile: { select: { cycleTrackingEnabled: true } }, }, }); const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson); @@ -795,6 +802,18 @@ async function buildCoachSnapshotImpl( now, coachLocale, ); + // v1.15 — cycle/phase block, gated on the resolved cycle toggle so a + // non-cycle account issues no query (the helper short-circuits to null + // before any read for a disabled account). The block is descriptive only — + // current phase + day-of-cycle, the next predicted event (period range, + // fertile window goal-gated), and the headline phase-correlation finding. + const cycleEnabled = isCycleEnabled( + prefsRow?.gender, + prefsRow?.cycleProfile ?? null, + ); + const cycleBlockPromise = cycleEnabled + ? buildCycleSnapshotBlock(userId, prefsRow?.gender, now) + : null; const [ moodRows, @@ -805,6 +824,7 @@ async function buildCoachSnapshotImpl( derivedBlock, trajectoryBlock, memoryBlock, + cycleBlock, ] = await Promise.all([ moodRowsPromise, intakeRowsPromise, @@ -814,6 +834,7 @@ async function buildCoachSnapshotImpl( derivedBlockPromise, trajectoryBlockPromise, memoryBlockPromise, + cycleBlockPromise, ]); const byType = (t: string) => @@ -1347,6 +1368,22 @@ async function buildCoachSnapshotImpl( registerBlock("memory", "skin_temp"); } + // ── v1.15 — cycle/phase block ──────────────────────────────────────── + // + // Present only for a cycle-enabled account (the promise is null otherwise, + // so this is byte-for-byte unchanged for everyone else). The block names the + // current phase + day-of-cycle, the next predicted event (period range + + // confidence + method; fertile window goal-gated), and the headline + // phase-correlation finding — all from the same deterministic engine the + // calendar + insights surface use, never re-derived. The Coach's cycle + // ground rule keeps replies descriptive: never contraception-grade, never a + // "safe day" claim. Registered against the lowest-priority `skin_temp` + // source so the soft-cap degrader sheds it before any clinical cluster. + if (cycleBlock) { + snapshot.cycle = cycleBlock; + registerBlock("cycle", "skin_temp"); + } + if (Object.keys(snapshot).length === 0) { metrics.add("general"); } diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 9ae0d0542..c0f0cc0d5 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -159,6 +159,20 @@ GROUND RULES Never invent or round a number the block does not carry. When NO "trajectory" block is present, do not project at all — pivot to the observed pattern instead. +12. Cycle data is DESCRIPTIVE ONLY. When a "cycle" block is present + in the SNAPSHOT you may state the user's current phase and + day-of-cycle, the next predicted period as a RANGE (reading + "nextEvent" straight from the block, always conditional), and + the headline phase finding ("phaseInsight") in the block's own + observational wording — never as a cause. NEVER frame any + prediction as contraception-grade, NEVER name a "safe day" or + "unsafe day", NEVER advise on conception timing beyond what the + goal-gated "fertileWindow" already states descriptively, and + NEVER present a predicted date as a certainty. If asked to use + the cycle for contraception or to guarantee a fertile/infertile + day, decline and say the forecast is a descriptive estimate, not + a contraceptive method. When NO "cycle" block is present, do not + infer or guess a phase. DAY-LEVEL READINGS — USE THE TIMELINE @@ -445,6 +459,22 @@ GRUNDREGELN Erfinde oder runde nie eine Zahl, die der Block nicht enthält. Wenn KEIN "trajectory"-Block vorhanden ist, projiziere überhaupt nicht — wechsle stattdessen zum beobachteten Muster. +12. Zyklusdaten sind AUSSCHLIESSLICH BESCHREIBEND. Wenn ein + "cycle"-Block im SNAPSHOT vorhanden ist, darfst du die aktuelle + Phase und den Zyklustag nennen, die nächste vorhergesagte + Periode als BEREICH ("nextEvent" direkt aus dem Block, immer + konditional) und das Phasen-Hauptmuster ("phaseInsight") in der + beobachtenden Formulierung des Blocks — nie als Ursache. Stelle + eine Vorhersage NIE als verhütungstauglich dar, nenne NIE einen + "sicheren" oder "unsicheren" Tag, berate NIE zum + Empfängniszeitpunkt über das hinaus, was das ziel-gebundene + "fertileWindow" bereits beschreibend angibt, und gib ein + vorhergesagtes Datum NIE als Gewissheit aus. Wirst du gebeten, + den Zyklus zur Verhütung zu nutzen oder einen fruchtbaren bzw. + unfruchtbaren Tag zu garantieren, lehne ab und weise darauf hin, + dass die Prognose eine beschreibende Schätzung ist, keine + Verhütungsmethode. Wenn KEIN "cycle"-Block vorhanden ist, leite + oder rate keine Phase ab. TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE diff --git a/src/lib/cycle/__tests__/phase-crosstab.test.ts b/src/lib/cycle/__tests__/phase-crosstab.test.ts new file mode 100644 index 000000000..98142de80 --- /dev/null +++ b/src/lib/cycle/__tests__/phase-crosstab.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vitest"; + +import { + computePhaseMetricCrosstab, + buildPhaseDiscoverySeries, + discoverPhaseCorrelations, + selectHeadlinePhaseRow, + PHASE_ORDINAL, + PHASE_CROSSTAB_METRIC_TYPES, + CYCLE_PHASE_CHANNEL_KEY, + type PhaseMetricCrosstabRow, +} from "../phase-crosstab"; +import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates"; +import type { CyclePhase } from "../types"; + +/** Build a measurement row on a given YYYY-MM-DD at noon UTC. */ +function m( + type: string, + day: string, + value: number, +): CrossMetricMeasurement { + return { + type, + value, + measuredAt: new Date(`${day}T12:00:00.000Z`), + source: "APPLE_HEALTH", + deviceType: null, + }; +} + +/** + * A phase-day map + matching daily metric values, with the luteal group's + * resting heart rate clearly higher than the follicular group's. Days run + * 2026-03-01 onward; the first `n` are LUTEAL, the next `n` FOLLICULAR. + */ +function fixture(opts: { + n: number; + lutealRhr: number; + follicularRhr: number; +}): { + phaseByDay: Map; + measurements: CrossMetricMeasurement[]; +} { + const phaseByDay = new Map(); + const measurements: CrossMetricMeasurement[] = []; + let dayIdx = 0; + const dayKey = (i: number) => { + const d = new Date(Date.UTC(2026, 2, 1)); + d.setUTCDate(d.getUTCDate() + i); + return d.toISOString().slice(0, 10); + }; + for (let i = 0; i < opts.n; i++) { + const k = dayKey(dayIdx++); + phaseByDay.set(k, "LUTEAL"); + // tiny deterministic jitter so the group has non-zero variance + measurements.push(m("RESTING_HEART_RATE", k, opts.lutealRhr + (i % 3) * 0.5)); + } + for (let i = 0; i < opts.n; i++) { + const k = dayKey(dayIdx++); + phaseByDay.set(k, "FOLLICULAR"); + measurements.push( + m("RESTING_HEART_RATE", k, opts.follicularRhr + (i % 3) * 0.5), + ); + } + return { phaseByDay, measurements }; +} + +describe("PHASE_ORDINAL", () => { + it("is the monotone menstrual→luteal progression", () => { + expect(PHASE_ORDINAL.MENSTRUAL).toBe(0); + expect(PHASE_ORDINAL.FOLLICULAR).toBe(1); + expect(PHASE_ORDINAL.OVULATORY).toBe(2); + expect(PHASE_ORDINAL.LUTEAL).toBe(3); + }); +}); + +describe("PHASE_CROSSTAB_METRIC_TYPES", () => { + it("includes the temperature channels held out of the general matrix", () => { + expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("WRIST_TEMPERATURE"); + expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("SKIN_TEMPERATURE"); + expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("BODY_TEMPERATURE"); + expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("RESTING_HEART_RATE"); + }); +}); + +describe("computePhaseMetricCrosstab", () => { + it("surfaces a clear luteal-vs-follicular RHR contrast", () => { + const { phaseByDay, measurements } = fixture({ + n: 14, + lutealRhr: 63, + follicularRhr: 57, + }); + const rows = computePhaseMetricCrosstab({ phaseByDay, measurements }); + const rhr = rows.find((r) => r.metricKey === "restingHeartRate"); + expect(rhr).toBeDefined(); + // luteal higher → positive delta (lutealAvg − follicularAvg) + expect(rhr!.delta).toBeGreaterThan(0); + expect(rhr!.lutealDays).toBe(14); + expect(rhr!.follicularDays).toBe(14); + expect(rhr!.pValue).toBeLessThan(0.05); + expect(rhr!.qValue).toBeLessThanOrEqual(0.1); + expect(["low", "medium", "high"]).toContain(rhr!.confidence); + }); + + it("returns no row when a phase group is below the day floor", () => { + // 4 luteal days only — under CROSSTAB_MIN_PRESENT_DAYS (5). + const { phaseByDay, measurements } = fixture({ + n: 4, + lutealRhr: 63, + follicularRhr: 57, + }); + const rows = computePhaseMetricCrosstab({ phaseByDay, measurements }); + expect(rows).toEqual([]); + }); + + it("does not fabricate a row when the phases do not differ", () => { + const { phaseByDay, measurements } = fixture({ + n: 20, + lutealRhr: 60, + follicularRhr: 60, + }); + const rows = computePhaseMetricCrosstab({ phaseByDay, measurements }); + // No real separation → nothing clears p < 0.05. + expect(rows.find((r) => r.metricKey === "restingHeartRate")).toBeUndefined(); + }); + + it("ignores MENSTRUAL / OVULATORY days (only the two contrast phases count)", () => { + const { phaseByDay, measurements } = fixture({ + n: 14, + lutealRhr: 63, + follicularRhr: 57, + }); + // Add menstrual + ovulatory days with extreme values that would skew a + // naive mean — they must be excluded from the contrast. + const extra = new Date(Date.UTC(2026, 5, 1)); + for (let i = 0; i < 6; i++) { + const k = new Date(extra); + k.setUTCDate(k.getUTCDate() + i); + const key = k.toISOString().slice(0, 10); + phaseByDay.set(key, i % 2 === 0 ? "MENSTRUAL" : "OVULATORY"); + measurements.push(m("RESTING_HEART_RATE", key, 200)); + } + const rows = computePhaseMetricCrosstab({ phaseByDay, measurements }); + const rhr = rows.find((r) => r.metricKey === "restingHeartRate")!; + // Means reflect only the luteal/follicular days, not the 200-bpm noise. + expect(rhr.lutealAvg).toBeLessThan(70); + expect(rhr.follicularAvg).toBeLessThan(70); + }); + + it("returns empty for an empty phase map", () => { + expect( + computePhaseMetricCrosstab({ + phaseByDay: new Map(), + measurements: [m("RESTING_HEART_RATE", "2026-03-01", 60)], + }), + ).toEqual([]); + }); +}); + +describe("buildPhaseDiscoverySeries", () => { + it("emits a sorted ordinal CYCLE_PHASE behaviour series", () => { + const phaseByDay = new Map([ + ["2026-03-03", "OVULATORY"], + ["2026-03-01", "MENSTRUAL"], + ["2026-03-02", "FOLLICULAR"], + ["2026-03-04", "LUTEAL"], + ]); + const series = buildPhaseDiscoverySeries(phaseByDay); + expect(series.key).toBe(CYCLE_PHASE_CHANNEL_KEY); + expect(series.role).toBe("behaviour"); + expect(series.points.map((p) => p.day)).toEqual([ + "2026-03-01", + "2026-03-02", + "2026-03-03", + "2026-03-04", + ]); + expect(series.points.map((p) => p.value)).toEqual([0, 1, 2, 3]); + }); +}); + +describe("discoverPhaseCorrelations", () => { + it("folds the continuous CYCLE_PHASE channel into the lagged-Pearson matrix", () => { + // A contiguous 42-day span cycling MENSTRUAL→FOLLICULAR→OVULATORY→LUTEAL, + // with next-day RHR tracking the phase ordinal so the lag-joined Pearson + // is strong and clears n ≥ 20. + const phaseByDay = new Map(); + const measurements: CrossMetricMeasurement[] = []; + const phases: CyclePhase[] = [ + "MENSTRUAL", + "FOLLICULAR", + "OVULATORY", + "LUTEAL", + ]; + const base = new Date(Date.UTC(2026, 0, 1)); + for (let i = 0; i < 42; i++) { + const d = new Date(base); + d.setUTCDate(d.getUTCDate() + i); + const key = d.toISOString().slice(0, 10); + const phase = phases[i % phases.length]; + phaseByDay.set(key, phase); + // Next-day RHR = a clean linear function of THIS day's ordinal, so the + // D→D+1 lag join recovers a near-perfect positive correlation. + const nextKey = new Date(d); + nextKey.setUTCDate(nextKey.getUTCDate() + 1); + measurements.push( + m( + "RESTING_HEART_RATE", + nextKey.toISOString().slice(0, 10), + 55 + PHASE_ORDINAL[phase] * 2, + ), + ); + } + const result = discoverPhaseCorrelations({ phaseByDay, measurements }); + const pair = result.discovered.find( + (p) => p.behaviour === CYCLE_PHASE_CHANNEL_KEY, + ); + expect(pair).toBeDefined(); + expect(pair!.outcome).toBe("RESTING_HEART_RATE"); + expect(pair!.n).toBeGreaterThanOrEqual(20); + expect(pair!.r).toBeGreaterThan(0.5); + expect(pair!.qValue).toBeLessThanOrEqual(result.fdrQ); + expect(pair!.interpretation).toMatch(/not a cause/); + }); + + it("returns no discovered pair on flat (non-correlated) data", () => { + const phaseByDay = new Map(); + const measurements: CrossMetricMeasurement[] = []; + const base = new Date(Date.UTC(2026, 0, 1)); + const phases: CyclePhase[] = ["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"]; + for (let i = 0; i < 42; i++) { + const d = new Date(base); + d.setUTCDate(d.getUTCDate() + i); + const key = d.toISOString().slice(0, 10); + phaseByDay.set(key, phases[i % phases.length]); + measurements.push(m("RESTING_HEART_RATE", key, 60)); // constant → no variance + } + const result = discoverPhaseCorrelations({ phaseByDay, measurements }); + expect(result.discovered).toEqual([]); + }); +}); + +describe("selectHeadlinePhaseRow", () => { + const row = (metricKey: string): PhaseMetricCrosstabRow => ({ + metricKey: metricKey as PhaseMetricCrosstabRow["metricKey"], + display: "bpm", + lutealDays: 10, + follicularDays: 10, + lutealAvg: 63, + follicularAvg: 57, + delta: 6, + pValue: 0.01, + qValue: 0.02, + confidence: "medium", + }); + + it("prefers resting heart rate", () => { + const rows = [row("weight"), row("restingHeartRate"), row("heartRateVariability")]; + expect(selectHeadlinePhaseRow(rows)?.metricKey).toBe("restingHeartRate"); + }); + + it("falls back to HRV when RHR is absent", () => { + const rows = [row("weight"), row("heartRateVariability")]; + expect(selectHeadlinePhaseRow(rows)?.metricKey).toBe("heartRateVariability"); + }); + + it("falls back to the strongest remaining row", () => { + const rows = [row("weight")]; + expect(selectHeadlinePhaseRow(rows)?.metricKey).toBe("weight"); + }); + + it("returns null on no rows", () => { + expect(selectHeadlinePhaseRow([])).toBeNull(); + }); +}); diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index 94b471388..1474d0fdb 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -100,7 +100,7 @@ function periodLengthOf( * span ends where the next begins; the latest open cycle runs to the * predicted next-period start). Used to label every calendar day's phase. */ -function buildPhaseCycles( +export function buildPhaseCycles( cycles: readonly Pick< MenstrualCycle, "startDate" | "endDate" | "periodEndDate" | "ovulationDate" @@ -127,7 +127,10 @@ function buildPhaseCycles( } /** Phase of a single date across the contiguous cycle list (or null). */ -function phaseAcross(date: string, phaseCycles: readonly PhaseCycle[]): CyclePhase | null { +export function phaseAcross( + date: string, + phaseCycles: readonly PhaseCycle[], +): CyclePhase | null { for (const pc of phaseCycles) { const r = phaseForDay(date, pc); if (r.phase !== null) return r.phase; @@ -135,6 +138,62 @@ function phaseAcross(date: string, phaseCycles: readonly PhaseCycle[]): CyclePha return null; } +/** + * Resolve the phase + 1-based day-of-cycle of a single `YYYY-MM-DD` date + * across the observed cycles, with the latest open cycle running to + * `predictedNextStart` (or +28d). Returns `{ phase: null, dayOfCycle: null }` + * when the date falls outside every cycle window. Used by the Coach snapshot + * to state "you are on day N, luteal phase". + */ +export function phaseForDate( + date: string, + cycles: readonly Pick< + MenstrualCycle, + "startDate" | "endDate" | "periodEndDate" | "ovulationDate" + >[], + predictedNextStart: string | null, + lutealLength: number, +): { phase: CyclePhase | null; dayOfCycle: number | null } { + const phaseCycles = buildPhaseCycles(cycles, predictedNextStart, lutealLength); + for (const pc of phaseCycles) { + const r = phaseForDay(date, pc); + if (r.phase !== null) return r; + } + return { phase: null, dayOfCycle: null }; +} + +/** + * Build a `YYYY-MM-DD → CyclePhase` map across `[from, to]` inclusive from the + * observed cycles. Days that fall outside every cycle window are omitted (no + * key) rather than carrying a null — a consumer joins on presence. The latest + * open cycle runs to `predictedNextStart` (or +28d) like `buildCalendar`, so + * the trailing window is labelled even before the next period is logged. + * + * This is the per-day phase series the CYCLE_PHASE correlation channel and the + * Coach phase block both consume, derived identically to the calendar grid so + * the phase a day shows in the UI matches the phase the stats engine uses. + */ +export function buildPhaseDayMap( + cycles: readonly Pick< + MenstrualCycle, + "startDate" | "endDate" | "periodEndDate" | "ovulationDate" + >[], + predictedNextStart: string | null, + lutealLength: number, + from: string, + to: string, +): Map { + const phaseCycles = buildPhaseCycles(cycles, predictedNextStart, lutealLength); + const out = new Map(); + const span = dayDiff(to, from); + for (let i = 0; i <= span; i++) { + const date = addDays(from, i); + const phase = phaseAcross(date, phaseCycles); + if (phase !== null) out.set(date, phase); + } + return out; +} + export interface CalendarBuildResult { prediction: CyclePredictionResult | null; days: CalendarDayDTO[]; diff --git a/src/lib/cycle/phase-crosstab.ts b/src/lib/cycle/phase-crosstab.ts new file mode 100644 index 000000000..7732cd790 --- /dev/null +++ b/src/lib/cycle/phase-crosstab.ts @@ -0,0 +1,302 @@ +/** + * v1.15 — CYCLE_PHASE × vital crosstab + discovery channel. + * + * The cycle phase becomes one more channel in the same FDR-guarded discovery + * machinery the RATED mood factors were folded into in v1.14.0 — no new + * statistics. Two complementary mechanisms (integration-audit §1): + * + * A. PHASE-CONTRAST (categorical → Welch + BH-FDR). Per outcome metric, + * compare the LUTEAL day-group against the FOLLICULAR day-group — the two + * phases with the largest documented physiological contrast — through the + * identical `welchTTest` + `benjaminiHochberg` pipeline the mood-factor + * crosstab runs (`computeFactorMetricCrosstab` in + * `src/lib/insights/mood-aggregates.ts`). Same day floors, same q = 0.10, + * same confidence bands. + * + * B. CONTINUOUS ordinal channel for the lagged-Pearson matrix. The phase maps + * to {MENSTRUAL:0, FOLLICULAR:1, OVULATORY:2, LUTEAL:3} and enters + * `discoverCorrelations` as a `NamedSeries{ key: "CYCLE_PHASE", role: + * "behaviour" }` exactly the way a `FACTOR:` channel does. + * + * Pure + DB-free: every function here takes already-fetched rows (the gated + * `/api/cycle/insights` route does the reads + the gender gate). The phase-day + * membership comes from `buildPhaseDayMap` in `engine-adapter.ts`, derived + * identically to the calendar grid so the phase the stats use matches the + * phase the UI shows. + * + * Privacy: this lives ONLY behind the cycle gate. `WRIST_TEMPERATURE` / + * `SKIN_TEMPERATURE` are deliberately held OUT of the GENERAL correlation + * matrix (`correlation-discovery.ts`) precisely so a cycle-phase relation can + * never leak into a non-gender-gated surface; the cycle feature is the + * deliberate decision that un-blocks them here, and nowhere else. + */ +import { + metricDayMap, + influenceConfidence, + CROSSTAB_MIN_PRESENT_DAYS, + CROSSTAB_MIN_ABSENT_DAYS, + CROSSTAB_FDR_Q, + CROSSTAB_MAX_ROWS, + type CrossMetricMeasurement, + type InfluenceConfidence, +} from "@/lib/insights/mood-aggregates"; +import { welchTTest } from "@/lib/insights/correlations"; +import { + benjaminiHochberg as fdrAdjust, + discoverCorrelations, + type NamedSeries, + type DailySeriesPoint, + type CorrelationDiscoveryResult, +} from "@/lib/insights/correlation-discovery"; +import { round } from "@/lib/insights/status-shared"; +import type { MeasurementType } from "@/generated/prisma/enums"; +import type { CyclePhase } from "@/lib/cycle/types"; + +/** Stable channel key for the continuous CYCLE_PHASE discovery series. */ +export const CYCLE_PHASE_CHANNEL_KEY = "CYCLE_PHASE"; + +/** + * Ordinal encoding of the phase for the continuous lagged-Pearson channel — + * the menstrual→luteal progression as a monotone 0..3 scale. The contrast + * crosstab (mechanism A) uses the categorical label directly and never this + * ordinal; the ordinal exists only for mechanism B. + */ +export const PHASE_ORDINAL: Record = { + MENSTRUAL: 0, + FOLLICULAR: 1, + OVULATORY: 2, + LUTEAL: 3, +}; + +/** Display-unit hint the client formatter branches on. */ +export type PhaseCrosstabDisplay = + | "hours" + | "steps" + | "bpm" + | "ms" + | "kg" + | "celsius"; + +/** + * The outcome metric grid the phase contrast walks. Mirrors + * `FACTOR_CROSSTAB_METRICS` (RHR / HRV / sleep / steps / weight) and EXTENDS it + * with the temperature channels (BBT + passive wrist/skin temp) that the + * audit flags as both a phase-correlation outcome AND the symptothermal + * ovulation input — surfaced here, never on the general matrix. + */ +export const PHASE_CROSSTAB_METRICS: Record< + string, + { type: MeasurementType; display: PhaseCrosstabDisplay } +> = { + restingHeartRate: { type: "RESTING_HEART_RATE", display: "bpm" }, + heartRateVariability: { type: "HEART_RATE_VARIABILITY", display: "ms" }, + sleepDuration: { type: "SLEEP_DURATION", display: "hours" }, + steps: { type: "ACTIVITY_STEPS", display: "steps" }, + weight: { type: "WEIGHT", display: "kg" }, + basalBodyTemp: { type: "BODY_TEMPERATURE", display: "celsius" }, + wristTemperature: { type: "WRIST_TEMPERATURE", display: "celsius" }, + skinTemperature: { type: "SKIN_TEMPERATURE", display: "celsius" }, +} as const; + +export type PhaseCrosstabMetricKey = keyof typeof PHASE_CROSSTAB_METRICS; + +/** Distinct measurement types the phase crosstab reads — single-sourced. */ +export const PHASE_CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from( + new Set(Object.values(PHASE_CROSSTAB_METRICS).map((m) => m.type)), +); + +/** The two phases the categorical contrast compares (largest contrast). */ +const CONTRAST_HIGH: CyclePhase = "LUTEAL"; +const CONTRAST_LOW: CyclePhase = "FOLLICULAR"; + +export interface PhaseMetricCrosstabRow { + /** Which metric channel this row compares against. */ + metricKey: PhaseCrosstabMetricKey; + /** Display-unit hint for the client formatter. */ + display: PhaseCrosstabDisplay; + /** Days in the luteal group with a paired metric value. */ + lutealDays: number; + /** Days in the follicular group with a paired metric value. */ + follicularDays: number; + /** Mean metric on luteal days (display unit). */ + lutealAvg: number; + /** Mean metric on follicular days (display unit). */ + follicularAvg: number; + /** lutealAvg − follicularAvg (display unit). Positive = higher in luteal. */ + 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; +} + +/** Convert a raw metric value to its phase-crosstab display unit. */ +function toDisplayUnit(value: number, display: PhaseCrosstabDisplay): number { + return display === "hours" ? value / 60 : value; +} + +interface PhaseCrosstabCandidate { + row: Omit; +} + +/** + * Compute the CYCLE_PHASE × metric crosstab. Pure over already-fetched rows. + * + * For every configured metric, group the per-day deduped metric values by + * whether the day was LUTEAL or FOLLICULAR (the phase-day map). Each group's + * samples feed `welchTTest`; rows that clear both day floors are tested, + * FDR-corrected as ONE family, and the survivors (p < 0.05 AND q ≤ + * `CROSSTAB_FDR_Q`) surface ranked by q then |delta|. + * + * Honest: thin data never fabricates a row (the day floors + FDR gate are the + * mood crosstab's, unchanged); a population-typical relation that does not + * appear in the user's own data simply yields no row. + */ +export function computePhaseMetricCrosstab(args: { + /** `YYYY-MM-DD → CyclePhase` for every dated day in the window. */ + phaseByDay: Map; + measurements: CrossMetricMeasurement[]; + /** The user's source-priority blob (threaded into `metricDayMap`). */ + userPriorityJson?: unknown; +}): PhaseMetricCrosstabRow[] { + const { phaseByDay, measurements } = args; + const userPriorityJson = args.userPriorityJson ?? null; + if (phaseByDay.size === 0) return []; + + const candidates: PhaseCrosstabCandidate[] = []; + + for (const [metricKey, cfg] of Object.entries(PHASE_CROSSTAB_METRICS) as Array< + [PhaseCrosstabMetricKey, (typeof PHASE_CROSSTAB_METRICS)[PhaseCrosstabMetricKey]] + >) { + const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson); + if (metricByDay.size === 0) continue; + + const lutealVals: number[] = []; + const follicularVals: number[] = []; + for (const [dayKey, phase] of phaseByDay) { + if (phase !== CONTRAST_HIGH && phase !== CONTRAST_LOW) continue; + const metricValue = metricByDay.get(dayKey); + if (metricValue == null || !Number.isFinite(metricValue)) continue; + const display = toDisplayUnit(metricValue, cfg.display); + if (phase === CONTRAST_HIGH) lutealVals.push(display); + else follicularVals.push(display); + } + + if ( + lutealVals.length < CROSSTAB_MIN_PRESENT_DAYS || + follicularVals.length < CROSSTAB_MIN_ABSENT_DAYS + ) { + continue; + } + + const welch = welchTTest(lutealVals, follicularVals); + const lutealAvg = lutealVals.reduce((s, v) => s + v, 0) / lutealVals.length; + const follicularAvg = + follicularVals.reduce((s, v) => s + v, 0) / follicularVals.length; + const delta = lutealAvg - follicularAvg; + if (delta === 0) continue; + + const pValue = welch.status === "ok" ? welch.pValue : 1; + const minGroupDays = Math.min(lutealVals.length, follicularVals.length); + + candidates.push({ + row: { + metricKey, + display: cfg.display, + lutealDays: lutealVals.length, + follicularDays: follicularVals.length, + lutealAvg: round(lutealAvg, 2), + follicularAvg: round(follicularAvg, 2), + delta: round(delta, 2), + pValue, + confidence: influenceConfidence(pValue, minGroupDays), + }, + }); + } + + if (candidates.length === 0) return []; + + // One BH family across every metric tested — the same step-up the mood + // 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.metricKey.localeCompare(b.metricKey), + ) + .slice(0, CROSSTAB_MAX_ROWS); +} + +/** + * Build the continuous CYCLE_PHASE behaviour series for the lagged-Pearson + * discovery matrix (mechanism B). One ordinal value per labelled day. The + * caller folds this into `discoverCorrelations(series)` alongside the existing + * behaviour/outcome channels — the engine is pure and parameterised over + * `NamedSeries[]`, so this never touches the general (non-gated) route. + */ +export function buildPhaseDiscoverySeries( + phaseByDay: Map, +): NamedSeries { + const points: DailySeriesPoint[] = []; + for (const [day, phase] of phaseByDay) { + points.push({ day, value: PHASE_ORDINAL[phase] }); + } + points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); + return { key: CYCLE_PHASE_CHANNEL_KEY, role: "behaviour", points }; +} + +/** + * Mechanism B — run the lagged-Pearson FDR discovery with the continuous + * CYCLE_PHASE ordinal folded in as a behaviour channel against the outcome + * metric series. Pure: the caller builds the per-day outcome series (from + * `metricDayMap`) and passes the phase map. Reuses `discoverCorrelations` + * verbatim — no new statistics, the same n ≥ 20 / p < 0.05 / BH-FDR gate the + * general matrix enforces — but scoped to the cycle-gated route only, so the + * phase channel never reaches the non-gated `/api/insights/correlations`. + */ +export function discoverPhaseCorrelations(args: { + phaseByDay: Map; + measurements: CrossMetricMeasurement[]; + userPriorityJson?: unknown; +}): CorrelationDiscoveryResult { + const { phaseByDay, measurements } = args; + const userPriorityJson = args.userPriorityJson ?? null; + + const phaseSeries = buildPhaseDiscoverySeries(phaseByDay); + const outcomeSeries: NamedSeries[] = []; + for (const [, cfg] of Object.entries(PHASE_CROSSTAB_METRICS)) { + const byDay = metricDayMap(measurements, cfg.type, userPriorityJson); + if (byDay.size === 0) continue; + const points: DailySeriesPoint[] = []; + for (const [day, value] of byDay) points.push({ day, value }); + points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); + outcomeSeries.push({ key: cfg.type, role: "outcome", points }); + } + + return discoverCorrelations([phaseSeries, ...outcomeSeries]); +} + +/** + * Pick the ONE headline phase insight for the v1.15.0 MVP card (research + * §7.1): resting-heart-rate-by-phase first (near-daily, the highest-value, + * most-reachable n), falling back to HRV-by-phase, then to the strongest + * remaining FDR-surviving row. Returns null when nothing cleared the gate — + * honest "still learning", never a fabricated headline. + */ +export function selectHeadlinePhaseRow( + rows: PhaseMetricCrosstabRow[], +): PhaseMetricCrosstabRow | null { + if (rows.length === 0) return null; + return ( + rows.find((r) => r.metricKey === "restingHeartRate") ?? + rows.find((r) => r.metricKey === "heartRateVariability") ?? + rows[0] + ); +} diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index cf43fabff..ec751e441 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -897,7 +897,7 @@ function berlinDayKey(measuredAt: Date): string { * test fixtures, or a metric whose source isn't in the ladder) fall * through the picker's pass-through branch unchanged. */ -function metricDayMap( +export function metricDayMap( measurements: CrossMetricMeasurement[], type: string, userPriorityJson: unknown, @@ -944,7 +944,7 @@ function metricDayMap( } /** Add `lagDays` to a YYYY-MM-DD day key (UTC-anchored, DST-immune). */ -function shiftDayKey(day: string, lagDays: number): string { +export function shiftDayKey(day: string, lagDays: number): string { const [y, m, d] = day.split("-").map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); dt.setUTCDate(dt.getUTCDate() + lagDays); diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 4914a9c18..ca6fb438f 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -2349,6 +2349,68 @@ const cyclePeriodResponse = z.object({ dayLog: cycleDayLogDto.nullable(), }); +const cyclePhaseCrosstabRow = z + .object({ + metricKey: z.enum([ + "restingHeartRate", + "heartRateVariability", + "sleepDuration", + "steps", + "weight", + "basalBodyTemp", + "wristTemperature", + "skinTemperature", + ]), + display: z.enum(["hours", "steps", "bpm", "ms", "kg", "celsius"]), + lutealDays: z.number().int(), + follicularDays: z.number().int(), + lutealAvg: z.number(), + follicularAvg: z.number(), + delta: z.number(), + pValue: z.number(), + qValue: z.number(), + confidence: z.enum(["low", "medium", "high"]), + }) + .meta({ + id: "CyclePhaseCrosstabRow", + description: + "One FDR-surviving luteal-vs-follicular contrast for an outcome metric. `delta = lutealAvg − follicularAvg`. Observational, never causal.", + }); + +const cyclePhaseLaggedPair = z + .object({ + behaviour: z.string(), + outcome: z.string(), + n: z.number().int(), + r: z.number(), + pValue: z.number(), + qValue: z.number(), + interpretation: z.string(), + lagDays: z.number().int(), + }) + .meta({ + id: "CyclePhaseLaggedPair", + description: + "One FDR-surviving lagged-Pearson pair from the continuous CYCLE_PHASE ordinal × outcome matrix (mechanism B). Descriptive, never causal.", + }); + +const cycleInsightsResponse = z.object({ + rows: z.array(cyclePhaseCrosstabRow), + headline: cyclePhaseCrosstabRow.nullable(), + lagged: z.object({ + discovered: z.array(cyclePhaseLaggedPair), + pairsTested: z.number().int(), + fdrQ: z.number(), + minPairs: z.number().int(), + }), + contrast: z.object({ + high: z.literal("LUTEAL"), + low: z.literal("FOLLICULAR"), + }), + windowDays: z.number().int(), + cyclesObserved: z.number().int(), +}); + const cycleBulkEntryResult = z.object({ index: z.number().int(), status: z.enum(["inserted", "duplicate", "updated", "skipped"]), @@ -2554,6 +2616,26 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/cycle/insights": { + get: { + tags: ["Cycle"], + summary: "Cycle-phase correlation insights (v1.15.0)", + description: + "FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off.", + responses: { + "200": { + description: "Phase-correlation rows + headline.", + content: { + "application/json": { + schema: dataEnvelope(cycleInsightsResponse, "CycleInsightsEnvelope"), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, "/api/cycle/cycles/{id}": { delete: { tags: ["Cycle"],