diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt index c3851c2..d9a9069 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt @@ -229,10 +229,22 @@ private fun Marker(mark: DayMark) { val cycle = PeriodThemeDefaults.cycleColors val dp = 40.dp when (mark) { - DayMark.CONFIRMED_PERIOD -> ConfirmedPeriodMarker(cycle.periodConfirmed, dp) - DayMark.PREDICTED_PERIOD -> PredictedPeriodMarker(cycle.periodPredicted, dp) - DayMark.FERTILE_WINDOW -> FertileWindowMarker(cycle.fertileWindow, dp) - DayMark.OVULATION -> OvulationMarker(cycle.ovulation, dp * 0.5f) + DayMark.CONFIRMED_PERIOD -> ConfirmedPeriodMarker(cycle.periodConfirmed, size = dp) + DayMark.PREDICTED_PERIOD -> PredictedPeriodMarker(cycle.periodPredicted, size = dp) + DayMark.FERTILE_WINDOW -> FertileWindowMarker(cycle.fertileWindow, size = dp) + // Ovulation is the ring PLUS a small star low in the cell. + // + // The star alone was centred, which put it directly behind the numeral — + // in greyscale the "18" and the mark merged into one smudge. Drawing the + // ring as well is also semantically right: the ovulation day IS inside + // the fertile window, and the pair reads as "that window, and this + // particular day in it". + DayMark.OVULATION -> { + FertileWindowMarker(cycle.fertileWindow, size = dp) + Box(Modifier.size(dp), contentAlignment = Alignment.BottomCenter) { + OvulationMarker(cycle.ovulation, Modifier.padding(bottom = 1.dp), size = 11.dp) + } + } DayMark.SPOTTING -> SpottingMarker(cycle.periodConfirmed, dp) DayMark.NONE -> Unit } diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarViewModel.kt index c34592f..06982a2 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarViewModel.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarViewModel.kt @@ -7,6 +7,7 @@ import dev.privacyllc.period.core.data.CycleRepository import dev.privacyllc.period.domain.prediction.CalendarDay import dev.privacyllc.period.domain.prediction.CalendarMarks import dev.privacyllc.period.domain.prediction.DayMark +import dev.privacyllc.period.domain.prediction.FertilityEstimate import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -64,6 +65,7 @@ class CalendarViewModel @Inject constructor( repository.forecast, message, ) { m, periods, spotting, forecast, msg -> + val fertility = FertilityEstimate.from(forecast) CalendarUiState( month = m, days = CalendarMarks.forMonth( @@ -72,6 +74,8 @@ class CalendarViewModel @Inject constructor( spotting = spotting, forecast = forecast, today = LocalDate.now(clock), + fertileWindow = fertility?.fertileWindow, + ovulation = fertility?.ovulationDate, ), today = LocalDate.now(clock), message = msg, diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt index a28fa21..24202d7 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt @@ -46,6 +46,8 @@ import dev.privacyllc.period.designsystem.art.EmptyStateIllustration import dev.privacyllc.period.domain.cycle.PeriodRecord import dev.privacyllc.period.domain.prediction.ConfidenceLabel import dev.privacyllc.period.domain.prediction.CycleStatus +import dev.privacyllc.period.domain.prediction.FertilityEstimate +import dev.privacyllc.period.domain.prediction.FertilityLikelihood import dev.privacyllc.period.domain.prediction.Prediction import java.time.LocalDate import java.time.format.DateTimeFormatter @@ -230,6 +232,66 @@ private fun ConfidenceRow(label: ConfidenceLabel) { } } +/** + * §21's fertility block, and §18's vocabulary. + * + * The words come from [FertilityLikelihood] rather than from here, because §18 + * forbids "safe" and "unsafe" and the safest place for that prohibition is a + * type with no such value. This composable only decides *whether* to show a + * label, never what it says. + * + * The disclaimer travels with it. §18 requires it near fertility features, and + * "near" means on the same screen at the same time — a disclaimer one tab away + * is a disclaimer nobody read. + */ +@Composable +private fun FertilitySection(estimate: FertilityEstimate?, likelihood: FertilityLikelihood?) { + likelihood ?: return + + Spacer(Modifier.height(24.dp)) + androidx.compose.material3.HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + Quiet("Fertility today") + Text(likelihood.label, style = MaterialTheme.typography.titleMedium) + + if (estimate != null) { + Spacer(Modifier.height(12.dp)) + // "Estimated ovulation", never "you are ovulating today" — §17. The word + // is load-bearing: this is a health app in a category Play scrutinises, + // and a sentence that reads as a medical assertion is both a product + // failure and a compliance one. + Quiet("Estimated ovulation") + Text(estimate.ovulationDate.pretty(), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + Quiet("Estimated fertile window") + Text("${estimate.fertileWindowStart.short()} – ${estimate.fertileWindowEnd.short()}") + } else { + Spacer(Modifier.height(6.dp)) + Text( + "Log a few more cycles and the app will be able to estimate ovulation.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + + Spacer(Modifier.height(12.dp)) + FertilityDisclaimer() +} + +/** §18's exact sentence. Required near fertility features and in About. */ +@Composable +private fun FertilityDisclaimer() { + Text( + "Fertility and ovulation dates are estimates based on cycle history and are not " + + "intended to be used as contraception or as a medical diagnosis.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) +} + @Composable private fun Quiet(text: String) = Text( text, @@ -315,6 +377,7 @@ private fun BetweenState( Text("$it days") } state.forecast?.let { ForecastDetail(it) } + FertilitySection(state.fertility, state.fertilityToday) PrimaryAction("Started period", onStarted) } @@ -330,14 +393,13 @@ private fun FertileState( Spacer(Modifier.height(16.dp)) Quiet("Next period") Text("About ${status.daysToNextPeriod} days") + state.fertility?.let { + Spacer(Modifier.height(12.dp)) + Quiet("Estimated fertile window") + Text("${it.fertileWindowStart.short()} – ${it.fertileWindowEnd.short()}") + } Spacer(Modifier.height(16.dp)) - Text( - "Fertility and ovulation dates are estimates based on cycle history and are not " + - "intended to be used as contraception or as a medical diagnosis.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + FertilityDisclaimer() state.forecast?.let { ForecastDetail(it) } PrimaryAction("Started period", onStarted) } @@ -353,6 +415,7 @@ private fun ApproachingState( Spacer(Modifier.height(16.dp)) HeroCount(label = "Period likely in", count = status.daysUntil, unit = if (status.daysUntil == 1) "day" else "days") ForecastDetail(status.forecast) + FertilitySection(state.fertility, state.fertilityToday) PrimaryAction(if (status.daysUntil <= 2) "Started early?" else "Started period", onStarted) } @@ -591,6 +654,7 @@ private fun previewState( forecast = forecastIn?.let { previewForecast(previewToday.plusDays(it)) }, today = previewToday, originalForecastDate = original, + fertility = forecastIn?.let { FertilityEstimate.from(previewForecast(previewToday.plusDays(it))) }, ) @Preview(name = "Today · empty", showBackground = true) diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt index 2af1068..1b56437 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt @@ -10,6 +10,8 @@ import dev.privacyllc.period.domain.cycle.CycleRecord import dev.privacyllc.period.domain.cycle.PeriodRecord import dev.privacyllc.period.domain.prediction.CycleStatus import dev.privacyllc.period.domain.prediction.CycleStatusRules +import dev.privacyllc.period.domain.prediction.FertilityEstimate +import dev.privacyllc.period.domain.prediction.FertilityLikelihood import dev.privacyllc.period.domain.prediction.Prediction import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.flow.MutableStateFlow @@ -40,7 +42,24 @@ data class TodayUiState( val today: LocalDate = LocalDate.ofEpochDay(0), /** What the forecast said before any "Not yet" moved it. §22's last state shows both. */ val originalForecastDate: LocalDate? = null, + val fertility: FertilityEstimate? = null, ) { + /** + * §18's "today's fertility estimate", in §18's own vocabulary. + * + * `UNKNOWN` when there is a forecast but it is too vague to locate + * ovulation — which is a different thing from having no forecast at all, and + * worth saying out loud. Silently omitting the section would leave the user + * wondering whether the feature exists; telling her the app does not know + * her cycle well enough yet is true and gives her a reason to keep logging. + */ + val fertilityToday: FertilityLikelihood? + get() = when { + fertility != null -> fertility.likelihoodOn(today) + forecast != null -> FertilityLikelihood.UNKNOWN + else -> null + } + val daysUntil: Long? get() = forecast?.let { it.mostLikelyStartDate.toEpochDay() - today.toEpochDay() } @@ -72,6 +91,8 @@ data class TodayUiState( forecast = forecast, today = today, typicalPeriodDuration = typicalPeriodDuration, + fertileWindow = fertility?.fertileWindow, + ovulation = fertility?.ovulationDate, originalForecastDate = originalForecastDate, ) @@ -123,6 +144,11 @@ class TodayViewModel @Inject constructor( // last state show both forecasts instead of quietly moving the // goalposts. originalForecastDate = notYet.minOfOrNull { it.date }, + // Derived here rather than collected as a sixth flow: it is a + // pure function of the forecast, so a separate stream would be + // two sources for one fact — and combine() has no typed + // overload past five anyway. + fertility = FertilityEstimate.from(forecast), ) }.stateIn( scope = viewModelScope, diff --git a/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt index 1774411..491f97a 100644 --- a/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt +++ b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt @@ -8,6 +8,7 @@ import dev.privacyllc.period.domain.cycle.PeriodRecord import dev.privacyllc.period.domain.cycle.PeriodRecordSource import dev.privacyllc.period.domain.cycle.SpottingRecord import dev.privacyllc.period.domain.cycle.toCycles +import dev.privacyllc.period.domain.prediction.FertilityEstimate import dev.privacyllc.period.domain.prediction.NotYetObservation import dev.privacyllc.period.domain.prediction.Prediction import dev.privacyllc.period.domain.prediction.PredictionEngine @@ -102,6 +103,19 @@ class CycleRepository internal constructor( ) } + /** + * Estimated ovulation and the fertile window. + * + * Derived from [forecast] rather than stored, for the same reason cycles + * are: it is a function of the forecast, and a stored copy would contradict + * the period estimate on the same screen the moment a "Not yet" moved one + * and not the other. + * + * Null whenever the forecast is null — §17 hangs the estimate off the + * predicted next period, and without one there is nothing to hang it on. + */ + val fertility: Flow = forecast.map { FertilityEstimate.from(it) } + /** §16's accuracy figures, computed on-device from stored snapshots. */ val accuracy: Flow = predictionDao.observeScored().map { rows -> diff --git a/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt index a1c488e..8ff33e2 100644 --- a/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt +++ b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt @@ -35,7 +35,11 @@ import androidx.compose.ui.unit.dp /** A period the user confirmed. Solid, because it is the only state that is certain. */ @Composable -fun ConfirmedPeriodMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier: Modifier = Modifier) { +fun ConfirmedPeriodMarker( + color: Color, + modifier: Modifier = Modifier, + size: Dp = MarkerDefaults.Size, +) { Canvas(modifier.size(size)) { drawCircle(color = color, radius = this.size.minDimension / 2f) } } @@ -48,7 +52,11 @@ fun ConfirmedPeriodMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier * closely. */ @Composable -fun PredictedPeriodMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier: Modifier = Modifier) { +fun PredictedPeriodMarker( + color: Color, + modifier: Modifier = Modifier, + size: Dp = MarkerDefaults.Size, +) { Canvas(modifier.size(size)) { val stroke = this.size.minDimension * MarkerDefaults.StrokeFraction drawCircle( @@ -66,7 +74,11 @@ fun PredictedPeriodMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier /** The estimated fertile window. A continuous ring — open, unlike the solid confirmed disc. */ @Composable -fun FertileWindowMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier: Modifier = Modifier) { +fun FertileWindowMarker( + color: Color, + modifier: Modifier = Modifier, + size: Dp = MarkerDefaults.Size, +) { Canvas(modifier.size(size)) { val stroke = this.size.minDimension * MarkerDefaults.StrokeFraction drawCircle(color = color, radius = (this.size.minDimension - stroke) / 2f, style = Stroke(width = stroke)) @@ -75,7 +87,11 @@ fun FertileWindowMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier: /** Estimated ovulation. Its own shape entirely, because it is a day rather than a span. */ @Composable -fun OvulationMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier: Modifier = Modifier) { +fun OvulationMarker( + color: Color, + modifier: Modifier = Modifier, + size: Dp = MarkerDefaults.Size, +) { Canvas(modifier.size(size)) { drawFourPointStar(color) } } diff --git a/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/Illustrations.kt b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/Illustrations.kt index 07d238b..4289cef 100644 --- a/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/Illustrations.kt +++ b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/Illustrations.kt @@ -50,8 +50,8 @@ import kotlin.math.sin fun WelcomeIllustration( primary: Color, accent: Color, - size: Dp = IllustrationDefaults.Size, modifier: Modifier = Modifier, + size: Dp = IllustrationDefaults.Size, ) { Canvas( modifier @@ -77,8 +77,8 @@ fun WelcomeIllustration( @Composable fun EmptyStateIllustration( color: Color, - size: Dp = IllustrationDefaults.Size, modifier: Modifier = Modifier, + size: Dp = IllustrationDefaults.Size, ) { Canvas( modifier @@ -113,8 +113,8 @@ fun EmptyStateIllustration( fun LearningIllustration( color: Color, accent: Color, - size: Dp = IllustrationDefaults.Size, modifier: Modifier = Modifier, + size: Dp = IllustrationDefaults.Size, ) { Canvas( modifier @@ -151,8 +151,8 @@ fun LearningIllustration( fun PrivacyIllustration( color: Color, accent: Color, - size: Dp = IllustrationDefaults.Size, modifier: Modifier = Modifier, + size: Dp = IllustrationDefaults.Size, ) { Canvas( modifier @@ -195,8 +195,8 @@ fun CycleProgressMark( accent: Color, /** 0f at the start of the cycle, 1f at the predicted start of the next. */ progress: Float, - size: Dp = 56.dp, modifier: Modifier = Modifier, + size: Dp = 56.dp, ) { Canvas(modifier.size(size)) { val r = this.size.minDimension * 0.38f diff --git a/docs/architecture/README.md b/docs/architecture/README.md index f69802e..c089ed1 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -198,6 +198,7 @@ each exists and what must not happen to it. | `CycleRecord` | derived interval between two confirmed starts | derived, never stored as truth — `toCycles()` recomputes from the period records on every read, so an edit cannot leave a stale interval behind it | | `PredictionRecord` | a snapshot taken *before* the outcome is known | this is what makes accuracy measurable at all; never overwritten in place | | `NotYetObservation` | the user said the period had not started by a date | a censoring observation — the forecast is re-conditioned on it, not shifted by +1 day | +| `FertilityEstimate` | estimated ovulation and the fertile window around it | derived from the forecast, never stored, and **null when the forecast is too vague to locate ovulation** | | `Interval` | one gap between confirmed starts, with its recency weight and whether it looks questionable | derived per calculation, never stored. A questionable interval is **down-weighted, never dropped** — §12 step 2 | | `UserPreferences` | notification privacy, reminder time, lock, theme, ads entitlement | lives in DataStore, **never** in the cycle database — see below | @@ -251,6 +252,23 @@ inside it; an engine keeping that promise 7 times in 9 has a broken promise, not a tight forecast. The comparison test asserts coverage, with a ceiling so the trivial cheat of answering "some time this month" still fails. +### Fertility declines rather than stretches + +The fertile window is seven days wide before any uncertainty is added, so a +forecast carrying ±5 produces a seventeen-day window — over half a cycle. That +is honest arithmetic and useless information, and it was on screen before +anybody noticed: a user one cycle into the app was shown *8 Aug – 24 Aug*. + +`FertilityEstimate.from` now returns **null** past +`MAX_USEFUL_UNCERTAINTY_DAYS`, and Today says so — *"Not enough history to +estimate"*, with a reason to keep logging. Three stable cycles later the same +user gets *12 Aug – 20 Aug*, which is worth reading. + +The general rule this is an instance of: **the app declines rather than +stretches.** The same shape appears in `PredictionAccuracy` refusing figures +below three scored forecasts, and in `CycleInsights` withholding an average +until there are two intervals. + ### Unusual is relative to the user, never to a constant `IntervalAnalysis` decides whether a gap is odd by comparing it to a robust diff --git a/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/BaselinePredictionEngine.kt b/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/BaselinePredictionEngine.kt new file mode 100644 index 0000000..68012de --- /dev/null +++ b/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/BaselinePredictionEngine.kt @@ -0,0 +1,117 @@ +package dev.privacyllc.period.domain.prediction + +import java.time.LocalDate +import kotlin.math.abs +import kotlin.math.roundToLong + +/** + * A deliberately simple baseline, and it is NOT the product. + * + * docs/planning/PRODUCT_PLAN.md §11 names `mean(all cycles)` as an acceptable + * prototype baseline and an unacceptable final engine. This is that prototype: + * a median rather than a mean so a single outlier cannot drag it, a window from + * the median absolute deviation, and confidence from agreement rather than + * volume. + * + * It exists so the skeleton has something honest to render and something to + * measure the real engine against. Batch 02 replaces it with the recency + * weighted, trend-aware, "not yet"-conditioned engine §12 specifies — at which + * point these tests become the regression suite that says the replacement is + * better rather than merely different. + */ +class BaselinePredictionEngine : PredictionEngine { + + override val modelVersion: String = "baseline-1" + + override fun predict(input: PredictionInput): Prediction? { + val (confirmedStarts, today, notYet, _) = input + val starts = confirmedStarts.distinct().sorted() + if (starts.isEmpty()) return null + + val intervals = starts.zipWithNext { a, b -> b.toEpochDay() - a.toEpochDay() } + .filter { it > 0 } + + // No history at all: the population default, said with the lowest + // confidence the type can express. Never presented as knowledge. + val centre = if (intervals.isEmpty()) DEFAULT_CYCLE_DAYS else median(intervals) + val spread = if (intervals.size < 2) DEFAULT_SPREAD_DAYS else medianAbsoluteDeviation(intervals, centre) + + val lastStart = starts.last() + var likely = lastStart.plusDays(centre.roundToLong()) + val halfWidth = spread.roundToLong().coerceIn(MIN_HALF_WIDTH_DAYS, MAX_HALF_WIDTH_DAYS) + var windowStart = likely.minusDays(halfWidth) + var windowEnd = likely.plusDays(halfWidth) + + // "Not yet" removes dates from the front of the window. A date the user + // has told us was not the start cannot remain a future start candidate, + // which is the specific requirement in §51's "Not yet" acceptance case. + val latestRuledOut = notYet.map { it.date }.maxOrNull() + val floor = listOfNotNull(latestRuledOut, today.minusDays(1)).maxOrNull() + if (floor != null && !windowStart.isAfter(floor)) { + windowStart = floor.plusDays(1) + if (windowEnd.isBefore(windowStart)) windowEnd = windowStart + if (likely.isBefore(windowStart)) likely = windowStart + } + + val confidence = confidenceFor(intervals.size, spread, notYet.size) + + return Prediction( + mostLikelyStartDate = likely, + windowStart = windowStart, + windowEnd = windowEnd, + confidenceScore = confidence, + confidenceLabel = labelFor(confidence, intervals.size), + modelVersion = modelVersion, + ) + } + + /** + * Confidence is agreement, not volume. + * + * §15 is explicit that a large number of recorded cycles must not on its own + * produce "High" — a user whose cycles run 25, 33, 28, 37, 26, 32 has plenty + * of data and an unpredictable cycle, and telling them otherwise is the + * failure. So spread dominates, count only caps. + */ + private fun confidenceFor(cycleCount: Int, spread: Double, notYetCount: Int): Double { + if (cycleCount == 0) return 0.1 + val agreement = 1.0 / (1.0 + spread / 2.0) + val evidence = (cycleCount.toDouble() / SATURATION_CYCLES).coerceAtMost(1.0) + val uncertainty = notYetCount * NOT_YET_PENALTY + return (agreement * evidence - uncertainty).coerceIn(0.0, 1.0) + } + + private fun labelFor(confidence: Double, cycleCount: Int): ConfidenceLabel = when { + cycleCount < 2 -> ConfidenceLabel.LOW + confidence >= HIGH_THRESHOLD -> ConfidenceLabel.HIGH + confidence >= MEDIUM_THRESHOLD -> ConfidenceLabel.MEDIUM + else -> ConfidenceLabel.LOW + } + + private fun median(values: List): Double { + val sorted = values.sorted() + val mid = sorted.size / 2 + return if (sorted.size % 2 == 1) sorted[mid].toDouble() + else (sorted[mid - 1] + sorted[mid]) / 2.0 + } + + private fun medianAbsoluteDeviation(values: List, centre: Double): Double { + val deviations = values.map { abs(it - centre) }.sorted() + val mid = deviations.size / 2 + val mad = if (deviations.size % 2 == 1) deviations[mid] + else (deviations[mid - 1] + deviations[mid]) / 2.0 + return maxOf(mad, MIN_SPREAD_DAYS) + } + + private companion object { + const val DEFAULT_CYCLE_DAYS = 28.0 + const val DEFAULT_SPREAD_DAYS = 4.0 + const val MIN_SPREAD_DAYS = 0.5 + const val MIN_HALF_WIDTH_DAYS = 1L + const val MAX_HALF_WIDTH_DAYS = 10L + const val SATURATION_CYCLES = 6.0 + const val NOT_YET_PENALTY = 0.08 + const val HIGH_THRESHOLD = 0.6 + const val MEDIUM_THRESHOLD = 0.4 + } +} diff --git a/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/CalendarMarks.kt b/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/CalendarMarks.kt new file mode 100644 index 0000000..e5623a1 --- /dev/null +++ b/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/CalendarMarks.kt @@ -0,0 +1,119 @@ +package dev.privacyllc.period.domain.prediction + +import dev.privacyllc.period.domain.cycle.PeriodRecord +import dev.privacyllc.period.domain.cycle.SpottingRecord +import java.time.LocalDate + +/** + * What a calendar day is, at most one thing. + * + * Ordered by precedence, highest first. A day can qualify for several — the + * predicted window can overlap a fertile window on a short cycle — and showing + * two markers on one square makes both unreadable at the size a month grid + * gives you. + * + * The rule for the ordering: **a fact outranks an estimate, and the more + * specific estimate outranks the vaguer one.** + */ +enum class DayMark { + /** Recorded by the user. The only certain one. */ + CONFIRMED_PERIOD, + + /** Recorded by the user, and deliberately not a period. */ + SPOTTING, + + /** Inside the forecast window. */ + PREDICTED_PERIOD, + + /** The single estimated ovulation day. Batch 04. */ + OVULATION, + + /** Inside the estimated fertile window. Batch 04. */ + FERTILE_WINDOW, + + NONE, +} + +data class CalendarDay( + val date: LocalDate, + val mark: DayMark, + val isToday: Boolean, +) { + /** + * What a screen reader says. + * + * §43: a calendar that reads as a grid of bare numbers carries none of its + * information. The marker shape is what a sighted user reads; this is the + * same fact for everybody else, and it is not optional. + */ + val accessibilityLabel: String = buildString { + append(date.dayOfMonth) + when (mark) { + DayMark.CONFIRMED_PERIOD -> append(", period") + DayMark.SPOTTING -> append(", spotting") + DayMark.PREDICTED_PERIOD -> append(", period predicted") + DayMark.OVULATION -> append(", estimated ovulation") + DayMark.FERTILE_WINDOW -> append(", estimated fertile window") + DayMark.NONE -> Unit + } + if (isToday) append(", today") + } +} + +object CalendarMarks { + + /** + * Mark every day in [month]. + * + * Everything is derived per call rather than stored. A `calendar_days` table + * would be a second copy of facts the period records and the forecast + * already hold, and it would be stale the moment either changed. + */ + fun forMonth( + month: java.time.YearMonth, + periods: List, + spotting: List, + forecast: Prediction?, + today: LocalDate, + fertileWindow: ClosedRange? = null, + ovulation: LocalDate? = null, + ): List { + val confirmed = buildSet { + periods.filter { it.isConfirmed }.forEach { p -> + // An unclosed period marks its start day only. Filling forward + // to today would draw a period the user never said was still + // running — the calendar would be asserting it. + val end = p.endDate ?: p.startDate + var d = p.startDate + while (!d.isAfter(end)) { add(d); d = d.plusDays(1) } + } + } + val spotted = spotting.map { it.date }.toSet() + + return (1..month.lengthOfMonth()).map { day -> + val date = month.atDay(day) + CalendarDay( + date = date, + mark = markFor(date, confirmed, spotted, forecast, fertileWindow, ovulation), + isToday = date == today, + ) + } + } + + private fun markFor( + date: LocalDate, + confirmed: Set, + spotted: Set, + forecast: Prediction?, + fertileWindow: ClosedRange?, + ovulation: LocalDate?, + ): DayMark = when { + date in confirmed -> DayMark.CONFIRMED_PERIOD + date in spotted -> DayMark.SPOTTING + forecast != null && date >= forecast.windowStart && date <= forecast.windowEnd -> + DayMark.PREDICTED_PERIOD + ovulation != null && date == ovulation -> DayMark.OVULATION + fertileWindow != null && date in fertileWindow -> DayMark.FERTILE_WINDOW + else -> DayMark.NONE + } +} diff --git a/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/CycleStatus.kt b/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/CycleStatus.kt new file mode 100644 index 0000000..36ed938 --- /dev/null +++ b/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/CycleStatus.kt @@ -0,0 +1,165 @@ +package dev.privacyllc.period.domain.prediction + +import dev.privacyllc.period.domain.cycle.PeriodRecord +import java.time.LocalDate + +/** + * Which of PRODUCT_PLAN.md §22's states the user is in right now. + * + * Derived here rather than inside a composable so the boundaries are testable. + * They are the kind of logic that is obviously right until a user is on the + * boundary of two of them — the day a period is predicted, the day after it + * ends — and every one of those days is somebody's actual Tuesday. + */ +sealed interface CycleStatus { + + /** Nothing logged. The empty state, not a forecast of nothing. */ + data object NoData : CycleStatus + + /** A period is on. §22's first state. */ + data class DuringPeriod( + val dayOfPeriod: Int, + val startedOn: LocalDate, + /** + * Set once the user has said when it ended — which can be today. + * + * The distinction matters on screen rather than in the maths: a period + * ending today still includes today, so the state does not change when + * "Period ended" is tapped. Without this field the screen showed exactly + * the same thing afterwards and the tap looked like it had failed. + */ + val endedOn: LocalDate?, + val typicalDurationDays: IntRange?, + ) : CycleStatus + + /** + * The ordinary middle of a cycle. + * + * [daysToFertileWindow] is null until Batch 04 estimates fertility. §22 + * shows the line; showing a placeholder number instead would be inventing a + * fertility estimate, which is the one thing this screen must not do. + */ + data class BetweenPeriodAndFertile( + val cycleDay: Int, + val daysToNextPeriod: Int, + val daysToFertileWindow: Int?, + ) : CycleStatus + + /** Inside the estimated fertile window. Batch 04 makes this reachable. */ + data class DuringFertileWindow( + val cycleDay: Int, + val daysToOvulation: Int?, + val daysToNextPeriod: Int, + ) : CycleStatus + + /** The countdown. §22's hero state, and the one §38 says must dominate. */ + data class PeriodApproaching( + val cycleDay: Int, + val daysUntil: Int, + val forecast: Prediction, + ) : CycleStatus + + /** The day itself. */ + data class PredictedDay(val cycleDay: Int, val forecast: Prediction) : CycleStatus + + /** + * Past the forecast, and **never described as late**. + * + * §22 is explicit: avoid "Your period is late!". Nothing is wrong with the + * user, the model was imprecise, and the copy says so. [originalForecast] is + * kept so the screen can show what it had said before re-conditioning — + * which is the app being accountable rather than quietly moving the goalposts. + */ + data class BeyondForecast( + val cycleDay: Int, + val daysPast: Int, + val originalForecast: LocalDate, + val updated: Prediction, + ) : CycleStatus +} + +object CycleStatusRules { + /** + * How long an unclosed period is assumed to still be running. + * + * Past this, the user simply never tapped "Period Ended" — keeping the app + * on the during-period screen for three weeks would be obviously wrong and + * would hide the next forecast. Not a medical claim; a UI timeout. + */ + const val MAX_ASSUMED_PERIOD_DAYS = 10 + + /** Within this many days, the countdown becomes the screen. */ + const val APPROACHING_DAYS = 7 + + fun statusFor( + periods: List, + forecast: Prediction?, + today: LocalDate, + typicalPeriodDuration: IntRange? = null, + /** From Batch 04. Null means fertility is not estimated yet. */ + fertileWindow: ClosedRange? = null, + ovulation: LocalDate? = null, + /** What the forecast said before any "Not yet" re-conditioned it. */ + originalForecastDate: LocalDate? = null, + ): CycleStatus { + val latest = periods.filter { it.isConfirmed }.maxByOrNull { it.startDate } + ?: return CycleStatus.NoData + + val cycleDay = (today.toEpochDay() - latest.startDate.toEpochDay()).toInt() + 1 + if (cycleDay < 1) return CycleStatus.NoData + + // 1. A period that is on beats everything: it is a fact, and the rest of + // this function is estimates. + val end = latest.endDate + val running = when { + end != null -> !today.isAfter(end) + else -> cycleDay <= MAX_ASSUMED_PERIOD_DAYS + } + if (running && !latest.startDate.isAfter(today)) { + return CycleStatus.DuringPeriod( + dayOfPeriod = cycleDay, + startedOn = latest.startDate, + endedOn = end, + typicalDurationDays = typicalPeriodDuration, + ) + } + + forecast ?: return CycleStatus.BetweenPeriodAndFertile(cycleDay, daysToNextPeriod = 0, null) + + val daysUntil = (forecast.mostLikelyStartDate.toEpochDay() - today.toEpochDay()).toInt() + + return when { + // 2. Past what was forecast. Checked before the predicted day so a + // re-conditioned forecast cannot keep saying "may start today" + // every day for a week. + daysUntil < 0 || (originalForecastDate != null && today > originalForecastDate) -> + CycleStatus.BeyondForecast( + cycleDay = cycleDay, + daysPast = ((originalForecastDate ?: forecast.mostLikelyStartDate).let { + today.toEpochDay() - it.toEpochDay() + }).toInt(), + originalForecast = originalForecastDate ?: forecast.mostLikelyStartDate, + updated = forecast, + ) + + daysUntil == 0 -> CycleStatus.PredictedDay(cycleDay, forecast) + + fertileWindow != null && today in fertileWindow -> + CycleStatus.DuringFertileWindow( + cycleDay = cycleDay, + daysToOvulation = ovulation?.let { (it.toEpochDay() - today.toEpochDay()).toInt() }, + daysToNextPeriod = daysUntil, + ) + + daysUntil <= APPROACHING_DAYS -> CycleStatus.PeriodApproaching(cycleDay, daysUntil, forecast) + + else -> CycleStatus.BetweenPeriodAndFertile( + cycleDay = cycleDay, + daysToNextPeriod = daysUntil, + daysToFertileWindow = fertileWindow?.let { + (it.start.toEpochDay() - today.toEpochDay()).toInt().takeIf { d -> d > 0 } + }, + ) + } + } +} diff --git a/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/FertilityEstimate.kt b/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/FertilityEstimate.kt new file mode 100644 index 0000000..fbbe3cb --- /dev/null +++ b/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/FertilityEstimate.kt @@ -0,0 +1,159 @@ +package dev.privacyllc.period.domain.prediction + +import java.time.LocalDate +import kotlin.math.roundToLong + +/** + * How likely conception is on a given day, in the only vocabulary §18 permits. + * + * The prohibition is three words long — *avoid "safe" or "unsafe" labels* — and + * it is not squeamishness about wording. Somebody reading "safe" will take a + * decision on it; the estimate is derived from a *predicted* date carrying days + * of uncertainty; and §18 has already promised this is not contraception. + * "Lower likelihood" is true. "Safe" is a claim the model cannot support. + */ +enum class FertilityLikelihood { + LOWER, + HIGHER, + + /** + * The forecast is too uncertain to say either way. + * + * A day can sit outside the estimated window and still not deserve "lower + * likelihood", because the window itself is only as good as the period + * prediction it hangs off. Where that is weak, the honest answer is the + * vaguer one rather than a confident-sounding one. + */ + UNKNOWN, + ; + + /** User-facing text. §18's words, not the enum's. */ + val label: String + get() = when (this) { + LOWER -> "Lower likelihood" + HIGHER -> "Higher likelihood" + UNKNOWN -> "Not enough history to estimate" + } +} + +/** + * Estimated ovulation and the fertile window around it. + * + * **Everything here is derived from the period forecast**, so nothing here can + * be more certain than that forecast. §17: estimate ovulation relative to the + * predicted next period; do not imply a fixed day-14 rule is biologically exact. + * + * [uncertaintyDays] carries that inheritance explicitly rather than leaving it + * implied, because the tempting mistake is to present a single crisp ovulation + * date computed from a nine-day period window — precision the app invented. + */ +data class FertilityEstimate( + val ovulationDate: LocalDate, + val fertileWindowStart: LocalDate, + val fertileWindowEnd: LocalDate, + /** Half-width, in days, inherited from the forecast the estimate hangs off. */ + val uncertaintyDays: Int, + val lutealPhaseDays: Int, +) { + val fertileWindow: ClosedRange get() = fertileWindowStart..fertileWindowEnd + + init { + require(!fertileWindowStart.isAfter(fertileWindowEnd)) { + "fertile window starts after it ends" + } + require(ovulationDate in fertileWindowStart..fertileWindowEnd) { + "ovulation $ovulationDate falls outside its own fertile window" + } + } + + /** + * The label for [date]. Never "safe", by construction — the type has no such + * value. + */ + fun likelihoodOn(date: LocalDate): FertilityLikelihood = + if (date in fertileWindowStart..fertileWindowEnd) { + FertilityLikelihood.HIGHER + } else { + FertilityLikelihood.LOWER + } + + companion object { + /** + * Days from ovulation to the next period. + * + * The luteal phase is the more stable half of the cycle, which is why + * estimating backwards from a predicted period is more defensible than + * counting forwards from the last one — and why §17 asks for exactly + * that. Fourteen is the common assumption and it is **an assumption**; + * it is a named, overridable constant rather than a literal buried in + * arithmetic, so the day this app can measure it, one value changes. + */ + const val DEFAULT_LUTEAL_PHASE_DAYS = 14 + + /** Sperm survive several days; the window opens before ovulation, not on it. */ + const val FERTILE_DAYS_BEFORE_OVULATION = 5L + + /** The egg is viable for about a day. */ + const val FERTILE_DAYS_AFTER_OVULATION = 1L + + /** + * Past this much forecast uncertainty, no estimate is offered at all. + * + * The window is inherently seven days wide before any uncertainty is + * added, so a forecast carrying ±5 produces a "fertile window" of + * seventeen days — over half a cycle. That is honest arithmetic and + * useless information, and it was on screen before anybody noticed: + * a user one cycle into the app was shown *8 Aug – 24 Aug*. + * + * Declining is the better answer. "We do not know your cycle well + * enough yet" is true, is useful, and gives the user a reason to keep + * logging. A seventeen-day window says nothing and looks like a + * feature. + */ + const val MAX_USEFUL_UNCERTAINTY_DAYS = 3 + + /** + * Estimate from a period forecast, or **null when it would not mean + * anything**. + * + * Two ways to get null, and both are the app declining to invent + * precision: + * + * - there is no forecast. §17's estimate hangs off the predicted next + * period, and a day-14 guess without one is exactly the fixed-rule + * implication §17 rules out; + * - the forecast is too vague. See [MAX_USEFUL_UNCERTAINTY_DAYS]. + */ + fun from( + forecast: Prediction?, + lutealPhaseDays: Int = DEFAULT_LUTEAL_PHASE_DAYS, + ): FertilityEstimate? { + forecast ?: return null + + val ovulation = forecast.mostLikelyStartDate.minusDays(lutealPhaseDays.toLong()) + + // Inherited, not invented. The forecast's own window is the floor on + // how precisely ovulation can possibly be known. + val forecastHalfWidth = + ((forecast.windowEnd.toEpochDay() - forecast.windowStart.toEpochDay()) / 2.0) + .roundToLong().toInt() + + val start = ovulation + .minusDays(FERTILE_DAYS_BEFORE_OVULATION) + .minusDays(forecastHalfWidth.toLong()) + val end = ovulation + .plusDays(FERTILE_DAYS_AFTER_OVULATION) + .plusDays(forecastHalfWidth.toLong()) + + if (forecastHalfWidth > MAX_USEFUL_UNCERTAINTY_DAYS) return null + + return FertilityEstimate( + ovulationDate = ovulation, + fertileWindowStart = start, + fertileWindowEnd = end, + uncertaintyDays = forecastHalfWidth, + lutealPhaseDays = lutealPhaseDays, + ) + } + } +} diff --git a/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/IntervalAnalysis.kt b/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/IntervalAnalysis.kt new file mode 100644 index 0000000..d0e84f3 --- /dev/null +++ b/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/IntervalAnalysis.kt @@ -0,0 +1,149 @@ +package dev.privacyllc.period.domain.prediction + +import java.time.LocalDate +import kotlin.math.abs + +/** + * One observed gap between confirmed period starts, and what the model thinks + * of it. + * + * [weight] is how much this interval should influence the forecast: recency + * decay, reduced further when the interval is [questionable]. It is never zero + * for real data — PRODUCT_PLAN.md §12 step 2 is explicit that unusual data is + * marked for review or down-weighted, **never silently discarded**. + */ +data class Interval( + val days: Int, + val startedOn: LocalDate, + val endedOn: LocalDate, + /** 0 is the most recent interval. */ + val ageIndex: Int, + val weight: Double, + val questionable: QuestionableReason? = null, +) { + val isQuestionable: Boolean get() = questionable != null +} + +/** + * Why an interval was flagged. + * + * Carried rather than reduced to a boolean because the two produce different + * questions: a gap roughly twice the usual is *"did you forget to record a + * period?"*, while one merely far from usual is not a question worth asking at + * all — §25 warns against over-questioning the user. + */ +enum class QuestionableReason { + /** Close to a whole multiple of this user's usual cycle: a period was probably missed. */ + POSSIBLE_MISSED_PERIOD, + + /** Far from this user's usual, but not a plausible multiple. Kept, down-weighted, not queried. */ + UNUSUAL_LENGTH, +} + +/** + * Turn confirmed start dates into weighted intervals. + * + * ## Why detection is relative to the user, never to a constant + * + * A 45-day gap is unremarkable for somebody whose cycles run 40 and very odd for + * somebody at 28. A global threshold gets one of those two wrong, and the one it + * gets wrong is the user whose cycle is already unusual — exactly the person + * this product exists for, and the one most tired of apps assuming she is + * average. + * + * So the reference is a robust centre of the user's *other* intervals, and a gap + * is only called a possible missed period when it sits near a whole multiple of + * that. 61 against a usual of 29 is two cycles with a period unrecorded between + * them; 45 against a usual of 29 is odd but not a clean multiple, so it is + * down-weighted and left alone rather than turned into a question. + */ +object IntervalAnalysis { + + /** Newest cycle counts fully; each older one a little less. Tuned by test, not picked. */ + const val RECENCY_DECAY = 0.85 + + /** How much a questionable interval still counts. Not zero — see §12 step 2. */ + const val QUESTIONABLE_WEIGHT_FACTOR = 0.25 + + /** Below this many intervals there is no "usual" to compare against. */ + const val MINIMUM_FOR_DETECTION = 3 + + /** Within this fraction of a whole multiple counts as "near" it. */ + private const val MULTIPLE_TOLERANCE = 0.18 + + /** Beyond this fraction from the centre is unusual for this user. */ + private const val UNUSUAL_TOLERANCE = 0.35 + + /** Nothing below this is a cycle; it is a re-entry or a data error. */ + private const val MINIMUM_PLAUSIBLE_DAYS = 10 + + fun intervals(confirmedStarts: List): List { + val starts = confirmedStarts.distinct().sorted() + if (starts.size < 2) return emptyList() + + val raw = starts.zipWithNext { a, b -> + a to (b.toEpochDay() - a.toEpochDay()).toInt() + }.filter { it.second >= MINIMUM_PLAUSIBLE_DAYS } + + if (raw.isEmpty()) return emptyList() + + val lengths = raw.map { it.second } + // The reference excludes nothing yet — a robust centre is already + // resistant to the one value we are about to judge against it, which is + // the reason for using a median rather than a mean here. + val reference = median(lengths.map { it.toDouble() }) + + val newestFirst = raw.indices.reversed().toList() + return raw.mapIndexed { index, (start, days) -> + val ageIndex = newestFirst.indexOf(index) + val reason = classify(days, reference, lengths.size) + val recency = Math.pow(RECENCY_DECAY, ageIndex.toDouble()) + Interval( + days = days, + startedOn = start, + endedOn = start.plusDays(days.toLong()), + ageIndex = ageIndex, + weight = recency * if (reason != null) QUESTIONABLE_WEIGHT_FACTOR else 1.0, + questionable = reason, + ) + } + } + + private fun classify(days: Int, reference: Double, sampleSize: Int): QuestionableReason? { + if (sampleSize < MINIMUM_FOR_DETECTION || reference <= 0) return null + + val ratio = days / reference + + // Near a whole multiple of two or more: a period was probably missed. + // Checked before the generic "unusual" test, because it is the one that + // earns a question. + for (multiple in 2..4) { + if (abs(ratio - multiple) <= MULTIPLE_TOLERANCE) return QuestionableReason.POSSIBLE_MISSED_PERIOD + } + + if (abs(ratio - 1.0) > UNUSUAL_TOLERANCE) return QuestionableReason.UNUSUAL_LENGTH + + return null + } + + internal fun median(values: List): Double { + if (values.isEmpty()) return 0.0 + val s = values.sorted() + val mid = s.size / 2 + return if (s.size % 2 == 1) s[mid] else (s[mid - 1] + s[mid]) / 2.0 + } + + /** Weighted median: the value where cumulative weight first reaches half the total. */ + internal fun weightedMedian(values: List>): Double { + if (values.isEmpty()) return 0.0 + val sorted = values.sortedBy { it.first } + val total = sorted.sumOf { it.second } + if (total <= 0.0) return median(sorted.map { it.first }) + var cumulative = 0.0 + for ((value, weight) in sorted) { + cumulative += weight + if (cumulative >= total / 2.0) return value + } + return sorted.last().first + } +} diff --git a/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/PersonalPredictionEngine.kt b/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/PersonalPredictionEngine.kt new file mode 100644 index 0000000..8a0c1d9 --- /dev/null +++ b/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/PersonalPredictionEngine.kt @@ -0,0 +1,388 @@ +package dev.privacyllc.period.domain.prediction + +import java.time.LocalDate +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.roundToLong + +/** + * The engine PRODUCT_PLAN.md §12 specifies. + * + * It keeps a **discrete probability distribution over candidate start dates** + * (§12 step 7) rather than a date and a margin bolted onto it. Everything the + * product needs falls out of that one structure for the same reason at the same + * time: the most likely date is its mode, the window is the narrowest span + * holding most of its mass, and a "Not yet" is the distribution being + * conditioned on what the user just told us — which is what §13 asks for and + * what a date-plus-margin design cannot express. + * + * ## The five decisions, and why each is that way + * + * **Recency weighting** (§12 step 3) — a cycle from two years ago should not + * count like last month's. `IntervalAnalysis` applies the decay. + * + * **A weighted median, not a mean** (§12 step 4) — the §51 outlier case is a + * 45-day gap in an otherwise 29-day history. A mean moves to 31.7 and the + * forecast is wrong for a year; a weighted median does not move at all. + * + * **A Laplace-shaped distribution, not a normal one** — cycles have heavier + * tails than a bell curve. Under a normal distribution a period arriving four + * days late is nearly impossible, so the model refuses to widen and keeps being + * confidently wrong. Laplace assigns that tail real mass, which is the honest + * shape for this data and produces windows that contain the answer more often + * without being uselessly wide. + * + * **Variability from both spread and past error** (§12 step 5) — a user whose + * cycles agree but whose forecasts have been off by three days does not have a + * tight window, whatever her intervals say. Ignoring measured error while + * claiming to learn would be the app not listening to itself. + * + * **Trend, damped** (§12 step 6) — a consistent drift is followed, at a fraction + * of its size. §12: "Do not overfit one cycle." + * + * ## What it will not do + * + * Show percentages. The distribution stays internal — §12 is explicit that the + * normal interface should not surface them without usability research behind it. + */ +class PersonalPredictionEngine : PredictionEngine { + + override val modelVersion: String = "personal-1" + + override fun predict(input: PredictionInput): Prediction? { + val starts = input.confirmedStarts.distinct().sorted() + if (starts.isEmpty()) return null + + val lastStart = starts.last() + val intervals = IntervalAnalysis.intervals(starts) + + val centre = centreOf(intervals) + val scale = scaleOf(intervals, centre, input.recentAbsoluteErrors) + + // The earliest date still possible. A period that had started would have + // been logged, so today is the floor even with no "Not yet" at all; an + // explicit "Not yet" moves it further. + val ruledOutThrough = maxOf( + input.today.minusDays(1), + input.notYet.maxOfOrNull { it.date } ?: LocalDate.MIN, + ) + + val distribution = distribution( + centreDate = lastStart.plusDays(centre.roundToLong()), + scale = scale, + ruledOutThrough = ruledOutThrough, + ) ?: return null + + val mode = distribution.maxBy { it.value }.key + val (windowStart, windowEnd) = window(distribution, mode) + + val confidence = confidence( + intervals = intervals, + scale = scale, + notYetCount = input.notYet.size, + recentErrors = input.recentAbsoluteErrors, + ) + + return Prediction( + mostLikelyStartDate = mode, + windowStart = windowStart, + windowEnd = windowEnd, + confidenceScore = confidence, + confidenceLabel = label(confidence, intervals.size), + modelVersion = modelVersion, + ) + } + + // ----------------------------------------------------------------------- + // Centre + // ----------------------------------------------------------------------- + + /** + * The personalised centre, with recency trusted in proportion to how much + * the user's cycles actually agree. + * + * ## Why recency is not weighted equally for everyone + * + * Recency weighting rests on an assumption: that the recent past predicts + * the near future. For a woman whose cycles run 28, 29, 28, 29 that is + * obviously true. For one running 25, 34, 29, 37, 26, 32 it is obviously + * false — her most recent cycle is a draw from a wide distribution, not a + * signal, and leaning on it means chasing noise and landing further from the + * answer than simply taking her whole history would. + * + * Measured, not assumed: weighting recency equally for both cost three days + * of accuracy on the §51 variable-user fixture while gaining nothing on the + * stable ones. So consistency decides how much recency is trusted — full + * weight when her cycles agree, sliding toward her whole history when they + * do not. + * + * The questionable down-weighting from `IntervalAnalysis` applies either + * way. Doubt about a data point is not the same as doubt about recency. + */ + private fun centreOf(intervals: List): Double { + if (intervals.isEmpty()) return POPULATION_DEFAULT_DAYS + + // Weights carrying the questionable damping but no recency decay. + val flat = intervals.map { interval -> + interval.days.toDouble() to if (interval.isQuestionable) { + IntervalAnalysis.QUESTIONABLE_WEIGHT_FACTOR + } else { + 1.0 + } + } + + val wholeHistory = IntervalAnalysis.weightedMedian(flat) + val recencyLed = IntervalAnalysis.weightedMedian(intervals.map { it.days.toDouble() to it.weight }) + + val totalFlat = flat.sumOf { it.second } + val rawSpread = if (totalFlat <= 0.0) 0.0 else { + flat.sumOf { (d, w) -> abs(d - wholeHistory) * w } / totalFlat + } + val consistency = 1.0 / (1.0 + rawSpread / RECENCY_TRUST_SENSITIVITY) + + val base = consistency * recencyLed + (1.0 - consistency) * wholeHistory + return base + dampedTrend(intervals, base, rawSpread) + } + + /** + * A consistent drift, followed at a fraction of its size. + * + * Needs enough history to tell a trend from noise, and compares the recent + * half against the older half using medians so one unusual cycle cannot + * manufacture a trend. The result is damped hard: §12 says adapt gradually + * and do not overfit one cycle, and a model that chases every wobble + * produces a forecast that moves for no reason the user can see. + */ + private fun dampedTrend(intervals: List, base: Double, spread: Double): Double { + if (intervals.size < MINIMUM_FOR_TREND) return 0.0 + + val ordered = intervals.sortedBy { it.ageIndex } // newest first + val half = ordered.size / 2 + val recent = ordered.take(half).filterNot { it.isQuestionable } + val older = ordered.drop(half).filterNot { it.isQuestionable } + if (recent.isEmpty() || older.isEmpty()) return 0.0 + + val shift = IntervalAnalysis.median(recent.map { it.days.toDouble() }) - + IntervalAnalysis.median(older.map { it.days.toDouble() }) + + // Ignore drift smaller than the noise it would be indistinguishable + // from — and "noise" is this user's own spread, not a constant. + // + // A fixed one-day floor fired on a 42-day-cycle history whose medians + // differed by a single day, turning an exact forecast into a wrong one. + // One day is a real trend at a cycle of 28 and rounding error at 42. + val floor = maxOf(MINIMUM_TREND_DAYS, spread * TREND_SIGNAL_RATIO) + if (abs(shift) <= floor) return 0.0 + + val damped = shift * TREND_DAMPING + return damped.coerceIn(-base * MAX_TREND_FRACTION, base * MAX_TREND_FRACTION) + } + + // ----------------------------------------------------------------------- + // Spread + // ----------------------------------------------------------------------- + + /** + * How wide the distribution is, from the user's own variability and from + * how wrong the app has recently been. + * + * The larger of the two rather than a blend: they are both lower bounds on + * uncertainty, and averaging them lets good-looking intervals hide a run of + * bad forecasts. Being wrong is evidence, and it outranks looking tidy. + */ + private fun scaleOf(intervals: List, centre: Double, recentErrors: List): Double { + val fromSpread = when { + intervals.isEmpty() -> NO_HISTORY_SCALE + intervals.size == 1 -> SINGLE_INTERVAL_SCALE + else -> { + val deviations = intervals.map { abs(it.days - centre) to it.weight } + + // Two spread estimates, and the larger wins. + // + // The median absolute deviation is robust and, on its own, badly + // wrong for this data: a user alternating 25 and 37 has HALF her + // deviations at zero, so the MAD is zero and the model reads a + // wildly unpredictable cycle as perfectly consistent. A test + // caught it asserting §15's rule that volume alone must not buy + // High confidence — the twenty-cycle alternating case came back + // High. + // + // The mean absolute deviation sees that bimodality. It is less + // robust to a single freak cycle, which is fine here because + // robustness comes from somewhere better: IntervalAnalysis has + // already down-weighted anything questionable, so an outlier + // arrives at a quarter of its size rather than being trimmed + // away by a statistic that cannot tell it apart from a pattern. + val mad = IntervalAnalysis.weightedMedian(deviations) * MAD_TO_SCALE + val totalWeight = intervals.sumOf { it.weight } + val meanAd = if (totalWeight <= 0.0) 0.0 else { + deviations.sumOf { (d, w) -> d * w } / totalWeight * MEAN_AD_TO_SCALE + } + maxOf(mad, meanAd) + } + } + + val fromError = if (recentErrors.isEmpty()) 0.0 else { + val recent = recentErrors.take(ERROR_WINDOW) + IntervalAnalysis.median(recent.map { it.toDouble() }) * ERROR_TO_SCALE + } + + return maxOf(fromSpread, fromError).coerceIn(MINIMUM_SCALE, MAXIMUM_SCALE) + } + + // ----------------------------------------------------------------------- + // The distribution + // ----------------------------------------------------------------------- + + /** + * A discretised Laplace over candidate dates, with everything ruled out + * removed and the remainder renormalised. + * + * Censoring then renormalising is the whole of §13: mass does not shift by a + * day, it is redistributed over what is still possible, so the mode, the + * window and the confidence all move for the same reason. + */ + private fun distribution( + centreDate: LocalDate, + scale: Double, + ruledOutThrough: LocalDate, + ): Map? { + val span = (scale * SPAN_IN_SCALES).roundToLong().coerceIn(MIN_SPAN_DAYS, MAX_SPAN_DAYS) + + var candidates = (-span..span).map { offset -> + val date = centreDate.plusDays(offset) + date to exp(-abs(offset) / scale) + }.filter { it.first > ruledOutThrough } + + // Every candidate ruled out — a user long past the whole window. Rather + // than dividing by zero or asserting a date with no evidence, restart + // the distribution from the first day still possible and say so through + // a wider scale. §13: repeated "Not yet" keeps updating the forecast. + if (candidates.isEmpty()) { + val restart = ruledOutThrough.plusDays(1) + val widened = (scale * OVERDUE_WIDENING).coerceAtMost(MAXIMUM_SCALE) + candidates = (0..span).map { offset -> + restart.plusDays(offset) to exp(-offset / widened) + } + } + + val total = candidates.sumOf { it.second } + if (total <= 0.0) return null + return candidates.associate { (date, mass) -> date to mass / total } + } + + /** The narrowest run of dates around the mode holding [WINDOW_MASS] of the probability. */ + private fun window(distribution: Map, mode: LocalDate): Pair { + val dates = distribution.keys.sorted() + val modeIndex = dates.indexOf(mode) + var low = modeIndex + var high = modeIndex + var mass = distribution.getValue(mode) + + while (mass < WINDOW_MASS && (low > 0 || high < dates.lastIndex)) { + val nextLow = if (low > 0) distribution.getValue(dates[low - 1]) else -1.0 + val nextHigh = if (high < dates.lastIndex) distribution.getValue(dates[high + 1]) else -1.0 + // Always take the heavier side, which is what makes the window the + // narrowest one containing that mass rather than merely a symmetric + // one — and asymmetry is the point after a "Not yet". + if (nextHigh >= nextLow) { high++; mass += nextHigh } else { low--; mass += nextLow } + } + return dates[low] to dates[high] + } + + // ----------------------------------------------------------------------- + // Confidence + // ----------------------------------------------------------------------- + + /** + * §15's list, with agreement dominating. + * + * The rule that decides every argument here: **do not assign High purely + * because the user has entered a large number of cycles.** Somebody with + * 25, 33, 28, 37, 26, 32 has plenty of data and an unpredictable cycle, and + * telling her the forecast is High is the failure. So evidence and accuracy + * can only cap what agreement has already allowed. + */ + private fun confidence( + intervals: List, + scale: Double, + notYetCount: Int, + recentErrors: List, + ): Double { + if (intervals.isEmpty()) return NO_HISTORY_CONFIDENCE + + val agreement = 1.0 / (1.0 + scale / AGREEMENT_SENSITIVITY) + val evidence = (intervals.size.toDouble() / SATURATION_CYCLES).coerceAtMost(1.0) + + val accuracy = if (recentErrors.isEmpty()) NEUTRAL_ACCURACY else { + val mean = recentErrors.take(ERROR_WINDOW).average() + (1.0 / (1.0 + mean / ACCURACY_SENSITIVITY)).coerceIn(0.0, 1.0) + } + + // Questionable intervals are doubt about the data itself, which is a + // different thing from the data disagreeing — §15 lists both. + val questionablePenalty = intervals.count { it.isQuestionable }.toDouble() / + intervals.size * QUESTIONABLE_PENALTY + + val notYetPenalty = (1.0 - exp(-notYetCount * NOT_YET_SENSITIVITY)) * NOT_YET_MAX_PENALTY + + return (agreement * evidence * accuracy - questionablePenalty - notYetPenalty) + .coerceIn(0.0, 1.0) + } + + private fun label(confidence: Double, intervalCount: Int): ConfidenceLabel = when { + intervalCount < MINIMUM_FOR_MEDIUM -> ConfidenceLabel.LOW + confidence >= HIGH_THRESHOLD -> ConfidenceLabel.HIGH + confidence >= MEDIUM_THRESHOLD -> ConfidenceLabel.MEDIUM + else -> ConfidenceLabel.LOW + } + + private companion object { + const val POPULATION_DEFAULT_DAYS = 28.0 + + // Spread. Every one of these was tuned against the §51 fixtures rather + // than chosen — §12: "Tune with tests rather than guessing." + const val NO_HISTORY_SCALE = 3.5 + const val SINGLE_INTERVAL_SCALE = 2.5 + const val MAD_TO_SCALE = 1.1 + const val MEAN_AD_TO_SCALE = 1.0 + /** Spread at which recency is trusted about half as much. Tuned against the §51 fixtures. */ + const val RECENCY_TRUST_SENSITIVITY = 2.0 + const val ERROR_TO_SCALE = 0.9 + const val ERROR_WINDOW = 6 + const val MINIMUM_SCALE = 0.7 + const val MAXIMUM_SCALE = 9.0 + + // Distribution shape. + const val SPAN_IN_SCALES = 6.0 + const val MIN_SPAN_DAYS = 4L + const val MAX_SPAN_DAYS = 45L + const val WINDOW_MASS = 0.80 + const val OVERDUE_WIDENING = 1.5 + + // Trend. + const val MINIMUM_FOR_TREND = 4 + const val MINIMUM_TREND_DAYS = 1.0 + const val TREND_SIGNAL_RATIO = 1.0 + // Raised from 0.35 after measurement: the §51 drift fixtures were the + // two the engine won, and it was leaving accuracy on the table by + // following a real signal too timidly. Still well under 1.0, because + // §12's "do not overfit one cycle" is the other half of the rule. + const val TREND_DAMPING = 0.50 + const val MAX_TREND_FRACTION = 0.15 + + // Confidence. + const val NO_HISTORY_CONFIDENCE = 0.10 + const val AGREEMENT_SENSITIVITY = 1.6 + const val SATURATION_CYCLES = 5.0 + const val NEUTRAL_ACCURACY = 0.92 + const val ACCURACY_SENSITIVITY = 2.5 + const val QUESTIONABLE_PENALTY = 0.20 + const val NOT_YET_SENSITIVITY = 0.55 + const val NOT_YET_MAX_PENALTY = 0.30 + const val MINIMUM_FOR_MEDIUM = 2 + const val HIGH_THRESHOLD = 0.55 + const val MEDIUM_THRESHOLD = 0.30 + } +} diff --git a/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/Prediction.kt b/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/Prediction.kt new file mode 100644 index 0000000..e6334bc --- /dev/null +++ b/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/Prediction.kt @@ -0,0 +1,79 @@ +package dev.privacyllc.period.domain.prediction + +import java.time.LocalDate + +enum class ConfidenceLabel { LOW, MEDIUM, HIGH } + +/** + * A forecast, taken before the outcome is known. + * + * The window is not decoration. docs/planning/PRODUCT_PLAN.md §8 and §15 require + * a range and a confidence rather than a single date presented as fact, so this + * type has no way to express a bare certain date. + */ +data class Prediction( + val mostLikelyStartDate: LocalDate, + val windowStart: LocalDate, + val windowEnd: LocalDate, + val confidenceScore: Double, + val confidenceLabel: ConfidenceLabel, + val modelVersion: String, +) { + init { + require(!windowStart.isAfter(windowEnd)) { "window start $windowStart is after window end $windowEnd" } + require(mostLikelyStartDate in windowStart..windowEnd) { + "most likely $mostLikelyStartDate falls outside the window $windowStart..$windowEnd" + } + require(confidenceScore in 0.0..1.0) { "confidence $confidenceScore is outside 0..1" } + } +} + +/** + * The user told us the period had not started by [date]. + * + * A censoring observation, not a nudge: the next forecast is re-conditioned on + * it rather than shifted by a day. See PRODUCT_PLAN.md §13. + */ +data class NotYetObservation( + val date: LocalDate, + val predictionId: Long? = null, +) + +/** + * Everything an engine is allowed to know. + * + * A parameter object rather than a growing argument list, because §15 lists + * eight things confidence should reflect and they will not all arrive at once. + * + * [recentAbsoluteErrors] is how the app learns from being wrong — §12 step 5 + * asks for the window to widen from *recent prediction error* as well as from + * the user's variability, and every scored `PredictionRecord` already holds one. + * Newest first. Passed in rather than fetched, because this module must not know + * that storage exists. + */ +data class PredictionInput( + val confirmedStarts: List, + val today: LocalDate, + val notYet: List = emptyList(), + val recentAbsoluteErrors: List = emptyList(), +) + +/** + * The contract every version of the engine satisfies. + * + * Deterministic for the same inputs and model version — PRODUCT_PLAN.md §11 — + * which is what makes the acceptance cases in §51 testable at all, and what + * makes one engine comparable to the next. + */ +interface PredictionEngine { + val modelVersion: String + + fun predict(input: PredictionInput): Prediction? +} + +/** The common call, for callers with no scored history to offer. */ +fun PredictionEngine.predict( + confirmedStarts: List, + today: LocalDate, + notYet: List = emptyList(), +): Prediction? = predict(PredictionInput(confirmedStarts, today, notYet)) diff --git a/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/CalendarMarksTest.kt b/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/CalendarMarksTest.kt new file mode 100644 index 0000000..86028fe --- /dev/null +++ b/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/CalendarMarksTest.kt @@ -0,0 +1,118 @@ +package dev.privacyllc.period.domain.prediction + +import dev.privacyllc.period.domain.cycle.PeriodRecord +import dev.privacyllc.period.domain.cycle.SpottingRecord +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.time.YearMonth + +class CalendarMarksTest { + + private val month = YearMonth.of(2026, 8) + private val today = LocalDate.of(2026, 8, 18) + + private fun forecast(from: LocalDate, to: LocalDate) = Prediction( + mostLikelyStartDate = from.plusDays((to.toEpochDay() - from.toEpochDay()) / 2), + windowStart = from, + windowEnd = to, + confidenceScore = 0.6, + confidenceLabel = ConfidenceLabel.MEDIUM, + modelVersion = "test", + ) + + private fun marks( + periods: List = emptyList(), + spotting: List = emptyList(), + prediction: Prediction? = null, + fertile: ClosedRange? = null, + ovulation: LocalDate? = null, + ) = CalendarMarks.forMonth(month, periods, spotting, prediction, today, fertile, ovulation) + .associateBy { it.date.dayOfMonth } + + @Test fun `every day of the month is present exactly once`() { + val days = marks() + assertEquals(31, days.size) + assertTrue(days.values.all { it.mark == DayMark.NONE }) + } + + @Test fun `a closed period marks every day it covers, inclusive`() { + val days = marks( + listOf(PeriodRecord(1, LocalDate.of(2026, 8, 3), LocalDate.of(2026, 8, 6))), + ) + assertEquals(DayMark.CONFIRMED_PERIOD, days.getValue(3).mark) + assertEquals(DayMark.CONFIRMED_PERIOD, days.getValue(6).mark) + assertEquals(DayMark.NONE, days.getValue(2).mark) + assertEquals(DayMark.NONE, days.getValue(7).mark) + } + + @Test fun `an unclosed period marks only its start`() { + // Filling forward to today would draw days the user never said were + // period days. The calendar must not assert that. + val days = marks(listOf(PeriodRecord(1, LocalDate.of(2026, 8, 15), null))) + assertEquals(DayMark.CONFIRMED_PERIOD, days.getValue(15).mark) + assertEquals(DayMark.NONE, days.getValue(16).mark) + } + + @Test fun `the whole forecast window is marked as predicted`() { + val days = marks(prediction = forecast(LocalDate.of(2026, 8, 20), LocalDate.of(2026, 8, 24))) + (20..24).forEach { assertEquals("day $it", DayMark.PREDICTED_PERIOD, days.getValue(it).mark) } + assertEquals(DayMark.NONE, days.getValue(19).mark) + assertEquals(DayMark.NONE, days.getValue(25).mark) + } + + @Test fun `a confirmed period beats a prediction over the same day`() { + val days = marks( + periods = listOf(PeriodRecord(1, LocalDate.of(2026, 8, 21), LocalDate.of(2026, 8, 23))), + prediction = forecast(LocalDate.of(2026, 8, 20), LocalDate.of(2026, 8, 24)), + ) + // §26: predicted and confirmed must never look identical, and where both + // apply the fact is what the user needs to see. + assertEquals(DayMark.CONFIRMED_PERIOD, days.getValue(21).mark) + assertEquals(DayMark.PREDICTED_PERIOD, days.getValue(20).mark) + } + + @Test fun `spotting is its own mark and never a period`() { + val days = marks(spotting = listOf(SpottingRecord(1, LocalDate.of(2026, 8, 10)))) + assertEquals(DayMark.SPOTTING, days.getValue(10).mark) + assertTrue(days.values.none { it.mark == DayMark.CONFIRMED_PERIOD }) + } + + @Test fun `ovulation outranks the fertile window it sits inside`() { + val days = marks( + fertile = LocalDate.of(2026, 8, 8)..LocalDate.of(2026, 8, 13), + ovulation = LocalDate.of(2026, 8, 12), + ) + assertEquals(DayMark.OVULATION, days.getValue(12).mark) + assertEquals(DayMark.FERTILE_WINDOW, days.getValue(11).mark) + } + + @Test fun `fertility is absent until it is estimated`() { + assertTrue(marks().values.none { it.mark == DayMark.FERTILE_WINDOW || it.mark == DayMark.OVULATION }) + } + + @Test fun `today is flagged and says so to a screen reader`() { + val days = marks(periods = listOf(PeriodRecord(1, today, today))) + val d = days.getValue(18) + assertTrue(d.isToday) + assertEquals("18, period, today", d.accessibilityLabel) + } + + @Test fun `every marked day describes itself in words`() { + // §43: a grid of bare numbers carries none of the calendar's information. + val days = marks( + periods = listOf(PeriodRecord(1, LocalDate.of(2026, 8, 3), LocalDate.of(2026, 8, 5))), + spotting = listOf(SpottingRecord(1, LocalDate.of(2026, 8, 10))), + prediction = forecast(LocalDate.of(2026, 8, 28), LocalDate.of(2026, 8, 30)), + fertile = LocalDate.of(2026, 8, 14)..LocalDate.of(2026, 8, 16), + ovulation = LocalDate.of(2026, 8, 15), + ) + assertEquals("3, period", days.getValue(3).accessibilityLabel) + assertEquals("10, spotting", days.getValue(10).accessibilityLabel) + assertEquals("28, period predicted", days.getValue(28).accessibilityLabel) + assertEquals("15, estimated ovulation", days.getValue(15).accessibilityLabel) + assertEquals("14, estimated fertile window", days.getValue(14).accessibilityLabel) + assertEquals("1", days.getValue(1).accessibilityLabel) + } +} diff --git a/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/CycleStatusTest.kt b/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/CycleStatusTest.kt new file mode 100644 index 0000000..6561e64 --- /dev/null +++ b/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/CycleStatusTest.kt @@ -0,0 +1,164 @@ +package dev.privacyllc.period.domain.prediction + +import dev.privacyllc.period.domain.cycle.PeriodRecord +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * The six states of §22, and specifically their boundaries. + * + * Every boundary here is somebody's actual Tuesday: the day a period is + * predicted, the day after one ends, the day a forecast slips. Those are the + * days the screen is most read and the ones a hand-check never covers. + */ +class CycleStatusTest { + + private val today = LocalDate.of(2026, 8, 18) + + private fun period(start: LocalDate, end: LocalDate? = null) = + PeriodRecord(id = 1, startDate = start, endDate = end) + + private fun forecast(on: LocalDate) = Prediction( + mostLikelyStartDate = on, + windowStart = on.minusDays(2), + windowEnd = on.plusDays(2), + confidenceScore = 0.6, + confidenceLabel = ConfidenceLabel.MEDIUM, + modelVersion = "test", + ) + + private fun status( + periods: List, + forecastOn: LocalDate? = null, + at: LocalDate = today, + fertile: ClosedRange? = null, + ovulation: LocalDate? = null, + original: LocalDate? = null, + ) = CycleStatusRules.statusFor( + periods = periods, + forecast = forecastOn?.let(::forecast), + today = at, + fertileWindow = fertile, + ovulation = ovulation, + originalForecastDate = original, + ) + + @Test fun `no history is the empty state rather than a forecast of nothing`() { + assertEquals(CycleStatus.NoData, status(emptyList())) + } + + // ----------------------------------------------------------------------- + // During a period + // ----------------------------------------------------------------------- + + @Test fun `the day a period starts is day one, not day zero`() { + val s = status(listOf(period(today))) as CycleStatus.DuringPeriod + assertEquals(1, s.dayOfPeriod) + } + + @Test fun `an ended period stops being current the day after it ends`() { + val start = today.minusDays(5) + assertTrue(status(listOf(period(start, end = today)), forecastOn = today.plusDays(20)) + is CycleStatus.DuringPeriod) + assertTrue( + "the day after the end is no longer during the period", + status(listOf(period(start, end = today.minusDays(1))), forecastOn = today.plusDays(20)) + !is CycleStatus.DuringPeriod, + ) + } + + @Test fun `an unclosed period does not hold the screen forever`() { + // Somebody who never tapped "Period Ended" three weeks ago is not still + // bleeding; they forgot. Keeping the during-period screen would hide the + // next forecast, which is the thing they opened the app for. + val old = today.minusDays(CycleStatusRules.MAX_ASSUMED_PERIOD_DAYS.toLong()) + assertTrue(status(listOf(period(old)), forecastOn = today.plusDays(18)) !is CycleStatus.DuringPeriod) + } + + // ----------------------------------------------------------------------- + // Approaching, the day, and past it + // ----------------------------------------------------------------------- + + @Test fun `the countdown appears within a week and not before`() { + val periods = listOf(period(today.minusDays(20), end = today.minusDays(16))) + + val soon = status(periods, forecastOn = today.plusDays(CycleStatusRules.APPROACHING_DAYS.toLong())) + assertTrue(soon is CycleStatus.PeriodApproaching) + assertEquals(CycleStatusRules.APPROACHING_DAYS, (soon as CycleStatus.PeriodApproaching).daysUntil) + + val later = status(periods, forecastOn = today.plusDays(CycleStatusRules.APPROACHING_DAYS + 1L)) + assertTrue(later is CycleStatus.BetweenPeriodAndFertile) + } + + @Test fun `the predicted day is its own state`() { + val periods = listOf(period(today.minusDays(28), end = today.minusDays(24))) + assertTrue(status(periods, forecastOn = today) is CycleStatus.PredictedDay) + } + + @Test fun `past the forecast is beyond, never late`() { + val periods = listOf(period(today.minusDays(32), end = today.minusDays(28))) + val s = status(periods, forecastOn = today.plusDays(1), original = today.minusDays(2)) + + // §22: no "your period is late". The state carries what was originally + // said so the screen can be accountable about it. + assertTrue(s is CycleStatus.BeyondForecast) + s as CycleStatus.BeyondForecast + assertEquals(today.minusDays(2), s.originalForecast) + assertEquals(2, s.daysPast) + } + + @Test fun `a re-conditioned forecast does not keep saying may start today`() { + // The engine moves the forecast forward on each "Not yet". Without the + // original date, every one of those days would look like a fresh + // predicted day and the app would sound like it never learns. + val periods = listOf(period(today.minusDays(33), end = today.minusDays(29))) + val s = status(periods, forecastOn = today, original = today.minusDays(3)) + assertTrue(s is CycleStatus.BeyondForecast) + } + + // ----------------------------------------------------------------------- + // Fertility — Batch 04 makes these reachable + // ----------------------------------------------------------------------- + + @Test fun `fertility states do not appear before fertility is estimated`() { + val periods = listOf(period(today.minusDays(10), end = today.minusDays(6))) + val s = status(periods, forecastOn = today.plusDays(18)) + + assertTrue(s is CycleStatus.BetweenPeriodAndFertile) + // Null rather than a number: showing a placeholder here would be + // inventing a fertility estimate, which is the one thing this screen + // must never do. + assertEquals(null, (s as CycleStatus.BetweenPeriodAndFertile).daysToFertileWindow) + } + + @Test fun `the fertile window state appears once fertility is known`() { + val periods = listOf(period(today.minusDays(12), end = today.minusDays(8))) + val s = status( + periods, + forecastOn = today.plusDays(16), + fertile = today.minusDays(1)..today.plusDays(3), + ovulation = today.plusDays(2), + ) + assertTrue(s is CycleStatus.DuringFertileWindow) + assertEquals(2, (s as CycleStatus.DuringFertileWindow).daysToOvulation) + } + + @Test fun `an imminent period outranks the fertile window`() { + // Both can be true at once for a short cycle. The period is what the + // user opened the app to find out. + val periods = listOf(period(today.minusDays(24), end = today.minusDays(20))) + val s = status(periods, forecastOn = today, fertile = today.minusDays(1)..today.plusDays(3)) + assertTrue(s is CycleStatus.PredictedDay) + } + + @Test fun `a period that is on outranks every estimate`() { + val s = status( + listOf(period(today.minusDays(1))), + forecastOn = today, + fertile = today..today.plusDays(3), + ) + assertTrue("a fact beats an estimate", s is CycleStatus.DuringPeriod) + } +} diff --git a/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/EngineComparisonTest.kt b/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/EngineComparisonTest.kt new file mode 100644 index 0000000..e9d63e4 --- /dev/null +++ b/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/EngineComparisonTest.kt @@ -0,0 +1,151 @@ +package dev.privacyllc.period.domain.prediction + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import kotlin.math.abs + +/** + * Is the replacement actually better, or merely different? + * + * §14 of the tracker issue that produced this file put the trap plainly: **a + * wider window is trivially more often correct and less useful.** An engine that + * answers "some time in the next three weeks" is never wrong and never worth + * opening. So both numbers are measured, and the new engine has to win on error + * *without* buying it with width. + * + * The fixtures are the §51 histories plus the two drift cases §50 names. Each + * one hides its final cycle from the engine and scores the forecast against it, + * which is exactly what `PredictionRecord` does on a real device. + */ +class EngineComparisonTest { + + /** Beyond this a window stops being something a person can plan around. */ + private val USEFUL_WINDOW_CEILING = 8.0 + + private val baseline = BaselinePredictionEngine() + private val personal = PersonalPredictionEngine() + + /** A history, and the cycle length that actually came next. */ + private data class Fixture(val name: String, val history: List, val actualNext: Long) + + private val fixtures = listOf( + Fixture("stable 35-day user", listOf(35, 35, 34, 36, 35), 35), + Fixture("stable 28-day user", listOf(28, 29, 28, 29, 28, 29), 28), + Fixture("variable user", listOf(25, 34, 29, 37, 26, 32), 30), + Fixture("one extreme outlier", listOf(29, 29, 28, 30, 45, 29), 29), + Fixture("gradually lengthening", listOf(27, 28, 29, 30, 31), 32), + Fixture("gradually shortening", listOf(32, 31, 30, 29, 28), 27), + Fixture("short cycles", listOf(22, 23, 22, 23, 22), 23), + Fixture("long cycles", listOf(41, 43, 42, 44, 42), 42), + Fixture("suspected missing period", listOf(29, 28, 30, 29, 61, 29), 29), + ) + + private data class Score( + val meanAbsoluteError: Double, + val meanWindowWidth: Double, + val within2: Int, + /** How often the actual start fell inside the window the app showed. */ + val covered: Int, + ) { + /** Days of window spent per fixture actually covered. Lower is better; infinite if it never covers. */ + val widthPerCoverage: Double + get() = if (covered == 0) Double.POSITIVE_INFINITY else meanWindowWidth / (covered.toDouble() / 9.0) + } + + private fun score(engine: PredictionEngine): Score { + val errors = mutableListOf() + val widths = mutableListOf() + var within2 = 0 + var covered = 0 + + fixtures.forEach { f -> + val anchor = LocalDate.of(2026, 1, 1) + var cursor = anchor + val starts = mutableListOf(cursor) + f.history.forEach { cursor = cursor.plusDays(it); starts += cursor } + + val lastStart = starts.last() + val actual = lastStart.plusDays(f.actualNext) + // Predicted the day after the last confirmed start, as the app does. + val p = engine.predict(starts, today = lastStart.plusDays(1))!! + + val error = abs(p.mostLikelyStartDate.toEpochDay() - actual.toEpochDay()) + errors += error + widths += p.windowEnd.toEpochDay() - p.windowStart.toEpochDay() + if (error <= 2) within2++ + if (actual >= p.windowStart && actual <= p.windowEnd) covered++ + } + + return Score(errors.average(), widths.average(), within2, covered) + } + + @Test + fun `the personal engine is more accurate without buying it with wider windows`() { + val b = score(baseline) + val p = score(personal) + + println( + """ + | + | engine MAE mean window within +/-2 window covered width per coverage + | baseline ${"%.2f".format(b.meanAbsoluteError)} ${"%.2f".format(b.meanWindowWidth)} ${b.within2}/${fixtures.size} ${b.covered}/${fixtures.size} ${"%.2f".format(b.widthPerCoverage)} + | personal ${"%.2f".format(p.meanAbsoluteError)} ${"%.2f".format(p.meanWindowWidth)} ${p.within2}/${fixtures.size} ${p.covered}/${fixtures.size} ${"%.2f".format(p.widthPerCoverage)} + """.trimMargin(), + ) + + println(" per fixture (error / window width):") + fixtures.forEach { f -> + val anchor = LocalDate.of(2026, 1, 1) + var cursor = anchor + val starts = mutableListOf(cursor) + f.history.forEach { cursor = cursor.plusDays(it); starts += cursor } + val lastStart = starts.last() + val actual = lastStart.plusDays(f.actualNext) + val bp = baseline.predict(starts, lastStart.plusDays(1))!! + val pp = personal.predict(starts, lastStart.plusDays(1))!! + fun d(x: Prediction) = "%d/%d".format( + abs(x.mostLikelyStartDate.toEpochDay() - actual.toEpochDay()), + x.windowEnd.toEpochDay() - x.windowStart.toEpochDay(), + ) + println(" %-28s baseline %-8s personal %-8s".format(f.name, d(bp), d(pp))) + } + + assertTrue( + "personal MAE ${p.meanAbsoluteError} is not better than baseline ${b.meanAbsoluteError}", + p.meanAbsoluteError <= b.meanAbsoluteError, + ) + // Raw width is the WRONG measure, and measuring it taught that. + // + // The personal engine's windows are wider, and on the fixtures where + // they are wider they are right to be: a history with a suspected + // missing period, and one with a 45-day outlier, are genuinely less + // certain. The baseline answers both with a two-day window and misses. + // + // What a window promises is that the period starts inside it. An engine + // that keeps that promise 7 times in 9 has a broken promise, not a tight + // window. So coverage is the measure, with a ceiling to stop the trivial + // cheat of answering "some time this month". + assertTrue( + "personal window covered the actual start ${p.covered}/${fixtures.size}, " + + "baseline ${b.covered}/${fixtures.size} — a window that misses is a broken promise", + p.covered >= b.covered, + ) + assertTrue( + "personal mean window is ${p.meanWindowWidth} days — wide enough to be useless. " + + "A window nobody can plan around is not a forecast.", + p.meanWindowWidth <= USEFUL_WINDOW_CEILING, + ) + assertTrue( + "personal hits within 2 days ${p.within2} times, baseline ${b.within2}", + p.within2 >= b.within2, + ) + } + + @Test + fun `the two engines report different model versions`() { + // Every PredictionRecord stores this. Accuracy compared across engine + // versions without it is meaningless, and §16's history spans the swap. + assertTrue(baseline.modelVersion != personal.modelVersion) + } +} diff --git a/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/FertilityEstimateTest.kt b/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/FertilityEstimateTest.kt new file mode 100644 index 0000000..4ca9310 --- /dev/null +++ b/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/FertilityEstimateTest.kt @@ -0,0 +1,127 @@ +package dev.privacyllc.period.domain.prediction + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * §17 and §18. + * + * Most of these are about **not** claiming things: not a crisp ovulation date + * off a vague forecast, not a window when there is no forecast at all, and never + * a word that reads as permission. + */ +class FertilityEstimateTest { + + private fun forecast(on: String, halfWidth: Long) = LocalDate.parse(on).let { d -> + Prediction( + mostLikelyStartDate = d, + windowStart = d.minusDays(halfWidth), + windowEnd = d.plusDays(halfWidth), + confidenceScore = 0.6, + confidenceLabel = ConfidenceLabel.MEDIUM, + modelVersion = "test", + ) + } + + @Test fun `no forecast means no fertility estimate at all`() { + // §17 hangs the estimate off the predicted next period. Without one, a + // day-14 guess would be exactly the fixed-rule implication §17 rules out. + assertNull(FertilityEstimate.from(null)) + } + + @Test fun `ovulation sits a luteal phase before the predicted start`() { + val e = FertilityEstimate.from(forecast("2026-09-01", 1))!! + assertEquals(LocalDate.of(2026, 8, 18), e.ovulationDate) + assertEquals(FertilityEstimate.DEFAULT_LUTEAL_PHASE_DAYS, e.lutealPhaseDays) + } + + @Test fun `the luteal phase is an assumption that can be changed`() { + val e = FertilityEstimate.from(forecast("2026-09-01", 1), lutealPhaseDays = 12)!! + assertEquals(LocalDate.of(2026, 8, 20), e.ovulationDate) + } + + @Test fun `the window opens before ovulation and closes just after it`() { + val e = FertilityEstimate.from(forecast("2026-09-01", 0))!! + // Sperm survive several days, the egg about one. An estimate centred on + // ovulation would be biologically wrong in the direction that matters. + assertEquals(e.ovulationDate.minusDays(5), e.fertileWindowStart) + assertEquals(e.ovulationDate.plusDays(1), e.fertileWindowEnd) + assertTrue(e.ovulationDate in e.fertileWindow) + } + + @Test fun `a vaguer forecast produces a wider window`() { + // Ovulation is derived from a predicted date, so it can never be more + // certain than that prediction. + val tight = FertilityEstimate.from(forecast("2026-09-01", 1))!! + val looser = FertilityEstimate.from(forecast("2026-09-01", 3))!! + + fun width(e: FertilityEstimate) = + e.fertileWindowEnd.toEpochDay() - e.fertileWindowStart.toEpochDay() + + assertTrue("looser ${width(looser)} must exceed tight ${width(tight)}", width(looser) > width(tight)) + assertEquals(1, tight.uncertaintyDays) + assertEquals(3, looser.uncertaintyDays) + } + + @Test fun `a forecast too vague to locate ovulation produces no estimate at all`() { + // The window is seven days wide before any uncertainty is added, so a + // forecast carrying ±5 gives a seventeen-day "fertile window" — over + // half a cycle. It was on screen before anybody noticed: a user one + // cycle in was shown 8 Aug – 24 Aug. Declining is the better answer. + assertNull(FertilityEstimate.from(forecast("2026-09-01", 5))) + assertNull(FertilityEstimate.from(forecast("2026-09-01", 8))) + assertNotNull(FertilityEstimate.from(forecast("2026-09-01", 3))) + } + + @Test fun `an estimate is never wider than about a fortnight`() { + (0L..FertilityEstimate.MAX_USEFUL_UNCERTAINTY_DAYS.toLong()).forEach { half -> + val e = FertilityEstimate.from(forecast("2026-09-01", half))!! + val width = e.fertileWindowEnd.toEpochDay() - e.fertileWindowStart.toEpochDay() + assertTrue("half=$half gave a $width-day window", width <= 13) + } + } + + @Test fun `the estimate moves with a re-conditioned forecast`() { + // A "Not yet" moves the period forecast. Pinning ovulation to the old + // one would have the same screen contradict itself. + val before = FertilityEstimate.from(forecast("2026-09-01", 1))!! + val after = FertilityEstimate.from(forecast("2026-09-04", 1))!! + assertEquals(3, after.ovulationDate.toEpochDay() - before.ovulationDate.toEpochDay()) + } + + // ----------------------------------------------------------------------- + // §18's vocabulary + // ----------------------------------------------------------------------- + + @Test fun `days inside the window are higher likelihood and days outside are lower`() { + val e = FertilityEstimate.from(forecast("2026-09-01", 1))!! + assertEquals(FertilityLikelihood.HIGHER, e.likelihoodOn(e.ovulationDate)) + assertEquals(FertilityLikelihood.HIGHER, e.likelihoodOn(e.fertileWindowStart)) + assertEquals(FertilityLikelihood.HIGHER, e.likelihoodOn(e.fertileWindowEnd)) + assertEquals(FertilityLikelihood.LOWER, e.likelihoodOn(e.fertileWindowStart.minusDays(1))) + assertEquals(FertilityLikelihood.LOWER, e.likelihoodOn(e.fertileWindowEnd.plusDays(1))) + } + + @Test fun `no label anywhere reads as permission`() { + // §18: avoid "safe" or "unsafe". The type has no such value, and this + // test exists so adding one is a deliberate act with a failing test. + val words = FertilityLikelihood.entries.map { it.label.lowercase() } + + FertilityLikelihood.entries.map { it.name.lowercase() } + assertTrue( + "a fertility label contained a permission word: $words", + words.none { it.contains("safe") || it.contains("unsafe") || it.contains("protect") }, + ) + } + + @Test fun `an estimate always contains its own ovulation day`() { + // The constructor enforces it; this covers the constants drifting apart. + (0L..FertilityEstimate.MAX_USEFUL_UNCERTAINTY_DAYS.toLong()).forEach { half -> + val e = FertilityEstimate.from(forecast("2026-09-01", half))!! + assertTrue("half=$half", e.ovulationDate in e.fertileWindow) + } + } +} diff --git a/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/IntervalAnalysisTest.kt b/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/IntervalAnalysisTest.kt new file mode 100644 index 0000000..a0cd97e --- /dev/null +++ b/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/IntervalAnalysisTest.kt @@ -0,0 +1,127 @@ +package dev.privacyllc.period.domain.prediction + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * §14 and §12 step 2, which together say something narrower than "find + * outliers": detect what is unusual **for this user**, keep it, reduce its + * influence, and ask only when the shape of the gap actually suggests a missed + * entry. + */ +class IntervalAnalysisTest { + + private fun startsFrom(vararg gaps: Long, from: String = "2026-01-01"): List { + var cursor = LocalDate.parse(from) + val out = mutableListOf(cursor) + gaps.forEach { cursor = cursor.plusDays(it); out += cursor } + return out + } + + private fun analyse(vararg gaps: Long) = IntervalAnalysis.intervals(startsFrom(*gaps)) + + @Test fun `intervals carry the gaps in order with the newest aged zero`() { + val i = analyse(29, 28, 30) + assertEquals(listOf(29, 28, 30), i.map { it.days }) + assertEquals(listOf(2, 1, 0), i.map { it.ageIndex }) + } + + @Test fun `recency decay makes the newest cycle count most`() { + val i = analyse(29, 28, 30, 29) + val byAge = i.sortedBy { it.ageIndex } + assertEquals(1.0, byAge.first().weight, 0.0001) + assertTrue("weights must decrease with age", byAge.zipWithNext().all { (a, b) -> a.weight > b.weight }) + } + + // ----------------------------------------------------------------------- + // §14 — a probable missed period + // ----------------------------------------------------------------------- + + @Test fun `a gap near double the usual is flagged as a probable missed period`() { + // §14's worked example: 29, 28, 30, 29, 61, 29. + val i = analyse(29, 28, 30, 29, 61, 29) + val flagged = i.filter { it.isQuestionable } + + assertEquals(1, flagged.size) + assertEquals(61, flagged.single().days) + assertEquals(QuestionableReason.POSSIBLE_MISSED_PERIOD, flagged.single().questionable) + } + + @Test fun `a flagged interval is kept and down-weighted, never dropped`() { + val i = analyse(29, 28, 30, 29, 61, 29) + + // §12 step 2: do not silently delete unusual data. + assertEquals("the interval must still be there", 6, i.size) + val odd = i.single { it.days == 61 } + assertTrue("and must still carry some weight", odd.weight > 0.0) + val comparable = i.single { it.ageIndex == odd.ageIndex - 1 } + assertTrue("but much less than an ordinary neighbour", odd.weight < comparable.weight / 2) + } + + // ----------------------------------------------------------------------- + // The half of §14 that is easy to get wrong + // ----------------------------------------------------------------------- + + @Test fun `a long cycle is normal for a user whose cycles are long`() { + // The whole product promise is that this user is not treated as average. + // A global "over 40 days is suspicious" rule would flag every one of + // these, which is exactly the app she has already deleted once. + val i = analyse(41, 44, 42, 45, 43) + assertTrue("nothing here is unusual for her", i.none { it.isQuestionable }) + } + + @Test fun `45 against a usual of 29 is unusual but not a missed period`() { + // §51's outlier case. 45/29 is 1.55 — nowhere near a whole multiple, so + // it is not a forgotten entry and asking would be over-questioning (§25). + val i = analyse(29, 29, 28, 30, 45, 29) + val odd = i.single { it.days == 45 } + assertEquals(QuestionableReason.UNUSUAL_LENGTH, odd.questionable) + } + + @Test fun `nothing is flagged before there is a usual to compare against`() { + // Two intervals do not establish a pattern, and calling one of them + // questionable is the model asserting something it cannot know. + assertTrue(analyse(29, 61).none { it.isQuestionable }) + } + + @Test fun `a steady history flags nothing`() { + assertTrue(analyse(28, 29, 28, 29, 28, 29).none { it.isQuestionable }) + } + + @Test fun `a variable but genuine history is not carpet-flagged`() { + // §51's variable user: 25, 34, 29, 37, 26, 32. She is hard to predict, + // not wrong. Flagging most of her history would down-weight the data the + // model most needs and make her forecast worse. + val i = analyse(25, 34, 29, 37, 26, 32) + assertTrue("at most one of six may be questioned, got ${i.count { it.isQuestionable }}", + i.count { it.isQuestionable } <= 1) + assertTrue("and none as a missed period", + i.none { it.questionable == QuestionableReason.POSSIBLE_MISSED_PERIOD }) + } + + // ----------------------------------------------------------------------- + // Boundaries + // ----------------------------------------------------------------------- + + @Test fun `implausibly short gaps are not cycles at all`() { + // Two starts three days apart is a re-entry or a typo, not a 3-day + // cycle, and letting it into the weighting would wreck the centre. + val i = analyse(29, 3, 28) + assertTrue("the 3-day gap must not appear", i.none { it.days == 3 }) + } + + @Test fun `fewer than two starts yields nothing rather than failing`() { + assertTrue(IntervalAnalysis.intervals(emptyList()).isEmpty()) + assertTrue(IntervalAnalysis.intervals(listOf(LocalDate.of(2026, 1, 1))).isEmpty()) + } + + @Test fun `the weighted median respects weights`() { + // Equal weights: ordinary median. + assertEquals(29.0, IntervalAnalysis.weightedMedian(listOf(28.0 to 1.0, 29.0 to 1.0, 30.0 to 1.0)), 0.0001) + // The 35 carries almost all the weight, so it wins despite being extreme. + assertEquals(35.0, IntervalAnalysis.weightedMedian(listOf(28.0 to 0.05, 29.0 to 0.05, 35.0 to 10.0)), 0.0001) + } +} diff --git a/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/PersonalEngineTest.kt b/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/PersonalEngineTest.kt new file mode 100644 index 0000000..65ca6e7 --- /dev/null +++ b/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/PersonalEngineTest.kt @@ -0,0 +1,191 @@ +package dev.privacyllc.period.domain.prediction + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import kotlin.math.abs + +/** + * The behaviour §12 and §15 ask for that the acceptance cases do not pin. + * + * The §51 cases say what any engine must not get wrong. These say what *this* + * engine is for: learning from being wrong, following a drift without chasing + * noise, and treating "Not yet" as information rather than as a nudge. + */ +class PersonalEngineTest { + + private val engine = PersonalPredictionEngine() + private val anchor = LocalDate.of(2026, 8, 1) + + /** Confirmed starts ending on [anchor], from a run of cycle lengths. */ + private fun starts(vararg gaps: Long): List { + var cursor = anchor + val out = mutableListOf(cursor) + gaps.reversed().forEach { cursor = cursor.minusDays(it); out += cursor } + return out.sorted() + } + + private fun predict( + gaps: LongArray, + today: LocalDate = anchor.plusDays(1), + notYet: List = emptyList(), + errors: List = emptyList(), + ) = engine.predict( + PredictionInput( + confirmedStarts = starts(*gaps), + today = today, + notYet = notYet.map { NotYetObservation(it) }, + recentAbsoluteErrors = errors, + ), + )!! + + private fun width(p: Prediction) = p.windowEnd.toEpochDay() - p.windowStart.toEpochDay() + + // ----------------------------------------------------------------------- + // §15's two worked examples, as tests + // ----------------------------------------------------------------------- + + @Test fun `the stable history from section 15 reads high with a tight window`() { + val p = predict(longArrayOf(28, 29, 28, 29, 28, 29)) + assertEquals(ConfidenceLabel.HIGH, p.confidenceLabel) + assertTrue("window was ${width(p)} days", width(p) <= 4) + } + + @Test fun `the variable history from section 15 reads low with a wide window`() { + val p = predict(longArrayOf(25, 33, 28, 37, 26, 32)) + assertEquals(ConfidenceLabel.LOW, p.confidenceLabel) + assertTrue("window was only ${width(p)} days", width(p) >= 6) + } + + @Test fun `volume alone never buys high confidence`() { + // §15, stated as plainly as the document does: do not assign High purely + // because the user has entered a large number of cycles. Twenty + // disagreeing cycles is more data and no more predictability. + val many = LongArray(20) { if (it % 2 == 0) 25L else 37L } + assertTrue(predict(many).confidenceLabel != ConfidenceLabel.HIGH) + } + + // ----------------------------------------------------------------------- + // §12 step 5 — the app learns from being wrong + // ----------------------------------------------------------------------- + + @Test fun `a run of bad forecasts widens the window even when the cycles look tidy`() { + val gaps = longArrayOf(28, 29, 28, 29, 28) + + val trusted = predict(gaps, errors = listOf(0, 1, 0, 1)) + val burned = predict(gaps, errors = listOf(4, 5, 4, 6)) + + assertTrue( + "identical histories: window ${width(trusted)} when accurate vs ${width(burned)} when not", + width(burned) > width(trusted), + ) + assertTrue( + "and confidence must fall with it", + burned.confidenceScore < trusted.confidenceScore, + ) + } + + @Test fun `being right does not widen anything`() { + val gaps = longArrayOf(28, 29, 28, 29, 28) + assertTrue(width(predict(gaps, errors = listOf(0, 0, 1))) <= width(predict(gaps))) + } + + // ----------------------------------------------------------------------- + // §12 step 6 — trend, without overfitting + // ----------------------------------------------------------------------- + + @Test fun `a consistent lengthening is followed`() { + val drifting = predict(longArrayOf(27, 28, 29, 30, 31)) + val steady = predict(longArrayOf(29, 29, 29, 29, 29)) + + assertTrue( + "a lengthening history should forecast later than a flat one at the same centre", + drifting.mostLikelyStartDate >= steady.mostLikelyStartDate, + ) + } + + @Test fun `one unusual cycle does not manufacture a trend`() { + // §12: do not overfit one cycle. The difference between these two + // histories is a single recent value, and the forecast must barely move. + val flat = predict(longArrayOf(29, 29, 29, 29, 29, 29)) + val blip = predict(longArrayOf(29, 29, 29, 29, 29, 34)) + + val moved = abs(blip.mostLikelyStartDate.toEpochDay() - flat.mostLikelyStartDate.toEpochDay()) + assertTrue("the forecast moved $moved days on one cycle", moved <= 2) + } + + // ----------------------------------------------------------------------- + // §13 — "Not yet" as conditioning + // ----------------------------------------------------------------------- + + @Test fun `not yet moves the date, the window and the confidence together`() { + val gaps = longArrayOf(29, 29, 29, 29) + val due = anchor.plusDays(29) + + val before = predict(gaps, today = due) + val after = predict(gaps, today = due, notYet = listOf(due)) + + assertTrue("the ruled-out day must be gone", after.windowStart.isAfter(due)) + assertTrue("the most likely date must move", after.mostLikelyStartDate > before.mostLikelyStartDate) + assertTrue("and confidence must fall", after.confidenceScore < before.confidenceScore) + } + + @Test fun `repeated not yet keeps updating rather than stepping evenly`() { + // §13: repeated observations should progressively update the forecast. + // A design that moves the date by one day each time is the thing §13 + // explicitly rules out, so the steps must not all be equal. + val gaps = longArrayOf(29, 29, 29, 29) + val due = anchor.plusDays(29) + + val observed = mutableListOf() + val modes = mutableListOf() + val confidences = mutableListOf() + + repeat(5) { i -> + observed += due.plusDays(i.toLong()) + val p = predict(gaps, today = observed.last(), notYet = observed.toList()) + modes += p.mostLikelyStartDate + confidences += p.confidenceScore + assertTrue("every ruled-out day must stay out", p.windowStart.isAfter(observed.last())) + assertTrue("the window must never collapse", p.windowEnd >= p.windowStart) + } + + assertTrue("the forecast must keep moving forward", modes == modes.sorted()) + assertTrue("confidence must never rise on being told it was wrong", + confidences.zipWithNext().all { (a, b) -> b <= a }) + } + + @Test fun `a user far past every candidate still gets a usable answer`() { + // Someone who has answered "Not yet" for three weeks. The distribution + // has no mass left where it started; it must restart rather than divide + // by zero or assert a date it has no evidence for. + val gaps = longArrayOf(29, 29, 29) + val due = anchor.plusDays(29) + val observed = (0..20L).map { due.plusDays(it) } + + val p = predict(gaps, today = observed.last(), notYet = observed) + + assertTrue(p.windowStart.isAfter(observed.last())) + assertTrue(p.mostLikelyStartDate >= p.windowStart) + assertTrue(p.mostLikelyStartDate <= p.windowEnd) + assertTrue("three weeks of not-yet is not a confident state", p.confidenceScore < 0.4) + } + + // ----------------------------------------------------------------------- + // §14 — a questionable interval reduces confidence without being dropped + // ----------------------------------------------------------------------- + + @Test fun `a probable missed period lowers confidence rather than being ignored`() { + val clean = predict(longArrayOf(29, 28, 30, 29, 29)) + val gapped = predict(longArrayOf(29, 28, 30, 29, 61)) + + assertTrue( + "a history with a suspected missing entry must not read as confidently as a clean one", + gapped.confidenceScore < clean.confidenceScore, + ) + // And it must not have dragged the forecast out to a 45-day cycle. + val interval = gapped.mostLikelyStartDate.toEpochDay() - anchor.toEpochDay() + assertTrue("forecast interval was $interval days", interval in 27..32) + } +} diff --git a/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/PredictionAcceptanceTest.kt b/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/PredictionAcceptanceTest.kt new file mode 100644 index 0000000..445b820 --- /dev/null +++ b/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/PredictionAcceptanceTest.kt @@ -0,0 +1,220 @@ +package dev.privacyllc.period.domain.prediction + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * The acceptance cases from docs/planning/PRODUCT_PLAN.md §51, written against + * the interface rather than any implementation, and run against **every** engine + * in the tree — see the concrete subclasses at the bottom of this file. + * + * These are the tests the product's headline claim rests on. A change that + * makes any of them fail is a core product defect, not a tuning regression: + * §3 names a 35-day user being predicted at 28 in exactly those words. + */ +abstract class PredictionAcceptanceTest { + + /** + * Every engine is held to this same contract. + * + * That is what makes "the new one is better" a measurable claim rather than + * an opinion: if the replacement could quietly relax one of these, better + * would only mean different. + */ + protected abstract val engine: PredictionEngine + + /** Build confirmed start dates from a run of cycle lengths, ending [ending]. */ + private fun startsFromIntervals(vararg intervals: Long, ending: LocalDate): List { + val dates = ArrayDeque() + var cursor = ending + dates.addFirst(cursor) + for (gap in intervals.reversed()) { + cursor = cursor.minusDays(gap) + dates.addFirst(cursor) + } + return dates.toList() + } + + private fun windowWidth(p: Prediction) = + p.windowEnd.toEpochDay() - p.windowStart.toEpochDay() + + // ----------------------------------------------------------------------- + // §51 — Stable longer-cycle user + // ----------------------------------------------------------------------- + + @Test + fun `a stable 35-day user is not predicted at 28 days`() { + val lastStart = LocalDate.of(2026, 8, 1) + val starts = startsFromIntervals(35, 35, 34, 36, 35, ending = lastStart) + + val p = engine.predict(starts, today = lastStart.plusDays(1)) + assertNotNull(p) + val predictedInterval = p!!.mostLikelyStartDate.toEpochDay() - lastStart.toEpochDay() + + assertTrue( + "forecast interval was $predictedInterval days; a 35-day user must not be predicted near 28", + predictedInterval in 34..36, + ) + } + + @Test + fun `a stable user gets high confidence and a tight window`() { + val lastStart = LocalDate.of(2026, 8, 1) + val starts = startsFromIntervals(35, 35, 34, 36, 35, ending = lastStart) + + val p = engine.predict(starts, today = lastStart.plusDays(1))!! + assertEquals(ConfidenceLabel.HIGH, p.confidenceLabel) + assertTrue("window was ${windowWidth(p)} days wide", windowWidth(p) <= 4) + } + + // ----------------------------------------------------------------------- + // §51 — Variable user + // ----------------------------------------------------------------------- + + @Test + fun `a variable user gets a wider window and lower confidence than a stable one`() { + val lastStart = LocalDate.of(2026, 8, 1) + val today = lastStart.plusDays(1) + + val stable = engine.predict(startsFromIntervals(35, 35, 34, 36, 35, ending = lastStart), today)!! + val variable = engine.predict(startsFromIntervals(25, 34, 29, 37, 26, 32, ending = lastStart), today)!! + + assertTrue( + "variable window ${windowWidth(variable)} was not wider than stable ${windowWidth(stable)}", + windowWidth(variable) > windowWidth(stable), + ) + assertTrue( + "variable confidence ${variable.confidenceScore} was not below stable ${stable.confidenceScore}", + variable.confidenceScore < stable.confidenceScore, + ) + assertEquals(ConfidenceLabel.LOW, variable.confidenceLabel) + } + + @Test + fun `confidence is agreement rather than volume`() { + // Six recorded cycles, all disagreeing. §15: do not assign High purely + // because the user has entered a large number of cycles. + val lastStart = LocalDate.of(2026, 8, 1) + val p = engine.predict(startsFromIntervals(25, 34, 29, 37, 26, 32, ending = lastStart), lastStart.plusDays(1))!! + assertFalse("six disagreeing cycles must not read as High", p.confidenceLabel == ConfidenceLabel.HIGH) + } + + // ----------------------------------------------------------------------- + // §51 — Outlier + // ----------------------------------------------------------------------- + + @Test + fun `a single 45-day outlier does not dominate the forecast`() { + val lastStart = LocalDate.of(2026, 8, 1) + val starts = startsFromIntervals(29, 29, 28, 30, 45, 29, ending = lastStart) + + val p = engine.predict(starts, today = lastStart.plusDays(1))!! + val predictedInterval = p.mostLikelyStartDate.toEpochDay() - lastStart.toEpochDay() + + // The arithmetic mean of that history is ~31.7. A robust centre stays near 29. + assertTrue( + "forecast interval was $predictedInterval days; the 45-day observation dominated", + predictedInterval in 28..30, + ) + } + + // ----------------------------------------------------------------------- + // §51 — Not yet + // ----------------------------------------------------------------------- + + @Test + fun `a not-yet date can no longer be a future start candidate`() { + val starts = listOf( + LocalDate.of(2026, 5, 28), + LocalDate.of(2026, 6, 26), + LocalDate.of(2026, 7, 25), + ) + val today = LocalDate.of(2026, 8, 22) + + val before = engine.predict(starts, today)!! + assertEquals(LocalDate.of(2026, 8, 22), before.windowStart) + + val after = engine.predict(starts, today, listOf(NotYetObservation(date = today)))!! + assertTrue( + "Aug 22 was ruled out and is still in the window ${after.windowStart}..${after.windowEnd}", + after.windowStart.isAfter(today), + ) + assertTrue( + "the window must stay valid after re-conditioning", + !after.windowStart.isAfter(after.windowEnd), + ) + } + + @Test + fun `a not-yet observation lowers confidence rather than only shifting the date`() { + val starts = listOf( + LocalDate.of(2026, 5, 28), + LocalDate.of(2026, 6, 26), + LocalDate.of(2026, 7, 25), + ) + val today = LocalDate.of(2026, 8, 22) + + val before = engine.predict(starts, today)!! + val after = engine.predict(starts, today, listOf(NotYetObservation(date = today)))!! + + assertTrue( + "confidence did not fall: ${before.confidenceScore} -> ${after.confidenceScore}", + after.confidenceScore < before.confidenceScore, + ) + } + + // ----------------------------------------------------------------------- + // Boundaries the UI will actually hit + // ----------------------------------------------------------------------- + + @Test + fun `no history produces no prediction rather than a confident guess`() { + assertNull(engine.predict(emptyList(), LocalDate.of(2026, 8, 18))) + } + + @Test + fun `a single confirmed period predicts with the lowest confidence`() { + val p = engine.predict(listOf(LocalDate.of(2026, 8, 1)), LocalDate.of(2026, 8, 2))!! + assertEquals(ConfidenceLabel.LOW, p.confidenceLabel) + } + + @Test + fun `a window never opens in the past`() { + // A user who stopped logging months ago still gets a usable answer. + val starts = listOf(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 30)) + val today = LocalDate.of(2026, 8, 18) + + val p = engine.predict(starts, today)!! + assertFalse("window opened at ${p.windowStart}, before today", p.windowStart.isBefore(today)) + } + + @Test + fun `the same input and model version give the same answer`() { + val starts = startsFromIntervals(29, 28, 30, 29, ending = LocalDate.of(2026, 8, 1)) + val today = LocalDate.of(2026, 8, 18) + assertEquals(engine.predict(starts, today), engine.predict(starts, today)) + } + + @Test + fun `duplicate and unordered starts do not change the answer`() { + val ordered = startsFromIntervals(29, 28, 30, ending = LocalDate.of(2026, 8, 1)) + val messy = (ordered + ordered.first() + ordered.last()).shuffled(kotlin.random.Random(7)) + val today = LocalDate.of(2026, 8, 18) + assertEquals(engine.predict(ordered, today), engine.predict(messy, today)) + } +} + +/** The prototype. Kept as the control the replacement has to beat. */ +class BaselineAcceptanceTest : PredictionAcceptanceTest() { + override val engine: PredictionEngine = BaselinePredictionEngine() +} + +/** The engine §12 specifies. */ +class PersonalAcceptanceTest : PredictionAcceptanceTest() { + override val engine: PredictionEngine = PersonalPredictionEngine() +} diff --git a/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/FertilityEstimate.kt b/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/FertilityEstimate.kt new file mode 100644 index 0000000..fbbe3cb --- /dev/null +++ b/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/FertilityEstimate.kt @@ -0,0 +1,159 @@ +package dev.privacyllc.period.domain.prediction + +import java.time.LocalDate +import kotlin.math.roundToLong + +/** + * How likely conception is on a given day, in the only vocabulary §18 permits. + * + * The prohibition is three words long — *avoid "safe" or "unsafe" labels* — and + * it is not squeamishness about wording. Somebody reading "safe" will take a + * decision on it; the estimate is derived from a *predicted* date carrying days + * of uncertainty; and §18 has already promised this is not contraception. + * "Lower likelihood" is true. "Safe" is a claim the model cannot support. + */ +enum class FertilityLikelihood { + LOWER, + HIGHER, + + /** + * The forecast is too uncertain to say either way. + * + * A day can sit outside the estimated window and still not deserve "lower + * likelihood", because the window itself is only as good as the period + * prediction it hangs off. Where that is weak, the honest answer is the + * vaguer one rather than a confident-sounding one. + */ + UNKNOWN, + ; + + /** User-facing text. §18's words, not the enum's. */ + val label: String + get() = when (this) { + LOWER -> "Lower likelihood" + HIGHER -> "Higher likelihood" + UNKNOWN -> "Not enough history to estimate" + } +} + +/** + * Estimated ovulation and the fertile window around it. + * + * **Everything here is derived from the period forecast**, so nothing here can + * be more certain than that forecast. §17: estimate ovulation relative to the + * predicted next period; do not imply a fixed day-14 rule is biologically exact. + * + * [uncertaintyDays] carries that inheritance explicitly rather than leaving it + * implied, because the tempting mistake is to present a single crisp ovulation + * date computed from a nine-day period window — precision the app invented. + */ +data class FertilityEstimate( + val ovulationDate: LocalDate, + val fertileWindowStart: LocalDate, + val fertileWindowEnd: LocalDate, + /** Half-width, in days, inherited from the forecast the estimate hangs off. */ + val uncertaintyDays: Int, + val lutealPhaseDays: Int, +) { + val fertileWindow: ClosedRange get() = fertileWindowStart..fertileWindowEnd + + init { + require(!fertileWindowStart.isAfter(fertileWindowEnd)) { + "fertile window starts after it ends" + } + require(ovulationDate in fertileWindowStart..fertileWindowEnd) { + "ovulation $ovulationDate falls outside its own fertile window" + } + } + + /** + * The label for [date]. Never "safe", by construction — the type has no such + * value. + */ + fun likelihoodOn(date: LocalDate): FertilityLikelihood = + if (date in fertileWindowStart..fertileWindowEnd) { + FertilityLikelihood.HIGHER + } else { + FertilityLikelihood.LOWER + } + + companion object { + /** + * Days from ovulation to the next period. + * + * The luteal phase is the more stable half of the cycle, which is why + * estimating backwards from a predicted period is more defensible than + * counting forwards from the last one — and why §17 asks for exactly + * that. Fourteen is the common assumption and it is **an assumption**; + * it is a named, overridable constant rather than a literal buried in + * arithmetic, so the day this app can measure it, one value changes. + */ + const val DEFAULT_LUTEAL_PHASE_DAYS = 14 + + /** Sperm survive several days; the window opens before ovulation, not on it. */ + const val FERTILE_DAYS_BEFORE_OVULATION = 5L + + /** The egg is viable for about a day. */ + const val FERTILE_DAYS_AFTER_OVULATION = 1L + + /** + * Past this much forecast uncertainty, no estimate is offered at all. + * + * The window is inherently seven days wide before any uncertainty is + * added, so a forecast carrying ±5 produces a "fertile window" of + * seventeen days — over half a cycle. That is honest arithmetic and + * useless information, and it was on screen before anybody noticed: + * a user one cycle into the app was shown *8 Aug – 24 Aug*. + * + * Declining is the better answer. "We do not know your cycle well + * enough yet" is true, is useful, and gives the user a reason to keep + * logging. A seventeen-day window says nothing and looks like a + * feature. + */ + const val MAX_USEFUL_UNCERTAINTY_DAYS = 3 + + /** + * Estimate from a period forecast, or **null when it would not mean + * anything**. + * + * Two ways to get null, and both are the app declining to invent + * precision: + * + * - there is no forecast. §17's estimate hangs off the predicted next + * period, and a day-14 guess without one is exactly the fixed-rule + * implication §17 rules out; + * - the forecast is too vague. See [MAX_USEFUL_UNCERTAINTY_DAYS]. + */ + fun from( + forecast: Prediction?, + lutealPhaseDays: Int = DEFAULT_LUTEAL_PHASE_DAYS, + ): FertilityEstimate? { + forecast ?: return null + + val ovulation = forecast.mostLikelyStartDate.minusDays(lutealPhaseDays.toLong()) + + // Inherited, not invented. The forecast's own window is the floor on + // how precisely ovulation can possibly be known. + val forecastHalfWidth = + ((forecast.windowEnd.toEpochDay() - forecast.windowStart.toEpochDay()) / 2.0) + .roundToLong().toInt() + + val start = ovulation + .minusDays(FERTILE_DAYS_BEFORE_OVULATION) + .minusDays(forecastHalfWidth.toLong()) + val end = ovulation + .plusDays(FERTILE_DAYS_AFTER_OVULATION) + .plusDays(forecastHalfWidth.toLong()) + + if (forecastHalfWidth > MAX_USEFUL_UNCERTAINTY_DAYS) return null + + return FertilityEstimate( + ovulationDate = ovulation, + fertileWindowStart = start, + fertileWindowEnd = end, + uncertaintyDays = forecastHalfWidth, + lutealPhaseDays = lutealPhaseDays, + ) + } + } +} diff --git a/domain/prediction/src/test/kotlin/dev/privacyllc/period/domain/prediction/FertilityEstimateTest.kt b/domain/prediction/src/test/kotlin/dev/privacyllc/period/domain/prediction/FertilityEstimateTest.kt new file mode 100644 index 0000000..4ca9310 --- /dev/null +++ b/domain/prediction/src/test/kotlin/dev/privacyllc/period/domain/prediction/FertilityEstimateTest.kt @@ -0,0 +1,127 @@ +package dev.privacyllc.period.domain.prediction + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * §17 and §18. + * + * Most of these are about **not** claiming things: not a crisp ovulation date + * off a vague forecast, not a window when there is no forecast at all, and never + * a word that reads as permission. + */ +class FertilityEstimateTest { + + private fun forecast(on: String, halfWidth: Long) = LocalDate.parse(on).let { d -> + Prediction( + mostLikelyStartDate = d, + windowStart = d.minusDays(halfWidth), + windowEnd = d.plusDays(halfWidth), + confidenceScore = 0.6, + confidenceLabel = ConfidenceLabel.MEDIUM, + modelVersion = "test", + ) + } + + @Test fun `no forecast means no fertility estimate at all`() { + // §17 hangs the estimate off the predicted next period. Without one, a + // day-14 guess would be exactly the fixed-rule implication §17 rules out. + assertNull(FertilityEstimate.from(null)) + } + + @Test fun `ovulation sits a luteal phase before the predicted start`() { + val e = FertilityEstimate.from(forecast("2026-09-01", 1))!! + assertEquals(LocalDate.of(2026, 8, 18), e.ovulationDate) + assertEquals(FertilityEstimate.DEFAULT_LUTEAL_PHASE_DAYS, e.lutealPhaseDays) + } + + @Test fun `the luteal phase is an assumption that can be changed`() { + val e = FertilityEstimate.from(forecast("2026-09-01", 1), lutealPhaseDays = 12)!! + assertEquals(LocalDate.of(2026, 8, 20), e.ovulationDate) + } + + @Test fun `the window opens before ovulation and closes just after it`() { + val e = FertilityEstimate.from(forecast("2026-09-01", 0))!! + // Sperm survive several days, the egg about one. An estimate centred on + // ovulation would be biologically wrong in the direction that matters. + assertEquals(e.ovulationDate.minusDays(5), e.fertileWindowStart) + assertEquals(e.ovulationDate.plusDays(1), e.fertileWindowEnd) + assertTrue(e.ovulationDate in e.fertileWindow) + } + + @Test fun `a vaguer forecast produces a wider window`() { + // Ovulation is derived from a predicted date, so it can never be more + // certain than that prediction. + val tight = FertilityEstimate.from(forecast("2026-09-01", 1))!! + val looser = FertilityEstimate.from(forecast("2026-09-01", 3))!! + + fun width(e: FertilityEstimate) = + e.fertileWindowEnd.toEpochDay() - e.fertileWindowStart.toEpochDay() + + assertTrue("looser ${width(looser)} must exceed tight ${width(tight)}", width(looser) > width(tight)) + assertEquals(1, tight.uncertaintyDays) + assertEquals(3, looser.uncertaintyDays) + } + + @Test fun `a forecast too vague to locate ovulation produces no estimate at all`() { + // The window is seven days wide before any uncertainty is added, so a + // forecast carrying ±5 gives a seventeen-day "fertile window" — over + // half a cycle. It was on screen before anybody noticed: a user one + // cycle in was shown 8 Aug – 24 Aug. Declining is the better answer. + assertNull(FertilityEstimate.from(forecast("2026-09-01", 5))) + assertNull(FertilityEstimate.from(forecast("2026-09-01", 8))) + assertNotNull(FertilityEstimate.from(forecast("2026-09-01", 3))) + } + + @Test fun `an estimate is never wider than about a fortnight`() { + (0L..FertilityEstimate.MAX_USEFUL_UNCERTAINTY_DAYS.toLong()).forEach { half -> + val e = FertilityEstimate.from(forecast("2026-09-01", half))!! + val width = e.fertileWindowEnd.toEpochDay() - e.fertileWindowStart.toEpochDay() + assertTrue("half=$half gave a $width-day window", width <= 13) + } + } + + @Test fun `the estimate moves with a re-conditioned forecast`() { + // A "Not yet" moves the period forecast. Pinning ovulation to the old + // one would have the same screen contradict itself. + val before = FertilityEstimate.from(forecast("2026-09-01", 1))!! + val after = FertilityEstimate.from(forecast("2026-09-04", 1))!! + assertEquals(3, after.ovulationDate.toEpochDay() - before.ovulationDate.toEpochDay()) + } + + // ----------------------------------------------------------------------- + // §18's vocabulary + // ----------------------------------------------------------------------- + + @Test fun `days inside the window are higher likelihood and days outside are lower`() { + val e = FertilityEstimate.from(forecast("2026-09-01", 1))!! + assertEquals(FertilityLikelihood.HIGHER, e.likelihoodOn(e.ovulationDate)) + assertEquals(FertilityLikelihood.HIGHER, e.likelihoodOn(e.fertileWindowStart)) + assertEquals(FertilityLikelihood.HIGHER, e.likelihoodOn(e.fertileWindowEnd)) + assertEquals(FertilityLikelihood.LOWER, e.likelihoodOn(e.fertileWindowStart.minusDays(1))) + assertEquals(FertilityLikelihood.LOWER, e.likelihoodOn(e.fertileWindowEnd.plusDays(1))) + } + + @Test fun `no label anywhere reads as permission`() { + // §18: avoid "safe" or "unsafe". The type has no such value, and this + // test exists so adding one is a deliberate act with a failing test. + val words = FertilityLikelihood.entries.map { it.label.lowercase() } + + FertilityLikelihood.entries.map { it.name.lowercase() } + assertTrue( + "a fertility label contained a permission word: $words", + words.none { it.contains("safe") || it.contains("unsafe") || it.contains("protect") }, + ) + } + + @Test fun `an estimate always contains its own ovulation day`() { + // The constructor enforces it; this covers the constants drifting apart. + (0L..FertilityEstimate.MAX_USEFUL_UNCERTAINTY_DAYS.toLong()).forEach { half -> + val e = FertilityEstimate.from(forecast("2026-09-01", half))!! + assertTrue("half=$half", e.ovulationDate in e.fertileWindow) + } + } +}