package coffee.liz.rookery.stats import coffee.liz.rookery.model.DNF /** Averages the way the WCA (and csTimer) compute them. Inputs are results in ms, [DNF] for DNFs. */ object Averages { /** Solves trimmed from each end of an average of [n]: 1 for ao5/ao12, 5% beyond that. */ fun trim(n: Int): Int = if (n < 5) 0 else maxOf(1, (n + 19) / 20) /** Trimmed mean of a window of results; DNF if more DNFs than the trim allows. */ fun average(window: List): Long { if (window.isEmpty()) return DNF val sorted = window.sorted() return trimmedMean(sorted, trim(window.size)) } /** Plain mean; any DNF makes it DNF. Used for mo3. */ fun mean(window: List): Long = if (window.isEmpty() || window.any { it == DNF }) DNF else window.sum() / window.size private fun trimmedMean(sorted: List, t: Int): Long { val kept = sorted.subList(t, sorted.size - t) if (kept.any { it == DNF }) return DNF return (kept.sum() + kept.size / 2) / kept.size } /** * Best aoN (or moN when [mean]) over all windows, oldest first. O(len * n), fine up to ao1000 * on six-figure solve counts. */ fun best(results: List, n: Int, mean: Boolean = false): Long? { if (results.size < n) return null val window = ArrayList(n + 1) var best = DNF for ((i, r) in results.withIndex()) { window.add(lowerBound(window, r), r) if (i >= n) window.removeAt(lowerBound(window, results[i - n])) if (i >= n - 1) { val v = if (mean) (if (window.last() == DNF) DNF else window.sum() / n) else trimmedMean(window, trim(n)) if (v < best) best = v } } return best } private fun lowerBound(xs: List, v: Long): Int { var lo = 0; var hi = xs.size while (lo < hi) { val m = (lo + hi) ushr 1; if (xs[m] < v) lo = m + 1 else hi = m } return lo } } enum class StatKind(val label: String, val n: Int, val mean: Boolean = false) { SINGLE("single", 1), MO3("mo3", 3, true), AO5("ao5", 5), AO12("ao12", 12), AO50("ao50", 50), AO100("ao100", 100), AO1000("ao1000", 1000); } data class StatLine(val kind: StatKind, val current: Long?, val best: Long?) /** Summary of a chronological list of results. */ data class StatSummary(val count: Int, val solved: Int, val mean: Long?, val lines: List) { fun line(k: StatKind): StatLine? = lines.firstOrNull { it.kind == k } companion object { fun of(results: List): StatSummary { val solved = results.filter { it != DNF } val lines = StatKind.entries.mapNotNull { k -> if (results.size < k.n) return@mapNotNull null val last = results.subList(results.size - k.n, results.size) val current = when { k == StatKind.SINGLE -> last[0] k.mean -> Averages.mean(last) else -> Averages.average(last) } val best = if (k == StatKind.SINGLE) results.min() else Averages.best(results, k.n, k.mean) StatLine(k, current, best) } return StatSummary(results.size, solved.size, if (solved.isEmpty()) null else solved.sum() / solved.size, lines) } } } /** A personal best set by the most recent solve. */ data class PersonalBest(val kind: StatKind, val value: Long, val previous: Long?) /** * PBs earned by appending one solve: the new current value strictly beats the best from before. * Only meaningful right after an add; edits to old solves don't throw parties. */ fun newPersonalBests(before: StatSummary, after: StatSummary): List = after.lines.mapNotNull { line -> val cur = line.current ?: return@mapNotNull null if (cur == DNF) return@mapNotNull null val prev = before.line(line.kind)?.best if (prev == null || cur < prev) PersonalBest(line.kind, cur, prev) else null }