diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt index 728b4d2..a2d9035 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt @@ -89,7 +89,13 @@ private fun LearningBanner(stage: LearningStage, cycleCount: Int) { LearningStage.NO_DATA -> "Nothing learned yet" to "Log a period and this screen will start filling in." LearningStage.LEARNING -> - "Learning your cycle" to "One cycle recorded. A few more and the predictions get noticeably better." + // Promises the mechanism, not the outcome. "A few more and the + // predictions get noticeably better" was a claim about accuracy made + // before a single forecast had been scored — and for a woman whose + // cycles genuinely vary it is one the engine can never keep: her + // forecast gets more honest, not more precise. This is true by + // construction, which is the standard the rest of this screen meets. + "Learning your cycle" to "One cycle recorded. A few more and the forecast will be built from your cycles, not an average." LearningStage.GETTING_TO_KNOW -> "Getting to know your pattern" to "$cycleCount cycles recorded. The forecast is starting to be yours." LearningStage.PERSONALIZED -> diff --git a/docs/DOC_TRUST_MAP.md b/docs/DOC_TRUST_MAP.md index fc2af11..54504bf 100644 --- a/docs/DOC_TRUST_MAP.md +++ b/docs/DOC_TRUST_MAP.md @@ -42,6 +42,7 @@ disagree, and nothing will say which one is right. | What did QA actually reach? | [`qa/ClaudeQACoverage.md`](qa/ClaudeQACoverage.md) | | What is a QA round? | [`qa/ClaudeQAPlan.md`](qa/ClaudeQAPlan.md) | | How is it built — modules, boundaries, data shapes, migrations? | [`architecture/README.md`](architecture/README.md) | +| Which constants are claims about bodies, and on what evidence? | [`architecture/SCIENCE.md`](architecture/SCIENCE.md) | | What should it feel like, and what words does it use? | [`design/README.md`](design/README.md) | | What colour, logo or type do I use? | [`design/BRAND_GUIDE.md`](design/BRAND_GUIDE.md) — it outranks [`planning/PRODUCT_PLAN.md`](planning/PRODUCT_PLAN.md) §39 where they differ | | What is protected, from whom, and what must never be logged? | [`security/SECURITY.md`](security/SECURITY.md) | @@ -80,7 +81,7 @@ Where the two disagree, `PRODUCT_PLAN.md` is the specification and | --- | --- | | `docs/planning/` | `PRODUCT_PLAN` — the V1 specification. `PROJECT_PLAN` — the vision. Neither is the schedule; that is the tracker. | | `docs/qa/` | `ClaudeQAPlan` (playbook, passes A–H), `ClaudeQACoverage` (what each pass reached), `ClaudeReport` (the verdict) | -| `docs/architecture/` | modules, boundaries, data shapes, the migration table; `GUARDS.md`; `githooks/README.md` | +| `docs/architecture/` | modules, boundaries, data shapes, the migration table; `SCIENCE.md` — the physiology constants and their citations; `GUARDS.md`; `githooks/README.md` | | `docs/design/` | `README.md` — tone, the four rules that settle arguments, and which specification sections own each surface. `BRAND_GUIDE.md` — the owner-supplied visual identity, which outranks `PRODUCT_PLAN.md` §39 on colour. `brand/` — the owner's source art, including the emblem the launcher icon is cut from and the Play listing exports. `dist/` — the onboarding illustration sources, and `splash.webp`, the lockup `docs/data/img/logo.webp` and `banner.webp` are generated from. `screenshots/` — captured app screenshots, for the product page on privacyllc.dev and the Play listing | | `docs/security/` | `SECURITY` — threat model, the advertising boundary, logging rules. `SECURITY_CHECKLIST` — the pre-release list | | `docs/history/` | `DEVELOPMENT_LOG` (dated, append-only), `HISTORY` (decisions and their reasons), `BATCH_LEDGER` (archived) | diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 3db2848..0af8945 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -455,6 +455,11 @@ belong in one commit. - **[`GUARDS.md`](GUARDS.md)** — how to write a check that actually checks. Read it before adding a structural test or a probe. +- **[`SCIENCE.md`](SCIENCE.md)** — which constants in `domain/prediction` are + claims about menstrual physiology rather than tuning, and the published + measurement behind each. Read it before changing a luteal phase, a fertile + window or the population default; the rest of the engine's constants are + calibration and belong to `LearningCurveTest`. ## What ships in this folder diff --git a/docs/architecture/SCIENCE.md b/docs/architecture/SCIENCE.md new file mode 100644 index 0000000..5e42cc9 --- /dev/null +++ b/docs/architecture/SCIENCE.md @@ -0,0 +1,221 @@ +# 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. diff --git a/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/PersonalPredictionEngine.kt b/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/PersonalPredictionEngine.kt index eb1e932..9e5d6e1 100644 --- a/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/PersonalPredictionEngine.kt +++ b/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/PersonalPredictionEngine.kt @@ -482,7 +482,19 @@ class PersonalPredictionEngine : PredictionEngine { } private companion object { - const val POPULATION_DEFAULT_DAYS = 28.0 + /** + * The one cycle length this engine ever assumes: the forecast for a user + * with a single confirmed period and no interval to measure yet. Her own + * data replaces it the moment she has any. + * + * 29, not the 28 everybody knows. Studies of hundreds of thousands of + * app-recorded cycles put the mean near 29 days, and only a minority of + * women average 28 — see docs/architecture/SCIENCE.md. + * + * `BaselinePredictionEngine` keeps 28.0 on purpose: it is the frozen + * control, and moving it would move the yardstick. + */ + const val POPULATION_DEFAULT_DAYS = 29.0 // Spread. Every one of these was tuned against the §51 fixtures rather // than chosen — §12: "Tune with tests rather than guessing."