Skip to main content

lemma/
engine.rs

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// ─── Temporal bound for Option<DateTimeValue> comparisons ────────────
8
9/// Explicit representation of a temporal bound, eliminating the ambiguity
10/// of `Option<DateTimeValue>` where `None` means `-∞` for start bounds
11/// and `+∞` for end bounds.
12#[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    /// Convert an `Option<&DateTimeValue>` used as a start bound (None = -∞).
42    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    /// Convert an `Option<&DateTimeValue>` used as an end bound (None = +∞).
50    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    /// Convert back to `Option<DateTimeValue>` for a start bound (NegInf → None).
58    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    /// Convert back to `Option<DateTimeValue>` for an end bound (PosInf → None).
69    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// ─── Spec store with temporal resolution ──────────────────────────────
81
82/// Ordered set of specs with temporal versioning.
83///
84/// Specs with the same name are ordered by effective_from.
85/// A temporal version's end is derived from the next temporal version's effective_from, or +inf.
86#[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    /// Exact identity lookup by (name, effective_from).
107    ///
108    /// None matches specs without temporal versioning.
109    /// Some(d) matches the temporal version whose effective_from equals d.
110    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    /// Temporal range resolution: find the temporal version of `name` that is active at `effective`.
121    ///
122    /// A spec is active at `effective` when:
123    ///   effective_from <= effective < effective_to
124    /// where effective_to is the next temporal version's effective_from, or +inf if no successor.
125    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    /// Insert a spec. Validates no duplicate (name, effective_from).
157    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    // ─── Temporal helpers ────────────────────────────────────────────
188
189    /// Returns the effective range `[from, to)` for a spec in this context.
190    ///
191    /// - `from`: `spec.effective_from()` (None = -∞)
192    /// - `to`: next temporal version's `effective_from`, or None (+∞) if no successor.
193    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    /// Returns all `effective_from` dates for temporal versions of `name`, sorted ascending.
215    /// Temporal versions without `effective_from` are excluded (they represent -∞).
216    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    /// Check if temporal versions of `dep_name` fully cover the range
224    /// `[required_from, required_to)`.
225    ///
226    /// Returns gaps as `(start, end)` intervals. Empty vec = fully covered.
227    /// Start: None = -∞, End: None = +∞.
228    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
287// ─── Slice plan lookup ───────────────────────────────────────────────
288
289/// Find the plan whose `[valid_from, valid_to)` interval contains `effective`.
290fn 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
312// ─── Engine ──────────────────────────────────────────────────────────
313
314/// Engine for evaluating Lemma rules.
315///
316/// Pure Rust implementation that evaluates Lemma specs directly from the AST.
317/// Uses pre-built execution plans that are self-contained and ready for evaluation.
318///
319/// The engine never performs network calls. External `@...` references must be
320/// pre-resolved before calling `add_lemma_files` — either by including dep files
321/// in the file map or by calling `resolve_registry_references` separately
322/// (e.g. in a `lemma fetch` command).
323pub 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    total_expression_count: usize,
331}
332
333impl Default for Engine {
334    fn default() -> Self {
335        Self {
336            execution_plans: HashMap::new(),
337            specs: Context::new(),
338            sources: HashMap::new(),
339            evaluator: Evaluator,
340            limits: ResourceLimits::default(),
341            hash_pins: HashMap::new(),
342            total_expression_count: 0,
343        }
344    }
345}
346
347impl Engine {
348    pub fn new() -> Self {
349        Self::default()
350    }
351
352    /// Create an engine with custom resource limits.
353    pub fn with_limits(limits: ResourceLimits) -> Self {
354        Self {
355            execution_plans: HashMap::new(),
356            specs: Context::new(),
357            sources: HashMap::new(),
358            evaluator: Evaluator,
359            limits,
360            hash_pins: HashMap::new(),
361            total_expression_count: 0,
362        }
363    }
364
365    /// Get the content hash (hash pin) for the temporal version active at `effective`.
366    pub fn hash_pin(&self, spec_name: &str, effective: &DateTimeValue) -> Option<&str> {
367        let spec_arc = self.get_spec(spec_name, effective)?;
368        self.hash_pin_for_spec(&spec_arc)
369    }
370
371    /// Get the content hash for a specific spec (by arc). Used when the resolved spec is already known.
372    pub fn hash_pin_for_spec(&self, spec: &Arc<LemmaSpec>) -> Option<&str> {
373        self.hash_pins.get(spec).map(|s| s.as_str())
374    }
375
376    /// Get all hash pins as (spec_name, effective_from_display, hash) triples.
377    pub fn all_hash_pins(&self) -> Vec<(&str, Option<String>, &str)> {
378        self.hash_pins
379            .iter()
380            .map(|(spec, hash)| {
381                (
382                    spec.name.as_str(),
383                    spec.effective_from().map(|af| af.to_string()),
384                    hash.as_str(),
385                )
386            })
387            .collect()
388    }
389
390    /// Get the spec with the given name whose content hash matches `hash_pin`.
391    /// Returns `None` if no such spec exists or if multiple versions match (hash collision).
392    pub fn get_spec_by_hash_pin(&self, spec_name: &str, hash_pin: &str) -> Option<Arc<LemmaSpec>> {
393        let mut matched: Option<Arc<LemmaSpec>> = None;
394        for spec in self.specs.specs_for_name(spec_name) {
395            let computed = match self.hash_pins.get(&spec) {
396                Some(h) => h.as_str(),
397                None => continue,
398            };
399            if crate::planning::content_hash::content_hash_matches(hash_pin, computed) {
400                if matched.is_some() {
401                    return None;
402                }
403                matched = Some(spec);
404            }
405        }
406        matched
407    }
408
409    /// Add Lemma source files, parse them, and build execution plans.
410    ///
411    /// External `@...` references must already be resolved: include dependency
412    /// `.lemma` files in the `files` map. The engine never
413    /// performs network calls. Use `resolve_registry_references` separately
414    /// (e.g. in `lemma fetch`) to download dependencies before calling this.
415    ///
416    /// - Validates and resolves types **once** across all specs
417    /// - Collects **all** errors across all files (parse, planning) instead of aborting on the first
418    ///
419    /// `files` maps source identifiers (e.g. file paths) to source code.
420    /// For a single file, pass a one-entry `HashMap`.
421    pub fn add_lemma_files(&mut self, files: HashMap<String, String>) -> Result<(), Vec<Error>> {
422        let mut errors: Vec<Error> = Vec::new();
423
424        for (source_id, code) in &files {
425            match parse(code, source_id, &self.limits) {
426                Ok(result) => {
427                    self.total_expression_count += result.expression_count;
428                    if self.total_expression_count > self.limits.max_total_expression_count {
429                        errors.push(Error::resource_limit_exceeded(
430                            "max_total_expression_count",
431                            self.limits.max_total_expression_count.to_string(),
432                            self.total_expression_count.to_string(),
433                            "Split logic across fewer files or reduce expression complexity",
434                            None::<crate::Source>,
435                        ));
436                        return Err(errors);
437                    }
438                    let new_specs = result.specs;
439                    let source_text: Arc<str> = Arc::from(code.as_str());
440                    for spec in new_specs {
441                        let attribute = spec.attribute.clone().unwrap_or_else(|| spec.name.clone());
442                        let start_line = spec.start_line;
443
444                        match self.specs.insert_spec(Arc::new(spec)) {
445                            Ok(()) => {
446                                self.sources.insert(attribute, code.clone());
447                            }
448                            Err(e) => {
449                                let source = crate::Source::new(
450                                    &attribute,
451                                    crate::parsing::ast::Span {
452                                        start: 0,
453                                        end: 0,
454                                        line: start_line,
455                                        col: 0,
456                                    },
457                                    Arc::clone(&source_text),
458                                );
459                                errors.push(Error::validation(
460                                    e.to_string(),
461                                    Some(source),
462                                    None::<String>,
463                                ));
464                            }
465                        }
466                    }
467                }
468                Err(e) => errors.push(e),
469            }
470        }
471
472        let planning_result = crate::planning::plan(&self.specs, self.sources.clone());
473        for spec_result in &planning_result.per_spec {
474            self.execution_plans
475                .insert(Arc::clone(&spec_result.spec), spec_result.plans.clone());
476            self.hash_pins
477                .insert(Arc::clone(&spec_result.spec), spec_result.hash_pin.clone());
478        }
479        errors.extend(planning_result.global_errors);
480        for spec_result in planning_result.per_spec {
481            for err in spec_result.errors {
482                errors.push(err.with_spec_context(Arc::clone(&spec_result.spec)));
483            }
484        }
485
486        if errors.is_empty() {
487            Ok(())
488        } else {
489            Err(errors)
490        }
491    }
492
493    pub fn remove_spec(&mut self, spec: Arc<LemmaSpec>) {
494        self.execution_plans.remove(&spec);
495        self.specs.remove_spec(&spec);
496    }
497
498    /// All specs, all temporal versions, ordered by (name, effective_from).
499    pub fn list_specs(&self) -> Vec<Arc<LemmaSpec>> {
500        self.specs.iter().collect()
501    }
502
503    /// Specs active at `effective` (one per name).
504    pub fn list_specs_effective(&self, effective: &DateTimeValue) -> Vec<Arc<LemmaSpec>> {
505        let mut seen_names = std::collections::HashSet::new();
506        let mut result = Vec::new();
507        for spec in self.specs.iter() {
508            if seen_names.contains(&spec.name) {
509                continue;
510            }
511            if let Some(active) = self.specs.get_spec(&spec.name, effective) {
512                if seen_names.insert(active.name.clone()) {
513                    result.push(active);
514                }
515            }
516        }
517        result.sort_by(|a, b| a.name.cmp(&b.name));
518        result
519    }
520
521    /// Get spec by name at a specific time.
522    pub fn get_spec(
523        &self,
524        spec_name: &str,
525        effective: &DateTimeValue,
526    ) -> Option<std::sync::Arc<LemmaSpec>> {
527        self.specs.get_spec(spec_name, effective)
528    }
529
530    /// Build a "not found" error that includes the effective date and lists
531    /// available temporal versions when the spec name exists but no temporal version
532    /// matches the requested time.
533    fn spec_not_found_error(&self, spec_name: &str, effective: &DateTimeValue) -> Error {
534        let versions = self.specs.specs_for_name(spec_name);
535        let msg = if versions.is_empty() {
536            format!("Spec '{}' not found", spec_name)
537        } else {
538            let version_list: Vec<String> = versions
539                .iter()
540                .map(|s| match s.effective_from() {
541                    Some(dt) => format!("  {} (effective from {})", s.name, dt),
542                    None => format!("  {} (no effective_from)", s.name),
543                })
544                .collect();
545            format!(
546                "Spec '{}' not found for effective {}. Available temporal versions:\n{}",
547                spec_name,
548                effective,
549                version_list.join("\n")
550            )
551        };
552        Error::request(msg, None::<String>)
553    }
554
555    /// Get the execution plan for a spec.
556    ///
557    /// When `hash_pin` is `Some`, resolves the spec by content hash for that name,
558    /// then returns the slice plan that covers `effective`. When `hash_pin` is `None`,
559    /// resolves the temporal version active at `effective` then finds the covering slice plan.
560    /// Returns `None` when the spec does not exist or has no matching plan.
561    pub fn get_execution_plan(
562        &self,
563        spec_name: &str,
564        hash_pin: Option<&str>,
565        effective: &DateTimeValue,
566    ) -> Option<&crate::planning::ExecutionPlan> {
567        let arc = if let Some(pin) = hash_pin {
568            self.get_spec_by_hash_pin(spec_name, pin)?
569        } else {
570            self.get_spec(spec_name, effective)?
571        };
572        let slice_plans = self.execution_plans.get(&arc)?;
573        let plan = find_slice_plan(slice_plans, effective);
574        if plan.is_none() && !slice_plans.is_empty() {
575            unreachable!(
576                "BUG: spec '{}' has {} slice plans but none covers effective={} — slice partition is broken",
577                spec_name, slice_plans.len(), effective
578            );
579        }
580        plan
581    }
582
583    pub fn get_spec_rules(
584        &self,
585        spec_name: &str,
586        effective: &DateTimeValue,
587    ) -> Result<Vec<crate::LemmaRule>, Error> {
588        let arc = self
589            .get_spec(spec_name, effective)
590            .ok_or_else(|| self.spec_not_found_error(spec_name, effective))?;
591        Ok(arc.rules.clone())
592    }
593
594    /// Evaluate rules in a spec with JSON values for facts.
595    ///
596    /// This is a convenience method that accepts JSON directly and converts it
597    /// to typed values using the spec's fact type declarations.
598    ///
599    /// If `rule_names` is empty, evaluates all rules.
600    /// Otherwise, only returns results for the specified rules (dependencies still computed).
601    ///
602    /// Values are provided as JSON bytes (e.g., `b"{\"quantity\": 5, \"is_member\": true}"`).
603    /// They are automatically parsed to the expected type based on the spec schema.
604    ///
605    /// When `hash_pin` is `Some`, the spec is resolved by that content hash; otherwise
606    /// by temporal resolution at `effective`. Evaluation uses the resolved plan.
607    pub fn evaluate_json(
608        &self,
609        spec_name: &str,
610        hash_pin: Option<&str>,
611        effective: &DateTimeValue,
612        rule_names: Vec<String>,
613        json: &[u8],
614    ) -> Result<Response, Error> {
615        let base_plan = self
616            .get_execution_plan(spec_name, hash_pin, effective)
617            .ok_or_else(|| self.spec_not_found_error(spec_name, effective))?;
618
619        let values = crate::serialization::from_json(json)?;
620        let plan = base_plan.clone().with_fact_values(values, &self.limits)?;
621
622        self.evaluate_plan(plan, rule_names, effective)
623    }
624
625    /// Evaluate rules in a spec with string values for facts.
626    ///
627    /// This is the user-friendly API that accepts raw string values and parses them
628    /// to the appropriate types based on the spec's fact type declarations.
629    /// Use this for CLI, HTTP APIs, and other user-facing interfaces.
630    ///
631    /// If `rule_names` is empty, evaluates all rules.
632    /// Otherwise, only returns results for the specified rules (dependencies still computed).
633    ///
634    /// Fact values are provided as name -> value string pairs (e.g., "type" -> "latte").
635    /// They are automatically parsed to the expected type based on the spec schema.
636    ///
637    /// When `hash_pin` is `Some`, the spec is resolved by that content hash; otherwise
638    /// by temporal resolution at `effective`. Evaluation uses the resolved plan.
639    pub fn evaluate(
640        &self,
641        spec_name: &str,
642        hash_pin: Option<&str>,
643        effective: &DateTimeValue,
644        rule_names: Vec<String>,
645        fact_values: HashMap<String, String>,
646    ) -> Result<Response, Error> {
647        let base_plan = self
648            .get_execution_plan(spec_name, hash_pin, effective)
649            .ok_or_else(|| self.spec_not_found_error(spec_name, effective))?;
650
651        let plan = base_plan
652            .clone()
653            .with_fact_values(fact_values, &self.limits)?;
654
655        self.evaluate_plan(plan, rule_names, effective)
656    }
657
658    /// Invert a rule to find input domains that produce a desired outcome.
659    ///
660    /// Values are provided as name -> value string pairs (e.g., "quantity" -> "5").
661    /// They are automatically parsed to the expected type based on the spec schema.
662    pub fn invert(
663        &self,
664        spec_name: &str,
665        effective: &DateTimeValue,
666        rule_name: &str,
667        target: crate::inversion::Target,
668        values: HashMap<String, String>,
669    ) -> Result<crate::InversionResponse, Error> {
670        let base_plan = self
671            .get_execution_plan(spec_name, None, effective)
672            .ok_or_else(|| self.spec_not_found_error(spec_name, effective))?;
673
674        let plan = base_plan.clone().with_fact_values(values, &self.limits)?;
675        let provided_facts: std::collections::HashSet<_> = plan
676            .facts
677            .iter()
678            .filter(|(_, d)| d.value().is_some())
679            .map(|(p, _)| p.clone())
680            .collect();
681
682        crate::inversion::invert(rule_name, target, &plan, &provided_facts)
683    }
684
685    fn evaluate_plan(
686        &self,
687        plan: crate::planning::ExecutionPlan,
688        rule_names: Vec<String>,
689        effective: &DateTimeValue,
690    ) -> Result<Response, Error> {
691        let now_semantic = crate::planning::semantics::date_time_to_semantic(effective);
692        let now_literal = crate::planning::semantics::LiteralValue {
693            value: crate::planning::semantics::ValueKind::Date(now_semantic),
694            lemma_type: crate::planning::semantics::primitive_date().clone(),
695        };
696        let mut response = self.evaluator.evaluate(&plan, now_literal);
697
698        if !rule_names.is_empty() {
699            response.filter_rules(&rule_names);
700        }
701
702        Ok(response)
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709    use rust_decimal::Decimal;
710    use std::str::FromStr;
711
712    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
713        DateTimeValue {
714            year,
715            month,
716            day,
717            hour: 0,
718            minute: 0,
719            second: 0,
720            microsecond: 0,
721            timezone: None,
722        }
723    }
724
725    fn make_spec(name: &str) -> LemmaSpec {
726        LemmaSpec::new(name.to_string())
727    }
728
729    fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
730        let mut spec = LemmaSpec::new(name.to_string());
731        spec.effective_from = effective_from;
732        spec
733    }
734
735    // ─── Context::effective_range tests ──────────────────────────────
736
737    #[test]
738    fn effective_range_unbounded_single_version() {
739        let mut ctx = Context::new();
740        let spec = Arc::new(make_spec("a"));
741        ctx.insert_spec(Arc::clone(&spec)).unwrap();
742
743        let (from, to) = ctx.effective_range(&spec);
744        assert_eq!(from, None);
745        assert_eq!(to, None);
746    }
747
748    #[test]
749    fn effective_range_soft_end_from_next_version() {
750        let mut ctx = Context::new();
751        let v1 = Arc::new(make_spec_with_range("a", Some(date(2025, 1, 1))));
752        let v2 = Arc::new(make_spec_with_range("a", Some(date(2025, 6, 1))));
753        ctx.insert_spec(Arc::clone(&v1)).unwrap();
754        ctx.insert_spec(Arc::clone(&v2)).unwrap();
755
756        let (from, to) = ctx.effective_range(&v1);
757        assert_eq!(from, Some(date(2025, 1, 1)));
758        assert_eq!(to, Some(date(2025, 6, 1)));
759
760        let (from, to) = ctx.effective_range(&v2);
761        assert_eq!(from, Some(date(2025, 6, 1)));
762        assert_eq!(to, None);
763    }
764
765    #[test]
766    fn effective_range_unbounded_start_with_successor() {
767        let mut ctx = Context::new();
768        let v1 = Arc::new(make_spec("a"));
769        let v2 = Arc::new(make_spec_with_range("a", Some(date(2025, 3, 1))));
770        ctx.insert_spec(Arc::clone(&v1)).unwrap();
771        ctx.insert_spec(Arc::clone(&v2)).unwrap();
772
773        let (from, to) = ctx.effective_range(&v1);
774        assert_eq!(from, None);
775        assert_eq!(to, Some(date(2025, 3, 1)));
776    }
777
778    // ─── Context::version_boundaries tests ───────────────────────────
779
780    #[test]
781    fn version_boundaries_single_unversioned() {
782        let mut ctx = Context::new();
783        ctx.insert_spec(Arc::new(make_spec("a"))).unwrap();
784
785        assert!(ctx.version_boundaries("a").is_empty());
786    }
787
788    #[test]
789    fn version_boundaries_multiple_versions() {
790        let mut ctx = Context::new();
791        ctx.insert_spec(Arc::new(make_spec("a"))).unwrap();
792        ctx.insert_spec(Arc::new(make_spec_with_range("a", Some(date(2025, 3, 1)))))
793            .unwrap();
794        ctx.insert_spec(Arc::new(make_spec_with_range("a", Some(date(2025, 6, 1)))))
795            .unwrap();
796
797        let boundaries = ctx.version_boundaries("a");
798        assert_eq!(boundaries, vec![date(2025, 3, 1), date(2025, 6, 1)]);
799    }
800
801    #[test]
802    fn version_boundaries_nonexistent_name() {
803        let ctx = Context::new();
804        assert!(ctx.version_boundaries("nope").is_empty());
805    }
806
807    // ─── Context::dep_coverage_gaps tests ────────────────────────────
808
809    #[test]
810    fn dep_coverage_no_versions_is_full_gap() {
811        let ctx = Context::new();
812        let gaps =
813            ctx.dep_coverage_gaps("missing", Some(&date(2025, 1, 1)), Some(&date(2025, 6, 1)));
814        assert_eq!(gaps, vec![(Some(date(2025, 1, 1)), Some(date(2025, 6, 1)))]);
815    }
816
817    #[test]
818    fn dep_coverage_single_unbounded_version_covers_everything() {
819        let mut ctx = Context::new();
820        ctx.insert_spec(Arc::new(make_spec("dep"))).unwrap();
821
822        let gaps = ctx.dep_coverage_gaps("dep", None, None);
823        assert!(gaps.is_empty());
824
825        let gaps = ctx.dep_coverage_gaps("dep", Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)));
826        assert!(gaps.is_empty());
827    }
828
829    #[test]
830    fn dep_coverage_single_version_with_from_leaves_leading_gap() {
831        let mut ctx = Context::new();
832        ctx.insert_spec(Arc::new(make_spec_with_range(
833            "dep",
834            Some(date(2025, 3, 1)),
835        )))
836        .unwrap();
837
838        let gaps = ctx.dep_coverage_gaps("dep", None, None);
839        assert_eq!(gaps, vec![(None, Some(date(2025, 3, 1)))]);
840    }
841
842    #[test]
843    fn dep_coverage_continuous_versions_no_gaps() {
844        let mut ctx = Context::new();
845        ctx.insert_spec(Arc::new(make_spec_with_range(
846            "dep",
847            Some(date(2025, 1, 1)),
848        )))
849        .unwrap();
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!(gaps.is_empty());
858    }
859
860    #[test]
861    fn dep_coverage_dep_starts_after_required_start() {
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", Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)));
870        assert_eq!(gaps, vec![(Some(date(2025, 1, 1)), Some(date(2025, 6, 1)))]);
871    }
872
873    #[test]
874    fn dep_coverage_unbounded_required_range() {
875        let mut ctx = Context::new();
876        ctx.insert_spec(Arc::new(make_spec_with_range(
877            "dep",
878            Some(date(2025, 6, 1)),
879        )))
880        .unwrap();
881
882        let gaps = ctx.dep_coverage_gaps("dep", None, None);
883        assert_eq!(gaps, vec![(None, Some(date(2025, 6, 1)))]);
884    }
885
886    fn add_lemma_code_blocking(
887        engine: &mut Engine,
888        code: &str,
889        source: &str,
890    ) -> Result<(), Vec<Error>> {
891        let files: HashMap<String, String> =
892            std::iter::once((source.to_string(), code.to_string())).collect();
893        engine.add_lemma_files(files)
894    }
895
896    #[test]
897    fn test_evaluate_spec_all_rules() {
898        let mut engine = Engine::new();
899        add_lemma_code_blocking(
900            &mut engine,
901            r#"
902        spec test
903        fact x: 10
904        fact y: 5
905        rule sum: x + y
906        rule product: x * y
907    "#,
908            "test.lemma",
909        )
910        .unwrap();
911
912        let now = DateTimeValue::now();
913        let response = engine
914            .evaluate("test", None, &now, vec![], HashMap::new())
915            .unwrap();
916        assert_eq!(response.results.len(), 2);
917
918        let sum_result = response
919            .results
920            .values()
921            .find(|r| r.rule.name == "sum")
922            .unwrap();
923        assert_eq!(
924            sum_result.result,
925            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
926                Decimal::from_str("15").unwrap()
927            )))
928        );
929
930        let product_result = response
931            .results
932            .values()
933            .find(|r| r.rule.name == "product")
934            .unwrap();
935        assert_eq!(
936            product_result.result,
937            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
938                Decimal::from_str("50").unwrap()
939            )))
940        );
941    }
942
943    #[test]
944    fn test_evaluate_empty_facts() {
945        let mut engine = Engine::new();
946        add_lemma_code_blocking(
947            &mut engine,
948            r#"
949        spec test
950        fact price: 100
951        rule total: price * 2
952    "#,
953            "test.lemma",
954        )
955        .unwrap();
956
957        let now = DateTimeValue::now();
958        let response = engine
959            .evaluate("test", None, &now, vec![], HashMap::new())
960            .unwrap();
961        assert_eq!(response.results.len(), 1);
962        assert_eq!(
963            response.results.values().next().unwrap().result,
964            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
965                Decimal::from_str("200").unwrap()
966            )))
967        );
968    }
969
970    #[test]
971    fn test_evaluate_boolean_rule() {
972        let mut engine = Engine::new();
973        add_lemma_code_blocking(
974            &mut engine,
975            r#"
976        spec test
977        fact age: 25
978        rule is_adult: age >= 18
979    "#,
980            "test.lemma",
981        )
982        .unwrap();
983
984        let now = DateTimeValue::now();
985        let response = engine
986            .evaluate("test", None, &now, vec![], HashMap::new())
987            .unwrap();
988        assert_eq!(
989            response.results.values().next().unwrap().result,
990            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::from_bool(true)))
991        );
992    }
993
994    #[test]
995    fn test_evaluate_with_unless_clause() {
996        let mut engine = Engine::new();
997        add_lemma_code_blocking(
998            &mut engine,
999            r#"
1000        spec test
1001        fact quantity: 15
1002        rule discount: 0
1003          unless quantity >= 10 then 10
1004    "#,
1005            "test.lemma",
1006        )
1007        .unwrap();
1008
1009        let now = DateTimeValue::now();
1010        let response = engine
1011            .evaluate("test", None, &now, vec![], HashMap::new())
1012            .unwrap();
1013        assert_eq!(
1014            response.results.values().next().unwrap().result,
1015            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1016                Decimal::from_str("10").unwrap()
1017            )))
1018        );
1019    }
1020
1021    #[test]
1022    fn test_spec_not_found() {
1023        let engine = Engine::new();
1024        let now = DateTimeValue::now();
1025        let result = engine.evaluate("nonexistent", None, &now, vec![], HashMap::new());
1026        assert!(result.is_err());
1027        assert!(result.unwrap_err().to_string().contains("not found"));
1028    }
1029
1030    #[test]
1031    fn test_multiple_specs() {
1032        let mut engine = Engine::new();
1033        add_lemma_code_blocking(
1034            &mut engine,
1035            r#"
1036        spec spec1
1037        fact x: 10
1038        rule result: x * 2
1039    "#,
1040            "spec 1.lemma",
1041        )
1042        .unwrap();
1043
1044        add_lemma_code_blocking(
1045            &mut engine,
1046            r#"
1047        spec spec2
1048        fact y: 5
1049        rule result: y * 3
1050    "#,
1051            "spec 2.lemma",
1052        )
1053        .unwrap();
1054
1055        let now = DateTimeValue::now();
1056        let response1 = engine
1057            .evaluate("spec1", None, &now, vec![], HashMap::new())
1058            .unwrap();
1059        assert_eq!(
1060            response1.results[0].result,
1061            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1062                Decimal::from_str("20").unwrap()
1063            )))
1064        );
1065
1066        let response2 = engine
1067            .evaluate("spec2", None, &now, vec![], HashMap::new())
1068            .unwrap();
1069        assert_eq!(
1070            response2.results[0].result,
1071            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1072                Decimal::from_str("15").unwrap()
1073            )))
1074        );
1075    }
1076
1077    #[test]
1078    fn test_runtime_error_mapping() {
1079        let mut engine = Engine::new();
1080        add_lemma_code_blocking(
1081            &mut engine,
1082            r#"
1083        spec test
1084        fact numerator: 10
1085        fact denominator: 0
1086        rule division: numerator / denominator
1087    "#,
1088            "test.lemma",
1089        )
1090        .unwrap();
1091
1092        let now = DateTimeValue::now();
1093        let result = engine.evaluate("test", None, &now, vec![], HashMap::new());
1094        // Division by zero returns a Veto (not an error)
1095        assert!(result.is_ok(), "Evaluation should succeed");
1096        let response = result.unwrap();
1097        let division_result = response
1098            .results
1099            .values()
1100            .find(|r| r.rule.name == "division");
1101        assert!(
1102            division_result.is_some(),
1103            "Should have division rule result"
1104        );
1105        match &division_result.unwrap().result {
1106            crate::OperationResult::Veto(message) => {
1107                assert!(
1108                    message
1109                        .as_ref()
1110                        .map(|m| m.contains("Division by zero"))
1111                        .unwrap_or(false),
1112                    "Veto message should mention division by zero: {:?}",
1113                    message
1114                );
1115            }
1116            other => panic!("Expected Veto for division by zero, got {:?}", other),
1117        }
1118    }
1119
1120    #[test]
1121    fn test_rules_sorted_by_source_order() {
1122        let mut engine = Engine::new();
1123        add_lemma_code_blocking(
1124            &mut engine,
1125            r#"
1126        spec test
1127        fact a: 1
1128        fact b: 2
1129        rule z: a + b
1130        rule y: a * b
1131        rule x: a - b
1132    "#,
1133            "test.lemma",
1134        )
1135        .unwrap();
1136
1137        let now = DateTimeValue::now();
1138        let response = engine
1139            .evaluate("test", None, &now, vec![], HashMap::new())
1140            .unwrap();
1141        assert_eq!(response.results.len(), 3);
1142
1143        // Verify source positions increase (z < y < x)
1144        let z_pos = response
1145            .results
1146            .values()
1147            .find(|r| r.rule.name == "z")
1148            .unwrap()
1149            .rule
1150            .source_location
1151            .span
1152            .start;
1153        let y_pos = response
1154            .results
1155            .values()
1156            .find(|r| r.rule.name == "y")
1157            .unwrap()
1158            .rule
1159            .source_location
1160            .span
1161            .start;
1162        let x_pos = response
1163            .results
1164            .values()
1165            .find(|r| r.rule.name == "x")
1166            .unwrap()
1167            .rule
1168            .source_location
1169            .span
1170            .start;
1171
1172        assert!(z_pos < y_pos);
1173        assert!(y_pos < x_pos);
1174    }
1175
1176    #[test]
1177    fn test_rule_filtering_evaluates_dependencies() {
1178        let mut engine = Engine::new();
1179        add_lemma_code_blocking(
1180            &mut engine,
1181            r#"
1182        spec test
1183        fact base: 100
1184        rule subtotal: base * 2
1185        rule tax: subtotal * 10%
1186        rule total: subtotal + tax
1187    "#,
1188            "test.lemma",
1189        )
1190        .unwrap();
1191
1192        // Request only 'total', but it depends on 'subtotal' and 'tax'
1193        let now = DateTimeValue::now();
1194        let response = engine
1195            .evaluate(
1196                "test",
1197                None,
1198                &now,
1199                vec!["total".to_string()],
1200                HashMap::new(),
1201            )
1202            .unwrap();
1203
1204        // Only 'total' should be in results
1205        assert_eq!(response.results.len(), 1);
1206        assert_eq!(response.results.keys().next().unwrap(), "total");
1207
1208        // But the value should be correct (dependencies were computed)
1209        let total = response.results.values().next().unwrap();
1210        assert_eq!(
1211            total.result,
1212            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1213                Decimal::from_str("220").unwrap()
1214            )))
1215        );
1216    }
1217
1218    // -------------------------------------------------------------------
1219    // Pre-resolved dependency tests (Engine never fetches from registry)
1220    // -------------------------------------------------------------------
1221
1222    use crate::parsing::ast::DateTimeValue;
1223
1224    #[test]
1225    fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1226        let mut engine = Engine::new();
1227        let mut files = HashMap::new();
1228        files.insert(
1229            "main.lemma".to_string(),
1230            r#"spec main_spec
1231fact external: spec @org/project/helper
1232rule value: external.quantity"#
1233                .to_string(),
1234        );
1235        files.insert(
1236            "deps/org_project_helper.lemma".to_string(),
1237            "spec @org/project/helper\nfact quantity: 42".to_string(),
1238        );
1239        engine
1240            .add_lemma_files(files)
1241            .expect("should succeed with pre-resolved deps in file map");
1242
1243        let now = DateTimeValue::now();
1244        let response = engine
1245            .evaluate("main_spec", None, &now, vec![], HashMap::new())
1246            .expect("evaluate should succeed");
1247
1248        let value_result = response
1249            .results
1250            .get("value")
1251            .expect("rule 'value' should exist");
1252        assert_eq!(
1253            value_result.result,
1254            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1255                Decimal::from_str("42").unwrap()
1256            )))
1257        );
1258    }
1259
1260    #[test]
1261    fn add_lemma_files_no_external_refs_works() {
1262        let mut engine = Engine::new();
1263
1264        add_lemma_code_blocking(
1265            &mut engine,
1266            r#"spec local_only
1267fact price: 100
1268rule doubled: price * 2"#,
1269            "local.lemma",
1270        )
1271        .expect("should succeed when there are no @... references");
1272
1273        let now = DateTimeValue::now();
1274        let response = engine
1275            .evaluate("local_only", None, &now, vec![], HashMap::new())
1276            .expect("evaluate should succeed");
1277
1278        assert!(response.results.contains_key("doubled"));
1279    }
1280
1281    #[test]
1282    fn unresolved_external_ref_without_deps_fails() {
1283        let mut engine = Engine::new();
1284
1285        let result = add_lemma_code_blocking(
1286            &mut engine,
1287            r#"spec main_spec
1288fact external: spec @org/project/missing
1289rule value: external.quantity"#,
1290            "main.lemma",
1291        );
1292
1293        assert!(
1294            result.is_err(),
1295            "Should fail when @... dep is not in file map"
1296        );
1297    }
1298
1299    #[test]
1300    fn pre_resolved_deps_with_spec_and_type_refs() {
1301        let mut engine = Engine::new();
1302        let mut files = HashMap::new();
1303        files.insert(
1304            "main.lemma".to_string(),
1305            r#"spec registry_demo
1306type money from @lemma/std/finance
1307fact unit_price: 5 eur
1308fact helper: spec @org/example/helper
1309rule helper_value: helper.value
1310rule line_total: unit_price * 2
1311rule formatted: helper_value + 0"#
1312                .to_string(),
1313        );
1314        files.insert(
1315            "deps/helper.lemma".to_string(),
1316            "spec @org/example/helper\nfact value: 42".to_string(),
1317        );
1318        files.insert(
1319            "deps/finance.lemma".to_string(),
1320            "spec @lemma/std/finance\ntype money: scale\n -> unit eur 1.00\n -> decimals 2"
1321                .to_string(),
1322        );
1323        engine
1324            .add_lemma_files(files)
1325            .expect("should succeed with pre-resolved spec and type deps");
1326
1327        let now = DateTimeValue::now();
1328        let response = engine
1329            .evaluate("registry_demo", None, &now, vec![], HashMap::new())
1330            .expect("evaluate should succeed");
1331
1332        assert!(response.results.contains_key("helper_value"));
1333        assert!(response.results.contains_key("formatted"));
1334    }
1335
1336    #[test]
1337    fn add_lemma_files_returns_all_errors_not_just_first() {
1338        let mut engine = Engine::new();
1339
1340        let result = add_lemma_code_blocking(
1341            &mut engine,
1342            r#"spec demo
1343type money from nonexistent_type_source
1344fact helper: spec nonexistent_spec
1345fact price: 10
1346rule total: helper.value + price"#,
1347            "test.lemma",
1348        );
1349
1350        assert!(result.is_err(), "Should fail with multiple errors");
1351        let errs = result.unwrap_err();
1352        assert!(
1353            errs.len() >= 2,
1354            "expected at least 2 errors (type + spec ref), got {}",
1355            errs.len()
1356        );
1357        let error_message = errs
1358            .iter()
1359            .map(ToString::to_string)
1360            .collect::<Vec<_>>()
1361            .join("; ");
1362
1363        assert!(
1364            error_message.contains("money"),
1365            "Should mention type error about 'money'. Got:\n{}",
1366            error_message
1367        );
1368        assert!(
1369            error_message.contains("nonexistent_spec"),
1370            "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
1371            error_message
1372        );
1373    }
1374
1375    // ── Default value type validation ────────────────────────────────
1376    // Planning must reject default values that don't match the type.
1377    // These tests cover both primitives and named types (which the parser
1378    // can't validate because it doesn't resolve type names).
1379
1380    #[test]
1381    fn planning_rejects_invalid_number_default() {
1382        let mut engine = Engine::new();
1383        let result = add_lemma_code_blocking(
1384            &mut engine,
1385            "spec t\nfact x: [number -> default \"10 $$\"]\nrule r: x",
1386            "t.lemma",
1387        );
1388        assert!(
1389            result.is_err(),
1390            "must reject non-numeric default on number type"
1391        );
1392    }
1393
1394    #[test]
1395    fn planning_rejects_text_literal_as_number_default() {
1396        // The parser produces CommandArg::Text("10") for `default "10"`.
1397        // Planning now checks the CommandArg variant: a Text literal is
1398        // rejected where a Number literal is required, even though the
1399        // string content "10" could be parsed as a valid Decimal.
1400        let mut engine = Engine::new();
1401        let result = add_lemma_code_blocking(
1402            &mut engine,
1403            "spec t\nfact x: [number -> default \"10\"]\nrule r: x",
1404            "t.lemma",
1405        );
1406        assert!(
1407            result.is_err(),
1408            "must reject text literal \"10\" as default for number type"
1409        );
1410    }
1411
1412    #[test]
1413    fn planning_rejects_invalid_boolean_default() {
1414        let mut engine = Engine::new();
1415        let result = add_lemma_code_blocking(
1416            &mut engine,
1417            "spec t\nfact x: [boolean -> default \"maybe\"]\nrule r: x",
1418            "t.lemma",
1419        );
1420        assert!(
1421            result.is_err(),
1422            "must reject non-boolean default on boolean type"
1423        );
1424    }
1425
1426    #[test]
1427    fn planning_rejects_invalid_named_type_default() {
1428        // Named type: the parser can't validate this, only planning can.
1429        let mut engine = Engine::new();
1430        let result = add_lemma_code_blocking(
1431            &mut engine,
1432            "spec t\ntype custom: number -> minimum 0\nfact x: [custom -> default \"abc\"]\nrule r: x",
1433            "t.lemma",
1434        );
1435        assert!(
1436            result.is_err(),
1437            "must reject non-numeric default on named number type"
1438        );
1439    }
1440
1441    #[test]
1442    fn planning_accepts_valid_number_default() {
1443        let mut engine = Engine::new();
1444        let result = add_lemma_code_blocking(
1445            &mut engine,
1446            "spec t\nfact x: [number -> default 10]\nrule r: x",
1447            "t.lemma",
1448        );
1449        assert!(result.is_ok(), "must accept valid number default");
1450    }
1451
1452    #[test]
1453    fn planning_accepts_valid_boolean_default() {
1454        let mut engine = Engine::new();
1455        let result = add_lemma_code_blocking(
1456            &mut engine,
1457            "spec t\nfact x: [boolean -> default true]\nrule r: x",
1458            "t.lemma",
1459        );
1460        assert!(result.is_ok(), "must accept valid boolean default");
1461    }
1462
1463    #[test]
1464    fn planning_accepts_valid_text_default() {
1465        let mut engine = Engine::new();
1466        let result = add_lemma_code_blocking(
1467            &mut engine,
1468            "spec t\nfact x: [text -> default \"hello\"]\nrule r: x",
1469            "t.lemma",
1470        );
1471        assert!(result.is_ok(), "must accept valid text default");
1472    }
1473}