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