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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 08:29:52 +02:00
1 parent a5946e6209
commit ea0d7681e2
8 files changed
+1684

No files matched your search

+101
View File
@@ -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);
});
});
});
+136
View File
@@ -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);
});
});
+403
View File
@@ -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));
});
});
+87
View File
@@ -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;
}
+23
View File
@@ -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";
+102
View File
@@ -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;
}
+584
View File
@@ -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<string, boolean>();
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,
};
}
+248
View File
@@ -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;
}