Skip to main content

ecological_model_core/
trajectory.rs

1//! Allocation-conscious observation of ecological trajectories.
2
3use 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
152/// A bounded observer. Construct it with [`TrajectoryObserver::from_policy`]
153/// so disabled observation allocates nothing.
154pub 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    /// Returns `None` for `Disabled`, ensuring that mode has no observer or allocation.
174    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    /// Lets a model avoid calculating a residual on observations that will not be retained.
211    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    /// Completes the observer and always incorporates `final_observation`, even off cadence.
233    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}