1use 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}