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>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 12:06:48 +02:00
1 parent ac13dd28ed
commit 15bece9849
13 files changed
+354 -36

No files matched your search

+2 -2
View File
@@ -703,8 +703,8 @@ paths:
lutealPhaseLength:
anyOf:
- type: integer
minimum: 8
maximum: 20
minimum: 10
maximum: 16
- type: "null"
responses:
"200":
+16 -2
View File
@@ -28,7 +28,8 @@ import {
goalAllowsFertileWindow,
} from "@/lib/cycle/dto";
import { persistPredictionCache } from "@/lib/cycle/prediction-cache";
import { addDays } from "@/lib/cycle/day-math";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import { BBT_WINDOW } from "@/lib/cycle/types";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -75,8 +76,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "asc" },
}),
// Bound the day-log read to the rendered span plus the symptothermal
// lookback — the earlier of `from` and (today − BBT_WINDOW). Cycle-length
// stats run off MenstrualCycle rows, so day-logs can be windowed (QA: perf
// — unbounded full-history read on a hot, per-navigation route).
prisma.cycleDayLog.findMany({
where: { userId: user.id, deletedAt: null },
where: {
userId: user.id,
deletedAt: null,
date: {
gte:
dayDiff(from, addDays(today, -BBT_WINDOW)) <= 0
? from
: addDays(today, -BBT_WINDOW),
},
},
orderBy: { date: "asc" },
select: {
date: true,
+38 -3
View File
@@ -35,6 +35,7 @@ import {
discoverPhaseCorrelations,
selectHeadlinePhaseRow,
PHASE_CROSSTAB_METRIC_TYPES,
MOOD_CHANNEL_KEY,
} from "@/lib/cycle/phase-crosstab";
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
import { addDays } from "@/lib/cycle/day-math";
@@ -56,14 +57,24 @@ export const GET = apiHandler(async () => {
const today = moodDateKey(new Date(), tz);
const from = addDays(today, -WINDOW_DAYS);
const [cycles, dayLogRows, nightlyTempRows, measurementRows, prefsRow] =
await Promise.all([
const [
cycles,
dayLogRows,
nightlyTempRows,
measurementRows,
prefsRow,
moodRows,
] = await Promise.all([
prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "asc" },
}),
// Bound the day-log read to the rendered window (`from` = today − 365d),
// which is already earlier than the symptothermal lookback. Cycle-length
// stats run off MenstrualCycle rows so the day-logs can be windowed (QA:
// perf — unbounded full-history read).
prisma.cycleDayLog.findMany({
where: { userId: user.id, deletedAt: null },
where: { userId: user.id, deletedAt: null, date: { gte: from } },
orderBy: { date: "asc" },
select: {
date: true,
@@ -112,6 +123,18 @@ export const GET = apiHandler(async () => {
where: { id: user.id },
select: { sourcePriorityJson: true },
}),
// MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a
// Measurement row, so read it here and inject it into the crosstab as a
// synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards).
prisma.moodEntry.findMany({
where: {
userId: user.id,
deletedAt: null,
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
},
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
}),
]);
// The latest open cycle runs to the predicted next-period start so the
@@ -146,6 +169,18 @@ export const GET = apiHandler(async () => {
source: m.source,
deviceType: m.deviceType,
}));
// Inject mood as a synthetic MOOD_CHANNEL_KEY series (no source ladder — the
// metricDayMap pass-through keeps every row, averaging multiple same-day
// entries like every other channel).
for (const m of moodRows) {
measurements.push({
type: MOOD_CHANNEL_KEY,
value: m.score,
measuredAt: m.moodLoggedAt,
source: null,
deviceType: null,
});
}
const userPriorityJson = prefsRow?.sourcePriorityJson ?? null;
@@ -7,7 +7,9 @@ import {
selectHeadlinePhaseRow,
PHASE_ORDINAL,
PHASE_CROSSTAB_METRIC_TYPES,
PHASE_CROSSTAB_METRICS,
CYCLE_PHASE_CHANNEL_KEY,
MOOD_CHANNEL_KEY,
type PhaseMetricCrosstabRow,
} from "../phase-crosstab";
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
@@ -81,6 +83,76 @@ describe("PHASE_CROSSTAB_METRIC_TYPES", () => {
expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("BODY_TEMPERATURE");
expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("RESTING_HEART_RATE");
});
it("includes GLUCOSE but NOT mood (mood is not a Measurement row)", () => {
expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("BLOOD_GLUCOSE");
expect(PHASE_CROSSTAB_METRIC_TYPES).not.toContain(MOOD_CHANNEL_KEY);
});
it("declares mood + glucose as outcome metrics (QA HIGH)", () => {
expect(PHASE_CROSSTAB_METRICS.mood.type).toBe(MOOD_CHANNEL_KEY);
expect(PHASE_CROSSTAB_METRICS.bloodGlucose.type).toBe("BLOOD_GLUCOSE");
});
});
describe("computePhaseMetricCrosstab — MOOD + GLUCOSE outcomes (QA HIGH)", () => {
/** Build a luteal-vs-follicular contrast for an arbitrary outcome `type`. */
function contrastFor(type: string, lutealVal: number, follicularVal: number) {
const phaseByDay = new Map<string, CyclePhase>();
const measurements: CrossMetricMeasurement[] = [];
let idx = 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 < 14; i++) {
const k = dayKey(idx++);
phaseByDay.set(k, "LUTEAL");
measurements.push(m(type, k, lutealVal + (i % 3) * 0.5));
}
for (let i = 0; i < 14; i++) {
const k = dayKey(idx++);
phaseByDay.set(k, "FOLLICULAR");
measurements.push(m(type, k, follicularVal + (i % 3) * 0.5));
}
return { phaseByDay, measurements };
}
it("surfaces a luteal-vs-follicular GLUCOSE contrast", () => {
const { phaseByDay, measurements } = contrastFor("BLOOD_GLUCOSE", 105, 92);
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
const glucose = rows.find((r) => r.metricKey === "bloodGlucose");
expect(glucose).toBeDefined();
expect(glucose!.display).toBe("glucose");
expect(glucose!.delta).toBeGreaterThan(0);
expect(glucose!.pValue).toBeLessThan(0.05);
});
it("surfaces a luteal-vs-follicular MOOD contrast (injected synthetic channel)", () => {
const { phaseByDay, measurements } = contrastFor(MOOD_CHANNEL_KEY, 4.4, 2.6);
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
const mood = rows.find((r) => r.metricKey === "mood");
expect(mood).toBeDefined();
expect(mood!.display).toBe("mood");
expect(mood!.delta).toBeGreaterThan(0);
expect(mood!.pValue).toBeLessThan(0.05);
});
});
describe("goalAllowsFertileWindow (QA MEDIUM)", () => {
it("shows the fertile window for TRYING_TO_CONCEIVE and AVOID_PREGNANCY", async () => {
const { goalAllowsFertileWindow } = await import("../dto");
expect(goalAllowsFertileWindow("TRYING_TO_CONCEIVE")).toBe(true);
expect(goalAllowsFertileWindow("AVOID_PREGNANCY")).toBe(true);
});
it("hides it for GENERAL_HEALTH / PERIMENOPAUSE / OFF", async () => {
const { goalAllowsFertileWindow } = await import("../dto");
expect(goalAllowsFertileWindow("GENERAL_HEALTH")).toBe(false);
expect(goalAllowsFertileWindow("PERIMENOPAUSE")).toBe(false);
expect(goalAllowsFertileWindow("OFF")).toBe(false);
});
});
describe("computePhaseMetricCrosstab", () => {
+113
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { addDays } from "../day-math";
import {
clampLuteal,
confirmSymptothermal,
detectTempShift,
detectTemperatureTrend,
@@ -10,6 +11,7 @@ import {
median,
observedPeriodLength,
predictCycle,
resolveLuteal,
} from "../prediction";
import type {
CycleInput,
@@ -401,3 +403,114 @@ describe("cycle/prediction — temperature-trend retrospective ovulation (§4.3)
expect(result.predictedOvulation).toBe(addDays(start, 5));
});
});
describe("cycle/prediction — multi-cycle window scoping (QA HIGH)", () => {
function bbtDay(date: string, t: number, mucus: DayLogInput["cervicalMucus"] = null): DayLogInput {
return { date, flow: null, basalBodyTempC: t, ovulationTest: null, cervicalMucus: mucus };
}
/** Build a confirmable symptothermal shift (temp + agreeing mucus) on `ovDay`. */
function symptothermalAround(ovDay: string): DayLogInput[] {
const logs: DayLogInput[] = [];
const riseStart = addDays(ovDay, 1);
const baselineStart = addDays(riseStart, -6);
[36.4, 36.41, 36.39, 36.4, 36.42, 36.4].forEach((t, i) =>
logs.push(bbtDay(addDays(baselineStart, i), t)),
);
logs.push(bbtDay(riseStart, 36.6));
logs.push(bbtDay(addDays(riseStart, 1), 36.62));
logs.push(bbtDay(addDays(riseStart, 2), 36.63));
logs.push({
date: ovDay,
flow: null,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: "EGG_WHITE",
});
return logs;
}
it("does not confirm a STALE prior-cycle ovulation for a multi-cycle user", () => {
// Three confirmed cycles; last start 2024-03-25, today well into the
// current cycle. The ONLY BBT/mucus data is a complete shift back in the
// FIRST cycle (months ago). Pre-fix, detectTempShift returned that stale
// shift and nextPeriodStart landed in the past.
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate;
expect(lastStart).toBe("2024-03-25");
const staleOv = addDays("2024-01-01", 13); // 2024-01-14, two cycles back
const logs = symptothermalAround(staleOv);
const today = addDays(lastStart, 16);
const result = predictCycle(cycles, logs, BASE_PROFILE, today);
// Stale signal is outside [lastStart − BBT_WINDOW, today] → ignored.
expect(result.ovulationConfirmed).toBe(false);
expect(result.method).toBe("CALENDAR");
// Calendar next-start is in the future, never in the past.
expect(result.nextPeriodStart >= today).toBe(true);
});
it("confirms the CURRENT-cycle shift even when a prior-cycle shift also exists", () => {
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate; // 2024-03-25
const staleOv = addDays("2024-01-01", 13); // months ago
const currentOv = addDays(lastStart, 13); // current cycle
const logs = [...symptothermalAround(staleOv), ...symptothermalAround(currentOv)];
const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 16));
expect(result.ovulationConfirmed).toBe(true);
expect(result.predictedOvulation).toBe(currentOv);
expect(result.nextPeriodStart).toBe(addDays(currentOv, 14));
});
it("detectTempShift returns the LATEST qualifying shift, not the earliest", () => {
const logs: DayLogInput[] = [];
// First shift around 2024-01-07.
const start = "2024-01-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start, i), t)),
);
logs.push(bbtDay(addDays(start, 6), 36.6));
logs.push(bbtDay(addDays(start, 7), 36.61));
logs.push(bbtDay(addDays(start, 8), 36.62));
// Drop back to baseline, then a SECOND shift a month later.
const start2 = "2024-02-01";
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
logs.push(bbtDay(addDays(start2, i), t)),
);
logs.push(bbtDay(addDays(start2, 6), 36.6));
logs.push(bbtDay(addDays(start2, 7), 36.61));
logs.push(bbtDay(addDays(start2, 8), 36.62));
const shift = detectTempShift(logs, 0.2);
// Latest match → day before the second rise onset (2024-02-07 → 2024-02-06).
expect(shift?.ovulationDate).toBe(addDays(start2, 5));
});
});
describe("cycle/prediction — luteal clamp single source of truth (QA HIGH)", () => {
it("clampLuteal pins a raw value to [10, 16]", () => {
expect(clampLuteal(8)).toBe(10);
expect(clampLuteal(20)).toBe(16);
expect(clampLuteal(13)).toBe(13);
});
it("resolveLuteal applies the default then the clamp", () => {
expect(resolveLuteal({ lutealPhaseLength: null })).toBe(14);
expect(resolveLuteal({ lutealPhaseLength: 8 })).toBe(10);
expect(resolveLuteal({ lutealPhaseLength: 19 })).toBe(16);
});
it("predictCycle ovulation uses the CLAMPED luteal for an out-of-clamp profile", () => {
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
const lastStart = cycles[cycles.length - 1].startDate;
// Profile asks for luteal 8 → engine clamps to 10.
const profile: CycleProfileInput = { ...BASE_PROFILE, lutealPhaseLength: 8 };
const result = predictCycle(cycles, [], profile, addDays(lastStart, 5));
// predictedOvulation = nextStart − clampedLuteal(10).
expect(result.predictedOvulation).toBe(addDays(result.nextPeriodStart, -10));
});
});
+9 -5
View File
@@ -236,11 +236,15 @@ type MenstrualCycleProfileRow = {
};
/**
* Whether a goal surfaces the fertile window. Per the v1.15 contract the
* window is gated to the conception goal only — GENERAL_HEALTH /
* AVOID_PREGNANCY / PERIMENOPAUSE / OFF suppress it (the suppression is
* always server-side, never iOS).
* Whether a goal surfaces the fertile window. Per algorithm.md §4 the window is
* shown for TRYING_TO_CONCEIVE and AVOID_PREGNANCY (AVOID_PREGNANCY carries a
* stronger "estimate, not a contraceptive method" caveat in the copy), and
* suppressed for GENERAL_HEALTH / PERIMENOPAUSE / OFF (the suppression is always
* server-side, never iOS).
*/
export function goalAllowsFertileWindow(goal: string): boolean {
return goal === "TRYING_TO_CONCEIVE";
// algorithm.md §4: shown for TRYING_TO_CONCEIVE AND AVOID_PREGNANCY (the
// latter carries a stronger "not a contraceptive method" caveat in copy),
// hidden for GENERAL_HEALTH / PERIMENOPAUSE / OFF.
return goal === "TRYING_TO_CONCEIVE" || goal === "AVOID_PREGNANCY";
}
+5 -2
View File
@@ -17,7 +17,7 @@ import {
type CyclePredictionResult,
type CyclePhase,
type PhaseCycle,
LUTEAL_DEFAULT,
resolveLuteal,
} from "@/lib/cycle";
import type {
CycleProfile,
@@ -219,7 +219,10 @@ export function buildCalendar(
today: string,
goalAllowsFertile: boolean,
): CalendarBuildResult {
const lutealLength = profile.lutealPhaseLength ?? LUTEAL_DEFAULT;
// Clamp identically to the engine's resolveLuteal so the predicted-ovulation
// dot (engine-clamped) and the OVULATORY phase band (this value) never
// diverge for an out-of-clamp stored luteal length (QA HIGH).
const lutealLength = resolveLuteal(profile);
// Forecast (only when prediction is on + not raw-chart mode).
let prediction: CyclePredictionResult | null = null;
+2
View File
@@ -19,5 +19,7 @@ export {
detectTemperatureTrend,
median,
mad,
resolveLuteal,
clampLuteal,
} from "./prediction";
export { phaseForDay, phaseSeries, type PhaseCycle } from "./phase";
+30 -4
View File
@@ -75,7 +75,20 @@ export type PhaseCrosstabDisplay =
| "bpm"
| "ms"
| "kg"
| "celsius";
| "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
@@ -86,7 +99,7 @@ export type PhaseCrosstabDisplay =
*/
export const PHASE_CROSSTAB_METRICS: Record<
string,
{ type: MeasurementType; display: PhaseCrosstabDisplay }
{ type: MeasurementType | typeof MOOD_CHANNEL_KEY; display: PhaseCrosstabDisplay }
> = {
restingHeartRate: { type: "RESTING_HEART_RATE", display: "bpm" },
heartRateVariability: { type: "HEART_RATE_VARIABILITY", display: "ms" },
@@ -96,13 +109,26 @@ export const PHASE_CROSSTAB_METRICS: Record<
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 measurement types the phase crosstab reads — single-sourced. */
/**
* 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)),
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). */
+3 -1
View File
@@ -18,6 +18,7 @@
*/
import { addDays, dayDiff } from "./day-math";
import { clampLuteal } from "./prediction";
import { LUTEAL_DEFAULT, POPULATION_DEFAULT_PERIOD, type CyclePhase } from "./types";
/** A resolved cycle window the phase mapper needs. */
@@ -54,7 +55,8 @@ export function phaseForDay(
const dayOfCycle = offsetFromStart + 1; // 1-based
const periodLength = cycle.periodLength ?? POPULATION_DEFAULT_PERIOD;
const lutealLength = cycle.lutealLength ?? LUTEAL_DEFAULT;
// Clamp identically to the prediction engine (QA HIGH: one source of truth).
const lutealLength = clampLuteal(cycle.lutealLength ?? LUTEAL_DEFAULT);
// Ovulation: confirmed/estimated date, or back-calculated from length.
const ovulationDate =
+52 -16
View File
@@ -16,6 +16,7 @@ import { addDays, dayDiff, roundHalf } from "./day-math";
import {
ADHERENCE_FLOOR,
ADHERENCE_SLOPE,
BBT_WINDOW,
COLD_START_BAND_BONUS,
CONFIDENCE_LABEL_HIGH_MIN,
CONFIDENCE_LABEL_LOW_MAX,
@@ -272,8 +273,11 @@ export function detectTempShift(
.map((l) => ({ date: l.date, t: l.basalBodyTempC as number }))
.sort((a, b) => dayDiff(a.date, b.date));
// Need 6 baseline + 3 elevated = 9 readings minimum.
for (let i = 6; i + 2 < temps.length; i++) {
// Need 6 baseline + 3 elevated = 9 readings minimum. Scan from the END and
// return the LATEST qualifying shift — for a multi-cycle BBT series the most
// recent rise is the one that belongs to the current cycle; the earliest
// match would confirm a months-old ovulation (QA: window scoping).
for (let i = temps.length - 3; i >= 6; i--) {
const baseline = temps.slice(i - 6, i);
const sixMax = Math.max(...baseline.map((x) => x.t));
const rise = [temps[i], temps[i + 1], temps[i + 2]];
@@ -413,11 +417,21 @@ function clamp(x: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, x));
}
function resolveLuteal(profile: CycleProfileInput): number {
const raw = profile.lutealPhaseLength ?? LUTEAL_DEFAULT;
/**
* Clamp a raw luteal length to the physiological range [LUTEAL_MIN, LUTEAL_MAX]
* (§4). The ONE source of truth for resolving luteal length — the engine,
* calendar adapter, and phase mapper all route through this so the
* predicted-ovulation dot and the OVULATORY band can never diverge for a user
* whose stored value is out of clamp (QA HIGH: luteal clamp divergence).
*/
export function clampLuteal(raw: number): number {
return clamp(raw, LUTEAL_MIN, LUTEAL_MAX);
}
export function resolveLuteal(profile: Pick<CycleProfileInput, "lutealPhaseLength">): number {
return clampLuteal(profile.lutealPhaseLength ?? LUTEAL_DEFAULT);
}
/* ------------------------------------------------------------------ */
/* main entry */
/* ------------------------------------------------------------------ */
@@ -490,21 +504,43 @@ export function predictCycle(
// Precedence: confirmed-symptothermal > temperature-trend > calendar. A
// confirmed signal overrides the CURRENT cycle's ovulation/next-start; the
// calendar predictor still fills all FUTURE cycles (BLENDED records that mix).
const symptoOvulation = confirmSymptothermal(dayLogs);
const trendOvulation = symptoOvulation ? null : detectTemperatureTrend(nights);
//
// Window scoping (QA HIGH): the symptothermal/temperature-trend detectors
// must only see the CURRENT cycle's signal, otherwise a multi-cycle user's
// stale prior-cycle BBT/mucus confirms a months-old ovulation and lands
// nextPeriodStart in the past. Scope to [lastConfirmedStart − BBT_WINDOW,
// today]; the BBT_WINDOW reach-back keeps the 6-reading baseline intact for a
// young current cycle. detectTempShift returns the LATEST qualifying shift.
const windowStart = addDays(lastConfirmedStart, -BBT_WINDOW);
const isInCurrentWindow = (date: string) =>
dayDiff(date, windowStart) >= 0 && dayDiff(today, date) >= 0;
const currentDayLogs = dayLogs.filter((l) => isInCurrentWindow(l.date));
const currentNights = nights.filter((n) => isInCurrentWindow(n.date));
const symptoOvulation = confirmSymptothermal(currentDayLogs);
const trendOvulation = symptoOvulation
? null
: detectTemperatureTrend(currentNights);
if (symptoOvulation) {
predictedOvulation = symptoOvulation;
nextStart = addDays(symptoOvulation, lutealLength);
ovulationConfirmed = true;
confirmMultiplier = HALF_WIDTH_MULT_SYMPTOTHERMAL;
method = lengths.length > 0 ? "BLENDED" : "SYMPTOTHERMAL";
const confirmedNextStart = addDays(symptoOvulation, lutealLength);
// Reject a confirmation whose next-start is already in the past (a stale
// signal that slipped through the window) — fall back to the calendar.
if (dayDiff(confirmedNextStart, today) >= 0) {
predictedOvulation = symptoOvulation;
nextStart = confirmedNextStart;
ovulationConfirmed = true;
confirmMultiplier = HALF_WIDTH_MULT_SYMPTOTHERMAL;
method = lengths.length > 0 ? "BLENDED" : "SYMPTOTHERMAL";
}
} else if (trendOvulation) {
predictedOvulation = trendOvulation;
nextStart = addDays(trendOvulation, lutealLength);
ovulationConfirmed = true;
confirmMultiplier = HALF_WIDTH_MULT_TEMP_TREND;
method = lengths.length > 0 ? "BLENDED" : "TEMPERATURE_TREND";
const confirmedNextStart = addDays(trendOvulation, lutealLength);
if (dayDiff(confirmedNextStart, today) >= 0) {
predictedOvulation = trendOvulation;
nextStart = confirmedNextStart;
ovulationConfirmed = true;
confirmMultiplier = HALF_WIDTH_MULT_TEMP_TREND;
method = lengths.length > 0 ? "BLENDED" : "TEMPERATURE_TREND";
}
}
// -------- §3 band half-width --------
+9
View File
@@ -120,6 +120,15 @@ export const HALF_WIDTH_MULT_TEMP_TREND = 0.75;
/** §4 — symptothermal agreement tolerance between temp-shift and mucus peak (days). */
export const SYMPTOTHERMAL_AGREE_DAYS = 2;
/**
* §4 — trailing lookback (days) for the symptothermal / temperature-trend
* detectors. The 3-over-6 BBT rule needs 6 baseline readings before the rise,
* so a current-cycle scan starting at lastConfirmedStart must reach back this
* far to keep the baseline window intact when the cycle is still young. Also
* the floor for windowing the day-log read on the hot calendar/insights routes.
*/
export const BBT_WINDOW = 40;
/** §3 — confidence scalar clamp (never certain, never zero). */
export const CONFIDENCE_MIN = 0.05;
export const CONFIDENCE_MAX = 0.98;
+3 -1
View File
@@ -176,7 +176,9 @@ export const cyclePrefsSchema = z
sensitiveCategoryEncryption: z.boolean().optional(),
typicalCycleLength: z.number().int().min(15).max(60).nullable().optional(),
typicalPeriodLength: z.number().int().min(1).max(15).nullable().optional(),
lutealPhaseLength: z.number().int().min(8).max(20).nullable().optional(),
// Bound matches the engine clamp [LUTEAL_MIN, LUTEAL_MAX] (§4) so the
// stored value never silently snaps in the predictor (QA HIGH).
lutealPhaseLength: z.number().int().min(10).max(16).nullable().optional(),
})
.refine((v) => Object.keys(v).length > 0, {
message: "At least one preference field is required",