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