package dev.privacyllc.period.domain.prediction

import java.time.LocalDate
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.roundToLong
import kotlin.math.sqrt

/**
 * The engine PRODUCT_PLAN.md §12 specifies.
 *
 * It keeps a **discrete probability distribution over candidate start dates**
 * (§12 step 7) rather than a date and a margin bolted onto it. Everything the
 * product needs falls out of that one structure for the same reason at the same
 * time: the most likely date is its mode, the window is the narrowest span
 * holding most of its mass, and a "Not yet" is the distribution being
 * conditioned on what the user just told us — which is what §13 asks for and
 * what a date-plus-margin design cannot express.
 *
 * ## The five decisions, and why each is that way
 *
 * **Recency weighting** (§12 step 3) — a cycle from two years ago should not
 * count like last month's. `IntervalAnalysis` applies the decay.
 *
 * **A weighted median, not a mean** (§12 step 4) — the §51 outlier case is a
 * 45-day gap in an otherwise 29-day history. A mean moves to 31.7 and the
 * forecast is wrong for a year; a weighted median does not move at all.
 *
 * **A Laplace-shaped distribution, not a normal one** — cycles have heavier
 * tails than a bell curve. Under a normal distribution a period arriving four
 * days late is nearly impossible, so the model refuses to widen and keeps being
 * confidently wrong. Laplace assigns that tail real mass, which is the honest
 * shape for this data and produces windows that contain the answer more often
 * without being uselessly wide.
 *
 * **Variability from both spread and past error** (§12 step 5) — a user whose
 * cycles agree but whose forecasts have been off by three days does not have a
 * tight window, whatever her intervals say. Ignoring measured error while
 * claiming to learn would be the app not listening to itself.
 *
 * **Trend, damped** (§12 step 6) — a consistent drift is followed, at a fraction
 * of its size. §12: "Do not overfit one cycle."
 *
 * ## What it will not do
 *
 * Show percentages. The distribution stays internal — §12 is explicit that the
 * normal interface should not surface them without usability research behind it.
 */
class PersonalPredictionEngine : PredictionEngine {

    /**
     * Bumped from `personal-1` when confidence stopped punishing measured
     * accuracy that was already at the achievable floor, and when spread began
     * being measured against a followed trend rather than a static centre. Every
     * snapshot stores this, so §16's history spans the change honestly instead
     * of comparing two engines' errors as though one engine made them.
     */
    override val modelVersion: String = "personal-2"

    override fun predict(input: PredictionInput): Prediction? {
        val starts = input.confirmedStarts.distinct().sorted()
        if (starts.isEmpty()) return null

        val lastStart = starts.last()
        val intervals = IntervalAnalysis.intervals(starts)

        val centre = centreOf(intervals)
        val scale = scaleOf(intervals, centre, input.recentAbsoluteErrors)

        // The earliest date still possible. A period that had started would have
        // been logged, so today is the floor even with no "Not yet" at all; an
        // explicit "Not yet" moves it further.
        val ruledOutThrough = maxOf(
            input.today.minusDays(1),
            input.notYet.maxOfOrNull { it.date } ?: LocalDate.MIN,
        )

        val distribution = distribution(
            centreDate = lastStart.plusDays(centre.days.roundToLong()),
            scale = scale,
            ruledOutThrough = ruledOutThrough,
        ) ?: return null

        val mode = distribution.maxBy { it.value }.key
        val (windowStart, windowEnd) = window(distribution, mode)

        val confidence = confidence(
            intervals = intervals,
            scale = scale,
            notYetCount = input.notYet.size,
            recentErrors = input.recentAbsoluteErrors,
        )

        return Prediction(
            mostLikelyStartDate = mode,
            windowStart = windowStart,
            windowEnd = windowEnd,
            confidenceScore = confidence,
            confidenceLabel = label(confidence, intervals.size),
            modelVersion = modelVersion,
        )
    }

    // -----------------------------------------------------------------------
    // Centre
    // -----------------------------------------------------------------------

    /**
     * The personalised centre, with recency trusted in proportion to how much
     * the user's cycles actually agree.
     *
     * ## Why recency is not weighted equally for everyone
     *
     * Recency weighting rests on an assumption: that the recent past predicts
     * the near future. For a woman whose cycles run 28, 29, 28, 29 that is
     * obviously true. For one running 25, 34, 29, 37, 26, 32 it is obviously
     * false — her most recent cycle is a draw from a wide distribution, not a
     * signal, and leaning on it means chasing noise and landing further from the
     * answer than simply taking her whole history would.
     *
     * Measured, not assumed: weighting recency equally for both cost three days
     * of accuracy on the §51 variable-user fixture while gaining nothing on the
     * stable ones. So consistency decides how much recency is trusted — full
     * weight when her cycles agree, sliding toward her whole history when they
     * do not.
     *
     * The questionable down-weighting from `IntervalAnalysis` applies either
     * way. Doubt about a data point is not the same as doubt about recency.
     */
    /**
     * The forecast centre, and the per-cycle slope it was built with.
     *
     * The slope travels with the centre rather than being recomputed, because
     * [scaleOf] needs to know whether a trend is being followed: deviations from
     * a static centre and deviations from a line are different quantities, and
     * only one of them is this user's actual variability. Zero whenever no trend
     * fired, which is what makes the spread computation reduce exactly to its
     * previous form for every history without a drift in it.
     */
    private data class CentreEstimate(
        val days: Double,
        val trendPerCycle: Double,
        /** Median cycle length of the recent half, and its mean age — the line's anchor. */
        val anchorDays: Double,
        val anchorAge: Double,
    )

    private fun centreOf(intervals: List<Interval>): CentreEstimate {
        if (intervals.isEmpty()) {
            return CentreEstimate(POPULATION_DEFAULT_DAYS, 0.0, POPULATION_DEFAULT_DAYS, 0.0)
        }

        // Weights carrying the questionable damping but no recency decay.
        val flat = intervals.map { interval ->
            interval.days.toDouble() to if (interval.isQuestionable) {
                IntervalAnalysis.QUESTIONABLE_WEIGHT_FACTOR
            } else {
                1.0
            }
        }

        val wholeHistory = IntervalAnalysis.weightedMedian(flat)
        val recencyLed = IntervalAnalysis.weightedMedian(intervals.map { it.days.toDouble() to it.weight })

        val totalFlat = flat.sumOf { it.second }
        val rawSpread = if (totalFlat <= 0.0) 0.0 else {
            flat.sumOf { (d, w) -> abs(d - wholeHistory) * w } / totalFlat
        }
        val consistency = 1.0 / (1.0 + rawSpread / RECENCY_TRUST_SENSITIVITY)

        val base = consistency * recencyLed + (1.0 - consistency) * wholeHistory
        val trend = trendOf(intervals, base, rawSpread)
        return CentreEstimate(
            days = base + trend.damped,
            trendPerCycle = trend.perCycle,
            anchorDays = trend.anchorDays.takeIf { trend.perCycle != 0.0 } ?: base,
            anchorAge = trend.anchorAge,
        )
    }

    /**
     * A consistent drift, followed at a fraction of its size.
     *
     * Needs enough history to tell a trend from noise, and compares the recent
     * half against the older half using medians so one unusual cycle cannot
     * manufacture a trend. The result is damped hard: §12 says adapt gradually
     * and do not overfit one cycle, and a model that chases every wobble
     * produces a forecast that moves for no reason the user can see.
     */
    private data class Trend(
        /** How much to move the forecast, damped. */
        val damped: Double,
        /** Undamped slope in days per cycle. Zero when no trend fired. */
        val perCycle: Double,
        val anchorDays: Double,
        val anchorAge: Double,
    )

    private val noTrend = Trend(0.0, 0.0, 0.0, 0.0)

    private fun trendOf(intervals: List<Interval>, base: Double, spread: Double): Trend {
        if (intervals.size < MINIMUM_FOR_TREND) return noTrend

        val ordered = intervals.sortedBy { it.ageIndex }          // newest first
        val half = ordered.size / 2
        val recent = ordered.take(half).filterNot { it.isQuestionable }
        val older = ordered.drop(half).filterNot { it.isQuestionable }
        if (recent.isEmpty() || older.isEmpty()) return noTrend

        val recentMedian = IntervalAnalysis.median(recent.map { it.days.toDouble() })
        val olderMedian = IntervalAnalysis.median(older.map { it.days.toDouble() })
        val shift = recentMedian - olderMedian

        // Ignore drift smaller than the noise it would be indistinguishable
        // from — and "noise" is this user's own spread, not a constant.
        //
        // A fixed one-day floor 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 a cycle of 28 and rounding error at 42.
        val floor = maxOf(MINIMUM_TREND_DAYS, spread * TREND_SIGNAL_RATIO)
        if (abs(shift) <= floor) return noTrend

        // The same two medians, read as a line rather than a step: the shift
        // between the halves divided by the gap between their mean ages. Two
        // resistant points, for the same reason the shift uses medians at all.
        val recentAge = recent.map { it.ageIndex.toDouble() }.average()
        val olderAge = older.map { it.ageIndex.toDouble() }.average()
        val ageGap = olderAge - recentAge
        val perCycle = if (ageGap <= 0.0) 0.0 else shift / ageGap

        val damped = shift * TREND_DAMPING
        return Trend(
            damped = damped.coerceIn(-base * MAX_TREND_FRACTION, base * MAX_TREND_FRACTION),
            perCycle = perCycle,
            anchorDays = recentMedian,
            anchorAge = recentAge,
        )
    }

    // -----------------------------------------------------------------------
    // Spread
    // -----------------------------------------------------------------------

    /**
     * How wide the distribution is, from the user's own variability and from
     * how wrong the app has recently been.
     *
     * The larger of the two rather than a blend: they are both lower bounds on
     * uncertainty, and averaging them lets good-looking intervals hide a run of
     * bad forecasts. Being wrong is evidence, and it outranks looking tidy.
     */
    private fun scaleOf(intervals: List<Interval>, centre: CentreEstimate, recentErrors: List<Int>): Double {
        val fromSpread = when {
            intervals.isEmpty() -> NO_HISTORY_SCALE
            intervals.size == 1 -> SINGLE_INTERVAL_SCALE
            else -> {
                // Deviations from the line the centre was built on, not from a
                // point on it.
                //
                // When a cycle is genuinely lengthening, every interval sits at
                // a predictable distance from a static centre and that distance
                // grows with the length of the history. The engine was reading
                // its own successful tracking as this user's variability:
                // measured, a drifting user's window grew from 3.1 to 5.1 days
                // while her forecast stayed accurate to within a day and a
                // quarter, and she was told the app was less sure the longer it
                // followed her correctly.
                //
                // `trendPerCycle` is zero unless a trend actually fired, so for
                // every history without a drift in it this is arithmetically the
                // previous computation — deviations from `centre.days`, since
                // the anchor is then the base itself and the slope contributes
                // nothing. That equivalence is what protects the §51 fixtures.
                val deviations = intervals.map { interval ->
                    val expected = centre.anchorDays +
                        centre.trendPerCycle * (centre.anchorAge - interval.ageIndex)
                    abs(interval.days - expected) to interval.weight
                }

                // Two spread estimates, and the larger wins.
                //
                // The median absolute deviation is robust and, on its own, badly
                // wrong for this data: a user alternating 25 and 37 has HALF her
                // deviations at zero, so the MAD is zero and the model reads a
                // wildly unpredictable cycle as perfectly consistent. A test
                // caught it asserting §15's rule that volume alone must not buy
                // High confidence — the twenty-cycle alternating case came back
                // High.
                //
                // The mean absolute deviation sees that bimodality. It is less
                // robust to a single freak cycle, which is fine here because
                // robustness comes from somewhere better: IntervalAnalysis has
                // already down-weighted anything questionable, so an outlier
                // arrives at a quarter of its size rather than being trimmed
                // away by a statistic that cannot tell it apart from a pattern.
                val mad = IntervalAnalysis.weightedMedian(deviations) * MAD_TO_SCALE
                val totalWeight = intervals.sumOf { it.weight }
                val meanAd = if (totalWeight <= 0.0) 0.0 else {
                    deviations.sumOf { (d, w) -> d * w } / totalWeight * MEAN_AD_TO_SCALE
                }
                // Her cycles vary, AND the centre they vary around is only an
                // estimate. Everything above measures the first; this is the
                // second, and leaving it out is why an early window promised 80%
                // and delivered less.
                //
                // 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) — over the EFFECTIVE sample size, because
                // recency weighting means eight intervals do not contribute
                // eight intervals' worth of evidence. Kish's formula says what
                // they do contribute.
                //
                // Applied here and not to the branches above, deliberately:
                // those are hand-tuned constants standing in for total
                // uncertainty when there is nothing to estimate from, so
                // multiplying them would count the same doubt twice.
                //
                // Not a knob. 1.22x at two intervals, 1.06x at eight, 1.03x at
                // fifteen — the size is fixed by the arithmetic rather than
                // chosen to reach a number.
                val totalWeight2 = intervals.sumOf { it.weight }
                val effectiveN = totalWeight2 * totalWeight2 /
                    intervals.sumOf { it.weight * it.weight }
                maxOf(mad, meanAd) * sqrt(1.0 + 1.0 / effectiveN)
            }
        }

        // Following a trend is still an extrapolation.
        //
        // Detrending measures how tightly the cycles hug the line, and for a
        // steadily drifting user that is very tightly indeed — which is true and
        // not the whole truth. The engine deliberately under-follows the slope
        // (TREND_DAMPING), so its centre lags the drift by design, and residuals
        // around the line cannot see that lag at all. Measured, detrending alone
        // took the drifting user from covering 96% of actual starts to 77%: an
        // honest window turned into an overconfident one in a single step.
        //
        // So a trend that is being followed carries a floor under its scale.
        // Only when a trend fires — every other history is untouched.
        val trending = intervals.isNotEmpty() && centre.trendPerCycle != 0.0
        val floored = if (trending) maxOf(fromSpread, TREND_FOLLOW_MINIMUM_SCALE) else fromSpread

        val fromError = if (recentErrors.isEmpty()) 0.0 else {
            val recent = recentErrors.take(ERROR_WINDOW)
            IntervalAnalysis.median(recent.map { it.toDouble() }) * ERROR_TO_SCALE
        }

        return maxOf(floored, fromError).coerceIn(MINIMUM_SCALE, MAXIMUM_SCALE)
    }

    // -----------------------------------------------------------------------
    // The distribution
    // -----------------------------------------------------------------------

    /**
     * A discretised Laplace over candidate dates, with everything ruled out
     * removed and the remainder renormalised.
     *
     * Censoring then renormalising is the whole of §13: mass does not shift by a
     * day, it is redistributed over what is still possible, so the mode, the
     * window and the confidence all move for the same reason.
     */
    private fun distribution(
        centreDate: LocalDate,
        scale: Double,
        ruledOutThrough: LocalDate,
    ): Map<LocalDate, Double>? {
        val span = (scale * SPAN_IN_SCALES).roundToLong().coerceIn(MIN_SPAN_DAYS, MAX_SPAN_DAYS)

        var candidates = (-span..span).map { offset ->
            val date = centreDate.plusDays(offset)
            date to exp(-abs(offset) / scale)
        }.filter { it.first > ruledOutThrough }

        // Every candidate ruled out — a user long past the whole window. Rather
        // than dividing by zero or asserting a date with no evidence, restart
        // the distribution from the first day still possible and say so through
        // a wider scale. §13: repeated "Not yet" keeps updating the forecast.
        if (candidates.isEmpty()) {
            val restart = ruledOutThrough.plusDays(1)
            val widened = (scale * OVERDUE_WIDENING).coerceAtMost(MAXIMUM_SCALE)
            candidates = (0..span).map { offset ->
                restart.plusDays(offset) to exp(-offset / widened)
            }
        }

        val total = candidates.sumOf { it.second }
        if (total <= 0.0) return null
        return candidates.associate { (date, mass) -> date to mass / total }
    }

    /** The narrowest run of dates around the mode holding [WINDOW_MASS] of the probability. */
    private fun window(distribution: Map<LocalDate, Double>, mode: LocalDate): Pair<LocalDate, LocalDate> {
        val dates = distribution.keys.sorted()
        val modeIndex = dates.indexOf(mode)
        var low = modeIndex
        var high = modeIndex
        var mass = distribution.getValue(mode)

        while (mass < WINDOW_MASS && (low > 0 || high < dates.lastIndex)) {
            val nextLow = if (low > 0) distribution.getValue(dates[low - 1]) else -1.0
            val nextHigh = if (high < dates.lastIndex) distribution.getValue(dates[high + 1]) else -1.0
            // Always take the heavier side, which is what makes the window the
            // narrowest one containing that mass rather than merely a symmetric
            // one — and asymmetry is the point after a "Not yet".
            if (nextHigh >= nextLow) { high++; mass += nextHigh } else { low--; mass += nextLow }
        }
        return dates[low] to dates[high]
    }

    // -----------------------------------------------------------------------
    // Confidence
    // -----------------------------------------------------------------------

    /**
     * §15's list, with agreement dominating.
     *
     * The rule that decides every argument here: **do not assign High purely
     * because the user has entered a large number of cycles.** Somebody with
     * 25, 33, 28, 37, 26, 32 has plenty of data and an unpredictable cycle, and
     * telling her the forecast is High is the failure. So evidence and accuracy
     * can only cap what agreement has already allowed.
     */
    private fun confidence(
        intervals: List<Interval>,
        scale: Double,
        notYetCount: Int,
        recentErrors: List<Int>,
    ): Double {
        if (intervals.isEmpty()) return NO_HISTORY_CONFIDENCE

        val agreement = 1.0 / (1.0 + scale / AGREEMENT_SENSITIVITY)
        val evidence = (intervals.size.toDouble() / SATURATION_CYCLES).coerceAtMost(1.0)

        // Being measured must not cost her confidence she has earned.
        //
        // This term used to divide raw mean error, and the arithmetic said
        // something absurd: a stable user with no scored forecasts scored 0.64
        // (High), and the moment real errors arrived — averaging 0.9 days, which
        // is as close as anyone can get to a cycle that varies by a day — she
        // scored 0.51 and read Medium, permanently. Being demonstrably as
        // accurate as her cycle allows rated *worse* than never having been
        // checked, which turned §16's "your predictions are getting better" into
        // a claim the confidence label quietly contradicted.
        //
        // So the yardstick is what the forecast already admits it does not know.
        // For a Laplace distribution with scale b, E|X| = b: the mean error of a
        // perfectly calibrated forecast IS its scale. Errors inside that are the
        // model working, not the model failing, and only the excess counts.
        // EXPECTED_ERROR_PER_SCALE is 1.0 because of that identity — a property
        // of the distribution this engine already chose, not a tuned number.
        //
        // Real inaccuracy is still punished, and harder than it looks here: it
        // arrives through `agreement`, because ERROR_TO_SCALE feeds scored error
        // into the scale itself. A run of genuinely bad forecasts widens the
        // window and collapses agreement, which is the honest route — the window
        // the user is shown gets wider at the same moment the confidence beside
        // it drops, for the same reason.
        val accuracy = if (recentErrors.isEmpty()) NEUTRAL_ACCURACY else {
            val mean = recentErrors.take(ERROR_WINDOW).average()
            val excess = (mean - scale * EXPECTED_ERROR_PER_SCALE).coerceAtLeast(0.0)
            (1.0 / (1.0 + excess / ACCURACY_SENSITIVITY)).coerceIn(0.0, 1.0)
        }

        // Questionable intervals are doubt about the data itself, which is a
        // different thing from the data disagreeing — §15 lists both.
        val questionablePenalty = intervals.count { it.isQuestionable }.toDouble() /
            intervals.size * QUESTIONABLE_PENALTY

        val notYetPenalty = (1.0 - exp(-notYetCount * NOT_YET_SENSITIVITY)) * NOT_YET_MAX_PENALTY

        return (agreement * evidence * accuracy - questionablePenalty - notYetPenalty)
            .coerceIn(0.0, 1.0)
    }

    private fun label(confidence: Double, intervalCount: Int): ConfidenceLabel = when {
        intervalCount < MINIMUM_FOR_MEDIUM -> ConfidenceLabel.LOW
        confidence >= HIGH_THRESHOLD -> ConfidenceLabel.HIGH
        confidence >= MEDIUM_THRESHOLD -> ConfidenceLabel.MEDIUM
        else -> ConfidenceLabel.LOW
    }

    private companion object {
        /**
         * The one cycle length this engine ever assumes: the forecast for a user
         * with a single confirmed period and no interval to measure yet. Her own
         * data replaces it the moment she has any.
         *
         * 29, not the 28 everybody knows. Studies of hundreds of thousands of
         * app-recorded cycles put the mean near 29 days, and only a minority of
         * women average 28 — see docs/architecture/SCIENCE.md.
         *
         * `BaselinePredictionEngine` keeps 28.0 on purpose: it is the frozen
         * control, and moving it would move the yardstick.
         */
        const val POPULATION_DEFAULT_DAYS = 29.0

        // Spread. Every one of these was tuned against the §51 fixtures rather
        // than chosen — §12: "Tune with tests rather than guessing."
        const val NO_HISTORY_SCALE = 3.5
        const val SINGLE_INTERVAL_SCALE = 2.5
        const val MAD_TO_SCALE = 1.1
        const val MEAN_AD_TO_SCALE = 1.0
        /** Spread at which recency is trusted about half as much. Tuned against the §51 fixtures. */
        const val RECENCY_TRUST_SENSITIVITY = 2.0
        const val ERROR_TO_SCALE = 0.9
        const val ERROR_WINDOW = 6
        const val MINIMUM_SCALE = 0.7
        const val MAXIMUM_SCALE = 9.0

        // Distribution shape.
        const val SPAN_IN_SCALES = 6.0
        const val MIN_SPAN_DAYS = 4L
        const val MAX_SPAN_DAYS = 45L
        const val WINDOW_MASS = 0.80
        const val OVERDUE_WIDENING = 1.5

        // Trend.
        const val MINIMUM_FOR_TREND = 4
        const val MINIMUM_TREND_DAYS = 1.0
        const val TREND_SIGNAL_RATIO = 1.0
        // 0.35 -> 0.50 -> 0.70, each step measured.
        //
        // The last step came with detrended residuals, and the two belong
        // together. While spread was measured from a static centre, following a
        // trend inflated the very number that decided how uncertain the forecast
        // was, so the engine had to under-follow to stay honest — the damping
        // was paying for a measurement error. Once residuals are read against
        // the line, that tax is gone: at 0.70 the §51 drift fixtures halve their
        // error (2 days -> 1), the fleet MAE falls from 0.67 to 0.44, window
        // coverage returns to 9/9 and the mean window *narrows* 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.
        //
        // Still under 1.0, and still capped by MAX_TREND_FRACTION, because §12's
        // "do not overfit one cycle" is the other half of the rule. 0.80 was
        // measured too: same fixtures, slightly worse simulated coverage.
        const val TREND_DAMPING = 0.70
        const val MAX_TREND_FRACTION = 0.15
        /**
         * The narrowest scale a forecast may claim while extrapolating a trend.
         *
         * Set by measurement: without it the drifting user's coverage falls to
         * 77% against a window that promises 80%. See the comment in `scaleOf`.
         */
        const val TREND_FOLLOW_MINIMUM_SCALE = 1.2

        // Confidence.
        const val NO_HISTORY_CONFIDENCE = 0.10
        const val AGREEMENT_SENSITIVITY = 1.6
        const val SATURATION_CYCLES = 5.0
        const val NEUTRAL_ACCURACY = 0.92
        /**
         * The mean absolute error a perfectly calibrated forecast still makes,
         * as a multiple of its own scale. For a Laplace distribution E|X| = b,
         * so this is 1.0 by identity rather than by tuning.
         */
        const val EXPECTED_ERROR_PER_SCALE = 1.0
        const val ACCURACY_SENSITIVITY = 2.5
        const val QUESTIONABLE_PENALTY = 0.20
        const val NOT_YET_SENSITIVITY = 0.55
        const val NOT_YET_MAX_PENALTY = 0.30
        const val MINIMUM_FOR_MEDIUM = 2
        const val HIGH_THRESHOLD = 0.55
        /**
         * Raised from 0.30 when [EXPECTED_ERROR_PER_SCALE] landed, and not as a
         * separate opinion — the two are one change.
         *
         * The old accuracy term deflated every score, including scores that had
         * earned better, so the thresholds sat where they had to sit to let a
         * stable user through at all. Once accuracy stopped punishing errors the
         * window already admits, every calibrated forecast scored near its own
         * agreement and 0.30 no longer separated anything: the §51 variable user
         * — cycles from 25 to 37 — landed at 0.32 and began reading Medium
         * beside an eleven-day window.
         *
         * Measured at 400 seeds, not guessed. The ordering agreement produces is
         * already right (stable ~0.66, drifting ~0.52, variable ~0.32); only the
         * line was in the wrong place. 0.40 puts it in the gap: stable reads High
         * 88% of the time late in her history, variable reads Low 93%, and
         * drifting — tracked to within a day and a quarter — keeps Medium. 0.44
         * was measured too and rejected: it pushes drifting back toward Low at
         * 21%, telling a woman the app has lost the thread of a cycle it is
         * following well.
         *
         * Tightening AGREEMENT_SENSITIVITY instead was tried across 1.0, 1.2 and
         * 1.4 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.
         */
        const val MEDIUM_THRESHOLD = 0.40
    }
}
