Skip to main content

open_eeg_codec_standard/
manifest.rs

1//! Codec manifest loader (SPEC/OpenECS-v1.0.md §7).
2//!
3//! A codec manifest (TOML) describes how to invoke a conformant external
4//! codec. [`load_codec_manifest`] parses it and [`CodecManifest::into_adapter`]
5//! turns it into a runnable [`ExternalCodec`]. This is host-side config: the
6//! grading hot path never touches it.
7
8use std::collections::BTreeMap;
9use std::fmt;
10use std::path::Path;
11use std::time::Duration;
12
13use serde::Deserialize;
14
15use crate::adapters_external::{
16    default_decode_args, default_encode_args, resolve_cmd, ExternalCodec, InputFormat, OutputFormat,
17};
18use crate::adapters_external::DEFAULT_TIMEOUT_SECS;
19use crate::subprocess::SampleDtype;
20
21/// Default `spec_version` when a manifest omits it.
22fn default_spec_version() -> String {
23    crate::SPEC_VERSION.to_string()
24}
25
26/// Default per-invocation timeout (seconds) when a manifest omits it.
27fn default_timeout_secs() -> u64 {
28    DEFAULT_TIMEOUT_SECS
29}
30
31/// A parsed codec manifest.
32#[derive(Debug, Clone, Deserialize)]
33pub struct CodecManifest {
34    /// OpenECS spec version the manifest targets.
35    #[serde(default = "default_spec_version")]
36    pub spec_version: String,
37    /// The codec definition.
38    pub codec: CodecSpec,
39}
40
41/// The `[codec]` table of a manifest.
42#[derive(Debug, Clone, Deserialize)]
43pub struct CodecSpec {
44    /// Report identifier.
45    pub name: String,
46    /// Binary / script to invoke.
47    pub cmd: String,
48    /// The codec author's lossless claim (verified, not trusted).
49    pub declared_lossless: bool,
50    /// Fixed tokens inserted before the encode/decode subcommand.
51    #[serde(default)]
52    pub prefix_args: Vec<String>,
53    /// Width of the raw decode stream. Default `i32`.
54    #[serde(default)]
55    pub sample_dtype: SampleDtype,
56    /// Encode-input format. Default `edf`.
57    #[serde(default)]
58    pub input_format: InputFormat,
59    /// Decode-output format. Default `raw`.
60    #[serde(default)]
61    pub output_format: OutputFormat,
62    /// Per-invocation timeout in seconds. Default 600.
63    #[serde(default = "default_timeout_secs")]
64    pub timeout_secs: u64,
65    /// Optional explicit encode arg template (else the default contract).
66    pub encode_args: Option<Vec<String>>,
67    /// Optional explicit decode arg template (else the default contract).
68    pub decode_args: Option<Vec<String>>,
69    /// Extra environment merged over the `ECS_*` variables.
70    #[serde(default)]
71    pub env: BTreeMap<String, String>,
72}
73
74/// An error loading or validating a codec manifest.
75#[derive(Debug)]
76pub enum ManifestError {
77    /// The manifest file could not be read.
78    Io(std::io::Error),
79    /// The manifest is not valid TOML / has the wrong shape.
80    Parse(toml::de::Error),
81    /// The manifest's spec major version is not implemented by this grader.
82    UnsupportedVersion(String),
83}
84
85impl fmt::Display for ManifestError {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            ManifestError::Io(e) => write!(f, "reading codec manifest: {e}"),
89            ManifestError::Parse(e) => write!(f, "parsing codec manifest: {e}"),
90            ManifestError::UnsupportedVersion(v) => write!(
91                f,
92                "codec manifest spec_version {v:?} has a major this grader (OpenECS {}) does not implement",
93                crate::SPEC_VERSION
94            ),
95        }
96    }
97}
98
99impl std::error::Error for ManifestError {}
100
101/// Load and validate a codec manifest from a TOML file.
102///
103/// Refuses (with [`ManifestError::UnsupportedVersion`]) a manifest whose
104/// spec **major** differs from this grader's (spec §11).
105pub fn load_codec_manifest<P: AsRef<Path>>(path: P) -> Result<CodecManifest, ManifestError> {
106    let text = std::fs::read_to_string(path).map_err(ManifestError::Io)?;
107    let manifest: CodecManifest = toml::from_str(&text).map_err(ManifestError::Parse)?;
108    // Accept this major or older (a newer grader reads older manifests); refuse
109    // a newer major it does not implement.
110    match crate::spec_major(&manifest.spec_version) {
111        Some(m) if m <= crate::SPEC_MAJOR => Ok(manifest),
112        _ => Err(ManifestError::UnsupportedVersion(manifest.spec_version)),
113    }
114}
115
116impl CodecManifest {
117    /// Build a runnable [`ExternalCodec`], or `None` when the codec's
118    /// command cannot be resolved on this host (so a caller can skip it
119    /// cleanly, mirroring the `lml` adapter's resolve-or-skip ethos).
120    pub fn into_adapter(&self) -> Option<ExternalCodec> {
121        let c = &self.codec;
122        let cmd = resolve_cmd(&c.name, &c.cmd)?;
123        let encode = c.encode_args.clone().unwrap_or_else(default_encode_args);
124        let decode = c.decode_args.clone().unwrap_or_else(default_decode_args);
125        let env: Vec<(String, String)> =
126            c.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
127        Some(
128            ExternalCodec::new(&c.name, cmd)
129                .with_prefix_args(c.prefix_args.clone())
130                .with_templates(encode, decode)
131                .with_env(env)
132                .with_declared_lossless(c.declared_lossless)
133                .with_sample_dtype(c.sample_dtype)
134                .with_formats(c.input_format, c.output_format)
135                .with_timeout(Duration::from_secs(c.timeout_secs)),
136        )
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::adapter::Codec; // trait methods name()/declared_lossless()
144
145    const MINIMAL: &str = r#"
146        [codec]
147        name = "gzip-ext"
148        cmd = "python3"
149        declared_lossless = true
150    "#;
151
152    const FULL: &str = r#"
153        spec_version = "1.0"
154        [codec]
155        name = "neural-lmq"
156        cmd = "/nonexistent/codec"
157        declared_lossless = false
158        prefix_args = ["-m", "neural_lmq.cli"]
159        sample_dtype = "i16"
160        input_format = "edf"
161        output_format = "ecs0"
162        timeout_secs = 1800
163        encode_args = ["enc", "{input}", "{output}"]
164        decode_args = ["dec", "{input}", "{output}"]
165        [codec.env]
166        CUDA_VISIBLE_DEVICES = "0"
167    "#;
168
169    #[test]
170    fn parses_minimal_with_defaults() {
171        let m: CodecManifest = toml::from_str(MINIMAL).expect("minimal parses");
172        assert_eq!(m.spec_version, crate::SPEC_VERSION); // serde default tracks the crate
173        assert_eq!(m.codec.name, "gzip-ext");
174        assert!(m.codec.declared_lossless);
175        assert_eq!(m.codec.sample_dtype, SampleDtype::I32); // default
176        assert_eq!(m.codec.input_format, InputFormat::Edf);
177        assert_eq!(m.codec.output_format, OutputFormat::Raw);
178        assert_eq!(m.codec.timeout_secs, DEFAULT_TIMEOUT_SECS);
179        assert!(m.codec.encode_args.is_none());
180    }
181
182    #[test]
183    fn parses_full() {
184        let m: CodecManifest = toml::from_str(FULL).expect("full parses");
185        assert_eq!(m.codec.sample_dtype, SampleDtype::I16);
186        assert_eq!(m.codec.output_format, OutputFormat::Ecs0);
187        assert_eq!(m.codec.timeout_secs, 1800);
188        assert_eq!(m.codec.prefix_args, vec!["-m", "neural_lmq.cli"]);
189        assert_eq!(m.codec.env.get("CUDA_VISIBLE_DEVICES").map(|s| s.as_str()), Some("0"));
190    }
191
192    #[test]
193    fn into_adapter_resolves_bare_command() {
194        let m: CodecManifest = toml::from_str(MINIMAL).expect("parses");
195        let codec = m.into_adapter().expect("bare 'python3' resolves");
196        assert_eq!(codec.name(), "gzip-ext");
197        assert!(codec.declared_lossless());
198    }
199
200    #[test]
201    fn into_adapter_none_when_path_missing() {
202        // FULL's cmd is an absolute path that does not exist => None.
203        let m: CodecManifest = toml::from_str(FULL).expect("parses");
204        assert!(m.into_adapter().is_none());
205    }
206
207    #[test]
208    fn malformed_toml_errors() {
209        assert!(toml::from_str::<CodecManifest>("not = [valid").is_err());
210        // Missing required [codec] table.
211        assert!(toml::from_str::<CodecManifest>("spec_version = \"1.0\"").is_err());
212    }
213
214    #[test]
215    fn unsupported_major_is_refused() {
216        // Round-trip through the file loader to exercise the version gate.
217        let dir = crate::subprocess::ScratchDir::new("manifest_ver").expect("scratch");
218        let p = dir.join("c.toml");
219        std::fs::write(
220            &p,
221            "spec_version = \"2.0\"\n[codec]\nname = \"x\"\ncmd = \"python3\"\ndeclared_lossless = true\n",
222        )
223        .expect("write");
224        match load_codec_manifest(&p) {
225            Err(ManifestError::UnsupportedVersion(v)) => assert_eq!(v, "2.0"),
226            other => panic!("expected UnsupportedVersion, got {other:?}"),
227        }
228    }
229}