Skip to main content

lemma/
engine.rs

1use crate::evaluation::Evaluator;
2use crate::parsing::ast::{DateTimeValue, LemmaSpec};
3use crate::planning::SpecSchema;
4use crate::spec_id;
5use crate::{parse, Error, ResourceLimits, Response};
6use std::collections::{BTreeMap, HashMap};
7use std::sync::Arc;
8
9#[cfg(not(target_arch = "wasm32"))]
10use std::collections::HashSet;
11#[cfg(not(target_arch = "wasm32"))]
12use std::path::Path;
13
14/// Load failure: errors plus the source files we attempted to load.
15#[derive(Debug, Clone)]
16pub struct Errors {
17    pub errors: Vec<Error>,
18    pub sources: HashMap<String, String>,
19}
20
21impl Errors {
22    /// Iterate over the errors.
23    pub fn iter(&self) -> std::slice::Iter<'_, Error> {
24        self.errors.iter()
25    }
26}
27
28// ─── Temporal bound for Option<DateTimeValue> comparisons ────────────
29
30/// Explicit representation of a temporal bound, eliminating the ambiguity
31/// of `Option<DateTimeValue>` where `None` means `-∞` for start bounds
32/// and `+∞` for end bounds.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub(crate) enum TemporalBound {
35    NegInf,
36    At(DateTimeValue),
37    PosInf,
38}
39
40impl PartialOrd for TemporalBound {
41    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
42        Some(self.cmp(other))
43    }
44}
45
46impl Ord for TemporalBound {
47    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
48        use std::cmp::Ordering;
49        match (self, other) {
50            (TemporalBound::NegInf, TemporalBound::NegInf) => Ordering::Equal,
51            (TemporalBound::NegInf, _) => Ordering::Less,
52            (_, TemporalBound::NegInf) => Ordering::Greater,
53            (TemporalBound::PosInf, TemporalBound::PosInf) => Ordering::Equal,
54            (TemporalBound::PosInf, _) => Ordering::Greater,
55            (_, TemporalBound::PosInf) => Ordering::Less,
56            (TemporalBound::At(a), TemporalBound::At(b)) => a.cmp(b),
57        }
58    }
59}
60
61impl TemporalBound {
62    /// Convert an `Option<&DateTimeValue>` used as a start bound (None = -∞).
63    pub(crate) fn from_start(opt: Option<&DateTimeValue>) -> Self {
64        match opt {
65            None => TemporalBound::NegInf,
66            Some(d) => TemporalBound::At(d.clone()),
67        }
68    }
69
70    /// Convert an `Option<&DateTimeValue>` used as an end bound (None = +∞).
71    pub(crate) fn from_end(opt: Option<&DateTimeValue>) -> Self {
72        match opt {
73            None => TemporalBound::PosInf,
74            Some(d) => TemporalBound::At(d.clone()),
75        }
76    }
77
78    /// Convert back to `Option<DateTimeValue>` for a start bound (NegInf → None).
79    pub(crate) fn to_start(&self) -> Option<DateTimeValue> {
80        match self {
81            TemporalBound::NegInf => None,
82            TemporalBound::At(d) => Some(d.clone()),
83            TemporalBound::PosInf => {
84                unreachable!("BUG: PosInf cannot represent a start bound")
85            }
86        }
87    }
88
89    /// Convert back to `Option<DateTimeValue>` for an end bound (PosInf → None).
90    pub(crate) fn to_end(&self) -> Option<DateTimeValue> {
91        match self {
92            TemporalBound::NegInf => {
93                unreachable!("BUG: NegInf cannot represent an end bound")
94            }
95            TemporalBound::At(d) => Some(d.clone()),
96            TemporalBound::PosInf => None,
97        }
98    }
99}
100
101// ─── Spec store with temporal resolution ──────────────────────────────
102
103/// Ordered set of specs with temporal versioning.
104///
105/// Specs with the same name are ordered by effective_from.
106/// A temporal version's end is derived from the next temporal version's effective_from, or +inf.
107#[derive(Debug, Default)]
108/// Index: name -> (effective_from -> Arc<LemmaSpec>). Lookups by spec_id (name, effective_from) are O(log n).
109pub struct Context {
110    specs: BTreeMap<String, BTreeMap<Option<DateTimeValue>, Arc<LemmaSpec>>>,
111}
112
113impl Context {
114    pub fn new() -> Self {
115        Self {
116            specs: BTreeMap::new(),
117        }
118    }
119
120    pub(crate) fn specs_for_name(&self, name: &str) -> Vec<Arc<LemmaSpec>> {
121        self.specs
122            .get(name)
123            .map(|m| m.values().cloned().collect())
124            .unwrap_or_default()
125    }
126
127    /// Exact identity lookup by (name, effective_from).
128    ///
129    /// None matches specs without temporal versioning.
130    /// Some(d) matches the temporal version whose effective_from equals d.
131    pub fn get_spec_effective_from(
132        &self,
133        name: &str,
134        effective_from: Option<&DateTimeValue>,
135    ) -> Option<Arc<LemmaSpec>> {
136        let key = effective_from.cloned();
137        self.specs.get(name).and_then(|m| m.get(&key).cloned())
138    }
139
140    /// Temporal range resolution: find the temporal version of `name` that is active at `effective`.
141    ///
142    /// A spec is active at `effective` when:
143    ///   effective_from <= effective < effective_to
144    /// where `effective_to` is the next temporal version's `effective_from`, or +∞ if there is no successor.
145    ///
146    /// Open bounds: `effective_from() == None` on a spec means no lower bound (treat as active from −∞).
147    /// For the upper bound, `effective_to.map(...).unwrap_or(true)` means “no next slice” ⇒ active until +∞.
148    pub fn get_spec(&self, name: &str, effective: &DateTimeValue) -> Option<Arc<LemmaSpec>> {
149        let versions = self.specs_for_name(name);
150        if versions.is_empty() {
151            return None;
152        }
153
154        for (i, spec) in versions.iter().enumerate() {
155            let from_ok = spec
156                .effective_from()
157                .map(|f| *effective >= *f)
158                .unwrap_or(true);
159            if !from_ok {
160                continue;
161            }
162
163            let effective_to: Option<&DateTimeValue> =
164                versions.get(i + 1).and_then(|next| next.effective_from());
165            let to_ok = effective_to.map(|end| *effective < *end).unwrap_or(true);
166
167            if to_ok {
168                return Some(spec.clone());
169            }
170        }
171
172        None
173    }
174
175    pub fn iter(&self) -> impl Iterator<Item = Arc<LemmaSpec>> + '_ {
176        self.specs.values().flat_map(|m| m.values().cloned())
177    }
178
179    /// Insert a spec. Set `from_registry` to `true` for pre-fetched registry
180    /// specs; `false` rejects `@`-prefixed spec definitions.
181    ///
182    /// When `from_registry` is true, only `@`-prefixed specs are accepted —
183    /// registry bundles must not introduce bare-named specs into the local namespace.
184    pub fn insert_spec(&mut self, spec: Arc<LemmaSpec>, from_registry: bool) -> Result<(), Error> {
185        if spec.from_registry && !from_registry {
186            return Err(Error::validation_with_context(
187                format!(
188                    "Spec '{}' uses '@' registry prefix, which is reserved for dependencies",
189                    spec.name
190                ),
191                None,
192                Some("Remove the '@' prefix, or load this file as a dependency."),
193                Some(Arc::clone(&spec)),
194                None,
195            ));
196        }
197
198        if from_registry && !spec.from_registry {
199            return Err(Error::validation_with_context(
200                format!(
201                    "Registry bundle contains spec '{}' without '@' prefix; \
202                     all specs in a registry bundle must use '@'-prefixed names \
203                     to avoid conflicts with local specs",
204                    spec.name
205                ),
206                None,
207                Some("Prefix the spec name with '@' (e.g. spec @org/project/name)."),
208                Some(Arc::clone(&spec)),
209                None,
210            ));
211        }
212
213        let key = spec.effective_from().cloned();
214        if self
215            .specs
216            .get(&spec.name)
217            .is_some_and(|m| m.contains_key(&key))
218        {
219            return Err(Error::validation_with_context(
220                format!(
221                    "Duplicate spec '{}' (same name and effective_from already in context)",
222                    spec.name
223                ),
224                None,
225                None::<String>,
226                Some(Arc::clone(&spec)),
227                None,
228            ));
229        }
230
231        self.specs
232            .entry(spec.name.clone())
233            .or_default()
234            .insert(key, spec);
235        Ok(())
236    }
237
238    pub fn remove_spec(&mut self, spec: &Arc<LemmaSpec>) -> bool {
239        let key = spec.effective_from().cloned();
240        self.specs
241            .get_mut(&spec.name)
242            .and_then(|m| m.remove(&key))
243            .is_some()
244    }
245
246    #[cfg(test)]
247    pub(crate) fn len(&self) -> usize {
248        self.specs.values().map(|m| m.len()).sum()
249    }
250
251    // ─── Temporal helpers ────────────────────────────────────────────
252
253    /// Returns the effective range `[from, to)` for a spec in this context.
254    ///
255    /// - `from`: `spec.effective_from()` (None = -∞)
256    /// - `to`: next temporal version's `effective_from`, or None (+∞) if no successor.
257    pub fn effective_range(
258        &self,
259        spec: &Arc<LemmaSpec>,
260    ) -> (Option<DateTimeValue>, Option<DateTimeValue>) {
261        let from = spec.effective_from().cloned();
262        let versions = self.specs_for_name(&spec.name);
263        let pos = versions
264            .iter()
265            .position(|v| Arc::ptr_eq(v, spec))
266            .unwrap_or_else(|| {
267                unreachable!(
268                    "BUG: effective_range called with spec '{}' not in context",
269                    spec.name
270                )
271            });
272        let to = versions
273            .get(pos + 1)
274            .and_then(|next| next.effective_from().cloned());
275        (from, to)
276    }
277
278    /// Returns all `effective_from` dates for temporal versions of `name`, sorted ascending.
279    /// Temporal versions without `effective_from` are excluded (they represent -∞).
280    pub fn version_boundaries(&self, name: &str) -> Vec<DateTimeValue> {
281        self.specs_for_name(name)
282            .iter()
283            .filter_map(|s| s.effective_from().cloned())
284            .collect()
285    }
286
287    /// Check if temporal versions of `dep_name` fully cover the range
288    /// `[required_from, required_to)`.
289    ///
290    /// Returns gaps as `(start, end)` intervals. Empty vec = fully covered.
291    /// Start: None = -∞, End: None = +∞.
292    pub fn dep_coverage_gaps(
293        &self,
294        dep_name: &str,
295        required_from: Option<&DateTimeValue>,
296        required_to: Option<&DateTimeValue>,
297    ) -> Vec<(Option<DateTimeValue>, Option<DateTimeValue>)> {
298        let versions = self.specs_for_name(dep_name);
299        if versions.is_empty() {
300            return vec![(required_from.cloned(), required_to.cloned())];
301        }
302
303        let req_start = TemporalBound::from_start(required_from);
304        let req_end = TemporalBound::from_end(required_to);
305
306        let intervals: Vec<(TemporalBound, TemporalBound)> = versions
307            .iter()
308            .enumerate()
309            .map(|(i, v)| {
310                let start = TemporalBound::from_start(v.effective_from());
311                let end = match versions.get(i + 1).and_then(|next| next.effective_from()) {
312                    Some(next_from) => TemporalBound::At(next_from.clone()),
313                    None => TemporalBound::PosInf,
314                };
315                (start, end)
316            })
317            .collect();
318
319        let mut gaps = Vec::new();
320        let mut cursor = req_start.clone();
321
322        for (v_start, v_end) in &intervals {
323            if cursor >= req_end {
324                break;
325            }
326
327            if *v_end <= cursor {
328                continue;
329            }
330
331            if *v_start > cursor {
332                let gap_end = std::cmp::min(v_start.clone(), req_end.clone());
333                if cursor < gap_end {
334                    gaps.push((cursor.to_start(), gap_end.to_end()));
335                }
336            }
337
338            if *v_end > cursor {
339                cursor = v_end.clone();
340            }
341        }
342
343        if cursor < req_end {
344            gaps.push((cursor.to_start(), req_end.to_end()));
345        }
346
347        gaps
348    }
349}
350
351// ─── Slice plan lookup ───────────────────────────────────────────────
352
353/// Find the plan whose `[valid_from, valid_to)` interval contains `effective`.
354///
355/// `None` for `valid_from` / `valid_to` means an open interval on that side (same convention as [`Engine::get_spec`]).
356fn find_slice_plan<'a>(
357    plans: &'a [crate::planning::ExecutionPlan],
358    effective: &DateTimeValue,
359) -> Option<&'a crate::planning::ExecutionPlan> {
360    for plan in plans {
361        let from_ok = plan
362            .valid_from
363            .as_ref()
364            .map(|f| *effective >= *f)
365            .unwrap_or(true);
366        let to_ok = plan
367            .valid_to
368            .as_ref()
369            .map(|t| *effective < *t)
370            .unwrap_or(true);
371        if from_ok && to_ok {
372            return Some(plan);
373        }
374    }
375    None
376}
377
378// ─── Engine ──────────────────────────────────────────────────────────
379
380/// How a single buffer is identified in parse/plan diagnostics and the engine source map.
381#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382pub enum SourceType<'a> {
383    /// Path, URI, test name, or any non-empty stable id.
384    Labeled(&'a str),
385    /// No stable path (pasted string, REPL). Stored under [`SourceType::INLINE_KEY`].
386    Inline,
387    // Pre-resolved registry bundle
388    Dependency(&'a str),
389}
390
391impl SourceType<'_> {
392    /// Source map key and span attribute for [`SourceType::Inline`].
393    pub const INLINE_KEY: &'static str = "inline source (no path)";
394
395    fn storage_key(self) -> Result<String, Vec<Error>> {
396        match self {
397            SourceType::Labeled(s) => {
398                if s.trim().is_empty() {
399                    return Err(vec![Error::request(
400                        "source label must be non-empty, or use SourceType::Inline",
401                        None::<String>,
402                    )]);
403                }
404                Ok(s.to_string())
405            }
406            SourceType::Inline => Ok(Self::INLINE_KEY.to_string()),
407            SourceType::Dependency(s) => Ok(s.to_string()),
408        }
409    }
410}
411
412/// Engine for evaluating Lemma rules.
413///
414/// Pure Rust implementation that evaluates Lemma specs directly from the AST.
415/// Uses pre-built execution plans that are self-contained and ready for evaluation.
416///
417/// The engine never performs network calls. External `@...` references must be
418/// pre-resolved before loading — either by including dep files
419/// in the file map or by calling `resolve_registry_references` separately
420/// (e.g. in a `lemma fetch` command).
421pub struct Engine {
422    execution_plans: HashMap<Arc<LemmaSpec>, Vec<crate::planning::ExecutionPlan>>,
423    plan_hash_registry: crate::planning::PlanHashRegistry,
424    specs: Context,
425    sources: HashMap<String, String>,
426    evaluator: Evaluator,
427    limits: ResourceLimits,
428    total_expression_count: usize,
429}
430
431impl Default for Engine {
432    fn default() -> Self {
433        Self {
434            execution_plans: HashMap::new(),
435            plan_hash_registry: crate::planning::PlanHashRegistry::default(),
436            specs: Context::new(),
437            sources: HashMap::new(),
438            evaluator: Evaluator,
439            limits: ResourceLimits::default(),
440            total_expression_count: 0,
441        }
442    }
443}
444
445impl Engine {
446    pub fn new() -> Self {
447        Self::default()
448    }
449
450    /// Source code map (attribute -> content). Used for error display.
451    pub fn sources(&self) -> &HashMap<String, String> {
452        &self.sources
453    }
454
455    /// Create an engine with custom resource limits.
456    pub fn with_limits(limits: ResourceLimits) -> Self {
457        Self {
458            execution_plans: HashMap::new(),
459            plan_hash_registry: crate::planning::PlanHashRegistry::default(),
460            specs: Context::new(),
461            sources: HashMap::new(),
462            evaluator: Evaluator,
463            limits,
464            total_expression_count: 0,
465        }
466    }
467
468    /// Load a single spec from source code.
469    /// When `source` is [`SourceType::Dependency`], content is treated as from a registry bundle (`from_registry: true`).
470    pub fn load(&mut self, code: &str, source: SourceType<'_>) -> Result<(), Errors> {
471        let from_registry = matches!(source, SourceType::Dependency(_));
472        let mut files = HashMap::new();
473        let key = source.storage_key().map_err(|errs| Errors {
474            errors: errs,
475            sources: HashMap::new(),
476        })?;
477        files.insert(key, code.to_string());
478        self.add_files_inner(files, from_registry)
479    }
480
481    /// Load .lemma files from paths (files and/or directories). Directories are expanded one level only (direct child .lemma files). Resource limits `max_files`, `max_loaded_bytes`, `max_file_size_bytes` are enforced in [`add_files_inner`].
482    ///
483    /// Set `from_registry` to `true` for pre-fetched registry bundles (same rules as [`Context::insert_spec`] with `from_registry`). Not available on wasm32 (no filesystem).
484    #[cfg(not(target_arch = "wasm32"))]
485    pub fn load_from_paths<P: AsRef<Path>>(
486        &mut self,
487        paths: &[P],
488        from_registry: bool,
489    ) -> Result<(), Errors> {
490        use std::fs;
491
492        let mut files = HashMap::new();
493        let mut seen = HashSet::<String>::new();
494
495        for path in paths {
496            let path = path.as_ref();
497            if path.is_file() {
498                // Skip non-`.lemma` files (extension missing or wrong).
499                if !path.extension().map(|e| e == "lemma").unwrap_or(false) {
500                    continue;
501                }
502                let key = path.display().to_string();
503                if seen.contains(&key) {
504                    continue;
505                }
506                seen.insert(key.clone());
507                let content = fs::read_to_string(path).map_err(|e| Errors {
508                    errors: vec![Error::request(
509                        format!("Cannot read '{}': {}", path.display(), e),
510                        None::<String>,
511                    )],
512                    sources: HashMap::new(),
513                })?;
514                files.insert(key, content);
515            } else if path.is_dir() {
516                let read_dir = fs::read_dir(path).map_err(|e| Errors {
517                    errors: vec![Error::request(
518                        format!("Cannot read directory '{}': {}", path.display(), e),
519                        None::<String>,
520                    )],
521                    sources: HashMap::new(),
522                })?;
523                for entry in read_dir.filter_map(Result::ok) {
524                    let p = entry.path();
525                    if !p.is_file() || !p.extension().map(|e| e == "lemma").unwrap_or(false) {
526                        continue;
527                    }
528                    let key = p.display().to_string();
529                    if seen.contains(&key) {
530                        continue;
531                    }
532                    seen.insert(key.clone());
533                    let Ok(content) = fs::read_to_string(&p) else {
534                        continue;
535                    };
536                    files.insert(key, content);
537                }
538            }
539        }
540
541        self.add_files_inner(files, from_registry)
542    }
543
544    fn add_files_inner(
545        &mut self,
546        files: HashMap<String, String>,
547        from_registry: bool,
548    ) -> Result<(), Errors> {
549        let limits = &self.limits;
550        if files.len() > limits.max_files {
551            return Err(Errors {
552                errors: vec![Error::resource_limit_exceeded(
553                    "max_files",
554                    limits.max_files.to_string(),
555                    files.len().to_string(),
556                    "Reduce the number of paths or files",
557                    None::<crate::Source>,
558                    None,
559                    None,
560                )],
561                sources: files,
562            });
563        }
564        let total_loaded_bytes: usize = files.values().map(|s| s.len()).sum();
565        if total_loaded_bytes > limits.max_loaded_bytes {
566            return Err(Errors {
567                errors: vec![Error::resource_limit_exceeded(
568                    "max_loaded_bytes",
569                    limits.max_loaded_bytes.to_string(),
570                    total_loaded_bytes.to_string(),
571                    "Load fewer or smaller files",
572                    None::<crate::Source>,
573                    None,
574                    None,
575                )],
576                sources: files,
577            });
578        }
579        for code in files.values() {
580            if code.len() > limits.max_file_size_bytes {
581                return Err(Errors {
582                    errors: vec![Error::resource_limit_exceeded(
583                        "max_file_size_bytes",
584                        limits.max_file_size_bytes.to_string(),
585                        code.len().to_string(),
586                        "Use a smaller file or increase limit",
587                        None::<crate::Source>,
588                        None,
589                        None,
590                    )],
591                    sources: files,
592                });
593            }
594        }
595
596        let mut errors: Vec<Error> = Vec::new();
597
598        for (source_id, code) in &files {
599            match parse(code, source_id, &self.limits) {
600                Ok(result) => {
601                    self.total_expression_count += result.expression_count;
602                    if self.total_expression_count > self.limits.max_total_expression_count {
603                        errors.push(Error::resource_limit_exceeded(
604                            "max_total_expression_count",
605                            self.limits.max_total_expression_count.to_string(),
606                            self.total_expression_count.to_string(),
607                            "Split logic across fewer files or reduce expression complexity",
608                            None::<crate::Source>,
609                            None,
610                            None,
611                        ));
612                        return Err(Errors {
613                            errors,
614                            sources: files,
615                        });
616                    }
617                    let new_specs = result.specs;
618                    for spec in new_specs {
619                        let attribute = spec.attribute.clone().unwrap_or_else(|| spec.name.clone());
620                        let start_line = spec.start_line;
621
622                        if from_registry {
623                            let bare_refs =
624                                crate::planning::validation::collect_bare_registry_refs(&spec);
625                            if !bare_refs.is_empty() {
626                                let source = crate::Source::new(
627                                    &attribute,
628                                    crate::parsing::ast::Span {
629                                        start: 0,
630                                        end: 0,
631                                        line: start_line,
632                                        col: 0,
633                                    },
634                                );
635                                errors.push(Error::validation(
636                                    format!(
637                                        "Registry spec '{}' contains references without '@' prefix: {}. \
638                                         The registry must rewrite all references to use '@'-prefixed names",
639                                        spec.name,
640                                        bare_refs.join(", ")
641                                    ),
642                                    Some(source),
643                                    Some(
644                                        "The registry must prefix all spec references with '@' \
645                                         before serving the bundle.",
646                                    ),
647                                ));
648                                continue;
649                            }
650                        }
651
652                        match self.specs.insert_spec(Arc::new(spec), from_registry) {
653                            Ok(()) => {
654                                self.sources.insert(attribute, code.clone());
655                            }
656                            Err(e) => {
657                                let source = crate::Source::new(
658                                    &attribute,
659                                    crate::parsing::ast::Span {
660                                        start: 0,
661                                        end: 0,
662                                        line: start_line,
663                                        col: 0,
664                                    },
665                                );
666                                errors.push(Error::validation(
667                                    e.to_string(),
668                                    Some(source),
669                                    None::<String>,
670                                ));
671                            }
672                        }
673                    }
674                }
675                Err(e) => errors.push(e),
676            }
677        }
678
679        let planning_result = crate::planning::plan(&self.specs, self.sources.clone());
680        self.plan_hash_registry = planning_result.plan_hash_registry.clone();
681        for spec_result in &planning_result.per_spec {
682            self.execution_plans
683                .insert(Arc::clone(&spec_result.spec), spec_result.plans.clone());
684        }
685        errors.extend(planning_result.global_errors);
686        for spec_result in planning_result.per_spec {
687            for err in spec_result.errors {
688                errors.push(err.with_spec_context(Arc::clone(&spec_result.spec)));
689            }
690        }
691
692        if errors.is_empty() {
693            Ok(())
694        } else {
695            Err(Errors {
696                errors,
697                sources: files,
698            })
699        }
700    }
701
702    /// Build a "not found" error that includes the effective date and lists
703    /// available temporal versions when the spec name exists but no temporal version
704    /// matches the requested time.
705    fn spec_not_found_error(&self, spec_name: &str, effective: &DateTimeValue) -> Error {
706        let versions = self.specs.specs_for_name(spec_name);
707        let msg = if versions.is_empty() {
708            format!("Spec '{}' not found", spec_name)
709        } else {
710            let version_list: Vec<String> = versions
711                .iter()
712                .map(|s| match s.effective_from() {
713                    Some(dt) => format!("  {} (effective from {})", s.name, dt),
714                    None => format!("  {} (no effective_from)", s.name),
715                })
716                .collect();
717            format!(
718                "Spec '{}' not found for effective {}. Available temporal versions:\n{}",
719                spec_name,
720                effective,
721                version_list.join("\n")
722            )
723        };
724        Error::request_not_found(msg, None::<String>)
725    }
726
727    /// Resolve `spec_id` (name or name~hash) and `effective` (or now if None) to the loaded spec.
728    pub fn get_spec(
729        &self,
730        spec_id: &str,
731        effective: Option<&DateTimeValue>,
732    ) -> Result<Arc<LemmaSpec>, Error> {
733        let (name, hash_pin) = spec_id::parse_spec_id(spec_id)?;
734        let eff_val = effective.cloned().unwrap_or_else(DateTimeValue::now);
735
736        if let Some(pin) = &hash_pin {
737            let arc = self
738                .plan_hash_registry
739                .get_by_pin(&name, pin)
740                .cloned()
741                .ok_or_else(|| {
742                    Error::request_not_found(
743                        format!("No spec '{}' found with plan hash {}", name, pin),
744                        Some("Use lemma schema <spec> --hash to get the current plan hash"),
745                    )
746                })?;
747
748            if effective.is_some() {
749                let slice_plans = self.execution_plans.get(&arc).unwrap_or_else(|| {
750                    panic!(
751                        "BUG: spec '{}' from pin registry has no execution plan",
752                        arc.name
753                    )
754                });
755                let plan = slice_plans
756                    .iter()
757                    .find(|p| p.plan_hash().trim().eq_ignore_ascii_case(pin.trim()))
758                    .ok_or_else(|| {
759                        Error::request_not_found(
760                            format!("No plan with hash {} for spec '{}'", pin, name),
761                            Some("Use lemma schema <spec> --hash to get the current plan hash"),
762                        )
763                    })?;
764                let from_ok = plan
765                    .valid_from
766                    .as_ref()
767                    .map(|f| eff_val >= *f)
768                    .unwrap_or(true);
769                let to_ok = plan.valid_to.as_ref().map(|t| eff_val < *t).unwrap_or(true);
770                if !from_ok || !to_ok {
771                    return Err(Error::request_not_found(
772                        format!(
773                            "Effective {} is outside the temporal range of spec '{}'~{} ([{:?}, {:?}))",
774                            eff_val, name, pin, plan.valid_from, plan.valid_to
775                        ),
776                        Some("Use an effective datetime within the pinned spec's slice"),
777                    ));
778                }
779            }
780
781            return Ok(arc);
782        }
783
784        self.specs
785            .get_spec(&name, &eff_val)
786            .ok_or_else(|| self.spec_not_found_error(&name, &eff_val))
787    }
788
789    /// Plan hash for a spec execution plan resolved by `spec_id` and `effective` datetime.
790    pub fn get_plan_hash(
791        &self,
792        spec_id: &str,
793        effective: &DateTimeValue,
794    ) -> Result<Option<String>, Error> {
795        Ok(Some(self.get_plan(spec_id, Some(effective))?.plan_hash()))
796    }
797
798    /// Remove the temporal version resolved by `spec_id` (`name` or `name~hash`) and `effective` (or now if None).
799    pub fn remove(
800        &mut self,
801        spec_id: &str,
802        effective: Option<&DateTimeValue>,
803    ) -> Result<(), Error> {
804        let arc = self.get_spec(spec_id, effective)?;
805        self.execution_plans.remove(&arc);
806        self.specs.remove_spec(&arc);
807        Ok(())
808    }
809
810    /// All specs, all temporal versions, ordered by (name, effective_from).
811    pub fn list_specs(&self) -> Vec<Arc<LemmaSpec>> {
812        self.specs.iter().collect()
813    }
814
815    /// Specs active at `effective` (one per name).
816    pub fn list_specs_effective(&self, effective: &DateTimeValue) -> Vec<Arc<LemmaSpec>> {
817        let mut seen_names = std::collections::HashSet::new();
818        let mut result = Vec::new();
819        for spec in self.specs.iter() {
820            if seen_names.contains(&spec.name) {
821                continue;
822            }
823            if let Some(active) = self.specs.get_spec(&spec.name, effective) {
824                if seen_names.insert(active.name.clone()) {
825                    result.push(active);
826                }
827            }
828        }
829        result.sort_by(|a, b| a.name.cmp(&b.name));
830        result
831    }
832
833    /// Resolve spec identifier (name or name~hash) and return the spec schema. Uses `effective` or now when None.
834    pub fn schema(
835        &self,
836        spec: &str,
837        effective: Option<&DateTimeValue>,
838    ) -> Result<SpecSchema, Error> {
839        Ok(self.get_plan(spec, effective)?.schema())
840    }
841
842    /// Resolve spec identifier and return the execution plan. Uses `effective` or now when None.
843    ///
844    /// With pin (`name~hash`): resolves spec by (name, hash) from the plan hash registry.
845    /// If `effective` is given, verifies it falls within the pinned slice's [valid_from, valid_to).
846    /// Without pin: resolves by (name, effective) temporal slice.
847    pub fn get_plan(
848        &self,
849        spec_id: &str,
850        effective: Option<&DateTimeValue>,
851    ) -> Result<&crate::planning::ExecutionPlan, Error> {
852        let (name, hash_pin) = spec_id::parse_spec_id(spec_id)?;
853        let eff_val = effective.cloned().unwrap_or_else(DateTimeValue::now);
854
855        if let Some(pin) = &hash_pin {
856            let arc = self
857                .plan_hash_registry
858                .get_by_pin(&name, pin)
859                .cloned()
860                .ok_or_else(|| {
861                    Error::request_not_found(
862                        format!("No spec '{}' found with plan hash {}", name, pin),
863                        Some("Use lemma schema <spec> --hash to get the current plan hash"),
864                    )
865                })?;
866
867            let slice_plans = self.execution_plans.get(&arc).unwrap_or_else(|| {
868                panic!(
869                    "BUG: spec '{}' from pin registry has no execution plan",
870                    arc.name
871                )
872            });
873
874            let plan = slice_plans
875                .iter()
876                .find(|p| p.plan_hash().trim().eq_ignore_ascii_case(pin.trim()))
877                .ok_or_else(|| {
878                    Error::request_not_found(
879                        format!("No plan with hash {} for spec '{}'", pin, name),
880                        Some("Use lemma schema <spec> --hash to get the current plan hash"),
881                    )
882                })?;
883
884            if effective.is_some() {
885                let from_ok = plan
886                    .valid_from
887                    .as_ref()
888                    .map(|f| eff_val >= *f)
889                    .unwrap_or(true);
890                let to_ok = plan.valid_to.as_ref().map(|t| eff_val < *t).unwrap_or(true);
891
892                if !from_ok || !to_ok {
893                    return Err(Error::request_not_found(
894                        format!(
895                            "Effective {} is outside the temporal range of spec '{}'~{} ([{:?}, {:?}))",
896                            eff_val, name, pin, plan.valid_from, plan.valid_to
897                        ),
898                        Some("Use an effective datetime within the pinned spec's slice"),
899                    ));
900                }
901            }
902
903            return Ok(plan);
904        }
905
906        let arc = self
907            .specs
908            .get_spec(&name, &eff_val)
909            .ok_or_else(|| self.spec_not_found_error(&name, &eff_val))?;
910
911        let slice_plans = self.execution_plans.get(&arc).unwrap_or_else(|| {
912            panic!(
913                "BUG: resolved spec '{}' has no execution plan (invariant: every loaded spec is planned)",
914                arc.name
915            )
916        });
917
918        Ok(find_slice_plan(slice_plans, &eff_val).unwrap_or_else(|| {
919            panic!(
920                "BUG: spec '{}' has {} slice plan(s) but none covers effective={} — every loaded spec has at least one plan covering its effective range",
921                arc.name, slice_plans.len(), eff_val
922            )
923        }))
924    }
925
926    /// Run a plan from [`get_plan`]: apply fact values and evaluate all rules.
927    ///
928    /// When `record_operations` is true, each rule's [`RuleResult::operations`] will
929    /// contain a trace of facts used, rules used, computations, and branch evaluations.
930    pub fn run_plan(
931        &self,
932        plan: &crate::planning::ExecutionPlan,
933        effective: &DateTimeValue,
934        fact_values: HashMap<String, String>,
935        record_operations: bool,
936    ) -> Result<Response, Error> {
937        let plan = plan.clone().with_fact_values(fact_values, &self.limits)?;
938        self.evaluate_plan(plan, effective, record_operations)
939    }
940
941    /// Run a spec: resolve by spec id, then [`run_plan`]. Returns all rules; filter via [`Response::filter_rules`] if needed.
942    ///
943    /// When `record_operations` is true, each rule's [`RuleResult::operations`] will
944    /// contain a trace of facts used, rules used, computations, and branch evaluations.
945    pub fn run(
946        &self,
947        spec_id: &str,
948        effective: Option<&DateTimeValue>,
949        fact_values: HashMap<String, String>,
950        record_operations: bool,
951    ) -> Result<Response, Error> {
952        let eff_val = effective.cloned().unwrap_or_else(DateTimeValue::now);
953        let plan = self.get_plan(spec_id, effective)?;
954        self.run_plan(plan, &eff_val, fact_values, record_operations)
955    }
956
957    /// Invert a rule to find input domains that produce a desired outcome.
958    ///
959    /// Values are provided as name -> value string pairs (e.g., "quantity" -> "5").
960    /// They are automatically parsed to the expected type based on the spec schema.
961    pub fn invert(
962        &self,
963        spec_name: &str,
964        effective: &DateTimeValue,
965        rule_name: &str,
966        target: crate::inversion::Target,
967        values: HashMap<String, String>,
968    ) -> Result<crate::InversionResponse, Error> {
969        let base_plan = self.get_plan(spec_name, Some(effective))?;
970
971        let plan = base_plan.clone().with_fact_values(values, &self.limits)?;
972        let provided_facts: std::collections::HashSet<_> = plan
973            .facts
974            .iter()
975            .filter(|(_, d)| d.value().is_some())
976            .map(|(p, _)| p.clone())
977            .collect();
978
979        crate::inversion::invert(rule_name, target, &plan, &provided_facts)
980    }
981
982    fn evaluate_plan(
983        &self,
984        plan: crate::planning::ExecutionPlan,
985        effective: &DateTimeValue,
986        record_operations: bool,
987    ) -> Result<Response, Error> {
988        let now_semantic = crate::planning::semantics::date_time_to_semantic(effective);
989        let now_literal = crate::planning::semantics::LiteralValue {
990            value: crate::planning::semantics::ValueKind::Date(now_semantic),
991            lemma_type: crate::planning::semantics::primitive_date().clone(),
992        };
993        Ok(self
994            .evaluator
995            .evaluate(&plan, now_literal, record_operations))
996    }
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002    use rust_decimal::Decimal;
1003    use std::str::FromStr;
1004
1005    fn date(year: i32, month: u32, day: u32) -> DateTimeValue {
1006        DateTimeValue {
1007            year,
1008            month,
1009            day,
1010            hour: 0,
1011            minute: 0,
1012            second: 0,
1013            microsecond: 0,
1014            timezone: None,
1015        }
1016    }
1017
1018    fn make_spec(name: &str) -> LemmaSpec {
1019        LemmaSpec::new(name.to_string())
1020    }
1021
1022    fn make_spec_with_range(name: &str, effective_from: Option<DateTimeValue>) -> LemmaSpec {
1023        let mut spec = LemmaSpec::new(name.to_string());
1024        spec.effective_from = effective_from;
1025        spec
1026    }
1027
1028    /// list_specs (and Context::iter) return specs in (name, effective_from) ascending order.
1029    /// So same-name temporal versions appear in temporal order; definition order in the file
1030    /// is irrelevant once inserted into the BTreeSet.
1031    #[test]
1032    fn list_specs_order_is_name_then_effective_from_ascending() {
1033        let mut ctx = Context::new();
1034        let s_2026 = Arc::new(make_spec_with_range("mortgage", Some(date(2026, 1, 1))));
1035        let s_2025 = Arc::new(make_spec_with_range("mortgage", Some(date(2025, 1, 1))));
1036        ctx.insert_spec(Arc::clone(&s_2026), false).unwrap();
1037        ctx.insert_spec(Arc::clone(&s_2025), false).unwrap();
1038        let listed: Vec<_> = ctx.iter().collect();
1039        assert_eq!(listed.len(), 2);
1040        assert_eq!(listed[0].effective_from(), Some(&date(2025, 1, 1)));
1041        assert_eq!(listed[1].effective_from(), Some(&date(2026, 1, 1)));
1042    }
1043
1044    // ─── Context::effective_range tests ──────────────────────────────
1045
1046    #[test]
1047    fn effective_range_unbounded_single_version() {
1048        let mut ctx = Context::new();
1049        let spec = Arc::new(make_spec("a"));
1050        ctx.insert_spec(Arc::clone(&spec), false).unwrap();
1051
1052        let (from, to) = ctx.effective_range(&spec);
1053        assert_eq!(from, None);
1054        assert_eq!(to, None);
1055    }
1056
1057    #[test]
1058    fn effective_range_soft_end_from_next_version() {
1059        let mut ctx = Context::new();
1060        let v1 = Arc::new(make_spec_with_range("a", Some(date(2025, 1, 1))));
1061        let v2 = Arc::new(make_spec_with_range("a", Some(date(2025, 6, 1))));
1062        ctx.insert_spec(Arc::clone(&v1), false).unwrap();
1063        ctx.insert_spec(Arc::clone(&v2), false).unwrap();
1064
1065        let (from, to) = ctx.effective_range(&v1);
1066        assert_eq!(from, Some(date(2025, 1, 1)));
1067        assert_eq!(to, Some(date(2025, 6, 1)));
1068
1069        let (from, to) = ctx.effective_range(&v2);
1070        assert_eq!(from, Some(date(2025, 6, 1)));
1071        assert_eq!(to, None);
1072    }
1073
1074    #[test]
1075    fn effective_range_unbounded_start_with_successor() {
1076        let mut ctx = Context::new();
1077        let v1 = Arc::new(make_spec("a"));
1078        let v2 = Arc::new(make_spec_with_range("a", Some(date(2025, 3, 1))));
1079        ctx.insert_spec(Arc::clone(&v1), false).unwrap();
1080        ctx.insert_spec(Arc::clone(&v2), false).unwrap();
1081
1082        let (from, to) = ctx.effective_range(&v1);
1083        assert_eq!(from, None);
1084        assert_eq!(to, Some(date(2025, 3, 1)));
1085    }
1086
1087    // ─── Context::version_boundaries tests ───────────────────────────
1088
1089    #[test]
1090    fn version_boundaries_single_unversioned() {
1091        let mut ctx = Context::new();
1092        ctx.insert_spec(Arc::new(make_spec("a")), false).unwrap();
1093
1094        assert!(ctx.version_boundaries("a").is_empty());
1095    }
1096
1097    #[test]
1098    fn version_boundaries_multiple_versions() {
1099        let mut ctx = Context::new();
1100        ctx.insert_spec(Arc::new(make_spec("a")), false).unwrap();
1101        ctx.insert_spec(
1102            Arc::new(make_spec_with_range("a", Some(date(2025, 3, 1)))),
1103            false,
1104        )
1105        .unwrap();
1106        ctx.insert_spec(
1107            Arc::new(make_spec_with_range("a", Some(date(2025, 6, 1)))),
1108            false,
1109        )
1110        .unwrap();
1111
1112        let boundaries = ctx.version_boundaries("a");
1113        assert_eq!(boundaries, vec![date(2025, 3, 1), date(2025, 6, 1)]);
1114    }
1115
1116    #[test]
1117    fn version_boundaries_nonexistent_name() {
1118        let ctx = Context::new();
1119        assert!(ctx.version_boundaries("nope").is_empty());
1120    }
1121
1122    // ─── Context::dep_coverage_gaps tests ────────────────────────────
1123
1124    #[test]
1125    fn dep_coverage_no_versions_is_full_gap() {
1126        let ctx = Context::new();
1127        let gaps =
1128            ctx.dep_coverage_gaps("missing", Some(&date(2025, 1, 1)), Some(&date(2025, 6, 1)));
1129        assert_eq!(gaps, vec![(Some(date(2025, 1, 1)), Some(date(2025, 6, 1)))]);
1130    }
1131
1132    #[test]
1133    fn dep_coverage_single_unbounded_version_covers_everything() {
1134        let mut ctx = Context::new();
1135        ctx.insert_spec(Arc::new(make_spec("dep")), false).unwrap();
1136
1137        let gaps = ctx.dep_coverage_gaps("dep", None, None);
1138        assert!(gaps.is_empty());
1139
1140        let gaps = ctx.dep_coverage_gaps("dep", Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)));
1141        assert!(gaps.is_empty());
1142    }
1143
1144    #[test]
1145    fn dep_coverage_single_version_with_from_leaves_leading_gap() {
1146        let mut ctx = Context::new();
1147        ctx.insert_spec(
1148            Arc::new(make_spec_with_range("dep", Some(date(2025, 3, 1)))),
1149            false,
1150        )
1151        .unwrap();
1152
1153        let gaps = ctx.dep_coverage_gaps("dep", None, None);
1154        assert_eq!(gaps, vec![(None, Some(date(2025, 3, 1)))]);
1155    }
1156
1157    #[test]
1158    fn dep_coverage_continuous_versions_no_gaps() {
1159        let mut ctx = Context::new();
1160        ctx.insert_spec(
1161            Arc::new(make_spec_with_range("dep", Some(date(2025, 1, 1)))),
1162            false,
1163        )
1164        .unwrap();
1165        ctx.insert_spec(
1166            Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1)))),
1167            false,
1168        )
1169        .unwrap();
1170
1171        let gaps = ctx.dep_coverage_gaps("dep", Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)));
1172        assert!(gaps.is_empty());
1173    }
1174
1175    #[test]
1176    fn dep_coverage_dep_starts_after_required_start() {
1177        let mut ctx = Context::new();
1178        ctx.insert_spec(
1179            Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1)))),
1180            false,
1181        )
1182        .unwrap();
1183
1184        let gaps = ctx.dep_coverage_gaps("dep", Some(&date(2025, 1, 1)), Some(&date(2025, 12, 1)));
1185        assert_eq!(gaps, vec![(Some(date(2025, 1, 1)), Some(date(2025, 6, 1)))]);
1186    }
1187
1188    #[test]
1189    fn dep_coverage_unbounded_required_range() {
1190        let mut ctx = Context::new();
1191        ctx.insert_spec(
1192            Arc::new(make_spec_with_range("dep", Some(date(2025, 6, 1)))),
1193            false,
1194        )
1195        .unwrap();
1196
1197        let gaps = ctx.dep_coverage_gaps("dep", None, None);
1198        assert_eq!(gaps, vec![(None, Some(date(2025, 6, 1)))]);
1199    }
1200
1201    fn add_lemma_code_blocking(
1202        engine: &mut Engine,
1203        code: &str,
1204        source: &str,
1205    ) -> Result<(), Errors> {
1206        engine.load(code, SourceType::Labeled(source))
1207    }
1208
1209    #[test]
1210    fn get_spec_resolves_temporal_version_by_effective() {
1211        let mut engine = Engine::new();
1212        add_lemma_code_blocking(
1213            &mut engine,
1214            r#"
1215        spec pricing 2025-01-01
1216        fact x: 1
1217        rule r: x
1218    "#,
1219            "a.lemma",
1220        )
1221        .unwrap();
1222        add_lemma_code_blocking(
1223            &mut engine,
1224            r#"
1225        spec pricing 2025-06-01
1226        fact x: 2
1227        rule r: x
1228    "#,
1229            "b.lemma",
1230        )
1231        .unwrap();
1232
1233        let jan = DateTimeValue {
1234            year: 2025,
1235            month: 1,
1236            day: 15,
1237            hour: 0,
1238            minute: 0,
1239            second: 0,
1240            microsecond: 0,
1241            timezone: None,
1242        };
1243        let jul = DateTimeValue {
1244            year: 2025,
1245            month: 7,
1246            day: 1,
1247            hour: 0,
1248            minute: 0,
1249            second: 0,
1250            microsecond: 0,
1251            timezone: None,
1252        };
1253
1254        let v1 = DateTimeValue {
1255            year: 2025,
1256            month: 1,
1257            day: 1,
1258            hour: 0,
1259            minute: 0,
1260            second: 0,
1261            microsecond: 0,
1262            timezone: None,
1263        };
1264        let v2 = DateTimeValue {
1265            year: 2025,
1266            month: 6,
1267            day: 1,
1268            hour: 0,
1269            minute: 0,
1270            second: 0,
1271            microsecond: 0,
1272            timezone: None,
1273        };
1274
1275        let s_jan = engine.get_spec("pricing", Some(&jan)).expect("jan spec");
1276        let s_jul = engine.get_spec("pricing", Some(&jul)).expect("jul spec");
1277        assert_eq!(s_jan.effective_from(), Some(&v1));
1278        assert_eq!(s_jul.effective_from(), Some(&v2));
1279    }
1280
1281    #[test]
1282    fn test_evaluate_spec_all_rules() {
1283        let mut engine = Engine::new();
1284        add_lemma_code_blocking(
1285            &mut engine,
1286            r#"
1287        spec test
1288        fact x: 10
1289        fact y: 5
1290        rule sum: x + y
1291        rule product: x * y
1292    "#,
1293            "test.lemma",
1294        )
1295        .unwrap();
1296
1297        let now = DateTimeValue::now();
1298        let response = engine
1299            .run("test", Some(&now), HashMap::new(), false)
1300            .unwrap();
1301        assert_eq!(response.results.len(), 2);
1302
1303        let sum_result = response
1304            .results
1305            .values()
1306            .find(|r| r.rule.name == "sum")
1307            .unwrap();
1308        assert_eq!(
1309            sum_result.result,
1310            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1311                Decimal::from_str("15").unwrap()
1312            )))
1313        );
1314
1315        let product_result = response
1316            .results
1317            .values()
1318            .find(|r| r.rule.name == "product")
1319            .unwrap();
1320        assert_eq!(
1321            product_result.result,
1322            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1323                Decimal::from_str("50").unwrap()
1324            )))
1325        );
1326    }
1327
1328    #[test]
1329    fn test_evaluate_empty_facts() {
1330        let mut engine = Engine::new();
1331        add_lemma_code_blocking(
1332            &mut engine,
1333            r#"
1334        spec test
1335        fact price: 100
1336        rule total: price * 2
1337    "#,
1338            "test.lemma",
1339        )
1340        .unwrap();
1341
1342        let now = DateTimeValue::now();
1343        let response = engine
1344            .run("test", Some(&now), HashMap::new(), false)
1345            .unwrap();
1346        assert_eq!(response.results.len(), 1);
1347        assert_eq!(
1348            response.results.values().next().unwrap().result,
1349            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1350                Decimal::from_str("200").unwrap()
1351            )))
1352        );
1353    }
1354
1355    #[test]
1356    fn test_evaluate_boolean_rule() {
1357        let mut engine = Engine::new();
1358        add_lemma_code_blocking(
1359            &mut engine,
1360            r#"
1361        spec test
1362        fact age: 25
1363        rule is_adult: age >= 18
1364    "#,
1365            "test.lemma",
1366        )
1367        .unwrap();
1368
1369        let now = DateTimeValue::now();
1370        let response = engine
1371            .run("test", Some(&now), HashMap::new(), false)
1372            .unwrap();
1373        assert_eq!(
1374            response.results.values().next().unwrap().result,
1375            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::from_bool(true)))
1376        );
1377    }
1378
1379    #[test]
1380    fn test_evaluate_with_unless_clause() {
1381        let mut engine = Engine::new();
1382        add_lemma_code_blocking(
1383            &mut engine,
1384            r#"
1385        spec test
1386        fact quantity: 15
1387        rule discount: 0
1388          unless quantity >= 10 then 10
1389    "#,
1390            "test.lemma",
1391        )
1392        .unwrap();
1393
1394        let now = DateTimeValue::now();
1395        let response = engine
1396            .run("test", Some(&now), HashMap::new(), false)
1397            .unwrap();
1398        assert_eq!(
1399            response.results.values().next().unwrap().result,
1400            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1401                Decimal::from_str("10").unwrap()
1402            )))
1403        );
1404    }
1405
1406    #[test]
1407    fn test_spec_not_found() {
1408        let engine = Engine::new();
1409        let now = DateTimeValue::now();
1410        let result = engine.run("nonexistent", Some(&now), HashMap::new(), false);
1411        assert!(result.is_err());
1412        assert!(result.unwrap_err().to_string().contains("not found"));
1413    }
1414
1415    #[test]
1416    fn test_multiple_specs() {
1417        let mut engine = Engine::new();
1418        add_lemma_code_blocking(
1419            &mut engine,
1420            r#"
1421        spec spec1
1422        fact x: 10
1423        rule result: x * 2
1424    "#,
1425            "spec 1.lemma",
1426        )
1427        .unwrap();
1428
1429        add_lemma_code_blocking(
1430            &mut engine,
1431            r#"
1432        spec spec2
1433        fact y: 5
1434        rule result: y * 3
1435    "#,
1436            "spec 2.lemma",
1437        )
1438        .unwrap();
1439
1440        let now = DateTimeValue::now();
1441        let response1 = engine
1442            .run("spec1", Some(&now), HashMap::new(), false)
1443            .unwrap();
1444        assert_eq!(
1445            response1.results[0].result,
1446            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1447                Decimal::from_str("20").unwrap()
1448            )))
1449        );
1450
1451        let response2 = engine
1452            .run("spec2", Some(&now), HashMap::new(), false)
1453            .unwrap();
1454        assert_eq!(
1455            response2.results[0].result,
1456            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1457                Decimal::from_str("15").unwrap()
1458            )))
1459        );
1460    }
1461
1462    #[test]
1463    fn test_runtime_error_mapping() {
1464        let mut engine = Engine::new();
1465        add_lemma_code_blocking(
1466            &mut engine,
1467            r#"
1468        spec test
1469        fact numerator: 10
1470        fact denominator: 0
1471        rule division: numerator / denominator
1472    "#,
1473            "test.lemma",
1474        )
1475        .unwrap();
1476
1477        let now = DateTimeValue::now();
1478        let result = engine.run("test", Some(&now), HashMap::new(), false);
1479        // Division by zero returns a Veto (not an error)
1480        assert!(result.is_ok(), "Evaluation should succeed");
1481        let response = result.unwrap();
1482        let division_result = response
1483            .results
1484            .values()
1485            .find(|r| r.rule.name == "division");
1486        assert!(
1487            division_result.is_some(),
1488            "Should have division rule result"
1489        );
1490        match &division_result.unwrap().result {
1491            crate::OperationResult::Veto(message) => {
1492                assert!(
1493                    message
1494                        .as_ref()
1495                        .map(|m| m.contains("Division by zero"))
1496                        .unwrap_or(false),
1497                    "Veto message should mention division by zero: {:?}",
1498                    message
1499                );
1500            }
1501            other => panic!("Expected Veto for division by zero, got {:?}", other),
1502        }
1503    }
1504
1505    #[test]
1506    fn test_rules_sorted_by_source_order() {
1507        let mut engine = Engine::new();
1508        add_lemma_code_blocking(
1509            &mut engine,
1510            r#"
1511        spec test
1512        fact a: 1
1513        fact b: 2
1514        rule z: a + b
1515        rule y: a * b
1516        rule x: a - b
1517    "#,
1518            "test.lemma",
1519        )
1520        .unwrap();
1521
1522        let now = DateTimeValue::now();
1523        let response = engine
1524            .run("test", Some(&now), HashMap::new(), false)
1525            .unwrap();
1526        assert_eq!(response.results.len(), 3);
1527
1528        // Verify source positions increase (z < y < x)
1529        let z_pos = response
1530            .results
1531            .values()
1532            .find(|r| r.rule.name == "z")
1533            .unwrap()
1534            .rule
1535            .source_location
1536            .span
1537            .start;
1538        let y_pos = response
1539            .results
1540            .values()
1541            .find(|r| r.rule.name == "y")
1542            .unwrap()
1543            .rule
1544            .source_location
1545            .span
1546            .start;
1547        let x_pos = response
1548            .results
1549            .values()
1550            .find(|r| r.rule.name == "x")
1551            .unwrap()
1552            .rule
1553            .source_location
1554            .span
1555            .start;
1556
1557        assert!(z_pos < y_pos);
1558        assert!(y_pos < x_pos);
1559    }
1560
1561    #[test]
1562    fn test_rule_filtering_evaluates_dependencies() {
1563        let mut engine = Engine::new();
1564        add_lemma_code_blocking(
1565            &mut engine,
1566            r#"
1567        spec test
1568        fact base: 100
1569        rule subtotal: base * 2
1570        rule tax: subtotal * 10%
1571        rule total: subtotal + tax
1572    "#,
1573            "test.lemma",
1574        )
1575        .unwrap();
1576
1577        // User filters to 'total' after run (deps were still computed)
1578        let now = DateTimeValue::now();
1579        let rules = vec!["total".to_string()];
1580        let mut response = engine
1581            .run("test", Some(&now), HashMap::new(), false)
1582            .unwrap();
1583        response.filter_rules(&rules);
1584
1585        assert_eq!(response.results.len(), 1);
1586        assert_eq!(response.results.keys().next().unwrap(), "total");
1587
1588        // But the value should be correct (dependencies were computed)
1589        let total = response.results.values().next().unwrap();
1590        assert_eq!(
1591            total.result,
1592            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1593                Decimal::from_str("220").unwrap()
1594            )))
1595        );
1596    }
1597
1598    // -------------------------------------------------------------------
1599    // Pre-resolved dependency tests (Engine never fetches from registry)
1600    // -------------------------------------------------------------------
1601
1602    use crate::parsing::ast::DateTimeValue;
1603
1604    #[test]
1605    fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1606        let mut engine = Engine::new();
1607
1608        engine
1609            .load(
1610                "spec @org/project/helper\nfact quantity: 42",
1611                SourceType::Dependency("deps/org_project_helper.lemma"),
1612            )
1613            .expect("should load dependency files");
1614
1615        engine
1616            .load(
1617                r#"spec main_spec
1618fact external: spec @org/project/helper
1619rule value: external.quantity"#,
1620                SourceType::Labeled("main.lemma"),
1621            )
1622            .expect("should succeed with pre-resolved deps");
1623
1624        let now = DateTimeValue::now();
1625        let response = engine
1626            .run("main_spec", Some(&now), HashMap::new(), false)
1627            .expect("evaluate should succeed");
1628
1629        let value_result = response
1630            .results
1631            .get("value")
1632            .expect("rule 'value' should exist");
1633        assert_eq!(
1634            value_result.result,
1635            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1636                Decimal::from_str("42").unwrap()
1637            )))
1638        );
1639    }
1640
1641    #[test]
1642    fn load_no_external_refs_works() {
1643        let mut engine = Engine::new();
1644
1645        add_lemma_code_blocking(
1646            &mut engine,
1647            r#"spec local_only
1648fact price: 100
1649rule doubled: price * 2"#,
1650            "local.lemma",
1651        )
1652        .expect("should succeed when there are no @... references");
1653
1654        let now = DateTimeValue::now();
1655        let response = engine
1656            .run("local_only", Some(&now), HashMap::new(), false)
1657            .expect("evaluate should succeed");
1658
1659        assert!(response.results.contains_key("doubled"));
1660    }
1661
1662    #[test]
1663    fn unresolved_external_ref_without_deps_fails() {
1664        let mut engine = Engine::new();
1665
1666        let result = add_lemma_code_blocking(
1667            &mut engine,
1668            r#"spec main_spec
1669fact external: spec @org/project/missing
1670rule value: external.quantity"#,
1671            "main.lemma",
1672        );
1673
1674        assert!(
1675            result.is_err(),
1676            "Should fail when @... dep is not in file map"
1677        );
1678    }
1679
1680    #[test]
1681    fn pre_resolved_deps_with_spec_and_type_refs() {
1682        let mut engine = Engine::new();
1683
1684        let mut deps = HashMap::new();
1685        deps.insert(
1686            "deps/helper.lemma".to_string(),
1687            "spec @org/example/helper\nfact value: 42".to_string(),
1688        );
1689        deps.insert(
1690            "deps/finance.lemma".to_string(),
1691            "spec @lemma/std/finance\ntype money: scale\n -> unit eur 1.00\n -> decimals 2"
1692                .to_string(),
1693        );
1694        engine
1695            .load(
1696                "spec @org/example/helper\nfact value: 42",
1697                SourceType::Dependency("deps/helper.lemma"),
1698            )
1699            .expect("should load helper file");
1700
1701        engine
1702            .load(
1703                "spec @lemma/std/finance\ntype money: scale\n -> unit eur 1.00\n -> decimals 2",
1704                SourceType::Dependency("deps/finance.lemma"),
1705            )
1706            .expect("should load finance file");
1707
1708        engine
1709            .load(
1710                r#"spec registry_demo
1711type money from @lemma/std/finance
1712fact unit_price: 5 eur
1713fact helper: spec @org/example/helper
1714rule helper_value: helper.value
1715rule line_total: unit_price * 2
1716rule formatted: helper_value + 0"#,
1717                SourceType::Labeled("main.lemma"),
1718            )
1719            .expect("should succeed with pre-resolved spec and type deps");
1720
1721        let now = DateTimeValue::now();
1722        let response = engine
1723            .run("registry_demo", Some(&now), HashMap::new(), false)
1724            .expect("evaluate should succeed");
1725
1726        assert!(response.results.contains_key("helper_value"));
1727        assert!(response.results.contains_key("formatted"));
1728    }
1729
1730    #[test]
1731    fn load_empty_labeled_source_is_error() {
1732        let mut engine = Engine::new();
1733        let err = engine
1734            .load("spec x\nfact a: 1", SourceType::Labeled("  "))
1735            .unwrap_err();
1736        assert!(err.errors.iter().any(|e| e.message().contains("non-empty")));
1737    }
1738
1739    #[test]
1740    fn load_inline_source_succeeds() {
1741        let mut engine = Engine::new();
1742        engine
1743            .load("spec x\nfact a: 1", SourceType::Inline)
1744            .expect("inline load");
1745    }
1746
1747    #[test]
1748    fn load_rejects_registry_spec_definitions() {
1749        let mut engine = Engine::new();
1750        let result = engine.load(
1751            "spec @org/example/helper\nfact x: 1",
1752            SourceType::Labeled("bad.lemma"),
1753        );
1754        assert!(result.is_err(), "should reject @-prefixed spec in load");
1755        let errors = result.unwrap_err();
1756        assert!(
1757            errors
1758                .errors
1759                .iter()
1760                .any(|e| e.message().contains("registry prefix")),
1761            "error should mention registry prefix, got: {:?}",
1762            errors
1763        );
1764    }
1765
1766    #[test]
1767    fn add_dependency_files_accepts_registry_spec_definitions() {
1768        let mut engine = Engine::new();
1769        let mut files = HashMap::new();
1770        files.insert(
1771            "deps/helper.lemma".to_string(),
1772            "spec @org/my/helper\nfact x: 1".to_string(),
1773        );
1774        engine
1775            .load(
1776                "spec @org/my/helper\nfact x: 1",
1777                SourceType::Dependency("helper.lemma"),
1778            )
1779            .expect("add_dependency_files should accept @-prefixed specs");
1780    }
1781
1782    #[test]
1783    fn add_dependency_files_rejects_bare_named_spec_in_registry_bundle() {
1784        let mut engine = Engine::new();
1785        let result = engine.load(
1786            "spec local_looking_name\nfact x: 1",
1787            SourceType::Dependency("bundle.lemma"),
1788        );
1789        assert!(
1790            result.is_err(),
1791            "should reject non-@-prefixed spec in registry bundle"
1792        );
1793        let errors = result.unwrap_err();
1794        assert!(
1795            errors
1796                .errors
1797                .iter()
1798                .any(|e| e.message().contains("without '@' prefix")),
1799            "error should mention missing @ prefix, got: {:?}",
1800            errors
1801        );
1802    }
1803
1804    #[test]
1805    fn add_dependency_files_rejects_spec_with_bare_spec_reference() {
1806        let mut engine = Engine::new();
1807        let result = engine.load(
1808            "spec @org/billing\nfact rates: spec local_rates",
1809            SourceType::Dependency("billing.lemma"),
1810        );
1811        assert!(
1812            result.is_err(),
1813            "should reject registry spec referencing non-@ spec"
1814        );
1815        let errors = result.unwrap_err();
1816        assert!(
1817            errors
1818                .errors
1819                .iter()
1820                .any(|e| e.message().contains("local_rates")),
1821            "error should mention bare ref name, got: {:?}",
1822            errors
1823        );
1824    }
1825
1826    #[test]
1827    fn add_dependency_files_rejects_spec_with_bare_type_import() {
1828        let mut engine = Engine::new();
1829        let result = engine.load(
1830            "spec @org/billing\ntype money from local_finance",
1831            SourceType::Dependency("billing.lemma"),
1832        );
1833        assert!(
1834            result.is_err(),
1835            "should reject registry spec importing type from non-@ spec"
1836        );
1837        let errors = result.unwrap_err();
1838        assert!(
1839            errors
1840                .errors
1841                .iter()
1842                .any(|e| e.message().contains("local_finance")),
1843            "error should mention bare ref name, got: {:?}",
1844            errors
1845        );
1846    }
1847
1848    #[test]
1849    fn add_dependency_files_accepts_fully_qualified_references() {
1850        let mut engine = Engine::new();
1851        let mut files = HashMap::new();
1852        files.insert(
1853            "deps/bundle.lemma".to_string(),
1854            r#"spec @org/billing
1855fact rates: spec @org/rates
1856
1857spec @org/rates
1858fact rate: 10"#
1859                .to_string(),
1860        );
1861        engine
1862            .load(
1863                r#"spec @org/billing
1864fact rates: spec @org/rates
1865
1866spec @org/rates
1867fact rate: 10"#,
1868                SourceType::Dependency("bundle.lemma"),
1869            )
1870            .expect("fully @-prefixed bundle should be accepted");
1871    }
1872
1873    #[test]
1874    fn load_returns_all_errors_not_just_first() {
1875        let mut engine = Engine::new();
1876
1877        let result = add_lemma_code_blocking(
1878            &mut engine,
1879            r#"spec demo
1880type money from nonexistent_type_source
1881fact helper: spec nonexistent_spec
1882fact price: 10
1883rule total: helper.value + price"#,
1884            "test.lemma",
1885        );
1886
1887        assert!(result.is_err(), "Should fail with multiple errors");
1888        let load_err = result.unwrap_err();
1889        assert!(
1890            load_err.errors.len() >= 2,
1891            "expected at least 2 errors (type + spec ref), got {}",
1892            load_err.errors.len()
1893        );
1894        let error_message = load_err
1895            .errors
1896            .iter()
1897            .map(ToString::to_string)
1898            .collect::<Vec<_>>()
1899            .join("; ");
1900
1901        assert!(
1902            error_message.contains("nonexistent_type_source"),
1903            "Should mention type import source spec. Got:\n{}",
1904            error_message
1905        );
1906        assert!(
1907            error_message.contains("nonexistent_spec"),
1908            "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
1909            error_message
1910        );
1911    }
1912
1913    // ── Default value type validation ────────────────────────────────
1914    // Planning must reject default values that don't match the type.
1915    // These tests cover both primitives and named types (which the parser
1916    // can't validate because it doesn't resolve type names).
1917
1918    #[test]
1919    fn planning_rejects_invalid_number_default() {
1920        let mut engine = Engine::new();
1921        let result = add_lemma_code_blocking(
1922            &mut engine,
1923            "spec t\nfact x: [number -> default \"10 $$\"]\nrule r: x",
1924            "t.lemma",
1925        );
1926        assert!(
1927            result.is_err(),
1928            "must reject non-numeric default on number type"
1929        );
1930    }
1931
1932    #[test]
1933    fn planning_rejects_text_literal_as_number_default() {
1934        // The parser produces CommandArg::Text("10") for `default "10"`.
1935        // Planning now checks the CommandArg variant: a Text literal is
1936        // rejected where a Number literal is required, even though the
1937        // string content "10" could be parsed as a valid Decimal.
1938        let mut engine = Engine::new();
1939        let result = add_lemma_code_blocking(
1940            &mut engine,
1941            "spec t\nfact x: [number -> default \"10\"]\nrule r: x",
1942            "t.lemma",
1943        );
1944        assert!(
1945            result.is_err(),
1946            "must reject text literal \"10\" as default for number type"
1947        );
1948    }
1949
1950    #[test]
1951    fn planning_rejects_invalid_boolean_default() {
1952        let mut engine = Engine::new();
1953        let result = add_lemma_code_blocking(
1954            &mut engine,
1955            "spec t\nfact x: [boolean -> default \"maybe\"]\nrule r: x",
1956            "t.lemma",
1957        );
1958        assert!(
1959            result.is_err(),
1960            "must reject non-boolean default on boolean type"
1961        );
1962    }
1963
1964    #[test]
1965    fn planning_rejects_invalid_named_type_default() {
1966        // Named type: the parser can't validate this, only planning can.
1967        let mut engine = Engine::new();
1968        let result = add_lemma_code_blocking(
1969            &mut engine,
1970            "spec t\ntype custom: number -> minimum 0\nfact x: [custom -> default \"abc\"]\nrule r: x",
1971            "t.lemma",
1972        );
1973        assert!(
1974            result.is_err(),
1975            "must reject non-numeric default on named number type"
1976        );
1977    }
1978
1979    #[test]
1980    fn planning_accepts_valid_number_default() {
1981        let mut engine = Engine::new();
1982        let result = add_lemma_code_blocking(
1983            &mut engine,
1984            "spec t\nfact x: [number -> default 10]\nrule r: x",
1985            "t.lemma",
1986        );
1987        assert!(result.is_ok(), "must accept valid number default");
1988    }
1989
1990    #[test]
1991    fn planning_accepts_valid_boolean_default() {
1992        let mut engine = Engine::new();
1993        let result = add_lemma_code_blocking(
1994            &mut engine,
1995            "spec t\nfact x: [boolean -> default true]\nrule r: x",
1996            "t.lemma",
1997        );
1998        assert!(result.is_ok(), "must accept valid boolean default");
1999    }
2000
2001    #[test]
2002    fn planning_accepts_valid_text_default() {
2003        let mut engine = Engine::new();
2004        let result = add_lemma_code_blocking(
2005            &mut engine,
2006            "spec t\nfact x: [text -> default \"hello\"]\nrule r: x",
2007            "t.lemma",
2008        );
2009        assert!(result.is_ok(), "must accept valid text default");
2010    }
2011}