From 6d4592467fda8e7a99f96beecfe6e554deaeb8ca Mon Sep 17 00:00:00 2001 From: null Date: Tue, 18 Aug 2026 02:41:05 -0500 Subject: [PATCH] feat: repository layer, and stop backfilled history fabricating accuracy figures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/data/build.gradle.kts | 42 +++ .../privacyllc/period/core/data/CycleData.kt | 43 +++ .../period/core/data/CycleRepository.kt | 254 ++++++++++++++ .../privacyllc/period/core/data/Mappers.kt | 74 +++++ .../period/core/data/PredictionAccuracy.kt | 52 +++ .../period/core/data/CycleRepositoryTest.kt | 311 ++++++++++++++++++ .../period/core/database/PeriodDatabase.kt | 11 + .../period/core/database/dao/Daos.kt | 24 ++ docs/architecture/README.md | 46 ++- settings.gradle.kts | 5 +- 10 files changed, 857 insertions(+), 5 deletions(-) create mode 100644 core/data/build.gradle.kts create mode 100644 core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleData.kt create mode 100644 core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt create mode 100644 core/data/src/main/kotlin/dev/privacyllc/period/core/data/Mappers.kt create mode 100644 core/data/src/main/kotlin/dev/privacyllc/period/core/data/PredictionAccuracy.kt create mode 100644 core/data/src/test/kotlin/dev/privacyllc/period/core/data/CycleRepositoryTest.kt diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts new file mode 100644 index 0000000..8f859f7 --- /dev/null +++ b/core/data/build.gradle.kts @@ -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) +} diff --git a/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleData.kt b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleData.kt new file mode 100644 index 0000000..491524a --- /dev/null +++ b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleData.kt @@ -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() +} diff --git a/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt new file mode 100644 index 0000000..214cfda --- /dev/null +++ b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt @@ -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> = + periodDao.observeConfirmed().map { rows -> rows.map { it.toDomain() } } + + val allPeriods: Flow> = + periodDao.observeAll().map { rows -> rows.map { it.toDomain() } } + + val cycles: Flow> = confirmedPeriods.map { it.toCycles() } + + val spotting: Flow> = + spottingDao.observeAll().map { rows -> rows.map { it.toDomain() } } + + val notYetObservations: Flow> = + 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 = + 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 = + 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)) + } +} diff --git a/core/data/src/main/kotlin/dev/privacyllc/period/core/data/Mappers.kt b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/Mappers.kt new file mode 100644 index 0000000..586ef3a --- /dev/null +++ b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/Mappers.kt @@ -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, +) diff --git a/core/data/src/main/kotlin/dev/privacyllc/period/core/data/PredictionAccuracy.kt b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/PredictionAccuracy.kt new file mode 100644 index 0000000..dd4dc05 --- /dev/null +++ b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/PredictionAccuracy.kt @@ -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): 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 }, + ) + } + } +} diff --git a/core/data/src/test/kotlin/dev/privacyllc/period/core/data/CycleRepositoryTest.kt b/core/data/src/test/kotlin/dev/privacyllc/period/core/data/CycleRepositoryTest.kt new file mode 100644 index 0000000..1552b81 --- /dev/null +++ b/core/data/src/test/kotlin/dev/privacyllc/period/core/data/CycleRepositoryTest.kt @@ -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) + } +} diff --git a/core/database/src/main/kotlin/dev/privacyllc/period/core/database/PeriodDatabase.kt b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/PeriodDatabase.kt index 1f14574..5c2565b 100644 --- a/core/database/src/main/kotlin/dev/privacyllc/period/core/database/PeriodDatabase.kt +++ b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/PeriodDatabase.kt @@ -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 inTransaction(block: suspend () -> R): R = withTransaction(block) + companion object { const val VERSION = 1 const val NAME = "period.db" diff --git a/core/database/src/main/kotlin/dev/privacyllc/period/core/database/dao/Daos.kt b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/dao/Daos.kt index 3d17d35..22672c5 100644 --- a/core/database/src/main/kotlin/dev/privacyllc/period/core/database/dao/Daos.kt +++ b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/dao/Daos.kt @@ -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` 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 } @Dao @@ -88,6 +98,20 @@ interface PredictionRecordDao { @Query("SELECT * FROM prediction_records WHERE actualStartDate IS NULL ORDER BY generatedAt DESC") suspend fun unscored(): List + @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 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index acef762..376870a 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 | diff --git a/settings.gradle.kts b/settings.gradle.kts index 65018b5..83df26c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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")