feat: initial release
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-03 10:00:00 +02:00
commit af4ee19703
617 changed files with 191195 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package stats
import (
"math"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Windowed (rolling) reductions over a series: each output element
// summarises one window of consecutive samples. The result holds
// n − window + 1 elements, one per full window, aligned so element i
// summarises samples [i, i+window). Series of every real dtype are
// accepted, and the extrema follow the package's NaN rule: a NaN never
// wins a comparison, and a window that holds nothing but NaN answers
// NaN, exactly as core.Min and core.Max do.
func rollingCheck(a *core.Array, window int) ([]float64, int, error) {
const name = "Rolling"
if a.NDim() != 1 {
return nil, 0, base.Errf("%s: the series must be a vector, got shape %s", name, base.ShapeText(a.Shape()))
}
if a.Dtype() == core.Complex {
return nil, 0, base.Errf("%s: complex series have no ordering to reduce", name)
}
n := a.Len()
if window < 1 || window > n {
return nil, 0, base.Errf("%s: the window must lie in [1, %d], got %d", name, n, window)
}
// Promote through FloatAt, never through RawFloats alone: the
// float64 payload is empty for an int or float32 array, and a
// strided view would be read at the wrong stride. A dense float64
// array copies its payload directly, the same elements the
// accessor walk returned. The copy also means the fold below
// costs no per-element bounds check.
src := make([]float64, n)
if fs := rawFloats(a); fs != nil {
copy(src, fs)
} else {
for i := range n {
src[i] = a.FloatAt(i)
}
}
return src, n - window + 1, nil
}
// RollingMean averages each window of the series.
func RollingMean(a *core.Array, window int) (*core.Array, error) {
src, outLen, err := rollingCheck(a, window)
if err != nil {
return nil, err
}
out := core.New(core.Float, outLen)
rollingTotals(src, out.RawFloats(), outLen, window, true)
return out, nil
}
// RollingSum totals each window of the series.
func RollingSum(a *core.Array, window int) (*core.Array, error) {
src, outLen, err := rollingCheck(a, window)
if err != nil {
return nil, err
}
out := core.New(core.Float, outLen)
rollingTotals(src, out.RawFloats(), outLen, window, false)
return out, nil
}
// rollingTotals fills the first outLen entries of vals with each full
// window's total of src: the sum, or the mean for mean.
//
// Past a short window the total moves through the series instead of
// rescanning it: it is carried between positions as a two-float pair,
// so one position costs the entering element plus the negated leaving
// one, whatever the window's length, where the rescan costs a fold
// over the whole window at every position. The pair, not a bare
// running sum, is what keeps that honest: a subtractive update sheds
// the low bits of every add and subtract, and on a long window of
// mixed magnitudes the drift ends up wider than what a per-window
// rescan loses, while the pair carries every bit the format holds.
//
// Short windows stay with the rescan, and that is a measured choice,
// not a concession: the rescan's fold costs a couple of cycles a
// sample while the carried walk pays its compensation chain at every
// position whatever the window, so below the crossover the rescan is
// the faster walk by two to three times, and equally accurate.
//
// A window that answers a non-finite total, through a non-finite
// sample or an overflowed sum, answers exactly what the per-window
// rescan answered: the carried pair turns non-finite with it, the
// window is folded from scratch, and the refolded pair becomes the
// carried state for the windows that follow.
func rollingTotals(src []float64, vals []float64, outLen, window int, mean bool) {
if window < rollingIncrementalWindow {
for i := range outLen {
total := 0.0
for _, v := range src[i : i+window] {
total += v
}
if mean {
total /= float64(window)
}
vals[i] = total
}
return
}
hi, lo := 0.0, 0.0
for _, v := range src[:window] {
hi, lo = rollingAdd(hi, lo, v)
}
total := hi + lo
if !rollingFinite(total) {
for _, v := range src[:window] {
total += v
}
}
if mean {
total /= float64(window)
}
vals[0] = total
for i := 1; i < outLen; i++ {
hi, lo = rollingAdd(hi, lo, src[i+window-1])
hi, lo = rollingAdd(hi, lo, -src[i-1])
total = hi + lo
if !rollingFinite(total) {
// The window answers what the per-window rescan answered,
// and the carried state restarts from the window itself,
// refolded with the same two-float care the walk carries:
// seeding the state from the rescan's rounded total would
// leak that rounding into every window after.
hi, lo, total = 0, 0, 0
for _, v := range src[i : i+window] {
total += v
hi, lo = rollingAdd(hi, lo, v)
}
}
if mean {
total /= float64(window)
}
vals[i] = total
}
}
// rollingIncrementalWindow is the window length the rolling totals
// switch from the per-window rescan to the carried update at. The
// rescan's cost per position grows with the window while the carried
// walk's is flat, and the crossover sits near a window of forty;
// sixty-four is the power of two above it, where the carried walk
// already answers twice as fast.
const rollingIncrementalWindow = 64
// rollingAdd returns the two-float pair for hi + lo + b. Knuth's
// TwoSum catches the rounding error of the wide add, and the
// renormalisation folds the pair back so the low word stays at the
// rounding level of the high one; the pair then represents the carried
// total to double-double precision across an unbounded walk of adds
// and subtracts.
func rollingAdd(hi, lo, b float64) (float64, float64) {
s := hi + b
bb := s - hi
lo += (hi - (s - bb)) + (b - bb)
t := s + lo
return t, lo - (t - s)
}
// rollingFinite reports whether v is a finite number: the carried
// total is trusted only while it stays one.
func rollingFinite(v float64) bool {
return math.Abs(v) <= math.MaxFloat64
}
// RollingMax tracks each window's largest sample. A NaN never wins a
// comparison and an all-NaN window answers NaN, the package's rule.
func RollingMax(a *core.Array, window int) (*core.Array, error) {
src, outLen, err := rollingCheck(a, window)
if err != nil {
return nil, err
}
return rollingExtreme(src, outLen, window, true), nil
}
// RollingMin tracks each window's smallest sample, with the same NaN
// rule as RollingMax.
func RollingMin(a *core.Array, window int) (*core.Array, error) {
src, outLen, err := rollingCheck(a, window)
if err != nil {
return nil, err
}
return rollingExtreme(src, outLen, window, false), nil
}
// rollingExtreme folds each window with a monotonic deque: an index
// leaves the back of the deque only when a later sample is strictly
// better, so the front always holds the window's extreme, and each
// index enters and leaves the deque once. The scan is O(n) where a
// rescan of every window is O(n·window), and it selects the sample the
// rescan selected: a comparison is strict in both, so a tie, the ±0
// pair included, keeps the earlier index, and a window whose samples
// are all NaN answers its last element, exactly the value the rescan's
// own seed walk ends on.
func rollingExtreme(src []float64, outLen, window int, greater bool) *core.Array {
out := core.New(core.Float, outLen)
vals := out.RawFloats()
// The deque holds indices in ascending order, improving towards the
// back; head is its front, and every entry before head has expired.
// The buffer is compacted once the dead prefix outgrows the live
// region, which keeps it proportional to the deque's depth rather
// than to the series length: each compaction copies at most the
// entries it drops, and every entry is dropped once.
deque := make([]int, 0, min(window, rollingDequeCompact))
head := 0
for i, v := range src {
if head >= rollingDequeCompact && head >= len(deque)-head {
deque = deque[:copy(deque, deque[head:])]
head = 0
}
if !math.IsNaN(v) {
if greater {
for len(deque) > head && src[deque[len(deque)-1]] < v {
deque = deque[:len(deque)-1]
}
} else {
for len(deque) > head && src[deque[len(deque)-1]] > v {
deque = deque[:len(deque)-1]
}
}
deque = append(deque, i)
}
if i+1 < window {
continue
}
oldest := i - window + 1
for head < len(deque) && deque[head] < oldest {
head++
}
if head == len(deque) {
// Every sample of the window is NaN; the rescan answers the
// window's last element, and so does this.
vals[oldest] = v
continue
}
vals[oldest] = src[deque[head]]
}
return out
}
// rollingDequeCompact is the dead-prefix length at which the windowed
// extrema compact their deque buffer: eight kilobytes of indices, past
// which the copy pays for itself on any series long enough to reach it.
const rollingDequeCompact = 1 << 10