open_eeg_codec_standard/adapter.rs
1//! Reference codec adapters.
2//!
3//! An adapter wraps a codec behind the uniform [`Codec`] byte interface
4//! so the harness can grade any codec the same way. Three reference
5//! adapters ship so the suite has something to run out of the box:
6//!
7//! - [`Store`] — identity/raw passthrough. The CR≈1.0 lossless baseline.
8//! - [`Gzip`] — pure-Rust miniz_oxide via `flate2` (no system dep), the
9//! always-available compressed lossless reference.
10//! - [`Zstd`] — optional, behind `#[cfg(feature = "zstd")]`, so the
11//! default CI build carries no system dependency.
12//!
13//! ## Codec interface
14//!
15//! A [`Codec`] takes a per-channel signal (`&[Vec<i64>]`, one `Vec<i64>`
16//! of samples per channel) plus the sample rate, and produces an opaque
17//! `Vec<u8>` blob; [`Codec::decode`] inverts that. The reference adapters
18//! are all *declared lossless* and round-trip the channel layout
19//! bit-exactly.
20//!
21//! ## Reference serialization
22//!
23//! The reference adapters share one tiny, explicit, byte-exact container
24//! (see [`serialize`] / [`deserialize`]): a fixed header followed by the
25//! samples. Layout (all integers little-endian):
26//!
27//! ```text
28//! magic : 4 bytes = b"ECS0"
29//! n_chan : u32 = number of channels
30//! per chan: u32 len, then `len` × i64 samples
31//! ```
32//!
33//! The sample rate `fs` is *not* part of the byte stream: it is metadata
34//! the harness already knows, and the reference codecs reconstruct the
35//! integer samples losslessly without it. A real codec is free to embed
36//! `fs` in its own blob; the trait simply makes it available.
37
38use std::io::Write;
39
40use flate2::read::GzDecoder;
41use flate2::write::GzEncoder;
42use flate2::Compression;
43
44/// Magic prefix for the reference serialization container.
45const MAGIC: &[u8; 4] = b"ECS0";
46
47/// The codec-agnostic interface every benchmarked codec implements.
48///
49/// `signal` is one `Vec<i64>` of samples per channel; `fs` is the sample
50/// rate in Hz (metadata the codec may use or ignore). [`encode`] returns
51/// an opaque blob and [`decode`] reconstructs the per-channel samples.
52///
53/// A codec that advertises [`declared_lossless`] `== true` is *claiming*
54/// bit-exact reconstruction; the harness verifies that claim against the
55/// L-tier gate. The claim is what is graded — it is not assumed true.
56///
57/// [`encode`]: Codec::encode
58/// [`decode`]: Codec::decode
59/// [`declared_lossless`]: Codec::declared_lossless
60pub trait Codec {
61 /// Short, stable identifier used in reports (e.g. `"store"`).
62 fn name(&self) -> &str;
63
64 /// Whether the codec *claims* bit-exact reconstruction.
65 fn declared_lossless(&self) -> bool;
66
67 /// Compress a per-channel integer signal into an opaque blob.
68 fn encode(&self, signal: &[Vec<i64>], fs: f64) -> Vec<u8>;
69
70 /// Reconstruct the per-channel integer signal from a blob.
71 fn decode(&self, blob: &[u8]) -> Vec<Vec<i64>>;
72}
73
74/// Serialize a per-channel integer signal to the reference container.
75///
76/// Deterministic and byte-exact: the same input always produces the same
77/// bytes, so two backends can be compared for byte-equality. Integers are
78/// little-endian. See the module docs for the layout.
79pub fn serialize(signal: &[Vec<i64>]) -> Vec<u8> {
80 // Pre-size: magic + n_chan, then per channel a u32 length + the samples.
81 let total_samples: usize = signal.iter().map(|c| c.len()).sum();
82 let mut out = Vec::with_capacity(4 + 4 + signal.len() * 4 + total_samples * 8);
83 out.extend_from_slice(MAGIC);
84 out.extend_from_slice(&(signal.len() as u32).to_le_bytes());
85 for chan in signal {
86 out.extend_from_slice(&(chan.len() as u32).to_le_bytes());
87 for &s in chan {
88 out.extend_from_slice(&s.to_le_bytes());
89 }
90 }
91 out
92}
93
94/// Inverse of [`serialize`].
95///
96/// Returns an empty `Vec` if the buffer is malformed (bad magic, or a
97/// truncated header / sample run). The reference adapters only ever feed
98/// this their own [`serialize`] output, so a malformed buffer means a
99/// corrupt blob rather than a recoverable state; the harness's L-tier
100/// gate then sees a length/value mismatch and fails the lossless claim,
101/// which is the correct outcome.
102pub fn deserialize(buf: &[u8]) -> Vec<Vec<i64>> {
103 // Header: 4-byte magic + 4-byte channel count.
104 if buf.len() < 8 || &buf[..4] != MAGIC {
105 return Vec::new();
106 }
107 let n_chan = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]) as usize;
108 let mut pos = 8usize;
109 let mut out = Vec::with_capacity(n_chan);
110 for _ in 0..n_chan {
111 // Per-channel sample count.
112 if pos + 4 > buf.len() {
113 return Vec::new();
114 }
115 let len = u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]) as usize;
116 pos += 4;
117 // `len` × i64 samples must fit in the remaining buffer.
118 let need = len.checked_mul(8);
119 match need {
120 Some(bytes) if pos + bytes <= buf.len() => {}
121 _ => return Vec::new(),
122 }
123 let mut chan = Vec::with_capacity(len);
124 for _ in 0..len {
125 let mut b = [0u8; 8];
126 b.copy_from_slice(&buf[pos..pos + 8]);
127 chan.push(i64::from_le_bytes(b));
128 pos += 8;
129 }
130 out.push(chan);
131 }
132 out
133}
134
135/// Gzip-compress a byte buffer with the pure-Rust backend.
136///
137/// This is the [`Gzip`] adapter's compression primitive. It never fails
138/// for in-memory buffers (miniz_oxide writes to a `Vec`); the `expect`
139/// guards a genuinely unreachable I/O error on an in-memory writer.
140pub fn gzip_compress(data: &[u8]) -> Vec<u8> {
141 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
142 enc.write_all(data).expect("gzip write to Vec is infallible");
143 enc.finish().expect("gzip finish on Vec is infallible")
144}
145
146/// Gunzip a byte buffer produced by [`gzip_compress`].
147///
148/// Returns an empty `Vec` if the input is not valid gzip; as with
149/// [`deserialize`] this surfaces a corrupt blob to the L-tier gate as a
150/// failed lossless claim rather than a panic.
151pub fn gzip_decompress(data: &[u8]) -> Vec<u8> {
152 use std::io::Read;
153 let mut dec = GzDecoder::new(data);
154 let mut out = Vec::new();
155 match dec.read_to_end(&mut out) {
156 Ok(_) => out,
157 Err(_) => Vec::new(),
158 }
159}
160
161#[cfg(feature = "zstd")]
162/// Zstd-compress a byte buffer (level 19). Only present under the
163/// `zstd` feature so the default CI build carries no system dependency.
164pub fn zstd_compress(data: &[u8]) -> Vec<u8> {
165 zstd::stream::encode_all(data, 19).expect("zstd encode of in-memory buffer")
166}
167
168#[cfg(feature = "zstd")]
169/// Zstd-decompress a buffer produced by [`zstd_compress`]. Returns an
170/// empty `Vec` on malformed input (see [`gzip_decompress`]).
171pub fn zstd_decompress(data: &[u8]) -> Vec<u8> {
172 zstd::stream::decode_all(data).unwrap_or_default()
173}
174
175/// Identity/raw passthrough adapter — the CR≈1.0 lossless baseline.
176///
177/// `encode` is just [`serialize`]; `decode` is just [`deserialize`]. No
178/// compression happens, so this is the reference point against which
179/// every other codec's compression ratio is measured.
180#[derive(Clone, Copy, Debug, Default)]
181pub struct Store;
182
183impl Codec for Store {
184 fn name(&self) -> &str {
185 "store"
186 }
187
188 fn declared_lossless(&self) -> bool {
189 true
190 }
191
192 fn encode(&self, signal: &[Vec<i64>], _fs: f64) -> Vec<u8> {
193 serialize(signal)
194 }
195
196 fn decode(&self, blob: &[u8]) -> Vec<Vec<i64>> {
197 deserialize(blob)
198 }
199}
200
201/// Gzip adapter — pure-Rust miniz_oxide via `flate2`, always available.
202///
203/// `encode` = [`serialize`] then [`gzip_compress`]; `decode` =
204/// [`gzip_decompress`] then [`deserialize`]. Bit-exact lossless.
205#[derive(Clone, Copy, Debug, Default)]
206pub struct Gzip;
207
208impl Codec for Gzip {
209 fn name(&self) -> &str {
210 "gzip"
211 }
212
213 fn declared_lossless(&self) -> bool {
214 true
215 }
216
217 fn encode(&self, signal: &[Vec<i64>], _fs: f64) -> Vec<u8> {
218 gzip_compress(&serialize(signal))
219 }
220
221 fn decode(&self, blob: &[u8]) -> Vec<Vec<i64>> {
222 deserialize(&gzip_decompress(blob))
223 }
224}
225
226/// Zstd adapter — optional, compiled in only under the `zstd` feature.
227///
228/// `encode` = [`serialize`] then [`zstd_compress`]; `decode` =
229/// [`zstd_decompress`] then [`deserialize`]. Bit-exact lossless.
230#[cfg(feature = "zstd")]
231#[derive(Clone, Copy, Debug, Default)]
232pub struct Zstd;
233
234#[cfg(feature = "zstd")]
235impl Codec for Zstd {
236 fn name(&self) -> &str {
237 "zstd"
238 }
239
240 fn declared_lossless(&self) -> bool {
241 true
242 }
243
244 fn encode(&self, signal: &[Vec<i64>], _fs: f64) -> Vec<u8> {
245 zstd_compress(&serialize(signal))
246 }
247
248 fn decode(&self, blob: &[u8]) -> Vec<Vec<i64>> {
249 deserialize(&zstd_decompress(blob))
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 /// A known multi-channel i64 signal that exercises sign, zero, the
258 /// i64 extremes, a ragged channel length, and an empty channel.
259 fn fixture() -> Vec<Vec<i64>> {
260 vec![
261 vec![0, 1, -1, 1000, -1000, i64::MAX, i64::MIN],
262 vec![-42, 42, 0, 7],
263 vec![], // empty channel must survive the round trip
264 vec![123_456_789_012, -987_654_321_098],
265 ]
266 }
267
268 /// Round-trip helper: assert the codec reconstructs the fixture
269 /// exactly and (for the declared-lossless reference adapters) keeps
270 /// the lossless claim honest.
271 fn assert_roundtrip<C: Codec>(codec: C) {
272 let signal = fixture();
273 let blob = codec.encode(&signal, 256.0);
274 let back = codec.decode(&blob);
275 assert_eq!(
276 back,
277 signal,
278 "{} failed bit-exact round trip",
279 codec.name()
280 );
281 assert!(
282 codec.declared_lossless(),
283 "{} reference adapter should declare lossless",
284 codec.name()
285 );
286 }
287
288 #[test]
289 fn serialize_deserialize_is_exact() {
290 let signal = fixture();
291 let bytes = serialize(&signal);
292 assert_eq!(deserialize(&bytes), signal);
293 }
294
295 #[test]
296 fn serialize_is_deterministic() {
297 // Byte-for-byte stable across calls — the property the
298 // cross-backend byte-equality gate relies on.
299 let signal = fixture();
300 assert_eq!(serialize(&signal), serialize(&signal));
301 }
302
303 #[test]
304 fn deserialize_rejects_bad_magic() {
305 assert!(deserialize(b"XXXX\x00\x00\x00\x00").is_empty());
306 assert!(deserialize(&[]).is_empty());
307 // Truncated header (claims 1 channel, no length follows).
308 let mut buf = MAGIC.to_vec();
309 buf.extend_from_slice(&1u32.to_le_bytes());
310 assert!(deserialize(&buf).is_empty());
311 }
312
313 #[test]
314 fn store_roundtrips() {
315 assert_roundtrip(Store);
316 // Store does not compress: blob is exactly the serialization.
317 let signal = fixture();
318 assert_eq!(Store.encode(&signal, 256.0), serialize(&signal));
319 }
320
321 #[test]
322 fn gzip_roundtrips() {
323 assert_roundtrip(Gzip);
324 }
325
326 #[test]
327 fn gzip_decompress_rejects_garbage() {
328 // Not valid gzip => empty, then empty deserialize => empty signal.
329 assert!(gzip_decompress(b"not gzip data").is_empty());
330 assert!(Gzip.decode(b"not gzip data").is_empty());
331 }
332
333 #[cfg(feature = "zstd")]
334 #[test]
335 fn zstd_roundtrips() {
336 assert_roundtrip(Zstd);
337 }
338
339 #[test]
340 fn empty_signal_roundtrips() {
341 // Zero channels is a valid (degenerate) signal.
342 let empty: Vec<Vec<i64>> = Vec::new();
343 assert_eq!(Store.decode(&Store.encode(&empty, 256.0)), empty);
344 assert_eq!(Gzip.decode(&Gzip.encode(&empty, 256.0)), empty);
345 }
346}