chore: ignore .kotlin/, the compiler's session directory
This commit is contained in:
parent
adfb1961ae
commit
809a4317a7
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Long>): 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<Long>, 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PeriodRecord>,
|
||||
spotting: List<SpottingRecord>,
|
||||
forecast: Prediction?,
|
||||
today: LocalDate,
|
||||
fertileWindow: ClosedRange<LocalDate>? = null,
|
||||
ovulation: LocalDate? = null,
|
||||
): List<CalendarDay> {
|
||||
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<LocalDate>,
|
||||
spotted: Set<LocalDate>,
|
||||
forecast: Prediction?,
|
||||
fertileWindow: ClosedRange<LocalDate>?,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PeriodRecord>,
|
||||
forecast: Prediction?,
|
||||
today: LocalDate,
|
||||
typicalPeriodDuration: IntRange? = null,
|
||||
/** From Batch 04. Null means fertility is not estimated yet. */
|
||||
fertileWindow: ClosedRange<LocalDate>? = 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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<LocalDate> 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<LocalDate>): List<Interval> {
|
||||
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>): 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<Pair<Double, Double>>): 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Interval>): 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<Interval>, 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<Interval>, centre: Double, recentErrors: List<Int>): 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<LocalDate, Double>? {
|
||||
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<LocalDate, Double>, mode: LocalDate): Pair<LocalDate, LocalDate> {
|
||||
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<Interval>,
|
||||
scale: Double,
|
||||
notYetCount: Int,
|
||||
recentErrors: List<Int>,
|
||||
): 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<LocalDate>,
|
||||
val today: LocalDate,
|
||||
val notYet: List<NotYetObservation> = emptyList(),
|
||||
val recentAbsoluteErrors: List<Int> = 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<LocalDate>,
|
||||
today: LocalDate,
|
||||
notYet: List<NotYetObservation> = emptyList(),
|
||||
): Prediction? = predict(PredictionInput(confirmedStarts, today, notYet))
|
||||
|
|
@ -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<PeriodRecord> = emptyList(),
|
||||
spotting: List<SpottingRecord> = emptyList(),
|
||||
prediction: Prediction? = null,
|
||||
fertile: ClosedRange<LocalDate>? = 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PeriodRecord>,
|
||||
forecastOn: LocalDate? = null,
|
||||
at: LocalDate = today,
|
||||
fertile: ClosedRange<LocalDate>? = 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Long>, 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<Long>()
|
||||
val widths = mutableListOf<Long>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<LocalDate> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<LocalDate> {
|
||||
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<LocalDate> = emptyList(),
|
||||
errors: List<Int> = 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<LocalDate>()
|
||||
val modes = mutableListOf<LocalDate>()
|
||||
val confidences = mutableListOf<Double>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<LocalDate> {
|
||||
val dates = ArrayDeque<LocalDate>()
|
||||
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()
|
||||
}
|
||||
Loading…
Reference in New Issue