Privacy-Period-Tracker/app/src/main/kotlin/dev/privacyllc/period/di/DataModule.kt

80 lines
3.0 KiB
Kotlin
Raw Normal View History

feat: period CRUD end to end, and stop a double tap killing the app The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device: launch, log a period, it is stored, the forecast recalculates, edit or delete it and the forecast moves again. Hilt wiring, a TodayViewModel exposing one immutable state, and a working surface that says "Batch 01 · working surface" at the top so nobody mistakes it for the designed Today screen, which is Batch 03. THE DEFECT THIS FOUND, ON A DEVICE Tapping "Started today" twice on the same day killed the app: FATAL EXCEPTION: main android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: period_records.startDate Not a hypothetical — the crash was reproduced on emulator-5580, the fix applied, and the same two taps then produced "That day is already logged." with the process still alive and zero FATAL lines in logcat. The constraint is right: a duplicate must not overwrite the original row and lose its createdAt and source. The API around it was wrong. Repeating a tap when you are not sure the first one registered is an ordinary thing for a person to do, not a fault, and it must never be an exception. So the period writes return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound — and only genuine faults still throw. editPeriod had the same hole: moving a record onto a date another record holds. That is refused rather than merged, because merging would delete a period the user entered and only they can settle it. The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a health app a crash mid-write is adjacent to losing what was just entered, and a message somebody can read beats a process that vanished. The message carries the exception type and never a record's contents (§45). Four regression tests pin all of it, plus two instrumented tests on a real file-backed database that close and reopen it — what a force-stop actually does, and something an in-memory database cannot fail. 70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB. closes #6
2026-08-18 02:52:35 -05:00
package dev.privacyllc.period.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dev.privacyllc.period.core.data.CycleData
import dev.privacyllc.period.core.data.CycleRepository
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
feat: the prediction engine section 12 specifies, and it beats the baseline PersonalPredictionEngine keeps a discrete probability distribution over candidate start dates rather than a date with a margin bolted on. Everything the product needs falls out of that one structure: the most likely date is its mode, the window is the narrowest span holding 80% of the mass, and a "Not yet" is the distribution conditioned on what the user just said — which is what §13 asks for and what a date-plus-margin design cannot express at all. It is better, and that is a number rather than an opinion. EngineComparisonTest scores both engines over the §51 fixtures on every build: engine MAE mean window within +/-2 window covered baseline 1.00 2.67 7/9 7/9 personal 0.67 4.56 9/9 9/9 COVERAGE IS THE MEASURE, NOT WIDTH The first version of that test asserted the new windows must not be wider, and it failed. Measuring showed why the assertion was wrong: the fixtures where the personal engine is wider are the ones that are genuinely less certain — a history with a suspected missing period, and one with a 45-day outlier — and the baseline answers both with a two-day window and misses. What a window promises is that the period starts inside it. An engine keeping that promise 7 times in 9 has a broken promise, not a tight forecast. The test now asserts coverage, with a ceiling so "some time this month" still fails. THREE MODELLING BUGS THE TESTS FOUND Each was found by a test failing, not by reading the code: - Median absolute deviation alone reads a user alternating 25 and 37 as perfectly consistent, because half her deviations are zero. Twenty disagreeing cycles came back High, breaking §15's rule that volume alone must never buy High confidence. Spread is now the larger of MAD and mean absolute deviation; robustness comes from IntervalAnalysis down-weighting what is questionable, which is a better place for it. - Recency weighting assumes the recent past predicts the near future. For a variable user that is false — her latest cycle is a draw from a wide distribution, not a signal — and weighting it equally cost three days on the §51 variable fixture. Recency is now trusted in proportion to how much her cycles actually agree. - A fixed one-day floor on trend detection fired on a 42-day-cycle history whose medians differed by a single day, turning an exact forecast into a wrong one. One day is a real trend at 28 and rounding error at 42, so the floor is relative to the user's own spread. WIRED THROUGH, NOT JUST TESTED PredictionInput carries recentAbsoluteErrors, and CycleRepository feeds the scored errors back in. Without that the app stores every error it makes and never reads one back — measuring accuracy rather than learning from it, with the widening happening only in a unit test. A repository test asserts the errors actually reach the engine. BaselinePredictionEngine stays as the control, and both engines run the same §51 acceptance suite, so the next engine's improvement is measurable too. 108 tests, all passing. ./gradlew check green. Verified on a device. closes #10 closes #11 closes #12 closes #14
2026-08-18 03:16:12 -05:00
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
feat: period CRUD end to end, and stop a double tap killing the app The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device: launch, log a period, it is stored, the forecast recalculates, edit or delete it and the forecast moves again. Hilt wiring, a TodayViewModel exposing one immutable state, and a working surface that says "Batch 01 · working surface" at the top so nobody mistakes it for the designed Today screen, which is Batch 03. THE DEFECT THIS FOUND, ON A DEVICE Tapping "Started today" twice on the same day killed the app: FATAL EXCEPTION: main android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: period_records.startDate Not a hypothetical — the crash was reproduced on emulator-5580, the fix applied, and the same two taps then produced "That day is already logged." with the process still alive and zero FATAL lines in logcat. The constraint is right: a duplicate must not overwrite the original row and lose its createdAt and source. The API around it was wrong. Repeating a tap when you are not sure the first one registered is an ordinary thing for a person to do, not a fault, and it must never be an exception. So the period writes return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound — and only genuine faults still throw. editPeriod had the same hole: moving a record onto a date another record holds. That is refused rather than merged, because merging would delete a period the user entered and only they can settle it. The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a health app a crash mid-write is adjacent to losing what was just entered, and a message somebody can read beats a process that vanished. The message carries the exception type and never a record's contents (§45). Four regression tests pin all of it, plus two instrumented tests on a real file-backed database that close and reopen it — what a force-stop actually does, and something an in-memory database cannot fail. 70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB. closes #6
2026-08-18 02:52:35 -05:00
import dev.privacyllc.period.domain.prediction.PredictionEngine
import java.time.Clock
import javax.inject.Singleton
/**
* Where the app learns what a file path is, and the only place it does.
*
* Note what is absent: no `PeriodDatabase`, no DAO, no Room import anywhere in
* this module or anywhere above it. `CycleData.repository` hands back a
* repository and keeps the storage to itself see
* docs/architecture/README.md.
*/
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
/**
* Singleton because Room and DataStore both are: two instances over one
* file is a corruption bug that only shows up under concurrency.
*/
@Provides
@Singleton
fun cycleRepository(
@ApplicationContext context: Context,
engine: PredictionEngine,
clock: Clock,
): CycleRepository = CycleData.repository(context, engine, clock)
@Provides
@Singleton
fun preferencesDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create {
context.preferencesDataStoreFile("user_preferences")
}
@Provides
@Singleton
fun userPreferencesRepository(store: DataStore<Preferences>) = UserPreferencesRepository(store)
/**
feat: the prediction engine section 12 specifies, and it beats the baseline PersonalPredictionEngine keeps a discrete probability distribution over candidate start dates rather than a date with a margin bolted on. Everything the product needs falls out of that one structure: the most likely date is its mode, the window is the narrowest span holding 80% of the mass, and a "Not yet" is the distribution conditioned on what the user just said — which is what §13 asks for and what a date-plus-margin design cannot express at all. It is better, and that is a number rather than an opinion. EngineComparisonTest scores both engines over the §51 fixtures on every build: engine MAE mean window within +/-2 window covered baseline 1.00 2.67 7/9 7/9 personal 0.67 4.56 9/9 9/9 COVERAGE IS THE MEASURE, NOT WIDTH The first version of that test asserted the new windows must not be wider, and it failed. Measuring showed why the assertion was wrong: the fixtures where the personal engine is wider are the ones that are genuinely less certain — a history with a suspected missing period, and one with a 45-day outlier — and the baseline answers both with a two-day window and misses. What a window promises is that the period starts inside it. An engine keeping that promise 7 times in 9 has a broken promise, not a tight forecast. The test now asserts coverage, with a ceiling so "some time this month" still fails. THREE MODELLING BUGS THE TESTS FOUND Each was found by a test failing, not by reading the code: - Median absolute deviation alone reads a user alternating 25 and 37 as perfectly consistent, because half her deviations are zero. Twenty disagreeing cycles came back High, breaking §15's rule that volume alone must never buy High confidence. Spread is now the larger of MAD and mean absolute deviation; robustness comes from IntervalAnalysis down-weighting what is questionable, which is a better place for it. - Recency weighting assumes the recent past predicts the near future. For a variable user that is false — her latest cycle is a draw from a wide distribution, not a signal — and weighting it equally cost three days on the §51 variable fixture. Recency is now trusted in proportion to how much her cycles actually agree. - A fixed one-day floor on trend detection fired on a 42-day-cycle history whose medians differed by a single day, turning an exact forecast into a wrong one. One day is a real trend at 28 and rounding error at 42, so the floor is relative to the user's own spread. WIRED THROUGH, NOT JUST TESTED PredictionInput carries recentAbsoluteErrors, and CycleRepository feeds the scored errors back in. Without that the app stores every error it makes and never reads one back — measuring accuracy rather than learning from it, with the widening happening only in a unit test. A repository test asserts the errors actually reach the engine. BaselinePredictionEngine stays as the control, and both engines run the same §51 acceptance suite, so the next engine's improvement is measurable too. 108 tests, all passing. ./gradlew check green. Verified on a device. closes #10 closes #11 closes #12 closes #14
2026-08-18 03:16:12 -05:00
* The engine, and the one line that decides which one the product ships.
feat: period CRUD end to end, and stop a double tap killing the app The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device: launch, log a period, it is stored, the forecast recalculates, edit or delete it and the forecast moves again. Hilt wiring, a TodayViewModel exposing one immutable state, and a working surface that says "Batch 01 · working surface" at the top so nobody mistakes it for the designed Today screen, which is Batch 03. THE DEFECT THIS FOUND, ON A DEVICE Tapping "Started today" twice on the same day killed the app: FATAL EXCEPTION: main android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: period_records.startDate Not a hypothetical — the crash was reproduced on emulator-5580, the fix applied, and the same two taps then produced "That day is already logged." with the process still alive and zero FATAL lines in logcat. The constraint is right: a duplicate must not overwrite the original row and lose its createdAt and source. The API around it was wrong. Repeating a tap when you are not sure the first one registered is an ordinary thing for a person to do, not a fault, and it must never be an exception. So the period writes return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound — and only genuine faults still throw. editPeriod had the same hole: moving a record onto a date another record holds. That is refused rather than merged, because merging would delete a period the user entered and only they can settle it. The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a health app a crash mid-write is adjacent to losing what was just entered, and a message somebody can read beats a process that vanished. The message carries the exception type and never a record's contents (§45). Four regression tests pin all of it, plus two instrumented tests on a real file-backed database that close and reopen it — what a force-stop actually does, and something an in-memory database cannot fail. 70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB. closes #6
2026-08-18 02:52:35 -05:00
*
feat: the prediction engine section 12 specifies, and it beats the baseline PersonalPredictionEngine keeps a discrete probability distribution over candidate start dates rather than a date with a margin bolted on. Everything the product needs falls out of that one structure: the most likely date is its mode, the window is the narrowest span holding 80% of the mass, and a "Not yet" is the distribution conditioned on what the user just said — which is what §13 asks for and what a date-plus-margin design cannot express at all. It is better, and that is a number rather than an opinion. EngineComparisonTest scores both engines over the §51 fixtures on every build: engine MAE mean window within +/-2 window covered baseline 1.00 2.67 7/9 7/9 personal 0.67 4.56 9/9 9/9 COVERAGE IS THE MEASURE, NOT WIDTH The first version of that test asserted the new windows must not be wider, and it failed. Measuring showed why the assertion was wrong: the fixtures where the personal engine is wider are the ones that are genuinely less certain — a history with a suspected missing period, and one with a 45-day outlier — and the baseline answers both with a two-day window and misses. What a window promises is that the period starts inside it. An engine keeping that promise 7 times in 9 has a broken promise, not a tight forecast. The test now asserts coverage, with a ceiling so "some time this month" still fails. THREE MODELLING BUGS THE TESTS FOUND Each was found by a test failing, not by reading the code: - Median absolute deviation alone reads a user alternating 25 and 37 as perfectly consistent, because half her deviations are zero. Twenty disagreeing cycles came back High, breaking §15's rule that volume alone must never buy High confidence. Spread is now the larger of MAD and mean absolute deviation; robustness comes from IntervalAnalysis down-weighting what is questionable, which is a better place for it. - Recency weighting assumes the recent past predicts the near future. For a variable user that is false — her latest cycle is a draw from a wide distribution, not a signal — and weighting it equally cost three days on the §51 variable fixture. Recency is now trusted in proportion to how much her cycles actually agree. - A fixed one-day floor on trend detection fired on a 42-day-cycle history whose medians differed by a single day, turning an exact forecast into a wrong one. One day is a real trend at 28 and rounding error at 42, so the floor is relative to the user's own spread. WIRED THROUGH, NOT JUST TESTED PredictionInput carries recentAbsoluteErrors, and CycleRepository feeds the scored errors back in. Without that the app stores every error it makes and never reads one back — measuring accuracy rather than learning from it, with the widening happening only in a unit test. A repository test asserts the errors actually reach the engine. BaselinePredictionEngine stays as the control, and both engines run the same §51 acceptance suite, so the next engine's improvement is measurable too. 108 tests, all passing. ./gradlew check green. Verified on a device. closes #10 closes #11 closes #12 closes #14
2026-08-18 03:16:12 -05:00
* Everything else depends on [PredictionEngine] rather than an
* implementation, which is what made this swap a single line and what makes
* the next one a single line too.
*
* `BaselinePredictionEngine` stays in the tree. It stopped being the product
* and became the control: `EngineComparisonTest` scores both over the §51
* fixtures every build, so "the new engine is better" is a number rather
* than an opinion. At the swap it was **mean absolute error 0.67 against
* 1.00, and the window contained the actual start 9 times out of 9 against
* 7** the second mattering more, because a window that misses is a broken
* promise rather than a tight forecast.
feat: period CRUD end to end, and stop a double tap killing the app The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device: launch, log a period, it is stored, the forecast recalculates, edit or delete it and the forecast moves again. Hilt wiring, a TodayViewModel exposing one immutable state, and a working surface that says "Batch 01 · working surface" at the top so nobody mistakes it for the designed Today screen, which is Batch 03. THE DEFECT THIS FOUND, ON A DEVICE Tapping "Started today" twice on the same day killed the app: FATAL EXCEPTION: main android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: period_records.startDate Not a hypothetical — the crash was reproduced on emulator-5580, the fix applied, and the same two taps then produced "That day is already logged." with the process still alive and zero FATAL lines in logcat. The constraint is right: a duplicate must not overwrite the original row and lose its createdAt and source. The API around it was wrong. Repeating a tap when you are not sure the first one registered is an ordinary thing for a person to do, not a fault, and it must never be an exception. So the period writes return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound — and only genuine faults still throw. editPeriod had the same hole: moving a record onto a date another record holds. That is refused rather than merged, because merging would delete a period the user entered and only they can settle it. The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a health app a crash mid-write is adjacent to losing what was just entered, and a message somebody can read beats a process that vanished. The message carries the exception type and never a record's contents (§45). Four regression tests pin all of it, plus two instrumented tests on a real file-backed database that close and reopen it — what a force-stop actually does, and something an in-memory database cannot fail. 70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB. closes #6
2026-08-18 02:52:35 -05:00
*/
@Provides
@Singleton
feat: the prediction engine section 12 specifies, and it beats the baseline PersonalPredictionEngine keeps a discrete probability distribution over candidate start dates rather than a date with a margin bolted on. Everything the product needs falls out of that one structure: the most likely date is its mode, the window is the narrowest span holding 80% of the mass, and a "Not yet" is the distribution conditioned on what the user just said — which is what §13 asks for and what a date-plus-margin design cannot express at all. It is better, and that is a number rather than an opinion. EngineComparisonTest scores both engines over the §51 fixtures on every build: engine MAE mean window within +/-2 window covered baseline 1.00 2.67 7/9 7/9 personal 0.67 4.56 9/9 9/9 COVERAGE IS THE MEASURE, NOT WIDTH The first version of that test asserted the new windows must not be wider, and it failed. Measuring showed why the assertion was wrong: the fixtures where the personal engine is wider are the ones that are genuinely less certain — a history with a suspected missing period, and one with a 45-day outlier — and the baseline answers both with a two-day window and misses. What a window promises is that the period starts inside it. An engine keeping that promise 7 times in 9 has a broken promise, not a tight forecast. The test now asserts coverage, with a ceiling so "some time this month" still fails. THREE MODELLING BUGS THE TESTS FOUND Each was found by a test failing, not by reading the code: - Median absolute deviation alone reads a user alternating 25 and 37 as perfectly consistent, because half her deviations are zero. Twenty disagreeing cycles came back High, breaking §15's rule that volume alone must never buy High confidence. Spread is now the larger of MAD and mean absolute deviation; robustness comes from IntervalAnalysis down-weighting what is questionable, which is a better place for it. - Recency weighting assumes the recent past predicts the near future. For a variable user that is false — her latest cycle is a draw from a wide distribution, not a signal — and weighting it equally cost three days on the §51 variable fixture. Recency is now trusted in proportion to how much her cycles actually agree. - A fixed one-day floor on trend detection fired on a 42-day-cycle history whose medians differed by a single day, turning an exact forecast into a wrong one. One day is a real trend at 28 and rounding error at 42, so the floor is relative to the user's own spread. WIRED THROUGH, NOT JUST TESTED PredictionInput carries recentAbsoluteErrors, and CycleRepository feeds the scored errors back in. Without that the app stores every error it makes and never reads one back — measuring accuracy rather than learning from it, with the widening happening only in a unit test. A repository test asserts the errors actually reach the engine. BaselinePredictionEngine stays as the control, and both engines run the same §51 acceptance suite, so the next engine's improvement is measurable too. 108 tests, all passing. ./gradlew check green. Verified on a device. closes #10 closes #11 closes #12 closes #14
2026-08-18 03:16:12 -05:00
fun predictionEngine(): PredictionEngine = PersonalPredictionEngine()
feat: period CRUD end to end, and stop a double tap killing the app The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device: launch, log a period, it is stored, the forecast recalculates, edit or delete it and the forecast moves again. Hilt wiring, a TodayViewModel exposing one immutable state, and a working surface that says "Batch 01 · working surface" at the top so nobody mistakes it for the designed Today screen, which is Batch 03. THE DEFECT THIS FOUND, ON A DEVICE Tapping "Started today" twice on the same day killed the app: FATAL EXCEPTION: main android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: period_records.startDate Not a hypothetical — the crash was reproduced on emulator-5580, the fix applied, and the same two taps then produced "That day is already logged." with the process still alive and zero FATAL lines in logcat. The constraint is right: a duplicate must not overwrite the original row and lose its createdAt and source. The API around it was wrong. Repeating a tap when you are not sure the first one registered is an ordinary thing for a person to do, not a fault, and it must never be an exception. So the period writes return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound — and only genuine faults still throw. editPeriod had the same hole: moving a record onto a date another record holds. That is refused rather than merged, because merging would delete a period the user entered and only they can settle it. The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a health app a crash mid-write is adjacent to losing what was just entered, and a message somebody can read beats a process that vanished. The message carries the exception type and never a record's contents (§45). Four regression tests pin all of it, plus two instrumented tests on a real file-backed database that close and reopen it — what a force-stop actually does, and something an in-memory database cannot fail. 70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB. closes #6
2026-08-18 02:52:35 -05:00
/** Injected rather than read from the environment, so §50's date edge cases stay testable. */
@Provides
@Singleton
fun clock(): Clock = Clock.systemDefaultZone()
}