Skip to main content

ecological_model_core/
interaction.rs

1//! Ecological interaction matrices, reproducible recipes, and verified artifacts.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use physics_in_parallel::math::prelude::{
8    DenseMatrix, MatrixError, RandType, TensorRandError, TensorRandFiller,
9};
10use physics_in_parallel::rng::{RngConfig, RngConfigError};
11use scientific_workflow::artifact::{
12    ArtifactDescriptor, ArtifactDisposition, ArtifactError, ArtifactLoadError,
13    load_verified_artifact, persist_artifact,
14};
15use scientific_workflow::execution::ExecutionScope;
16use scientific_workflow::rng_record::{RngRecord, RngRecordError};
17use serde::{Deserialize, Serialize};
18use serde_json::{Map, Value};
19use thiserror::Error;
20
21pub const INTERACTION_MATRIX_FORMAT: &str = "ecological.interaction-matrix.v1";
22pub const INTERACTION_MATRIX_METADATA_KEY: &str = "interaction_matrix";
23pub const INTERACTION_GENERATOR_RNG_NAMESPACE: &str = "ecological_model_core.interaction_matrix";
24pub const INTERACTION_GENERATOR_IDENTITY: &str = "ecological_model_core.interaction_matrix";
25pub const INTERACTION_GENERATOR_VERSION: &str = "2";
26
27const DOMAIN_CONNECTANCE: u64 = 0x5c1a_9f20_f678_314d;
28const DOMAIN_FIRST_NORMAL: u64 = 0x9841_d60a_334b_c8e7;
29const DOMAIN_SECOND_NORMAL: u64 = 0xa72e_1b49_963c_05fd;
30
31#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
32#[serde(rename_all = "snake_case")]
33pub enum MatrixNormalization {
34    None,
35    SqrtSpecies,
36}
37
38impl MatrixNormalization {
39    fn divisor(self, species: usize) -> f64 {
40        match self {
41            Self::None => 1.0,
42            Self::SqrtSpecies => (species as f64).sqrt(),
43        }
44    }
45}
46
47#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
48#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
49pub enum DiagonalPolicy {
50    Zero,
51    Constant(f64),
52}
53
54impl DiagonalPolicy {
55    fn value(self) -> f64 {
56        match self {
57            Self::Zero => 0.0,
58            Self::Constant(value) => value,
59        }
60    }
61}
62
63#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
64#[serde(rename_all = "snake_case")]
65pub enum SignStructure {
66    Competition,
67    Mutualism,
68    ConsumerResource,
69}
70
71/// Reproducible scientific instructions for an ecological matrix realization.
72#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
73#[serde(tag = "family", rename_all = "snake_case", deny_unknown_fields)]
74pub enum InteractionMatrixRecipe {
75    /// Exact Dispatcher family: zero diagonal and `A[j,i] = -A[i,j]`.
76    AntisymmetricGaussian {
77        scale: f64,
78        #[serde(default = "sqrt_species")]
79        normalization: MatrixNormalization,
80        #[serde(default)]
81        rng: RngConfig,
82    },
83    /// Independent directed Gaussian coefficients with optional sparsity.
84    IndependentGaussian {
85        mean: f64,
86        standard_deviation: f64,
87        #[serde(default = "one")]
88        connectance: f64,
89        #[serde(default = "zero_diagonal")]
90        diagonal: DiagonalPolicy,
91        #[serde(default = "sqrt_species")]
92        normalization: MatrixNormalization,
93        #[serde(default)]
94        rng: RngConfig,
95    },
96    /// Gaussian reciprocal pairs with Pearson correlation in `[-1,1]`.
97    CorrelatedGaussian {
98        mean: f64,
99        standard_deviation: f64,
100        reciprocal_correlation: f64,
101        #[serde(default = "one")]
102        connectance: f64,
103        #[serde(default = "zero_diagonal")]
104        diagonal: DiagonalPolicy,
105        #[serde(default = "sqrt_species")]
106        normalization: MatrixNormalization,
107        #[serde(default)]
108        rng: RngConfig,
109    },
110    /// Pairwise signs constrained to a common ecological relationship class.
111    SignStructuredGaussian {
112        structure: SignStructure,
113        scale: f64,
114        #[serde(default = "one")]
115        connectance: f64,
116        #[serde(default = "zero_diagonal")]
117        diagonal: DiagonalPolicy,
118        #[serde(default = "sqrt_species")]
119        normalization: MatrixNormalization,
120        #[serde(default)]
121        rng: RngConfig,
122    },
123}
124
125const fn sqrt_species() -> MatrixNormalization {
126    MatrixNormalization::SqrtSpecies
127}
128const fn zero_diagonal() -> DiagonalPolicy {
129    DiagonalPolicy::Zero
130}
131const fn one() -> f64 {
132    1.0
133}
134
135impl InteractionMatrixRecipe {
136    pub fn validate(&self, species: usize) -> Result<(), InteractionRecipeError> {
137        if species == 0 {
138            return Err(InteractionRecipeError::EmptySpecies);
139        }
140        match self {
141            Self::AntisymmetricGaussian { scale, .. } => {
142                require_nonnegative_finite("scale", *scale)?;
143            }
144            Self::IndependentGaussian {
145                mean,
146                standard_deviation,
147                connectance,
148                diagonal,
149                ..
150            }
151            | Self::CorrelatedGaussian {
152                mean,
153                standard_deviation,
154                connectance,
155                diagonal,
156                ..
157            } => {
158                require_finite("mean", *mean)?;
159                require_nonnegative_finite("standard_deviation", *standard_deviation)?;
160                require_probability(*connectance)?;
161                require_finite("diagonal", diagonal.value())?;
162            }
163            Self::SignStructuredGaussian {
164                scale,
165                connectance,
166                diagonal,
167                ..
168            } => {
169                require_nonnegative_finite("scale", *scale)?;
170                require_probability(*connectance)?;
171                require_finite("diagonal", diagonal.value())?;
172            }
173        }
174        if let Self::CorrelatedGaussian {
175            reciprocal_correlation,
176            ..
177        } = self
178            && (!reciprocal_correlation.is_finite()
179                || !(-1.0..=1.0).contains(reciprocal_correlation))
180        {
181            return Err(InteractionRecipeError::InvalidParameter {
182                name: "reciprocal_correlation",
183                value: *reciprocal_correlation,
184            });
185        }
186        Ok(())
187    }
188
189    pub fn generate(&self, species: usize) -> Result<InteractionMatrix, InteractionRecipeError> {
190        self.validate(species)?;
191        let mut filler = TensorRandFiller::try_new_indexed(
192            RandType::Normal {
193                mean: 0.0,
194                std: 1.0,
195            },
196            self.rng(),
197        )?;
198        let resolved_recipe = self.with_rng(filler.rng_config());
199        let matrix_len = species * species;
200        let mut first_normal = vec![0.0; matrix_len];
201        let mut second_normal = vec![0.0; matrix_len];
202        let mut connection_uniform = vec![0.0; matrix_len];
203        let first_domain = if matches!(self, Self::AntisymmetricGaussian { .. }) {
204            species as u64
205        } else {
206            DOMAIN_FIRST_NORMAL ^ species as u64
207        };
208        filler.try_fill_slice_at_layout(&mut first_normal, species, 0, first_domain)?;
209        filler.try_fill_slice_at_layout(
210            &mut second_normal,
211            species,
212            0,
213            DOMAIN_SECOND_NORMAL ^ species as u64,
214        )?;
215        filler.set_kind(RandType::Uniform {
216            low: 0.0,
217            high: 1.0,
218        });
219        filler.try_fill_slice_at_layout(
220            &mut connection_uniform,
221            species,
222            0,
223            DOMAIN_CONNECTANCE ^ species as u64,
224        )?;
225        let mut values = vec![0.0; species * species];
226        match &resolved_recipe {
227            Self::AntisymmetricGaussian {
228                scale,
229                normalization,
230                ..
231            } => {
232                let divisor = normalization.divisor(species);
233                for row in 0..species {
234                    for column in (row + 1)..species {
235                        let value = first_normal[row * species + column] * scale / divisor;
236                        values[row * species + column] = value;
237                        values[column * species + row] = -value;
238                    }
239                }
240            }
241            Self::IndependentGaussian {
242                mean,
243                standard_deviation,
244                connectance,
245                diagonal,
246                normalization,
247                ..
248            } => {
249                let divisor = normalization.divisor(species);
250                for row in 0..species {
251                    for column in 0..species {
252                        values[row * species + column] = if row == column {
253                            diagonal.value()
254                        } else if *connectance >= 1.0
255                            || connection_uniform[row * species + column] < *connectance
256                        {
257                            (mean + standard_deviation * first_normal[row * species + column])
258                                / divisor
259                        } else {
260                            0.0
261                        };
262                    }
263                }
264            }
265            Self::CorrelatedGaussian {
266                mean,
267                standard_deviation,
268                reciprocal_correlation,
269                connectance,
270                diagonal,
271                normalization,
272                ..
273            } => {
274                fill_diagonal(&mut values, species, diagonal.value());
275                let divisor = normalization.divisor(species);
276                let independent_weight = (1.0 - reciprocal_correlation.powi(2)).sqrt();
277                for row in 0..species {
278                    for column in (row + 1)..species {
279                        let index = row * species + column;
280                        if *connectance < 1.0 && connection_uniform[index] >= *connectance {
281                            continue;
282                        }
283                        let first = first_normal[index];
284                        let second = second_normal[index];
285                        values[row * species + column] =
286                            (mean + standard_deviation * first) / divisor;
287                        values[column * species + row] = (mean
288                            + standard_deviation
289                                * (reciprocal_correlation * first + independent_weight * second))
290                            / divisor;
291                    }
292                }
293            }
294            Self::SignStructuredGaussian {
295                structure,
296                scale,
297                connectance,
298                diagonal,
299                normalization,
300                ..
301            } => {
302                fill_diagonal(&mut values, species, diagonal.value());
303                let magnitude = scale / normalization.divisor(species);
304                for row in 0..species {
305                    for column in (row + 1)..species {
306                        let index = row * species + column;
307                        if *connectance < 1.0 && connection_uniform[index] >= *connectance {
308                            continue;
309                        }
310                        let first = first_normal[index].abs() * magnitude;
311                        let second = second_normal[index].abs() * magnitude;
312                        let (forward, reverse) = match structure {
313                            SignStructure::Competition => (-first, -second),
314                            SignStructure::Mutualism => (first, second),
315                            SignStructure::ConsumerResource => (first, -second),
316                        };
317                        values[row * species + column] = forward;
318                        values[column * species + row] = reverse;
319                    }
320                }
321            }
322        }
323        let generator = GeneratorProvenance::new(
324            INTERACTION_GENERATOR_IDENTITY,
325            INTERACTION_GENERATOR_VERSION,
326            serde_json::to_value(&resolved_recipe)?,
327            Some(filler.rng_config()),
328        )?;
329        Ok(InteractionMatrix::from_generated(
330            DenseMatrix::try_from_vec(species, species, values)?,
331            species,
332            generator,
333        )?)
334    }
335
336    pub const fn rng(&self) -> RngConfig {
337        match self {
338            Self::AntisymmetricGaussian { rng, .. }
339            | Self::IndependentGaussian { rng, .. }
340            | Self::CorrelatedGaussian { rng, .. }
341            | Self::SignStructuredGaussian { rng, .. } => *rng,
342        }
343    }
344
345    fn with_rng(&self, resolved: RngConfig) -> Self {
346        let mut recipe = self.clone();
347        match &mut recipe {
348            Self::AntisymmetricGaussian { rng, .. }
349            | Self::IndependentGaussian { rng, .. }
350            | Self::CorrelatedGaussian { rng, .. }
351            | Self::SignStructuredGaussian { rng, .. } => *rng = resolved,
352        }
353        recipe
354    }
355}
356
357fn fill_diagonal(values: &mut [f64], species: usize, diagonal: f64) {
358    for index in 0..species {
359        values[index * species + index] = diagonal;
360    }
361}
362
363fn require_finite(name: &'static str, value: f64) -> Result<(), InteractionRecipeError> {
364    if value.is_finite() {
365        Ok(())
366    } else {
367        Err(InteractionRecipeError::InvalidParameter { name, value })
368    }
369}
370
371fn require_nonnegative_finite(
372    name: &'static str,
373    value: f64,
374) -> Result<(), InteractionRecipeError> {
375    if value.is_finite() && value >= 0.0 {
376        Ok(())
377    } else {
378        Err(InteractionRecipeError::InvalidParameter { name, value })
379    }
380}
381
382fn require_probability(value: f64) -> Result<(), InteractionRecipeError> {
383    if value.is_finite() && (0.0..=1.0).contains(&value) {
384        Ok(())
385    } else {
386        Err(InteractionRecipeError::InvalidParameter {
387            name: "connectance",
388            value,
389        })
390    }
391}
392
393#[derive(Clone, Debug)]
394pub struct InteractionMatrix {
395    values: Arc<DenseMatrix<f64>>,
396    provenance: InteractionProvenance,
397}
398
399impl InteractionMatrix {
400    pub fn from_matrix(
401        values: DenseMatrix<f64>,
402        species: usize,
403    ) -> Result<Self, InteractionMatrixError> {
404        Self::resolve(
405            Arc::new(values),
406            species,
407            InteractionProvenance::InMemory { label: None },
408        )
409    }
410
411    pub fn from_shared(
412        values: Arc<DenseMatrix<f64>>,
413        species: usize,
414    ) -> Result<Self, InteractionMatrixError> {
415        Self::resolve(
416            values,
417            species,
418            InteractionProvenance::InMemory { label: None },
419        )
420    }
421
422    pub fn from_labeled_matrix(
423        values: DenseMatrix<f64>,
424        species: usize,
425        label: impl Into<String>,
426    ) -> Result<Self, InteractionMatrixError> {
427        let label = label.into();
428        if label.trim().is_empty() {
429            return Err(InteractionMatrixError::EmptyLabel);
430        }
431        Self::resolve(
432            Arc::new(values),
433            species,
434            InteractionProvenance::InMemory { label: Some(label) },
435        )
436    }
437
438    pub fn from_rows(rows: Vec<Vec<f64>>, species: usize) -> Result<Self, InteractionMatrixError> {
439        let row_count = rows.len();
440        let column_count = rows.first().map_or(0, Vec::len);
441        for (row, values) in rows.iter().enumerate() {
442            if values.len() != column_count {
443                return Err(InteractionMatrixError::RaggedRows {
444                    row,
445                    expected: column_count,
446                    actual: values.len(),
447                });
448            }
449        }
450        let values = DenseMatrix::try_from_vec(
451            row_count,
452            column_count,
453            rows.into_iter().flatten().collect(),
454        )?;
455        Self::resolve(Arc::new(values), species, InteractionProvenance::Inline)
456    }
457
458    pub fn load_json(
459        path: impl Into<PathBuf>,
460        species: usize,
461    ) -> Result<Self, InteractionMatrixError> {
462        let path = path.into();
463        let bytes = fs::read(&path).map_err(|source| InteractionMatrixError::Io {
464            path: path.clone(),
465            source,
466        })?;
467        Self::from_json_bytes(bytes, path, species, None)
468    }
469
470    pub fn from_generated(
471        values: DenseMatrix<f64>,
472        species: usize,
473        generator: GeneratorProvenance,
474    ) -> Result<Self, InteractionMatrixError> {
475        Self::resolve(
476            Arc::new(values),
477            species,
478            InteractionProvenance::Generated { generator },
479        )
480    }
481
482    pub fn generate(
483        species: usize,
484        recipe: &InteractionMatrixRecipe,
485    ) -> Result<Self, InteractionRecipeError> {
486        recipe.generate(species)
487    }
488
489    pub fn species(&self) -> usize {
490        self.values.rows()
491    }
492    pub fn values(&self) -> &DenseMatrix<f64> {
493        &self.values
494    }
495    pub fn shared_values(&self) -> Arc<DenseMatrix<f64>> {
496        Arc::clone(&self.values)
497    }
498    #[inline]
499    pub fn coefficient(&self, row: usize, column: usize) -> f64 {
500        self.values.get(row as isize, column as isize)
501    }
502    #[inline]
503    pub fn mul_vector_into(&self, input: &[f64], output: &mut [f64]) -> Result<(), MatrixError> {
504        self.values.mul_vector_into(input, output)
505    }
506    pub const fn provenance(&self) -> &InteractionProvenance {
507        &self.provenance
508    }
509    pub fn generator_rng_record(&self) -> Result<Option<RngRecord>, RngRecordError> {
510        self.provenance.generator_rng_record()
511    }
512
513    fn from_json_bytes(
514        bytes: Vec<u8>,
515        path: PathBuf,
516        species: usize,
517        generator: Option<GeneratorProvenance>,
518    ) -> Result<Self, InteractionMatrixError> {
519        let values =
520            serde_json::from_slice(&bytes).map_err(|source| InteractionMatrixError::Json {
521                path: path.clone(),
522                source,
523            })?;
524        let provenance = generator.map_or(InteractionProvenance::JsonFile { path }, |generator| {
525            InteractionProvenance::Generated { generator }
526        });
527        Self::resolve(Arc::new(values), species, provenance)
528    }
529
530    fn resolve(
531        values: Arc<DenseMatrix<f64>>,
532        species: usize,
533        provenance: InteractionProvenance,
534    ) -> Result<Self, InteractionMatrixError> {
535        if species == 0 {
536            return Err(InteractionMatrixError::EmptySpecies);
537        }
538        let rows = values.rows();
539        let columns = values.cols();
540        if rows != columns {
541            return Err(InteractionMatrixError::NonSquare { rows, columns });
542        }
543        if rows != species {
544            return Err(InteractionMatrixError::SpeciesMismatch {
545                expected: species,
546                actual: rows,
547            });
548        }
549        for row in 0..rows {
550            for column in 0..columns {
551                let value = values.get(row as isize, column as isize);
552                if !value.is_finite() {
553                    return Err(InteractionMatrixError::NonFiniteEntry { row, column, value });
554                }
555            }
556        }
557        Ok(Self { values, provenance })
558    }
559}
560
561#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
562#[serde(rename_all = "snake_case")]
563pub enum InteractionSourceKind {
564    InMemory,
565    Inline,
566    JsonFile,
567    Generated,
568}
569
570#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
571#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
572pub enum InteractionProvenance {
573    InMemory { label: Option<String> },
574    Inline,
575    JsonFile { path: PathBuf },
576    Generated { generator: GeneratorProvenance },
577}
578
579impl InteractionProvenance {
580    pub const fn kind(&self) -> InteractionSourceKind {
581        match self {
582            Self::InMemory { .. } => InteractionSourceKind::InMemory,
583            Self::Inline => InteractionSourceKind::Inline,
584            Self::JsonFile { .. } => InteractionSourceKind::JsonFile,
585            Self::Generated { .. } => InteractionSourceKind::Generated,
586        }
587    }
588    pub const fn generator(&self) -> Option<&GeneratorProvenance> {
589        match self {
590            Self::Generated { generator } => Some(generator),
591            _ => None,
592        }
593    }
594    pub fn generator_rng_record(&self) -> Result<Option<RngRecord>, RngRecordError> {
595        self.generator()
596            .map_or(Ok(None), GeneratorProvenance::rng_record)
597    }
598}
599
600#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
601#[serde(deny_unknown_fields)]
602pub struct GeneratorProvenance {
603    identity: String,
604    version: String,
605    recipe: Value,
606    rng: Option<RngConfig>,
607}
608
609impl GeneratorProvenance {
610    pub fn new(
611        identity: impl Into<String>,
612        version: impl Into<String>,
613        recipe: Value,
614        rng: Option<RngConfig>,
615    ) -> Result<Self, InteractionMatrixError> {
616        let identity = identity.into();
617        let version = version.into();
618        if identity.trim().is_empty() {
619            return Err(InteractionMatrixError::InvalidGeneratorLabel { field: "identity" });
620        }
621        if version.trim().is_empty() {
622            return Err(InteractionMatrixError::InvalidGeneratorLabel { field: "version" });
623        }
624        if rng.is_some_and(|value| value.seed().is_none() || value.method().is_none()) {
625            return Err(InteractionMatrixError::UnresolvedGeneratorRng { identity });
626        }
627        Ok(Self {
628            identity,
629            version,
630            recipe,
631            rng,
632        })
633    }
634    pub fn identity(&self) -> &str {
635        &self.identity
636    }
637    pub fn version(&self) -> &str {
638        &self.version
639    }
640    pub const fn recipe(&self) -> &Value {
641        &self.recipe
642    }
643    pub const fn rng(&self) -> Option<RngConfig> {
644        self.rng
645    }
646    pub fn rng_record(&self) -> Result<Option<RngRecord>, RngRecordError> {
647        let Some(rng) = self.rng else {
648            return Ok(None);
649        };
650        let method = rng.method().expect("generator RNG is resolved");
651        let mut parameters = Map::new();
652        parameters.insert("recipe".to_owned(), self.recipe.clone());
653        if let Some(streams) = rng.parallel_streams() {
654            parameters.insert("parallel_streams".to_owned(), Value::from(streams.get()));
655        }
656        Ok(Some(RngRecord::new(
657            INTERACTION_GENERATOR_RNG_NAMESPACE,
658            format!("{}+{}", self.identity, method.name()),
659            format!("{}+{}", self.version, method.version()),
660            method.seed_encoding(),
661            rng.encode_seed().expect("generator RNG seed is resolved"),
662            Some(parameters),
663        )?))
664    }
665}
666
667#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
668#[serde(deny_unknown_fields)]
669pub struct InteractionArtifactDescriptor {
670    format: String,
671    species: usize,
672    #[serde(flatten)]
673    artifact: ArtifactDescriptor,
674    source_kind: InteractionSourceKind,
675    #[serde(skip_serializing_if = "Option::is_none")]
676    generator: Option<GeneratorProvenance>,
677}
678
679impl InteractionArtifactDescriptor {
680    pub fn format(&self) -> &str {
681        &self.format
682    }
683    pub const fn species(&self) -> usize {
684        self.species
685    }
686    pub const fn shape(&self) -> [usize; 2] {
687        [self.species, self.species]
688    }
689    pub fn sha256(&self) -> &str {
690        self.artifact.sha256()
691    }
692    pub fn path(&self) -> &str {
693        self.artifact.path()
694    }
695    pub const fn source_kind(&self) -> InteractionSourceKind {
696        self.source_kind
697    }
698    pub const fn generator(&self) -> Option<&GeneratorProvenance> {
699        self.generator.as_ref()
700    }
701    pub fn insert_into_metadata(&self, metadata: &mut Map<String, Value>) -> Option<Value> {
702        metadata.insert(
703            INTERACTION_MATRIX_METADATA_KEY.to_owned(),
704            serde_json::to_value(self).expect("interaction descriptor is JSON-compatible"),
705        )
706    }
707}
708
709#[derive(Clone, Debug, PartialEq)]
710pub struct PersistedInteraction {
711    descriptor: InteractionArtifactDescriptor,
712    disposition: ArtifactDisposition,
713}
714
715impl PersistedInteraction {
716    pub const fn descriptor(&self) -> &InteractionArtifactDescriptor {
717        &self.descriptor
718    }
719    pub const fn disposition(&self) -> ArtifactDisposition {
720        self.disposition
721    }
722    pub fn into_descriptor(self) -> InteractionArtifactDescriptor {
723        self.descriptor
724    }
725}
726
727pub fn persist_interaction_matrix(
728    scope: &ExecutionScope,
729    matrix: &InteractionMatrix,
730) -> Result<PersistedInteraction, InteractionArtifactError> {
731    let bytes = serde_json::to_vec(matrix.values())?;
732    let persisted = persist_artifact(scope, "interaction", "json", &bytes)?;
733    Ok(PersistedInteraction {
734        descriptor: InteractionArtifactDescriptor {
735            format: INTERACTION_MATRIX_FORMAT.to_owned(),
736            species: matrix.species(),
737            artifact: persisted.descriptor().clone(),
738            source_kind: matrix.provenance().kind(),
739            generator: matrix.provenance().generator().cloned(),
740        },
741        disposition: persisted.disposition(),
742    })
743}
744
745pub fn load_verified_interaction_matrix(
746    execution_directory: impl AsRef<Path>,
747    descriptor: &InteractionArtifactDescriptor,
748) -> Result<InteractionMatrix, InteractionArtifactLoadError> {
749    if descriptor.format != INTERACTION_MATRIX_FORMAT || descriptor.species == 0 {
750        return Err(InteractionArtifactLoadError::InvalidDescriptor);
751    }
752    let verified = load_verified_artifact(execution_directory, &descriptor.artifact)?;
753    let path = verified.path().to_path_buf();
754    Ok(InteractionMatrix::from_json_bytes(
755        verified.into_bytes(),
756        path,
757        descriptor.species,
758        descriptor.generator.clone(),
759    )?)
760}
761
762#[derive(Debug, Error)]
763#[non_exhaustive]
764pub enum InteractionRecipeError {
765    #[error("interaction recipe requires at least one species")]
766    EmptySpecies,
767    #[error("interaction recipe parameter {name} is invalid: {value}")]
768    InvalidParameter { name: &'static str, value: f64 },
769    #[error(transparent)]
770    Rng(#[from] RngConfigError),
771    #[error(transparent)]
772    TensorRand(#[from] TensorRandError),
773    #[error(transparent)]
774    Matrix(#[from] MatrixError),
775    #[error(transparent)]
776    Interaction(#[from] InteractionMatrixError),
777    #[error(transparent)]
778    Json(#[from] serde_json::Error),
779}
780
781#[derive(Debug, Error)]
782#[non_exhaustive]
783pub enum InteractionMatrixError {
784    #[error(transparent)]
785    Matrix(#[from] MatrixError),
786    #[error("interaction matrix species dimension must be positive")]
787    EmptySpecies,
788    #[error("interaction matrix must be square, found {rows}x{columns}")]
789    NonSquare { rows: usize, columns: usize },
790    #[error("interaction matrix has {actual} species, expected {expected}")]
791    SpeciesMismatch { expected: usize, actual: usize },
792    #[error("interaction matrix row {row} has {actual} columns, expected {expected}")]
793    RaggedRows {
794        row: usize,
795        expected: usize,
796        actual: usize,
797    },
798    #[error("interaction matrix entry ({row}, {column}) is not finite: {value}")]
799    NonFiniteEntry {
800        row: usize,
801        column: usize,
802        value: f64,
803    },
804    #[error("interaction matrix label must not be empty")]
805    EmptyLabel,
806    #[error("failed to read interaction matrix at `{path}`")]
807    Io {
808        path: PathBuf,
809        #[source]
810        source: std::io::Error,
811    },
812    #[error("invalid interaction matrix JSON at `{path}`")]
813    Json {
814        path: PathBuf,
815        #[source]
816        source: serde_json::Error,
817    },
818    #[error("interaction generator {field} must not be empty")]
819    InvalidGeneratorLabel { field: &'static str },
820    #[error("interaction generator `{identity}` has unresolved RNG")]
821    UnresolvedGeneratorRng { identity: String },
822}
823
824#[derive(Debug, Error)]
825#[non_exhaustive]
826pub enum InteractionArtifactError {
827    #[error(transparent)]
828    Serialize(#[from] serde_json::Error),
829    #[error(transparent)]
830    Workflow(#[from] ArtifactError),
831}
832
833#[derive(Debug, Error)]
834#[non_exhaustive]
835pub enum InteractionArtifactLoadError {
836    #[error("invalid interaction artifact descriptor")]
837    InvalidDescriptor,
838    #[error(transparent)]
839    Workflow(#[from] ArtifactLoadError),
840    #[error(transparent)]
841    Matrix(#[from] InteractionMatrixError),
842}