summaryrefslogtreecommitdiff
path: root/core/src/commonMain/kotlin/coffee/liz/rookery/stats/Stats.kt
blob: 59a2a9338da36fe9098bc09cc236cda28a090fb2 (plain) (blame)
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
    }