feat(cycle): cycle-phase correlation insight + Coach phase dimension

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 09:59:02 +02:00
1 parent 5f485ca780
commit 77a652a47c
18 files changed
+2057 -4

No files matched your search

+195
View File
@@ -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:
+34
View File
@@ -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"
}
}
}
}
+34
View File
@@ -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"
}
}
}
}
+34
View File
@@ -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"
}
}
}
}
+34
View File
@@ -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"
}
}
}
}
+34
View File
@@ -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"
}
}
}
}
+34
View File
@@ -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ść"
}
}
}
}
+192
View File
@@ -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,
});
});
@@ -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<string, string> = {
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<CyclePhaseCrosstabDisplay, string> = {
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<CyclePhaseCrosstabConfidence, string> = {
low: "cycle.insights.crosstab.confidenceLow",
medium: "cycle.insights.crosstab.confidenceMedium",
high: "cycle.insights.crosstab.confidenceHigh",
};
const CONFIDENCE_CLASS: Record<CyclePhaseCrosstabConfidence, 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: CyclePhaseCrosstabDisplay): string {
return display === "steps" ? Math.round(value).toString() : value.toFixed(1);
}
const HEADLINE_KEY: Record<string, string> = {
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 (
<p
data-slot="cycle-phase-headline-empty"
className="text-muted-foreground text-sm"
>
{t("cycle.insights.headline.empty")}
</p>
);
}
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 (
<p
data-slot="cycle-phase-headline"
data-metric={headline.metricKey}
className="text-foreground text-sm font-medium"
>
{t(key, params)}
</p>
);
}
export function CyclePhaseCrosstab({
rows,
}: {
rows: CyclePhaseCrosstabRow[];
}) {
const { t } = useTranslations();
if (rows.length === 0) return null;
return (
<div data-slot="cycle-phase-crosstab">
<p className="text-muted-foreground mb-2 text-sm">
{t("cycle.insights.crosstab.description")}
</p>
<ul className="divide-border divide-y">
{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 (
<li
key={row.metricKey}
className="flex flex-col gap-1.5 py-2"
data-slot="cycle-phase-crosstab-row"
data-metric={row.metricKey}
data-direction={up ? "up" : "down"}
data-confidence={row.confidence}
>
<div className="flex items-center gap-2 text-sm">
<span
className="text-foreground min-w-0 flex-1 truncate"
title={metricLabel}
>
{t("cycle.insights.crosstab.pairLabel", { metric: metricLabel })}
</span>
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
{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("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,
})}
</p>
</li>
);
})}
</ul>
<p className="text-muted-foreground mt-3 text-xs">
{t("cycle.insights.crosstab.footer")}
</p>
</div>
);
}
@@ -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<typeof vi.fn> };
menstrualCycle: { findMany: ReturnType<typeof vi.fn> };
cycleDayLog: { findMany: ReturnType<typeof vi.fn> };
measurement: { findMany: ReturnType<typeof vi.fn> };
user: { findUnique: ReturnType<typeof vi.fn> };
};
const NOW = new Date("2026-03-22T12:00:00.000Z");
function profile(overrides: Record<string, unknown> = {}) {
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<Record<string, unknown>> = [];
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/);
});
});
+321
View File
@@ -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<string, string> = {
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<string, string> = {
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<CycleSnapshotBlock | null> {
// 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,
};
}
+37
View File
@@ -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");
}
+30
View File
@@ -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
@@ -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<string, CyclePhase>;
measurements: CrossMetricMeasurement[];
} {
const phaseByDay = new Map<string, CyclePhase>();
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<string, CyclePhase>([
["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<string, CyclePhase>();
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<string, CyclePhase>();
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();
});
});
+61 -2
View File
@@ -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<string, CyclePhase> {
const phaseCycles = buildPhaseCycles(cycles, predictedNextStart, lutealLength);
const out = new Map<string, CyclePhase>();
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[];
+302
View File
@@ -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<CyclePhase, number> = {
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<PhaseMetricCrosstabRow, "qValue">;
}
/**
* 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<string, CyclePhase>;
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<string, CyclePhase>,
): 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<string, CyclePhase>;
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]
);
}
+2 -2
View File
@@ -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);
+82
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],