1use std::fs;
4use std::path::{Path, PathBuf};
5
6use physics_in_parallel::space::prelude::{
7 RngConfig, Space as SpaceOperations, SquareLattice, SquareLatticeConfig,
8 SquareLatticeConfigError, SquareLatticeInitMethod,
9};
10use scientific_workflow::artifact::{
11 ArtifactDescriptor, ArtifactDisposition, ArtifactError, ArtifactLoadError,
12 load_verified_artifact, persist_artifact,
13};
14use scientific_workflow::execution::ExecutionScope;
15use scientific_workflow::rng_record::{RngRecord, RngRecordError};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use thiserror::Error;
19
20pub const INITIALIZATION_RNG_NAMESPACE: &str = "ecological_model_core.initial_state";
21pub const INITIAL_STATE_FORMAT: &str = "ecological.initial-state.v1";
22pub const INITIAL_STATE_METADATA_KEY: &str = "initial_state";
23
24pub type CategoricalSpace = SquareLattice<usize>;
25pub type TaxonCounts = Vec<usize>;
26
27#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
29#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
30pub enum DistributionSource {
31 Uniform,
32 Inline { weights: Vec<f64> },
33 Json { path: PathBuf },
34}
35
36impl DistributionSource {
37 pub fn path(&self) -> Option<&Path> {
38 match self {
39 Self::Json { path } => Some(path),
40 Self::Uniform | Self::Inline { .. } => None,
41 }
42 }
43
44 fn validate(&self, num_taxa: usize) -> Result<(), InitialStateError> {
45 match self {
46 Self::Uniform => Ok(()),
47 Self::Inline { weights } => validate_distribution(weights, num_taxa),
48 Self::Json { path } if path.as_os_str().is_empty() => Err(InitialStateError::EmptyPath),
49 Self::Json { .. } => Ok(()),
50 }
51 }
52
53 fn resolve(self, num_taxa: usize) -> Result<Option<Vec<f64>>, InitialStateError> {
54 let weights = match self {
55 Self::Uniform => return Ok(None),
56 Self::Inline { weights } => weights,
57 Self::Json { path } => {
58 let bytes = fs::read(&path).map_err(|source| InitialStateError::Io {
59 operation: "read distribution",
60 path: path.clone(),
61 source,
62 })?;
63 serde_json::from_slice(&bytes)
64 .map_err(|source| InitialStateError::Json { path, source })?
65 }
66 };
67 validate_distribution(&weights, num_taxa)?;
68 Ok(Some(weights))
69 }
70}
71
72#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
73#[serde(rename_all = "snake_case")]
74pub enum InitializationMethod {
75 Random,
76 CenteredSeed,
77 CenteredDominantSeed,
78}
79
80#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
82#[serde(tag = "method", rename_all = "snake_case", deny_unknown_fields)]
83pub enum InitialStateRecipe {
84 Random {
85 distribution: DistributionSource,
86 #[serde(default)]
87 rng: RngConfig,
88 },
89 CenteredSeed {
90 distribution: DistributionSource,
91 seed_taxon: usize,
92 seed_radius: usize,
93 #[serde(default)]
94 rng: RngConfig,
95 },
96 CenteredDominantSeed {
97 distribution: DistributionSource,
98 seed_radius: usize,
99 #[serde(default)]
100 rng: RngConfig,
101 },
102}
103
104impl InitialStateRecipe {
105 pub const fn method(&self) -> InitializationMethod {
106 match self {
107 Self::Random { .. } => InitializationMethod::Random,
108 Self::CenteredSeed { .. } => InitializationMethod::CenteredSeed,
109 Self::CenteredDominantSeed { .. } => InitializationMethod::CenteredDominantSeed,
110 }
111 }
112
113 pub fn distribution_path(&self) -> Option<&Path> {
114 match self {
115 Self::Random { distribution, .. }
116 | Self::CenteredSeed { distribution, .. }
117 | Self::CenteredDominantSeed { distribution, .. } => distribution.path(),
118 }
119 }
120
121 pub fn validate(
122 &self,
123 lattice: &SquareLatticeConfig,
124 num_taxa: usize,
125 ) -> Result<(), InitialStateError> {
126 if num_taxa == 0 {
127 return Err(InitialStateError::ZeroTaxa);
128 }
129 match self {
130 Self::Random { distribution, .. } => distribution.validate(num_taxa),
131 Self::CenteredSeed {
132 distribution,
133 seed_taxon,
134 seed_radius,
135 ..
136 } => {
137 distribution.validate(num_taxa)?;
138 validate_seed(lattice, num_taxa, *seed_taxon, *seed_radius)
139 }
140 Self::CenteredDominantSeed {
141 distribution,
142 seed_radius,
143 ..
144 } => {
145 distribution.validate(num_taxa)?;
146 validate_seed_geometry(lattice, *seed_radius)
147 }
148 }
149 }
150
151 pub fn create(
152 self,
153 lattice: SquareLatticeConfig,
154 num_taxa: usize,
155 ) -> Result<InitialState, InitialStateError> {
156 self.validate(&lattice, num_taxa)?;
157 let method = self.method();
158 let (distribution, rng, explicit_seed, seed_radius, dominant) = match self {
159 Self::Random { distribution, rng } => (distribution, rng, None, None, false),
160 Self::CenteredSeed {
161 distribution,
162 seed_taxon,
163 seed_radius,
164 rng,
165 } => (
166 distribution,
167 rng,
168 Some(seed_taxon),
169 Some(seed_radius),
170 false,
171 ),
172 Self::CenteredDominantSeed {
173 distribution,
174 seed_radius,
175 rng,
176 } => (distribution, rng, None, Some(seed_radius), true),
177 };
178 let mut weights = distribution.resolve(num_taxa)?;
179 let seed_taxon = if dominant {
180 let background = weights.get_or_insert_with(|| vec![1.0; num_taxa]);
181 Some(remove_dominant_taxon(background)?)
182 } else {
183 explicit_seed
184 };
185 let mut space = CategoricalSpace::new(
186 lattice,
187 SquareLatticeInitMethod::RandomChoices {
188 choices: (0..num_taxa).collect(),
189 weights,
190 rng,
191 },
192 )?;
193 let mut counts = count_taxa(&space, num_taxa)?;
194 if let (Some(taxon), Some(radius)) = (seed_taxon, seed_radius) {
195 let shape = space.config().shape().to_vec();
196 plant_centered_seed(&mut space, &mut counts, &shape, taxon, radius);
197 }
198 let rng_record = Some(rng_record_from_space(&space)?);
199 Ok(InitialState {
200 num_taxa,
201 method,
202 seed_taxon,
203 rng_record,
204 space,
205 counts,
206 })
207 }
208}
209
210#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
212#[serde(tag = "source", rename_all = "snake_case", deny_unknown_fields)]
213pub enum InitialStateSource {
214 Recipe {
215 recipe: InitialStateRecipe,
216 },
217 VerifiedArtifact {
218 execution_directory: PathBuf,
219 descriptor: InitialStateArtifactDescriptor,
220 },
221}
222
223impl InitialStateSource {
224 pub fn validate(
225 &self,
226 lattice: &SquareLatticeConfig,
227 num_taxa: usize,
228 ) -> Result<(), InitialStateError> {
229 match self {
230 Self::Recipe { recipe } => recipe.validate(lattice, num_taxa),
231 Self::VerifiedArtifact {
232 execution_directory,
233 descriptor,
234 } => {
235 if execution_directory.as_os_str().is_empty() {
236 return Err(InitialStateError::EmptyPath);
237 }
238 if descriptor.lattice() != lattice {
239 return Err(InitialStateError::LatticeMismatch);
240 }
241 if descriptor.num_taxa() != num_taxa {
242 return Err(InitialStateError::TaxonDimensionMismatch {
243 expected: num_taxa,
244 actual: descriptor.num_taxa(),
245 });
246 }
247 Ok(())
248 }
249 }
250 }
251
252 pub fn resolve(
253 &self,
254 lattice: SquareLatticeConfig,
255 num_taxa: usize,
256 ) -> Result<InitialState, InitialStateError> {
257 self.validate(&lattice, num_taxa)?;
258 match self {
259 Self::Recipe { recipe } => recipe.clone().create(lattice, num_taxa),
260 Self::VerifiedArtifact {
261 execution_directory,
262 descriptor,
263 } => load_verified_initial_state(execution_directory, descriptor),
264 }
265 }
266}
267
268#[derive(Debug)]
269pub struct InitialState {
270 num_taxa: usize,
271 method: InitializationMethod,
272 seed_taxon: Option<usize>,
273 rng_record: Option<RngRecord>,
274 space: CategoricalSpace,
275 counts: TaxonCounts,
276}
277
278impl InitialState {
279 pub const fn num_taxa(&self) -> usize {
280 self.num_taxa
281 }
282 pub const fn method(&self) -> InitializationMethod {
283 self.method
284 }
285 pub const fn seed_taxon(&self) -> Option<usize> {
286 self.seed_taxon
287 }
288 pub const fn rng_record(&self) -> Option<&RngRecord> {
289 self.rng_record.as_ref()
290 }
291 pub const fn space(&self) -> &CategoricalSpace {
292 &self.space
293 }
294 pub fn counts(&self) -> &[usize] {
295 &self.counts
296 }
297 pub fn clone_space(&self) -> CategoricalSpace {
298 self.space.clone()
299 }
300 pub fn into_parts(self) -> (CategoricalSpace, TaxonCounts) {
301 (self.space, self.counts)
302 }
303}
304
305#[derive(Serialize)]
306struct InitialStateDocumentRef<'a> {
307 format: &'static str,
308 num_taxa: usize,
309 method: InitializationMethod,
310 seed_taxon: Option<usize>,
311 rng_record: Option<&'a RngRecord>,
312 lattice: &'a SquareLatticeConfig,
313 sites: &'a [usize],
314}
315
316impl<'a> From<&'a InitialState> for InitialStateDocumentRef<'a> {
317 fn from(initial: &'a InitialState) -> Self {
318 Self {
319 format: INITIAL_STATE_FORMAT,
320 num_taxa: initial.num_taxa,
321 method: initial.method,
322 seed_taxon: initial.seed_taxon,
323 rng_record: initial.rng_record.as_ref(),
324 lattice: initial.space.config(),
325 sites: initial.space.data(),
326 }
327 }
328}
329
330#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
331#[serde(deny_unknown_fields)]
332pub struct InitialStateArtifactDescriptor {
333 format: String,
334 num_taxa: usize,
335 lattice: SquareLatticeConfig,
336 method: InitializationMethod,
337 #[serde(skip_serializing_if = "Option::is_none")]
338 seed_taxon: Option<usize>,
339 #[serde(flatten)]
340 artifact: ArtifactDescriptor,
341}
342
343impl InitialStateArtifactDescriptor {
344 pub fn format(&self) -> &str {
345 &self.format
346 }
347 pub const fn num_taxa(&self) -> usize {
348 self.num_taxa
349 }
350 pub const fn lattice(&self) -> &SquareLatticeConfig {
351 &self.lattice
352 }
353 pub const fn method(&self) -> InitializationMethod {
354 self.method
355 }
356 pub const fn seed_taxon(&self) -> Option<usize> {
357 self.seed_taxon
358 }
359 pub fn sha256(&self) -> &str {
360 self.artifact.sha256()
361 }
362 pub fn path(&self) -> &str {
363 self.artifact.path()
364 }
365 pub fn insert_into_metadata(&self, metadata: &mut Map<String, Value>) -> Option<Value> {
366 metadata.insert(
367 INITIAL_STATE_METADATA_KEY.to_owned(),
368 serde_json::to_value(self).expect("initial-state descriptor is JSON-compatible"),
369 )
370 }
371}
372
373#[derive(Clone, Debug)]
374pub struct PersistedInitialState {
375 descriptor: InitialStateArtifactDescriptor,
376 disposition: ArtifactDisposition,
377}
378
379impl PersistedInitialState {
380 pub const fn descriptor(&self) -> &InitialStateArtifactDescriptor {
381 &self.descriptor
382 }
383 pub const fn disposition(&self) -> ArtifactDisposition {
384 self.disposition
385 }
386 pub fn into_descriptor(self) -> InitialStateArtifactDescriptor {
387 self.descriptor
388 }
389}
390
391pub fn persist_initial_state(
392 scope: &ExecutionScope,
393 initial: &InitialState,
394) -> Result<PersistedInitialState, InitialStateError> {
395 let bytes = serde_json::to_vec(&InitialStateDocumentRef::from(initial))?;
396 let persisted = persist_artifact(scope, "initial-state", "json", &bytes)?;
397 Ok(PersistedInitialState {
398 descriptor: InitialStateArtifactDescriptor {
399 format: INITIAL_STATE_FORMAT.to_owned(),
400 num_taxa: initial.num_taxa,
401 lattice: initial.space.config().clone(),
402 method: initial.method,
403 seed_taxon: initial.seed_taxon,
404 artifact: persisted.descriptor().clone(),
405 },
406 disposition: persisted.disposition(),
407 })
408}
409
410pub fn load_verified_initial_state(
411 execution_directory: impl AsRef<Path>,
412 descriptor: &InitialStateArtifactDescriptor,
413) -> Result<InitialState, InitialStateError> {
414 if descriptor.format != INITIAL_STATE_FORMAT {
415 return Err(InitialStateError::UnsupportedFormat {
416 actual: descriptor.format.clone(),
417 });
418 }
419 let verified = load_verified_artifact(execution_directory, &descriptor.artifact)?;
420 let document: InitialStateDocument =
421 serde_json::from_slice(verified.bytes()).map_err(|source| InitialStateError::Json {
422 path: verified.path().to_path_buf(),
423 source,
424 })?;
425 let initial = document.resolve(descriptor.lattice.clone(), descriptor.num_taxa)?;
426 if initial.method != descriptor.method || initial.seed_taxon != descriptor.seed_taxon {
427 return Err(InitialStateError::DescriptorMismatch);
428 }
429 Ok(initial)
430}
431
432#[derive(Debug, Deserialize)]
433#[serde(deny_unknown_fields)]
434struct InitialStateDocument {
435 format: String,
436 num_taxa: usize,
437 method: InitializationMethod,
438 seed_taxon: Option<usize>,
439 rng_record: Option<RngRecord>,
440 lattice: SquareLatticeConfig,
441 sites: Vec<usize>,
442}
443
444impl InitialStateDocument {
445 fn resolve(
446 self,
447 expected_lattice: SquareLatticeConfig,
448 expected_num_taxa: usize,
449 ) -> Result<InitialState, InitialStateError> {
450 if self.format != INITIAL_STATE_FORMAT {
451 return Err(InitialStateError::UnsupportedFormat {
452 actual: self.format,
453 });
454 }
455 if self.num_taxa != expected_num_taxa {
456 return Err(InitialStateError::TaxonDimensionMismatch {
457 expected: expected_num_taxa,
458 actual: self.num_taxa,
459 });
460 }
461 if self.lattice != expected_lattice {
462 return Err(InitialStateError::LatticeMismatch);
463 }
464 if self.seed_taxon.is_some_and(|taxon| taxon >= self.num_taxa) {
465 return Err(InitialStateError::SeedTaxonOutOfRange {
466 seed_taxon: self.seed_taxon.expect("checked Some"),
467 num_taxa: self.num_taxa,
468 });
469 }
470 let space = CategoricalSpace::new(
471 self.lattice,
472 SquareLatticeInitMethod::Values { values: self.sites },
473 )?;
474 let counts = count_taxa(&space, self.num_taxa)?;
475 Ok(InitialState {
476 num_taxa: self.num_taxa,
477 method: self.method,
478 seed_taxon: self.seed_taxon,
479 rng_record: self.rng_record,
480 space,
481 counts,
482 })
483 }
484}
485
486fn rng_record_from_space(space: &CategoricalSpace) -> Result<RngRecord, InitialStateError> {
487 let config = space
488 .initialization_rng_config()
489 .ok_or(InitialStateError::MissingResolvedRngConfig)?;
490 let method = config
491 .method()
492 .ok_or(InitialStateError::MissingResolvedRngConfig)?;
493 let key = config
494 .encode_seed()
495 .ok_or(InitialStateError::MissingResolvedRngConfig)?;
496 let mut parameters = Map::new();
497 if let Some(streams) = config.parallel_streams() {
498 parameters.insert("parallel_streams".to_owned(), Value::from(streams.get()));
499 }
500 Ok(RngRecord::new(
501 INITIALIZATION_RNG_NAMESPACE,
502 method.name(),
503 method.version(),
504 method.seed_encoding(),
505 key,
506 Some(parameters),
507 )?)
508}
509
510fn validate_distribution(weights: &[f64], num_taxa: usize) -> Result<(), InitialStateError> {
511 if weights.len() != num_taxa {
512 return Err(InitialStateError::DistributionLength {
513 expected: num_taxa,
514 actual: weights.len(),
515 });
516 }
517 let mut total = 0.0;
518 for (taxon, &weight) in weights.iter().enumerate() {
519 if !weight.is_finite() || weight < 0.0 {
520 return Err(InitialStateError::InvalidWeight { taxon, weight });
521 }
522 total += weight;
523 }
524 if !total.is_finite() || total <= 0.0 {
525 return Err(InitialStateError::NonPositiveDistribution);
526 }
527 Ok(())
528}
529
530fn remove_dominant_taxon(weights: &mut [f64]) -> Result<usize, InitialStateError> {
531 validate_distribution(weights, weights.len())?;
532 let dominant = weights
533 .iter()
534 .enumerate()
535 .max_by(|left, right| left.1.total_cmp(right.1).then(right.0.cmp(&left.0)))
536 .map(|(taxon, _)| taxon)
537 .expect("validated distribution is nonempty");
538 let background_total = weights.iter().sum::<f64>() - weights[dominant];
539 if background_total <= 0.0 {
540 return Err(InitialStateError::NoDominantSeedBackground);
541 }
542 weights[dominant] = 0.0;
543 for weight in weights {
544 *weight /= background_total;
545 }
546 Ok(dominant)
547}
548
549fn count_taxa(space: &CategoricalSpace, num_taxa: usize) -> Result<TaxonCounts, InitialStateError> {
550 let mut counts = vec![0; num_taxa];
551 for (site, &taxon) in space.data().iter().enumerate() {
552 let count = counts
553 .get_mut(taxon)
554 .ok_or(InitialStateError::SpaceTaxonOutOfRange {
555 site,
556 taxon,
557 num_taxa,
558 })?;
559 *count += 1;
560 }
561 Ok(counts)
562}
563
564fn validate_seed(
565 lattice: &SquareLatticeConfig,
566 num_taxa: usize,
567 seed_taxon: usize,
568 seed_radius: usize,
569) -> Result<(), InitialStateError> {
570 if seed_taxon >= num_taxa {
571 return Err(InitialStateError::SeedTaxonOutOfRange {
572 seed_taxon,
573 num_taxa,
574 });
575 }
576 validate_seed_geometry(lattice, seed_radius)
577}
578
579fn validate_seed_geometry(
580 lattice: &SquareLatticeConfig,
581 seed_radius: usize,
582) -> Result<(), InitialStateError> {
583 let width = seed_radius
584 .checked_mul(2)
585 .and_then(|diameter| diameter.checked_add(1))
586 .ok_or(InitialStateError::SeedWidthOverflow {
587 radius: seed_radius,
588 })?;
589 for (axis, &length) in lattice.shape().iter().enumerate() {
590 if width > length {
591 return Err(InitialStateError::SeedDoesNotFit {
592 axis,
593 width,
594 length,
595 });
596 }
597 }
598 Ok(())
599}
600
601fn plant_centered_seed(
602 space: &mut CategoricalSpace,
603 counts: &mut [usize],
604 shape: &[usize],
605 seed_taxon: usize,
606 radius: usize,
607) {
608 let starts = shape
609 .iter()
610 .map(|length| length / 2 - radius)
611 .collect::<Vec<_>>();
612 let widths = vec![radius * 2 + 1; shape.len()];
613 let seed_sites = widths.iter().product::<usize>();
614 let mut coordinate = vec![0isize; shape.len()];
615 for local_flat in 0..seed_sites {
616 let mut local = local_flat;
617 for axis in (0..shape.len()).rev() {
618 coordinate[axis] = (starts[axis] + local % widths[axis]) as isize;
619 local /= widths[axis];
620 }
621 let previous = *space.get(&coordinate);
622 if previous != seed_taxon {
623 counts[previous] -= 1;
624 counts[seed_taxon] += 1;
625 space.set(&coordinate, seed_taxon);
626 }
627 }
628}
629
630#[derive(Debug, Error)]
631#[non_exhaustive]
632pub enum InitialStateError {
633 #[error("num_taxa must be positive")]
634 ZeroTaxa,
635 #[error("distribution length is {actual}, expected {expected}")]
636 DistributionLength { expected: usize, actual: usize },
637 #[error("distribution weight {taxon} is invalid: {weight}")]
638 InvalidWeight { taxon: usize, weight: f64 },
639 #[error("distribution must have positive finite total weight")]
640 NonPositiveDistribution,
641 #[error("dominant-seed initialization requires positive background mass")]
642 NoDominantSeedBackground,
643 #[error("seed taxon {seed_taxon} is outside 0..{num_taxa}")]
644 SeedTaxonOutOfRange { seed_taxon: usize, num_taxa: usize },
645 #[error("seed radius {radius} is too large")]
646 SeedWidthOverflow { radius: usize },
647 #[error("seed width {width} does not fit lattice axis {axis} of length {length}")]
648 SeedDoesNotFit {
649 axis: usize,
650 width: usize,
651 length: usize,
652 },
653 #[error("path must not be empty")]
654 EmptyPath,
655 #[error("initial-state format `{actual}` is unsupported")]
656 UnsupportedFormat { actual: String },
657 #[error("initial state declares {actual} taxa, expected {expected}")]
658 TaxonDimensionMismatch { expected: usize, actual: usize },
659 #[error("initial-state lattice does not match the expected lattice")]
660 LatticeMismatch,
661 #[error("initial-state descriptor does not match its verified document")]
662 DescriptorMismatch,
663 #[error("initial-state site {site} contains taxon {taxon}, outside 0..{num_taxa}")]
664 SpaceTaxonOutOfRange {
665 site: usize,
666 taxon: usize,
667 num_taxa: usize,
668 },
669 #[error("failed to {operation} at `{path}`")]
670 Io {
671 operation: &'static str,
672 path: PathBuf,
673 #[source]
674 source: std::io::Error,
675 },
676 #[error("invalid JSON initial state at `{path}`")]
677 Json {
678 path: PathBuf,
679 #[source]
680 source: serde_json::Error,
681 },
682 #[error(transparent)]
683 Serialize(#[from] serde_json::Error),
684 #[error(transparent)]
685 Lattice(#[from] SquareLatticeConfigError),
686 #[error("initialized lattice has no resolved RNG configuration")]
687 MissingResolvedRngConfig,
688 #[error(transparent)]
689 RngRecord(#[from] RngRecordError),
690 #[error(transparent)]
691 Artifact(#[from] ArtifactError),
692 #[error(transparent)]
693 ArtifactLoad(#[from] ArtifactLoadError),
694}