Skip to content

Improve statistics mean and average function #32

@ricky9667

Description

@ricky9667

Original content: https://hackmd.io/@Xanonymous/SyKFsFFxZg
Thanks @Xanonymous-GitHub for providing the improvement


What the WCA rules require (why these changes matter)
• Average of 5 (Ao5): remove the best and worst; one DNF/DNS may count as the worst; with 2+ DNF/DNS the average is DNF. 
• Mean of 3 (Mo3): any DNF/DNS ⇒ mean is DNF. 
• Rounding: attempts are recorded to hundredths under 10:00; averages/means are rounded to nearest hundredth under 10:00 and to the nearest second above 10:00. (This is easy to overlook!) 

Review of your code
• ✅ Logic for Ao5 with 0 or 1 DNF is conceptually right, and Mo3 returns DNF if any null—good.
⚠️ Rounding not applied per WCA (nearest 0.01s under 10:00; nearest second ≥ 10:00).
⚠️ Multiple passes over the list (sum, min, max)—fine for 5 items, but trivial to make single-pass.
⚠️ Implicit size assumptions. solveAverage() should assert exactly 5 attempts; solveMean() should assert 3 attempts, otherwise unexpected input can sneak through.
⚠️ Floating-point drift. Using Double math for 0.01s rounding can bite (e.g., 1.23*100 ≈ 122.999…). Prefer integer centiseconds or BigDecimal.
⚠️ Validation. Guard against negatives / NaN.
• 🤏 Readability: explicit names like averageAo5 / meanMo3 make intent clear and encode the rule in the signature.

A tight, robust implementation (Kotlin 2.2.x)

This version:
• Uses centiseconds (cs) for arithmetic to avoid FP error.
• Does one pass to compute sum/min/max and DNF count.
• Applies the exact WCA rounding rules to the final average/mean.
• Enforces expected attempt counts.

private const val EPS = 1e-9

private fun toCentiseconds(timeSec: Double): Long {
    require(timeSec >= 0.0 && !timeSec.isNaN()) { "Time must be a non-negative number" }
    // WCA attempts are truncated to hundredths under 10:00; if your inputs already are,
    // this preserves them. EPS avoids 122.999... from 1.23 * 100.
    return kotlin.math.floor(timeSec * 100.0 + EPS).toLong()
}

/** Round a total (in centiseconds) divided by n attempts per WCA 9f1. */
private fun roundAverage(totalCs: Long, n: Int): Double {
    // If the (true) average >= 600s, round to nearest second; else nearest hundredth.
    val threshold = 60000L * n  // 600s * 100cs/s * n
    return if (totalCs >= threshold) {
        // nearest second: divide by (n*100), half-up
        val sec = (totalCs + (n * 100) / 2) / (n * 100)
        sec.toDouble()
    } else {
        // nearest hundredth: divide by n cs, half-up
        val cs = (totalCs + n / 2) / n
        cs / 100.0
    }
}

fun List<Double?>.averageAo5(): Double? {
    require(size == 5) { "Average of 5 requires exactly 5 attempts." }

    var dnfs = 0
    var sumCs = 0L
    var minCs = Long.MAX_VALUE
    var maxCs = Long.MIN_VALUE

    for (t in this) {
        if (t == null) {
            dnfs++
            continue
        }
        val cs = toCentiseconds(t)
        sumCs += cs
        if (cs < minCs) minCs = cs
        if (cs > maxCs) maxCs = cs
    }

    if (dnfs >= 2) return null // 9f9

    val usedTotalCs = if (dnfs == 0) {
        // remove best and worst
        sumCs - minCs - maxCs
    } else {
        // one DNF counts as worst; remove best only
        sumCs - minCs
    }

    // average over 3 results (size - 2)
    return roundAverage(usedTotalCs, 3)
}

fun List<Double?>.meanMo3(): Double? {
    require(size == 3) { "Mean of 3 requires exactly 3 attempts." }

    var sumCs = 0L
    for (t in this) {
        if (t == null) return null // 9f11
        sumCs += toCentiseconds(t)
    }
    return roundAverage(sumCs, 3)
}

Notes & optional tweaks
• If you also want to represent +2 penalties, keep your Double inputs as “final times including any penalties”. If you want stronger typing, model attempts as a sealed type (Time, DNF, DNS) and map to nullable Double at the edges.
• If you prefer BigDecimal over integer centiseconds, use it only at the final rounding step (it’s heavier than ints but fine for 3–5 items).
• If these run in a hot path, you can mark helpers @JvmInline value classes for cs/seconds, but for Ao5/Mo3 it won’t matter.

Why this is “correct”
• Ao5 keep/drop rules and DNF handling match 9f8–9f9; Mo3 DNF handling matches 9f10–9f11; rounding matches 9f1 (nearest 0.01s under 10:00; nearest second ≥ 10:00). 

If you want, toss me a couple real input examples (including a ≥10:00 case and a “with +2” case) and I’ll sanity-check the outputs against the regs.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementImprovements don't affect user

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions