1use crate::evaluation::Evaluator;
2use crate::parsing::ast::{DateTimeValue, LemmaSpec};
3use crate::{parse, Error, ResourceLimits, Response};
4use std::collections::{BTreeSet, HashMap};
5use std::sync::Arc;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
13pub(crate) enum TemporalBound {
14 NegInf,
15 At(DateTimeValue),
16 PosInf,
17}
18
19impl PartialOrd for TemporalBound {
20 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
21 Some(self.cmp(other))
22 }
23}
24
25impl Ord for TemporalBound {
26 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
27 use std::cmp::Ordering;
28 match (self, other) {
29 (TemporalBound::NegInf, TemporalBound::NegInf) => Ordering::Equal,
30 (TemporalBound::NegInf, _) => Ordering::Less,
31 (_, TemporalBound::NegInf) => Ordering::Greater,
32 (TemporalBound::PosInf, TemporalBound::PosInf) => Ordering::Equal,
33 (TemporalBound::PosInf, _) => Ordering::Greater,
34 (_, TemporalBound::PosInf) => Ordering::Less,
35 (TemporalBound::At(a), TemporalBound::At(b)) => a.cmp(b),
36 }
37 }
38}
39
40impl TemporalBound {
41 pub(crate) fn from_start(opt: Option<&DateTimeValue>) -> Self {
43 match opt {
44 None => TemporalBound::NegInf,
45 Some(d) => TemporalBound::At(d.clone()),
46 }
47 }
48
49 pub(crate) fn from_end(opt: Option<&DateTimeValue>) -> Self {
51 match opt {
52 None => TemporalBound::PosInf,
53 Some(d) => TemporalBound::At(d.clone()),
54 }
55 }
56
57 pub(crate) fn to_start(&self) -> Option<DateTimeValue> {
59 match self {
60 TemporalBound::NegInf => None,
61 TemporalBound::At(d) => Some(d.clone()),
62 TemporalBound::PosInf => {
63 unreachable!("BUG: PosInf cannot represent a start bound")
64 }
65 }
66 }
67
68 pub(crate) fn to_end(&self) -> Option<DateTimeValue> {
70 match self {
71 TemporalBound::NegInf => {
72 unreachable!("BUG: NegInf cannot represent an end bound")
73 }
74 TemporalBound::At(d) => Some(d.clone()),
75 TemporalBound::PosInf => None,
76 }
77 }
78}
79
80#[derive(Debug, Default)]
87pub struct Context {
88 specs: BTreeSet<Arc<LemmaSpec>>,
89}
90
91impl Context {
92 pub fn new() -> Self {
93 Self {
94 specs: BTreeSet::new(),
95 }
96 }
97
98 pub(crate) fn specs_for_name(&self, name: &str) -> Vec<Arc<LemmaSpec>> {
99 self.specs
100 .iter()
101 .filter(|a| a.name == name)
102 .cloned()
103 .collect()
104 }
105
106 pub fn get_spec_effective_from(
111 &self,
112 name: &str,
113 effective_from: Option<&DateTimeValue>,
114 ) -> Option<Arc<LemmaSpec>> {
115 self.specs_for_name(name)
116 .into_iter()
117 .find(|s| s.effective_from() == effective_from)
118 }
119
120 pub fn get_spec(&self, name: &str, effective: &DateTimeValue) -> Option<Arc<LemmaSpec>> {
126 let versions = self.specs_for_name(name);
127 if versions.is_empty() {
128 return None;
129 }
130
131 for (i, spec) in versions.iter().enumerate() {
132 let from_ok = spec
133 .effective_from()
134 .map(|f| *effective >= *f)
135 .unwrap_or(true);
136 if !from_ok {
137 continue;
138 }
139
140 let effective_to: Option<&DateTimeValue> =
141 versions.get(i + 1).and_then(|next| next.effective_from());
142 let to_ok = effective_to.map(|end| *effective < *end).unwrap_or(true);
143
144 if to_ok {
145 return Some(spec.clone());
146 }
147 }
148
149 None
150 }
151
152 pub fn iter(&self) -> impl Iterator<Item = Arc<LemmaSpec>> + '_ {
153 self.specs.iter().cloned()
154 }
155
156 pub fn insert_spec(&mut self, spec: Arc<LemmaSpec>) -> Result<(), Error> {
158 let existing = self.specs_for_name(&spec.name);
159
160 if existing
161 .iter()
162 .any(|o| o.effective_from() == spec.effective_from())
163 {
164 return Err(Error::validation(
165 format!(
166 "Duplicate spec '{}' (same name and effective_from already in context)",
167 spec.name
168 ),
169 None,
170 None::<String>,
171 ));
172 }
173
174 self.specs.insert(spec);
175 Ok(())
176 }
177
178 pub fn remove_spec(&mut self, spec: &Arc<LemmaSpec>) -> bool {
179 self.specs.remove(spec)
180 }
181
182 #[cfg(test)]
183 pub(crate) fn len(&self) -> usize {
184 self.specs.len()
185 }
186
187 pub fn effective_range(
194 &self,
195 spec: &Arc<LemmaSpec>,
196 ) -> (Option<DateTimeValue>, Option<DateTimeValue>) {
197 let from = spec.effective_from().cloned();
198 let versions = self.specs_for_name(&spec.name);
199 let pos = versions
200 .iter()
201 .position(|v| Arc::ptr_eq(v, spec))
202 .unwrap_or_else(|| {
203 unreachable!(
204 "BUG: effective_range called with spec '{}' not in context",
205 spec.name
206 )
207 });
208 let to = versions
209 .get(pos + 1)
210 .and_then(|next| next.effective_from().cloned());
211 (from, to)
212 }
213
214 pub fn version_boundaries(&self, name: &str) -> Vec<DateTimeValue> {
217 self.specs_for_name(name)
218 .iter()
219 .filter_map(|s| s.effective_from().cloned())
220 .collect()
221 }
222
223 pub fn dep_coverage_gaps(
229 &self,
230 dep_name: &str,
231 required_from: Option<&DateTimeValue>,
232 required_to: Option<&DateTimeValue>,
233 ) -> Vec<(Option<DateTimeValue>, Option<DateTimeValue>)> {
234 let versions = self.specs_for_name(dep_name);
235 if versions.is_empty() {
236 return vec![(required_from.cloned(), required_to.cloned())];
237 }
238
239 let req_start = TemporalBound::from_start(required_from);
240 let req_end = TemporalBound::from_end(required_to);
241
242 let intervals: Vec<(TemporalBound, TemporalBound)> = versions
243 .iter()
244 .enumerate()
245 .map(|(i, v)| {
246 let start = TemporalBound::from_start(v.effective_from());
247 let end = match versions.get(i + 1).and_then(|next| next.effective_from()) {
248 Some(next_from) => TemporalBound::At(next_from.clone()),
249 None => TemporalBound::PosInf,
250 };
251 (start, end)
252 })
253 .collect();
254
255 let mut gaps = Vec::new();
256 let mut cursor = req_start.clone();
257
258 for (v_start, v_end) in &intervals {
259 if cursor >= req_end {
260 break;
261 }
262
263 if *v_end <= cursor {
264 continue;
265 }
266
267 if *v_start > cursor {
268 let gap_end = std::cmp::min(v_start.clone(), req_end.clone());
269 if cursor < gap_end {
270 gaps.push((cursor.to_start(), gap_end.to_end()));
271 }
272 }
273
274 if *v_end > cursor {
275 cursor = v_end.clone();
276 }
277 }
278
279 if cursor < req_end {
280 gaps.push((cursor.to_start(), req_end.to_end()));
281 }
282
283 gaps
284 }
285}
286
287fn find_slice_plan<'a>(
291 plans: &'a [crate::planning::ExecutionPlan],
292 effective: &DateTimeValue,
293) -> Option<&'a crate::planning::ExecutionPlan> {
294 for plan in plans {
295 let from_ok = plan
296 .valid_from
297 .as_ref()
298 .map(|f| *effective >= *f)
299 .unwrap_or(true);
300 let to_ok = plan
301 .valid_to
302 .as_ref()
303 .map(|t| *effective < *t)
304 .unwrap_or(true);
305 if from_ok && to_ok {
306 return Some(plan);
307 }
308 }
309 None
310}
311
312pub struct Engine {
324 execution_plans: HashMap<Arc<LemmaSpec>, Vec<crate::planning::ExecutionPlan>>,
325 specs: Context,
326 sources: HashMap<String, String>,
327 evaluator: Evaluator,
328 limits: ResourceLimits,
329 hash_pins: HashMap<Arc<LemmaSpec>, String>,
330}
331
332impl Default for Engine {
333 fn default() -> Self {
334 Self {
335 execution_plans: HashMap::new(),
336 specs: Context::new(),
337 sources: HashMap::new(),
338 evaluator: Evaluator,
339 limits: ResourceLimits::default(),
340 hash_pins: HashMap::new(),
341 }
342 }
343}
344
345impl Engine {
346 pub fn new() -> Self {
347 Self::default()
348 }
349
350 pub fn with_limits(limits: ResourceLimits) -> Self {
352 Self {
353 execution_plans: HashMap::new(),
354 specs: Context::new(),
355 sources: HashMap::new(),
356 evaluator: Evaluator,
357 limits,
358 hash_pins: HashMap::new(),
359 }
360 }
361
362 pub fn hash_pin(&self, spec_name: &str, effective: &DateTimeValue) -> Option<&str> {
364 let spec_arc = self.get_spec(spec_name, effective)?;
365 self.hash_pin_for_spec(&spec_arc)
366 }
367
368 pub fn hash_pin_for_spec(&self, spec: &Arc<LemmaSpec>) -> Option<&str> {
370 self.hash_pins.get(spec).map(|s| s.as_str())
371 }
372
373 pub fn all_hash_pins(&self) -> Vec<(&str, Option<String>, &str)> {
375 self.hash_pins
376 .iter()
377 .map(|(spec, hash)| {
378 (
379 spec.name.as_str(),
380 spec.effective_from().map(|af| af.to_string()),
381 hash.as_str(),
382 )
383 })
384 .collect()
385 }
386
387 pub fn get_spec_by_hash_pin(&self, spec_name: &str, hash_pin: &str) -> Option<Arc<LemmaSpec>> {
390 let mut matched: Option<Arc<LemmaSpec>> = None;
391 for spec in self.specs.specs_for_name(spec_name) {
392 let computed = match self.hash_pins.get(&spec) {
393 Some(h) => h.as_str(),
394 None => continue,
395 };
396 if crate::planning::content_hash::content_hash_matches(hash_pin, computed) {
397 if matched.is_some() {
398 return None;
399 }
400 matched = Some(spec);
401 }
402 }
403 matched
404 }
405
406 pub fn add_lemma_files(&mut self, files: HashMap<String, String>) -> Result<(), Vec<Error>> {
419 let mut errors: Vec<Error> = Vec::new();
420
421 for (source_id, code) in &files {
422 match parse(code, source_id, &self.limits) {
423 Ok(new_specs) => {
424 let source_text: Arc<str> = Arc::from(code.as_str());
425 for spec in new_specs {
426 let attribute = spec.attribute.clone().unwrap_or_else(|| spec.name.clone());
427 let start_line = spec.start_line;
428 let spec_name = spec.name.clone();
429
430 match self.specs.insert_spec(Arc::new(spec)) {
431 Ok(()) => {
432 self.sources.insert(attribute, code.clone());
433 }
434 Err(e) => {
435 let source = crate::Source::new(
436 &attribute,
437 crate::parsing::ast::Span {
438 start: 0,
439 end: 0,
440 line: start_line,
441 col: 0,
442 },
443 &spec_name,
444 Arc::clone(&source_text),
445 );
446 errors.push(Error::validation(
447 e.to_string(),
448 Some(source),
449 None::<String>,
450 ));
451 }
452 }
453 }
454 }
455 Err(e) => errors.push(e),
456 }
457 }
458
459 let planning_result = crate::planning::plan(&self.specs, self.sources.clone());
460 for spec_result in &planning_result.per_spec {
461 self.execution_plans
462 .insert(Arc::clone(&spec_result.spec), spec_result.plans.clone());
463 self.hash_pins
464 .insert(Arc::clone(&spec_result.spec), spec_result.hash_pin.clone());
465 }
466 errors.extend(planning_result.global_errors);
467 for spec_result in planning_result.per_spec {
468 for err in spec_result.errors {
469 errors.push(err.with_spec_context(Arc::clone(&spec_result.spec)));
470 }
471 }
472
473 if errors.is_empty() {
474 Ok(())
475 } else {
476 Err(errors)
477 }
478 }
479
480 pub fn remove_spec(&mut self, spec: Arc<LemmaSpec>) {
481 self.execution_plans.remove(&spec);
482 self.specs.remove_spec(&spec);
483 }
484
485 pub fn list_specs(&self) -> Vec<Arc<LemmaSpec>> {
487 self.specs.iter().collect()
488 }
489
490 pub fn list_specs_effective(&self, effective: &DateTimeValue) -> Vec<Arc<LemmaSpec>> {
492 let mut seen_names = std::collections::HashSet::new();
493 let mut result = Vec::new();
494 for spec in self.specs.iter() {
495 if seen_names.contains(&spec.name) {
496 continue;
497 }
498 if let Some(active) = self.specs.get_spec(&spec.name, effective) {
499 if seen_names.insert(active.name.clone()) {
500 result.push(active);
501 }
502 }
503 }
504 result.sort_by(|a, b| a.name.cmp(&b.name));
505 result
506 }
507
508 pub fn get_spec(
510 &self,
511 spec_name: &str,
512 effective: &DateTimeValue,
513 ) -> Option<std::sync::Arc<LemmaSpec>> {
514 self.specs.get_spec(spec_name, effective)
515 }
516
517 fn spec_not_found_error(&self, spec_name: &str, effective: &DateTimeValue) -> Error {
521 let versions = self.specs.specs_for_name(spec_name);
522 let msg = if versions.is_empty() {
523 format!("Spec '{}' not found", spec_name)
524 } else {
525 let version_list: Vec<String> = versions
526 .iter()
527 .map(|s| match s.effective_from() {
528 Some(dt) => format!(" {} (effective from {})", s.name, dt),
529 None => format!(" {} (no effective_from)", s.name),
530 })
531 .collect();
532 format!(
533 "Spec '{}' not found for effective {}. Available temporal versions:\n{}",
534 spec_name,
535 effective,
536 version_list.join("\n")
537 )
538 };
539 Error::request(msg, None, None::<String>)
540 }
541
542 pub fn get_execution_plan(
549 &self,
550 spec_name: &str,
551 hash_pin: Option<&str>,
552 effective: &DateTimeValue,
553 ) -> Option<&crate::planning::ExecutionPlan> {
554 let arc = if let Some(pin) = hash_pin {
555 self.get_spec_by_hash_pin(spec_name, pin)?
556 } else {
557 self.get_spec(spec_name, effective)?
558 };
559 let slice_plans = self.execution_plans.get(&arc)?;
560 let plan = find_slice_plan(slice_plans, effective);
561 if plan.is_none() && !slice_plans.is_empty() {
562 unreachable!(
563 "BUG: spec '{}' has {} slice plans but none covers effective={} — slice partition is broken",
564 spec_name, slice_plans.len(), effective
565 );
566 }
567 plan
568 }
569
570 pub fn get_spec_rules(
571 &self,
572 spec_name: &str,
573 effective: &DateTimeValue,
574 ) -> Result<Vec<crate::LemmaRule>, Error> {
575 let arc = self
576 .get_spec(spec_name, effective)
577 .ok_or_else(|| self.spec_not_found_error(spec_name, effective))?;
578 Ok(arc.rules.clone())
579 }
580
581 pub fn evaluate_json(
595 &self,
596 spec_name: &str,
597 hash_pin: Option<&str>,
598 effective: &DateTimeValue,
599 rule_names: Vec<String>,
600 json: &[u8],
601 ) -> Result<Response, Error> {
602 let base_plan = self
603 .get_execution_plan(spec_name, hash_pin, effective)
604 .ok_or_else(|| self.spec_not_found_error(spec_name, effective))?;
605
606 let values = crate::serialization::from_json(json)?;
607 let plan = base_plan.clone().with_fact_values(values, &self.limits)?;
608
609 self.evaluate_plan(plan, rule_names, effective)
610 }
611
612 pub fn evaluate(
627 &self,
628 spec_name: &str,
629 hash_pin: Option<&str>,
630 effective: &DateTimeValue,
631 rule_names: Vec<String>,
632 fact_values: HashMap<String, String>,
633 ) -> Result<Response, Error> {
634 let base_plan = self
635 .get_execution_plan(spec_name, hash_pin, effective)
636 .ok_or_else(|| self.spec_not_found_error(spec_name, effective))?;
637
638 let plan = base_plan
639 .clone()
640 .with_fact_values(fact_values, &self.limits)?;
641
642 self.evaluate_plan(plan, rule_names, effective)
643 }
644
645 pub fn invert(
650 &self,
651 spec_name: &str,
652 effective: &DateTimeValue,
653 rule_name: &str,
654 target: crate::inversion::Target,
655 values: HashMap<String, String>,
656 ) -> Result<crate::InversionResponse, Error> {
657 let base_plan = self
658 .get_execution_plan(spec_name, None, effective)
659 .ok_or_else(|| self.spec_not_found_error(spec_name, effective))?;
660
661 let plan = base_plan.clone().with_fact_values(values, &self.limits)?;
662 let provided_facts: std::collections::HashSet<_> = plan
663 .facts
664 .iter()
665 .filter(|(_, d)| d.value().is_some())
666 .map(|(p, _)| p.clone())
667 .collect();
668
669 crate::inversion::invert(rule_name, target, &plan, &provided_facts)
670 }
671
672 fn evaluate_plan(
673 &self,
674 plan: crate::planning::ExecutionPlan,
675 rule_names: Vec<String>,
676 effective: &DateTimeValue,
677 ) -> Result<Response, Error> {
678 let now_semantic = crate::planning::semantics::date_time_to_semantic(effective);
679 let now_literal = crate::planning::semantics::LiteralValue {
680 value: crate::planning::semantics::ValueKind::Date(now_semantic),
681 lemma_type: crate::planning::semantics::primitive_date().clone(),
682 };
683 let mut response = self.evaluator.evaluate(&plan, now_literal);
684
685 if !rule_names.is_empty() {
686 response.filter_rules(&rule_names);
687 }
688
689 Ok(response)
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696 use rust_decimal::Decimal;
697 use std::str::FromStr;
698
699 fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
700 DateTimeValue {
701 year,
702 month,
703 day,
704 hour: 0,
705 minute: 0,
706 second: 0,
707 microsecond: 0,
708 timezone: None,
709 }
710 }
711
712 fn make_spec(name: &str) -> LemmaSpec {
713 LemmaSpec::new(name.to_string())
714 }
715
716 fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
717 let mut spec = LemmaSpec::new(name.to_string());
718 spec.effective_from = effective_from;
719 spec
720 }
721
722 #[test]
725 fn effective_range_unbounded_single_version() {
726 let mut ctx = Context::new();
727 let spec = Arc::new(make_spec("a"));
728 ctx.insert_spec(Arc::clone(&spec)).unwrap();
729
730 let (from, to) = ctx.effective_range(&spec);
731 assert_eq!(from, None);
732 assert_eq!(to, None);
733 }
734
735 #[test]
736 fn effective_range_soft_end_from_next_version() {
737 let mut ctx = Context::new();
738 let v1 = Arc::new(make_spec_with_range("a", Some(date(2025, 1, 1))));
739 let v2 = Arc::new(make_spec_with_range("a", Some(date(2025, 6, 1))));
740 ctx.insert_spec(Arc::clone(&v1)).unwrap();
741 ctx.insert_spec(Arc::clone(&v2)).unwrap();
742
743 let (from, to) = ctx.effective_range(&v1);
744 assert_eq!(from, Some(date(2025, 1, 1)));
745 assert_eq!(to, Some(date(2025, 6, 1)));
746
747 let (from, to) = ctx.effective_range(&v2);
748 assert_eq!(from, Some(date(2025, 6, 1)));
749 assert_eq!(to, None);
750 }
751
752 #[test]
753 fn effective_range_unbounded_start_with_successor() {
754 let mut ctx = Context::new();
755 let v1 = Arc::new(make_spec("a"));
756 let v2 = Arc::new(make_spec_with_range("a", Some(date(2025, 3, 1))));
757 ctx.insert_spec(Arc::clone(&v1)).unwrap();
758 ctx.insert_spec(Arc::clone(&v2)).unwrap();
759
760 let (from, to) = ctx.effective_range(&v1);
761 assert_eq!(from, None);
762 assert_eq!(to, Some(date(2025, 3, 1)));
763 }
764
765 #[test]
768 fn version_boundaries_single_unversioned() {
769 let mut ctx = Context::new();
770 ctx.insert_spec(Arc::new(make_spec("a"))).unwrap();
771
772 assert!(ctx.version_boundaries("a").is_empty());
773 }
774
775 #[test]
776 fn version_boundaries_multiple_versions() {
777 let mut ctx = Context::new();
778 ctx.insert_spec(Arc::new(make_spec("a"))).unwrap();
779 ctx.insert_spec(Arc::new(make_spec_with_range("a", Some(date(2025, 3, 1)))))
780 .unwrap();
781 ctx.insert_spec(Arc::new(make_spec_with_range("a", Some(date(2025, 6, 1)))))
782 .unwrap();
783
784 let boundaries = ctx.version_boundaries("a");
785 assert_eq!(boundaries, vec![date(2025, 3, 1), date(2025, 6, 1)]);
786 }
787
788 #[test]
789 fn version_boundaries_nonexistent_name() {
790 let ctx = Context::new();
791 assert!(ctx.version_boundaries("nope").is_empty());
792 }
793
794 #[test]
797 fn dep_coverage_no_versions_is_full_gap() {
798 let ctx = Context::new();
799 let gaps =
800 ctx.dep_coverage_gaps("missing", Some(&date(2025, 1, 1)), Some(&date(2025, 6, 1)));
801 assert_eq!(gaps, vec![(Some(date(2025, 1, 1)), Some(date(2025, 6, 1)))]);
802 }
803
804 #[test]
805 fn dep_coverage_single_unbounded_version_covers_everything() {
806 let mut ctx = Context::new();
807 ctx.insert_spec(Arc::new(make_spec("dep"))).unwrap();
808
809 let gaps = ctx.dep_coverage_gaps("dep", None, None);
810 assert!(gaps.is_empty());
811
812 let gaps = ctx.dep_coverage_gaps("dep", Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)));
813 assert!(gaps.is_empty());
814 }
815
816 #[test]
817 fn dep_coverage_single_version_with_from_leaves_leading_gap() {
818 let mut ctx = Context::new();
819 ctx.insert_spec(Arc::new(make_spec_with_range(
820 "dep",
821 Some(date(2025, 3, 1)),
822 )))
823 .unwrap();
824
825 let gaps = ctx.dep_coverage_gaps("dep", None, None);
826 assert_eq!(gaps, vec![(None, Some(date(2025, 3, 1)))]);
827 }
828
829 #[test]
830 fn dep_coverage_continuous_versions_no_gaps() {
831 let mut ctx = Context::new();
832 ctx.insert_spec(Arc::new(make_spec_with_range(
833 "dep",
834 Some(date(2025, 1, 1)),
835 )))
836 .unwrap();
837 ctx.insert_spec(Arc::new(make_spec_with_range(
838 "dep",
839 Some(date(2025, 6, 1)),
840 )))
841 .unwrap();
842
843 let gaps = ctx.dep_coverage_gaps("dep", Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)));
844 assert!(gaps.is_empty());
845 }
846
847 #[test]
848 fn dep_coverage_dep_starts_after_required_start() {
849 let mut ctx = Context::new();
850 ctx.insert_spec(Arc::new(make_spec_with_range(
851 "dep",
852 Some(date(2025, 6, 1)),
853 )))
854 .unwrap();
855
856 let gaps = ctx.dep_coverage_gaps("dep", Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)));
857 assert_eq!(gaps, vec![(Some(date(2025, 1, 1)), Some(date(2025, 6, 1)))]);
858 }
859
860 #[test]
861 fn dep_coverage_unbounded_required_range() {
862 let mut ctx = Context::new();
863 ctx.insert_spec(Arc::new(make_spec_with_range(
864 "dep",
865 Some(date(2025, 6, 1)),
866 )))
867 .unwrap();
868
869 let gaps = ctx.dep_coverage_gaps("dep", None, None);
870 assert_eq!(gaps, vec![(None, Some(date(2025, 6, 1)))]);
871 }
872
873 fn add_lemma_code_blocking(
874 engine: &mut Engine,
875 code: &str,
876 source: &str,
877 ) -> Result<(), Vec<Error>> {
878 let files: HashMap<String, String> =
879 std::iter::once((source.to_string(), code.to_string())).collect();
880 engine.add_lemma_files(files)
881 }
882
883 #[test]
884 fn test_evaluate_spec_all_rules() {
885 let mut engine = Engine::new();
886 add_lemma_code_blocking(
887 &mut engine,
888 r#"
889 spec test
890 fact x: 10
891 fact y: 5
892 rule sum: x + y
893 rule product: x * y
894 "#,
895 "test.lemma",
896 )
897 .unwrap();
898
899 let now = DateTimeValue::now();
900 let response = engine
901 .evaluate("test", None, &now, vec![], HashMap::new())
902 .unwrap();
903 assert_eq!(response.results.len(), 2);
904
905 let sum_result = response
906 .results
907 .values()
908 .find(|r| r.rule.name == "sum")
909 .unwrap();
910 assert_eq!(
911 sum_result.result,
912 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
913 Decimal::from_str("15").unwrap()
914 )))
915 );
916
917 let product_result = response
918 .results
919 .values()
920 .find(|r| r.rule.name == "product")
921 .unwrap();
922 assert_eq!(
923 product_result.result,
924 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
925 Decimal::from_str("50").unwrap()
926 )))
927 );
928 }
929
930 #[test]
931 fn test_evaluate_empty_facts() {
932 let mut engine = Engine::new();
933 add_lemma_code_blocking(
934 &mut engine,
935 r#"
936 spec test
937 fact price: 100
938 rule total: price * 2
939 "#,
940 "test.lemma",
941 )
942 .unwrap();
943
944 let now = DateTimeValue::now();
945 let response = engine
946 .evaluate("test", None, &now, vec![], HashMap::new())
947 .unwrap();
948 assert_eq!(response.results.len(), 1);
949 assert_eq!(
950 response.results.values().next().unwrap().result,
951 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
952 Decimal::from_str("200").unwrap()
953 )))
954 );
955 }
956
957 #[test]
958 fn test_evaluate_boolean_rule() {
959 let mut engine = Engine::new();
960 add_lemma_code_blocking(
961 &mut engine,
962 r#"
963 spec test
964 fact age: 25
965 rule is_adult: age >= 18
966 "#,
967 "test.lemma",
968 )
969 .unwrap();
970
971 let now = DateTimeValue::now();
972 let response = engine
973 .evaluate("test", None, &now, vec![], HashMap::new())
974 .unwrap();
975 assert_eq!(
976 response.results.values().next().unwrap().result,
977 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::from_bool(true)))
978 );
979 }
980
981 #[test]
982 fn test_evaluate_with_unless_clause() {
983 let mut engine = Engine::new();
984 add_lemma_code_blocking(
985 &mut engine,
986 r#"
987 spec test
988 fact quantity: 15
989 rule discount: 0
990 unless quantity >= 10 then 10
991 "#,
992 "test.lemma",
993 )
994 .unwrap();
995
996 let now = DateTimeValue::now();
997 let response = engine
998 .evaluate("test", None, &now, vec![], HashMap::new())
999 .unwrap();
1000 assert_eq!(
1001 response.results.values().next().unwrap().result,
1002 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1003 Decimal::from_str("10").unwrap()
1004 )))
1005 );
1006 }
1007
1008 #[test]
1009 fn test_spec_not_found() {
1010 let engine = Engine::new();
1011 let now = DateTimeValue::now();
1012 let result = engine.evaluate("nonexistent", None, &now, vec![], HashMap::new());
1013 assert!(result.is_err());
1014 assert!(result.unwrap_err().to_string().contains("not found"));
1015 }
1016
1017 #[test]
1018 fn test_multiple_specs() {
1019 let mut engine = Engine::new();
1020 add_lemma_code_blocking(
1021 &mut engine,
1022 r#"
1023 spec spec1
1024 fact x: 10
1025 rule result: x * 2
1026 "#,
1027 "spec 1.lemma",
1028 )
1029 .unwrap();
1030
1031 add_lemma_code_blocking(
1032 &mut engine,
1033 r#"
1034 spec spec2
1035 fact y: 5
1036 rule result: y * 3
1037 "#,
1038 "spec 2.lemma",
1039 )
1040 .unwrap();
1041
1042 let now = DateTimeValue::now();
1043 let response1 = engine
1044 .evaluate("spec1", None, &now, vec![], HashMap::new())
1045 .unwrap();
1046 assert_eq!(
1047 response1.results[0].result,
1048 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1049 Decimal::from_str("20").unwrap()
1050 )))
1051 );
1052
1053 let response2 = engine
1054 .evaluate("spec2", None, &now, vec![], HashMap::new())
1055 .unwrap();
1056 assert_eq!(
1057 response2.results[0].result,
1058 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1059 Decimal::from_str("15").unwrap()
1060 )))
1061 );
1062 }
1063
1064 #[test]
1065 fn test_runtime_error_mapping() {
1066 let mut engine = Engine::new();
1067 add_lemma_code_blocking(
1068 &mut engine,
1069 r#"
1070 spec test
1071 fact numerator: 10
1072 fact denominator: 0
1073 rule division: numerator / denominator
1074 "#,
1075 "test.lemma",
1076 )
1077 .unwrap();
1078
1079 let now = DateTimeValue::now();
1080 let result = engine.evaluate("test", None, &now, vec![], HashMap::new());
1081 assert!(result.is_ok(), "Evaluation should succeed");
1083 let response = result.unwrap();
1084 let division_result = response
1085 .results
1086 .values()
1087 .find(|r| r.rule.name == "division");
1088 assert!(
1089 division_result.is_some(),
1090 "Should have division rule result"
1091 );
1092 match &division_result.unwrap().result {
1093 crate::OperationResult::Veto(message) => {
1094 assert!(
1095 message
1096 .as_ref()
1097 .map(|m| m.contains("Division by zero"))
1098 .unwrap_or(false),
1099 "Veto message should mention division by zero: {:?}",
1100 message
1101 );
1102 }
1103 other => panic!("Expected Veto for division by zero, got {:?}", other),
1104 }
1105 }
1106
1107 #[test]
1108 fn test_rules_sorted_by_source_order() {
1109 let mut engine = Engine::new();
1110 add_lemma_code_blocking(
1111 &mut engine,
1112 r#"
1113 spec test
1114 fact a: 1
1115 fact b: 2
1116 rule z: a + b
1117 rule y: a * b
1118 rule x: a - b
1119 "#,
1120 "test.lemma",
1121 )
1122 .unwrap();
1123
1124 let now = DateTimeValue::now();
1125 let response = engine
1126 .evaluate("test", None, &now, vec![], HashMap::new())
1127 .unwrap();
1128 assert_eq!(response.results.len(), 3);
1129
1130 let z_pos = response
1132 .results
1133 .values()
1134 .find(|r| r.rule.name == "z")
1135 .unwrap()
1136 .rule
1137 .source_location
1138 .span
1139 .start;
1140 let y_pos = response
1141 .results
1142 .values()
1143 .find(|r| r.rule.name == "y")
1144 .unwrap()
1145 .rule
1146 .source_location
1147 .span
1148 .start;
1149 let x_pos = response
1150 .results
1151 .values()
1152 .find(|r| r.rule.name == "x")
1153 .unwrap()
1154 .rule
1155 .source_location
1156 .span
1157 .start;
1158
1159 assert!(z_pos < y_pos);
1160 assert!(y_pos < x_pos);
1161 }
1162
1163 #[test]
1164 fn test_rule_filtering_evaluates_dependencies() {
1165 let mut engine = Engine::new();
1166 add_lemma_code_blocking(
1167 &mut engine,
1168 r#"
1169 spec test
1170 fact base: 100
1171 rule subtotal: base * 2
1172 rule tax: subtotal * 10%
1173 rule total: subtotal + tax
1174 "#,
1175 "test.lemma",
1176 )
1177 .unwrap();
1178
1179 let now = DateTimeValue::now();
1181 let response = engine
1182 .evaluate(
1183 "test",
1184 None,
1185 &now,
1186 vec!["total".to_string()],
1187 HashMap::new(),
1188 )
1189 .unwrap();
1190
1191 assert_eq!(response.results.len(), 1);
1193 assert_eq!(response.results.keys().next().unwrap(), "total");
1194
1195 let total = response.results.values().next().unwrap();
1197 assert_eq!(
1198 total.result,
1199 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1200 Decimal::from_str("220").unwrap()
1201 )))
1202 );
1203 }
1204
1205 use crate::parsing::ast::DateTimeValue;
1210
1211 #[test]
1212 fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1213 let mut engine = Engine::new();
1214 let mut files = HashMap::new();
1215 files.insert(
1216 "main.lemma".to_string(),
1217 r#"spec main_spec
1218fact external: spec @org/project/helper
1219rule value: external.quantity"#
1220 .to_string(),
1221 );
1222 files.insert(
1223 "deps/org_project_helper.lemma".to_string(),
1224 "spec @org/project/helper\nfact quantity: 42".to_string(),
1225 );
1226 engine
1227 .add_lemma_files(files)
1228 .expect("should succeed with pre-resolved deps in file map");
1229
1230 let now = DateTimeValue::now();
1231 let response = engine
1232 .evaluate("main_spec", None, &now, vec![], HashMap::new())
1233 .expect("evaluate should succeed");
1234
1235 let value_result = response
1236 .results
1237 .get("value")
1238 .expect("rule 'value' should exist");
1239 assert_eq!(
1240 value_result.result,
1241 crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1242 Decimal::from_str("42").unwrap()
1243 )))
1244 );
1245 }
1246
1247 #[test]
1248 fn add_lemma_files_no_external_refs_works() {
1249 let mut engine = Engine::new();
1250
1251 add_lemma_code_blocking(
1252 &mut engine,
1253 r#"spec local_only
1254fact price: 100
1255rule doubled: price * 2"#,
1256 "local.lemma",
1257 )
1258 .expect("should succeed when there are no @... references");
1259
1260 let now = DateTimeValue::now();
1261 let response = engine
1262 .evaluate("local_only", None, &now, vec![], HashMap::new())
1263 .expect("evaluate should succeed");
1264
1265 assert!(response.results.contains_key("doubled"));
1266 }
1267
1268 #[test]
1269 fn unresolved_external_ref_without_deps_fails() {
1270 let mut engine = Engine::new();
1271
1272 let result = add_lemma_code_blocking(
1273 &mut engine,
1274 r#"spec main_spec
1275fact external: spec @org/project/missing
1276rule value: external.quantity"#,
1277 "main.lemma",
1278 );
1279
1280 assert!(
1281 result.is_err(),
1282 "Should fail when @... dep is not in file map"
1283 );
1284 }
1285
1286 #[test]
1287 fn pre_resolved_deps_with_spec_and_type_refs() {
1288 let mut engine = Engine::new();
1289 let mut files = HashMap::new();
1290 files.insert(
1291 "main.lemma".to_string(),
1292 r#"spec registry_demo
1293type money from @lemma/std/finance
1294fact unit_price: 5 eur
1295fact helper: spec @org/example/helper
1296rule helper_value: helper.value
1297rule line_total: unit_price * 2
1298rule formatted: helper_value + 0"#
1299 .to_string(),
1300 );
1301 files.insert(
1302 "deps/helper.lemma".to_string(),
1303 "spec @org/example/helper\nfact value: 42".to_string(),
1304 );
1305 files.insert(
1306 "deps/finance.lemma".to_string(),
1307 "spec @lemma/std/finance\ntype money: scale\n -> unit eur 1.00\n -> decimals 2"
1308 .to_string(),
1309 );
1310 engine
1311 .add_lemma_files(files)
1312 .expect("should succeed with pre-resolved spec and type deps");
1313
1314 let now = DateTimeValue::now();
1315 let response = engine
1316 .evaluate("registry_demo", None, &now, vec![], HashMap::new())
1317 .expect("evaluate should succeed");
1318
1319 assert!(response.results.contains_key("helper_value"));
1320 assert!(response.results.contains_key("formatted"));
1321 }
1322
1323 #[test]
1324 fn add_lemma_files_returns_all_errors_not_just_first() {
1325 let mut engine = Engine::new();
1326
1327 let result = add_lemma_code_blocking(
1328 &mut engine,
1329 r#"spec demo
1330type money from nonexistent_type_source
1331fact helper: spec nonexistent_spec
1332fact price: 10
1333rule total: helper.value + price"#,
1334 "test.lemma",
1335 );
1336
1337 assert!(result.is_err(), "Should fail with multiple errors");
1338 let errs = result.unwrap_err();
1339 assert!(
1340 errs.len() >= 2,
1341 "expected at least 2 errors (type + spec ref), got {}",
1342 errs.len()
1343 );
1344 let error_message = errs
1345 .iter()
1346 .map(ToString::to_string)
1347 .collect::<Vec<_>>()
1348 .join("; ");
1349
1350 assert!(
1351 error_message.contains("money"),
1352 "Should mention type error about 'money'. Got:\n{}",
1353 error_message
1354 );
1355 assert!(
1356 error_message.contains("nonexistent_spec"),
1357 "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
1358 error_message
1359 );
1360 }
1361
1362 #[test]
1368 fn planning_rejects_invalid_number_default() {
1369 let mut engine = Engine::new();
1370 let result = add_lemma_code_blocking(
1371 &mut engine,
1372 "spec t\nfact x: [number -> default \"10 $$\"]\nrule r: x",
1373 "t.lemma",
1374 );
1375 assert!(
1376 result.is_err(),
1377 "must reject non-numeric default on number type"
1378 );
1379 }
1380
1381 #[test]
1382 fn planning_rejects_text_literal_as_number_default() {
1383 let mut engine = Engine::new();
1388 let result = add_lemma_code_blocking(
1389 &mut engine,
1390 "spec t\nfact x: [number -> default \"10\"]\nrule r: x",
1391 "t.lemma",
1392 );
1393 assert!(
1394 result.is_err(),
1395 "must reject text literal \"10\" as default for number type"
1396 );
1397 }
1398
1399 #[test]
1400 fn planning_rejects_invalid_boolean_default() {
1401 let mut engine = Engine::new();
1402 let result = add_lemma_code_blocking(
1403 &mut engine,
1404 "spec t\nfact x: [boolean -> default \"maybe\"]\nrule r: x",
1405 "t.lemma",
1406 );
1407 assert!(
1408 result.is_err(),
1409 "must reject non-boolean default on boolean type"
1410 );
1411 }
1412
1413 #[test]
1414 fn planning_rejects_invalid_named_type_default() {
1415 let mut engine = Engine::new();
1417 let result = add_lemma_code_blocking(
1418 &mut engine,
1419 "spec t\ntype custom: number -> minimum 0\nfact x: [custom -> default \"abc\"]\nrule r: x",
1420 "t.lemma",
1421 );
1422 assert!(
1423 result.is_err(),
1424 "must reject non-numeric default on named number type"
1425 );
1426 }
1427
1428 #[test]
1429 fn planning_accepts_valid_number_default() {
1430 let mut engine = Engine::new();
1431 let result = add_lemma_code_blocking(
1432 &mut engine,
1433 "spec t\nfact x: [number -> default 10]\nrule r: x",
1434 "t.lemma",
1435 );
1436 assert!(result.is_ok(), "must accept valid number default");
1437 }
1438
1439 #[test]
1440 fn planning_accepts_valid_boolean_default() {
1441 let mut engine = Engine::new();
1442 let result = add_lemma_code_blocking(
1443 &mut engine,
1444 "spec t\nfact x: [boolean -> default true]\nrule r: x",
1445 "t.lemma",
1446 );
1447 assert!(result.is_ok(), "must accept valid boolean default");
1448 }
1449
1450 #[test]
1451 fn planning_accepts_valid_text_default() {
1452 let mut engine = Engine::new();
1453 let result = add_lemma_code_blocking(
1454 &mut engine,
1455 "spec t\nfact x: [text -> default \"hello\"]\nrule r: x",
1456 "t.lemma",
1457 );
1458 assert!(result.is_ok(), "must accept valid text default");
1459 }
1460}