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