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}