Skip to main content

open_eeg_codec_standard/
suites.rs

1//! Codec-agnostic benchmark batteries — the native Rust port of the
2//! black-box benchmark *logic* that used to live in Eagle's Python
3//! `tests/benchmarks/` + `tools/`.
4//!
5//! These are the batteries that exercise a codec purely through its
6//! [`crate::adapter::Codec`] byte interface — encode, decode, measure.
7//! They never reach inside a model: no FSQ symbols, no latents, no Cayley
8//! rotations, no rANS state. (Those internal-introspection benches stay in
9//! Python, behind `-m internal`.) Because every battery here operates over
10//! the same `&dyn Codec` + `&[(signal, fs)]` corpus, the canonical fast
11//! path (this crate) now covers them, and any codec — neural, classical,
12//! hybrid — runs through the identical measurement.
13//!
14//! Each battery is a **pure function** of `(&dyn Codec, &[(Vec<Vec<i64>>,
15//! f64)])`: no global state, no I/O, no checkpoint discovery. The corpus is
16//! one `(per-channel integer signal, sample rate)` tuple per file, matching
17//! [`crate::harness::run_corpus`].
18//!
19//! Ported Python bench logics:
20//!
21//! | Rust battery                  | Python source(s)                          |
22//! |-------------------------------|-------------------------------------------|
23//! | [`rate_distortion`]           | `benchmark_rate_distortion.py` (the (rate, R, PRD) point any codec produces) + the per-file `(cr, R, PRD)` rows of `benchmark_compression_ratio.py` |
24//! | [`per_file_cr_distribution`]  | `tools/bench_per_file_cr.py` (percentile distribution + pooled aggregate CR) |
25//! | [`throughput`]                | the perf-counter harness in `paper_benchmark.py` (`encode_speed_mbps` / `decode_speed_mbps`) + `tools/bench_chbmit.py` (`throughput_mibs`) |
26//! | [`corpus_summary`]            | the corpus roll-up — delegates to [`crate::harness::run_corpus`], no duplicate logic |
27//!
28//! What is deliberately NOT here (stays Python, `-m internal`):
29//! FSQ entropy / validation, latent utilization, Cayley rotation, residual
30//! FSQ, subband leakage — all reach into model internals and have no
31//! meaning at the `Codec` byte boundary.
32
33use crate::adapter::{serialize, Codec};
34use crate::harness::{self, CorpusSummary};
35use crate::metrics;
36use std::time::Instant;
37
38/// A corpus file: one `Vec<i64>` per channel plus the sample rate in Hz.
39///
40/// Type alias for the tuple every battery iterates over, so the signatures
41/// read cleanly and the corpus shape is documented in one place.
42pub type CorpusFile = (Vec<Vec<i64>>, f64);
43
44/// Flatten a per-channel integer signal into a contiguous `f64` stream,
45/// channels concatenated in order.
46///
47/// The aggregate fidelity metrics (PRD, R) treat the multichannel signal as
48/// one flat vector — this matches the Python reference, which calls
49/// `.flatten()` before `pearsonr` / the PRD ratio (see `compute_metrics` in
50/// `benchmark_rate_distortion.py` and `benchmark_compression_ratio.py`).
51fn flatten_f64(signal: &[Vec<i64>]) -> Vec<f64> {
52    let total: usize = signal.iter().map(|c| c.len()).sum();
53    let mut out = Vec::with_capacity(total);
54    for chan in signal {
55        out.extend(chan.iter().map(|&s| s as f64));
56    }
57    out
58}
59
60/// Raw (uncompressed) byte size of a signal under the reference container —
61/// the denominator of the compression ratio. Mirrors `harness::raw_bytes`:
62/// the size the codec is compressing *against*.
63fn raw_bytes(signal: &[Vec<i64>]) -> u64 {
64    serialize(signal).len() as u64
65}
66
67// ===================================================================
68// (1) Rate–distortion curve
69// ===================================================================
70
71/// One point on a codec's rate–distortion curve for a single file.
72///
73/// Ports the per-file row produced by `benchmark_rate_distortion.py` /
74/// `benchmark_compression_ratio.py`: a `(compression ratio, PRD, R)` triple.
75/// In the Python sweep the *rate* axis came from varying FSQ levels; at the
76/// `Codec` byte boundary the codec itself sets the operating point, so each
77/// file contributes one R-D point and the corpus traces the curve the codec
78/// produces across its natural rate spread.
79#[derive(Clone, Copy, Debug, PartialEq)]
80pub struct RdPoint {
81    /// Compression ratio (raw bytes / compressed bytes) for this file.
82    pub cr: f64,
83    /// Percentage RMS difference (lower is better). `0.0` when bit-exact.
84    pub prd: f64,
85    /// Pearson correlation R (higher is better). `1.0` when bit-exact.
86    pub r: f64,
87}
88
89/// Trace the rate–distortion curve `codec` produces over `signals`.
90///
91/// Returns one [`RdPoint`] `(cr, prd, r)` per file, in corpus order. This is
92/// the curve any codec produces: the Python `benchmark_rate_distortion.py`
93/// built it by sweeping FSQ levels and recording `(bps, R, PRD)`; here the
94/// codec's own per-file operating point supplies the rate, and we record the
95/// matching `(cr, prd, r)`. The metrics are computed on the flattened
96/// multichannel signal in the `f64` domain, exactly like the Python
97/// `compute_metrics` (flatten, Pearson R, PRD = rms_diff / rms_orig).
98///
99/// A bit-exact file yields `(cr, 0.0, 1.0)` — zero distortion, perfect
100/// correlation — matching the lossless short-circuit in
101/// [`crate::harness::run`].
102pub fn rate_distortion(codec: &dyn Codec, signals: &[CorpusFile]) -> Vec<RdPoint> {
103    let mut points = Vec::with_capacity(signals.len());
104    for (signal, fs) in signals {
105        let raw = raw_bytes(signal);
106        let blob = codec.encode(signal, *fs);
107        let comp = blob.len() as u64;
108        let recon = codec.decode(&blob);
109
110        let cr = metrics::compression_ratio(raw, comp);
111
112        let orig_f = flatten_f64(signal);
113        let recon_f = flatten_f64(&recon);
114        let prd = metrics::prd(&orig_f, &recon_f);
115        let r = metrics::pearson_r(&orig_f, &recon_f);
116
117        points.push(RdPoint { cr, prd, r });
118    }
119    points
120}
121
122// ===================================================================
123// (2) Per-file CR distribution
124// ===================================================================
125
126/// Percentile distribution of per-file compression ratios.
127///
128/// Direct port of the `distribution` block in `tools/bench_per_file_cr.py`:
129/// the seven order-statistic percentiles plus the arithmetic mean of the
130/// per-file CRs. The percentile index uses the same nearest-rank rule as the
131/// Python `pct(p)` helper: `idx = round(p * (n - 1) / 100)`, clamped to
132/// `[0, n-1]`, over the ascending-sorted CR list.
133#[derive(Clone, Copy, Debug, PartialEq)]
134pub struct CrDistribution {
135    /// Number of files contributing to the distribution.
136    pub n_files: usize,
137    /// Smallest per-file CR.
138    pub min: f64,
139    /// 5th percentile CR.
140    pub p5: f64,
141    /// 25th percentile CR.
142    pub p25: f64,
143    /// Median (50th percentile) CR.
144    pub median: f64,
145    /// 75th percentile CR.
146    pub p75: f64,
147    /// 95th percentile CR.
148    pub p95: f64,
149    /// Largest per-file CR.
150    pub max: f64,
151    /// Arithmetic mean of the per-file CRs.
152    pub mean: f64,
153}
154
155/// Per-file compression-ratio distribution for `codec` over `signals`.
156///
157/// For each file, encode and form `cr = raw / compressed`, then summarize
158/// the spread with the order-statistic percentiles + mean from
159/// `bench_per_file_cr.py`. This is the per-file CR claim the paper's §IV.C
160/// substantiates: the range from near-1:1 on noisy recordings up to
161/// dozens:1 on low-noise segments, made auditable as fixed percentiles.
162///
163/// An empty corpus returns an all-zero distribution with `n_files == 0`
164/// (the Python tool returns `{"files": 0}` and skips the distribution; the
165/// all-zero record is the typed equivalent).
166///
167/// Note: this is the *unpooled* per-file spread. The single pooled aggregate
168/// CR (byte-weighted) is reported by [`corpus_summary`] — the two answer
169/// different questions and are intentionally separate.
170pub fn per_file_cr_distribution(codec: &dyn Codec, signals: &[CorpusFile]) -> CrDistribution {
171    let n = signals.len();
172    if n == 0 {
173        return CrDistribution {
174            n_files: 0,
175            min: 0.0,
176            p5: 0.0,
177            p25: 0.0,
178            median: 0.0,
179            p75: 0.0,
180            p95: 0.0,
181            max: 0.0,
182            mean: 0.0,
183        };
184    }
185
186    let mut crs: Vec<f64> = Vec::with_capacity(n);
187    for (signal, fs) in signals {
188        let raw = raw_bytes(signal);
189        let comp = codec.encode(signal, *fs).len() as u64;
190        crs.push(metrics::compression_ratio(raw, comp));
191    }
192
193    // Ascending sort for the order statistics. CR values are finite (raw is
194    // finite, the divisor is clamped to >= 1 by compression_ratio), so a
195    // total order via partial_cmp is well-defined here.
196    crs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
197
198    let mean = crs.iter().sum::<f64>() / n as f64;
199
200    // Nearest-rank percentile, matching bench_per_file_cr.py's `pct`:
201    //   idx = clamp(round(p * (n - 1) / 100), 0, n - 1)
202    let pct = |p: f64| -> f64 {
203        let idx = (p * (n as f64 - 1.0) / 100.0).round() as i64;
204        let idx = idx.clamp(0, n as i64 - 1) as usize;
205        crs[idx]
206    };
207
208    CrDistribution {
209        n_files: n,
210        min: crs[0],
211        p5: pct(5.0),
212        p25: pct(25.0),
213        median: pct(50.0),
214        p75: pct(75.0),
215        p95: pct(95.0),
216        max: crs[n - 1],
217        mean,
218    }
219}
220
221// ===================================================================
222// (3) Throughput
223// ===================================================================
224
225/// Encode / decode throughput in MiB/s over a corpus.
226///
227/// Ports the perf-counter harness from `paper_benchmark.py`
228/// (`encode_speed_mbps` / `decode_speed_mbps`) and `tools/bench_chbmit.py`
229/// (`throughput_mibs`): wall-clock the encode pass and the decode pass
230/// separately, then divide the *raw* corpus byte total by each elapsed time.
231/// Rates are reported in MiB/s (`1024 * 1024` bytes), matching the
232/// `bench_chbmit.py` convention (`paper_benchmark.py`'s `MB/s` used `1e6`;
233/// we standardize on the harness's MiB to stay consistent with
234/// [`crate::report::EcsReport::throughput_mibs`]).
235#[derive(Clone, Copy, Debug, PartialEq)]
236pub struct Throughput {
237    /// Total raw (uncompressed) bytes processed across the corpus.
238    pub raw_bytes: u64,
239    /// Encode throughput in MiB/s over the raw payload.
240    pub encode_mibs: f64,
241    /// Decode throughput in MiB/s over the raw payload.
242    pub decode_mibs: f64,
243}
244
245/// Measure encode + decode throughput of `codec` over `signals`.
246///
247/// Two timed passes over the whole corpus — all encodes, then all decodes —
248/// so the encode and decode rates are independent (mirrors the separate
249/// `encode_speed_mbps` / `decode_speed_mbps` in `paper_benchmark.py`). The
250/// blobs from the encode pass are retained and fed to the decode pass so the
251/// decode timing measures real reconstruction, not re-encoding.
252///
253/// Throughput is `raw_total / elapsed`, where `raw_total` is the pooled raw
254/// byte size of the corpus. A zero / sub-tick elapsed time (tiny synthetic
255/// fixtures) yields a `0.0` rate rather than `+inf`, matching the guard in
256/// [`crate::harness::run`].
257pub fn throughput(codec: &dyn Codec, signals: &[CorpusFile]) -> Throughput {
258    let raw_total: u64 = signals.iter().map(|(s, _)| raw_bytes(s)).sum();
259
260    // ── Encode pass: time every encode, keep the blobs. ──────────────
261    let t_enc = Instant::now();
262    let mut blobs: Vec<Vec<u8>> = Vec::with_capacity(signals.len());
263    for (signal, fs) in signals {
264        blobs.push(codec.encode(signal, *fs));
265    }
266    let enc_secs = t_enc.elapsed().as_secs_f64();
267
268    // ── Decode pass: time every decode of the retained blobs. ─────────
269    let t_dec = Instant::now();
270    for blob in &blobs {
271        let _ = codec.decode(blob);
272    }
273    let dec_secs = t_dec.elapsed().as_secs_f64();
274
275    let mib = raw_total as f64 / (1024.0 * 1024.0);
276    let encode_mibs = if enc_secs > 0.0 { mib / enc_secs } else { 0.0 };
277    let decode_mibs = if dec_secs > 0.0 { mib / dec_secs } else { 0.0 };
278
279    Throughput {
280        raw_bytes: raw_total,
281        encode_mibs,
282        decode_mibs,
283    }
284}
285
286// ===================================================================
287// (4) Corpus summary
288// ===================================================================
289
290/// Aggregate corpus roll-up for `codec` over `signals`.
291///
292/// The byte-pooled aggregate CR, mean PRD/R, and worst grade across the
293/// corpus. This logic already lives in [`crate::harness::run_corpus`] (it
294/// pools CR by total bytes via [`crate::metrics::aggregate_cr`], averages
295/// PRD/R per file, and tracks the weakest grade), so this battery is a thin
296/// re-export that drops the per-file reports and returns only the
297/// [`CorpusSummary`] roll-up — no duplicated aggregation.
298pub fn corpus_summary(codec: &dyn Codec, signals: &[CorpusFile]) -> CorpusSummary {
299    let (_reports, summary) = harness::run_corpus(codec, signals);
300    summary
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::adapter::{Gzip, Store};
307
308    /// A deterministic, repeatable synthetic signal: a small multichannel
309    /// integer waveform with cross-band energy. Reused across batteries so
310    /// the corpus is fixed and the assertions are exact.
311    fn synthetic(scale: f64) -> Vec<i64> {
312        let fs = 256.0;
313        let n = 256;
314        (0..n)
315            .map(|i| {
316                let t = i as f64 / fs;
317                let v = scale
318                    * (100.0 * (2.0 * std::f64::consts::PI * 2.0 * t).sin()
319                        + 60.0 * (2.0 * std::f64::consts::PI * 10.0 * t).sin()
320                        + 30.0 * (2.0 * std::f64::consts::PI * 40.0 * t).sin());
321                v.round() as i64
322            })
323            .collect()
324    }
325
326    /// A three-file corpus of distinct synthetic signals at 256 Hz.
327    fn corpus() -> Vec<CorpusFile> {
328        vec![
329            (vec![synthetic(1.0), synthetic(1.5)], 256.0),
330            (vec![synthetic(0.7), synthetic(2.0), synthetic(1.2)], 256.0),
331            (vec![synthetic(0.5)], 256.0),
332        ]
333    }
334
335    // ── rate_distortion ──────────────────────────────────────────────
336
337    #[test]
338    fn rd_lossless_is_zero_distortion() {
339        // Identical reconstruction => PRD exactly 0, R exactly 1, for every
340        // file, under both lossless reference adapters.
341        let files = corpus();
342        for pts in [rate_distortion(&Store, &files), rate_distortion(&Gzip, &files)] {
343            assert_eq!(pts.len(), files.len());
344            for p in &pts {
345                assert_eq!(p.prd, 0.0, "lossless codec must have prd 0");
346                assert_eq!(p.r, 1.0, "lossless codec must have r 1");
347                assert!(p.cr > 0.0 && p.cr.is_finite());
348            }
349        }
350    }
351
352    #[test]
353    fn rd_gzip_compresses_more_than_store() {
354        // Store's blob IS the serialization (cr ~= 1.0); gzip squeezes the
355        // structured synthetic signal further (cr > store's).
356        let files = corpus();
357        let store = rate_distortion(&Store, &files);
358        let gzip = rate_distortion(&Gzip, &files);
359        for (s, g) in store.iter().zip(&gzip) {
360            assert!(
361                g.cr >= s.cr,
362                "gzip cr {} should be >= store cr {}",
363                g.cr,
364                s.cr
365            );
366        }
367    }
368
369    #[test]
370    fn rd_deterministic() {
371        let files = corpus();
372        assert_eq!(rate_distortion(&Store, &files), rate_distortion(&Store, &files));
373    }
374
375    #[test]
376    fn rd_empty_corpus() {
377        assert!(rate_distortion(&Store, &[]).is_empty());
378    }
379
380    // ── per_file_cr_distribution ─────────────────────────────────────
381
382    #[test]
383    fn cr_distribution_ordered_and_consistent() {
384        let files = corpus();
385        let d = per_file_cr_distribution(&Gzip, &files);
386        assert_eq!(d.n_files, files.len());
387        // Percentiles are monotone non-decreasing.
388        assert!(d.min <= d.p5);
389        assert!(d.p5 <= d.p25);
390        assert!(d.p25 <= d.median);
391        assert!(d.median <= d.p75);
392        assert!(d.p75 <= d.p95);
393        assert!(d.p95 <= d.max);
394        // Mean lies within [min, max].
395        assert!(d.mean >= d.min && d.mean <= d.max);
396    }
397
398    #[test]
399    fn cr_distribution_percentile_indices() {
400        // Single file: every percentile collapses to that file's CR.
401        let one = vec![(vec![synthetic(1.0)], 256.0)];
402        let d = per_file_cr_distribution(&Gzip, &one);
403        assert_eq!(d.n_files, 1);
404        assert_eq!(d.min, d.max);
405        assert_eq!(d.median, d.min);
406        assert_eq!(d.mean, d.min);
407        assert_eq!(d.p5, d.min);
408        assert_eq!(d.p95, d.min);
409    }
410
411    #[test]
412    fn cr_distribution_store_is_near_one() {
413        // Store does not compress: every per-file CR ~= 1.0, so the whole
414        // distribution sits near 1.0.
415        let files = corpus();
416        let d = per_file_cr_distribution(&Store, &files);
417        assert!(d.min >= 0.8 && d.max <= 1.2, "store cr spread {:?}", d);
418    }
419
420    #[test]
421    fn cr_distribution_deterministic() {
422        let files = corpus();
423        assert_eq!(
424            per_file_cr_distribution(&Gzip, &files),
425            per_file_cr_distribution(&Gzip, &files)
426        );
427    }
428
429    #[test]
430    fn cr_distribution_empty_corpus() {
431        let d = per_file_cr_distribution(&Store, &[]);
432        assert_eq!(d.n_files, 0);
433        assert_eq!(d.mean, 0.0);
434        assert_eq!(d.median, 0.0);
435    }
436
437    // ── throughput ───────────────────────────────────────────────────
438
439    #[test]
440    fn throughput_reports_finite_rates() {
441        let files = corpus();
442        let t = throughput(&Gzip, &files);
443        let expected_raw: u64 = files
444            .iter()
445            .map(|(s, _)| serialize(s).len() as u64)
446            .sum();
447        assert_eq!(t.raw_bytes, expected_raw);
448        assert!(t.encode_mibs.is_finite());
449        assert!(t.decode_mibs.is_finite());
450        assert!(t.encode_mibs >= 0.0);
451        assert!(t.decode_mibs >= 0.0);
452    }
453
454    #[test]
455    fn throughput_empty_corpus_is_zero() {
456        let t = throughput(&Store, &[]);
457        assert_eq!(t.raw_bytes, 0);
458        // No bytes + no elapsed => guarded 0.0, never +inf.
459        assert_eq!(t.encode_mibs, 0.0);
460        assert_eq!(t.decode_mibs, 0.0);
461    }
462
463    // ── corpus_summary ───────────────────────────────────────────────
464
465    #[test]
466    fn corpus_summary_matches_run_corpus() {
467        // The battery must be a faithful thin wrapper over run_corpus.
468        let files = corpus();
469        let summary = corpus_summary(&Store, &files);
470        let (_, expected) = harness::run_corpus(&Store, &files);
471        assert_eq!(summary.codec, expected.codec);
472        assert_eq!(summary.n_files, expected.n_files);
473        assert_eq!(summary.mean_cr, expected.mean_cr);
474        assert_eq!(summary.mean_prd, expected.mean_prd);
475        assert_eq!(summary.mean_r, expected.mean_r);
476        assert_eq!(summary.worst_grade, expected.worst_grade);
477        assert_eq!(summary.all_bit_exact, expected.all_bit_exact);
478    }
479
480    #[test]
481    fn corpus_summary_lossless_is_grade_l() {
482        let files = corpus();
483        let summary = corpus_summary(&Gzip, &files);
484        assert_eq!(summary.n_files, files.len());
485        assert!(summary.all_bit_exact, "gzip is lossless on synthetic corpus");
486        assert_eq!(summary.worst_grade, 'L');
487        assert_eq!(summary.mean_prd, 0.0);
488        assert_eq!(summary.mean_r, 1.0);
489        // Gzip compresses the structured signal: aggregate CR clears 1.0.
490        assert!(summary.mean_cr >= 1.0);
491    }
492
493    #[test]
494    fn corpus_summary_empty_is_below_floor() {
495        let summary = corpus_summary(&Store, &[]);
496        assert_eq!(summary.n_files, 0);
497        assert_eq!(summary.worst_grade, '\0');
498    }
499}