solverforge_core/solver/
request.rs

1use crate::constraints::StreamComponent;
2use crate::domain::PlanningAnnotation;
3use crate::solver::TerminationConfig;
4use indexmap::IndexMap;
5use serde::{Deserialize, Serialize};
6
7/// Solve request matching solverforge-wasm-service's PlanningProblem schema
8/// Uses IndexMap for domain and constraints to preserve insertion order.
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct SolveRequest {
12    pub domain: IndexMap<String, DomainObjectDto>,
13    pub constraints: IndexMap<String, Vec<StreamComponent>>,
14    pub wasm: String,
15    pub allocator: String,
16    pub deallocator: String,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub solution_deallocator: Option<String>,
19    pub list_accessor: ListAccessorDto,
20    pub problem: String,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub environment_mode: Option<String>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub termination: Option<TerminationConfig>,
25    /// Pre-computed method results for methods that couldn't be inlined.
26    /// Maps method_hash to a table of (object_key -> result).
27    /// Object keys are strings like "ptr1_ptr2" for 2-arg methods.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub precomputed: Option<IndexMap<i32, IndexMap<String, i32>>>,
30}
31
32impl SolveRequest {
33    pub fn new(
34        domain: IndexMap<String, DomainObjectDto>,
35        constraints: IndexMap<String, Vec<StreamComponent>>,
36        wasm: String,
37        allocator: String,
38        deallocator: String,
39        list_accessor: ListAccessorDto,
40        problem: String,
41    ) -> Self {
42        Self {
43            domain,
44            constraints,
45            wasm,
46            allocator,
47            deallocator,
48            solution_deallocator: None,
49            list_accessor,
50            problem,
51            environment_mode: None,
52            termination: None,
53            precomputed: None,
54        }
55    }
56
57    pub fn with_solution_deallocator(mut self, deallocator: impl Into<String>) -> Self {
58        self.solution_deallocator = Some(deallocator.into());
59        self
60    }
61
62    pub fn with_environment_mode(mut self, mode: impl Into<String>) -> Self {
63        self.environment_mode = Some(mode.into());
64        self
65    }
66
67    pub fn with_termination(mut self, termination: TerminationConfig) -> Self {
68        self.termination = Some(termination);
69        self
70    }
71
72    pub fn with_precomputed(mut self, precomputed: IndexMap<i32, IndexMap<String, i32>>) -> Self {
73        self.precomputed = Some(precomputed);
74        self
75    }
76}
77
78/// Domain object definition with fields and optional mapper
79/// Fields use IndexMap to preserve insertion order, which is critical
80/// for correct WASM memory layout alignment.
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct DomainObjectDto {
83    pub fields: IndexMap<String, FieldDescriptor>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub mapper: Option<DomainObjectMapper>,
86    /// Class-level annotations (e.g., PlanningEntity)
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub annotations: Vec<ClassAnnotation>,
89}
90
91impl DomainObjectDto {
92    pub fn new() -> Self {
93        Self {
94            fields: IndexMap::new(),
95            mapper: None,
96            annotations: Vec::new(),
97        }
98    }
99
100    pub fn with_annotation(mut self, annotation: ClassAnnotation) -> Self {
101        self.annotations.push(annotation);
102        self
103    }
104
105    pub fn with_field(mut self, name: impl Into<String>, field: FieldDescriptor) -> Self {
106        self.fields.insert(name.into(), field);
107        self
108    }
109
110    pub fn with_mapper(mut self, mapper: DomainObjectMapper) -> Self {
111        self.mapper = Some(mapper);
112        self
113    }
114}
115
116impl Default for DomainObjectDto {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122/// Field descriptor with type, accessor, and annotations
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct FieldDescriptor {
125    #[serde(rename = "type")]
126    pub field_type: String,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub accessor: Option<DomainAccessor>,
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub annotations: Vec<PlanningAnnotation>,
131}
132
133impl FieldDescriptor {
134    pub fn new(field_type: impl Into<String>) -> Self {
135        Self {
136            field_type: field_type.into(),
137            accessor: None,
138            annotations: Vec::new(),
139        }
140    }
141
142    pub fn with_accessor(mut self, accessor: DomainAccessor) -> Self {
143        self.accessor = Some(accessor);
144        self
145    }
146
147    pub fn with_annotation(mut self, annotation: PlanningAnnotation) -> Self {
148        self.annotations.push(annotation);
149        self
150    }
151
152    pub fn with_annotations(mut self, annotations: Vec<PlanningAnnotation>) -> Self {
153        self.annotations = annotations;
154        self
155    }
156}
157
158/// Getter/setter accessor for a field
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct DomainAccessor {
161    pub getter: String,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub setter: Option<String>,
164}
165
166impl DomainAccessor {
167    pub fn new(getter: impl Into<String>) -> Self {
168        Self {
169            getter: getter.into(),
170            setter: None,
171        }
172    }
173
174    pub fn with_setter(mut self, setter: impl Into<String>) -> Self {
175        self.setter = Some(setter.into());
176        self
177    }
178
179    pub fn getter_setter(getter: impl Into<String>, setter: impl Into<String>) -> Self {
180        Self {
181            getter: getter.into(),
182            setter: Some(setter.into()),
183        }
184    }
185}
186
187/// Mapper for parsing/serializing solution objects
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189pub struct DomainObjectMapper {
190    #[serde(rename = "fromString")]
191    pub from_string: String,
192    #[serde(rename = "toString")]
193    pub to_string: String,
194}
195
196impl DomainObjectMapper {
197    pub fn new(from_string: impl Into<String>, to_string: impl Into<String>) -> Self {
198        Self {
199            from_string: from_string.into(),
200            to_string: to_string.into(),
201        }
202    }
203}
204
205/// Class-level annotations (applied to the class itself, not fields)
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(tag = "annotation")]
208pub enum ClassAnnotation {
209    PlanningEntity,
210    PlanningSolution,
211}
212
213/// List accessor for WASM list operations
214/// JSON field names match Java's DomainListAccessor
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct ListAccessorDto {
217    #[serde(rename = "new")]
218    pub create: String,
219    #[serde(rename = "get")]
220    pub get_item: String,
221    #[serde(rename = "set")]
222    pub set_item: String,
223    #[serde(rename = "length")]
224    pub get_size: String,
225    pub append: String,
226    pub insert: String,
227    pub remove: String,
228    pub deallocator: String,
229}
230
231impl ListAccessorDto {
232    #[allow(clippy::too_many_arguments)]
233    pub fn new(
234        create: impl Into<String>,
235        get_item: impl Into<String>,
236        set_item: impl Into<String>,
237        get_size: impl Into<String>,
238        append: impl Into<String>,
239        insert: impl Into<String>,
240        remove: impl Into<String>,
241        deallocator: impl Into<String>,
242    ) -> Self {
243        Self {
244            create: create.into(),
245            get_item: get_item.into(),
246            set_item: set_item.into(),
247            get_size: get_size.into(),
248            append: append.into(),
249            insert: insert.into(),
250            remove: remove.into(),
251            deallocator: deallocator.into(),
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_solve_request_new() {
262        let request = SolveRequest::new(
263            IndexMap::new(),
264            IndexMap::new(),
265            "AGFzbQ==".to_string(),
266            "allocate".to_string(),
267            "deallocate".to_string(),
268            ListAccessorDto::new(
269                "newList", "getItem", "setItem", "size", "append", "insert", "remove", "dealloc",
270            ),
271            "{}".to_string(),
272        );
273
274        assert_eq!(request.wasm, "AGFzbQ==");
275        assert_eq!(request.allocator, "allocate");
276        assert!(request.environment_mode.is_none());
277    }
278
279    #[test]
280    fn test_solve_request_with_options() {
281        let termination = TerminationConfig::new().with_spent_limit("PT5M");
282        let request = SolveRequest::new(
283            IndexMap::new(),
284            IndexMap::new(),
285            "AGFzbQ==".to_string(),
286            "allocate".to_string(),
287            "deallocate".to_string(),
288            ListAccessorDto::new(
289                "newList", "getItem", "setItem", "size", "append", "insert", "remove", "dealloc",
290            ),
291            "{}".to_string(),
292        )
293        .with_environment_mode("FULL_ASSERT")
294        .with_termination(termination);
295
296        assert_eq!(request.environment_mode, Some("FULL_ASSERT".to_string()));
297        assert!(request.termination.is_some());
298    }
299
300    #[test]
301    fn test_domain_object_dto_with_fields() {
302        let dto = DomainObjectDto::new()
303            .with_field(
304                "id",
305                FieldDescriptor::new("int")
306                    .with_accessor(DomainAccessor::new("getId"))
307                    .with_annotation(PlanningAnnotation::planning_id()),
308            )
309            .with_field(
310                "employee",
311                FieldDescriptor::new("Employee")
312                    .with_accessor(DomainAccessor::getter_setter("getEmployee", "setEmployee"))
313                    .with_annotation(PlanningAnnotation::planning_variable(vec![])),
314            );
315
316        assert_eq!(dto.fields.len(), 2);
317        assert!(dto.fields.contains_key("id"));
318        assert!(dto.fields.contains_key("employee"));
319    }
320
321    #[test]
322    fn test_domain_object_dto_with_mapper() {
323        let dto = DomainObjectDto::new()
324            .with_mapper(DomainObjectMapper::new("parseSchedule", "scheduleString"));
325
326        assert!(dto.mapper.is_some());
327        let mapper = dto.mapper.unwrap();
328        assert_eq!(mapper.from_string, "parseSchedule");
329        assert_eq!(mapper.to_string, "scheduleString");
330    }
331
332    #[test]
333    fn test_field_descriptor() {
334        let field = FieldDescriptor::new("Employee")
335            .with_accessor(DomainAccessor::getter_setter("getEmployee", "setEmployee"))
336            .with_annotation(PlanningAnnotation::PlanningVariable {
337                value_range_provider_refs: vec![],
338                allows_unassigned: false,
339            });
340
341        assert_eq!(field.field_type, "Employee");
342        assert!(field.accessor.is_some());
343        assert_eq!(field.annotations.len(), 1);
344    }
345
346    #[test]
347    fn test_domain_accessor() {
348        let accessor = DomainAccessor::new("getRoom").with_setter("setRoom");
349        assert_eq!(accessor.getter, "getRoom");
350        assert_eq!(accessor.setter, Some("setRoom".to_string()));
351
352        let accessor2 = DomainAccessor::getter_setter("getRoom", "setRoom");
353        assert_eq!(accessor2.getter, "getRoom");
354        assert_eq!(accessor2.setter, Some("setRoom".to_string()));
355    }
356
357    #[test]
358    fn test_planning_annotation_constructors() {
359        assert!(matches!(
360            PlanningAnnotation::planning_variable(vec![]),
361            PlanningAnnotation::PlanningVariable {
362                allows_unassigned: false,
363                ..
364            }
365        ));
366        assert!(matches!(
367            PlanningAnnotation::planning_variable_unassigned(vec![]),
368            PlanningAnnotation::PlanningVariable {
369                allows_unassigned: true,
370                ..
371            }
372        ));
373        assert!(matches!(
374            PlanningAnnotation::planning_id(),
375            PlanningAnnotation::PlanningId
376        ));
377        assert!(matches!(
378            PlanningAnnotation::planning_score(),
379            PlanningAnnotation::PlanningScore { .. }
380        ));
381        assert!(matches!(
382            PlanningAnnotation::value_range_provider(),
383            PlanningAnnotation::ValueRangeProvider { .. }
384        ));
385    }
386
387    #[test]
388    fn test_list_accessor_dto() {
389        let accessor = ListAccessorDto::new(
390            "newList", "getItem", "setItem", "size", "append", "insert", "remove", "dealloc",
391        );
392
393        assert_eq!(accessor.create, "newList");
394        assert_eq!(accessor.get_item, "getItem");
395        assert_eq!(accessor.set_item, "setItem");
396        assert_eq!(accessor.get_size, "size");
397        assert_eq!(accessor.deallocator, "dealloc");
398    }
399
400    #[test]
401    fn test_solve_request_json_serialization() {
402        let mut domain = IndexMap::new();
403        domain.insert(
404            "Employee".to_string(),
405            DomainObjectDto::new().with_field(
406                "id",
407                FieldDescriptor::new("int")
408                    .with_accessor(DomainAccessor::new("getEmployeeId"))
409                    .with_annotation(PlanningAnnotation::planning_id()),
410            ),
411        );
412
413        let request = SolveRequest::new(
414            domain,
415            IndexMap::new(),
416            "AGFzbQ==".to_string(),
417            "allocate".to_string(),
418            "deallocate".to_string(),
419            ListAccessorDto::new(
420                "newList", "getItem", "setItem", "size", "append", "insert", "remove", "dealloc",
421            ),
422            r#"{"employees": []}"#.to_string(),
423        );
424
425        let json = serde_json::to_string(&request).unwrap();
426        assert!(json.contains("\"domain\""));
427        assert!(json.contains("\"listAccessor\""));
428        assert!(json.contains("\"type\":\"int\""));
429        assert!(json.contains("\"annotation\":\"PlanningId\""));
430
431        let parsed: SolveRequest = serde_json::from_str(&json).unwrap();
432        assert_eq!(parsed, request);
433    }
434
435    #[test]
436    fn test_solve_request_omits_none_fields() {
437        let request = SolveRequest::new(
438            IndexMap::new(),
439            IndexMap::new(),
440            "AGFzbQ==".to_string(),
441            "allocate".to_string(),
442            "deallocate".to_string(),
443            ListAccessorDto::new(
444                "newList", "getItem", "setItem", "size", "append", "insert", "remove", "dealloc",
445            ),
446            "{}".to_string(),
447        );
448
449        let json = serde_json::to_string(&request).unwrap();
450        assert!(!json.contains("environmentMode"));
451        assert!(!json.contains("termination"));
452        assert!(!json.contains("solutionDeallocator"));
453    }
454
455    #[test]
456    fn test_planning_annotation_json_serialization() {
457        let variable = PlanningAnnotation::PlanningVariable {
458            value_range_provider_refs: vec![],
459            allows_unassigned: false,
460        };
461        let json = serde_json::to_string(&variable).unwrap();
462        assert!(json.contains("\"annotation\":\"PlanningVariable\""));
463        // allows_unassigned: false should be omitted
464        assert!(!json.contains("allowsUnassigned"));
465
466        let variable_unassigned = PlanningAnnotation::PlanningVariable {
467            value_range_provider_refs: vec![],
468            allows_unassigned: true,
469        };
470        let json2 = serde_json::to_string(&variable_unassigned).unwrap();
471        assert!(json2.contains("\"allowsUnassigned\":true"));
472
473        let planning_id = PlanningAnnotation::PlanningId;
474        let json3 = serde_json::to_string(&planning_id).unwrap();
475        assert!(json3.contains("\"annotation\":\"PlanningId\""));
476    }
477
478    #[test]
479    fn test_list_accessor_json_field_names() {
480        let accessor = ListAccessorDto::new(
481            "newList", "getItem", "setItem", "size", "append", "insert", "remove", "dealloc",
482        );
483
484        let json = serde_json::to_string(&accessor).unwrap();
485        // Verify Java-compatible field names
486        assert!(json.contains("\"new\":\"newList\""));
487        assert!(json.contains("\"get\":\"getItem\""));
488        assert!(json.contains("\"set\":\"setItem\""));
489        assert!(json.contains("\"length\":\"size\""));
490        assert!(json.contains("\"append\":\"append\""));
491    }
492
493    #[test]
494    fn test_domain_object_mapper_json_serialization() {
495        let mapper = DomainObjectMapper::new("parseSchedule", "scheduleString");
496        let json = serde_json::to_string(&mapper).unwrap();
497        assert!(json.contains("\"fromString\":\"parseSchedule\""));
498        assert!(json.contains("\"toString\":\"scheduleString\""));
499    }
500
501    #[test]
502    fn test_field_descriptor_json_serialization() {
503        let field = FieldDescriptor::new("Employee")
504            .with_accessor(DomainAccessor::getter_setter("getEmployee", "setEmployee"))
505            .with_annotation(PlanningAnnotation::planning_variable(vec![]));
506
507        let json = serde_json::to_string(&field).unwrap();
508        assert!(json.contains("\"type\":\"Employee\""));
509        assert!(json.contains("\"getter\":\"getEmployee\""));
510        assert!(json.contains("\"setter\":\"setEmployee\""));
511        assert!(json.contains("\"annotation\":\"PlanningVariable\""));
512
513        let parsed: FieldDescriptor = serde_json::from_str(&json).unwrap();
514        assert_eq!(parsed, field);
515    }
516
517    #[test]
518    fn test_full_domain_json_structure() {
519        // Build a domain matching Java's test case
520        let mut domain = IndexMap::new();
521
522        // Employee with PlanningId
523        domain.insert(
524            "Employee".to_string(),
525            DomainObjectDto::new().with_field(
526                "id",
527                FieldDescriptor::new("int")
528                    .with_accessor(DomainAccessor::new("getEmployeeId"))
529                    .with_annotation(PlanningAnnotation::planning_id()),
530            ),
531        );
532
533        // Shift with PlanningVariable
534        domain.insert(
535            "Shift".to_string(),
536            DomainObjectDto::new().with_field(
537                "employee",
538                FieldDescriptor::new("Employee")
539                    .with_accessor(DomainAccessor::getter_setter("getEmployee", "setEmployee"))
540                    .with_annotation(PlanningAnnotation::planning_variable(vec![])),
541            ),
542        );
543
544        // Schedule (solution) with collections and score
545        domain.insert(
546            "Schedule".to_string(),
547            DomainObjectDto::new()
548                .with_field(
549                    "employees",
550                    FieldDescriptor::new("Employee[]")
551                        .with_accessor(DomainAccessor::getter_setter(
552                            "getEmployees",
553                            "setEmployees",
554                        ))
555                        .with_annotation(PlanningAnnotation::problem_fact_collection_property())
556                        .with_annotation(PlanningAnnotation::value_range_provider()),
557                )
558                .with_field(
559                    "shifts",
560                    FieldDescriptor::new("Shift[]")
561                        .with_accessor(DomainAccessor::getter_setter("getShifts", "setShifts"))
562                        .with_annotation(PlanningAnnotation::planning_entity_collection_property()),
563                )
564                .with_field(
565                    "score",
566                    FieldDescriptor::new("SimpleScore")
567                        .with_annotation(PlanningAnnotation::planning_score()),
568                )
569                .with_mapper(DomainObjectMapper::new("parseSchedule", "scheduleString")),
570        );
571
572        let json = serde_json::to_string_pretty(&domain).unwrap();
573
574        // Verify structure
575        assert!(json.contains("\"Employee\""));
576        assert!(json.contains("\"Shift\""));
577        assert!(json.contains("\"Schedule\""));
578        // Type is serialized under "type" field
579        assert!(json.contains("\"type\": \"int\"") || json.contains("\"type\":\"int\""));
580        assert!(json.contains("\"Employee\""));
581        assert!(json.contains("\"Employee[]\""));
582        assert!(json.contains("\"SimpleScore\""));
583        // Annotations use PascalCase for variant names
584        assert!(
585            json.contains("\"annotation\": \"PlanningId\"")
586                || json.contains("\"annotation\":\"PlanningId\"")
587        );
588        assert!(
589            json.contains("\"annotation\": \"PlanningVariable\"")
590                || json.contains("\"annotation\":\"PlanningVariable\"")
591        );
592        assert!(
593            json.contains("\"annotation\": \"PlanningScore\"")
594                || json.contains("\"annotation\":\"PlanningScore\"")
595        );
596        assert!(
597            json.contains("\"annotation\": \"ValueRangeProvider\"")
598                || json.contains("\"annotation\":\"ValueRangeProvider\"")
599        );
600        assert!(
601            json.contains("\"fromString\": \"parseSchedule\"")
602                || json.contains("\"fromString\":\"parseSchedule\"")
603        );
604    }
605
606    #[test]
607    fn test_domain_object_dto_clone() {
608        let dto = DomainObjectDto::new().with_field(
609            "id",
610            FieldDescriptor::new("int").with_annotation(PlanningAnnotation::planning_id()),
611        );
612        let cloned = dto.clone();
613        assert_eq!(dto, cloned);
614    }
615
616    #[test]
617    fn test_domain_object_dto_debug() {
618        let dto = DomainObjectDto::new();
619        let debug = format!("{:?}", dto);
620        assert!(debug.contains("DomainObjectDto"));
621    }
622}