1use 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
23pub type LoadedCorpus = Vec<(Vec<Vec<i64>>, f64)>;
26
27fn default_spec_version() -> String {
29 crate::SPEC_VERSION.to_string()
30}
31
32#[derive(Debug, Clone, Deserialize)]
34pub struct CorpusManifest {
35 #[serde(default = "default_spec_version")]
37 pub spec_version: String,
38 pub name: String,
40 pub version: String,
42 #[serde(default)]
44 pub file: Vec<CorpusFileEntry>,
45}
46
47#[derive(Debug, Clone, Deserialize)]
49pub struct CorpusFileEntry {
50 pub path: String,
52 pub sha256: String,
54 pub fs: f64,
56 pub n_chan: usize,
58 pub n_samples: usize,
60}
61
62#[derive(Debug)]
64pub enum CorpusError {
65 Io(String, std::io::Error),
67 Parse(toml::de::Error),
69 UnsupportedVersion(String),
71 Integrity {
73 path: String,
75 expected: String,
77 got: String,
79 },
80 Shape {
82 path: String,
84 detail: String,
86 },
87 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
115pub 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
125pub 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 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
141pub 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 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 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
182fn 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
215pub 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 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 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 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 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 let dir = crate::subprocess::ScratchDir::new("corpus_test").expect("scratch");
331 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 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 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, 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}