Privacy-Period-Tracker/docs/architecture/SCIENCE.md

222 lines
12 KiB
Markdown
Raw Normal View History

docs: separate the constants that are physiology from the ones that are tuning PersonalPredictionEngine has thirty constants and no way to tell which kind each is. Both are argued for in comments, in the same voice, so the difference is 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. SCIENCE.md is that line, with a verdict and a citation per constant: supported, needs-a-caveat, contradicted, or not-a-scientific-claim. The last category is listed deliberately, so nobody demands a citation for a tuning parameter and nobody mistakes one for physiology and refuses to tune it. It is a sibling of architecture/README rather than a section in it. That document's subject is the module graph, and 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. Two subjects with unrelated review cycles in one document means the one nobody is looking at goes stale behind the one they are. One value changed as a result. POPULATION_DEFAULT_DAYS -- the single length assumed for a user with one confirmed period and no interval yet -- moves 28.0 to 29.0. Twenty-eight is the number everyone knows and the large app-data studies agree it is not the average; the mean sits near 29 and only a minority of women average 28. The app's one unavoidable guess is now the population's actual centre rather than folklore's. BaselinePredictionEngine keeps 28.0: it is the frozen control, and moving it would move the yardstick. The luteal phase is filed rather than changed (#56). Fourteen is above the measured mean of 12-13, but moving it shifts what the fertility screen shows every user -- a product decision with its own evidence and QA, not a line inside a calibration change. Also fixes the one user-facing claim that outran its measurements. The Insights banner told a user one cycle in that "a few more and the predictions get noticeably better" -- an accuracy promise made before a single forecast had been scored, and for a woman whose cycles genuinely vary, one the engine can never keep: her forecast gets more honest, not more precise. It now promises the mechanism, which is true by construction and is the standard the rest of that screen already meets. closes #54 closes #55 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:32:16 -05:00
# 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 1213
days on average, with a normal range of roughly 1117. 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 1224 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 2438 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.