12 KiB
The science — which constants are claims about bodies, and on what evidence
Status: Current
Owner: _null
Last reviewed: 2026-08-20
Governs: docs/architecture/SCIENCE.md — the constants in domain/prediction that
encode a claim about menstrual physiology, and the evidence for each
Review trigger: Any change to DEFAULT_LUTEAL_PHASE_DAYS, FERTILE_DAYS_BEFORE_OVULATION,
FERTILE_DAYS_AFTER_OVULATION, MAX_USEFUL_UNCERTAINTY_DAYS,
POPULATION_DEFAULT_DAYS, MINIMUM_PLAUSIBLE_DAYS, or the shape of the
distribution the engine fits
A reader who opens
PersonalPredictionEngine.ktfinds thirty constants and no way to tell which are physiology and which are engineering. Both kinds are argued for in comments, in the same voice, which makes the difference invisible:MAD_TO_SCALEwas tuned against fixtures and can be retuned by anyone with a measurement, whileFERTILE_DAYS_BEFORE_OVULATIONis a claim about how long sperm survive and cannot.This file is the line between them.
Why this is not in architecture/README.md
That document's subject is the module graph and the shapes data takes — it fires its review trigger on a new Gradle module or a changed Room entity. The evidence for a luteal phase changes when somebody publishes, which has nothing to do with either. Two subjects with unrelated review cycles in one document means the one nobody is looking at goes stale behind the one they are.
What "supported" means here
Supported — the value matches the published measurement, and the citation says which.
Needs a caveat — the value is defensible but the literature says something the code does not: usually that a population mean hides a distribution the app cannot see for an individual user. The caveat belongs in the code comment, not only here.
Contradicted — the measurement disagrees, and the constant should move or the disagreement should be recorded as a deliberate choice with its reason.
Not a scientific claim — an engineering parameter. Listed so nobody demands a citation for one, and so nobody mistakes one for physiology and refuses to tune it.
The constants
| Constant | Where | Value | Verdict |
|---|---|---|---|
DEFAULT_LUTEAL_PHASE_DAYS |
FertilityEstimate.kt |
14 | Needs a caveat |
FERTILE_DAYS_BEFORE_OVULATION |
FertilityEstimate.kt |
5 | Supported |
FERTILE_DAYS_AFTER_OVULATION |
FertilityEstimate.kt |
1 | Supported, deliberately conservative |
MAX_USEFUL_UNCERTAINTY_DAYS |
FertilityEstimate.kt |
3 | Product judgment, not biology |
POPULATION_DEFAULT_DAYS |
PersonalPredictionEngine.kt |
29.0 | Was 28.0 — contradicted, changed |
| Laplace tails | PersonalPredictionEngine.kt |
— | Supported, with a caveat about skew |
MINIMUM_PLAUSIBLE_DAYS |
IntervalAnalysis.kt |
10 | Supported |
MULTIPLE_TOLERANCE, UNUSUAL_TOLERANCE |
IntervalAnalysis.kt |
0.18, 0.35 | Not scientific claims |
| Everything in the engine's spread and confidence blocks | PersonalPredictionEngine.kt |
— | Not scientific claims |
DEFAULT_LUTEAL_PHASE_DAYS = 14 — needs a caveat
Fourteen days is the textbook convention, and the app anchors every ovulation estimate to it: ovulation is estimated as the predicted period minus this. Anchoring backwards from the next period rather than forwards from the last is the defensible half, and the reason is real — the luteal phase is the more stable part of the cycle, so the follicular phase absorbs most of the variation.
The caveat is that "more stable" is not "fixed", and 14 is above the measured mean. Large studies of app-recorded cycles put the luteal phase closer to 12–13 days on average, with a normal range of roughly 11–17. A woman with an 11-day luteal phase has her ovulation estimated three days late by this constant.
Kept for now, and filed rather than changed — moving it shifts what the fertility screen shows every user, which is a product decision with its own evidence and its own QA, not a line inside a prediction-calibration change. The constant is already named and overridable rather than buried in arithmetic, so the day this app can measure a user's own luteal phase, one value changes.
What protects the user meanwhile is not this constant but the two rules around
it: the estimate is a window, never a day presented as fact, and
MAX_USEFUL_UNCERTAINTY_DAYS makes the app decline entirely when the forecast is
too vague to hang one on.
Sources. Bull et al., Real-world menstrual cycle characteristics of more than 600,000 menstrual cycles (npj Digital Medicine, 2019). Lenton et al., The normal variation of the length of the luteal phase of the menstrual cycle (BJOG, 1984).
FERTILE_DAYS_BEFORE_OVULATION = 5 — supported
The fertile window opens before ovulation, not on it, because sperm survive in the reproductive tract for several days. The classic prospective study of conception timing found essentially all conceptions occurred within a six-day window ending on the day of ovulation itself.
Five days before, plus the day of ovulation, is that window. No change.
Source. Wilcox, Weinberg & Baird, Timing of sexual intercourse in relation to ovulation (New England Journal of Medicine 333:1517, 1995).
FERTILE_DAYS_AFTER_OVULATION = 1 — supported, and conservative on purpose
The egg is viable for roughly 12–24 hours, and the same study found conception probability falling to essentially zero the day after ovulation. One day is therefore at or slightly beyond the measured edge.
That direction is deliberate. Every rounding decision in the fertility estimate widens the window rather than narrowing it, because a window that is too wide tells a user the app is unsure, and one that is too narrow tells her something false about a day. §18 has already promised this is not contraception; the arithmetic should not quietly imply otherwise.
Source. Wilcox, Weinberg & Baird (1995), as above.
MAX_USEFUL_UNCERTAINTY_DAYS = 3 — a product judgment, and it should stay one
Past this much forecast uncertainty the app offers no fertility estimate at all. This is not a biological threshold and should never be cited as one: the fertile window is inherently seven days wide before any forecast uncertainty is added, so a forecast carrying ±5 produces a seventeen-day "fertile window" — over half a cycle, honest arithmetic, and useless information. It was on screen before anybody noticed.
The literature supports the shape of the decision rather than the number: calendar-based ovulation estimates carry several days of irreducible uncertainty even for women who describe their cycles as regular, so there is no threshold at which a calendar app should present a precise ovulation day. Even at uncertainty zero this app shows a range.
Source. Wilcox, Dunson & Baird, The timing of the "fertile window" in the menstrual cycle: day specific estimates from a prospective study (BMJ 321:1259, 2000).
POPULATION_DEFAULT_DAYS = 29.0 — was 28.0, and 28.0 was wrong
This is the only cycle length the engine ever assumes: the forecast for a user with exactly one confirmed period and therefore no interval to measure. It is replaced by her own data the moment she has any.
Twenty-eight is the number everyone knows, and the large app-data studies agree it is not the average. Mean cycle length across hundreds of thousands of users sits near 29 days, and only a minority of women average 28 — cycle length also shortens with age, which no single constant can express.
Changed to 29.0. The honest gain is small and worth having anyway: the app's one unavoidable guess is now the population's actual centre rather than the folklore's.
BaselinePredictionEngine.DEFAULT_CYCLE_DAYS stays 28.0 and must not be
changed. That engine is the frozen control EngineComparisonTest measures
against; moving it would silently move the yardstick.
Sources. Bull et al. (2019), as above. Grieger & Norman, Menstrual cycle length and patterns in a global cohort of women using a mobile phone app (JMIR, 2020).
The Laplace distribution — supported, with a caveat the engine handles elsewhere
The engine fits a Laplace rather than a normal distribution over candidate start dates. The justification in the code is that cycle lengths have heavier tails than a bell curve, and that is what the distributional data shows: cycle-length distributions are leptokurtic, and under a normal fit a period arriving four days late reads as nearly impossible, so the model refuses to widen and stays confidently wrong.
The caveat is that the real distribution is also right-skewed — long cycles
are further from the centre than short ones — and a symmetric Laplace does not
express that. What compensates is not a distributional trick but the censoring in
§13: as each day passes without a period, the mass on that day is removed and the
remainder renormalised, so the surviving distribution leans late exactly when the
evidence says it should. OVERDUE_WIDENING extends the same idea past the end of
the window.
Worth knowing if anybody replaces the distribution: a skewed family would let the engine express this before the user has to answer "Not yet" three times.
Sources. Chiazze et al., The length and variability of the human menstrual cycle (JAMA 203:377, 1968). Bull et al. (2019), distribution figures.
MINIMUM_PLAUSIBLE_DAYS = 10 — supported
A gap shorter than this between two confirmed starts is discarded as an interval. The clinical description of a normal cycle runs roughly 24–38 days, and nothing at 9 days is a menstrual cycle: it is a re-entry, a correction, or two records of one period.
Bleeding genuinely occurring between periods is a real thing and this app records it — as spotting, which by design cannot start or reset a cycle.
Source. Munro et al., The FIGO classification of causes of abnormal uterine bleeding — FIGO AUB System 1 (2018).
The engineering constants — no citation exists, and none is needed
MULTIPLE_TOLERANCE and UNUSUAL_TOLERANCE decide when an interval is
questionable, and they are expressed as fractions of this user's own centre
rather than as day counts. That relative framing is the part with evidence behind
it — within-woman variation is what the cycle literature describes, and a global
day-count threshold gets the unusual user wrong, which is precisely the user this
product exists for. The fractions themselves were tuned against fixtures.
The same applies to everything in the engine's spread and confidence blocks —
MAD_TO_SCALE, AGREEMENT_SENSITIVITY, WINDOW_MASS, the thresholds, the
recency decay. They are calibration, they were measured, and they are recorded in
LearningCurveTest rather than in the literature. Two of them carry an identity
rather than a tuning — EXPECTED_ERROR_PER_SCALE is 1.0 because the mean
absolute deviation of a Laplace distribution equals its scale, and the
estimation-uncertainty term is √(1 + 1/n) for the same textbook reason — and
those are mathematics, not physiology, so they are not in the table above either.
What this file does not claim
That the engine is clinically validated. It is not, and nothing here should be read as a medical claim about an individual user. These citations justify the defaults and shapes the app assumes in the absence of her data, which is a much smaller claim: that where the app has to guess, it guesses what the population data says rather than what folklore says, and that it declines rather than inventing precision when her own data cannot support an answer.