Skip to main content

open_eeg_codec_standard/
subprocess.rs

1//! Shared subprocess + file-IO primitives for the file-driven codec
2//! adapters.
3//!
4//! The generic external-codec adapter ([`crate::adapters_external`], which
5//! drives any conformant codec via the file-based CLI contract of
6//! `SPEC/OpenECS-v1.0.md` §6) — and any vendor adapter that shells out to a
7//! specific codec binary — need the same handful of building blocks:
8//!
9//! - [`ScratchDir`] — a uniquely-named temp directory removed on drop, so
10//!   concurrent encode/decode calls never collide and cleanup is one
11//!   `remove_dir_all`.
12//! - [`write_edf_bytes`] — render a per-channel integer signal as a
13//!   minimal, spec-valid EDF byte image (the default encode-input format).
14//! - [`reshape_channel_major`] — split a flat little-endian integer stream
15//!   (the default decode-output format) into one `Vec<i64>` per channel,
16//!   validating the byte length against the declared shape first.
17//! - [`SampleDtype`] — the integer width of the raw decode stream.
18//!
19//! This module is pure `std` (no new dependencies beyond `serde` for the
20//! small [`SampleDtype`] enum, which the manifest layer deserializes). It
21//! is *not* on the grading hot path — the metric/grade core stays
22//! dependency-light.
23
24use std::path::PathBuf;
25
26use serde::{Deserialize, Serialize};
27
28/// Size in bytes of the fixed EDF main header and of each signal header.
29const EDF_HEADER_BLOCK: usize = 256;
30
31/// Integer width of the raw, channel-major decode stream a conformant
32/// codec writes (and that [`reshape_channel_major`] reads back).
33///
34/// EEG digital ADC samples are 16-bit, but a lossless codec round-tripping
35/// through wider intermediate domains (e.g. `lml`'s int32 output) may emit
36/// a wider stream; the dtype is declared per codec in its manifest.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "lowercase")]
39pub enum SampleDtype {
40    /// 16-bit signed little-endian.
41    I16,
42    /// 32-bit signed little-endian.
43    I32,
44    /// 64-bit signed little-endian.
45    I64,
46}
47
48impl SampleDtype {
49    /// Width of one sample in bytes (2, 4, or 8).
50    pub fn width(self) -> usize {
51        match self {
52            SampleDtype::I16 => 2,
53            SampleDtype::I32 => 4,
54            SampleDtype::I64 => 8,
55        }
56    }
57
58    /// The canonical lowercase token (`"i16"`, `"i32"`, `"i64"`) — the form
59    /// passed to a codec via the `--dtype` flag and `ECS_DTYPE` env var.
60    pub fn as_token(self) -> &'static str {
61        match self {
62            SampleDtype::I16 => "i16",
63            SampleDtype::I32 => "i32",
64            SampleDtype::I64 => "i64",
65        }
66    }
67}
68
69impl Default for SampleDtype {
70    /// `i32` — wide enough for the EEG i16 digital domain and for `lml`'s
71    /// int32 decode stream, the conservative default for a new codec.
72    fn default() -> Self {
73        SampleDtype::I32
74    }
75}
76
77/// A scratch directory under the system temp dir, removed on drop.
78///
79/// File-driven codecs write a handful of sidecar files (the input, the
80/// blob, the output, plus any codec-private state) next to their output;
81/// isolating each invocation in its own directory keeps concurrent adapter
82/// calls from colliding and makes cleanup a single `remove_dir_all`.
83pub struct ScratchDir {
84    /// Absolute path to the created directory.
85    pub path: PathBuf,
86}
87
88impl ScratchDir {
89    /// Create a uniquely-named scratch directory (`pid` + nanos + seq +
90    /// `tag`). The monotonic counter disambiguates two calls within the
91    /// same nanosecond, so parallel adapter invocations never collide.
92    pub fn new(tag: &str) -> std::io::Result<Self> {
93        let nanos = std::time::SystemTime::now()
94            .duration_since(std::time::UNIX_EPOCH)
95            .map(|d| d.as_nanos())
96            .unwrap_or(0);
97        use std::sync::atomic::{AtomicU64, Ordering};
98        static SEQ: AtomicU64 = AtomicU64::new(0);
99        let seq = SEQ.fetch_add(1, Ordering::Relaxed);
100        let name = format!("lqs_{}_{}_{}_{}", tag, std::process::id(), nanos, seq);
101        let path = std::env::temp_dir().join(name);
102        std::fs::create_dir_all(&path)?;
103        Ok(Self { path })
104    }
105
106    /// Join a file name onto the scratch directory.
107    pub fn join(&self, name: &str) -> PathBuf {
108        self.path.join(name)
109    }
110}
111
112impl Drop for ScratchDir {
113    fn drop(&mut self) {
114        // Best-effort cleanup; a leaked tempdir must never panic a test.
115        let _ = std::fs::remove_dir_all(&self.path);
116    }
117}
118
119/// Write an ASCII value left-justified into a fixed-width EDF field,
120/// space-padded. Returns `Err(())` if the value overflows the field.
121fn edf_field(out: &mut Vec<u8>, value: &str, width: usize) -> Result<(), ()> {
122    let bytes = value.as_bytes();
123    if bytes.len() > width {
124        return Err(());
125    }
126    out.extend_from_slice(bytes);
127    out.resize(out.len() + (width - bytes.len()), b' ');
128    Ok(())
129}
130
131/// Format a float for an EDF ASCII field as compactly as possible.
132///
133/// EDF numeric fields are ASCII; integers render without a decimal point
134/// and fractional values keep just enough digits to round-trip the rate.
135fn format_edf_number(x: f64) -> String {
136    if x.fract() == 0.0 && x.abs() < 1e15 {
137        format!("{}", x as i64)
138    } else {
139        let s = format!("{x:.6}");
140        let trimmed = s.trim_end_matches('0').trim_end_matches('.');
141        trimmed.to_string()
142    }
143}
144
145/// Build a minimal, spec-valid EDF byte image for `signal` at rate `fs`.
146///
147/// One data record holds every channel's samples (`record_duration`
148/// chosen so the stored rate matches `fs`). Returns `None` when the signal
149/// cannot be expressed as EDF digital samples: non-i16 values, ragged
150/// channels, or an empty / zero-length signal. The layout mirrors
151/// [`crate::edf::read_edf`]'s parser exactly, so the EDF this writes reads
152/// back identically.
153pub fn write_edf_bytes(signal: &[Vec<i64>], fs: f64) -> Option<Vec<u8>> {
154    let ns = signal.len();
155    if ns == 0 {
156        return None;
157    }
158    // All channels must share one length (EDF single-rate requirement).
159    let spr = signal[0].len();
160    if spr == 0 || signal.iter().any(|c| c.len() != spr) {
161        return None;
162    }
163    // Every sample must fit signed 16-bit (the EDF digital domain).
164    if signal
165        .iter()
166        .flat_map(|c| c.iter())
167        .any(|&s| s < i16::MIN as i64 || s > i16::MAX as i64)
168    {
169        return None;
170    }
171    if !fs.is_finite() || fs <= 0.0 {
172        return None;
173    }
174
175    // One record covers the whole signal: record_duration = spr / fs so the
176    // per-signal rate (spr / duration) reconstructs `fs`.
177    let record_duration = spr as f64 / fs;
178    let dur_str = format_edf_number(record_duration);
179    if dur_str.len() > 8 {
180        return None;
181    }
182
183    let header_bytes = EDF_HEADER_BLOCK + ns * EDF_HEADER_BLOCK;
184    let header_str = header_bytes.to_string();
185    if header_str.len() > 8 || ns.to_string().len() > 4 || spr.to_string().len() > 8 {
186        return None;
187    }
188
189    let mut buf = Vec::with_capacity(header_bytes + ns * spr * 2);
190
191    // ── Main header. ───────────────────────────────────────────────────
192    edf_field(&mut buf, "0", 8).ok()?; // version
193    edf_field(&mut buf, "OpenECS X X X", 80).ok()?; // patient
194    edf_field(&mut buf, "Startdate X", 80).ok()?; // recording
195    edf_field(&mut buf, "01.01.26", 8).ok()?; // startdate
196    edf_field(&mut buf, "00.00.00", 8).ok()?; // starttime
197    edf_field(&mut buf, &header_str, 8).ok()?; // header_bytes
198    edf_field(&mut buf, "", 44).ok()?; // reserved
199    edf_field(&mut buf, "1", 8).ok()?; // n_data_records = 1
200    edf_field(&mut buf, &dur_str, 8).ok()?; // record_duration_sec
201    edf_field(&mut buf, &ns.to_string(), 4).ok()?; // n_signals
202
203    // ── Signal headers, field-by-field across all signals. ─────────────
204    for i in 0..ns {
205        edf_field(&mut buf, &format!("ch{i}"), 16).ok()?; // label
206    }
207    for _ in 0..ns {
208        edf_field(&mut buf, "AgAgCl", 80).ok()?; // transducer
209    }
210    for _ in 0..ns {
211        edf_field(&mut buf, "uV", 8).ok()?; // phys_dim
212    }
213    for _ in 0..ns {
214        edf_field(&mut buf, "-32768", 8).ok()?; // phys_min
215    }
216    for _ in 0..ns {
217        edf_field(&mut buf, "32767", 8).ok()?; // phys_max
218    }
219    for _ in 0..ns {
220        edf_field(&mut buf, "-32768", 8).ok()?; // dig_min
221    }
222    for _ in 0..ns {
223        edf_field(&mut buf, "32767", 8).ok()?; // dig_max
224    }
225    for _ in 0..ns {
226        edf_field(&mut buf, "", 80).ok()?; // prefilter
227    }
228    for _ in 0..ns {
229        edf_field(&mut buf, &spr.to_string(), 8).ok()?; // n_samples_per_record
230    }
231    for _ in 0..ns {
232        edf_field(&mut buf, "", 32).ok()?; // signal reserved
233    }
234    debug_assert_eq!(buf.len(), header_bytes, "EDF header block size mismatch");
235
236    // ── Data: one record, signals in order, each `spr` little-endian i16.
237    for chan in signal {
238        for &s in chan {
239            buf.extend_from_slice(&(s as i16).to_le_bytes());
240        }
241    }
242
243    Some(buf)
244}
245
246/// Split a flat little-endian, channel-major integer stream into one
247/// `Vec<i64>` per channel.
248///
249/// `raw` holds `n_chan * per_chan` integers, each `dtype.width()` bytes,
250/// signed and little-endian, laid out channel-major (all of channel 0, then
251/// all of channel 1, …). The byte length is validated against the declared
252/// shape **before** any split: a stream that does not match
253/// `n_chan * per_chan * width` returns `None`, which the adapter surfaces to
254/// the L-tier gate as a failed round trip rather than a panic.
255pub fn reshape_channel_major(
256    raw: &[u8],
257    n_chan: usize,
258    per_chan: usize,
259    dtype: SampleDtype,
260) -> Option<Vec<Vec<i64>>> {
261    let width = dtype.width();
262    let total = n_chan.checked_mul(per_chan)?.checked_mul(width)?;
263    if raw.len() != total {
264        return None;
265    }
266
267    let mut out = Vec::with_capacity(n_chan);
268    let mut pos = 0usize;
269    for _ in 0..n_chan {
270        let mut chan = Vec::with_capacity(per_chan);
271        for _ in 0..per_chan {
272            let v = match dtype {
273                SampleDtype::I16 => {
274                    i16::from_le_bytes([raw[pos], raw[pos + 1]]) as i64
275                }
276                SampleDtype::I32 => {
277                    i32::from_le_bytes([raw[pos], raw[pos + 1], raw[pos + 2], raw[pos + 3]])
278                        as i64
279                }
280                SampleDtype::I64 => i64::from_le_bytes([
281                    raw[pos],
282                    raw[pos + 1],
283                    raw[pos + 2],
284                    raw[pos + 3],
285                    raw[pos + 4],
286                    raw[pos + 5],
287                    raw[pos + 6],
288                    raw[pos + 7],
289                ]),
290            };
291            chan.push(v);
292            pos += width;
293        }
294        out.push(chan);
295    }
296    Some(out)
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn fixture() -> Vec<Vec<i64>> {
304        vec![vec![0, 1, -1, 1000, -1000], vec![5, 6, 7, 8, 9]]
305    }
306
307    #[test]
308    fn scratchdir_is_unique_and_cleans_up() {
309        let a = ScratchDir::new("t").expect("scratch a");
310        let b = ScratchDir::new("t").expect("scratch b");
311        assert_ne!(a.path, b.path, "two scratch dirs must not collide");
312        let p = a.path.clone();
313        assert!(p.is_dir());
314        drop(a);
315        assert!(!p.exists(), "scratch dir removed on drop");
316        drop(b);
317    }
318
319    #[test]
320    fn write_edf_rejects_unsupported_shapes() {
321        assert!(write_edf_bytes(&[], 256.0).is_none());
322        assert!(write_edf_bytes(&[vec![]], 256.0).is_none());
323        assert!(write_edf_bytes(&[vec![1, 2], vec![1]], 256.0).is_none());
324        assert!(write_edf_bytes(&[vec![i64::MAX]], 256.0).is_none());
325        assert!(write_edf_bytes(&[vec![1, 2]], 0.0).is_none());
326        let edf = write_edf_bytes(&fixture(), 256.0).expect("valid fixture -> EDF");
327        let ns = 2usize;
328        let spr = 5usize;
329        assert_eq!(edf.len(), EDF_HEADER_BLOCK * (1 + ns) + ns * spr * 2);
330        assert_eq!(&edf[..8], b"0       ", "EDF version field");
331    }
332
333    #[test]
334    fn reshape_round_trips_each_dtype() {
335        for dt in [SampleDtype::I16, SampleDtype::I32, SampleDtype::I64] {
336            let sig = vec![vec![0i64, 1, -1, 100], vec![-100, 2, 3, 4]];
337            // Build the channel-major LE stream the codec would emit.
338            let mut raw = Vec::new();
339            for chan in &sig {
340                for &s in chan {
341                    match dt {
342                        SampleDtype::I16 => raw.extend_from_slice(&(s as i16).to_le_bytes()),
343                        SampleDtype::I32 => raw.extend_from_slice(&(s as i32).to_le_bytes()),
344                        SampleDtype::I64 => raw.extend_from_slice(&s.to_le_bytes()),
345                    }
346                }
347            }
348            let back = reshape_channel_major(&raw, 2, 4, dt).expect("valid stream");
349            assert_eq!(back, sig, "round trip for {dt:?}");
350        }
351    }
352
353    #[test]
354    fn reshape_rejects_wrong_length() {
355        // 2 chan * 4 samp * 4 bytes = 32 expected; give 30.
356        let raw = vec![0u8; 30];
357        assert!(reshape_channel_major(&raw, 2, 4, SampleDtype::I32).is_none());
358        // Overflow in the shape multiply is None, not a panic.
359        assert!(reshape_channel_major(&[], usize::MAX, usize::MAX, SampleDtype::I64).is_none());
360    }
361
362    #[test]
363    fn dtype_width_and_token() {
364        assert_eq!(SampleDtype::I16.width(), 2);
365        assert_eq!(SampleDtype::I32.width(), 4);
366        assert_eq!(SampleDtype::I64.width(), 8);
367        assert_eq!(SampleDtype::default(), SampleDtype::I32);
368        assert_eq!(SampleDtype::I16.as_token(), "i16");
369    }
370}