chore: ignore .kotlin/, the compiler's session directory

This commit is contained in:
null 2026-08-18 14:57:07 -05:00
parent adfb1961ae
commit 809a4317a7
15 changed files with 2 additions and 2274 deletions

2
.gitignore vendored
View File

@ -21,6 +21,8 @@ build/
# is build output that looks exactly like source, and it slipped into a commit # is build output that looks exactly like source, and it slipped into a commit
# once before this line existed. # once before this line existed.
bin/ bin/
# Kotlin compiler session dir, same story.
.kotlin/
captures/ captures/
.cxx/ .cxx/
*.apk *.apk

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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 }
},
)
}
}
}

View File

@ -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,
)
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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))

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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()
}