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}
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    /// Create an engine with custom resource limits.
351    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    /// Get the content hash (hash pin) for the temporal version active at `effective`.
363    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    /// Get the content hash for a specific spec (by arc). Used when the resolved spec is already known.
369    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    /// Get all hash pins as (spec_name, effective_from_display, hash) triples.
374    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    /// Get the spec with the given name whose content hash matches `hash_pin`.
388    /// Returns `None` if no such spec exists or if multiple versions match (hash collision).
389    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    /// Add Lemma source files, parse them, and build execution plans.
407    ///
408    /// External `@...` references must already be resolved: include dependency
409    /// `.lemma` files in the `files` map. The engine never
410    /// performs network calls. Use `resolve_registry_references` separately
411    /// (e.g. in `lemma fetch`) to download dependencies before calling this.
412    ///
413    /// - Validates and resolves types **once** across all specs
414    /// - Collects **all** errors across all files (parse, planning) instead of aborting on the first
415    ///
416    /// `files` maps source identifiers (e.g. file paths) to source code.
417    /// For a single file, pass a one-entry `HashMap`.
418    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    /// All specs, all temporal versions, ordered by (name, effective_from).
486    pub fn list_specs(&self) -> Vec<Arc<LemmaSpec>> {
487        self.specs.iter().collect()
488    }
489
490    /// Specs active at `effective` (one per name).
491    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    /// Get spec by name at a specific time.
509    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    /// Build a "not found" error that includes the effective date and lists
518    /// available temporal versions when the spec name exists but no temporal version
519    /// matches the requested time.
520    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    /// Get the execution plan for a spec.
543    ///
544    /// When `hash_pin` is `Some`, resolves the spec by content hash for that name,
545    /// then returns the slice plan that covers `effective`. When `hash_pin` is `None`,
546    /// resolves the temporal version active at `effective` then finds the covering slice plan.
547    /// Returns `None` when the spec does not exist or has no matching plan.
548    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    /// Evaluate rules in a spec with JSON values for facts.
582    ///
583    /// This is a convenience method that accepts JSON directly and converts it
584    /// to typed values using the spec's fact type declarations.
585    ///
586    /// If `rule_names` is empty, evaluates all rules.
587    /// Otherwise, only returns results for the specified rules (dependencies still computed).
588    ///
589    /// Values are provided as JSON bytes (e.g., `b"{\"quantity\": 5, \"is_member\": true}"`).
590    /// They are automatically parsed to the expected type based on the spec schema.
591    ///
592    /// When `hash_pin` is `Some`, the spec is resolved by that content hash; otherwise
593    /// by temporal resolution at `effective`. Evaluation uses the resolved plan.
594    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    /// Evaluate rules in a spec with string values for facts.
613    ///
614    /// This is the user-friendly API that accepts raw string values and parses them
615    /// to the appropriate types based on the spec's fact type declarations.
616    /// Use this for CLI, HTTP APIs, and other user-facing interfaces.
617    ///
618    /// If `rule_names` is empty, evaluates all rules.
619    /// Otherwise, only returns results for the specified rules (dependencies still computed).
620    ///
621    /// Fact values are provided as name -> value string pairs (e.g., "type" -> "latte").
622    /// They are automatically parsed to the expected type based on the spec schema.
623    ///
624    /// When `hash_pin` is `Some`, the spec is resolved by that content hash; otherwise
625    /// by temporal resolution at `effective`. Evaluation uses the resolved plan.
626    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    /// Invert a rule to find input domains that produce a desired outcome.
646    ///
647    /// Values are provided as name -> value string pairs (e.g., "quantity" -> "5").
648    /// They are automatically parsed to the expected type based on the spec schema.
649    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    // ─── Context::effective_range tests ──────────────────────────────
723
724    #[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    // ─── Context::version_boundaries tests ───────────────────────────
766
767    #[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    // ─── Context::dep_coverage_gaps tests ────────────────────────────
795
796    #[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        // Division by zero returns a Veto (not an error)
1082        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        // Verify source positions increase (z < y < x)
1131        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        // Request only 'total', but it depends on 'subtotal' and 'tax'
1180        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        // Only 'total' should be in results
1192        assert_eq!(response.results.len(), 1);
1193        assert_eq!(response.results.keys().next().unwrap(), "total");
1194
1195        // But the value should be correct (dependencies were computed)
1196        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    // -------------------------------------------------------------------
1206    // Pre-resolved dependency tests (Engine never fetches from registry)
1207    // -------------------------------------------------------------------
1208
1209    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    // ── Default value type validation ────────────────────────────────
1363    // Planning must reject default values that don't match the type.
1364    // These tests cover both primitives and named types (which the parser
1365    // can't validate because it doesn't resolve type names).
1366
1367    #[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        // The parser produces CommandArg::Text("10") for `default "10"`.
1384        // Planning now checks the CommandArg variant: a Text literal is
1385        // rejected where a Number literal is required, even though the
1386        // string content "10" could be parsed as a valid Decimal.
1387        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        // Named type: the parser can't validate this, only planning can.
1416        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}