Skip to main content

limbic_critic/
error.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Typed errors for checked critic assessment.
4//!
5//! [`SimpleCritic::try_assess`](crate::SimpleCritic::try_assess) and
6//! [`TDCritic::try_assess`](crate::TDCritic::try_assess) reject non-finite
7//! environment observations before any modulator is produced and, for the
8//! temporal critic, before internal state is committed. The compatibility
9//! [`assess`](crate::SimpleCritic::assess) methods keep their documented IEEE
10//! `f32` passthrough (including `TDCritic` state poisoning) — see those
11//! methods and the crate changelog for the migration path.
12
13/// Classification of a non-finite `f32` that failed critic validation.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum NonFiniteKind {
17    /// IEEE NaN (any payload, including negative-NaN).
18    Nan,
19    /// `+∞`.
20    PositiveInfinity,
21    /// `−∞`.
22    NegativeInfinity,
23}
24
25impl NonFiniteKind {
26    /// Classify `value` as non-finite, or `None` when it is finite.
27    ///
28    /// Finite values include subnormals and signed zero.
29    #[must_use]
30    pub fn classify(value: f32) -> Option<Self> {
31        if value.is_nan() {
32            Some(Self::Nan)
33        } else if value.is_infinite() {
34            if value.is_sign_positive() {
35                Some(Self::PositiveInfinity)
36            } else {
37                Some(Self::NegativeInfinity)
38            }
39        } else {
40            None
41        }
42    }
43}
44
45impl std::fmt::Display for NonFiniteKind {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::Nan => f.write_str("NaN"),
49            Self::PositiveInfinity => f.write_str("+inf"),
50            Self::NegativeInfinity => f.write_str("-inf"),
51        }
52    }
53}
54
55/// A validated field or computed intermediate in a critic assessment.
56///
57/// Input channels (`Objective`, `Volatility`, `Surprise`, `Stress`) are
58/// read from [`Environment`](crate::Environment). Intermediates (`TdError`,
59/// `EmaReward`) are computed by [`TDCritic`](crate::TDCritic) and checked
60/// before state is committed.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62#[non_exhaustive]
63pub enum CriticField {
64    /// [`Environment::objective`](crate::Environment::objective).
65    Objective,
66    /// [`Environment::volatility`](crate::Environment::volatility).
67    Volatility,
68    /// [`Environment::surprise`](crate::Environment::surprise).
69    ///
70    /// Validated by [`SimpleCritic::try_assess`](crate::SimpleCritic::try_assess)
71    /// only. [`TDCritic`](crate::TDCritic) ignores this channel.
72    Surprise,
73    /// [`Environment::stress`](crate::Environment::stress).
74    Stress,
75    /// `objective − prev_objective` inside [`TDCritic`](crate::TDCritic).
76    TdError,
77    /// The EMA of TD errors inside [`TDCritic`](crate::TDCritic).
78    EmaReward,
79}
80
81impl std::fmt::Display for CriticField {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.write_str(match self {
84            Self::Objective => "objective",
85            Self::Volatility => "volatility",
86            Self::Surprise => "surprise",
87            Self::Stress => "stress",
88            Self::TdError => "TD error",
89            Self::EmaReward => "EMA reward",
90        })
91    }
92}
93
94/// A checked assessment failed because a value was not finite.
95///
96/// Each error names the field that failed and whether the value was NaN or
97/// an infinity. Successful [`try_assess`](crate::SimpleCritic::try_assess)
98/// results have finite modulator fields in the critic's documented ranges.
99///
100/// # Example
101///
102/// ```rust
103/// use limbic_critic::{
104///     CriticError, CriticField, Environment, NonFiniteKind, SimpleCritic,
105/// };
106///
107/// struct NanObjective;
108/// impl Environment for NanObjective {
109///     fn objective(&self) -> f32 {
110///         f32::NAN
111///     }
112/// }
113///
114/// let err = SimpleCritic::try_assess(&NanObjective).unwrap_err();
115/// assert_eq!(
116///     err,
117///     CriticError::NonFinite {
118///         field: CriticField::Objective,
119///         kind: NonFiniteKind::Nan,
120///     }
121/// );
122/// ```
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124#[non_exhaustive]
125pub enum CriticError {
126    /// A raw observation or a computed intermediate was NaN or ±∞.
127    NonFinite {
128        /// Which input channel or intermediate failed validation.
129        field: CriticField,
130        /// Whether the value was NaN or an infinity.
131        kind: NonFiniteKind,
132    },
133}
134
135impl CriticError {
136    /// Construct a [`NonFinite`](Self::NonFinite) error from a non-finite `f32`.
137    ///
138    /// Returns `None` when `value` is finite.
139    #[must_use]
140    pub fn from_non_finite(field: CriticField, value: f32) -> Option<Self> {
141        NonFiniteKind::classify(value).map(|kind| Self::NonFinite { field, kind })
142    }
143
144    /// The field or intermediate that failed validation.
145    #[must_use]
146    pub const fn field(&self) -> CriticField {
147        match *self {
148            Self::NonFinite { field, .. } => field,
149        }
150    }
151
152    /// Whether the rejected value was NaN or an infinity.
153    #[must_use]
154    pub const fn kind(&self) -> NonFiniteKind {
155        match *self {
156            Self::NonFinite { kind, .. } => kind,
157        }
158    }
159}
160
161impl std::fmt::Display for CriticError {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        match *self {
164            Self::NonFinite { field, kind } => {
165                write!(f, "non-finite {field} ({kind})")
166            }
167        }
168    }
169}
170
171impl std::error::Error for CriticError {}
172
173/// Reject `value` unless it is finite.
174pub(crate) fn require_finite(value: f32, field: CriticField) -> Result<f32, CriticError> {
175    match CriticError::from_non_finite(field, value) {
176        Some(err) => Err(err),
177        None => Ok(value),
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn classify_finite_and_nonfinite() {
187        assert_eq!(NonFiniteKind::classify(0.0), None);
188        assert_eq!(NonFiniteKind::classify(-0.0), None);
189        assert_eq!(NonFiniteKind::classify(f32::MIN_POSITIVE), None);
190        assert_eq!(NonFiniteKind::classify(f32::MAX), None);
191        assert_eq!(NonFiniteKind::classify(f32::NAN), Some(NonFiniteKind::Nan));
192        assert_eq!(
193            NonFiniteKind::classify(f32::INFINITY),
194            Some(NonFiniteKind::PositiveInfinity)
195        );
196        assert_eq!(
197            NonFiniteKind::classify(f32::NEG_INFINITY),
198            Some(NonFiniteKind::NegativeInfinity)
199        );
200    }
201
202    #[test]
203    fn display_and_error_trait() {
204        let err = CriticError::NonFinite {
205            field: CriticField::Objective,
206            kind: NonFiniteKind::Nan,
207        };
208        assert_eq!(err.to_string(), "non-finite objective (NaN)");
209        assert_eq!(
210            CriticError::NonFinite {
211                field: CriticField::Volatility,
212                kind: NonFiniteKind::PositiveInfinity,
213            }
214            .to_string(),
215            "non-finite volatility (+inf)"
216        );
217        assert_eq!(
218            CriticError::NonFinite {
219                field: CriticField::Stress,
220                kind: NonFiniteKind::NegativeInfinity,
221            }
222            .to_string(),
223            "non-finite stress (-inf)"
224        );
225        let as_error: &dyn std::error::Error = &err;
226        assert!(as_error.source().is_none());
227        assert_eq!(err.field(), CriticField::Objective);
228        assert_eq!(err.kind(), NonFiniteKind::Nan);
229        assert_eq!(CriticField::Surprise.to_string(), "surprise");
230        assert_eq!(CriticField::TdError.to_string(), "TD error");
231        assert_eq!(CriticField::EmaReward.to_string(), "EMA reward");
232    }
233
234    #[test]
235    fn from_non_finite_skips_finite() {
236        assert!(CriticError::from_non_finite(CriticField::Objective, 1.0).is_none());
237        assert_eq!(require_finite(1.5, CriticField::Surprise).unwrap(), 1.5);
238        assert!(require_finite(f32::NAN, CriticField::Surprise).is_err());
239    }
240}