solverforge_core/analysis/
manager.rs

1use crate::analysis::ScoreExplanation;
2use crate::bridge::LanguageBridge;
3use crate::constraints::ConstraintSet;
4use crate::domain::DomainModel;
5use crate::error::{SolverForgeError, SolverForgeResult};
6use crate::solver::{
7    ListAccessorDto, ScoreDto, SolveRequest, SolveResponse, Solver, SolverConfig, SolverService,
8};
9use crate::ObjectHandle;
10use std::marker::PhantomData;
11use std::sync::Arc;
12
13pub struct SolutionManager<B: LanguageBridge> {
14    config: SolverConfig,
15    service: Arc<dyn SolverService>,
16    domain_model: DomainModel,
17    constraints: ConstraintSet,
18    wasm_module: String,
19    _bridge: PhantomData<B>,
20}
21
22impl<B: LanguageBridge> SolutionManager<B> {
23    pub fn create(solver: &Solver<B>) -> Self {
24        Self {
25            config: solver.config().clone(),
26            service: solver.service().clone(),
27            domain_model: solver.domain_model().clone(),
28            constraints: solver.constraints().clone(),
29            wasm_module: solver.wasm_module().to_string(),
30            _bridge: PhantomData,
31        }
32    }
33
34    pub fn new(
35        config: SolverConfig,
36        service: Arc<dyn SolverService>,
37        domain_model: DomainModel,
38        constraints: ConstraintSet,
39        wasm_module: String,
40    ) -> Self {
41        Self {
42            config,
43            service,
44            domain_model,
45            constraints,
46            wasm_module,
47            _bridge: PhantomData,
48        }
49    }
50
51    pub fn explain(
52        &self,
53        bridge: &B,
54        solution: ObjectHandle,
55    ) -> SolverForgeResult<ScoreExplanation> {
56        let request = self.build_explain_request(bridge, solution)?;
57        let response = self.service.solve(&request)?;
58        self.parse_explanation(response)
59    }
60
61    pub fn update(&self, bridge: &B, solution: ObjectHandle) -> SolverForgeResult<ScoreDto> {
62        let request = self.build_update_request(bridge, solution)?;
63        let response = self.service.solve(&request)?;
64        Ok(parse_score_string(&response.score))
65    }
66
67    fn build_explain_request(
68        &self,
69        bridge: &B,
70        solution: ObjectHandle,
71    ) -> SolverForgeResult<SolveRequest> {
72        let solution_json = bridge.serialize_object(solution).map_err(|e| {
73            SolverForgeError::Bridge(format!("Failed to serialize solution: {}", e))
74        })?;
75
76        let domain_dto = self.domain_model.to_dto();
77        let constraints_dto = self.constraints.to_dto();
78
79        let list_accessor = ListAccessorDto::new(
80            "create_list",
81            "get_item",
82            "set_item",
83            "get_size",
84            "append",
85            "insert",
86            "remove",
87            "deallocate_list",
88        );
89
90        let mut request = SolveRequest::new(
91            domain_dto,
92            constraints_dto,
93            self.wasm_module.clone(),
94            "allocate".to_string(),
95            "deallocate".to_string(),
96            list_accessor,
97            solution_json,
98        );
99
100        // Set step limit to 0 for explain (just calculate score)
101        if let Some(termination) = self.config.termination.as_ref() {
102            request = request.with_termination(termination.clone());
103        }
104
105        if let Some(mode) = &self.config.environment_mode {
106            request = request.with_environment_mode(format!("{:?}", mode).to_uppercase());
107        }
108
109        Ok(request)
110    }
111
112    fn build_update_request(
113        &self,
114        bridge: &B,
115        solution: ObjectHandle,
116    ) -> SolverForgeResult<SolveRequest> {
117        self.build_explain_request(bridge, solution)
118    }
119
120    fn parse_explanation(&self, response: SolveResponse) -> SolverForgeResult<ScoreExplanation> {
121        // Parse the score string into a ScoreDto
122        // Score format: "0" for simple, "0hard/-5soft" for hard/soft
123        let score = parse_score_string(&response.score);
124        Ok(ScoreExplanation::new(score))
125    }
126
127    pub fn config(&self) -> &SolverConfig {
128        &self.config
129    }
130
131    pub fn domain_model(&self) -> &DomainModel {
132        &self.domain_model
133    }
134
135    pub fn constraints(&self) -> &ConstraintSet {
136        &self.constraints
137    }
138}
139
140/// Parse a score string from Java into a ScoreDto.
141fn parse_score_string(s: &str) -> ScoreDto {
142    // Try to parse as hard/soft first: "0hard/-5soft"
143    if let Some((hard_str, rest)) = s.split_once("hard/") {
144        if let Some((soft_str, _)) = rest.split_once("soft") {
145            if let (Ok(hard), Ok(soft)) = (hard_str.parse(), soft_str.parse()) {
146                return ScoreDto::hard_soft(hard, soft);
147            }
148        }
149        // Try hard/medium/soft: "0hard/-10medium/-5soft"
150        if let Some((medium_str, rest2)) = rest.split_once("medium/") {
151            if let Some((soft_str, _)) = rest2.split_once("soft") {
152                if let (Ok(hard), Ok(medium), Ok(soft)) =
153                    (hard_str.parse(), medium_str.parse(), soft_str.parse())
154                {
155                    return ScoreDto::hard_medium_soft(hard, medium, soft);
156                }
157            }
158        }
159    }
160    // Try to parse as simple score
161    if let Ok(score) = s.parse() {
162        return ScoreDto::simple(score);
163    }
164    // Fallback to 0
165    ScoreDto::simple(0)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::analysis::{ConstraintMatch, Indictment};
172    use crate::bridge::tests::MockBridge;
173    use crate::constraints::Constraint;
174    use crate::domain::{DomainModelBuilder, FieldDescriptor, FieldType, PrimitiveType, ScoreType};
175    use crate::solver::HttpSolverService;
176
177    fn create_test_config() -> SolverConfig {
178        SolverConfig::new().with_solution_class("Timetable")
179    }
180
181    fn create_test_domain() -> DomainModel {
182        use crate::domain::{DomainClass, PlanningAnnotation};
183
184        DomainModelBuilder::new()
185            .add_class(
186                DomainClass::new("Timetable")
187                    .with_annotation(PlanningAnnotation::PlanningSolution)
188                    .with_field(
189                        FieldDescriptor::new("score", FieldType::Score(ScoreType::HardSoft))
190                            .with_planning_annotation(PlanningAnnotation::planning_score()),
191                    ),
192            )
193            .add_class(
194                DomainClass::new("Lesson")
195                    .with_annotation(PlanningAnnotation::PlanningEntity)
196                    .with_field(
197                        FieldDescriptor::new("id", FieldType::Primitive(PrimitiveType::String))
198                            .with_planning_annotation(PlanningAnnotation::PlanningId),
199                    )
200                    .with_field(
201                        FieldDescriptor::new("room", FieldType::object("Room"))
202                            .with_planning_annotation(PlanningAnnotation::planning_variable(vec![
203                                "rooms".to_string(),
204                            ])),
205                    ),
206            )
207            .build()
208    }
209
210    fn create_test_constraints() -> ConstraintSet {
211        ConstraintSet::new().with_constraint(Constraint::new("testConstraint"))
212    }
213
214    #[test]
215    fn test_solution_manager_new() {
216        let service = Arc::new(HttpSolverService::new("http://localhost:8080"));
217
218        let manager = SolutionManager::<MockBridge>::new(
219            create_test_config(),
220            service,
221            create_test_domain(),
222            create_test_constraints(),
223            "AGFzbQ==".to_string(),
224        );
225
226        assert_eq!(
227            manager.config().solution_class,
228            Some("Timetable".to_string())
229        );
230        assert_eq!(manager.constraints().len(), 1);
231    }
232
233    #[test]
234    fn test_solution_manager_accessors() {
235        let service = Arc::new(HttpSolverService::new("http://localhost:8080"));
236
237        let manager = SolutionManager::<MockBridge>::new(
238            create_test_config(),
239            service,
240            create_test_domain(),
241            create_test_constraints(),
242            "AGFzbQ==".to_string(),
243        );
244
245        assert!(manager.domain_model().classes.contains_key("Timetable"));
246        assert!(manager.domain_model().classes.contains_key("Lesson"));
247    }
248
249    #[test]
250    fn test_parse_explanation() {
251        let service = Arc::new(HttpSolverService::new("http://localhost:8080"));
252
253        let manager = SolutionManager::<MockBridge>::new(
254            create_test_config(),
255            service,
256            create_test_domain(),
257            create_test_constraints(),
258            "AGFzbQ==".to_string(),
259        );
260
261        let response = SolveResponse::new("{}".to_string(), "-2hard/-15soft");
262
263        let explanation = manager.parse_explanation(response).unwrap();
264
265        assert_eq!(explanation.hard_score(), -2);
266        assert_eq!(explanation.soft_score(), -15);
267        assert!(!explanation.is_feasible());
268    }
269
270    #[test]
271    fn test_parse_feasible_explanation() {
272        let service = Arc::new(HttpSolverService::new("http://localhost:8080"));
273
274        let manager = SolutionManager::<MockBridge>::new(
275            create_test_config(),
276            service,
277            create_test_domain(),
278            create_test_constraints(),
279            "AGFzbQ==".to_string(),
280        );
281
282        let response = SolveResponse::new("{}".to_string(), "0hard/-5soft");
283
284        let explanation = manager.parse_explanation(response).unwrap();
285
286        assert!(explanation.is_feasible());
287    }
288
289    #[test]
290    fn test_constraint_match_creation() {
291        let cm = ConstraintMatch::new("roomConflict", ScoreDto::hard_soft(-1, 0))
292            .with_package("com.example")
293            .with_indicted_object(ObjectHandle::new(1));
294
295        assert_eq!(cm.full_constraint_name(), "com.example.roomConflict");
296        assert_eq!(cm.indicted_objects.len(), 1);
297    }
298
299    #[test]
300    fn test_indictment_creation() {
301        let obj = ObjectHandle::new(42);
302        let cm = ConstraintMatch::new("roomConflict", ScoreDto::hard_soft(-1, 0));
303
304        let indictment = Indictment::new(obj, ScoreDto::hard_soft(-1, 0)).with_constraint_match(cm);
305
306        assert_eq!(indictment.indicted_object, obj);
307        assert_eq!(indictment.constraint_count(), 1);
308    }
309
310    #[test]
311    fn test_score_explanation_builder() {
312        let explanation = ScoreExplanation::new(ScoreDto::hard_soft(-3, -20))
313            .with_constraint_match(ConstraintMatch::new(
314                "conflict1",
315                ScoreDto::hard_soft(-2, 0),
316            ))
317            .with_constraint_match(ConstraintMatch::new(
318                "conflict2",
319                ScoreDto::hard_soft(-1, -20),
320            ))
321            .with_indictment(Indictment::new(
322                ObjectHandle::new(1),
323                ScoreDto::hard_soft(-2, 0),
324            ));
325
326        assert_eq!(explanation.constraint_count(), 2);
327        assert_eq!(explanation.indictments.len(), 1);
328    }
329}