diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt index 49f44ed..d76a557 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt @@ -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", diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarViewModel.kt index 06982a2..d0725ec 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarViewModel.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarViewModel.kt @@ -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 = 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) } diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/common/Confidence.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/common/Confidence.kt new file mode 100644 index 0000000..5f0d264 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/common/Confidence.kt @@ -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" +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt index 68e9db9..828dc49 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt @@ -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) diff --git a/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt index 7739187..bc485f2 100644 --- a/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt +++ b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt @@ -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( diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0af8945..06d44f3 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 | diff --git a/docs/design/README.md b/docs/design/README.md index f4441c1..88e0652 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -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 diff --git a/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/CalendarMarks.kt b/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/CalendarMarks.kt index e5623a1..3ac9bdd 100644 --- a/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/CalendarMarks.kt +++ b/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/CalendarMarks.kt @@ -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? = 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 { 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, spotted: Set, forecast: Prediction?, + projected: ProjectedCycle?, fertileWindow: ClosedRange?, 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()) } diff --git a/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/CycleProjection.kt b/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/CycleProjection.kt new file mode 100644 index 0000000..db719d2 --- /dev/null +++ b/domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/CycleProjection.kt @@ -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, + 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 + } + } +} diff --git a/domain/prediction/src/test/kotlin/dev/privacyllc/period/domain/prediction/CycleProjectionTest.kt b/domain/prediction/src/test/kotlin/dev/privacyllc/period/domain/prediction/CycleProjectionTest.kt new file mode 100644 index 0000000..3c7302c --- /dev/null +++ b/domain/prediction/src/test/kotlin/dev/privacyllc/period/domain/prediction/CycleProjectionTest.kt @@ -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) + } +}