solverforge_core/analysis/
explanation.rs1use 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}