use cobre_core::scenario::{HistoricalYears, SamplingScheme, ScenarioSource};
use crate::LoadError;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Config {
#[serde(rename = "$schema")]
pub schema: Option<String>,
#[serde(default)]
pub modeling: ModelingConfig,
pub training: TrainingConfig,
#[serde(default)]
pub upper_bound_evaluation: UpperBoundEvaluationConfig,
#[serde(default)]
pub policy: PolicyConfig,
#[serde(default)]
pub simulation: SimulationConfig,
#[serde(default)]
pub exports: ExportsConfig,
#[serde(default)]
pub estimation: EstimationConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ModelingConfig {
#[serde(default)]
pub inflow_non_negativity: InflowNonNegativityConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct InflowNonNegativityConfig {
pub method: String,
pub penalty_cost: f64,
}
impl Default for InflowNonNegativityConfig {
fn default() -> Self {
Self {
method: "penalty".to_string(),
penalty_cost: 1000.0,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TrainingConfig {
#[serde(default = "TrainingConfig::default_enabled")]
pub enabled: bool,
#[serde(default)]
pub tree_seed: Option<i64>,
pub forward_passes: Option<u32>,
pub stopping_rules: Option<Vec<StoppingRuleConfig>>,
#[serde(default = "TrainingConfig::default_stopping_mode")]
pub stopping_mode: String,
#[serde(default)]
pub cut_formulation: Option<String>,
#[serde(default)]
pub forward_pass: Option<ForwardPassConfig>,
#[serde(default)]
pub cut_selection: RowSelectionConfig,
#[serde(default)]
pub solver: TrainingSolverConfig,
#[serde(default)]
pub scenario_source: Option<RawScenarioSourceConfig>,
}
impl TrainingConfig {
fn default_enabled() -> bool {
true
}
fn default_stopping_mode() -> String {
"any".to_string()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ForwardPassConfig {
#[serde(rename = "type")]
pub pass_type: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct RowSelectionConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub method: Option<String>,
#[serde(default, deserialize_with = "deserialize_deprecated_threshold")]
pub threshold: Option<u32>,
#[serde(default)]
pub memory_window: Option<u32>,
#[serde(default)]
pub domination_epsilon: Option<f64>,
#[serde(default)]
pub check_frequency: Option<u32>,
#[serde(default)]
pub cut_activity_tolerance: Option<f64>,
#[serde(default)]
pub basis_activity_window: Option<u32>,
#[serde(default)]
pub max_active_per_stage: Option<u32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TrainingSolverConfig {
pub retry_max_attempts: u32,
pub retry_time_budget_seconds: f64,
}
impl Default for TrainingSolverConfig {
fn default() -> Self {
Self {
retry_max_attempts: 5,
retry_time_budget_seconds: 30.0,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct RawScenarioSourceConfig {
#[serde(default)]
pub seed: Option<i64>,
#[serde(default)]
pub historical_years: Option<RawHistoricalYearsConfig>,
#[serde(default)]
pub inflow: Option<RawClassConfigEntry>,
#[serde(default)]
pub load: Option<RawClassConfigEntry>,
#[serde(default)]
pub ncs: Option<RawClassConfigEntry>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct RawClassConfigEntry {
pub scheme: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum RawHistoricalYearsConfig {
List(Vec<i32>),
Range {
from: i32,
to: i32,
},
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum StoppingRuleConfig {
IterationLimit {
limit: u32,
},
TimeLimit {
seconds: f64,
},
BoundStalling {
iterations: u32,
tolerance: f64,
},
Simulation {
replications: u32,
period: u32,
bound_window: u32,
distance_tol: f64,
bound_tol: f64,
},
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UpperBoundEvaluationConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub initial_iteration: Option<u32>,
#[serde(default)]
pub interval_iterations: Option<u32>,
#[serde(default)]
pub lipschitz: LipschitzConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct LipschitzConfig {
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub fallback_value: Option<f64>,
#[serde(default)]
pub scale_factor: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum PolicyMode {
Fresh,
WarmStart,
Resume,
}
impl std::fmt::Display for PolicyMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PolicyMode::Fresh => f.write_str("fresh"),
PolicyMode::WarmStart => f.write_str("warm_start"),
PolicyMode::Resume => f.write_str("resume"),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct BoundaryPolicy {
pub path: String,
pub source_stage: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PolicyConfig {
pub path: String,
pub mode: PolicyMode,
pub validate_compatibility: bool,
pub checkpointing: CheckpointingConfig,
#[serde(default)]
pub boundary: Option<BoundaryPolicy>,
}
impl Default for PolicyConfig {
fn default() -> Self {
Self {
path: "./policy".to_string(),
mode: PolicyMode::Fresh,
validate_compatibility: true,
checkpointing: CheckpointingConfig::default(),
boundary: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CheckpointingConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub initial_iteration: Option<u32>,
#[serde(default)]
pub interval_iterations: Option<u32>,
#[serde(default)]
pub store_basis: Option<bool>,
#[serde(default)]
pub compress: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SimulationConfig {
pub enabled: bool,
pub num_scenarios: u32,
pub policy_type: String,
pub output_path: Option<String>,
pub output_mode: Option<String>,
pub io_channel_capacity: u32,
#[serde(default)]
pub scenario_source: Option<RawScenarioSourceConfig>,
}
impl Default for SimulationConfig {
fn default() -> Self {
Self {
enabled: false,
num_scenarios: 2000,
policy_type: "outer".to_string(),
output_path: None,
output_mode: None,
io_channel_capacity: 64,
scenario_source: None,
}
}
}
#[derive(Debug, Clone, Serialize, Default)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum OrderSelectionMethod {
#[default]
Pacf,
PacfAnnual,
}
impl<'de> serde::Deserialize<'de> for OrderSelectionMethod {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
match s.as_str() {
"pacf" => Ok(Self::Pacf),
"pacf_annual" => Ok(Self::PacfAnnual),
"fixed" => {
tracing::warn!(
"OrderSelectionMethod::Fixed is deprecated and will be removed \
in a future release. The PACF method is now used for all order \
selection. Please update your config.json to use \"pacf\"."
);
Ok(Self::Pacf)
}
other => Err(serde::de::Error::unknown_variant(
other,
&["pacf", "pacf_annual", "fixed"],
)),
}
}
}
fn deserialize_deprecated_threshold<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value: Option<u32> = Option::deserialize(deserializer)?;
if value.is_some() {
tracing::warn!(
"RowSelectionConfig::threshold is deprecated and will be removed in a \
future release. Use `memory_window` for the \"lml1\" method and \
`domination_epsilon` for the \"domination\" method. Please update \
your config.json."
);
}
Ok(value)
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct EstimationConfig {
pub max_order: u32,
pub order_selection: OrderSelectionMethod,
pub min_observations_per_season: u32,
#[serde(default)]
pub max_coefficient_magnitude: Option<f64>,
}
impl Default for EstimationConfig {
fn default() -> Self {
Self {
max_order: 6,
order_selection: OrderSelectionMethod::Pacf,
min_observations_per_season: 30,
max_coefficient_magnitude: None,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ExportsConfig {
pub states: bool,
pub stochastic: bool,
}
pub fn parse_config(path: &Path) -> Result<Config, LoadError> {
let raw = std::fs::read_to_string(path).map_err(|e| LoadError::io(path, e))?;
let config: Config = serde_json::from_str(&raw).map_err(|e| {
let msg = e.to_string();
if msg.contains("unknown variant") || msg.contains("missing field") {
LoadError::SchemaError {
path: path.to_path_buf(),
field: extract_field_from_serde_msg(&msg),
message: msg,
}
} else {
LoadError::parse(path, msg)
}
})?;
validate_config(&config, path)?;
Ok(config)
}
fn extract_field_from_serde_msg(msg: &str) -> String {
if let Some(start) = msg.find('`') {
if let Some(end) = msg[start + 1..].find('`') {
return msg[start + 1..start + 1 + end].to_string();
}
}
"<unknown>".to_string()
}
fn validate_config(config: &Config, path: &Path) -> Result<(), LoadError> {
if config.training.forward_passes.is_none() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: "training.forward_passes".to_string(),
message: "required field is missing".to_string(),
});
}
if config.training.stopping_rules.is_none() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: "training.stopping_rules".to_string(),
message: "required field is missing".to_string(),
});
}
Ok(())
}
fn convert_sampling_scheme_cfg(
s: &str,
field: &str,
path: &Path,
) -> Result<SamplingScheme, LoadError> {
match s {
"in_sample" => Ok(SamplingScheme::InSample),
"out_of_sample" => Ok(SamplingScheme::OutOfSample),
"external" => Ok(SamplingScheme::External),
"historical" => Ok(SamplingScheme::Historical),
other => Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: field.to_string(),
message: format!(
"unknown scheme '{other}', expected one of: in_sample, out_of_sample, external, historical"
),
}),
}
}
fn convert_class_scheme_cfg(
class: Option<&RawClassConfigEntry>,
section: &str,
class_name: &str,
path: &Path,
) -> Result<SamplingScheme, LoadError> {
convert_sampling_scheme_cfg(
class.map_or("in_sample", |c| c.scheme.as_str()),
&format!("{section}.scenario_source.{class_name}.scheme"),
path,
)
}
fn convert_scenario_source_config(
raw: Option<&RawScenarioSourceConfig>,
section: &str,
path: &Path,
) -> Result<ScenarioSource, LoadError> {
let Some(r) = raw else {
return Ok(ScenarioSource::default());
};
let inflow_scheme = convert_class_scheme_cfg(r.inflow.as_ref(), section, "inflow", path)?;
let load_scheme = convert_class_scheme_cfg(r.load.as_ref(), section, "load", path)?;
let ncs_scheme = convert_class_scheme_cfg(r.ncs.as_ref(), section, "ncs", path)?;
let source = ScenarioSource {
inflow_scheme,
load_scheme,
ncs_scheme,
seed: r.seed,
historical_years: r.historical_years.as_ref().map(|hy| match hy {
RawHistoricalYearsConfig::List(years) => HistoricalYears::List(years.clone()),
RawHistoricalYearsConfig::Range { from, to } => HistoricalYears::Range {
from: *from,
to: *to,
},
}),
};
validate_scenario_source_cfg(&source, section, path)?;
Ok(source)
}
fn validate_scenario_source_cfg(
source: &ScenarioSource,
section: &str,
path: &Path,
) -> Result<(), LoadError> {
let uses_historical = source.inflow_scheme == SamplingScheme::Historical
|| source.load_scheme == SamplingScheme::Historical
|| source.ncs_scheme == SamplingScheme::Historical;
if source.historical_years.is_some() && !uses_historical {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("{section}.scenario_source.historical_years"),
message: "historical_years is specified but no class uses the 'historical' scheme"
.to_string(),
});
}
if source.load_scheme == SamplingScheme::Historical {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("{section}.scenario_source.load.scheme"),
message: "historical scheme is only valid for the inflow class".to_string(),
});
}
if source.ncs_scheme == SamplingScheme::Historical {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("{section}.scenario_source.ncs.scheme"),
message: "historical scheme is only valid for the inflow class".to_string(),
});
}
let all_in_sample = source.inflow_scheme == SamplingScheme::InSample
&& source.load_scheme == SamplingScheme::InSample
&& source.ncs_scheme == SamplingScheme::InSample;
if !all_in_sample && source.seed.is_none() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("{section}.scenario_source.seed"),
message:
"seed is required when any class uses out_of_sample, historical, or external scheme"
.to_string(),
});
}
if let Some(HistoricalYears::Range { from, to }) = source.historical_years {
if from > to {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("{section}.scenario_source.historical_years"),
message: format!("range 'from' ({from}) must be <= 'to' ({to})"),
});
}
}
Ok(())
}
impl Config {
pub fn training_scenario_source(&self, path: &Path) -> Result<ScenarioSource, LoadError> {
convert_scenario_source_config(self.training.scenario_source.as_ref(), "training", path)
}
pub fn simulation_scenario_source(&self, path: &Path) -> Result<ScenarioSource, LoadError> {
if self.simulation.scenario_source.is_some() {
convert_scenario_source_config(
self.simulation.scenario_source.as_ref(),
"simulation",
path,
)
} else {
self.training_scenario_source(path)
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::panic,
clippy::too_many_lines,
clippy::doc_markdown
)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_config(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn test_parse_minimal_config() {
let f = write_config(
r#"{"training": {"tree_seed": 42, "forward_passes": 192, "stopping_rules": [{"type": "iteration_limit", "limit": 50}]}}"#,
);
let cfg = parse_config(f.path()).unwrap();
assert_eq!(cfg.training.forward_passes, Some(192));
assert_eq!(cfg.training.tree_seed, Some(42));
assert_eq!(cfg.training.stopping_mode, "any");
assert!(cfg.training.enabled);
assert_eq!(
cfg.modeling.inflow_non_negativity.method,
"penalty".to_string()
);
assert!((cfg.modeling.inflow_non_negativity.penalty_cost - 1000.0).abs() < f64::EPSILON);
assert!(!cfg.simulation.enabled);
assert_eq!(cfg.simulation.num_scenarios, 2000);
assert_eq!(cfg.policy.mode, PolicyMode::Fresh);
assert_eq!(cfg.policy.path, "./policy");
assert!(cfg.policy.validate_compatibility);
}
#[test]
fn test_missing_forward_passes() {
let f = write_config(
r#"{"training": {"tree_seed": 1, "stopping_rules": [{"type": "iteration_limit", "limit": 10}]}}"#,
);
let err = parse_config(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, .. } => {
assert!(
field.contains("forward_passes"),
"field should contain 'forward_passes', got: {field}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_missing_stopping_rules() {
let f = write_config(r#"{"training": {"tree_seed": 1, "forward_passes": 100}}"#);
let err = parse_config(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, .. } => {
assert!(
field.contains("stopping_rules"),
"field should contain 'stopping_rules', got: {field}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_nonexistent_file() {
let path = std::path::Path::new("/nonexistent/path/config.json");
let err = parse_config(path).unwrap_err();
match &err {
LoadError::IoError { path: p, .. } => {
assert_eq!(p, path);
}
other => panic!("expected IoError, got: {other:?}"),
}
}
#[test]
fn test_parse_full_config() {
let json = r#"{
"$schema": "https://raw.githubusercontent.com/cobre-rs/cobre/refs/heads/main/book/src/schemas/config.schema.json",
"modeling": {
"inflow_non_negativity": {
"method": "penalty",
"penalty_cost": 500.0
}
},
"training": {
"tree_seed": 42,
"forward_passes": 192,
"stopping_rules": [
{"type": "iteration_limit", "limit": 50},
{"type": "bound_stalling", "iterations": 10, "tolerance": 0.0001}
],
"stopping_mode": "any",
"cut_formulation": "single",
"forward_pass": {"type": "default"},
"cut_selection": {
"enabled": true,
"method": "domination",
"threshold": 0
}
},
"upper_bound_evaluation": {
"enabled": true,
"initial_iteration": 10,
"interval_iterations": 5
},
"policy": {
"path": "./policy",
"mode": "fresh",
"checkpointing": {
"enabled": true,
"initial_iteration": 10,
"interval_iterations": 10,
"store_basis": true,
"compress": true
},
"validate_compatibility": true
},
"simulation": {
"enabled": true,
"num_scenarios": 2000,
"policy_type": "outer",
"output_path": "./simulation",
"output_mode": "streaming"
},
"exports": {
"states": true,
"stochastic": true
}
}"#;
let f = write_config(json);
let cfg = parse_config(f.path()).unwrap();
assert_eq!(cfg.modeling.inflow_non_negativity.method, "penalty");
assert!((cfg.modeling.inflow_non_negativity.penalty_cost - 500.0).abs() < f64::EPSILON);
assert_eq!(cfg.training.forward_passes, Some(192));
assert_eq!(cfg.training.stopping_mode, "any");
let rules = cfg.training.stopping_rules.as_ref().unwrap();
assert_eq!(rules.len(), 2);
assert_eq!(cfg.training.cut_formulation.as_deref(), Some("single"));
let cut_sel = &cfg.training.cut_selection;
assert_eq!(cut_sel.enabled, Some(true));
assert_eq!(cut_sel.method.as_deref(), Some("domination"));
assert_eq!(cfg.upper_bound_evaluation.enabled, Some(true));
assert_eq!(cfg.upper_bound_evaluation.initial_iteration, Some(10));
assert_eq!(cfg.policy.mode, PolicyMode::Fresh);
assert!(cfg.policy.validate_compatibility);
assert_eq!(cfg.policy.checkpointing.enabled, Some(true));
assert!(cfg.simulation.enabled);
assert_eq!(cfg.simulation.num_scenarios, 2000);
assert_eq!(cfg.simulation.policy_type, "outer");
assert!(cfg.exports.states);
assert!(cfg.exports.stochastic);
}
#[test]
fn test_invalid_json_syntax() {
let f = write_config(r#"{"training": {not valid json}}"#);
let err = parse_config(f.path()).unwrap_err();
assert!(
matches!(err, LoadError::ParseError { .. }),
"expected ParseError, got: {err:?}"
);
}
#[test]
fn test_stopping_rule_variants() {
let json = r#"{
"training": {
"forward_passes": 10,
"stopping_rules": [
{"type": "iteration_limit", "limit": 100},
{"type": "time_limit", "seconds": 3600.0},
{"type": "bound_stalling", "iterations": 10, "tolerance": 0.0001},
{
"type": "simulation",
"replications": 100,
"period": 20,
"bound_window": 5,
"distance_tol": 0.01,
"bound_tol": 0.0001
}
]
}
}"#;
let f = write_config(json);
let cfg = parse_config(f.path()).unwrap();
let rules = cfg.training.stopping_rules.unwrap();
assert_eq!(rules.len(), 4);
assert!(matches!(
rules[0],
StoppingRuleConfig::IterationLimit { limit: 100 }
));
assert!(
matches!(rules[1], StoppingRuleConfig::TimeLimit { seconds } if (seconds - 3600.0).abs() < f64::EPSILON)
);
assert!(matches!(
rules[2],
StoppingRuleConfig::BoundStalling { iterations: 10, .. }
));
assert!(matches!(
rules[3],
StoppingRuleConfig::Simulation {
replications: 100,
period: 20,
..
}
));
}
#[test]
fn test_unknown_stopping_rule_type() {
let f = write_config(
r#"{"training": {"forward_passes": 10, "stopping_rules": [{"type": "nonexistent_rule"}]}}"#,
);
let err = parse_config(f.path()).unwrap_err();
assert!(
matches!(err, LoadError::SchemaError { .. }),
"expected SchemaError for unknown rule type, got: {err:?}"
);
}
#[test]
fn test_config_has_no_version_field() {
let f = write_config(
r#"{"training": {"forward_passes": 1, "stopping_rules": [{"type": "iteration_limit", "limit": 10}]}}"#,
);
let cfg = parse_config(f.path()).unwrap();
assert!(cfg.schema.is_none(), "schema should be None when absent");
}
#[test]
fn test_schema_field_accepted() {
let f = write_config(
r#"{
"$schema": "https://raw.githubusercontent.com/cobre-rs/cobre/refs/heads/main/book/src/schemas/config.schema.json",
"training": {
"forward_passes": 1,
"stopping_rules": [{"type": "iteration_limit", "limit": 10}]
}
}"#,
);
let cfg = parse_config(f.path()).unwrap();
assert_eq!(
cfg.schema.as_deref(),
Some(
"https://raw.githubusercontent.com/cobre-rs/cobre/refs/heads/main/book/src/schemas/config.schema.json"
),
"schema field should be stored when present in JSON"
);
}
#[test]
fn test_invalid_policy_mode_rejected() {
let f = write_config(
r#"{"training": {"forward_passes": 1, "stopping_rules": [{"type": "iteration_limit", "limit": 10}]}, "policy": {"mode": "warmstart"}}"#,
);
let err = parse_config(f.path()).unwrap_err();
assert!(
matches!(err, LoadError::SchemaError { .. }),
"expected SchemaError for invalid policy.mode, got: {err:?}"
);
}
#[test]
fn test_legacy_version_field_silently_ignored() {
let f = write_config(
r#"{
"version": "1.0.0",
"training": {
"forward_passes": 1,
"stopping_rules": [{"type": "iteration_limit", "limit": 10}]
}
}"#,
);
let cfg = parse_config(f.path()).unwrap();
assert_eq!(cfg.training.forward_passes, Some(1));
}
#[test]
fn test_truncation_method_accepted() {
let f = write_config(
r#"{
"modeling": {
"inflow_non_negativity": {
"method": "truncation"
}
},
"training": {
"forward_passes": 10,
"stopping_rules": [{"type": "iteration_limit", "limit": 5}]
}
}"#,
);
let cfg = parse_config(f.path()).unwrap();
assert_eq!(
cfg.modeling.inflow_non_negativity.method, "truncation",
"method field should round-trip as 'truncation'"
);
assert!(
(cfg.modeling.inflow_non_negativity.penalty_cost - 1000.0).abs() < f64::EPSILON,
"penalty_cost should be the default 1000.0 when absent from JSON"
);
}
#[test]
fn test_estimation_config_defaults() {
let f = write_config(
r#"{"training": {"forward_passes": 10, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]}}"#,
);
let cfg = parse_config(f.path()).unwrap();
assert_eq!(cfg.estimation.max_order, 6);
assert!(
matches!(cfg.estimation.order_selection, OrderSelectionMethod::Pacf),
"default order_selection should be Pacf"
);
assert_eq!(cfg.estimation.min_observations_per_season, 30);
}
#[test]
fn test_estimation_config_order_selection_fixed_deprecated() {
let f = write_config(
r#"{
"training": {"forward_passes": 10, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]},
"estimation": {"max_order": 3, "order_selection": "fixed", "min_observations_per_season": 20}
}"#,
);
let cfg = parse_config(f.path()).unwrap();
assert_eq!(cfg.estimation.max_order, 3);
assert!(
matches!(cfg.estimation.order_selection, OrderSelectionMethod::Pacf),
"deprecated 'fixed' must deserialize to Pacf"
);
assert_eq!(cfg.estimation.min_observations_per_season, 20);
}
#[test]
fn test_estimation_config_order_selection_pacf() {
let f = write_config(
r#"{
"training": {"forward_passes": 10, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]},
"estimation": {"max_order": 4, "order_selection": "pacf", "min_observations_per_season": 15}
}"#,
);
let cfg = parse_config(f.path()).unwrap();
assert_eq!(cfg.estimation.max_order, 4);
assert!(
matches!(cfg.estimation.order_selection, OrderSelectionMethod::Pacf),
"explicit 'pacf' must deserialize to Pacf"
);
assert_eq!(cfg.estimation.min_observations_per_season, 15);
}
#[test]
fn test_estimation_config_unknown_order_selection() {
let f = write_config(
r#"{
"training": {"forward_passes": 10, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]},
"estimation": {"order_selection": "bogus"}
}"#,
);
let err = parse_config(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains("unknown variant"),
"message should contain 'unknown variant', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_exports_stochastic_explicit_true() {
let f = write_config(
r#"{
"training": {"forward_passes": 10, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]},
"exports": {"stochastic": true}
}"#,
);
let cfg = parse_config(f.path()).unwrap();
assert!(
cfg.exports.stochastic,
"exports.stochastic should be true when set in config"
);
}
#[test]
fn test_exports_stochastic_defaults_to_false() {
let f = write_config(
r#"{
"training": {"forward_passes": 10, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]}
}"#,
);
let cfg = parse_config(f.path()).unwrap();
assert!(
!cfg.exports.stochastic,
"exports.stochastic should default to false when absent"
);
}
const MINIMAL_TRAINING: &str =
r#"{"forward_passes": 10, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]}"#;
fn write_with_training_scenario_source(scenario_source_json: &str) -> NamedTempFile {
write_config(&format!(
r#"{{"training": {{"forward_passes": 10, "stopping_rules": [{{"type": "iteration_limit", "limit": 5}}], "scenario_source": {scenario_source_json}}}}}"#
))
}
fn write_with_both_scenario_sources(
training_json: &str,
simulation_json: &str,
) -> NamedTempFile {
write_config(&format!(
r#"{{"training": {{"forward_passes": 10, "stopping_rules": [{{"type": "iteration_limit", "limit": 5}}], "scenario_source": {training_json}}}, "simulation": {{"scenario_source": {simulation_json}}}}}"#
))
}
#[test]
fn test_training_scenario_source_default() {
let f = write_config(&format!(r#"{{"training": {MINIMAL_TRAINING}}}"#));
let cfg = parse_config(f.path()).unwrap();
let source = cfg.training_scenario_source(f.path()).unwrap();
assert_eq!(source, ScenarioSource::default());
assert_eq!(source.inflow_scheme, SamplingScheme::InSample);
assert_eq!(source.load_scheme, SamplingScheme::InSample);
assert_eq!(source.ncs_scheme, SamplingScheme::InSample);
assert_eq!(source.seed, None);
assert_eq!(source.historical_years, None);
}
#[test]
fn test_training_scenario_source_explicit() {
let f = write_with_training_scenario_source(
r#"{"seed": 42, "inflow": {"scheme": "historical"}, "historical_years": [1940, 1953]}"#,
);
let cfg = parse_config(f.path()).unwrap();
let source = cfg.training_scenario_source(f.path()).unwrap();
assert_eq!(source.inflow_scheme, SamplingScheme::Historical);
assert_eq!(source.load_scheme, SamplingScheme::InSample);
assert_eq!(source.ncs_scheme, SamplingScheme::InSample);
assert_eq!(source.seed, Some(42));
assert_eq!(
source.historical_years,
Some(HistoricalYears::List(vec![1940, 1953]))
);
}
#[test]
fn test_simulation_scenario_source_fallback() {
let f = write_with_training_scenario_source(
r#"{"seed": 7, "inflow": {"scheme": "out_of_sample"}}"#,
);
let cfg = parse_config(f.path()).unwrap();
let training = cfg.training_scenario_source(f.path()).unwrap();
let simulation = cfg.simulation_scenario_source(f.path()).unwrap();
assert_eq!(training, simulation);
assert_eq!(simulation.inflow_scheme, SamplingScheme::OutOfSample);
assert_eq!(simulation.seed, Some(7));
}
#[test]
fn test_simulation_scenario_source_independent() {
let f = write_with_both_scenario_sources(
r#"{"seed": 1, "inflow": {"scheme": "out_of_sample"}}"#,
r#"{"seed": 2, "load": {"scheme": "out_of_sample"}}"#,
);
let cfg = parse_config(f.path()).unwrap();
let training = cfg.training_scenario_source(f.path()).unwrap();
let simulation = cfg.simulation_scenario_source(f.path()).unwrap();
assert_ne!(training, simulation);
assert_eq!(training.inflow_scheme, SamplingScheme::OutOfSample);
assert_eq!(training.load_scheme, SamplingScheme::InSample);
assert_eq!(simulation.inflow_scheme, SamplingScheme::InSample);
assert_eq!(simulation.load_scheme, SamplingScheme::OutOfSample);
}
#[test]
fn test_scenario_source_historical_inflow_valid() {
let f = write_with_training_scenario_source(
r#"{"seed": 99, "inflow": {"scheme": "historical"}}"#,
);
let cfg = parse_config(f.path()).unwrap();
let source = cfg.training_scenario_source(f.path()).unwrap();
assert_eq!(source.inflow_scheme, SamplingScheme::Historical);
}
#[test]
fn test_scenario_source_historical_load_rejected() {
let f = write_config(&format!(
r#"{{"training": {MINIMAL_TRAINING}, "simulation": {{"scenario_source": {{"seed": 1, "load": {{"scheme": "historical"}}}}}}}}"#
));
let cfg = parse_config(f.path()).unwrap();
let err = cfg.simulation_scenario_source(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, field, .. } => {
assert!(
message.contains("historical scheme is only valid for the inflow class"),
"unexpected message: {message}"
);
assert!(field.contains("load.scheme"), "unexpected field: {field}");
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_scenario_source_historical_ncs_rejected() {
let f =
write_with_training_scenario_source(r#"{"seed": 1, "ncs": {"scheme": "historical"}}"#);
let cfg = parse_config(f.path()).unwrap();
let err = cfg.training_scenario_source(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, field, .. } => {
assert!(
message.contains("historical scheme is only valid for the inflow class"),
"unexpected message: {message}"
);
assert!(field.contains("ncs.scheme"), "unexpected field: {field}");
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_scenario_source_seed_required_for_oos() {
let f = write_with_training_scenario_source(r#"{"inflow": {"scheme": "out_of_sample"}}"#);
let cfg = parse_config(f.path()).unwrap();
let err = cfg.training_scenario_source(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, field, .. } => {
assert!(
message.contains("seed is required"),
"unexpected message: {message}"
);
assert!(field.contains("seed"), "unexpected field: {field}");
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_scenario_source_historical_years_range() {
let f = write_with_training_scenario_source(
r#"{"seed": 5, "inflow": {"scheme": "historical"}, "historical_years": {"from": 1940, "to": 2010}}"#,
);
let cfg = parse_config(f.path()).unwrap();
let source = cfg.training_scenario_source(f.path()).unwrap();
assert_eq!(
source.historical_years,
Some(HistoricalYears::Range {
from: 1940,
to: 2010
})
);
}
#[test]
fn test_scenario_source_historical_years_without_historical_scheme() {
let f = write_with_training_scenario_source(
r#"{"seed": 1, "inflow": {"scheme": "out_of_sample"}, "historical_years": [1990, 2000]}"#,
);
let cfg = parse_config(f.path()).unwrap();
let err = cfg.training_scenario_source(f.path()).unwrap_err();
match &err {
LoadError::SchemaError { message, .. } => {
assert!(
message.contains(
"historical_years is specified but no class uses the 'historical' scheme"
),
"unexpected message: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_dead_sampling_scheme_field_removed() {
let f = write_config(
r#"{
"training": {"forward_passes": 10, "stopping_rules": [{"type": "iteration_limit", "limit": 5}]},
"simulation": {"enabled": true, "sampling_scheme": {"type": "in_sample"}}
}"#,
);
let cfg = parse_config(f.path()).unwrap();
assert!(cfg.simulation.enabled);
assert!(cfg.simulation.scenario_source.is_none());
}
#[test]
fn max_active_per_stage_serde_roundtrip() {
let original = RowSelectionConfig {
enabled: Some(true),
method: Some("level1".to_string()),
threshold: None,
memory_window: None,
domination_epsilon: None,
check_frequency: None,
cut_activity_tolerance: None,
max_active_per_stage: Some(100),
basis_activity_window: Some(7),
};
let json = serde_json::to_string(&original).unwrap();
let roundtripped: RowSelectionConfig = serde_json::from_str(&json).unwrap();
assert_eq!(roundtripped.max_active_per_stage, Some(100));
assert_eq!(roundtripped.enabled, Some(true));
assert_eq!(roundtripped.method.as_deref(), Some("level1"));
assert_eq!(roundtripped.basis_activity_window, Some(7));
}
#[test]
fn max_active_per_stage_absent_defaults_none() {
let f = write_config(
r#"{
"training": {
"forward_passes": 10,
"stopping_rules": [{"type": "iteration_limit", "limit": 5}],
"cut_selection": {"enabled": true, "method": "level1"}
}
}"#,
);
let cfg = parse_config(f.path()).unwrap();
assert!(
cfg.training.cut_selection.max_active_per_stage.is_none(),
"max_active_per_stage must be None when absent from config.json"
);
}
#[test]
fn test_boundary_policy_present() {
let f = write_config(
r#"{
"training": {
"forward_passes": 10,
"stopping_rules": [{"type": "iteration_limit", "limit": 5}]
},
"policy": {
"mode": "fresh",
"boundary": {
"path": "../monthly/policy",
"source_stage": 2
}
}
}"#,
);
let cfg = parse_config(f.path()).unwrap();
let boundary = cfg.policy.boundary.unwrap();
assert_eq!(boundary.path, "../monthly/policy");
assert_eq!(boundary.source_stage, 2);
}
#[test]
fn test_boundary_policy_absent() {
let f = write_config(
r#"{
"training": {
"forward_passes": 10,
"stopping_rules": [{"type": "iteration_limit", "limit": 5}]
},
"policy": {}
}"#,
);
let cfg = parse_config(f.path()).unwrap();
assert!(
cfg.policy.boundary.is_none(),
"boundary must be None when the key is absent"
);
}
#[test]
fn test_boundary_policy_explicit_null() {
let f = write_config(
r#"{
"training": {
"forward_passes": 10,
"stopping_rules": [{"type": "iteration_limit", "limit": 5}]
},
"policy": { "boundary": null }
}"#,
);
let cfg = parse_config(f.path()).unwrap();
assert!(
cfg.policy.boundary.is_none(),
"boundary must be None when explicitly null"
);
}
#[test]
fn test_policy_config_default_boundary_is_none() {
assert!(
PolicyConfig::default().boundary.is_none(),
"default PolicyConfig must have boundary = None"
);
}
#[test]
fn test_boundary_policy_round_trip() {
let original = PolicyConfig {
path: "./policy".to_string(),
mode: PolicyMode::Fresh,
validate_compatibility: true,
checkpointing: CheckpointingConfig::default(),
boundary: Some(BoundaryPolicy {
path: "../monthly/policy".to_string(),
source_stage: 5,
}),
};
let json = serde_json::to_string(&original).unwrap();
let restored: PolicyConfig = serde_json::from_str(&json).unwrap();
let boundary = restored.boundary.unwrap();
assert_eq!(boundary.path, "../monthly/policy");
assert_eq!(boundary.source_stage, 5);
}
mod test_subscriber {
use std::sync::{Arc, Mutex};
use tracing::{
Event, Level, Metadata, Subscriber,
span::{Attributes, Id, Record},
};
pub(super) struct WarnRecorder {
pub(super) messages: Arc<Mutex<Vec<String>>>,
}
impl WarnRecorder {
pub(super) fn new() -> (Self, Arc<Mutex<Vec<String>>>) {
let messages = Arc::new(Mutex::new(Vec::new()));
(
Self {
messages: Arc::clone(&messages),
},
messages,
)
}
}
impl Subscriber for WarnRecorder {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
*metadata.level() <= Level::WARN
}
fn new_span(&self, _attrs: &Attributes<'_>) -> Id {
Id::from_u64(1)
}
fn record(&self, _span: &Id, _values: &Record<'_>) {}
fn record_follows_from(&self, _span: &Id, _follows: &Id) {}
fn event(&self, event: &Event<'_>) {
if *event.metadata().level() == Level::WARN {
struct MessageVisitor(String);
impl tracing::field::Visit for MessageVisitor {
fn record_debug(
&mut self,
field: &tracing::field::Field,
value: &dyn std::fmt::Debug,
) {
if field.name() == "message" {
self.0 = format!("{value:?}");
}
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "message" {
self.0 = value.to_string();
}
}
}
let mut visitor = MessageVisitor(String::new());
event.record(&mut visitor);
self.messages.lock().unwrap().push(visitor.0);
}
}
fn enter(&self, _span: &Id) {}
fn exit(&self, _span: &Id) {}
}
}
#[test]
fn test_row_selection_threshold_deprecated_warning() {
let (subscriber, messages) = test_subscriber::WarnRecorder::new();
tracing::subscriber::with_default(subscriber, || {
let json = r#"{"threshold": 5}"#;
let cfg: RowSelectionConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.threshold, Some(5), "threshold must be stored");
});
let recorded = messages.lock().unwrap();
let warn_events: Vec<&str> = recorded
.iter()
.map(std::string::String::as_str)
.filter(|msg| msg.contains("threshold") && msg.contains("deprecated"))
.collect();
assert!(
!warn_events.is_empty(),
"expected at least one WARN event containing 'threshold' and 'deprecated', got: {recorded:?}"
);
}
#[test]
fn test_row_selection_threshold_absent_no_warning() {
let (subscriber, messages) = test_subscriber::WarnRecorder::new();
tracing::subscriber::with_default(subscriber, || {
let json = r#"{"enabled": true, "method": "lml1", "memory_window": 10}"#;
let cfg: RowSelectionConfig = serde_json::from_str(json).unwrap();
assert!(
cfg.threshold.is_none(),
"threshold must be None when absent"
);
});
let recorded = messages.lock().unwrap();
let threshold_warns: Vec<&str> = recorded
.iter()
.map(std::string::String::as_str)
.filter(|msg| msg.contains("threshold") && msg.contains("deprecated"))
.collect();
assert!(
threshold_warns.is_empty(),
"expected no WARN events about threshold deprecation when field is absent, got: {threshold_warns:?}"
);
}
#[test]
fn parse_config_ignores_removed_exports_fields() {
let json = r#"{
"training": { "forward_passes": 4, "stopping_rules": [] },
"exports": {
"training": true,
"cuts": false,
"vertices": true,
"simulation": true,
"forward_detail": true,
"backward_detail": true,
"compression": "zstd"
}
}"#;
let cfg: Config = serde_json::from_str(json).unwrap();
assert!(!cfg.exports.states);
assert!(!cfg.exports.stochastic);
}
#[test]
fn order_selection_pacf_annual_round_trip() {
let parsed: OrderSelectionMethod = serde_json::from_str("\"pacf_annual\"").unwrap();
assert!(
matches!(parsed, OrderSelectionMethod::PacfAnnual),
"\"pacf_annual\" must deserialize to PacfAnnual, got: {parsed:?}"
);
let serialized = serde_json::to_string(&OrderSelectionMethod::PacfAnnual).unwrap();
assert_eq!(
serialized, "\"pacf_annual\"",
"PacfAnnual must serialize to \"pacf_annual\", got: {serialized}"
);
}
#[test]
fn order_selection_unknown_variant_lists_pacf_annual() {
let err = serde_json::from_str::<OrderSelectionMethod>("\"pacf_seasonal\"").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("pacf_annual"),
"error message must contain \"pacf_annual\", got: {msg}"
);
}
#[test]
fn order_selection_default_is_pacf() {
assert!(
matches!(OrderSelectionMethod::default(), OrderSelectionMethod::Pacf),
"default must be Pacf, not PacfAnnual"
);
}
#[test]
fn order_selection_fixed_still_maps_to_pacf() {
let parsed: OrderSelectionMethod = serde_json::from_str("\"fixed\"").unwrap();
assert!(
matches!(parsed, OrderSelectionMethod::Pacf),
"deprecated \"fixed\" must still resolve to Pacf, got: {parsed:?}"
);
}
}