Commit Graph

13 Commits

Author SHA1 Message Date
null 1761bf33a0 fix: account for not knowing her centre, not just her spread
The window promises to hold the actual start four times in five. For a
user whose cycles genuinely disagree it was delivering closer to three in
four, and the missing piece was structural rather than a mistuned knob:
the spread estimate measured how far her cycles fall from a centre and
never asked how well that centre is known. With four intervals it is not
known well.

The correction is the textbook one -- a future draw around an ESTIMATED
centre is wider than around the true centre by sqrt(1 + 1/n) -- taken over
the effective sample size, because recency weighting means eight intervals
do not contribute eight intervals' worth of evidence.

Applied only to the branch that actually estimates from a sample. The
zero- and one-interval branches are hand-tuned constants standing in for
total uncertainty when there is nothing to estimate from, and multiplying
those would have counted the same doubt twice -- measured, that mistake
widened a first-cycle window by a day and a half and bought nothing.

Not a knob: 1.22x at two intervals, 1.06x at eight, 1.03x at fifteen,
fixed by the arithmetic rather than chosen to reach a number.

The issue this closes was filed on a wrong measurement, and that is worth
recording. A 40-seed run showed mid-learning coverage collapsing to 57.5%;
at 400 seeds the same cells sit at 73-85%, and the collapse was one
unlucky cell rather than a defect. What survived the larger sample is
smaller and real, and is what this fixes: variable-user coverage 75.6% ->
76.8% late, 72.2% -> 73.3% mid-learning.

The floor in the guard stays below the promise on purpose. For an
intrinsically unpredictable cycle the last few points would cost width she
cannot afford, and the width ceiling in the same file is the other jaw of
that vice.

Fixture window returns to 4.56 days, exactly where it was before this
batch -- so across the three calibration commits the engine now predicts
the §51 fleet at MAE 0.44 instead of 0.67, with 9/9 coverage, for the same
window it always had.

closes #52

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:30:00 -05:00
null 45aefbbcb6 fix: stop charging a drifting cycle for being followed correctly
A user whose cycle lengthens steadily was tracked to within 1.2 days and
told the app was losing confidence: her window grew from 3.1 to 5.1 days
and read Low 31% of the time, the longer it followed her correctly.

The spread estimate measured every interval's distance from a single
static centre. Under a real trend those distances grow with the length of
the history however well the trend is being followed, so the engine was
reading its own success as her variability. Residuals are now measured
against the line the centre was built on. The slope is zero unless a
trend actually fires, so every history without a drift in it is
arithmetically the previous computation -- which is what protects the §51
fixtures rather than a promise to be careful.

Two things fell out of that, and both are the same discovery.

Detrending alone took drift coverage from 96% to 77%: honest residuals
around a line cannot see that the engine deliberately under-follows the
slope, so its centre lags by design. A scale floor applies while
extrapolating a trend -- following one is still an extrapolation.

And the damping itself was paying for the measurement error. While spread
came from a static centre, following a trend inflated the number that
decided how uncertain the forecast was, so the engine had to under-follow
to stay honest. With residuals read against the line that tax is gone:
raising damping 0.50 -> 0.70 halves the error on both §51 drift fixtures
(2 days -> 1), takes fleet MAE from 0.67 to 0.44, returns window coverage
to 9/9, and *narrows* the mean window from 4.56 to 4.33.

Everything improved at once, which is the signature of removing a
distortion rather than trading one fault for another.

Measured at 400 seeds, drifting user at ten or more cycles: MAE 1.19 ->
0.97, window 5.06 -> 3.31 days, coverage 96.1% -> 94.2%, High 6.6% ->
68.1%, Low 31% -> 0.2%. The guard now pins the narrower window together
with a coverage floor, because a tighter window that stopped holding the
answer would be the trade this must not make.

closes #53

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:27:14 -05:00
null e5001fedd4 fix: set the Medium threshold to the value that was measured
The previous commit shipped 0.44. The sweep that chose the threshold left
its last tried value in the file, and 0.44 was the one measured and
rejected: it pushes the drifting user back toward Low 21% of the time for
a cycle the engine tracks to within a day and a quarter -- telling her the
app has lost the thread of something it is following well.

0.40 is the measured choice. At 400 seeds and eight or more cycles:
stable reads High 88.5% and 88.1%, variable reads Low 93.1% and High
0.1%, drifting keeps Medium with Low at 3.3%.

Both values pass the suite, which is why this needed the numbers rather
than a green tick. The rationale is now in the constant, including the
AGREEMENT_SENSITIVITY alternative that was tried across three values and
why it cannot work: agreement multiplies into every score, so quietening
the variable user always costs the stable one her High.

Part of #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:21:33 -05:00
null 2450c9d854 fix: stop charging a woman confidence for being measured
A user whose cycles run 28 +/- 1 was predicted to within a day and told,
five times in six, that the app was only moderately sure. Measured: High
appeared in 15.7% of her forecasts once she had eight cycles logged.

The cause was an asymmetry in confidence(). With no scored forecasts the
accuracy term was a flat 0.92 and she scored 0.64 -- High. The moment
real errors arrived, averaging 0.9 days against a cycle that varies by a
day, the term fell to 0.735 and she scored 0.51 -- Medium, and Medium
from then on. Being demonstrably as accurate as her cycle allows rated
worse than never having been checked, which is §16's "your predictions
are getting better" contradicted by the label printed next to it.

So the yardstick is now what the forecast already admits it does not
know. For a Laplace distribution E|X| = b: the mean error of a perfectly
calibrated forecast IS its scale, so errors inside the scale are the
model working and only the excess counts. The constant is 1.0 by that
identity, not by tuning.

Real inaccuracy is still punished and still visible: it arrives through
agreement, because scored error already feeds the scale. A run of bad
forecasts widens the window and drops the confidence beside it, together,
for the same reason.

MEDIUM_THRESHOLD moves 0.30 -> 0.40 as part of the same change, not as a
second opinion. The old thresholds sat where they had to sit while every
score was deflated; with scores no longer deflated, 0.30 stopped
separating anything and the §51 variable user began reading Medium beside
an eleven-day window. Tightening AGREEMENT_SENSITIVITY was tried first
across three values and rejected -- agreement multiplies into every
score, so every value that quietened the variable user also took High
from the stable one, and 1.0 broke §51 outright.

Measured at 400 seeds, stable users k>=8: High 15.7% -> 88.5%. Variable
user stays Low 93% of the time and reads High in 0.1%. Accuracy and
window widths are untouched by this commit -- it changes what the app
says about its forecasts, not the forecasts.

modelVersion -> personal-2, so §16's history spans the change rather than
comparing two engines' errors as though one engine made them.

closes #51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:20:48 -05:00
null 335d0217a8 test: measure whether the engine actually learns, and keep measuring it
docs/qa/ClaudeQACoverage.md has carried this as a standing gap for four
rounds: whether predictions measurably improve at 3, 6 and 12 confirmed
cycles cannot be reached by a QA round, because it needs a simulated
history or a year of elapsed time. So the product's headline claim was
tested only one hand-picked fixture at a time.

This walks whole histories forward the way a device does -- predict,
score against what happened, feed the error back, predict again -- for
four simulated users across 400 seeds each, and asserts on aggregates.

Both directions are pinned. Accuracy floors alone would be satisfied by
an engine that answered "some time this month", so every accuracy row is
paired with a width ceiling and a confidence-honesty row.

Landing it before the calibration work it exists to guard, at thresholds
the current engine already meets, so each fix can be watched turning its
own row from red to green. A guard never seen failing is not known to
guard anything.

Two things the sample size taught, both worth keeping:

400 seeds rather than 40 costs 1.2 seconds and changes the conclusions.
The mid-learning coverage dip that a 40-seed run showed at 57% turns out
to be sampling noise -- the aggregate sits at 76-86% -- which is why the
assertions quantify over ranges of cycle-counts and never a single cell.

The variable user reads High twice in ~6,400 predictions, so this asserts
a rate ceiling rather than zero. Those two are not the failure §15
describes: the engine judges the evidence in front of it, not the process
behind it, and six cycles that happen to land within a day of each other
are a consistent history on the evidence. Asserting zero would have been
a guard that passed at 40 seeds and failed the first time anyone looked
harder.

Part of #50

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:18:01 -05:00
null a1efbde973 feat: guard §45's logging rules, and stop the leak that needed no log call
closes #38

checkNoHealthLogging fails the build on any logging call in a module that can
see a cycle date. It runs in `./gradlew check`.

WHY IT IS A GUARD AND NOT A GREP

Both traps were already live in this repository. PeriodApplication passes
android.util.Log.WARN to WorkManager as a CONSTANT, which is not a log call.
ReminderWorker's KDoc says "a Log.d in a worker is the kind that survives",
explaining why there isn't one — a naive grep fails the build on the clearest
possible explanation, and the obvious fix is to delete the explanation. So it
matches a call shape, and strips comments first.

Proved both directions per GUARDS.md §1: an injected Log.d in CycleRepository
produced exactly one failure; a comment containing Log.d( and println( stayed
green. It also failed its own first run by walking domain/*/bin/, a gitignored
IDE output holding stale copies of test files — a guard that fails on untracked
build output is one somebody switches off.

THE LEAK IT WAS NOT LOOKING FOR

Prediction's init block interpolated dates into its require messages:

  require(!windowStart.isAfter(windowEnd)) { "window start $windowStart is..." }

Five predicted dates across three messages, inside an IllegalArgumentException —
the one string a crash reporter collects without anybody choosing to log it.
§45 forbids exactly this and no logging statement was involved.

The same applies to every data class, since toString() renders every field into
any string that touches it. PeriodRecord, SpottingRecord, CycleRecord,
Prediction and NotYetObservation now override it: ids and cycle lengths survive,
dates do not. NoDatesInDiagnosticsTest pins seven cases and was itself proved to
fail.

R8 -assumenosideeffects strips android.util.Log from release, covering what a
source guard cannot reach: a dependency logging on our behalf, and a module
added without being listed in the guard.

VERIFIED ON A RELEASE BUILD, NOT REASONED ABOUT

assembleRelease signed with the debug keystore, installed, driven from
onboarding to a forecast and then logging a period: zero ISO dates in logcat,
zero health words, and the only mentions of the package are the system's own. A
screenshot confirms it reached a real forecast, because "no logs" is trivially
true of an app that did nothing.

201 tests pass. All three guards green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:01:33 -05:00
null 809a4317a7 chore: ignore .kotlin/, the compiler's session directory 2026-08-18 14:57:07 -05:00
null 5da7c18364 feat: fertility that declines rather than stretches
§17 and §18. Ovulation is estimated a luteal phase before the PREDICTED next
period rather than counted forwards from the last one — the luteal phase is the
stable half of the cycle, which is why §17 asks for it that way — and the
fertile window opens five days before ovulation and closes one day after,
because sperm survive and the egg does not.

The uncertainty is inherited, not invented. Ovulation is derived from a
predicted date, so it can never be more certain than that prediction.

THE PART THE DEVICE TAUGHT

The first version showed a user one cycle in a fertile window of 8 Aug – 24 Aug.
Seventeen days. Arithmetically honest, and completely useless — over half a
cycle, dressed up as a feature.

So the estimate now returns null past a usable uncertainty, and Today says "Not
enough history to estimate. Log a few more cycles and the app will be able to
estimate ovulation." Three stable cycles later the same user gets 12 Aug – 20
Aug, which is worth reading. Verified in both states on a device.

That is the same shape as PredictionAccuracy refusing figures below three scored
forecasts and CycleInsights withholding an average below two intervals, and it
is now written down in the architecture doc as a rule rather than three
coincidences: the app declines rather than stretches.

§18'S PROHIBITION IS A TYPE, NOT A CONVENTION

FertilityLikelihood has LOWER, HIGHER and UNKNOWN and no fourth value. Somebody
reading "safe" would take a decision on it; the estimate comes from a predicted
date carrying days of uncertainty; and §18 has already promised this is not
contraception. A test asserts no label contains a permission word, so adding one
is a deliberate act with a failing test.

The disclaimer travels with the feature — same screen, same time. A disclaimer
one tab away is a disclaimer nobody read.

ANOTHER GREYSCALE COLLISION

The ovulation star was centred, which put it directly behind the numeral: in
greyscale "18" and the mark merged into one smudge. Ovulation is now the fertile
ring plus a small star low in the cell, which is also semantically right — that
day IS inside the window, and the pair reads as "that window, and this day".

Nine Compose ModifierParameter warnings fixed properly rather than suppressed.

164 tests, all passing. ./gradlew check green, 0 lint errors.

closes #21
closes #22
closes #23
2026-08-18 14:56:05 -05:00
null 9d2d15038c feat: calendar whose states survive greyscale
§26's four states plus spotting, distinguished by SHAPE: a solid disc for a
period you logged, a dotted ring for one predicted, a small low dot for
spotting, a continuous ring for the fertile window, a four-pointed mark for
ovulation. Verified the way the requirement is written — the screenshot was
converted to greyscale and every mark is still tellable apart.

Predicted is never a lighter confirmed. §26 forbids them looking identical, and
opacity alone fails exactly when the screen is dim or the user is not looking
closely.

TWO PROBLEMS THE GREYSCALE CHECK FOUND

Today was drawn as an underline under the numeral. Spotting is a dot low in the
same cell. On the one day that was both — which was, of course, the day I looked
at — they merged into an unreadable smudge. Today is now an outline around the
whole cell, which cannot overlap anything drawn inside it.

The legend's spotting row was misaligned for the same reason: a mark anchored to
the bottom of its box next to vertically centred text. Fixed by moving the dot
up off the floor of the cell.

Neither was visible in the colour screenshot. Both were obvious in greyscale,
which is the argument for doing the check the way §43 describes rather than
assuming shapes differ because they were designed to.

Marks are derived per call, never stored: a calendar_days table would be a
second copy of facts the period records and the forecast already hold, stale the
moment either changed. An unclosed period marks only its start day, because
filling forward to today would draw days the user never said were period days.

Every day carries a content description — "18, spotting, today" — because §43 is
right that a grid of bare numerals carries none of a calendar's information.
Weekday initials repeat, so those are labelled too.

Tapping a day logs or removes an entry there; future days do nothing rather than
opening a sheet whose every action is disabled.

145 tests, all passing. ./gradlew check green.

closes #19
2026-08-18 04:01:47 -05:00
null afd22dddfa feat: the Today screen and its six states, with the number as the hero
§21 and §22, each state its own screen rather than a variant of one. Which one
applies is decided by CycleStatusRules in a pure module with twelve tests on its
boundaries — and the boundaries are the point, because they are the days this
screen is most read: the day a period is due, the day after one ends, the day a
forecast slips.

The number dominates (§38): displayLarge at 72sp, in the primary colour, with
the unit as a separate quiet line so "4" reads instantly and "DAYS" is there if
you look. Its content description carries the whole sentence, so TalkBack says
"Period likely in: 4 days" rather than reading a bare numeral.

The state this screen exists to get right is the last one. Past the forecast the
app NEVER says late — late implies a schedule the user failed to keep, and the
truth is that an estimate was imprecise. It shows what it originally said, what
it says now, and asks.

A UX DEFECT FOUND BY DRIVING IT

Tapping "Period ended" changed nothing on screen. The logic was right — a period
that ends today still includes today, so the state does not change — but the
button looked broken, which is worse than being broken somewhere visible.

DuringPeriod now carries the end date, so the screen shows "Ended 18 August" and
offers only the useful action (undo) rather than a button that visibly does
nothing. §24's "Updated ✓" acknowledgement is there too. No test would have
caught this; it needed somebody to tap the button and look.

The banner slot is reserved and empty. §48 wants no layout jump when an ad loads
and a graceful gap when one fails, and both are properties of the space existing
whether or not it is filled — reserving it in Batch 07 instead means shipping
the jump first. Deliberately not a "your ad here" box, which would be a
placeholder for the thing a user pays to remove.

Fertility lines are absent rather than faked: §22 shows them and Batch 04
estimates them, and a placeholder number there would be inventing a fertility
estimate, which is the one thing this screen must not do.

Preview pairs for every state, light and dark. 129 tests, all passing.

closes #17
2026-08-18 03:50:28 -05:00
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 b0b4f47a67 feat: detect a probable missed period without touching the history
IntervalAnalysis turns confirmed starts into weighted intervals and decides what
looks questionable — relative to this user's own history, never to a constant.

That distinction is the whole point. A global "over 40 days is suspicious" rule
gets exactly one group wrong, and it is the group whose cycles are already
unusual: the person this product exists for, and the one most tired of apps
assuming she is average. 45 days is unremarkable at a usual of 43 and worth
questioning at a usual of 29. Both are tests.

Two flags, because they earn different responses. A gap near a whole multiple of
the usual is a probable missed entry and produces §14's question. A gap merely
far from usual is down-weighted and left alone — asking would be the
over-questioning §25 warns against, and §51's 45-against-29 outlier is exactly
that case.

Nothing is ever dropped. §12 step 2: unusual data is marked for review or given
less influence, never silently deleted. A questionable interval keeps a quarter
of its recency weight, and a test asserts it is still present and still counts.

Recency decay is here too, ready for the centre in #10: newest cycle weight 1.0,
each older one 0.85 of the last.

12 new tests. The §51 acceptance cases still pass unchanged.

closes #13
2026-08-18 03:06:38 -05:00
null 96dd878ac5 chore: adopt the project template and add the Kotlin/Compose skeleton
Period was a bare directory holding one 2,527-line specification, with no git
repository, no tracker and no documentation convention. This is the adoption
from Projects/Template/START-HERE-New-Project.md, plus a project that compiles
so the hooks and future guards have something real to run against.

Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to
docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the
capitalised Docs/ is gone. Every scaffolded document was filled in for Period.
docs/OPERATIONS.md deleted — an offline app is not a deployed service.
DOC_TRUST_MAP.md written last, describing what is actually here, including what
this project deliberately does not have.

Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm")
and cannot see the Android SDK, so the engine is testable without an emulator —
17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51.
BaselinePredictionEngine is a robust-median prototype and explicitly not the
product; it exists so Batch 02's replacement can be shown to be better rather
than merely different.

Versions verified against their official sources today rather than inherited
from the specification's own numbers, which that document asks for: Kotlin
2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt
2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no
longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk
stays 36, Play's floor from 2026-08-31, and the difference is deliberate.

Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md.
Three hooks in .githooks/, with pre-commit adapted to Gradle.

closes #1
closes #2
2026-08-18 02:16:47 -05:00