1mod export;
10mod tables;
11
12pub use tables::write_feature_table;
13
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use modewise_pipeline::{
19 Artifact, ArtifactStore, InputSource, PipelineError, Plan, RecordingRun, RunManifest,
20 StoreError, run_batch,
21};
22use modewise_store_zarr::ZarrStore;
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26#[derive(Debug, Error)]
27pub enum ResultsError {
28 #[error("{path}: {message}")]
29 Io { path: String, message: String },
30 #[error(transparent)]
31 Store(#[from] StoreError),
32 #[error(transparent)]
33 Pipeline(#[from] PipelineError),
34 #[error(
35 "pipeline inputs {pipeline:?} are not all among the inputs the data provides {source_inputs:?}"
36 )]
37 InputMismatch {
38 pipeline: Vec<String>,
39 source_inputs: Vec<String>,
40 },
41 #[error("no run for recording {recording:?} of pipeline {pipeline:?}")]
42 MissingRun { pipeline: String, recording: String },
43 #[error("step {step:?} has keep = false, so its output {reference:?} was not stored")]
44 NotStored { step: String, reference: String },
45 #[error("no output {reference:?}; outputs are {available:?}")]
46 MissingOutput {
47 reference: String,
48 available: Vec<String>,
49 },
50}
51
52fn io(path: &Path, e: impl std::fmt::Display) -> ResultsError {
53 ResultsError::Io {
54 path: path.display().to_string(),
55 message: e.to_string(),
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct BatchSummary {
62 pub pipeline: String,
63 pub shard: Option<(usize, usize)>,
64 pub succeeded: Vec<String>,
65 pub failed: BTreeMap<String, String>,
66}
67
68pub struct ResultsDir {
69 root: PathBuf,
70 store: ZarrStore,
71}
72
73impl ResultsDir {
74 pub fn open(root: &Path) -> Result<Self, ResultsError> {
76 let store = ZarrStore::open(&root.join("store"))?;
77 Ok(Self {
78 root: root.to_path_buf(),
79 store,
80 })
81 }
82
83 pub fn root(&self) -> &Path {
84 &self.root
85 }
86
87 pub fn store(&self) -> &ZarrStore {
88 &self.store
89 }
90
91 fn runs_dir(&self, pipeline: &str) -> PathBuf {
92 self.root.join("runs").join(file_name(pipeline))
93 }
94
95 pub fn save_run(
96 &self,
97 pipeline: &str,
98 recording: &str,
99 manifest: &RunManifest,
100 ) -> Result<(), ResultsError> {
101 let dir = self.runs_dir(pipeline);
102 std::fs::create_dir_all(&dir).map_err(|e| io(&dir, e))?;
103 let path = dir.join(format!("{}.json", file_name(recording)));
104 std::fs::write(&path, manifest.to_json()).map_err(|e| io(&path, e))
105 }
106
107 pub fn save_summary(&self, summary: &BatchSummary) -> Result<PathBuf, ResultsError> {
108 let dir = self.runs_dir(&summary.pipeline).join("summaries");
109 std::fs::create_dir_all(&dir).map_err(|e| io(&dir, e))?;
110 let name = match summary.shard {
111 Some((i, n)) => format!("shard-{i}-of-{n}.json"),
112 None => "all.json".to_string(),
113 };
114 let path = dir.join(name);
115 let text = serde_json::to_string_pretty(summary).map_err(|e| io(&path, e))?;
116 std::fs::write(&path, text).map_err(|e| io(&path, e))?;
117 Ok(path)
118 }
119
120 pub fn run_batch(
123 &self,
124 plan: &Plan,
125 source: &dyn InputSource,
126 shard: Option<(usize, usize)>,
127 workers: usize,
128 on_done: &(dyn Fn(&RecordingRun) + Sync),
129 ) -> Result<BatchSummary, ResultsError> {
130 let mut wanted = plan.inputs().to_vec();
131 let mut given = source.input_names();
132 wanted.sort();
133 given.sort();
134 if !wanted.iter().all(|w| given.contains(w)) {
136 return Err(ResultsError::InputMismatch {
137 pipeline: wanted,
138 source_inputs: given,
139 });
140 }
141 let ids: Vec<String> = match shard {
142 Some((i, n)) => source
143 .recording_ids()
144 .into_iter()
145 .enumerate()
146 .filter(|(j, _)| j % n == i - 1)
147 .map(|(_, id)| id)
148 .collect(),
149 None => source.recording_ids(),
150 };
151 let save_failures = std::sync::Mutex::new(BTreeMap::new());
152 let save = |run: &RecordingRun| {
153 if let Ok(result) = &run.result
154 && let Err(e) = self.save_run(plan.name(), &run.id, &result.manifest)
155 && let Ok(mut failures) = save_failures.lock()
156 {
157 failures.insert(run.id.clone(), format!("saving run record failed: {e}"));
158 }
159 on_done(run);
160 };
161 let runs = run_batch(plan, source, &ids, &self.store, workers, &save)?;
162 let save_failures = save_failures.into_inner().unwrap_or_default();
163
164 let mut summary = BatchSummary {
165 pipeline: plan.name().to_string(),
166 shard,
167 succeeded: Vec::new(),
168 failed: BTreeMap::new(),
169 };
170 for run in runs {
171 let error = run
172 .result
173 .err()
174 .or_else(|| save_failures.get(&run.id).cloned());
175 match error {
176 None => summary.succeeded.push(run.id),
177 Some(e) => {
178 summary.failed.insert(run.id, e);
179 }
180 }
181 }
182 self.save_summary(&summary)?;
183 Ok(summary)
184 }
185
186 pub fn pipelines(&self) -> Vec<String> {
187 list(&self.root.join("runs"), |p| p.is_dir())
188 }
189
190 pub fn recordings(&self, pipeline: &str) -> Vec<String> {
192 list(&self.runs_dir(pipeline), |p| {
193 p.extension().is_some_and(|e| e == "json")
194 })
195 .into_iter()
196 .filter_map(|n| n.strip_suffix(".json").map(String::from))
197 .collect()
198 }
199
200 pub fn manifest(&self, pipeline: &str, recording: &str) -> Result<RunManifest, ResultsError> {
201 let path = self
202 .runs_dir(pipeline)
203 .join(format!("{}.json", file_name(recording)));
204 let text = std::fs::read_to_string(&path).map_err(|_| ResultsError::MissingRun {
205 pipeline: pipeline.to_string(),
206 recording: recording.to_string(),
207 })?;
208 serde_json::from_str(&text).map_err(|e| io(&path, e))
209 }
210
211 pub fn output(
213 &self,
214 pipeline: &str,
215 recording: &str,
216 reference: &str,
217 ) -> Result<Arc<Artifact>, ResultsError> {
218 let manifest = self.manifest(pipeline, recording)?;
219 let outputs = manifest.outputs();
220 let prefix = format!("{reference}.");
221 let mut candidates = outputs
222 .iter()
223 .filter(|(name, _)| *name == reference || name.starts_with(&prefix));
224 let key = match (candidates.next(), candidates.next()) {
225 (Some((_, key)), None) => key,
226 _ => {
227 return Err(ResultsError::MissingOutput {
228 reference: reference.to_string(),
229 available: outputs.keys().cloned().collect(),
230 });
231 }
232 };
233 let step = reference.split('.').next().unwrap_or(reference);
234 if manifest.steps.iter().any(|s| s.id == step && !s.stored) {
235 return Err(ResultsError::NotStored {
236 step: step.to_string(),
237 reference: reference.to_string(),
238 });
239 }
240 self.store
241 .get(key)?
242 .ok_or_else(|| ResultsError::MissingOutput {
243 reference: reference.to_string(),
244 available: outputs.keys().cloned().collect(),
245 })
246 }
247
248 pub fn export_hdf5(&self, pipeline: &str, dir: &Path) -> Result<Vec<PathBuf>, ResultsError> {
250 std::fs::create_dir_all(dir).map_err(|e| io(dir, e))?;
251 self.recordings(pipeline)
252 .iter()
253 .map(|recording| {
254 let manifest = self.manifest(pipeline, recording)?;
255 let path = dir.join(format!("{}.h5", file_name(recording)));
256 export::write_recording(&self.store, recording, &manifest, &path)?;
257 Ok(path)
258 })
259 .collect()
260 }
261}
262
263fn file_name(name: &str) -> String {
265 name.chars()
266 .map(|c| {
267 if c.is_ascii_alphanumeric() || "-_.".contains(c) {
268 c
269 } else {
270 '_'
271 }
272 })
273 .collect()
274}
275
276fn list(dir: &Path, keep: impl Fn(&Path) -> bool) -> Vec<String> {
277 let mut names: Vec<String> = std::fs::read_dir(dir)
278 .into_iter()
279 .flatten()
280 .flatten()
281 .filter(|e| keep(&e.path()))
282 .map(|e| e.file_name().to_string_lossy().into_owned())
283 .collect();
284 names.sort();
285 names
286}