using System; namespace T3.Core.Stats; /// /// Sliding-window sample buffer with a bucketed histogram and running aggregates. /// Pure data: no drawing. Pair with a renderer (e.g. Editor's MetricGraphView) to visualise. /// /// All updates are O(1) amortized and allocation-free after construction: /// - cyclic sample buffer /// - histogram increment/decrement on head/tail only /// - sliding sum for average /// - monotonic deques for exact min/max over the window /// public sealed class RollingMetric { /// Per-bucket state inside a . public struct HistogramSlot { public int CountRecent; // values in the current sliding window public long CountTotal; // all-time count since construction public double LastIncrementTime; public float ValueRangeMin; // lower edge of the bucket's value range } public delegate int BucketIndexer(float value); public int Capacity => _capacity; public int Count => _count; public int BucketCount => _slots.Length; public ReadOnlySpan Slots => _slots; public float Min => _minCount > 0 ? _values[_minDeque[_minHead]] : 0f; public float Max => _maxCount > 0 ? _values[_maxDeque[_maxHead]] : 0f; public float Average => _count > 0 ? (float)(_slidingSum / _count) : 0f; public float Last => _count > 0 ? _values[(_tail - 1 + _capacity) % _capacity] : 0f; public RollingMetric(int capacity, int bucketCount, BucketIndexer bucketOf, Func bucketRangeMin) { if (capacity <= 0) throw new ArgumentException("capacity must be > 0", nameof(capacity)); if (bucketCount <= 0) throw new ArgumentException("bucketCount must be > 0", nameof(bucketCount)); _capacity = capacity; _values = new float[capacity]; _bucketOfValue = new int[capacity]; _bucketOf = bucketOf; _slots = new HistogramSlot[bucketCount]; for (var i = 0; i < bucketCount; i++) _slots[i].ValueRangeMin = bucketRangeMin(i); _minDeque = new int[capacity]; _maxDeque = new int[capacity]; } public void Update(float value, double time) { if (_count == _capacity) EvictOldest(); var idx = _tail; _values[idx] = value; var b = _bucketOf(value); if (b < 0) b = 0; else if (b >= _slots.Length) b = _slots.Length - 1; _bucketOfValue[idx] = b; ref var slot = ref _slots[b]; slot.CountRecent++; slot.CountTotal++; slot.LastIncrementTime = time; _slidingSum += value; // Min deque: strictly ascending from head to tail. while (_minCount > 0) { var backIdx = (_minTail - 1 + _capacity) % _capacity; if (_values[_minDeque[backIdx]] < value) break; _minTail = backIdx; _minCount--; } _minDeque[_minTail] = idx; _minTail = (_minTail + 1) % _capacity; _minCount++; // Max deque: strictly descending from head to tail. while (_maxCount > 0) { var backIdx = (_maxTail - 1 + _capacity) % _capacity; if (_values[_maxDeque[backIdx]] > value) break; _maxTail = backIdx; _maxCount--; } _maxDeque[_maxTail] = idx; _maxTail = (_maxTail + 1) % _capacity; _maxCount++; _tail = (_tail + 1) % _capacity; _count++; } /// /// Returns the samples in chronological order (oldest first) as a contiguous span, without allocating. /// When the data is already contiguous inside the internal buffer the span points directly at it /// (zero-copy); otherwise the samples are copied into . /// The caller owns and should reuse the same array across frames. /// public ReadOnlySpan AsOrderedSpan(float[] scratch) { if (_count == 0) return ReadOnlySpan.Empty; if (_head < _tail) return new ReadOnlySpan(_values, _head, _count); if (scratch.Length < _count) throw new ArgumentException("Scratch array is too small to hold the samples.", nameof(scratch)); var firstLen = _capacity - _head; _values.AsSpan(_head, firstLen).CopyTo(scratch); _values.AsSpan(0, _tail).CopyTo(scratch.AsSpan(firstLen)); return new ReadOnlySpan(scratch, 0, _count); } // ---------- factories ---------- /// /// Linear bucketing. Bucket i covers [minValue + i*w, minValue + (i+1)*w) where w = (maxValue - minValue) / bucketCount. /// public static RollingMetric CreateLinear(int capacity, int bucketCount, float minValue, float maxValue) { var width = (maxValue - minValue) / bucketCount; return new RollingMetric( capacity, bucketCount, v => (int)MathF.Floor((v - minValue) / width), i => minValue + i * width); } /// /// Log10 bucketing over [10^log10Min, 10^log10Max]. Non-positive values fall in bucket 0. /// public static RollingMetric CreateLog10(int capacity, int bucketCount, float log10Min, float log10Max) { var step = (log10Max - log10Min) / bucketCount; return new RollingMetric( capacity, bucketCount, v => v > 0f ? (int)MathF.Floor((MathF.Log10(v) - log10Min) / step) : 0, i => MathF.Pow(10f, log10Min + i * step)); } private void EvictOldest() { var idx = _head; var v = _values[idx]; var b = _bucketOfValue[idx]; _slots[b].CountRecent--; _slidingSum -= v; if (_minCount > 0 && _minDeque[_minHead] == idx) { _minHead = (_minHead + 1) % _capacity; _minCount--; } if (_maxCount > 0 && _maxDeque[_maxHead] == idx) { _maxHead = (_maxHead + 1) % _capacity; _maxCount--; } _head = (_head + 1) % _capacity; _count--; } // --- sample buffer --- private readonly float[] _values; private readonly int[] _bucketOfValue; // parallel: which bucket each stored value was placed in private int _head; // index of oldest sample private int _tail; // next write position private int _count; private readonly int _capacity; // --- histogram --- private readonly HistogramSlot[] _slots; private readonly BucketIndexer _bucketOf; // --- aggregates --- private double _slidingSum; // --- monotonic deques for running min / max (ring-buffer storage of value-array indices) --- private readonly int[] _minDeque; private int _minHead, _minTail, _minCount; private readonly int[] _maxDeque; private int _maxHead, _maxTail, _maxCount; }