1use crate::terminal_state::{
4 AbsorptionDiagnostics, EquilibriumDiagnostics, PeriodicOrbitDiagnostics, StopReason,
5 TerminalClassification, TerminalState, TerminalStateError, TerminationSignal,
6};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
11#[serde(deny_unknown_fields)]
12pub struct ResidualTolerance {
13 pub absolute: f64,
14 pub relative: f64,
15}
16
17#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
18#[serde(deny_unknown_fields)]
19pub struct TerminalPolicy {
20 pub sample_interval_iterations: u64,
21 pub trailing_window_samples: usize,
22}
23
24#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
25#[serde(deny_unknown_fields)]
26pub struct EquilibriumPolicy {
27 pub base_window_samples: usize,
28 pub confirmation_window_multipliers: Vec<usize>,
29 pub maximum_observable_distance: f64,
30 pub maximum_relative_mass_range: Option<f64>,
31 pub support_threshold: f64,
32 pub residual_tolerance: ResidualTolerance,
33}
34
35#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
36#[serde(deny_unknown_fields)]
37pub struct PeriodicOrbitPolicy {
38 pub minimum_period_samples: usize,
39 pub maximum_period_samples: usize,
40 pub repeated_cycles: usize,
41 pub maximum_recurrence_distance: f64,
42 pub minimum_orbit_amplitude: f64,
43}
44
45#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
46#[serde(deny_unknown_fields)]
47pub struct DetectionPolicy {
48 pub terminal: TerminalPolicy,
49 #[serde(default)]
50 pub start_after_iteration: u64,
51 pub equilibrium: Option<EquilibriumPolicy>,
52 pub periodic_orbit: Option<PeriodicOrbitPolicy>,
53 #[serde(default = "default_true")]
54 pub detect_absorbing_state: bool,
55}
56
57const fn default_true() -> bool {
58 true
59}
60
61#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
62#[serde(tag = "mode", content = "policy", rename_all = "snake_case")]
63pub enum TrajectoryObservationPolicy {
64 Disabled,
65 TerminalOnly(TerminalPolicy),
66 Detect(DetectionPolicy),
67}
68
69#[derive(Clone, Copy, Debug)]
70pub enum AbundanceView<'a> {
71 Continuous(&'a [f64]),
72 Counts(&'a [usize]),
73}
74
75#[derive(Clone, Copy, Debug)]
76pub enum EquilibriumEvidence<'a> {
77 Unavailable,
78 Residual { values: &'a [f64] },
79 MaximumScaledResidual { value: f64 },
80 AbsorbingState,
81}
82
83#[derive(Clone, Copy, Debug)]
84pub struct TrajectoryObservation<'a> {
85 pub iteration: u64,
86 pub physical_time: Option<f64>,
87 pub abundance: AbundanceView<'a>,
88 pub detector_observable: Option<&'a [f64]>,
89 pub equilibrium_evidence: EquilibriumEvidence<'a>,
90}
91
92#[derive(Default)]
93struct Sample {
94 iteration: u64,
95 physical_time: Option<f64>,
96 composition: Vec<f64>,
97 observable: Option<Vec<f64>>,
98 mass: f64,
99 scaled_residual: Option<f64>,
100 absorbing: bool,
101}
102
103impl Sample {
104 fn detector_observable(&self) -> &[f64] {
105 self.observable.as_deref().unwrap_or(&self.composition)
106 }
107}
108
109struct SampleArena {
110 slots: Vec<Sample>,
111 head: usize,
112 len: usize,
113}
114
115impl SampleArena {
116 fn new(capacity: usize) -> Self {
117 Self {
118 slots: (0..capacity).map(|_| Sample::default()).collect(),
119 head: 0,
120 len: 0,
121 }
122 }
123
124 fn push_slot(&mut self) -> &mut Sample {
125 let index = if self.len < self.slots.len() {
126 let index = (self.head + self.len) % self.slots.len();
127 self.len += 1;
128 index
129 } else {
130 let index = self.head;
131 self.head = (self.head + 1) % self.slots.len();
132 index
133 };
134 &mut self.slots[index]
135 }
136
137 fn get(&self, offset: usize) -> &Sample {
138 &self.slots[(self.head + offset) % self.slots.len()]
139 }
140
141 fn last(&self) -> Option<&Sample> {
142 self.len.checked_sub(1).map(|index| self.get(index))
143 }
144
145 fn take_last_composition(&mut self) -> Option<Vec<f64>> {
146 let index = self.len.checked_sub(1)?;
147 let slot = (self.head + index) % self.slots.len();
148 Some(std::mem::take(&mut self.slots[slot].composition))
149 }
150}
151
152pub struct TrajectoryObserver {
155 policy: ActivePolicy,
156 arena: SampleArena,
157 abundance_dimension: Option<usize>,
158 observable_dimension: Option<usize>,
159 uses_distinct_observable: Option<bool>,
160 last_seen_iteration: Option<u64>,
161 last_seen_time: Option<f64>,
162 equilibrium_stage: usize,
163 equilibrium_stage_samples: usize,
164 scratch: Vec<f64>,
165}
166
167enum ActivePolicy {
168 TerminalOnly(TerminalPolicy),
169 Detect(DetectionPolicy),
170}
171
172impl TrajectoryObserver {
173 pub fn from_policy(
175 policy: TrajectoryObservationPolicy,
176 ) -> Result<Option<Self>, TrajectoryObserverError> {
177 let active = match policy {
178 TrajectoryObservationPolicy::Disabled => return Ok(None),
179 TrajectoryObservationPolicy::TerminalOnly(policy) => {
180 validate_terminal_policy(&policy)?;
181 ActivePolicy::TerminalOnly(policy)
182 }
183 TrajectoryObservationPolicy::Detect(policy) => {
184 validate_detection_policy(&policy)?;
185 ActivePolicy::Detect(policy)
186 }
187 };
188 let capacity = required_capacity(&active)?;
189 Ok(Some(Self {
190 policy: active,
191 arena: SampleArena::new(capacity),
192 abundance_dimension: None,
193 observable_dimension: None,
194 uses_distinct_observable: None,
195 last_seen_iteration: None,
196 last_seen_time: None,
197 equilibrium_stage: 0,
198 equilibrium_stage_samples: 0,
199 scratch: Vec::new(),
200 }))
201 }
202
203 pub const fn sample_interval_iterations(&self) -> u64 {
204 match &self.policy {
205 ActivePolicy::TerminalOnly(policy) => policy.sample_interval_iterations,
206 ActivePolicy::Detect(policy) => policy.terminal.sample_interval_iterations,
207 }
208 }
209
210 pub fn requires_equilibrium_evidence(&self, iteration: u64) -> bool {
212 self.is_due(iteration)
213 && matches!(&self.policy, ActivePolicy::Detect(policy)
214 if iteration >= policy.start_after_iteration && policy.equilibrium.is_some())
215 }
216
217 pub fn observe(
218 &mut self,
219 observation: TrajectoryObservation<'_>,
220 ) -> Result<Option<TerminationSignal>, TrajectoryObserverError> {
221 self.validate_order(&observation)?;
222 let due = self.is_due(observation.iteration);
223 self.last_seen_iteration = Some(observation.iteration);
224 self.last_seen_time = observation.physical_time.or(self.last_seen_time);
225 if !due {
226 return Ok(None);
227 }
228 self.retain(observation)?;
229 self.detect()
230 }
231
232 pub fn finish(
234 mut self,
235 final_observation: TrajectoryObservation<'_>,
236 stop_reason: StopReason,
237 ) -> Result<TerminalState, TrajectoryObserverError> {
238 let already_retained = self
239 .arena
240 .last()
241 .is_some_and(|sample| sample.iteration == final_observation.iteration);
242 if !already_retained {
243 self.validate_final_order(&final_observation)?;
244 self.retain(final_observation)?;
245 }
246 let final_sample = self
247 .arena
248 .last()
249 .ok_or(TrajectoryObserverError::NoSamples)?;
250 if final_sample.iteration
251 != stop_reason
252 .signal()
253 .map_or(final_sample.iteration, TerminationSignal::iteration)
254 {
255 return Err(TrajectoryObserverError::StopReasonIterationMismatch);
256 }
257 let final_iteration = final_sample.iteration;
258 let final_time = final_sample.physical_time;
259
260 match &stop_reason {
261 StopReason::Detected(TerminationSignal::Equilibrium(_)) => TerminalState::new(
262 TerminalClassification::Equilibrium,
263 stop_reason,
264 final_iteration,
265 final_time,
266 self.arena
267 .take_last_composition()
268 .expect("final sample was validated"),
269 1,
270 final_iteration,
271 final_iteration,
272 ),
273 StopReason::Detected(TerminationSignal::AbsorbingState(_)) => TerminalState::new(
274 TerminalClassification::AbsorbingState,
275 stop_reason,
276 final_iteration,
277 final_time,
278 self.arena
279 .take_last_composition()
280 .expect("final sample was validated"),
281 1,
282 final_iteration,
283 final_iteration,
284 ),
285 StopReason::Detected(TerminationSignal::PeriodicOrbit(diagnostics)) => {
286 let (composition, count, first, last) = self.average_range(
287 diagnostics.first_cycle_iteration,
288 diagnostics.last_cycle_iteration,
289 )?;
290 TerminalState::new(
291 TerminalClassification::PeriodicOrbit,
292 stop_reason,
293 final_iteration,
294 final_time,
295 composition,
296 count,
297 first,
298 last,
299 )
300 }
301 _ => {
302 let count = self.trailing_window_samples().min(self.arena.len);
303 let start = self.arena.len - count;
304 let first = self.arena.get(start).iteration;
305 let last = self.arena.get(self.arena.len - 1).iteration;
306 let composition = average_samples(&self.arena, start, self.arena.len);
307 TerminalState::new(
308 TerminalClassification::TrailingAverage,
309 stop_reason,
310 final_iteration,
311 final_time,
312 composition,
313 count,
314 first,
315 last,
316 )
317 }
318 }
319 .map_err(Into::into)
320 }
321
322 fn validate_order(
323 &self,
324 observation: &TrajectoryObservation<'_>,
325 ) -> Result<(), TrajectoryObserverError> {
326 if self
327 .last_seen_iteration
328 .is_some_and(|last| observation.iteration <= last)
329 {
330 return Err(TrajectoryObserverError::NonIncreasingIteration);
331 }
332 if observation
333 .physical_time
334 .is_some_and(|time| !time.is_finite())
335 || matches!((self.last_seen_time, observation.physical_time), (Some(last), Some(time)) if time <= last)
336 {
337 return Err(TrajectoryObserverError::InvalidPhysicalTime);
338 }
339 Ok(())
340 }
341
342 fn validate_final_order(
343 &self,
344 observation: &TrajectoryObservation<'_>,
345 ) -> Result<(), TrajectoryObserverError> {
346 if self
347 .last_seen_iteration
348 .is_some_and(|last| observation.iteration < last)
349 {
350 return Err(TrajectoryObserverError::NonIncreasingIteration);
351 }
352 if observation
353 .physical_time
354 .is_some_and(|time| !time.is_finite())
355 || matches!((self.last_seen_iteration, self.last_seen_time, observation.physical_time),
356 (Some(last_iteration), Some(last_time), Some(time))
357 if observation.iteration == last_iteration && time != last_time
358 || observation.iteration > last_iteration && time <= last_time)
359 {
360 return Err(TrajectoryObserverError::InvalidPhysicalTime);
361 }
362 Ok(())
363 }
364
365 fn is_due(&self, iteration: u64) -> bool {
366 iteration.is_multiple_of(self.sample_interval_iterations())
367 }
368
369 fn trailing_window_samples(&self) -> usize {
370 match &self.policy {
371 ActivePolicy::TerminalOnly(policy) => policy.trailing_window_samples,
372 ActivePolicy::Detect(policy) => policy.terminal.trailing_window_samples,
373 }
374 }
375
376 fn retain(
377 &mut self,
378 observation: TrajectoryObservation<'_>,
379 ) -> Result<(), TrajectoryObserverError> {
380 let abundance_len = abundance_len(observation.abundance);
381 require_stable_dimension(&mut self.abundance_dimension, abundance_len, "abundance")?;
382 let observable_len = observation
383 .detector_observable
384 .map_or(abundance_len, <[f64]>::len);
385 require_stable_dimension(
386 &mut self.observable_dimension,
387 observable_len,
388 "detector observable",
389 )?;
390 let uses_distinct = observation.detector_observable.is_some();
391 match self.uses_distinct_observable {
392 Some(expected) if expected != uses_distinct => {
393 return Err(TrajectoryObserverError::DetectorObservableModeChanged);
394 }
395 None => self.uses_distinct_observable = Some(uses_distinct),
396 _ => {}
397 }
398
399 let equilibrium = match &self.policy {
400 ActivePolicy::Detect(policy)
401 if observation.iteration >= policy.start_after_iteration =>
402 {
403 policy.equilibrium.as_ref()
404 }
405 _ => None,
406 };
407 let (mass, scaled_residual, absorbing) = validate_evidence(
408 observation.abundance,
409 observation.detector_observable,
410 observation.equilibrium_evidence,
411 equilibrium,
412 )?;
413 let slot = self.arena.push_slot();
414 slot.iteration = observation.iteration;
415 slot.physical_time = observation.physical_time;
416 slot.mass = mass;
417 slot.scaled_residual = scaled_residual;
418 slot.absorbing = absorbing;
419 normalize_abundance(observation.abundance, &mut slot.composition)?;
420 if let Some(values) = observation.detector_observable {
421 normalize_continuous(values, slot.observable.get_or_insert_with(Vec::new))?;
422 } else {
423 slot.observable = None;
424 }
425 Ok(())
426 }
427
428 fn detect(&mut self) -> Result<Option<TerminationSignal>, TrajectoryObserverError> {
429 let ActivePolicy::Detect(policy) = &self.policy else {
430 return Ok(None);
431 };
432 let Some(current) = self.arena.last() else {
433 return Ok(None);
434 };
435 if current.iteration < policy.start_after_iteration {
436 return Ok(None);
437 }
438 if policy.detect_absorbing_state && current.absorbing {
439 let threshold = policy
440 .equilibrium
441 .as_ref()
442 .map_or(0.0, |value| value.support_threshold);
443 let supported_taxa = current
444 .composition
445 .iter()
446 .filter(|value| **value > threshold)
447 .count();
448 return Ok(Some(TerminationSignal::AbsorbingState(
449 AbsorptionDiagnostics {
450 iteration: current.iteration,
451 supported_taxa,
452 },
453 )));
454 }
455 if policy.equilibrium.is_some()
456 && let Some(signal) = self.detect_equilibrium()?
457 {
458 return Ok(Some(signal));
459 }
460 Ok(self.detect_periodic_orbit())
461 }
462
463 fn detect_equilibrium(&mut self) -> Result<Option<TerminationSignal>, TrajectoryObserverError> {
464 let ActivePolicy::Detect(policy) = &self.policy else {
465 return Ok(None);
466 };
467 let equilibrium = policy
468 .equilibrium
469 .as_ref()
470 .expect("checked by caller")
471 .clone();
472 if self.arena.len >= 2
473 && !same_support(
474 self.arena.get(self.arena.len - 2).detector_observable(),
475 self.arena.get(self.arena.len - 1).detector_observable(),
476 equilibrium.support_threshold,
477 )
478 {
479 self.equilibrium_stage = 0;
480 self.equilibrium_stage_samples = 0;
481 }
482 let required = equilibrium.base_window_samples
483 * equilibrium.confirmation_window_multipliers[self.equilibrium_stage];
484 self.equilibrium_stage_samples += 1;
485 if self.equilibrium_stage_samples < required || self.arena.len < required {
486 return Ok(None);
487 }
488 self.equilibrium_stage_samples = 0;
489 let start = self.arena.len - required;
490 let maximum_distance = self.maximum_arena_distance_from_mean(start);
491 let passed = window_support_is_invariant(&self.arena, start, equilibrium.support_threshold)
492 && relative_mass_range(&self.arena, start)
493 <= equilibrium
494 .maximum_relative_mass_range
495 .unwrap_or(f64::INFINITY)
496 && maximum_distance <= equilibrium.maximum_observable_distance
497 && (start..self.arena.len).all(|index| {
498 self.arena
499 .get(index)
500 .scaled_residual
501 .is_some_and(|value| value <= 1.0)
502 });
503 if !passed {
504 self.equilibrium_stage = 0;
505 return Ok(None);
506 }
507 self.equilibrium_stage += 1;
508 if self.equilibrium_stage < equilibrium.confirmation_window_multipliers.len() {
509 return Ok(None);
510 }
511 let current = self.arena.last().expect("nonempty window");
512 let max_residual = (start..self.arena.len)
513 .filter_map(|index| self.arena.get(index).scaled_residual)
514 .fold(0.0, f64::max);
515 Ok(Some(TerminationSignal::Equilibrium(
516 EquilibriumDiagnostics {
517 iteration: current.iteration,
518 completed_windows: self.equilibrium_stage,
519 final_window_samples: required,
520 maximum_observable_distance: maximum_distance,
521 relative_mass_range: relative_mass_range(&self.arena, start),
522 maximum_scaled_residual: max_residual,
523 },
524 )))
525 }
526
527 fn maximum_arena_distance_from_mean(&mut self, start: usize) -> f64 {
528 fill_mean_observable(&self.arena, start, &mut self.scratch);
529 (start..self.arena.len)
530 .map(|index| jensen_shannon(self.arena.get(index).detector_observable(), &self.scratch))
531 .fold(0.0, f64::max)
532 }
533
534 fn detect_periodic_orbit(&self) -> Option<TerminationSignal> {
535 let ActivePolicy::Detect(policy) = &self.policy else {
536 return None;
537 };
538 let periodic = policy.periodic_orbit.as_ref()?;
539 for period in periodic.minimum_period_samples..=periodic.maximum_period_samples {
540 let required = period * periodic.repeated_cycles + 1;
541 if self.arena.len < required {
542 continue;
543 }
544 let start = self.arena.len - required;
545 let mut maximum_recurrence: f64 = 0.0;
546 let mut amplitude: f64 = 0.0;
547 for index in (start + period)..self.arena.len {
548 maximum_recurrence = maximum_recurrence.max(jensen_shannon(
549 self.arena.get(index).detector_observable(),
550 self.arena.get(index - period).detector_observable(),
551 ));
552 }
553 for index in (start + 1)..(start + period + 1) {
554 amplitude = amplitude.max(jensen_shannon(
555 self.arena.get(start).detector_observable(),
556 self.arena.get(index).detector_observable(),
557 ));
558 }
559 if maximum_recurrence <= periodic.maximum_recurrence_distance
560 && amplitude >= periodic.minimum_orbit_amplitude
561 {
562 let current = self.arena.last().expect("required samples");
563 return Some(TerminationSignal::PeriodicOrbit(PeriodicOrbitDiagnostics {
564 iteration: current.iteration,
565 period_samples: period,
566 repeated_cycles: periodic.repeated_cycles,
567 first_cycle_iteration: self.arena.get(start).iteration,
568 last_cycle_iteration: current.iteration,
569 maximum_recurrence_distance: maximum_recurrence,
570 orbit_amplitude: amplitude,
571 }));
572 }
573 }
574 None
575 }
576
577 fn average_range(
578 &self,
579 first_iteration: u64,
580 last_iteration: u64,
581 ) -> Result<(Vec<f64>, usize, u64, u64), TrajectoryObserverError> {
582 let start = (0..self.arena.len)
583 .find(|index| self.arena.get(*index).iteration == first_iteration)
584 .ok_or(TrajectoryObserverError::DetectionHistoryUnavailable)?;
585 let end = (start..self.arena.len)
586 .find(|index| self.arena.get(*index).iteration == last_iteration)
587 .ok_or(TrajectoryObserverError::DetectionHistoryUnavailable)?
588 + 1;
589 Ok((
590 average_samples(&self.arena, start, end),
591 end - start,
592 first_iteration,
593 last_iteration,
594 ))
595 }
596}
597
598fn validate_terminal_policy(policy: &TerminalPolicy) -> Result<(), TrajectoryObserverError> {
599 if policy.sample_interval_iterations == 0 || policy.trailing_window_samples == 0 {
600 return Err(TrajectoryObserverError::InvalidPolicy(
601 "terminal sampling values must be positive",
602 ));
603 }
604 Ok(())
605}
606
607fn validate_detection_policy(policy: &DetectionPolicy) -> Result<(), TrajectoryObserverError> {
608 validate_terminal_policy(&policy.terminal)?;
609 if let Some(value) = &policy.equilibrium
610 && (value.base_window_samples == 0
611 || value.confirmation_window_multipliers.is_empty()
612 || value.confirmation_window_multipliers.contains(&0)
613 || !valid_nonnegative(value.maximum_observable_distance)
614 || !valid_nonnegative(value.support_threshold)
615 || value
616 .maximum_relative_mass_range
617 .is_some_and(|limit| !valid_nonnegative(limit))
618 || !valid_positive(value.residual_tolerance.absolute)
619 || !valid_nonnegative(value.residual_tolerance.relative))
620 {
621 return Err(TrajectoryObserverError::InvalidPolicy(
622 "invalid equilibrium policy",
623 ));
624 }
625 if let Some(value) = &policy.periodic_orbit
626 && (value.minimum_period_samples == 0
627 || value.maximum_period_samples < value.minimum_period_samples
628 || value.repeated_cycles < 2
629 || !valid_nonnegative(value.maximum_recurrence_distance)
630 || !valid_positive(value.minimum_orbit_amplitude))
631 {
632 return Err(TrajectoryObserverError::InvalidPolicy(
633 "invalid periodic-orbit policy",
634 ));
635 }
636 Ok(())
637}
638
639fn required_capacity(policy: &ActivePolicy) -> Result<usize, TrajectoryObserverError> {
640 let terminal = match policy {
641 ActivePolicy::TerminalOnly(value) => return Ok(value.trailing_window_samples),
642 ActivePolicy::Detect(value) => &value.terminal,
643 };
644 let ActivePolicy::Detect(detection) = policy else {
645 unreachable!()
646 };
647 let equilibrium = detection
648 .equilibrium
649 .as_ref()
650 .map_or(Some(0), |value| {
651 value.base_window_samples.checked_mul(
652 value
653 .confirmation_window_multipliers
654 .iter()
655 .copied()
656 .max()
657 .unwrap_or(1),
658 )
659 })
660 .ok_or(TrajectoryObserverError::InvalidPolicy(
661 "history capacity overflows usize",
662 ))?;
663 let periodic = detection
664 .periodic_orbit
665 .as_ref()
666 .map_or(Some(0), |value| {
667 value
668 .maximum_period_samples
669 .checked_mul(value.repeated_cycles)?
670 .checked_add(1)
671 })
672 .ok_or(TrajectoryObserverError::InvalidPolicy(
673 "history capacity overflows usize",
674 ))?;
675 Ok(terminal
676 .trailing_window_samples
677 .max(equilibrium)
678 .max(periodic))
679}
680
681fn validate_evidence(
682 abundance: AbundanceView<'_>,
683 observable: Option<&[f64]>,
684 evidence: EquilibriumEvidence<'_>,
685 policy: Option<&EquilibriumPolicy>,
686) -> Result<(f64, Option<f64>, bool), TrajectoryObserverError> {
687 let mass = validate_abundance(abundance)?;
688 if let Some(values) = observable {
689 validate_continuous(values)?;
690 }
691 let Some(policy) = policy else {
692 return match evidence {
693 EquilibriumEvidence::MaximumScaledResidual { value } if !valid_nonnegative(value) => {
694 Err(TrajectoryObserverError::InvalidEvidence)
695 }
696 EquilibriumEvidence::Residual { values }
697 if values.iter().any(|value| !value.is_finite()) =>
698 {
699 Err(TrajectoryObserverError::InvalidEvidence)
700 }
701 EquilibriumEvidence::AbsorbingState => Ok((mass, None, true)),
702 _ => Ok((mass, None, false)),
703 };
704 };
705 match evidence {
706 EquilibriumEvidence::Unavailable => Ok((mass, None, false)),
707 EquilibriumEvidence::AbsorbingState => Ok((mass, None, true)),
708 EquilibriumEvidence::MaximumScaledResidual { value } if valid_nonnegative(value) => {
709 Ok((mass, Some(value), false))
710 }
711 EquilibriumEvidence::MaximumScaledResidual { .. } => {
712 Err(TrajectoryObserverError::InvalidEvidence)
713 }
714 EquilibriumEvidence::Residual { values } => {
715 if values.len() != observable.map_or_else(|| abundance_len(abundance), <[f64]>::len)
716 || values.iter().any(|value| !value.is_finite())
717 {
718 return Err(TrajectoryObserverError::InvalidEvidence);
719 }
720 let scaled = |residual: &f64, state: f64| {
721 residual.abs()
722 / (policy.residual_tolerance.absolute
723 + policy.residual_tolerance.relative * state.abs())
724 };
725 let maximum = if let Some(observed) = observable {
726 values
727 .iter()
728 .zip(observed)
729 .map(|(residual, state)| scaled(residual, *state))
730 .fold(0.0, f64::max)
731 } else {
732 match abundance {
733 AbundanceView::Continuous(observed) => values
734 .iter()
735 .zip(observed)
736 .map(|(residual, state)| scaled(residual, *state))
737 .fold(0.0, f64::max),
738 AbundanceView::Counts(observed) => values
739 .iter()
740 .zip(observed)
741 .map(|(residual, state)| scaled(residual, *state as f64))
742 .fold(0.0, f64::max),
743 }
744 };
745 Ok((mass, Some(maximum), false))
746 }
747 }
748}
749
750fn abundance_len(value: AbundanceView<'_>) -> usize {
751 match value {
752 AbundanceView::Continuous(v) => v.len(),
753 AbundanceView::Counts(v) => v.len(),
754 }
755}
756
757fn validate_abundance(value: AbundanceView<'_>) -> Result<f64, TrajectoryObserverError> {
758 match value {
759 AbundanceView::Continuous(values) => validate_continuous(values),
760 AbundanceView::Counts(values) => {
761 if values.is_empty() {
762 return Err(TrajectoryObserverError::InvalidAbundance);
763 }
764 let total = values
765 .iter()
766 .try_fold(0usize, |sum, value| sum.checked_add(*value))
767 .ok_or(TrajectoryObserverError::InvalidAbundance)?;
768 if total == 0 {
769 Err(TrajectoryObserverError::InvalidAbundance)
770 } else {
771 Ok(total as f64)
772 }
773 }
774 }
775}
776
777fn validate_continuous(values: &[f64]) -> Result<f64, TrajectoryObserverError> {
778 if values.is_empty()
779 || values
780 .iter()
781 .any(|value| !value.is_finite() || *value < 0.0)
782 {
783 return Err(TrajectoryObserverError::InvalidAbundance);
784 }
785 let total = values.iter().sum::<f64>();
786 if valid_positive(total) {
787 Ok(total)
788 } else {
789 Err(TrajectoryObserverError::InvalidAbundance)
790 }
791}
792
793fn normalize_abundance(
794 value: AbundanceView<'_>,
795 output: &mut Vec<f64>,
796) -> Result<(), TrajectoryObserverError> {
797 let total = validate_abundance(value)?;
798 output.clear();
799 match value {
800 AbundanceView::Continuous(values) => {
801 output.extend(values.iter().map(|value| value / total))
802 }
803 AbundanceView::Counts(values) => {
804 output.extend(values.iter().map(|value| *value as f64 / total))
805 }
806 }
807 Ok(())
808}
809
810fn normalize_continuous(
811 values: &[f64],
812 output: &mut Vec<f64>,
813) -> Result<(), TrajectoryObserverError> {
814 let total = validate_continuous(values)?;
815 output.clear();
816 output.extend(values.iter().map(|value| value / total));
817 Ok(())
818}
819
820fn require_stable_dimension(
821 target: &mut Option<usize>,
822 actual: usize,
823 name: &'static str,
824) -> Result<(), TrajectoryObserverError> {
825 match *target {
826 Some(expected) if expected != actual => Err(TrajectoryObserverError::DimensionChanged {
827 name,
828 expected,
829 actual,
830 }),
831 None => {
832 *target = Some(actual);
833 Ok(())
834 }
835 _ => Ok(()),
836 }
837}
838
839fn window_support_is_invariant(arena: &SampleArena, start: usize, threshold: f64) -> bool {
840 let reference = arena.get(start).detector_observable();
841 (start + 1..arena.len)
842 .all(|index| same_support(reference, arena.get(index).detector_observable(), threshold))
843}
844
845fn same_support(left: &[f64], right: &[f64], threshold: f64) -> bool {
846 left.iter()
847 .zip(right)
848 .all(|(left, right)| (*left > threshold) == (*right > threshold))
849}
850
851fn relative_mass_range(arena: &SampleArena, start: usize) -> f64 {
852 let (minimum, maximum) = (start..arena.len).map(|index| arena.get(index).mass).fold(
853 (f64::INFINITY, f64::NEG_INFINITY),
854 |(minimum, maximum), value| (minimum.min(value), maximum.max(value)),
855 );
856 (maximum - minimum) / maximum.max(f64::MIN_POSITIVE)
857}
858
859fn fill_mean_observable(arena: &SampleArena, start: usize, output: &mut Vec<f64>) {
860 output.clear();
861 output.resize(arena.get(start).detector_observable().len(), 0.0);
862 for index in start..arena.len {
863 for (sum, value) in output
864 .iter_mut()
865 .zip(arena.get(index).detector_observable())
866 {
867 *sum += value;
868 }
869 }
870 let count = (arena.len - start) as f64;
871 for value in output {
872 *value /= count;
873 }
874}
875
876fn average_samples(arena: &SampleArena, start: usize, end: usize) -> Vec<f64> {
877 let mut mean = vec![0.0; arena.get(start).composition.len()];
878 for index in start..end {
879 for (sum, value) in mean.iter_mut().zip(&arena.get(index).composition) {
880 *sum += value;
881 }
882 }
883 for value in &mut mean {
884 *value /= (end - start) as f64;
885 }
886 let total = mean.iter().sum::<f64>();
887 for value in &mut mean {
888 *value /= total;
889 }
890 mean
891}
892
893fn jensen_shannon(left: &[f64], right: &[f64]) -> f64 {
894 0.5 * left
895 .iter()
896 .zip(right)
897 .map(|(left, right)| {
898 let middle = 0.5 * (left + right);
899 let a = if *left == 0.0 {
900 0.0
901 } else {
902 left * (left / middle).ln()
903 };
904 let b = if *right == 0.0 {
905 0.0
906 } else {
907 right * (right / middle).ln()
908 };
909 a + b
910 })
911 .sum::<f64>()
912}
913
914const fn valid_nonnegative(value: f64) -> bool {
915 value.is_finite() && value >= 0.0
916}
917const fn valid_positive(value: f64) -> bool {
918 value.is_finite() && value > 0.0
919}
920
921#[derive(Debug, Error)]
922#[non_exhaustive]
923pub enum TrajectoryObserverError {
924 #[error("invalid trajectory-observation policy: {0}")]
925 InvalidPolicy(&'static str),
926 #[error("iterations must increase strictly")]
927 NonIncreasingIteration,
928 #[error("physical time must be finite and increase strictly when present")]
929 InvalidPhysicalTime,
930 #[error("abundance must be nonempty, nonnegative, finite, and have positive finite mass")]
931 InvalidAbundance,
932 #[error("{name} dimension changed from {expected} to {actual}")]
933 DimensionChanged {
934 name: &'static str,
935 expected: usize,
936 actual: usize,
937 },
938 #[error("equilibrium evidence is invalid or does not match the detector observable")]
939 InvalidEvidence,
940 #[error("the presence of a distinct detector observable changed during observation")]
941 DetectorObservableModeChanged,
942 #[error("no observations are available")]
943 NoSamples,
944 #[error("the termination signal does not describe the final iteration")]
945 StopReasonIterationMismatch,
946 #[error("the retained history does not contain the accepted detection interval")]
947 DetectionHistoryUnavailable,
948 #[error(transparent)]
949 TerminalState(#[from] TerminalStateError),
950}