solverforge_core/solver/
response.rs

1use serde::{Deserialize, Deserializer, Serialize};
2
3/// Performance statistics from a solver run.
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "camelCase")]
6pub struct SolverStats {
7    pub time_spent_millis: u64,
8    pub score_calculation_count: u64,
9    pub score_calculation_speed: u64,
10    pub move_evaluation_count: u64,
11    pub move_evaluation_speed: u64,
12}
13
14impl SolverStats {
15    pub fn new(
16        time_spent_millis: u64,
17        score_calculation_count: u64,
18        score_calculation_speed: u64,
19        move_evaluation_count: u64,
20        move_evaluation_speed: u64,
21    ) -> Self {
22        Self {
23            time_spent_millis,
24            score_calculation_count,
25            score_calculation_speed,
26            move_evaluation_count,
27            move_evaluation_speed,
28        }
29    }
30
31    /// Returns a formatted summary of the solver statistics.
32    pub fn summary(&self) -> String {
33        format!(
34            "Time: {}ms | Moves: {} ({}/sec) | Score calcs: {} ({}/sec)",
35            self.time_spent_millis,
36            self.move_evaluation_count,
37            self.move_evaluation_speed,
38            self.score_calculation_count,
39            self.score_calculation_speed
40        )
41    }
42}
43
44/// Custom deserializer for score that handles both string format (e.g., "-8")
45/// and object format (e.g., {"SimpleScore": "-8"})
46fn deserialize_score<'de, D>(deserializer: D) -> Result<String, D::Error>
47where
48    D: Deserializer<'de>,
49{
50    use serde::de::{Error, Visitor};
51
52    struct ScoreVisitor;
53
54    impl<'de> Visitor<'de> for ScoreVisitor {
55        type Value = String;
56
57        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
58            formatter.write_str("a string, null, or an object with score type key")
59        }
60
61        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
62        where
63            E: Error,
64        {
65            Ok(value.to_string())
66        }
67
68        fn visit_unit<E>(self) -> Result<Self::Value, E>
69        where
70            E: Error,
71        {
72            // Handle null as an uninitialized score
73            Ok("uninitialized".to_string())
74        }
75
76        fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
77        where
78            M: serde::de::MapAccess<'de>,
79        {
80            // Expect a single entry like {"SimpleScore": "-8"} or {"HardSoftScore": "0hard/-5soft"}
81            if let Some((_score_type, score_value)) = map.next_entry::<String, String>()? {
82                // For SimpleScore, HardSoftScore, etc., return just the value
83                Ok(score_value)
84            } else {
85                Err(Error::custom("expected score object to have one entry"))
86            }
87        }
88    }
89
90    deserializer.deserialize_any(ScoreVisitor)
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct SolveResponse {
96    pub solution: String,
97    /// The score as a string (e.g., "-8" for SimpleScore, "0hard/-5soft" for HardSoftScore)
98    /// Handles both string and object formats from the server.
99    #[serde(deserialize_with = "deserialize_score")]
100    pub score: String,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub stats: Option<SolverStats>,
103}
104
105impl SolveResponse {
106    pub fn new(solution: String, score: impl Into<String>) -> Self {
107        Self {
108            solution,
109            score: score.into(),
110            stats: None,
111        }
112    }
113
114    pub fn with_stats(solution: String, score: impl Into<String>, stats: SolverStats) -> Self {
115        Self {
116            solution,
117            score: score.into(),
118            stats: Some(stats),
119        }
120    }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct ScoreDto {
126    pub score_string: String,
127    pub hard_score: i64,
128    pub soft_score: i64,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub medium_score: Option<i64>,
131    pub is_feasible: bool,
132}
133
134impl ScoreDto {
135    pub fn hard_soft(hard: i64, soft: i64) -> Self {
136        Self {
137            score_string: format!("{}hard/{}soft", hard, soft),
138            hard_score: hard,
139            soft_score: soft,
140            medium_score: None,
141            is_feasible: hard >= 0,
142        }
143    }
144
145    pub fn hard_medium_soft(hard: i64, medium: i64, soft: i64) -> Self {
146        Self {
147            score_string: format!("{}hard/{}medium/{}soft", hard, medium, soft),
148            hard_score: hard,
149            soft_score: soft,
150            medium_score: Some(medium),
151            is_feasible: hard >= 0,
152        }
153    }
154
155    pub fn simple(score: i64) -> Self {
156        Self {
157            score_string: score.to_string(),
158            hard_score: score,
159            soft_score: 0,
160            medium_score: None,
161            is_feasible: true,
162        }
163    }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
168pub enum SolveState {
169    Pending,
170    Running,
171    Completed,
172    Failed,
173    Stopped,
174}
175
176impl SolveState {
177    pub fn is_terminal(&self) -> bool {
178        matches!(
179            self,
180            SolveState::Completed | SolveState::Failed | SolveState::Stopped
181        )
182    }
183
184    pub fn is_running(&self) -> bool {
185        matches!(self, SolveState::Running)
186    }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "camelCase")]
191pub struct SolveStatus {
192    pub state: SolveState,
193    pub time_spent_ms: u64,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub best_score: Option<ScoreDto>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub error: Option<String>,
198}
199
200impl SolveStatus {
201    pub fn pending() -> Self {
202        Self {
203            state: SolveState::Pending,
204            time_spent_ms: 0,
205            best_score: None,
206            error: None,
207        }
208    }
209
210    pub fn running(time_spent_ms: u64, best_score: Option<ScoreDto>) -> Self {
211        Self {
212            state: SolveState::Running,
213            time_spent_ms,
214            best_score,
215            error: None,
216        }
217    }
218
219    pub fn completed(time_spent_ms: u64, score: ScoreDto) -> Self {
220        Self {
221            state: SolveState::Completed,
222            time_spent_ms,
223            best_score: Some(score),
224            error: None,
225        }
226    }
227
228    pub fn failed(time_spent_ms: u64, error: impl Into<String>) -> Self {
229        Self {
230            state: SolveState::Failed,
231            time_spent_ms,
232            best_score: None,
233            error: Some(error.into()),
234        }
235    }
236
237    pub fn stopped(time_spent_ms: u64, best_score: Option<ScoreDto>) -> Self {
238        Self {
239            state: SolveState::Stopped,
240            time_spent_ms,
241            best_score,
242            error: None,
243        }
244    }
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "camelCase")]
249pub struct AsyncSolveResponse {
250    pub solve_id: String,
251}
252
253impl AsyncSolveResponse {
254    pub fn new(solve_id: impl Into<String>) -> Self {
255        Self {
256            solve_id: solve_id.into(),
257        }
258    }
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Hash)]
262pub struct SolveHandle {
263    pub id: String,
264}
265
266impl SolveHandle {
267    pub fn new(id: impl Into<String>) -> Self {
268        Self { id: id.into() }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_solve_response_new() {
278        let response = SolveResponse::new(r#"{"lessons": []}"#.to_string(), "0hard/-10soft");
279
280        assert_eq!(response.solution, r#"{"lessons": []}"#);
281        assert_eq!(response.score, "0hard/-10soft");
282    }
283
284    #[test]
285    fn test_score_dto_hard_soft() {
286        let score = ScoreDto::hard_soft(-5, -100);
287
288        assert_eq!(score.hard_score, -5);
289        assert_eq!(score.soft_score, -100);
290        assert!(score.medium_score.is_none());
291        assert!(!score.is_feasible);
292        assert_eq!(score.score_string, "-5hard/-100soft");
293    }
294
295    #[test]
296    fn test_score_dto_hard_medium_soft() {
297        let score = ScoreDto::hard_medium_soft(0, -10, -50);
298
299        assert_eq!(score.hard_score, 0);
300        assert_eq!(score.medium_score, Some(-10));
301        assert_eq!(score.soft_score, -50);
302        assert!(score.is_feasible);
303        assert_eq!(score.score_string, "0hard/-10medium/-50soft");
304    }
305
306    #[test]
307    fn test_score_dto_simple() {
308        let score = ScoreDto::simple(42);
309
310        assert_eq!(score.hard_score, 42);
311        assert_eq!(score.soft_score, 0);
312        assert!(score.is_feasible);
313        assert_eq!(score.score_string, "42");
314    }
315
316    #[test]
317    fn test_solve_state_is_terminal() {
318        assert!(!SolveState::Pending.is_terminal());
319        assert!(!SolveState::Running.is_terminal());
320        assert!(SolveState::Completed.is_terminal());
321        assert!(SolveState::Failed.is_terminal());
322        assert!(SolveState::Stopped.is_terminal());
323    }
324
325    #[test]
326    fn test_solve_state_is_running() {
327        assert!(!SolveState::Pending.is_running());
328        assert!(SolveState::Running.is_running());
329        assert!(!SolveState::Completed.is_running());
330    }
331
332    #[test]
333    fn test_solve_status_pending() {
334        let status = SolveStatus::pending();
335
336        assert_eq!(status.state, SolveState::Pending);
337        assert_eq!(status.time_spent_ms, 0);
338        assert!(status.best_score.is_none());
339        assert!(status.error.is_none());
340    }
341
342    #[test]
343    fn test_solve_status_running() {
344        let score = ScoreDto::hard_soft(-10, -50);
345        let status = SolveStatus::running(5000, Some(score));
346
347        assert_eq!(status.state, SolveState::Running);
348        assert_eq!(status.time_spent_ms, 5000);
349        assert!(status.best_score.is_some());
350    }
351
352    #[test]
353    fn test_solve_status_completed() {
354        let score = ScoreDto::hard_soft(0, -20);
355        let status = SolveStatus::completed(30000, score);
356
357        assert_eq!(status.state, SolveState::Completed);
358        assert_eq!(status.time_spent_ms, 30000);
359        assert!(status.best_score.is_some());
360        assert!(status.best_score.as_ref().unwrap().is_feasible);
361    }
362
363    #[test]
364    fn test_solve_status_failed() {
365        let status = SolveStatus::failed(1000, "Timeout exceeded");
366
367        assert_eq!(status.state, SolveState::Failed);
368        assert_eq!(status.error, Some("Timeout exceeded".to_string()));
369    }
370
371    #[test]
372    fn test_solve_status_stopped() {
373        let score = ScoreDto::hard_soft(-5, -30);
374        let status = SolveStatus::stopped(15000, Some(score));
375
376        assert_eq!(status.state, SolveState::Stopped);
377        assert!(status.best_score.is_some());
378    }
379
380    #[test]
381    fn test_async_solve_response() {
382        let response = AsyncSolveResponse::new("solve-12345");
383        assert_eq!(response.solve_id, "solve-12345");
384    }
385
386    #[test]
387    fn test_solve_handle() {
388        let handle = SolveHandle::new("solve-12345");
389        assert_eq!(handle.id, "solve-12345");
390    }
391
392    #[test]
393    fn test_solve_response_json_serialization() {
394        let response = SolveResponse::new(r#"{"data": "test"}"#.to_string(), "0hard/-15soft");
395
396        let json = serde_json::to_string(&response).unwrap();
397        assert!(json.contains("\"solution\""));
398        assert!(json.contains("\"score\":\"0hard/-15soft\""));
399
400        let parsed: SolveResponse = serde_json::from_str(&json).unwrap();
401        assert_eq!(parsed, response);
402    }
403
404    #[test]
405    fn test_score_dto_json_omits_medium_when_none() {
406        let score = ScoreDto::hard_soft(0, -10);
407        let json = serde_json::to_string(&score).unwrap();
408        assert!(!json.contains("mediumScore"));
409    }
410
411    #[test]
412    fn test_solve_status_json_serialization() {
413        let status = SolveStatus::running(10000, Some(ScoreDto::hard_soft(-2, -100)));
414
415        let json = serde_json::to_string(&status).unwrap();
416        assert!(json.contains("\"state\":\"RUNNING\""));
417        assert!(json.contains("\"timeSpentMs\":10000"));
418
419        let parsed: SolveStatus = serde_json::from_str(&json).unwrap();
420        assert_eq!(parsed, status);
421    }
422
423    #[test]
424    fn test_solve_state_json_serialization() {
425        assert_eq!(
426            serde_json::to_string(&SolveState::Pending).unwrap(),
427            "\"PENDING\""
428        );
429        assert_eq!(
430            serde_json::to_string(&SolveState::Running).unwrap(),
431            "\"RUNNING\""
432        );
433        assert_eq!(
434            serde_json::to_string(&SolveState::Completed).unwrap(),
435            "\"COMPLETED\""
436        );
437        assert_eq!(
438            serde_json::to_string(&SolveState::Failed).unwrap(),
439            "\"FAILED\""
440        );
441        assert_eq!(
442            serde_json::to_string(&SolveState::Stopped).unwrap(),
443            "\"STOPPED\""
444        );
445    }
446
447    #[test]
448    fn test_score_dto_clone() {
449        let score = ScoreDto::hard_soft(0, -10);
450        let cloned = score.clone();
451        assert_eq!(score, cloned);
452    }
453
454    #[test]
455    fn test_solve_response_debug() {
456        let response = SolveResponse::new("{}".to_string(), "0");
457        let debug = format!("{:?}", response);
458        assert!(debug.contains("SolveResponse"));
459    }
460
461    #[test]
462    fn test_solve_status_debug() {
463        let status = SolveStatus::pending();
464        let debug = format!("{:?}", status);
465        assert!(debug.contains("SolveStatus"));
466    }
467}