From 426f5992c7ee1a753385dad5d92a15d39eaf23ca Mon Sep 17 00:00:00 2001 From: null Date: Thu, 20 Aug 2026 16:32:16 -0500 Subject: [PATCH] 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) --- .../period/feature/insights/InsightsScreen.kt | 8 +- docs/DOC_TRUST_MAP.md | 3 +- docs/architecture/README.md | 5 + docs/architecture/SCIENCE.md | 221 ++++++++++++++++++ .../prediction/PersonalPredictionEngine.kt | 14 +- 5 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 docs/architecture/SCIENCE.md 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."