// Copyright (c) 2026 Petr Balvín (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