use serde::{Deserialize, 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 forward_passes: Option<u32>,
pub stopping_rules: Option<Vec<StoppingRuleConfig>>,
#[serde(default = "TrainingConfig::default_stopping_mode")]
pub stopping_mode: String,
#[serde(default)]
pub cut_selection: RowSelectionConfig,
#[serde(default)]
pub solver: TrainingSolverConfig,
#[serde(default)]
pub scenario_source: Option<RawScenarioSourceConfig>,
}
impl TrainingConfig {
pub(super) fn default_enabled() -> bool {
true
}
pub(super) fn default_stopping_mode() -> String {
"any".to_string()
}
}
#[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,
}
impl Default for TrainingSolverConfig {
fn default() -> Self {
Self {
retry_max_attempts: 5,
retry_time_budget_seconds: 30.0,
}
}
}
#[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)]
#[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::{SelectionMethod, TrainingConfig};
#[test]
fn dynamic_selection_block_round_trips() {
let json = r#"{
"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#"{
"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#"{
"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#"{
"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#"{
"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#"{
"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"
);
}
}