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_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 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#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
78#[serde(tag = "family", rename_all = "snake_case", deny_unknown_fields)]
79pub enum InteractionMatrixRecipe {
80 RandomUniform {
82 minimum: f64,
83 maximum: f64,
84 #[serde(default)]
85 rng: RngConfig,
86 },
87 RandomGaussian {
89 mean: f64,
90 standard_deviation: f64,
91 #[serde(default)]
92 rng: RngConfig,
93 },
94 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 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 #[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 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 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 pub fn abs(&self) -> Result<Self, InteractionMatrixError> {
556 self.derive(self.values.abs(), InteractionTransformation::Abs)
557 }
558
559 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 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 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 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#[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}