diff --git a/.gitignore b/.gitignore index b794da1..cf80f6a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ build/ # is build output that looks exactly like source, and it slipped into a commit # once before this line existed. bin/ +# Kotlin compiler session dir, same story. +.kotlin/ captures/ .cxx/ *.apk 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 deleted file mode 100644 index 68012de..0000000 --- a/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/BaselinePredictionEngine.kt +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index e5623a1..0000000 --- a/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/CalendarMarks.kt +++ /dev/null @@ -1,119 +0,0 @@ -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 deleted file mode 100644 index 36ed938..0000000 --- a/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/CycleStatus.kt +++ /dev/null @@ -1,165 +0,0 @@ -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 deleted file mode 100644 index fbbe3cb..0000000 --- a/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/FertilityEstimate.kt +++ /dev/null @@ -1,159 +0,0 @@ -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 deleted file mode 100644 index d0e84f3..0000000 --- a/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/IntervalAnalysis.kt +++ /dev/null @@ -1,149 +0,0 @@ -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 deleted file mode 100644 index 8a0c1d9..0000000 --- a/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/PersonalPredictionEngine.kt +++ /dev/null @@ -1,388 +0,0 @@ -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 deleted file mode 100644 index e6334bc..0000000 --- a/domain/prediction/bin/main/dev/privacyllc/period/domain/prediction/Prediction.kt +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 86028fe..0000000 --- a/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/CalendarMarksTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index 6561e64..0000000 --- a/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/CycleStatusTest.kt +++ /dev/null @@ -1,164 +0,0 @@ -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 deleted file mode 100644 index e9d63e4..0000000 --- a/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/EngineComparisonTest.kt +++ /dev/null @@ -1,151 +0,0 @@ -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 deleted file mode 100644 index 4ca9310..0000000 --- a/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/FertilityEstimateTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -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 deleted file mode 100644 index a0cd97e..0000000 --- a/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/IntervalAnalysisTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -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 deleted file mode 100644 index 65ca6e7..0000000 --- a/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/PersonalEngineTest.kt +++ /dev/null @@ -1,191 +0,0 @@ -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 deleted file mode 100644 index 445b820..0000000 --- a/domain/prediction/bin/test/dev/privacyllc/period/domain/prediction/PredictionAcceptanceTest.kt +++ /dev/null @@ -1,220 +0,0 @@ -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() -}