1use 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#[derive(Debug, Clone, Deserialize, Serialize)]
65#[serde(deny_unknown_fields)]
66#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
67pub struct Config {
68 #[serde(rename = "$schema")]
70 pub schema: Option<String>,
71
72 #[serde(default)]
74 pub modeling: ModelingConfig,
75
76 pub training: TrainingConfig,
78
79 #[serde(default)]
81 pub upper_bound_evaluation: UpperBoundEvaluationConfig,
82
83 #[serde(default)]
85 pub policy: PolicyConfig,
86
87 #[serde(default)]
89 pub simulation: SimulationConfig,
90
91 #[serde(default)]
93 pub exports: ExportsConfig,
94
95 #[serde(default)]
97 pub estimation: EstimationConfig,
98}
99
100pub 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
146fn 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
157pub(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
179fn 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
193fn 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
233fn 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
259fn 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 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 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 #[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 #[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 #[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 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 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 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 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 assert_eq!(cfg.policy.checkpointing.enabled, Some(true));
1803 }
1804
1805 #[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 #[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 assert_eq!(
1835 serde_json::to_value(&cfg).unwrap(),
1836 serde_json::to_value(&direct).unwrap()
1837 );
1838 }
1839
1840 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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}