feat: repository layer, and stop backfilled history fabricating accuracy figures

core/data is the seam between storage and everything else. Reads return domain
types, cycles are derived rather than stored, and the forecast is a function of
the data instead of a field somebody has to remember to refresh — so §11's
"recalculate after a confirmed start, after an edit, after a Not yet" is
automatic rather than three call sites.

Confirming a period is four writes in one transaction, because a partial result
is a corrupt history rather than a failed action: write the record, score the
forecast that was standing, clear the "not yet" observations it resolved, and
snapshot a fresh forecast.

THE DEFECT THIS FOUND

A test expecting one scored prediction found three. The cause was not the test:
every historical period entered during onboarding was scoring the current
forecast against a date in the past, inventing an error for a prediction nobody
had ever been shown. §16's "your predictions are getting better" would have been
populated with figures the app made up about itself — plausible ones, which is
what makes it expensive to notice.

Two rules now, both pinned by tests:

  - exactly one unscored snapshot exists at a time. A forecast superseded before
    its outcome was known is not a wrong forecast, and counting it lets one
    cycle contribute several errors.
  - a confirmed start only scores a forecast made on or before it. Anything
    earlier is backfill and leaves the standing forecast alone.

Accuracy also stays quiet below three scored predictions. One lucky forecast
reading "average error: 0 days" is an overstatement, not a measurement.

THE ROOM BOUNDARY, HELD THREE WAYS

implementation rather than api on core:database; CycleRepository's constructor
internal because it names a PeriodDatabase; reads mapped to domain types in
Mappers.kt. Callers use CycleData.repository(context) and never learn Room
exists. Verified rather than asserted: grep -rn "androidx.room" app/src domain
is empty, and Room appears zero times in :app's debugCompileClasspath.

No fallbackToDestructiveMigration: it turns a forgotten migration into a silent
wipe of the user's entire cycle history on update.

Also fixed: `domain/*` inside a KDoc silently opened a nested block comment —
Kotlin block comments nest — which broke compilation in a way the error message
pointed nowhere near.

58 tests across the project, all passing.

closes #5
This commit is contained in:
null 2026-08-18 02:41:05 -05:00
parent 8d7a7252cb
commit 6d4592467f
10 changed files with 857 additions and 5 deletions

View File

@ -0,0 +1,42 @@
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "dev.privacyllc.period.core.data"
compileSdk = 37
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
dependencies {
// Room is an `implementation` dependency on purpose: it must NOT leak onto
// the compile classpath of anything above this module. That is half of what
// makes "no module above the repository imports Room" true rather than
// merely intended — the other half is the guard in issue #7.
implementation(project(":core:database"))
// Needed only to name RoomDatabase, PeriodDatabase's supertype, and to build
// the instance in CycleData. `implementation`, so it stops here.
implementation(libs.androidx.room.runtime)
api(project(":domain:cycle"))
api(project(":domain:prediction"))
implementation(libs.kotlinx.coroutines.core)
testImplementation(project(":core:database"))
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.room.runtime)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
}

View File

@ -0,0 +1,43 @@
package dev.privacyllc.period.core.data
import android.content.Context
import androidx.room.Room
import dev.privacyllc.period.core.database.PeriodDatabase
import dev.privacyllc.period.domain.prediction.BaselinePredictionEngine
import dev.privacyllc.period.domain.prediction.PredictionEngine
import java.time.Clock
/**
* The only way in.
*
* Everything above this module gets a [CycleRepository] and never learns that
* Room exists no `PeriodDatabase` in a signature, no `androidx.room` on a
* compile classpath, no DAO reachable from a ViewModel. That is what makes the
* boundary in docs/architecture/README.md a fact rather than an intention, and
* it is checkable: `grep -rn "androidx.room" app/src domain` returns nothing.
*
* The database file lives in app-private storage and is excluded from platform
* backup see docs/security/SECURITY.md and the manifest's
* `data_extraction_rules.xml`.
*/
object CycleData {
fun repository(
context: Context,
engine: PredictionEngine = BaselinePredictionEngine(),
clock: Clock = Clock.systemDefaultZone(),
): CycleRepository = CycleRepository(database(context), engine, clock)
private fun database(context: Context): PeriodDatabase =
Room.databaseBuilder(
context.applicationContext,
PeriodDatabase::class.java,
PeriodDatabase.NAME,
)
// No fallbackToDestructiveMigration, deliberately. It turns a
// forgotten migration into silent data loss on update — for this
// product, a user's entire cycle history gone with no error and no
// way back. A missing migration must be a crash in testing, not a
// wipe in production.
.build()
}

View File

@ -0,0 +1,254 @@
package dev.privacyllc.period.core.data
import dev.privacyllc.period.core.database.PeriodDatabase
import dev.privacyllc.period.core.database.entity.NotYetObservationEntity
import dev.privacyllc.period.core.database.entity.SpottingRecordEntity
import dev.privacyllc.period.domain.cycle.CycleRecord
import dev.privacyllc.period.domain.cycle.PeriodRecord
import dev.privacyllc.period.domain.cycle.PeriodRecordSource
import dev.privacyllc.period.domain.cycle.SpottingRecord
import dev.privacyllc.period.domain.cycle.toCycles
import dev.privacyllc.period.domain.prediction.NotYetObservation
import dev.privacyllc.period.domain.prediction.Prediction
import dev.privacyllc.period.domain.prediction.PredictionEngine
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import java.time.Clock
import java.time.LocalDate
import kotlin.math.abs
/**
* The seam between storage and the rest of the app.
*
* Everything above this returns domain types; nothing above this touches a DAO.
* Two consequences worth stating, because they are the reasons the module
* exists rather than side effects of it:
*
* - **Cycles are derived, never stored.** `toCycles()` recomputes them from the
* confirmed records on every read, so editing a record cannot leave a stale
* interval behind it. A `cycles` table would be a second copy of a fact the
* period records already hold, and the two would disagree the first time an
* edit missed one.
* - **The forecast is a function of the data, and recomputed with it.** There
* is no "current prediction" field anybody has to remember to refresh
* [forecast] combines the history and the "not yet" observations and asks
* the engine. §11 requires recalculation after a confirmed start, after an
* edit and after a "Not yet"; deriving it makes all three automatic.
*
* [clock] is injected because §50 asks for timezone, midnight, DST and leap-year
* cases to be tested, and none of those are testable against the system clock.
*
* **The constructor is internal on purpose.** It takes a [PeriodDatabase], and a
* public constructor would force every caller to be able to name that type
* which means Room on their compile classpath, which is the exact leak this
* module exists to prevent. Build one with [CycleData.repository] instead.
*/
class CycleRepository internal constructor(
private val db: PeriodDatabase,
private val engine: PredictionEngine,
private val clock: Clock = Clock.systemDefaultZone(),
) {
private val periodDao get() = db.periodRecordDao()
private val spottingDao get() = db.spottingRecordDao()
private val predictionDao get() = db.predictionRecordDao()
private val notYetDao get() = db.notYetObservationDao()
private fun today(): LocalDate = LocalDate.now(clock)
// -----------------------------------------------------------------------
// Reads
// -----------------------------------------------------------------------
val confirmedPeriods: Flow<List<PeriodRecord>> =
periodDao.observeConfirmed().map { rows -> rows.map { it.toDomain() } }
val allPeriods: Flow<List<PeriodRecord>> =
periodDao.observeAll().map { rows -> rows.map { it.toDomain() } }
val cycles: Flow<List<CycleRecord>> = confirmedPeriods.map { it.toCycles() }
val spotting: Flow<List<SpottingRecord>> =
spottingDao.observeAll().map { rows -> rows.map { it.toDomain() } }
val notYetObservations: Flow<List<NotYetObservation>> =
notYetDao.observeAll().map { rows -> rows.map { it.toDomain() } }
/**
* The live forecast.
*
* Null when there is no confirmed history [PredictionEngine] returns null
* rather than a confident guess, and the UI shows an empty state rather
* than a number it has not earned.
*/
val forecast: Flow<Prediction?> =
combine(confirmedPeriods, notYetObservations) { periods, notYet ->
engine.predict(
confirmedStarts = periods.map { it.startDate },
today = today(),
notYet = notYet,
)
}
/** §16's accuracy figures, computed on-device from stored snapshots. */
val accuracy: Flow<PredictionAccuracy> =
predictionDao.observeScored().map { rows ->
PredictionAccuracy.from(
rows.mapNotNull { r ->
val actual = r.actualStartDate ?: return@mapNotNull null
(r.predictedStartDate.toEpochDay() - actual.toEpochDay()).toInt()
},
)
}
// -----------------------------------------------------------------------
// Writes
// -----------------------------------------------------------------------
/**
* The central operation: the user says the period started.
*
* Four things happen, in this order and in one transaction, because a
* partial result here is a corrupt history rather than a failed action:
*
* 1. the record is written;
* 2. any outstanding forecast is **scored** against it that is the whole
* basis of §16, and it can only be done at this moment, because this is
* when the answer becomes known;
* 3. the "not yet" observations that were censoring that forecast are
* cleared, since the question they answered is now resolved;
* 4. a fresh forecast is snapshotted for the next cycle.
*
* Returns the new record's id.
*/
suspend fun confirmPeriodStart(
startDate: LocalDate,
source: PeriodRecordSource = PeriodRecordSource.MANUAL,
): Long = db.inTransaction {
val now = clock.instant()
val id = periodDao.insert(
PeriodRecord(id = 0, startDate = startDate, source = source).toEntity(now, now),
)
scoreOutstanding(startDate)
notYetDao.deleteBefore(startDate.plusDays(1))
snapshotForecast(basedOnPeriodId = id)
id
}
/** "Still going" answered, or a date chosen. Never invents an end date. */
suspend fun setPeriodEnd(id: Long, endDate: LocalDate?) = db.inTransaction {
val existing = periodDao.byId(id) ?: return@inTransaction
require(endDate == null || !endDate.isBefore(existing.startDate)) {
"a period cannot end ($endDate) before it started (${existing.startDate})"
}
periodDao.update(existing.copy(endDate = endDate, updatedAt = clock.instant()))
}
/**
* Correcting a record the user got wrong.
*
* The source becomes [PeriodRecordSource.EDITED] and `updatedAt` moves.
* §14: health history is never modified silently even by its owner, the
* change is recorded as a change.
*/
suspend fun editPeriod(id: Long, startDate: LocalDate, endDate: LocalDate?) = db.inTransaction {
val existing = periodDao.byId(id) ?: return@inTransaction
require(endDate == null || !endDate.isBefore(startDate)) {
"a period cannot end ($endDate) before it started ($startDate)"
}
periodDao.update(
existing.copy(
startDate = startDate,
endDate = endDate,
source = PeriodRecordSource.EDITED.name,
updatedAt = clock.instant(),
),
)
snapshotForecast(basedOnPeriodId = id)
}
suspend fun deletePeriod(id: Long) = db.inTransaction {
periodDao.deleteById(id)
snapshotForecast(basedOnPeriodId = null)
}
/**
* "Not yet."
*
* A censoring observation, not a nudge the next forecast is conditioned on
* the period not having begun by this date, rather than shifted a day. §13.
*/
suspend fun recordNotYet(date: LocalDate = today()) = db.inTransaction {
val latest = predictionDao.latestId()
notYetDao.insert(
NotYetObservationEntity(date = date, predictionId = latest, createdAt = clock.instant()),
)
snapshotForecast(basedOnPeriodId = null)
}
/** Spotting. Deliberately incapable of starting or resetting a cycle — §25. */
suspend fun recordSpotting(date: LocalDate) {
spottingDao.insert(SpottingRecordEntity(date = date, createdAt = clock.instant()))
}
suspend fun removeSpotting(date: LocalDate) = spottingDao.deleteByDate(date)
/**
* Delete My Data.
*
* Health history only. The user's settings live in `core/datastore` and are
* deliberately untouched: somebody exercising a privacy control has not
* asked to have notification privacy reset to a default they did not pick.
*/
suspend fun deleteAllHealthData() = db.deleteEverything()
// -----------------------------------------------------------------------
// Internals
// -----------------------------------------------------------------------
/**
* Record what actually happened against the forecast that was standing.
*
* Only `actualStartDate` and `absoluteErrorDays` are written what was
* predicted is never touched. A snapshot editable after the fact can only
* ever report that the app was right.
*
* **Backfill is not a prediction, and this is where that is enforced.**
* Onboarding asks for earlier periods (§19, screen 4) and a user may add one
* at any time. Each of those confirmations runs through here, and scoring
* the current forecast against a date in the past would invent an error for
* a prediction nobody was ever shown filling §16's "your predictions are
* getting better" with figures the app made up about itself.
*
* So a forecast is only scored when the confirmed start falls on or after
* the day the forecast was made. Otherwise it survives untouched, still
* waiting for the period it is actually about.
*/
private suspend fun scoreOutstanding(actualStart: LocalDate) {
val standing = predictionDao.unscored().firstOrNull() ?: return
val madeOn = LocalDate.ofInstant(standing.generatedAt, clock.zone)
if (madeOn.isAfter(actualStart)) return // backfill
val error = abs(standing.predictedStartDate.toEpochDay() - actualStart.toEpochDay()).toInt()
predictionDao.score(standing.id, actualStart, error)
}
/**
* Replace the standing forecast.
*
* Exactly one unscored snapshot exists at any moment, which is what makes
* [scoreOutstanding] unambiguous about which forecast a period answers. The
* ones dropped here were superseded before their outcome was known a
* forecast nobody was looking at when the period arrived is not a wrong
* forecast, and counting it as one would let a single cycle contribute
* several errors to the accuracy figures.
*/
private suspend fun snapshotForecast(basedOnPeriodId: Long?) {
val starts = periodDao.confirmedStartDates()
val notYet = notYetDao.since(LocalDate.EPOCH).map { it.toDomain() }
val prediction = engine.predict(starts, today(), notYet) ?: return
predictionDao.deleteUnscored()
predictionDao.insert(prediction.toEntity(clock.instant(), basedOnPeriodId))
}
}

View File

@ -0,0 +1,74 @@
package dev.privacyllc.period.core.data
import dev.privacyllc.period.core.database.entity.NotYetObservationEntity
import dev.privacyllc.period.core.database.entity.PeriodRecordEntity
import dev.privacyllc.period.core.database.entity.PredictionRecordEntity
import dev.privacyllc.period.core.database.entity.SpottingRecordEntity
import dev.privacyllc.period.domain.cycle.PeriodRecord
import dev.privacyllc.period.domain.cycle.PeriodRecordSource
import dev.privacyllc.period.domain.cycle.SpottingRecord
import dev.privacyllc.period.domain.prediction.ConfidenceLabel
import dev.privacyllc.period.domain.prediction.NotYetObservation
import dev.privacyllc.period.domain.prediction.Prediction
import java.time.Instant
/**
* Entity domain, and this file is the only place either type meets the other.
*
* The mapping is not ceremony. `domain` modules are pure-JVM modules that cannot see
* the Android SDK, which is what makes the prediction engine testable in a
* second rather than on an emulator and returning a Room entity from a
* repository would drag `androidx.room` onto the compile classpath of every
* caller and quietly end that.
*
* Enums cross as their names. An unrecognised name falls back rather than
* throwing: a row written by a newer build must not crash an older one, and a
* cycle history is worth more than strictness about a label.
*/
internal fun PeriodRecordEntity.toDomain() = PeriodRecord(
id = id,
startDate = startDate,
endDate = endDate,
source = PeriodRecordSource.entries.firstOrNull { it.name == source } ?: PeriodRecordSource.MANUAL,
isConfirmed = isConfirmed,
)
internal fun PeriodRecord.toEntity(createdAt: Instant, updatedAt: Instant) = PeriodRecordEntity(
id = id,
startDate = startDate,
endDate = endDate,
createdAt = createdAt,
updatedAt = updatedAt,
source = source.name,
isConfirmed = isConfirmed,
)
internal fun SpottingRecordEntity.toDomain() = SpottingRecord(id = id, date = date)
internal fun NotYetObservationEntity.toDomain() = NotYetObservation(date = date, predictionId = predictionId)
internal fun PredictionRecordEntity.toDomain() = Prediction(
mostLikelyStartDate = predictedStartDate,
windowStart = predictedWindowStart,
windowEnd = predictedWindowEnd,
confidenceScore = confidenceScore,
confidenceLabel = ConfidenceLabel.entries.firstOrNull { it.name == confidenceLabel } ?: ConfidenceLabel.LOW,
modelVersion = modelVersion,
)
internal fun Prediction.toEntity(generatedAt: Instant, basedOnPeriodId: Long?) = PredictionRecordEntity(
generatedAt = generatedAt,
basedOnLastConfirmedPeriodId = basedOnPeriodId,
predictedStartDate = mostLikelyStartDate,
predictedWindowStart = windowStart,
predictedWindowEnd = windowEnd,
estimatedOvulationDate = null,
fertileWindowStart = null,
fertileWindowEnd = null,
confidenceScore = confidenceScore,
confidenceLabel = confidenceLabel.name,
modelVersion = modelVersion,
actualStartDate = null,
absoluteErrorDays = null,
)

View File

@ -0,0 +1,52 @@
package dev.privacyllc.period.core.data
import kotlin.math.abs
/**
* What §16 shows the user: "5 of your last 6 predictions were within one day."
*
* Computed on the device from stored snapshots. Nothing about accuracy leaves
* the phone §46 is explicit that `prediction_error=` is not an analytics
* event.
*/
data class PredictionAccuracy(
val scoredCount: Int,
val lastErrorDays: Int?,
/** Negative means the forecast was early; positive, late. Signed, because "1 day early" is what the user is told. */
val lastSignedErrorDays: Int?,
val meanAbsoluteErrorDays: Double?,
val medianAbsoluteErrorDays: Double?,
val withinOneDay: Int,
val withinTwoDays: Int,
) {
val hasEnoughToShow: Boolean get() = scoredCount >= MINIMUM_TO_SHOW
companion object {
/**
* Below this, an accuracy claim is noise dressed as a measurement. One
* lucky prediction reading "average error: 0 days" is the kind of
* overstatement §27 warns against.
*/
const val MINIMUM_TO_SHOW = 3
val Empty = PredictionAccuracy(0, null, null, null, null, 0, 0)
/** [errors] newest first, each the signed difference predicted actual. */
fun from(errors: List<Int>): PredictionAccuracy {
if (errors.isEmpty()) return Empty
val abs = errors.map { abs(it) }
val sorted = abs.sorted()
val mid = sorted.size / 2
return PredictionAccuracy(
scoredCount = errors.size,
lastErrorDays = abs.first(),
lastSignedErrorDays = errors.first(),
meanAbsoluteErrorDays = abs.average(),
medianAbsoluteErrorDays =
if (sorted.size % 2 == 1) sorted[mid].toDouble() else (sorted[mid - 1] + sorted[mid]) / 2.0,
withinOneDay = abs.count { it <= 1 },
withinTwoDays = abs.count { it <= 2 },
)
}
}
}

View File

@ -0,0 +1,311 @@
package dev.privacyllc.period.core.data
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import dev.privacyllc.period.core.database.PeriodDatabase
import dev.privacyllc.period.domain.cycle.PeriodRecordSource
import dev.privacyllc.period.domain.prediction.BaselinePredictionEngine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
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.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneOffset
/**
* The repository is where the four writes that make up "my period started" are
* held together. These tests are mostly about that: not that a row was written,
* but that the other three things happened with it.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class CycleRepositoryTest {
private lateinit var db: PeriodDatabase
private lateinit var repo: CycleRepository
/** Fixed, so nothing here depends on the day it is run. §50. */
private var today: LocalDate = LocalDate.of(2026, 8, 18)
private fun clockAt(date: LocalDate) =
Clock.fixed(date.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC)
private fun repoAt(date: LocalDate): CycleRepository {
today = date
return CycleRepository(db, BaselinePredictionEngine(), clockAt(date))
}
@Before fun open() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
PeriodDatabase::class.java,
).allowMainThreadQueries().build()
repo = repoAt(LocalDate.of(2026, 8, 18))
}
@After fun close() = db.close()
private suspend fun seed(vararg dates: String) =
dates.forEach { repo.confirmPeriodStart(LocalDate.parse(it)) }
// -----------------------------------------------------------------------
// Domain types out, entities never
// -----------------------------------------------------------------------
@Test fun `periods come back as domain records, not rows`() = runTest {
repo.confirmPeriodStart(LocalDate.of(2026, 8, 1))
val p = repo.confirmedPeriods.first().single()
assertEquals(LocalDate.of(2026, 8, 1), p.startDate)
assertEquals(PeriodRecordSource.MANUAL, p.source)
assertTrue(p.isConfirmed)
}
@Test fun `cycles are derived from the records and never stored`() = runTest {
seed("2026-06-01", "2026-06-30", "2026-07-29")
assertEquals(listOf(29, 29), repo.cycles.first().map { it.cycleLengthDays })
// Edit the middle record; the derived cycles must follow immediately.
// A stored cycles table is what would go stale here, which is why there
// is not one.
val middle = repo.confirmedPeriods.first()[1]
repo.editPeriod(middle.id, LocalDate.of(2026, 7, 1), null)
assertEquals(listOf(30, 28), repo.cycles.first().map { it.cycleLengthDays })
}
// -----------------------------------------------------------------------
// Confirming a period: the four things that must happen together
// -----------------------------------------------------------------------
@Test fun `confirming a period produces a fresh forecast`() = runTest {
assertNull("no history means no forecast, not a guess", repo.forecast.first())
seed("2026-06-01", "2026-06-30", "2026-07-29")
val f = repo.forecast.first()
assertNotNull(f)
assertEquals(LocalDate.of(2026, 8, 27), f!!.mostLikelyStartDate)
assertTrue(f.windowStart <= f.mostLikelyStartDate && f.mostLikelyStartDate <= f.windowEnd)
}
@Test fun `confirming a period scores the forecast that was outstanding`() = runTest {
seed("2026-06-01", "2026-06-30", "2026-07-29")
// The snapshot taken above predicted 2026-08-27.
val late = repoAt(LocalDate.of(2026, 8, 29))
late.confirmPeriodStart(LocalDate.of(2026, 8, 29))
val acc = late.accuracy.first()
assertEquals(1, acc.scoredCount)
// Predicted the 27th, arrived on the 29th: two days early.
assertEquals(2, acc.lastErrorDays)
assertEquals(-2, acc.lastSignedErrorDays)
}
@Test fun `a scored snapshot keeps what it predicted`() = runTest {
seed("2026-06-01", "2026-06-30", "2026-07-29")
val late = repoAt(LocalDate.of(2026, 8, 29))
late.confirmPeriodStart(LocalDate.of(2026, 8, 29))
val scored = db.predictionRecordDao().observeScored().first()
assertTrue(scored.isNotEmpty())
// Whatever it said before the answer was known, it still says.
assertEquals(LocalDate.of(2026, 8, 27), scored.first().predictedStartDate)
assertEquals(LocalDate.of(2026, 8, 29), scored.first().actualStartDate)
}
@Test fun `confirming a period clears the not-yet observations it resolved`() = runTest {
seed("2026-06-01", "2026-06-30", "2026-07-29")
val waiting = repoAt(LocalDate.of(2026, 8, 27))
waiting.recordNotYet(LocalDate.of(2026, 8, 27))
assertEquals(1, waiting.notYetObservations.first().size)
val started = repoAt(LocalDate.of(2026, 8, 29))
started.confirmPeriodStart(LocalDate.of(2026, 8, 29))
// The question they answered is resolved; leaving them would censor
// every future forecast with a date that no longer means anything.
assertTrue(started.notYetObservations.first().isEmpty())
}
// -----------------------------------------------------------------------
// Not yet
// -----------------------------------------------------------------------
@Test fun `not yet moves the window past the ruled-out date`() = runTest {
seed("2026-06-01", "2026-06-30", "2026-07-29")
val onTheDay = repoAt(LocalDate.of(2026, 8, 27))
val before = onTheDay.forecast.first()!!
assertTrue(before.windowStart <= LocalDate.of(2026, 8, 27))
onTheDay.recordNotYet(LocalDate.of(2026, 8, 27))
val after = onTheDay.forecast.first()!!
assertTrue(
"the 27th was ruled out and is still in ${after.windowStart}..${after.windowEnd}",
after.windowStart.isAfter(LocalDate.of(2026, 8, 27)),
)
assertTrue("confidence must fall too, not just the date move",
after.confidenceScore < before.confidenceScore)
}
// -----------------------------------------------------------------------
// Editing and deleting
// -----------------------------------------------------------------------
@Test fun `editing a record marks it as edited rather than changing it silently`() = runTest {
val id = repo.confirmPeriodStart(LocalDate.of(2026, 8, 1))
repo.editPeriod(id, LocalDate.of(2026, 8, 3), null)
val p = repo.confirmedPeriods.first().single()
assertEquals(LocalDate.of(2026, 8, 3), p.startDate)
// §14: health history is never modified silently — even by its owner.
assertEquals(PeriodRecordSource.EDITED, p.source)
}
@Test fun `a period cannot be made to end before it started`() = runTest {
val id = repo.confirmPeriodStart(LocalDate.of(2026, 8, 10))
var refused = false
try {
repo.setPeriodEnd(id, LocalDate.of(2026, 8, 1))
} catch (e: IllegalArgumentException) {
refused = true
}
assertTrue("an end before the start is nonsense and must be refused", refused)
assertNull(repo.confirmedPeriods.first().single().endDate)
}
@Test fun `an ongoing period has a null end rather than an invented one`() = runTest {
val id = repo.confirmPeriodStart(LocalDate.of(2026, 8, 10))
assertNull(repo.confirmedPeriods.first().single().endDate)
repo.setPeriodEnd(id, LocalDate.of(2026, 8, 14))
assertEquals(LocalDate.of(2026, 8, 14), repo.confirmedPeriods.first().single().endDate)
// "Actually, still going."
repo.setPeriodEnd(id, null)
assertNull(repo.confirmedPeriods.first().single().endDate)
}
@Test fun `deleting a record removes it from the derived cycles`() = runTest {
seed("2026-06-01", "2026-06-30", "2026-07-29")
val middle = repo.confirmedPeriods.first()[1]
repo.deletePeriod(middle.id)
assertEquals(2, repo.confirmedPeriods.first().size)
assertEquals(listOf(58), repo.cycles.first().map { it.cycleLengthDays })
}
// -----------------------------------------------------------------------
// Spotting
// -----------------------------------------------------------------------
@Test fun `spotting never becomes a period or a cycle boundary`() = runTest {
seed("2026-06-01", "2026-06-30")
repo.recordSpotting(LocalDate.of(2026, 6, 14))
// §25. The structural guarantee is that spotting cannot reach any of
// these three answers.
assertEquals(2, repo.confirmedPeriods.first().size)
assertEquals(listOf(29), repo.cycles.first().map { it.cycleLengthDays })
assertEquals(1, repo.spotting.first().size)
repo.removeSpotting(LocalDate.of(2026, 6, 14))
assertTrue(repo.spotting.first().isEmpty())
}
// -----------------------------------------------------------------------
// Delete My Data
// -----------------------------------------------------------------------
@Test fun `deleting health data leaves nothing behind`() = runTest {
seed("2026-06-01", "2026-06-30", "2026-07-29")
repo.recordSpotting(LocalDate.of(2026, 7, 10))
repo.recordNotYet(LocalDate.of(2026, 8, 18))
repo.deleteAllHealthData()
assertTrue(repo.confirmedPeriods.first().isEmpty())
assertTrue(repo.spotting.first().isEmpty())
assertTrue(repo.notYetObservations.first().isEmpty())
assertTrue(repo.cycles.first().isEmpty())
assertNull(repo.forecast.first())
assertEquals(PredictionAccuracy.Empty, repo.accuracy.first())
}
// -----------------------------------------------------------------------
// Accuracy
// -----------------------------------------------------------------------
@Test fun `accuracy stays quiet until there is enough of it to mean anything`() = runTest {
assertFalse(repo.accuracy.first().hasEnoughToShow)
seed("2026-06-01", "2026-06-30", "2026-07-29")
val late = repoAt(LocalDate.of(2026, 8, 28))
late.confirmPeriodStart(LocalDate.of(2026, 8, 28))
// One lucky prediction reading "average error: 0 days" is an
// overstatement, not a measurement. §27: do not overstate accuracy.
assertFalse(late.accuracy.first().hasEnoughToShow)
assertEquals(1, late.accuracy.first().scoredCount)
}
@Test fun `backfilled history does not fabricate accuracy figures`() = runTest {
// Onboarding asks for earlier periods (§19 screen 4) and a user can add
// one at any time. Scoring the standing forecast against a date in the
// past would invent an error for a prediction nobody was ever shown,
// and fill "your predictions are getting better" with figures the app
// made up about itself. Regression: this scored 3 before it was fixed.
seed("2026-06-01", "2026-06-30", "2026-07-29")
assertEquals(0, repo.accuracy.first().scoredCount)
// Remembering one from further back, entered today, must change nothing.
repo.confirmPeriodStart(LocalDate.of(2026, 5, 3), PeriodRecordSource.HISTORICAL_ENTRY)
assertEquals(0, repo.accuracy.first().scoredCount)
// A period that actually arrives after the forecast was made does score.
val later = repoAt(LocalDate.of(2026, 8, 30))
later.confirmPeriodStart(LocalDate.of(2026, 8, 30))
assertEquals(1, later.accuracy.first().scoredCount)
}
@Test fun `only one forecast stands at a time`() = runTest {
seed("2026-06-01", "2026-06-30", "2026-07-29")
repo.recordNotYet(LocalDate.of(2026, 8, 18))
repo.recordNotYet(LocalDate.of(2026, 8, 19))
// Each recalculation replaces the standing forecast rather than adding
// to a pile. Otherwise one cycle contributes several "errors" to the
// accuracy figures and the sample is inflated rather than measured.
assertEquals(1, db.predictionRecordDao().unscored().size)
}
@Test fun `accuracy arithmetic is signed for the user and absolute for the average`() {
// predicted actual: negative is early, positive is late.
val a = PredictionAccuracy.from(listOf(-1, 2, 0, -3))
assertEquals(4, a.scoredCount)
assertEquals(1, a.lastErrorDays)
assertEquals(-1, a.lastSignedErrorDays)
assertEquals(1.5, a.meanAbsoluteErrorDays!!, 0.001)
assertEquals(1.5, a.medianAbsoluteErrorDays!!, 0.001)
assertEquals(2, a.withinOneDay)
assertEquals(3, a.withinTwoDays)
assertTrue(a.hasEnoughToShow)
}
}

View File

@ -55,6 +55,17 @@ abstract class PeriodDatabase : RoomDatabase() {
clearAllTables()
}
/**
* Run [block] in one transaction.
*
* Exposed here rather than letting callers reach for `withTransaction`
* directly, so `room-ktx` stays an `implementation` dependency of this
* module and never reaches the compile classpath of anything above it.
* Confirming a period is four writes that are meaningless apart a partial
* result is a corrupt history rather than a failed action.
*/
suspend fun <R> inTransaction(block: suspend () -> R): R = withTransaction(block)
companion object {
const val VERSION = 1
const val NAME = "period.db"

View File

@ -60,6 +60,16 @@ interface PeriodRecordDao {
@Query("SELECT COUNT(*) FROM period_records WHERE isConfirmed = 1")
suspend fun confirmedCount(): Int
/**
* Just the dates, for the prediction engine.
*
* The engine takes `List<LocalDate>` and nothing else it is a pure-JVM
* module that has never heard of a row. Selecting one column rather than
* mapping whole entities keeps that honest and cheap.
*/
@Query("SELECT startDate FROM period_records WHERE isConfirmed = 1 ORDER BY startDate ASC")
suspend fun confirmedStartDates(): List<LocalDate>
}
@Dao
@ -88,6 +98,20 @@ interface PredictionRecordDao {
@Query("SELECT * FROM prediction_records WHERE actualStartDate IS NULL ORDER BY generatedAt DESC")
suspend fun unscored(): List<PredictionRecordEntity>
@Query("SELECT id FROM prediction_records ORDER BY generatedAt DESC LIMIT 1")
suspend fun latestId(): Long?
/**
* Throw away forecasts that were replaced before their outcome was known.
*
* A superseded forecast is not a wrong one nobody was ever shown it when
* the period arrived. Keeping them would let a single cycle contribute
* several "errors" to §16's figures, which is the difference between
* measuring accuracy and inflating a sample.
*/
@Query("DELETE FROM prediction_records WHERE actualStartDate IS NULL")
suspend fun deleteUnscored()
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(record: PredictionRecordEntity): Long

View File

@ -30,7 +30,7 @@ function calls. Nothing below the ViewModel knows Compose exists.
## Modules
Six today. core/data is Batch 01 issue #5 and **does not exist yet** — a module created before it has contents is a place
Seven today — a module created before it has contents is a place
for things to be put by accident. The wider layout sketched in
[`../planning/PRODUCT_PLAN.md` §9](../planning/PRODUCT_PLAN.md) arrives the same
way, with the batch that needs it.
@ -41,6 +41,7 @@ way, with the batch that needs it.
| `core/designsystem` | Android library | Material 3 theme, colour and type tokens | nothing in this project |
| `core/database` | Android library | Room entities, DAOs, converters, the schema export | `domain/cycle`, `domain/prediction` |
| `core/datastore` | Android library | `UserPreferences` and the settings that are not health history | nothing in this project |
| `core/data` | Android library | `CycleRepository`, entity⇄domain mapping, accuracy — the only module that touches a DAO | `core/database`, `domain/cycle`, `domain/prediction` |
| `domain/cycle` | **Kotlin JVM** | `PeriodRecord`, `SpottingRecord`, `CycleRecord` and the rules over them | nothing |
| `domain/prediction` | **Kotlin JVM** | the forecast, the window, confidence, `NotYetObservation` | `domain/cycle` |
@ -50,7 +51,6 @@ there, and none of these are:
| Module | Plugin | Owns | May depend on | Issue |
| --- | --- | --- | --- | --- |
| core/data | Android library | the repositories — the only things that touch a DAO | `core/database`, `core/datastore`, `domain/cycle` | #5 |
| core/ads | Android library | the `AdProvider` implementation | **neither core/database nor `domain/*`** | Batch 07 |
### Why `domain/*` is `kotlin("jvm")` and not an Android library
@ -65,6 +65,46 @@ user, variable user, 45-day outlier, "not yet" — run on the JVM in under a
second, so they run on every commit rather than on an emulator when someone
remembers.
### How the Room boundary is actually held
Three mechanisms, and it matters that none of them is "people remember":
1. `core/data` depends on `core/database` with **`implementation`**, not `api`,
so Room never reaches the compile classpath of anything above it.
2. `CycleRepository`'s constructor is **`internal`** — it names a
`PeriodDatabase`, and a public constructor would force every caller to be
able to name that type too. Callers use `CycleData.repository(context)`.
3. Repository reads return domain types. `Mappers.kt` is the one place an entity
and a domain object meet.
Checkable, not merely intended: `grep -rn "androidx.room" app/src domain` is
empty, and `./gradlew :app:dependencies --configuration debugCompileClasspath`
lists Room zero times.
**No `fallbackToDestructiveMigration`.** It turns a forgotten migration into
silent data loss on update — here, a user's entire cycle history gone with no
error and no way back. A missing migration must be a crash in testing rather
than a wipe in production.
### One forecast stands at a time, and backfill is not a prediction
Two rules about `prediction_records` that are easy to get wrong and expensive to
notice, because both failure modes produce *plausible* accuracy figures rather
than obviously broken ones.
**Exactly one unscored snapshot exists at any moment.** Every recalculation
replaces the standing forecast instead of appending. A forecast superseded
before its outcome was known is not a wrong forecast — nobody was looking at it
when the period arrived — and counting it as one lets a single cycle contribute
several errors to §16's figures.
**A confirmed start only scores a forecast made on or before it.** Onboarding
asks for earlier periods and a user can add one at any time; scoring today's
forecast against a date in the past invents an error for a prediction nobody was
ever shown. This was a real defect, caught by a test expecting one scored
prediction and finding three, and it is pinned by
`backfilled history does not fabricate accuracy figures`.
### The boundary that is not negotiable
> The advertising subsystem must never receive menstrual dates, cycle length,
@ -92,7 +132,7 @@ each exists and what must not happen to it.
| --- | --- | --- |
| `PeriodRecord` | a confirmed period, with its source and whether it is confirmed | a record's `source` is kept; edits are recorded, never silent |
| `SpottingRecord` | spotting, tracked separately | **must not** start a cycle or reset one |
| `CycleRecord` | derived interval between two confirmed starts | derived, never stored as truth — recomputed from period records |
| `CycleRecord` | derived interval between two confirmed starts | derived, never stored as truth — `toCycles()` recomputes from the period records on every read, so an edit cannot leave a stale interval behind it |
| `PredictionRecord` | a snapshot taken *before* the outcome is known | this is what makes accuracy measurable at all; never overwritten in place |
| `NotYetObservation` | the user said the period had not started by a date | a censoring observation — the forecast is re-conditioned on it, not shifted by +1 day |
| `UserPreferences` | notification privacy, reminder time, lock, theme, ads entitlement | lives in DataStore, **never** in the cycle database — see below |

View File

@ -22,11 +22,12 @@ dependencyResolutionManagement {
rootProject.name = "Period"
// core/data arrives with Batch 01 issue #5 — see docs/architecture/README.md
// for why a module is not created before it has contents.
// core/ads arrives with Batch 07 — see docs/architecture/README.md for why a
// module is not created before it has contents.
include(":app")
include(":core:designsystem")
include(":core:database")
include(":core:datastore")
include(":core:data")
include(":domain:cycle")
include(":domain:prediction")