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

+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"],