# 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.kt` finds 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_SCALE` was tuned against fixtures and can be retuned by > anyone with a measurement, while `FERTILE_DAYS_BEFORE_OVULATION` is 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.