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:
parent
8d7a7252cb
commit
6d4592467f
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,17 @@ abstract class PeriodDatabase : RoomDatabase() {
|
||||||
clearAllTables()
|
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 {
|
companion object {
|
||||||
const val VERSION = 1
|
const val VERSION = 1
|
||||||
const val NAME = "period.db"
|
const val NAME = "period.db"
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,16 @@ interface PeriodRecordDao {
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM period_records WHERE isConfirmed = 1")
|
@Query("SELECT COUNT(*) FROM period_records WHERE isConfirmed = 1")
|
||||||
suspend fun confirmedCount(): Int
|
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
|
@Dao
|
||||||
|
|
@ -88,6 +98,20 @@ interface PredictionRecordDao {
|
||||||
@Query("SELECT * FROM prediction_records WHERE actualStartDate IS NULL ORDER BY generatedAt DESC")
|
@Query("SELECT * FROM prediction_records WHERE actualStartDate IS NULL ORDER BY generatedAt DESC")
|
||||||
suspend fun unscored(): List<PredictionRecordEntity>
|
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)
|
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||||
suspend fun insert(record: PredictionRecordEntity): Long
|
suspend fun insert(record: PredictionRecordEntity): Long
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ function calls. Nothing below the ViewModel knows Compose exists.
|
||||||
|
|
||||||
## Modules
|
## 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
|
for things to be put by accident. The wider layout sketched in
|
||||||
[`../planning/PRODUCT_PLAN.md` §9](../planning/PRODUCT_PLAN.md) arrives the same
|
[`../planning/PRODUCT_PLAN.md` §9](../planning/PRODUCT_PLAN.md) arrives the same
|
||||||
way, with the batch that needs it.
|
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/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/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/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/cycle` | **Kotlin JVM** | `PeriodRecord`, `SpottingRecord`, `CycleRecord` and the rules over them | nothing |
|
||||||
| `domain/prediction` | **Kotlin JVM** | the forecast, the window, confidence, `NotYetObservation` | `domain/cycle` |
|
| `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 |
|
| 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 |
|
| core/ads | Android library | the `AdProvider` implementation | **neither core/database nor `domain/*`** | Batch 07 |
|
||||||
|
|
||||||
### Why `domain/*` is `kotlin("jvm")` and not an Android library
|
### 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
|
second, so they run on every commit rather than on an emulator when someone
|
||||||
remembers.
|
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 boundary that is not negotiable
|
||||||
|
|
||||||
> The advertising subsystem must never receive menstrual dates, cycle length,
|
> 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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `UserPreferences` | notification privacy, reminder time, lock, theme, ads entitlement | lives in DataStore, **never** in the cycle database — see below |
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,12 @@ dependencyResolutionManagement {
|
||||||
|
|
||||||
rootProject.name = "Period"
|
rootProject.name = "Period"
|
||||||
|
|
||||||
// core/data arrives with Batch 01 issue #5 — see docs/architecture/README.md
|
// core/ads arrives with Batch 07 — see docs/architecture/README.md for why a
|
||||||
// for why a module is not created before it has contents.
|
// module is not created before it has contents.
|
||||||
include(":app")
|
include(":app")
|
||||||
include(":core:designsystem")
|
include(":core:designsystem")
|
||||||
include(":core:database")
|
include(":core:database")
|
||||||
include(":core:datastore")
|
include(":core:datastore")
|
||||||
|
include(":core:data")
|
||||||
include(":domain:cycle")
|
include(":domain:cycle")
|
||||||
include(":domain:prediction")
|
include(":domain:prediction")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue