From ea0d7681e24ac8746456995f20346c8f878863bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 08:29:16 +0200 Subject: [PATCH] feat(cycle): add deterministic prediction + phase engine Pure, DB-free cycle prediction and phase-assignment engine under src/lib/cycle/. The math mirrors the offline-parity spec exactly so the server and the native client compute byte-identical forecasts from the same canonical inputs. - day-math: noon-UTC anchored YYYY-MM-DD arithmetic (dayDiff, addDays, round-half-up) that stays stable across timezones and DST transitions. - prediction: robust median+MAD cycle-length estimator over a trailing 12-cycle window with 3-MAD outlier exclusion and missed-log detection; period-length estimator; one-sigma Low/High date band widened by logging sparsity; luteal back-calculated ovulation + six-day fertile window; the CALENDAR -> symptothermal -> temperature-trend method ladder (sensiplan 3-over-6 + mucus-peak re-implemented from the published method) with documented blend precedence; a multiplicative confidence scalar shrinking with length variance, low cycle count, and sparse logging. - phase: day-of-cycle -> MENSTRUAL/FOLLICULAR/OVULATORY/LUTEAL boundary mapping with menstrual precedence on overlap. - 48 unit tests covering the regular, irregular, cold-start, outlier, symptothermal, temperature-trend, and every phase-boundary case; all assertions are timezone-independent and pin exact dates + confidence. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/cycle/__tests__/day-math.test.ts | 101 ++++ src/lib/cycle/__tests__/phase.test.ts | 136 +++++ src/lib/cycle/__tests__/prediction.test.ts | 403 ++++++++++++++ src/lib/cycle/day-math.ts | 87 +++ src/lib/cycle/index.ts | 23 + src/lib/cycle/phase.ts | 102 ++++ src/lib/cycle/prediction.ts | 584 +++++++++++++++++++++ src/lib/cycle/types.ts | 248 +++++++++ 8 files changed, 1684 insertions(+) create mode 100644 src/lib/cycle/__tests__/day-math.test.ts create mode 100644 src/lib/cycle/__tests__/phase.test.ts create mode 100644 src/lib/cycle/__tests__/prediction.test.ts create mode 100644 src/lib/cycle/day-math.ts create mode 100644 src/lib/cycle/index.ts create mode 100644 src/lib/cycle/phase.ts create mode 100644 src/lib/cycle/prediction.ts create mode 100644 src/lib/cycle/types.ts diff --git a/src/lib/cycle/__tests__/day-math.test.ts b/src/lib/cycle/__tests__/day-math.test.ts new file mode 100644 index 000000000..03bedaca2 --- /dev/null +++ b/src/lib/cycle/__tests__/day-math.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { addDays, dayDiff, isOnOrBefore, isWithin, parseDayMs, roundHalf } from "../day-math"; + +/** + * The day-math is the shared substrate iOS re-implements bit-for-bit. These + * tests are written to be TIMEZONE-INDEPENDENT: every assertion is about the + * noon-UTC anchored calendar difference, so the result is the same whether the + * host runs under TZ=UTC, America/New_York, Pacific/Auckland, or across a DST + * transition. No assertion anchors on a local calendar value. + */ +describe("cycle/day-math", () => { + describe("parseDayMs", () => { + it("anchors a date at noon UTC", () => { + // 2024-03-10 is a US DST spring-forward day; the noon anchor is immune. + expect(parseDayMs("2024-03-10")).toBe(Date.parse("2024-03-10T12:00:00Z")); + }); + + it("throws on a malformed date (fail-closed)", () => { + expect(() => parseDayMs("2024-3-10")).toThrow(); + expect(() => parseDayMs("not-a-date")).toThrow(); + expect(() => parseDayMs("2024-13-01")).toThrow(); + }); + }); + + describe("dayDiff", () => { + it("counts whole days, sign follows a - b", () => { + expect(dayDiff("2024-01-10", "2024-01-01")).toBe(9); + expect(dayDiff("2024-01-01", "2024-01-10")).toBe(-9); + expect(dayDiff("2024-01-01", "2024-01-01")).toBe(0); + }); + + it("is stable across a DST spring-forward boundary (no half-day drift)", () => { + // US/EU clocks lose an hour around 2024-03-10 / 2024-03-31. The noon-UTC + // anchor means a calendar week is still exactly 7 days, not 6 or 8. + expect(dayDiff("2024-03-15", "2024-03-08")).toBe(7); + expect(dayDiff("2024-03-31", "2024-03-10")).toBe(21); + }); + + it("is stable across a DST fall-back boundary", () => { + expect(dayDiff("2024-11-10", "2024-11-03")).toBe(7); + }); + + it("counts a 28-day cycle exactly across a month boundary", () => { + expect(dayDiff("2024-02-29", "2024-02-01")).toBe(28); // leap year + expect(dayDiff("2023-03-01", "2023-02-01")).toBe(28); // non-leap Feb + }); + + it("spans a leap day correctly", () => { + expect(dayDiff("2024-03-01", "2024-02-28")).toBe(2); + expect(dayDiff("2023-03-01", "2023-02-28")).toBe(1); + }); + }); + + describe("addDays", () => { + it("adds and subtracts across month + year boundaries", () => { + expect(addDays("2024-01-31", 1)).toBe("2024-02-01"); + expect(addDays("2024-02-28", 1)).toBe("2024-02-29"); // leap + expect(addDays("2023-02-28", 1)).toBe("2023-03-01"); // non-leap + expect(addDays("2023-12-31", 1)).toBe("2024-01-01"); + expect(addDays("2024-01-01", -1)).toBe("2023-12-31"); + }); + + it("round-trips with dayDiff across a DST boundary", () => { + const start = "2024-03-08"; + expect(addDays(start, 14)).toBe("2024-03-22"); + expect(dayDiff(addDays(start, 14), start)).toBe(14); + }); + }); + + describe("roundHalf", () => { + it("rounds half UP (away from zero), matching Swift toNearestOrAwayFromZero", () => { + expect(roundHalf(2.5)).toBe(3); + expect(roundHalf(0.5)).toBe(1); + expect(roundHalf(-2.5)).toBe(-3); + expect(roundHalf(-0.5)).toBe(-1); + }); + + it("rounds to k decimal places", () => { + expect(roundHalf(2.345, 2)).toBe(2.35); + expect(roundHalf(-2.345, 2)).toBe(-2.35); + expect(roundHalf(0.155, 2)).toBe(0.16); + }); + + it("passes non-finite through unchanged", () => { + expect(roundHalf(NaN)).toBeNaN(); + expect(roundHalf(Infinity)).toBe(Infinity); + }); + }); + + describe("isOnOrBefore / isWithin", () => { + it("handles inclusive spans", () => { + expect(isOnOrBefore("2024-01-01", "2024-01-01")).toBe(true); + expect(isOnOrBefore("2024-01-02", "2024-01-01")).toBe(false); + expect(isWithin("2024-01-05", "2024-01-01", "2024-01-10")).toBe(true); + expect(isWithin("2024-01-01", "2024-01-01", "2024-01-10")).toBe(true); + expect(isWithin("2024-01-10", "2024-01-01", "2024-01-10")).toBe(true); + expect(isWithin("2024-01-11", "2024-01-01", "2024-01-10")).toBe(false); + }); + }); +}); diff --git a/src/lib/cycle/__tests__/phase.test.ts b/src/lib/cycle/__tests__/phase.test.ts new file mode 100644 index 000000000..0ad4c1eb2 --- /dev/null +++ b/src/lib/cycle/__tests__/phase.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; + +import { addDays } from "../day-math"; +import { phaseForDay, phaseSeries, type PhaseCycle } from "../phase"; + +/** + * Phase boundary tests. A 28-day cycle starting 2024-01-01, period 5 days, + * ovulation on day-14 (offset 13 → 2024-01-14). Boundaries per §"Phase channel": + * MENSTRUAL offsets 0..4 (2024-01-01 .. 2024-01-05) + * FOLLICULAR offsets 5..11 (2024-01-06 .. 2024-01-12) + * OVULATORY offsets 12..14 (2024-01-13 .. 2024-01-15) [ov-1 .. ov+1] + * LUTEAL offsets 15..27 (2024-01-16 .. 2024-01-28) + */ +const CYCLE: PhaseCycle = { + startDate: "2024-01-01", + nextStart: "2024-01-29", // 28-day cycle + ovulationDate: "2024-01-14", + periodLength: 5, + lutealLength: 14, +}; + +describe("cycle/phase — boundary mapping (§Phase channel)", () => { + it("menstrual window [start, start+P-1]", () => { + expect(phaseForDay("2024-01-01", CYCLE).phase).toBe("MENSTRUAL"); + expect(phaseForDay("2024-01-05", CYCLE).phase).toBe("MENSTRUAL"); + // day 6 is the first follicular day. + expect(phaseForDay("2024-01-06", CYCLE).phase).toBe("FOLLICULAR"); + }); + + it("follicular window (period end, ovulatory start)", () => { + expect(phaseForDay("2024-01-06", CYCLE).phase).toBe("FOLLICULAR"); + expect(phaseForDay("2024-01-12", CYCLE).phase).toBe("FOLLICULAR"); + // 2024-01-13 is ov-1 → ovulatory begins. + expect(phaseForDay("2024-01-13", CYCLE).phase).toBe("OVULATORY"); + }); + + it("ovulatory 3-day window [ov-1, ov+1]", () => { + expect(phaseForDay("2024-01-13", CYCLE).phase).toBe("OVULATORY"); + expect(phaseForDay("2024-01-14", CYCLE).phase).toBe("OVULATORY"); + expect(phaseForDay("2024-01-15", CYCLE).phase).toBe("OVULATORY"); + // 2024-01-16 starts luteal. + expect(phaseForDay("2024-01-16", CYCLE).phase).toBe("LUTEAL"); + }); + + it("luteal window (ovulatory end, nextStart)", () => { + expect(phaseForDay("2024-01-16", CYCLE).phase).toBe("LUTEAL"); + expect(phaseForDay("2024-01-28", CYCLE).phase).toBe("LUTEAL"); + }); + + it("1-based day-of-cycle is exact", () => { + expect(phaseForDay("2024-01-01", CYCLE).dayOfCycle).toBe(1); + expect(phaseForDay("2024-01-14", CYCLE).dayOfCycle).toBe(14); + expect(phaseForDay("2024-01-28", CYCLE).dayOfCycle).toBe(28); + }); + + it("dates outside [start, nextStart) return null", () => { + expect(phaseForDay("2023-12-31", CYCLE).phase).toBeNull(); + expect(phaseForDay("2023-12-31", CYCLE).dayOfCycle).toBeNull(); + // nextStart itself belongs to the NEXT cycle, not this one. + expect(phaseForDay("2024-01-29", CYCLE).phase).toBeNull(); + }); + + it("MENSTRUAL precedence: a bleeding day wins even if it overlaps another window", () => { + // A short cycle where the ovulatory window would mathematically overlap the + // period: period 5, but ovulation pulled early to offset 4 (2024-01-05). + const overlap: PhaseCycle = { + startDate: "2024-01-01", + nextStart: "2024-01-15", + ovulationDate: "2024-01-05", // ov-1 = 2024-01-04 collides with menstrual + periodLength: 5, + lutealLength: 10, + }; + // 2024-01-04 is within both the menstrual [0..4] and ovulatory [3..5] windows + // → MENSTRUAL wins. + expect(phaseForDay("2024-01-04", overlap).phase).toBe("MENSTRUAL"); + // 2024-01-06 is past the period → ovulatory (ov+1). + expect(phaseForDay("2024-01-06", overlap).phase).toBe("OVULATORY"); + }); + + it("estimates ovulation from length/luteal when ovulationDate is null", () => { + const noOv: PhaseCycle = { + startDate: "2024-01-01", + nextStart: "2024-01-29", // length 28 + ovulationDate: null, + periodLength: 5, + lutealLength: 14, + }; + // estimated ovulation = start + (28 - 14) = offset 14 → 2024-01-15. + expect(phaseForDay("2024-01-15", noOv).phase).toBe("OVULATORY"); + expect(phaseForDay("2024-01-14", noOv).phase).toBe("OVULATORY"); // ov-1 + expect(phaseForDay("2024-01-13", noOv).phase).toBe("FOLLICULAR"); + }); + + it("defaults period length + luteal length when omitted", () => { + const bare: PhaseCycle = { + startDate: "2024-01-01", + nextStart: "2024-01-29", + ovulationDate: null, + periodLength: null, + }; + // period default 5 → day 5 menstrual, day 6 follicular. + expect(phaseForDay("2024-01-05", bare).phase).toBe("MENSTRUAL"); + expect(phaseForDay("2024-01-06", bare).phase).toBe("FOLLICULAR"); + }); +}); + +describe("cycle/phase — phaseSeries", () => { + it("emits a categorical value per day across the span", () => { + const series = phaseSeries("2024-01-01", "2024-01-28", CYCLE); + expect(series).toHaveLength(28); + expect(series[0]).toEqual({ date: "2024-01-01", phase: "MENSTRUAL", dayOfCycle: 1 }); + expect(series[27]).toEqual({ date: "2024-01-28", phase: "LUTEAL", dayOfCycle: 28 }); + }); + + it("marks out-of-cycle days null at the span edges", () => { + const series = phaseSeries("2023-12-30", "2024-01-02", CYCLE); + expect(series[0].phase).toBeNull(); // 2023-12-30 before start + expect(series[1].phase).toBeNull(); // 2023-12-31 before start + expect(series[2].phase).toBe("MENSTRUAL"); // 2024-01-01 + }); + + it("is timezone-independent across a DST boundary", () => { + // A cycle spanning the 2024-03-10 US DST change; the noon-UTC anchor keeps + // day-of-cycle exact. + const dstCycle: PhaseCycle = { + startDate: "2024-03-01", + nextStart: "2024-03-29", + ovulationDate: "2024-03-14", + periodLength: 5, + lutealLength: 14, + }; + expect(phaseForDay("2024-03-14", dstCycle).dayOfCycle).toBe(14); + expect(phaseForDay("2024-03-14", dstCycle).phase).toBe("OVULATORY"); + expect(phaseForDay(addDays("2024-03-01", 27), dstCycle).dayOfCycle).toBe(28); + }); +}); diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts new file mode 100644 index 000000000..4791bdd9d --- /dev/null +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it } from "vitest"; + +import { addDays } from "../day-math"; +import { + confirmSymptothermal, + detectTempShift, + detectTemperatureTrend, + estimateCycleLength, + estimatePeriodLength, + median, + observedPeriodLength, + predictCycle, +} from "../prediction"; +import type { + CycleInput, + CycleProfileInput, + DayLogInput, + NightlyTempInput, +} from "../types"; + +/** + * These fixtures are the parity contract iOS reuses. Every expected date + + * confidence is computed by hand from algorithm.md's pinned constants and + * asserted exactly. The fixtures are constructed from a fixed seed date so the + * assertions are timezone-independent (all day math is noon-UTC anchored). + */ + +const BASE_PROFILE: CycleProfileInput = { + goal: "TRYING_TO_CONCEIVE", + typicalCycleLength: null, + typicalPeriodLength: null, + lutealPhaseLength: null, + predictionEnabled: true, + rawChartMode: false, +}; + +/** Build a chain of cycles from an anchor start, each `gap[i]` days apart. */ +function cyclesFromGaps(anchor: string, gaps: number[]): CycleInput[] { + const starts: string[] = [anchor]; + for (const g of gaps) starts.push(addDays(starts[starts.length - 1], g)); + return starts.map((startDate, i) => ({ + startDate, + endDate: i < starts.length - 1 ? starts[i + 1] : null, + periodEndDate: null, + ovulationDate: null, + ovulationConfirmed: false, + })); +} + +describe("cycle/prediction — robust statistics", () => { + it("median: odd + even counts (even = mean of two central, unrounded)", () => { + expect(median([28, 30, 29])).toBe(29); + expect(median([28, 30])).toBe(29); + expect(median([28, 31])).toBe(29.5); + expect(median([])).toBeNaN(); + }); +}); + +describe("cycle/prediction — cycle-length estimator (§1, §5)", () => { + it("regular 28-day user: median 28, MAD 0 → sigma floor 1.0", () => { + const est = estimateCycleLength([28, 28, 28, 28, 28, 28], "TRYING_TO_CONCEIVE"); + expect(est.lengthRounded).toBe(28); + expect(est.sigma).toBe(1.0); // SIGMA_FLOOR (MAD = 0) + expect(est.cyclesObserved).toBe(6); + expect(est.cv).toBeCloseTo(1 / 28, 6); + }); + + it("irregular user: high MAD widens sigma", () => { + // lengths 24,28,32,26,30,28 → median 28, deviations 4,0,4,2,2,0 → MAD 2 + const est = estimateCycleLength([24, 28, 32, 26, 30, 28], "TRYING_TO_CONCEIVE"); + expect(est.lengthRounded).toBe(28); + expect(est.sigma).toBeCloseTo(1.4826 * 2, 6); // 2.9652 + }); + + it("excludes a 3-MAD outlier from the point estimate but counts it against confidence", () => { + // 28,28,29,28,28 + one wild 60-day (missed-log: >= 1.75*28 = 49). + const est = estimateCycleLength([28, 28, 29, 28, 28, 60], "TRYING_TO_CONCEIVE"); + // 60 excluded → median over kept = 28, kept count = 5. + expect(est.lengthRounded).toBe(28); + expect(est.cyclesObserved).toBe(5); + }); + + it("hard-bounds: a 14-day length is always an outlier candidate", () => { + const est = estimateCycleLength([28, 28, 14, 28, 29], "TRYING_TO_CONCEIVE"); + expect(est.cyclesObserved).toBe(4); // 14 excluded (< HARD_CYCLE_MIN 21) + expect(est.lengthRounded).toBe(28); + }); + + it("perimenopause widens the fence (OUTLIER_K 4 keeps more)", () => { + const lengths = [28, 30, 26, 40, 24, 32]; + const normal = estimateCycleLength(lengths, "TRYING_TO_CONCEIVE"); + const peri = estimateCycleLength(lengths, "PERIMENOPAUSE"); + expect(peri.cyclesObserved).toBeGreaterThanOrEqual(normal.cyclesObserved); + }); +}); + +describe("cycle/prediction — period-length estimator (§2)", () => { + const cyc: CycleInput = { + startDate: "2024-01-01", + endDate: "2024-01-29", + periodEndDate: null, + ovulationDate: null, + ovulationConfirmed: false, + }; + + function flowDay(date: string, flow: DayLogInput["flow"]): DayLogInput { + return { date, flow, basalBodyTempC: null, ovulationTest: null, cervicalMucus: null }; + } + + it("counts a contiguous 5-day bleeding run", () => { + const logs = [ + flowDay("2024-01-01", "MEDIUM"), + flowDay("2024-01-02", "HEAVY"), + flowDay("2024-01-03", "MEDIUM"), + flowDay("2024-01-04", "LIGHT"), + flowDay("2024-01-05", "SPOTTING"), + ]; + expect(observedPeriodLength("2024-01-01", logs)).toBe(5); + }); + + it("tolerates a single dry day inside the run but breaks on a 2-day gap", () => { + const logs = [ + flowDay("2024-01-01", "MEDIUM"), + flowDay("2024-01-02", "LIGHT"), + // 01-03 dry (single gap, tolerated) + flowDay("2024-01-04", "LIGHT"), + // 01-05, 01-06 dry (2-day gap → break) + flowDay("2024-01-07", "SPOTTING"), + ]; + expect(observedPeriodLength("2024-01-01", logs)).toBe(4); // through 01-04 + }); + + it("returns 0 when startDate itself has no bleeding", () => { + expect(observedPeriodLength("2024-01-01", [flowDay("2024-01-02", "MEDIUM")])).toBe(0); + }); + + it("estimatePeriodLength prefers explicit periodEndDate", () => { + const c = { ...cyc, periodEndDate: "2024-01-04" }; // 4-day period + expect(estimatePeriodLength([c], [], BASE_PROFILE)).toBe(4); + }); + + it("falls back to population default (5) with no data and no prior", () => { + expect(estimatePeriodLength([], [], BASE_PROFILE)).toBe(5); + }); +}); + +describe("cycle/prediction — predictCycle calendar path (§1–§3)", () => { + it("regular 28-day user (≥6 cycles, no logs): exact band + medium confidence", () => { + // 7 starts → 6 completed lengths of 28. Last start 2024-06-24. + const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28, 28, 28, 28]); + const lastStart = cycles[cycles.length - 1].startDate; + // 7 starts = anchor + 6*28 = 2024-01-01 + 168 days. + expect(lastStart).toBe("2024-06-17"); + + const result = predictCycle(cycles, [], BASE_PROFILE, "2024-06-24"); + + expect(result.method).toBe("CALENDAR"); + expect(result.cyclesObserved).toBe(6); + expect(result.stillLearning).toBe(false); + expect(result.estimatedCycleLength).toBe(28); + expect(result.estimatedCycleSd).toBe(1.0); + + // nextStart = 2024-06-17 + 28 = 2024-07-15. + expect(result.nextPeriodStart).toBe("2024-07-15"); + + // halfWidth: density 0 (no logs) → logSparsity 0.5 → penalty 1.5. + // Z_BAND(1) * sigma(1) * 1.5 = 1.5 → round 2 → clamp [1,14] = 2. + expect(result.nextPeriodStartLow).toBe("2024-07-13"); + expect(result.nextPeriodStartHigh).toBe("2024-07-17"); + + // Ovulation = nextStart - luteal(14) = 2024-07-01. + expect(result.predictedOvulation).toBe("2024-07-01"); + // Fertile window ov-5 … ov+1. + expect(result.fertileWindowStart).toBe("2024-06-26"); + expect(result.fertileWindowEnd).toBe("2024-07-02"); + + // confidence = cCount(1.0) * cVariance(1.0, cv≈0.036≤0.05) * cAdherence(0.4) = 0.40. + expect(result.confidence).toBe(0.4); + expect(result.confidenceLabel).toBe("medium"); + }); + + it("irregular user: wider band, lower confidence", () => { + // lengths 24,32,26,30,28,28 → median 28, MAD 2 → sigma 2.9652. + const cycles = cyclesFromGaps("2024-01-01", [24, 32, 26, 30, 28, 28]); + const result = predictCycle(cycles, [], BASE_PROFILE, "2024-07-10"); + + expect(result.estimatedCycleLength).toBe(28); + expect(result.estimatedCycleSd).toBeCloseTo(2.97, 2); + // halfWidth = round(1 * 2.9652 * 1.5) = round(4.4478) = 4. + const low = result.nextPeriodStartLow; + const high = result.nextPeriodStartHigh; + // band half-width is 4 days each side of nextStart. + expect(low).toBe(addDays(result.nextPeriodStart, -4)); + expect(high).toBe(addDays(result.nextPeriodStart, 4)); + // cv = 2.9652/28 ≈ 0.1059 → cVariance 0.65; cCount 1.0; cAdherence 0.4. + // confidence = 1.0 * 0.65 * 0.4 = 0.26 → low. + expect(result.confidence).toBe(0.26); + expect(result.confidenceLabel).toBe("low"); + }); + + it("<2 cycles cold start (1 length): priors-flavoured, stillLearning, band bonus", () => { + // 2 starts → 1 completed length of 30. + const cycles = cyclesFromGaps("2024-01-01", [30]); + const result = predictCycle(cycles, [], BASE_PROFILE, "2024-02-05"); + + expect(result.cyclesObserved).toBe(1); + expect(result.stillLearning).toBe(true); + expect(result.estimatedCycleLength).toBe(30); + // sigma falls back to SIGMA_FLOOR 1.0 (no MAD); +COLD_START_BAND_BONUS 3. + // halfWidth = round(1*1*1.5)=2, +3 = 5 → clamp 5. + expect(result.nextPeriodStartLow).toBe(addDays(result.nextPeriodStart, -5)); + expect(result.nextPeriodStartHigh).toBe(addDays(result.nextPeriodStart, 5)); + // cCount(1)=0.35, cVariance(cv=1/30≈0.033≤0.05)=1.0, cAdherence=0.4. + // confidence = 0.35 * 1.0 * 0.4 = 0.14 → low. + expect(result.confidence).toBe(0.14); + }); + + it("0 cycles priors-only: population default 28, confidence 0.20, fixed band 4", () => { + const onlyStart: CycleInput[] = [ + { + startDate: "2024-05-01", + endDate: null, + periodEndDate: null, + ovulationDate: null, + ovulationConfirmed: false, + }, + ]; + const result = predictCycle(onlyStart, [], BASE_PROFILE, "2024-05-10"); + + expect(result.method).toBe("CALENDAR"); + expect(result.cyclesObserved).toBe(0); + expect(result.stillLearning).toBe(true); + expect(result.estimatedCycleLength).toBe(28); + expect(result.confidence).toBe(0.2); + // nextStart = 2024-05-01 + 28 = 2024-05-29, fixed half-width 4. + expect(result.nextPeriodStart).toBe("2024-05-29"); + expect(result.nextPeriodStartLow).toBe("2024-05-25"); + expect(result.nextPeriodStartHigh).toBe("2024-06-02"); + }); + + it("respects a user-set typical-cycle prior on cold start", () => { + const onlyStart: CycleInput[] = [ + { + startDate: "2024-05-01", + endDate: null, + periodEndDate: null, + ovulationDate: null, + ovulationConfirmed: false, + }, + ]; + const profile = { ...BASE_PROFILE, typicalCycleLength: 30 }; + const result = predictCycle(onlyStart, [], profile, "2024-05-10"); + expect(result.estimatedCycleLength).toBe(30); + expect(result.nextPeriodStart).toBe("2024-05-31"); + }); + + it("logging density lifts confidence and tightens the band", () => { + const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28, 28, 28, 28]); + const lastStart = cycles[cycles.length - 1].startDate; // 2024-06-24 + // Log every day since the last start through 'today' = full density. + const logs: DayLogInput[] = []; + for (let i = 0; i <= 7; i++) { + logs.push({ + date: addDays(lastStart, i), + flow: i < 4 ? "MEDIUM" : "NONE", + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, + }); + } + const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 7)); + // density 1 → logSparsity 0 → penalty 1 → halfWidth round(1*1*1)=1. + expect(result.nextPeriodStartLow).toBe(addDays(result.nextPeriodStart, -1)); + // cAdherence = 0.4 + 0.6*1 = 1.0 → confidence 1*1*1.0 = 0.98 (clamped from 1.0). + expect(result.confidence).toBe(0.98); + expect(result.confidenceLabel).toBe("high"); + }); +}); + +describe("cycle/prediction — symptothermal confirmation (§4.2)", () => { + function bbtDay(date: string, t: number, mucus: DayLogInput["cervicalMucus"] = null): DayLogInput { + return { date, flow: null, basalBodyTempC: t, ovulationTest: null, cervicalMucus: mucus }; + } + + it("detectTempShift: 3-over-6 with the 3rd reading >= 0.2°C over the 6-day max", () => { + // 6 low baseline ~36.40, then 3 elevated clearing the max by 0.2. + const logs: DayLogInput[] = []; + const start = "2024-01-01"; + const baseline = [36.4, 36.42, 36.38, 36.41, 36.4, 36.39]; // max 36.42 + baseline.forEach((t, i) => logs.push(bbtDay(addDays(start, i), t))); + // rise: each > 36.42; 3rd (index 8) must be >= 36.62. + logs.push(bbtDay(addDays(start, 6), 36.55)); + logs.push(bbtDay(addDays(start, 7), 36.6)); + logs.push(bbtDay(addDays(start, 8), 36.62)); + + const shift = detectTempShift(logs, 0.2); + expect(shift).not.toBeNull(); + // ovulation = day before the first elevated reading (offset 6 → 2024-01-07). + expect(shift?.ovulationDate).toBe(addDays(start, 5)); + }); + + it("detectTempShift: rejects a rise that does not clear the 0.2°C third-measurement bar", () => { + const logs: DayLogInput[] = []; + 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)), + ); + // 3rd reading only +0.1 over the 36.42 max. + logs.push(bbtDay(addDays(start, 6), 36.45)); + logs.push(bbtDay(addDays(start, 7), 36.48)); + logs.push(bbtDay(addDays(start, 8), 36.52)); + expect(detectTempShift(logs, 0.2)).toBeNull(); + }); + + it("confirmSymptothermal requires temp-shift and mucus-peak within ±2 days", () => { + const logs: DayLogInput[] = []; + 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.55)); + logs.push(bbtDay(addDays(start, 7), 36.6)); + logs.push(bbtDay(addDays(start, 8), 36.62)); + // temp ovulation = 2024-01-06. Add a mucus peak (EGG_WHITE) on 2024-01-06. + logs.push({ + date: "2024-01-06", + flow: null, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: "EGG_WHITE", + }); + expect(confirmSymptothermal(logs)).toBe("2024-01-06"); + }); + + it("predictCycle: a confirmed symptothermal ovulation overrides next-start and tightens the band", () => { + const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); // 3 lengths, last start 2024-03-25 + const lastStart = cycles[cycles.length - 1].startDate; + expect(lastStart).toBe("2024-03-25"); + + // Build a temp shift in the current (in-progress) cycle with ovulation on lastStart+13. + const logs: DayLogInput[] = []; + const ovDay = addDays(lastStart, 13); // ovulation we want confirmed + // 6 baseline ending the day before the rise; rise starts ovDay+1. + 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)); + // mucus peak agreeing with the temp ovulation. + logs.push({ + date: ovDay, + flow: null, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: "EGG_WHITE", + }); + + const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 16)); + expect(result.ovulationConfirmed).toBe(true); + expect(result.method).toBe("BLENDED"); + expect(result.predictedOvulation).toBe(ovDay); + // nextStart = confirmed ovulation + luteal(14). + expect(result.nextPeriodStart).toBe(addDays(ovDay, 14)); + }); +}); + +describe("cycle/prediction — temperature-trend retrospective ovulation (§4.3)", () => { + it("detectTemperatureTrend: 3-of-4 nights >= +0.15°C over the trailing 6-night mean", () => { + const start = "2024-02-01"; + const nights: NightlyTempInput[] = []; + // 6 trailing nights at 36.30 → mean 36.30. + for (let i = 0; i < 6; i++) nights.push({ date: addDays(start, i), valueC: 36.3 }); + // 4-night window: 3 elevated (>= 36.45) + 1 not. + nights.push({ date: addDays(start, 6), valueC: 36.46 }); + nights.push({ date: addDays(start, 7), valueC: 36.47 }); + nights.push({ date: addDays(start, 8), valueC: 36.3 }); + nights.push({ date: addDays(start, 9), valueC: 36.48 }); + + const ov = detectTemperatureTrend(nights); + // rise onset = first window night (offset 6 → 2024-02-07); ovulation = night before. + expect(ov).toBe(addDays(start, 5)); + }); + + it("predictCycle uses temperature-trend when no manual symptothermal confirmation exists", () => { + const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); + const lastStart = cycles[cycles.length - 1].startDate; + const start = addDays(lastStart, 5); + const nights: NightlyTempInput[] = []; + for (let i = 0; i < 6; i++) nights.push({ date: addDays(start, i), valueC: 36.3 }); + nights.push({ date: addDays(start, 6), valueC: 36.46 }); + nights.push({ date: addDays(start, 7), valueC: 36.47 }); + nights.push({ date: addDays(start, 8), valueC: 36.48 }); + nights.push({ date: addDays(start, 9), valueC: 36.49 }); + + const result = predictCycle(cycles, [], BASE_PROFILE, addDays(lastStart, 20), nights); + expect(result.ovulationConfirmed).toBe(true); + expect(result.method).toBe("BLENDED"); + expect(result.predictedOvulation).toBe(addDays(start, 5)); + }); +}); diff --git a/src/lib/cycle/day-math.ts b/src/lib/cycle/day-math.ts new file mode 100644 index 000000000..68014e15c --- /dev/null +++ b/src/lib/cycle/day-math.ts @@ -0,0 +1,87 @@ +/** + * Shared day arithmetic for the cycle engine. + * + * This MUST match the iOS Swift re-implementation bit-for-bit. The whole point + * of the cycle prediction engine is that the server (TypeScript) and the device + * (Swift) compute byte-identical predictions from the same canonical inputs so + * the offline calendar renders without a network round-trip and the materialised + * server row matches the device's local recompute (a mismatch is a parity bug, + * not last-write-wins). + * + * The load-bearing convention (algorithm.md §"Notation"): + * - Dates are `YYYY-MM-DD` strings (the MoodEntry / CycleDayLog convention). + * - A date is anchored at noon UTC (`T12:00:00Z`). Anchoring at noon — rather + * than midnight — keeps a whole-day difference exact across every timezone + * and DST transition (a ±1 h shift can never flip the rounded day count). + * - `dayDiff(a, b) = round((ms_a - ms_b) / 86_400_000)`. + * - NEVER diff with wall-clock / local time. + * - Rounding everywhere is round-half-UP to the stated precision: + * `Math.round(x * 10^k) / 10^k` in TS, mirrored by + * `(x * 10^k).rounded(.toNearestOrAwayFromZero) / 10^k` in Swift. + */ + +/** Milliseconds in one calendar day. */ +export const MS_PER_DAY = 86_400_000; + +/** Strict `YYYY-MM-DD` shape guard. */ +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Parse a `YYYY-MM-DD` string into the epoch-ms of that date anchored at noon + * UTC. Throws on a malformed string so a bad input fails closed rather than + * silently producing `NaN` that would poison every downstream diff. + */ +export function parseDayMs(date: string): number { + if (!ISO_DATE_RE.test(date)) { + throw new Error(`cycle/day-math: malformed date "${date}" (expected YYYY-MM-DD)`); + } + const ms = Date.parse(`${date}T12:00:00Z`); + if (Number.isNaN(ms)) { + throw new Error(`cycle/day-math: unparseable date "${date}"`); + } + return ms; +} + +/** + * Whole-day difference `a - b` (positive when `a` is later than `b`). + * Rounds the ms quotient so a noon-UTC anchor makes the result DST-stable. + */ +export function dayDiff(a: string, b: string): number { + return Math.round((parseDayMs(a) - parseDayMs(b)) / MS_PER_DAY); +} + +/** + * Return the `YYYY-MM-DD` string `n` days after `date` (`n` may be negative). + * Computed off the noon-UTC anchor so it never crosses a day boundary by error. + */ +export function addDays(date: string, n: number): string { + const ms = parseDayMs(date) + n * MS_PER_DAY; + const d = new Date(ms); + const y = d.getUTCFullYear().toString().padStart(4, "0"); + const m = (d.getUTCMonth() + 1).toString().padStart(2, "0"); + const day = d.getUTCDate().toString().padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +/** + * Round-half-up to `k` decimal places. `roundHalf(2.345, 2) === 2.35`, + * `roundHalf(-2.345, 2) === -2.35` (away from zero on a tie), matching Swift's + * `.toNearestOrAwayFromZero`. `k` defaults to 0 (integer rounding). + */ +export function roundHalf(x: number, k = 0): number { + if (!Number.isFinite(x)) return x; + const factor = 10 ** k; + // Math.round is round-half-up toward +Infinity; mirror it away-from-zero for + // negatives so the rule matches Swift's .toNearestOrAwayFromZero exactly. + return x < 0 ? -Math.round(-x * factor) / factor : Math.round(x * factor) / factor; +} + +/** `true` when `date` is on or before `other` (string compare is safe for ISO). */ +export function isOnOrBefore(date: string, other: string): boolean { + return dayDiff(date, other) <= 0; +} + +/** `true` when `date` falls within the inclusive `[start, end]` span. */ +export function isWithin(date: string, start: string, end: string): boolean { + return dayDiff(date, start) >= 0 && dayDiff(end, date) >= 0; +} diff --git a/src/lib/cycle/index.ts b/src/lib/cycle/index.ts new file mode 100644 index 000000000..13ff49741 --- /dev/null +++ b/src/lib/cycle/index.ts @@ -0,0 +1,23 @@ +/** + * Cycle prediction + phase engine — public surface. + * + * Pure, deterministic, DB-free. The iOS team re-implements this 1:1 in Swift + * from `.planning/v1.15-cycle/algorithm.md`; the unit-test fixtures under + * `__tests__/` are the shared parity contract. + */ + +export * from "./types"; +export * from "./day-math"; +export { + predictCycle, + estimateCycleLength, + estimatePeriodLength, + observedPeriodLength, + detectTempShift, + detectMucusPeak, + confirmSymptothermal, + detectTemperatureTrend, + median, + mad, +} from "./prediction"; +export { phaseForDay, phaseSeries, type PhaseCycle } from "./phase"; diff --git a/src/lib/cycle/phase.ts b/src/lib/cycle/phase.ts new file mode 100644 index 000000000..211f6bc1a --- /dev/null +++ b/src/lib/cycle/phase.ts @@ -0,0 +1,102 @@ +/** + * Phase assignment — pure, per calendar date, both engines identical. + * + * Implements algorithm.md §"Phase channel". Given a cycle [start, nextStart) + * with length L and an ovulation day (confirmed or estimated as + * start + (L − lutealLength)): + * + * - MENSTRUAL = [start, start + P̂ − 1] + * - OVULATORY = [ovulationDay − 1, ovulationDay + 1] (3-day window) + * - FOLLICULAR = (end of menstrual, ovulatory start) day after period → ovulatory + * - LUTEAL = (ovulatory end, nextStart) after ovulatory → day before next start + * + * Precedence on overlap: MENSTRUAL > OVULATORY > FOLLICULAR/LUTEAL (a day is + * menstrual if bleeding even when the calendar window says otherwise). + * + * This MUST match the calendar route, the CYCLE_PHASE correlation channel, and + * what iOS computes offline. + */ + +import { addDays, dayDiff } from "./day-math"; +import { LUTEAL_DEFAULT, POPULATION_DEFAULT_PERIOD, type CyclePhase } from "./types"; + +/** A resolved cycle window the phase mapper needs. */ +export interface PhaseCycle { + /** First bleeding day, `YYYY-MM-DD`. */ + startDate: string; + /** First day of the NEXT cycle, `YYYY-MM-DD`. The cycle is [start, nextStart). */ + nextStart: string; + /** Ovulation day (confirmed or estimated). Null → estimate from length/luteal. */ + ovulationDate: string | null; + /** Period (bleeding) length in days. Null → POPULATION_DEFAULT_PERIOD. */ + periodLength: number | null; + /** Luteal length used to estimate ovulation when ovulationDate is null. */ + lutealLength?: number; +} + +/** + * Map a single date to its phase within the supplied cycle, plus the 1-based + * day-of-cycle. Returns `{ phase: null, dayOfCycle: null }` when the date is + * outside `[startDate, nextStart)`. + */ +export function phaseForDay( + date: string, + cycle: PhaseCycle, +): { phase: CyclePhase | null; dayOfCycle: number | null } { + const offsetFromStart = dayDiff(date, cycle.startDate); + const cycleLength = dayDiff(cycle.nextStart, cycle.startDate); + + // Outside [start, nextStart): not part of this cycle. + if (offsetFromStart < 0 || offsetFromStart >= cycleLength) { + return { phase: null, dayOfCycle: null }; + } + + const dayOfCycle = offsetFromStart + 1; // 1-based + + const periodLength = cycle.periodLength ?? POPULATION_DEFAULT_PERIOD; + const lutealLength = cycle.lutealLength ?? LUTEAL_DEFAULT; + + // Ovulation: confirmed/estimated date, or back-calculated from length. + const ovulationDate = + cycle.ovulationDate ?? addDays(cycle.startDate, cycleLength - lutealLength); + + // Boundary days (inclusive offsets from start). + const menstrualEnd = periodLength - 1; // [0, P-1] + const ovulationOffset = dayDiff(ovulationDate, cycle.startDate); + const ovulatoryStart = ovulationOffset - 1; // [ov-1, ov+1] + const ovulatoryEnd = ovulationOffset + 1; + + // Precedence: MENSTRUAL first (bleeding wins). + if (offsetFromStart <= menstrualEnd) { + return { phase: "MENSTRUAL", dayOfCycle }; + } + // OVULATORY next. + if (offsetFromStart >= ovulatoryStart && offsetFromStart <= ovulatoryEnd) { + return { phase: "OVULATORY", dayOfCycle }; + } + // FOLLICULAR = after menstrual, before the ovulatory window. + if (offsetFromStart < ovulatoryStart) { + return { phase: "FOLLICULAR", dayOfCycle }; + } + // LUTEAL = after the ovulatory window, to the day before nextStart. + return { phase: "LUTEAL", dayOfCycle }; +} + +/** + * Convenience: produce the daily phase series across `[from, to]` inclusive for + * a single cycle. Days outside the cycle window carry `phase: null`. This is the + * shape the CYCLE_PHASE behaviour channel consumes (one categorical value/day). + */ +export function phaseSeries( + from: string, + to: string, + cycle: PhaseCycle, +): { date: string; phase: CyclePhase | null; dayOfCycle: number | null }[] { + const span = dayDiff(to, from); + const out: { date: string; phase: CyclePhase | null; dayOfCycle: number | null }[] = []; + for (let i = 0; i <= span; i++) { + const date = addDays(from, i); + out.push({ date, ...phaseForDay(date, cycle) }); + } + return out; +} diff --git a/src/lib/cycle/prediction.ts b/src/lib/cycle/prediction.ts new file mode 100644 index 000000000..94ac6b64e --- /dev/null +++ b/src/lib/cycle/prediction.ts @@ -0,0 +1,584 @@ +/** + * Cycle prediction engine — pure, deterministic, DB-free. + * + * Implements algorithm.md §1–§5 exactly. The iOS team re-implements this 1:1 in + * Swift from the same spec, so every constant, every rounding step, and every + * day-diff MUST match. See `day-math.ts` for the shared arithmetic and + * `types.ts` for the pinned constants. + * + * The symptothermal layer re-implements the published sensiplan rules + * (3-over-6 temperature double-check + cervical-mucus peak). It does NOT copy + * drip's GPL-3.0 code; the rule LOGIC is the gold-standard FAM method and is not + * itself copyrightable. drip is cited as prior art only. + */ + +import { addDays, dayDiff, roundHalf } from "./day-math"; +import { + ADHERENCE_FLOOR, + ADHERENCE_SLOPE, + COLD_START_BAND_BONUS, + CONFIDENCE_LABEL_HIGH_MIN, + CONFIDENCE_LABEL_LOW_MAX, + CONFIDENCE_MAX, + CONFIDENCE_MIN, + FERTILE_POST, + FERTILE_PRE, + HALF_WIDTH_MAX, + HALF_WIDTH_MIN, + HALF_WIDTH_MULT_SYMPTOTHERMAL, + HALF_WIDTH_MULT_TEMP_TREND, + HARD_CYCLE_MAX, + HARD_CYCLE_MIN, + HISTORY_WINDOW_N, + LOG_SPARSITY_SCALE, + LUTEAL_DEFAULT, + LUTEAL_MAX, + LUTEAL_MIN, + MAD_NORMAL_CONST, + MISSED_LOG_FACTOR, + OUTLIER_K, + OUTLIER_K_PERIMENOPAUSE, + PERIOD_MAX, + PERIOD_MIN, + PERIOD_WINDOW_N, + POPULATION_DEFAULT_CYCLE, + POPULATION_DEFAULT_PERIOD, + PRIORS_ONLY_HALF_WIDTH, + SIGMA_FLOOR, + SYMPTOTHERMAL_AGREE_DAYS, + TEMP_SHIFT_C_MANUAL, + TEMP_SHIFT_C_PASSIVE, + Z_BAND, + type ConfidenceLabel, + type CycleInput, + type CyclePredictionResult, + type CycleProfileInput, + type DayLogInput, + type NightlyTempInput, + type PredictionMethod, +} from "./types"; + +/* ------------------------------------------------------------------ */ +/* Robust statistics primitives */ +/* ------------------------------------------------------------------ */ + +/** + * Median of a numeric array. For an even count, the mean of the two central + * order statistics (NOT rounded — fractional precision is kept through the CI + * math per §1 step 3). Empty input returns NaN; callers guard against that. + */ +export function median(values: readonly number[]): number { + if (values.length === 0) return NaN; + const sorted = [...values].sort((a, b) => a - b); + const mid = sorted.length >> 1; + return sorted.length % 2 === 1 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; +} + +/** Median absolute deviation about a given centre. */ +export function mad(values: readonly number[], centre: number): number { + if (values.length === 0) return NaN; + return median(values.map((v) => Math.abs(v - centre))); +} + +/* ------------------------------------------------------------------ */ +/* §1 — cycle-length estimation */ +/* ------------------------------------------------------------------ */ + +/** Completed cycle = one whose successor's start exists, giving a known length. */ +function completedLengths(cycles: readonly CycleInput[]): number[] { + // Sort by startDate ascending (the canonical order), then diff consecutive + // starts. A cycle is completed iff a later start exists. + const starts = cycles + .map((c) => c.startDate) + .sort((a, b) => dayDiff(a, b)); + const lengths: number[] = []; + for (let i = 0; i < starts.length - 1; i++) { + lengths.push(dayDiff(starts[i + 1], starts[i])); + } + return lengths; +} + +interface LengthEstimate { + /** Robust central length, fractional (rounded only at point-estimate use). */ + lengthFractional: number; + /** Rounded typical length (days). */ + lengthRounded: number; + /** Robust SD (days), >= SIGMA_FLOOR. */ + sigma: number; + /** Coefficient of robust variation sigma/length. */ + cv: number; + /** Count of lengths used post outlier-exclusion. */ + cyclesObserved: number; +} + +/** + * §1 + §5 — robust median+MAD over the trailing window with 3-MAD outlier + * exclusion and missed-log detection. Returns the central length, robust SD, + * cv, and post-exclusion count. + */ +export function estimateCycleLength( + lengths: readonly number[], + goal: CycleProfileInput["goal"], +): LengthEstimate { + // Trailing window: most recent N completed lengths. + const Ls = lengths.slice(-HISTORY_WINDOW_N); + + if (Ls.length === 0) { + return { + lengthFractional: NaN, + lengthRounded: NaN, + sigma: SIGMA_FLOOR, + cv: 0, + cyclesObserved: 0, + }; + } + + // Pre-exclusion robust dispersion for the fence (§5). + const preMedian = median(Ls); + const sigmaPre = MAD_NORMAL_CONST * mad(Ls, preMedian); + + const k = goal === "PERIMENOPAUSE" ? OUTLIER_K_PERIMENOPAUSE : OUTLIER_K; + const fenceLow = preMedian - k * sigmaPre; + const fenceHigh = preMedian + k * sigmaPre; + const missedLogThreshold = MISSED_LOG_FACTOR * preMedian; + + // Outlier exclusion: robust MAD fence OR hard physiological bounds OR a + // probable missed-log (a single length >= 1.75 * median). + const kept = Ls.filter((L) => { + const beyondFence = sigmaPre > 0 && (L < fenceLow || L > fenceHigh); + const beyondHard = L < HARD_CYCLE_MIN || L > HARD_CYCLE_MAX; + const missedLog = L >= missedLogThreshold; + return !(beyondFence || beyondHard || missedLog); + }); + + // If exclusion empties the array, fall back to the un-excluded median (§1.2), + // but the cyclesObserved still reflects the excluded count (§5: outliers don't + // bias the point estimate but they DO erode confidence). + const usable = kept.length >= 1 ? kept : Ls; + + const lengthFractional = median(usable); + const lengthRounded = roundHalf(lengthFractional); + const sigmaRaw = MAD_NORMAL_CONST * mad(usable, lengthFractional); + const sigma = sigmaRaw > 0 ? sigmaRaw : SIGMA_FLOOR; + const cv = lengthFractional > 0 ? sigma / lengthFractional : 0; + + return { + lengthFractional, + lengthRounded, + sigma, + cv, + // cyclesObserved counts the kept (non-excluded) lengths — excluded cycles + // are not counted toward the cycle-count confidence factor (§5). + cyclesObserved: kept.length, + }; +} + +/* ------------------------------------------------------------------ */ +/* §2 — period-length estimation */ +/* ------------------------------------------------------------------ */ + +/** Bleeding day = flow in {SPOTTING, LIGHT, MEDIUM, HEAVY}. */ +function isBleeding(flow: DayLogInput["flow"]): boolean { + return ( + flow === "SPOTTING" || + flow === "LIGHT" || + flow === "MEDIUM" || + flow === "HEAVY" + ); +} + +/** + * §2 — observed period length for one cycle: the maximal CONTIGUOUS run of + * bleeding days starting at `startDate`. A single isolated dry day inside the + * run does NOT break it; a gap of >= 2 consecutive non-bleeding days does. + */ +export function observedPeriodLength( + startDate: string, + dayLogs: readonly DayLogInput[], +): number { + const bleedingByDate = new Map(); + for (const log of dayLogs) { + if (isBleeding(log.flow)) bleedingByDate.set(log.date, true); + } + if (!bleedingByDate.get(startDate)) return 0; + + let lastBleeding = 0; // offset (days) of the last confirmed bleeding day + let gap = 0; // consecutive non-bleeding days seen since last bleeding + // Walk forward up to PERIOD_MAX + 1 days; the run breaks on a >=2-day gap. + for (let offset = 0; offset <= PERIOD_MAX + 1; offset++) { + const date = addDays(startDate, offset); + if (bleedingByDate.get(date)) { + lastBleeding = offset; + gap = 0; + } else { + gap++; + if (gap >= 2) break; // two consecutive dry days ends the run + } + } + return lastBleeding + 1; // inclusive count of days from start to last bleeding +} + +/** §2 — robust period-length estimate over the last 6 cycles, clamped [1,10]. */ +export function estimatePeriodLength( + cycles: readonly CycleInput[], + dayLogs: readonly DayLogInput[], + profile: CycleProfileInput, +): number { + const sorted = [...cycles].sort((a, b) => dayDiff(a.startDate, b.startDate)); + const recent = sorted.slice(-PERIOD_WINDOW_N); + const observed: number[] = []; + for (const c of recent) { + // Prefer an explicit periodEndDate if present; else derive from day logs. + if (c.periodEndDate) { + const len = dayDiff(c.periodEndDate, c.startDate) + 1; + if (len >= PERIOD_MIN) observed.push(len); + } else { + const len = observedPeriodLength(c.startDate, dayLogs); + if (len >= PERIOD_MIN) observed.push(len); + } + } + + if (observed.length === 0) { + const prior = profile.typicalPeriodLength ?? POPULATION_DEFAULT_PERIOD; + return clamp(roundHalf(prior), PERIOD_MIN, PERIOD_MAX); + } + return clamp(roundHalf(median(observed)), PERIOD_MIN, PERIOD_MAX); +} + +/* ------------------------------------------------------------------ */ +/* §4 — symptothermal + temperature-trend ovulation detection */ +/* ------------------------------------------------------------------ */ + +interface TempShiftResult { + /** Confirmed ovulation day = day before the first of the 3 elevated readings. */ + ovulationDate: string; +} + +/** + * §4.2(a) — sensiplan 3-over-6 BBT rule on manual basal temperatures. + * The first day of 3 consecutive temps each strictly above the highest of the + * immediately preceding 6 temps, AND the 3rd is >= 0.2°C above that 6-day max. + * Ovulation = the last low-temp day before the rise (the day BEFORE the first + * elevated reading). + */ +export function detectTempShift( + dayLogs: readonly DayLogInput[], + thresholdC: number, +): TempShiftResult | null { + const temps = dayLogs + .filter((l) => l.basalBodyTempC != null) + .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++) { + 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]]; + const allAbove = rise.every((r) => r.t > sixMax); + // The 3rd elevated reading must clear the 6-day max by the threshold. + const thirdClears = roundHalf(rise[2].t - sixMax, 2) >= thresholdC; + if (allAbove && thirdClears) { + // Ovulation = day before the first elevated reading. + return { ovulationDate: addDays(rise[0].date, -1) }; + } + } + return null; +} + +/** + * §4.2(b) — cervical-mucus peak: the LAST day of egg-white/watery mucus. + * Infertile phase confirmed on peak+3; ovulation is taken at the peak day for + * the agreement check. + */ +export function detectMucusPeak(dayLogs: readonly DayLogInput[]): string | null { + const peakDays = dayLogs + .filter((l) => l.cervicalMucus === "EGG_WHITE" || l.cervicalMucus === "WATERY") + .map((l) => l.date) + .sort((a, b) => dayDiff(a, b)); + return peakDays.length > 0 ? peakDays[peakDays.length - 1] : null; +} + +/** + * §4.2 — symptothermal CONFIRMATION: ovulation is confirmed only when the + * temp-shift day and the mucus-peak agree within ±2 days. Returns the + * temp-derived ovulation day when confirmed, else null. + */ +export function confirmSymptothermal( + dayLogs: readonly DayLogInput[], +): string | null { + const shift = detectTempShift(dayLogs, TEMP_SHIFT_C_MANUAL); + if (!shift) return null; + const peak = detectMucusPeak(dayLogs); + if (!peak) return null; + if (Math.abs(dayDiff(shift.ovulationDate, peak)) <= SYMPTOTHERMAL_AGREE_DAYS) { + return shift.ovulationDate; + } + return null; +} + +/** + * §4.3 — passive TEMPERATURE_TREND: detect the post-ovulatory sustained thermal + * shift on a nightly series — 3-of-4 consecutive nights with deviation + * >= +0.15°C above the trailing 6-night mean. Ovulation ≈ the night before the + * sustained rise onset. Returns the ovulation day or null. + */ +export function detectTemperatureTrend( + nights: readonly NightlyTempInput[], +): string | null { + const series = [...nights].sort((a, b) => dayDiff(a.date, b.date)); + // Need 6 trailing nights + a 4-night window. + for (let i = 6; i + 3 < series.length; i++) { + const trailing = series.slice(i - 6, i); + const mean = trailing.reduce((s, x) => s + x.valueC, 0) / trailing.length; + const window = series.slice(i, i + 4); + const elevated = window.filter( + (n) => roundHalf(n.valueC - mean, 2) >= TEMP_SHIFT_C_PASSIVE, + ).length; + if (elevated >= 3) { + // Ovulation ≈ night before the sustained rise onset (the first window day). + return addDays(window[0].date, -1); + } + } + return null; +} + +/* ------------------------------------------------------------------ */ +/* §3 — confidence + band */ +/* ------------------------------------------------------------------ */ + +/** §3(b) — cycle-count factor. */ +function countFactor(cyclesObserved: number): number { + if (cyclesObserved <= 0) return 0.2; + if (cyclesObserved === 1) return 0.35; + if (cyclesObserved === 2) return 0.55; + if (cyclesObserved <= 5) return 0.75; + return 1.0; +} + +/** §3(b) — regularity factor from the coefficient of robust variation. */ +function varianceFactor(cv: number): number { + if (cv <= 0.05) return 1.0; + if (cv <= 0.09) return 0.85; + if (cv <= 0.14) return 0.65; + if (cv <= 0.2) return 0.45; + return 0.25; +} + +/** + * §3(b)/§3 — logging-density derived factors. Returns the adherence confidence + * factor and the LOG_SPARSITY band-penalty term. + */ +function adherenceFactors( + lastStart: string, + estimatedLength: number, + dayLogs: readonly DayLogInput[], + today: string, +): { cAdherence: number; logSparsity: number } { + // expectedDays = days since the most recent cycle start, capped at length. + const sinceStart = Math.max(0, dayDiff(today, lastStart)); + const expectedDays = Math.min(sinceStart, Math.max(1, Math.round(estimatedLength))); + // loggedDays = day logs with any non-null observation in that span. + let loggedDays = 0; + for (const log of dayLogs) { + const offset = dayDiff(log.date, lastStart); + if (offset < 0 || offset > expectedDays) continue; + const hasObservation = + log.flow != null || + log.basalBodyTempC != null || + log.ovulationTest != null || + log.cervicalMucus != null; + if (hasObservation) loggedDays++; + } + const density = clamp(loggedDays / Math.max(1, expectedDays), 0, 1); + const cAdherence = ADHERENCE_FLOOR + ADHERENCE_SLOPE * density; + const logSparsity = (1 - density) * LOG_SPARSITY_SCALE; + return { cAdherence, logSparsity }; +} + +/** §3 — confidence → label mapping. */ +function confidenceLabel(confidence: number): ConfidenceLabel { + if (confidence < CONFIDENCE_LABEL_LOW_MAX) return "low"; + if (confidence > CONFIDENCE_LABEL_HIGH_MIN) return "high"; + return "medium"; +} + +/* ------------------------------------------------------------------ */ +/* helpers */ +/* ------------------------------------------------------------------ */ + +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; + return clamp(raw, LUTEAL_MIN, LUTEAL_MAX); +} + +/* ------------------------------------------------------------------ */ +/* main entry */ +/* ------------------------------------------------------------------ */ + +/** + * §1–§5 — compute the cycle prediction. + * + * The fertile window is ALWAYS returned (goal-gating — suppressing it for + * non-conception goals — is the caller's responsibility per the spec; the + * engine returns the window and the caller hides it). + * + * @param cycles confirmed (non-predicted) cycles; order-independent. + * @param dayLogs all day logs the device holds (for period, symptothermal). + * @param profile user priors + mode flags. + * @param today `YYYY-MM-DD` reference day (for adherence density). + * @param nights optional nightly passive-temperature series (Apple Watch). + */ +export function predictCycle( + cycles: readonly CycleInput[], + dayLogs: readonly DayLogInput[], + profile: CycleProfileInput, + today: string, + nights: readonly NightlyTempInput[] = [], +): CyclePredictionResult { + const lengths = completedLengths(cycles); + const lutealLength = resolveLuteal(profile); + const sortedStarts = cycles + .map((c) => c.startDate) + .sort((a, b) => dayDiff(a, b)); + const lastConfirmedStart = sortedStarts[sortedStarts.length - 1] ?? today; + + // -------- 0-cycle priors-only cold start (§5) -------- + if (lengths.length === 0) { + const estLen = profile.typicalCycleLength ?? POPULATION_DEFAULT_CYCLE; + const periodLen = clamp( + roundHalf(profile.typicalPeriodLength ?? POPULATION_DEFAULT_PERIOD), + PERIOD_MIN, + PERIOD_MAX, + ); + const nextStart = addDays(lastConfirmedStart, Math.round(estLen)); + const predictedOvulation = addDays(nextStart, -lutealLength); + return finalize({ + method: "CALENDAR", + nextStart, + halfWidth: PRIORS_ONLY_HALF_WIDTH, + predictedPeriodLength: periodLen, + predictedOvulation, + ovulationConfirmed: false, + confidence: 0.2, + cyclesObserved: 0, + estimatedCycleLength: Math.round(estLen), + estimatedCycleSd: SIGMA_FLOOR, + lutealLength, + goal: profile.goal, + }); + } + + // -------- robust estimation (§1, §2) -------- + const est = estimateCycleLength(lengths, profile.goal); + const periodLen = estimatePeriodLength(cycles, dayLogs, profile); + + // -------- calendar baseline next-period point estimate (§1) -------- + let method: PredictionMethod = "CALENDAR"; + let nextStart = addDays(lastConfirmedStart, est.lengthRounded); + let predictedOvulation = addDays(nextStart, -lutealLength); + let ovulationConfirmed = false; + let confirmMultiplier = 1.0; + + // -------- §4 method ladder + blend (confirmed-current overrides) -------- + // 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); + + if (symptoOvulation) { + predictedOvulation = symptoOvulation; + nextStart = addDays(symptoOvulation, lutealLength); + 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"; + } + + // -------- §3 band half-width -------- + const { cAdherence, logSparsity } = adherenceFactors( + lastConfirmedStart, + est.lengthFractional, + dayLogs, + today, + ); + const adherencePenalty = 1 + logSparsity; + let halfWidth = roundHalf(Z_BAND * est.sigma * adherencePenalty) * confirmMultiplier; + // Single-cycle cold-start gets a fixed band bonus (§5). + if (est.cyclesObserved === 1) halfWidth += COLD_START_BAND_BONUS; + halfWidth = clamp(Math.round(halfWidth), HALF_WIDTH_MIN, HALF_WIDTH_MAX); + + // -------- §3 confidence scalar -------- + const cCount = countFactor(est.cyclesObserved); + const cVariance = varianceFactor(est.cv); + const confidence = clamp( + cCount * cVariance * cAdherence, + CONFIDENCE_MIN, + CONFIDENCE_MAX, + ); + + return finalize({ + method, + nextStart, + halfWidth, + predictedPeriodLength: periodLen, + predictedOvulation, + ovulationConfirmed, + confidence, + cyclesObserved: est.cyclesObserved, + estimatedCycleLength: est.lengthRounded, + estimatedCycleSd: roundHalf(est.sigma, 2), + lutealLength, + goal: profile.goal, + }); +} + +interface FinalizeArgs { + method: PredictionMethod; + nextStart: string; + halfWidth: number; + predictedPeriodLength: number; + predictedOvulation: string; + ovulationConfirmed: boolean; + confidence: number; + cyclesObserved: number; + estimatedCycleLength: number; + estimatedCycleSd: number; + lutealLength: number; + goal: CycleProfileInput["goal"]; +} + +/** Assemble the result struct, deriving the band + fertile window. */ +function finalize(a: FinalizeArgs): CyclePredictionResult { + const halfWidth = clamp(Math.round(a.halfWidth), HALF_WIDTH_MIN, HALF_WIDTH_MAX); + const confidence = roundHalf(a.confidence, 2); + return { + method: a.method, + nextPeriodStart: a.nextStart, + nextPeriodStartLow: addDays(a.nextStart, -halfWidth), + nextPeriodStartHigh: addDays(a.nextStart, halfWidth), + predictedPeriodLength: a.predictedPeriodLength, + fertileWindowStart: addDays(a.predictedOvulation, -FERTILE_PRE), + fertileWindowEnd: addDays(a.predictedOvulation, FERTILE_POST), + predictedOvulation: a.predictedOvulation, + ovulationConfirmed: a.ovulationConfirmed, + confidence, + confidenceLabel: confidenceLabel(confidence), + cyclesObserved: a.cyclesObserved, + stillLearning: a.cyclesObserved < 3, + estimatedCycleLength: a.estimatedCycleLength, + estimatedCycleSd: a.estimatedCycleSd, + }; +} diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts new file mode 100644 index 000000000..87d64aa13 --- /dev/null +++ b/src/lib/cycle/types.ts @@ -0,0 +1,248 @@ +/** + * Plain TypeScript input/output contracts for the cycle prediction + phase + * engine. + * + * These interfaces deliberately do NOT depend on Prisma row types. The engine + * is a pure, deterministic, DB-free function set: it takes the canonical inputs + * the iOS device also holds in its local mirror and returns the same struct iOS + * computes offline. Keeping the inputs as plain shapes (a) makes the engine + * unit-testable without a database and (b) makes the parity contract with the + * Swift re-implementation explicit — these fixtures are what iOS reuses. + * + * The literal union types below mirror the Prisma enums (FlowLevel, + * OvulationTest, CervicalMucus, CyclePhase, PredictionMethod, CycleTrackingGoal) + * named in data-model-ux.md, but are declared locally so the engine never + * imports the generated client. + */ + +/* ------------------------------------------------------------------ */ +/* Literal union types (mirror the Prisma enums) */ +/* ------------------------------------------------------------------ */ + +export type FlowLevel = "NONE" | "SPOTTING" | "LIGHT" | "MEDIUM" | "HEAVY"; + +export type OvulationTest = + | "NEGATIVE" + | "POSITIVE_LH_SURGE" + | "ESTROGEN_SURGE" + | "INDETERMINATE"; + +export type CervicalMucus = "DRY" | "STICKY" | "CREAMY" | "WATERY" | "EGG_WHITE"; + +export type CyclePhase = "MENSTRUAL" | "FOLLICULAR" | "OVULATORY" | "LUTEAL"; + +export type PredictionMethod = + | "CALENDAR" + | "SYMPTOTHERMAL" + | "TEMPERATURE_TREND" + | "BLENDED"; + +export type CycleTrackingGoal = + | "GENERAL_HEALTH" + | "AVOID_PREGNANCY" + | "TRYING_TO_CONCEIVE" + | "PERIMENOPAUSE" + | "OFF"; + +/** Coarse confidence label derived from the scalar (for badge/copy). */ +export type ConfidenceLabel = "low" | "medium" | "high"; + +/* ------------------------------------------------------------------ */ +/* Pinned constants (algorithm.md §1–§5, the parity substrate) */ +/* ------------------------------------------------------------------ */ + +/** §1 — at most the 12 most recent COMPLETED cycles feed the length estimator. */ +export const HISTORY_WINDOW_N = 12; + +/** §1 — need ≥2 completed cycles for a real (non-priors) prediction. */ +export const MIN_CYCLES_TO_PREDICT = 2; + +/** §1 — optional recency half-life (cycles). OFF in MVP (see below). */ +export const RECENCY_HALF_LIFE = 6; + +/** + * §1 — recency weighting is OFF in the v1.15 MVP (plain median) so the TS and + * Swift engines stay trivially identical. Flip in lockstep later. + */ +export const RECENCY_WEIGHTING_ENABLED = false; + +/** §1 — floor on the robust SD so the band is never zero-width (false precision). */ +export const SIGMA_FLOOR = 1.0; + +/** §1 — normal-consistency constant for MAD → SD (sigma = 1.4826 * MAD). */ +export const MAD_NORMAL_CONST = 1.4826; + +/** §3 — one-sigma (~68%) band multiplier. Deliberately NOT 1.96. */ +export const Z_BAND = 1.0; + +/** §3 — date-band half-width clamp (days); never zero, never absurd. */ +export const HALF_WIDTH_MIN = 1; +export const HALF_WIDTH_MAX = 14; + +/** §5 — fixed half-width (days) for the 0-cycle priors-only path. */ +export const PRIORS_ONLY_HALF_WIDTH = 4; + +/** §5 — extra half-width (days) added on the single-cycle cold-start path. */ +export const COLD_START_BAND_BONUS = 3; + +/** §2 — last 6 cycles feed the period-length estimator. */ +export const PERIOD_WINDOW_N = 6; + +/** §5 — population fallbacks when the user has no data and no prior. */ +export const POPULATION_DEFAULT_CYCLE = 28; +export const POPULATION_DEFAULT_PERIOD = 5; + +/** §2 — period-length clamp (days). */ +export const PERIOD_MIN = 1; +export const PERIOD_MAX = 10; + +/** §4 — luteal-phase length default + physiological clamp. */ +export const LUTEAL_DEFAULT = 14; +export const LUTEAL_MIN = 10; +export const LUTEAL_MAX = 16; + +/** §4 — six-day fertile window: ovulation − 5 … ovulation + 1. */ +export const FERTILE_PRE = 5; +export const FERTILE_POST = 1; + +/** §5 — robust MAD-fence multiplier (3-MAD normal, 4-MAD perimenopause). */ +export const OUTLIER_K = 3.0; +export const OUTLIER_K_PERIMENOPAUSE = 4.0; + +/** §5 — hard physiological cycle-length bounds (always outlier candidates). */ +export const HARD_CYCLE_MIN = 21; +export const HARD_CYCLE_MAX = 45; + +/** §5 — a single length ≥ this × median is treated as a probable missed-log. */ +export const MISSED_LOG_FACTOR = 1.75; + +/** §4 — symptothermal (manual BBT) "third measurement" rise threshold (°C). */ +export const TEMP_SHIFT_C_MANUAL = 0.2; + +/** §4 — passive wrist/skin temperature-trend deviation threshold (°C). */ +export const TEMP_SHIFT_C_PASSIVE = 0.15; + +/** §4 — half-width multipliers when ovulation is confirmed by each layer. */ +export const HALF_WIDTH_MULT_SYMPTOTHERMAL = 0.6; +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; + +/** §3 — confidence scalar clamp (never certain, never zero). */ +export const CONFIDENCE_MIN = 0.05; +export const CONFIDENCE_MAX = 0.98; + +/** §3 — confidence → label cut points. */ +export const CONFIDENCE_LABEL_LOW_MAX = 0.4; // < 0.40 → low +export const CONFIDENCE_LABEL_HIGH_MIN = 0.7; // > 0.70 → high + +/** §3 — c_adherence floor + slope (0.4 + 0.6 * density). */ +export const ADHERENCE_FLOOR = 0.4; +export const ADHERENCE_SLOPE = 0.6; + +/** §3 — max band widening from sparse logging (LOG_SPARSITY scale). */ +export const LOG_SPARSITY_SCALE = 0.5; + +/* ------------------------------------------------------------------ */ +/* Input contracts */ +/* ------------------------------------------------------------------ */ + +/** + * One confirmed (or predicted) cycle. `startDate` is the first bleeding day. + * The engine treats a cycle as COMPLETED only when a later cycle's `startDate` + * exists, giving it a known length. + */ +export interface CycleInput { + /** First bleeding day of the menstruation, `YYYY-MM-DD`. */ + startDate: string; + /** First bleeding day of the NEXT cycle, or null if this is the latest. */ + endDate: string | null; + /** Last bleeding day of THIS cycle's period run, or null if unknown. */ + periodEndDate: string | null; + /** Confirmed/estimated ovulation day, or null. */ + ovulationDate: string | null; + /** Whether `ovulationDate` was confirmed (symptothermal/temp), not estimated. */ + ovulationConfirmed: boolean; +} + +/** One day's observations (the HealthKit / manual sync unit). */ +export interface DayLogInput { + /** The logged day, `YYYY-MM-DD`. */ + date: string; + flow: FlowLevel | null; + /** Basal body temperature in °C, already rounded to 2 dp before input. */ + basalBodyTempC: number | null; + ovulationTest: OvulationTest | null; + cervicalMucus: CervicalMucus | null; +} + +/** + * One nightly passive-temperature deviation reading (Apple Watch wrist / + * skin temperature), used by the TEMPERATURE_TREND layer. `deviationC` is the + * nightly value's deviation from the user's own baseline in °C (the engine also + * accepts a raw nightly value and derives the trailing-mean deviation itself — + * see prediction.ts). + */ +export interface NightlyTempInput { + date: string; + /** Nightly temperature value in °C (raw; baseline derived internally). */ + valueC: number; +} + +/** User-set priors + mode flags. All optional; the engine applies defaults. */ +export interface CycleProfileInput { + goal: CycleTrackingGoal; + typicalCycleLength: number | null; + typicalPeriodLength: number | null; + lutealPhaseLength: number | null; + /** When false the engine emits no prediction (caller checks before calling). */ + predictionEnabled: boolean; + /** Read-Your-Body mode: suppress all interpretation. */ + rawChartMode: boolean; +} + +/* ------------------------------------------------------------------ */ +/* Output contracts */ +/* ------------------------------------------------------------------ */ + +/** The materialised forecast (mirrors the CyclePrediction row). */ +export interface CyclePredictionResult { + method: PredictionMethod; + /** Point estimate of the next period's first day, `YYYY-MM-DD`. */ + nextPeriodStart: string; + /** Low end of the one-sigma date band, `YYYY-MM-DD`. */ + nextPeriodStartLow: string; + /** High end of the one-sigma date band, `YYYY-MM-DD`. */ + nextPeriodStartHigh: string; + /** Predicted length of the next period bar (days), clamped [1,10]. */ + predictedPeriodLength: number; + /** Fertile window start (ovulation − 5), or null when goal-gated off upstream. */ + fertileWindowStart: string | null; + /** Fertile window end (ovulation + 1), or null. */ + fertileWindowEnd: string | null; + /** Estimated/confirmed ovulation day for the upcoming cycle. */ + predictedOvulation: string | null; + /** Whether `predictedOvulation` was confirmed by a signal layer. */ + ovulationConfirmed: boolean; + /** Confidence scalar in [0.05, 0.98]. */ + confidence: number; + /** Coarse label for the badge/copy. */ + confidenceLabel: ConfidenceLabel; + /** Completed cycle lengths used (post outlier-exclusion). */ + cyclesObserved: number; + /** <3 observed cycles → "still learning" UI state, independent of score. */ + stillLearning: boolean; + /** Estimated typical cycle length (days, rounded). */ + estimatedCycleLength: number; + /** Robust SD of cycle length (days), >= SIGMA_FLOOR. */ + estimatedCycleSd: number; +} + +/** The phase of a single calendar date relative to its containing cycle. */ +export interface PhaseResult { + date: string; + phase: CyclePhase | null; + /** 1-based day-of-cycle (1 = startDate), or null if the date is outside any cycle. */ + dayOfCycle: number | null; +}