mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
5f485ca780
commit
77a652a47c
18 files changed
+2057
-4
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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ść"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user