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::prelude::basic::{
8    DenseMatrix, MatrixError, RandType, RngConfig, RngConfigError, TensorRandError,
9    TensorRandFiller,
10};
11use scientific_workflow::prelude::basics::{
12    ArtifactDescriptor, ArtifactDisposition, ArtifactError, ArtifactLoadError, ExecutionScope,
13    RngRecord, RngRecordError, load_verified_artifact, persist_artifact,
14};
15use serde::{Deserialize, Serialize};
16use serde_json::{Map, Value};
17use thiserror::Error;
18
19pub const INTERACTION_MATRIX_FORMAT: &str = "ecological.interaction-matrix.v2";
20pub const INTERACTION_MATRIX_METADATA_KEY: &str = "interaction_matrix";
21pub const INTERACTION_GENERATOR_RNG_NAMESPACE: &str = "ecological_model_core.interaction_matrix";
22pub const INTERACTION_GENERATOR_IDENTITY: &str = "ecological_model_core.interaction_matrix";
23pub const INTERACTION_GENERATOR_VERSION: &str = "3";
24
25const DOMAIN_INDEPENDENT: u64 = 0x73d8_ba6e_209f_54c1;
26const DOMAIN_CONNECTANCE: u64 = 0x5c1a_9f20_f678_314d;
27const DOMAIN_FIRST_NORMAL: u64 = 0x9841_d60a_334b_c8e7;
28const DOMAIN_SECOND_NORMAL: u64 = 0xa72e_1b49_963c_05fd;
29
30#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum MatrixNormalization {
33    None,
34    SqrtSpecies,
35}
36
37impl MatrixNormalization {
38    fn divisor(self, species: usize) -> f64 {
39        match self {
40            Self::None => 1.0,
41            Self::SqrtSpecies => (species as f64).sqrt(),
42        }
43    }
44}
45
46#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
47#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
48pub enum DiagonalPolicy {
49    Zero,
50    Constant(f64),
51    /// Sample diagonal entries from the Gaussian distribution of the recipe.
52    Sampled,
53}
54
55impl DiagonalPolicy {
56    fn fixed_value(self) -> Option<f64> {
57        match self {
58            Self::Zero => Some(0.0),
59            Self::Constant(value) => Some(value),
60            Self::Sampled => None,
61        }
62    }
63}
64
65#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
66#[serde(rename_all = "snake_case")]
67pub enum SignStructure {
68    Competition,
69    Mutualism,
70    ConsumerResource,
71}
72
73/// Reproducible scientific instructions for an ecological matrix realization.
74#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
75#[serde(tag = "family", rename_all = "snake_case", deny_unknown_fields)]
76pub enum InteractionMatrixRecipe {
77    /// Independently sample every matrix entry from a uniform distribution.
78    RandomUniform {
79        minimum: f64,
80        maximum: f64,
81        #[serde(default)]
82        rng: RngConfig,
83    },
84    /// Independently sample every matrix entry from a Gaussian distribution.
85    RandomGaussian {
86        mean: f64,
87        standard_deviation: f64,
88        #[serde(default)]
89        rng: RngConfig,
90    },
91    /// Gaussian reciprocal pairs with Pearson correlation in `[-1,1]`.
92    CorrelatedGaussian {
93        mean: f64,
94        standard_deviation: f64,
95        reciprocal_correlation: f64,
96        #[serde(default = "one")]
97        connectance: f64,
98        #[serde(default = "zero_diagonal")]
99        diagonal: DiagonalPolicy,
100        #[serde(default = "sqrt_species")]
101        normalization: MatrixNormalization,
102        #[serde(default)]
103        rng: RngConfig,
104    },
105    /// Pairwise signs constrained to a common ecological relationship class.
106    SignStructuredGaussian {
107        structure: SignStructure,
108        scale: f64,
109        #[serde(default = "one")]
110        connectance: f64,
111        #[serde(default = "zero_diagonal")]
112        diagonal: DiagonalPolicy,
113        #[serde(default = "sqrt_species")]
114        normalization: MatrixNormalization,
115        #[serde(default)]
116        rng: RngConfig,
117    },
118}
119
120const fn sqrt_species() -> MatrixNormalization {
121    MatrixNormalization::SqrtSpecies
122}
123const fn zero_diagonal() -> DiagonalPolicy {
124    DiagonalPolicy::Zero
125}
126const fn one() -> f64 {
127    1.0
128}
129
130impl InteractionMatrixRecipe {
131    pub fn validate(&self, species: usize) -> Result<(), InteractionRecipeError> {
132        if species == 0 {
133            return Err(InteractionRecipeError::EmptySpecies);
134        }
135        match self {
136            Self::RandomUniform {
137                minimum, maximum, ..
138            } => {
139                require_finite("minimum", *minimum)?;
140                require_finite("maximum", *maximum)?;
141                if minimum >= maximum {
142                    return Err(InteractionRecipeError::InvalidRange {
143                        minimum: *minimum,
144                        maximum: *maximum,
145                    });
146                }
147            }
148            Self::RandomGaussian {
149                mean,
150                standard_deviation,
151                ..
152            } => {
153                require_finite("mean", *mean)?;
154                require_nonnegative_finite("standard_deviation", *standard_deviation)?;
155            }
156            Self::CorrelatedGaussian {
157                mean,
158                standard_deviation,
159                connectance,
160                diagonal,
161                ..
162            } => {
163                require_finite("mean", *mean)?;
164                require_nonnegative_finite("standard_deviation", *standard_deviation)?;
165                require_probability(*connectance)?;
166                if let Some(value) = diagonal.fixed_value() {
167                    require_finite("diagonal", value)?;
168                }
169            }
170            Self::SignStructuredGaussian {
171                scale,
172                connectance,
173                diagonal,
174                ..
175            } => {
176                require_nonnegative_finite("scale", *scale)?;
177                require_probability(*connectance)?;
178                let value = diagonal.fixed_value().ok_or(
179                    InteractionRecipeError::SampledDiagonalUnsupported {
180                        family: "sign_structured_gaussian",
181                    },
182                )?;
183                require_finite("diagonal", value)?;
184            }
185        }
186        if let Self::CorrelatedGaussian {
187            reciprocal_correlation,
188            ..
189        } = self
190            && (!reciprocal_correlation.is_finite()
191                || !(-1.0..=1.0).contains(reciprocal_correlation))
192        {
193            return Err(InteractionRecipeError::InvalidParameter {
194                name: "reciprocal_correlation",
195                value: *reciprocal_correlation,
196            });
197        }
198        Ok(())
199    }
200
201    pub fn generate(&self, species: usize) -> Result<InteractionMatrix, InteractionRecipeError> {
202        self.validate(species)?;
203        let kind = match self {
204            Self::RandomUniform {
205                minimum, maximum, ..
206            } => RandType::Uniform {
207                low: *minimum,
208                high: *maximum,
209            },
210            Self::RandomGaussian {
211                mean,
212                standard_deviation,
213                ..
214            } => RandType::Normal {
215                mean: *mean,
216                std: *standard_deviation,
217            },
218            Self::CorrelatedGaussian { .. } | Self::SignStructuredGaussian { .. } => {
219                RandType::Normal {
220                    mean: 0.0,
221                    std: 1.0,
222                }
223            }
224        };
225        let mut filler = TensorRandFiller::try_new_indexed(kind, self.rng())?;
226        let resolved_recipe = self.with_rng(filler.rng_config());
227        let matrix_len = species
228            .checked_mul(species)
229            .ok_or(MatrixError::ShapeProductOverflow {
230                rows: species,
231                cols: species,
232            })?;
233        let mut values = vec![0.0; matrix_len];
234        match &resolved_recipe {
235            Self::RandomUniform { .. } | Self::RandomGaussian { .. } => {
236                filler.try_fill_slice_at_layout(
237                    &mut values,
238                    species,
239                    0,
240                    DOMAIN_INDEPENDENT ^ species as u64,
241                )?;
242            }
243            Self::CorrelatedGaussian {
244                mean,
245                standard_deviation,
246                reciprocal_correlation,
247                connectance,
248                diagonal,
249                normalization,
250                ..
251            } => {
252                let samples =
253                    sample_structured_inputs(&mut filler, species, matrix_len, &mut values)?;
254                let first_normal = samples.first_normal;
255                let second_normal = samples.second_normal;
256                let divisor = normalization.divisor(species);
257                for index in 0..species {
258                    values[index * species + index] = diagonal.fixed_value().unwrap_or_else(|| {
259                        (mean + standard_deviation * first_normal[index * species + index])
260                            / divisor
261                    });
262                }
263                let independent_weight = (1.0 - reciprocal_correlation.powi(2)).sqrt();
264                for row in 0..species {
265                    for column in (row + 1)..species {
266                        let index = row * species + column;
267                        if *connectance < 1.0 && values[index] >= *connectance {
268                            continue;
269                        }
270                        let first = first_normal[index];
271                        let second = second_normal[index];
272                        values[row * species + column] =
273                            (mean + standard_deviation * first) / divisor;
274                        values[column * species + row] = (mean
275                            + standard_deviation
276                                * (reciprocal_correlation * first + independent_weight * second))
277                            / divisor;
278                    }
279                }
280            }
281            Self::SignStructuredGaussian {
282                structure,
283                scale,
284                connectance,
285                diagonal,
286                normalization,
287                ..
288            } => {
289                let samples =
290                    sample_structured_inputs(&mut filler, species, matrix_len, &mut values)?;
291                let first_normal = samples.first_normal;
292                let second_normal = samples.second_normal;
293                fill_diagonal(
294                    &mut values,
295                    species,
296                    diagonal
297                        .fixed_value()
298                        .expect("sampled diagonal rejected during validation"),
299                );
300                let magnitude = scale / normalization.divisor(species);
301                for row in 0..species {
302                    for column in (row + 1)..species {
303                        let index = row * species + column;
304                        if *connectance < 1.0 && values[index] >= *connectance {
305                            continue;
306                        }
307                        let first = first_normal[index].abs() * magnitude;
308                        let second = second_normal[index].abs() * magnitude;
309                        let (forward, reverse) = match structure {
310                            SignStructure::Competition => (-first, -second),
311                            SignStructure::Mutualism => (first, second),
312                            SignStructure::ConsumerResource => (first, -second),
313                        };
314                        values[row * species + column] = forward;
315                        values[column * species + row] = reverse;
316                    }
317                }
318            }
319        }
320        let generator = GeneratorProvenance::new(
321            INTERACTION_GENERATOR_IDENTITY,
322            INTERACTION_GENERATOR_VERSION,
323            serde_json::to_value(&resolved_recipe)?,
324            Some(filler.rng_config()),
325        )?;
326        Ok(InteractionMatrix::from_generated(
327            DenseMatrix::try_from_vec(species, species, values)?,
328            generator,
329        )?)
330    }
331
332    pub const fn rng(&self) -> RngConfig {
333        match self {
334            Self::RandomUniform { rng, .. }
335            | Self::RandomGaussian { rng, .. }
336            | Self::CorrelatedGaussian { rng, .. }
337            | Self::SignStructuredGaussian { rng, .. } => *rng,
338        }
339    }
340
341    fn with_rng(&self, resolved: RngConfig) -> Self {
342        let mut recipe = self.clone();
343        match &mut recipe {
344            Self::RandomUniform { rng, .. }
345            | Self::RandomGaussian { rng, .. }
346            | Self::CorrelatedGaussian { rng, .. }
347            | Self::SignStructuredGaussian { rng, .. } => *rng = resolved,
348        }
349        recipe
350    }
351}
352
353struct StructuredSamples {
354    first_normal: Vec<f64>,
355    second_normal: Vec<f64>,
356}
357
358fn sample_structured_inputs(
359    filler: &mut TensorRandFiller,
360    species: usize,
361    matrix_len: usize,
362    output: &mut [f64],
363) -> Result<StructuredSamples, TensorRandError> {
364    let mut first_normal = vec![0.0; matrix_len];
365    let mut second_normal = vec![0.0; matrix_len];
366    filler.try_fill_slice_at_layout(
367        &mut first_normal,
368        species,
369        0,
370        DOMAIN_FIRST_NORMAL ^ species as u64,
371    )?;
372    filler.try_fill_slice_at_layout(
373        &mut second_normal,
374        species,
375        0,
376        DOMAIN_SECOND_NORMAL ^ species as u64,
377    )?;
378    filler.set_kind(RandType::Uniform {
379        low: 0.0,
380        high: 1.0,
381    });
382    filler.try_fill_slice_at_layout(output, species, 0, DOMAIN_CONNECTANCE ^ species as u64)?;
383    Ok(StructuredSamples {
384        first_normal,
385        second_normal,
386    })
387}
388
389fn fill_diagonal(values: &mut [f64], species: usize, diagonal: f64) {
390    for index in 0..species {
391        values[index * species + index] = diagonal;
392    }
393}
394
395fn require_finite(name: &'static str, value: f64) -> Result<(), InteractionRecipeError> {
396    if value.is_finite() {
397        Ok(())
398    } else {
399        Err(InteractionRecipeError::InvalidParameter { name, value })
400    }
401}
402
403fn require_nonnegative_finite(
404    name: &'static str,
405    value: f64,
406) -> Result<(), InteractionRecipeError> {
407    if value.is_finite() && value >= 0.0 {
408        Ok(())
409    } else {
410        Err(InteractionRecipeError::InvalidParameter { name, value })
411    }
412}
413
414fn require_probability(value: f64) -> Result<(), InteractionRecipeError> {
415    if value.is_finite() && (0.0..=1.0).contains(&value) {
416        Ok(())
417    } else {
418        Err(InteractionRecipeError::InvalidParameter {
419            name: "connectance",
420            value,
421        })
422    }
423}
424
425#[derive(Clone, Debug)]
426pub struct InteractionMatrix {
427    values: Arc<DenseMatrix<f64>>,
428    provenance: InteractionProvenance,
429}
430
431impl InteractionMatrix {
432    pub fn from_matrix(values: DenseMatrix<f64>) -> Result<Self, InteractionMatrixError> {
433        Self::resolve(
434            Arc::new(values),
435            InteractionProvenance::InMemory { label: None },
436        )
437    }
438
439    pub fn from_shared(values: Arc<DenseMatrix<f64>>) -> Result<Self, InteractionMatrixError> {
440        Self::resolve(values, InteractionProvenance::InMemory { label: None })
441    }
442
443    pub fn from_labeled_matrix(
444        values: DenseMatrix<f64>,
445        label: impl Into<String>,
446    ) -> Result<Self, InteractionMatrixError> {
447        let label = label.into();
448        if label.trim().is_empty() {
449            return Err(InteractionMatrixError::EmptyLabel);
450        }
451        Self::resolve(
452            Arc::new(values),
453            InteractionProvenance::InMemory { label: Some(label) },
454        )
455    }
456
457    pub fn from_rows(rows: Vec<Vec<f64>>) -> Result<Self, InteractionMatrixError> {
458        let row_count = rows.len();
459        if row_count == 0 {
460            return Err(InteractionMatrixError::EmptySpecies);
461        }
462        let column_count = rows.first().map_or(0, Vec::len);
463        for (row, values) in rows.iter().enumerate() {
464            if values.len() != column_count {
465                return Err(InteractionMatrixError::RaggedRows {
466                    row,
467                    expected: column_count,
468                    actual: values.len(),
469                });
470            }
471        }
472        let values = DenseMatrix::try_from_vec(
473            row_count,
474            column_count,
475            rows.into_iter().flatten().collect(),
476        )?;
477        Self::resolve(Arc::new(values), InteractionProvenance::Inline)
478    }
479
480    pub fn load_json(path: impl Into<PathBuf>) -> Result<Self, InteractionMatrixError> {
481        let path = path.into();
482        let bytes = fs::read(&path).map_err(|source| InteractionMatrixError::Io {
483            path: path.clone(),
484            source,
485        })?;
486        Self::from_json_bytes(
487            bytes,
488            path.clone(),
489            InteractionProvenance::JsonFile { path },
490        )
491    }
492
493    pub fn from_generated(
494        values: DenseMatrix<f64>,
495        generator: GeneratorProvenance,
496    ) -> Result<Self, InteractionMatrixError> {
497        Self::resolve(
498            Arc::new(values),
499            InteractionProvenance::Generated { generator },
500        )
501    }
502
503    pub fn species(&self) -> usize {
504        self.values.rows()
505    }
506    pub fn values(&self) -> &DenseMatrix<f64> {
507        &self.values
508    }
509    pub fn shared_values(&self) -> Arc<DenseMatrix<f64>> {
510        Arc::clone(&self.values)
511    }
512    #[inline]
513    pub fn coefficient(&self, row: usize, column: usize) -> f64 {
514        self.values.get(row as isize, column as isize)
515    }
516    #[inline]
517    pub fn mul_vector_into(&self, input: &[f64], output: &mut [f64]) -> Result<(), MatrixError> {
518        self.values.mul_vector_into(input, output)
519    }
520    /// Apply this interaction matrix to a contiguous batch of species vectors.
521    #[inline]
522    pub fn mul_vectors_into(&self, input: &[f64], output: &mut [f64]) -> Result<(), MatrixError> {
523        self.values.mul_vectors_into(input, output)
524    }
525    pub const fn provenance(&self) -> &InteractionProvenance {
526        &self.provenance
527    }
528    /// Return `A - A^T` from this exact matrix realization.
529    pub fn antisymmetrize(&self) -> Result<Self, InteractionMatrixError> {
530        let transposed = self.values.transpose();
531        let values = self.values.sub(&transposed);
532        self.derive(values, InteractionTransformation::Antisymmetrize)
533    }
534
535    /// Return this matrix multiplied by a finite scalar.
536    pub fn scale(&self, scalar: f64) -> Result<Self, InteractionMatrixError> {
537        require_finite_transform("scalar", scalar)?;
538        self.derive(
539            self.values.scalar_mul(scalar),
540            InteractionTransformation::Scale { scalar },
541        )
542    }
543
544    /// Return the elementwise absolute value of this matrix.
545    pub fn abs(&self) -> Result<Self, InteractionMatrixError> {
546        self.derive(self.values.abs(), InteractionTransformation::Abs)
547    }
548
549    /// Raise every entry below `minimum` to that finite lower bound.
550    pub fn clamp_min(&self, minimum: f64) -> Result<Self, InteractionMatrixError> {
551        require_finite_transform("minimum", minimum)?;
552        let rows = self.values.rows();
553        let columns = self.values.cols();
554        self.derive(
555            DenseMatrix::from_fn(rows, columns, |row, column| {
556                self.values.get(row as isize, column as isize).max(minimum)
557            }),
558            InteractionTransformation::ClampMin { minimum },
559        )
560    }
561
562    /// Lower every entry above `maximum` to that finite upper bound.
563    pub fn clamp_max(&self, maximum: f64) -> Result<Self, InteractionMatrixError> {
564        require_finite_transform("maximum", maximum)?;
565        let rows = self.values.rows();
566        let columns = self.values.cols();
567        self.derive(
568            DenseMatrix::from_fn(rows, columns, |row, column| {
569                self.values.get(row as isize, column as isize).min(maximum)
570            }),
571            InteractionTransformation::ClampMax { maximum },
572        )
573    }
574
575    /// Require every absolute entry to be at most `threshold`.
576    ///
577    /// The maximum is reduced by PiP's backend-native parallel implementation.
578    pub fn ensure_max_abs_at_most(&self, threshold: f64) -> Result<(), InteractionMatrixError> {
579        if !threshold.is_finite() || threshold < 0.0 {
580            return Err(InteractionMatrixError::InvalidTransformationParameter {
581                name: "threshold",
582                value: threshold,
583            });
584        }
585        let maximum = self.values.max_abs_real();
586        if maximum > threshold {
587            return Err(InteractionMatrixError::MaximumAbsoluteEntryExceeded {
588                threshold,
589                maximum,
590            });
591        }
592        Ok(())
593    }
594
595    /// Scale down uniformly until every absolute entry is at most `threshold`.
596    ///
597    /// A matrix already within the threshold is not enlarged.
598    pub fn normalize(&self, threshold: f64) -> Result<Self, InteractionMatrixError> {
599        if !threshold.is_finite() || threshold < 0.0 {
600            return Err(InteractionMatrixError::InvalidTransformationParameter {
601                name: "threshold",
602                value: threshold,
603            });
604        }
605        let maximum = self.values.max_abs_real();
606        let scalar = if maximum > threshold {
607            threshold / maximum
608        } else {
609            1.0
610        };
611        let transformation = InteractionTransformation::Normalize {
612            threshold,
613            maximum,
614            scalar,
615        };
616        if scalar == 1.0 {
617            return Ok(Self {
618                values: Arc::clone(&self.values),
619                provenance: self.derived_provenance(transformation),
620            });
621        }
622        self.derive(self.values.scalar_mul(scalar), transformation)
623    }
624    pub fn generator_rng_record(&self) -> Result<Option<RngRecord>, RngRecordError> {
625        self.provenance.generator_rng_record()
626    }
627
628    fn from_json_bytes(
629        bytes: Vec<u8>,
630        path: PathBuf,
631        provenance: InteractionProvenance,
632    ) -> Result<Self, InteractionMatrixError> {
633        let values =
634            serde_json::from_slice(&bytes).map_err(|source| InteractionMatrixError::Json {
635                path: path.clone(),
636                source,
637            })?;
638        Self::resolve(Arc::new(values), provenance)
639    }
640
641    fn derive(
642        &self,
643        values: DenseMatrix<f64>,
644        transformation: InteractionTransformation,
645    ) -> Result<Self, InteractionMatrixError> {
646        Self::resolve(Arc::new(values), self.derived_provenance(transformation))
647    }
648
649    fn derived_provenance(
650        &self,
651        transformation: InteractionTransformation,
652    ) -> InteractionProvenance {
653        InteractionProvenance::Derived {
654            source: Box::new(self.provenance.clone()),
655            transformation,
656        }
657    }
658
659    fn resolve(
660        values: Arc<DenseMatrix<f64>>,
661        provenance: InteractionProvenance,
662    ) -> Result<Self, InteractionMatrixError> {
663        let rows = values.rows();
664        let columns = values.cols();
665        if rows != columns {
666            return Err(InteractionMatrixError::NonSquare { rows, columns });
667        }
668        for flat in 0..values.size() {
669            let value = values.get_flat(flat as isize);
670            if !value.is_finite() {
671                return Err(InteractionMatrixError::NonFiniteEntry {
672                    row: flat / columns,
673                    column: flat % columns,
674                    value,
675                });
676            }
677        }
678        Ok(Self { values, provenance })
679    }
680}
681
682fn require_finite_transform(name: &'static str, value: f64) -> Result<(), InteractionMatrixError> {
683    if value.is_finite() {
684        Ok(())
685    } else {
686        Err(InteractionMatrixError::InvalidTransformationParameter { name, value })
687    }
688}
689
690#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
691#[serde(rename_all = "snake_case")]
692pub enum InteractionSourceKind {
693    InMemory,
694    Inline,
695    JsonFile,
696    Generated,
697    Derived,
698}
699
700#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
701#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
702pub enum InteractionTransformation {
703    Antisymmetrize,
704    Abs,
705    Scale {
706        scalar: f64,
707    },
708    ClampMin {
709        minimum: f64,
710    },
711    ClampMax {
712        maximum: f64,
713    },
714    Normalize {
715        threshold: f64,
716        maximum: f64,
717        scalar: f64,
718    },
719}
720
721#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
722#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
723pub enum InteractionProvenance {
724    InMemory {
725        label: Option<String>,
726    },
727    Inline,
728    JsonFile {
729        path: PathBuf,
730    },
731    Generated {
732        generator: GeneratorProvenance,
733    },
734    Derived {
735        source: Box<InteractionProvenance>,
736        transformation: InteractionTransformation,
737    },
738}
739
740impl InteractionProvenance {
741    pub const fn kind(&self) -> InteractionSourceKind {
742        match self {
743            Self::InMemory { .. } => InteractionSourceKind::InMemory,
744            Self::Inline => InteractionSourceKind::Inline,
745            Self::JsonFile { .. } => InteractionSourceKind::JsonFile,
746            Self::Generated { .. } => InteractionSourceKind::Generated,
747            Self::Derived { .. } => InteractionSourceKind::Derived,
748        }
749    }
750    pub const fn generator(&self) -> Option<&GeneratorProvenance> {
751        match self {
752            Self::Generated { generator } => Some(generator),
753            Self::Derived { source, .. } => source.generator(),
754            _ => None,
755        }
756    }
757    pub fn generator_rng_record(&self) -> Result<Option<RngRecord>, RngRecordError> {
758        self.generator()
759            .map_or(Ok(None), GeneratorProvenance::rng_record)
760    }
761}
762
763#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
764#[serde(deny_unknown_fields)]
765pub struct GeneratorProvenance {
766    identity: String,
767    version: String,
768    recipe: Value,
769    rng: Option<RngConfig>,
770}
771
772impl GeneratorProvenance {
773    pub fn new(
774        identity: impl Into<String>,
775        version: impl Into<String>,
776        recipe: Value,
777        rng: Option<RngConfig>,
778    ) -> Result<Self, InteractionMatrixError> {
779        let identity = identity.into();
780        let version = version.into();
781        if identity.trim().is_empty() {
782            return Err(InteractionMatrixError::InvalidGeneratorLabel { field: "identity" });
783        }
784        if version.trim().is_empty() {
785            return Err(InteractionMatrixError::InvalidGeneratorLabel { field: "version" });
786        }
787        if rng.is_some_and(|value| value.seed().is_none() || value.method().is_none()) {
788            return Err(InteractionMatrixError::UnresolvedGeneratorRng { identity });
789        }
790        Ok(Self {
791            identity,
792            version,
793            recipe,
794            rng,
795        })
796    }
797    pub fn identity(&self) -> &str {
798        &self.identity
799    }
800    pub fn version(&self) -> &str {
801        &self.version
802    }
803    pub const fn recipe(&self) -> &Value {
804        &self.recipe
805    }
806    pub const fn rng(&self) -> Option<RngConfig> {
807        self.rng
808    }
809    pub fn rng_record(&self) -> Result<Option<RngRecord>, RngRecordError> {
810        let Some(rng) = self.rng else {
811            return Ok(None);
812        };
813        let method = rng.method().expect("generator RNG is resolved");
814        let mut parameters = Map::new();
815        parameters.insert("recipe".to_owned(), self.recipe.clone());
816        Ok(Some(RngRecord::new(
817            INTERACTION_GENERATOR_RNG_NAMESPACE,
818            format!("{}+{}", self.identity, method.name()),
819            format!("{}+{}", self.version, method.version()),
820            method.seed_encoding(),
821            rng.encode_seed().expect("generator RNG seed is resolved"),
822            Some(parameters),
823        )?))
824    }
825}
826
827#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
828#[serde(deny_unknown_fields)]
829pub struct InteractionArtifactDescriptor {
830    format: String,
831    species: usize,
832    #[serde(flatten)]
833    artifact: ArtifactDescriptor,
834    provenance: InteractionProvenance,
835}
836
837impl InteractionArtifactDescriptor {
838    pub fn format(&self) -> &str {
839        &self.format
840    }
841    pub const fn species(&self) -> usize {
842        self.species
843    }
844    pub const fn shape(&self) -> [usize; 2] {
845        [self.species, self.species]
846    }
847    pub fn sha256(&self) -> &str {
848        self.artifact.sha256()
849    }
850    pub fn path(&self) -> &str {
851        self.artifact.path()
852    }
853    pub const fn source_kind(&self) -> InteractionSourceKind {
854        self.provenance.kind()
855    }
856    pub const fn generator(&self) -> Option<&GeneratorProvenance> {
857        self.provenance.generator()
858    }
859    pub const fn provenance(&self) -> &InteractionProvenance {
860        &self.provenance
861    }
862    pub fn insert_into_metadata(&self, metadata: &mut Map<String, Value>) -> Option<Value> {
863        metadata.insert(
864            INTERACTION_MATRIX_METADATA_KEY.to_owned(),
865            serde_json::to_value(self).expect("interaction descriptor is JSON-compatible"),
866        )
867    }
868}
869
870#[derive(Clone, Debug, PartialEq)]
871pub struct PersistedInteraction {
872    descriptor: InteractionArtifactDescriptor,
873    disposition: ArtifactDisposition,
874}
875
876impl PersistedInteraction {
877    pub const fn descriptor(&self) -> &InteractionArtifactDescriptor {
878        &self.descriptor
879    }
880    pub const fn disposition(&self) -> ArtifactDisposition {
881        self.disposition
882    }
883    pub fn into_descriptor(self) -> InteractionArtifactDescriptor {
884        self.descriptor
885    }
886}
887
888pub fn persist_interaction_matrix(
889    scope: &ExecutionScope,
890    matrix: &InteractionMatrix,
891) -> Result<PersistedInteraction, InteractionArtifactError> {
892    let bytes = serde_json::to_vec(matrix.values())?;
893    let persisted = persist_artifact(scope, "interaction", "json", &bytes)?;
894    Ok(PersistedInteraction {
895        descriptor: InteractionArtifactDescriptor {
896            format: INTERACTION_MATRIX_FORMAT.to_owned(),
897            species: matrix.species(),
898            artifact: persisted.descriptor().clone(),
899            provenance: matrix.provenance().clone(),
900        },
901        disposition: persisted.disposition(),
902    })
903}
904
905pub fn load_verified_interaction_matrix(
906    execution_directory: impl AsRef<Path>,
907    descriptor: &InteractionArtifactDescriptor,
908) -> Result<InteractionMatrix, InteractionArtifactLoadError> {
909    if descriptor.format != INTERACTION_MATRIX_FORMAT || descriptor.species == 0 {
910        return Err(InteractionArtifactLoadError::InvalidDescriptor);
911    }
912    let verified = load_verified_artifact(execution_directory, &descriptor.artifact)?;
913    let path = verified.path().to_path_buf();
914    let matrix = InteractionMatrix::from_json_bytes(
915        verified.into_bytes(),
916        path,
917        descriptor.provenance.clone(),
918    )?;
919    if matrix.species() != descriptor.species {
920        return Err(InteractionMatrixError::SpeciesMismatch {
921            expected: descriptor.species,
922            actual: matrix.species(),
923        }
924        .into());
925    }
926    Ok(matrix)
927}
928
929#[derive(Debug, Error)]
930#[non_exhaustive]
931pub enum InteractionRecipeError {
932    #[error("interaction recipe requires at least one species")]
933    EmptySpecies,
934    #[error("interaction recipe parameter {name} is invalid: {value}")]
935    InvalidParameter { name: &'static str, value: f64 },
936    #[error("uniform interaction range requires minimum < maximum, got [{minimum}, {maximum})")]
937    InvalidRange { minimum: f64, maximum: f64 },
938    #[error("interaction family {family} does not support sampled diagonal entries")]
939    SampledDiagonalUnsupported { family: &'static str },
940    #[error(transparent)]
941    Rng(#[from] RngConfigError),
942    #[error(transparent)]
943    TensorRand(#[from] TensorRandError),
944    #[error(transparent)]
945    Matrix(#[from] MatrixError),
946    #[error(transparent)]
947    Interaction(#[from] InteractionMatrixError),
948    #[error(transparent)]
949    Json(#[from] serde_json::Error),
950}
951
952#[derive(Debug, Error)]
953#[non_exhaustive]
954pub enum InteractionMatrixError {
955    #[error(transparent)]
956    Matrix(#[from] MatrixError),
957    #[error("interaction matrix species dimension must be positive")]
958    EmptySpecies,
959    #[error("interaction matrix must be square, found {rows}x{columns}")]
960    NonSquare { rows: usize, columns: usize },
961    #[error("interaction matrix has {actual} species, expected {expected}")]
962    SpeciesMismatch { expected: usize, actual: usize },
963    #[error("interaction matrix row {row} has {actual} columns, expected {expected}")]
964    RaggedRows {
965        row: usize,
966        expected: usize,
967        actual: usize,
968    },
969    #[error("interaction matrix entry ({row}, {column}) is not finite: {value}")]
970    NonFiniteEntry {
971        row: usize,
972        column: usize,
973        value: f64,
974    },
975    #[error("interaction matrix transformation parameter {name} is invalid: {value}")]
976    InvalidTransformationParameter { name: &'static str, value: f64 },
977    #[error("interaction matrix maximum absolute entry {maximum} exceeds threshold {threshold}")]
978    MaximumAbsoluteEntryExceeded { threshold: f64, maximum: f64 },
979    #[error("interaction matrix label must not be empty")]
980    EmptyLabel,
981    #[error("failed to read interaction matrix at `{path}`")]
982    Io {
983        path: PathBuf,
984        #[source]
985        source: std::io::Error,
986    },
987    #[error("invalid interaction matrix JSON at `{path}`")]
988    Json {
989        path: PathBuf,
990        #[source]
991        source: serde_json::Error,
992    },
993    #[error("interaction generator {field} must not be empty")]
994    InvalidGeneratorLabel { field: &'static str },
995    #[error("interaction generator `{identity}` has unresolved RNG")]
996    UnresolvedGeneratorRng { identity: String },
997}
998
999#[derive(Debug, Error)]
1000#[non_exhaustive]
1001pub enum InteractionArtifactError {
1002    #[error(transparent)]
1003    Serialize(#[from] serde_json::Error),
1004    #[error(transparent)]
1005    Workflow(#[from] ArtifactError),
1006}
1007
1008#[derive(Debug, Error)]
1009#[non_exhaustive]
1010pub enum InteractionArtifactLoadError {
1011    #[error("invalid interaction artifact descriptor")]
1012    InvalidDescriptor,
1013    #[error(transparent)]
1014    Workflow(#[from] ArtifactLoadError),
1015    #[error(transparent)]
1016    Matrix(#[from] InteractionMatrixError),
1017}