1use crate::bridge::LanguageBridge;
47use crate::constraints::ConstraintSet;
48use crate::domain::DomainModel;
49use crate::error::{SolverForgeError, SolverForgeResult};
50use crate::solver::{
51 EnvironmentMode, HttpSolverService, MoveThreadCount, SolverConfig, SolverService,
52 TerminationConfig,
53};
54use crate::traits::PlanningSolution;
55use crate::wasm::{HostFunctionRegistry, WasmModuleBuilder};
56use std::marker::PhantomData;
57use std::sync::Arc;
58
59pub const DEFAULT_SERVICE_URL: &str = "http://localhost:8080";
61
62pub struct SolverBuilder<S: PlanningSolution> {
71 service_url: String,
72 termination: Option<TerminationConfig>,
73 environment_mode: Option<EnvironmentMode>,
74 random_seed: Option<u64>,
75 move_thread_count: Option<MoveThreadCount>,
76 custom_service: Option<Arc<dyn SolverService>>,
77 _phantom: PhantomData<S>,
78}
79
80impl<S: PlanningSolution> Default for SolverBuilder<S> {
81 fn default() -> Self {
82 Self::new()
83 }
84}
85
86impl<S: PlanningSolution> SolverBuilder<S> {
87 pub fn new() -> Self {
91 Self {
92 service_url: DEFAULT_SERVICE_URL.to_string(),
93 termination: None,
94 environment_mode: None,
95 random_seed: None,
96 move_thread_count: None,
97 custom_service: None,
98 _phantom: PhantomData,
99 }
100 }
101
102 pub fn with_service_url(mut self, url: impl Into<String>) -> Self {
104 self.service_url = url.into();
105 self
106 }
107
108 pub fn with_termination(mut self, termination: TerminationConfig) -> Self {
110 self.termination = Some(termination);
111 self
112 }
113
114 pub fn with_environment_mode(mut self, mode: EnvironmentMode) -> Self {
116 self.environment_mode = Some(mode);
117 self
118 }
119
120 pub fn with_random_seed(mut self, seed: u64) -> Self {
122 self.random_seed = Some(seed);
123 self
124 }
125
126 pub fn with_move_thread_count(mut self, count: MoveThreadCount) -> Self {
128 self.move_thread_count = Some(count);
129 self
130 }
131
132 pub fn with_service(mut self, service: Arc<dyn SolverService>) -> Self {
136 self.custom_service = Some(service);
137 self
138 }
139
140 pub fn domain_model() -> DomainModel {
144 S::domain_model()
145 }
146
147 pub fn constraints() -> ConstraintSet {
151 S::constraints()
152 }
153
154 fn build_config(&self) -> SolverConfig {
156 let domain_model = S::domain_model();
157 let solution_class = domain_model.solution_class().map(|s| s.to_string());
158
159 let entity_classes: Vec<String> = domain_model
160 .classes
161 .values()
162 .filter(|c| c.is_planning_entity())
163 .map(|c| c.name.clone())
164 .collect();
165
166 let mut config = SolverConfig::new().with_entity_classes(entity_classes);
167
168 if let Some(solution_class) = solution_class {
169 config = config.with_solution_class(solution_class);
170 }
171
172 if let Some(termination) = &self.termination {
173 config = config.with_termination(termination.clone());
174 }
175
176 if let Some(mode) = &self.environment_mode {
177 config = config.with_environment_mode(*mode);
178 }
179
180 if let Some(seed) = self.random_seed {
181 config = config.with_random_seed(seed);
182 }
183
184 if let Some(count) = &self.move_thread_count {
185 config = config.with_move_thread_count(count.clone());
186 }
187
188 config
189 }
190
191 fn generate_wasm_module(&self) -> SolverForgeResult<String> {
198 let domain_model = S::domain_model();
199 let constraints = S::constraints();
200
201 let predicates = constraints.extract_predicates();
203
204 let mut builder = WasmModuleBuilder::new()
205 .with_host_functions(HostFunctionRegistry::with_standard_functions())
206 .with_domain_model(domain_model);
207
208 for predicate in predicates {
210 builder = builder.add_predicate(predicate);
211 }
212
213 builder.build_base64()
214 }
215
216 pub fn build<B: LanguageBridge>(self) -> SolverForgeResult<TypedSolver<S, B>> {
222 let config = self.build_config();
223 let domain_model = S::domain_model();
224 let constraints = S::constraints();
225 let wasm_module = self.generate_wasm_module()?;
226
227 let service: Arc<dyn SolverService> = match self.custom_service {
228 Some(service) => service,
229 None => Arc::new(HttpSolverService::new(&self.service_url)),
230 };
231
232 Ok(TypedSolver {
233 config,
234 domain_model,
235 constraints,
236 wasm_module,
237 service,
238 service_url: self.service_url,
239 _phantom: PhantomData,
240 })
241 }
242
243 pub fn build_with_bridge<B: LanguageBridge>(
248 self,
249 _bridge: Arc<B>,
250 ) -> SolverForgeResult<TypedSolver<S, B>> {
251 self.build()
252 }
253}
254
255pub struct TypedSolver<S: PlanningSolution, B: LanguageBridge> {
260 config: SolverConfig,
261 domain_model: DomainModel,
262 constraints: ConstraintSet,
263 wasm_module: String,
264 service: Arc<dyn SolverService>,
265 service_url: String,
266 _phantom: PhantomData<(S, B)>,
267}
268
269impl<S: PlanningSolution, B: LanguageBridge> TypedSolver<S, B> {
270 pub fn config(&self) -> &SolverConfig {
272 &self.config
273 }
274
275 pub fn domain_model(&self) -> &DomainModel {
277 &self.domain_model
278 }
279
280 pub fn constraints(&self) -> &ConstraintSet {
282 &self.constraints
283 }
284
285 pub fn wasm_module(&self) -> &str {
287 &self.wasm_module
288 }
289
290 pub fn service_url(&self) -> &str {
292 &self.service_url
293 }
294
295 pub fn is_service_available(&self) -> bool {
297 self.service.is_available()
298 }
299
300 pub fn solve(&self, problem: S) -> SolverForgeResult<S> {
311 let problem_json = problem.to_json()?;
313
314 let domain_dto = self.domain_model.to_dto();
316 let constraints_dto = self.constraints.to_dto();
317
318 let list_accessor = crate::solver::ListAccessorDto::new(
319 "newList", "getItem", "setItem", "size", "append", "insert", "remove", "dealloc",
320 );
321
322 let mut request = crate::solver::SolveRequest::new(
323 domain_dto,
324 constraints_dto,
325 self.wasm_module.clone(),
326 "alloc".to_string(),
327 "dealloc".to_string(),
328 list_accessor,
329 problem_json,
330 );
331
332 if let Some(mode) = &self.config.environment_mode {
333 request = request.with_environment_mode(format!("{:?}", mode).to_uppercase());
334 }
335
336 if let Some(termination) = &self.config.termination {
337 request = request.with_termination(termination.clone());
338 }
339
340 let response = self.service.solve(&request)?;
342
343 S::from_json(&response.solution)
345 }
346
347 pub fn solve_async(&self, problem: S) -> SolverForgeResult<crate::solver::SolveHandle> {
352 let problem_json = problem.to_json()?;
353
354 let domain_dto = self.domain_model.to_dto();
355 let constraints_dto = self.constraints.to_dto();
356
357 let list_accessor = crate::solver::ListAccessorDto::new(
358 "newList", "getItem", "setItem", "size", "append", "insert", "remove", "dealloc",
359 );
360
361 let mut request = crate::solver::SolveRequest::new(
362 domain_dto,
363 constraints_dto,
364 self.wasm_module.clone(),
365 "alloc".to_string(),
366 "dealloc".to_string(),
367 list_accessor,
368 problem_json,
369 );
370
371 if let Some(mode) = &self.config.environment_mode {
372 request = request.with_environment_mode(format!("{:?}", mode).to_uppercase());
373 }
374
375 if let Some(termination) = &self.config.termination {
376 request = request.with_termination(termination.clone());
377 }
378
379 self.service.solve_async(&request)
380 }
381
382 pub fn get_status(
384 &self,
385 handle: &crate::solver::SolveHandle,
386 ) -> SolverForgeResult<crate::solver::SolveStatus> {
387 self.service.get_status(handle)
388 }
389
390 pub fn get_best_solution(
392 &self,
393 handle: &crate::solver::SolveHandle,
394 ) -> SolverForgeResult<Option<S>> {
395 let response = self.service.get_best_solution(handle)?;
396 match response {
397 Some(r) => Ok(Some(S::from_json(&r.solution)?)),
398 None => Ok(None),
399 }
400 }
401
402 pub fn stop(&self, handle: &crate::solver::SolveHandle) -> SolverForgeResult<()> {
404 self.service.stop(handle)
405 }
406}
407
408#[derive(Debug, Clone)]
410pub enum SolverBuilderError {
411 WasmGeneration(String),
413 InvalidConfiguration(String),
415}
416
417impl std::fmt::Display for SolverBuilderError {
418 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
419 match self {
420 SolverBuilderError::WasmGeneration(msg) => {
421 write!(f, "WASM module generation failed: {}", msg)
422 }
423 SolverBuilderError::InvalidConfiguration(msg) => {
424 write!(f, "Invalid solver configuration: {}", msg)
425 }
426 }
427 }
428}
429
430impl std::error::Error for SolverBuilderError {}
431
432impl From<SolverBuilderError> for SolverForgeError {
433 fn from(err: SolverBuilderError) -> Self {
434 match err {
435 SolverBuilderError::WasmGeneration(msg) => SolverForgeError::WasmGeneration(msg),
436 SolverBuilderError::InvalidConfiguration(msg) => SolverForgeError::Configuration(msg),
437 }
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use crate::bridge::tests::MockBridge;
445 use crate::constraints::Constraint;
446 use crate::domain::{
447 DomainClass, FieldDescriptor, FieldType, PlanningAnnotation, PrimitiveType, ScoreType,
448 };
449 use crate::traits::PlanningEntity;
450 use crate::HardSoftScore;
451 use std::collections::HashMap;
452
453 #[derive(Clone, Debug)]
455 struct TestLesson {
456 id: String,
457 subject: String,
458 room: Option<String>,
459 }
460
461 impl crate::traits::PlanningEntity for TestLesson {
462 fn domain_class() -> crate::domain::DomainClass {
463 DomainClass::new("TestLesson")
464 .with_annotation(PlanningAnnotation::PlanningEntity)
465 .with_field(
466 FieldDescriptor::new("id", FieldType::Primitive(PrimitiveType::String))
467 .with_annotation(PlanningAnnotation::PlanningId),
468 )
469 .with_field(FieldDescriptor::new(
470 "subject",
471 FieldType::Primitive(PrimitiveType::String),
472 ))
473 .with_field(
474 FieldDescriptor::new("room", FieldType::Primitive(PrimitiveType::String))
475 .with_annotation(PlanningAnnotation::planning_variable(vec![
476 "rooms".to_string()
477 ])),
478 )
479 }
480
481 fn planning_id(&self) -> crate::Value {
482 crate::Value::String(self.id.clone())
483 }
484
485 fn to_value(&self) -> crate::Value {
486 let mut map = HashMap::new();
487 map.insert("id".to_string(), crate::Value::String(self.id.clone()));
488 map.insert(
489 "subject".to_string(),
490 crate::Value::String(self.subject.clone()),
491 );
492 map.insert(
493 "room".to_string(),
494 self.room
495 .clone()
496 .map(crate::Value::String)
497 .unwrap_or(crate::Value::Null),
498 );
499 crate::Value::Object(map)
500 }
501
502 fn from_value(value: &crate::Value) -> SolverForgeResult<Self> {
503 match value {
504 crate::Value::Object(map) => {
505 let id = map
506 .get("id")
507 .and_then(|v| v.as_str())
508 .ok_or_else(|| SolverForgeError::Serialization("Missing id".to_string()))?
509 .to_string();
510 let subject = map
511 .get("subject")
512 .and_then(|v| v.as_str())
513 .ok_or_else(|| {
514 SolverForgeError::Serialization("Missing subject".to_string())
515 })?
516 .to_string();
517 let room = map.get("room").and_then(|v| v.as_str()).map(String::from);
518 Ok(TestLesson { id, subject, room })
519 }
520 _ => Err(SolverForgeError::Serialization(
521 "Expected object".to_string(),
522 )),
523 }
524 }
525 }
526
527 #[derive(Clone, Debug)]
529 struct TestTimetable {
530 rooms: Vec<String>,
531 lessons: Vec<TestLesson>,
532 score: Option<HardSoftScore>,
533 }
534
535 impl PlanningSolution for TestTimetable {
536 type Score = HardSoftScore;
537
538 fn domain_model() -> DomainModel {
539 DomainModel::builder()
540 .add_class(TestLesson::domain_class())
541 .add_class(
542 DomainClass::new("TestTimetable")
543 .with_annotation(PlanningAnnotation::PlanningSolution)
544 .with_field(
545 FieldDescriptor::new(
546 "rooms",
547 FieldType::list(FieldType::Primitive(PrimitiveType::String)),
548 )
549 .with_annotation(PlanningAnnotation::ProblemFactCollectionProperty)
550 .with_annotation(
551 PlanningAnnotation::value_range_provider_with_id("rooms"),
552 ),
553 )
554 .with_field(
555 FieldDescriptor::new(
556 "lessons",
557 FieldType::list(FieldType::object("TestLesson")),
558 )
559 .with_annotation(PlanningAnnotation::PlanningEntityCollectionProperty),
560 )
561 .with_field(
562 FieldDescriptor::new("score", FieldType::Score(ScoreType::HardSoft))
563 .with_annotation(PlanningAnnotation::planning_score()),
564 ),
565 )
566 .build()
567 }
568
569 fn constraints() -> ConstraintSet {
570 ConstraintSet::new().with_constraint(Constraint::new("Test constraint"))
571 }
572
573 fn score(&self) -> Option<Self::Score> {
574 self.score
575 }
576
577 fn set_score(&mut self, score: Self::Score) {
578 self.score = Some(score);
579 }
580
581 fn to_json(&self) -> SolverForgeResult<String> {
582 let mut map = HashMap::new();
583
584 let rooms: Vec<crate::Value> = self
585 .rooms
586 .iter()
587 .map(|r| crate::Value::String(r.clone()))
588 .collect();
589 map.insert("rooms".to_string(), crate::Value::Array(rooms));
590
591 let lessons: Vec<crate::Value> = self.lessons.iter().map(|l| l.to_value()).collect();
592 map.insert("lessons".to_string(), crate::Value::Array(lessons));
593
594 if let Some(score) = &self.score {
595 map.insert(
596 "score".to_string(),
597 crate::Value::String(format!("{}", score)),
598 );
599 }
600
601 serde_json::to_string(&crate::Value::Object(map))
602 .map_err(|e| SolverForgeError::Serialization(e.to_string()))
603 }
604
605 fn from_json(json: &str) -> SolverForgeResult<Self> {
606 let value: crate::Value = serde_json::from_str(json)
607 .map_err(|e| SolverForgeError::Serialization(e.to_string()))?;
608
609 match value {
610 crate::Value::Object(map) => {
611 let rooms = match map.get("rooms") {
612 Some(crate::Value::Array(arr)) => arr
613 .iter()
614 .filter_map(|v| v.as_str().map(String::from))
615 .collect(),
616 _ => Vec::new(),
617 };
618
619 let lessons = match map.get("lessons") {
620 Some(crate::Value::Array(arr)) => arr
621 .iter()
622 .filter_map(|v| TestLesson::from_value(v).ok())
623 .collect(),
624 _ => Vec::new(),
625 };
626
627 Ok(TestTimetable {
628 rooms,
629 lessons,
630 score: None,
631 })
632 }
633 _ => Err(SolverForgeError::Serialization(
634 "Expected object".to_string(),
635 )),
636 }
637 }
638 }
639
640 #[test]
641 fn test_solver_builder_new() {
642 let builder = SolverBuilder::<TestTimetable>::new();
643 assert_eq!(builder.service_url, DEFAULT_SERVICE_URL);
644 assert!(builder.termination.is_none());
645 assert!(builder.environment_mode.is_none());
646 }
647
648 #[test]
649 fn test_solver_builder_default() {
650 let builder = SolverBuilder::<TestTimetable>::default();
651 assert_eq!(builder.service_url, DEFAULT_SERVICE_URL);
652 }
653
654 #[test]
655 fn test_solver_builder_with_service_url() {
656 let builder = SolverBuilder::<TestTimetable>::new().with_service_url("http://custom:9000");
657 assert_eq!(builder.service_url, "http://custom:9000");
658 }
659
660 #[test]
661 fn test_solver_builder_with_termination() {
662 let termination = TerminationConfig::new().with_spent_limit("PT5M");
663 let builder = SolverBuilder::<TestTimetable>::new().with_termination(termination.clone());
664 assert_eq!(builder.termination, Some(termination));
665 }
666
667 #[test]
668 fn test_solver_builder_with_environment_mode() {
669 let builder = SolverBuilder::<TestTimetable>::new()
670 .with_environment_mode(EnvironmentMode::FullAssert);
671 assert_eq!(builder.environment_mode, Some(EnvironmentMode::FullAssert));
672 }
673
674 #[test]
675 fn test_solver_builder_with_random_seed() {
676 let builder = SolverBuilder::<TestTimetable>::new().with_random_seed(42);
677 assert_eq!(builder.random_seed, Some(42));
678 }
679
680 #[test]
681 fn test_solver_builder_with_move_thread_count() {
682 let builder =
683 SolverBuilder::<TestTimetable>::new().with_move_thread_count(MoveThreadCount::Auto);
684 assert_eq!(builder.move_thread_count, Some(MoveThreadCount::Auto));
685 }
686
687 #[test]
688 fn test_solver_builder_domain_model() {
689 let model = SolverBuilder::<TestTimetable>::domain_model();
690 assert!(model.get_solution_class().is_some());
691 assert_eq!(model.get_solution_class().unwrap().name, "TestTimetable");
692 assert!(model.get_class("TestLesson").is_some());
693 }
694
695 #[test]
696 fn test_solver_builder_constraints() {
697 let constraints = SolverBuilder::<TestTimetable>::constraints();
698 assert!(!constraints.is_empty());
699 }
700
701 #[test]
702 fn test_solver_builder_build_config() {
703 let builder = SolverBuilder::<TestTimetable>::new()
704 .with_termination(TerminationConfig::new().with_spent_limit("PT5M"))
705 .with_environment_mode(EnvironmentMode::Reproducible)
706 .with_random_seed(42);
707
708 let config = builder.build_config();
709 assert_eq!(config.solution_class, Some("TestTimetable".to_string()));
710 assert!(config.entity_class_list.contains(&"TestLesson".to_string()));
711 assert_eq!(config.environment_mode, Some(EnvironmentMode::Reproducible));
712 assert_eq!(config.random_seed, Some(42));
713 assert!(config.termination.is_some());
714 }
715
716 #[test]
717 fn test_solver_builder_generate_wasm() {
718 let builder = SolverBuilder::<TestTimetable>::new();
719 let wasm = builder.generate_wasm_module().unwrap();
720 assert!(wasm.starts_with("AGFzbQ")); }
722
723 #[test]
724 fn test_solver_builder_build() {
725 let solver = SolverBuilder::<TestTimetable>::new()
726 .with_service_url("http://localhost:19999")
727 .with_termination(TerminationConfig::new().with_spent_limit("PT1M"))
728 .build::<MockBridge>()
729 .unwrap();
730
731 assert_eq!(
732 solver.config().solution_class,
733 Some("TestTimetable".to_string())
734 );
735 assert!(solver.wasm_module().starts_with("AGFzbQ"));
736 assert_eq!(solver.service_url(), "http://localhost:19999");
737 }
738
739 #[test]
740 fn test_solver_builder_chained() {
741 let solver = SolverBuilder::<TestTimetable>::new()
742 .with_service_url("http://test:8080")
743 .with_termination(TerminationConfig::new().with_spent_limit("PT10M"))
744 .with_environment_mode(EnvironmentMode::NoAssert)
745 .with_random_seed(123)
746 .with_move_thread_count(MoveThreadCount::Count(4))
747 .build::<MockBridge>()
748 .unwrap();
749
750 let config = solver.config();
751 assert_eq!(config.environment_mode, Some(EnvironmentMode::NoAssert));
752 assert_eq!(config.random_seed, Some(123));
753 assert_eq!(config.move_thread_count, Some(MoveThreadCount::Count(4)));
754 }
755
756 #[test]
757 fn test_typed_solver_domain_model() {
758 let solver = SolverBuilder::<TestTimetable>::new()
759 .build::<MockBridge>()
760 .unwrap();
761
762 let model = solver.domain_model();
763 assert!(model.get_solution_class().is_some());
764 }
765
766 #[test]
767 fn test_typed_solver_constraints() {
768 let solver = SolverBuilder::<TestTimetable>::new()
769 .build::<MockBridge>()
770 .unwrap();
771
772 let constraints = solver.constraints();
773 assert!(!constraints.is_empty());
774 }
775
776 #[test]
777 fn test_typed_solver_is_service_available_offline() {
778 let solver = SolverBuilder::<TestTimetable>::new()
779 .with_service_url("http://localhost:19999")
780 .build::<MockBridge>()
781 .unwrap();
782
783 assert!(!solver.is_service_available());
785 }
786
787 #[test]
788 fn test_solver_builder_error_display() {
789 let err = SolverBuilderError::WasmGeneration("test error".to_string());
790 assert!(err.to_string().contains("WASM module generation failed"));
791
792 let err = SolverBuilderError::InvalidConfiguration("invalid".to_string());
793 assert!(err.to_string().contains("Invalid solver configuration"));
794 }
795
796 #[test]
797 fn test_solver_builder_error_conversion() {
798 let err = SolverBuilderError::WasmGeneration("test".to_string());
799 let forge_err: SolverForgeError = err.into();
800 assert!(matches!(forge_err, SolverForgeError::WasmGeneration(_)));
801
802 let err = SolverBuilderError::InvalidConfiguration("test".to_string());
803 let forge_err: SolverForgeError = err.into();
804 assert!(matches!(forge_err, SolverForgeError::Configuration(_)));
805 }
806}