Skip to main content

ecological_model_core/
initial_state.rs

1//! Reproducible categorical ecological initial states and verified artifacts.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use physics_in_parallel::prelude::basic::{
7    RngConfig, SquareLattice, SquareLatticeConfig, SquareLatticeConfigError,
8    SquareLatticeInitMethod,
9};
10use scientific_workflow::prelude::basics::{
11    ArtifactDescriptor, ArtifactDisposition, ArtifactError, ArtifactLoadError, ExecutionScope,
12    RngRecord, RngRecordError, load_verified_artifact, persist_artifact,
13};
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value};
16use thiserror::Error;
17
18pub const INITIALIZATION_RNG_NAMESPACE: &str = "ecological_model_core.initial_state";
19pub const INITIAL_STATE_FORMAT: &str = "ecological.initial-state.v1";
20pub const INITIAL_STATE_METADATA_KEY: &str = "initial_state";
21
22pub type CategoricalSpace = SquareLattice<usize>;
23pub type TaxonCounts = Vec<usize>;
24
25/// Source of relative taxon weights for categorical placement.
26#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
27#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
28pub enum DistributionSource {
29    Uniform,
30    Inline { weights: Vec<f64> },
31    Json { path: PathBuf },
32}
33
34impl DistributionSource {
35    pub fn path(&self) -> Option<&Path> {
36        match self {
37            Self::Json { path } => Some(path),
38            Self::Uniform | Self::Inline { .. } => None,
39        }
40    }
41
42    fn validate(&self, num_taxa: usize) -> Result<(), InitialStateError> {
43        match self {
44            Self::Uniform => Ok(()),
45            Self::Inline { weights } => validate_distribution(weights, num_taxa),
46            Self::Json { path } if path.as_os_str().is_empty() => Err(InitialStateError::EmptyPath),
47            Self::Json { .. } => Ok(()),
48        }
49    }
50
51    fn resolve(self, num_taxa: usize) -> Result<Option<Vec<f64>>, InitialStateError> {
52        let weights = match self {
53            Self::Uniform => return Ok(None),
54            Self::Inline { weights } => weights,
55            Self::Json { path } => {
56                let bytes = fs::read(&path).map_err(|source| InitialStateError::Io {
57                    operation: "read distribution",
58                    path: path.clone(),
59                    source,
60                })?;
61                serde_json::from_slice(&bytes)
62                    .map_err(|source| InitialStateError::Json { path, source })?
63            }
64        };
65        validate_distribution(&weights, num_taxa)?;
66        Ok(Some(weights))
67    }
68}
69
70#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
71#[serde(rename_all = "snake_case")]
72pub enum InitializationMethod {
73    Random,
74    BalancedUniform,
75    CenteredSeed,
76    CenteredDominantSeed,
77}
78
79/// Reproducible scientific instructions for one categorical state.
80#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
81#[serde(tag = "method", rename_all = "snake_case", deny_unknown_fields)]
82pub enum InitialStateRecipe {
83    Random {
84        distribution: DistributionSource,
85        #[serde(default)]
86        rng: RngConfig,
87    },
88    /// Assign the minimum-variance uniform taxon counts, then shuffle all sites.
89    BalancedUniform {
90        #[serde(default)]
91        rng: RngConfig,
92    },
93    CenteredSeed {
94        distribution: DistributionSource,
95        seed_taxon: usize,
96        seed_radius: usize,
97        #[serde(default)]
98        rng: RngConfig,
99    },
100    CenteredDominantSeed {
101        distribution: DistributionSource,
102        seed_radius: usize,
103        #[serde(default)]
104        rng: RngConfig,
105    },
106}
107
108impl InitialStateRecipe {
109    pub const fn method(&self) -> InitializationMethod {
110        match self {
111            Self::Random { .. } => InitializationMethod::Random,
112            Self::BalancedUniform { .. } => InitializationMethod::BalancedUniform,
113            Self::CenteredSeed { .. } => InitializationMethod::CenteredSeed,
114            Self::CenteredDominantSeed { .. } => InitializationMethod::CenteredDominantSeed,
115        }
116    }
117
118    pub fn distribution_path(&self) -> Option<&Path> {
119        match self {
120            Self::Random { distribution, .. }
121            | Self::CenteredSeed { distribution, .. }
122            | Self::CenteredDominantSeed { distribution, .. } => distribution.path(),
123            Self::BalancedUniform { .. } => None,
124        }
125    }
126
127    pub fn validate(
128        &self,
129        lattice: &SquareLatticeConfig,
130        num_taxa: usize,
131    ) -> Result<(), InitialStateError> {
132        if num_taxa == 0 {
133            return Err(InitialStateError::ZeroTaxa);
134        }
135        match self {
136            Self::Random { distribution, .. } => distribution.validate(num_taxa),
137            Self::BalancedUniform { .. } => Ok(()),
138            Self::CenteredSeed {
139                distribution,
140                seed_taxon,
141                seed_radius,
142                ..
143            } => {
144                distribution.validate(num_taxa)?;
145                validate_seed(lattice, num_taxa, *seed_taxon, *seed_radius)
146            }
147            Self::CenteredDominantSeed {
148                distribution,
149                seed_radius,
150                ..
151            } => {
152                distribution.validate(num_taxa)?;
153                validate_seed_geometry(lattice, *seed_radius)
154            }
155        }
156    }
157
158    pub fn create(
159        self,
160        lattice: SquareLatticeConfig,
161        num_taxa: usize,
162    ) -> Result<InitialState, InitialStateError> {
163        self.validate(&lattice, num_taxa)?;
164        let method = self.method();
165        let (distribution, rng, explicit_seed, seed_radius, dominant) = match self {
166            Self::Random { distribution, rng } => (distribution, rng, None, None, false),
167            Self::CenteredSeed {
168                distribution,
169                seed_taxon,
170                seed_radius,
171                rng,
172            } => (
173                distribution,
174                rng,
175                Some(seed_taxon),
176                Some(seed_radius),
177                false,
178            ),
179            Self::CenteredDominantSeed {
180                distribution,
181                seed_radius,
182                rng,
183            } => (distribution, rng, None, Some(seed_radius), true),
184            Self::BalancedUniform { rng } => {
185                let values = balanced_uniform_values(lattice.num_sites(), num_taxa);
186                let space = CategoricalSpace::new(
187                    lattice,
188                    SquareLatticeInitMethod::ShuffledValues { values, rng },
189                )?;
190                let counts = count_taxa(&space, num_taxa)?;
191                let rng_record = Some(rng_record_from_space(&space)?);
192                return Ok(InitialState {
193                    num_taxa,
194                    method,
195                    seed_taxon: None,
196                    rng_record,
197                    space,
198                    counts,
199                });
200            }
201        };
202        let mut weights = distribution.resolve(num_taxa)?;
203        let seed_taxon = if dominant {
204            let background = weights.get_or_insert_with(|| vec![1.0; num_taxa]);
205            Some(remove_dominant_taxon(background)?)
206        } else {
207            explicit_seed
208        };
209        let mut space = CategoricalSpace::new(
210            lattice,
211            SquareLatticeInitMethod::RandomChoices {
212                choices: (0..num_taxa).collect(),
213                weights,
214                rng,
215            },
216        )?;
217        let mut counts = count_taxa(&space, num_taxa)?;
218        if let (Some(taxon), Some(radius)) = (seed_taxon, seed_radius) {
219            let shape = space.config().shape().to_vec();
220            plant_centered_seed(&mut space, &mut counts, &shape, taxon, radius);
221        }
222        let rng_record = Some(rng_record_from_space(&space)?);
223        Ok(InitialState {
224            num_taxa,
225            method,
226            seed_taxon,
227            rng_record,
228            space,
229            counts,
230        })
231    }
232}
233
234/// A generated recipe or a verified prior-execution artifact.
235#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
236#[serde(tag = "source", rename_all = "snake_case", deny_unknown_fields)]
237pub enum InitialStateSource {
238    Recipe {
239        recipe: InitialStateRecipe,
240    },
241    VerifiedArtifact {
242        execution_directory: PathBuf,
243        descriptor: InitialStateArtifactDescriptor,
244    },
245}
246
247impl InitialStateSource {
248    pub fn validate(
249        &self,
250        lattice: &SquareLatticeConfig,
251        num_taxa: usize,
252    ) -> Result<(), InitialStateError> {
253        match self {
254            Self::Recipe { recipe } => recipe.validate(lattice, num_taxa),
255            Self::VerifiedArtifact {
256                execution_directory,
257                descriptor,
258            } => {
259                if execution_directory.as_os_str().is_empty() {
260                    return Err(InitialStateError::EmptyPath);
261                }
262                if descriptor.lattice() != lattice {
263                    return Err(InitialStateError::LatticeMismatch);
264                }
265                if descriptor.num_taxa() != num_taxa {
266                    return Err(InitialStateError::TaxonDimensionMismatch {
267                        expected: num_taxa,
268                        actual: descriptor.num_taxa(),
269                    });
270                }
271                Ok(())
272            }
273        }
274    }
275
276    pub fn resolve(
277        &self,
278        lattice: SquareLatticeConfig,
279        num_taxa: usize,
280    ) -> Result<InitialState, InitialStateError> {
281        self.validate(&lattice, num_taxa)?;
282        match self {
283            Self::Recipe { recipe } => recipe.clone().create(lattice, num_taxa),
284            Self::VerifiedArtifact {
285                execution_directory,
286                descriptor,
287            } => load_verified_initial_state(execution_directory, descriptor),
288        }
289    }
290}
291
292#[derive(Debug)]
293pub struct InitialState {
294    num_taxa: usize,
295    method: InitializationMethod,
296    seed_taxon: Option<usize>,
297    rng_record: Option<RngRecord>,
298    space: CategoricalSpace,
299    counts: TaxonCounts,
300}
301
302impl InitialState {
303    pub const fn num_taxa(&self) -> usize {
304        self.num_taxa
305    }
306    pub const fn method(&self) -> InitializationMethod {
307        self.method
308    }
309    pub const fn seed_taxon(&self) -> Option<usize> {
310        self.seed_taxon
311    }
312    pub const fn rng_record(&self) -> Option<&RngRecord> {
313        self.rng_record.as_ref()
314    }
315    pub const fn space(&self) -> &CategoricalSpace {
316        &self.space
317    }
318    pub fn counts(&self) -> &[usize] {
319        &self.counts
320    }
321    /// Returns the exact aggregate relative frequencies represented by the lattice.
322    pub fn frequencies(&self) -> Vec<f64> {
323        let total = self.space.num_sites() as f64;
324        self.counts
325            .iter()
326            .map(|&count| count as f64 / total)
327            .collect()
328    }
329    /// Encodes the complete reproducible state in eco_core's canonical JSON format.
330    pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
331        serde_json::to_vec(&InitialStateDocumentRef::from(self))
332    }
333    pub fn clone_space(&self) -> CategoricalSpace {
334        self.space.clone()
335    }
336    pub fn into_parts(self) -> (CategoricalSpace, TaxonCounts) {
337        (self.space, self.counts)
338    }
339}
340
341#[derive(Serialize)]
342struct InitialStateDocumentRef<'a> {
343    format: &'static str,
344    num_taxa: usize,
345    method: InitializationMethod,
346    seed_taxon: Option<usize>,
347    rng_record: Option<&'a RngRecord>,
348    lattice: &'a SquareLatticeConfig,
349    sites: &'a [usize],
350}
351
352impl<'a> From<&'a InitialState> for InitialStateDocumentRef<'a> {
353    fn from(initial: &'a InitialState) -> Self {
354        Self {
355            format: INITIAL_STATE_FORMAT,
356            num_taxa: initial.num_taxa,
357            method: initial.method,
358            seed_taxon: initial.seed_taxon,
359            rng_record: initial.rng_record.as_ref(),
360            lattice: initial.space.config(),
361            sites: initial.space.data(),
362        }
363    }
364}
365
366#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
367#[serde(deny_unknown_fields)]
368pub struct InitialStateArtifactDescriptor {
369    format: String,
370    num_taxa: usize,
371    lattice: SquareLatticeConfig,
372    method: InitializationMethod,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    seed_taxon: Option<usize>,
375    #[serde(flatten)]
376    artifact: ArtifactDescriptor,
377}
378
379impl InitialStateArtifactDescriptor {
380    pub fn format(&self) -> &str {
381        &self.format
382    }
383    pub const fn num_taxa(&self) -> usize {
384        self.num_taxa
385    }
386    pub const fn lattice(&self) -> &SquareLatticeConfig {
387        &self.lattice
388    }
389    pub const fn method(&self) -> InitializationMethod {
390        self.method
391    }
392    pub const fn seed_taxon(&self) -> Option<usize> {
393        self.seed_taxon
394    }
395    pub fn sha256(&self) -> &str {
396        self.artifact.sha256()
397    }
398    pub fn path(&self) -> &str {
399        self.artifact.path()
400    }
401    pub fn insert_into_metadata(&self, metadata: &mut Map<String, Value>) -> Option<Value> {
402        metadata.insert(
403            INITIAL_STATE_METADATA_KEY.to_owned(),
404            serde_json::to_value(self).expect("initial-state descriptor is JSON-compatible"),
405        )
406    }
407}
408
409#[derive(Clone, Debug)]
410pub struct PersistedInitialState {
411    descriptor: InitialStateArtifactDescriptor,
412    disposition: ArtifactDisposition,
413}
414
415impl PersistedInitialState {
416    pub const fn descriptor(&self) -> &InitialStateArtifactDescriptor {
417        &self.descriptor
418    }
419    pub const fn disposition(&self) -> ArtifactDisposition {
420        self.disposition
421    }
422    pub fn into_descriptor(self) -> InitialStateArtifactDescriptor {
423        self.descriptor
424    }
425}
426
427pub fn persist_initial_state(
428    scope: &ExecutionScope,
429    initial: &InitialState,
430) -> Result<PersistedInitialState, InitialStateError> {
431    let bytes = serde_json::to_vec(&InitialStateDocumentRef::from(initial))?;
432    let persisted = persist_artifact(scope, "initial-state", "json", &bytes)?;
433    Ok(PersistedInitialState {
434        descriptor: InitialStateArtifactDescriptor {
435            format: INITIAL_STATE_FORMAT.to_owned(),
436            num_taxa: initial.num_taxa,
437            lattice: initial.space.config().clone(),
438            method: initial.method,
439            seed_taxon: initial.seed_taxon,
440            artifact: persisted.descriptor().clone(),
441        },
442        disposition: persisted.disposition(),
443    })
444}
445
446pub fn load_verified_initial_state(
447    execution_directory: impl AsRef<Path>,
448    descriptor: &InitialStateArtifactDescriptor,
449) -> Result<InitialState, InitialStateError> {
450    if descriptor.format != INITIAL_STATE_FORMAT {
451        return Err(InitialStateError::UnsupportedFormat {
452            actual: descriptor.format.clone(),
453        });
454    }
455    let verified = load_verified_artifact(execution_directory, &descriptor.artifact)?;
456    let document: InitialStateDocument =
457        serde_json::from_slice(verified.bytes()).map_err(|source| InitialStateError::Json {
458            path: verified.path().to_path_buf(),
459            source,
460        })?;
461    let initial = document.resolve(descriptor.lattice.clone(), descriptor.num_taxa)?;
462    if initial.method != descriptor.method || initial.seed_taxon != descriptor.seed_taxon {
463        return Err(InitialStateError::DescriptorMismatch);
464    }
465    Ok(initial)
466}
467
468#[derive(Debug, Deserialize)]
469#[serde(deny_unknown_fields)]
470struct InitialStateDocument {
471    format: String,
472    num_taxa: usize,
473    method: InitializationMethod,
474    seed_taxon: Option<usize>,
475    rng_record: Option<RngRecord>,
476    lattice: SquareLatticeConfig,
477    sites: Vec<usize>,
478}
479
480impl InitialStateDocument {
481    fn resolve(
482        self,
483        expected_lattice: SquareLatticeConfig,
484        expected_num_taxa: usize,
485    ) -> Result<InitialState, InitialStateError> {
486        if self.format != INITIAL_STATE_FORMAT {
487            return Err(InitialStateError::UnsupportedFormat {
488                actual: self.format,
489            });
490        }
491        if self.num_taxa != expected_num_taxa {
492            return Err(InitialStateError::TaxonDimensionMismatch {
493                expected: expected_num_taxa,
494                actual: self.num_taxa,
495            });
496        }
497        if self.lattice != expected_lattice {
498            return Err(InitialStateError::LatticeMismatch);
499        }
500        if self.seed_taxon.is_some_and(|taxon| taxon >= self.num_taxa) {
501            return Err(InitialStateError::SeedTaxonOutOfRange {
502                seed_taxon: self.seed_taxon.expect("checked Some"),
503                num_taxa: self.num_taxa,
504            });
505        }
506        let space = CategoricalSpace::new(
507            self.lattice,
508            SquareLatticeInitMethod::Values { values: self.sites },
509        )?;
510        let counts = count_taxa(&space, self.num_taxa)?;
511        Ok(InitialState {
512            num_taxa: self.num_taxa,
513            method: self.method,
514            seed_taxon: self.seed_taxon,
515            rng_record: self.rng_record,
516            space,
517            counts,
518        })
519    }
520}
521
522fn rng_record_from_space(space: &CategoricalSpace) -> Result<RngRecord, InitialStateError> {
523    let config = space
524        .initialization_rng_config()
525        .ok_or(InitialStateError::MissingResolvedRngConfig)?;
526    let method = config
527        .method()
528        .ok_or(InitialStateError::MissingResolvedRngConfig)?;
529    let key = config
530        .encode_seed()
531        .ok_or(InitialStateError::MissingResolvedRngConfig)?;
532    let parameters = Map::new();
533    Ok(RngRecord::new(
534        INITIALIZATION_RNG_NAMESPACE,
535        method.name(),
536        method.version(),
537        method.seed_encoding(),
538        key,
539        Some(parameters),
540    )?)
541}
542
543fn validate_distribution(weights: &[f64], num_taxa: usize) -> Result<(), InitialStateError> {
544    if weights.len() != num_taxa {
545        return Err(InitialStateError::DistributionLength {
546            expected: num_taxa,
547            actual: weights.len(),
548        });
549    }
550    let mut total = 0.0;
551    for (taxon, &weight) in weights.iter().enumerate() {
552        if !weight.is_finite() || weight < 0.0 {
553            return Err(InitialStateError::InvalidWeight { taxon, weight });
554        }
555        total += weight;
556    }
557    if !total.is_finite() || total <= 0.0 {
558        return Err(InitialStateError::NonPositiveDistribution);
559    }
560    Ok(())
561}
562
563fn remove_dominant_taxon(weights: &mut [f64]) -> Result<usize, InitialStateError> {
564    validate_distribution(weights, weights.len())?;
565    let dominant = weights
566        .iter()
567        .enumerate()
568        .max_by(|left, right| left.1.total_cmp(right.1).then(right.0.cmp(&left.0)))
569        .map(|(taxon, _)| taxon)
570        .expect("validated distribution is nonempty");
571    let background_total = weights.iter().sum::<f64>() - weights[dominant];
572    if background_total <= 0.0 {
573        return Err(InitialStateError::NoDominantSeedBackground);
574    }
575    weights[dominant] = 0.0;
576    for weight in weights {
577        *weight /= background_total;
578    }
579    Ok(dominant)
580}
581
582fn count_taxa(space: &CategoricalSpace, num_taxa: usize) -> Result<TaxonCounts, InitialStateError> {
583    let mut counts = vec![0; num_taxa];
584    for (site, &taxon) in space.data().iter().enumerate() {
585        let count = counts
586            .get_mut(taxon)
587            .ok_or(InitialStateError::SpaceTaxonOutOfRange {
588                site,
589                taxon,
590                num_taxa,
591            })?;
592        *count += 1;
593    }
594    Ok(counts)
595}
596
597fn balanced_uniform_values(num_sites: usize, num_taxa: usize) -> Vec<usize> {
598    let per_taxon = num_sites / num_taxa;
599    let remainder = num_sites % num_taxa;
600    let mut values = Vec::with_capacity(num_sites);
601    for taxon in 0..num_taxa {
602        values.extend(std::iter::repeat_n(
603            taxon,
604            per_taxon + usize::from(taxon < remainder),
605        ));
606    }
607    debug_assert_eq!(values.len(), num_sites);
608    values
609}
610
611fn validate_seed(
612    lattice: &SquareLatticeConfig,
613    num_taxa: usize,
614    seed_taxon: usize,
615    seed_radius: usize,
616) -> Result<(), InitialStateError> {
617    if seed_taxon >= num_taxa {
618        return Err(InitialStateError::SeedTaxonOutOfRange {
619            seed_taxon,
620            num_taxa,
621        });
622    }
623    validate_seed_geometry(lattice, seed_radius)
624}
625
626fn validate_seed_geometry(
627    lattice: &SquareLatticeConfig,
628    seed_radius: usize,
629) -> Result<(), InitialStateError> {
630    let width = seed_radius
631        .checked_mul(2)
632        .and_then(|diameter| diameter.checked_add(1))
633        .ok_or(InitialStateError::SeedWidthOverflow {
634            radius: seed_radius,
635        })?;
636    for (axis, &length) in lattice.shape().iter().enumerate() {
637        if width > length {
638            return Err(InitialStateError::SeedDoesNotFit {
639                axis,
640                width,
641                length,
642            });
643        }
644    }
645    Ok(())
646}
647
648fn plant_centered_seed(
649    space: &mut CategoricalSpace,
650    counts: &mut [usize],
651    shape: &[usize],
652    seed_taxon: usize,
653    radius: usize,
654) {
655    let starts = shape
656        .iter()
657        .map(|length| length / 2 - radius)
658        .collect::<Vec<_>>();
659    let widths = vec![radius * 2 + 1; shape.len()];
660    let seed_sites = widths.iter().product::<usize>();
661    let mut coordinate = vec![0isize; shape.len()];
662    for local_flat in 0..seed_sites {
663        let mut local = local_flat;
664        for axis in (0..shape.len()).rev() {
665            coordinate[axis] = (starts[axis] + local % widths[axis]) as isize;
666            local /= widths[axis];
667        }
668        let previous = *space.get(&coordinate);
669        if previous != seed_taxon {
670            counts[previous] -= 1;
671            counts[seed_taxon] += 1;
672            space.set(&coordinate, seed_taxon);
673        }
674    }
675}
676
677#[derive(Debug, Error)]
678#[non_exhaustive]
679pub enum InitialStateError {
680    #[error("num_taxa must be positive")]
681    ZeroTaxa,
682    #[error("distribution length is {actual}, expected {expected}")]
683    DistributionLength { expected: usize, actual: usize },
684    #[error("distribution weight {taxon} is invalid: {weight}")]
685    InvalidWeight { taxon: usize, weight: f64 },
686    #[error("distribution must have positive finite total weight")]
687    NonPositiveDistribution,
688    #[error("dominant-seed initialization requires positive background mass")]
689    NoDominantSeedBackground,
690    #[error("seed taxon {seed_taxon} is outside 0..{num_taxa}")]
691    SeedTaxonOutOfRange { seed_taxon: usize, num_taxa: usize },
692    #[error("seed radius {radius} is too large")]
693    SeedWidthOverflow { radius: usize },
694    #[error("seed width {width} does not fit lattice axis {axis} of length {length}")]
695    SeedDoesNotFit {
696        axis: usize,
697        width: usize,
698        length: usize,
699    },
700    #[error("path must not be empty")]
701    EmptyPath,
702    #[error("initial-state format `{actual}` is unsupported")]
703    UnsupportedFormat { actual: String },
704    #[error("initial state declares {actual} taxa, expected {expected}")]
705    TaxonDimensionMismatch { expected: usize, actual: usize },
706    #[error("initial-state lattice does not match the expected lattice")]
707    LatticeMismatch,
708    #[error("initial-state descriptor does not match its verified document")]
709    DescriptorMismatch,
710    #[error("initial-state site {site} contains taxon {taxon}, outside 0..{num_taxa}")]
711    SpaceTaxonOutOfRange {
712        site: usize,
713        taxon: usize,
714        num_taxa: usize,
715    },
716    #[error("failed to {operation} at `{path}`")]
717    Io {
718        operation: &'static str,
719        path: PathBuf,
720        #[source]
721        source: std::io::Error,
722    },
723    #[error("invalid JSON initial state at `{path}`")]
724    Json {
725        path: PathBuf,
726        #[source]
727        source: serde_json::Error,
728    },
729    #[error(transparent)]
730    Serialize(#[from] serde_json::Error),
731    #[error(transparent)]
732    Lattice(#[from] SquareLatticeConfigError),
733    #[error("initialized lattice has no resolved RNG configuration")]
734    MissingResolvedRngConfig,
735    #[error(transparent)]
736    RngRecord(#[from] RngRecordError),
737    #[error(transparent)]
738    Artifact(#[from] ArtifactError),
739    #[error(transparent)]
740    ArtifactLoad(#[from] ArtifactLoadError),
741}