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        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            "newList", "getItem", "setItem", "size", "append", "insert", "remove", "dealloc",
81        );
82
83        let mut request = SolveRequest::new(
84            domain_dto,
85            constraints_dto,
86            self.wasm_module.clone(),
87            "alloc".to_string(),
88            "dealloc".to_string(),
89            list_accessor,
90            solution_json,
91        );
92
93        // Set step limit to 0 for explain (just calculate score)
94        if let Some(termination) = self.config.termination.as_ref() {
95            request = request.with_termination(termination.clone());
96        }
97
98        if let Some(mode) = &self.config.environment_mode {
99            request = request.with_environment_mode(format!("{:?}", mode).to_uppercase());
100        }
101
102        Ok(request)
103    }
104
105    fn build_update_request(
106        &self,
107        bridge: &B,
108        solution: ObjectHandle,
109    ) -> SolverForgeResult<SolveRequest> {
110        self.build_explain_request(bridge, solution)
111    }
112
113    fn parse_explanation(&self, response: SolveResponse) -> SolverForgeResult<ScoreExplanation> {
114        // Parse the score string into a ScoreDto
115        // Score format: "0" for simple, "0hard/-5soft" for hard/soft
116        let score = parse_score_string(&response.score)?;
117        Ok(ScoreExplanation::new(score))
118    }
119
120    pub fn config(&self) -> &SolverConfig {
121        &self.config
122    }
123
124    pub fn domain_model(&self) -> &DomainModel {
125        &self.domain_model
126    }
127
128    pub fn constraints(&self) -> &ConstraintSet {
129        &self.constraints
130    }
131}
132
133/// Parse a score string from Java into a ScoreDto.
134fn parse_score_string(s: &str) -> SolverForgeResult<ScoreDto> {
135    // Try to parse as hard/soft first: "0hard/-5soft"
136    if let Some((hard_str, rest)) = s.split_once("hard/") {
137        if let Some((soft_str, _)) = rest.split_once("soft") {
138            if let (Ok(hard), Ok(soft)) = (hard_str.parse(), soft_str.parse()) {
139                return Ok(ScoreDto::hard_soft(hard, soft));
140            }
141        }
142        // Try hard/medium/soft: "0hard/-10medium/-5soft"
143        if let Some((medium_str, rest2)) = rest.split_once("medium/") {
144            if let Some((soft_str, _)) = rest2.split_once("soft") {
145                if let (Ok(hard), Ok(medium), Ok(soft)) =
146                    (hard_str.parse(), medium_str.parse(), soft_str.parse())
147                {
148                    return Ok(ScoreDto::hard_medium_soft(hard, medium, soft));
149                }
150            }
151        }
152    }
153    // Try to parse as simple score
154    if let Ok(score) = s.parse() {
155        return Ok(ScoreDto::simple(score));
156    }
157    Err(SolverForgeError::Solver(format!(
158        "Invalid score format: '{}'. Expected formats: '<n>' (simple), '<n>hard/<n>soft', or '<n>hard/<n>medium/<n>soft'",
159        s
160    )))
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::analysis::{ConstraintMatch, Indictment};
167    use crate::bridge::tests::MockBridge;
168    use crate::constraints::Constraint;
169    use crate::domain::{DomainModelBuilder, FieldDescriptor, FieldType, PrimitiveType, ScoreType};
170    use crate::solver::HttpSolverService;
171
172    fn create_test_config() -> SolverConfig {
173        SolverConfig::new().with_solution_class("Timetable")
174    }
175
176    fn create_test_domain() -> DomainModel {
177        use crate::domain::{DomainClass, PlanningAnnotation};
178
179        DomainModelBuilder::new()
180            .add_class(
181                DomainClass::new("Timetable")
182                    .with_annotation(PlanningAnnotation::PlanningSolution)
183                    .with_field(
184                        FieldDescriptor::new("score", FieldType::Score(ScoreType::HardSoft))
185                            .with_annotation(PlanningAnnotation::planning_score()),
186                    ),
187            )
188            .add_class(
189                DomainClass::new("Lesson")
190                    .with_annotation(PlanningAnnotation::PlanningEntity)
191                    .with_field(
192                        FieldDescriptor::new("id", FieldType::Primitive(PrimitiveType::String))
193                            .with_annotation(PlanningAnnotation::PlanningId),
194                    )
195                    .with_field(
196                        FieldDescriptor::new("room", FieldType::object("Room")).with_annotation(
197                            PlanningAnnotation::planning_variable(vec!["rooms".to_string()]),
198                        ),
199                    ),
200            )
201            .build()
202    }
203
204    fn create_test_constraints() -> ConstraintSet {
205        ConstraintSet::new().with_constraint(Constraint::new("testConstraint"))
206    }
207
208    #[test]
209    fn test_solution_manager_new() {
210        let service = Arc::new(HttpSolverService::new("http://localhost:8080"));
211
212        let manager = SolutionManager::<MockBridge>::new(
213            create_test_config(),
214            service,
215            create_test_domain(),
216            create_test_constraints(),
217            "AGFzbQ==".to_string(),
218        );
219
220        assert_eq!(
221            manager.config().solution_class,
222            Some("Timetable".to_string())
223        );
224        assert_eq!(manager.constraints().len(), 1);
225    }
226
227    #[test]
228    fn test_solution_manager_accessors() {
229        let service = Arc::new(HttpSolverService::new("http://localhost:8080"));
230
231        let manager = SolutionManager::<MockBridge>::new(
232            create_test_config(),
233            service,
234            create_test_domain(),
235            create_test_constraints(),
236            "AGFzbQ==".to_string(),
237        );
238
239        assert!(manager.domain_model().classes.contains_key("Timetable"));
240        assert!(manager.domain_model().classes.contains_key("Lesson"));
241    }
242
243    #[test]
244    fn test_parse_explanation() {
245        let service = Arc::new(HttpSolverService::new("http://localhost:8080"));
246
247        let manager = SolutionManager::<MockBridge>::new(
248            create_test_config(),
249            service,
250            create_test_domain(),
251            create_test_constraints(),
252            "AGFzbQ==".to_string(),
253        );
254
255        let response = SolveResponse::new("{}".to_string(), "-2hard/-15soft");
256
257        let explanation = manager.parse_explanation(response).unwrap();
258
259        assert_eq!(explanation.hard_score(), -2);
260        assert_eq!(explanation.soft_score(), -15);
261        assert!(!explanation.is_feasible());
262    }
263
264    #[test]
265    fn test_parse_feasible_explanation() {
266        let service = Arc::new(HttpSolverService::new("http://localhost:8080"));
267
268        let manager = SolutionManager::<MockBridge>::new(
269            create_test_config(),
270            service,
271            create_test_domain(),
272            create_test_constraints(),
273            "AGFzbQ==".to_string(),
274        );
275
276        let response = SolveResponse::new("{}".to_string(), "0hard/-5soft");
277
278        let explanation = manager.parse_explanation(response).unwrap();
279
280        assert!(explanation.is_feasible());
281    }
282
283    #[test]
284    fn test_constraint_match_creation() {
285        let cm = ConstraintMatch::new("roomConflict", ScoreDto::hard_soft(-1, 0))
286            .with_package("com.example")
287            .with_indicted_object(ObjectHandle::new(1));
288
289        assert_eq!(cm.full_constraint_name(), "com.example.roomConflict");
290        assert_eq!(cm.indicted_objects.len(), 1);
291    }
292
293    #[test]
294    fn test_indictment_creation() {
295        let obj = ObjectHandle::new(42);
296        let cm = ConstraintMatch::new("roomConflict", ScoreDto::hard_soft(-1, 0));
297
298        let indictment = Indictment::new(obj, ScoreDto::hard_soft(-1, 0)).with_constraint_match(cm);
299
300        assert_eq!(indictment.indicted_object, obj);
301        assert_eq!(indictment.constraint_count(), 1);
302    }
303
304    #[test]
305    fn test_score_explanation_builder() {
306        let explanation = ScoreExplanation::new(ScoreDto::hard_soft(-3, -20))
307            .with_constraint_match(ConstraintMatch::new(
308                "conflict1",
309                ScoreDto::hard_soft(-2, 0),
310            ))
311            .with_constraint_match(ConstraintMatch::new(
312                "conflict2",
313                ScoreDto::hard_soft(-1, -20),
314            ))
315            .with_indictment(Indictment::new(
316                ObjectHandle::new(1),
317                ScoreDto::hard_soft(-2, 0),
318            ));
319
320        assert_eq!(explanation.constraint_count(), 2);
321        assert_eq!(explanation.indictments.len(), 1);
322    }
323}