Skip to main content

fpm_rs/tabular/parquet/
bundle.rs

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/// Verified manifest metadata for one file inside a result bundle.
42#[derive(Clone, Debug)]
43pub struct BundleArtifact {
44    /// Stable semantic artifact role.
45    pub role: String,
46    /// Absolute or bundle-root-relative resolved local path.
47    pub path: PathBuf,
48    /// Declared MIME media type.
49    pub media_type: String,
50    /// Exact artifact size in bytes.
51    pub byte_size: u64,
52    /// Lowercase hexadecimal SHA-256 digest.
53    pub sha256: String,
54    /// Optional NPY element-dtype descriptor.
55    pub dtype: Option<String>,
56    /// Optional array shape in axis order.
57    pub shape: Option<Vec<u64>>,
58}
59
60/// Parquet table artifact descriptors in a result bundle.
61#[derive(Clone, Debug)]
62pub struct BundleTables {
63    /// Required one-row run summary table.
64    pub summary: BundleArtifact,
65    /// Required iteration history table.
66    pub history: BundleArtifact,
67    /// Optional algorithm-specific scalar metric table.
68    pub algorithm_metrics: Option<BundleArtifact>,
69    /// Optional convergence diagnostics table.
70    pub iteration_diagnostics: Option<BundleArtifact>,
71    /// Optional per-frame diagnostic comparison table.
72    pub frame_diagnostics: Option<BundleArtifact>,
73    /// Optional raw measured-frame statistics table.
74    pub raw_frame_statistics: Option<BundleArtifact>,
75    /// Optional predicted-versus-measured frame evaluation table.
76    pub frame_evaluation: Option<BundleArtifact>,
77    /// Optional per-source Fourier-grid correction table.
78    pub illumination_calibration: Option<BundleArtifact>,
79    /// Optional per-frame gain and background table.
80    pub frame_calibration: Option<BundleArtifact>,
81    /// Optional named scalar diagnostics table.
82    pub scalar_diagnostics: Option<BundleArtifact>,
83    /// Optional extensible string metadata table.
84    pub metadata: Option<BundleArtifact>,
85}
86
87/// Lossless NPY array artifact descriptors in a result bundle.
88#[derive(Clone, Debug)]
89pub struct BundleArrays {
90    /// High-resolution `(height, width)` complex object field.
91    pub object: BundleArtifact,
92    /// Centered high-resolution complex object spectrum.
93    pub object_spectrum: BundleArtifact,
94    /// Low-resolution complex pupil values.
95    pub pupil: BundleArtifact,
96    /// Low-resolution binary pupil support.
97    pub pupil_support: BundleArtifact,
98    /// Optional source-order `(row, column)` corrections in Fourier-grid pixels.
99    pub illumination_calibration: Option<BundleArtifact>,
100    /// Optional acquisition-frame gains.
101    pub frame_gains: Option<BundleArtifact>,
102    /// Optional acquisition-frame additive backgrounds.
103    pub background: Option<BundleArtifact>,
104}
105
106/// Optional derived PNG preview artifact descriptors.
107#[derive(Clone, Debug, Default)]
108pub struct BundlePreviews {
109    /// Linearly normalized object-amplitude preview.
110    pub object_amplitude: Option<BundleArtifact>,
111    /// Wrapped object-phase preview.
112    pub object_phase: Option<BundleArtifact>,
113    /// Linearly normalized pupil-amplitude preview.
114    pub pupil_amplitude: Option<BundleArtifact>,
115    /// Wrapped pupil-phase preview.
116    pub pupil_phase: Option<BundleArtifact>,
117    /// Rendered individual-source Fourier-coverage preview.
118    pub fourier_coverage: Option<BundleArtifact>,
119}
120
121/// Aggregate result of verifying every artifact in a bundle.
122#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct BundleVerificationResult {
124    /// Number of manifest-declared artifacts verified.
125    pub artifact_count: usize,
126    /// Sum of verified artifact sizes in bytes.
127    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/// One final, disk-backed reconstruction bundle.
155///
156/// The manifest and artifact handles are eager. Scientific arrays and domain
157/// objects are hash-checked, loaded, and cached on first access.
158#[derive(Clone)]
159pub struct ResultBundle {
160    /// Bundle root directory.
161    pub path: PathBuf,
162    /// Path to the bundle manifest JSON file.
163    pub manifest_path: PathBuf,
164    /// Unique run identifier shared by tables and manifest.
165    pub run_id: String,
166    /// Optional human-readable result label.
167    pub label: Option<String>,
168    /// Parquet table descriptors.
169    pub tables: BundleTables,
170    /// Lossless scientific-array descriptors.
171    pub arrays: BundleArrays,
172    /// Optional derived preview descriptors.
173    pub previews: BundlePreviews,
174    inner: Arc<ResultBundleInner>,
175}
176
177/// Opens and validates a complete bundle manifest without eagerly loading scientific arrays.
178pub fn read_bundle(path: impl AsRef<Path>) -> Result<ResultBundle> {
179    ResultBundle::read(path)
180}
181
182impl ResultBundle {
183    /// Opens a complete bundle and validates manifest paths and required artifact metadata.
184    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    /// Hash-checks, lazily loads, and caches the high-resolution complex object.
266    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    /// Hash-checks, lazily loads, and caches the centered complex object spectrum.
282    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    /// Hash-checks, lazily loads, and caches low-resolution complex pupil values.
298    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    /// Hash-checks, lazily loads, and caches the low-resolution binary pupil support.
314    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    /// Lazily reconstructs and caches a validated domain result from lossless artifacts.
330    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        // Build every fallible value locally first. A failed load does not
337        // install a partially initialized result.
338        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    /// Lazily loads and caches optional structured diagnostics JSON.
466    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    /// Lazily loads and caches optional structured ground-truth evaluation JSON.
481    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    /// Lazily loads and caches optional physical planar-array calibration state.
496    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    /// Lazily loads and caches the optional final model compiled from physical calibration.
519    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    /// Drops this bundle's shared in-memory array and domain-object cache.
540    pub fn clear_cache(&self) {
541        *self.cache() = BundleCache::default();
542    }
543
544    /// Verifies existence, byte size, SHA-256, NPY metadata, Parquet schema, and run IDs.
545    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        // This also validates all authoritative array dtypes, shapes, and
558        // cross-artifact reconstruction invariants.
559        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}