mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
a5946e6209
commit
ea0d7681e2
8 files changed
+1684
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user