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}