Skip to main content

cobre_io/config/
simulation.rs

1//! Post-training simulation configuration types for `config.json → simulation`.
2
3use serde::{Deserialize, Serialize};
4
5use super::scenario_source::RawScenarioSourceConfig;
6use super::training::PhaseSolverProfileConfig;
7
8/// Default scenario count when `simulation.selection` is absent. Sole owner of
9/// the value; the count resolver reads it.
10pub(crate) const DEFAULT_NUM_SCENARIOS: u32 = 2000;
11
12/// Post-training simulation settings (`config.json → simulation`).
13#[derive(Debug, Clone, Deserialize, Serialize)]
14#[serde(default, deny_unknown_fields)]
15#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16pub struct SimulationConfig {
17    /// Enable post-training simulation.
18    pub enabled: bool,
19
20    /// Bounded channel capacity between simulation threads and the I/O writer thread.
21    pub io_channel_capacity: u32,
22
23    /// Scenario source configuration for the post-training simulation forward pass.
24    /// When absent, falls back to the training scenario source.
25    #[serde(default)]
26    pub scenario_source: Option<RawScenarioSourceConfig>,
27
28    /// Simulation solver profile. Absent leaves the phase's built-in
29    /// tuned profile.
30    #[serde(default)]
31    pub solver: Option<PhaseSolverProfileConfig>,
32
33    /// Phase-level scenario selection. Absent resolves to the default sampled
34    /// count ([`DEFAULT_NUM_SCENARIOS`]).
35    #[serde(default)]
36    pub selection: Option<SimulationSelection>,
37}
38
39impl Default for SimulationConfig {
40    fn default() -> Self {
41        Self {
42            enabled: false,
43            io_channel_capacity: 64,
44            scenario_source: None,
45            solver: None,
46            selection: None,
47        }
48    }
49}
50
51/// Post-training scenario selection and its method-specific parameters
52/// (`config.json → simulation.selection`).
53///
54/// Internally tagged on `method`; the tag is the semantic selection word, never
55/// a mechanism name. `sampled` draws `num_scenarios` trajectories; `enumerated`
56/// walks the scenario set exhaustively. Each variant carries only its own
57/// parameters, so pairing a count with `enumerated` is a parse error under
58/// `deny_unknown_fields` rather than a runtime-gated combination.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
60#[serde(tag = "method", rename_all = "snake_case", deny_unknown_fields)]
61#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
62pub enum SimulationSelection {
63    /// Sampled scenarios: draw `num_scenarios` trajectories.
64    Sampled {
65        /// Number of simulation trajectories to draw.
66        num_scenarios: u32,
67    },
68    /// Exhaustive enumeration of the scenario set.
69    // A braced variant, not a unit one: serde enforces `deny_unknown_fields`
70    // only for braced variants of an internally tagged enum, and this variant
71    // must reject a stray `num_scenarios`.
72    Enumerated {},
73}
74
75/// Effective simulation scenario-count resolution
76/// ([`Config::resolve_num_scenarios`](super::Config::resolve_num_scenarios)):
77/// either a concrete sampled count or a signal that the count is derived from
78/// the policy graph downstream, since config load holds no graph.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum NumScenariosResolution {
81    /// `num_scenarios` sampled simulation trajectories.
82    Sampled(u32),
83    /// Exhaustive enumeration; the count is derived from the policy graph.
84    Enumerated,
85}
86
87#[cfg(test)]
88#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
89mod tests {
90    use super::{SimulationConfig, SimulationSelection};
91    use crate::config::training::{PriceStrategy, ScaleStrategy};
92
93    /// A `sampled` phase selection round-trips into the `Sampled` variant.
94    #[test]
95    fn sampled_selection_round_trips() {
96        let json = r#"{"enabled": true, "selection": {"method": "sampled", "num_scenarios": 500}}"#;
97        let cfg: SimulationConfig = serde_json::from_str(json).unwrap();
98        assert_eq!(
99            cfg.selection,
100            Some(SimulationSelection::Sampled { num_scenarios: 500 })
101        );
102    }
103
104    /// The removed flat `num_scenarios` alias is an unknown-field deserialize
105    /// error under `deny_unknown_fields`; the count lives solely in the
106    /// `selection.sampled` arm.
107    #[test]
108    fn flat_num_scenarios_alias_is_deserialize_error() {
109        let alias = r#"{"enabled": true, "num_scenarios": 500}"#;
110        assert!(
111            serde_json::from_str::<SimulationConfig>(alias).is_err(),
112            "flat num_scenarios must be rejected as an unknown field"
113        );
114
115        let arm = r#"{"enabled": true, "selection": {"method": "sampled", "num_scenarios": 500}}"#;
116        let cfg: SimulationConfig = serde_json::from_str(arm).unwrap();
117        assert_eq!(
118            cfg.selection,
119            Some(SimulationSelection::Sampled { num_scenarios: 500 })
120        );
121    }
122
123    /// A count under `enumerated` is unrepresentable — `deny_unknown_fields` on
124    /// the braced variant rejects it at parse time.
125    #[test]
126    fn enumerated_selection_with_count_is_deserialize_error() {
127        let json =
128            r#"{"enabled": true, "selection": {"method": "enumerated", "num_scenarios": 500}}"#;
129        let result = serde_json::from_str::<SimulationConfig>(json);
130        assert!(
131            result.is_err(),
132            "a count under enumerated must be rejected as unrepresentable"
133        );
134    }
135
136    #[test]
137    fn simulation_solver_profile_block_round_trips() {
138        let json = r#"{
139            "enabled": true,
140            "selection": { "method": "sampled", "num_scenarios": 500 },
141            "solver": {
142                "scale": "off",
143                "price": "row"
144            }
145        }"#;
146        let cfg: SimulationConfig = serde_json::from_str(json).unwrap();
147        let solver = cfg.solver.as_ref().expect("solver present");
148        assert_eq!(solver.scale, Some(ScaleStrategy::Off));
149        assert_eq!(solver.price, Some(PriceStrategy::Row));
150    }
151
152    #[test]
153    fn simulation_solver_profile_absent_is_none() {
154        assert!(SimulationConfig::default().solver.is_none());
155    }
156
157    #[test]
158    fn simulation_solver_profile_steepest_edge_fallback_threshold_round_trips() {
159        let json = r#"{
160            "enabled": true,
161            "selection": { "method": "sampled", "num_scenarios": 500 },
162            "solver": {
163                "steepest_edge_devex_fallback_threshold": 12.5
164            }
165        }"#;
166        let cfg: SimulationConfig = serde_json::from_str(json).unwrap();
167        let solver = cfg.solver.as_ref().expect("solver present");
168        assert_eq!(solver.steepest_edge_devex_fallback_threshold, Some(12.5));
169    }
170}