Skip to main content

ecological_model_core/
terminal_state.rs

1//! Validated terminal ecological composition and long-term-behavior diagnostics.
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6pub const TERMINAL_STATE_FORMAT: &str = "ecological.terminal-state.v1";
7pub const TERMINAL_STATE_METADATA_KEY: &str = "terminal_state";
8
9#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
10#[serde(deny_unknown_fields)]
11pub struct EquilibriumDiagnostics {
12    pub iteration: u64,
13    pub completed_windows: usize,
14    pub final_window_samples: usize,
15    pub maximum_observable_distance: f64,
16    pub relative_mass_range: f64,
17    pub maximum_scaled_residual: f64,
18}
19
20#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
21#[serde(deny_unknown_fields)]
22pub struct PeriodicOrbitDiagnostics {
23    pub iteration: u64,
24    pub period_samples: usize,
25    pub repeated_cycles: usize,
26    pub first_cycle_iteration: u64,
27    pub last_cycle_iteration: u64,
28    pub maximum_recurrence_distance: f64,
29    pub orbit_amplitude: f64,
30}
31
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33#[serde(deny_unknown_fields)]
34pub struct AbsorptionDiagnostics {
35    pub iteration: u64,
36    pub supported_taxa: usize,
37}
38
39#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
40#[serde(tag = "kind", content = "diagnostics", rename_all = "snake_case")]
41pub enum TerminationSignal {
42    Equilibrium(EquilibriumDiagnostics),
43    PeriodicOrbit(PeriodicOrbitDiagnostics),
44    AbsorbingState(AbsorptionDiagnostics),
45}
46
47impl TerminationSignal {
48    pub const fn iteration(&self) -> u64 {
49        match self {
50            Self::Equilibrium(value) => value.iteration,
51            Self::PeriodicOrbit(value) => value.iteration,
52            Self::AbsorbingState(value) => value.iteration,
53        }
54    }
55}
56
57#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
58#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
59pub enum StopReason {
60    Detected(TerminationSignal),
61    MaximumIterations,
62    Requested,
63    ModelSpecific(String),
64}
65
66impl StopReason {
67    pub const fn signal(&self) -> Option<&TerminationSignal> {
68        match self {
69            Self::Detected(signal) => Some(signal),
70            _ => None,
71        }
72    }
73}
74
75#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
76#[serde(rename_all = "snake_case")]
77pub enum TerminalClassification {
78    Equilibrium,
79    PeriodicOrbit,
80    AbsorbingState,
81    TrailingAverage,
82}
83
84#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
85#[serde(deny_unknown_fields)]
86pub struct TerminalState {
87    format: String,
88    classification: TerminalClassification,
89    stop_reason: StopReason,
90    iteration: u64,
91    physical_time: Option<f64>,
92    composition: Vec<f64>,
93    sample_count: usize,
94    first_sample_iteration: u64,
95    last_sample_iteration: u64,
96}
97
98impl TerminalState {
99    #[allow(clippy::too_many_arguments)]
100    pub(crate) fn new(
101        classification: TerminalClassification,
102        stop_reason: StopReason,
103        iteration: u64,
104        physical_time: Option<f64>,
105        composition: Vec<f64>,
106        sample_count: usize,
107        first_sample_iteration: u64,
108        last_sample_iteration: u64,
109    ) -> Result<Self, TerminalStateError> {
110        let state = Self {
111            format: TERMINAL_STATE_FORMAT.to_owned(),
112            classification,
113            stop_reason,
114            iteration,
115            physical_time,
116            composition,
117            sample_count,
118            first_sample_iteration,
119            last_sample_iteration,
120        };
121        state.validate()?;
122        Ok(state)
123    }
124
125    pub fn format(&self) -> &str {
126        &self.format
127    }
128    pub const fn classification(&self) -> TerminalClassification {
129        self.classification
130    }
131    pub const fn stop_reason(&self) -> &StopReason {
132        &self.stop_reason
133    }
134    pub const fn iteration(&self) -> u64 {
135        self.iteration
136    }
137    pub const fn physical_time(&self) -> Option<f64> {
138        self.physical_time
139    }
140    pub fn composition(&self) -> &[f64] {
141        &self.composition
142    }
143    pub const fn sample_count(&self) -> usize {
144        self.sample_count
145    }
146    pub const fn first_sample_iteration(&self) -> u64 {
147        self.first_sample_iteration
148    }
149    pub const fn last_sample_iteration(&self) -> u64 {
150        self.last_sample_iteration
151    }
152    pub fn to_json_bytes(&self) -> Result<Vec<u8>, TerminalStateError> {
153        Ok(serde_json::to_vec(self)?)
154    }
155    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, TerminalStateError> {
156        let state: Self = serde_json::from_slice(bytes)?;
157        state.validate()?;
158        Ok(state)
159    }
160
161    pub fn validate(&self) -> Result<(), TerminalStateError> {
162        if self.format != TERMINAL_STATE_FORMAT
163            || self.sample_count == 0
164            || self.first_sample_iteration > self.last_sample_iteration
165            || self.last_sample_iteration > self.iteration
166            || self.physical_time.is_some_and(|value| !value.is_finite())
167        {
168            return Err(TerminalStateError::InvalidProduct);
169        }
170        validate_composition(&self.composition)?;
171        match (&self.classification, &self.stop_reason) {
172            (
173                TerminalClassification::Equilibrium,
174                StopReason::Detected(TerminationSignal::Equilibrium(value)),
175            ) if value.iteration == self.iteration && self.sample_count == 1 => {}
176            (
177                TerminalClassification::PeriodicOrbit,
178                StopReason::Detected(TerminationSignal::PeriodicOrbit(value)),
179            ) if value.iteration == self.iteration
180                && self.sample_count >= value.period_samples
181                && self.first_sample_iteration == value.first_cycle_iteration
182                && self.last_sample_iteration == value.last_cycle_iteration => {}
183            (
184                TerminalClassification::AbsorbingState,
185                StopReason::Detected(TerminationSignal::AbsorbingState(value)),
186            ) if value.iteration == self.iteration && self.sample_count == 1 => {}
187            (
188                TerminalClassification::TrailingAverage,
189                StopReason::MaximumIterations | StopReason::Requested,
190            ) => {}
191            (TerminalClassification::TrailingAverage, StopReason::ModelSpecific(value))
192                if !value.trim().is_empty() => {}
193            _ => return Err(TerminalStateError::ClassificationMismatch),
194        }
195        Ok(())
196    }
197}
198
199fn validate_composition(values: &[f64]) -> Result<(), TerminalStateError> {
200    if values.is_empty()
201        || values
202            .iter()
203            .any(|value| !value.is_finite() || *value < 0.0)
204    {
205        return Err(TerminalStateError::InvalidComposition);
206    }
207    let total = values.iter().sum::<f64>();
208    if !total.is_finite() || (total - 1.0).abs() > 1.0e-10 {
209        return Err(TerminalStateError::InvalidComposition);
210    }
211    Ok(())
212}
213
214#[derive(Debug, Error)]
215#[non_exhaustive]
216pub enum TerminalStateError {
217    #[error("terminal-state document is structurally invalid")]
218    InvalidProduct,
219    #[error("terminal composition must be nonempty, finite, nonnegative, and normalized")]
220    InvalidComposition,
221    #[error("terminal classification and stop reason are inconsistent")]
222    ClassificationMismatch,
223    #[error(transparent)]
224    Json(#[from] serde_json::Error),
225}