1use std::{
2 collections::{BTreeMap, BTreeSet},
3 fs::{self, File},
4 io::BufReader,
5 path::{Component, Path, PathBuf},
6 sync::{Arc, Mutex, MutexGuard},
7};
8
9use ndarray::Array2;
10use num_complex::Complex64;
11use polars::prelude::{DataFrame, DataType, ParquetReader, SerReader};
12use serde::de::DeserializeOwned;
13
14use crate::{
15 Error, Result, complex,
16 diagnostics::ReconstructionDiagnostics,
17 evaluation::ReconstructionEvaluation,
18 illumination_calibration::IlluminationCalibrationState,
19 model::{ImagePlaneModel, Pupil},
20 reconstruction::{
21 AlgorithmMetricRecord, IterationRecord, ReconstructionResult, ReconstructionTrace,
22 },
23};
24
25use super::{
26 manifest::{
27 ARRAY_BACKGROUND, ARRAY_FRAME_GAINS, ARRAY_ILLUMINATION_CALIBRATION, ARRAY_OBJECT,
28 ARRAY_OBJECT_SPECTRUM, ARRAY_PUPIL, ARRAY_PUPIL_SUPPORT, BUNDLE_FORMAT_VERSION,
29 BundleManifest, DOMAIN_DIAGNOSTICS, DOMAIN_EVALUATION, DOMAIN_PHYSICAL_ILLUMINATION,
30 KNOWN_ROLES, ManifestArtifact, PREVIEW_FOURIER_COVERAGE, PREVIEW_OBJECT_AMPLITUDE,
31 PREVIEW_OBJECT_PHASE, PREVIEW_PUPIL_AMPLITUDE, PREVIEW_PUPIL_PHASE,
32 TABLE_ALGORITHM_METRICS, TABLE_FRAME_CALIBRATION, TABLE_FRAME_DIAGNOSTICS,
33 TABLE_FRAME_EVALUATION, TABLE_HISTORY, TABLE_ILLUMINATION_CALIBRATION,
34 TABLE_ITERATION_DIAGNOSTICS, TABLE_METADATA, TABLE_RAW_FRAME_STATISTICS,
35 TABLE_SCALAR_DIAGNOSTICS, TABLE_SUMMARY,
36 },
37 npy,
38 write::sha256,
39};
40
41#[derive(Clone, Debug)]
43pub struct BundleArtifact {
44 pub role: String,
46 pub path: PathBuf,
48 pub media_type: String,
50 pub byte_size: u64,
52 pub sha256: String,
54 pub dtype: Option<String>,
56 pub shape: Option<Vec<u64>>,
58}
59
60#[derive(Clone, Debug)]
62pub struct BundleTables {
63 pub summary: BundleArtifact,
65 pub history: BundleArtifact,
67 pub algorithm_metrics: Option<BundleArtifact>,
69 pub iteration_diagnostics: Option<BundleArtifact>,
71 pub frame_diagnostics: Option<BundleArtifact>,
73 pub raw_frame_statistics: Option<BundleArtifact>,
75 pub frame_evaluation: Option<BundleArtifact>,
77 pub illumination_calibration: Option<BundleArtifact>,
79 pub frame_calibration: Option<BundleArtifact>,
81 pub scalar_diagnostics: Option<BundleArtifact>,
83 pub metadata: Option<BundleArtifact>,
85}
86
87#[derive(Clone, Debug)]
89pub struct BundleArrays {
90 pub object: BundleArtifact,
92 pub object_spectrum: BundleArtifact,
94 pub pupil: BundleArtifact,
96 pub pupil_support: BundleArtifact,
98 pub illumination_calibration: Option<BundleArtifact>,
100 pub frame_gains: Option<BundleArtifact>,
102 pub background: Option<BundleArtifact>,
104}
105
106#[derive(Clone, Debug, Default)]
108pub struct BundlePreviews {
109 pub object_amplitude: Option<BundleArtifact>,
111 pub object_phase: Option<BundleArtifact>,
113 pub pupil_amplitude: Option<BundleArtifact>,
115 pub pupil_phase: Option<BundleArtifact>,
117 pub fourier_coverage: Option<BundleArtifact>,
119}
120
121#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct BundleVerificationResult {
124 pub artifact_count: usize,
126 pub total_bytes: u64,
128}
129
130#[derive(Default)]
131struct BundleCache {
132 object: Option<Arc<Array2<Complex64>>>,
133 object_spectrum: Option<Arc<Array2<Complex64>>>,
134 pupil: Option<Arc<Array2<Complex64>>>,
135 pupil_support: Option<Arc<Array2<u8>>>,
136 illumination_calibration: Option<Arc<Vec<(f64, f64)>>>,
137 frame_gains: Option<Arc<Vec<f64>>>,
138 background: Option<Arc<Vec<f64>>>,
139 trace: Option<Arc<ReconstructionTrace>>,
140 scalar_diagnostics: Option<Arc<BTreeMap<String, f64>>>,
141 metadata: Option<Arc<BTreeMap<String, String>>>,
142 result: Option<Arc<ReconstructionResult>>,
143 diagnostics: Option<Arc<ReconstructionDiagnostics>>,
144 evaluation: Option<Arc<ReconstructionEvaluation>>,
145 physical_illumination: Option<Arc<(IlluminationCalibrationState, ImagePlaneModel)>>,
146}
147
148struct ResultBundleInner {
149 manifest: BundleManifest,
150 artifacts: BTreeMap<String, BundleArtifact>,
151 cache: Mutex<BundleCache>,
152}
153
154#[derive(Clone)]
159pub struct ResultBundle {
160 pub path: PathBuf,
162 pub manifest_path: PathBuf,
164 pub run_id: String,
166 pub label: Option<String>,
168 pub tables: BundleTables,
170 pub arrays: BundleArrays,
172 pub previews: BundlePreviews,
174 inner: Arc<ResultBundleInner>,
175}
176
177pub fn read_bundle(path: impl AsRef<Path>) -> Result<ResultBundle> {
179 ResultBundle::read(path)
180}
181
182impl ResultBundle {
183 pub fn read(path: impl AsRef<Path>) -> Result<Self> {
185 let path = path.as_ref();
186 if path
187 .file_name()
188 .and_then(|value| value.to_str())
189 .is_some_and(|value| value.ends_with(".inprogress"))
190 {
191 return Err(Error::IncompleteBundle(format!(
192 "{} is an in-progress workspace",
193 path.display()
194 )));
195 }
196 let manifest_path = path.join("manifest.json");
197 if !manifest_path.is_file() {
198 return Err(Error::MissingArtifact {
199 role: "manifest".into(),
200 });
201 }
202 let manifest: BundleManifest =
203 serde_json::from_reader(BufReader::new(File::open(&manifest_path)?))
204 .map_err(|error| Error::InvalidManifest(error.to_string()))?;
205 validate_manifest(path, &manifest)?;
206 let artifacts = manifest
207 .artifacts
208 .iter()
209 .map(|artifact| (artifact.role.clone(), public_artifact(path, artifact)))
210 .collect::<BTreeMap<_, _>>();
211 let required = |role: &str| {
212 artifacts
213 .get(role)
214 .cloned()
215 .ok_or_else(|| Error::MissingArtifact { role: role.into() })
216 };
217 let optional = |role: &str| artifacts.get(role).cloned();
218 let tables = BundleTables {
219 summary: required(TABLE_SUMMARY)?,
220 history: required(TABLE_HISTORY)?,
221 algorithm_metrics: optional(TABLE_ALGORITHM_METRICS),
222 iteration_diagnostics: optional(TABLE_ITERATION_DIAGNOSTICS),
223 frame_diagnostics: optional(TABLE_FRAME_DIAGNOSTICS),
224 raw_frame_statistics: optional(TABLE_RAW_FRAME_STATISTICS),
225 frame_evaluation: optional(TABLE_FRAME_EVALUATION),
226 illumination_calibration: optional(TABLE_ILLUMINATION_CALIBRATION),
227 frame_calibration: optional(TABLE_FRAME_CALIBRATION),
228 scalar_diagnostics: optional(TABLE_SCALAR_DIAGNOSTICS),
229 metadata: optional(TABLE_METADATA),
230 };
231 let arrays = BundleArrays {
232 object: required(ARRAY_OBJECT)?,
233 object_spectrum: required(ARRAY_OBJECT_SPECTRUM)?,
234 pupil: required(ARRAY_PUPIL)?,
235 pupil_support: required(ARRAY_PUPIL_SUPPORT)?,
236 illumination_calibration: optional(ARRAY_ILLUMINATION_CALIBRATION),
237 frame_gains: optional(ARRAY_FRAME_GAINS),
238 background: optional(ARRAY_BACKGROUND),
239 };
240 let previews = BundlePreviews {
241 object_amplitude: optional(PREVIEW_OBJECT_AMPLITUDE),
242 object_phase: optional(PREVIEW_OBJECT_PHASE),
243 pupil_amplitude: optional(PREVIEW_PUPIL_AMPLITUDE),
244 pupil_phase: optional(PREVIEW_PUPIL_PHASE),
245 fourier_coverage: optional(PREVIEW_FOURIER_COVERAGE),
246 };
247 let run_id = manifest.run_id.clone();
248 let label = manifest.label.clone();
249 Ok(Self {
250 path: path.to_owned(),
251 manifest_path,
252 run_id,
253 label,
254 tables,
255 arrays,
256 previews,
257 inner: Arc::new(ResultBundleInner {
258 manifest,
259 artifacts,
260 cache: Mutex::new(BundleCache::default()),
261 }),
262 })
263 }
264
265 pub fn object(&self) -> Result<Arc<Array2<Complex64>>> {
267 let mut cache = self.cache();
268 if let Some(value) = &cache.object {
269 return Ok(value.clone());
270 }
271 let shape = self.reconstruction_shape()?;
272 let value = Arc::new(npy::read_complex2(
273 &self.checked_artifact(ARRAY_OBJECT)?.path,
274 ARRAY_OBJECT,
275 shape,
276 )?);
277 cache.object = Some(value.clone());
278 Ok(value)
279 }
280
281 pub fn object_spectrum(&self) -> Result<Arc<Array2<Complex64>>> {
283 let mut cache = self.cache();
284 if let Some(value) = &cache.object_spectrum {
285 return Ok(value.clone());
286 }
287 let shape = self.reconstruction_shape()?;
288 let value = Arc::new(npy::read_complex2(
289 &self.checked_artifact(ARRAY_OBJECT_SPECTRUM)?.path,
290 ARRAY_OBJECT_SPECTRUM,
291 shape,
292 )?);
293 cache.object_spectrum = Some(value.clone());
294 Ok(value)
295 }
296
297 pub fn pupil(&self) -> Result<Arc<Array2<Complex64>>> {
299 let mut cache = self.cache();
300 if let Some(value) = &cache.pupil {
301 return Ok(value.clone());
302 }
303 let shape = self.image_shape()?;
304 let value = Arc::new(npy::read_complex2(
305 &self.checked_artifact(ARRAY_PUPIL)?.path,
306 ARRAY_PUPIL,
307 shape,
308 )?);
309 cache.pupil = Some(value.clone());
310 Ok(value)
311 }
312
313 pub fn pupil_support(&self) -> Result<Arc<Array2<u8>>> {
315 let mut cache = self.cache();
316 if let Some(value) = &cache.pupil_support {
317 return Ok(value.clone());
318 }
319 let shape = self.image_shape()?;
320 let value = Arc::new(npy::read_u8_2(
321 &self.checked_artifact(ARRAY_PUPIL_SUPPORT)?.path,
322 ARRAY_PUPIL_SUPPORT,
323 shape,
324 )?);
325 cache.pupil_support = Some(value.clone());
326 Ok(value)
327 }
328
329 pub fn result(&self) -> Result<Arc<ReconstructionResult>> {
331 let mut cache = self.cache();
332 if let Some(value) = &cache.result {
333 return Ok(value.clone());
334 }
335
336 let reconstruction_shape = self.reconstruction_shape()?;
339 let image_shape = self.image_shape()?;
340 let object = cache.object.clone().map_or_else(
341 || {
342 npy::read_complex2(
343 &self.checked_artifact(ARRAY_OBJECT)?.path,
344 ARRAY_OBJECT,
345 reconstruction_shape,
346 )
347 .map(Arc::new)
348 },
349 Ok,
350 )?;
351 let object_spectrum = cache.object_spectrum.clone().map_or_else(
352 || {
353 npy::read_complex2(
354 &self.checked_artifact(ARRAY_OBJECT_SPECTRUM)?.path,
355 ARRAY_OBJECT_SPECTRUM,
356 reconstruction_shape,
357 )
358 .map(Arc::new)
359 },
360 Ok,
361 )?;
362 let pupil_values = cache.pupil.clone().map_or_else(
363 || {
364 npy::read_complex2(
365 &self.checked_artifact(ARRAY_PUPIL)?.path,
366 ARRAY_PUPIL,
367 image_shape,
368 )
369 .map(Arc::new)
370 },
371 Ok,
372 )?;
373 let pupil_support = cache.pupil_support.clone().map_or_else(
374 || {
375 npy::read_u8_2(
376 &self.checked_artifact(ARRAY_PUPIL_SUPPORT)?.path,
377 ARRAY_PUPIL_SUPPORT,
378 image_shape,
379 )
380 .map(Arc::new)
381 },
382 Ok,
383 )?;
384 let trace = cache
385 .trace
386 .clone()
387 .map_or_else(|| self.load_trace().map(Arc::new), Ok)?;
388 let scalar_diagnostics = cache.scalar_diagnostics.clone().map_or_else(
389 || self.load_f64_map(TABLE_SCALAR_DIAGNOSTICS).map(Arc::new),
390 Ok,
391 )?;
392 let metadata = cache
393 .metadata
394 .clone()
395 .map_or_else(|| self.load_result_metadata().map(Arc::new), Ok)?;
396 let illumination_calibration = cache
397 .illumination_calibration
398 .clone()
399 .map_or_else(|| self.load_illumination_calibration().map(Arc::new), Ok)?;
400 let frame_gains = cache.frame_gains.clone().map_or_else(
401 || self.load_optional_f64(ARRAY_FRAME_GAINS).map(Arc::new),
402 Ok,
403 )?;
404 let background = cache.background.clone().map_or_else(
405 || self.load_optional_f64(ARRAY_BACKGROUND).map(Arc::new),
406 Ok,
407 )?;
408 let physical_illumination = cache.physical_illumination.clone().map_or_else(
409 || {
410 if self
411 .inner
412 .artifacts
413 .contains_key(DOMAIN_PHYSICAL_ILLUMINATION)
414 {
415 self.load_json_artifact(DOMAIN_PHYSICAL_ILLUMINATION)
416 .map(Arc::new)
417 .map(Some)
418 } else {
419 Ok(None)
420 }
421 },
422 |value| Ok(Some(value)),
423 )?;
424
425 let recovered_pupil = Pupil::new((*pupil_values).clone(), (*pupil_support).clone())?;
426 let amplitude = complex::amplitude(object.view());
427 let phase = complex::phase(object.view());
428 let result = Arc::new(ReconstructionResult {
429 object: (*object).clone(),
430 amplitude,
431 phase,
432 object_spectrum: (*object_spectrum).clone(),
433 recovered_pupil,
434 calibrated_illumination: (!illumination_calibration.is_empty())
435 .then(|| (*illumination_calibration).clone()),
436 recovered_frame_gains: (!frame_gains.is_empty()).then(|| (*frame_gains).clone()),
437 recovered_background: (!background.is_empty()).then(|| (*background).clone()),
438 physical_illumination_calibration: physical_illumination
439 .as_ref()
440 .map(|value| value.0.clone()),
441 calibrated_model: physical_illumination.as_ref().map(|value| value.1.clone()),
442 trace: (*trace).clone(),
443 scalar_diagnostics: (*scalar_diagnostics).clone(),
444 runtime: self.inner.manifest.runtime.clone(),
445 metadata: (*metadata).clone(),
446 });
447 result.validate()?;
448 self.validate_summary(&result)?;
449
450 cache.object = Some(object);
451 cache.object_spectrum = Some(object_spectrum);
452 cache.pupil = Some(pupil_values);
453 cache.pupil_support = Some(pupil_support);
454 cache.trace = Some(trace);
455 cache.scalar_diagnostics = Some(scalar_diagnostics);
456 cache.metadata = Some(metadata);
457 cache.illumination_calibration = Some(illumination_calibration);
458 cache.frame_gains = Some(frame_gains);
459 cache.background = Some(background);
460 cache.physical_illumination = physical_illumination;
461 cache.result = Some(result.clone());
462 Ok(result)
463 }
464
465 pub fn diagnostics(&self) -> Result<Option<Arc<ReconstructionDiagnostics>>> {
467 if !self.inner.artifacts.contains_key(DOMAIN_DIAGNOSTICS) {
468 return Ok(None);
469 }
470 let mut cache = self.cache();
471 if let Some(value) = &cache.diagnostics {
472 return Ok(Some(value.clone()));
473 }
474 let value: Arc<ReconstructionDiagnostics> =
475 Arc::new(self.load_json_artifact(DOMAIN_DIAGNOSTICS)?);
476 cache.diagnostics = Some(value.clone());
477 Ok(Some(value))
478 }
479
480 pub fn evaluation(&self) -> Result<Option<Arc<ReconstructionEvaluation>>> {
482 if !self.inner.artifacts.contains_key(DOMAIN_EVALUATION) {
483 return Ok(None);
484 }
485 let mut cache = self.cache();
486 if let Some(value) = &cache.evaluation {
487 return Ok(Some(value.clone()));
488 }
489 let value: Arc<ReconstructionEvaluation> =
490 Arc::new(self.load_json_artifact(DOMAIN_EVALUATION)?);
491 cache.evaluation = Some(value.clone());
492 Ok(Some(value))
493 }
494
495 pub fn physical_illumination_calibration(
497 &self,
498 ) -> Result<Option<Arc<IlluminationCalibrationState>>> {
499 if !self
500 .inner
501 .artifacts
502 .contains_key(DOMAIN_PHYSICAL_ILLUMINATION)
503 {
504 return Ok(None);
505 }
506 let mut cache = self.cache();
507 let value = if let Some(value) = &cache.physical_illumination {
508 value.clone()
509 } else {
510 let value: Arc<(IlluminationCalibrationState, ImagePlaneModel)> =
511 Arc::new(self.load_json_artifact(DOMAIN_PHYSICAL_ILLUMINATION)?);
512 cache.physical_illumination = Some(value.clone());
513 value
514 };
515 Ok(Some(Arc::new(value.0.clone())))
516 }
517
518 pub fn calibrated_model(&self) -> Result<Option<Arc<ImagePlaneModel>>> {
520 if !self
521 .inner
522 .artifacts
523 .contains_key(DOMAIN_PHYSICAL_ILLUMINATION)
524 {
525 return Ok(None);
526 }
527 let mut cache = self.cache();
528 let value = if let Some(value) = &cache.physical_illumination {
529 value.clone()
530 } else {
531 let value: Arc<(IlluminationCalibrationState, ImagePlaneModel)> =
532 Arc::new(self.load_json_artifact(DOMAIN_PHYSICAL_ILLUMINATION)?);
533 cache.physical_illumination = Some(value.clone());
534 value
535 };
536 Ok(Some(Arc::new(value.1.clone())))
537 }
538
539 pub fn clear_cache(&self) {
541 *self.cache() = BundleCache::default();
542 }
543
544 pub fn verify(&self) -> Result<BundleVerificationResult> {
546 let mut total_bytes = 0_u64;
547 for artifact in self.inner.artifacts.values() {
548 self.verify_artifact(artifact)?;
549 total_bytes = total_bytes.checked_add(artifact.byte_size).ok_or_else(|| {
550 Error::InvalidManifest("artifact byte-size total overflows u64".into())
551 })?;
552 if artifact.media_type == "application/vnd.apache.parquet" {
553 let dataframe = read_parquet(&artifact.path)?;
554 validate_table_schema(&artifact.role, &dataframe, &self.run_id)?;
555 }
556 }
557 let result = self.result()?;
560 self.validate_summary(&result)?;
561 Ok(BundleVerificationResult {
562 artifact_count: self.inner.artifacts.len(),
563 total_bytes,
564 })
565 }
566
567 fn validate_summary(&self, result: &ReconstructionResult) -> Result<()> {
568 let artifact = self.checked_artifact(TABLE_SUMMARY)?;
569 let summary = read_parquet(&artifact.path)?;
570 validate_table_schema(TABLE_SUMMARY, &summary, &self.run_id)?;
571 let string =
572 |name: &str| -> Result<Option<&str>> { Ok(summary.column(name)?.str()?.get(0)) };
573 let unsigned =
574 |name: &str| -> Result<Option<u64>> { Ok(summary.column(name)?.u64()?.get(0)) };
575 let float = |name: &str| -> Result<Option<f64>> { Ok(summary.column(name)?.f64()?.get(0)) };
576 let boolean =
577 |name: &str| -> Result<Option<bool>> { Ok(summary.column(name)?.bool()?.get(0)) };
578 let image_shape = result.recovered_pupil.shape();
579 let reconstruction_shape = result.object.dim();
580 let expected_dataset = self
581 .inner
582 .manifest
583 .dataset
584 .as_ref()
585 .map(|value| value.name.as_str());
586 let expected_dataset_version = self
587 .inner
588 .manifest
589 .dataset
590 .as_ref()
591 .and_then(|value| value.version.as_deref());
592 let consistent = string("crate_version")?
593 == Some(self.inner.manifest.crate_version.as_str())
594 && string("dataset_name")? == expected_dataset
595 && string("dataset_version")? == expected_dataset_version
596 && string("algorithm")? == Some(result.runtime.algorithm.as_str())
597 && unsigned("image_width")? == Some(image_shape.1 as u64)
598 && unsigned("image_height")? == Some(image_shape.0 as u64)
599 && unsigned("reconstruction_width")? == Some(reconstruction_shape.1 as u64)
600 && unsigned("reconstruction_height")? == Some(reconstruction_shape.0 as u64)
601 && unsigned("completed_iterations")?
602 == Some(result.runtime.completed_iterations as u64)
603 && float("elapsed_seconds")? == Some(result.runtime.elapsed_seconds)
604 && boolean("stopped_early")? == Some(result.runtime.stopped_early)
605 && float("final_objective")? == result.trace.final_objective();
606 if !consistent {
607 return Err(Error::InvalidParquetSchema {
608 role: TABLE_SUMMARY.into(),
609 reason: "summary values disagree with the manifest or reconstructed result".into(),
610 });
611 }
612 Ok(())
613 }
614
615 fn reconstruction_shape(&self) -> Result<(usize, usize)> {
616 shape2(
617 self.inner.manifest.result.reconstruction_shape,
618 ARRAY_OBJECT,
619 )
620 }
621
622 fn image_shape(&self) -> Result<(usize, usize)> {
623 shape2(self.inner.manifest.result.image_shape, ARRAY_PUPIL)
624 }
625
626 fn checked_artifact(&self, role: &str) -> Result<BundleArtifact> {
627 let artifact = self
628 .inner
629 .artifacts
630 .get(role)
631 .ok_or_else(|| Error::MissingArtifact { role: role.into() })?;
632 self.verify_artifact(artifact)?;
633 Ok(artifact.clone())
634 }
635
636 fn verify_artifact(&self, artifact: &BundleArtifact) -> Result<()> {
637 let metadata = fs::metadata(&artifact.path).map_err(|error| {
638 if error.kind() == std::io::ErrorKind::NotFound {
639 Error::MissingArtifact {
640 role: artifact.role.clone(),
641 }
642 } else {
643 Error::Io(error)
644 }
645 })?;
646 if metadata.len() != artifact.byte_size {
647 return Err(Error::InvalidManifest(format!(
648 "artifact {} has size {}, expected {}",
649 artifact.role,
650 metadata.len(),
651 artifact.byte_size
652 )));
653 }
654 if sha256(&artifact.path)? != artifact.sha256 {
655 return Err(Error::ArtifactHashMismatch {
656 role: artifact.role.clone(),
657 });
658 }
659 Ok(())
660 }
661
662 fn load_trace(&self) -> Result<ReconstructionTrace> {
663 let history_artifact = self.checked_artifact(TABLE_HISTORY)?;
664 let history = read_parquet(&history_artifact.path)?;
665 validate_table_schema(TABLE_HISTORY, &history, &self.run_id)?;
666 let iterations = history.column("iteration")?.u64()?;
667 let objectives = history.column("objective")?.f64()?;
668 let elapsed = history.column("elapsed_seconds")?.f64()?;
669 let iterations = iterations
670 .into_no_null_iter()
671 .zip(objectives.into_no_null_iter())
672 .zip(elapsed.into_no_null_iter())
673 .map(|((iteration, objective), elapsed_seconds)| {
674 Ok(IterationRecord {
675 iteration: usize::try_from(iteration).map_err(|_| {
676 Error::InvalidParquetSchema {
677 role: TABLE_HISTORY.into(),
678 reason: "iteration is not addressable as usize".into(),
679 }
680 })?,
681 objective,
682 elapsed_seconds,
683 })
684 })
685 .collect::<Result<Vec<_>>>()?;
686 let algorithm_metrics =
687 if let Some(artifact) = self.inner.artifacts.get(TABLE_ALGORITHM_METRICS) {
688 self.verify_artifact(artifact)?;
689 let dataframe = read_parquet(&artifact.path)?;
690 validate_table_schema(TABLE_ALGORITHM_METRICS, &dataframe, &self.run_id)?;
691 let iteration = dataframe.column("iteration")?.u64()?;
692 let namespace = dataframe.column("namespace")?.str()?;
693 let metric = dataframe.column("metric")?.str()?;
694 let value = dataframe.column("value")?.f64()?;
695 iteration
696 .into_no_null_iter()
697 .zip(namespace.iter().flatten())
698 .zip(metric.iter().flatten())
699 .zip(value.into_no_null_iter())
700 .map(|(((iteration, namespace), metric), value)| {
701 Ok(AlgorithmMetricRecord {
702 iteration: usize::try_from(iteration).map_err(|_| {
703 Error::InvalidParquetSchema {
704 role: TABLE_ALGORITHM_METRICS.into(),
705 reason: "iteration is not addressable as usize".into(),
706 }
707 })?,
708 namespace: namespace.into(),
709 metric: metric.into(),
710 value,
711 })
712 })
713 .collect::<Result<Vec<_>>>()?
714 } else {
715 Vec::new()
716 };
717 Ok(ReconstructionTrace {
718 iterations,
719 algorithm_metrics,
720 })
721 }
722
723 fn load_f64_map(&self, role: &str) -> Result<BTreeMap<String, f64>> {
724 let Some(artifact) = self.inner.artifacts.get(role) else {
725 return Ok(BTreeMap::new());
726 };
727 self.verify_artifact(artifact)?;
728 let dataframe = read_parquet(&artifact.path)?;
729 validate_table_schema(role, &dataframe, &self.run_id)?;
730 let keys = dataframe.column("key")?.str()?;
731 let values = dataframe.column("value")?.f64()?;
732 Ok(keys
733 .iter()
734 .flatten()
735 .zip(values.into_no_null_iter())
736 .map(|(key, value)| (key.into(), value))
737 .collect())
738 }
739
740 fn load_string_map(&self, role: &str) -> Result<BTreeMap<String, String>> {
741 let Some(artifact) = self.inner.artifacts.get(role) else {
742 return Ok(BTreeMap::new());
743 };
744 self.verify_artifact(artifact)?;
745 let dataframe = read_parquet(&artifact.path)?;
746 validate_table_schema(role, &dataframe, &self.run_id)?;
747 let keys = dataframe.column("key")?.str()?;
748 let values = dataframe.column("value")?.str()?;
749 Ok(keys
750 .iter()
751 .flatten()
752 .zip(values.iter().flatten())
753 .map(|(key, value)| (key.into(), value.into()))
754 .collect())
755 }
756
757 fn load_result_metadata(&self) -> Result<BTreeMap<String, String>> {
758 let mut metadata = self.load_string_map(TABLE_METADATA)?;
759 let artifact = self.checked_artifact(TABLE_SUMMARY)?;
760 let summary = read_parquet(&artifact.path)?;
761 validate_table_schema(TABLE_SUMMARY, &summary, &self.run_id)?;
762 for key in [
763 "case_id",
764 "dataset_name",
765 "dataset_version",
766 "preset_name",
767 "algorithm_configuration",
768 ] {
769 if let Some(value) = summary.column(key)?.str()?.get(0) {
770 metadata.insert(key.into(), value.into());
771 }
772 }
773 for key in ["random_seed", "frame_count"] {
774 if let Some(value) = summary.column(key)?.u64()?.get(0) {
775 metadata.insert(key.into(), value.to_string());
776 }
777 }
778 Ok(metadata)
779 }
780
781 fn load_illumination_calibration(&self) -> Result<Vec<(f64, f64)>> {
782 let Some(artifact) = self.inner.artifacts.get(ARRAY_ILLUMINATION_CALIBRATION) else {
783 return Ok(Vec::new());
784 };
785 self.verify_artifact(artifact)?;
786 let shape = artifact_shape(artifact)?;
787 if shape.len() != 2 || shape[1] != 2 {
788 return Err(Error::InvalidArrayShape {
789 role: ARRAY_ILLUMINATION_CALIBRATION.into(),
790 reason: format!("expected (source_count, 2), got {shape:?}"),
791 });
792 }
793 let values = npy::read_f64(&artifact.path, ARRAY_ILLUMINATION_CALIBRATION, &shape)?;
794 Ok(values
795 .chunks_exact(2)
796 .map(|values| (values[0], values[1]))
797 .collect())
798 }
799
800 fn load_optional_f64(&self, role: &str) -> Result<Vec<f64>> {
801 let Some(artifact) = self.inner.artifacts.get(role) else {
802 return Ok(Vec::new());
803 };
804 self.verify_artifact(artifact)?;
805 let shape = artifact_shape(artifact)?;
806 if shape.len() != 1 {
807 return Err(Error::InvalidArrayShape {
808 role: role.into(),
809 reason: format!("expected one dimension, got {shape:?}"),
810 });
811 }
812 npy::read_f64(&artifact.path, role, &shape)
813 }
814
815 fn load_json_artifact<T: DeserializeOwned>(&self, role: &str) -> Result<T> {
816 let artifact = self.checked_artifact(role)?;
817 serde_json::from_reader(BufReader::new(File::open(artifact.path)?))
818 .map_err(Error::Serialization)
819 }
820
821 fn cache(&self) -> MutexGuard<'_, BundleCache> {
822 self.inner
823 .cache
824 .lock()
825 .unwrap_or_else(|poisoned| poisoned.into_inner())
826 }
827}
828
829fn validate_manifest(root: &Path, manifest: &BundleManifest) -> Result<()> {
830 if manifest.bundle_format_version != BUNDLE_FORMAT_VERSION {
831 return Err(Error::UnsupportedBundleVersion {
832 actual: manifest.bundle_format_version,
833 supported: BUNDLE_FORMAT_VERSION,
834 });
835 }
836 if manifest.run_id.is_empty() || manifest.crate_version.is_empty() {
837 return Err(Error::InvalidManifest(
838 "run_id and crate_version must be non-empty".into(),
839 ));
840 }
841 let canonical_root = root.canonicalize()?;
842 let mut roles = BTreeSet::new();
843 for artifact in &manifest.artifacts {
844 if !KNOWN_ROLES.contains(&artifact.role.as_str()) {
845 return Err(Error::UnsupportedArtifactRole(artifact.role.clone()));
846 }
847 if !roles.insert(artifact.role.as_str()) {
848 return Err(Error::InvalidManifest(format!(
849 "duplicate artifact role {}",
850 artifact.role
851 )));
852 }
853 validate_relative_path(&artifact.relative_path)?;
854 let path = root.join(&artifact.relative_path);
855 let canonical_path = path.canonicalize().map_err(|error| {
856 if error.kind() == std::io::ErrorKind::NotFound {
857 Error::MissingArtifact {
858 role: artifact.role.clone(),
859 }
860 } else {
861 Error::Io(error)
862 }
863 })?;
864 if !canonical_path.starts_with(&canonical_root) {
865 return Err(Error::InvalidRelativePath(
866 artifact.relative_path.display().to_string(),
867 ));
868 }
869 if artifact.sha256.len() != 64
870 || !artifact.sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
871 {
872 return Err(Error::InvalidManifest(format!(
873 "artifact {} has an invalid SHA-256 encoding",
874 artifact.role
875 )));
876 }
877 validate_array_descriptor(artifact, &manifest.result)?;
878 }
879 for role in [
880 TABLE_SUMMARY,
881 TABLE_HISTORY,
882 ARRAY_OBJECT,
883 ARRAY_OBJECT_SPECTRUM,
884 ARRAY_PUPIL,
885 ARRAY_PUPIL_SUPPORT,
886 ] {
887 if !roles.contains(role) {
888 return Err(Error::MissingArtifact { role: role.into() });
889 }
890 }
891 Ok(())
892}
893
894fn validate_array_descriptor(
895 artifact: &ManifestArtifact,
896 result: &super::manifest::ResultDescriptor,
897) -> Result<()> {
898 let (expected_dtype, expected_shape): (&str, Option<&[u64]>) = match artifact.role.as_str() {
899 ARRAY_OBJECT | ARRAY_OBJECT_SPECTRUM => ("<c16", Some(&result.reconstruction_shape)),
900 ARRAY_PUPIL => ("<c16", Some(&result.image_shape)),
901 ARRAY_PUPIL_SUPPORT => ("|u1", Some(&result.image_shape)),
902 ARRAY_ILLUMINATION_CALIBRATION | ARRAY_FRAME_GAINS | ARRAY_BACKGROUND => ("<f8", None),
903 _ => return Ok(()),
904 };
905 if artifact.dtype.as_deref() != Some(expected_dtype) {
906 return Err(Error::InvalidArrayDtype {
907 role: artifact.role.clone(),
908 actual: artifact.dtype.as_deref().unwrap_or("<missing>").into(),
909 expected: expected_dtype.into(),
910 });
911 }
912 let shape = artifact
913 .shape
914 .as_deref()
915 .ok_or_else(|| Error::InvalidArrayShape {
916 role: artifact.role.clone(),
917 reason: "manifest shape is missing".into(),
918 })?;
919 if let Some(expected_shape) = expected_shape {
920 if shape != expected_shape {
921 return Err(Error::InvalidArrayShape {
922 role: artifact.role.clone(),
923 reason: format!("manifest shape {shape:?}, expected {expected_shape:?}"),
924 });
925 }
926 } else {
927 let valid = match artifact.role.as_str() {
928 ARRAY_ILLUMINATION_CALIBRATION => shape.len() == 2 && shape[1] == 2,
929 ARRAY_FRAME_GAINS | ARRAY_BACKGROUND => shape.len() == 1,
930 _ => true,
931 };
932 if !valid {
933 return Err(Error::InvalidArrayShape {
934 role: artifact.role.clone(),
935 reason: format!("invalid manifest shape {shape:?}"),
936 });
937 }
938 }
939 Ok(())
940}
941
942fn validate_relative_path(path: &Path) -> Result<()> {
943 if path.as_os_str().is_empty()
944 || path.is_absolute()
945 || path
946 .components()
947 .any(|component| !matches!(component, Component::Normal(_)))
948 {
949 return Err(Error::InvalidRelativePath(path.display().to_string()));
950 }
951 Ok(())
952}
953
954fn public_artifact(root: &Path, artifact: &ManifestArtifact) -> BundleArtifact {
955 BundleArtifact {
956 role: artifact.role.clone(),
957 path: root.join(&artifact.relative_path),
958 media_type: artifact.media_type.clone(),
959 byte_size: artifact.byte_size,
960 sha256: artifact.sha256.clone(),
961 dtype: artifact.dtype.clone(),
962 shape: artifact.shape.clone(),
963 }
964}
965
966fn read_parquet(path: &Path) -> Result<DataFrame> {
967 Ok(ParquetReader::new(File::open(path)?).finish()?)
968}
969
970fn validate_table_schema(role: &str, dataframe: &DataFrame, run_id: &str) -> Result<()> {
971 let expected: &[(&str, DataType, bool)] = match role {
972 TABLE_SUMMARY => &[
973 ("run_id", DataType::String, false),
974 ("case_id", DataType::String, true),
975 ("crate_version", DataType::String, false),
976 ("dataset_name", DataType::String, true),
977 ("dataset_version", DataType::String, true),
978 ("preset_name", DataType::String, true),
979 ("algorithm", DataType::String, false),
980 ("algorithm_configuration", DataType::String, true),
981 ("random_seed", DataType::UInt64, true),
982 ("frame_count", DataType::UInt64, true),
983 ("image_width", DataType::UInt64, false),
984 ("image_height", DataType::UInt64, false),
985 ("reconstruction_width", DataType::UInt64, false),
986 ("reconstruction_height", DataType::UInt64, false),
987 ("completed_iterations", DataType::UInt64, true),
988 ("elapsed_seconds", DataType::Float64, false),
989 ("stopped_early", DataType::Boolean, false),
990 ("final_objective", DataType::Float64, true),
991 ("success", DataType::Boolean, false),
992 ("error", DataType::String, true),
993 ],
994 TABLE_HISTORY => &[
995 ("run_id", DataType::String, false),
996 ("iteration", DataType::UInt64, false),
997 ("objective", DataType::Float64, false),
998 ("elapsed_seconds", DataType::Float64, false),
999 ],
1000 TABLE_ALGORITHM_METRICS => &[
1001 ("run_id", DataType::String, false),
1002 ("iteration", DataType::UInt64, false),
1003 ("namespace", DataType::String, false),
1004 ("metric", DataType::String, false),
1005 ("value", DataType::Float64, false),
1006 ],
1007 TABLE_ITERATION_DIAGNOSTICS => &[
1008 ("run_id", DataType::String, false),
1009 ("iteration", DataType::UInt64, false),
1010 ("total_objective", DataType::Float64, true),
1011 ("data_objective", DataType::Float64, true),
1012 ("regularization_objective", DataType::Float64, true),
1013 ("object_relative_change", DataType::Float64, true),
1014 ("pupil_relative_change", DataType::Float64, true),
1015 ("median_frame_objective", DataType::Float64, true),
1016 ("worst_frame_objective", DataType::Float64, true),
1017 ("elapsed_seconds", DataType::Float64, true),
1018 ],
1019 TABLE_FRAME_DIAGNOSTICS => &[
1020 ("run_id", DataType::String, false),
1021 ("iteration", DataType::UInt64, true),
1022 ("frame_index", DataType::UInt64, false),
1023 ("illumination_index", DataType::UInt64, false),
1024 ("reference_sum", DataType::Float64, false),
1025 ("estimate_sum", DataType::Float64, false),
1026 ("residual_l1", DataType::Float64, false),
1027 ("residual_l2", DataType::Float64, false),
1028 ("residual_mean", DataType::Float64, false),
1029 ("residual_std", DataType::Float64, false),
1030 ("residual_max_abs", DataType::Float64, false),
1031 ("normalized_l2", DataType::Float64, false),
1032 ("saturated_pixels", DataType::UInt64, true),
1033 ],
1034 TABLE_RAW_FRAME_STATISTICS => &[
1035 ("run_id", DataType::String, false),
1036 ("frame_index", DataType::UInt64, false),
1037 ("mean", DataType::Float64, false),
1038 ("std", DataType::Float64, false),
1039 ("min", DataType::Float64, false),
1040 ("max", DataType::Float64, false),
1041 ("sum", DataType::Float64, false),
1042 ("saturated_pixels", DataType::UInt64, false),
1043 ("zero_pixels", DataType::UInt64, false),
1044 ],
1045 TABLE_FRAME_EVALUATION => &[
1046 ("run_id", DataType::String, false),
1047 ("frame_index", DataType::UInt64, false),
1048 ("reference_sum", DataType::Float64, false),
1049 ("estimate_sum", DataType::Float64, false),
1050 ("residual_l1", DataType::Float64, false),
1051 ("residual_l2", DataType::Float64, false),
1052 ("residual_mean", DataType::Float64, false),
1053 ("residual_std", DataType::Float64, false),
1054 ("residual_max_abs", DataType::Float64, false),
1055 ("normalized_l2", DataType::Float64, false),
1056 ("saturated_pixels", DataType::UInt64, true),
1057 ],
1058 TABLE_ILLUMINATION_CALIBRATION => &[
1059 ("run_id", DataType::String, false),
1060 ("source_index", DataType::UInt64, false),
1061 ("row_correction_pixels", DataType::Float64, false),
1062 ("column_correction_pixels", DataType::Float64, false),
1063 ],
1064 TABLE_FRAME_CALIBRATION => &[
1065 ("run_id", DataType::String, false),
1066 ("frame_index", DataType::UInt64, false),
1067 ("gain", DataType::Float64, true),
1068 ("background", DataType::Float64, true),
1069 ],
1070 TABLE_SCALAR_DIAGNOSTICS => &[
1071 ("run_id", DataType::String, false),
1072 ("key", DataType::String, false),
1073 ("value", DataType::Float64, false),
1074 ],
1075 TABLE_METADATA => &[
1076 ("run_id", DataType::String, false),
1077 ("key", DataType::String, false),
1078 ("value", DataType::String, false),
1079 ],
1080 _ => &[],
1081 };
1082 if !expected.is_empty() {
1083 if dataframe.width() != expected.len() {
1084 return Err(Error::InvalidParquetSchema {
1085 role: role.into(),
1086 reason: format!(
1087 "found {} columns, expected {}",
1088 dataframe.width(),
1089 expected.len()
1090 ),
1091 });
1092 }
1093 for &(name, ref dtype, nullable) in expected {
1094 let column = dataframe
1095 .column(name)
1096 .map_err(|error| Error::InvalidParquetSchema {
1097 role: role.into(),
1098 reason: error.to_string(),
1099 })?;
1100 if column.dtype() != dtype || (!nullable && column.null_count() != 0) {
1101 return Err(Error::InvalidParquetSchema {
1102 role: role.into(),
1103 reason: format!(
1104 "column {name} has dtype {:?} and {} nulls; expected {dtype:?}",
1105 column.dtype(),
1106 column.null_count()
1107 ),
1108 });
1109 }
1110 }
1111 }
1112 if let Ok(column) = dataframe.column("run_id") {
1113 let strings = column.str().map_err(|error| Error::InvalidParquetSchema {
1114 role: role.into(),
1115 reason: error.to_string(),
1116 })?;
1117 for actual in strings.iter().flatten() {
1118 if actual != run_id {
1119 return Err(Error::InconsistentRunId {
1120 expected: run_id.into(),
1121 actual: actual.into(),
1122 });
1123 }
1124 }
1125 }
1126 if role == TABLE_HISTORY {
1127 let iterations = dataframe.column("iteration")?.u64()?;
1128 for (index, iteration) in iterations.into_no_null_iter().enumerate() {
1129 if iteration != index as u64 + 1 {
1130 return Err(Error::InvalidParquetSchema {
1131 role: role.into(),
1132 reason: "iterations must be unique, ordered, and one-based".into(),
1133 });
1134 }
1135 }
1136 }
1137 if role == TABLE_SUMMARY {
1138 if dataframe.height() != 1 {
1139 return Err(Error::InvalidParquetSchema {
1140 role: role.into(),
1141 reason: "summary must contain exactly one row".into(),
1142 });
1143 }
1144 if dataframe.column("success")?.bool()?.get(0) != Some(true)
1145 || dataframe.column("error")?.null_count() != 1
1146 {
1147 return Err(Error::InvalidParquetSchema {
1148 role: role.into(),
1149 reason: "a result-bundle summary must describe one successful run".into(),
1150 });
1151 }
1152 }
1153 if role == TABLE_ALGORITHM_METRICS {
1154 let iterations = dataframe.column("iteration")?.u64()?;
1155 let namespaces = dataframe.column("namespace")?.str()?;
1156 let metrics = dataframe.column("metric")?.str()?;
1157 let mut previous_iteration = 0;
1158 let mut keys = BTreeSet::new();
1159 for ((iteration, namespace), metric) in iterations
1160 .into_no_null_iter()
1161 .zip(namespaces.iter().flatten())
1162 .zip(metrics.iter().flatten())
1163 {
1164 if iteration == 0
1165 || iteration < previous_iteration
1166 || namespace.is_empty()
1167 || metric.is_empty()
1168 || !keys.insert((iteration, namespace, metric))
1169 {
1170 return Err(Error::InvalidParquetSchema {
1171 role: role.into(),
1172 reason: "metric keys must be non-empty and unique, with ordered one-based iterations"
1173 .into(),
1174 });
1175 }
1176 previous_iteration = iteration;
1177 }
1178 }
1179 if role == TABLE_ITERATION_DIAGNOSTICS {
1180 let mut previous = 0;
1181 for iteration in dataframe.column("iteration")?.u64()?.into_no_null_iter() {
1182 if iteration == 0 || iteration <= previous {
1183 return Err(Error::InvalidParquetSchema {
1184 role: role.into(),
1185 reason: "diagnostic iterations must be unique, ordered, and one-based".into(),
1186 });
1187 }
1188 previous = iteration;
1189 }
1190 }
1191 if role == TABLE_FRAME_DIAGNOSTICS {
1192 let iterations = dataframe.column("iteration")?.u64()?;
1193 let frames = dataframe.column("frame_index")?.u64()?;
1194 let mut keys = BTreeSet::new();
1195 for (iteration, frame) in iterations.iter().zip(frames.into_no_null_iter()) {
1196 if iteration == Some(0) || !keys.insert((iteration, frame)) {
1197 return Err(Error::InvalidParquetSchema {
1198 role: role.into(),
1199 reason: "frame diagnostic keys must be unique and iterations one-based".into(),
1200 });
1201 }
1202 }
1203 }
1204 if matches!(
1205 role,
1206 TABLE_RAW_FRAME_STATISTICS | TABLE_FRAME_EVALUATION | TABLE_FRAME_CALIBRATION
1207 ) {
1208 validate_zero_based_index(role, dataframe, "frame_index")?;
1209 }
1210 if role == TABLE_ILLUMINATION_CALIBRATION {
1211 validate_zero_based_index(role, dataframe, "source_index")?;
1212 }
1213 if matches!(role, TABLE_SCALAR_DIAGNOSTICS | TABLE_METADATA) {
1214 let keys = dataframe.column("key")?.str()?;
1215 let mut unique = BTreeSet::new();
1216 if keys
1217 .iter()
1218 .flatten()
1219 .any(|key| key.is_empty() || !unique.insert(key))
1220 {
1221 return Err(Error::InvalidParquetSchema {
1222 role: role.into(),
1223 reason: "keys must be non-empty and unique within one run".into(),
1224 });
1225 }
1226 }
1227 Ok(())
1228}
1229
1230fn validate_zero_based_index(role: &str, dataframe: &DataFrame, column: &str) -> Result<()> {
1231 for (expected, actual) in dataframe
1232 .column(column)?
1233 .u64()?
1234 .into_no_null_iter()
1235 .enumerate()
1236 {
1237 if actual != expected as u64 {
1238 return Err(Error::InvalidParquetSchema {
1239 role: role.into(),
1240 reason: format!("{column} must be unique, ordered, and zero-based"),
1241 });
1242 }
1243 }
1244 Ok(())
1245}
1246
1247fn shape2(shape: [u64; 2], role: &str) -> Result<(usize, usize)> {
1248 Ok((
1249 usize::try_from(shape[0]).map_err(|_| Error::InvalidArrayShape {
1250 role: role.into(),
1251 reason: "height is not addressable".into(),
1252 })?,
1253 usize::try_from(shape[1]).map_err(|_| Error::InvalidArrayShape {
1254 role: role.into(),
1255 reason: "width is not addressable".into(),
1256 })?,
1257 ))
1258}
1259
1260fn artifact_shape(artifact: &BundleArtifact) -> Result<Vec<usize>> {
1261 artifact
1262 .shape
1263 .as_ref()
1264 .ok_or_else(|| Error::InvalidArrayShape {
1265 role: artifact.role.clone(),
1266 reason: "manifest shape is missing".into(),
1267 })?
1268 .iter()
1269 .map(|&value| {
1270 usize::try_from(value).map_err(|_| Error::InvalidArrayShape {
1271 role: artifact.role.clone(),
1272 reason: "dimension is not addressable".into(),
1273 })
1274 })
1275 .collect()
1276}