1use crate::bridge::LanguageBridge;
22use crate::constraints::ConstraintSet;
23use crate::domain::DomainModel;
24use crate::error::{SolverForgeError, SolverForgeResult};
25use crate::solver::{
26 EnvironmentMode, HttpSolverService, MoveThreadCount, SolverConfig, SolverService,
27 TerminationConfig,
28};
29use crate::traits::PlanningSolution;
30use crate::wasm::WasmModuleBuilder;
31use std::marker::PhantomData;
32use std::sync::Arc;
33
34pub const DEFAULT_SERVICE_URL: &str = "http://localhost:8080";
36
37pub struct SolverBuilder<S: PlanningSolution> {
54 service_url: String,
55 termination: Option<TerminationConfig>,
56 environment_mode: Option<EnvironmentMode>,
57 random_seed: Option<u64>,
58 move_thread_count: Option<MoveThreadCount>,
59 custom_service: Option<Arc<dyn SolverService>>,
60 _phantom: PhantomData<S>,
61}
62
63impl<S: PlanningSolution> Default for SolverBuilder<S> {
64 fn default() -> Self {
65 Self::new()
66 }
67}
68
69impl<S: PlanningSolution> SolverBuilder<S> {
70 pub fn new() -> Self {
74 Self {
75 service_url: DEFAULT_SERVICE_URL.to_string(),
76 termination: None,
77 environment_mode: None,
78 random_seed: None,
79 move_thread_count: None,
80 custom_service: None,
81 _phantom: PhantomData,
82 }
83 }
84
85 pub fn with_service_url(mut self, url: impl Into<String>) -> Self {
94 self.service_url = url.into();
95 self
96 }
97
98 pub fn with_termination(mut self, termination: TerminationConfig) -> Self {
111 self.termination = Some(termination);
112 self
113 }
114
115 pub fn with_environment_mode(mut self, mode: EnvironmentMode) -> Self {
124 self.environment_mode = Some(mode);
125 self
126 }
127
128 pub fn with_random_seed(mut self, seed: u64) -> Self {
137 self.random_seed = Some(seed);
138 self
139 }
140
141 pub fn with_move_thread_count(mut self, count: MoveThreadCount) -> Self {
150 self.move_thread_count = Some(count);
151 self
152 }
153
154 pub fn with_service(mut self, service: Arc<dyn SolverService>) -> Self {
158 self.custom_service = Some(service);
159 self
160 }
161
162 pub fn domain_model() -> DomainModel {
166 S::domain_model()
167 }
168
169 pub fn constraints() -> ConstraintSet {
173 S::constraints()
174 }
175
176 fn build_config(&self) -> SolverConfig {
178 let domain_model = S::domain_model();
179 let solution_class = domain_model.solution_class().map(|s| s.to_string());
180
181 let entity_classes: Vec<String> = domain_model
182 .classes
183 .values()
184 .filter(|c| c.is_planning_entity())
185 .map(|c| c.name.clone())
186 .collect();
187
188 let mut config = SolverConfig::new().with_entity_classes(entity_classes);
189
190 if let Some(solution_class) = solution_class {
191 config = config.with_solution_class(solution_class);
192 }
193
194 if let Some(termination) = &self.termination {
195 config = config.with_termination(termination.clone());
196 }
197
198 if let Some(mode) = &self.environment_mode {
199 config = config.with_environment_mode(*mode);
200 }
201
202 if let Some(seed) = self.random_seed {
203 config = config.with_random_seed(seed);
204 }
205
206 if let Some(count) = &self.move_thread_count {
207 config = config.with_move_thread_count(count.clone());
208 }
209
210 config
211 }
212
213 fn generate_wasm_module(&self) -> SolverForgeResult<String> {
220 let domain_model = S::domain_model();
221
222 WasmModuleBuilder::new()
223 .with_domain_model(domain_model)
224 .build_base64()
225 }
226
227 pub fn build<B: LanguageBridge>(self) -> SolverForgeResult<TypedSolver<S, B>> {
241 let config = self.build_config();
242 let domain_model = S::domain_model();
243 let constraints = S::constraints();
244 let wasm_module = self.generate_wasm_module()?;
245
246 let service: Arc<dyn SolverService> = match self.custom_service {
247 Some(service) => service,
248 None => Arc::new(HttpSolverService::new(&self.service_url)),
249 };
250
251 Ok(TypedSolver {
252 config,
253 domain_model,
254 constraints,
255 wasm_module,
256 service,
257 service_url: self.service_url,
258 _phantom: PhantomData,
259 })
260 }
261
262 pub fn build_with_bridge<B: LanguageBridge>(
267 self,
268 _bridge: Arc<B>,
269 ) -> SolverForgeResult<TypedSolver<S, B>> {
270 self.build()
271 }
272}
273
274pub struct TypedSolver<S: PlanningSolution, B: LanguageBridge> {
279 config: SolverConfig,
280 domain_model: DomainModel,
281 constraints: ConstraintSet,
282 wasm_module: String,
283 service: Arc<dyn SolverService>,
284 service_url: String,
285 _phantom: PhantomData<(S, B)>,
286}
287
288impl<S: PlanningSolution, B: LanguageBridge> TypedSolver<S, B> {
289 pub fn config(&self) -> &SolverConfig {
291 &self.config
292 }
293
294 pub fn domain_model(&self) -> &DomainModel {
296 &self.domain_model
297 }
298
299 pub fn constraints(&self) -> &ConstraintSet {
301 &self.constraints
302 }
303
304 pub fn wasm_module(&self) -> &str {
306 &self.wasm_module
307 }
308
309 pub fn service_url(&self) -> &str {
311 &self.service_url
312 }
313
314 pub fn is_service_available(&self) -> bool {
316 self.service.is_available()
317 }
318
319 pub fn solve(&self, problem: S) -> SolverForgeResult<S> {
344 let problem_json = problem.to_json()?;
346
347 let domain_dto = self.domain_model.to_dto();
349 let constraints_dto = self.constraints.to_dto();
350
351 let list_accessor = crate::solver::ListAccessorDto::new(
352 "create_list",
353 "get_item",
354 "set_item",
355 "get_size",
356 "append",
357 "insert",
358 "remove",
359 "deallocate_list",
360 );
361
362 let mut request = crate::solver::SolveRequest::new(
363 domain_dto,
364 constraints_dto,
365 self.wasm_module.clone(),
366 "alloc".to_string(),
367 "dealloc".to_string(),
368 list_accessor,
369 problem_json,
370 );
371
372 if let Some(mode) = &self.config.environment_mode {
373 request = request.with_environment_mode(format!("{:?}", mode).to_uppercase());
374 }
375
376 if let Some(termination) = &self.config.termination {
377 request = request.with_termination(termination.clone());
378 }
379
380 let response = self.service.solve(&request)?;
382
383 S::from_json(&response.solution)
385 }
386
387 pub fn solve_async(&self, problem: S) -> SolverForgeResult<crate::solver::SolveHandle> {
392 let problem_json = problem.to_json()?;
393
394 let domain_dto = self.domain_model.to_dto();
395 let constraints_dto = self.constraints.to_dto();
396
397 let list_accessor = crate::solver::ListAccessorDto::new(
398 "create_list",
399 "get_item",
400 "set_item",
401 "get_size",
402 "append",
403 "insert",
404 "remove",
405 "deallocate_list",
406 );
407
408 let mut request = crate::solver::SolveRequest::new(
409 domain_dto,
410 constraints_dto,
411 self.wasm_module.clone(),
412 "alloc".to_string(),
413 "dealloc".to_string(),
414 list_accessor,
415 problem_json,
416 );
417
418 if let Some(mode) = &self.config.environment_mode {
419 request = request.with_environment_mode(format!("{:?}", mode).to_uppercase());
420 }
421
422 if let Some(termination) = &self.config.termination {
423 request = request.with_termination(termination.clone());
424 }
425
426 self.service.solve_async(&request)
427 }
428
429 pub fn get_status(
431 &self,
432 handle: &crate::solver::SolveHandle,
433 ) -> SolverForgeResult<crate::solver::SolveStatus> {
434 self.service.get_status(handle)
435 }
436
437 pub fn get_best_solution(
439 &self,
440 handle: &crate::solver::SolveHandle,
441 ) -> SolverForgeResult<Option<S>> {
442 let response = self.service.get_best_solution(handle)?;
443 match response {
444 Some(r) => Ok(Some(S::from_json(&r.solution)?)),
445 None => Ok(None),
446 }
447 }
448
449 pub fn stop(&self, handle: &crate::solver::SolveHandle) -> SolverForgeResult<()> {
451 self.service.stop(handle)
452 }
453}
454
455#[derive(Debug, Clone)]
457pub enum SolverBuilderError {
458 WasmGeneration(String),
460 InvalidConfiguration(String),
462}
463
464impl std::fmt::Display for SolverBuilderError {
465 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466 match self {
467 SolverBuilderError::WasmGeneration(msg) => {
468 write!(f, "WASM module generation failed: {}", msg)
469 }
470 SolverBuilderError::InvalidConfiguration(msg) => {
471 write!(f, "Invalid solver configuration: {}", msg)
472 }
473 }
474 }
475}
476
477impl std::error::Error for SolverBuilderError {}
478
479impl From<SolverBuilderError> for SolverForgeError {
480 fn from(err: SolverBuilderError) -> Self {
481 match err {
482 SolverBuilderError::WasmGeneration(msg) => SolverForgeError::WasmGeneration(msg),
483 SolverBuilderError::InvalidConfiguration(msg) => SolverForgeError::Configuration(msg),
484 }
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use crate::bridge::tests::MockBridge;
492 use crate::constraints::Constraint;
493 use crate::domain::{
494 DomainClass, FieldDescriptor, FieldType, PlanningAnnotation, PrimitiveType, ScoreType,
495 };
496 use crate::traits::PlanningEntity;
497 use crate::HardSoftScore;
498 use std::collections::HashMap;
499
500 #[derive(Clone, Debug)]
502 struct TestLesson {
503 id: String,
504 subject: String,
505 room: Option<String>,
506 }
507
508 impl crate::traits::PlanningEntity for TestLesson {
509 fn domain_class() -> crate::domain::DomainClass {
510 DomainClass::new("TestLesson")
511 .with_annotation(PlanningAnnotation::PlanningEntity)
512 .with_field(
513 FieldDescriptor::new("id", FieldType::Primitive(PrimitiveType::String))
514 .with_planning_annotation(PlanningAnnotation::PlanningId),
515 )
516 .with_field(FieldDescriptor::new(
517 "subject",
518 FieldType::Primitive(PrimitiveType::String),
519 ))
520 .with_field(
521 FieldDescriptor::new("room", FieldType::Primitive(PrimitiveType::String))
522 .with_planning_annotation(PlanningAnnotation::planning_variable(vec![
523 "rooms".to_string(),
524 ])),
525 )
526 }
527
528 fn planning_id(&self) -> crate::Value {
529 crate::Value::String(self.id.clone())
530 }
531
532 fn to_value(&self) -> crate::Value {
533 let mut map = HashMap::new();
534 map.insert("id".to_string(), crate::Value::String(self.id.clone()));
535 map.insert(
536 "subject".to_string(),
537 crate::Value::String(self.subject.clone()),
538 );
539 map.insert(
540 "room".to_string(),
541 self.room
542 .clone()
543 .map(crate::Value::String)
544 .unwrap_or(crate::Value::Null),
545 );
546 crate::Value::Object(map)
547 }
548
549 fn from_value(value: &crate::Value) -> SolverForgeResult<Self> {
550 match value {
551 crate::Value::Object(map) => {
552 let id = map
553 .get("id")
554 .and_then(|v| v.as_str())
555 .ok_or_else(|| SolverForgeError::Serialization("Missing id".to_string()))?
556 .to_string();
557 let subject = map
558 .get("subject")
559 .and_then(|v| v.as_str())
560 .ok_or_else(|| {
561 SolverForgeError::Serialization("Missing subject".to_string())
562 })?
563 .to_string();
564 let room = map.get("room").and_then(|v| v.as_str()).map(String::from);
565 Ok(TestLesson { id, subject, room })
566 }
567 _ => Err(SolverForgeError::Serialization(
568 "Expected object".to_string(),
569 )),
570 }
571 }
572 }
573
574 #[derive(Clone, Debug)]
576 struct TestTimetable {
577 rooms: Vec<String>,
578 lessons: Vec<TestLesson>,
579 score: Option<HardSoftScore>,
580 }
581
582 impl PlanningSolution for TestTimetable {
583 type Score = HardSoftScore;
584
585 fn domain_model() -> DomainModel {
586 DomainModel::builder()
587 .add_class(TestLesson::domain_class())
588 .add_class(
589 DomainClass::new("TestTimetable")
590 .with_annotation(PlanningAnnotation::PlanningSolution)
591 .with_field(
592 FieldDescriptor::new(
593 "rooms",
594 FieldType::list(FieldType::Primitive(PrimitiveType::String)),
595 )
596 .with_planning_annotation(
597 PlanningAnnotation::ProblemFactCollectionProperty,
598 )
599 .with_planning_annotation(
600 PlanningAnnotation::value_range_provider("rooms"),
601 ),
602 )
603 .with_field(
604 FieldDescriptor::new(
605 "lessons",
606 FieldType::list(FieldType::object("TestLesson")),
607 )
608 .with_planning_annotation(
609 PlanningAnnotation::PlanningEntityCollectionProperty,
610 ),
611 )
612 .with_field(
613 FieldDescriptor::new("score", FieldType::Score(ScoreType::HardSoft))
614 .with_planning_annotation(PlanningAnnotation::planning_score()),
615 ),
616 )
617 .build()
618 }
619
620 fn constraints() -> ConstraintSet {
621 ConstraintSet::new().with_constraint(Constraint::new("Test constraint"))
622 }
623
624 fn score(&self) -> Option<Self::Score> {
625 self.score
626 }
627
628 fn set_score(&mut self, score: Self::Score) {
629 self.score = Some(score);
630 }
631
632 fn to_json(&self) -> SolverForgeResult<String> {
633 let mut map = HashMap::new();
634
635 let rooms: Vec<crate::Value> = self
636 .rooms
637 .iter()
638 .map(|r| crate::Value::String(r.clone()))
639 .collect();
640 map.insert("rooms".to_string(), crate::Value::Array(rooms));
641
642 let lessons: Vec<crate::Value> = self.lessons.iter().map(|l| l.to_value()).collect();
643 map.insert("lessons".to_string(), crate::Value::Array(lessons));
644
645 if let Some(score) = &self.score {
646 map.insert(
647 "score".to_string(),
648 crate::Value::String(format!("{}", score)),
649 );
650 }
651
652 serde_json::to_string(&crate::Value::Object(map))
653 .map_err(|e| SolverForgeError::Serialization(e.to_string()))
654 }
655
656 fn from_json(json: &str) -> SolverForgeResult<Self> {
657 let value: crate::Value = serde_json::from_str(json)
658 .map_err(|e| SolverForgeError::Serialization(e.to_string()))?;
659
660 match value {
661 crate::Value::Object(map) => {
662 let rooms = match map.get("rooms") {
663 Some(crate::Value::Array(arr)) => arr
664 .iter()
665 .filter_map(|v| v.as_str().map(String::from))
666 .collect(),
667 _ => Vec::new(),
668 };
669
670 let lessons = match map.get("lessons") {
671 Some(crate::Value::Array(arr)) => arr
672 .iter()
673 .filter_map(|v| TestLesson::from_value(v).ok())
674 .collect(),
675 _ => Vec::new(),
676 };
677
678 Ok(TestTimetable {
679 rooms,
680 lessons,
681 score: None,
682 })
683 }
684 _ => Err(SolverForgeError::Serialization(
685 "Expected object".to_string(),
686 )),
687 }
688 }
689 }
690
691 #[test]
692 fn test_solver_builder_new() {
693 let builder = SolverBuilder::<TestTimetable>::new();
694 assert_eq!(builder.service_url, DEFAULT_SERVICE_URL);
695 assert!(builder.termination.is_none());
696 assert!(builder.environment_mode.is_none());
697 }
698
699 #[test]
700 fn test_solver_builder_default() {
701 let builder = SolverBuilder::<TestTimetable>::default();
702 assert_eq!(builder.service_url, DEFAULT_SERVICE_URL);
703 }
704
705 #[test]
706 fn test_solver_builder_with_service_url() {
707 let builder = SolverBuilder::<TestTimetable>::new().with_service_url("http://custom:9000");
708 assert_eq!(builder.service_url, "http://custom:9000");
709 }
710
711 #[test]
712 fn test_solver_builder_with_termination() {
713 let termination = TerminationConfig::new().with_spent_limit("PT5M");
714 let builder = SolverBuilder::<TestTimetable>::new().with_termination(termination.clone());
715 assert_eq!(builder.termination, Some(termination));
716 }
717
718 #[test]
719 fn test_solver_builder_with_environment_mode() {
720 let builder = SolverBuilder::<TestTimetable>::new()
721 .with_environment_mode(EnvironmentMode::FullAssert);
722 assert_eq!(builder.environment_mode, Some(EnvironmentMode::FullAssert));
723 }
724
725 #[test]
726 fn test_solver_builder_with_random_seed() {
727 let builder = SolverBuilder::<TestTimetable>::new().with_random_seed(42);
728 assert_eq!(builder.random_seed, Some(42));
729 }
730
731 #[test]
732 fn test_solver_builder_with_move_thread_count() {
733 let builder =
734 SolverBuilder::<TestTimetable>::new().with_move_thread_count(MoveThreadCount::Auto);
735 assert_eq!(builder.move_thread_count, Some(MoveThreadCount::Auto));
736 }
737
738 #[test]
739 fn test_solver_builder_domain_model() {
740 let model = SolverBuilder::<TestTimetable>::domain_model();
741 assert!(model.get_solution_class().is_some());
742 assert_eq!(model.get_solution_class().unwrap().name, "TestTimetable");
743 assert!(model.get_class("TestLesson").is_some());
744 }
745
746 #[test]
747 fn test_solver_builder_constraints() {
748 let constraints = SolverBuilder::<TestTimetable>::constraints();
749 assert!(!constraints.is_empty());
750 }
751
752 #[test]
753 fn test_solver_builder_build_config() {
754 let builder = SolverBuilder::<TestTimetable>::new()
755 .with_termination(TerminationConfig::new().with_spent_limit("PT5M"))
756 .with_environment_mode(EnvironmentMode::Reproducible)
757 .with_random_seed(42);
758
759 let config = builder.build_config();
760 assert_eq!(config.solution_class, Some("TestTimetable".to_string()));
761 assert!(config.entity_class_list.contains(&"TestLesson".to_string()));
762 assert_eq!(config.environment_mode, Some(EnvironmentMode::Reproducible));
763 assert_eq!(config.random_seed, Some(42));
764 assert!(config.termination.is_some());
765 }
766
767 #[test]
768 fn test_solver_builder_generate_wasm() {
769 let builder = SolverBuilder::<TestTimetable>::new();
770 let wasm = builder.generate_wasm_module().unwrap();
771 assert!(wasm.starts_with("AGFzbQ")); }
773
774 #[test]
775 fn test_solver_builder_build() {
776 let solver = SolverBuilder::<TestTimetable>::new()
777 .with_service_url("http://localhost:19999")
778 .with_termination(TerminationConfig::new().with_spent_limit("PT1M"))
779 .build::<MockBridge>()
780 .unwrap();
781
782 assert_eq!(
783 solver.config().solution_class,
784 Some("TestTimetable".to_string())
785 );
786 assert!(solver.wasm_module().starts_with("AGFzbQ"));
787 assert_eq!(solver.service_url(), "http://localhost:19999");
788 }
789
790 #[test]
791 fn test_solver_builder_chained() {
792 let solver = SolverBuilder::<TestTimetable>::new()
793 .with_service_url("http://test:8080")
794 .with_termination(TerminationConfig::new().with_spent_limit("PT10M"))
795 .with_environment_mode(EnvironmentMode::NoAssert)
796 .with_random_seed(123)
797 .with_move_thread_count(MoveThreadCount::Count(4))
798 .build::<MockBridge>()
799 .unwrap();
800
801 let config = solver.config();
802 assert_eq!(config.environment_mode, Some(EnvironmentMode::NoAssert));
803 assert_eq!(config.random_seed, Some(123));
804 assert_eq!(config.move_thread_count, Some(MoveThreadCount::Count(4)));
805 }
806
807 #[test]
808 fn test_typed_solver_domain_model() {
809 let solver = SolverBuilder::<TestTimetable>::new()
810 .build::<MockBridge>()
811 .unwrap();
812
813 let model = solver.domain_model();
814 assert!(model.get_solution_class().is_some());
815 }
816
817 #[test]
818 fn test_typed_solver_constraints() {
819 let solver = SolverBuilder::<TestTimetable>::new()
820 .build::<MockBridge>()
821 .unwrap();
822
823 let constraints = solver.constraints();
824 assert!(!constraints.is_empty());
825 }
826
827 #[test]
828 fn test_typed_solver_is_service_available_offline() {
829 let solver = SolverBuilder::<TestTimetable>::new()
830 .with_service_url("http://localhost:19999")
831 .build::<MockBridge>()
832 .unwrap();
833
834 assert!(!solver.is_service_available());
836 }
837
838 #[test]
839 fn test_solver_builder_error_display() {
840 let err = SolverBuilderError::WasmGeneration("test error".to_string());
841 assert!(err.to_string().contains("WASM module generation failed"));
842
843 let err = SolverBuilderError::InvalidConfiguration("invalid".to_string());
844 assert!(err.to_string().contains("Invalid solver configuration"));
845 }
846
847 #[test]
848 fn test_solver_builder_error_conversion() {
849 let err = SolverBuilderError::WasmGeneration("test".to_string());
850 let forge_err: SolverForgeError = err.into();
851 assert!(matches!(forge_err, SolverForgeError::WasmGeneration(_)));
852
853 let err = SolverBuilderError::InvalidConfiguration("test".to_string());
854 let forge_err: SolverForgeError = err.into();
855 assert!(matches!(forge_err, SolverForgeError::Configuration(_)));
856 }
857}