1use 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 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#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
75#[serde(tag = "family", rename_all = "snake_case", deny_unknown_fields)]
76pub enum InteractionMatrixRecipe {
77 RandomUniform {
79 minimum: f64,
80 maximum: f64,
81 #[serde(default)]
82 rng: RngConfig,
83 },
84 RandomGaussian {
86 mean: f64,
87 standard_deviation: f64,
88 #[serde(default)]
89 rng: RngConfig,
90 },
91 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 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 #[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 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 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 pub fn abs(&self) -> Result<Self, InteractionMatrixError> {
546 self.derive(self.values.abs(), InteractionTransformation::Abs)
547 }
548
549 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 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 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 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}