Skip to main content

open_eeg_codec_standard/
corpus.rs

1//! Corpus manifest loader + integrity verifier (SPEC/OpenECS-v1.0.md §8).
2//!
3//! Cross-lab comparability requires a frozen, hash-pinned corpus. A corpus
4//! manifest (TOML) names a corpus, its version, and each file with a pinned
5//! SHA-256, sample rate, and shape. [`verify_and_load`] checks every file's
6//! SHA-256 against the manifest **before** grading — refusing on any
7//! mismatch — then reads each EDF and asserts the declared shape, returning
8//! the `(signal, fs)` corpus that [`crate::harness::run_corpus`] consumes
9//! directly. Host-side only; not on the grading hot path.
10
11use std::fmt;
12use std::path::{Path, PathBuf};
13
14use rayon::prelude::*;
15use serde::Deserialize;
16use sha2::{Digest, Sha256};
17
18use crate::adapter::{serialize, Codec};
19use crate::edf::{self, EdfSignal};
20use crate::harness::{self, CorpusSummary};
21use crate::report::EcsReport;
22
23/// A loaded corpus: one `(per-channel integer signal, sample rate)` tuple
24/// per file — the exact shape [`crate::harness::run_corpus`] consumes.
25pub type LoadedCorpus = Vec<(Vec<Vec<i64>>, f64)>;
26
27/// Default `spec_version` when a manifest omits it.
28fn default_spec_version() -> String {
29    crate::SPEC_VERSION.to_string()
30}
31
32/// A parsed corpus manifest.
33#[derive(Debug, Clone, Deserialize)]
34pub struct CorpusManifest {
35    /// OpenECS spec version the manifest targets.
36    #[serde(default = "default_spec_version")]
37    pub spec_version: String,
38    /// Corpus identifier, e.g. `"ecs-smoke"`.
39    pub name: String,
40    /// Corpus version, e.g. `"1.0.0"`.
41    pub version: String,
42    /// The pinned files (TOML `[[file]]` array).
43    #[serde(default)]
44    pub file: Vec<CorpusFileEntry>,
45}
46
47/// One pinned file in a corpus manifest.
48#[derive(Debug, Clone, Deserialize)]
49pub struct CorpusFileEntry {
50    /// Path to the EDF file, relative to the manifest's directory.
51    pub path: String,
52    /// Lowercase-hex SHA-256 of the file bytes.
53    pub sha256: String,
54    /// Expected sample rate in Hz.
55    pub fs: f64,
56    /// Expected channel count.
57    pub n_chan: usize,
58    /// Expected samples per channel.
59    pub n_samples: usize,
60}
61
62/// An error loading, verifying, or reading a corpus.
63#[derive(Debug)]
64pub enum CorpusError {
65    /// A file could not be read.
66    Io(String, std::io::Error),
67    /// The manifest is not valid TOML / has the wrong shape.
68    Parse(toml::de::Error),
69    /// The manifest's spec major version is not implemented.
70    UnsupportedVersion(String),
71    /// A file's SHA-256 did not match the pinned hash.
72    Integrity {
73        /// Manifest-relative path.
74        path: String,
75        /// Hash the manifest pinned.
76        expected: String,
77        /// Hash actually computed.
78        got: String,
79    },
80    /// A file's decoded shape did not match the manifest.
81    Shape {
82        /// Manifest-relative path.
83        path: String,
84        /// Human-readable description of the disagreement.
85        detail: String,
86    },
87    /// An EDF file failed to parse.
88    Edf(String, std::io::Error),
89}
90
91impl fmt::Display for CorpusError {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        match self {
94            CorpusError::Io(p, e) => write!(f, "reading {p:?}: {e}"),
95            CorpusError::Parse(e) => write!(f, "parsing corpus manifest: {e}"),
96            CorpusError::UnsupportedVersion(v) => write!(
97                f,
98                "corpus manifest spec_version {v:?} has a major this grader (OpenECS {}) does not implement",
99                crate::SPEC_VERSION
100            ),
101            CorpusError::Integrity { path, expected, got } => write!(
102                f,
103                "integrity check failed for {path:?}: expected sha256 {expected}, got {got}"
104            ),
105            CorpusError::Shape { path, detail } => {
106                write!(f, "shape mismatch for {path:?}: {detail}")
107            }
108            CorpusError::Edf(p, e) => write!(f, "reading EDF {p:?}: {e}"),
109        }
110    }
111}
112
113impl std::error::Error for CorpusError {}
114
115/// Lowercase-hex SHA-256 of a byte buffer.
116pub fn sha256_hex(bytes: &[u8]) -> String {
117    let digest = Sha256::digest(bytes);
118    let mut s = String::with_capacity(64);
119    for b in digest {
120        s.push_str(&format!("{b:02x}"));
121    }
122    s
123}
124
125/// Load and validate a corpus manifest from a TOML file.
126///
127/// Refuses (with [`CorpusError::UnsupportedVersion`]) a manifest whose spec
128/// **major** differs from this grader's (spec §11).
129pub fn load_corpus_manifest<P: AsRef<Path>>(path: P) -> Result<CorpusManifest, CorpusError> {
130    let p = path.as_ref();
131    let text = std::fs::read_to_string(p)
132        .map_err(|e| CorpusError::Io(p.display().to_string(), e))?;
133    let manifest: CorpusManifest = toml::from_str(&text).map_err(CorpusError::Parse)?;
134    // Accept this major or older; refuse a newer major (see manifest loader).
135    match crate::spec_major(&manifest.spec_version) {
136        Some(m) if m <= crate::SPEC_MAJOR => Ok(manifest),
137        _ => Err(CorpusError::UnsupportedVersion(manifest.spec_version)),
138    }
139}
140
141/// Verify every file's SHA-256 and shape, then load the corpus.
142///
143/// `base_dir` is the directory the manifest's `path` entries are relative
144/// to (normally the manifest file's own directory). Returns the
145/// `(per-channel signal, fs)` corpus ready for
146/// [`crate::harness::run_corpus`]. The first integrity / shape / read
147/// failure aborts with a precise [`CorpusError`]; a corpus is only graded
148/// once every file is proven bit-identical to its pin.
149pub fn verify_and_load<P: AsRef<Path>>(
150    manifest: &CorpusManifest,
151    base_dir: P,
152) -> Result<LoadedCorpus, CorpusError> {
153    let base = base_dir.as_ref();
154    let mut out = Vec::with_capacity(manifest.file.len());
155
156    for entry in &manifest.file {
157        let full: PathBuf = base.join(&entry.path);
158
159        // 1. Integrity: bytes must hash to the pinned digest.
160        let bytes =
161            std::fs::read(&full).map_err(|e| CorpusError::Io(entry.path.clone(), e))?;
162        let got = sha256_hex(&bytes);
163        if !got.eq_ignore_ascii_case(&entry.sha256) {
164            return Err(CorpusError::Integrity {
165                path: entry.path.clone(),
166                expected: entry.sha256.to_lowercase(),
167                got,
168            });
169        }
170
171        // 2. Decode + shape: channel count, per-channel length, and rate
172        //    must match the manifest.
173        let signal =
174            edf::read_edf(&full).map_err(|e| CorpusError::Edf(entry.path.clone(), e))?;
175        check_shape(entry, &signal)?;
176        out.push((signal.channels, signal.fs));
177    }
178
179    Ok(out)
180}
181
182/// Verify a decoded EDF's shape (channel count, per-channel length, rate)
183/// against its manifest entry. Shared by [`verify_and_load`] and
184/// [`grade_manifest_parallel`].
185fn check_shape(entry: &CorpusFileEntry, signal: &EdfSignal) -> Result<(), CorpusError> {
186    if signal.channels.len() != entry.n_chan {
187        return Err(CorpusError::Shape {
188            path: entry.path.clone(),
189            detail: format!(
190                "expected {} channels, got {}",
191                entry.n_chan,
192                signal.channels.len()
193            ),
194        });
195    }
196    if let Some(bad) = signal.channels.iter().find(|c| c.len() != entry.n_samples) {
197        return Err(CorpusError::Shape {
198            path: entry.path.clone(),
199            detail: format!(
200                "expected {} samples/channel, got a channel of {}",
201                entry.n_samples,
202                bad.len()
203            ),
204        });
205    }
206    if (signal.fs - entry.fs).abs() > 1e-6 {
207        return Err(CorpusError::Shape {
208            path: entry.path.clone(),
209            detail: format!("expected fs {}, got {}", entry.fs, signal.fs),
210        });
211    }
212    Ok(())
213}
214
215/// Grade an entire corpus manifest in parallel, with bounded memory.
216///
217/// Unlike [`verify_and_load`] (which loads every file into RAM up front),
218/// this `rayon`-parallel grader processes each `[[file]]` entry independently:
219/// read → SHA-256 verify → `edf::read_edf` → shape-check →
220/// [`harness::run_measured`] → drop the signal. Only ~`num_threads` files are
221/// resident at once, so it scales to corpora far larger than RAM. `repeats`
222/// is forwarded to the throughput measurement; `progress` is called once per
223/// graded file (use it to drive a progress bar — it must be thread-safe).
224///
225/// Reports are returned in manifest order (deterministic). The first
226/// integrity / shape / read failure aborts the whole run with the precise
227/// [`CorpusError`] (which file races to surface first is unspecified, but a
228/// failure always aborts). Per-file *grades and metrics* match the sequential
229/// path exactly; only `throughput_mibs` differs (it is a wall-clock
230/// measurement).
231pub fn grade_manifest_parallel<F>(
232    manifest: &CorpusManifest,
233    base_dir: impl AsRef<Path>,
234    codec: &(dyn Codec + Sync),
235    repeats: usize,
236    progress: F,
237) -> Result<(Vec<EcsReport>, CorpusSummary), CorpusError>
238where
239    F: Fn() + Sync,
240{
241    let base = base_dir.as_ref();
242    let indexed: Vec<(usize, &CorpusFileEntry)> = manifest.file.iter().enumerate().collect();
243
244    let mut graded: Vec<(usize, EcsReport, u64, u64)> = indexed
245        .par_iter()
246        .map(|(idx, entry)| -> Result<(usize, EcsReport, u64, u64), CorpusError> {
247            let full = base.join(&entry.path);
248
249            // Integrity: bytes must hash to the pinned digest.
250            let bytes =
251                std::fs::read(&full).map_err(|e| CorpusError::Io(entry.path.clone(), e))?;
252            let got = sha256_hex(&bytes);
253            if !got.eq_ignore_ascii_case(&entry.sha256) {
254                return Err(CorpusError::Integrity {
255                    path: entry.path.clone(),
256                    expected: entry.sha256.to_lowercase(),
257                    got,
258                });
259            }
260
261            // Decode + shape, then grade this one file.
262            let signal =
263                edf::read_edf(&full).map_err(|e| CorpusError::Edf(entry.path.clone(), e))?;
264            check_shape(entry, &signal)?;
265            let raw = serialize(&signal.channels).len() as u64;
266            let mut rep = harness::run_measured(codec, &signal.channels, signal.fs, repeats);
267            rep.dataset = manifest.name.clone();
268            let comp = if rep.cr > 0.0 {
269                (raw as f64 / rep.cr).round() as u64
270            } else {
271                raw
272            };
273
274            progress();
275            Ok((*idx, rep, raw, comp))
276        })
277        .collect::<Result<Vec<_>, CorpusError>>()?;
278
279    // Restore manifest order for deterministic reporting.
280    graded.sort_by_key(|(idx, _, _, _)| *idx);
281    let per_file: Vec<(EcsReport, u64, u64)> = graded
282        .into_iter()
283        .map(|(_, rep, raw, comp)| (rep, raw, comp))
284        .collect();
285    let summary = harness::summarize(codec.name(), &per_file);
286    let reports = per_file.into_iter().map(|(r, _, _)| r).collect();
287    Ok((reports, summary))
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn sha256_matches_known_vector() {
296        // SHA-256 of the empty string and of "abc" (NIST vectors).
297        assert_eq!(
298            sha256_hex(b""),
299            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
300        );
301        assert_eq!(
302            sha256_hex(b"abc"),
303            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
304        );
305    }
306
307    #[test]
308    fn parses_manifest_with_files() {
309        let src = r#"
310            spec_version = "1.0"
311            name = "ecs-smoke"
312            version = "1.0.0"
313            [[file]]
314            path = "smoke/a.edf"
315            sha256 = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
316            fs = 256.0
317            n_chan = 4
318            n_samples = 1024
319        "#;
320        let m: CorpusManifest = toml::from_str(src).expect("parses");
321        assert_eq!(m.name, "ecs-smoke");
322        assert_eq!(m.version, "1.0.0");
323        assert_eq!(m.file.len(), 1);
324        assert_eq!(m.file[0].n_chan, 4);
325    }
326
327    #[test]
328    fn integrity_mismatch_is_reported() {
329        // Build a one-file corpus on disk whose pinned hash is wrong.
330        let dir = crate::subprocess::ScratchDir::new("corpus_test").expect("scratch");
331        // A minimal valid EDF written via the shared writer.
332        let sig = vec![vec![0i64, 1, -1, 2], vec![3, 4, 5, 6]];
333        let edf_bytes =
334            crate::subprocess::write_edf_bytes(&sig, 256.0).expect("fixture -> EDF");
335        let edf_path = dir.join("a.edf");
336        std::fs::write(&edf_path, &edf_bytes).expect("write edf");
337
338        let good = sha256_hex(&edf_bytes);
339        let bad = "0".repeat(64);
340
341        // Wrong hash -> Integrity error.
342        let m_bad = CorpusManifest {
343            spec_version: "1.0".to_string(),
344            name: "t".to_string(),
345            version: "1".to_string(),
346            file: vec![CorpusFileEntry {
347                path: "a.edf".to_string(),
348                sha256: bad,
349                fs: 256.0,
350                n_chan: 2,
351                n_samples: 4,
352            }],
353        };
354        match verify_and_load(&m_bad, &dir.path) {
355            Err(CorpusError::Integrity { .. }) => {}
356            other => panic!("expected Integrity error, got {other:?}"),
357        }
358
359        // Correct hash + shape -> loads.
360        let m_ok = CorpusManifest {
361            file: vec![CorpusFileEntry {
362                path: "a.edf".to_string(),
363                sha256: good,
364                fs: 256.0,
365                n_chan: 2,
366                n_samples: 4,
367            }],
368            ..m_bad
369        };
370        let loaded = verify_and_load(&m_ok, &dir.path).expect("verifies + loads");
371        assert_eq!(loaded.len(), 1);
372        assert_eq!(loaded[0].0, sig);
373        assert_eq!(loaded[0].1, 256.0);
374    }
375
376    #[test]
377    fn shape_mismatch_is_reported() {
378        let dir = crate::subprocess::ScratchDir::new("corpus_shape").expect("scratch");
379        let sig = vec![vec![0i64, 1, -1, 2], vec![3, 4, 5, 6]];
380        let edf_bytes =
381            crate::subprocess::write_edf_bytes(&sig, 256.0).expect("fixture -> EDF");
382        std::fs::write(dir.join("a.edf"), &edf_bytes).expect("write");
383        let m = CorpusManifest {
384            spec_version: "1.0".to_string(),
385            name: "t".to_string(),
386            version: "1".to_string(),
387            file: vec![CorpusFileEntry {
388                path: "a.edf".to_string(),
389                sha256: sha256_hex(&edf_bytes),
390                fs: 256.0,
391                n_chan: 3, // wrong: file has 2
392                n_samples: 4,
393            }],
394        };
395        match verify_and_load(&m, &dir.path) {
396            Err(CorpusError::Shape { .. }) => {}
397            other => panic!("expected Shape error, got {other:?}"),
398        }
399    }
400}