Skip to main content

rlevo_core/
config.rs

1//! Configuration validation: the [`Validate`] trait and [`ConfigError`].
2//!
3//! Across the workspace, dozens of `*Config` and hyperparameter-bearing structs
4//! accept values that are only checked — if at all — deep inside training,
5//! physics, or genetic-operator code. A `v_min == v_max`, a `pop_size == 0`, a
6//! `tau` outside `[0, 1]`, or an inverted `action_clip` surfaces as a confusing
7//! panic (or, worse, a silently wrong result) far from the line that supplied
8//! it. This module is the single shared convention that turns those into a
9//! clear, field-named rejection at construction.
10//!
11//! # The contract
12//!
13//! A config implements [`Validate`], checking its invariants and returning the
14//! **first** violation as a [`ConfigError`] (fail-fast). The *construction
15//! chokepoint* that consumes a caller-supplied config — an environment's
16//! `with_config`, an agent's `new`, a builder's `build`, a harness's `new` —
17//! calls [`Validate`]'s `validate` before the value can reach the algorithm, and
18//! propagates the error as `Result<_, ConfigError>`.
19//!
20//! `Default` construction stays infallible: a library default must itself be
21//! valid, which every implementor guarantees with a
22//! `assert!(Config::default().validate().is_ok())` unit test. That is what lets
23//! the infallible `Default` / `ConstructableEnv::new` paths keep returning
24//! `Self`.
25//!
26//! # Panic vs. `Result` (rules.md §4 / ADR 0026)
27//!
28//! Validating an **assembled config as a whole** returns `Result` — never a
29//! panic — because many configs derive `Deserialize`, so a config loaded from a
30//! file is user-supplied runtime data. A single documented **builder setter**
31//! (`with_capacity(0)`, `with_alpha(x ∉ [0, 1])`) may still panic: the panic
32//! points at the offending call site, and it is an additive fail-fast
33//! convenience, not a substitute for `validate`.
34//!
35//! # Example
36//!
37//! ```
38//! use rlevo_core::config::{self, ConfigError, Validate};
39//!
40//! struct AtomsConfig {
41//!     num_atoms: usize,
42//!     v_min: f32,
43//!     v_max: f32,
44//! }
45//!
46//! impl Validate for AtomsConfig {
47//!     fn validate(&self) -> Result<(), ConfigError> {
48//!         const C: &str = "AtomsConfig";
49//!         config::at_least(C, "num_atoms", self.num_atoms, 2)?;
50//!         config::distinct(C, "v_max", f64::from(self.v_min), f64::from(self.v_max))?;
51//!         config::ordered(C, "v_max", f64::from(self.v_min), f64::from(self.v_max))?;
52//!         Ok(())
53//!     }
54//! }
55//!
56//! let good = AtomsConfig { num_atoms: 51, v_min: -10.0, v_max: 10.0 };
57//! assert!(good.validate().is_ok());
58//!
59//! let bad = AtomsConfig { num_atoms: 51, v_min: 5.0, v_max: 5.0 };
60//! let err = bad.validate().unwrap_err();
61//! assert_eq!(err.field, "v_max");
62//! ```
63
64/// A configuration (or hyperparameter-bearing) type that can check its own
65/// invariants before it is used to construct anything.
66///
67/// Implement this on every public `*Config` and on builders that carry tunable
68/// hyperparameters. Keep the check cheap and purely local — inspect the fields,
69/// never run the algorithm. Return the **first** violated invariant; callers
70/// that want every violation can call `validate` after fixing each one.
71///
72/// The [`config` module helpers](self) (`positive`, `in_range`, `ordered`,
73/// `distinct`, `nonzero`, `at_least`) keep an implementation to one line per
74/// invariant. See the [module documentation](self) for the construction-time
75/// contract and the panic-vs-`Result` rule.
76pub trait Validate {
77    /// Returns `Ok(())` when every invariant holds, or the first
78    /// [`ConfigError`] otherwise.
79    ///
80    /// # Errors
81    ///
82    /// Returns a [`ConfigError`] naming the offending field and the violated
83    /// [`ConstraintKind`] as soon as any invariant fails.
84    fn validate(&self) -> Result<(), ConfigError>;
85
86    /// Returns *every* violated invariant rather than just the first.
87    ///
88    /// The default implementation defers to [`validate`](Self::validate),
89    /// yielding at most one error wrapped in a `Vec`. Override it for configs
90    /// with several **independent** derived fields — CMA-ES recombination
91    /// weights and covariance learning rates being the motivating case — where
92    /// surfacing all violations at once spares the caller a fix-recheck-repeat
93    /// cycle. Accumulate the checks with [`Violations`], and keep [`validate`](Self::validate)
94    /// consistent by deriving it from the first collected error.
95    ///
96    /// # Errors
97    ///
98    /// Returns a non-empty `Vec<ConfigError>` — one entry per violated
99    /// invariant — if any invariant fails.
100    fn validate_all(&self) -> Result<(), Vec<ConfigError>> {
101        self.validate().map_err(|e| vec![e])
102    }
103}
104
105/// Accumulator for [`Validate::validate_all`] implementations.
106///
107/// Feed each check into [`check`](Self::check): a failing [`ConfigError`] is
108/// recorded and evaluation continues, so one pass collects *every* violation.
109/// [`into_result`](Self::into_result) then yields `Ok(())` when nothing failed
110/// or the full list otherwise.
111///
112/// ```
113/// use rlevo_core::config::{self, Violations};
114///
115/// let mut v = Violations::new();
116/// v.check(config::positive("Demo", "a", -1.0)); // fails
117/// v.check(config::nonzero("Demo", "b", 3)); // ok
118/// v.check(config::in_range("Demo", "c", 0.0, 1.0, 2.0)); // fails
119/// let errs = v.into_result().unwrap_err();
120/// assert_eq!(errs.len(), 2);
121/// assert_eq!(errs[0].field, "a");
122/// assert_eq!(errs[1].field, "c");
123/// ```
124#[derive(Debug, Default)]
125pub struct Violations {
126    errors: Vec<ConfigError>,
127}
128
129impl Violations {
130    /// Creates an empty accumulator.
131    #[must_use]
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    /// Records `check` when it failed; a passing check is a no-op. Either way
137    /// evaluation continues, so callers list all their invariants back to back.
138    pub fn check(&mut self, check: Result<(), ConfigError>) {
139        if let Err(e) = check {
140            self.errors.push(e);
141        }
142    }
143
144    /// Consumes the accumulator: `Ok(())` if nothing failed, otherwise every
145    /// collected violation.
146    ///
147    /// # Errors
148    ///
149    /// Returns the non-empty `Vec<ConfigError>` of all recorded violations.
150    pub fn into_result(self) -> Result<(), Vec<ConfigError>> {
151        if self.errors.is_empty() {
152            Ok(())
153        } else {
154            Err(self.errors)
155        }
156    }
157}
158
159/// A single violated configuration invariant.
160///
161/// Names the config type, the offending field, and the specific
162/// [`ConstraintKind`]. The type is allocation-free — every field is `Copy` or
163/// `&'static str` — so validation is cheap and [`ConfigError`] is `PartialEq`
164/// for straightforward assertions in tests.
165///
166/// ```
167/// use rlevo_core::config::{ConfigError, ConstraintKind};
168///
169/// let err = ConfigError {
170///     config: "GaConfig",
171///     field: "pop_size",
172///     kind: ConstraintKind::Zero,
173/// };
174/// assert!(err.to_string().contains("GaConfig.pop_size"));
175/// ```
176#[derive(Debug, Clone, PartialEq, thiserror::Error)]
177#[error("{config}.{field}: {kind}")]
178pub struct ConfigError {
179    /// The config type that failed validation, e.g. `"C51TrainingConfig"`.
180    pub config: &'static str,
181    /// The offending field, e.g. `"v_max"`.
182    pub field: &'static str,
183    /// The specific invariant that was violated.
184    pub kind: ConstraintKind,
185}
186
187/// The closed set of configuration-invariant violations.
188///
189/// Structured rather than string-based (per `rules.md` §4). The `Custom`
190/// variant carries a `&'static str`, never an owned `String`, so
191/// [`ConfigError`] allocates nothing.
192#[derive(Debug, Clone, PartialEq, thiserror::Error)]
193pub enum ConstraintKind {
194    /// Value must lie in the closed interval `[lo, hi]`.
195    #[error("value {got} is out of range [{lo}, {hi}]")]
196    OutOfRange {
197        /// Inclusive lower bound.
198        lo: f64,
199        /// Inclusive upper bound.
200        hi: f64,
201        /// The offending value.
202        got: f64,
203    },
204    /// Value must be strictly positive (`> 0`).
205    #[error("value {got} must be strictly positive")]
206    NotPositive {
207        /// The offending value.
208        got: f64,
209    },
210    /// A `(low, high)` pair must satisfy `low < high`.
211    #[error("{low} must be strictly less than {high}")]
212    NotOrdered {
213        /// The lower value that was not strictly below `high`.
214        low: f64,
215        /// The upper value.
216        high: f64,
217    },
218    /// Two values that must differ are equal (e.g. `v_min == v_max`).
219    #[error("value {value} must differ from its pair")]
220    DegenerateInterval {
221        /// The value shared by both sides of the degenerate interval.
222        value: f64,
223    },
224    /// An integer count / size / capacity must be at least `min`.
225    #[error("count {got} must be at least {min}")]
226    TooSmall {
227        /// The required minimum.
228        min: u64,
229        /// The offending count.
230        got: u64,
231    },
232    /// A count / size / capacity must be non-zero.
233    #[error("count/size must be non-zero")]
234    Zero,
235    /// A one-off invariant with a static explanation.
236    #[error("{0}")]
237    Custom(&'static str),
238}
239
240/// Rejects a value that is not strictly positive (`got > 0`).
241///
242/// # Errors
243///
244/// Returns [`ConstraintKind::NotPositive`] when `got <= 0` (or is `NaN`).
245pub fn positive(config: &'static str, field: &'static str, got: f64) -> Result<(), ConfigError> {
246    if got > 0.0 {
247        Ok(())
248    } else {
249        Err(ConfigError {
250            config,
251            field,
252            kind: ConstraintKind::NotPositive { got },
253        })
254    }
255}
256
257/// Rejects a value outside the closed interval `[lo, hi]`.
258///
259/// # Errors
260///
261/// Returns [`ConstraintKind::OutOfRange`] when `got < lo`, `got > hi`, or `got`
262/// is `NaN`.
263pub fn in_range(
264    config: &'static str,
265    field: &'static str,
266    lo: f64,
267    hi: f64,
268    got: f64,
269) -> Result<(), ConfigError> {
270    if got >= lo && got <= hi {
271        Ok(())
272    } else {
273        Err(ConfigError {
274            config,
275            field,
276            kind: ConstraintKind::OutOfRange { lo, hi, got },
277        })
278    }
279}
280
281/// Rejects a `(low, high)` pair that is not strictly ordered (`low < high`).
282///
283/// # Errors
284///
285/// Returns [`ConstraintKind::NotOrdered`] when `low >= high` (or either is
286/// `NaN`).
287pub fn ordered(
288    config: &'static str,
289    field: &'static str,
290    low: f64,
291    high: f64,
292) -> Result<(), ConfigError> {
293    if low < high {
294        Ok(())
295    } else {
296        Err(ConfigError {
297            config,
298            field,
299            kind: ConstraintKind::NotOrdered { low, high },
300        })
301    }
302}
303
304/// Rejects two values that must differ but are equal.
305///
306/// # Errors
307///
308/// Returns [`ConstraintKind::DegenerateInterval`] when `a == b`.
309pub fn distinct(
310    config: &'static str,
311    field: &'static str,
312    a: f64,
313    b: f64,
314) -> Result<(), ConfigError> {
315    if (a - b).abs() > 0.0 {
316        Ok(())
317    } else {
318        Err(ConfigError {
319            config,
320            field,
321            kind: ConstraintKind::DegenerateInterval { value: a },
322        })
323    }
324}
325
326/// Rejects a zero count / size / capacity.
327///
328/// # Errors
329///
330/// Returns [`ConstraintKind::Zero`] when `n == 0`.
331pub fn nonzero(config: &'static str, field: &'static str, n: usize) -> Result<(), ConfigError> {
332    if n == 0 {
333        Err(ConfigError {
334            config,
335            field,
336            kind: ConstraintKind::Zero,
337        })
338    } else {
339        Ok(())
340    }
341}
342
343/// Rejects an integer count below `min`.
344///
345/// # Errors
346///
347/// Returns [`ConstraintKind::TooSmall`] when `got < min`.
348pub fn at_least(
349    config: &'static str,
350    field: &'static str,
351    got: usize,
352    min: usize,
353) -> Result<(), ConfigError> {
354    if got >= min {
355        Ok(())
356    } else {
357        Err(ConfigError {
358            config,
359            field,
360            kind: ConstraintKind::TooSmall {
361                min: min as u64,
362                got: got as u64,
363            },
364        })
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::{
371        ConfigError, ConstraintKind, Validate, Violations, at_least, distinct, in_range, nonzero,
372        ordered, positive,
373    };
374
375    const C: &str = "TestConfig";
376
377    #[test]
378    fn positive_accepts_and_rejects() {
379        assert!(positive(C, "dt", 0.5).is_ok());
380        let err = positive(C, "dt", 0.0).unwrap_err();
381        assert_eq!(err.field, "dt");
382        assert_eq!(err.kind, ConstraintKind::NotPositive { got: 0.0 });
383        assert!(positive(C, "dt", -1.0).is_err());
384    }
385
386    #[test]
387    fn positive_rejects_nan() {
388        assert!(positive(C, "dt", f64::NAN).is_err());
389    }
390
391    #[test]
392    fn in_range_boundaries_are_inclusive() {
393        assert!(in_range(C, "gamma", 0.0, 1.0, 0.0).is_ok());
394        assert!(in_range(C, "gamma", 0.0, 1.0, 1.0).is_ok());
395        assert!(in_range(C, "gamma", 0.0, 1.0, 0.5).is_ok());
396        let err = in_range(C, "gamma", 0.0, 1.0, 1.5).unwrap_err();
397        assert_eq!(
398            err.kind,
399            ConstraintKind::OutOfRange {
400                lo: 0.0,
401                hi: 1.0,
402                got: 1.5
403            }
404        );
405        assert!(in_range(C, "gamma", 0.0, 1.0, f64::NAN).is_err());
406    }
407
408    #[test]
409    fn ordered_requires_strict() {
410        assert!(ordered(C, "clip", -1.0, 1.0).is_ok());
411        assert!(ordered(C, "clip", 1.0, 1.0).is_err());
412        let err = ordered(C, "clip", 2.0, 1.0).unwrap_err();
413        assert_eq!(
414            err.kind,
415            ConstraintKind::NotOrdered {
416                low: 2.0,
417                high: 1.0
418            }
419        );
420    }
421
422    #[test]
423    fn distinct_rejects_equal() {
424        assert!(distinct(C, "v_max", -10.0, 10.0).is_ok());
425        let err = distinct(C, "v_max", 5.0, 5.0).unwrap_err();
426        assert_eq!(err.kind, ConstraintKind::DegenerateInterval { value: 5.0 });
427    }
428
429    #[test]
430    fn nonzero_and_at_least() {
431        assert!(nonzero(C, "max_steps", 1).is_ok());
432        assert_eq!(
433            nonzero(C, "max_steps", 0).unwrap_err().kind,
434            ConstraintKind::Zero
435        );
436        assert!(at_least(C, "pop_size", 2, 2).is_ok());
437        assert_eq!(
438            at_least(C, "pop_size", 1, 2).unwrap_err().kind,
439            ConstraintKind::TooSmall { min: 2, got: 1 }
440        );
441    }
442
443    #[test]
444    fn violations_accumulate_every_failure() {
445        let mut v = Violations::new();
446        v.check(positive(C, "a", -1.0));
447        v.check(nonzero(C, "b", 4));
448        v.check(in_range(C, "c", 0.0, 1.0, 2.0));
449        let errs = v.into_result().unwrap_err();
450        assert_eq!(errs.len(), 2);
451        assert_eq!(errs[0].field, "a");
452        assert_eq!(errs[1].field, "c");
453    }
454
455    #[test]
456    fn violations_all_ok_is_ok() {
457        let mut v = Violations::new();
458        v.check(positive(C, "a", 1.0));
459        v.check(nonzero(C, "b", 4));
460        assert!(v.into_result().is_ok());
461    }
462
463    #[test]
464    fn validate_all_default_wraps_first_error() {
465        struct One;
466        impl Validate for One {
467            fn validate(&self) -> Result<(), ConfigError> {
468                positive(C, "x", 0.0)
469            }
470        }
471        let errs = One.validate_all().unwrap_err();
472        assert_eq!(errs.len(), 1);
473        assert_eq!(errs[0].field, "x");
474    }
475
476    #[test]
477    fn error_display_format() {
478        let err = ConfigError {
479            config: "C51TrainingConfig",
480            field: "v_max",
481            kind: ConstraintKind::DegenerateInterval { value: 0.0 },
482        };
483        let s = err.to_string();
484        assert!(s.contains("C51TrainingConfig.v_max"));
485        assert!(s.contains("must differ"));
486    }
487}