1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
|
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>): 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>): Long =
if (window.isEmpty() || window.any { it == DNF }) DNF else window.sum() / window.size
private fun trimmedMean(sorted: List<Long>, 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<Long>, n: Int, mean: Boolean = false): Long? {
if (results.size < n) return null
val window = ArrayList<Long>(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<Long>, 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<StatLine>) {
fun line(k: StatKind): StatLine? = lines.firstOrNull { it.kind == k }
companion object {
fun of(results: List<Long>): 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<PersonalBest> =
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
}
|