solverforge_core/analysis/
explanation.rs

1use crate::solver::ScoreDto;
2use crate::{ObjectHandle, Value};
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6#[serde(rename_all = "camelCase")]
7pub struct ScoreExplanation {
8    pub score: ScoreDto,
9    pub constraint_matches: Vec<ConstraintMatch>,
10    pub indictments: Vec<Indictment>,
11}
12
13impl ScoreExplanation {
14    pub fn new(score: ScoreDto) -> Self {
15        Self {
16            score,
17            constraint_matches: Vec::new(),
18            indictments: Vec::new(),
19        }
20    }
21
22    pub fn with_constraint_match(mut self, constraint_match: ConstraintMatch) -> Self {
23        self.constraint_matches.push(constraint_match);
24        self
25    }
26
27    pub fn with_indictment(mut self, indictment: Indictment) -> Self {
28        self.indictments.push(indictment);
29        self
30    }
31
32    pub fn is_feasible(&self) -> bool {
33        self.score.is_feasible
34    }
35
36    pub fn constraint_count(&self) -> usize {
37        self.constraint_matches.len()
38    }
39
40    pub fn get_constraint_matches_by_name(&self, name: &str) -> Vec<&ConstraintMatch> {
41        self.constraint_matches
42            .iter()
43            .filter(|m| m.constraint_name == name)
44            .collect()
45    }
46
47    pub fn get_indictment_for_object(&self, object: ObjectHandle) -> Option<&Indictment> {
48        self.indictments
49            .iter()
50            .find(|i| i.indicted_object == object)
51    }
52
53    pub fn hard_score(&self) -> i64 {
54        self.score.hard_score
55    }
56
57    pub fn soft_score(&self) -> i64 {
58        self.score.soft_score
59    }
60
61    pub fn medium_score(&self) -> Option<i64> {
62        self.score.medium_score
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct ConstraintMatch {
69    pub constraint_name: String,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub constraint_package: Option<String>,
72    pub score: ScoreDto,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub justification: Option<Value>,
75    pub indicted_objects: Vec<ObjectHandle>,
76}
77
78impl ConstraintMatch {
79    pub fn new(constraint_name: impl Into<String>, score: ScoreDto) -> Self {
80        Self {
81            constraint_name: constraint_name.into(),
82            constraint_package: None,
83            score,
84            justification: None,
85            indicted_objects: Vec::new(),
86        }
87    }
88
89    pub fn with_package(mut self, package: impl Into<String>) -> Self {
90        self.constraint_package = Some(package.into());
91        self
92    }
93
94    pub fn with_justification(mut self, justification: Value) -> Self {
95        self.justification = Some(justification);
96        self
97    }
98
99    pub fn with_indicted_object(mut self, object: ObjectHandle) -> Self {
100        self.indicted_objects.push(object);
101        self
102    }
103
104    pub fn with_indicted_objects(mut self, objects: Vec<ObjectHandle>) -> Self {
105        self.indicted_objects = objects;
106        self
107    }
108
109    pub fn full_constraint_name(&self) -> String {
110        match &self.constraint_package {
111            Some(pkg) => format!("{}.{}", pkg, self.constraint_name),
112            None => self.constraint_name.clone(),
113        }
114    }
115
116    pub fn is_feasible(&self) -> bool {
117        self.score.is_feasible
118    }
119
120    pub fn hard_score(&self) -> i64 {
121        self.score.hard_score
122    }
123
124    pub fn soft_score(&self) -> i64 {
125        self.score.soft_score
126    }
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct Indictment {
132    pub indicted_object: ObjectHandle,
133    pub constraint_matches: Vec<ConstraintMatch>,
134    pub score: ScoreDto,
135}
136
137impl Indictment {
138    pub fn new(object: ObjectHandle, score: ScoreDto) -> Self {
139        Self {
140            indicted_object: object,
141            constraint_matches: Vec::new(),
142            score,
143        }
144    }
145
146    pub fn with_constraint_match(mut self, constraint_match: ConstraintMatch) -> Self {
147        self.constraint_matches.push(constraint_match);
148        self
149    }
150
151    pub fn constraint_count(&self) -> usize {
152        self.constraint_matches.len()
153    }
154
155    pub fn is_feasible(&self) -> bool {
156        self.score.is_feasible
157    }
158
159    pub fn hard_score(&self) -> i64 {
160        self.score.hard_score
161    }
162
163    pub fn soft_score(&self) -> i64 {
164        self.score.soft_score
165    }
166
167    pub fn get_constraint_matches_by_name(&self, name: &str) -> Vec<&ConstraintMatch> {
168        self.constraint_matches
169            .iter()
170            .filter(|m| m.constraint_name == name)
171            .collect()
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn create_test_score() -> ScoreDto {
180        ScoreDto::hard_soft(-1, -10)
181    }
182
183    fn create_feasible_score() -> ScoreDto {
184        ScoreDto::hard_soft(0, -5)
185    }
186
187    #[test]
188    fn test_score_explanation_new() {
189        let explanation = ScoreExplanation::new(create_test_score());
190
191        assert_eq!(explanation.hard_score(), -1);
192        assert_eq!(explanation.soft_score(), -10);
193        assert!(!explanation.is_feasible());
194        assert!(explanation.constraint_matches.is_empty());
195        assert!(explanation.indictments.is_empty());
196    }
197
198    #[test]
199    fn test_score_explanation_builder() {
200        let obj = ObjectHandle::new(1);
201        let constraint_match = ConstraintMatch::new("roomConflict", ScoreDto::hard_soft(-1, 0))
202            .with_indicted_object(obj);
203
204        let indictment = Indictment::new(obj, ScoreDto::hard_soft(-1, 0))
205            .with_constraint_match(constraint_match.clone());
206
207        let explanation = ScoreExplanation::new(create_test_score())
208            .with_constraint_match(constraint_match)
209            .with_indictment(indictment);
210
211        assert_eq!(explanation.constraint_count(), 1);
212        assert_eq!(explanation.indictments.len(), 1);
213    }
214
215    #[test]
216    fn test_score_explanation_get_by_name() {
217        let m1 = ConstraintMatch::new("roomConflict", ScoreDto::hard_soft(-1, 0));
218        let m2 = ConstraintMatch::new("teacherConflict", ScoreDto::hard_soft(-1, 0));
219        let m3 = ConstraintMatch::new("roomConflict", ScoreDto::hard_soft(-1, 0));
220
221        let explanation = ScoreExplanation::new(create_test_score())
222            .with_constraint_match(m1)
223            .with_constraint_match(m2)
224            .with_constraint_match(m3);
225
226        let room_matches = explanation.get_constraint_matches_by_name("roomConflict");
227        assert_eq!(room_matches.len(), 2);
228
229        let teacher_matches = explanation.get_constraint_matches_by_name("teacherConflict");
230        assert_eq!(teacher_matches.len(), 1);
231    }
232
233    #[test]
234    fn test_score_explanation_get_indictment() {
235        let obj1 = ObjectHandle::new(1);
236        let obj2 = ObjectHandle::new(2);
237
238        let indictment1 = Indictment::new(obj1, ScoreDto::hard_soft(-1, 0));
239        let indictment2 = Indictment::new(obj2, ScoreDto::hard_soft(0, -5));
240
241        let explanation = ScoreExplanation::new(create_test_score())
242            .with_indictment(indictment1)
243            .with_indictment(indictment2);
244
245        let found = explanation.get_indictment_for_object(obj1);
246        assert!(found.is_some());
247        assert!(!found.unwrap().is_feasible());
248
249        let found2 = explanation.get_indictment_for_object(obj2);
250        assert!(found2.is_some());
251        assert!(found2.unwrap().is_feasible());
252
253        let not_found = explanation.get_indictment_for_object(ObjectHandle::new(99));
254        assert!(not_found.is_none());
255    }
256
257    #[test]
258    fn test_score_explanation_medium_score() {
259        let score = ScoreDto::hard_medium_soft(0, -3, -10);
260        let explanation = ScoreExplanation::new(score);
261
262        assert_eq!(explanation.medium_score(), Some(-3));
263    }
264
265    #[test]
266    fn test_constraint_match_new() {
267        let cm = ConstraintMatch::new("testConstraint", create_test_score());
268
269        assert_eq!(cm.constraint_name, "testConstraint");
270        assert!(cm.constraint_package.is_none());
271        assert_eq!(cm.hard_score(), -1);
272        assert_eq!(cm.soft_score(), -10);
273        assert!(!cm.is_feasible());
274    }
275
276    #[test]
277    fn test_constraint_match_builder() {
278        let obj1 = ObjectHandle::new(1);
279        let obj2 = ObjectHandle::new(2);
280
281        let cm = ConstraintMatch::new("roomConflict", create_feasible_score())
282            .with_package("com.example.constraints")
283            .with_justification(Value::String("Room A is overbooked".into()))
284            .with_indicted_objects(vec![obj1, obj2]);
285
286        assert_eq!(
287            cm.constraint_package,
288            Some("com.example.constraints".into())
289        );
290        assert!(cm.justification.is_some());
291        assert_eq!(cm.indicted_objects.len(), 2);
292        assert!(cm.is_feasible());
293    }
294
295    #[test]
296    fn test_constraint_match_full_name() {
297        let cm_no_pkg = ConstraintMatch::new("testConstraint", create_test_score());
298        assert_eq!(cm_no_pkg.full_constraint_name(), "testConstraint");
299
300        let cm_with_pkg =
301            ConstraintMatch::new("roomConflict", create_test_score()).with_package("com.example");
302        assert_eq!(
303            cm_with_pkg.full_constraint_name(),
304            "com.example.roomConflict"
305        );
306    }
307
308    #[test]
309    fn test_constraint_match_add_single_indicted() {
310        let obj = ObjectHandle::new(1);
311        let cm = ConstraintMatch::new("test", create_test_score()).with_indicted_object(obj);
312
313        assert_eq!(cm.indicted_objects.len(), 1);
314        assert_eq!(cm.indicted_objects[0], obj);
315    }
316
317    #[test]
318    fn test_indictment_new() {
319        let obj = ObjectHandle::new(42);
320        let indictment = Indictment::new(obj, create_test_score());
321
322        assert_eq!(indictment.indicted_object, obj);
323        assert_eq!(indictment.hard_score(), -1);
324        assert_eq!(indictment.soft_score(), -10);
325        assert!(!indictment.is_feasible());
326        assert_eq!(indictment.constraint_count(), 0);
327    }
328
329    #[test]
330    fn test_indictment_with_matches() {
331        let obj = ObjectHandle::new(1);
332        let cm1 = ConstraintMatch::new("roomConflict", ScoreDto::hard_soft(-1, 0));
333        let cm2 = ConstraintMatch::new("teacherConflict", ScoreDto::hard_soft(-1, 0));
334
335        let indictment = Indictment::new(obj, ScoreDto::hard_soft(-2, 0))
336            .with_constraint_match(cm1)
337            .with_constraint_match(cm2);
338
339        assert_eq!(indictment.constraint_count(), 2);
340    }
341
342    #[test]
343    fn test_indictment_get_by_name() {
344        let obj = ObjectHandle::new(1);
345        let cm1 = ConstraintMatch::new("roomConflict", ScoreDto::hard_soft(-1, 0));
346        let cm2 = ConstraintMatch::new("teacherConflict", ScoreDto::hard_soft(-1, 0));
347        let cm3 = ConstraintMatch::new("roomConflict", ScoreDto::hard_soft(-1, 0));
348
349        let indictment = Indictment::new(obj, ScoreDto::hard_soft(-3, 0))
350            .with_constraint_match(cm1)
351            .with_constraint_match(cm2)
352            .with_constraint_match(cm3);
353
354        let room_matches = indictment.get_constraint_matches_by_name("roomConflict");
355        assert_eq!(room_matches.len(), 2);
356    }
357
358    #[test]
359    fn test_score_explanation_json_serialization() {
360        let explanation = ScoreExplanation::new(create_feasible_score())
361            .with_constraint_match(ConstraintMatch::new("test", ScoreDto::hard_soft(0, -5)));
362
363        let json = serde_json::to_string(&explanation).unwrap();
364        assert!(json.contains("\"score\""));
365        assert!(json.contains("\"constraintMatches\""));
366        assert!(json.contains("\"indictments\""));
367
368        let parsed: ScoreExplanation = serde_json::from_str(&json).unwrap();
369        assert_eq!(parsed.constraint_count(), 1);
370    }
371
372    #[test]
373    fn test_constraint_match_json_omits_optional() {
374        let cm = ConstraintMatch::new("test", create_test_score());
375        let json = serde_json::to_string(&cm).unwrap();
376
377        assert!(!json.contains("constraintPackage"));
378        assert!(!json.contains("justification"));
379    }
380
381    #[test]
382    fn test_indictment_json_serialization() {
383        let obj = ObjectHandle::new(1);
384        let indictment = Indictment::new(obj, create_test_score());
385
386        let json = serde_json::to_string(&indictment).unwrap();
387        assert!(json.contains("\"indictedObject\""));
388        assert!(json.contains("\"constraintMatches\""));
389        assert!(json.contains("\"score\""));
390
391        let parsed: Indictment = serde_json::from_str(&json).unwrap();
392        assert_eq!(parsed.indicted_object, obj);
393    }
394
395    #[test]
396    fn test_feasible_explanation() {
397        let explanation = ScoreExplanation::new(create_feasible_score());
398        assert!(explanation.is_feasible());
399    }
400
401    #[test]
402    fn test_infeasible_explanation() {
403        let explanation = ScoreExplanation::new(create_test_score());
404        assert!(!explanation.is_feasible());
405    }
406}