solverforge_core/solver/
termination.rs

1use serde::{Deserialize, Serialize};
2
3/// Configuration for when the solver should stop.
4///
5/// Combine multiple criteria - the solver stops when ANY criterion is met.
6///
7/// # Example
8///
9/// ```
10/// use solverforge_core::solver::TerminationConfig;
11///
12/// // Stop after 5 minutes OR when a feasible solution is found
13/// let termination = TerminationConfig::new()
14///     .with_spent_limit("PT5M")
15///     .with_best_score_feasible(true);
16///
17/// assert_eq!(termination.spent_limit, Some("PT5M".to_string()));
18/// ```
19///
20/// # Time Format
21///
22/// Time limits use ISO-8601 duration format:
23/// - `PT30S` = 30 seconds
24/// - `PT5M` = 5 minutes
25/// - `PT1H` = 1 hour
26/// - `PT1H30M` = 1 hour 30 minutes
27#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct TerminationConfig {
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub spent_limit: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub unimproved_spent_limit: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub unimproved_step_count: Option<u64>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub best_score_limit: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub best_score_feasible: Option<bool>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub step_count_limit: Option<u64>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub move_count_limit: Option<u64>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub score_calculation_count_limit: Option<u64>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub diminished_returns: Option<DiminishedReturnsConfig>,
48}
49
50impl TerminationConfig {
51    pub fn new() -> Self {
52        Self::default()
53    }
54
55    pub fn with_spent_limit(mut self, limit: impl Into<String>) -> Self {
56        self.spent_limit = Some(limit.into());
57        self
58    }
59
60    pub fn with_unimproved_spent_limit(mut self, limit: impl Into<String>) -> Self {
61        self.unimproved_spent_limit = Some(limit.into());
62        self
63    }
64
65    pub fn with_unimproved_step_count(mut self, count: u64) -> Self {
66        self.unimproved_step_count = Some(count);
67        self
68    }
69
70    pub fn with_best_score_limit(mut self, limit: impl Into<String>) -> Self {
71        self.best_score_limit = Some(limit.into());
72        self
73    }
74
75    pub fn with_best_score_feasible(mut self, feasible: bool) -> Self {
76        self.best_score_feasible = Some(feasible);
77        self
78    }
79
80    pub fn with_step_count_limit(mut self, count: u64) -> Self {
81        self.step_count_limit = Some(count);
82        self
83    }
84
85    pub fn with_move_count_limit(mut self, count: u64) -> Self {
86        self.move_count_limit = Some(count);
87        self
88    }
89
90    pub fn with_score_calculation_count_limit(mut self, count: u64) -> Self {
91        self.score_calculation_count_limit = Some(count);
92        self
93    }
94
95    pub fn with_diminished_returns(mut self, config: DiminishedReturnsConfig) -> Self {
96        self.diminished_returns = Some(config);
97        self
98    }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct DiminishedReturnsConfig {
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub minimum_improvement_ratio: Option<String>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub slow_improvement_limit: Option<String>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub slow_improvement_spent_limit: Option<String>,
110}
111
112impl DiminishedReturnsConfig {
113    pub fn new() -> Self {
114        Self::default()
115    }
116
117    pub fn with_minimum_improvement_ratio(mut self, ratio: impl Into<String>) -> Self {
118        self.minimum_improvement_ratio = Some(ratio.into());
119        self
120    }
121
122    pub fn with_slow_improvement_limit(mut self, limit: impl Into<String>) -> Self {
123        self.slow_improvement_limit = Some(limit.into());
124        self
125    }
126
127    pub fn with_slow_improvement_spent_limit(mut self, limit: impl Into<String>) -> Self {
128        self.slow_improvement_spent_limit = Some(limit.into());
129        self
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn test_termination_config_new() {
139        let config = TerminationConfig::new();
140        assert!(config.spent_limit.is_none());
141        assert!(config.unimproved_spent_limit.is_none());
142    }
143
144    #[test]
145    fn test_termination_config_spent_limit() {
146        let config = TerminationConfig::new().with_spent_limit("PT5M");
147        assert_eq!(config.spent_limit, Some("PT5M".to_string()));
148    }
149
150    #[test]
151    fn test_termination_config_unimproved_spent_limit() {
152        let config = TerminationConfig::new().with_unimproved_spent_limit("PT30S");
153        assert_eq!(config.unimproved_spent_limit, Some("PT30S".to_string()));
154    }
155
156    #[test]
157    fn test_termination_config_unimproved_step_count() {
158        let config = TerminationConfig::new().with_unimproved_step_count(100);
159        assert_eq!(config.unimproved_step_count, Some(100));
160    }
161
162    #[test]
163    fn test_termination_config_best_score_limit() {
164        let config = TerminationConfig::new().with_best_score_limit("0hard/-100soft");
165        assert_eq!(config.best_score_limit, Some("0hard/-100soft".to_string()));
166    }
167
168    #[test]
169    fn test_termination_config_best_score_feasible() {
170        let config = TerminationConfig::new().with_best_score_feasible(true);
171        assert_eq!(config.best_score_feasible, Some(true));
172    }
173
174    #[test]
175    fn test_termination_config_step_count_limit() {
176        let config = TerminationConfig::new().with_step_count_limit(1000);
177        assert_eq!(config.step_count_limit, Some(1000));
178    }
179
180    #[test]
181    fn test_termination_config_move_count_limit() {
182        let config = TerminationConfig::new().with_move_count_limit(10000);
183        assert_eq!(config.move_count_limit, Some(10000));
184    }
185
186    #[test]
187    fn test_termination_config_score_calculation_count_limit() {
188        let config = TerminationConfig::new().with_score_calculation_count_limit(1000000);
189        assert_eq!(config.score_calculation_count_limit, Some(1000000));
190    }
191
192    #[test]
193    fn test_termination_config_chained() {
194        let config = TerminationConfig::new()
195            .with_spent_limit("PT10M")
196            .with_unimproved_spent_limit("PT1M")
197            .with_best_score_feasible(true);
198
199        assert_eq!(config.spent_limit, Some("PT10M".to_string()));
200        assert_eq!(config.unimproved_spent_limit, Some("PT1M".to_string()));
201        assert_eq!(config.best_score_feasible, Some(true));
202    }
203
204    #[test]
205    fn test_diminished_returns_config_new() {
206        let config = DiminishedReturnsConfig::new();
207        assert!(config.minimum_improvement_ratio.is_none());
208    }
209
210    #[test]
211    fn test_diminished_returns_config_with_ratio() {
212        let config = DiminishedReturnsConfig::new().with_minimum_improvement_ratio("0.001");
213        assert_eq!(config.minimum_improvement_ratio, Some("0.001".to_string()));
214    }
215
216    #[test]
217    fn test_termination_config_with_diminished_returns() {
218        let dr = DiminishedReturnsConfig::new().with_minimum_improvement_ratio("0.01");
219        let config = TerminationConfig::new().with_diminished_returns(dr);
220        assert!(config.diminished_returns.is_some());
221    }
222
223    #[test]
224    fn test_termination_config_json_serialization() {
225        let config = TerminationConfig::new()
226            .with_spent_limit("PT5M")
227            .with_best_score_feasible(true);
228
229        let json = serde_json::to_string(&config).unwrap();
230        assert!(json.contains("\"spentLimit\":\"PT5M\""));
231        assert!(json.contains("\"bestScoreFeasible\":true"));
232
233        let parsed: TerminationConfig = serde_json::from_str(&json).unwrap();
234        assert_eq!(parsed, config);
235    }
236
237    #[test]
238    fn test_termination_config_json_omits_none() {
239        let config = TerminationConfig::new().with_spent_limit("PT1H");
240        let json = serde_json::to_string(&config).unwrap();
241        assert!(!json.contains("unimprovedSpentLimit"));
242        assert!(!json.contains("bestScoreLimit"));
243    }
244
245    #[test]
246    fn test_diminished_returns_json_serialization() {
247        let config = DiminishedReturnsConfig::new()
248            .with_minimum_improvement_ratio("0.001")
249            .with_slow_improvement_limit("PT30S");
250
251        let json = serde_json::to_string(&config).unwrap();
252        assert!(json.contains("\"minimumImprovementRatio\":\"0.001\""));
253        assert!(json.contains("\"slowImprovementLimit\":\"PT30S\""));
254
255        let parsed: DiminishedReturnsConfig = serde_json::from_str(&json).unwrap();
256        assert_eq!(parsed, config);
257    }
258
259    #[test]
260    fn test_termination_config_clone() {
261        let config = TerminationConfig::new().with_spent_limit("PT5M");
262        let cloned = config.clone();
263        assert_eq!(config, cloned);
264    }
265
266    #[test]
267    fn test_termination_config_debug() {
268        let config = TerminationConfig::new().with_spent_limit("PT5M");
269        let debug = format!("{:?}", config);
270        assert!(debug.contains("TerminationConfig"));
271        assert!(debug.contains("PT5M"));
272    }
273}