solverforge_core/analysis/
manager.rs1use 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 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 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
133fn parse_score_string(s: &str) -> SolverForgeResult<ScoreDto> {
135 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 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 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}