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    #[test]
1202    fn get_spec_resolves_temporal_version_by_effective() {
1203        let mut engine = Engine::new();
1204        engine
1205            .load(
1206                r#"
1207        spec pricing 2025-01-01
1208        fact x: 1
1209        rule r: x
1210    "#,
1211                SourceType::Labeled("a.lemma"),
1212            )
1213            .unwrap();
1214        engine
1215            .load(
1216                r#"
1217        spec pricing 2025-06-01
1218        fact x: 2
1219        rule r: x
1220    "#,
1221                SourceType::Labeled("b.lemma"),
1222            )
1223            .unwrap();
1224
1225        let jan = DateTimeValue {
1226            year: 2025,
1227            month: 1,
1228            day: 15,
1229            hour: 0,
1230            minute: 0,
1231            second: 0,
1232            microsecond: 0,
1233            timezone: None,
1234        };
1235        let jul = DateTimeValue {
1236            year: 2025,
1237            month: 7,
1238            day: 1,
1239            hour: 0,
1240            minute: 0,
1241            second: 0,
1242            microsecond: 0,
1243            timezone: None,
1244        };
1245
1246        let v1 = DateTimeValue {
1247            year: 2025,
1248            month: 1,
1249            day: 1,
1250            hour: 0,
1251            minute: 0,
1252            second: 0,
1253            microsecond: 0,
1254            timezone: None,
1255        };
1256        let v2 = DateTimeValue {
1257            year: 2025,
1258            month: 6,
1259            day: 1,
1260            hour: 0,
1261            minute: 0,
1262            second: 0,
1263            microsecond: 0,
1264            timezone: None,
1265        };
1266
1267        let s_jan = engine.get_spec("pricing", Some(&jan)).expect("jan spec");
1268        let s_jul = engine.get_spec("pricing", Some(&jul)).expect("jul spec");
1269        assert_eq!(s_jan.effective_from(), Some(&v1));
1270        assert_eq!(s_jul.effective_from(), Some(&v2));
1271    }
1272
1273    #[test]
1274    fn test_evaluate_spec_all_rules() {
1275        let mut engine = Engine::new();
1276        engine
1277            .load(
1278                r#"
1279        spec test
1280        fact x: 10
1281        fact y: 5
1282        rule sum: x + y
1283        rule product: x * y
1284    "#,
1285                SourceType::Labeled("test.lemma"),
1286            )
1287            .unwrap();
1288
1289        let now = DateTimeValue::now();
1290        let response = engine
1291            .run("test", Some(&now), HashMap::new(), false)
1292            .unwrap();
1293        assert_eq!(response.results.len(), 2);
1294
1295        let sum_result = response
1296            .results
1297            .values()
1298            .find(|r| r.rule.name == "sum")
1299            .unwrap();
1300        assert_eq!(
1301            sum_result.result,
1302            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1303                Decimal::from_str("15").unwrap()
1304            )))
1305        );
1306
1307        let product_result = response
1308            .results
1309            .values()
1310            .find(|r| r.rule.name == "product")
1311            .unwrap();
1312        assert_eq!(
1313            product_result.result,
1314            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1315                Decimal::from_str("50").unwrap()
1316            )))
1317        );
1318    }
1319
1320    #[test]
1321    fn test_evaluate_empty_facts() {
1322        let mut engine = Engine::new();
1323        engine
1324            .load(
1325                r#"
1326        spec test
1327        fact price: 100
1328        rule total: price * 2
1329    "#,
1330                SourceType::Labeled("test.lemma"),
1331            )
1332            .unwrap();
1333
1334        let now = DateTimeValue::now();
1335        let response = engine
1336            .run("test", Some(&now), HashMap::new(), false)
1337            .unwrap();
1338        assert_eq!(response.results.len(), 1);
1339        assert_eq!(
1340            response.results.values().next().unwrap().result,
1341            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1342                Decimal::from_str("200").unwrap()
1343            )))
1344        );
1345    }
1346
1347    #[test]
1348    fn test_evaluate_boolean_rule() {
1349        let mut engine = Engine::new();
1350        engine
1351            .load(
1352                r#"
1353        spec test
1354        fact age: 25
1355        rule is_adult: age >= 18
1356    "#,
1357                SourceType::Labeled("test.lemma"),
1358            )
1359            .unwrap();
1360
1361        let now = DateTimeValue::now();
1362        let response = engine
1363            .run("test", Some(&now), HashMap::new(), false)
1364            .unwrap();
1365        assert_eq!(
1366            response.results.values().next().unwrap().result,
1367            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::from_bool(true)))
1368        );
1369    }
1370
1371    #[test]
1372    fn test_evaluate_with_unless_clause() {
1373        let mut engine = Engine::new();
1374        engine
1375            .load(
1376                r#"
1377        spec test
1378        fact quantity: 15
1379        rule discount: 0
1380          unless quantity >= 10 then 10
1381    "#,
1382                SourceType::Labeled("test.lemma"),
1383            )
1384            .unwrap();
1385
1386        let now = DateTimeValue::now();
1387        let response = engine
1388            .run("test", Some(&now), HashMap::new(), false)
1389            .unwrap();
1390        assert_eq!(
1391            response.results.values().next().unwrap().result,
1392            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1393                Decimal::from_str("10").unwrap()
1394            )))
1395        );
1396    }
1397
1398    #[test]
1399    fn test_spec_not_found() {
1400        let engine = Engine::new();
1401        let now = DateTimeValue::now();
1402        let result = engine.run("nonexistent", Some(&now), HashMap::new(), false);
1403        assert!(result.is_err());
1404        assert!(result.unwrap_err().to_string().contains("not found"));
1405    }
1406
1407    #[test]
1408    fn test_multiple_specs() {
1409        let mut engine = Engine::new();
1410        engine
1411            .load(
1412                r#"
1413        spec spec1
1414        fact x: 10
1415        rule result: x * 2
1416    "#,
1417                SourceType::Labeled("spec 1.lemma"),
1418            )
1419            .unwrap();
1420
1421        engine
1422            .load(
1423                r#"
1424        spec spec2
1425        fact y: 5
1426        rule result: y * 3
1427    "#,
1428                SourceType::Labeled("spec 2.lemma"),
1429            )
1430            .unwrap();
1431
1432        let now = DateTimeValue::now();
1433        let response1 = engine
1434            .run("spec1", Some(&now), HashMap::new(), false)
1435            .unwrap();
1436        assert_eq!(
1437            response1.results[0].result,
1438            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1439                Decimal::from_str("20").unwrap()
1440            )))
1441        );
1442
1443        let response2 = engine
1444            .run("spec2", Some(&now), HashMap::new(), false)
1445            .unwrap();
1446        assert_eq!(
1447            response2.results[0].result,
1448            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1449                Decimal::from_str("15").unwrap()
1450            )))
1451        );
1452    }
1453
1454    #[test]
1455    fn test_runtime_error_mapping() {
1456        let mut engine = Engine::new();
1457        engine
1458            .load(
1459                r#"
1460        spec test
1461        fact numerator: 10
1462        fact denominator: 0
1463        rule division: numerator / denominator
1464    "#,
1465                SourceType::Labeled("test.lemma"),
1466            )
1467            .unwrap();
1468
1469        let now = DateTimeValue::now();
1470        let result = engine.run("test", Some(&now), HashMap::new(), false);
1471        // Division by zero returns a Veto (not an error)
1472        assert!(result.is_ok(), "Evaluation should succeed");
1473        let response = result.unwrap();
1474        let division_result = response
1475            .results
1476            .values()
1477            .find(|r| r.rule.name == "division");
1478        assert!(
1479            division_result.is_some(),
1480            "Should have division rule result"
1481        );
1482        match &division_result.unwrap().result {
1483            crate::OperationResult::Veto(message) => {
1484                assert!(
1485                    message
1486                        .as_ref()
1487                        .map(|m| m.contains("Division by zero"))
1488                        .unwrap_or(false),
1489                    "Veto message should mention division by zero: {:?}",
1490                    message
1491                );
1492            }
1493            other => panic!("Expected Veto for division by zero, got {:?}", other),
1494        }
1495    }
1496
1497    #[test]
1498    fn test_rules_sorted_by_source_order() {
1499        let mut engine = Engine::new();
1500        engine
1501            .load(
1502                r#"
1503        spec test
1504        fact a: 1
1505        fact b: 2
1506        rule z: a + b
1507        rule y: a * b
1508        rule x: a - b
1509    "#,
1510                SourceType::Labeled("test.lemma"),
1511            )
1512            .unwrap();
1513
1514        let now = DateTimeValue::now();
1515        let response = engine
1516            .run("test", Some(&now), HashMap::new(), false)
1517            .unwrap();
1518        assert_eq!(response.results.len(), 3);
1519
1520        // Verify source positions increase (z < y < x)
1521        let z_pos = response
1522            .results
1523            .values()
1524            .find(|r| r.rule.name == "z")
1525            .unwrap()
1526            .rule
1527            .source_location
1528            .span
1529            .start;
1530        let y_pos = response
1531            .results
1532            .values()
1533            .find(|r| r.rule.name == "y")
1534            .unwrap()
1535            .rule
1536            .source_location
1537            .span
1538            .start;
1539        let x_pos = response
1540            .results
1541            .values()
1542            .find(|r| r.rule.name == "x")
1543            .unwrap()
1544            .rule
1545            .source_location
1546            .span
1547            .start;
1548
1549        assert!(z_pos < y_pos);
1550        assert!(y_pos < x_pos);
1551    }
1552
1553    #[test]
1554    fn test_rule_filtering_evaluates_dependencies() {
1555        let mut engine = Engine::new();
1556        engine
1557            .load(
1558                r#"
1559        spec test
1560        fact base: 100
1561        rule subtotal: base * 2
1562        rule tax: subtotal * 10%
1563        rule total: subtotal + tax
1564    "#,
1565                SourceType::Labeled("test.lemma"),
1566            )
1567            .unwrap();
1568
1569        // User filters to 'total' after run (deps were still computed)
1570        let now = DateTimeValue::now();
1571        let rules = vec!["total".to_string()];
1572        let mut response = engine
1573            .run("test", Some(&now), HashMap::new(), false)
1574            .unwrap();
1575        response.filter_rules(&rules);
1576
1577        assert_eq!(response.results.len(), 1);
1578        assert_eq!(response.results.keys().next().unwrap(), "total");
1579
1580        // But the value should be correct (dependencies were computed)
1581        let total = response.results.values().next().unwrap();
1582        assert_eq!(
1583            total.result,
1584            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1585                Decimal::from_str("220").unwrap()
1586            )))
1587        );
1588    }
1589
1590    // -------------------------------------------------------------------
1591    // Pre-resolved dependency tests (Engine never fetches from registry)
1592    // -------------------------------------------------------------------
1593
1594    use crate::parsing::ast::DateTimeValue;
1595
1596    #[test]
1597    fn pre_resolved_deps_in_file_map_evaluates_external_spec() {
1598        let mut engine = Engine::new();
1599
1600        engine
1601            .load(
1602                "spec @org/project/helper\nfact quantity: 42",
1603                SourceType::Dependency("deps/org_project_helper.lemma"),
1604            )
1605            .expect("should load dependency files");
1606
1607        engine
1608            .load(
1609                r#"spec main_spec
1610fact external: spec @org/project/helper
1611rule value: external.quantity"#,
1612                SourceType::Labeled("main.lemma"),
1613            )
1614            .expect("should succeed with pre-resolved deps");
1615
1616        let now = DateTimeValue::now();
1617        let response = engine
1618            .run("main_spec", Some(&now), HashMap::new(), false)
1619            .expect("evaluate should succeed");
1620
1621        let value_result = response
1622            .results
1623            .get("value")
1624            .expect("rule 'value' should exist");
1625        assert_eq!(
1626            value_result.result,
1627            crate::OperationResult::Value(Box::new(crate::planning::LiteralValue::number(
1628                Decimal::from_str("42").unwrap()
1629            )))
1630        );
1631    }
1632
1633    #[test]
1634    fn load_no_external_refs_works() {
1635        let mut engine = Engine::new();
1636
1637        engine
1638            .load(
1639                r#"spec local_only
1640fact price: 100
1641rule doubled: price * 2"#,
1642                SourceType::Labeled("local.lemma"),
1643            )
1644            .expect("should succeed when there are no @... references");
1645
1646        let now = DateTimeValue::now();
1647        let response = engine
1648            .run("local_only", Some(&now), HashMap::new(), false)
1649            .expect("evaluate should succeed");
1650
1651        let doubled = response
1652            .results
1653            .get("doubled")
1654            .expect("doubled rule")
1655            .result
1656            .value()
1657            .expect("value");
1658        assert_eq!(doubled.to_string(), "200");
1659    }
1660
1661    #[test]
1662    fn unresolved_external_ref_without_deps_fails() {
1663        let mut engine = Engine::new();
1664
1665        let result = engine.load(
1666            r#"spec main_spec
1667fact external: spec @org/project/missing
1668rule value: external.quantity"#,
1669            SourceType::Labeled("main.lemma"),
1670        );
1671
1672        let errs = result.expect_err("Should fail when @... dep is not in file map");
1673        let msg = errs
1674            .iter()
1675            .map(|e| e.to_string())
1676            .collect::<Vec<_>>()
1677            .join(" ");
1678        assert!(
1679            msg.contains("missing") || msg.contains("not found") || msg.contains("Unknown"),
1680            "error should indicate missing dep: {msg}"
1681        );
1682    }
1683
1684    #[test]
1685    fn pre_resolved_deps_with_spec_and_type_refs() {
1686        let mut engine = Engine::new();
1687
1688        let mut deps = HashMap::new();
1689        deps.insert(
1690            "deps/helper.lemma".to_string(),
1691            "spec @org/example/helper\nfact value: 42".to_string(),
1692        );
1693        deps.insert(
1694            "deps/finance.lemma".to_string(),
1695            "spec @lemma/std/finance\ntype money: scale\n -> unit eur 1.00\n -> decimals 2"
1696                .to_string(),
1697        );
1698        engine
1699            .load(
1700                "spec @org/example/helper\nfact value: 42",
1701                SourceType::Dependency("deps/helper.lemma"),
1702            )
1703            .expect("should load helper file");
1704
1705        engine
1706            .load(
1707                "spec @lemma/std/finance\ntype money: scale\n -> unit eur 1.00\n -> decimals 2",
1708                SourceType::Dependency("deps/finance.lemma"),
1709            )
1710            .expect("should load finance file");
1711
1712        engine
1713            .load(
1714                r#"spec registry_demo
1715type money from @lemma/std/finance
1716fact unit_price: 5 eur
1717fact helper: spec @org/example/helper
1718rule helper_value: helper.value
1719rule line_total: unit_price * 2
1720rule formatted: helper_value + 0"#,
1721                SourceType::Labeled("main.lemma"),
1722            )
1723            .expect("should succeed with pre-resolved spec and type deps");
1724
1725        let now = DateTimeValue::now();
1726        let response = engine
1727            .run("registry_demo", Some(&now), HashMap::new(), false)
1728            .expect("evaluate should succeed");
1729
1730        assert_eq!(
1731            response
1732                .results
1733                .get("helper_value")
1734                .expect("helper_value")
1735                .result
1736                .value()
1737                .expect("value")
1738                .to_string(),
1739            "42"
1740        );
1741        let line = response
1742            .results
1743            .get("line_total")
1744            .expect("line_total")
1745            .result
1746            .value()
1747            .expect("value")
1748            .to_string();
1749        assert!(
1750            line.contains("10") && line.to_lowercase().contains("eur"),
1751            "5 eur * 2 => ~10 eur, got {line}"
1752        );
1753        assert_eq!(
1754            response
1755                .results
1756                .get("formatted")
1757                .expect("formatted")
1758                .result
1759                .value()
1760                .expect("value")
1761                .to_string(),
1762            "42"
1763        );
1764    }
1765
1766    #[test]
1767    fn load_empty_labeled_source_is_error() {
1768        let mut engine = Engine::new();
1769        let err = engine
1770            .load("spec x\nfact a: 1", SourceType::Labeled("  "))
1771            .unwrap_err();
1772        assert!(err.errors.iter().any(|e| e.message().contains("non-empty")));
1773    }
1774
1775    #[test]
1776    fn load_inline_source_succeeds() {
1777        let mut engine = Engine::new();
1778        engine
1779            .load("spec x\nfact a: 1", SourceType::Inline)
1780            .expect("inline load");
1781    }
1782
1783    #[test]
1784    fn load_rejects_registry_spec_definitions() {
1785        let mut engine = Engine::new();
1786        let result = engine.load(
1787            "spec @org/example/helper\nfact x: 1",
1788            SourceType::Labeled("bad.lemma"),
1789        );
1790        assert!(result.is_err(), "should reject @-prefixed spec in load");
1791        let errors = result.unwrap_err();
1792        assert!(
1793            errors
1794                .errors
1795                .iter()
1796                .any(|e| e.message().contains("registry prefix")),
1797            "error should mention registry prefix, got: {:?}",
1798            errors
1799        );
1800    }
1801
1802    #[test]
1803    fn add_dependency_files_accepts_registry_spec_definitions() {
1804        let mut engine = Engine::new();
1805        let mut files = HashMap::new();
1806        files.insert(
1807            "deps/helper.lemma".to_string(),
1808            "spec @org/my/helper\nfact x: 1".to_string(),
1809        );
1810        engine
1811            .load(
1812                "spec @org/my/helper\nfact x: 1",
1813                SourceType::Dependency("helper.lemma"),
1814            )
1815            .expect("add_dependency_files should accept @-prefixed specs");
1816    }
1817
1818    #[test]
1819    fn add_dependency_files_rejects_bare_named_spec_in_registry_bundle() {
1820        let mut engine = Engine::new();
1821        let result = engine.load(
1822            "spec local_looking_name\nfact x: 1",
1823            SourceType::Dependency("bundle.lemma"),
1824        );
1825        assert!(
1826            result.is_err(),
1827            "should reject non-@-prefixed spec in registry bundle"
1828        );
1829        let errors = result.unwrap_err();
1830        assert!(
1831            errors
1832                .errors
1833                .iter()
1834                .any(|e| e.message().contains("without '@' prefix")),
1835            "error should mention missing @ prefix, got: {:?}",
1836            errors
1837        );
1838    }
1839
1840    #[test]
1841    fn add_dependency_files_rejects_spec_with_bare_spec_reference() {
1842        let mut engine = Engine::new();
1843        let result = engine.load(
1844            "spec @org/billing\nfact rates: spec local_rates",
1845            SourceType::Dependency("billing.lemma"),
1846        );
1847        assert!(
1848            result.is_err(),
1849            "should reject registry spec referencing non-@ spec"
1850        );
1851        let errors = result.unwrap_err();
1852        assert!(
1853            errors
1854                .errors
1855                .iter()
1856                .any(|e| e.message().contains("local_rates")),
1857            "error should mention bare ref name, got: {:?}",
1858            errors
1859        );
1860    }
1861
1862    #[test]
1863    fn add_dependency_files_rejects_spec_with_bare_type_import() {
1864        let mut engine = Engine::new();
1865        let result = engine.load(
1866            "spec @org/billing\ntype money from local_finance",
1867            SourceType::Dependency("billing.lemma"),
1868        );
1869        assert!(
1870            result.is_err(),
1871            "should reject registry spec importing type from non-@ spec"
1872        );
1873        let errors = result.unwrap_err();
1874        assert!(
1875            errors
1876                .errors
1877                .iter()
1878                .any(|e| e.message().contains("local_finance")),
1879            "error should mention bare ref name, got: {:?}",
1880            errors
1881        );
1882    }
1883
1884    #[test]
1885    fn add_dependency_files_accepts_fully_qualified_references() {
1886        let mut engine = Engine::new();
1887        let mut files = HashMap::new();
1888        files.insert(
1889            "deps/bundle.lemma".to_string(),
1890            r#"spec @org/billing
1891fact rates: spec @org/rates
1892
1893spec @org/rates
1894fact rate: 10"#
1895                .to_string(),
1896        );
1897        engine
1898            .load(
1899                r#"spec @org/billing
1900fact rates: spec @org/rates
1901
1902spec @org/rates
1903fact rate: 10"#,
1904                SourceType::Dependency("bundle.lemma"),
1905            )
1906            .expect("fully @-prefixed bundle should be accepted");
1907    }
1908
1909    #[test]
1910    fn load_returns_all_errors_not_just_first() {
1911        let mut engine = Engine::new();
1912
1913        let result = engine.load(
1914            r#"spec demo
1915type money from nonexistent_type_source
1916fact helper: spec nonexistent_spec
1917fact price: 10
1918rule total: helper.value + price"#,
1919            SourceType::Labeled("test.lemma"),
1920        );
1921
1922        assert!(result.is_err(), "Should fail with multiple errors");
1923        let load_err = result.unwrap_err();
1924        assert!(
1925            load_err.errors.len() >= 2,
1926            "expected at least 2 errors (type + spec ref), got {}",
1927            load_err.errors.len()
1928        );
1929        let error_message = load_err
1930            .errors
1931            .iter()
1932            .map(ToString::to_string)
1933            .collect::<Vec<_>>()
1934            .join("; ");
1935
1936        assert!(
1937            error_message.contains("nonexistent_type_source"),
1938            "Should mention type import source spec. Got:\n{}",
1939            error_message
1940        );
1941        assert!(
1942            error_message.contains("nonexistent_spec"),
1943            "Should mention spec reference error about 'nonexistent_spec'. Got:\n{}",
1944            error_message
1945        );
1946    }
1947
1948    // ── Default value type validation ────────────────────────────────
1949    // Planning must reject default values that don't match the type.
1950    // These tests cover both primitives and named types (which the parser
1951    // can't validate because it doesn't resolve type names).
1952
1953    #[test]
1954    fn planning_rejects_invalid_number_default() {
1955        let mut engine = Engine::new();
1956        let result = engine.load(
1957            "spec t\nfact x: [number -> default \"10 $$\"]\nrule r: x",
1958            SourceType::Labeled("t.lemma"),
1959        );
1960        assert!(
1961            result.is_err(),
1962            "must reject non-numeric default on number type"
1963        );
1964    }
1965
1966    #[test]
1967    fn planning_rejects_text_literal_as_number_default() {
1968        // The parser produces CommandArg::Text("10") for `default "10"`.
1969        // Planning now checks the CommandArg variant: a Text literal is
1970        // rejected where a Number literal is required, even though the
1971        // string content "10" could be parsed as a valid Decimal.
1972        let mut engine = Engine::new();
1973        let result = engine.load(
1974            "spec t\nfact x: [number -> default \"10\"]\nrule r: x",
1975            SourceType::Labeled("t.lemma"),
1976        );
1977        assert!(
1978            result.is_err(),
1979            "must reject text literal \"10\" as default for number type"
1980        );
1981    }
1982
1983    #[test]
1984    fn planning_rejects_invalid_boolean_default() {
1985        let mut engine = Engine::new();
1986        let result = engine.load(
1987            "spec t\nfact x: [boolean -> default \"maybe\"]\nrule r: x",
1988            SourceType::Labeled("t.lemma"),
1989        );
1990        assert!(
1991            result.is_err(),
1992            "must reject non-boolean default on boolean type"
1993        );
1994    }
1995
1996    #[test]
1997    fn planning_rejects_invalid_named_type_default() {
1998        // Named type: the parser can't validate this, only planning can.
1999        let mut engine = Engine::new();
2000        let result = engine.load("spec t\ntype custom: number -> minimum 0\nfact x: [custom -> default \"abc\"]\nrule r: x", SourceType::Labeled("t.lemma",));
2001        assert!(
2002            result.is_err(),
2003            "must reject non-numeric default on named number type"
2004        );
2005    }
2006
2007    #[test]
2008    fn planning_accepts_valid_number_default() {
2009        let mut engine = Engine::new();
2010        let result = engine.load(
2011            "spec t\nfact x: [number -> default 10]\nrule r: x",
2012            SourceType::Labeled("t.lemma"),
2013        );
2014        assert!(result.is_ok(), "must accept valid number default");
2015    }
2016
2017    #[test]
2018    fn planning_accepts_valid_boolean_default() {
2019        let mut engine = Engine::new();
2020        let result = engine.load(
2021            "spec t\nfact x: [boolean -> default true]\nrule r: x",
2022            SourceType::Labeled("t.lemma"),
2023        );
2024        assert!(result.is_ok(), "must accept valid boolean default");
2025    }
2026
2027    #[test]
2028    fn planning_accepts_valid_text_default() {
2029        let mut engine = Engine::new();
2030        let result = engine.load(
2031            "spec t\nfact x: [text -> default \"hello\"]\nrule r: x",
2032            SourceType::Labeled("t.lemma"),
2033        );
2034        assert!(result.is_ok(), "must accept valid text default");
2035    }
2036}