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