diff --git a/src/AcDream.App/Diagnostics/FrameStatsBuffer.cs b/src/AcDream.App/Diagnostics/FrameStatsBuffer.cs new file mode 100644 index 00000000..080e6ac8 --- /dev/null +++ b/src/AcDream.App/Diagnostics/FrameStatsBuffer.cs @@ -0,0 +1,64 @@ +using System; + +namespace AcDream.App.Diagnostics; + +/// +/// MP0 (2026-07-05) — fixed-capacity ring buffer of long samples +/// (microseconds or bytes) with percentile/max over the current window. +/// Pure and allocation-free after construction: +/// sorts into a preallocated scratch array, so the 5-second report path +/// allocates nothing. Not thread-safe — owned by the window loop thread. +/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5. +/// +public sealed class FrameStatsBuffer +{ + private readonly long[] _samples; + private readonly long[] _scratch; + private int _cursor; + private int _count; + + public FrameStatsBuffer(int capacity) + { + if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); + _samples = new long[capacity]; + _scratch = new long[capacity]; + } + + public int Count => _count; + + public void Push(long value) + { + _samples[_cursor] = value; + _cursor = (_cursor + 1) % _samples.Length; + if (_count < _samples.Length) _count++; + } + + public void Reset() + { + _cursor = 0; + _count = 0; + } + + /// + /// Nearest-rank percentile over the current window: element at + /// ceil(q·n) in the ascending sort (1-based), 0 when empty. + /// + public long Percentile(double q) + { + if (_count == 0) return 0; + Array.Copy(_samples, _scratch, _count); + Array.Sort(_scratch, 0, _count); + int rank = (int)Math.Ceiling(q * _count); // 1-based nearest rank + if (rank < 1) rank = 1; + if (rank > _count) rank = _count; + return _scratch[rank - 1]; + } + + public long Max() + { + long max = 0; + for (int i = 0; i < _count; i++) + if (_samples[i] > max) max = _samples[i]; + return max; + } +} diff --git a/tests/AcDream.App.Tests/FrameStatsBufferTests.cs b/tests/AcDream.App.Tests/FrameStatsBufferTests.cs new file mode 100644 index 00000000..0d4cd438 --- /dev/null +++ b/tests/AcDream.App.Tests/FrameStatsBufferTests.cs @@ -0,0 +1,51 @@ +using AcDream.App.Diagnostics; +using Xunit; + +namespace AcDream.App.Tests; + +public class FrameStatsBufferTests +{ + [Fact] + public void Percentiles_OnKnownDistribution_AreExact() + { + var buf = new FrameStatsBuffer(capacity: 100); + // 1..100 µs — p50 = 50, p95 = 95, p99 = 99, max = 100. + for (long i = 1; i <= 100; i++) buf.Push(i); + + Assert.Equal(50, buf.Percentile(0.50)); + Assert.Equal(95, buf.Percentile(0.95)); + Assert.Equal(99, buf.Percentile(0.99)); + Assert.Equal(100, buf.Max()); + } + + [Fact] + public void Push_PastCapacity_KeepsOnlyNewestWindow() + { + var buf = new FrameStatsBuffer(capacity: 4); + foreach (long v in new long[] { 1000, 1000, 1000, 1000, 1, 2, 3, 4 }) + buf.Push(v); + // The four 1000s were overwritten; window is {1,2,3,4}. + Assert.Equal(4, buf.Count); + Assert.Equal(4, buf.Max()); + Assert.Equal(2, buf.Percentile(0.50)); + } + + [Fact] + public void Percentile_Empty_ReturnsZero() + { + var buf = new FrameStatsBuffer(capacity: 8); + Assert.Equal(0, buf.Percentile(0.95)); + Assert.Equal(0, buf.Max()); + Assert.Equal(0, buf.Count); + } + + [Fact] + public void Reset_ClearsWindow() + { + var buf = new FrameStatsBuffer(capacity: 8); + buf.Push(5); buf.Push(7); + buf.Reset(); + Assert.Equal(0, buf.Count); + Assert.Equal(0, buf.Percentile(0.5)); + } +}