1use crate::computation::UnitResolutionContext;
13use crate::literals::Value;
14use crate::parsing::ast::{DateTimeValue, EffectiveDate, LemmaSpec};
15use crate::parsing::source::{Source, SourceType};
16use crate::planning::graph::Graph;
17use crate::planning::graph::ResolvedSpecTypes;
18use crate::planning::normalize::{
19 data_path_result_type, NormalForm, NormalFormId, NormalFormInterner, NormalizeContext,
20 NormalizedRule,
21};
22use crate::planning::semantics::{
23 value_kind_matches_spec, ComparisonComputation, DataDefinition, DataPath, LemmaType,
24 LiteralValue, ReferenceEnd, ReferenceTarget, RulePath, TypeSpecification, ValueKind,
25};
26use crate::planning::spec_set::LemmaSpecSet;
27use crate::planning::unit_family::FamilyUnitCatalog;
28use crate::result_value::RuleResultValue;
29use crate::Error;
30use indexmap::{IndexMap, IndexSet};
31use serde::{Deserialize, Serialize};
32use std::collections::{BTreeMap, HashMap, HashSet};
33use std::sync::Arc;
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ExecutionPlan {
41 pub spec_name: String,
43
44 pub commentary: Option<String>,
46
47 pub data: IndexMap<DataPath, DataDefinition>,
49
50 pub(crate) normal_forms: Vec<NormalForm>,
53
54 pub rules: IndexMap<RulePath, ExecutableRule>,
57
58 pub data_reference_order: Vec<DataPath>,
62
63 pub meta: IndexMap<String, Value>,
65
66 pub resolved_types: ResolvedSpecTypes,
70
71 pub family_units: FamilyUnitCatalog,
73
74 pub signature_index: crate::computation::arithmetic::SignatureIndex,
80
81 pub effective: EffectiveDate,
82
83 pub effective_from: Option<DateTimeValue>,
86 pub effective_to: Option<DateTimeValue>,
87
88 pub versions: Arc<[ShowVersion]>,
92
93 pub start_line: usize,
95
96 pub source_type: Option<SourceType>,
98
99 pub(crate) needed_by_rules: Vec<Vec<u32>>,
105
106 pub(crate) data_display: IndexMap<DataPath, ShowDataCache>,
109
110 pub(crate) show_rule_types: IndexMap<RulePath, LemmaType>,
113
114 pub(crate) reference_ends: IndexMap<DataPath, ReferenceEnd>,
118
119 pub(crate) input_key_index: IndexMap<String, DataPath>,
122
123 pub(crate) data_leaf: IndexMap<DataPath, NormalFormId>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub(crate) struct ShowDataCache {
132 pub fill: Option<RuleResultValue>,
133 pub suggestion: Option<RuleResultValue>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ExecutableRule {
139 pub path: RulePath,
141
142 pub normal_form: NormalFormId,
144
145 pub source: Source,
147
148 pub rule_type: Arc<LemmaType>,
151
152 pub depends_on_rules: Vec<RulePath>,
155}
156
157impl ExecutableRule {
158 pub fn name(&self) -> &str {
159 &self.path.rule
160 }
161}
162
163pub(crate) fn plan_at<'a>(
166 plans: &'a BTreeMap<EffectiveDate, ExecutionPlan>,
167 instant: &EffectiveDate,
168) -> Option<&'a ExecutionPlan> {
169 plans
170 .range(..=instant.clone())
171 .next_back()
172 .map(|(_, plan)| plan)
173}
174
175pub(crate) fn build_execution_plan(
182 graph: &Graph<'_>,
183 resolved_types: ResolvedSpecTypes,
184 effective: &EffectiveDate,
185 limits: &crate::limits::ResourceLimits,
186 interner: &mut NormalFormInterner,
187) -> Result<ExecutionPlan, Vec<Error>> {
188 let rule_order = graph.rule_order();
189
190 let main_spec = graph.main_spec();
191 let data = graph.build_data(&resolved_types.resolved)?;
192
193 let undetermined_errors: Vec<Error> = data
200 .iter()
201 .filter_map(|(path, definition)| {
202 let (resolved_type, source) = match definition {
203 DataDefinition::TypeDeclaration {
204 resolved_type,
205 source,
206 ..
207 } => (resolved_type, source),
208 DataDefinition::Reference {
209 target: ReferenceTarget::Data(_),
210 resolved_type,
211 source,
212 ..
213 } => (resolved_type, source),
214 DataDefinition::Reference {
215 target: ReferenceTarget::Rule(_),
216 ..
217 }
218 | DataDefinition::Value { .. }
219 | DataDefinition::Import { .. } => return None,
220 };
221 if resolved_type.is_undetermined() {
222 Some(Error::validation(
223 format!("could not determine the type of '{path}'"),
224 Some(source.clone()),
225 None::<String>,
226 ))
227 } else {
228 None
229 }
230 })
231 .collect();
232 if !undetermined_errors.is_empty() {
233 return Err(undetermined_errors);
234 }
235
236 let signature_index =
237 crate::planning::graph::build_signature_index(&main_spec.name, &resolved_types.unit_index)
238 .expect("BUG: signature_index build already validated during resolve_and_validate");
239
240 let family_units = FamilyUnitCatalog::build(&resolved_types.unit_index);
241
242 let reference_ends = graph.reference_ends();
243 let mut rules: IndexMap<RulePath, ExecutableRule> = IndexMap::new();
244 let mut completed_rules: HashMap<RulePath, NormalFormId> = HashMap::new();
245
246 for rule_path in rule_order {
247 let rule_node = graph.rules().get(rule_path).expect(
248 "bug: rule from topological sort not in graph - validation should have caught this",
249 );
250
251 let unit_ctx = UnitResolutionContext::WithIndex(&resolved_types.unit_index);
252 let normalize_ctx = NormalizeContext {
253 data: &data,
254 unit_ctx: &unit_ctx,
255 max_normalized_expression_nodes: limits.max_normalized_expression_nodes,
256 max_normal_form_depth: limits.max_normal_form_depth,
257 };
258 let normalized = crate::planning::normalize::build_normalized_rule(
259 &normalize_ctx,
260 &completed_rules,
261 reference_ends,
262 &rule_node.branches,
263 Some(rule_node.source.clone()),
264 interner,
265 )
266 .map_err(|error| vec![error])?;
267 let NormalizedRule { body } = normalized;
268 completed_rules.insert(rule_path.clone(), body);
269
270 rules.insert(
271 rule_path.clone(),
272 ExecutableRule {
273 path: rule_path.clone(),
274 normal_form: body,
275 source: rule_node.source.clone(),
276 rule_type: Arc::clone(&rule_node.rule_type),
277 depends_on_rules: rule_node.depends_on_rules.iter().cloned().collect(),
278 },
279 );
280 }
281
282 let root_ids: Vec<NormalFormId> = rules.values().map(|rule| rule.normal_form).collect();
283 let (normal_forms, remapped_roots) = interner.extract_reachable(&root_ids);
284 for (rule, remapped) in rules.values_mut().zip(remapped_roots) {
285 rule.normal_form = remapped;
286 }
287
288 let mut plan = ExecutionPlan {
289 spec_name: main_spec.name.clone(),
290 commentary: main_spec.commentary.clone(),
291 data,
292 normal_forms,
293 rules,
294 data_reference_order: graph.data_reference_order().to_vec(),
295 meta: main_spec
296 .meta_fields
297 .iter()
298 .map(|f| (f.key.clone(), f.value.clone()))
299 .collect(),
300 resolved_types,
301 family_units,
302 signature_index,
303 effective: effective.clone(),
304 effective_from: None,
306 effective_to: None,
307 versions: Arc::from(Vec::new().into_boxed_slice()),
308 start_line: 1,
309 source_type: None,
310 needed_by_rules: Vec::new(),
311 data_display: IndexMap::new(),
312 show_rule_types: IndexMap::new(),
313 reference_ends: IndexMap::new(),
315 input_key_index: IndexMap::new(),
316 data_leaf: IndexMap::new(),
317 };
318
319 let mut plan_errors = validate_literal_data_against_types(&plan);
320 if let Err(error) = validate_unit_conversion_targets(&plan) {
321 plan_errors.push(error);
322 }
323 if !plan_errors.is_empty() {
324 return Err(plan_errors);
325 }
326
327 plan.reference_ends = graph.reference_ends().clone();
328 plan.input_key_index = plan
329 .data
330 .keys()
331 .map(|path| (path.input_key(), path.clone()))
332 .collect();
333 plan.data_leaf = ensure_data_leaves(&mut plan.normal_forms, &plan.data);
334 for (path, &id) in &plan.data_leaf {
335 debug_assert_eq!(
336 plan.result_type(id).as_ref(),
337 data_path_result_type(&plan.data, path).as_ref(),
338 "BUG: DataPath leaf result_type drift for {path}"
339 );
340 }
341 plan.needed_by_rules = plan.build_needed_by_rules();
342 Ok(plan)
343}
344
345fn ensure_data_leaves(
350 normal_forms: &mut Vec<NormalForm>,
351 data: &IndexMap<DataPath, DataDefinition>,
352) -> IndexMap<DataPath, NormalFormId> {
353 use crate::planning::normalize::LeafKind;
354 use crate::planning::normalize::NormalFormKind;
355
356 let mut data_leaf: IndexMap<DataPath, NormalFormId> = IndexMap::new();
357 for (index, cell) in normal_forms.iter().enumerate() {
358 if let NormalFormKind::Leaf(LeafKind::DataPath(path)) = &cell.kind {
359 data_leaf.insert(path.clone(), NormalFormId::from_index(index));
360 }
361 }
362 for path in data.keys() {
363 if data_leaf.contains_key(path) {
364 continue;
365 }
366 let id = NormalFormId::from_index(normal_forms.len());
367 normal_forms.push(NormalForm {
368 kind: NormalFormKind::Leaf(LeafKind::DataPath(path.clone())),
369 result_type: data_path_result_type(data, path),
370 source: None,
371 origin: None,
372 rule_embed: None,
373 });
374 data_leaf.insert(path.clone(), id);
375 }
376 data_leaf
377}
378
379pub(crate) fn attach_show_cache(
383 plan: &mut ExecutionPlan,
384 lemma_spec_set: &LemmaSpecSet,
385 spec: &LemmaSpec,
386 versions: &Arc<[ShowVersion]>,
387) {
388 let (effective_from, effective_to) = lemma_spec_set.effective_range(spec);
389 plan.effective_from = effective_from;
390 plan.effective_to = effective_to;
391 plan.versions = Arc::clone(versions);
392 plan.start_line = spec.start_line;
393 plan.source_type = spec.source_type.clone();
394 plan.data_display = build_data_display(plan);
395 plan.show_rule_types = plan
396 .rules
397 .values()
398 .filter(|rule| rule.path.segments.is_empty())
399 .map(|rule| {
400 (
401 rule.path.clone(),
402 plan.family_units
403 .rule_type_for_show(rule.rule_type.as_ref()),
404 )
405 })
406 .collect();
407}
408
409fn build_data_display(plan: &ExecutionPlan) -> IndexMap<DataPath, ShowDataCache> {
410 let mut out = IndexMap::new();
411 for (path, data) in &plan.data {
412 if data.schema_type().is_none() || matches!(data, DataDefinition::Reference { .. }) {
413 continue;
414 }
415 let lemma_type = data
416 .schema_type()
417 .expect("BUG: filter above ensured lemma_type is Some");
418 let input_key = path.input_key();
419 let fill = data.value().map(|literal| {
420 crate::result_value::type_scoped_result_value_from_literal(&literal, lemma_type)
421 .unwrap_or_else(|failure| {
422 panic!(
423 "BUG: show fill value for '{input_key}' failed type_scoped_result_value_from_literal: {}",
424 crate::result_value::rule_result_value_failure_message(failure)
425 )
426 })
427 });
428 let suggestion = data.suggestion().map(|literal| {
429 crate::result_value::type_scoped_result_value_from_literal(&literal, lemma_type)
430 .unwrap_or_else(|failure| {
431 panic!(
432 "BUG: show suggestion value for '{input_key}' failed type_scoped_result_value_from_literal: {}",
433 crate::result_value::rule_result_value_failure_message(failure)
434 )
435 })
436 });
437 if fill.is_some() || suggestion.is_some() {
438 out.insert(path.clone(), ShowDataCache { fill, suggestion });
439 }
440 }
441 out
442}
443
444#[derive(Debug, Clone, PartialEq)]
452pub struct ShowData {
453 pub lemma_type: LemmaType,
454 pub fill: Option<RuleResultValue>,
455 pub suggestion: Option<RuleResultValue>,
456 pub needed_by_rules: Vec<String>,
459}
460
461#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
463pub struct ShowVersion {
464 pub effective_from: Option<crate::parsing::ast::DateTimeValue>,
465 pub effective_to: Option<crate::parsing::ast::DateTimeValue>,
466}
467
468impl std::fmt::Display for ShowVersion {
469 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
470 match (&self.effective_from, &self.effective_to) {
471 (Some(from), Some(to)) => write!(f, "{from} → {to}"),
472 (Some(from), None) => write!(f, "{from} →"),
473 (None, Some(to)) => write!(f, "→ {to}"),
474 (None, None) => write!(f, "—"),
475 }
476 }
477}
478
479#[derive(Debug, Clone, PartialEq)]
483pub struct Show {
484 pub spec: String,
485 pub commentary: Option<String>,
486 pub effective_from: Option<crate::parsing::ast::DateTimeValue>,
487 pub effective_to: Option<crate::parsing::ast::DateTimeValue>,
488 pub versions: Vec<ShowVersion>,
489 pub start_line: usize,
490 pub source_type: Option<crate::parsing::source::SourceType>,
491 pub data: indexmap::IndexMap<String, ShowData>,
492 pub rules: indexmap::IndexMap<String, LemmaType>,
493 pub meta: IndexMap<String, Value>,
495}
496
497impl std::fmt::Display for Show {
498 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499 write!(f, "Spec: {}", self.spec)?;
500
501 if let Some(commentary) = &self.commentary {
502 write!(f, "\n {}", commentary)?;
503 }
504
505 if let Some(from) = &self.effective_from {
506 write!(f, "\n effective_from: {}", from)?;
507 }
508 if let Some(to) = &self.effective_to {
509 write!(f, "\n effective_to: {}", to)?;
510 }
511
512 if self.versions.len() > 1 {
513 let version_strs: Vec<String> = self
514 .versions
515 .iter()
516 .map(|v| match (&v.effective_from, &v.effective_to) {
517 (Some(f), Some(t)) => format!("{f} → {t}"),
518 (Some(f), None) => format!("{f} →"),
519 (None, Some(t)) => format!("→ {t}"),
520 (None, None) => "—".to_string(),
521 })
522 .collect();
523 write!(f, "\n versions: {}", version_strs.join(", "))?;
524 }
525
526 if !self.meta.is_empty() {
527 write!(f, "\n\nMeta:")?;
528 let mut entries: Vec<(&String, &Value)> = self.meta.iter().collect();
529 entries.sort_by_key(|(k, _)| *k);
530 for (key, value) in entries {
531 write!(f, "\n {}: {}", key, value)?;
532 }
533 }
534
535 if !self.data.is_empty() {
536 write!(f, "\n\nData:")?;
537 for (name, entry) in &self.data {
538 write!(f, "\n {} ({})", name, entry.lemma_type.specifications)?;
539 for line in type_detail_lines(&entry.lemma_type.specifications) {
540 write!(f, "\n {}", line)?;
541 }
542 let help = entry.lemma_type.specifications.help();
543 if !help.is_empty() {
544 write!(f, "\n help: {}", help)?;
545 }
546 if let Some(val) = &entry.fill {
547 write!(f, "\n fill: {}", val)?;
548 }
549 if let Some(val) = &entry.suggestion {
550 write!(f, "\n suggestion: {}", val)?;
551 }
552 if !entry.needed_by_rules.is_empty() {
553 write!(
554 f,
555 "\n needed_by_rules: {}",
556 entry.needed_by_rules.join(", ")
557 )?;
558 }
559 }
560 }
561
562 if !self.rules.is_empty() {
563 write!(f, "\n\nRules:")?;
564 for (name, rule_type) in &self.rules {
565 write!(f, "\n {} ({})", name, rule_type.specifications)?;
566 }
567 }
568
569 if self.data.is_empty() && self.rules.is_empty() {
570 write!(f, "\n (no data or rules)")?;
571 }
572
573 Ok(())
574 }
575}
576
577pub fn type_detail_lines(spec: &TypeSpecification) -> Vec<String> {
583 let mut lines = Vec::new();
584 match spec {
585 TypeSpecification::Measure {
586 minimum,
587 maximum,
588 decimals,
589 units,
590 ..
591 } => {
592 let unit_names: Vec<&str> = units.0.iter().map(|u| u.name.as_str()).collect();
593 if !unit_names.is_empty() {
594 lines.push(format!("units: {}", unit_names.join(", ")));
595 }
596 if let Some(d) = decimals {
597 lines.push(format!("decimals: {}", d));
598 }
599 if let Some((magnitude, unit_name)) = minimum {
600 lines.push(format!(
601 "minimum: {} {}",
602 magnitude.display_str(),
603 unit_name
604 ));
605 }
606 if let Some((magnitude, unit_name)) = maximum {
607 lines.push(format!(
608 "maximum: {} {}",
609 magnitude.display_str(),
610 unit_name
611 ));
612 }
613 }
614 TypeSpecification::Number {
615 minimum,
616 maximum,
617 decimals,
618 ..
619 } => {
620 if let Some(d) = decimals {
621 lines.push(format!("decimals: {}", d));
622 }
623 if let Some(v) = minimum {
624 lines.push(format!("minimum: {}", v.display_str()));
625 }
626 if let Some(v) = maximum {
627 lines.push(format!("maximum: {}", v.display_str()));
628 }
629 }
630 TypeSpecification::Ratio {
631 minimum,
632 maximum,
633 decimals,
634 units,
635 ..
636 } => {
637 let unit_names: Vec<&str> = units.0.iter().map(|u| u.name.as_str()).collect();
638 if !unit_names.is_empty() {
639 lines.push(format!("units: {}", unit_names.join(", ")));
640 }
641 if let Some(d) = decimals {
642 lines.push(format!("decimals: {}", d));
643 }
644 if let Some(v) = minimum {
645 lines.push(format!("minimum: {}", v.display_str()));
646 }
647 if let Some(v) = maximum {
648 lines.push(format!("maximum: {}", v.display_str()));
649 }
650 }
651 TypeSpecification::Text {
652 options, length, ..
653 } => {
654 if let Some(l) = length {
655 lines.push(format!("length: {}", l));
656 }
657 if !options.is_empty() {
658 let quoted: Vec<String> = options.iter().map(|o| format!("\"{}\"", o)).collect();
659 lines.push(format!("options: {}", quoted.join(", ")));
660 }
661 }
662 TypeSpecification::Date {
663 minimum, maximum, ..
664 } => {
665 if let Some(v) = minimum {
666 lines.push(format!("minimum: {}", v));
667 }
668 if let Some(v) = maximum {
669 lines.push(format!("maximum: {}", v));
670 }
671 }
672 TypeSpecification::Time {
673 minimum, maximum, ..
674 } => {
675 if let Some(v) = minimum {
676 lines.push(format!("minimum: {}", v));
677 }
678 if let Some(v) = maximum {
679 lines.push(format!("maximum: {}", v));
680 }
681 }
682 TypeSpecification::MeasureRange {
683 lower,
684 upper,
685 minimum,
686 maximum,
687 units,
688 ..
689 } => {
690 let unit_names: Vec<&str> = units.0.iter().map(|u| u.name.as_str()).collect();
691 if !unit_names.is_empty() {
692 lines.push(format!("units: {}", unit_names.join(", ")));
693 }
694 if let Some((magnitude, unit_name)) = lower {
695 lines.push(format!("lower: {} {}", magnitude.display_str(), unit_name));
696 }
697 if let Some((magnitude, unit_name)) = upper {
698 lines.push(format!("upper: {} {}", magnitude.display_str(), unit_name));
699 }
700 if let Some((magnitude, unit_name)) = minimum {
701 lines.push(format!(
702 "minimum: {} {}",
703 magnitude.display_str(),
704 unit_name
705 ));
706 }
707 if let Some((magnitude, unit_name)) = maximum {
708 lines.push(format!(
709 "maximum: {} {}",
710 magnitude.display_str(),
711 unit_name
712 ));
713 }
714 }
715 TypeSpecification::RatioRange {
716 lower,
717 upper,
718 minimum,
719 maximum,
720 units,
721 ..
722 } => {
723 let unit_names: Vec<&str> = units.0.iter().map(|u| u.name.as_str()).collect();
724 if !unit_names.is_empty() {
725 lines.push(format!("units: {}", unit_names.join(", ")));
726 }
727 if let Some(v) = lower {
728 lines.push(format!("lower: {}", v.display_str()));
729 }
730 if let Some(v) = upper {
731 lines.push(format!("upper: {}", v.display_str()));
732 }
733 if let Some(v) = minimum {
734 lines.push(format!("minimum: {}", v.display_str()));
735 }
736 if let Some(v) = maximum {
737 lines.push(format!("maximum: {}", v.display_str()));
738 }
739 }
740 TypeSpecification::NumberRange {
741 lower,
742 upper,
743 minimum,
744 maximum,
745 ..
746 } => {
747 if let Some(v) = lower {
748 lines.push(format!("lower: {}", v.display_str()));
749 }
750 if let Some(v) = upper {
751 lines.push(format!("upper: {}", v.display_str()));
752 }
753 if let Some(v) = minimum {
754 lines.push(format!("minimum: {}", v.display_str()));
755 }
756 if let Some(v) = maximum {
757 lines.push(format!("maximum: {}", v.display_str()));
758 }
759 }
760 TypeSpecification::DateRange {
761 lower,
762 upper,
763 minimum,
764 maximum,
765 ..
766 } => {
767 if let Some(v) = lower {
768 lines.push(format!("lower: {}", v));
769 }
770 if let Some(v) = upper {
771 lines.push(format!("upper: {}", v));
772 }
773 if let Some((magnitude, unit_name)) = minimum {
774 lines.push(format!(
775 "minimum: {} {}",
776 magnitude.display_str(),
777 unit_name
778 ));
779 }
780 if let Some((magnitude, unit_name)) = maximum {
781 lines.push(format!(
782 "maximum: {} {}",
783 magnitude.display_str(),
784 unit_name
785 ));
786 }
787 }
788 TypeSpecification::TimeRange {
789 lower,
790 upper,
791 minimum,
792 maximum,
793 ..
794 } => {
795 if let Some(v) = lower {
796 lines.push(format!("lower: {}", v));
797 }
798 if let Some(v) = upper {
799 lines.push(format!("upper: {}", v));
800 }
801 if let Some((magnitude, unit_name)) = minimum {
802 lines.push(format!(
803 "minimum: {} {}",
804 magnitude.display_str(),
805 unit_name
806 ));
807 }
808 if let Some((magnitude, unit_name)) = maximum {
809 lines.push(format!(
810 "maximum: {} {}",
811 magnitude.display_str(),
812 unit_name
813 ));
814 }
815 }
816 TypeSpecification::Boolean { .. }
817 | TypeSpecification::Veto { .. }
818 | TypeSpecification::Undetermined => {}
819 }
820 lines
821}
822
823impl ExecutionPlan {
824 pub(crate) fn expression_unit_index(&self) -> &crate::planning::unit_index::UnitIndex {
826 &self.resolved_types.unit_index
827 }
828
829 pub fn local_rule_names(&self) -> Vec<String> {
831 self.rules
832 .values()
833 .filter(|r| r.path.segments.is_empty())
834 .map(|r| r.path.rule.clone())
835 .collect()
836 }
837
838 fn build_needed_by_rules(&self) -> Vec<Vec<u32>> {
846 use crate::planning::normalize::{push_child_ids, LeafKind, NormalFormKind};
847
848 let data_len = self.data.len();
849 let words = data_len.div_ceil(64);
850 let mut bits_by_rule: Vec<Vec<u64>> = Vec::with_capacity(self.rules.len());
851
852 for (rule_pos, (_path, rule)) in self.rules.iter().enumerate() {
853 let mut bits = vec![0u64; words];
854 let mut visited = HashSet::new();
855 let mut stack = vec![rule.normal_form];
856 while let Some(id) = stack.pop() {
857 if !visited.insert(id) {
858 continue;
859 }
860 let nf = self.normal_form(id);
861 if let Some(embed_path) = &nf.rule_embed {
862 let embed_pos = self.rules.get_index_of(embed_path).unwrap_or_else(|| {
863 panic!(
864 "BUG: embed target '{embed_path}' missing from plan.rules (rule '{}')",
865 rule.path
866 )
867 });
868 assert!(
869 embed_pos < rule_pos,
870 "BUG: embed target '{embed_path}' must precede '{}' in topo order",
871 rule.path
872 );
873 let dep = &bits_by_rule[embed_pos];
874 for (word, dep_word) in bits.iter_mut().zip(dep.iter()) {
875 *word |= *dep_word;
876 }
877 continue;
878 }
879 match &nf.kind {
880 NormalFormKind::Leaf(LeafKind::DataPath(path)) => {
881 if let Some(target) = self.promptable_data_path(path) {
882 let data_pos = self.data.get_index_of(target).unwrap_or_else(|| {
883 panic!(
884 "BUG: promptable target '{target}' absent from plan.data (rule '{}')",
885 rule.path
886 )
887 });
888 bits[data_pos / 64] |= 1u64 << (data_pos % 64);
889 }
890 }
891 NormalFormKind::OrderedDispatch { .. } => {
892 let origin = nf.origin.unwrap_or_else(|| {
895 panic!(
896 "BUG: non-embed OrderedDispatch must carry origin (rule '{}')",
897 rule.path
898 )
899 });
900 stack.push(origin);
901 }
902 _ => {
903 push_child_ids(&nf.kind, &mut stack);
904 }
905 }
906 }
907 bits_by_rule.push(bits);
908 }
909
910 let mut local: Vec<usize> = self
911 .rules
912 .iter()
913 .enumerate()
914 .filter(|(_, (path, _))| path.segments.is_empty())
915 .map(|(pos, _)| pos)
916 .collect();
917 local.sort_by(|&a, &b| self.rules[a].path.rule.cmp(&self.rules[b].path.rule));
918
919 let mut needed_by_rules = vec![Vec::new(); data_len];
920 for &rule_pos in &local {
921 let rule_id = u32::try_from(rule_pos).expect("BUG: rule count exceeds u32");
922 let bits = &bits_by_rule[rule_pos];
923 for (word_idx, &word) in bits.iter().enumerate() {
924 let mut remaining = word;
925 while remaining != 0 {
926 let bit = remaining.trailing_zeros() as usize;
927 let data_pos = word_idx * 64 + bit;
928 needed_by_rules[data_pos].push(rule_id);
929 remaining &= remaining - 1;
930 }
931 }
932 }
933 needed_by_rules
934 }
935
936 pub(crate) fn promptable_data_path<'a>(&'a self, path: &'a DataPath) -> Option<&'a DataPath> {
942 match self.data.get(path) {
943 Some(DataDefinition::Value { .. } | DataDefinition::TypeDeclaration { .. }) => {
944 Some(path)
945 }
946 Some(DataDefinition::Reference { .. }) => match self.reference_ends.get(path) {
947 Some(ReferenceEnd::Promptable(target)) => Some(target),
948 Some(ReferenceEnd::Rule(_) | ReferenceEnd::Import) => None,
949 None => {
950 panic!("BUG: reference '{path}' missing from reference_ends after planning")
951 }
952 },
953 Some(DataDefinition::Import { .. }) => None,
954 None => panic!("BUG: normal-form DataPath leaf absent from plan.data: {path}"),
955 }
956 }
957
958 pub fn validated_response_rule_names(
962 &self,
963 rules: Option<&[String]>,
964 ) -> Result<std::collections::HashSet<String>, Error> {
965 let Some(rules) = rules else {
966 return Ok(self.local_rule_names().into_iter().collect());
967 };
968 if rules.is_empty() {
969 return Err(Error::request(
970 "at least one rule required".to_string(),
971 None::<String>,
972 ));
973 }
974 let mut names = std::collections::HashSet::new();
975 for rule_name in rules {
976 let rule = self.get_rule(rule_name).ok_or_else(|| {
977 Error::request(
978 format!("Rule '{rule_name}' not found in spec '{}'", self.spec_name),
979 None::<String>,
980 )
981 })?;
982 names.insert(rule.path.rule.clone());
983 }
984 Ok(names)
985 }
986
987 pub fn get_rule(&self, name: &str) -> Option<&ExecutableRule> {
989 let canonical_name = crate::parsing::ast::ascii_lowercase_logical_name(name.to_string());
990 self.rules
991 .values()
992 .find(|r| r.path.rule == canonical_name && r.path.segments.is_empty())
993 }
994
995 pub(crate) fn normal_form(&self, id: NormalFormId) -> &NormalForm {
997 self.normal_forms.get(id.index()).unwrap_or_else(|| {
998 panic!(
999 "BUG: NormalFormId {} out of range (table len {})",
1000 id.index(),
1001 self.normal_forms.len()
1002 )
1003 })
1004 }
1005
1006 pub(crate) fn result_type(&self, id: NormalFormId) -> &Arc<LemmaType> {
1008 &self.normal_form(id).result_type
1009 }
1010
1011 pub(crate) fn promptable_data_paths(&self) -> impl Iterator<Item = &DataPath> {
1023 self.data
1024 .iter()
1025 .filter_map(|(path, definition)| match definition {
1026 DataDefinition::Value { .. } | DataDefinition::TypeDeclaration { .. } => Some(path),
1027 DataDefinition::Reference { .. } | DataDefinition::Import { .. } => None,
1028 })
1029 }
1030}
1031
1032pub(crate) fn reachable_data_paths(
1046 plan: &ExecutionPlan,
1047 root: NormalFormId,
1048 values: &[Option<crate::computation::OperationResult>],
1049) -> IndexSet<DataPath> {
1050 use crate::computation::OperationResult;
1051 use crate::planning::normalize::LeafKind;
1052 use crate::planning::normalize::NormalFormKind;
1053 use crate::planning::ordered_dispatch::{
1054 dispatch_probe_of, region_for_value, DispatchProbeOutcome,
1055 };
1056
1057 fn slot_bool(values: &[Option<OperationResult>], id: NormalFormId) -> Option<bool> {
1058 match values.get(id.index()).and_then(|s| s.as_ref()) {
1059 Some(OperationResult::Value(literal)) => match &literal.value {
1060 ValueKind::Boolean(b) => Some(*b),
1061 _ => None,
1062 },
1063 _ => None,
1064 }
1065 }
1066
1067 fn slot_value_kind(values: &[Option<OperationResult>], id: NormalFormId) -> Option<&ValueKind> {
1068 match values.get(id.index()).and_then(|s| s.as_ref()) {
1069 Some(OperationResult::Value(literal)) => Some(&literal.value),
1070 _ => None,
1071 }
1072 }
1073
1074 fn push_piecewise_live(
1078 arms: &[(NormalFormId, NormalFormId)],
1079 values: &[Option<OperationResult>],
1080 stack: &mut Vec<NormalFormId>,
1081 ) {
1082 assert!(!arms.is_empty(), "BUG: empty piecewise");
1083 let mut taken: Option<usize> = None;
1085 for i in (1..arms.len()).rev() {
1086 match slot_bool(values, arms[i].0) {
1087 Some(true) => {
1088 taken = Some(i);
1089 break;
1090 }
1091 Some(false) => continue,
1092 None => break,
1093 }
1094 }
1095
1096 match taken {
1097 Some(i) => {
1098 stack.push(arms[i].1);
1100 stack.push(arms[i].0);
1101 for (cond, _) in arms.iter().skip(i + 1) {
1102 stack.push(*cond);
1103 }
1104 }
1105 None => {
1106 let default_wins =
1110 (1..arms.len()).all(|i| matches!(slot_bool(values, arms[i].0), Some(false)));
1111 if default_wins {
1112 stack.push(arms[0].1);
1113 for (cond, _) in arms.iter().skip(1) {
1114 stack.push(*cond);
1115 }
1116 } else {
1117 stack.push(arms[0].1);
1118 for (cond, body) in arms.iter().skip(1) {
1119 stack.push(*body);
1120 stack.push(*cond);
1121 }
1122 }
1123 }
1124 }
1125 }
1126
1127 let mut out = IndexSet::new();
1128 let mut visited = HashSet::new();
1129 let mut stack = vec![root];
1130
1131 while let Some(id) = stack.pop() {
1132 if !visited.insert(id) {
1133 continue;
1134 }
1135 let nf = plan.normal_form(id);
1136 match &nf.kind {
1137 NormalFormKind::Leaf(LeafKind::DataPath(path)) => {
1138 out.insert(path.clone());
1139 }
1140 NormalFormKind::Leaf(LeafKind::Literal(_))
1141 | NormalFormKind::Now
1142 | NormalFormKind::Veto(_) => {}
1143 NormalFormKind::Sum(children) | NormalFormKind::Product(children) => {
1144 for child in children.iter().rev() {
1145 stack.push(*child);
1146 }
1147 }
1148 NormalFormKind::And(children) => {
1149 if children.len() >= 2 && matches!(slot_bool(values, children[0]), Some(false)) {
1151 stack.push(children[0]);
1152 } else {
1153 for child in children.iter().rev() {
1154 stack.push(*child);
1155 }
1156 }
1157 }
1158 NormalFormKind::Subtract(a, b)
1159 | NormalFormKind::Divide(a, b)
1160 | NormalFormKind::Power(a, b)
1161 | NormalFormKind::Modulo(a, b)
1162 | NormalFormKind::Comparison(a, _, b)
1163 | NormalFormKind::RangeLiteral(a, b)
1164 | NormalFormKind::RangeContainment(a, b) => {
1165 stack.push(*b);
1166 stack.push(*a);
1167 }
1168 NormalFormKind::Negate(x)
1169 | NormalFormKind::Reciprocal(x)
1170 | NormalFormKind::Not(x)
1171 | NormalFormKind::MathOp(_, x)
1172 | NormalFormKind::UnitConversion(x, _)
1173 | NormalFormKind::DateRelative(_, x)
1174 | NormalFormKind::DateCalendar(_, _, x)
1175 | NormalFormKind::PastFutureRange(_, x)
1176 | NormalFormKind::ResultIsVeto(x) => {
1177 stack.push(*x);
1178 }
1179 NormalFormKind::Piecewise(arms) => {
1180 push_piecewise_live(arms, values, &mut stack);
1181 }
1182 NormalFormKind::OrderedDispatch {
1183 scrutinee,
1184 boundaries,
1185 regions,
1186 } => {
1187 let origin_id = match nf.origin {
1188 Some(origin) => origin,
1189 None => {
1190 let embed_path = nf.rule_embed.as_ref().unwrap_or_else(|| {
1191 panic!("BUG: OrderedDispatch without origin must carry rule_embed")
1192 });
1193 let body_id = plan
1194 .rules
1195 .get(embed_path)
1196 .unwrap_or_else(|| {
1197 panic!("BUG: rule embed '{embed_path}' missing from plan.rules")
1198 })
1199 .normal_form;
1200 plan.normal_form(body_id).origin.unwrap_or_else(|| {
1201 panic!(
1202 "BUG: OrderedDispatch body for '{embed_path}' must have Piecewise origin"
1203 )
1204 })
1205 }
1206 };
1207 let origin_nf = plan.normal_form(origin_id);
1208 let NormalFormKind::Piecewise(arms) = &origin_nf.kind else {
1209 panic!("BUG: OrderedDispatch origin must be Piecewise");
1210 };
1211 visited.insert(origin_id);
1212
1213 stack.push(*scrutinee);
1215
1216 if let Some(scrutinee_kind) = slot_value_kind(values, *scrutinee) {
1217 match dispatch_probe_of(scrutinee_kind) {
1218 DispatchProbeOutcome::Probe(probe) => {
1219 if let Ok(region) = region_for_value(boundaries, &probe) {
1220 let selected = regions[region];
1222 for (cond, body) in arms.iter().skip(1).rev() {
1224 if *body == selected {
1225 stack.push(*body);
1226 stack.push(*cond);
1227 } else {
1229 stack.push(*cond);
1230 }
1231 }
1232 if arms[0].1 == selected {
1233 stack.push(arms[0].1);
1234 }
1235 continue;
1236 }
1237 }
1238 DispatchProbeOutcome::CalendarFailure(_)
1239 | DispatchProbeOutcome::Unsupported => {}
1240 }
1241 }
1242 push_piecewise_live(arms, &[], &mut stack);
1244 }
1245 }
1246 }
1247 out
1248}
1249
1250pub(crate) fn validate_value_against_type(
1251 expected_type: &LemmaType,
1252 value: &LiteralValue,
1253 unit_index: &crate::planning::unit_index::UnitIndex,
1254) -> Result<(), String> {
1255 use crate::computation::rational::{checked_mul, rational_new, try_pow_i32, RationalInteger};
1256 use crate::planning::semantics::TypeSpecification;
1257
1258 fn exceeds_decimal_places(magnitude: &RationalInteger, max_decimals: u8) -> bool {
1259 let scale = match try_pow_i32(&rational_new(10, 1), i32::from(max_decimals)) {
1260 Ok(value) => value,
1261 Err(_) => return true,
1262 };
1263 let scaled = match checked_mul(magnitude, &scale) {
1264 Ok(value) => value,
1265 Err(_) => return true,
1266 };
1267 match RationalInteger::try_reduce_ref(&scaled) {
1268 Ok(reduced) => !reduced.is_integer(),
1269 Err(_) => true,
1270 }
1271 }
1272
1273 fn format_rational_for_validation_message(
1274 expected_type: &crate::planning::semantics::LemmaType,
1275 magnitude: &RationalInteger,
1276 ) -> String {
1277 expected_type
1278 .try_rational_as_decimal_string(magnitude)
1279 .unwrap_or_else(|_| magnitude.display_str())
1280 }
1281
1282 match (&expected_type.specifications, &value.value) {
1283 (
1284 TypeSpecification::Number {
1285 minimum,
1286 maximum,
1287 decimals,
1288 ..
1289 },
1290 ValueKind::Number(n),
1291 ) => {
1292 if let Some(d) = decimals {
1293 if exceeds_decimal_places(n, *d) {
1294 return Err(format!(
1295 "{} exceeds decimals constraint {d}",
1296 n.display_str()
1297 ));
1298 }
1299 }
1300 if let Some(min) = minimum {
1301 if n < min {
1302 return Err(format!(
1303 "{} is below minimum {}",
1304 format_rational_for_validation_message(expected_type, n),
1305 format_rational_for_validation_message(expected_type, min)
1306 ));
1307 }
1308 }
1309 if let Some(max) = maximum {
1310 if n > max {
1311 return Err(format!(
1312 "{} is above maximum {}",
1313 format_rational_for_validation_message(expected_type, n),
1314 format_rational_for_validation_message(expected_type, max)
1315 ));
1316 }
1317 }
1318 Ok(())
1319 }
1320 (
1321 TypeSpecification::Measure {
1322 minimum,
1323 maximum,
1324 decimals,
1325 units,
1326 ..
1327 },
1328 ValueKind::Measure(magnitude),
1329 ) => {
1330 use crate::computation::rational::checked_div;
1331 use crate::planning::semantics::measure_declared_bound_to_canonical;
1332 let unit = expected_type
1333 .measure_binding_unit
1334 .as_deref()
1335 .or_else(|| {
1336 units
1337 .iter()
1338 .find(|u| u.is_canonical_factor())
1339 .or_else(|| units.iter().next())
1340 .map(|u| u.name.as_str())
1341 })
1342 .ok_or_else(|| {
1343 format!(
1344 "measure type '{}' has no declared units for validation",
1345 expected_type.name()
1346 )
1347 })?;
1348 let measure_unit = units.get(unit)?;
1349 let factor = &measure_unit.factor;
1350 let in_unit = checked_div(magnitude, factor).map_err(|failure| {
1351 format!("cannot de-canonicalize measure for validation: {failure}")
1352 })?;
1353 if let Some(d) = decimals {
1354 if exceeds_decimal_places(&in_unit, *d) {
1355 return Err(format!(
1356 "{} {unit} exceeds decimals constraint {d}",
1357 in_unit.display_str()
1358 ));
1359 }
1360 }
1361 if let Some(bound) = minimum {
1362 let canonical_min = measure_declared_bound_to_canonical(
1363 &bound.0,
1364 &bound.1,
1365 units,
1366 expected_type.name().as_str(),
1367 "minimum",
1368 )?;
1369 if magnitude < &canonical_min {
1370 let min_in_unit = checked_div(&canonical_min, factor).map_err(|failure| {
1371 format!("cannot de-canonicalize minimum for validation: {failure}")
1372 })?;
1373 let value_display = format!(
1374 "{} {}",
1375 format_rational_for_validation_message(expected_type, &in_unit),
1376 unit
1377 );
1378 let bound_display = format!(
1379 "{} {}",
1380 format_rational_for_validation_message(expected_type, &min_in_unit),
1381 measure_unit.name
1382 );
1383 return Err(format!("{value_display} is below minimum {bound_display}"));
1384 }
1385 }
1386 if let Some(bound) = maximum {
1387 let canonical_max = measure_declared_bound_to_canonical(
1388 &bound.0,
1389 &bound.1,
1390 units,
1391 expected_type.name().as_str(),
1392 "maximum",
1393 )?;
1394 if magnitude > &canonical_max {
1395 let max_in_unit = checked_div(&canonical_max, factor).map_err(|failure| {
1396 format!("cannot de-canonicalize maximum for validation: {failure}")
1397 })?;
1398 let value_display = format!(
1399 "{} {}",
1400 format_rational_for_validation_message(expected_type, &in_unit),
1401 unit
1402 );
1403 let bound_display = format!(
1404 "{} {}",
1405 format_rational_for_validation_message(expected_type, &max_in_unit),
1406 measure_unit.name
1407 );
1408 return Err(format!("{value_display} is above maximum {bound_display}"));
1409 }
1410 }
1411 Ok(())
1412 }
1413 (
1414 TypeSpecification::Text {
1415 length, options, ..
1416 },
1417 ValueKind::Text(s),
1418 ) => {
1419 let len = s.chars().count();
1420 if let Some(exact) = length {
1421 if len != *exact {
1422 return Err(format!(
1423 "'{}' has length {} but required length is {}",
1424 s, len, exact
1425 ));
1426 }
1427 }
1428 if !options.is_empty() && !options.iter().any(|opt| opt == s) {
1429 return Err(format!(
1430 "'{}' is not in allowed options: {}",
1431 s,
1432 options.join(", ")
1433 ));
1434 }
1435 Ok(())
1436 }
1437 (
1438 TypeSpecification::Ratio {
1439 minimum,
1440 maximum,
1441 decimals,
1442 units,
1443 ..
1444 },
1445 ValueKind::Ratio(r),
1446 ) => {
1447 use crate::computation::rational::checked_mul;
1448
1449 let primary_unit = expected_type
1450 .measure_binding_unit
1451 .as_deref()
1452 .or_else(|| expected_type.ratio_primary_unit());
1453
1454 if let Some(d) = decimals {
1455 let magnitude_for_decimals = match primary_unit {
1456 Some(unit) => {
1457 let ratio_unit = units.get(unit)?;
1458 checked_mul(r, &ratio_unit.value).map_err(|failure| failure.to_string())?
1459 }
1460 None => r.clone(),
1461 };
1462 if exceeds_decimal_places(&magnitude_for_decimals, *d) {
1463 return Err(format!(
1464 "{} exceeds decimals constraint {d}",
1465 magnitude_for_decimals.display_str()
1466 ));
1467 }
1468 }
1469 if let Some(type_minimum) = minimum {
1470 if r < type_minimum {
1471 let message = match primary_unit {
1472 Some(unit) => {
1473 let ratio_unit = units.get(unit)?;
1474 let value_per_unit = checked_mul(r, &ratio_unit.value)
1475 .map_err(|failure| failure.to_string())?;
1476 let bound_per_unit = ratio_unit.minimum.clone().expect(
1477 "BUG: RatioUnit.minimum missing after type minimum set by sync_ratio_units_from_canonical",
1478 );
1479 format!(
1480 "{} {unit} is below minimum {} {unit}",
1481 format_rational_for_validation_message(
1482 expected_type,
1483 &value_per_unit
1484 ),
1485 format_rational_for_validation_message(
1486 expected_type,
1487 &bound_per_unit.clone()
1488 ),
1489 )
1490 }
1491 None => format!(
1492 "{} is below minimum {}",
1493 format_rational_for_validation_message(expected_type, r),
1494 format_rational_for_validation_message(expected_type, type_minimum),
1495 ),
1496 };
1497 return Err(message);
1498 }
1499 }
1500 if let Some(type_maximum) = maximum {
1501 if r > type_maximum {
1502 let message = match primary_unit {
1503 Some(unit) => {
1504 let ratio_unit = units.get(unit)?;
1505 let value_per_unit = checked_mul(r, &ratio_unit.value)
1506 .map_err(|failure| failure.to_string())?;
1507 let bound_per_unit = ratio_unit.maximum.clone().expect(
1508 "BUG: RatioUnit.maximum missing after type maximum set by sync_ratio_units_from_canonical",
1509 );
1510 format!(
1511 "{} {unit} is above maximum {} {unit}",
1512 format_rational_for_validation_message(
1513 expected_type,
1514 &value_per_unit
1515 ),
1516 format_rational_for_validation_message(
1517 expected_type,
1518 &bound_per_unit.clone()
1519 ),
1520 )
1521 }
1522 None => format!(
1523 "{} is above maximum {}",
1524 format_rational_for_validation_message(expected_type, r),
1525 format_rational_for_validation_message(expected_type, type_maximum),
1526 ),
1527 };
1528 return Err(message);
1529 }
1530 }
1531 Ok(())
1532 }
1533 (
1534 TypeSpecification::Ratio {
1535 minimum,
1536 maximum,
1537 decimals,
1538 units: _,
1539 ..
1540 },
1541 ValueKind::Number(n),
1542 ) => {
1543 if let Some(d) = decimals {
1544 if exceeds_decimal_places(n, *d) {
1545 return Err(format!(
1546 "{} exceeds decimals constraint {d}",
1547 n.display_str()
1548 ));
1549 }
1550 }
1551 if let Some(type_minimum) = minimum {
1552 if n < type_minimum {
1553 return Err(format!(
1554 "{} is below minimum {}",
1555 format_rational_for_validation_message(expected_type, n),
1556 format_rational_for_validation_message(expected_type, type_minimum)
1557 ));
1558 }
1559 }
1560 if let Some(type_maximum) = maximum {
1561 if n > type_maximum {
1562 return Err(format!(
1563 "{} is above maximum {}",
1564 format_rational_for_validation_message(expected_type, n),
1565 format_rational_for_validation_message(expected_type, type_maximum)
1566 ));
1567 }
1568 }
1569 Ok(())
1570 }
1571 (
1572 TypeSpecification::Date {
1573 minimum, maximum, ..
1574 },
1575 ValueKind::Date(dt),
1576 ) => {
1577 use crate::planning::semantics::{compare_semantic_dates, date_time_to_semantic};
1578 use std::cmp::Ordering;
1579 if let Some(min) = minimum {
1580 let min_sem = date_time_to_semantic(min);
1581 if compare_semantic_dates(dt, &min_sem) == Ordering::Less {
1582 return Err(format!("{} is below minimum {}", dt, min));
1583 }
1584 }
1585 if let Some(max) = maximum {
1586 let max_sem = date_time_to_semantic(max);
1587 if compare_semantic_dates(dt, &max_sem) == Ordering::Greater {
1588 return Err(format!("{} is above maximum {}", dt, max));
1589 }
1590 }
1591 Ok(())
1592 }
1593 (
1594 TypeSpecification::Time {
1595 minimum, maximum, ..
1596 },
1597 ValueKind::Time(t),
1598 ) => {
1599 use crate::planning::semantics::{compare_semantic_times, time_to_semantic};
1600 use std::cmp::Ordering;
1601 if let Some(min) = minimum {
1602 let min_sem = time_to_semantic(min);
1603 if compare_semantic_times(t, &min_sem) == Ordering::Less {
1604 return Err(format!("{} is below minimum {}", t, min));
1605 }
1606 }
1607 if let Some(max) = maximum {
1608 let max_sem = time_to_semantic(max);
1609 if compare_semantic_times(t, &max_sem) == Ordering::Greater {
1610 return Err(format!("{} is above maximum {}", t, max));
1611 }
1612 }
1613 Ok(())
1614 }
1615 (TypeSpecification::Boolean { .. }, ValueKind::Boolean(_)) => Ok(()),
1616 (
1617 range_spec @ (TypeSpecification::NumberRange { .. }
1618 | TypeSpecification::DateRange { .. }
1619 | TypeSpecification::TimeRange { .. }
1620 | TypeSpecification::MeasureRange { .. }
1621 | TypeSpecification::RatioRange { .. }),
1622 ValueKind::Range(left, right),
1623 ) => validate_range_literal(
1624 expected_type,
1625 range_spec,
1626 left.as_ref(),
1627 right.as_ref(),
1628 unit_index,
1629 ),
1630 (TypeSpecification::Veto { .. }, _) | (TypeSpecification::Undetermined, _) => Ok(()),
1631 (spec, value_kind) if !value_kind_matches_spec(value_kind, spec) => unreachable!(
1632 "BUG: validate_value_against_type called with mismatched type/value: \
1633 spec={:?}, value={:?} — typing must be enforced before validation",
1634 spec, value_kind
1635 ),
1636 (spec, value_kind) => unreachable!(
1637 "BUG: validate_value_against_type missed a value_kind_matches_spec pair: \
1638 spec={:?}, value={:?}",
1639 spec, value_kind
1640 ),
1641 }
1642}
1643
1644fn validate_range_literal(
1645 expected_type: &LemmaType,
1646 range_spec: &TypeSpecification,
1647 left: &LiteralValue,
1648 right: &LiteralValue,
1649 unit_index: &crate::planning::unit_index::UnitIndex,
1650) -> Result<(), String> {
1651 use crate::computation::{comparison_operation, OperationResult, UnitResolutionContext};
1652 use crate::planning::semantics::{
1653 compare_semantic_dates, compare_semantic_times, measure_declared_bound_to_canonical,
1654 ValueKind,
1655 };
1656 use std::cmp::Ordering;
1657 use std::sync::Arc;
1658
1659 let mut element_spec = range_spec
1660 .element_from_range()
1661 .expect("BUG: element_from_range missing arm for validated range");
1662 if let TypeSpecification::Measure {
1663 units,
1664 decomposition,
1665 ..
1666 } = &mut element_spec
1667 {
1668 if decomposition.is_none() && !units.0.is_empty() {
1669 *decomposition = Some([(expected_type.name(), 1i32)].into_iter().collect());
1670 }
1671 }
1672 let element_type = Arc::new(LemmaType::primitive(element_spec));
1673 let left = LiteralValue {
1674 value: left.value.clone(),
1675 };
1676 let right = LiteralValue {
1677 value: right.value.clone(),
1678 };
1679 validate_value_against_type(element_type.as_ref(), &left, unit_index)?;
1680 validate_value_against_type(element_type.as_ref(), &right, unit_index)?;
1681
1682 let ordering = match (&left.value, &right.value) {
1683 (ValueKind::Number(l), ValueKind::Number(r)) => l.cmp(r),
1684 (ValueKind::Date(l), ValueKind::Date(r)) => compare_semantic_dates(l, r),
1685 (ValueKind::Time(l), ValueKind::Time(r)) => compare_semantic_times(l, r),
1686 (ValueKind::Ratio(l), ValueKind::Ratio(r)) => l.cmp(r),
1687 (ValueKind::Measure(l), ValueKind::Measure(r)) => l.cmp(r),
1688 (left_kind, right_kind) => unreachable!(
1689 "BUG: range endpoints have mismatched value kinds after typing: {left_kind:?} vs {right_kind:?}"
1690 ),
1691 };
1692 if ordering == Ordering::Greater {
1693 return Err(format!(
1694 "range left endpoint {left} is above right endpoint {right}"
1695 ));
1696 }
1697
1698 let range_lit = LiteralValue {
1699 value: ValueKind::Range(Box::new(left.clone()), Box::new(right.clone())),
1700 };
1701 let range_type = Arc::new(expected_type.clone());
1702
1703 let compare_width = |bound: &LiteralValue,
1704 bound_type: &Arc<LemmaType>,
1705 op: ComparisonComputation,
1706 fail_msg: String|
1707 -> Result<(), String> {
1708 match comparison_operation(
1709 &range_lit,
1710 &range_type,
1711 &op,
1712 bound,
1713 bound_type,
1714 UnitResolutionContext::WithIndex(unit_index),
1715 ) {
1716 OperationResult::Value(result) => match &result.value {
1717 ValueKind::Boolean(true) => Ok(()),
1718 ValueKind::Boolean(false) => Err(fail_msg),
1719 other => unreachable!("BUG: width comparison must return boolean, got {other:?}"),
1720 },
1721 OperationResult::Veto(veto) => Err(veto.to_string()),
1722 }
1723 };
1724
1725 match range_spec {
1726 TypeSpecification::NumberRange {
1727 minimum, maximum, ..
1728 } => {
1729 if let Some(min_w) = minimum {
1730 compare_width(
1731 &LiteralValue::number(min_w.clone()),
1732 crate::planning::semantics::primitive_number_arc(),
1733 ComparisonComputation::GreaterThanOrEqual,
1734 format!("span is below minimum width {}", min_w.display_str()),
1735 )?;
1736 }
1737 if let Some(max_w) = maximum {
1738 compare_width(
1739 &LiteralValue::number(max_w.clone()),
1740 crate::planning::semantics::primitive_number_arc(),
1741 ComparisonComputation::LessThanOrEqual,
1742 format!("span is above maximum width {}", max_w.display_str()),
1743 )?;
1744 }
1745 }
1746 TypeSpecification::RatioRange {
1747 minimum, maximum, ..
1748 } => {
1749 if let Some(min_w) = minimum {
1750 compare_width(
1751 &LiteralValue::ratio(min_w.clone()),
1752 crate::planning::semantics::primitive_ratio_arc(),
1753 ComparisonComputation::GreaterThanOrEqual,
1754 format!("span is below minimum width {}", min_w.display_str()),
1755 )?;
1756 }
1757 if let Some(max_w) = maximum {
1758 compare_width(
1759 &LiteralValue::ratio(max_w.clone()),
1760 crate::planning::semantics::primitive_ratio_arc(),
1761 ComparisonComputation::LessThanOrEqual,
1762 format!("span is above maximum width {}", max_w.display_str()),
1763 )?;
1764 }
1765 }
1766 TypeSpecification::MeasureRange {
1767 minimum,
1768 maximum,
1769 units,
1770 ..
1771 } => {
1772 if let Some(min_w) = minimum {
1773 let canonical = measure_declared_bound_to_canonical(
1774 &min_w.0, &min_w.1, units, "range", "minimum",
1775 )?;
1776 let bound = LiteralValue::measure_with_type(canonical, Arc::clone(&element_type));
1777 compare_width(
1778 &bound,
1779 &element_type,
1780 ComparisonComputation::GreaterThanOrEqual,
1781 format!(
1782 "span is below minimum width {} {}",
1783 min_w.0.display_str(),
1784 min_w.1
1785 ),
1786 )?;
1787 }
1788 if let Some(max_w) = maximum {
1789 let canonical = measure_declared_bound_to_canonical(
1790 &max_w.0, &max_w.1, units, "range", "maximum",
1791 )?;
1792 let bound = LiteralValue::measure_with_type(canonical, Arc::clone(&element_type));
1793 compare_width(
1794 &bound,
1795 &element_type,
1796 ComparisonComputation::LessThanOrEqual,
1797 format!(
1798 "span is above maximum width {} {}",
1799 max_w.0.display_str(),
1800 max_w.1
1801 ),
1802 )?;
1803 }
1804 }
1805 TypeSpecification::DateRange {
1806 minimum, maximum, ..
1807 }
1808 | TypeSpecification::TimeRange {
1809 minimum, maximum, ..
1810 } => {
1811 let allow_calendar = matches!(range_spec, TypeSpecification::DateRange { .. });
1812 let resolve_bound =
1813 |bound: &(crate::computation::rational::RationalInteger, String),
1814 command: &str|
1815 -> Result<(LiteralValue, Arc<LemmaType>), String> {
1816 let (bare, owner) = unit_index
1817 .resolve(bound.1.as_str())
1818 .map_err(|err| format!("{command} width unit '{}': {err}", bound.1))?;
1819 if allow_calendar {
1820 if !owner.is_duration_like() && !owner.is_calendar_like() {
1821 return Err(format!(
1822 "{command} width unit '{bare}' must be a duration or calendar unit",
1823 ));
1824 }
1825 } else if !owner.is_duration_like() {
1826 return Err(format!(
1827 "{command} width unit '{bare}' must be a duration unit",
1828 ));
1829 }
1830 let TypeSpecification::Measure { units, .. } = &owner.specifications else {
1831 return Err(format!(
1832 "{command} width unit '{bare}' must resolve to a measure type",
1833 ));
1834 };
1835 let type_name = owner.name();
1836 let canonical = measure_declared_bound_to_canonical(
1837 &bound.0,
1838 &bare,
1839 units,
1840 type_name.as_str(),
1841 command,
1842 )?;
1843 Ok((
1844 LiteralValue::measure_with_type(canonical, Arc::clone(&owner)),
1845 Arc::clone(&owner),
1846 ))
1847 };
1848 if let Some(min_w) = minimum {
1849 let (bound, bound_type) = resolve_bound(min_w, "minimum")?;
1850 compare_width(
1851 &bound,
1852 &bound_type,
1853 ComparisonComputation::GreaterThanOrEqual,
1854 format!(
1855 "span is below minimum width {} {}",
1856 min_w.0.display_str(),
1857 min_w.1
1858 ),
1859 )?;
1860 }
1861 if let Some(max_w) = maximum {
1862 let (bound, bound_type) = resolve_bound(max_w, "maximum")?;
1863 compare_width(
1864 &bound,
1865 &bound_type,
1866 ComparisonComputation::LessThanOrEqual,
1867 format!(
1868 "span is above maximum width {} {}",
1869 max_w.0.display_str(),
1870 max_w.1
1871 ),
1872 )?;
1873 }
1874 }
1875 _ => {}
1876 }
1877
1878 Ok(())
1879}
1880
1881fn validate_literal_data_against_types(plan: &ExecutionPlan) -> Vec<Error> {
1882 let mut errors = Vec::new();
1883
1884 for (data_path, data_definition) in &plan.data {
1885 let (expected_type, lit) = match data_definition {
1886 DataDefinition::Value {
1887 value,
1888 resolved_type,
1889 ..
1890 } => (resolved_type, value),
1891 DataDefinition::TypeDeclaration { .. }
1892 | DataDefinition::Import { .. }
1893 | DataDefinition::Reference { .. } => continue,
1894 };
1895
1896 if let Err(msg) =
1897 validate_value_against_type(expected_type, lit, plan.expression_unit_index())
1898 {
1899 let source = data_definition.source().clone();
1900 errors.push(Error::validation(
1901 format!(
1902 "Invalid value for data {} (expected {}): {}",
1903 data_path,
1904 expected_type.name().as_str(),
1905 msg
1906 ),
1907 Some(source),
1908 None::<String>,
1909 ));
1910 }
1911 }
1912
1913 errors
1914}
1915
1916fn validate_unit_conversion_targets(plan: &ExecutionPlan) -> Result<(), Error> {
1917 use crate::planning::normalize::{push_child_ids, NormalFormKind};
1918
1919 let mut errors: Vec<Error> = Vec::new();
1920 let mut visited = HashSet::new();
1921 let mut worklist: Vec<NormalFormId> =
1922 plan.rules.values().map(|rule| rule.normal_form).collect();
1923 while let Some(id) = worklist.pop() {
1924 if !visited.insert(id) {
1925 continue;
1926 }
1927 let nf = plan.normal_form(id);
1928 if let NormalFormKind::UnitConversion(inner, target) = &nf.kind {
1929 if let Some((unit_name, owning_type)) =
1930 crate::computation::units::conversion_target_declares_unit(target)
1931 {
1932 if !crate::computation::units::owning_type_declares_unit_name(
1933 owning_type.as_ref(),
1934 unit_name,
1935 ) {
1936 errors.push(Error::validation(
1937 format!(
1938 "Unit conversion target '{unit_name}' is not declared on owning type '{}'",
1939 owning_type.name()
1940 ),
1941 None::<Source>,
1942 Some(plan.spec_name.clone()),
1943 ));
1944 }
1945 }
1946 worklist.push(*inner);
1947 } else {
1948 push_child_ids(&nf.kind, &mut worklist);
1949 }
1950 }
1951 if let Some(error) = errors.into_iter().next() {
1952 return Err(error);
1953 }
1954 Ok(())
1955}
1956
1957#[cfg(test)]
1958mod tests {
1959 use super::*;
1960 use crate::computation::rational::{rational_new, rational_zero};
1961 use crate::computation::{OperationResult, VetoType};
1962 use crate::evaluation::run_data::RunData;
1963 use crate::literals::DateGranularity;
1964 use crate::literals::TimezoneValue;
1965 use crate::parsing::ast::DateTimeValue;
1966 use crate::planning::semantics::{DataDefinition, DataPath, PathSegment, TypeSpecification};
1967 use crate::Engine;
1968 use crate::{ResourceLimits, RunDataValue};
1969 use serde_json;
1970 use std::collections::HashMap;
1971 use std::str::FromStr;
1972 use std::sync::Arc;
1973
1974 fn default_limits() -> ResourceLimits {
1975 ResourceLimits::default()
1976 }
1977
1978 fn resolve_run_data(plan: &ExecutionPlan, values: HashMap<String, RunDataValue>) -> RunData {
1979 RunData::resolve(plan, values, &default_limits()).expect("resolve")
1980 }
1981
1982 fn veto_reason<'a>(run_data: &'a RunData, path: &DataPath) -> Option<&'a str> {
1983 match run_data.bindings.get(path) {
1984 Some(OperationResult::Veto(veto)) => Some(match veto {
1985 VetoType::Computation { message } => message.as_str(),
1986 other => panic!("expected Computation veto, got {other:?}"),
1987 }),
1988 _ => None,
1989 }
1990 }
1991
1992 fn bound_value<'a>(run_data: &'a RunData, path: &DataPath) -> Option<&'a LiteralValue> {
1993 run_data.bindings.get(path).and_then(OperationResult::value)
1994 }
1995
1996 fn input_data(pairs: &[(&str, &str)]) -> HashMap<String, RunDataValue> {
1997 pairs
1998 .iter()
1999 .map(|(k, v)| (k.to_string(), RunDataValue::string(*v)))
2000 .collect()
2001 }
2002
2003 #[test]
2004 fn test_with_raw_values() {
2005 let mut engine = Engine::new();
2006 engine
2007 .load([(
2008 crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2009 "test.lemma",
2010 ))),
2011 r#"
2012 spec test
2013 data age: number -> suggest 25
2014 "#
2015 .to_string(),
2016 )])
2017 .unwrap();
2018
2019 let plans = engine
2020 .plans
2021 .get_plans(None, "test")
2022 .expect("plans for test");
2023 let plan = plans.values().next().expect("plan");
2024 let data_path = DataPath::new(vec![], "age".to_string());
2025
2026 let values = input_data(&[("age", "30")]);
2027
2028 let run_data = resolve_run_data(plan, values);
2029 let updated_value = bound_value(&run_data, &data_path).expect("bound value");
2030 match &updated_value.value {
2031 crate::planning::semantics::ValueKind::Number(n) => {
2032 assert_eq!(n, &rational_new(30, 1));
2033 }
2034 other => panic!("Expected number literal, got {:?}", other),
2035 }
2036 }
2037
2038 #[test]
2039 fn test_with_raw_values_type_mismatch() {
2040 let mut engine = Engine::new();
2041 engine
2042 .load([(
2043 crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2044 "test.lemma",
2045 ))),
2046 r#"
2047 spec test
2048 data age: number
2049 "#
2050 .to_string(),
2051 )])
2052 .unwrap();
2053
2054 let plans = engine
2055 .plans
2056 .get_plans(None, "test")
2057 .expect("plans for test");
2058 let plan = plans.values().next().expect("plan");
2059
2060 let values = input_data(&[("age", "thirty")]);
2061
2062 let run_data = resolve_run_data(plan, values);
2063 let data_path = DataPath::new(vec![], "age".to_string());
2064 match veto_reason(&run_data, &data_path) {
2065 Some(reason) => {
2066 assert!(
2067 reason.contains("number"),
2068 "type mismatch must record violation reason, got: {reason}"
2069 );
2070 }
2071 None => panic!("expected veto-bound data for age=thirty"),
2072 }
2073 }
2074
2075 #[test]
2076 fn test_with_raw_values_unknown_data_ignored() {
2077 let mut engine = Engine::new();
2078 engine
2079 .load([(
2080 crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2081 "test.lemma",
2082 ))),
2083 r#"
2084 spec test
2085 data known: number
2086 "#
2087 .to_string(),
2088 )])
2089 .unwrap();
2090
2091 let plans = engine
2092 .plans
2093 .get_plans(None, "test")
2094 .expect("plans for test");
2095 let plan = plans.values().next().expect("plan");
2096
2097 let values = input_data(&[("unknown", "30")]);
2098
2099 let run_data = resolve_run_data(plan, values);
2100 assert!(run_data.bindings.is_empty());
2101 assert!(run_data.ignored_unknown.iter().any(|k| k == "unknown"));
2102 }
2103
2104 #[test]
2105 fn test_with_raw_values_nested() {
2106 let mut engine = Engine::new();
2107 engine
2108 .load([(
2109 crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2110 "test.lemma",
2111 ))),
2112 r#"
2113 spec private
2114 data base_price: number
2115
2116 spec test
2117 uses rules: private
2118 "#
2119 .to_string(),
2120 )])
2121 .unwrap();
2122
2123 let plans = engine
2124 .plans
2125 .get_plans(None, "test")
2126 .expect("plans for test");
2127 let plan = plans.values().next().expect("plan");
2128
2129 let values = input_data(&[("rules.base_price", "100")]);
2130
2131 let run_data = resolve_run_data(plan, values);
2132 let data_path = DataPath {
2133 segments: vec![PathSegment {
2134 data: "rules".to_string(),
2135 spec: "private".to_string(),
2136 }],
2137 data: "base_price".to_string(),
2138 };
2139 let updated_value = bound_value(&run_data, &data_path).expect("bound value");
2140 match &updated_value.value {
2141 crate::planning::semantics::ValueKind::Number(n) => {
2142 assert_eq!(n, &rational_new(100, 1));
2143 }
2144 other => panic!("Expected number literal, got {:?}", other),
2145 }
2146 }
2147
2148 #[test]
2149 fn run_data_should_enforce_number_maximum_constraint() {
2150 let data_path = DataPath::new(vec![], "x".to_string());
2153
2154 let max10 = crate::planning::semantics::LemmaType::primitive(
2155 crate::planning::semantics::TypeSpecification::Number {
2156 minimum: None,
2157 maximum: Some(rational_new(10, 1)),
2158 decimals: None,
2159 help: String::new(),
2160 },
2161 );
2162 let source = Source::new(
2163 crate::parsing::source::SourceType::Volatile,
2164 crate::parsing::ast::Span {
2165 start: 0,
2166 end: 0,
2167 line: 1,
2168 col: 0,
2169 },
2170 );
2171 let mut data = IndexMap::new();
2172 data.insert(
2173 data_path.clone(),
2174 crate::planning::semantics::DataDefinition::Value {
2175 value: crate::planning::semantics::LiteralValue::number_with_type(
2176 rational_new(0, 1),
2177 Arc::new(max10.clone()),
2178 ),
2179 resolved_type: Arc::new(max10.clone()),
2180 source: source.clone(),
2181 },
2182 );
2183
2184 let input_key_index = data.keys().map(|p| (p.input_key(), p.clone())).collect();
2185 let data_len = data.len();
2186 let plan = ExecutionPlan {
2187 spec_name: "test".to_string(),
2188 commentary: None,
2189 data,
2190 normal_forms: Vec::new(),
2191 rules: IndexMap::new(),
2192 data_reference_order: Vec::new(),
2193 meta: IndexMap::new(),
2194 resolved_types: ResolvedSpecTypes::default(),
2195 family_units: FamilyUnitCatalog::default(),
2196 signature_index: IndexMap::new(),
2197 effective: EffectiveDate::Origin,
2198 effective_from: None,
2199 effective_to: None,
2200 versions: std::sync::Arc::from([]),
2201 start_line: 1,
2202 source_type: None,
2203 needed_by_rules: vec![Vec::new(); data_len],
2204 data_display: IndexMap::new(),
2205 show_rule_types: IndexMap::new(),
2206 reference_ends: IndexMap::new(),
2207 input_key_index,
2208 data_leaf: IndexMap::new(),
2209 };
2210
2211 let values = input_data(&[("x", "11")]);
2212
2213 let run_data = resolve_run_data(&plan, values);
2214 match veto_reason(&run_data, &data_path) {
2215 Some(reason) => {
2216 assert!(
2217 reason.contains("maximum") || reason.contains("10"),
2218 "x=11 must violate maximum 10, got: {reason}"
2219 );
2220 }
2221 None => panic!("expected veto-bound data for x=11"),
2222 }
2223 }
2224
2225 #[test]
2226 fn run_data_should_enforce_text_enum_options() {
2227 let data_path = DataPath::new(vec![], "tier".to_string());
2229
2230 let tier = crate::planning::semantics::LemmaType::primitive(
2231 crate::planning::semantics::TypeSpecification::Text {
2232 length: None,
2233 options: vec!["silver".to_string(), "gold".to_string()],
2234 help: String::new(),
2235 },
2236 );
2237 let source = Source::new(
2238 crate::parsing::source::SourceType::Volatile,
2239 crate::parsing::ast::Span {
2240 start: 0,
2241 end: 0,
2242 line: 1,
2243 col: 0,
2244 },
2245 );
2246 let mut data = IndexMap::new();
2247 data.insert(
2248 data_path.clone(),
2249 crate::planning::semantics::DataDefinition::Value {
2250 value: crate::planning::semantics::LiteralValue::text_with_type(
2251 "silver".to_string(),
2252 Arc::new(tier.clone()),
2253 ),
2254 resolved_type: Arc::new(tier.clone()),
2255 source,
2256 },
2257 );
2258
2259 let input_key_index = data.keys().map(|p| (p.input_key(), p.clone())).collect();
2260 let data_len = data.len();
2261 let plan = ExecutionPlan {
2262 spec_name: "test".to_string(),
2263 commentary: None,
2264 data,
2265 normal_forms: Vec::new(),
2266 rules: IndexMap::new(),
2267 data_reference_order: Vec::new(),
2268 meta: IndexMap::new(),
2269 resolved_types: ResolvedSpecTypes::default(),
2270 family_units: FamilyUnitCatalog::default(),
2271 signature_index: IndexMap::new(),
2272 effective: EffectiveDate::Origin,
2273 effective_from: None,
2274 effective_to: None,
2275 versions: std::sync::Arc::from([]),
2276 start_line: 1,
2277 source_type: None,
2278 needed_by_rules: vec![Vec::new(); data_len],
2279 data_display: IndexMap::new(),
2280 show_rule_types: IndexMap::new(),
2281 reference_ends: IndexMap::new(),
2282 input_key_index,
2283 data_leaf: IndexMap::new(),
2284 };
2285
2286 let values = input_data(&[("tier", "platinum")]);
2287
2288 let run_data = resolve_run_data(&plan, values);
2289 match veto_reason(&run_data, &data_path) {
2290 Some(reason) => {
2291 assert!(
2292 reason.contains("allowed options") || reason.contains("platinum"),
2293 "invalid enum must record violation, got: {reason}"
2294 );
2295 }
2296 None => panic!("expected veto-bound data for tier=platinum"),
2297 }
2298 }
2299
2300 #[test]
2301 fn run_data_should_enforce_measure_decimals() {
2302 let data_path = DataPath::new(vec![], "price".to_string());
2305
2306 let money = crate::planning::semantics::LemmaType::primitive(
2307 crate::planning::semantics::TypeSpecification::Measure {
2308 minimum: None,
2309 maximum: None,
2310 decimals: Some(2),
2311 units: crate::planning::semantics::MeasureUnits::from(vec![
2312 crate::planning::semantics::MeasureUnit::from_decimal_factor(
2313 "eur".to_string(),
2314 rust_decimal::Decimal::from_str("1.0").unwrap(),
2315 Vec::new(),
2316 )
2317 .expect("eur unit factor must be exact decimal"),
2318 ]),
2319 traits: Vec::new(),
2320 decomposition: None,
2321 help: String::new(),
2322 },
2323 );
2324 let source = Source::new(
2325 crate::parsing::source::SourceType::Volatile,
2326 crate::parsing::ast::Span {
2327 start: 0,
2328 end: 0,
2329 line: 1,
2330 col: 0,
2331 },
2332 );
2333 let mut data = IndexMap::new();
2334 data.insert(
2335 data_path.clone(),
2336 crate::planning::semantics::DataDefinition::Value {
2337 value: crate::planning::semantics::LiteralValue::measure_with_type(
2338 rational_zero(),
2339 Arc::new(money.clone()),
2340 ),
2341 resolved_type: Arc::new(money.clone()),
2342 source,
2343 },
2344 );
2345
2346 let input_key_index = data.keys().map(|p| (p.input_key(), p.clone())).collect();
2347 let data_len = data.len();
2348 let plan = ExecutionPlan {
2349 spec_name: "test".to_string(),
2350 commentary: None,
2351 data,
2352 normal_forms: Vec::new(),
2353 rules: IndexMap::new(),
2354 data_reference_order: Vec::new(),
2355 meta: IndexMap::new(),
2356 resolved_types: ResolvedSpecTypes::default(),
2357 family_units: FamilyUnitCatalog::default(),
2358 signature_index: IndexMap::new(),
2359 effective: EffectiveDate::Origin,
2360 effective_from: None,
2361 effective_to: None,
2362 versions: std::sync::Arc::from([]),
2363 start_line: 1,
2364 source_type: None,
2365 needed_by_rules: vec![Vec::new(); data_len],
2366 data_display: IndexMap::new(),
2367 show_rule_types: IndexMap::new(),
2368 reference_ends: IndexMap::new(),
2369 input_key_index,
2370 data_leaf: IndexMap::new(),
2371 };
2372
2373 let values = input_data(&[("price", "1.234 eur")]);
2374
2375 let run_data = resolve_run_data(&plan, values);
2376 match veto_reason(&run_data, &data_path) {
2377 Some(reason) => {
2378 assert!(
2379 reason.contains("decimals") || reason.contains("decimal"),
2380 "1.234 eur must violate decimals=2, got: {reason}"
2381 );
2382 }
2383 None => panic!("expected veto-bound data for price=1.234 eur"),
2384 }
2385 }
2386
2387 fn empty_plan(effective: crate::parsing::ast::EffectiveDate) -> ExecutionPlan {
2388 ExecutionPlan {
2389 spec_name: "s".into(),
2390 commentary: None,
2391 data: IndexMap::new(),
2392 normal_forms: Vec::new(),
2393 rules: IndexMap::new(),
2394 data_reference_order: Vec::new(),
2395 meta: IndexMap::new(),
2396 resolved_types: ResolvedSpecTypes::default(),
2397 family_units: FamilyUnitCatalog::default(),
2398 signature_index: IndexMap::new(),
2399 effective,
2400 effective_from: None,
2401 effective_to: None,
2402 versions: std::sync::Arc::from([]),
2403 start_line: 1,
2404 source_type: None,
2405 needed_by_rules: Vec::new(),
2406 data_display: IndexMap::new(),
2407 show_rule_types: IndexMap::new(),
2408 reference_ends: IndexMap::new(),
2409 input_key_index: IndexMap::new(),
2410 data_leaf: IndexMap::new(),
2411 }
2412 }
2413
2414 fn plans_by_effective(
2415 plans: impl IntoIterator<Item = ExecutionPlan>,
2416 ) -> BTreeMap<EffectiveDate, ExecutionPlan> {
2417 plans
2418 .into_iter()
2419 .map(|plan| (plan.effective.clone(), plan))
2420 .collect()
2421 }
2422
2423 #[test]
2425 fn plan_set_plans_are_in_ascending_effective_order() {
2426 let june = DateTimeValue {
2427 year: 2025,
2428 month: 6,
2429 day: 1,
2430 hour: 0,
2431 minute: 0,
2432 second: 0,
2433 microsecond: 0,
2434 timezone: None,
2435 granularity: DateGranularity::Full,
2436 };
2437 let dec = DateTimeValue {
2438 year: 2025,
2439 month: 12,
2440 day: 1,
2441 hour: 0,
2442 minute: 0,
2443 second: 0,
2444 microsecond: 0,
2445 timezone: None,
2446 granularity: DateGranularity::Full,
2447 };
2448
2449 let plans = plans_by_effective([
2450 empty_plan(EffectiveDate::Origin),
2451 empty_plan(EffectiveDate::DateTimeValue(june)),
2452 empty_plan(EffectiveDate::DateTimeValue(dec)),
2453 ]);
2454
2455 let effectives: Vec<_> = plans.keys().cloned().collect();
2456 for window in effectives.windows(2) {
2457 assert!(
2458 window[0] < window[1],
2459 "plans must be strictly ascending: {:?} >= {:?}",
2460 window[0],
2461 window[1]
2462 );
2463 }
2464 }
2465
2466 #[test]
2467 fn plan_at_exact_boundary_selects_later_slice() {
2468 use crate::parsing::ast::{DateTimeValue, EffectiveDate};
2469
2470 let june = DateTimeValue {
2471 year: 2025,
2472 month: 6,
2473 day: 1,
2474 hour: 0,
2475 minute: 0,
2476 second: 0,
2477 microsecond: 0,
2478 timezone: None,
2479
2480 granularity: DateGranularity::Full,
2481 };
2482 let dec = DateTimeValue {
2483 year: 2025,
2484 month: 12,
2485 day: 1,
2486 hour: 0,
2487 minute: 0,
2488 second: 0,
2489 microsecond: 0,
2490 timezone: None,
2491
2492 granularity: DateGranularity::Full,
2493 };
2494
2495 let june_key = EffectiveDate::DateTimeValue(june.clone());
2496 let dec_key = EffectiveDate::DateTimeValue(dec.clone());
2497 let plans = plans_by_effective([
2498 empty_plan(EffectiveDate::Origin),
2499 empty_plan(june_key.clone()),
2500 empty_plan(dec_key.clone()),
2501 ]);
2502
2503 let june_plan = plan_at(&plans, &june_key).expect("boundary instant");
2504 assert!(std::ptr::eq(
2505 june_plan,
2506 plans.get(&june_key).expect("june slice")
2507 ));
2508
2509 let dec_plan = plan_at(&plans, &dec_key).expect("dec boundary");
2510 assert!(std::ptr::eq(
2511 dec_plan,
2512 plans.get(&dec_key).expect("dec slice")
2513 ));
2514 }
2515
2516 #[test]
2517 fn plan_at_day_before_boundary_stays_in_earlier_slice() {
2518 use crate::parsing::ast::{DateTimeValue, EffectiveDate};
2519
2520 let june = DateTimeValue {
2521 year: 2025,
2522 month: 6,
2523 day: 1,
2524 hour: 0,
2525 minute: 0,
2526 second: 0,
2527 microsecond: 0,
2528 timezone: None,
2529
2530 granularity: DateGranularity::Full,
2531 };
2532 let may_end = DateTimeValue {
2533 year: 2025,
2534 month: 5,
2535 day: 31,
2536 hour: 23,
2537 minute: 59,
2538 second: 59,
2539 microsecond: 0,
2540 timezone: None,
2541
2542 granularity: DateGranularity::DateTime,
2543 };
2544
2545 let origin = EffectiveDate::Origin;
2546 let plans = plans_by_effective([
2547 empty_plan(origin.clone()),
2548 empty_plan(EffectiveDate::DateTimeValue(june)),
2549 ]);
2550
2551 let may_instant = EffectiveDate::DateTimeValue(may_end);
2552 let may_plan = plan_at(&plans, &may_instant).expect("may 31");
2553 assert!(std::ptr::eq(
2554 may_plan,
2555 plans.get(&origin).expect("origin slice")
2556 ));
2557 }
2558
2559 #[test]
2560 fn plan_at_single_plan_matches_any_instant_after_start() {
2561 use crate::parsing::ast::{DateTimeValue, EffectiveDate};
2562
2563 let t = DateTimeValue {
2564 year: 2025,
2565 month: 3,
2566 day: 1,
2567 hour: 0,
2568 minute: 0,
2569 second: 0,
2570 microsecond: 0,
2571 timezone: None,
2572
2573 granularity: DateGranularity::Full,
2574 };
2575 let start = EffectiveDate::DateTimeValue(DateTimeValue {
2576 year: 2025,
2577 month: 1,
2578 day: 1,
2579 hour: 0,
2580 minute: 0,
2581 second: 0,
2582 microsecond: 0,
2583 timezone: None,
2584
2585 granularity: DateGranularity::Full,
2586 });
2587 let plans = plans_by_effective([empty_plan(start.clone())]);
2588 let instant = EffectiveDate::DateTimeValue(t);
2589 let selected = plan_at(&plans, &instant).expect("inside single slice");
2590 assert!(std::ptr::eq(
2591 selected,
2592 plans.get(&start).expect("single slice")
2593 ));
2594 }
2595
2596 #[test]
2599 fn show_json_shape_contract() {
2600 let mut engine = Engine::new();
2601 engine
2602 .load([(
2603 crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2604 "test.lemma",
2605 ))),
2606 r#"
2607 spec pricing
2608 data bridge_height: measure
2609 -> unit meter: 1
2610 -> suggest 100 meter
2611 data quantity: number -> minimum 0
2612 rule cost: bridge_height * quantity
2613 "#
2614 .to_string(),
2615 )])
2616 .unwrap();
2617 let now = DateTimeValue::now();
2618 let schema = engine.show(None, "pricing", Some(&now)).unwrap();
2619
2620 let value: serde_json::Value =
2621 serde_json::to_value(crate::api::Show::from(&schema)).unwrap();
2622
2623 let bh = &value["data"]["bridge_height"];
2624 assert!(
2625 bh.is_object(),
2626 "data entry must be a named object, not tuple"
2627 );
2628 assert!(
2629 bh.get("type").is_some(),
2630 "data entry must expose `type` field"
2631 );
2632 assert!(
2633 bh.get("suggestion").is_some(),
2634 "bridge_height exposes `-> suggest` as schema suggestion"
2635 );
2636 assert!(
2637 bh.get("fill").is_none(),
2638 "bridge_height is not filled from spec"
2639 );
2640
2641 let ty = &bh["type"];
2642 assert_eq!(
2643 ty["kind"], "measure",
2644 "kind tag sits on the type object itself"
2645 );
2646 assert!(
2647 ty["units"].is_array(),
2648 "measure-only fields flatten up to top level"
2649 );
2650 assert!(
2651 ty.get("options").is_none(),
2652 "text-only fields must not leak"
2653 );
2654
2655 let quantity = &value["data"]["quantity"];
2656 assert_eq!(quantity["type"]["kind"], "number");
2657 assert!(
2658 quantity.get("suggestion").is_none(),
2659 "quantity has no suggestion"
2660 );
2661 assert!(
2662 quantity.get("fill").is_none(),
2663 "quantity has no fill literal"
2664 );
2665
2666 let cost = &value["rules"]["cost"];
2667 assert_eq!(
2668 cost["kind"], "measure",
2669 "rule types use the same flat shape"
2670 );
2671 assert!(
2672 cost["units"].is_array() && !cost["units"].as_array().unwrap().is_empty(),
2673 "measure rule result types expose declared units"
2674 );
2675 assert!(
2676 cost["units"][0].get("factor").is_some(),
2677 "measure rule units use factor field"
2678 );
2679 }
2680
2681 #[test]
2682 fn show_rule_result_units_contract() {
2683 let mut engine = Engine::new();
2684 engine
2685 .load([(
2686 crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2687 "units_contract.lemma",
2688 ))),
2689 r#"
2690 spec units_contract
2691 data money: measure
2692 -> unit eur: 1
2693 -> unit usd: 0.91
2694 data rate: ratio
2695 -> unit basis_points: 10000
2696 -> unit percent: 100
2697 -> suggest 500 basis_points
2698 rule total: money
2699 rule rate_out: rate
2700 "#
2701 .to_string(),
2702 )])
2703 .unwrap();
2704 let now = DateTimeValue::now();
2705 let schema = engine.show(None, "units_contract", Some(&now)).unwrap();
2706 let value: serde_json::Value =
2707 serde_json::to_value(crate::api::Show::from(&schema)).unwrap();
2708
2709 let money_units = &value["data"]["money"]["type"]["units"];
2710 assert!(money_units.is_array() && !money_units.as_array().unwrap().is_empty());
2711 assert!(money_units[0].get("name").is_some());
2712 assert!(money_units[0].get("factor").is_some());
2713 assert!(money_units[0]["factor"].get("numer").is_some());
2714 assert!(money_units[0]["factor"].get("denom").is_some());
2715
2716 let rate_units = &value["data"]["rate"]["type"]["units"];
2717 assert!(rate_units.is_array() && !rate_units.as_array().unwrap().is_empty());
2718 assert!(rate_units[0].get("name").is_some());
2719 assert!(rate_units[0].get("value").is_some());
2720 assert!(rate_units[0]["value"].get("numer").is_some());
2721 assert!(rate_units[0]["value"].get("denom").is_some());
2722
2723 let total_rule_units = &value["rules"]["total"]["units"];
2724 let money_unit_names: Vec<_> = money_units
2725 .as_array()
2726 .unwrap()
2727 .iter()
2728 .map(|u| u["name"].as_str().unwrap())
2729 .collect();
2730 let total_rule_unit_names: Vec<_> = total_rule_units
2731 .as_array()
2732 .unwrap()
2733 .iter()
2734 .map(|u| u["name"].as_str().unwrap())
2735 .collect();
2736 assert_eq!(total_rule_unit_names, money_unit_names);
2737
2738 let rate_out_rule_units = &value["rules"]["rate_out"]["units"];
2739 let rate_unit_names: Vec<_> = rate_units
2740 .as_array()
2741 .unwrap()
2742 .iter()
2743 .map(|u| u["name"].as_str().unwrap())
2744 .collect();
2745 let rate_out_rule_unit_names: Vec<_> = rate_out_rule_units
2746 .as_array()
2747 .unwrap()
2748 .iter()
2749 .map(|u| u["name"].as_str().unwrap())
2750 .collect();
2751 assert_eq!(rate_out_rule_unit_names, rate_unit_names);
2752 }
2753
2754 #[test]
2755 fn show_json_round_trip_preserves_shape() {
2756 let mut engine = Engine::new();
2757 engine
2758 .load([(
2759 crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("s.lemma"))),
2760 r#"
2761 spec s
2762 data age: number -> minimum 0 -> suggest 18
2763 data grade: text -> options "A" "B" "C"
2764 rule adult: age >= 18
2765 "#
2766 .to_string(),
2767 )])
2768 .unwrap();
2769 let now = DateTimeValue::now();
2770 let schema = engine.show(None, "s", Some(&now)).unwrap();
2771
2772 let api_show = crate::api::Show::from(&schema);
2773 let json = serde_json::to_string(&api_show).unwrap();
2774 let round_tripped: crate::api::Show = serde_json::from_str(&json).unwrap();
2775 assert_eq!(api_show, round_tripped);
2776 }
2777
2778 const COST_PRICE_SPEC: &str = r#"
2779spec cost_price
2780uses lemma units
2781
2782data money: measure
2783 -> unit eur: 1.00
2784 -> unit inr: 0.0092
2785 -> decimals 2
2786
2787data labor_cost: measure
2788 -> unit eur_per_hour: eur/hour
2789 -> unit inr_per_hour: inr/hour
2790 -> suggest 25 eur_per_hour
2791
2792data product_cost: measure
2793 -> unit eur_per_kg: eur/kilogram
2794 -> unit inr_per_kg: inr/kilogram
2795 -> suggest 4 eur_per_kg
2796
2797data throughput: measure
2798 -> unit kg_per_hour: kilogram/hour
2799 -> suggest 12 kg_per_hour
2800
2801rule cost_price: product_cost + labor_cost / throughput
2802"#;
2803
2804 fn cost_price_inputs() -> HashMap<String, RunDataValue> {
2805 let mut data = HashMap::new();
2806 data.insert("product_cost".into(), RunDataValue::string("4 eur_per_kg"));
2807 data.insert("labor_cost".into(), RunDataValue::string("25 eur_per_hour"));
2808 data.insert("throughput".into(), RunDataValue::string("12 kg_per_hour"));
2809 data
2810 }
2811
2812 const FILM_ACCESS: &str = r#"
2813spec premium_membership
2814uses lemma units
2815data start: date
2816data length: units.calendar
2817rule valid: now in start...start + length
2818
2819spec film_access
2820uses premium_membership
2821data type: text
2822 -> option "rental"
2823 -> option "purchase"
2824data views_consumed: number
2825data premium_member: boolean
2826rule max_views: 3
2827 unless premium_membership.valid then 10
2828 unless premium_member then 5
2829rule can_view: no
2830 unless type is "rental" and views_consumed < max_views then yes
2831 unless type is "purchase" then yes
2832"#;
2833
2834 fn film_access_effective() -> DateTimeValue {
2835 DateTimeValue {
2836 year: 2027,
2837 month: 2,
2838 day: 14,
2839 hour: 12,
2840 minute: 0,
2841 second: 0,
2842 microsecond: 0,
2843 timezone: Some(TimezoneValue {
2844 offset_hours: 0,
2845 offset_minutes: 0,
2846 }),
2847 granularity: DateGranularity::DateTime,
2848 }
2849 }
2850
2851 #[test]
2852 fn run_data_accepts_per_unit_measure_equivalent_to_canonical_magnitude() {
2853 let mut engine = Engine::new();
2854 engine
2855 .load([(
2856 crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2857 "cost_price.lemma",
2858 ))),
2859 COST_PRICE_SPEC.to_string(),
2860 )])
2861 .expect("load");
2862 let plans = engine
2863 .plans
2864 .get_plans(None, "cost_price")
2865 .expect("plans for cost_price");
2866 let plan = plans.values().next().expect("plan");
2867 let mut data = HashMap::new();
2868 data.insert("product_cost".into(), RunDataValue::string("4 eur_per_kg"));
2869 data.insert(
2870 "labor_cost".into(),
2871 RunDataValue::string("0.0069444444444444444444444444 eur_per_hour"),
2872 );
2873 data.insert(
2874 "throughput".into(),
2875 RunDataValue::string("0.0033333333333333333333333333 kg_per_hour"),
2876 );
2877 let run_data = resolve_run_data(plan, data);
2878 assert!(
2879 !run_data
2880 .bindings
2881 .values()
2882 .any(|b| matches!(b, OperationResult::Veto(_))),
2883 "parsed decimal run data values must not be veto-bound after input boundary: {:?}",
2884 run_data.bindings
2885 );
2886 }
2887
2888 #[test]
2889 fn run_data_accepts_per_unit_measure() {
2890 let mut engine = Engine::new();
2891 engine
2892 .load([(
2893 crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2894 "cost_price.lemma",
2895 ))),
2896 COST_PRICE_SPEC.to_string(),
2897 )])
2898 .expect("load");
2899 let plans = engine
2900 .plans
2901 .get_plans(None, "cost_price")
2902 .expect("plans for cost_price");
2903 let plan = plans.values().next().expect("plan");
2904 let run_data = resolve_run_data(plan, cost_price_inputs());
2905 assert!(!run_data
2906 .bindings
2907 .values()
2908 .any(|b| matches!(b, OperationResult::Veto(_))));
2909 }
2910
2911 #[test]
2912 fn run_data_rejects_oversize_input() {
2913 let mut engine = Engine::new();
2914 engine
2915 .load([(
2916 crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
2917 "cost_price.lemma",
2918 ))),
2919 COST_PRICE_SPEC.to_string(),
2920 )])
2921 .expect("load");
2922 let plans = engine
2923 .plans
2924 .get_plans(None, "cost_price")
2925 .expect("plans for cost_price");
2926 let plan = plans.values().next().expect("plan");
2927 let mut data = cost_price_inputs();
2928 data.insert(
2929 "labor_cost".into(),
2930 RunDataValue::string(
2931 "1000000000000000000000000000000000000000000000000000000000000 eur_per_hour",
2932 ),
2933 );
2934 let run_data = resolve_run_data(plan, data);
2935 assert!(matches!(
2936 run_data.bindings.get(&DataPath::local("labor_cost".into())),
2937 Some(OperationResult::Veto(_))
2938 ));
2939 }
2940 #[test]
2941 fn typedecl_default_stays_typedecl_on_immutable_plan() {
2942 let mut engine = Engine::new();
2943 engine
2944 .load([(
2945 crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("s.lemma"))),
2946 r#"
2947 spec s
2948 data n: number -> suggest 42
2949 rule r: n
2950 "#
2951 .to_string(),
2952 )])
2953 .expect("load");
2954
2955 let plans = engine.plans.get_plans(None, "s").expect("plans for s");
2956 let plan = plans.values().next().expect("plan");
2957 let path = DataPath::local("n".into());
2958 match plan.data.get(&path).expect("n") {
2959 DataDefinition::TypeDeclaration {
2960 declared_suggestion: Some(_),
2961 ..
2962 } => {}
2963 other => panic!("expected TypeDeclaration with default, got {other:?}"),
2964 }
2965 }
2966
2967 fn response_missing_data_union(response: &crate::Response) -> Vec<String> {
2968 let mut seen = std::collections::HashSet::new();
2969 let mut names = Vec::new();
2970 for result in response.results.values() {
2971 for key in result.missing_data() {
2972 if seen.insert(key.clone()) {
2973 names.push(key.clone());
2974 }
2975 }
2976 }
2977 names
2978 }
2979
2980 #[test]
2981 fn run_prunes_inactive_nut_branches_for_total_price() {
2982 let code = r#"
2983spec bag
2984uses lemma units
2985
2986data weight: measure
2987 -> unit kg: 1
2988
2989data money: measure
2990 -> unit eur: 1
2991
2992data price_per_weight: measure
2993 -> unit eur_per_kg: eur/kg
2994
2995data item_cost: price_per_weight
2996data roasting: price_per_weight
2997data chocolatizing: price_per_weight
2998
2999rule total_price: weight * (item_cost + roasting + chocolatizing)
3000
3001spec calc
3002uses bag
3003 -> with item_cost: item_cost
3004 -> with roasting: roasting
3005
3006data type_of_nut: text -> options "peanut" "cashew"
3007
3008rule price_peanut: 1.5 eur_per_kg
3009rule price_peanut_roasting: 0.45 eur_per_kg
3010
3011rule price_cashew: 2.0 eur_per_kg
3012rule price_cashew_roasting: 0.55 eur_per_kg
3013
3014rule item_cost: veto "No item cost"
3015 unless type_of_nut is "peanut" then price_peanut
3016 unless type_of_nut is "cashew" then price_cashew
3017
3018rule roasting: veto "No roasting"
3019 unless type_of_nut is "peanut" then price_peanut_roasting
3020 unless type_of_nut is "cashew" then price_cashew_roasting
3021
3022rule total_price: bag.total_price
3023"#;
3024
3025 let mut engine = Engine::new();
3026 engine
3027 .load([(
3028 crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
3029 "calc.lemma",
3030 ))),
3031 code.to_string(),
3032 )])
3033 .unwrap();
3034
3035 let now = DateTimeValue::now();
3036 let mut inputs = HashMap::new();
3037 inputs.insert("type_of_nut".to_string(), "peanut".to_string());
3038 let response = engine
3039 .run(
3040 None,
3041 "calc",
3042 Some(&now),
3043 inputs,
3044 Some(&["total_price".to_string()]),
3045 false,
3046 )
3047 .expect("run must succeed");
3048
3049 let names = response_missing_data_union(&response);
3050 assert!(
3051 !names.contains(&"type_of_nut".to_string()),
3052 "supplied type_of_nut is bound and must not appear in missing_data: {names:?}"
3053 );
3054 assert!(names.contains(&"bag.weight".to_string()));
3055 assert!(names.contains(&"bag.chocolatizing".to_string()));
3056 assert!(!names.contains(&"bag.item_cost".to_string()));
3057 assert!(!names.contains(&"bag.roasting".to_string()));
3058 }
3059
3060 #[test]
3061 fn run_includes_membership_dates_when_premium_member_false() {
3062 let mut engine = Engine::new();
3063 engine
3064 .load([(crate::SourceType::Volatile, FILM_ACCESS.to_string())])
3065 .expect("film_access spec must load");
3066 let now = film_access_effective();
3067 let mut inputs = HashMap::new();
3068 inputs.insert("type".to_string(), "rental".to_string());
3069 inputs.insert("views_consumed".to_string(), "6".to_string());
3070 inputs.insert("premium_member".to_string(), "false".to_string());
3071 let response = engine
3072 .run(
3073 None,
3074 "film_access",
3075 Some(&now),
3076 inputs,
3077 Some(&["can_view".to_string()]),
3078 false,
3079 )
3080 .expect("run must succeed");
3081
3082 let names = response_missing_data_union(&response);
3083 assert!(names.contains(&"premium_membership.start".to_string()));
3084 assert!(names.contains(&"premium_membership.length".to_string()));
3085 }
3086
3087 #[test]
3088 fn run_includes_membership_dates_when_premium_member_unknown() {
3089 let mut engine = Engine::new();
3090 engine
3091 .load([(crate::SourceType::Volatile, FILM_ACCESS.to_string())])
3092 .expect("film_access spec must load");
3093 let now = film_access_effective();
3094 let mut inputs = HashMap::new();
3095 inputs.insert("type".to_string(), "rental".to_string());
3096 inputs.insert("views_consumed".to_string(), "6".to_string());
3097 let response = engine
3098 .run(
3099 None,
3100 "film_access",
3101 Some(&now),
3102 inputs,
3103 Some(&["can_view".to_string()]),
3104 false,
3105 )
3106 .expect("run must succeed");
3107
3108 let names = response_missing_data_union(&response);
3109 assert!(names.contains(&"premium_member".to_string()));
3110 assert!(names.contains(&"premium_membership.start".to_string()));
3111 assert!(names.contains(&"premium_membership.length".to_string()));
3112 }
3113
3114 const UNITS_SPEC: &str = r#"
3115spec units
3116uses lemma units
3117data money: measure
3118 -> unit eur: 1
3119 -> decimals 2
3120"#;
3121
3122 const WAREHOUSING_SPEC: &str = r#"
3123spec warehousing
3124uses units
3125uses si: lemma units
3126
3127data units_per_pallet: number
3128 -> minimum 1
3129 -> suggest 1
3130
3131data storage_duration: si.duration
3132 -> minimum 0 week
3133 -> suggest 10 day
3134
3135data interbranch_transport_per_pallet: units.money
3136 -> minimum 0 eur
3137 -> suggest 0 eur
3138
3139data inbound_handling_per_pallet: units.money
3140 -> minimum 0 eur
3141 -> suggest 0 eur
3142
3143data storage_per_pallet_per_week: units.money
3144 -> minimum 0 eur
3145 -> suggest 10 eur
3146
3147data labeling_per_pallet: units.money
3148 -> minimum 0 eur
3149 -> suggest 0 eur
3150
3151data outbound_handling_per_pallet: units.money
3152 -> minimum 0 eur
3153 -> suggest 0 eur
3154
3155rule storage_cost_per_pallet:
3156 storage_per_pallet_per_week
3157 * ceil storage_duration as week as Number
3158
3159rule total_logistics_per_pallet:
3160 interbranch_transport_per_pallet
3161 + inbound_handling_per_pallet
3162 + storage_cost_per_pallet
3163 + labeling_per_pallet
3164 + outbound_handling_per_pallet
3165
3166rule total_logistics_per_ce:
3167 total_logistics_per_pallet / units_per_pallet
3168"#;
3169
3170 const QUOTATION_SPEC: &str = r#"
3171spec quotation
3172uses wh: warehousing
3173rule total: wh.total_logistics_per_ce
3174"#;
3175
3176 fn load_cross_spec_fixtures(engine: &mut Engine) {
3177 engine
3178 .load([(crate::SourceType::Volatile, UNITS_SPEC.to_string())])
3179 .expect("units spec must load");
3180 engine
3181 .load([(crate::SourceType::Volatile, WAREHOUSING_SPEC.to_string())])
3182 .expect("warehousing spec must load");
3183 }
3184
3185 #[test]
3186 fn quotation_plans_without_consumer_stdlib_units() {
3187 let mut engine = Engine::new();
3188 load_cross_spec_fixtures(&mut engine);
3189 engine
3190 .load([(crate::SourceType::Volatile, QUOTATION_SPEC.to_string())])
3191 .expect("quotation must plan without uses lemma units");
3192 let plans = engine
3193 .plans
3194 .get_plans(None, "quotation")
3195 .expect("plans for quotation");
3196 let plan = plans.values().next().expect("plan");
3197
3198 let expression_units = &plan.resolved_types.unit_index;
3199 assert!(
3200 expression_units.unique_owner("week").is_none(),
3201 "consumer expression scope must not contain week: {:?}",
3202 expression_units.keys().collect::<Vec<_>>()
3203 );
3204 assert!(
3205 expression_units.unique_owner("minute").is_none(),
3206 "consumer expression scope must not contain minute: {:?}",
3207 expression_units.keys().collect::<Vec<_>>()
3208 );
3209 let mut keys: Vec<_> = expression_units.keys().cloned().collect();
3210 keys.sort();
3211 assert_eq!(
3212 keys,
3213 ["percent", "permille"],
3214 "consumer expression scope must only have builtin ratio units, not dependency units"
3215 );
3216 }
3217
3218 fn warehousing_default_inputs(prefix: &str) -> HashMap<String, String> {
3219 let key = |name: &str| {
3220 if prefix.is_empty() {
3221 name.to_string()
3222 } else {
3223 format!("{prefix}.{name}")
3224 }
3225 };
3226 HashMap::from([
3227 (key("units_per_pallet"), "1".into()),
3228 (key("storage_duration"), "10 day".into()),
3229 (key("interbranch_transport_per_pallet"), "0 eur".into()),
3230 (key("inbound_handling_per_pallet"), "0 eur".into()),
3231 (key("storage_per_pallet_per_week"), "10 eur".into()),
3232 (key("labeling_per_pallet"), "0 eur".into()),
3233 (key("outbound_handling_per_pallet"), "0 eur".into()),
3234 ])
3235 }
3236
3237 #[test]
3238 fn quotation_evaluates_cross_spec_duration_conversion() {
3239 let mut engine = Engine::new();
3240 load_cross_spec_fixtures(&mut engine);
3241 engine
3242 .load([(crate::SourceType::Volatile, QUOTATION_SPEC.to_string())])
3243 .expect("quotation must load");
3244 let plans = engine
3245 .plans
3246 .get_plans(None, "quotation")
3247 .expect("plans for quotation");
3248 let plan = plans.values().next().expect("plan");
3249 assert!(
3250 plan.resolved_types
3251 .unit_index
3252 .unique_owner("week")
3253 .is_none(),
3254 "consumer unit_index must not contain week"
3255 );
3256 let now = DateTimeValue::now();
3257 let response = engine
3258 .run(
3259 None,
3260 "quotation",
3261 Some(&now),
3262 warehousing_default_inputs("wh"),
3263 None,
3264 false,
3265 )
3266 .expect("quotation must evaluate");
3267 let display = response
3268 .results
3269 .get("total")
3270 .expect("rule total must be present")
3271 .display()
3272 .expect("total must have display")
3273 .to_string();
3274 assert_eq!(
3275 display, "20.00 eur",
3276 "10 eur/week * ceil(10 day as week) / 1 CE must be 20.00 eur, got: {display}"
3277 );
3278 }
3279
3280 #[test]
3281 fn ratio_range_default_endpoints_must_be_ratio_not_measure() {
3282 let code = r#"
3283spec policy
3284data allowed_band: ratio range -> suggest 10%...50%
3285rule band: allowed_band
3286"#;
3287 let mut engine = Engine::new();
3288 engine
3289 .load([(
3290 crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
3291 "ratio_range_endpoint_typing.lemma",
3292 ))),
3293 code.to_string(),
3294 )])
3295 .unwrap();
3296
3297 let plans = engine
3298 .plans
3299 .get_plans(None, "policy")
3300 .expect("plans for policy");
3301 let plan = plans.values().next().expect("plan");
3302 let path = DataPath::local("allowed_band".into());
3303 let def = plan.data.get(&path).expect("allowed_band in plan.data");
3304 let suggestion = def.suggestion().expect("declared default must exist");
3305
3306 let (left, right) = match &suggestion.value {
3307 crate::planning::semantics::ValueKind::Range(l, r) => (l.as_ref(), r.as_ref()),
3308 other => panic!("expected Range, got {other:?}"),
3309 };
3310 for (label, endpoint) in [("left", left), ("right", right)] {
3311 assert!(
3312 matches!(
3313 &endpoint.value,
3314 crate::planning::semantics::ValueKind::Ratio(_)
3315 ),
3316 "{label} endpoint must be Ratio for a percent literal in a ratio range default, got {:?}",
3317 endpoint.value
3318 );
3319 assert!(
3320 matches!(
3321 &endpoint.value,
3322 crate::planning::semantics::ValueKind::Ratio(_)
3323 ),
3324 "{label} endpoint ValueKind must be Ratio (got {:?})",
3325 endpoint.value
3326 );
3327 }
3328 }
3329
3330 #[test]
3331 fn ratio_range_typedef_with_second_ratio_field_loads() {
3332 let code = r#"
3333spec policy
3334data margin_pct: ratio -> suggest 15%
3335data allowed_band: ratio range
3336rule margin: margin_pct
3337rule band_slot: allowed_band
3338"#;
3339 let mut engine = Engine::new();
3340 engine
3341 .load([(
3342 crate::SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from(
3343 "ratio_range_load.lemma",
3344 ))),
3345 code.to_string(),
3346 )])
3347 .unwrap();
3348
3349 let plans = engine
3350 .plans
3351 .get_plans(None, "policy")
3352 .expect("plans for policy");
3353 let plan = plans.values().next().expect("plan");
3354 let path = DataPath::local("allowed_band".into());
3355 let def = plan.data.get(&path).expect("allowed_band in plan.data");
3356 let lemma_type = def
3357 .schema_type()
3358 .expect("allowed_band must be a typed data slot");
3359 match &lemma_type.specifications {
3360 TypeSpecification::RatioRange { units, .. } => {
3361 let names: Vec<&str> = units.iter().map(|u| u.name.as_str()).collect();
3362 assert!(
3363 names.contains(&"percent"),
3364 "ratio range must inherit builtin percent, got {names:?}"
3365 );
3366 }
3367 other => panic!("allowed_band must be RatioRange, got {other:?}"),
3368 }
3369 }
3370}