Skip to main content

open_eeg_codec_standard/
adapters_external.rs

1//! Generic external-codec adapter — grade ANY conformant codec, in ANY
2//! language, with no Rust.
3//!
4//! Where a vendor adapter hard-wires a specific codec binary, this module
5//! drives an *arbitrary* codec through the file-based CLI contract of
6//! `SPEC/OpenECS-v1.0.md` §6: a codec is any executable exposing
7//!
8//! ```text
9//! <cmd> [prefix…] encode <in_path>  <out_path>
10//! <cmd> [prefix…] decode <in_path>  <out_path> --channels N --samples M --rate FS --dtype DT
11//! ```
12//!
13//! The codec is described by a [`crate::manifest::CodecManifest`] (TOML);
14//! [`ExternalCodec`] is the runtime form. Because it implements the
15//! standard [`Codec`] trait, the harness grades it identically to a
16//! built-in adapter.
17//!
18//! ## The shape problem and the envelope
19//!
20//! The [`Codec`] trait's `decode(&[u8]) -> Vec<Vec<i64>>` receives only the
21//! opaque blob — it is not handed the original shape. A generic codec also
22//! cannot be assumed to expose an `info` subcommand. The adapter therefore
23//! recovers the shape **statelessly** by prepending a tiny private envelope
24//! to the blob it returns from [`encode`]:
25//!
26//! ```text
27//! magic     : 4 bytes = b"ECSX"
28//! n_chan    : u32 LE
29//! n_samples : u32 LE   (per-channel sample count; 0 only valid for ecs0 output)
30//! dtype     : u8       (0=i16, 1=i32, 2=i64)
31//! fs        : f64 LE   (sample rate, metadata for the codec's --rate)
32//! payload   : the codec's own opaque bytes
33//! ```
34//!
35//! The harness treats the whole thing as opaque and feeds it straight back
36//! to [`decode`], where the header is stripped to recover `(N, M, dtype,
37//! fs)`. The codec never sees the envelope; it is an implementation detail
38//! of this grader, **not** part of the normative codec contract.
39//!
40//! ## Failure semantics
41//!
42//! Any failure — unsupported signal shape, spawn error, non-zero exit, a
43//! missing / short / wrong-length output, or a timeout — makes [`encode`]
44//! return an empty blob and [`decode`] return an empty signal, so the
45//! harness's L-tier gate reports a failed claim rather than panicking
46//! (matching the vendor adapter contract).
47//!
48//! [`encode`]: Codec::encode
49//! [`decode`]: Codec::decode
50
51use std::path::PathBuf;
52use std::process::{Command, Stdio};
53use std::time::{Duration, Instant};
54
55use serde::{Deserialize, Serialize};
56
57use crate::adapter::{deserialize, serialize, Codec};
58use crate::subprocess::{reshape_channel_major, write_edf_bytes, SampleDtype, ScratchDir};
59
60/// Magic prefix of the adapter-private envelope (see module docs).
61const ENVELOPE_MAGIC: &[u8; 4] = b"ECSX";
62/// Envelope header length: magic(4) + n_chan(4) + n_samples(4) + dtype(1) + fs(8).
63const ENVELOPE_HEADER_LEN: usize = 21;
64/// Default per-invocation timeout when the manifest does not set one.
65pub const DEFAULT_TIMEOUT_SECS: u64 = 600;
66
67/// Format of the file the grader hands the codec on `encode`.
68#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum InputFormat {
71    /// A minimal, spec-valid EDF file (the default; digital i16 samples).
72    #[default]
73    Edf,
74    /// The ECS0 reference container (ragged / empty channels survive).
75    Ecs0,
76}
77
78/// Format of the file the codec writes on `decode`.
79#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
80#[serde(rename_all = "lowercase")]
81pub enum OutputFormat {
82    /// Flat, channel-major, little-endian integers of width `sample_dtype`
83    /// (the default).
84    #[default]
85    Raw,
86    /// The ECS0 reference container (carries its own shape).
87    Ecs0,
88}
89
90/// A codec driven through the file-based CLI contract.
91///
92/// Construct directly (for tests) via [`ExternalCodec::new`] then the
93/// builder setters, or from a manifest via
94/// [`crate::manifest::CodecManifest::into_adapter`].
95#[derive(Clone, Debug)]
96pub struct ExternalCodec {
97    /// Report identifier.
98    name: String,
99    /// Resolved binary / script path. `Command::new` resolves a bare name
100    /// against `PATH`.
101    cmd: PathBuf,
102    /// Fixed tokens inserted before the `encode` / `decode` subcommand.
103    prefix_args: Vec<String>,
104    /// `encode` argument template (placeholders substituted per call).
105    encode_args: Vec<String>,
106    /// `decode` argument template (placeholders substituted per call).
107    decode_args: Vec<String>,
108    /// Extra environment merged over the `ECS_*` variables.
109    env: Vec<(String, String)>,
110    /// The codec author's lossless claim (verified, not trusted).
111    declared_lossless: bool,
112    /// Width of the raw decode stream.
113    sample_dtype: SampleDtype,
114    /// Format of the encode-input file.
115    input_format: InputFormat,
116    /// Format of the decode-output file.
117    output_format: OutputFormat,
118    /// Per-invocation timeout.
119    timeout: Duration,
120}
121
122/// Default `encode` argument template: `encode {input} {output}`.
123pub fn default_encode_args() -> Vec<String> {
124    ["encode", "{input}", "{output}"]
125        .iter()
126        .map(|s| s.to_string())
127        .collect()
128}
129
130/// Default `decode` argument template:
131/// `decode {input} {output} --channels {channels} --samples {samples}
132/// --rate {rate} --dtype {dtype}`.
133pub fn default_decode_args() -> Vec<String> {
134    [
135        "decode",
136        "{input}",
137        "{output}",
138        "--channels",
139        "{channels}",
140        "--samples",
141        "{samples}",
142        "--rate",
143        "{rate}",
144        "--dtype",
145        "{dtype}",
146    ]
147    .iter()
148    .map(|s| s.to_string())
149    .collect()
150}
151
152impl ExternalCodec {
153    /// A new codec with the default templates and formats.
154    pub fn new(name: impl Into<String>, cmd: impl Into<PathBuf>) -> Self {
155        Self {
156            name: name.into(),
157            cmd: cmd.into(),
158            prefix_args: Vec::new(),
159            encode_args: default_encode_args(),
160            decode_args: default_decode_args(),
161            env: Vec::new(),
162            declared_lossless: false,
163            sample_dtype: SampleDtype::default(),
164            input_format: InputFormat::default(),
165            output_format: OutputFormat::default(),
166            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
167        }
168    }
169
170    /// Set the fixed prefix args (before the subcommand).
171    pub fn with_prefix_args(mut self, args: Vec<String>) -> Self {
172        self.prefix_args = args;
173        self
174    }
175
176    /// Override the `encode` / `decode` argument templates.
177    pub fn with_templates(mut self, encode: Vec<String>, decode: Vec<String>) -> Self {
178        self.encode_args = encode;
179        self.decode_args = decode;
180        self
181    }
182
183    /// Set extra environment variables (merged over the `ECS_*` set).
184    pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
185        self.env = env;
186        self
187    }
188
189    /// Set the lossless claim.
190    pub fn with_declared_lossless(mut self, v: bool) -> Self {
191        self.declared_lossless = v;
192        self
193    }
194
195    /// Set the raw decode-stream integer width.
196    pub fn with_sample_dtype(mut self, d: SampleDtype) -> Self {
197        self.sample_dtype = d;
198        self
199    }
200
201    /// Set the encode-input and decode-output file formats.
202    pub fn with_formats(mut self, input: InputFormat, output: OutputFormat) -> Self {
203        self.input_format = input;
204        self.output_format = output;
205        self
206    }
207
208    /// Set the per-invocation timeout.
209    pub fn with_timeout(mut self, timeout: Duration) -> Self {
210        self.timeout = timeout;
211        self
212    }
213
214    /// The resolved command path.
215    pub fn cmd(&self) -> &PathBuf {
216        &self.cmd
217    }
218}
219
220/// Map a [`SampleDtype`] to its one-byte envelope code.
221fn dtype_code(d: SampleDtype) -> u8 {
222    match d {
223        SampleDtype::I16 => 0,
224        SampleDtype::I32 => 1,
225        SampleDtype::I64 => 2,
226    }
227}
228
229/// Inverse of [`dtype_code`].
230fn code_dtype(c: u8) -> Option<SampleDtype> {
231    match c {
232        0 => Some(SampleDtype::I16),
233        1 => Some(SampleDtype::I32),
234        2 => Some(SampleDtype::I64),
235        _ => None,
236    }
237}
238
239/// Prepend the adapter-private envelope onto a codec's blob.
240fn wrap_envelope(
241    payload: &[u8],
242    n_chan: u32,
243    n_samples: u32,
244    dtype: SampleDtype,
245    fs: f64,
246) -> Vec<u8> {
247    let mut out = Vec::with_capacity(ENVELOPE_HEADER_LEN + payload.len());
248    out.extend_from_slice(ENVELOPE_MAGIC);
249    out.extend_from_slice(&n_chan.to_le_bytes());
250    out.extend_from_slice(&n_samples.to_le_bytes());
251    out.push(dtype_code(dtype));
252    out.extend_from_slice(&fs.to_le_bytes());
253    out.extend_from_slice(payload);
254    out
255}
256
257/// Strip the envelope, returning `(n_chan, n_samples, dtype, fs, payload)`.
258/// `None` on a missing magic or a truncated header.
259fn parse_envelope(blob: &[u8]) -> Option<(usize, usize, SampleDtype, f64, &[u8])> {
260    if blob.len() < ENVELOPE_HEADER_LEN || &blob[0..4] != ENVELOPE_MAGIC {
261        return None;
262    }
263    let n_chan = u32::from_le_bytes([blob[4], blob[5], blob[6], blob[7]]) as usize;
264    let n_samples = u32::from_le_bytes([blob[8], blob[9], blob[10], blob[11]]) as usize;
265    let dtype = code_dtype(blob[12])?;
266    let fs = f64::from_le_bytes([
267        blob[13], blob[14], blob[15], blob[16], blob[17], blob[18], blob[19], blob[20],
268    ]);
269    Some((n_chan, n_samples, dtype, fs, &blob[ENVELOPE_HEADER_LEN..]))
270}
271
272/// Substitute `{placeholder}` tokens in an argument template.
273fn substitute(args: &[String], map: &[(&str, String)]) -> Vec<String> {
274    args.iter()
275        .map(|a| {
276            let mut s = a.clone();
277            for (k, v) in map {
278                if s.contains(k) {
279                    s = s.replace(k, v);
280                }
281            }
282            s
283        })
284        .collect()
285}
286
287impl ExternalCodec {
288    /// Build the per-call substitution map.
289    fn subst_map(
290        &self,
291        input: &str,
292        output: &str,
293        n_chan: usize,
294        n_samples: usize,
295        fs: f64,
296    ) -> Vec<(&'static str, String)> {
297        vec![
298            ("{input}", input.to_string()),
299            ("{output}", output.to_string()),
300            ("{channels}", n_chan.to_string()),
301            ("{samples}", n_samples.to_string()),
302            ("{rate}", format!("{fs}")),
303            ("{dtype}", self.sample_dtype.as_token().to_string()),
304        ]
305    }
306
307    /// Spawn `cmd` with the resolved args + merged env, enforcing the
308    /// timeout. Returns `true` only on a clean (`exit 0`) completion.
309    /// stdout/stderr are discarded so a chatty codec does not pollute the
310    /// benchmark output.
311    fn run(
312        &self,
313        args: &[String],
314        n_chan: usize,
315        n_samples: usize,
316        fs: f64,
317        workdir: &std::path::Path,
318    ) -> bool {
319        let mut cmd = Command::new(&self.cmd);
320        cmd.args(&self.prefix_args)
321            .args(args)
322            .stdin(Stdio::null())
323            .stdout(Stdio::null())
324            .stderr(Stdio::null())
325            // The shape metadata is also exposed as env, for codecs that
326            // prefer it over argv (Python codecs commonly do).
327            .env("ECS_CHANNELS", n_chan.to_string())
328            .env("ECS_SAMPLES", n_samples.to_string())
329            .env("ECS_RATE", format!("{fs}"))
330            .env("ECS_DTYPE", self.sample_dtype.as_token())
331            .env("ECS_WORKDIR", workdir);
332        for (k, v) in &self.env {
333            cmd.env(k, v);
334        }
335
336        let mut child = match cmd.spawn() {
337            Ok(c) => c,
338            Err(_) => return false,
339        };
340        let start = Instant::now();
341        loop {
342            match child.try_wait() {
343                Ok(Some(status)) => return status.success(),
344                Ok(None) => {
345                    if start.elapsed() >= self.timeout {
346                        let _ = child.kill();
347                        let _ = child.wait();
348                        return false;
349                    }
350                    std::thread::sleep(Duration::from_millis(5));
351                }
352                Err(_) => {
353                    let _ = child.kill();
354                    let _ = child.wait();
355                    return false;
356                }
357            }
358        }
359    }
360
361    /// Encode `signal` at rate `fs`, returning the enveloped blob or
362    /// `Vec::new()` on any failure.
363    fn try_encode(&self, signal: &[Vec<i64>], fs: f64) -> Vec<u8> {
364        let n_chan = signal.len();
365        if n_chan == 0 || n_chan > u32::MAX as usize {
366            return Vec::new();
367        }
368        // Per-channel sample count. The default `raw` decode requires a
369        // uniform (rectangular) signal so the flat stream reshapes; the
370        // `lqs0` output carries its own shape, so a ragged signal is only
371        // representable when the codec emits lqs0.
372        let n_samples = signal[0].len();
373        let uniform = signal.iter().all(|c| c.len() == n_samples);
374        if self.output_format == OutputFormat::Raw && (!uniform || n_samples == 0) {
375            return Vec::new();
376        }
377        if n_samples > u32::MAX as usize {
378            return Vec::new();
379        }
380
381        // Build the encode-input file.
382        let input_bytes = match self.input_format {
383            InputFormat::Edf => match write_edf_bytes(signal, fs) {
384                Some(b) => b,
385                None => return Vec::new(),
386            },
387            InputFormat::Ecs0 => serialize(signal),
388        };
389
390        let dir = match ScratchDir::new("ext_enc") {
391            Ok(d) => d,
392            Err(_) => return Vec::new(),
393        };
394        let in_name = match self.input_format {
395            InputFormat::Edf => "in.edf",
396            InputFormat::Ecs0 => "in.ecs0",
397        };
398        let in_path = dir.join(in_name);
399        let out_path = dir.join("blob.bin");
400        if std::fs::write(&in_path, &input_bytes).is_err() {
401            return Vec::new();
402        }
403
404        let map = self.subst_map(
405            &in_path.to_string_lossy(),
406            &out_path.to_string_lossy(),
407            n_chan,
408            n_samples,
409            fs,
410        );
411        let args = substitute(&self.encode_args, &map);
412        if !self.run(&args, n_chan, n_samples, fs, &dir.path) {
413            return Vec::new();
414        }
415
416        let payload = match std::fs::read(&out_path) {
417            Ok(p) => p,
418            Err(_) => return Vec::new(),
419        };
420        wrap_envelope(&payload, n_chan as u32, n_samples as u32, self.sample_dtype, fs)
421    }
422
423    /// Decode an enveloped blob back to the per-channel signal, or
424    /// `Vec::new()` on any failure.
425    fn try_decode(&self, blob: &[u8]) -> Vec<Vec<i64>> {
426        let (n_chan, n_samples, dtype, fs, payload) = match parse_envelope(blob) {
427            Some(parts) => parts,
428            None => return Vec::new(),
429        };
430
431        let dir = match ScratchDir::new("ext_dec") {
432            Ok(d) => d,
433            Err(_) => return Vec::new(),
434        };
435        let in_path = dir.join("blob.bin");
436        let out_name = match self.output_format {
437            OutputFormat::Raw => "out.raw",
438            OutputFormat::Ecs0 => "out.ecs0",
439        };
440        let out_path = dir.join(out_name);
441        if std::fs::write(&in_path, payload).is_err() {
442            return Vec::new();
443        }
444
445        let map = self.subst_map(
446            &in_path.to_string_lossy(),
447            &out_path.to_string_lossy(),
448            n_chan,
449            n_samples,
450            fs,
451        );
452        let args = substitute(&self.decode_args, &map);
453        if !self.run(&args, n_chan, n_samples, fs, &dir.path) {
454            return Vec::new();
455        }
456
457        let out = match std::fs::read(&out_path) {
458            Ok(o) => o,
459            Err(_) => return Vec::new(),
460        };
461        match self.output_format {
462            OutputFormat::Raw => {
463                // Reshape using the dtype the envelope recorded at encode
464                // time, not `self.sample_dtype`, so the two always agree.
465                let _ = dtype; // recorded == self.sample_dtype by construction
466                reshape_channel_major(&out, n_chan, n_samples, self.sample_dtype).unwrap_or_default()
467            }
468            OutputFormat::Ecs0 => deserialize(&out),
469        }
470    }
471}
472
473impl Codec for ExternalCodec {
474    fn name(&self) -> &str {
475        &self.name
476    }
477
478    fn declared_lossless(&self) -> bool {
479        self.declared_lossless
480    }
481
482    fn encode(&self, signal: &[Vec<i64>], fs: f64) -> Vec<u8> {
483        let rate = if fs.is_finite() && fs > 0.0 { fs } else { 1.0 };
484        self.try_encode(signal, rate)
485    }
486
487    fn decode(&self, blob: &[u8]) -> Vec<Vec<i64>> {
488        self.try_decode(blob)
489    }
490}
491
492/// Resolve a codec command to a runnable form, or `None` if unusable.
493///
494/// Order: `$ECS_CODEC_<NAME>_BIN` (uppercased, `-`→`_`) if it points at a
495/// file; then `cmd` itself — required to be an existing file if it contains
496/// a path separator, otherwise trusted as a bare `PATH` command name that
497/// `Command::new` resolves at spawn time.
498pub fn resolve_cmd(name: &str, cmd: &str) -> Option<PathBuf> {
499    let envvar = format!("ECS_CODEC_{}_BIN", name.to_uppercase().replace('-', "_"));
500    if let Some(p) = std::env::var_os(&envvar) {
501        let pb = PathBuf::from(p);
502        if pb.is_file() {
503            return Some(pb);
504        }
505    }
506    let pb = PathBuf::from(cmd);
507    if cmd.contains('/') || cmd.contains('\\') {
508        if pb.is_file() {
509            return Some(pb);
510        }
511        return None;
512    }
513    // Bare command name: let the OS resolve it on PATH at spawn time.
514    Some(pb)
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn envelope_round_trips() {
523        let payload = b"arbitrary codec bytes";
524        let blob = wrap_envelope(payload, 4, 256, SampleDtype::I32, 250.0);
525        let (n_chan, n_samples, dtype, fs, back) =
526            parse_envelope(&blob).expect("valid envelope parses");
527        assert_eq!(n_chan, 4);
528        assert_eq!(n_samples, 256);
529        assert_eq!(dtype, SampleDtype::I32);
530        assert_eq!(fs, 250.0);
531        assert_eq!(back, payload);
532    }
533
534    #[test]
535    fn parse_envelope_rejects_bad_input() {
536        assert!(parse_envelope(b"").is_none());
537        assert!(parse_envelope(b"XXXX too short").is_none());
538        // Right length region but wrong magic.
539        let mut bad = vec![0u8; ENVELOPE_HEADER_LEN + 4];
540        bad[0] = b'N';
541        assert!(parse_envelope(&bad).is_none());
542        // Good magic but an invalid dtype code (3).
543        let mut bad2 = wrap_envelope(b"x", 1, 1, SampleDtype::I16, 1.0);
544        bad2[12] = 3;
545        assert!(parse_envelope(&bad2).is_none());
546    }
547
548    #[test]
549    fn decode_of_garbage_is_empty() {
550        // No binary is spawned: a blob with no valid envelope short-circuits.
551        let codec = ExternalCodec::new("x", "/nonexistent/codec");
552        assert!(codec.decode(b"").is_empty());
553        assert!(codec.decode(b"not an envelope").is_empty());
554    }
555
556    #[test]
557    fn empty_signal_encodes_empty() {
558        // Zero channels cannot be represented; encode short-circuits before
559        // any spawn, so this is binary-free.
560        let codec = ExternalCodec::new("x", "/nonexistent/codec");
561        assert!(codec.encode(&[], 256.0).is_empty());
562    }
563
564    #[test]
565    fn name_and_claim_are_pure() {
566        let codec = ExternalCodec::new("mycodec", "python3").with_declared_lossless(true);
567        assert_eq!(codec.name(), "mycodec");
568        assert!(codec.declared_lossless());
569    }
570
571    #[test]
572    fn default_templates_carry_placeholders() {
573        let enc = default_encode_args();
574        assert_eq!(enc[0], "encode");
575        assert!(enc.iter().any(|a| a == "{input}"));
576        assert!(enc.iter().any(|a| a == "{output}"));
577        let dec = default_decode_args();
578        assert_eq!(dec[0], "decode");
579        for tok in ["{input}", "{output}", "{channels}", "{samples}", "{rate}", "{dtype}"] {
580            assert!(dec.iter().any(|a| a == tok), "decode template missing {tok}");
581        }
582    }
583
584    #[test]
585    fn substitute_replaces_tokens() {
586        let tmpl = default_decode_args();
587        let map = vec![
588            ("{input}", "/tmp/a".to_string()),
589            ("{output}", "/tmp/b".to_string()),
590            ("{channels}", "3".to_string()),
591            ("{samples}", "8".to_string()),
592            ("{rate}", "256".to_string()),
593            ("{dtype}", "i32".to_string()),
594        ];
595        let out = substitute(&tmpl, &map);
596        assert!(out.contains(&"/tmp/a".to_string()));
597        assert!(out.contains(&"3".to_string()));
598        assert!(out.contains(&"i32".to_string()));
599        assert!(!out.iter().any(|a| a.contains('{')), "no placeholder left");
600    }
601
602    #[test]
603    fn resolve_cmd_bare_name_is_trusted() {
604        // A bare name is trusted (PATH-resolved at spawn).
605        assert_eq!(resolve_cmd("c", "python3"), Some(PathBuf::from("python3")));
606        // A path that does not exist is rejected.
607        assert!(resolve_cmd("c", "/definitely/not/here/codec").is_none());
608    }
609}