feat: project the next year onto the calendar, and say what it assumes

"Will I have my period the week of the wedding?" is a core prediction
question the app could not answer. The calendar marked only the next
forecast window and stopped browsing one month out, so a textbook-regular
user paged forward into blank months.

CycleProjection reaches a year. It is a separate type from Prediction,
deliberately: a forecast is something the app scores itself on -- the
period arrives inside the window or it does not, and PredictionRecord
writes down which -- while a projection eleven cycles out is never
scored, never learned from, and will have been replaced four times before
its date arrives. Separate types mean a projection cannot reach the
accuracy figures by accident.

Three rules keep it honest, each with a test named after it.

Uncertainty grows as sqrt(n), because cycle lengths are near-independent
draws: eleven cycles out is about three times as uncertain as one, not
eleven times. Real cycles do correlate, so that is the optimistic edge --
which is the second reason the assumption sentence is mandatory.

It declines rather than stretching. Past ten days either side a
projection stops being an answer, so the projection ends and reports that
it ended -- FertilityEstimate's precedent, which already refuses to show
a seventeen-day fertile window. The caller can tell "no period due here"
from "cannot see this far", and only the second invites her to log more.

The assumption is stated, not implied. Any month showing projected marks
carries "If your cycles continue as they have, this is the forecast" with
the confidence for that distance beside it. A year-ahead date drawn
without that is the clearest overstatement this app could make.

Cycle 1 is the engine's own forecast copied through unchanged, so the
calendar and the Today screen cannot disagree about the next period. The
typical cycle length comes from her own history and never a population
default -- projecting 29 days for a 35-day user is §3's core defect
repeated twelve times over.

PROJECTED_PERIOD is its own mark: the same dashed ring as a prediction,
thinner and finer. The family resemblance is the message -- still a
period, weaker claim -- and it survives greyscale, which an opacity
difference would not. Screen readers get the distance in the label, since
they cannot see that a square is months away.

Browsing now reaches a year; logging still stops at today.

ConfidenceRow and readable() move to feature/common and are shared with
the Today screen rather than copied. Two of them drifting apart is a
screen saying Medium in dots and Low in words.

closes #57

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-20 16:38:30 -05:00
parent 426f5992c7
commit 866b7055f5
10 changed files with 714 additions and 32 deletions

View File

@ -53,6 +53,8 @@ import dev.privacyllc.period.designsystem.art.ConfirmedPeriodMarker
import dev.privacyllc.period.designsystem.art.FertileWindowMarker
import dev.privacyllc.period.designsystem.art.OvulationMarker
import dev.privacyllc.period.designsystem.art.PredictedPeriodMarker
import dev.privacyllc.period.designsystem.art.ProjectedPeriodMarker
import dev.privacyllc.period.feature.common.ConfidenceRow
import dev.privacyllc.period.domain.prediction.CalendarDay
import dev.privacyllc.period.domain.prediction.CalendarMarks
import dev.privacyllc.period.domain.prediction.DayMark
@ -115,6 +117,26 @@ private fun CalendarContent(
WeekdayRow()
Spacer(Modifier.height(4.dp))
MonthGrid(state, onSelect)
// The assumption, said out loud on any month that is showing one.
//
// A dashed ring on a March square is a claim, and the user cannot see
// from the mark alone what it rests on. §27: do not overstate accuracy —
// and a year-ahead date drawn without its "if nothing changes" is the
// clearest overstatement this app could make.
state.projectionNote?.let { note ->
Spacer(Modifier.height(16.dp))
Card(Modifier.fillMaxWidth()) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Text(note, style = MaterialTheme.typography.bodyMedium)
state.projectedConfidence?.let {
Spacer(Modifier.height(6.dp))
ConfidenceRow(it, prefix = "Confidence this far ahead")
}
}
}
}
Spacer(Modifier.height(24.dp))
Legend()
@ -233,6 +255,7 @@ private fun Marker(mark: DayMark) {
when (mark) {
DayMark.CONFIRMED_PERIOD -> ConfirmedPeriodMarker(cycle.periodConfirmed, size = dp)
DayMark.PREDICTED_PERIOD -> PredictedPeriodMarker(cycle.periodPredicted, size = dp)
DayMark.PROJECTED_PERIOD -> ProjectedPeriodMarker(cycle.periodPredicted, size = dp)
DayMark.FERTILE_WINDOW -> FertileWindowMarker(cycle.fertileWindow, size = dp)
// Ovulation is the ring PLUS a small star low in the cell.
//
@ -288,6 +311,7 @@ private fun Legend() {
listOf(
DayMark.CONFIRMED_PERIOD to "Period you logged",
DayMark.PREDICTED_PERIOD to "Period predicted",
DayMark.PROJECTED_PERIOD to "Period projected further ahead",
DayMark.SPOTTING to "Spotting",
DayMark.FERTILE_WINDOW to "Estimated fertile window",
DayMark.OVULATION to "Estimated ovulation",

View File

@ -6,6 +6,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dev.privacyllc.period.core.data.CycleRepository
import dev.privacyllc.period.domain.prediction.CalendarDay
import dev.privacyllc.period.domain.prediction.CalendarMarks
import dev.privacyllc.period.domain.prediction.ConfidenceLabel
import dev.privacyllc.period.domain.prediction.CycleProjection
import dev.privacyllc.period.domain.prediction.DayMark
import dev.privacyllc.period.domain.prediction.FertilityEstimate
import kotlinx.coroutines.CoroutineExceptionHandler
@ -27,17 +29,53 @@ data class CalendarUiState(
val days: List<CalendarDay> = emptyList(),
val today: LocalDate = LocalDate.ofEpochDay(0),
val message: String? = null,
val projection: CycleProjection = CycleProjection.Empty,
) {
/** Blank leading cells so the 1st lands under its weekday. Monday-first. */
val leadingBlanks: Int
get() = days.firstOrNull()?.date?.dayOfWeek?.value?.minus(1) ?: 0
/** Nothing ahead of today can be logged, so next-month browsing stops there. */
/**
* How far forward browsing goes.
*
* A year, since the calendar now has something to show out there. Logging
* still stops at today [CalendarViewModel.select] refuses a future day
* so this widens what can be *seen*, never what can be entered.
*/
val canGoForward: Boolean get() = month < YearMonth.from(today).plusMonths(FORWARD_MONTHS)
/** True when this month is showing projections rather than the next forecast. */
val showsProjection: Boolean get() = days.any { it.mark == DayMark.PROJECTED_PERIOD }
/**
* The sentence a month of projections has to carry, or null when the month
* needs no caveat.
*
* Two different empty months, deliberately distinguished: one where the app
* cannot see this far, and one where it simply has no period to draw. Only
* the first is worth saying anything about, and it is the one that tells her
* logging more would help.
*/
val projectionNote: String?
get() = when {
showsProjection -> CycleProjection.PROJECTION_ASSUMPTION
beyondReach -> CycleProjection.BEYOND_HORIZON
else -> null
}
/** This month is past everything the history can support. */
private val beyondReach: Boolean
get() = !projection.reachedHorizon &&
projection.lastProjectedDate?.let { month > YearMonth.from(it) } ?: false
/** Confidence in the projection covering this month, for the row beside the note. */
val projectedConfidence: ConfidenceLabel?
get() = days.firstOrNull { it.mark == DayMark.PROJECTED_PERIOD }
?.let { projection.cycleCovering(it.date)?.confidenceLabel }
private companion object {
/** One month ahead, so a predicted period near a month boundary is visible. */
const val FORWARD_MONTHS = 1L
/** A year, matching how far [CycleProjection] will ever reach. */
const val FORWARD_MONTHS = CycleProjection.MAX_MONTHS_AHEAD
}
}
@ -66,6 +104,20 @@ class CalendarViewModel @Inject constructor(
message,
) { m, periods, spotting, forecast, msg ->
val fertility = FertilityEstimate.from(forecast)
val today = LocalDate.now(clock)
// Her own typical cycle, never a population default: projecting a
// year on 29 days for a 35-day user is the §3 defect repeated
// twelve times over. With too little history to have a typical
// length there is nothing to project, and CycleProjection returns
// nothing rather than assuming one.
val lengths = periods.map { it.startDate }.sorted()
.zipWithNext { a, b -> b.toEpochDay() - a.toEpochDay() }
.filter { it >= MINIMUM_PLAUSIBLE_CYCLE }
val typical = if (lengths.isEmpty()) 0.0 else lengths.sorted()[lengths.size / 2].toDouble()
val projection = CycleProjection.from(forecast, typical, today)
CalendarUiState(
month = m,
days = CalendarMarks.forMonth(
@ -73,15 +125,22 @@ class CalendarViewModel @Inject constructor(
periods = periods,
spotting = spotting,
forecast = forecast,
today = LocalDate.now(clock),
today = today,
fertileWindow = fertility?.fertileWindow,
ovulation = fertility?.ovulationDate,
projection = projection,
),
today = LocalDate.now(clock),
today = today,
message = msg,
projection = projection,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), CalendarUiState())
private companion object {
/** Below this a gap between starts is not a cycle. Mirrors IntervalAnalysis. */
const val MINIMUM_PLAUSIBLE_CYCLE = 10L
}
fun previousMonth() { month.value = month.value.minusMonths(1) }
fun nextMonth() { if (state.value.canGoForward) month.value = month.value.plusMonths(1) }

View File

@ -0,0 +1,51 @@
package dev.privacyllc.period.feature.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import dev.privacyllc.period.domain.prediction.ConfidenceLabel
/**
* How sure the app is, in the one form it says it.
*
* Shared rather than copied, because two of these drifting apart is a screen
* telling a user "Medium" in dots and "Low" in words. It lives here rather than
* in `core/designsystem` for a boundary reason: the design system draws things
* and knows nothing about a forecast, and giving it `ConfidenceLabel` would put
* `domain/prediction` on its compile path for one enum.
*
* The dots are never the whole message. §43: `clearAndSetSemantics` replaces the
* glyphs with the word, so a screen reader gets "Prediction confidence: High"
* instead of six bullet characters read aloud one at a time.
*/
@Composable
fun ConfidenceRow(label: ConfidenceLabel, prefix: String = "Prediction confidence") {
val filled = when (label) {
ConfidenceLabel.LOW -> 1
ConfidenceLabel.MEDIUM -> 2
ConfidenceLabel.HIGH -> 3
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.clearAndSetSemantics {
contentDescription = "$prefix: ${label.readable()}"
},
) {
Text("".repeat(filled) + "".repeat(3 - filled), color = MaterialTheme.colorScheme.primary)
Text(label.readable(), style = MaterialTheme.typography.bodyMedium)
}
}
fun ConfidenceLabel.readable(): String = when (this) {
ConfidenceLabel.LOW -> "Low"
ConfidenceLabel.MEDIUM -> "Medium"
ConfidenceLabel.HIGH -> "High"
}

View File

@ -47,6 +47,8 @@ import dev.privacyllc.period.designsystem.art.CycleProgressMark
import dev.privacyllc.period.designsystem.art.EmptyStateIllustration
import dev.privacyllc.period.domain.cycle.PeriodRecord
import dev.privacyllc.period.domain.prediction.ConfidenceLabel
import dev.privacyllc.period.feature.common.ConfidenceRow
import dev.privacyllc.period.feature.common.readable
import dev.privacyllc.period.domain.prediction.CycleStatus
import dev.privacyllc.period.domain.prediction.FertilityEstimate
import dev.privacyllc.period.domain.prediction.FertilityLikelihood
@ -215,24 +217,6 @@ private fun ForecastDetail(forecast: Prediction) {
* Never dots alone: §43 forbids relying on a visual cue by itself, and "how
* sure is it" is precisely the thing a screen reader user needs.
*/
@Composable
private fun ConfidenceRow(label: ConfidenceLabel) {
val filled = when (label) {
ConfidenceLabel.LOW -> 1
ConfidenceLabel.MEDIUM -> 2
ConfidenceLabel.HIGH -> 3
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.clearAndSetSemantics {
contentDescription = "Prediction confidence: ${label.readable()}"
},
) {
Text("".repeat(filled) + "".repeat(3 - filled), color = MaterialTheme.colorScheme.primary)
Text(label.readable(), style = MaterialTheme.typography.bodyMedium)
}
}
/**
* §21's fertility block, and §18's vocabulary.
@ -610,12 +594,6 @@ private fun PeriodOrSpottingDialog(onPeriod: () -> Unit, onSpotting: () -> Unit)
// ---------------------------------------------------------------------------
internal fun ConfidenceLabel.readable(): String = when (this) {
ConfidenceLabel.LOW -> "Low"
ConfidenceLabel.MEDIUM -> "Medium"
ConfidenceLabel.HIGH -> "High"
}
private val longFormat = DateTimeFormatter.ofPattern("d MMMM")
private val shortFormat = DateTimeFormatter.ofPattern("d MMM")
private fun LocalDate.pretty(): String = format(longFormat)

View File

@ -76,6 +76,38 @@ fun PredictedPeriodMarker(
}
}
/**
* A period projected months ahead, on the assumption nothing changes.
*
* The same dashed ring as [PredictedPeriodMarker], drawn thinner and with finer
* dashes: the family resemblance is the point it is still a period mark and
* the lighter weight is the claim being weaker. A different shape would say
* "different kind of thing"; this says "same thing, less certainly", which is
* what it is.
*
* Never distinguished by colour alone (§43), and never by opacity alone, which
* is the distinction that disappears on a dim screen.
*/
@Composable
fun ProjectedPeriodMarker(
color: Color,
modifier: Modifier = Modifier,
size: Dp = MarkerDefaults.Size,
) {
Canvas(modifier.size(size)) {
val stroke = this.size.minDimension * MarkerDefaults.StrokeFraction * 0.6f
val dash = this.size.minDimension / 18f
drawCircle(
color = color,
radius = (this.size.minDimension - stroke) / 2f,
style = Stroke(
width = stroke,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(dash, dash)),
),
)
}
}
/** The estimated fertile window. A continuous ring — open, unlike the solid confirmed disc. */
@Composable
fun FertileWindowMarker(

View File

@ -348,7 +348,7 @@ versions of the text out — so every combination is tested without an emulator.
### The prediction engine
`PersonalPredictionEngine` (`modelVersion` `personal-1`) is what the app ships.
`PersonalPredictionEngine` (`modelVersion` `personal-2`) is what the app ships.
It keeps a **discrete probability distribution over candidate start dates**
rather than a date with a margin bolted on, and everything the product needs
falls out of that one structure: the most likely date is its mode, the window is
@ -356,6 +356,40 @@ the narrowest span holding 80% of its mass, and a "Not yet" is the distribution
being conditioned on what the user just said. A date-plus-margin design cannot
express that last one, which is why §13 is the reason for the shape.
### Projecting a year, without claiming to know one
`CycleProjection` reaches past the next period so the calendar can answer *"will
I have my period the week of the wedding?"*. It is a **separate type from
`Prediction`, deliberately**: a forecast is something the app scores itself on —
the period arrives inside the window or it does not, and `PredictionRecord`
writes down which — while a projection eleven cycles out is never scored, never
learned from, and will have been replaced four times before its date arrives.
Separate types mean a projection cannot be snapshotted into the accuracy figures
by accident.
Three rules hold it honest, and each has a test named after it:
- **Uncertainty grows as √n.** Cycle lengths are near-independent draws, so
eleven cycles out is about three times as uncertain as one, not eleven times.
The independence is an approximation — real cycles correlate — which is why the
square root is the *optimistic* edge and the assumption below is mandatory.
- **It declines rather than stretching.** Past ten days either side, a projection
stops being an answer, so the projection ends and reports that it ended. This
is `FertilityEstimate`'s precedent applied again: a window covering half a
cycle says nothing while looking like it said something.
- **The assumption is stated, not implied.** Any surface showing projected marks
carries `CycleProjection.PROJECTION_ASSUMPTION` — *"If your cycles continue as
they have, this is the forecast"* — with the confidence for that distance
beside it. A year-ahead date drawn without it is the clearest overstatement
this app could make (§27).
`DayMark.PROJECTED_PERIOD` is separate from `PREDICTED_PERIOD` for the same
reason the types are: the two make different promises, and drawing them
identically would be the calendar making the weaker claim in the stronger one's
voice. Cycle 1 of a projection is the engine's own forecast copied through
unchanged, so the calendar and the Today screen cannot disagree about the next
period.
Five decisions, each measured rather than assumed:
| Decision | Why |

View File

@ -68,6 +68,14 @@ marker — distinguishable in greyscale, because that is also what makes them
distinguishable to a colourblind user and to a screenshot in a bug report.
Predicted and confirmed days must never look identical.
A period *projected* months ahead is a fifth state, and it is the one case where
the family resemblance is the message: the same dashed ring as a prediction,
drawn thinner and finer, because it is still a period mark making a weaker claim.
A different shape would say "different kind of thing"; a lighter opacity of the
same ring would say nothing at all on a dim screen. The weaker claim also has to
be said in words — the month carries its assumption sentence and the confidence
for that distance — since no marker can carry "if nothing changes" on its own.
**4. Ads never touch a health action.** No banner in onboarding, in the
period-start confirmation, in the period-end confirmation, or between steps of a
health workflow — and never an interstitial after logging

View File

@ -22,9 +22,25 @@ enum class DayMark {
/** Recorded by the user, and deliberately not a period. */
SPOTTING,
/** Inside the forecast window. */
/** Inside the forecast window — the next period, the one the app scores itself on. */
PREDICTED_PERIOD,
/**
* Inside a projected window further out than the next period.
*
* Ranked below [PREDICTED_PERIOD] and above the fertility estimates, which
* is the same rule the rest of this list follows: an estimate the app will
* be held to outranks one it will not, and both outrank a vaguer estimate
* derived from them.
*
* Distinct from [PREDICTED_PERIOD] rather than reusing it, because the two
* make different promises. The next period is a forecast conditioned on
* everything the app knows today; a projection assumes nothing changes for
* months. Drawing them identically would be the calendar making the second
* claim in the first one's voice.
*/
PROJECTED_PERIOD,
/** The single estimated ovulation day. Batch 04. */
OVULATION,
@ -38,6 +54,8 @@ data class CalendarDay(
val date: LocalDate,
val mark: DayMark,
val isToday: Boolean,
/** Set only on [DayMark.PROJECTED_PERIOD]: how far out this square is reaching. */
val monthsAhead: Int? = null,
) {
/**
* What a screen reader says.
@ -52,6 +70,15 @@ data class CalendarDay(
DayMark.CONFIRMED_PERIOD -> append(", period")
DayMark.SPOTTING -> append(", spotting")
DayMark.PREDICTED_PERIOD -> append(", period predicted")
DayMark.PROJECTED_PERIOD -> {
append(", period projected")
// How far out, in the label itself. A screen reader user cannot
// see that this square is months away from the one the forecast
// covers, and "period projected" alone would read as the same
// claim. §43: the marker shape is what a sighted user reads;
// this is the same fact for everybody else.
monthsAhead?.let { append(", about $it month${if (it == 1) "" else "s"} away") }
}
DayMark.OVULATION -> append(", estimated ovulation")
DayMark.FERTILE_WINDOW -> append(", estimated fertile window")
DayMark.NONE -> Unit
@ -77,6 +104,11 @@ object CalendarMarks {
today: LocalDate,
fertileWindow: ClosedRange<LocalDate>? = null,
ovulation: LocalDate? = null,
/**
* Cycles beyond the next one. Empty by default, so a caller that has not
* opted into projecting a year ahead gets exactly the calendar it had.
*/
projection: CycleProjection = CycleProjection.Empty,
): List<CalendarDay> {
val confirmed = buildSet {
periods.filter { it.isConfirmed }.forEach { p ->
@ -92,10 +124,15 @@ object CalendarMarks {
return (1..month.lengthOfMonth()).map { day ->
val date = month.atDay(day)
val projected = projection.cycleCovering(date)?.takeIf { it.cyclesAhead > 1 }
val mark = markFor(date, confirmed, spotted, forecast, projected, fertileWindow, ovulation)
CalendarDay(
date = date,
mark = markFor(date, confirmed, spotted, forecast, fertileWindow, ovulation),
mark = mark,
isToday = date == today,
monthsAhead = projected
?.takeIf { mark == DayMark.PROJECTED_PERIOD }
?.let { monthsBetween(today, date) },
)
}
}
@ -105,6 +142,7 @@ object CalendarMarks {
confirmed: Set<LocalDate>,
spotted: Set<LocalDate>,
forecast: Prediction?,
projected: ProjectedCycle?,
fertileWindow: ClosedRange<LocalDate>?,
ovulation: LocalDate?,
): DayMark = when {
@ -112,8 +150,13 @@ object CalendarMarks {
date in spotted -> DayMark.SPOTTING
forecast != null && date >= forecast.windowStart && date <= forecast.windowEnd ->
DayMark.PREDICTED_PERIOD
projected != null -> DayMark.PROJECTED_PERIOD
ovulation != null && date == ovulation -> DayMark.OVULATION
fertileWindow != null && date in fertileWindow -> DayMark.FERTILE_WINDOW
else -> DayMark.NONE
}
/** Rounded to the nearest month, for a label that says "about". */
private fun monthsBetween(today: LocalDate, date: LocalDate): Int =
maxOf(1, Math.round((date.toEpochDay() - today.toEpochDay()) / 30.44).toInt())
}

View File

@ -0,0 +1,208 @@
package dev.privacyllc.period.domain.prediction
import java.time.LocalDate
import kotlin.math.roundToLong
import kotlin.math.sqrt
/**
* One forecast further out than the next one *"will I have my period on the
* week of the wedding?"*
*
* ## Why this is a separate type rather than a longer forecast
*
* [Prediction] answers a question the app can score: the period after the last
* confirmed start either arrives inside its window or it does not, and
* `PredictionRecord` writes down which. A projection eleven cycles out is not
* that. Nothing scores it, nothing learns from it, and by the time it could be
* scored the user will have logged four periods that replaced it.
*
* Giving it its own type keeps that difference honest in the type system: a
* projection cannot be handed to anything expecting a forecast, cannot be
* snapshotted into the accuracy figures, and carries [cyclesAhead] so nothing
* downstream can forget how far it is reaching.
*
* ## The assumption, stated once
*
* Every projection past the first assumes **nothing changes** that her cycles
* go on behaving as they have, including a trend if one is running. That is a
* real assumption about a real body, and it is the one thing a user must be told
* rather than left to infer from a dashed outline. [PROJECTION_ASSUMPTION] is
* that sentence, and it lives here beside the arithmetic it describes rather
* than in a screen, because any surface that renders these has to say it.
*
* ## Why the window widens, and how fast
*
* Each cycle's length is an independent draw, so uncertainty accumulates as the
* square root of the number of cycles rather than linearly eleven cycles out
* is about three times as uncertain as one, not eleven times. That is the whole
* of [scaleFor].
*
* The independence is an approximation and worth naming: cycles are correlated
* in real bodies, and a woman entering a period of upheaval will see them move
* together. Square-root growth is therefore the optimistic edge of the range,
* which is the second reason the assumption sentence is mandatory rather than
* decorative.
*
* ## It stops rather than stretching
*
* Past [MAX_USEFUL_HALF_WIDTH] days of uncertainty a projection stops being an
* answer. Nineteen days either side of a date is not a forecast, it is a
* shrug and the app already has a precedent for what to do about that, in
* `FertilityEstimate`, which declines rather than showing a seventeen-day
* fertile window. So does this: [project] returns the cycles it can stand
* behind and stops, and the caller is told where it stopped rather than left to
* work it out from an empty month.
*/
data class ProjectedCycle(
/** 1 is the next period — the same date [Prediction] forecasts. */
val cyclesAhead: Int,
val mostLikelyStartDate: LocalDate,
val windowStart: LocalDate,
val windowEnd: LocalDate,
val confidenceLabel: ConfidenceLabel,
) {
init {
require(cyclesAhead >= 1) { "a projection reaches at least one cycle ahead" }
require(!windowStart.isAfter(windowEnd)) { "window start is after window end" }
require(mostLikelyStartDate in windowStart..windowEnd) {
"most likely start falls outside its own window"
}
}
/** Dateless, for the reason [Prediction] gives. */
override fun toString(): String =
"ProjectedCycle(cyclesAhead=$cyclesAhead, confidence=$confidenceLabel)"
}
/**
* A year of projections, and where they had to stop.
*
* [reachedHorizon] is false when uncertainty ran out before the calendar did.
* Callers need the difference: "nothing to show here because your period is not
* due" and "nothing to show here because we cannot see that far" are different
* sentences, and only one of them is an invitation to keep logging.
*/
data class CycleProjection(
val cycles: List<ProjectedCycle>,
val reachedHorizon: Boolean,
) {
val lastProjectedDate: LocalDate? get() = cycles.lastOrNull()?.windowEnd
fun cycleCovering(date: LocalDate): ProjectedCycle? =
cycles.firstOrNull { date >= it.windowStart && date <= it.windowEnd }
companion object {
val Empty = CycleProjection(emptyList(), reachedHorizon = false)
/**
* The sentence any surface showing a projection has to carry.
*
* Not "your period will start on the 14th of March". The difference
* between those two is the entire honesty of this feature.
*/
const val PROJECTION_ASSUMPTION =
"If your cycles continue as they have, this is the forecast."
/** What to say instead when the history cannot reach that far. */
const val BEYOND_HORIZON =
"Too far ahead for your history to say. Keep logging and this will reach further."
/** How far out the calendar will ever look. A year, and not one cycle more. */
const val MAX_MONTHS_AHEAD = 12L
/**
* Past this much uncertainty either side, a projection is a shrug.
*
* Ten days either side is already a three-week window. Wider than that
* and marking a calendar with it tells the user nothing while looking
* like it told her something, which is the failure `FertilityEstimate`
* ran into on a real screen and now refuses.
*/
const val MAX_USEFUL_HALF_WIDTH = 10
/**
* Project forward from a scored forecast.
*
* [forecast] is cycle 1 and is copied through unchanged the near
* forecast is the engine's, not a re-derivation of it, so the calendar
* and the Today screen can never disagree about the next period.
*/
fun from(
forecast: Prediction?,
typicalCycleDays: Double,
today: LocalDate,
): CycleProjection {
forecast ?: return Empty
if (typicalCycleDays <= 0.0) return Empty
val firstHalfWidth =
(forecast.windowEnd.toEpochDay() - forecast.windowStart.toEpochDay()) / 2.0
val horizon = today.plusMonths(MAX_MONTHS_AHEAD)
val cycles = mutableListOf(
ProjectedCycle(
cyclesAhead = 1,
mostLikelyStartDate = forecast.mostLikelyStartDate,
windowStart = forecast.windowStart,
windowEnd = forecast.windowEnd,
confidenceLabel = forecast.confidenceLabel,
),
)
var reachedHorizon = true
var ahead = 2
while (true) {
val centre = forecast.mostLikelyStartDate
.plusDays((typicalCycleDays * (ahead - 1)).roundToLong())
val halfWidth = scaleFor(firstHalfWidth, ahead).roundToLong()
if (halfWidth > MAX_USEFUL_HALF_WIDTH) {
// Ran out of certainty before running out of calendar.
reachedHorizon = false
break
}
if (centre.minusDays(halfWidth).isAfter(horizon)) break // ran out of calendar
cycles += ProjectedCycle(
cyclesAhead = ahead,
mostLikelyStartDate = centre,
windowStart = centre.minusDays(halfWidth),
windowEnd = centre.plusDays(halfWidth),
confidenceLabel = labelFor(forecast.confidenceLabel, ahead),
)
ahead++
}
return CycleProjection(cycles, reachedHorizon)
}
/**
* Half-width [ahead] cycles out, from the half-width of the first.
*
* Independent draws accumulate as sqrt(n). A floor of half a day keeps a
* very confident user's later projections from collapsing to a point,
* which would be the same overclaim in the other direction.
*/
private fun scaleFor(firstHalfWidth: Double, ahead: Int): Double =
maxOf(firstHalfWidth, 0.5) * sqrt(ahead.toDouble())
/**
* Confidence decays with distance, and can only fall.
*
* A projection is never more trustworthy than the forecast it is built
* on, so this starts from that label and steps down High survives a
* few cycles, Medium fewer, and Low was never going to become anything
* else. The thresholds are deliberately coarse: this decides whether a
* user sees three dots or one, not a number.
*/
private fun labelFor(base: ConfidenceLabel, ahead: Int): ConfidenceLabel = when (base) {
ConfidenceLabel.HIGH -> when {
ahead <= 3 -> ConfidenceLabel.HIGH
ahead <= 7 -> ConfidenceLabel.MEDIUM
else -> ConfidenceLabel.LOW
}
ConfidenceLabel.MEDIUM -> if (ahead <= 3) ConfidenceLabel.MEDIUM else ConfidenceLabel.LOW
ConfidenceLabel.LOW -> ConfidenceLabel.LOW
}
}
}

View File

@ -0,0 +1,245 @@
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
import java.time.YearMonth
/**
* A year of calendar, without pretending to know a year of body.
*
* The trap this feature walks into if nobody is watching is the one the widget
* library it was inspired by fell straight into: stamp a fixed cycle length
* twelve months forward and draw it exactly like the next period. Every test
* here exists to stop one part of that the window has to widen with distance,
* the confidence has to fall, the marks have to be visibly a different claim,
* and past the point the history can support it the app has to stop.
*/
class CycleProjectionTest {
private val today = LocalDate.of(2026, 8, 20)
private fun forecast(
start: LocalDate = LocalDate.of(2026, 8, 28),
halfWidth: Long = 1,
label: ConfidenceLabel = ConfidenceLabel.HIGH,
) = Prediction(
mostLikelyStartDate = start,
windowStart = start.minusDays(halfWidth),
windowEnd = start.plusDays(halfWidth),
confidenceScore = when (label) {
ConfidenceLabel.HIGH -> 0.7
ConfidenceLabel.MEDIUM -> 0.45
ConfidenceLabel.LOW -> 0.2
},
confidenceLabel = label,
modelVersion = "test",
)
private fun widthOf(c: ProjectedCycle) = c.windowEnd.toEpochDay() - c.windowStart.toEpochDay()
// -----------------------------------------------------------------------
// Reach
// -----------------------------------------------------------------------
@Test
fun `a stable cycle is projected across the whole year`() {
val p = CycleProjection.from(forecast(), typicalCycleDays = 29.0, today = today)
assertTrue("only reached ${p.cycles.size} cycles", p.cycles.size >= 12)
assertTrue("stopped short of the horizon for a stable user", p.reachedHorizon)
// Reaching next spring, without asserting a particular day is inside a
// window — most days are between periods, which is rather the point.
assertNotNull(
"nothing projected as far as spring 2027",
p.cycles.lastOrNull()?.takeIf { it.windowStart >= LocalDate.of(2027, 4, 1) },
)
}
@Test
fun `the first projected cycle is the engine's own forecast, untouched`() {
// The calendar and the Today screen must never disagree about the next
// period. Re-deriving it here is how they would start to.
val f = forecast()
val first = CycleProjection.from(f, 29.0, today).cycles.first()
assertEquals(1, first.cyclesAhead)
assertEquals(f.mostLikelyStartDate, first.mostLikelyStartDate)
assertEquals(f.windowStart, first.windowStart)
assertEquals(f.windowEnd, first.windowEnd)
assertEquals(f.confidenceLabel, first.confidenceLabel)
}
@Test
fun `no forecast means no projection rather than a year of guesses`() {
assertEquals(CycleProjection.Empty, CycleProjection.from(null, 29.0, today))
assertTrue(CycleProjection.from(forecast(), typicalCycleDays = 0.0, today = today).cycles.isEmpty())
}
// -----------------------------------------------------------------------
// Honesty about distance
// -----------------------------------------------------------------------
@Test
fun `the window widens the further out it reaches`() {
val cycles = CycleProjection.from(forecast(), 29.0, today).cycles
cycles.zipWithNext { near, far ->
assertTrue(
"cycle ${far.cyclesAhead} (${widthOf(far)}d) is not wider than " +
"cycle ${near.cyclesAhead} (${widthOf(near)}d)",
widthOf(far) >= widthOf(near),
)
}
// And strictly wider across a real distance, not merely non-decreasing.
assertTrue(widthOf(cycles.last()) > widthOf(cycles.first()))
}
@Test
fun `confidence falls with distance and never rises`() {
val cycles = CycleProjection.from(forecast(label = ConfidenceLabel.HIGH), 29.0, today).cycles
val rank = mapOf(ConfidenceLabel.LOW to 0, ConfidenceLabel.MEDIUM to 1, ConfidenceLabel.HIGH to 2)
cycles.zipWithNext { near, far ->
assertTrue(
"confidence rose from ${near.confidenceLabel} to ${far.confidenceLabel}",
rank.getValue(far.confidenceLabel) <= rank.getValue(near.confidenceLabel),
)
}
assertEquals(ConfidenceLabel.LOW, cycles.last().confidenceLabel)
}
@Test
fun `a projection is never more confident than the forecast it hangs off`() {
CycleProjection.from(forecast(label = ConfidenceLabel.LOW), 29.0, today).cycles.forEach {
assertEquals(ConfidenceLabel.LOW, it.confidenceLabel)
}
}
// -----------------------------------------------------------------------
// Declining rather than stretching
// -----------------------------------------------------------------------
@Test
fun `an uncertain cycle stops early instead of drawing a shrug`() {
// Six days either side already; sqrt growth passes the useful ceiling
// within a couple of cycles. FertilityEstimate's precedent: a window
// that covers half a cycle says nothing and looks like it said something.
val p = CycleProjection.from(forecast(halfWidth = 6, label = ConfidenceLabel.LOW), 29.0, today)
assertFalse("claimed to reach the horizon on a vague forecast", p.reachedHorizon)
assertTrue("projected ${p.cycles.size} cycles from a six-day window", p.cycles.size < 6)
p.cycles.forEach {
assertTrue(
"cycle ${it.cyclesAhead} is ${widthOf(it)} days wide",
widthOf(it) <= CycleProjection.MAX_USEFUL_HALF_WIDTH * 2L,
)
}
}
@Test
fun `stopping for want of certainty is distinguishable from stopping for want of calendar`() {
val stable = CycleProjection.from(forecast(), 29.0, today)
val vague = CycleProjection.from(forecast(halfWidth = 6, label = ConfidenceLabel.LOW), 29.0, today)
// Same empty month, two different sentences — and only one of them is
// an invitation to keep logging.
assertTrue(stable.reachedHorizon)
assertFalse(vague.reachedHorizon)
}
@Test
fun `nothing is projected past a year`() {
val p = CycleProjection.from(forecast(), 29.0, today)
val horizon = today.plusMonths(CycleProjection.MAX_MONTHS_AHEAD)
p.cycles.forEach {
assertTrue(
"cycle ${it.cyclesAhead} opens ${it.windowStart} — past the year",
!it.windowStart.isAfter(horizon),
)
}
}
@Test
fun `the same inputs give the same projection`() {
assertEquals(
CycleProjection.from(forecast(), 29.0, today),
CycleProjection.from(forecast(), 29.0, today),
)
}
// -----------------------------------------------------------------------
// What the calendar makes of it
// -----------------------------------------------------------------------
@Test
fun `a far month is marked as projected, not as the forecast`() {
val f = forecast()
val p = CycleProjection.from(f, 29.0, today)
val thisMonth = CalendarMarks.forMonth(
YearMonth.of(2026, 8), emptyList(), emptyList(), f, today, projection = p,
)
val nextSpring = CalendarMarks.forMonth(
YearMonth.of(2027, 3), emptyList(), emptyList(), f, today, projection = p,
)
// The next period keeps the mark the app scores itself on.
assertTrue(thisMonth.any { it.mark == DayMark.PREDICTED_PERIOD })
assertTrue(thisMonth.none { it.mark == DayMark.PROJECTED_PERIOD })
// Months away it is a different claim, and says so.
assertTrue(nextSpring.any { it.mark == DayMark.PROJECTED_PERIOD })
assertTrue(nextSpring.none { it.mark == DayMark.PREDICTED_PERIOD })
}
@Test
fun `a projected day tells a screen reader how far out it is`() {
val f = forecast()
val day = CalendarMarks.forMonth(
YearMonth.of(2027, 3), emptyList(), emptyList(), f, today,
projection = CycleProjection.from(f, 29.0, today),
).first { it.mark == DayMark.PROJECTED_PERIOD }
assertTrue(day.accessibilityLabel, day.accessibilityLabel.contains("period projected"))
assertTrue(day.accessibilityLabel, day.accessibilityLabel.contains("months away"))
}
@Test
fun `a confirmed period still outranks a projection on the same day`() {
val f = forecast()
val p = CycleProjection.from(f, 29.0, today)
val projectedDay = CalendarMarks.forMonth(
YearMonth.of(2027, 3), emptyList(), emptyList(), f, today, projection = p,
).first { it.mark == DayMark.PROJECTED_PERIOD }.date
val marks = CalendarMarks.forMonth(
YearMonth.of(2027, 3),
listOf(
dev.privacyllc.period.domain.cycle.PeriodRecord(
id = 1, startDate = projectedDay, endDate = projectedDay,
),
),
emptyList(), f, today, projection = p,
)
// A fact outranks an estimate — the existing rule, still holding with a
// new estimate in the list.
assertEquals(DayMark.CONFIRMED_PERIOD, marks.first { it.date == projectedDay }.mark)
}
@Test
fun `a caller that does not ask for projections gets the calendar it had`() {
val f = forecast()
val nextSpring = CalendarMarks.forMonth(
YearMonth.of(2027, 3), emptyList(), emptyList(), f, today,
)
assertTrue(nextSpring.all { it.mark == DayMark.NONE })
assertNull(nextSpring.first().monthsAhead)
}
}