Files
HealthLog/src/lib/cycle/phase-crosstab.ts
T
Marc-André BombeckandClaude Opus 4.8 15bece9849 fix(cycle): scope symptothermal to the current cycle, unify luteal clamp, widen fertile + crosstab coverage
The symptothermal layer ran against the full day-log history: detectTempShift
returned the earliest 3-over-6 rise and detectMucusPeak the last egg-white day
across all cycles, so a multi-cycle user with historical BBT confirmed a
months-old ovulation and the predicted next-period start landed in the past.
Scope the temperature/mucus detectors to the current cycle window
[lastConfirmedStart − BBT_WINDOW, today], take the latest qualifying temp shift,
and reject any confirmation whose next-start is already in the past.

Resolve luteal length through a single clamp. The predictor clamped to [10,16]
while the calendar adapter and phase mapper used the raw stored value, so the
ovulation dot and the OVULATORY band could land on different days for an
out-of-clamp profile. Export resolveLuteal/clampLuteal, route buildCalendar,
buildPhaseCycles and phaseForDay through it, and tighten the profile-prefs Zod
bound to the same [10,16] range.

Show the fertile window for AVOID_PREGNANCY as well as TRYING_TO_CONCEIVE per the
algorithm spec; it stays hidden for GENERAL_HEALTH/PERIMENOPAUSE.

Bound the day-log reads on the calendar and insights routes to the rendered span
plus the symptothermal lookback instead of the full history; cycle-length stats
already run off the MenstrualCycle rows.

Add mood and blood glucose as phase-crosstab outcome metrics under the same FDR
and per-group day floors. Mood is read from MoodEntry and injected as a synthetic
channel since it is not a measurement row.

Add multi-cycle, luteal-clamp and fertile-window fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:06:48 +02:00

329 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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"
| "glucose"
| "mood";
/**
* Synthetic non-MeasurementType channel key for the MOOD outcome. Mood lives in
* the separate `MoodEntry` model (1–5 score), not a Measurement row, so the
* caller injects it into the crosstab `measurements` array tagged with this key
* and `metricDayMap` groups it like any other type (its pass-through fallback
* keeps every row — there is no source ladder for mood). It is deliberately NOT
* a member of `PHASE_CROSSTAB_METRIC_TYPES`, so it never enters the Measurement
* read query.
*/
export const MOOD_CHANNEL_KEY = "MOOD";
/**
* 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 | typeof MOOD_CHANNEL_KEY; 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" },
// QA HIGH (features): the crosstab must cover MOOD + GLUCOSE as outcomes,
// under the identical FDR / day-floor guards. Glucose is a MeasurementType;
// mood is the synthetic MOOD_CHANNEL_KEY the route injects (see above).
bloodGlucose: { type: "BLOOD_GLUCOSE", display: "glucose" },
mood: { type: MOOD_CHANNEL_KEY, display: "mood" },
} as const;
export type PhaseCrosstabMetricKey = keyof typeof PHASE_CROSSTAB_METRICS;
/**
* Distinct MeasurementType values the phase crosstab reads — single-sourced.
* MOOD_CHANNEL_KEY is excluded: it is not a Measurement row (the route reads it
* from MoodEntry and injects it), so it must never enter the Measurement query.
*/
export const PHASE_CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from(
new Set(
Object.values(PHASE_CROSSTAB_METRICS)
.map((m) => m.type)
.filter((t): t is MeasurementType => t !== MOOD_CHANNEL_KEY),
),
);
/** 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]
);
}