solverforge_core/domain/
model.rs

1use super::{DomainClass, FieldType, PlanningAnnotation};
2use crate::SolverForgeError;
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7pub struct DomainModel {
8    pub classes: IndexMap<String, DomainClass>,
9    pub solution_class: Option<String>,
10    pub entity_classes: Vec<String>,
11}
12
13impl DomainModel {
14    pub fn new() -> Self {
15        Self::default()
16    }
17
18    pub fn builder() -> DomainModelBuilder {
19        DomainModelBuilder::new()
20    }
21
22    pub fn get_class(&self, name: &str) -> Option<&DomainClass> {
23        self.classes.get(name)
24    }
25
26    pub fn get_solution_class(&self) -> Option<&DomainClass> {
27        self.solution_class
28            .as_ref()
29            .and_then(|name| self.classes.get(name))
30    }
31
32    pub fn get_entity_classes(&self) -> impl Iterator<Item = &DomainClass> {
33        self.entity_classes
34            .iter()
35            .filter_map(|name| self.classes.get(name))
36    }
37
38    pub fn solution_class(&self) -> Option<&str> {
39        self.solution_class.as_deref()
40    }
41
42    /// Looks up the element type of a value range provider by its ID.
43    fn lookup_value_range_provider_element_type(&self, provider_id: &str) -> Option<String> {
44        for class in self.classes.values() {
45            for field in &class.fields {
46                for annotation in &field.annotations {
47                    if let PlanningAnnotation::ValueRangeProvider { id } = annotation {
48                        let effective_id = id.as_deref().unwrap_or(&field.name);
49                        if effective_id == provider_id {
50                            // Extract element type from the field's FieldType
51                            match &field.field_type {
52                                FieldType::List { element_type }
53                                | FieldType::Array { element_type }
54                                | FieldType::Set { element_type } => {
55                                    return Some(element_type.to_type_string());
56                                }
57                                // If not a collection, return the type directly
58                                _ => return Some(field.field_type.to_type_string()),
59                            }
60                        }
61                    }
62                }
63            }
64        }
65        None
66    }
67
68    pub fn to_dto(&self) -> indexmap::IndexMap<String, crate::solver::DomainObjectDto> {
69        use crate::solver::{DomainAccessor, DomainObjectDto, DomainObjectMapper, FieldDescriptor};
70
71        let mut result = indexmap::IndexMap::new();
72
73        for (name, class) in &self.classes {
74            let mut dto = DomainObjectDto::new();
75
76            for field in &class.fields {
77                // Generate accessor names that match WasmModuleBuilder exports:
78                // get_{Class}_{field} and set_{Class}_{field}
79                let (getter, setter) = if let Some(a) = &field.accessor {
80                    // Use explicitly defined accessor
81                    (a.getter.clone(), Some(a.setter.clone()))
82                } else {
83                    // Generate defaults
84                    let getter = format!("get_{}_{}", name, field.name);
85                    // Generate setter for fields that need to be modified:
86                    // - PlanningVariable: solver assigns values
87                    // - PlanningListVariable: solver modifies lists
88                    // - ProblemFactCollectionProperty: solution class collections
89                    // - PlanningEntityCollectionProperty: solution class entity collections
90                    // - ALL shadow variables: Need setters to sync Java-side updates to WASM
91                    //   memory so constraint evaluation reads current values
92                    let setter = if field.annotations.iter().any(|a| {
93                        matches!(
94                            a,
95                            PlanningAnnotation::PlanningVariable { .. }
96                                | PlanningAnnotation::PlanningListVariable { .. }
97                                | PlanningAnnotation::ProblemFactCollectionProperty
98                                | PlanningAnnotation::PlanningEntityCollectionProperty
99                                | PlanningAnnotation::InverseRelationShadowVariable { .. }
100                                | PlanningAnnotation::PreviousElementShadowVariable { .. }
101                                | PlanningAnnotation::NextElementShadowVariable { .. }
102                                | PlanningAnnotation::CascadingUpdateShadowVariable { .. }
103                        )
104                    }) {
105                        Some(format!("set_{}_{}", name, field.name))
106                    } else {
107                        None
108                    };
109                    (getter, setter)
110                };
111
112                let accessor = if let Some(s) = setter {
113                    DomainAccessor::getter_setter(getter, s)
114                } else {
115                    DomainAccessor::new(getter)
116                };
117
118                // Resolve element types for planning list variables
119                let field_type = if let Some(provider_refs) =
120                    field.annotations.iter().find_map(|a| {
121                        if let PlanningAnnotation::PlanningListVariable {
122                            value_range_provider_refs,
123                            ..
124                        } = a
125                        {
126                            if !value_range_provider_refs.is_empty() {
127                                return Some(value_range_provider_refs);
128                            }
129                        }
130                        None
131                    }) {
132                    // Planning list variable: resolve from value range provider
133                    let provider_id = &provider_refs[0];
134                    let element_type = self
135                        .lookup_value_range_provider_element_type(provider_id)
136                        .unwrap_or_else(|| {
137                            panic!("Value range provider '{}' not found", provider_id)
138                        });
139                    format!("{}[]", element_type)
140                } else {
141                    // Use the field type directly (shadow variables set their type in derive macro)
142                    field.field_type.to_type_string()
143                };
144
145                let field_descriptor = FieldDescriptor::new(field_type)
146                    .with_accessor(accessor)
147                    .with_annotations(field.annotations.clone());
148
149                dto = dto.with_field(&field.name, field_descriptor);
150            }
151
152            // Add class-level annotations
153            if class.is_planning_entity() {
154                dto = dto.with_annotation(crate::solver::ClassAnnotation::PlanningEntity);
155            }
156            if class.is_planning_solution() {
157                dto = dto.with_annotation(crate::solver::ClassAnnotation::PlanningSolution);
158            }
159
160            // Add mapper for solution class (PlanningSolution)
161            // Uses parseSchedule/scheduleString which are exported by WasmModuleBuilder
162            if class.is_planning_solution() {
163                dto = dto.with_mapper(DomainObjectMapper::new("parseSchedule", "scheduleString"));
164            }
165
166            result.insert(name.clone(), dto);
167        }
168
169        result
170    }
171
172    pub fn validate(&self) -> Result<(), SolverForgeError> {
173        if self.solution_class.is_none() {
174            return Err(SolverForgeError::Validation(
175                "Domain model must have a solution class".to_string(),
176            ));
177        }
178
179        let solution_name = self.solution_class.as_ref().unwrap();
180        let solution = self.classes.get(solution_name).ok_or_else(|| {
181            SolverForgeError::Validation(format!(
182                "Solution class '{}' not found in domain model",
183                solution_name
184            ))
185        })?;
186
187        if !solution.is_planning_solution() {
188            return Err(SolverForgeError::Validation(format!(
189                "Class '{}' is marked as solution but lacks @PlanningSolution annotation",
190                solution_name
191            )));
192        }
193
194        if solution.get_score_field().is_none() {
195            return Err(SolverForgeError::Validation(format!(
196                "Solution class '{}' must have a @PlanningScore field",
197                solution_name
198            )));
199        }
200
201        if self.entity_classes.is_empty() {
202            return Err(SolverForgeError::Validation(
203                "Domain model must have at least one entity class".to_string(),
204            ));
205        }
206
207        for entity_name in &self.entity_classes {
208            let entity = self.classes.get(entity_name).ok_or_else(|| {
209                SolverForgeError::Validation(format!(
210                    "Entity class '{}' not found in domain model",
211                    entity_name
212                ))
213            })?;
214
215            if !entity.is_planning_entity() {
216                return Err(SolverForgeError::Validation(format!(
217                    "Class '{}' is marked as entity but lacks @PlanningEntity annotation",
218                    entity_name
219                )));
220            }
221
222            let has_variable = entity.get_planning_variables().next().is_some();
223            if !has_variable {
224                return Err(SolverForgeError::Validation(format!(
225                    "Entity class '{}' must have at least one @PlanningVariable",
226                    entity_name
227                )));
228            }
229        }
230
231        Ok(())
232    }
233
234    /// Sets the compute expression for a CascadingUpdateShadowVariable.
235    ///
236    /// This must be called for each cascading update shadow variable before WASM
237    /// generation, or the build will fail with an error.
238    ///
239    /// # Arguments
240    /// * `class_name` - The entity class name (e.g., "Visit")
241    /// * `field_name` - The field name with the shadow variable
242    /// * `expression` - The expression to compute the shadow value
243    ///
244    /// # Returns
245    /// Ok(()) if the annotation was found and updated, Err otherwise.
246    pub fn set_cascading_expression(
247        &mut self,
248        class_name: &str,
249        field_name: &str,
250        expression: crate::wasm::Expression,
251    ) -> Result<(), SolverForgeError> {
252        let class = self.classes.get_mut(class_name).ok_or_else(|| {
253            SolverForgeError::Validation(format!("Class '{}' not found", class_name))
254        })?;
255
256        let field = class
257            .fields
258            .iter_mut()
259            .find(|f| f.name == field_name)
260            .ok_or_else(|| {
261                SolverForgeError::Validation(format!(
262                    "Field '{}' not found in class '{}'",
263                    field_name, class_name
264                ))
265            })?;
266
267        let found = field.annotations.iter_mut().any(|ann| {
268            if let PlanningAnnotation::CascadingUpdateShadowVariable {
269                compute_expression, ..
270            } = ann
271            {
272                *compute_expression = Some(expression.clone());
273                true
274            } else {
275                false
276            }
277        });
278
279        if found {
280            Ok(())
281        } else {
282            Err(SolverForgeError::Validation(format!(
283                "Field '{}' in class '{}' has no CascadingUpdateShadowVariable annotation",
284                field_name, class_name
285            )))
286        }
287    }
288}
289
290#[derive(Debug, Default)]
291pub struct DomainModelBuilder {
292    classes: IndexMap<String, DomainClass>,
293    solution_class: Option<String>,
294    entity_classes: Vec<String>,
295}
296
297impl DomainModelBuilder {
298    pub fn new() -> Self {
299        Self::default()
300    }
301
302    pub fn add_class(mut self, class: DomainClass) -> Self {
303        let name = class.name.clone();
304
305        if class.is_planning_solution() {
306            self.solution_class = Some(name.clone());
307        }
308
309        if class.is_planning_entity() {
310            self.entity_classes.push(name.clone());
311        }
312
313        self.classes.insert(name, class);
314        self
315    }
316
317    pub fn with_solution(mut self, class_name: impl Into<String>) -> Self {
318        self.solution_class = Some(class_name.into());
319        self
320    }
321
322    pub fn with_entity(mut self, class_name: impl Into<String>) -> Self {
323        self.entity_classes.push(class_name.into());
324        self
325    }
326
327    pub fn solution_class(self, class_name: impl Into<String>) -> Self {
328        self.with_solution(class_name)
329    }
330
331    pub fn entity_class(self, class_name: impl Into<String>) -> Self {
332        self.with_entity(class_name)
333    }
334
335    pub fn build(self) -> DomainModel {
336        DomainModel {
337            classes: self.classes,
338            solution_class: self.solution_class,
339            entity_classes: self.entity_classes,
340        }
341    }
342
343    pub fn build_validated(self) -> Result<DomainModel, SolverForgeError> {
344        let model = self.build();
345        model.validate()?;
346        Ok(model)
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::domain::{FieldDescriptor, FieldType, PlanningAnnotation, ScoreType};
354
355    fn create_lesson_entity() -> DomainClass {
356        DomainClass::new("Lesson")
357            .with_annotation(PlanningAnnotation::PlanningEntity)
358            .with_field(
359                FieldDescriptor::new(
360                    "id",
361                    FieldType::Primitive(crate::domain::PrimitiveType::String),
362                )
363                .with_annotation(PlanningAnnotation::PlanningId),
364            )
365            .with_field(
366                FieldDescriptor::new("room", FieldType::object("Room")).with_annotation(
367                    PlanningAnnotation::planning_variable(vec!["rooms".to_string()]),
368                ),
369            )
370    }
371
372    fn create_timetable_solution() -> DomainClass {
373        DomainClass::new("Timetable")
374            .with_annotation(PlanningAnnotation::PlanningSolution)
375            .with_field(
376                FieldDescriptor::new("lessons", FieldType::list(FieldType::object("Lesson")))
377                    .with_annotation(PlanningAnnotation::PlanningEntityCollectionProperty),
378            )
379            .with_field(
380                FieldDescriptor::new("rooms", FieldType::list(FieldType::object("Room")))
381                    .with_annotation(PlanningAnnotation::value_range_provider_with_id("rooms")),
382            )
383            .with_field(
384                FieldDescriptor::new("score", FieldType::Score(ScoreType::HardSoft))
385                    .with_annotation(PlanningAnnotation::planning_score()),
386            )
387    }
388
389    #[test]
390    fn test_builder_basic() {
391        let model = DomainModel::builder()
392            .add_class(create_lesson_entity())
393            .add_class(create_timetable_solution())
394            .build();
395
396        assert_eq!(model.classes.len(), 2);
397        assert_eq!(model.solution_class, Some("Timetable".to_string()));
398        assert_eq!(model.entity_classes, vec!["Lesson"]);
399    }
400
401    #[test]
402    fn test_get_class() {
403        let model = DomainModel::builder()
404            .add_class(create_lesson_entity())
405            .build();
406
407        assert!(model.get_class("Lesson").is_some());
408        assert!(model.get_class("Unknown").is_none());
409    }
410
411    #[test]
412    fn test_get_solution_class() {
413        let model = DomainModel::builder()
414            .add_class(create_timetable_solution())
415            .build();
416
417        let solution = model.get_solution_class().unwrap();
418        assert_eq!(solution.name, "Timetable");
419    }
420
421    #[test]
422    fn test_get_entity_classes() {
423        let model = DomainModel::builder()
424            .add_class(create_lesson_entity())
425            .build();
426
427        let entities: Vec<_> = model.get_entity_classes().collect();
428        assert_eq!(entities.len(), 1);
429        assert_eq!(entities[0].name, "Lesson");
430    }
431
432    #[test]
433    fn test_validate_success() {
434        let model = DomainModel::builder()
435            .add_class(create_lesson_entity())
436            .add_class(create_timetable_solution())
437            .build();
438
439        assert!(model.validate().is_ok());
440    }
441
442    #[test]
443    fn test_validate_no_solution() {
444        let model = DomainModel::builder()
445            .add_class(create_lesson_entity())
446            .build();
447
448        let err = model.validate().unwrap_err();
449        assert!(err.to_string().contains("solution class"));
450    }
451
452    #[test]
453    fn test_validate_no_entities() {
454        let model = DomainModel::builder()
455            .add_class(create_timetable_solution())
456            .build();
457
458        let err = model.validate().unwrap_err();
459        assert!(err.to_string().contains("entity class"));
460    }
461
462    #[test]
463    fn test_validate_solution_without_score() {
464        let solution =
465            DomainClass::new("Timetable").with_annotation(PlanningAnnotation::PlanningSolution);
466
467        let model = DomainModel::builder()
468            .add_class(solution)
469            .add_class(create_lesson_entity())
470            .build();
471
472        let err = model.validate().unwrap_err();
473        assert!(err.to_string().contains("@PlanningScore"));
474    }
475
476    #[test]
477    fn test_validate_entity_without_variable() {
478        let entity = DomainClass::new("Lesson")
479            .with_annotation(PlanningAnnotation::PlanningEntity)
480            .with_field(
481                FieldDescriptor::new(
482                    "id",
483                    FieldType::Primitive(crate::domain::PrimitiveType::String),
484                )
485                .with_annotation(PlanningAnnotation::PlanningId),
486            );
487
488        let model = DomainModel::builder()
489            .add_class(entity)
490            .add_class(create_timetable_solution())
491            .build();
492
493        let err = model.validate().unwrap_err();
494        assert!(err.to_string().contains("@PlanningVariable"));
495    }
496
497    #[test]
498    fn test_build_validated() {
499        let result = DomainModel::builder()
500            .add_class(create_lesson_entity())
501            .add_class(create_timetable_solution())
502            .build_validated();
503
504        assert!(result.is_ok());
505    }
506
507    #[test]
508    fn test_json_serialization() {
509        let model = DomainModel::builder()
510            .add_class(create_lesson_entity())
511            .add_class(create_timetable_solution())
512            .build();
513
514        let json = serde_json::to_string(&model).unwrap();
515        let parsed: DomainModel = serde_json::from_str(&json).unwrap();
516
517        assert_eq!(parsed.classes.len(), model.classes.len());
518        assert_eq!(parsed.solution_class, model.solution_class);
519    }
520}