use std::fmt;
use std::num::NonZeroUsize;
use serde::{Deserialize, Deserializer, Serialize};
use super::scenario_source::RawScenarioSourceConfig;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
#[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 stopping_rules: Option<Vec<StoppingRuleConfig>>,
#[serde(default)]
pub stopping_mode: StoppingMode,
#[serde(default)]
pub cut_selection: RowSelectionConfig,
#[serde(default)]
pub solver: TrainingSolverConfig,
#[serde(default)]
pub parallelism: ParallelismConfig,
#[serde(default)]
pub scenario_source: Option<RawScenarioSourceConfig>,
#[serde(default)]
pub selection: Option<TrainingSelection>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "method", rename_all = "snake_case", deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum TrainingSelection {
Sampled {
forward_passes: u32,
},
Enumerated {},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ForwardPassesResolution {
Sampled(u32),
Enumerated,
}
impl TrainingConfig {
pub(super) fn default_enabled() -> bool {
true
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum StoppingMode {
#[default]
Any,
All,
}
impl<'de> Deserialize<'de> for StoppingMode {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
match s.as_str() {
"any" => Ok(Self::Any),
"all" => Ok(Self::All),
other => Err(serde::de::Error::unknown_variant(other, &["any", "all"])),
}
}
}
impl fmt::Display for StoppingMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Any => f.write_str("any"),
Self::All => f.write_str("all"),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default, deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct RowSelectionConfig {
#[serde(default)]
pub row_activity_tolerance: Option<f64>,
#[serde(default)]
pub max_active_per_stage: Option<u32>,
#[serde(default)]
pub selection: Option<SelectionMethod>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "method", rename_all = "snake_case", deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum SelectionMethod {
Level1 {
#[serde(default = "default_tie_tolerance")]
tie_tolerance: f64,
#[serde(default = "default_check_frequency")]
check_frequency: u32,
},
Lml1 {
#[serde(default = "default_tie_tolerance")]
tie_tolerance: f64,
#[serde(default = "default_check_frequency")]
check_frequency: u32,
},
Domination {
domination_tolerance: f64,
#[serde(default = "default_check_frequency")]
check_frequency: u32,
},
Dynamic {
#[serde(default = "default_start_iteration")]
start_iteration: u32,
#[serde(default = "default_seed_window")]
seed_window: u32,
#[serde(default)]
candidate_recency: Option<u32>,
#[serde(default = "default_max_added_per_round")]
max_added_per_round: u32,
#[serde(default = "default_violation_tolerance")]
violation_tolerance: f64,
},
}
fn default_tie_tolerance() -> f64 {
1e-10
}
fn default_check_frequency() -> u32 {
5
}
fn default_start_iteration() -> u32 {
2
}
fn default_seed_window() -> u32 {
5
}
fn default_max_added_per_round() -> u32 {
10
}
fn default_violation_tolerance() -> f64 {
1e-10
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TrainingSolverConfig {
pub retry_max_attempts: u32,
pub retry_time_budget_seconds: f64,
#[serde(default)]
pub backward: Option<PhaseSolverProfileConfig>,
#[serde(default)]
pub forward: Option<PhaseSolverProfileConfig>,
}
impl Default for TrainingSolverConfig {
fn default() -> Self {
Self {
retry_max_attempts: 5,
retry_time_budget_seconds: 30.0,
backward: None,
forward: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PhaseSolverProfileConfig {
#[serde(default)]
pub dual_edge_weight: Option<DualEdgeWeight>,
#[serde(default)]
pub scale: Option<ScaleStrategy>,
#[serde(default)]
pub price: Option<PriceStrategy>,
#[serde(default)]
pub primal_feasibility_tolerance: Option<f64>,
#[serde(default)]
pub dual_feasibility_tolerance: Option<f64>,
#[serde(default)]
pub presolve: Option<PresolveMode>,
#[serde(default)]
pub simplex_update_limit: Option<u32>,
#[serde(default)]
pub cost_perturbation: Option<f64>,
#[serde(default)]
pub refactor_error_tolerance: Option<f64>,
#[serde(default)]
pub factor_pivot_threshold: Option<f64>,
#[serde(default)]
pub use_warm_start: Option<bool>,
#[serde(default)]
pub steepest_edge_devex_fallback_threshold: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum PresolveMode {
On,
Off,
Choose,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ParallelismConfig {
pub backward_scheduler: BackwardScheduler,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "method", rename_all = "snake_case", deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum BackwardScheduler {
ByScenario {},
ByNode {
#[serde(default)]
block_size: Option<NonZeroUsize>,
},
}
impl Default for BackwardScheduler {
fn default() -> Self {
Self::ByScenario {}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum DualEdgeWeight {
Devex,
SteepestEdge,
Dantzig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum ScaleStrategy {
Off,
SolverScaling,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum PriceStrategy {
Row,
RowHyperSparse,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum StoppingRuleConfig {
IterationLimit {
limit: u32,
},
TimeLimit {
seconds: f64,
},
BoundStalling {
iterations: u32,
tolerance: f64,
},
Gap {
#[serde(default)]
tolerance: Option<f64>,
#[serde(default)]
relative_tolerance: Option<f64>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(default, deny_unknown_fields)]
#[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)]
#[serde(default, deny_unknown_fields)]
#[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>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::{
BackwardScheduler, DualEdgeWeight, NonZeroUsize, PresolveMode, PriceStrategy,
ScaleStrategy, SelectionMethod, StoppingRuleConfig, TrainingConfig, TrainingSelection,
};
#[test]
fn dynamic_selection_block_round_trips() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"cut_selection": {
"row_activity_tolerance": 1e-6,
"max_active_per_stage": 4000,
"selection": {
"method": "dynamic",
"start_iteration": 5,
"seed_window": 0,
"candidate_recency": 20,
"max_added_per_round": 3,
"violation_tolerance": 1e-9
}
}
}"#;
let cfg: TrainingConfig = serde_json::from_str(json).unwrap();
let cs = &cfg.cut_selection;
assert_eq!(cs.row_activity_tolerance, Some(1e-6));
assert_eq!(cs.max_active_per_stage, Some(4000));
match cs.selection.as_ref().expect("selection present") {
SelectionMethod::Dynamic {
start_iteration,
seed_window,
candidate_recency,
max_added_per_round,
violation_tolerance,
} => {
assert_eq!(*start_iteration, 5);
assert_eq!(*seed_window, 0);
assert_eq!(*candidate_recency, Some(20));
assert_eq!(*max_added_per_round, 3);
assert!((*violation_tolerance - 1e-9).abs() < f64::EPSILON);
}
other => panic!("expected Dynamic, got {other:?}"),
}
}
#[test]
fn level1_selection_block_round_trips_with_defaults() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"cut_selection": { "selection": { "method": "level1" } }
}"#;
let cfg: TrainingConfig = serde_json::from_str(json).unwrap();
match cfg
.cut_selection
.selection
.as_ref()
.expect("selection present")
{
SelectionMethod::Level1 {
tie_tolerance,
check_frequency,
} => {
assert!((*tie_tolerance - 1e-10).abs() < 1e-20);
assert_eq!(*check_frequency, 5);
}
other => panic!("expected Level1, got {other:?}"),
}
}
#[test]
fn omitting_selection_disables_row_selection() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"cut_selection": {}
}"#;
let cfg: TrainingConfig = serde_json::from_str(json).unwrap();
assert!(cfg.cut_selection.selection.is_none());
}
#[test]
fn wrong_method_field_is_deserialize_error() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"cut_selection": {
"selection": { "method": "level1", "max_added_per_round": 3 }
}
}"#;
let result = serde_json::from_str::<TrainingConfig>(json);
assert!(
result.is_err(),
"a Dynamic-only field under level1 must be rejected"
);
}
#[test]
fn bad_method_string_is_deserialize_error() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"cut_selection": { "selection": { "method": "dynmic" } }
}"#;
let result = serde_json::from_str::<TrainingConfig>(json);
assert!(result.is_err(), "an unknown method tag must be rejected");
}
#[test]
fn domination_without_tolerance_is_missing_field_error() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"cut_selection": { "selection": { "method": "domination" } }
}"#;
let result = serde_json::from_str::<TrainingConfig>(json);
assert!(
result.is_err(),
"domination requires domination_tolerance; absence must be rejected"
);
}
#[test]
fn backward_solver_profile_block_round_trips() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"solver": {
"backward": {
"dual_edge_weight": "steepest_edge",
"scale": "solver_scaling",
"price": "row",
"primal_feasibility_tolerance": 1e-7
}
}
}"#;
let cfg: TrainingConfig = serde_json::from_str(json).unwrap();
let backward = cfg.solver.backward.as_ref().expect("backward present");
assert_eq!(
backward.dual_edge_weight,
Some(DualEdgeWeight::SteepestEdge)
);
assert_eq!(backward.scale, Some(ScaleStrategy::SolverScaling));
assert_eq!(backward.price, Some(PriceStrategy::Row));
assert_eq!(backward.primal_feasibility_tolerance, Some(1e-7));
assert!(cfg.solver.forward.is_none());
}
#[test]
fn backward_solver_profile_new_fields_round_trip() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"solver": {
"backward": {
"presolve": "off",
"use_warm_start": false,
"factor_pivot_threshold": 0.2
}
}
}"#;
let cfg: TrainingConfig = serde_json::from_str(json).unwrap();
let backward = cfg.solver.backward.as_ref().expect("backward present");
assert_eq!(backward.presolve, Some(PresolveMode::Off));
assert_eq!(backward.use_warm_start, Some(false));
assert_eq!(backward.factor_pivot_threshold, Some(0.2));
assert!(backward.dual_feasibility_tolerance.is_none());
assert!(backward.simplex_update_limit.is_none());
assert!(backward.cost_perturbation.is_none());
assert!(backward.refactor_error_tolerance.is_none());
assert!(backward.steepest_edge_devex_fallback_threshold.is_none());
}
#[test]
fn forward_solver_profile_block_round_trips() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"solver": {
"forward": {
"price": "row_hyper_sparse",
"dual_edge_weight": "dantzig"
}
}
}"#;
let cfg: TrainingConfig = serde_json::from_str(json).unwrap();
let forward = cfg.solver.forward.as_ref().expect("forward present");
assert_eq!(forward.price, Some(PriceStrategy::RowHyperSparse));
assert_eq!(forward.dual_edge_weight, Some(DualEdgeWeight::Dantzig));
assert!(cfg.solver.backward.is_none());
}
#[test]
fn backward_solver_profile_unknown_field_is_deserialize_error() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"solver": { "backward": { "dual_edge_weght": "devex" } }
}"#;
let result = serde_json::from_str::<TrainingConfig>(json);
assert!(
result.is_err(),
"an unknown field under backward must be rejected"
);
}
#[test]
fn backward_solver_profile_presolv_typo_is_deserialize_error() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"solver": { "backward": { "presolv": "off" } }
}"#;
let result = serde_json::from_str::<TrainingConfig>(json);
assert!(
result.is_err(),
"the presolv typo under backward must be rejected"
);
}
#[test]
fn backward_solver_profile_bad_enum_value_is_deserialize_error() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"solver": { "backward": { "scale": "curtis_reid" } }
}"#;
let result = serde_json::from_str::<TrainingConfig>(json);
assert!(result.is_err(), "an unknown scale value must be rejected");
}
#[test]
fn backward_scheduler_defaults_to_by_scenario_when_absent() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }]
}"#;
let cfg: TrainingConfig = serde_json::from_str(json).unwrap();
assert_eq!(
cfg.parallelism.backward_scheduler,
BackwardScheduler::ByScenario {}
);
}
#[test]
fn by_node_scheduler_and_block_size_round_trip() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"parallelism": {
"backward_scheduler": { "method": "by_node", "block_size": 4 }
}
}"#;
let cfg: TrainingConfig = serde_json::from_str(json).unwrap();
assert_eq!(
cfg.parallelism.backward_scheduler,
BackwardScheduler::ByNode {
block_size: NonZeroUsize::new(4)
}
);
}
#[test]
fn by_node_scheduler_without_block_size_round_trips() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"parallelism": {
"backward_scheduler": { "method": "by_node" }
}
}"#;
let cfg: TrainingConfig = serde_json::from_str(json).unwrap();
assert_eq!(
cfg.parallelism.backward_scheduler,
BackwardScheduler::ByNode { block_size: None }
);
}
#[test]
fn block_size_under_by_scenario_is_deserialize_error() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"parallelism": {
"backward_scheduler": { "method": "by_scenario", "block_size": 4 }
}
}"#;
let result = serde_json::from_str::<TrainingConfig>(json);
assert!(
result.is_err(),
"block_size under by_scenario must be rejected"
);
}
#[test]
fn retired_scheduler_spellings_are_deserialize_error() {
for method in ["trial_point", "opening_block"] {
let json = format!(
r#"{{
"selection": {{ "method": "sampled", "forward_passes": 4 }},
"stopping_rules": [{{ "type": "iteration_limit", "limit": 100 }}],
"parallelism": {{
"backward_scheduler": {{ "method": "{method}" }}
}}
}}"#
);
let result = serde_json::from_str::<TrainingConfig>(&json);
assert!(
result.is_err(),
"retired scheduler spelling '{method}' must be an unknown-variant error"
);
}
}
#[test]
fn unknown_scheduler_method_is_deserialize_error() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"parallelism": {
"backward_scheduler": { "method": "by_nod" }
}
}"#;
let result = serde_json::from_str::<TrainingConfig>(json);
assert!(result.is_err(), "an unknown method tag must be rejected");
}
#[test]
fn block_size_zero_is_deserialize_error() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"parallelism": {
"backward_scheduler": { "method": "by_node", "block_size": 0 }
}
}"#;
let result = serde_json::from_str::<TrainingConfig>(json);
assert!(
result.is_err(),
"block_size = 0 must be rejected by NonZeroUsize"
);
}
#[test]
fn removed_root_scheduler_keys_are_rejected() {
for stale in [
r#""backward_scheduler": "opening_block""#,
r#""opening_block_size": 4"#,
r#""backward_opening_order": "sigma_key""#,
] {
let json = format!(
r#"{{
"selection": {{ "method": "sampled", "forward_passes": 4 }},
"stopping_rules": [{{ "type": "iteration_limit", "limit": 100 }}],
{stale}
}}"#
);
let result = serde_json::from_str::<TrainingConfig>(&json);
assert!(
result.is_err(),
"removed root key must be rejected, got Ok for: {stale}"
);
}
}
#[test]
fn sampled_selection_round_trips() {
let json = r#"{
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"selection": { "method": "sampled", "forward_passes": 8 }
}"#;
let cfg: TrainingConfig = serde_json::from_str(json).unwrap();
assert_eq!(
cfg.selection,
Some(TrainingSelection::Sampled { forward_passes: 8 })
);
}
#[test]
fn root_forward_passes_alias_is_deserialize_error() {
let alias = r#"{
"forward_passes": 4,
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }]
}"#;
assert!(
serde_json::from_str::<TrainingConfig>(alias).is_err(),
"root forward_passes must be rejected as an unknown field"
);
let arm = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }]
}"#;
let cfg: TrainingConfig = serde_json::from_str(arm).unwrap();
assert_eq!(
cfg.selection,
Some(TrainingSelection::Sampled { forward_passes: 4 })
);
}
#[test]
fn enumerated_selection_with_count_is_deserialize_error() {
let json = r#"{
"stopping_rules": [{ "type": "iteration_limit", "limit": 100 }],
"selection": { "method": "enumerated", "forward_passes": 8 }
}"#;
let result = serde_json::from_str::<TrainingConfig>(json);
assert!(
result.is_err(),
"a count under enumerated must be rejected as unrepresentable"
);
}
#[test]
fn wrong_stopping_rule_field_is_deserialize_error() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [
{ "type": "iteration_limit", "limit": 100, "seconds": 60.0 }
]
}"#;
let result = serde_json::from_str::<TrainingConfig>(json);
assert!(
result.is_err(),
"a time_limit-only field under iteration_limit must be rejected"
);
}
#[test]
fn gap_stopping_rule_tolerance_only_round_trips() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "gap", "tolerance": 1000.0 }]
}"#;
let cfg: TrainingConfig = serde_json::from_str(json).unwrap();
let rules = cfg.stopping_rules.expect("stopping_rules present");
assert!(matches!(
rules[0],
StoppingRuleConfig::Gap {
tolerance: Some(t),
relative_tolerance: None
} if (t - 1000.0).abs() < f64::EPSILON
));
}
#[test]
fn gap_stopping_rule_relative_tolerance_only_round_trips() {
let json = r#"{
"selection": { "method": "sampled", "forward_passes": 4 },
"stopping_rules": [{ "type": "gap", "relative_tolerance": 0.01 }]
}"#;
let cfg: TrainingConfig = serde_json::from_str(json).unwrap();
let rules = cfg.stopping_rules.expect("stopping_rules present");
assert!(matches!(
rules[0],
StoppingRuleConfig::Gap {
tolerance: None,
relative_tolerance: Some(rt)
} if (rt - 0.01).abs() < f64::EPSILON
));
}
}