Commit Graph

2 Commits

Author SHA1 Message Date
null 2fe423cf47 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
null adc50751d8 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