solverforge_core/solver/
request.rs

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