diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts index 019daff4d..acd39024d 100644 --- a/src/lib/cycle/__tests__/prediction.test.ts +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -88,6 +88,17 @@ describe("cycle/prediction — cycle-length estimator (§1, §5)", () => { expect(est.lengthRounded).toBe(28); }); + it("keeps a legitimate long (oligomenorrhea) cycle inside the hard bound", () => { + // A run of long-but-real cycles (ACOG: > 35 d is oligomenorrhea, still a + // real cycle; perimenopause routinely runs longer). A 52-day length must + // not be force-excluded by the hard ceiling — only the MAD fence / missed- + // log heuristic should ever drop it. Here the whole run is long, so the + // robust median sits near 50 and 52 is well inside the fence. + const est = estimateCycleLength([48, 50, 52, 49, 51], "PERIMENOPAUSE"); + expect(est.cyclesObserved).toBe(5); // none clipped by HARD_CYCLE_MAX + expect(est.lengthRounded).toBe(50); + }); + 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"); diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts index cab9114ee..425cd6e53 100644 --- a/src/lib/cycle/types.ts +++ b/src/lib/cycle/types.ts @@ -105,9 +105,23 @@ export const FERTILE_POST = 1; export const OUTLIER_K = 3.0; export const OUTLIER_K_PERIMENOPAUSE = 4.0; -/** §5 — hard physiological cycle-length bounds (always outlier candidates). */ +/** + * §5 — hard physiological cycle-length bounds (always outlier candidates). + * + * The ceiling is a deliberately generous outlier *backstop*, not a clinical + * "normal" cap — the robust MAD fence + missed-log heuristic do the real + * outlier work. ACOG flags cycles > 35 d as oligomenorrhea, but such cycles are + * still genuine, and adolescents / perimenopausal users routinely run to ~50+ d. + * A hard cap of 45 force-excluded those legitimate long cycles, biasing the + * estimate toward the population middle for exactly the irregular users who most + * need an honest estimate. 60 d keeps the rare gross-error guard (a length this + * long is almost always a missed log, which the missed-log rule also catches) + * while letting real long cycles reach the MAD fence. The MAD fence is + * computed pre-exclusion, so raising the ceiling does not move the fence — it + * only stops the hard rule from pre-empting it on long but consistent cycles. + */ export const HARD_CYCLE_MIN = 21; -export const HARD_CYCLE_MAX = 45; +export const HARD_CYCLE_MAX = 60; /** §5 — a single length ≥ this × median is treated as a probable missed-log. */ export const MISSED_LOG_FACTOR = 1.75;