222 lines
12 KiB
Markdown
222 lines
12 KiB
Markdown
# 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.
|