Skip to main content

cobre_io/config/
mod.rs

1//! Configuration types for `config.json`.
2//!
3//! [`Config`] is the top-level deserialized representation of `config.json`.
4//! Use [`parse_config`] to load and validate the file.
5//!
6//! All optional sections use `#[serde(default)]` so that a minimal `config.json`
7//! containing only the mandatory `training` fields deserializes cleanly.
8//!
9//! # Mandatory fields
10//!
11//! The following fields have no defaults and must be present in `config.json`:
12//!
13//! - `training.selection` — the scenario-selection method (carries the
14//!   forward-pass count in its `sampled` arm)
15//! - `training.stopping_rules` — at least one rule entry (must include `iteration_limit`)
16//!
17//! # Examples
18//!
19//! ```no_run
20//! use cobre_io::config::parse_config;
21//! use std::path::Path;
22//!
23//! let cfg = parse_config(Path::new("case/config.json")).unwrap();
24//! println!("forward passes = {:?}", cfg.resolve_forward_passes());
25//! ```
26
27use serde_json::{Map, Value};
28pub mod estimation;
29pub mod exports;
30pub mod modeling;
31pub mod policy;
32pub mod scenario_source;
33pub mod simulation;
34pub mod training;
35
36pub use estimation::{EstimationConfig, OrderSelectionMethod};
37pub use exports::ExportsConfig;
38pub use modeling::{InflowNonNegativityConfig, InflowNonNegativityMethod, ModelingConfig};
39pub use policy::{BoundaryPolicy, CheckpointingConfig, PolicyConfig, PolicyMode};
40pub use scenario_source::{
41    HistoricalYearRange, Openings, RawClassConfigEntry, RawHistoricalYearsConfig,
42    RawSamplingScheme, RawScenarioSourceConfig,
43};
44pub use simulation::{NumScenariosResolution, SimulationConfig, SimulationSelection};
45pub use training::{
46    BackwardScheduler, DualEdgeWeight, ForwardPassesResolution, LipschitzConfig, ParallelismConfig,
47    PhaseSolverProfileConfig, PresolveMode, PriceStrategy, RowSelectionConfig, ScaleStrategy,
48    SelectionMethod, StoppingMode, StoppingRuleConfig, TrainingConfig, TrainingSelection,
49    TrainingSolverConfig, UpperBoundEvaluationConfig,
50};
51
52use simulation::DEFAULT_NUM_SCENARIOS;
53
54use cobre_core::scenario::{HistoricalYears, SamplingScheme, ScenarioSource};
55
56use crate::LoadError;
57use serde::{Deserialize, Serialize};
58use std::path::{Path, PathBuf};
59
60/// Top-level deserialized representation of `config.json`.
61///
62/// All sections except `training` are optional; their defaults are applied by
63/// serde when the section is absent from the JSON.
64#[derive(Debug, Clone, Deserialize, Serialize)]
65#[serde(deny_unknown_fields)]
66#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
67pub struct Config {
68    /// JSON schema URI — informational, not validated.
69    #[serde(rename = "$schema")]
70    pub schema: Option<String>,
71
72    /// Modeling options (inflow non-negativity treatment).
73    #[serde(default)]
74    pub modeling: ModelingConfig,
75
76    /// Training parameters — contains mandatory fields.
77    pub training: TrainingConfig,
78
79    /// Upper-bound evaluation via inner approximation.
80    #[serde(default)]
81    pub upper_bound_evaluation: UpperBoundEvaluationConfig,
82
83    /// Policy directory settings (warm-start / resume).
84    #[serde(default)]
85    pub policy: PolicyConfig,
86
87    /// Post-training simulation settings.
88    #[serde(default)]
89    pub simulation: SimulationConfig,
90
91    /// Export flags controlling which outputs are written to disk.
92    #[serde(default)]
93    pub exports: ExportsConfig,
94
95    /// Time series estimation settings for automatic model parameter fitting.
96    #[serde(default)]
97    pub estimation: EstimationConfig,
98}
99
100/// Load and validate `config.json` from `path`.
101///
102/// Reads the JSON file, deserializes it into a [`Config`] struct (applying
103/// `#[serde(default)]` for optional sections), then performs post-deserialization
104/// validation of mandatory fields.
105///
106/// # Errors
107///
108/// | Condition                         | Error variant                 |
109/// | --------------------------------- | ----------------------------- |
110/// | File not found / read failure     | [`LoadError::IoError`]        |
111/// | Invalid JSON syntax               | [`LoadError::ParseError`]     |
112/// | `training.selection` missing      | [`LoadError::SchemaError`]    |
113/// | `training.stopping_rules` missing | [`LoadError::SchemaError`]    |
114/// | Unknown stopping rule `"type"`    | [`LoadError::SchemaError`]    |
115///
116/// # Examples
117///
118/// ```no_run
119/// use cobre_io::config::parse_config;
120/// use std::path::Path;
121///
122/// let cfg = parse_config(Path::new("case/config.json")).unwrap();
123/// assert!(cfg.resolve_forward_passes().is_some());
124/// ```
125pub fn parse_config(path: &Path) -> Result<Config, LoadError> {
126    let raw = std::fs::read_to_string(path).map_err(|e| LoadError::io(path, e))?;
127
128    let config: Config = serde_json::from_str(&raw).map_err(|e| {
129        let msg = e.to_string();
130        if msg.contains("unknown variant") || msg.contains("missing field") {
131            LoadError::SchemaError {
132                path: path.to_path_buf(),
133                field: extract_field_from_serde_msg(&msg),
134                message: msg,
135            }
136        } else {
137            LoadError::parse(path, msg)
138        }
139    })?;
140
141    validate_config(&config, path)?;
142
143    Ok(config)
144}
145
146/// Extract a field-name hint (the first backtick-quoted identifier) from a
147/// `serde_json` error message, or `"<unknown>"` when none is present.
148fn extract_field_from_serde_msg(msg: &str) -> String {
149    if let Some(start) = msg.find('`')
150        && let Some(end) = msg[start + 1..].find('`')
151    {
152        return msg[start + 1..start + 1 + end].to_string();
153    }
154    "<unknown>".to_string()
155}
156
157/// Post-deserialization validation that the mandatory `forward_passes` and
158/// `stopping_rules` fields are present.
159pub(crate) fn validate_config(config: &Config, path: &Path) -> Result<(), LoadError> {
160    if config.resolve_forward_passes().is_none() {
161        return Err(LoadError::SchemaError {
162            path: path.to_path_buf(),
163            field: "training.selection".to_string(),
164            message: "a forward-pass count is required via training.selection".to_string(),
165        });
166    }
167
168    if config.training.stopping_rules.is_none() {
169        return Err(LoadError::SchemaError {
170            path: path.to_path_buf(),
171            field: "training.stopping_rules".to_string(),
172            message: "required field is missing".to_string(),
173        });
174    }
175
176    Ok(())
177}
178
179// ── ScenarioSource helpers ───────────────────────────────────────────────────
180
181/// Map a per-class config entry to its [`SamplingScheme`], defaulting to
182/// `InSample` when the entry is absent. Infallible: an unknown scheme is
183/// rejected at parse by [`RawSamplingScheme`]'s `Deserialize`.
184fn convert_class_scheme_cfg(class: Option<&RawClassConfigEntry>) -> SamplingScheme {
185    match class.map(|c| c.scheme) {
186        None | Some(RawSamplingScheme::InSample) => SamplingScheme::InSample,
187        Some(RawSamplingScheme::OutOfSample) => SamplingScheme::OutOfSample,
188        Some(RawSamplingScheme::External) => SamplingScheme::External,
189        Some(RawSamplingScheme::Historical) => SamplingScheme::Historical,
190    }
191}
192
193/// Convert `Option<RawScenarioSourceConfig>` into a [`ScenarioSource`].
194///
195/// `section` is either `"training"` or `"simulation"`, used to build field
196/// paths in error messages that reference `config.json`.
197///
198/// Returns `ScenarioSource::default()` (all `InSample`, no seed, no years)
199/// when `raw` is `None`.
200fn convert_scenario_source_config(
201    raw: Option<&RawScenarioSourceConfig>,
202    section: &str,
203    path: &Path,
204) -> Result<ScenarioSource, LoadError> {
205    let Some(r) = raw else {
206        return Ok(ScenarioSource::default());
207    };
208
209    let inflow_scheme = convert_class_scheme_cfg(r.inflow.as_ref());
210    let load_scheme = convert_class_scheme_cfg(r.load.as_ref());
211    let ncs_scheme = convert_class_scheme_cfg(r.ncs.as_ref());
212
213    let source = ScenarioSource {
214        inflow_scheme,
215        load_scheme,
216        ncs_scheme,
217        seed: r.seed,
218        historical_years: r.historical_years.as_ref().map(|hy| match hy {
219            RawHistoricalYearsConfig::List(years) => HistoricalYears::List(years.clone()),
220            RawHistoricalYearsConfig::Range(range) => HistoricalYears::Range {
221                from: range.from,
222                to: range.to,
223            },
224        }),
225    };
226
227    validate_scenario_source_cfg(&source, section, path)?;
228    validate_openings_cfg(r.openings.as_ref(), section, path)?;
229
230    Ok(source)
231}
232
233/// Validate a declared `openings` source from `config.json`: `generated` and
234/// `file` are both admitted under `training`; any declaration outside the
235/// `training` section is rejected.
236fn validate_openings_cfg(
237    openings: Option<&Openings>,
238    section: &str,
239    path: &Path,
240) -> Result<(), LoadError> {
241    if openings.is_none() {
242        return Ok(());
243    }
244
245    if section != "training" {
246        return Err(LoadError::SchemaError {
247            path: path.to_path_buf(),
248            field: format!("{section}.scenario_source.openings"),
249            message: format!(
250                "openings is only valid under training.scenario_source, not \
251                 {section}.scenario_source"
252            ),
253        });
254    }
255
256    Ok(())
257}
258
259/// Tier-1 structural validation of a parsed [`ScenarioSource`] from `config.json`.
260fn validate_scenario_source_cfg(
261    source: &ScenarioSource,
262    section: &str,
263    path: &Path,
264) -> Result<(), LoadError> {
265    let uses_historical = source.inflow_scheme == SamplingScheme::Historical
266        || source.load_scheme == SamplingScheme::Historical
267        || source.ncs_scheme == SamplingScheme::Historical;
268
269    if source.historical_years.is_some() && !uses_historical {
270        return Err(LoadError::SchemaError {
271            path: path.to_path_buf(),
272            field: format!("{section}.scenario_source.historical_years"),
273            message: "historical_years is specified but no class uses the 'historical' scheme"
274                .to_string(),
275        });
276    }
277
278    if source.load_scheme == SamplingScheme::Historical {
279        return Err(LoadError::SchemaError {
280            path: path.to_path_buf(),
281            field: format!("{section}.scenario_source.load.scheme"),
282            message: "historical scheme is only valid for the inflow class".to_string(),
283        });
284    }
285
286    if source.ncs_scheme == SamplingScheme::Historical {
287        return Err(LoadError::SchemaError {
288            path: path.to_path_buf(),
289            field: format!("{section}.scenario_source.ncs.scheme"),
290            message: "historical scheme is only valid for the inflow class".to_string(),
291        });
292    }
293
294    let all_in_sample = source.inflow_scheme == SamplingScheme::InSample
295        && source.load_scheme == SamplingScheme::InSample
296        && source.ncs_scheme == SamplingScheme::InSample;
297    if !all_in_sample && source.seed.is_none() {
298        return Err(LoadError::SchemaError {
299            path: path.to_path_buf(),
300            field: format!("{section}.scenario_source.seed"),
301            message:
302                "seed is required when any class uses out_of_sample, historical, or external scheme"
303                    .to_string(),
304        });
305    }
306
307    if let Some(HistoricalYears::Range { from, to }) = source.historical_years
308        && from > to
309    {
310        return Err(LoadError::SchemaError {
311            path: path.to_path_buf(),
312            field: format!("{section}.scenario_source.historical_years"),
313            message: format!("range 'from' ({from}) must be <= 'to' ({to})"),
314        });
315    }
316
317    Ok(())
318}
319
320impl Config {
321    /// Resolve the training-phase [`ScenarioSource`].
322    ///
323    /// When `training.scenario_source` is absent, returns `ScenarioSource::default()`
324    /// (all classes `InSample`, no seed, no historical years).
325    ///
326    /// # Errors
327    ///
328    /// Returns `LoadError::SchemaError` if the raw config contains an invalid
329    /// scheme string, Historical on a non-inflow class, or seed/year validation
330    /// failures.
331    ///
332    /// # Examples
333    ///
334    /// ```no_run
335    /// use cobre_io::config::parse_config;
336    /// use std::path::Path;
337    ///
338    /// let cfg = parse_config(Path::new("case/config.json")).unwrap();
339    /// let source = cfg.training_scenario_source(Path::new("case/config.json")).unwrap();
340    /// ```
341    pub fn training_scenario_source(&self, path: &Path) -> Result<ScenarioSource, LoadError> {
342        convert_scenario_source_config(self.training.scenario_source.as_ref(), "training", path)
343    }
344
345    /// Resolve the simulation-phase [`ScenarioSource`].
346    ///
347    /// Falls back to `training_scenario_source()` when
348    /// `simulation.scenario_source` is absent.
349    ///
350    /// # Errors
351    ///
352    /// Returns `LoadError::SchemaError` on validation failures in either the
353    /// simulation or training scenario source.
354    ///
355    /// # Examples
356    ///
357    /// ```no_run
358    /// use cobre_io::config::parse_config;
359    /// use std::path::Path;
360    ///
361    /// let cfg = parse_config(Path::new("case/config.json")).unwrap();
362    /// let source = cfg.simulation_scenario_source(Path::new("case/config.json")).unwrap();
363    /// ```
364    pub fn simulation_scenario_source(&self, path: &Path) -> Result<ScenarioSource, LoadError> {
365        if self.simulation.scenario_source.is_some() {
366            convert_scenario_source_config(
367                self.simulation.scenario_source.as_ref(),
368                "simulation",
369                path,
370            )
371        } else {
372            self.training_scenario_source(path)
373        }
374    }
375
376    /// The training-phase `openings` source declaration, or `None` when absent
377    /// (equivalent to `generated`). Single owner of the training `openings`
378    /// lookup that downstream opening-tree selection reads.
379    #[must_use]
380    pub fn training_openings(&self) -> Option<&Openings> {
381        self.training
382            .scenario_source
383            .as_ref()
384            .and_then(|s| s.openings.as_ref())
385    }
386
387    /// Resolve the effective training forward-pass method from
388    /// `training.selection`.
389    ///
390    /// Returns `None` when `selection` is absent, leaving the mandatory-count
391    /// rejection to [`validate_config`]. An `enumerated` selection resolves to
392    /// [`ForwardPassesResolution::Enumerated`] — config load holds no policy
393    /// graph, so the count itself is derived downstream.
394    #[must_use]
395    pub fn resolve_forward_passes(&self) -> Option<ForwardPassesResolution> {
396        match &self.training.selection {
397            Some(TrainingSelection::Enumerated {}) => Some(ForwardPassesResolution::Enumerated),
398            Some(TrainingSelection::Sampled { forward_passes }) => {
399                Some(ForwardPassesResolution::Sampled(*forward_passes))
400            }
401            None => None,
402        }
403    }
404
405    /// Resolve the effective simulation scenario-count method from
406    /// `simulation.selection`, defaulting to [`DEFAULT_NUM_SCENARIOS`] when
407    /// absent.
408    ///
409    /// An `enumerated` selection resolves to
410    /// [`NumScenariosResolution::Enumerated`] — config load holds no policy
411    /// graph, so the derived count happens downstream.
412    #[must_use]
413    pub fn resolve_num_scenarios(&self) -> NumScenariosResolution {
414        match &self.simulation.selection {
415            Some(SimulationSelection::Enumerated {}) => NumScenariosResolution::Enumerated,
416            Some(SimulationSelection::Sampled { num_scenarios }) => {
417                NumScenariosResolution::Sampled(*num_scenarios)
418            }
419            None => NumScenariosResolution::Sampled(DEFAULT_NUM_SCENARIOS),
420        }
421    }
422
423    /// Deep-merge a flat map of dotted-key overrides into `base` and re-deserialize
424    /// the result into a validated [`Config`].
425    ///
426    /// `base` is the parsed-but-not-typed `config.json` (a [`serde_json::Value::Object`]).
427    /// `overrides` is a flat map whose keys are dotted paths into the config schema
428    /// (e.g. `"training.tree_seed"`, `"policy.checkpointing.compress"`). Intermediate
429    /// objects are reused rather than replaced, so setting `policy.checkpointing.compress`
430    /// does not clobber sibling keys under `policy` or `policy.checkpointing`.
431    ///
432    /// After merging, the value is re-deserialized into [`Config`]. Because `Config`
433    /// is `#[serde(deny_unknown_fields)]`, an override key that does not exist in the
434    /// schema (a typo such as `trainning.tree_seed`) fails loudly. The same
435    /// post-deserialization checks as [`parse_config`] then run via `validate_config`.
436    ///
437    /// All errors carry the synthetic path `"<config_overrides>"` so callers can
438    /// recognize override-originated failures.
439    ///
440    /// # Errors
441    ///
442    /// - [`LoadError::SchemaError`] if `base` is not a JSON object.
443    /// - [`LoadError::SchemaError`] if any override key contains an empty path segment
444    ///   (e.g. `"training..seed"` or a leading/trailing dot).
445    /// - [`LoadError::SchemaError`] if the merged value fails to deserialize into
446    ///   [`Config`] (e.g. an unknown field) or fails `validate_config`.
447    pub fn with_overrides(
448        base: &Value,
449        overrides: &Map<String, Value>,
450    ) -> Result<Config, LoadError> {
451        if !base.is_object() {
452            return Err(LoadError::SchemaError {
453                path: PathBuf::from("<config_overrides>"),
454                field: "<root>".to_string(),
455                message: "base config must be a JSON object".to_string(),
456            });
457        }
458
459        let mut merged = base.clone();
460        for (dotted_key, value) in overrides {
461            Self::set_dotted(&mut merged, dotted_key, value.clone())?;
462        }
463
464        let config: Config = serde_json::from_value(merged).map_err(|e| {
465            let msg = e.to_string();
466            LoadError::SchemaError {
467                path: PathBuf::from("<config_overrides>"),
468                field: extract_field_from_serde_msg(&msg),
469                message: msg,
470            }
471        })?;
472
473        validate_config(&config, Path::new("<config_overrides>")).map(|()| config)
474    }
475
476    /// Deep-merge `value` into `target` at the dotted path `dotted_key`, reusing
477    /// existing intermediate objects so sibling keys survive.
478    ///
479    /// # Errors
480    ///
481    /// Returns [`LoadError::SchemaError`] (with `field` set to the offending
482    /// `dotted_key`) when any path segment is empty — i.e. an empty key, a leading or
483    /// trailing dot, or a doubled dot such as `"training..seed"`.
484    fn set_dotted(target: &mut Value, dotted_key: &str, value: Value) -> Result<(), LoadError> {
485        let segments: Vec<&str> = dotted_key.split('.').collect();
486        if segments.iter().any(|s| s.is_empty()) {
487            return Err(LoadError::SchemaError {
488                path: PathBuf::from("<config_overrides>"),
489                field: dotted_key.to_string(),
490                message: format!("override key has an empty path segment: `{dotted_key}`"),
491            });
492        }
493
494        let mut current = target;
495        for segment in &segments[..segments.len() - 1] {
496            // Reuse the existing intermediate object rather than replacing it; a
497            // replace clobbers sibling keys, breaking the deep-merge contract.
498            if !current.is_object() {
499                *current = serde_json::Value::Object(serde_json::Map::new());
500            }
501            let serde_json::Value::Object(map) = current else {
502                unreachable!("current was just coerced to an object")
503            };
504            current = map
505                .entry((*segment).to_string())
506                .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
507        }
508
509        // The empty-segment guard above rejects the only `dotted_key` that could
510        // make `segments` empty, so this last index never panics.
511        let last = segments[segments.len() - 1];
512        if !current.is_object() {
513            *current = serde_json::Value::Object(serde_json::Map::new());
514        }
515        let serde_json::Value::Object(map) = current else {
516            unreachable!("current was just coerced to an object")
517        };
518        map.insert(last.to_string(), value);
519
520        Ok(())
521    }
522}
523
524// ── Tests ────────────────────────────────────────────────────────────────────
525
526#[cfg(test)]
527#[allow(
528    clippy::unwrap_used,
529    clippy::expect_used,
530    clippy::panic,
531    clippy::too_many_lines,
532    clippy::doc_markdown
533)]
534mod tests {
535    use super::*;
536    use std::io::Write;
537    use tempfile::NamedTempFile;
538
539    fn write_config(content: &str) -> NamedTempFile {
540        let mut f = NamedTempFile::new().unwrap();
541        f.write_all(content.as_bytes()).unwrap();
542        f
543    }
544
545    /// Minimal config returns Ok with correct forward_passes and all
546    /// optional sections at their default values.
547    #[test]
548    fn test_parse_minimal_config() {
549        let f = write_config(
550            r#"{"training": {"tree_seed": 42, "selection": {"method": "sampled", "forward_passes": 192}, "stopping_rules": [{"type": "iteration_limit", "limit": 50}]}}"#,
551        );
552        let cfg = parse_config(f.path()).unwrap();
553
554        assert_eq!(
555            cfg.resolve_forward_passes(),
556            Some(ForwardPassesResolution::Sampled(192))
557        );
558        assert_eq!(cfg.training.tree_seed, Some(42));
559        assert_eq!(cfg.training.stopping_mode, StoppingMode::Any);
560        assert!(cfg.training.enabled);
561        assert_eq!(
562            cfg.modeling.inflow_non_negativity.method,
563            InflowNonNegativityMethod::Penalty
564        );
565        assert!(!cfg.simulation.enabled);
566        assert_eq!(
567            cfg.resolve_num_scenarios(),
568            NumScenariosResolution::Sampled(2000),
569            "absent simulation selection resolves to the default sampled count"
570        );
571        assert_eq!(cfg.policy.mode, PolicyMode::Fresh);
572        assert_eq!(cfg.policy.path, "./policy");
573    }
574
575    /// The retired `state_space` section (inflow-lag depth is now always inferred
576    /// from the boundary policy) is a `deny_unknown_fields` reject, not a silent
577    /// ignore — a stale `config.json` fails loudly with the field name.
578    #[test]
579    fn test_retired_state_space_section_is_rejected() {
580        let f = write_config(
581            r#"{"training": {"selection": {"method": "sampled", "forward_passes": 1}, "stopping_rules": [{"type": "iteration_limit", "limit": 10}]}, "state_space": {"inflow_lag_depth": 12}}"#,
582        );
583        let err = parse_config(f.path()).unwrap_err();
584        assert!(
585            err.to_string().contains("state_space"),
586            "expected an unknown-field error naming state_space, got: {err}"
587        );
588    }
589
590    /// Missing `training.selection` (no forward-pass count) → SchemaError
591    /// with field name.
592    #[test]
593    fn test_missing_forward_passes() {
594        let f = write_config(
595            r#"{"training": {"tree_seed": 1, "stopping_rules": [{"type": "iteration_limit", "limit": 10}]}}"#,
596        );
597        let err = parse_config(f.path()).unwrap_err();
598        match &err {
599            LoadError::SchemaError { field, .. } => {
600                assert!(
601                    field.contains("selection"),
602                    "field should name training.selection, got: {field}"
603                );
604            }
605            other => panic!("expected SchemaError, got: {other:?}"),
606        }
607    }
608
609    /// Missing `training.stopping_rules` → SchemaError.
610    #[test]
611    fn test_missing_stopping_rules() {
612        let f = write_config(
613            r#"{"training": {"tree_seed": 1, "selection": {"method": "sampled", "forward_passes": 100}}}"#,
614        );
615        let err = parse_config(f.path()).unwrap_err();
616        match &err {
617            LoadError::SchemaError { field, .. } => {
618                assert!(
619                    field.contains("stopping_rules"),
620                    "field should contain 'stopping_rules', got: {field}"
621                );
622            }
623            other => panic!("expected SchemaError, got: {other:?}"),
624        }
625    }
626
627    /// Nonexistent file → IoError with matching path.
628    #[test]
629    fn test_nonexistent_file() {
630        let path = std::path::Path::new("/nonexistent/path/config.json");
631        let err = parse_config(path).unwrap_err();
632        match &err {
633            LoadError::IoError { path: p, .. } => {
634                assert_eq!(p, path);
635            }
636            other => panic!("expected IoError, got: {other:?}"),
637        }
638    }
639
640    /// Full config with all sections → Ok with non-default values.
641    #[test]
642    fn test_parse_full_config() {
643        let json = r#"{
644          "$schema": "https://raw.githubusercontent.com/cobre-rs/cobre/refs/heads/main/schemas/config.schema.json",
645          "modeling": {
646            "inflow_non_negativity": {
647              "method": "penalty"
648            }
649          },
650          "training": {
651            "tree_seed": 42,
652            "selection": {"method": "sampled", "forward_passes": 192},
653            "stopping_rules": [
654              {"type": "iteration_limit", "limit": 50},
655              {"type": "bound_stalling", "iterations": 10, "tolerance": 0.0001}
656            ],
657            "stopping_mode": "any",
658            "cut_selection": {
659              "selection": {
660                "method": "domination",
661                "domination_tolerance": 1e-6
662              }
663            }
664          },
665          "upper_bound_evaluation": {
666            "enabled": true,
667            "initial_iteration": 10,
668            "interval_iterations": 5
669          },
670          "policy": {
671            "path": "./policy",
672            "mode": "fresh",
673            "checkpointing": {
674              "enabled": true,
675              "initial_iteration": 10,
676              "interval_iterations": 10,
677              "store_basis": true,
678              "compress": true
679            }
680          },
681          "simulation": {
682            "enabled": true,
683            "selection": {"method": "sampled", "num_scenarios": 2000}
684          },
685          "exports": {
686            "states": true,
687            "stochastic": true
688          }
689        }"#;
690
691        let f = write_config(json);
692        let cfg = parse_config(f.path()).unwrap();
693
694        assert_eq!(
695            cfg.modeling.inflow_non_negativity.method,
696            InflowNonNegativityMethod::Penalty
697        );
698
699        assert_eq!(
700            cfg.resolve_forward_passes(),
701            Some(ForwardPassesResolution::Sampled(192))
702        );
703        assert_eq!(cfg.training.stopping_mode, StoppingMode::Any);
704        let rules = cfg.training.stopping_rules.as_ref().unwrap();
705        assert_eq!(rules.len(), 2);
706        let cut_sel = &cfg.training.cut_selection;
707        match cut_sel.selection.as_ref().expect("selection present") {
708            SelectionMethod::Domination {
709                domination_tolerance,
710                check_frequency,
711            } => {
712                assert!((domination_tolerance - 1e-6).abs() < f64::EPSILON);
713                assert_eq!(*check_frequency, 5);
714            }
715            other => panic!("expected Domination, got {other:?}"),
716        }
717
718        assert_eq!(cfg.upper_bound_evaluation.enabled, Some(true));
719        assert_eq!(cfg.upper_bound_evaluation.initial_iteration, Some(10));
720
721        assert_eq!(cfg.policy.mode, PolicyMode::Fresh);
722        assert_eq!(cfg.policy.checkpointing.enabled, Some(true));
723
724        assert!(cfg.simulation.enabled);
725        assert_eq!(
726            cfg.resolve_num_scenarios(),
727            NumScenariosResolution::Sampled(2000)
728        );
729
730        assert!(cfg.exports.states);
731        assert!(cfg.exports.stochastic);
732    }
733
734    /// Invalid JSON syntax → ParseError.
735    #[test]
736    fn test_invalid_json_syntax() {
737        let f = write_config(r#"{"training": {not valid json}}"#);
738        let err = parse_config(f.path()).unwrap_err();
739        assert!(
740            matches!(err, LoadError::ParseError { .. }),
741            "expected ParseError, got: {err:?}"
742        );
743    }
744
745    /// All 4 JSON-configurable stopping rule variants deserialize correctly.
746    ///
747    /// The `GracefulShutdown` variant is runtime-only and has no JSON representation
748    /// per the stopping-rule-trait spec (SS4.1).
749    #[test]
750    fn test_stopping_rule_variants() {
751        let json = r#"{
752          "training": {
753            "selection": {"method": "sampled", "forward_passes": 10},
754            "stopping_rules": [
755              {"type": "iteration_limit", "limit": 100},
756              {"type": "time_limit", "seconds": 3600.0},
757              {"type": "bound_stalling", "iterations": 10, "tolerance": 0.0001},
758              {
759                "type": "gap",
760                "tolerance": 1000.0
761              }
762            ]
763          }
764        }"#;
765
766        let f = write_config(json);
767        let cfg = parse_config(f.path()).unwrap();
768        let rules = cfg.training.stopping_rules.unwrap();
769        assert_eq!(rules.len(), 4);
770
771        assert!(matches!(
772            rules[0],
773            StoppingRuleConfig::IterationLimit { limit: 100 }
774        ));
775        assert!(
776            matches!(rules[1], StoppingRuleConfig::TimeLimit { seconds } if (seconds - 3600.0).abs() < f64::EPSILON)
777        );
778        assert!(matches!(
779            rules[2],
780            StoppingRuleConfig::BoundStalling { iterations: 10, .. }
781        ));
782        assert!(matches!(
783            rules[3],
784            StoppingRuleConfig::Gap {
785                tolerance: Some(t),
786                ..
787            } if (t - 1000.0).abs() < f64::EPSILON
788        ));
789    }
790
791    /// Unknown stopping rule type → SchemaError (not a panic or ParseError).
792    #[test]
793    fn test_unknown_stopping_rule_type() {
794        let f = write_config(
795            r#"{"training": {"selection": {"method": "sampled", "forward_passes": 10}, "stopping_rules": [{"type": "nonexistent_rule"}]}}"#,
796        );
797        let err = parse_config(f.path()).unwrap_err();
798        assert!(
799            matches!(err, LoadError::SchemaError { .. }),
800            "expected SchemaError for unknown rule type, got: {err:?}"
801        );
802    }
803
804    /// The retired `simulation` stopping-rule type (replaced by `gap`) is
805    /// rejected as an unknown variant, not a recognized-but-invalid one.
806    #[test]
807    fn old_simulation_stopping_rule_type_is_unknown_variant() {
808        let f = write_config(
809            r#"{"training": {"selection": {"method": "sampled", "forward_passes": 10}, "stopping_rules": [
810                {"type": "simulation", "replications": 100, "period": 20,
811                 "bound_window": 5, "distance_tol": 0.01, "bound_tol": 0.0001}
812            ]}}"#,
813        );
814        let err = parse_config(f.path()).unwrap_err();
815        assert!(
816            matches!(err, LoadError::SchemaError { .. }),
817            "expected SchemaError for the retired simulation rule type, got: {err:?}"
818        );
819    }
820
821    /// `Config` has no `version` field — the struct does not
822    /// expose `.version` and the field is not present after deserialization.
823    #[test]
824    fn test_config_has_no_version_field() {
825        let f = write_config(
826            r#"{"training": {"selection": {"method": "sampled", "forward_passes": 1}, "stopping_rules": [{"type": "iteration_limit", "limit": 10}]}}"#,
827        );
828        let cfg = parse_config(f.path()).unwrap();
829        assert!(cfg.schema.is_none(), "schema should be None when absent");
830    }
831
832    /// JSON with `"$schema"` property is accepted and the field
833    /// value is stored correctly.
834    #[test]
835    fn test_schema_field_accepted() {
836        let f = write_config(
837            r#"{
838            "$schema": "https://raw.githubusercontent.com/cobre-rs/cobre/refs/heads/main/schemas/config.schema.json",
839            "training": {
840                "selection": {"method": "sampled", "forward_passes": 1},
841                "stopping_rules": [{"type": "iteration_limit", "limit": 10}]
842            }
843        }"#,
844        );
845        let cfg = parse_config(f.path()).unwrap();
846        assert_eq!(
847            cfg.schema.as_deref(),
848            Some(
849                "https://raw.githubusercontent.com/cobre-rs/cobre/refs/heads/main/schemas/config.schema.json"
850            ),
851            "schema field should be stored when present in JSON"
852        );
853    }
854
855    /// Invalid `policy.mode` values are rejected at parse time.
856    #[test]
857    fn test_invalid_policy_mode_rejected() {
858        let f = write_config(
859            r#"{"training": {"selection": {"method": "sampled", "forward_passes": 1}, "stopping_rules": [{"type": "iteration_limit", "limit": 10}]}, "policy": {"mode": "warmstart"}}"#,
860        );
861        let err = parse_config(f.path()).unwrap_err();
862        assert!(
863            matches!(err, LoadError::SchemaError { .. }),
864            "expected SchemaError for invalid policy.mode, got: {err:?}"
865        );
866    }
867
868    /// JSON that contains the dead `"version"` property must now be rejected
869    /// because `Config` uses `deny_unknown_fields`. Old case dirs that still
870    /// contain this key will fail to parse — which is the desired behaviour.
871    #[test]
872    fn test_legacy_version_field_rejected() {
873        let f = write_config(
874            r#"{
875            "version": "1.0.0",
876            "training": {
877                "selection": {"method": "sampled", "forward_passes": 1},
878                "stopping_rules": [{"type": "iteration_limit", "limit": 10}]
879            }
880        }"#,
881        );
882        let err = parse_config(f.path()).unwrap_err();
883        assert!(
884            matches!(
885                err,
886                LoadError::ParseError { .. } | LoadError::SchemaError { .. }
887            ),
888            "expected parse/schema error for unknown 'version' field, got: {err:?}"
889        );
890    }
891
892    /// A config that still sets the removed `policy.validate_compatibility` flag
893    /// is rejected: `PolicyConfig` uses `deny_unknown_fields`, so the stale key
894    /// fails deserialization with an error naming the field.
895    #[test]
896    fn test_stale_validate_compatibility_field_rejected() {
897        let f = write_config(
898            r#"{
899            "training": {
900                "selection": {"method": "sampled", "forward_passes": 1},
901                "stopping_rules": [{"type": "iteration_limit", "limit": 10}]
902            },
903            "policy": {
904                "validate_compatibility": false
905            }
906        }"#,
907        );
908        let err = parse_config(f.path()).unwrap_err();
909        let msg = err.to_string();
910        assert!(
911            msg.contains("validate_compatibility"),
912            "expected the stale field name in the rejection error, got: {msg}"
913        );
914    }
915
916    /// `"truncation"` is accepted as a method value and round-trips correctly
917    /// through `parse_config`.
918    #[test]
919    fn test_truncation_method_accepted() {
920        let f = write_config(
921            r#"{
922            "modeling": {
923                "inflow_non_negativity": {
924                    "method": "truncation"
925                }
926            },
927            "training": {
928                "selection": {"method": "sampled", "forward_passes": 10},
929                "stopping_rules": [{"type": "iteration_limit", "limit": 5}]
930            }
931        }"#,
932        );
933        let cfg = parse_config(f.path()).unwrap();
934        assert_eq!(
935            cfg.modeling.inflow_non_negativity.method,
936            InflowNonNegativityMethod::Truncation,
937            "method field should round-trip as Truncation"
938        );
939    }
940
941    /// An unknown inflow non-negativity method string is rejected at parse time.
942    #[test]
943    fn test_unknown_inflow_method_rejected() {
944        let f = write_config(
945            r#"{
946            "modeling": {
947                "inflow_non_negativity": {
948                    "method": "bogus_method"
949                }
950            },
951            "training": {
952                "selection": {"method": "sampled", "forward_passes": 10},
953                "stopping_rules": [{"type": "iteration_limit", "limit": 5}]
954            }
955        }"#,
956        );
957        let err = parse_config(f.path()).unwrap_err();
958        assert!(
959            matches!(
960                err,
961                LoadError::SchemaError { .. } | LoadError::ParseError { .. }
962            ),
963            "expected parse/schema error for unknown method, got: {err:?}"
964        );
965    }
966
967    /// `config.json` without `"estimation"` section → all three defaults applied.
968    #[test]
969    fn test_estimation_config_defaults() {
970        let f = write_config(
971            r#"{"training": {"selection": {"method": "sampled", "forward_passes": 10}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]}}"#,
972        );
973        let cfg = parse_config(f.path()).unwrap();
974        assert_eq!(cfg.estimation.max_order, 6);
975        assert!(
976            matches!(cfg.estimation.order_selection, OrderSelectionMethod::Pacf),
977            "default order_selection should be Pacf"
978        );
979        assert_eq!(cfg.estimation.min_observations_per_season, 30);
980    }
981
982    /// `"order_selection": "fixed"` is now a hard parse error.
983    #[test]
984    fn test_estimation_config_order_selection_fixed_rejected() {
985        let f = write_config(
986            r#"{
987            "training": {"selection": {"method": "sampled", "forward_passes": 10}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]},
988            "estimation": {"max_order": 3, "order_selection": "fixed", "min_observations_per_season": 20}
989        }"#,
990        );
991        let result = parse_config(f.path());
992        assert!(
993            result.is_err(),
994            "\"fixed\" order_selection must now be a parse error"
995        );
996    }
997
998    /// `"order_selection": "pacf"` deserializes to `Pacf` with no warning.
999    #[test]
1000    fn test_estimation_config_order_selection_pacf() {
1001        let f = write_config(
1002            r#"{
1003            "training": {"selection": {"method": "sampled", "forward_passes": 10}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]},
1004            "estimation": {"max_order": 4, "order_selection": "pacf", "min_observations_per_season": 15}
1005        }"#,
1006        );
1007        let cfg = parse_config(f.path()).unwrap();
1008        assert_eq!(cfg.estimation.max_order, 4);
1009        assert!(
1010            matches!(cfg.estimation.order_selection, OrderSelectionMethod::Pacf),
1011            "explicit 'pacf' must deserialize to Pacf"
1012        );
1013        assert_eq!(cfg.estimation.min_observations_per_season, 15);
1014    }
1015
1016    /// Unknown `order_selection` value → `LoadError::SchemaError` with
1017    /// message containing `"unknown variant"`.
1018    #[test]
1019    fn test_estimation_config_unknown_order_selection() {
1020        let f = write_config(
1021            r#"{
1022            "training": {"selection": {"method": "sampled", "forward_passes": 10}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]},
1023            "estimation": {"order_selection": "bogus"}
1024        }"#,
1025        );
1026        let err = parse_config(f.path()).unwrap_err();
1027        match &err {
1028            LoadError::SchemaError { message, .. } => {
1029                assert!(
1030                    message.contains("unknown variant"),
1031                    "message should contain 'unknown variant', got: {message}"
1032                );
1033            }
1034            other => panic!("expected SchemaError, got: {other:?}"),
1035        }
1036    }
1037
1038    /// `exports.stochastic: true` deserializes correctly.
1039    #[test]
1040    fn test_exports_stochastic_explicit_true() {
1041        let f = write_config(
1042            r#"{
1043            "training": {"selection": {"method": "sampled", "forward_passes": 10}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]},
1044            "exports": {"stochastic": true}
1045        }"#,
1046        );
1047        let cfg = parse_config(f.path()).unwrap();
1048        assert!(
1049            cfg.exports.stochastic,
1050            "exports.stochastic should be true when set in config"
1051        );
1052    }
1053
1054    /// `exports.stochastic` defaults to `false` when the field is absent.
1055    #[test]
1056    fn test_exports_stochastic_defaults_to_false() {
1057        let f = write_config(
1058            r#"{
1059            "training": {"selection": {"method": "sampled", "forward_passes": 10}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]}
1060        }"#,
1061        );
1062        let cfg = parse_config(f.path()).unwrap();
1063        assert!(
1064            !cfg.exports.stochastic,
1065            "exports.stochastic should default to false when absent"
1066        );
1067    }
1068
1069    /// `exports.fpha_deviation_points: true` deserializes correctly.
1070    #[test]
1071    fn test_exports_fpha_deviation_points_explicit_true() {
1072        let f = write_config(
1073            r#"{
1074            "training": {"selection": {"method": "sampled", "forward_passes": 10}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]},
1075            "exports": {"fpha_deviation_points": true}
1076        }"#,
1077        );
1078        let cfg = parse_config(f.path()).unwrap();
1079        assert!(
1080            cfg.exports.fpha_deviation_points,
1081            "exports.fpha_deviation_points should be true when set in config"
1082        );
1083    }
1084
1085    /// `exports.fpha_deviation_points` defaults to `false` when absent, so the
1086    /// flag-off run emits no deviation-points file and is byte-identical.
1087    #[test]
1088    fn test_exports_fpha_deviation_points_defaults_to_false() {
1089        let f = write_config(
1090            r#"{
1091            "training": {"selection": {"method": "sampled", "forward_passes": 10}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]}
1092        }"#,
1093        );
1094        let cfg = parse_config(f.path()).unwrap();
1095        assert!(
1096            !cfg.exports.fpha_deviation_points,
1097            "exports.fpha_deviation_points should default to false when absent"
1098        );
1099    }
1100
1101    // ── ScenarioSource parsing tests ──────────────────────────────────────────
1102
1103    const MINIMAL_TRAINING: &str = r#"{"selection": {"method": "sampled", "forward_passes": 10}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]}"#;
1104
1105    fn write_with_training_scenario_source(scenario_source_json: &str) -> NamedTempFile {
1106        write_config(&format!(
1107            r#"{{"training": {{"selection": {{"method": "sampled", "forward_passes": 10}}, "stopping_rules": [{{"type": "iteration_limit", "limit": 5}}], "scenario_source": {scenario_source_json}}}}}"#
1108        ))
1109    }
1110
1111    fn write_with_both_scenario_sources(
1112        training_json: &str,
1113        simulation_json: &str,
1114    ) -> NamedTempFile {
1115        write_config(&format!(
1116            r#"{{"training": {{"selection": {{"method": "sampled", "forward_passes": 10}}, "stopping_rules": [{{"type": "iteration_limit", "limit": 5}}], "scenario_source": {training_json}}}, "simulation": {{"scenario_source": {simulation_json}}}}}"#
1117        ))
1118    }
1119
1120    /// Absent `training.scenario_source` → all InSample, no seed, no historical_years.
1121    #[test]
1122    fn test_training_scenario_source_default() {
1123        let f = write_config(&format!(r#"{{"training": {MINIMAL_TRAINING}}}"#));
1124        let cfg = parse_config(f.path()).unwrap();
1125        let source = cfg.training_scenario_source(f.path()).unwrap();
1126        assert_eq!(source, ScenarioSource::default());
1127        assert_eq!(source.inflow_scheme, SamplingScheme::InSample);
1128        assert_eq!(source.load_scheme, SamplingScheme::InSample);
1129        assert_eq!(source.ncs_scheme, SamplingScheme::InSample);
1130        assert_eq!(source.seed, None);
1131        assert_eq!(source.historical_years, None);
1132    }
1133
1134    /// Explicit per-class schemes are parsed correctly.
1135    #[test]
1136    fn test_training_scenario_source_explicit() {
1137        let f = write_with_training_scenario_source(
1138            r#"{"seed": 42, "inflow": {"scheme": "historical"}, "historical_years": [1940, 1953]}"#,
1139        );
1140        let cfg = parse_config(f.path()).unwrap();
1141        let source = cfg.training_scenario_source(f.path()).unwrap();
1142        assert_eq!(source.inflow_scheme, SamplingScheme::Historical);
1143        assert_eq!(source.load_scheme, SamplingScheme::InSample);
1144        assert_eq!(source.ncs_scheme, SamplingScheme::InSample);
1145        assert_eq!(source.seed, Some(42));
1146        assert_eq!(
1147            source.historical_years,
1148            Some(HistoricalYears::List(vec![1940, 1953]))
1149        );
1150    }
1151
1152    /// Absent `simulation.scenario_source` falls back to `training_scenario_source()`.
1153    #[test]
1154    fn test_simulation_scenario_source_fallback() {
1155        let f = write_with_training_scenario_source(
1156            r#"{"seed": 7, "inflow": {"scheme": "out_of_sample"}}"#,
1157        );
1158        let cfg = parse_config(f.path()).unwrap();
1159        let training = cfg.training_scenario_source(f.path()).unwrap();
1160        let simulation = cfg.simulation_scenario_source(f.path()).unwrap();
1161        assert_eq!(training, simulation);
1162        assert_eq!(simulation.inflow_scheme, SamplingScheme::OutOfSample);
1163        assert_eq!(simulation.seed, Some(7));
1164    }
1165
1166    /// Both sections present with different schemes → different `ScenarioSource` values returned.
1167    #[test]
1168    fn test_simulation_scenario_source_independent() {
1169        let f = write_with_both_scenario_sources(
1170            r#"{"seed": 1, "inflow": {"scheme": "out_of_sample"}}"#,
1171            r#"{"seed": 2, "load": {"scheme": "out_of_sample"}}"#,
1172        );
1173        let cfg = parse_config(f.path()).unwrap();
1174        let training = cfg.training_scenario_source(f.path()).unwrap();
1175        let simulation = cfg.simulation_scenario_source(f.path()).unwrap();
1176        assert_ne!(training, simulation);
1177        assert_eq!(training.inflow_scheme, SamplingScheme::OutOfSample);
1178        assert_eq!(training.load_scheme, SamplingScheme::InSample);
1179        assert_eq!(simulation.inflow_scheme, SamplingScheme::InSample);
1180        assert_eq!(simulation.load_scheme, SamplingScheme::OutOfSample);
1181    }
1182
1183    /// Historical scheme on inflow class is accepted.
1184    #[test]
1185    fn test_scenario_source_historical_inflow_valid() {
1186        let f = write_with_training_scenario_source(
1187            r#"{"seed": 99, "inflow": {"scheme": "historical"}}"#,
1188        );
1189        let cfg = parse_config(f.path()).unwrap();
1190        let source = cfg.training_scenario_source(f.path()).unwrap();
1191        assert_eq!(source.inflow_scheme, SamplingScheme::Historical);
1192    }
1193
1194    /// Historical on load class → SchemaError.
1195    #[test]
1196    fn test_scenario_source_historical_load_rejected() {
1197        let f = write_config(&format!(
1198            r#"{{"training": {MINIMAL_TRAINING}, "simulation": {{"scenario_source": {{"seed": 1, "load": {{"scheme": "historical"}}}}}}}}"#
1199        ));
1200        let cfg = parse_config(f.path()).unwrap();
1201        let err = cfg.simulation_scenario_source(f.path()).unwrap_err();
1202        match &err {
1203            LoadError::SchemaError { message, field, .. } => {
1204                assert!(
1205                    message.contains("historical scheme is only valid for the inflow class"),
1206                    "unexpected message: {message}"
1207                );
1208                assert!(field.contains("load.scheme"), "unexpected field: {field}");
1209            }
1210            other => panic!("expected SchemaError, got: {other:?}"),
1211        }
1212    }
1213
1214    /// Historical on ncs class → SchemaError.
1215    #[test]
1216    fn test_scenario_source_historical_ncs_rejected() {
1217        let f =
1218            write_with_training_scenario_source(r#"{"seed": 1, "ncs": {"scheme": "historical"}}"#);
1219        let cfg = parse_config(f.path()).unwrap();
1220        let err = cfg.training_scenario_source(f.path()).unwrap_err();
1221        match &err {
1222            LoadError::SchemaError { message, field, .. } => {
1223                assert!(
1224                    message.contains("historical scheme is only valid for the inflow class"),
1225                    "unexpected message: {message}"
1226                );
1227                assert!(field.contains("ncs.scheme"), "unexpected field: {field}");
1228            }
1229            other => panic!("expected SchemaError, got: {other:?}"),
1230        }
1231    }
1232
1233    /// An unknown per-class `scheme` is rejected during parse, and the error
1234    /// names the accepted set.
1235    #[test]
1236    fn unknown_scheme_is_rejected_naming_accepted_set() {
1237        let f = write_with_training_scenario_source(r#"{"inflow": {"scheme": "antithetic"}}"#);
1238        let err = parse_config(f.path()).unwrap_err();
1239        match &err {
1240            LoadError::SchemaError { message, .. } => {
1241                assert!(
1242                    message.contains("in_sample")
1243                        && message.contains("out_of_sample")
1244                        && message.contains("external")
1245                        && message.contains("historical"),
1246                    "message should name the accepted set, got: {message}"
1247                );
1248            }
1249            other => panic!("expected SchemaError, got: {other:?}"),
1250        }
1251    }
1252
1253    /// `stopping_mode = "any"` / `"all"` both parse into the enum — the accepted
1254    /// set is unchanged by the representation promotion.
1255    #[test]
1256    fn stopping_mode_any_and_all_parse() {
1257        for (value, expected) in [("any", StoppingMode::Any), ("all", StoppingMode::All)] {
1258            let f = write_config(&format!(
1259                r#"{{"training": {{"selection": {{"method": "sampled", "forward_passes": 10}}, "stopping_rules": [{{"type": "iteration_limit", "limit": 5}}], "stopping_mode": "{value}"}}}}"#
1260            ));
1261            let cfg = parse_config(f.path()).unwrap();
1262            assert_eq!(cfg.training.stopping_mode, expected);
1263        }
1264    }
1265
1266    /// An unknown `stopping_mode` is rejected during parse (the tightening: no
1267    /// silent fallback to `any`), and the error names the accepted set.
1268    #[test]
1269    fn unknown_stopping_mode_is_rejected_naming_accepted_set() {
1270        let f = write_config(
1271            r#"{"training": {"selection": {"method": "sampled", "forward_passes": 10}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}], "stopping_mode": "either"}}"#,
1272        );
1273        let err = parse_config(f.path()).unwrap_err();
1274        match &err {
1275            LoadError::SchemaError { message, .. } => {
1276                assert!(
1277                    message.contains("any") && message.contains("all"),
1278                    "message should name the accepted set, got: {message}"
1279                );
1280            }
1281            other => panic!("expected SchemaError, got: {other:?}"),
1282        }
1283    }
1284
1285    /// OutOfSample without seed → SchemaError.
1286    #[test]
1287    fn test_scenario_source_seed_required_for_oos() {
1288        let f = write_with_training_scenario_source(r#"{"inflow": {"scheme": "out_of_sample"}}"#);
1289        let cfg = parse_config(f.path()).unwrap();
1290        let err = cfg.training_scenario_source(f.path()).unwrap_err();
1291        match &err {
1292            LoadError::SchemaError { message, field, .. } => {
1293                assert!(
1294                    message.contains("seed is required"),
1295                    "unexpected message: {message}"
1296                );
1297                assert!(field.contains("seed"), "unexpected field: {field}");
1298            }
1299            other => panic!("expected SchemaError, got: {other:?}"),
1300        }
1301    }
1302
1303    /// Range form of `historical_years` parses correctly.
1304    #[test]
1305    fn test_scenario_source_historical_years_range() {
1306        let f = write_with_training_scenario_source(
1307            r#"{"seed": 5, "inflow": {"scheme": "historical"}, "historical_years": {"from": 1940, "to": 2010}}"#,
1308        );
1309        let cfg = parse_config(f.path()).unwrap();
1310        let source = cfg.training_scenario_source(f.path()).unwrap();
1311        assert_eq!(
1312            source.historical_years,
1313            Some(HistoricalYears::Range {
1314                from: 1940,
1315                to: 2010
1316            })
1317        );
1318    }
1319
1320    /// `historical_years` specified without any Historical scheme → SchemaError.
1321    #[test]
1322    fn test_scenario_source_historical_years_without_historical_scheme() {
1323        let f = write_with_training_scenario_source(
1324            r#"{"seed": 1, "inflow": {"scheme": "out_of_sample"}, "historical_years": [1990, 2000]}"#,
1325        );
1326        let cfg = parse_config(f.path()).unwrap();
1327        let err = cfg.training_scenario_source(f.path()).unwrap_err();
1328        match &err {
1329            LoadError::SchemaError { message, .. } => {
1330                assert!(
1331                    message.contains(
1332                        "historical_years is specified but no class uses the 'historical' scheme"
1333                    ),
1334                    "unexpected message: {message}"
1335                );
1336            }
1337            other => panic!("expected SchemaError, got: {other:?}"),
1338        }
1339    }
1340
1341    // ── openings source tests ─────────────────────────────────────────────────
1342
1343    /// A declared `generated` openings source under training is admitted, and
1344    /// the resolved `ScenarioSource` is unchanged (openings lives on the raw
1345    /// config, not `ScenarioSource`).
1346    #[test]
1347    fn openings_generated_accepted_under_training() {
1348        let f = write_with_training_scenario_source(r#"{"openings": {"source": "generated"}}"#);
1349        let cfg = parse_config(f.path()).unwrap();
1350        let source = cfg.training_scenario_source(f.path()).unwrap();
1351        assert_eq!(source, ScenarioSource::default());
1352    }
1353
1354    /// The dropped `external` openings source is now an unknown-variant parse
1355    /// error — the arm no longer exists (`generated` and `file` remain).
1356    #[test]
1357    fn openings_external_is_unknown_variant_parse_error() {
1358        let f = write_with_training_scenario_source(r#"{"openings": {"source": "external"}}"#);
1359        let err = parse_config(f.path()).unwrap_err();
1360        match &err {
1361            LoadError::SchemaError { message, .. } => {
1362                assert!(
1363                    message.contains("unknown variant"),
1364                    "unexpected message: {message}"
1365                );
1366            }
1367            other => panic!("expected SchemaError (unknown variant), got: {other:?}"),
1368        }
1369    }
1370
1371    /// A `file` openings source under training is admitted, and
1372    /// `training_openings()` surfaces the declared `File` arm.
1373    #[test]
1374    fn openings_file_accepted_under_training() {
1375        let f = write_with_training_scenario_source(r#"{"openings": {"source": "file"}}"#);
1376        let cfg = parse_config(f.path()).unwrap();
1377        cfg.training_scenario_source(f.path())
1378            .expect("file openings source must load under training");
1379        assert_eq!(cfg.training_openings(), Some(&Openings::File {}));
1380    }
1381
1382    /// The dropped `path` field on the `file` openings source is now an
1383    /// unknown-field parse error — the arm is convention-located, no user path.
1384    #[test]
1385    fn openings_file_path_field_rejected() {
1386        let f = write_with_training_scenario_source(
1387            r#"{"openings": {"source": "file", "path": "scenarios/openings.parquet"}}"#,
1388        );
1389        let err = parse_config(f.path()).unwrap_err();
1390        assert!(
1391            matches!(
1392                err,
1393                LoadError::SchemaError { .. } | LoadError::ParseError { .. }
1394            ),
1395            "a path field on the file arm must be rejected, got: {err:?}"
1396        );
1397    }
1398
1399    /// `openings` under `simulation.scenario_source` is rejected naming the
1400    /// field — a declared openings source is valid only for training.
1401    #[test]
1402    fn openings_under_simulation_rejected() {
1403        let f = write_with_both_scenario_sources(
1404            r#"{"inflow": {"scheme": "in_sample"}}"#,
1405            r#"{"openings": {"source": "generated"}}"#,
1406        );
1407        let cfg = parse_config(f.path()).unwrap();
1408        let err = cfg.simulation_scenario_source(f.path()).unwrap_err();
1409        match &err {
1410            LoadError::SchemaError { field, message, .. } => {
1411                assert_eq!(field, "simulation.scenario_source.openings");
1412                assert!(
1413                    message.contains("only valid under training.scenario_source"),
1414                    "unexpected message: {message}"
1415                );
1416            }
1417            other => panic!("expected SchemaError, got: {other:?}"),
1418        }
1419    }
1420
1421    /// A `file` openings source round-trips through serde into the `File`
1422    /// variant (no path field).
1423    #[test]
1424    fn openings_file_variant_round_trips() {
1425        let parsed: Openings = serde_json::from_str(r#"{"source": "file"}"#).unwrap();
1426        assert_eq!(parsed, Openings::File {});
1427    }
1428
1429    /// `simulation.sampling_scheme` (dead field) is now rejected because
1430    /// `SimulationConfig` uses `deny_unknown_fields`. Old case dirs must remove
1431    /// this key before loading.
1432    #[test]
1433    fn test_dead_sampling_scheme_field_rejected() {
1434        let f = write_config(
1435            r#"{
1436            "training": {"selection": {"method": "sampled", "forward_passes": 10}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]},
1437            "simulation": {"enabled": true, "sampling_scheme": {"type": "in_sample"}}
1438        }"#,
1439        );
1440        let err = parse_config(f.path()).unwrap_err();
1441        assert!(
1442            matches!(
1443                err,
1444                LoadError::ParseError { .. } | LoadError::SchemaError { .. }
1445            ),
1446            "expected parse/schema error for unknown 'sampling_scheme' field, got: {err:?}"
1447        );
1448    }
1449
1450    /// `RowSelectionConfig` round-trips through JSON: the parent always-on knobs
1451    /// and a tagged `selection` block survive serialize → deserialize.
1452    #[test]
1453    fn row_selection_config_serde_roundtrip() {
1454        let original = RowSelectionConfig {
1455            row_activity_tolerance: Some(1e-6),
1456            max_active_per_stage: Some(100),
1457            selection: Some(SelectionMethod::Level1 {
1458                tie_tolerance: 1e-9,
1459                check_frequency: 7,
1460            }),
1461        };
1462        let json = serde_json::to_string(&original).unwrap();
1463        let roundtripped: RowSelectionConfig = serde_json::from_str(&json).unwrap();
1464        assert_eq!(roundtripped.max_active_per_stage, Some(100));
1465        assert_eq!(roundtripped.row_activity_tolerance, Some(1e-6));
1466        match roundtripped.selection.expect("selection present") {
1467            SelectionMethod::Level1 {
1468                tie_tolerance,
1469                check_frequency,
1470            } => {
1471                assert!((tie_tolerance - 1e-9).abs() < f64::EPSILON);
1472                assert_eq!(check_frequency, 7);
1473            }
1474            other => panic!("expected Level1, got {other:?}"),
1475        }
1476    }
1477
1478    /// max_active_per_stage absent from JSON deserializes to None.
1479    #[test]
1480    fn max_active_per_stage_absent_defaults_none() {
1481        let f = write_config(
1482            r#"{
1483            "training": {
1484                "selection": {"method": "sampled", "forward_passes": 10},
1485                "stopping_rules": [{"type": "iteration_limit", "limit": 5}],
1486                "cut_selection": {"selection": {"method": "level1"}}
1487            }
1488        }"#,
1489        );
1490        let cfg = parse_config(f.path()).unwrap();
1491        assert!(
1492            cfg.training.cut_selection.max_active_per_stage.is_none(),
1493            "max_active_per_stage must be None when absent from config.json"
1494        );
1495    }
1496
1497    /// `policy.boundary` with `path` and `source_stage` deserializes
1498    /// to `Some(BoundaryPolicy { .. })` with the correct field values.
1499    #[test]
1500    fn test_boundary_policy_present() {
1501        let f = write_config(
1502            r#"{
1503            "training": {
1504                "selection": {"method": "sampled", "forward_passes": 10},
1505                "stopping_rules": [{"type": "iteration_limit", "limit": 5}]
1506            },
1507            "policy": {
1508                "mode": "fresh",
1509                "boundary": {
1510                    "path": "../monthly/policy",
1511                    "source_stage": 2
1512                }
1513            }
1514        }"#,
1515        );
1516        let cfg = parse_config(f.path()).unwrap();
1517        let boundary = cfg.policy.boundary.unwrap();
1518        assert_eq!(boundary.path, "../monthly/policy");
1519        assert_eq!(boundary.source_stage, Some(2));
1520    }
1521
1522    /// `policy.boundary` with `path` but no `source_stage` deserializes to
1523    /// `Some(BoundaryPolicy { source_stage: None, .. })`; an unknown key
1524    /// under `boundary` is still rejected by `deny_unknown_fields`.
1525    #[test]
1526    fn test_boundary_policy_source_stage_absent_is_none() {
1527        let f = write_config(
1528            r#"{
1529            "training": {
1530                "selection": {"method": "sampled", "forward_passes": 10},
1531                "stopping_rules": [{"type": "iteration_limit", "limit": 5}]
1532            },
1533            "policy": {
1534                "mode": "fresh",
1535                "boundary": {
1536                    "path": "../monthly/policy"
1537                }
1538            }
1539        }"#,
1540        );
1541        let cfg = parse_config(f.path()).unwrap();
1542        let boundary = cfg.policy.boundary.unwrap();
1543        assert_eq!(boundary.path, "../monthly/policy");
1544        assert_eq!(boundary.source_stage, None);
1545
1546        let unknown_key_json = r#"{
1547            "training": {
1548                "selection": {"method": "sampled", "forward_passes": 10},
1549                "stopping_rules": [{"type": "iteration_limit", "limit": 5}]
1550            },
1551            "policy": {
1552                "mode": "fresh",
1553                "boundary": { "path": "../monthly/policy", "unexpected": true }
1554            }
1555        }"#;
1556        assert!(
1557            serde_json::from_str::<Config>(unknown_key_json).is_err(),
1558            "an unknown key under policy.boundary must still be rejected"
1559        );
1560    }
1561
1562    /// `policy` without a `boundary` key deserializes to `None`.
1563    #[test]
1564    fn test_boundary_policy_absent() {
1565        let f = write_config(
1566            r#"{
1567            "training": {
1568                "selection": {"method": "sampled", "forward_passes": 10},
1569                "stopping_rules": [{"type": "iteration_limit", "limit": 5}]
1570            },
1571            "policy": {}
1572        }"#,
1573        );
1574        let cfg = parse_config(f.path()).unwrap();
1575        assert!(
1576            cfg.policy.boundary.is_none(),
1577            "boundary must be None when the key is absent"
1578        );
1579    }
1580
1581    /// `"boundary": null` deserializes to `None`.
1582    #[test]
1583    fn test_boundary_policy_explicit_null() {
1584        let f = write_config(
1585            r#"{
1586            "training": {
1587                "selection": {"method": "sampled", "forward_passes": 10},
1588                "stopping_rules": [{"type": "iteration_limit", "limit": 5}]
1589            },
1590            "policy": { "boundary": null }
1591        }"#,
1592        );
1593        let cfg = parse_config(f.path()).unwrap();
1594        assert!(
1595            cfg.policy.boundary.is_none(),
1596            "boundary must be None when explicitly null"
1597        );
1598    }
1599
1600    /// `PolicyConfig::default()` has `boundary` set to `None`.
1601    #[test]
1602    fn test_policy_config_default_boundary_is_none() {
1603        assert!(
1604            PolicyConfig::default().boundary.is_none(),
1605            "default PolicyConfig must have boundary = None"
1606        );
1607    }
1608
1609    /// Round-trip: serialize `PolicyConfig` with `Some(BoundaryPolicy)`
1610    /// to JSON and deserialize back; values are preserved.
1611    #[test]
1612    fn test_boundary_policy_round_trip() {
1613        let original = PolicyConfig {
1614            path: "./policy".to_string(),
1615            mode: PolicyMode::Fresh,
1616            checkpointing: CheckpointingConfig::default(),
1617            boundary: Some(BoundaryPolicy {
1618                path: "../monthly/policy".to_string(),
1619                source_stage: Some(5),
1620            }),
1621        };
1622        let json = serde_json::to_string(&original).unwrap();
1623        let restored: PolicyConfig = serde_json::from_str(&json).unwrap();
1624        let boundary = restored.boundary.unwrap();
1625        assert_eq!(boundary.path, "../monthly/policy");
1626        assert_eq!(boundary.source_stage, Some(5));
1627    }
1628
1629    /// Stale `exports` keys (`training`, `cuts`, `vertices`, `simulation`,
1630    /// `forward_detail`, `backward_detail`, `compression`) are now rejected
1631    /// because `ExportsConfig` uses `deny_unknown_fields`. Old case dirs that
1632    /// still contain these keys must remove them before loading.
1633    #[test]
1634    fn parse_config_rejects_removed_exports_fields() {
1635        let json = r#"{
1636            "training": { "selection": {"method": "sampled", "forward_passes": 4}, "stopping_rules": [] },
1637            "exports": {
1638                "training": true,
1639                "cuts": false,
1640                "vertices": true,
1641                "simulation": true,
1642                "forward_detail": true,
1643                "backward_detail": true,
1644                "compression": "zstd"
1645            }
1646        }"#;
1647        let result = serde_json::from_str::<Config>(json);
1648        assert!(
1649            result.is_err(),
1650            "expected parse error for stale exports fields, got Ok"
1651        );
1652    }
1653
1654    // ── OrderSelectionMethod::PacfAnnual tests ────────────────────────────────
1655
1656    /// `"pacf_annual"` round-trips through serde_json.
1657    ///
1658    /// Deserialization must produce `PacfAnnual`; serialization must produce
1659    /// the `"pacf_annual"` string.
1660    #[test]
1661    fn order_selection_pacf_annual_round_trip() {
1662        let parsed: OrderSelectionMethod = serde_json::from_str("\"pacf_annual\"").unwrap();
1663        assert!(
1664            matches!(parsed, OrderSelectionMethod::PacfAnnual),
1665            "\"pacf_annual\" must deserialize to PacfAnnual, got: {parsed:?}"
1666        );
1667        let serialized = serde_json::to_string(&OrderSelectionMethod::PacfAnnual).unwrap();
1668        assert_eq!(
1669            serialized, "\"pacf_annual\"",
1670            "PacfAnnual must serialize to \"pacf_annual\", got: {serialized}"
1671        );
1672    }
1673
1674    /// An unknown variant error must mention `"pacf_annual"` as an expected
1675    /// variant so users know the option exists.
1676    #[test]
1677    fn order_selection_unknown_variant_lists_pacf_annual() {
1678        let err = serde_json::from_str::<OrderSelectionMethod>("\"pacf_seasonal\"").unwrap_err();
1679        let msg = err.to_string();
1680        assert!(
1681            msg.contains("pacf_annual"),
1682            "error message must contain \"pacf_annual\", got: {msg}"
1683        );
1684    }
1685
1686    /// The default variant must remain `Pacf`; `PacfAnnual` is opt-in.
1687    #[test]
1688    fn order_selection_default_is_pacf() {
1689        assert!(
1690            matches!(OrderSelectionMethod::default(), OrderSelectionMethod::Pacf),
1691            "default must be Pacf, not PacfAnnual"
1692        );
1693    }
1694
1695    /// `"fixed"` is no longer a valid value and must hard-error on parse.
1696    #[test]
1697    fn order_selection_fixed_rejected() {
1698        let result: Result<OrderSelectionMethod, _> = serde_json::from_str("\"fixed\"");
1699        assert!(
1700            result.is_err(),
1701            "\"fixed\" must be rejected; expected an error"
1702        );
1703    }
1704
1705    // ── with_overrides ────────────────────────────────────────────────────────
1706
1707    /// Minimal valid config used as the `base` Value in override tests.
1708    const OVERRIDE_BASE_CONFIG: &str = r#"{
1709      "training": {
1710        "tree_seed": 42,
1711        "selection": {"method": "sampled", "forward_passes": 192},
1712        "stopping_rules": [{"type": "iteration_limit", "limit": 50}],
1713        "stopping_mode": "any"
1714      },
1715      "policy": {
1716        "checkpointing": {"enabled": true}
1717      }
1718    }"#;
1719
1720    fn base_value(json: &str) -> serde_json::Value {
1721        serde_json::from_str(json).unwrap()
1722    }
1723
1724    fn override_map(
1725        pairs: &[(&str, serde_json::Value)],
1726    ) -> serde_json::Map<String, serde_json::Value> {
1727        pairs
1728            .iter()
1729            .map(|(k, v)| ((*k).to_string(), v.clone()))
1730            .collect()
1731    }
1732
1733    /// A scalar override sets the value and leaves sibling `training` fields intact.
1734    #[test]
1735    fn with_overrides_sets_scalar_and_preserves_siblings() {
1736        let base = base_value(OVERRIDE_BASE_CONFIG);
1737        let overrides = override_map(&[("training.tree_seed", serde_json::json!(7))]);
1738
1739        let cfg = Config::with_overrides(&base, &overrides).unwrap();
1740
1741        assert_eq!(cfg.training.tree_seed, Some(7));
1742        // Siblings unchanged from base.
1743        assert_eq!(
1744            cfg.resolve_forward_passes(),
1745            Some(ForwardPassesResolution::Sampled(192))
1746        );
1747        assert_eq!(cfg.training.stopping_mode, StoppingMode::Any);
1748        let rules = cfg.training.stopping_rules.as_deref().unwrap();
1749        assert!(matches!(
1750            rules,
1751            [StoppingRuleConfig::IterationLimit { limit: 50 }]
1752        ));
1753    }
1754
1755    /// An array override deserializes into the expected typed vector.
1756    #[test]
1757    fn with_overrides_accepts_array_value() {
1758        let base = base_value(OVERRIDE_BASE_CONFIG);
1759        let overrides = override_map(&[(
1760            "training.stopping_rules",
1761            serde_json::json!([{"type": "iteration_limit", "limit": 50}]),
1762        )]);
1763
1764        let cfg = Config::with_overrides(&base, &overrides).unwrap();
1765
1766        let rules = cfg.training.stopping_rules.as_deref().unwrap();
1767        assert!(matches!(
1768            rules,
1769            [StoppingRuleConfig::IterationLimit { limit: 50 }]
1770        ));
1771    }
1772
1773    /// A typo key produces SchemaError whose message contains "unknown field".
1774    #[test]
1775    fn with_overrides_typo_key_is_schema_error() {
1776        let base = base_value(OVERRIDE_BASE_CONFIG);
1777        let overrides = override_map(&[("trainning.tree_seed", serde_json::json!(7))]);
1778
1779        let err = Config::with_overrides(&base, &overrides).unwrap_err();
1780        match &err {
1781            LoadError::SchemaError { message, path, .. } => {
1782                assert!(
1783                    message.contains("unknown field"),
1784                    "message should contain 'unknown field', got: {message}"
1785                );
1786                assert_eq!(path, std::path::Path::new("<config_overrides>"));
1787            }
1788            other => panic!("expected SchemaError, got: {other:?}"),
1789        }
1790    }
1791
1792    /// Deep-merge into a nested object does not clobber sibling keys.
1793    #[test]
1794    fn with_overrides_deep_merge_preserves_nested_sibling() {
1795        let base = base_value(OVERRIDE_BASE_CONFIG);
1796        let overrides = override_map(&[("policy.checkpointing.compress", serde_json::json!(true))]);
1797
1798        let cfg = Config::with_overrides(&base, &overrides).unwrap();
1799
1800        assert_eq!(cfg.policy.checkpointing.compress, Some(true));
1801        // Sibling `enabled` (true in base) must survive the merge.
1802        assert_eq!(cfg.policy.checkpointing.enabled, Some(true));
1803    }
1804
1805    /// An override that clears a required field fails post-merge validation —
1806    /// the override pipeline runs `validate_config`, not just type-checking.
1807    #[test]
1808    fn with_overrides_invalid_value_fails_validation() {
1809        let base = base_value(OVERRIDE_BASE_CONFIG);
1810        let overrides = override_map(&[("training.selection", serde_json::Value::Null)]);
1811
1812        let err = Config::with_overrides(&base, &overrides).unwrap_err();
1813        match &err {
1814            LoadError::SchemaError { field, .. } => {
1815                assert!(
1816                    field.contains("training.selection"),
1817                    "field should name training.selection, got: {field}"
1818                );
1819            }
1820            other => panic!("expected SchemaError, got: {other:?}"),
1821        }
1822    }
1823
1824    /// Empty override map yields a Config equal to `from_value(base)`.
1825    #[test]
1826    fn with_overrides_empty_map_equals_direct_deserialize() {
1827        let base = base_value(OVERRIDE_BASE_CONFIG);
1828        let overrides = serde_json::Map::new();
1829
1830        let cfg = Config::with_overrides(&base, &overrides).unwrap();
1831        let direct: Config = serde_json::from_value(base.clone()).unwrap();
1832
1833        // `Config` has no `PartialEq`; compare via canonical JSON round-trip instead.
1834        assert_eq!(
1835            serde_json::to_value(&cfg).unwrap(),
1836            serde_json::to_value(&direct).unwrap()
1837        );
1838    }
1839
1840    /// An empty path segment (`"training..seed"`) is a SchemaError naming the key.
1841    #[test]
1842    fn with_overrides_empty_segment_is_schema_error() {
1843        let base = base_value(OVERRIDE_BASE_CONFIG);
1844        let overrides = override_map(&[("training..seed", serde_json::json!(7))]);
1845
1846        let err = Config::with_overrides(&base, &overrides).unwrap_err();
1847        match &err {
1848            LoadError::SchemaError { field, .. } => {
1849                assert_eq!(field, "training..seed");
1850            }
1851            other => panic!("expected SchemaError, got: {other:?}"),
1852        }
1853    }
1854
1855    /// A non-object `base` is rejected with a SchemaError naming `<root>`.
1856    #[test]
1857    fn with_overrides_non_object_base_is_schema_error() {
1858        let base = serde_json::json!([1, 2, 3]);
1859        let overrides = serde_json::Map::new();
1860
1861        let err = Config::with_overrides(&base, &overrides).unwrap_err();
1862        match &err {
1863            LoadError::SchemaError { field, message, .. } => {
1864                assert_eq!(field, "<root>");
1865                assert!(message.contains("must be a JSON object"));
1866            }
1867            other => panic!("expected SchemaError, got: {other:?}"),
1868        }
1869    }
1870
1871    /// A stray key in the `historical_years` range form is a deserialize
1872    /// error, never a silently dropped key (the untagged enum's `Range`
1873    /// variant routes through `HistoricalYearRange`'s `deny_unknown_fields`).
1874    #[test]
1875    fn historical_years_range_stray_key_is_deserialize_error() {
1876        let json = r#"{
1877            "training": {
1878                "selection": {"method": "sampled", "forward_passes": 4},
1879                "stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
1880                "scenario_source": {
1881                    "seed": 7,
1882                    "inflow": { "scheme": "historical" },
1883                    "historical_years": { "from": 1940, "to": 2010, "step": 2 }
1884                }
1885            }
1886        }"#;
1887        let result = serde_json::from_str::<Config>(json);
1888        assert!(
1889            result.is_err(),
1890            "a stray key in the historical_years range form must be rejected"
1891        );
1892    }
1893
1894    // ------------------------------------------------------------------
1895    // Unknown-key injection sweep
1896    // ------------------------------------------------------------------
1897
1898    /// Maximal valid configs for the injection sweep: together they exercise
1899    /// every config section and each internally-tagged / untagged variant
1900    /// family (both scheduler methods, a `level1` and a `dynamic` selection,
1901    /// all four stopping-rule types, both `historical_years` forms).
1902    fn injection_sweep_base_configs() -> Vec<serde_json::Value> {
1903        let by_scenario_flavored = serde_json::json!({
1904            "modeling": {
1905                "inflow_non_negativity": { "method": "penalty" },
1906                "cost_scale_factor": 1_000_000.0
1907            },
1908            "training": {
1909                "enabled": true,
1910                "tree_seed": 42,
1911                "selection": {"method": "sampled", "forward_passes": 4},
1912                "stopping_rules": [
1913                    { "type": "iteration_limit", "limit": 10 },
1914                    { "type": "time_limit", "seconds": 60.0 },
1915                    { "type": "bound_stalling", "iterations": 5, "tolerance": 0.001 },
1916                    { "type": "gap", "tolerance": 1000.0, "relative_tolerance": 0.01 }
1917                ],
1918                "stopping_mode": "any",
1919                "cut_selection": {
1920                    "row_activity_tolerance": 1e-6,
1921                    "max_active_per_stage": 1000,
1922                    "selection": {
1923                        "method": "level1", "tie_tolerance": 1e-10, "check_frequency": 5
1924                    }
1925                },
1926                "solver": {
1927                    "retry_max_attempts": 3,
1928                    "retry_time_budget_seconds": 10.0,
1929                    "backward": {
1930                        "dual_edge_weight": "devex",
1931                        "scale": "off",
1932                        "price": "row",
1933                        "primal_feasibility_tolerance": 1e-9,
1934                        "dual_feasibility_tolerance": 1e-9,
1935                        "presolve": "on",
1936                        "simplex_update_limit": 5000,
1937                        "cost_perturbation": 0.0,
1938                        "refactor_error_tolerance": 1e-6,
1939                        "factor_pivot_threshold": 0.1,
1940                        "use_warm_start": true,
1941                        "steepest_edge_devex_fallback_threshold": 10.0
1942                    },
1943                    "forward": { "price": "row_hyper_sparse" }
1944                },
1945                "parallelism": {
1946                    "backward_scheduler": { "method": "by_scenario" }
1947                },
1948                "scenario_source": {
1949                    "seed": 7,
1950                    "historical_years": { "from": 1940, "to": 2010 },
1951                    "inflow": { "scheme": "historical" },
1952                    "load": { "scheme": "in_sample" },
1953                    "ncs": { "scheme": "in_sample" }
1954                }
1955            },
1956            "upper_bound_evaluation": {
1957                "enabled": true,
1958                "initial_iteration": 5,
1959                "interval_iterations": 10,
1960                "lipschitz": { "mode": "auto", "fallback_value": 1.0, "scale_factor": 1.1 }
1961            },
1962            "policy": {
1963                "path": "./policy",
1964                "mode": "fresh",
1965                "checkpointing": {
1966                    "enabled": true, "initial_iteration": 1, "interval_iterations": 5,
1967                    "store_basis": true, "compress": false
1968                },
1969                "boundary": { "path": "./boundary", "source_stage": 3 }
1970            },
1971            "simulation": {
1972                "enabled": true,
1973                "selection": {"method": "sampled", "num_scenarios": 100},
1974                "io_channel_capacity": 64,
1975                "scenario_source": {
1976                    "seed": 9,
1977                    "historical_years": [1940, 1953],
1978                    "inflow": { "scheme": "historical" }
1979                },
1980                "solver": { "price": "row" },
1981                "selection": { "method": "sampled", "num_scenarios": 100 }
1982            },
1983            "exports": { "states": true, "stochastic": true, "fpha_deviation_points": true },
1984            "estimation": {
1985                "max_order": 6,
1986                "order_selection": "pacf",
1987                "min_observations_per_season": 30,
1988                "max_coefficient_magnitude": 2.0
1989            }
1990        });
1991        let by_node_flavored = serde_json::json!({
1992            "training": {
1993                "selection": {"method": "sampled", "forward_passes": 4},
1994                "stopping_rules": [{ "type": "iteration_limit", "limit": 10 }],
1995                "cut_selection": {
1996                    "selection": {
1997                        "method": "dynamic",
1998                        "start_iteration": 2,
1999                        "seed_window": 5,
2000                        "candidate_recency": 20,
2001                        "max_added_per_round": 10,
2002                        "violation_tolerance": 1e-10
2003                    }
2004                },
2005                "parallelism": {
2006                    "backward_scheduler": { "method": "by_node", "block_size": 4 }
2007                },
2008                "selection": { "method": "sampled", "forward_passes": 4 }
2009            }
2010        });
2011        vec![by_scenario_flavored, by_node_flavored]
2012    }
2013
2014    /// Collect the JSON Pointer of every object node in `value`.
2015    fn collect_object_pointers(value: &serde_json::Value, pointer: &str, out: &mut Vec<String>) {
2016        match value {
2017            serde_json::Value::Object(map) => {
2018                out.push(pointer.to_string());
2019                for (key, child) in map {
2020                    let escaped = key.replace('~', "~0").replace('/', "~1");
2021                    collect_object_pointers(child, &format!("{pointer}/{escaped}"), out);
2022                }
2023            }
2024            serde_json::Value::Array(items) => {
2025                for (idx, child) in items.iter().enumerate() {
2026                    collect_object_pointers(child, &format!("{pointer}/{idx}"), out);
2027                }
2028            }
2029            _ => {}
2030        }
2031    }
2032
2033    /// Every JSON object node in a maximal valid config rejects an injected
2034    /// unknown key. This is the mechanical closure over serde's per-attribute
2035    /// enforcement gaps — an internally-tagged unit variant, an untagged
2036    /// inline struct variant, or a plain missing `deny_unknown_fields` each
2037    /// silently ignore unknown keys, and a per-type reject test only covers
2038    /// the types someone remembered to test.
2039    #[test]
2040    fn unknown_key_injection_is_rejected_at_every_object_path() {
2041        for (i, base) in injection_sweep_base_configs().into_iter().enumerate() {
2042            serde_json::from_value::<Config>(base.clone())
2043                .unwrap_or_else(|e| panic!("sweep base config {i} must be valid: {e}"));
2044
2045            let mut pointers = Vec::new();
2046            collect_object_pointers(&base, "", &mut pointers);
2047            assert!(
2048                pointers.len() > 1,
2049                "sweep base config {i} must contain nested objects"
2050            );
2051
2052            for pointer in &pointers {
2053                let mut mutated = base.clone();
2054                mutated
2055                    .pointer_mut(pointer)
2056                    .and_then(serde_json::Value::as_object_mut)
2057                    .unwrap_or_else(|| panic!("pointer {pointer:?} must resolve to an object"))
2058                    .insert("__unknown_key__".to_string(), serde_json::json!(1));
2059                let result = serde_json::from_value::<Config>(mutated);
2060                assert!(
2061                    result.is_err(),
2062                    "config {i}: an unknown key injected at {pointer:?} must be rejected, \
2063                     but the config loaded successfully"
2064                );
2065            }
2066        }
2067    }
2068
2069    // ── Scenario-selection count resolution ───────────────────────────────────
2070
2071    /// A `sampled` training selection resolves the forward-pass count.
2072    #[test]
2073    fn training_sampled_selection_resolves_count() {
2074        let via_selection = write_config(
2075            r#"{"training": {"selection": {"method": "sampled", "forward_passes": 8}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]}}"#,
2076        );
2077        let cfg_sel = parse_config(via_selection.path()).unwrap();
2078        assert_eq!(
2079            cfg_sel.resolve_forward_passes(),
2080            Some(ForwardPassesResolution::Sampled(8))
2081        );
2082    }
2083
2084    /// A config setting the removed root `training.forward_passes` alias fails
2085    /// to load under `deny_unknown_fields`, the error naming the unknown field;
2086    /// a flat `simulation.num_scenarios` alias likewise. The count lives solely
2087    /// in the `selection.sampled` arm.
2088    #[test]
2089    fn removed_selection_aliases_fail_to_load() {
2090        let root_fp = write_config(
2091            r#"{"training": {"forward_passes": 8, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]}}"#,
2092        );
2093        let err = parse_config(root_fp.path()).unwrap_err();
2094        assert!(
2095            err.to_string().contains("forward_passes"),
2096            "root training.forward_passes must be an unknown-field load error naming it, got: {err}"
2097        );
2098
2099        let flat_ns = write_config(
2100            r#"{"training": {"selection": {"method": "sampled", "forward_passes": 4}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]}, "simulation": {"enabled": true, "num_scenarios": 500}}"#,
2101        );
2102        let err = parse_config(flat_ns.path()).unwrap_err();
2103        assert!(
2104            err.to_string().contains("num_scenarios"),
2105            "flat simulation.num_scenarios must be an unknown-field load error naming it, got: {err}"
2106        );
2107    }
2108
2109    /// A `training.selection` of `enumerated` loads and resolves to
2110    /// [`ForwardPassesResolution::Enumerated`] — the count itself is a
2111    /// setup-layer concern (the graph-derived count), not config load's.
2112    #[test]
2113    fn training_enumerated_selection_resolves() {
2114        let f = write_config(
2115            r#"{"training": {"selection": {"method": "enumerated"}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]}}"#,
2116        );
2117        let cfg = parse_config(f.path()).unwrap();
2118        assert_eq!(
2119            cfg.resolve_forward_passes(),
2120            Some(ForwardPassesResolution::Enumerated)
2121        );
2122    }
2123
2124    /// The simulation mirror of `enumerated` loads and resolves to
2125    /// [`NumScenariosResolution::Enumerated`]; the census count is graph-derived
2126    /// downstream, carried with no config-side count.
2127    #[test]
2128    fn simulation_enumerated_selection_resolves() {
2129        let f = write_config(
2130            r#"{"training": {"selection": {"method": "sampled", "forward_passes": 4}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]}, "simulation": {"enabled": true, "selection": {"method": "enumerated"}}}"#,
2131        );
2132        let cfg = parse_config(f.path()).unwrap();
2133        assert_eq!(
2134            cfg.resolve_num_scenarios(),
2135            NumScenariosResolution::Enumerated
2136        );
2137    }
2138
2139    /// A `sampled` simulation selection resolves its scenario count; an absent
2140    /// selection resolves to the default sampled count.
2141    #[test]
2142    fn simulation_sampled_and_default_num_scenarios_resolve() {
2143        let via_selection = write_config(
2144            r#"{"training": {"selection": {"method": "sampled", "forward_passes": 4}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]}, "simulation": {"enabled": true, "selection": {"method": "sampled", "num_scenarios": 500}}}"#,
2145        );
2146        let cfg_sel = parse_config(via_selection.path()).unwrap();
2147        assert_eq!(
2148            cfg_sel.resolve_num_scenarios(),
2149            NumScenariosResolution::Sampled(500)
2150        );
2151
2152        let via_default = write_config(
2153            r#"{"training": {"selection": {"method": "sampled", "forward_passes": 4}, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]}, "simulation": {"enabled": true}}"#,
2154        );
2155        let cfg_default = parse_config(via_default.path()).unwrap();
2156        assert_eq!(
2157            cfg_default.resolve_num_scenarios(),
2158            NumScenariosResolution::Sampled(2000)
2159        );
2160    }
2161}