Skip to content

stats.kt

src/jvmMain/kotlin/afield/math/statistics/stats.kt · 1956 lines · cited by Measuring dependence between two signals

package and imports — 80 lines, from line 1
@file:Suppress("MemberVisibilityCanBePrivate")
@file:afield.runtime.completion.CompletionCategory("Math")

package afield.math.statistics

import afield.data.collections.get
import afield.data.collections.times
import afield.data.graph.summaryHtml
import afield.document.graph.minusAssign
import afield.document.graph.named
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.document.workspace.path
import afield.geometry.spatial.center
import afield.graphics.color.rgba
import afield.graphics.render.filled
import afield.graphics.render.rangeTo
import afield.graphics.render.rect
import afield.lang.stacksl.evolution.render
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.round
import afield.math.autodiff.sign
import afield.math.autodiff.times
import afield.math.linalg.f
import afield.math.linalg.remAssign
import afield.math.linalg.rgba
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.linalg.www
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.open
import afield.runtime.execution.go
import afield.runtime.lifecycle.once
import afield.study.ui.numbers

import afield.animation.keyframes.h
import afield.audio.synth.*
import afield.data.collections.invoke
import afield.data.collections.s
import afield.document.properties.clear
import afield.geometry.mesh.summary
import afield.graphics.render.color
import afield.graphics.vulkan.buffers.add
import afield.lang.stacksl.h
import afield.math.autodiff.abs
import afield.math.autodiff.ceil
import afield.math.autodiff.floor
import afield.math.autodiff.log2
import afield.math.autodiff.max
import afield.math.autodiff.maxOf
import afield.math.autodiff.mean
import afield.math.autodiff.min
import afield.math.autodiff.pow
import afield.math.autodiff.sqrt
import afield.math.autodiff.sum
import afield.math.linalg.Vec2
import afield.math.linalg.Vec3
import afield.math.linalg.Vec4
import afield.math.linalg.d
import afield.math.linalg.i
import afield.math.linalg.invoke
import afield.math.linalg.median
import afield.math.linalg.x
import afield.media.image.histogram
import afield.media.image.median
import afield.runtime.execution.end
import afield.runtime.execution.start
import afield.ui.output.h
import java.lang.Math.ulp
import java.util.Locale
import kotlin.collections.ArrayDeque
import kotlin.math.*
import kotlin.math.atan2

Descriptive statistics, and the SVG/HTML renderings Field uses to put them in a box output.

buildSummaryStats reduces a sequence of doubles to a SummaryStats — count, quartiles, IQR, MAD, variance, skewness, excess kurtosis — dropping and counting non-finite values as it goes. buildHistogram picks a bin width as the smallest of the Freedman–Diaconis, Scott and Sturges estimates, clamped to at most 2000 bins. SummaryStatsBuffer wraps both behind a synchronized, capped sliding window you can add to sample by sample; there are parallel Vec2SummaryStats / Vec3SummaryStats / Vec4SummaryStats forms that carry per-component stats, vector length and the pairwise component correlations, each with their own buffer.

The rendering half — histogramToSvg, seriesPlotToSvg and summaryStatsBoxToHtml — builds SVG and HTML by string interpolation, with no drawing library involved, and defaults to Field's cyan-on-black look through a long list of colour and padding parameters. Pass the previous SummaryStats and changed numbers are tinted by the sign and relative size of the change. seriesPlotToSvg envelope-downsamples to maxSeriesPoints (500) before drawing.

data class HistogramBin(val start: Double, val end: Double, val count: Int)

One bin of a Histogram: the half-open interval [start, end) and how many samples fell in it.

buildHistogram tiles [min, max] with these, every bin the same width, so end is the next bin's start with no gap and no overlap — except for the last bin, whose end is set to the sample maximum exactly so the largest value has somewhere to go.

data class Histogram

A histogram and the five-number summary of the same data, as buildHistogram returns them.

bins are uniform-width and in ascending order; count sums them, so it is the number of finite samples that went in rather than the length of the input. binWidth is the width every bin shares, and modeBin the fullest one (the first, on a tie). The quartiles are the same linear-interpolated ones SummaryStats carries.

data class Histogram(
    val bins: List<HistogramBin>,
    val min: Double,
    val q1: Double,
    val median: Double,
    val q3: Double,
    val max: Double,
    val binWidth: Double
) {

val count: Int

    val count: Int
        get() = bins.sumOf { it.count }

val modeBin: HistogramBin?

    val modeBin: HistogramBin?
        get() = bins.maxByOrNull { it.count }
}

data class SummaryStats

Seventeen descriptive numbers about one sample, as buildSummaryStats returns them.

The two conventions do not match, and that is the one thing to know. sampleVariance / sampleStdDev divide by n−1 while populationVariance / populationStdDev divide by n — both are here, named — but skewness and kurtosisExcess are only ever the population forms, with no bias correction and no n−1 counterpart. So a SummaryStats printed field-by-field mixes the two, which is a trap when comparing against another tool. Against NumPy/SciPy the mapping is exact: q1/median/q3 are numpy.percentile's default linear interpolation, mad is median(|y − median(y)|) unscaled (no 1.4826 factor), sampleStdDev is numpy.std(ddof=1), and skewness/kurtosisExcess are scipy.stats.skew/scipy.stats.kurtosis at their default bias=True. Pinned to ten decimal places in StatsSummaryTest.

Non-finite input never reaches any of these: it is filtered out and counted in droppedCount, so count is the number of finite samples and can be well below the input length. Small samples are guarded rather than propagated, so you get a zero rather than a NaN: sampleVariance is 0.0 at n=1, skewness is 0.0 unless n>2, kurtosisExcess is 0.0 unless n>3. An empty sample gives Companion.empty, where everything but sum (0.0) and count (0) is NaN.

range and coeffOfVariation are derived on read. coeffOfVariation is sampleStdDev/mean, which is meaningless the moment the mean approaches zero — it returns NaN at exactly 0.0 and something enormous just beside it, so do not reach for it on a centred signal.

data class SummaryStats(
    val count: Int,
    val droppedCount: Int,
    val min: Double,
    val q1: Double,
    val median: Double,
    val q3: Double,
    val max: Double,
    val sum: Double,
    val mean: Double,
    val sampleVariance: Double,
    val sampleStdDev: Double,
    val populationVariance: Double,
    val populationStdDev: Double,
    val iqr: Double,
    val mad: Double,
    val skewness: Double,
    val kurtosisExcess: Double
) {

val isEmpty: Boolean

    val isEmpty: Boolean
        get() = count == 0

val range: Double

    val range: Double
        get() = if (isEmpty || !min.isFinite() || !max.isFinite()) Double.NaN else max - min

val coeffOfVariation: Double

    val coeffOfVariation: Double
        get() = if (mean == 0.0 || !mean.isFinite() || !sampleStdDev.isFinite()) Double.NaN else sampleStdDev / mean

companion object

fun empty(droppedCount: Int = 0): SummaryStats

        fun empty(droppedCount: Int = 0): SummaryStats = SummaryStats(
            count = 0,
            droppedCount = droppedCount,
            min = Double.NaN,
            q1 = Double.NaN,
            median = Double.NaN,
            q3 = Double.NaN,
            max = Double.NaN,
            sum = 0.0,
            mean = Double.NaN,
            sampleVariance = Double.NaN,
            sampleStdDev = Double.NaN,
            populationVariance = Double.NaN,
            populationStdDev = Double.NaN,
            iqr = Double.NaN,
            mad = Double.NaN,
            skewness = Double.NaN,
            kurtosisExcess = Double.NaN
        )
    }
}

data class SummaryStatsBox

Everything SummaryStatsBuffer.summaryBox knows in one value: the window's values in arrival order, their stats and histogram, and the buffer's three counters — so it is what summaryStatsBoxToHtml needs to render a whole card, and what you keep if you want to pass a previous card to it and get the changed numbers tinted.

windowSize is the buffer's cap, not the number of samples held; that is values.size, which equals stats.count. droppedNonFinite and droppedByCapacity are the two distinct counters described on SummaryStatsBuffer.

body — 116 lines, from line 214
data class SummaryStatsBox(
    val values: List<Double>,
    val stats: SummaryStats,
    val histogram: Histogram,
    val windowSize: Int,
    val samplesSeen: Long,
    val droppedNonFinite: Long,
    val droppedByCapacity: Long
)

private const val STATS_ORANGE_COLOR = "#ff8a00"
private const val STATS_UP_COLOR = "#55ff88"
private const val STATS_DOWN_COLOR = "#ff4d6d"
private const val STATS_SMALL_CHANGE_PIVOT = 0.08

private fun mixChannel(a: Int, b: Int, t: Double): Int =
    (a + ((b - a) * t.coerceIn(0.0, 1.0))).roundToInt().coerceIn(0, 255)

private fun parseHexColor(color: String): Triple<Int, Int, Int>? {
    if (!color.startsWith("#") || color.length != 7) return null
    return try {
        Triple(
            color.substring(1, 3).toInt(16),
            color.substring(3, 5).toInt(16),
            color.substring(5, 7).toInt(16)
        )
    } catch (_: NumberFormatException) {
        null
    }
}

private fun colorRamp(from: String, to: String, t: Double): String {
    val a = parseHexColor(from) ?: return from
    val b = parseHexColor(to) ?: return to
    val u = t.coerceIn(0.0, 1.0)
    return "#%02x%02x%02x".format(
        Locale.US,
        mixChannel(a.first, b.first, u),
        mixChannel(a.second, b.second, u),
        mixChannel(a.third, b.third, u)
    )
}

private fun relativeChangeMagnitude(current: Double, previous: Double?): Double {
    if (previous == null || !current.isFinite() || !previous.isFinite()) return 0.0
    val denom = abs(current) + abs(previous)
    if (denom <= 0.0) return 0.0
    return (abs(current - previous) / denom).coerceIn(0.0, 1.0)
}

private fun signedDeltaColor(current: Double, previous: Double?, fallback: String): String {
    if (previous == null || !current.isFinite() || !previous.isFinite()) return fallback
    if (current == previous) return fallback
    val amount = relativeChangeMagnitude(current, previous)
    if (amount <= 0.0) return fallback
    val target = if (current > previous) STATS_UP_COLOR else STATS_DOWN_COLOR
    return if (amount <= STATS_SMALL_CHANGE_PIVOT) {
        colorRamp(fallback, STATS_ORANGE_COLOR, amount / STATS_SMALL_CHANGE_PIVOT)
    } else {
        colorRamp(
            STATS_ORANGE_COLOR,
            target,
            (amount - STATS_SMALL_CHANGE_PIVOT) / (1.0 - STATS_SMALL_CHANGE_PIVOT)
        )
    }
}

private fun deltaStyle(current: Double, previous: Double?, fallback: String = "#0af"): String {
    val color = signedDeltaColor(current, previous, fallback)
    if (color == fallback) return ""
    return "color:$color;"
}

private fun deltaStyle(current: Long, previous: Long?, fallback: String = "#0af"): String =
    deltaStyle(current.toDouble(), previous?.toDouble(), fallback)

private fun deltaColor(current: Double, previous: Double?, fallback: String): String {
    return signedDeltaColor(current, previous, fallback)
}

private fun statMetric(key: String, value: String, style: String = "", keyTextOpacity: Double = 0.5): String = """
    <div style="display:grid;grid-template-columns:minmax(0,1fr) minmax(3.2em,max-content);gap:4px;white-space:nowrap;min-width:0;align-items:baseline;">
      <span style="opacity:${keyTextOpacity};text-align:right;overflow:hidden;text-overflow:ellipsis;">$key</span>
      <span style="$style;font-variant-numeric:tabular-nums;min-width:3.2em;">$value</span>
    </div>
""".trimIndent()

private fun svgTitleBackdrop(
    title: String?,
    widthPx: Int,
    padTop: Int,
    fontFamily: String,
    textColor: String
): String {
    if (title == null) return ""
    val titleWidth = (title.length * 7 + 18).coerceAtLeast(72)
    val x = ((widthPx - titleWidth) / 2.0).toFloat()
    val y = (padTop * 0.9 - 14.0).toFloat()
    return """
        <foreignObject x="$x" y="$y" width="$titleWidth" height="22">
          <div xmlns="http://www.w3.org/1999/xhtml" style="
              height:22px;
              display:flex;
              align-items:center;
              justify-content:center;
              color:$textColor;
              font-family:$fontFamily;
              font-size:12px;
              line-height:22px;
              background:rgba(0,0,0,0.88);
              backdrop-filter:blur(5px);
              -webkit-backdrop-filter:blur(5px);
          ">$title</div>
        </foreignObject>
    """.trimIndent()
}

data class Vec2SummaryStats

buildVec2SummaryStats's answer: a full SummaryStats per component, plus one for the vector length and one for the angleRadians (atan2(y, x), so in −π..π), plus the Pearson correlation between the two components.

A vector is kept only if both components are finite — one NaN drops the whole sample — and count/droppedCount read through to the x component's. correlationXY is exactly 1.0 for y = kx with k > 0, and NaN when fewer than two vectors survived or either component is constant. mean/median/min/max recombine the per-component numbers into a Vec2, so median is the componentwise median and not a point that was ever in the input.

data class Vec2SummaryStats(
    val x: SummaryStats,
    val y: SummaryStats,
    val length: SummaryStats,
    val angleRadians: SummaryStats,
    val correlationXY: Double
) {

val count: Int

    val count: Int
        get() = x.count

val droppedCount: Int

    val droppedCount: Int
        get() = x.droppedCount

val mean: Vec2

    val mean: Vec2
        get() = Vec2(x.mean.toFloat(), y.mean.toFloat())

val median: Vec2

    val median: Vec2
        get() = Vec2(x.median.toFloat(), y.median.toFloat())

val min: Vec2

    val min: Vec2
        get() = Vec2(x.min.toFloat(), y.min.toFloat())

val max: Vec2

    val max: Vec2
        get() = Vec2(x.max.toFloat(), y.max.toFloat())
}

data class Vec3SummaryStats

buildVec3SummaryStats's answer: a SummaryStats per component and one for the vector length, plus the three pairwise component correlations. There is no angle summary here — Vec2SummaryStats has one because a 2D direction is a single number and a 3D one is not.

A vector is kept only if all three components are finite. Each correlation is exactly 1.0 for an exactly proportional pair with a positive factor, and NaN when fewer than two vectors survived or one of the two components is constant.

data class Vec3SummaryStats(
    val x: SummaryStats,
    val y: SummaryStats,
    val z: SummaryStats,
    val length: SummaryStats,
    val correlationXY: Double,
    val correlationXZ: Double,
    val correlationYZ: Double
) {

val count: Int

    val count: Int
        get() = x.count

val droppedCount: Int

    val droppedCount: Int
        get() = x.droppedCount

val mean: Vec3

    val mean: Vec3
        get() = Vec3(x.mean.toFloat(), y.mean.toFloat(), z.mean.toFloat())

val median: Vec3

    val median: Vec3
        get() = Vec3(x.median.toFloat(), y.median.toFloat(), z.median.toFloat())

val min: Vec3

    val min: Vec3
        get() = Vec3(x.min.toFloat(), y.min.toFloat(), z.min.toFloat())

val max: Vec3

    val max: Vec3
        get() = Vec3(x.max.toFloat(), y.max.toFloat(), z.max.toFloat())
}

data class Vec4SummaryStats

buildVec4SummaryStats's answer: a SummaryStats per component and one for the vector length, plus all six pairwise component correlations.

A vector is kept only if all four components are finite. Nothing here treats w as special, so a Vec4 used as an RGBA colour gets its alpha summarised alongside the channels and folded into length with them.

data class Vec4SummaryStats(
    val x: SummaryStats,
    val y: SummaryStats,
    val z: SummaryStats,
    val w: SummaryStats,
    val length: SummaryStats,
    val correlationXY: Double,
    val correlationXZ: Double,
    val correlationXW: Double,
    val correlationYZ: Double,
    val correlationYW: Double,
    val correlationZW: Double
) {

val count: Int

    val count: Int
        get() = x.count

val droppedCount: Int

    val droppedCount: Int
        get() = x.droppedCount

val mean: Vec4

    val mean: Vec4
        get() = Vec4(x.mean.toFloat(), y.mean.toFloat(), z.mean.toFloat(), w.mean.toFloat())

val median: Vec4

    val median: Vec4
        get() = Vec4(x.median.toFloat(), y.median.toFloat(), z.median.toFloat(), w.median.toFloat())

val min: Vec4

    val min: Vec4
        get() = Vec4(x.min.toFloat(), y.min.toFloat(), z.min.toFloat(), w.min.toFloat())

val max: Vec4

    val max: Vec4
        get() = Vec4(x.max.toFloat(), y.max.toFloat(), z.max.toFloat(), w.max.toFloat())
}

data class Vec2SummaryStatsBox

What Vec2SummaryStatsBuffer.summaryBox returns: the window's vectors in arrival order, their stats, and the buffer's three counters. The scalar twin, SummaryStatsBox, also carries a Histogram; these do not, because there is no single histogram of a vector — ask the buffer for Vec2SummaryStatsBuffer.lengthHistogram if that is what you want.

data class Vec2SummaryStatsBox(
    val values: List<Vec2>,
    val stats: Vec2SummaryStats,
    val windowSize: Int,
    val samplesSeen: Long,
    val droppedNonFinite: Long,
    val droppedByCapacity: Long
)

data class Vec3SummaryStatsBox

What Vec3SummaryStatsBuffer.summaryBox returns — see Vec2SummaryStatsBox; the only difference is the stats type.

data class Vec3SummaryStatsBox(
    val values: List<Vec3>,
    val stats: Vec3SummaryStats,
    val windowSize: Int,
    val samplesSeen: Long,
    val droppedNonFinite: Long,
    val droppedByCapacity: Long
)

data class Vec4SummaryStatsBox

What Vec4SummaryStatsBuffer.summaryBox returns — see Vec2SummaryStatsBox; the only difference is the stats type.

data class Vec4SummaryStatsBox(
    val values: List<Vec4>,
    val stats: Vec4SummaryStats,
    val windowSize: Int,
    val samplesSeen: Long,
    val droppedNonFinite: Long,
    val droppedByCapacity: Long
)

class SummaryStatsBuffer

Rolling numeric summary + histogram buffer: add samples one at a time, ask for a snapshot, a histogram, a whole summaryBox or a rendered summaryHtml whenever you like.

Two independent drop counters. droppedNonFinite counts samples the buffer refused — null, NaN or an infinity, rejected at add time so values never holds one — while droppedByCapacity counts samples it accepted and later evicted because the window filled. Feeding 200 finite samples to a maxSize = 64 buffer leaves size == 64, samplesSeen == 200 and droppedByCapacity == 136. samplesSeen counts every add including the refused ones. snapshot carries droppedNonFinite forward into SummaryStats.droppedCount, so a snapshot's droppedCount is about refusals and says nothing about evictions.

Every read and mutation is @Synchronized on the buffer, so it is safe to add from one thread and snapshot from another — but note the maxSize setter is not, and it trims. maxSize is floored at 1. clear empties the window and keeps the counters unless you pass resetCounters = true.

class SummaryStatsBuffer(
    maxSize: Int = 100
) {
    private val values = ArrayDeque<Double>()

var maxSize: Int

    var maxSize: Int = maxSize.coerceAtLeast(1)
        set(value) {
            field = value.coerceAtLeast(1)
            trimToWindow()
        }

var samplesSeen: Long

    var samplesSeen: Long = 0L
        private set

var droppedNonFinite: Long

    var droppedNonFinite: Long = 0L
        private set

var droppedByCapacity: Long

    var droppedByCapacity: Long = 0L
        private set

val size: Int

    val size: Int
        @Synchronized
        get() = values.size

fun setWindow(size: Int): SummaryStatsBuffer

    @Synchronized
    fun setWindow(size: Int): SummaryStatsBuffer {
        maxSize = size
        return this
    }

fun clear(resetCounters: Boolean = false): SummaryStatsBuffer

    @Synchronized
    fun clear(resetCounters: Boolean = false): SummaryStatsBuffer {
        values.clear()
        if (resetCounters) {
            samplesSeen = 0L
            droppedNonFinite = 0L
            droppedByCapacity = 0L
        }
        return this
    }

fun add(value: Number?): SummaryStatsBuffer

Adds a numeric sample and returns this buffer for chaining.

Null and non-finite values are counted as dropped; finite values are converted to Double, appended, and trimmed to the current window.

    @Synchronized
    fun add(value: Number?): SummaryStatsBuffer {
        samplesSeen++
        val v = value?.toDouble()
        if (v == null || !v.isFinite()) {
            droppedNonFinite++
            return this
        }
        values.addLast(v)
        trimToWindow()
        return this
    }

fun add(value: Double): SummaryStatsBuffer = add(value as Number)

fun addAll(values: Iterable<Number?>): SummaryStatsBuffer

    fun addAll(values: Iterable<Number?>): SummaryStatsBuffer {
        values.forEach { add(it) }
        return this
    }

fun values(): List<Double>

    @Synchronized
    fun values(): List<Double> = values.toList()

fun snapshot(): SummaryStats

    @Synchronized
    fun snapshot(): SummaryStats =
        buildSummaryStats(values.toList(), droppedCount = clampLongToInt(droppedNonFinite))

fun histogram(): Histogram

    @Synchronized
    fun histogram(): Histogram = buildHistogram(values.toList())

fun summaryBox(): SummaryStatsBox

    @Synchronized
    fun summaryBox(): SummaryStatsBox {
        val current = values.toList()
        return SummaryStatsBox(
            values = current,
            stats = buildSummaryStats(current, droppedCount = clampLongToInt(droppedNonFinite)),
            histogram = buildHistogram(current),
            windowSize = maxSize,
            samplesSeen = samplesSeen,
            droppedNonFinite = droppedNonFinite,
            droppedByCapacity = droppedByCapacity
        )
    }

fun histogramSvg

body — 46 lines, from line 579
    fun histogramSvg(
        previousStats: SummaryStats? = null,
        widthPx: Int = 480,
        heightPx: Int = 240,
        padLeft: Int = 14,
        padRight: Int = 14,
        padTop: Int = 10,
        padBottom: Int = 22,
        fontFamily: String = "'Iosevka Web', Iosevka, monospace",
        title: String? = null,
        showCountsOnHover: Boolean = true,
        decimals: Int = 3,
        background: String = "#000000",
        textColor: String = "#0af",
        tickColor: String = "#0af",
        axisColor: String = "#0af",
        barFill: String = "#0af",
        barFillOpacity: Double = 0.2,
        barStroke: String = "#0af",
        barStrokeOpacity: Double = 0.5,
        barStrokeWidth: Double = 1.5,
        keyTextOpacity: Double = 0.5
    ): String = histogramToSvg(
        hist = histogram(),
        previousStats = previousStats,
        widthPx = widthPx,
        heightPx = heightPx,
        padLeft = padLeft,
        padRight = padRight,
        padTop = padTop,
        padBottom = padBottom,
        fontFamily = fontFamily,
        title = title,
        showCountsOnHover = showCountsOnHover,
        decimals = decimals,
        background = background,
        textColor = textColor,
        tickColor = tickColor,
        axisColor = axisColor,
        barFill = barFill,
        barFillOpacity = barFillOpacity,
        barStroke = barStroke,
        barStrokeOpacity = barStrokeOpacity,
        barStrokeWidth = barStrokeWidth,
        keyTextOpacity = keyTextOpacity
    )

fun seriesSvg

body — 44 lines, from line 626
    fun seriesSvg(
        previousStats: SummaryStats? = null,
        widthPx: Int = 480,
        heightPx: Int = 240,
        padLeft: Int = 40,
        padRight: Int = 64,
        padTop: Int = 10,
        padBottom: Int = 22,
        fontFamily: String = "'Iosevka Web', Iosevka, monospace",
        title: String? = null,
        decimals: Int = 3,
        background: String = "#000000",
        textColor: String = "#0af",
        tickColor: String = "#0af",
        axisColor: String = "#0af",
        seriesColor: String = "#0af",
        seriesOpacity: Double = 0.85,
        seriesStrokeWidth: Double = 1.5,
        refDash: String = "3,2",
        keyTextOpacity: Double = 0.5,
        minLabelSeparationPx: Double = 12.0
    ): String = seriesPlotToSvg(
        rawValues = values(),
        previousStats = previousStats,
        widthPx = widthPx,
        heightPx = heightPx,
        padLeft = padLeft,
        padRight = padRight,
        padTop = padTop,
        padBottom = padBottom,
        fontFamily = fontFamily,
        title = title,
        decimals = decimals,
        background = background,
        textColor = textColor,
        tickColor = tickColor,
        axisColor = axisColor,
        seriesColor = seriesColor,
        seriesOpacity = seriesOpacity,
        seriesStrokeWidth = seriesStrokeWidth,
        refDash = refDash,
        keyTextOpacity = keyTextOpacity,
        minLabelSeparationPx = minLabelSeparationPx
    )

fun summaryHtml

    fun summaryHtml(
        widthPx: Int = 480,
        chartHeightPx: Int = 120,
        decimals: Int = 3,
        fontFamily: String = "'Iosevka Web', Iosevka, monospace",
        background: String = "#000000",
        textColor: String = "#0af",
        axisColor: String = "#0af",
        keyTextOpacity: Double = 0.5,
        previousSummary: SummaryStatsBox? = null
    ): String = summaryStatsBoxToHtml(
        summary = summaryBox(),
        previousSummary = previousSummary,
        widthPx = widthPx,
        chartHeightPx = chartHeightPx,
        decimals = decimals,
        fontFamily = fontFamily,
        background = background,
        textColor = textColor,
        axisColor = axisColor,
        keyTextOpacity = keyTextOpacity
    )

    private fun trimToWindow() {
        while (values.size > maxSize) {
            values.removeFirst()
            droppedByCapacity++
        }
    }
}

class Vec2SummaryStatsBuffer

The Vec2 twin of SummaryStatsBuffer: add vectors one at a time into a capped, synchronized sliding window, then ask for a snapshot, a summaryBox or a lengthHistogram.

Same two independent counters, with the same distinction: droppedNonFinite counts vectors refused at add time — and a vector is refused if either component is non-finite — while droppedByCapacity counts vectors evicted once the window filled. maxSize is floored at 1. lengthHistogram bins the vector lengths, which is the only scalar a 2D sample has that is not a component.

class Vec2SummaryStatsBuffer(
    maxSize: Int = 100
) {
    private val values = ArrayDeque<Vec2>()

var maxSize: Int

    var maxSize: Int = maxSize.coerceAtLeast(1)
        set(value) {
            field = value.coerceAtLeast(1)
            trimToWindow()
        }

var samplesSeen: Long

    var samplesSeen: Long = 0L
        private set

var droppedNonFinite: Long

    var droppedNonFinite: Long = 0L
        private set

var droppedByCapacity: Long

    var droppedByCapacity: Long = 0L
        private set

val size: Int

    val size: Int
        @Synchronized
        get() = values.size

fun setWindow(size: Int): Vec2SummaryStatsBuffer

    @Synchronized
    fun setWindow(size: Int): Vec2SummaryStatsBuffer {
        maxSize = size
        return this
    }

fun clear(resetCounters: Boolean = false): Vec2SummaryStatsBuffer

    @Synchronized
    fun clear(resetCounters: Boolean = false): Vec2SummaryStatsBuffer {
        values.clear()
        if (resetCounters) {
            samplesSeen = 0L
            droppedNonFinite = 0L
            droppedByCapacity = 0L
        }
        return this
    }

fun add(value: Vec2?): Vec2SummaryStatsBuffer

    @Synchronized
    fun add(value: Vec2?): Vec2SummaryStatsBuffer {
        samplesSeen++
        val v = value?.let(::Vec2)
        if (v == null || !v.isFinite()) {
            droppedNonFinite++
            return this
        }
        values.addLast(v)
        trimToWindow()
        return this
    }

fun addAll(values: Iterable<Vec2?>): Vec2SummaryStatsBuffer

    fun addAll(values: Iterable<Vec2?>): Vec2SummaryStatsBuffer {
        values.forEach { add(it) }
        return this
    }

fun values(): List<Vec2>

    @Synchronized
    fun values(): List<Vec2> = values.map(::Vec2)

fun snapshot(): Vec2SummaryStats

    @Synchronized
    fun snapshot(): Vec2SummaryStats =
        buildVec2SummaryStats(values.map(::Vec2), droppedCount = clampLongToInt(droppedNonFinite))

fun summaryBox(): Vec2SummaryStatsBox

    @Synchronized
    fun summaryBox(): Vec2SummaryStatsBox {
        val current = values.map(::Vec2)
        return Vec2SummaryStatsBox(
            values = current,
            stats = buildVec2SummaryStats(current, droppedCount = clampLongToInt(droppedNonFinite)),
            windowSize = maxSize,
            samplesSeen = samplesSeen,
            droppedNonFinite = droppedNonFinite,
            droppedByCapacity = droppedByCapacity
        )
    }

fun lengthHistogram(): Histogram

    @Synchronized
    fun lengthHistogram(): Histogram = buildHistogram(values.asSequence().map { it.length().toDouble() }.toList())

fun lengthHistogramSvg

    fun lengthHistogramSvg(
        widthPx: Int = 480,
        heightPx: Int = 240,
        title: String? = "Vec2 length",
        decimals: Int = 3
    ): String = histogramToSvg(
        hist = lengthHistogram(),
        widthPx = widthPx,
        heightPx = heightPx,
        title = title,
        decimals = decimals
    )

    private fun trimToWindow() {
        while (values.size > maxSize) {
            values.removeFirst()
            droppedByCapacity++
        }
    }
}

class Vec3SummaryStatsBuffer

The Vec3 twin of SummaryStatsBuffer — see Vec2SummaryStatsBuffer for the counters and the all-components-must-be-finite rule. snapshot carries the three pairwise component correlations.

class Vec3SummaryStatsBuffer(
    maxSize: Int = 100
) {
    private val values = ArrayDeque<Vec3>()

var maxSize: Int

    var maxSize: Int = maxSize.coerceAtLeast(1)
        set(value) {
            field = value.coerceAtLeast(1)
            trimToWindow()
        }

var samplesSeen: Long

    var samplesSeen: Long = 0L
        private set

var droppedNonFinite: Long

    var droppedNonFinite: Long = 0L
        private set

var droppedByCapacity: Long

    var droppedByCapacity: Long = 0L
        private set

val size: Int

    val size: Int
        @Synchronized
        get() = values.size

fun setWindow(size: Int): Vec3SummaryStatsBuffer

    @Synchronized
    fun setWindow(size: Int): Vec3SummaryStatsBuffer {
        maxSize = size
        return this
    }

fun clear(resetCounters: Boolean = false): Vec3SummaryStatsBuffer

    @Synchronized
    fun clear(resetCounters: Boolean = false): Vec3SummaryStatsBuffer {
        values.clear()
        if (resetCounters) {
            samplesSeen = 0L
            droppedNonFinite = 0L
            droppedByCapacity = 0L
        }
        return this
    }

fun add(value: Vec3?): Vec3SummaryStatsBuffer

    @Synchronized
    fun add(value: Vec3?): Vec3SummaryStatsBuffer {
        samplesSeen++
        val v = value?.let(::Vec3)
        if (v == null || !v.isFinite()) {
            droppedNonFinite++
            return this
        }
        values.addLast(v)
        trimToWindow()
        return this
    }

fun addAll(values: Iterable<Vec3?>): Vec3SummaryStatsBuffer

    fun addAll(values: Iterable<Vec3?>): Vec3SummaryStatsBuffer {
        values.forEach { add(it) }
        return this
    }

fun values(): List<Vec3>

    @Synchronized
    fun values(): List<Vec3> = values.map(::Vec3)

fun snapshot(): Vec3SummaryStats

    @Synchronized
    fun snapshot(): Vec3SummaryStats =
        buildVec3SummaryStats(values.map(::Vec3), droppedCount = clampLongToInt(droppedNonFinite))

fun summaryBox(): Vec3SummaryStatsBox

    @Synchronized
    fun summaryBox(): Vec3SummaryStatsBox {
        val current = values.map(::Vec3)
        return Vec3SummaryStatsBox(
            values = current,
            stats = buildVec3SummaryStats(current, droppedCount = clampLongToInt(droppedNonFinite)),
            windowSize = maxSize,
            samplesSeen = samplesSeen,
            droppedNonFinite = droppedNonFinite,
            droppedByCapacity = droppedByCapacity
        )
    }

fun lengthHistogram(): Histogram

    @Synchronized
    fun lengthHistogram(): Histogram = buildHistogram(values.asSequence().map { it.length().toDouble() }.toList())

fun lengthHistogramSvg

    fun lengthHistogramSvg(
        widthPx: Int = 480,
        heightPx: Int = 240,
        title: String? = "Vec3 length",
        decimals: Int = 3
    ): String = histogramToSvg(
        hist = lengthHistogram(),
        widthPx = widthPx,
        heightPx = heightPx,
        title = title,
        decimals = decimals
    )

    private fun trimToWindow() {
        while (values.size > maxSize) {
            values.removeFirst()
            droppedByCapacity++
        }
    }
}

class Vec4SummaryStatsBuffer

The Vec4 twin of SummaryStatsBuffer — see Vec2SummaryStatsBuffer for the counters and the all-components-must-be-finite rule. snapshot carries all six pairwise component correlations.

class Vec4SummaryStatsBuffer(
    maxSize: Int = 100
) {
    private val values = ArrayDeque<Vec4>()

var maxSize: Int

    var maxSize: Int = maxSize.coerceAtLeast(1)
        set(value) {
            field = value.coerceAtLeast(1)
            trimToWindow()
        }

var samplesSeen: Long

    var samplesSeen: Long = 0L
        private set

var droppedNonFinite: Long

    var droppedNonFinite: Long = 0L
        private set

var droppedByCapacity: Long

    var droppedByCapacity: Long = 0L
        private set

val size: Int

    val size: Int
        @Synchronized
        get() = values.size

fun setWindow(size: Int): Vec4SummaryStatsBuffer

    @Synchronized
    fun setWindow(size: Int): Vec4SummaryStatsBuffer {
        maxSize = size
        return this
    }

fun clear(resetCounters: Boolean = false): Vec4SummaryStatsBuffer

    @Synchronized
    fun clear(resetCounters: Boolean = false): Vec4SummaryStatsBuffer {
        values.clear()
        if (resetCounters) {
            samplesSeen = 0L
            droppedNonFinite = 0L
            droppedByCapacity = 0L
        }
        return this
    }

fun add(value: Vec4?): Vec4SummaryStatsBuffer

    @Synchronized
    fun add(value: Vec4?): Vec4SummaryStatsBuffer {
        samplesSeen++
        val v = value?.let(::Vec4)
        if (v == null || !v.isFinite()) {
            droppedNonFinite++
            return this
        }
        values.addLast(v)
        trimToWindow()
        return this
    }

fun addAll(values: Iterable<Vec4?>): Vec4SummaryStatsBuffer

    fun addAll(values: Iterable<Vec4?>): Vec4SummaryStatsBuffer {
        values.forEach { add(it) }
        return this
    }

fun values(): List<Vec4>

    @Synchronized
    fun values(): List<Vec4> = values.map(::Vec4)

fun snapshot(): Vec4SummaryStats

    @Synchronized
    fun snapshot(): Vec4SummaryStats =
        buildVec4SummaryStats(values.map(::Vec4), droppedCount = clampLongToInt(droppedNonFinite))

fun summaryBox(): Vec4SummaryStatsBox

    @Synchronized
    fun summaryBox(): Vec4SummaryStatsBox {
        val current = values.map(::Vec4)
        return Vec4SummaryStatsBox(
            values = current,
            stats = buildVec4SummaryStats(current, droppedCount = clampLongToInt(droppedNonFinite)),
            windowSize = maxSize,
            samplesSeen = samplesSeen,
            droppedNonFinite = droppedNonFinite,
            droppedByCapacity = droppedByCapacity
        )
    }

fun lengthHistogram(): Histogram

    @Synchronized
    fun lengthHistogram(): Histogram = buildHistogram(values.asSequence().map { it.length().toDouble() }.toList())

fun lengthHistogramSvg

    fun lengthHistogramSvg(
        widthPx: Int = 480,
        heightPx: Int = 240,
        title: String? = "Vec4 length",
        decimals: Int = 3
    ): String = histogramToSvg(
        hist = lengthHistogram(),
        widthPx = widthPx,
        heightPx = heightPx,
        title = title,
        decimals = decimals
    )

    private fun trimToWindow() {
        while (values.size > maxSize) {
            values.removeFirst()
            droppedByCapacity++
        }
    }
}

fun buildSummaryStats

Builds descriptive summary statistics from the finite values in rawValues.

Non-finite inputs are excluded, added to droppedCount, and reported via onDrop when any values are dropped. Returns an empty summary if no finite values remain.

body — 80 lines, from line 1035
fun buildSummaryStats(
    rawValues: Iterable<Double>,
    droppedCount: Int = 0,
    onDrop: ((droppedCount: Int) -> Unit)? = null
): SummaryStats {
    val all = rawValues.toList()
    val filtered = all.filter { it.isFinite() }.toDoubleArray()
    val dropped = droppedCount + (all.size - filtered.size)
    if (dropped > 0) onDrop?.invoke(dropped)

    val n = filtered.size
    if (n == 0) {
        return SummaryStats.empty(droppedCount = dropped)
    }

    filtered.sort()
    val min = filtered.first()
    val max = filtered.last()
    val q1 = quantileSorted(filtered, 0.25)
    val median = quantileSorted(filtered, 0.5)
    val q3 = quantileSorted(filtered, 0.75)
    val iqr = q3 - q1

    var sum = 0.0
    for (v in filtered) sum += v
    val mean = sum / n.toDouble()

    var m2 = 0.0
    var m3 = 0.0
    var m4 = 0.0
    for (v in filtered) {
        val d = v - mean
        val d2 = d * d
        m2 += d2
        m3 += d2 * d
        m4 += d2 * d2
    }

    val populationVariance = m2 / n.toDouble()
    val sampleVariance = if (n > 1) m2 / (n - 1).toDouble() else 0.0
    val populationStdDev = sqrt(populationVariance.coerceAtLeast(0.0))
    val sampleStdDev = sqrt(sampleVariance.coerceAtLeast(0.0))

    val mad = filtered
        .map { abs(it - median) }
        .sorted()
        .toDoubleArray()
        .let { quantileSorted(it, 0.5) }

    val skewness = if (populationStdDev > 0.0 && n > 2) {
        (m3 / n.toDouble()) / populationStdDev.pow(3)
    } else {
        0.0
    }
    val kurtosisExcess = if (populationStdDev > 0.0 && n > 3) {
        (m4 / n.toDouble()) / populationStdDev.pow(4) - 3.0
    } else {
        0.0
    }

    return SummaryStats(
        count = n,
        droppedCount = dropped,
        min = min,
        q1 = q1,
        median = median,
        q3 = q3,
        max = max,
        sum = sum,
        mean = mean,
        sampleVariance = sampleVariance,
        sampleStdDev = sampleStdDev,
        populationVariance = populationVariance,
        populationStdDev = populationStdDev,
        iqr = iqr,
        mad = mad,
        skewness = skewness,
        kurtosisExcess = kurtosisExcess
    )
}

fun buildVec2SummaryStats

Summarizes finite 2D vectors by component, length, angle, and X/Y correlation.

Non-finite vectors are excluded, added to droppedCount, and reported via onDrop when any values are dropped. Empty input yields empty component stats and NaN correlation.

body — 42 lines, from line 1122
fun buildVec2SummaryStats(
    rawValues: Iterable<Vec2>,
    droppedCount: Int = 0,
    onDrop: ((droppedCount: Int) -> Unit)? = null
): Vec2SummaryStats {
    val all = rawValues.toList()
    val finite = all.filter { it.isFinite() }
    val dropped = droppedCount + (all.size - finite.size)
    if (dropped > 0) onDrop?.invoke(dropped)

    if (finite.isEmpty()) {
        val empty = SummaryStats.empty(droppedCount = dropped)
        return Vec2SummaryStats(
            x = empty,
            y = empty,
            length = empty,
            angleRadians = empty,
            correlationXY = Double.NaN
        )
    }

    val n = finite.size
    val xs = DoubleArray(n)
    val ys = DoubleArray(n)
    val lengths = DoubleArray(n)
    val angles = DoubleArray(n)
    for (i in 0 until n) {
        val v = finite[i]
        xs[i] = v.x.toDouble()
        ys[i] = v.y.toDouble()
        lengths[i] = v.length().toDouble()
        angles[i] = atan2(v.y.toDouble(), v.x.toDouble())
    }

    return Vec2SummaryStats(
        x = buildSummaryStats(xs.toList(), droppedCount = dropped),
        y = buildSummaryStats(ys.toList(), droppedCount = dropped),
        length = buildSummaryStats(lengths.toList(), droppedCount = dropped),
        angleRadians = buildSummaryStats(angles.toList(), droppedCount = dropped),
        correlationXY = correlation(xs, ys)
    )
}

fun buildVec3SummaryStats

Summarizes finite 3D vectors by component, length, and pairwise correlations.

Non-finite vectors are excluded, added to droppedCount, and reported via onDrop when any values are dropped. Empty input yields empty component stats and NaN correlations.

body — 46 lines, from line 1171
fun buildVec3SummaryStats(
    rawValues: Iterable<Vec3>,
    droppedCount: Int = 0,
    onDrop: ((droppedCount: Int) -> Unit)? = null
): Vec3SummaryStats {
    val all = rawValues.toList()
    val finite = all.filter { it.isFinite() }
    val dropped = droppedCount + (all.size - finite.size)
    if (dropped > 0) onDrop?.invoke(dropped)

    if (finite.isEmpty()) {
        val empty = SummaryStats.empty(droppedCount = dropped)
        return Vec3SummaryStats(
            x = empty,
            y = empty,
            z = empty,
            length = empty,
            correlationXY = Double.NaN,
            correlationXZ = Double.NaN,
            correlationYZ = Double.NaN
        )
    }

    val n = finite.size
    val xs = DoubleArray(n)
    val ys = DoubleArray(n)
    val zs = DoubleArray(n)
    val lengths = DoubleArray(n)
    for (i in 0 until n) {
        val v = finite[i]
        xs[i] = v.x.toDouble()
        ys[i] = v.y.toDouble()
        zs[i] = v.z.toDouble()
        lengths[i] = v.length().toDouble()
    }

    return Vec3SummaryStats(
        x = buildSummaryStats(xs.toList(), droppedCount = dropped),
        y = buildSummaryStats(ys.toList(), droppedCount = dropped),
        z = buildSummaryStats(zs.toList(), droppedCount = dropped),
        length = buildSummaryStats(lengths.toList(), droppedCount = dropped),
        correlationXY = correlation(xs, ys),
        correlationXZ = correlation(xs, zs),
        correlationYZ = correlation(ys, zs)
    )
}

fun buildVec4SummaryStats

Summarizes finite 4D vectors by component, length, and pairwise component correlations.

Non-finite vectors are excluded, added to droppedCount, and reported via onDrop when any values are dropped. Empty input yields empty component stats and NaN correlations.

body — 56 lines, from line 1224
fun buildVec4SummaryStats(
    rawValues: Iterable<Vec4>,
    droppedCount: Int = 0,
    onDrop: ((droppedCount: Int) -> Unit)? = null
): Vec4SummaryStats {
    val all = rawValues.toList()
    val finite = all.filter { it.isFinite() }
    val dropped = droppedCount + (all.size - finite.size)
    if (dropped > 0) onDrop?.invoke(dropped)

    if (finite.isEmpty()) {
        val empty = SummaryStats.empty(droppedCount = dropped)
        return Vec4SummaryStats(
            x = empty,
            y = empty,
            z = empty,
            w = empty,
            length = empty,
            correlationXY = Double.NaN,
            correlationXZ = Double.NaN,
            correlationXW = Double.NaN,
            correlationYZ = Double.NaN,
            correlationYW = Double.NaN,
            correlationZW = Double.NaN
        )
    }

    val n = finite.size
    val xs = DoubleArray(n)
    val ys = DoubleArray(n)
    val zs = DoubleArray(n)
    val ws = DoubleArray(n)
    val lengths = DoubleArray(n)
    for (i in 0 until n) {
        val v = finite[i]
        xs[i] = v.x.toDouble()
        ys[i] = v.y.toDouble()
        zs[i] = v.z.toDouble()
        ws[i] = v.w.toDouble()
        lengths[i] = v.length().toDouble()
    }

    return Vec4SummaryStats(
        x = buildSummaryStats(xs.toList(), droppedCount = dropped),
        y = buildSummaryStats(ys.toList(), droppedCount = dropped),
        z = buildSummaryStats(zs.toList(), droppedCount = dropped),
        w = buildSummaryStats(ws.toList(), droppedCount = dropped),
        length = buildSummaryStats(lengths.toList(), droppedCount = dropped),
        correlationXY = correlation(xs, ys),
        correlationXZ = correlation(xs, zs),
        correlationXW = correlation(xs, ws),
        correlationYZ = correlation(ys, zs),
        correlationYW = correlation(ys, ws),
        correlationZW = correlation(zs, ws)
    )
}

fun buildHistogram

Bin rawValues into a Histogram, choosing the bin width itself: the smallest of the Freedman–Diaconis, Scott and Sturges estimates, floored at range/2000 so the bin count can never exceed 2000. There is no bin-count parameter — if you need a specific one, bin it yourself.

"Smallest" means finest, which is usually what you want, but it is decided by global spread and that misfires on multi-modal data: for two clean modes 4 apart with a within-mode sd of 0.4, both Freedman–Diaconis (1.0065) and Scott (0.9077) read the between-mode separation as spread, so Sturges wins at 0.6329 — and Sturges knows nothing but n, giving ceil(log2 n + 1) = 10 bins for any 500-point sample. The same n of a unimodal signal gets 28. StatsSummaryTest pins both.

Bins tile [min, max] at uniform width with no gap, and the last bin's end is the maximum exactly, so Histogram.count equals the number of finite samples. Non-finite values are dropped before anything else and reported via onDrop when any were. A constant sample cannot be binned by width, so it gets one synthetic bin of max(8·ulp(min), 1e-9) centred on the value; an empty sample gives no bins at all and NaN for every quantile.

body — 56 lines, from line 1298
fun buildHistogram(
    rawValues: Iterable<Double>,
    onDrop: ((droppedCount: Int) -> Unit)? = null
): Histogram {
    val all = rawValues.toList()
    val values = all.filter { it.isFinite() }.toDoubleArray()
    val dropped = all.size - values.size
    if (dropped > 0) onDrop?.invoke(dropped)

    val n = values.size
    if (n == 0) {
        return Histogram(emptyList(), Double.NaN, Double.NaN, Double.NaN, Double.NaN, Double.NaN, Double.NaN)
    }

    values.sort()
    val min = values.first()
    val max = values.last()
    val range = max - min
    val q1 = quantileSorted(values, 0.25)
    val median = quantileSorted(values, 0.5)
    val q3 = quantileSorted(values, 0.75)

    if (range == 0.0) {
        val w = max(ulp(min) * 8.0, 1e-9)
        val b = HistogramBin(min - w / 2.0, min + w / 2.0, n)
        return Histogram(listOf(b), min, q1, median, q3, max, w)
    }

    val hFD = freedmanDiaconisWidth(values)
    val hScott = scottWidth(values)
    val kSturges = ceil(log2(n.toDouble()) + 1.0).toInt().coerceAtLeast(1)
    val hSturges = range / kSturges
    val hCandidate =
        listOf(hFD, hScott, hSturges).filter { it.isFinite() && it > 0.0 }.minOrNull() ?: range / sqrt(n.toDouble())

    val minWidth = range / 2000.0
    val h = max(hCandidate, minWidth)
    val k = ((range / h).roundToInt()).coerceIn(1, 2000)
    val width = range / k

    val starts = DoubleArray(k) { i -> min + i * width }
    val bins = MutableList(k) { i ->
        val s = starts[i]
        val e = if (i == k - 1) max else s + width
        HistogramBin(s, e, 0)
    }.toMutableList()

    for (x in values) {
        val idx = if (x == max) k - 1
        else ((x - min) / width).toInt().coerceIn(0, k - 1)
        val b = bins[idx]
        bins[idx] = b.copy(count = b.count + 1)
    }

    return Histogram(bins, min, q1, median, q3, max, width)
}

fun histogramToSvg

Render a histogram to an SVG string.

The chart includes bin bars and reference ticks at min/Q1/median/Q3/max.

body — 137 lines, from line 1360
fun histogramToSvg(
    hist: Histogram,
    previousStats: SummaryStats? = null,
    widthPx: Int = 480,
    heightPx: Int = 240,
    padLeft: Int = 14,
    padRight: Int = 14,
    padTop: Int = 10,
    padBottom: Int = 22,
    fontFamily: String = "'Iosevka Web', Iosevka, monospace",
    title: String? = null,
    showCountsOnHover: Boolean = true,
    decimals: Int = 3,
    background: String = "#000000",
    textColor: String = "#0af",
    tickColor: String = "#0af",
    axisColor: String = "#0af",
    barFill: String = "#0af",
    barFillOpacity: Double = 0.2,
    barStroke: String = "#0af",
    barStrokeOpacity: Double = 0.5,
    barStrokeWidth: Double = 1.5,
    keyTextOpacity: Double = 0.5
): String {
    if (hist.bins.isEmpty() || !hist.min.isFinite() || !hist.max.isFinite()) {
        return """
            <svg xmlns="http://www.w3.org/2000/svg" width="$widthPx" height="$heightPx" viewBox="0 0 $widthPx $heightPx">
              <rect x="0" y="0" width="$widthPx" height="$heightPx" fill="$background"/>
              <text x="${widthPx / 2}" y="${heightPx / 2}" text-anchor="middle" dominant-baseline="middle"
                    font-family="$fontFamily" font-size="12" fill="$textColor">No data</text>
            </svg>
        """.trimIndent()
    }

    val w = widthPx.toDouble()
    val h = heightPx.toDouble()
    val innerW = (w - padLeft - padRight).coerceAtLeast(1.0)
    val innerH = (h - padTop - padBottom).coerceAtLeast(1.0)
    val x0 = padLeft.toDouble()
    val y0 = padTop.toDouble()

    val maxCount = hist.bins.maxOf { it.count }.coerceAtLeast(1)
    val barW = innerW / hist.bins.size

    fun xForVal(v: Double): Double {
        val t = if (hist.max == hist.min) 0.5 else (v - hist.min) / (hist.max - hist.min)
        return x0 + t.coerceIn(0.0, 1.0) * innerW
    }

    fun fmt(d: Double): String = formatNumber(d, decimals)

    val bars = buildString {
        hist.bins.forEachIndexed { i, b ->
            val c = b.count
            if (c <= 0) return@forEachIndexed
            val barHeight = innerH * (c.toDouble() / maxCount.toDouble())
            val x = x0 + i * barW
            val gw = max(0.0, barW - 1.0)
            val drawHeight = barHeight.coerceAtLeast(0.5)

            append(
                """<rect x="${x.toFloat()}" y="${(y0 + innerH - drawHeight).toFloat()}" width="${gw.toFloat()}" height="${drawHeight.toFloat()}" """
            )
            append("""fill="$barFill" fill-opacity="${barFillOpacity}" """)
            append("""stroke="$barStroke" stroke-opacity="$barStrokeOpacity" stroke-width="$barStrokeWidth" vector-effect="non-scaling-stroke">""")
            if (showCountsOnHover) {
                append("<title>${c} in [${fmt(b.start)}, ${fmt(b.end)}]</title>")
            }
            append("</rect>\n")
        }
    }

    fun vTick(x: Double, color: String) =
        """<line x1="${x.toFloat()}" y1="${(y0 - 3).toFloat()}" x2="${x.toFloat()}" y2="${(y0 + innerH + 3).toFloat()}" stroke="$color" stroke-width="1" stroke-dasharray="2,2" opacity="0.9"/>"""

    val xMin = xForVal(hist.min)
    val xQ1 = xForVal(hist.q1)
    val xMed = xForVal(hist.median)
    val xQ3 = xForVal(hist.q3)
    val xMax = xForVal(hist.max)

    val axisBaseline =
        """<line x1="${x0.toFloat()}" y1="${(y0 + innerH).toFloat()}" x2="${(x0 + innerW).toFloat()}" y2="${(y0 + innerH).toFloat()}" stroke="$axisColor" stroke-width="1"/>"""

    val topY = (y0 - 6).toFloat()
    val botY = (y0 + innerH + 14).toFloat()
    val botYAlt = (y0 + innerH + 30).toFloat()

    val edgeInset = 2.0
    val minLabelX = max(x0 + edgeInset, xMin)
    val maxLabelX = min(x0 + innerW - edgeInset, xMax)

    fun dimmedLabel(x: Double, y: Double, anchor: String, key: String, value: Double, color: String): String = """
        <text x="${x.toFloat()}" y="${y.toFloat()}" text-anchor="$anchor"
              font-family="$fontFamily" font-size="10" fill="$color">
          <tspan fill-opacity="$keyTextOpacity">$key </tspan><tspan>${fmt(value)}</tspan>
        </text>
    """.trimIndent()

    val minColor = deltaColor(hist.min, previousStats?.min, tickColor)
    val q1Color = deltaColor(hist.q1, previousStats?.q1, tickColor)
    val medianColor = deltaColor(hist.median, previousStats?.median, tickColor)
    val q3Color = deltaColor(hist.q3, previousStats?.q3, tickColor)
    val maxColor = deltaColor(hist.max, previousStats?.max, tickColor)

    val labelsTop = buildString {
        append(dimmedLabel(minLabelX, topY.toDouble(), "start", "min", hist.min, minColor))
        append('\n')
        append(dimmedLabel(xMed, topY.toDouble(), "middle", "median", hist.median, medianColor))
        append('\n')
        append(dimmedLabel(maxLabelX, topY.toDouble(), "end", "max", hist.max, maxColor))
    }

    val labelsBottom = buildString {
        append(dimmedLabel(xQ1, botY.toDouble(), "middle", "Q1", hist.q1, q1Color))
        append('\n')
        append(dimmedLabel(xQ3, botYAlt.toDouble()-10, "middle", "Q3", hist.q3, q3Color))
    }

    val topTitle = svgTitleBackdrop(title, widthPx, padTop, fontFamily, textColor)

    return """
        <svg xmlns="http://www.w3.org/2000/svg" width="$widthPx" height="$heightPx" viewBox="0 0 $widthPx $heightPx">
          <rect x="0" y="0" width="$widthPx" height="$heightPx" fill="$background"/>
          $axisBaseline
          $bars
          ${vTick(xMin, minColor)}
          ${vTick(xQ1, q1Color)}
          ${vTick(xMed, medianColor)}
          ${vTick(xQ3, q3Color)}
          ${vTick(xMax, maxColor)}
          $labelsTop
          $labelsBottom
          $topTitle
        </svg>
    """.trimIndent()
}

fun seriesPlotToSvg

Render a time-series plot to SVG.

The plot shows a polyline with reference lines for min/Q1/median/Q3/max and an optional zero line when the range crosses 0.

body — 222 lines, from line 1504
fun seriesPlotToSvg(
    rawValues: Iterable<Double>,
    previousStats: SummaryStats? = null,
    widthPx: Int = 480,
    heightPx: Int = 240,
    padLeft: Int = 40,
    padRight: Int = 64,
    padTop: Int = 10,
    padBottom: Int = 22,
    fontFamily: String = "'Iosevka Web', Iosevka, monospace",
    title: String? = null,
    decimals: Int = 3,
    background: String = "#000000",
    textColor: String = "#0af",
    tickColor: String = "#0af",
    axisColor: String = "#0af",
    seriesColor: String = "#0af",
    seriesOpacity: Double = 0.85,
    seriesStrokeWidth: Double = 1.5,
    refDash: String = "3,2",
    keyTextOpacity: Double = 0.5,
    minLabelSeparationPx: Double = 12.0,
    maxSeriesPoints: Int = 500
): String {
    val values = rawValues.filter { it.isFinite() }
    if (values.isEmpty()) {
        return """
            <svg xmlns="http://www.w3.org/2000/svg" width="$widthPx" height="$heightPx" viewBox="0 0 $widthPx $heightPx">
              <rect x="0" y="0" width="$widthPx" height="$heightPx" fill="$background"/>
              <text x="${widthPx / 2}" y="${heightPx / 2}" text-anchor="middle" dominant-baseline="middle"
                    font-family="$fontFamily" font-size="12" fill="$textColor">No data</text>
            </svg>
        """.trimIndent()
    }

    val w = widthPx.toDouble()
    val h = heightPx.toDouble()
    val innerW = (w - padLeft - padRight).coerceAtLeast(1.0)
    val innerH = (h - padTop - padBottom).coerceAtLeast(1.0)
    val x0 = padLeft.toDouble()
    val y0 = padTop.toDouble()

    val sorted = values.sorted().toDoubleArray()
    val yMin0 = sorted.first()
    val yMax0 = sorted.last()
    val (yMin, yMax) = if (yMax0 == yMin0) {
        val d = max(ulp(yMin0) * 8.0, 1e-9)
        yMin0 - d / 2.0 to yMax0 + d / 2.0
    } else yMin0 to yMax0

    val q1 = quantileSorted(sorted, 0.25)
    val med = quantileSorted(sorted, 0.5)
    val q3 = quantileSorted(sorted, 0.75)

    val xMaxIndex = (values.lastIndex).coerceAtLeast(1).toDouble()

    fun xForIndex(i: Double): Double {
        return x0 + (i / xMaxIndex) * innerW
    }

    fun yForVal(v: Double): Double {
        val t = (v - yMin) / (yMax - yMin)
        return y0 + (1.0 - t) * innerH
    }

    fun fmt(d: Double) = formatNumber(d, decimals)

    data class IndexedValue(val index: Int, val value: Double)
    data class PlotPoint(val x: Double, val y: Double)

    fun envelopeDownsample(source: List<Double>, limit: Int): List<IndexedValue> {
        if (source.size <= limit || limit < 4) {
            return source.mapIndexed(::IndexedValue)
        }

        val interiorCount = source.size - 2
        val bucketCount = min((limit - 2) / 2, interiorCount)
        val sampled = ArrayList<IndexedValue>(limit)
        sampled += IndexedValue(0, source.first())

        repeat(bucketCount) { bucket ->
            val start = 1 + bucket * interiorCount / bucketCount
            val endExclusive = 1 + (bucket + 1) * interiorCount / bucketCount
            if (start >= endExclusive) return@repeat

            var minIndex = start
            var maxIndex = start
            for (index in start + 1 until endExclusive) {
                if (source[index] < source[minIndex]) minIndex = index
                if (source[index] > source[maxIndex]) maxIndex = index
            }

            if (minIndex <= maxIndex) {
                sampled += IndexedValue(minIndex, source[minIndex])
                if (maxIndex != minIndex) sampled += IndexedValue(maxIndex, source[maxIndex])
            } else {
                sampled += IndexedValue(maxIndex, source[maxIndex])
                sampled += IndexedValue(minIndex, source[minIndex])
            }
        }

        sampled += IndexedValue(source.lastIndex, source.last())
        return sampled
    }

    val points = envelopeDownsample(values, maxSeriesPoints.coerceAtLeast(4)).map { sample ->
        PlotPoint(xForIndex(sample.index.toDouble()), yForVal(sample.value))
    }
    val seriesPath = if (points.isNotEmpty()) {
        buildString {
            append("M ${points.first().x.toFloat()} ${points.first().y.toFloat()}")
            points.drop(1).forEach { p -> append(" L ${p.x.toFloat()} ${p.y.toFloat()}") }
        }
    } else ""

    val yMaxY = yForVal(yMax0)
    val yQ3 = yForVal(q3)
    val yMed = yForVal(med)
    val yQ1 = yForVal(q1)
    val yMinY = yForVal(yMin0)
    val hasZeroLine = (0.0 in yMin..yMax)
    val yZero = if (hasZeroLine) yForVal(0.0) else Double.NaN

    val axisBaseline =
        """<line x1="${x0.toFloat()}" y1="${(y0 + innerH).toFloat()}" x2="${(x0 + innerW).toFloat()}" y2="${(y0 + innerH).toFloat()}" stroke="$axisColor" stroke-width="1" opacity="0.6"/>"""

    fun hRef(y: Double, color: String, opacity: Double = 0.9, dash: String = refDash) =
        """<line x1="${x0.toFloat()}" y1="${y.toFloat()}" x2="${(x0 + innerW).toFloat()}" y2="${y.toFloat()}" stroke="$color" stroke-width="1" stroke-dasharray="$dash" opacity="$opacity"/>"""

    fun rightLabel(y: Double, key: String, value: Double?, color: String): String = """
        <text x="${(w - 6.0).toFloat()}" y="${(y + 3).toFloat()}" text-anchor="end"
              font-family="$fontFamily" font-size="10" fill="$color">
          <tspan fill-opacity="$keyTextOpacity">$key </tspan>""" + (if (value != null) """<tspan>${fmt(value)}</tspan>""" else "") + "</text>"

    fun leftLabel(y: Double, key: String?, value: Double?, color: String): String {
        val labelText = if (key == null) {
            if (value != null) "<tspan>${fmt(value)}</tspan>" else ""
        } else {
            """<tspan fill-opacity="$keyTextOpacity">$key </tspan>""" + (if (value != null) """<tspan>${fmt(value)}</tspan>""" else "")
        }
        return """
        <text x="6" y="${(y + 3).toFloat()}" text-anchor="start"
              font-family="$fontFamily" font-size="10" fill="$color">
          $labelText
        </text>
        """.trimIndent()
    }

    val minColor = deltaColor(yMin0, previousStats?.min, tickColor)
    val q1Color = deltaColor(q1, previousStats?.q1, tickColor)
    val medianColor = deltaColor(med, previousStats?.median, tickColor)
    val q3Color = deltaColor(q3, previousStats?.q3, tickColor)
    val maxColor = deltaColor(yMax0, previousStats?.max, tickColor)

    data class L(val y: Double, val key: String?, val value: Double?)

    fun layoutLabels(labels: List<L>, minSep: Double, yMinPix: Double, yMaxPix: Double): List<L> {
        if (labels.isEmpty()) return labels
        val sortedLs = labels.sortedBy { it.y }.toMutableList()
        var lastY = -1e18
        for (i in sortedLs.indices) {
            var y = sortedLs[i].y
            if (y - lastY < minSep) y = lastY + minSep
            y = y.coerceIn(yMinPix + 6.0, yMaxPix - 6.0)
            sortedLs[i] = sortedLs[i].copy(y = y)
            lastY = y
        }
        return sortedLs
    }

    val leftLabels = layoutLabels(
        buildList {
            add(L(yQ1, "Q1", q1))
            if (hasZeroLine) add(L(yZero, "0", null))
            add(L(yQ3, "Q3", q3))
        }, minLabelSeparationPx, y0, y0 + innerH
    )
    val rightLabels = layoutLabels(
        listOf(
            L(yMinY, "min", yMin0), L(yMed, "median", med), L(yMaxY, "max", yMax0)
        ), minLabelSeparationPx + 2.0, y0, y0 + innerH
    )

    val titleEl = svgTitleBackdrop(title, widthPx, padTop, fontFamily, textColor)

    return """
        <svg xmlns="http://www.w3.org/2000/svg" width="$widthPx" height="$heightPx" viewBox="0 0 $widthPx $heightPx">
          <rect x="0" y="0" width="$widthPx" height="$heightPx" fill="$background"/>
          $axisBaseline

          ${hRef(yMaxY, maxColor, opacity = 0.9)} 
          ${hRef(yQ3, q3Color, opacity = 0.85)} 
          ${hRef(yMed, medianColor, opacity = 0.95)} 
          ${hRef(yQ1, q1Color, opacity = 0.85)} 
          ${hRef(yMinY, minColor, opacity = 0.9)} 
          ${if (hasZeroLine) hRef(yZero, tickColor, opacity = 0.7, dash = "1,2") else ""}

          <path d="$seriesPath" fill="none" stroke="$seriesColor" stroke-opacity="$seriesOpacity"
                stroke-width="$seriesStrokeWidth" stroke-linecap="round" stroke-linejoin="round"
                vector-effect="non-scaling-stroke"/>

          ${leftLabels.joinToString("\n") {
            val color = when (it.key) {
                "Q1" -> q1Color
                "Q3" -> q3Color
                else -> tickColor
            }
            leftLabel(it.y, it.key, it.value, color)
          }}
          ${rightLabels.joinToString("\n") {
            val color = when (it.key) {
                "min" -> minColor
                "median" -> medianColor
                "max" -> maxColor
                else -> tickColor
            }
            rightLabel(it.y, it.key!!, it.value, color)
          }}
          $titleEl
        </svg>
    """.trimIndent()
}

fun summaryStatsBoxToHtml

Render a whole SummaryStatsBox as one self-contained HTML card: a three-column grid of thirteen metrics, then a histogramToSvg and a seriesPlotToSvg of the same window, all inline. No <img>, no <script>, nothing to fetch — hand it to display and it draws.

Each label names its convention, because SummaryStats mixes them: stddev (n−1) and variance (n−1) are the sample forms, skew (pop.) and kurtosis(ex, pop.) the population forms. dropped(non-finite) and window-evicted are the buffer's two distinct counters.

Pass previousSummary and every number that moved is tinted by the sign and relative size of the change — green up, red down, saturating as the relative move grows — which is what makes the card useful on a runloop. The long parameter list is colour and padding; the defaults are Field's cyan-on-black.

body — 165 lines, from line 1741
fun summaryStatsBoxToHtml(
    summary: SummaryStatsBox,
    previousSummary: SummaryStatsBox? = null,
    widthPx: Int = 480,
    chartHeightPx: Int = 120,
    decimals: Int = 3,
    fontFamily: String = "'Iosevka Web', Iosevka, monospace",
    background: String = "#000000",
    textColor: String = "#0af",
    axisColor: String = "#0af",
    keyTextOpacity: Double = 0.5,
    showFrame: Boolean = false,
    showHeader: Boolean = true,
    histogramTitle: String? = "Histogram",
    seriesTitle: String? = "Series",
    chartPadLeft: Int = 28,
    chartPadRight: Int = 28,
    chartPadTop: Int = 22,
    chartPadBottom: Int = 26
): String {
    val s = summary.stats
    val p = previousSummary?.stats
    val w = widthPx.coerceAtLeast(220)
    val panelPadding = 3
    val chartW = (w - panelPadding * 2).coerceAtLeast(214)
    val chartH = chartHeightPx.coerceAtLeast(64)

    val histogramSvg = histogramToSvg(
        hist = summary.histogram,
        previousStats = p,
        widthPx = chartW,
        heightPx = chartH,
        padLeft = chartPadLeft,
        padRight = chartPadRight,
        padTop = chartPadTop,
        padBottom = chartPadBottom,
        decimals = decimals,
        title = histogramTitle,
        fontFamily = fontFamily,
        background = background,
        textColor = textColor,
        tickColor = textColor,
        axisColor = axisColor,
        barFill = textColor,
        barStroke = textColor,
        keyTextOpacity = keyTextOpacity
    )

    val seriesSvg = seriesPlotToSvg(
        rawValues = summary.values,
        previousStats = p,
        widthPx = chartW,
        heightPx = chartH,
        padLeft = chartPadLeft,
        padRight = chartPadRight,
        padTop = chartPadTop,
        padBottom = chartPadBottom,
        decimals = decimals,
        title = seriesTitle,
        fontFamily = fontFamily,
        background = background,
        textColor = textColor,
        tickColor = textColor,
        axisColor = axisColor,
        seriesColor = textColor,
        keyTextOpacity = keyTextOpacity
    )

    val kv = listOf(
        statMetric("n", s.count.toString(), deltaStyle(s.count.toLong(), p?.count?.toLong()), keyTextOpacity),
        statMetric("seen", summary.samplesSeen.toString(), deltaStyle(summary.samplesSeen, previousSummary?.samplesSeen), keyTextOpacity),
        statMetric(
            "dropped(non-finite)",
            summary.droppedNonFinite.toString(),
            deltaStyle(summary.droppedNonFinite, previousSummary?.droppedNonFinite),
            keyTextOpacity
        ),
        statMetric(
            "window-evicted",
            summary.droppedByCapacity.toString(),
            deltaStyle(summary.droppedByCapacity, previousSummary?.droppedByCapacity),
            keyTextOpacity
        ),
        statMetric("range", formatNumber(s.range, decimals), deltaStyle(s.range, p?.range), keyTextOpacity),
        statMetric("mean", formatNumber(s.mean, decimals), deltaStyle(s.mean, p?.mean), keyTextOpacity),
        statMetric("stddev (n−1)", formatNumber(s.sampleStdDev, decimals), deltaStyle(s.sampleStdDev, p?.sampleStdDev), keyTextOpacity),
        statMetric("variance (n−1)", formatNumber(s.sampleVariance, decimals), deltaStyle(s.sampleVariance, p?.sampleVariance), keyTextOpacity),
        statMetric("IQR", formatNumber(s.iqr, decimals), deltaStyle(s.iqr, p?.iqr), keyTextOpacity),
        statMetric("MAD", formatNumber(s.mad, decimals), deltaStyle(s.mad, p?.mad), keyTextOpacity),
        statMetric("skew (pop.)", formatNumber(s.skewness, decimals), deltaStyle(s.skewness, p?.skewness), keyTextOpacity),
        statMetric(
            "kurtosis(ex, pop.)",
            formatNumber(s.kurtosisExcess, decimals),
            deltaStyle(s.kurtosisExcess, p?.kurtosisExcess),
            keyTextOpacity
        ),
        statMetric(
            "cv",
            formatNumber(s.coeffOfVariation, decimals),
            deltaStyle(s.coeffOfVariation, p?.coeffOfVariation),
            keyTextOpacity
        )
    )
    val metricsHtml = kv.joinToString("")

    val borderStyle = if (showFrame) "border:1px solid ${axisColor};" else ""
    val headerHtml = if (showHeader) {
        """
            <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px;">
              <span style="font-size:12px;">Summary stats</span>
              <span style="opacity:${keyTextOpacity};">window=${summary.windowSize}</span>
            </div>
        """.trimIndent()
    } else ""

    return """
        <div style="
            width:${w}px;
            box-sizing:border-box;
            ${borderStyle}
            padding:${panelPadding}px;
            background:${background};
            color:${textColor};
            font-family:${fontFamily};
            font-size:11px;
            line-height:1.25;
        ">
            $headerHtml
            <div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:4px 18px;margin-bottom:8px;align-items:baseline;">
              $metricsHtml
            </div>
            <div style="display:flex;flex-direction:column;gap:10px;">
              $histogramSvg
              $seriesSvg
            </div>
        </div>
    """.trimIndent()
}


private fun correlation(a: DoubleArray, b: DoubleArray): Double {
    if (a.size != b.size || a.size < 2) return Double.NaN

    val n = a.size
    val meanA = a.average()
    val meanB = b.average()

    var covSum = 0.0
    var varASum = 0.0
    var varBSum = 0.0
    for (i in 0 until n) {
        val da = a[i] - meanA
        val db = b[i] - meanB
        covSum += da * db
        varASum += da * da
        varBSum += db * db
    }

    val denom = kotlin.math.sqrt(varASum * varBSum)
    if (denom <= 0.0 || !denom.isFinite()) return Double.NaN
    return covSum / denom
}

private fun clampLongToInt(v: Long): Int =
    if (v > Int.MAX_VALUE.toLong()) Int.MAX_VALUE else if (v < Int.MIN_VALUE.toLong()) Int.MIN_VALUE else v.toInt()

Freedman–Diaconis rule of thumb for histogram bin width.

private fun freedmanDiaconisWidth(sorted: DoubleArray): Double {
    val n = sorted.size
    if (n < 2) return Double.NaN
    val q1 = quantileSorted(sorted, 0.25)
    val q3 = quantileSorted(sorted, 0.75)
    val iqr = q3 - q1
    if (!(iqr > 0.0)) return Double.NaN
    return 2.0 * iqr * n.toDouble().pow(-1.0 / 3.0)
}

Scott's rule of thumb for histogram bin width.

private fun scottWidth(sorted: DoubleArray): Double {
    val n = sorted.size
    if (n < 2) return Double.NaN
    val mean = sorted.average()
    val varSum = sorted.fold(0.0) { acc, v -> acc + (v - mean) * (v - mean) }
    val sigma = sqrt(varSum / (n - 1))
    if (!(sigma > 0.0)) return Double.NaN
    return 3.5 * sigma * n.toDouble().pow(-1.0 / 3.0)
}

Linear interpolation of the empirical quantile for sorted data.

private fun quantileSorted(sorted: DoubleArray, p: Double): Double {
    require(p in 0.0..1.0) { "p must be in [0,1]" }
    val n = sorted.size
    if (n == 0) return Double.NaN
    if (n == 1) return sorted[0]
    val x = (n - 1) * p
    val i = floor(x).toInt()
    val frac = x - i
    return if (i >= n - 1) sorted[n - 1] else sorted[i] * (1 - frac) + sorted[i + 1] * frac
}

Format a number for chart labels.

private fun formatNumber(v: Double, decimals: Int): String {
    if (!v.isFinite()) return v.toString()
    val abs = abs(v)
    return when {
        abs >= 1e6 || (abs != 0.0 && abs < 1e-3) -> "%.${decimals}e".format(v)
        else -> "%.${decimals}f".format(v)
    }
}