fix(cycle): raise the hard cycle-length ceiling so long cycles survive

The hard upper bound clamped cycle lengths at 45 days, force-excluding any
longer cycle as an outlier before the robust statistics ran. Cycles over 35
days are oligomenorrhea but still real, and adolescent and perimenopausal users
routinely run to fifty days or more — exactly the irregular users for whom an
honest estimate matters most. Clamping their genuine cycles biased the estimate
toward the population middle.

Raise the ceiling to 60 days. It stays a coarse gross-error backstop; the
median-plus-MAD fence and the missed-log heuristic still do the real outlier
work, and because the fence is computed before exclusion, raising the ceiling
does not move it — it only stops the hard rule from pre-empting the fence on
long but consistent cycles.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 14:43:57 +02:00
1 parent f5798e20d8
commit df67b60420
2 files changed
+27 -2

No files matched your search

@@ -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");
+16 -2
View File
@@ -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;