Skip to main content

lemma/planning/
mod.rs

1//! Planning module for Lemma specs
2//!
3//! This module performs complete static analysis and builds execution plans:
4//! - Builds Graph with facts and rules (validated, with types computed)
5//! - Builds ExecutionPlan from Graph (topologically sorted, ready for evaluation)
6//! - Validates spec structure and references
7
8pub mod execution_plan;
9pub mod fingerprint;
10pub mod graph;
11pub mod semantics;
12pub mod slice_interface;
13pub mod temporal;
14pub mod types;
15pub mod validation;
16use crate::engine::Context;
17use crate::parsing::ast::{DateTimeValue, FactValue as ParsedFactValue, LemmaSpec, TypeDef};
18use crate::Error;
19pub use execution_plan::{Branch, ExecutableRule, ExecutionPlan, SpecSchema};
20pub use semantics::{
21    is_same_spec, negated_comparison, ArithmeticComputation, ComparisonComputation, Expression,
22    ExpressionKind, Fact, FactData, FactPath, FactValue, LemmaType, LiteralValue,
23    LogicalComputation, MathematicalComputation, NegationType, PathSegment, RulePath, Source, Span,
24    TypeDefiningSpec, TypeExtends, ValueKind, VetoExpression,
25};
26use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
27use std::sync::Arc;
28
29/// Slice-keyed registry of plan hashes built during the planning loop.
30/// Filled incrementally as specs are planned in topological order.
31#[derive(Debug, Default, Clone)]
32pub struct PlanHashRegistry {
33    by_slice: BTreeMap<(String, Option<DateTimeValue>), String>,
34    by_pin: BTreeMap<(String, String), Arc<LemmaSpec>>,
35}
36
37impl PlanHashRegistry {
38    pub(crate) fn insert(
39        &mut self,
40        spec: &Arc<LemmaSpec>,
41        slice_from: Option<DateTimeValue>,
42        hash: String,
43    ) {
44        let hash_lower = hash.trim().to_ascii_lowercase();
45        self.by_slice
46            .insert((spec.name.clone(), slice_from), hash_lower.clone());
47        self.by_pin
48            .insert((spec.name.clone(), hash_lower), Arc::clone(spec));
49    }
50
51    /// Lookup plan hash for a dependency at a given slice start.
52    pub(crate) fn get_by_slice(
53        &self,
54        spec_name: &str,
55        slice_from: &Option<DateTimeValue>,
56    ) -> Option<&str> {
57        self.by_slice
58            .get(&(spec_name.to_string(), slice_from.clone()))
59            .map(|s| s.as_str())
60    }
61
62    /// Lookup spec arc by (name, hash) for pin resolution.
63    pub(crate) fn get_by_pin(&self, spec_name: &str, hash: &str) -> Option<&Arc<LemmaSpec>> {
64        let key = (spec_name.to_string(), hash.trim().to_ascii_lowercase());
65        self.by_pin.get(&key)
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
70struct SpecName(String);
71
72impl SpecName {
73    fn new(name: impl Into<String>) -> Self {
74        Self(name.into())
75    }
76}
77
78impl std::fmt::Display for SpecName {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        write!(f, "{}", self.0)
81    }
82}
83
84fn collect_spec_dependencies(
85    spec: &LemmaSpec,
86    known_names: &BTreeSet<SpecName>,
87) -> BTreeSet<SpecName> {
88    let mut deps = BTreeSet::new();
89    let current = SpecName::new(spec.name.clone());
90
91    for fact in &spec.facts {
92        match &fact.value {
93            ParsedFactValue::SpecReference(r) => {
94                let dep = SpecName::new(r.name.clone());
95                if dep != current && known_names.contains(&dep) {
96                    deps.insert(dep);
97                }
98            }
99            ParsedFactValue::TypeDeclaration {
100                from: Some(from_ref),
101                ..
102            } => {
103                let dep = SpecName::new(from_ref.name.clone());
104                if dep != current && known_names.contains(&dep) {
105                    deps.insert(dep);
106                }
107            }
108            _ => {}
109        }
110    }
111
112    for type_def in &spec.types {
113        if let TypeDef::Import { from, .. } = type_def {
114            let dep = SpecName::new(from.name.clone());
115            if dep != current && known_names.contains(&dep) {
116                deps.insert(dep);
117            }
118        }
119    }
120
121    deps
122}
123
124/// Order specs so dependencies (referenced specs / type-import sources) are planned first.
125/// Needed for `spec dep~hash` resolution: hashes are recorded as each spec's plans are built.
126pub(crate) fn order_specs_for_planning_graph(
127    specs: Vec<Arc<LemmaSpec>>,
128) -> Result<Vec<Arc<LemmaSpec>>, Vec<Error>> {
129    let all_names: BTreeSet<SpecName> = specs
130        .iter()
131        .map(|s| SpecName::new(s.name.clone()))
132        .collect();
133
134    let mut deps_by_name: BTreeMap<SpecName, BTreeSet<SpecName>> = BTreeMap::new();
135    for name in &all_names {
136        deps_by_name.insert(name.clone(), BTreeSet::new());
137    }
138    for spec in &specs {
139        let deps = collect_spec_dependencies(spec, &all_names);
140        deps_by_name.insert(SpecName::new(spec.name.clone()), deps);
141    }
142
143    // Kahn: in_degree[name] = number of prerequisites for that name.
144    let mut in_degree: BTreeMap<SpecName, usize> = BTreeMap::new();
145    for (name, deps) in &deps_by_name {
146        in_degree.insert(name.clone(), deps.len());
147    }
148
149    // Reverse edges: dependency -> dependents
150    let mut dependents: BTreeMap<SpecName, BTreeSet<SpecName>> = BTreeMap::new();
151    for name in &all_names {
152        dependents.insert(name.clone(), BTreeSet::new());
153    }
154    for (name, deps) in &deps_by_name {
155        for dep in deps {
156            if let Some(children) = dependents.get_mut(dep) {
157                children.insert(name.clone());
158            }
159        }
160    }
161
162    let mut queue: VecDeque<SpecName> = VecDeque::new();
163    for (name, degree) in &in_degree {
164        if *degree == 0 {
165            queue.push_back(name.clone());
166        }
167    }
168
169    let mut ordered_names: Vec<SpecName> = Vec::new();
170    while let Some(name) = queue.pop_front() {
171        ordered_names.push(name.clone());
172        if let Some(children) = dependents.get(&name) {
173            for child in children {
174                if let Some(degree) = in_degree.get_mut(child) {
175                    *degree -= 1;
176                    if *degree == 0 {
177                        queue.push_back(child.clone());
178                    }
179                }
180            }
181        }
182    }
183
184    if ordered_names.len() != all_names.len() {
185        let mut cycle_nodes: Vec<SpecName> = in_degree
186            .iter()
187            .filter_map(|(name, degree)| {
188                if *degree > 0 {
189                    Some(name.clone())
190                } else {
191                    None
192                }
193            })
194            .collect();
195        cycle_nodes.sort();
196        let cycle_path = if cycle_nodes.len() > 1 {
197            let mut path = cycle_nodes.clone();
198            path.push(cycle_nodes[0].clone());
199            path.iter()
200                .map(std::string::ToString::to_string)
201                .collect::<Vec<_>>()
202                .join(" -> ")
203        } else {
204            cycle_nodes
205                .iter()
206                .map(std::string::ToString::to_string)
207                .collect::<Vec<_>>()
208                .join(" -> ")
209        };
210        return Err(vec![Error::validation(
211            format!("Spec dependency cycle: {}", cycle_path),
212            None,
213            None::<String>,
214        )]);
215    }
216
217    let mut by_name: HashMap<SpecName, Vec<Arc<LemmaSpec>>> = HashMap::new();
218    for s in specs {
219        by_name
220            .entry(SpecName::new(s.name.clone()))
221            .or_default()
222            .push(s);
223    }
224    for v in by_name.values_mut() {
225        v.sort_by(|a, b| a.effective_from().cmp(&b.effective_from()));
226    }
227
228    let mut out = Vec::new();
229    for name in ordered_names {
230        if let Some(mut vec) = by_name.remove(&name) {
231            out.append(&mut vec);
232        }
233    }
234    Ok(out)
235}
236
237/// Result of planning a single spec: the spec, its execution plans (if any), and errors produced while planning it.
238#[derive(Debug, Clone)]
239pub struct SpecPlanningResult {
240    /// The spec we were planning (the one this result is for).
241    pub spec: Arc<LemmaSpec>,
242    /// Execution plans for that spec (one per temporal interval; empty if planning failed).
243    pub plans: Vec<ExecutionPlan>,
244    /// All planning errors produced while planning this spec.
245    pub errors: Vec<Error>,
246}
247
248/// Result of running plan() across the context: per-spec results and global errors (e.g. temporal coverage).
249#[derive(Debug, Clone)]
250pub struct PlanningResult {
251    /// One result per spec we attempted to plan.
252    pub per_spec: Vec<SpecPlanningResult>,
253    /// Errors not tied to a single spec (e.g. from validate_temporal_coverage).
254    pub global_errors: Vec<Error>,
255    /// Slice- and pin-keyed registry of plan hashes for request-level spec resolution.
256    pub plan_hash_registry: PlanHashRegistry,
257}
258
259/// Build execution plans for one or more Lemma specs.
260///
261/// Context is immutable — types are resolved per (spec, slice) inside Graph::build
262/// and never stored in Context. The flow:
263/// 1. Per-spec, per-slice: Graph::build registers + resolves types using Context + resolve_at
264/// 2. ExecutionPlan is built from the graph (types baked into facts/rules)
265///
266/// Returns a PlanningResult: per-spec results (spec, plans, errors) and global errors.
267/// When displaying errors, iterate per_spec and for each with non-empty errors output "In spec 'X':" then each error.
268pub fn plan(context: &Context, sources: HashMap<String, String>) -> PlanningResult {
269    let mut global_errors: Vec<Error> = Vec::new();
270    global_errors.extend(temporal::validate_temporal_coverage(context));
271
272    let all_specs: Vec<_> = match order_specs_for_planning_graph(context.iter().collect()) {
273        Ok(specs) => specs,
274        Err(mut cycle_errors) => {
275            global_errors.append(&mut cycle_errors);
276            return PlanningResult {
277                per_spec: Vec::new(),
278                global_errors,
279                plan_hash_registry: PlanHashRegistry::default(),
280            };
281        }
282    };
283
284    let mut per_spec: Vec<SpecPlanningResult> = Vec::new();
285    let mut plan_hashes = PlanHashRegistry::default();
286
287    for spec_arc in &all_specs {
288        let slices = temporal::compute_temporal_slices(spec_arc, context);
289        let mut spec_plans: Vec<ExecutionPlan> = Vec::new();
290        let mut spec_errors: Vec<Error> = Vec::new();
291        let mut slice_resolved_types: Vec<HashMap<Arc<LemmaSpec>, types::ResolvedSpecTypes>> =
292            Vec::new();
293
294        for slice in &slices {
295            match graph::Graph::build(
296                spec_arc,
297                context,
298                sources.clone(),
299                slice.from.clone(),
300                &plan_hashes,
301            ) {
302                Ok((graph, slice_types)) => {
303                    let execution_plan = execution_plan::build_execution_plan(
304                        &graph,
305                        &slice_types,
306                        slice.from.clone(),
307                        slice.to.clone(),
308                    );
309                    let value_errors =
310                        execution_plan::validate_literal_facts_against_types(&execution_plan);
311                    if value_errors.is_empty() {
312                        let hash = execution_plan.plan_hash();
313                        plan_hashes.insert(spec_arc, slice.from.clone(), hash);
314                        spec_plans.push(execution_plan);
315                    } else {
316                        spec_errors.extend(value_errors);
317                    }
318                    slice_resolved_types.push(slice_types);
319                }
320                Err(build_errors) => {
321                    spec_errors.extend(build_errors);
322                }
323            }
324        }
325
326        if spec_errors.is_empty() && spec_plans.len() > 1 {
327            spec_errors.extend(slice_interface::validate_slice_interfaces(
328                spec_arc,
329                &spec_plans,
330                &slice_resolved_types,
331            ));
332        }
333
334        per_spec.push(SpecPlanningResult {
335            spec: Arc::clone(spec_arc),
336            plans: spec_plans,
337            errors: spec_errors,
338        });
339    }
340
341    PlanningResult {
342        per_spec,
343        global_errors,
344        plan_hash_registry: plan_hashes,
345    }
346}
347
348// ============================================================================
349// Tests
350// ============================================================================
351
352#[cfg(test)]
353mod internal_tests {
354    use super::{order_specs_for_planning_graph, plan};
355    use crate::engine::Context;
356    use crate::parsing::ast::{FactValue, LemmaFact, LemmaSpec, ParentType, Reference, Span};
357    use crate::parsing::source::Source;
358    use crate::planning::execution_plan::ExecutionPlan;
359    use crate::planning::semantics::{FactPath, PathSegment, TypeDefiningSpec, TypeExtends};
360    use crate::{parse, Error, ResourceLimits};
361    use std::collections::HashMap;
362    use std::sync::Arc;
363
364    /// Test helper: plan a single spec and return its execution plan.
365    fn plan_single(
366        main_spec: &LemmaSpec,
367        all_specs: &[LemmaSpec],
368        sources: HashMap<String, String>,
369    ) -> Result<ExecutionPlan, Vec<Error>> {
370        let mut ctx = Context::new();
371        for spec in all_specs {
372            if let Err(e) = ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry) {
373                return Err(vec![e]);
374            }
375        }
376        let main_spec_arc = ctx
377            .get_spec_effective_from(main_spec.name.as_str(), main_spec.effective_from())
378            .expect("main_spec must be in all_specs");
379        let result = plan(&ctx, sources);
380        let all_errors: Vec<Error> = result
381            .global_errors
382            .into_iter()
383            .chain(
384                result
385                    .per_spec
386                    .iter()
387                    .flat_map(|r| r.errors.clone())
388                    .collect::<Vec<_>>(),
389            )
390            .collect();
391        if !all_errors.is_empty() {
392            return Err(all_errors);
393        }
394        match result
395            .per_spec
396            .into_iter()
397            .find(|r| Arc::ptr_eq(&r.spec, &main_spec_arc))
398        {
399            Some(spec_result) if !spec_result.plans.is_empty() => {
400                let mut plans = spec_result.plans;
401                Ok(plans.remove(0))
402            }
403            _ => Err(vec![Error::validation(
404                format!("No execution plan produced for spec '{}'", main_spec.name),
405                Some(crate::planning::semantics::Source::new(
406                    "<test>",
407                    crate::planning::semantics::Span {
408                        start: 0,
409                        end: 0,
410                        line: 1,
411                        col: 0,
412                    },
413                )),
414                None::<String>,
415            )]),
416        }
417    }
418
419    #[test]
420    fn test_basic_validation() {
421        let input = r#"spec person
422fact name: "John"
423fact age: 25
424rule is_adult: age >= 18"#;
425
426        let specs = parse(input, "test.lemma", &ResourceLimits::default())
427            .unwrap()
428            .specs;
429
430        let mut sources = HashMap::new();
431        sources.insert("test.lemma".to_string(), input.to_string());
432
433        for spec in &specs {
434            let result = plan_single(spec, &specs, sources.clone());
435            assert!(
436                result.is_ok(),
437                "Basic validation should pass: {:?}",
438                result.err()
439            );
440        }
441    }
442
443    #[test]
444    fn test_duplicate_facts() {
445        let input = r#"spec person
446fact name: "John"
447fact name: "Jane""#;
448
449        let specs = parse(input, "test.lemma", &ResourceLimits::default())
450            .unwrap()
451            .specs;
452
453        let mut sources = HashMap::new();
454        sources.insert("test.lemma".to_string(), input.to_string());
455
456        let result = plan_single(&specs[0], &specs, sources);
457
458        assert!(
459            result.is_err(),
460            "Duplicate facts should cause validation error"
461        );
462        let errors = result.unwrap_err();
463        let error_string = errors
464            .iter()
465            .map(|e| e.to_string())
466            .collect::<Vec<_>>()
467            .join(", ");
468        assert!(
469            error_string.contains("Duplicate fact"),
470            "Error should mention duplicate fact: {}",
471            error_string
472        );
473        assert!(error_string.contains("name"));
474    }
475
476    #[test]
477    fn test_duplicate_rules() {
478        let input = r#"spec person
479fact age: 25
480rule is_adult: age >= 18
481rule is_adult: age >= 21"#;
482
483        let specs = parse(input, "test.lemma", &ResourceLimits::default())
484            .unwrap()
485            .specs;
486
487        let mut sources = HashMap::new();
488        sources.insert("test.lemma".to_string(), input.to_string());
489
490        let result = plan_single(&specs[0], &specs, sources);
491
492        assert!(
493            result.is_err(),
494            "Duplicate rules should cause validation error"
495        );
496        let errors = result.unwrap_err();
497        let error_string = errors
498            .iter()
499            .map(|e| e.to_string())
500            .collect::<Vec<_>>()
501            .join(", ");
502        assert!(
503            error_string.contains("Duplicate rule"),
504            "Error should mention duplicate rule: {}",
505            error_string
506        );
507        assert!(error_string.contains("is_adult"));
508    }
509
510    #[test]
511    fn test_circular_dependency() {
512        let input = r#"spec test
513rule a: b
514rule b: a"#;
515
516        let specs = parse(input, "test.lemma", &ResourceLimits::default())
517            .unwrap()
518            .specs;
519
520        let mut sources = HashMap::new();
521        sources.insert("test.lemma".to_string(), input.to_string());
522
523        let result = plan_single(&specs[0], &specs, sources);
524
525        assert!(
526            result.is_err(),
527            "Circular dependency should cause validation error"
528        );
529        let errors = result.unwrap_err();
530        let error_string = errors
531            .iter()
532            .map(|e| e.to_string())
533            .collect::<Vec<_>>()
534            .join(", ");
535        assert!(error_string.contains("Circular dependency") || error_string.contains("circular"));
536    }
537
538    #[test]
539    fn test_unified_references_work() {
540        let input = r#"spec test
541fact age: 25
542rule is_adult: age >= 18
543rule test1: age
544rule test2: is_adult"#;
545
546        let specs = parse(input, "test.lemma", &ResourceLimits::default())
547            .unwrap()
548            .specs;
549
550        let mut sources = HashMap::new();
551        sources.insert("test.lemma".to_string(), input.to_string());
552
553        let result = plan_single(&specs[0], &specs, sources);
554
555        assert!(
556            result.is_ok(),
557            "Unified references should work: {:?}",
558            result.err()
559        );
560    }
561
562    #[test]
563    fn test_multiple_specs() {
564        let input = r#"spec person
565fact name: "John"
566fact age: 25
567
568spec company
569fact name: "Acme Corp"
570fact employee: spec person"#;
571
572        let specs = parse(input, "test.lemma", &ResourceLimits::default())
573            .unwrap()
574            .specs;
575
576        let mut sources = HashMap::new();
577        sources.insert("test.lemma".to_string(), input.to_string());
578
579        let result = plan_single(&specs[0], &specs, sources);
580
581        assert!(
582            result.is_ok(),
583            "Multiple specs should validate successfully: {:?}",
584            result.err()
585        );
586    }
587
588    #[test]
589    fn test_invalid_spec_reference() {
590        let input = r#"spec person
591fact name: "John"
592fact contract: spec nonexistent"#;
593
594        let specs = parse(input, "test.lemma", &ResourceLimits::default())
595            .unwrap()
596            .specs;
597
598        let mut sources = HashMap::new();
599        sources.insert("test.lemma".to_string(), input.to_string());
600
601        let result = plan_single(&specs[0], &specs, sources);
602
603        assert!(
604            result.is_err(),
605            "Invalid spec reference should cause validation error"
606        );
607        let errors = result.unwrap_err();
608        let error_string = errors
609            .iter()
610            .map(|e| e.to_string())
611            .collect::<Vec<_>>()
612            .join(", ");
613        assert!(
614            error_string.contains("not found")
615                || error_string.contains("Spec")
616                || (error_string.contains("nonexistent") && error_string.contains("depends")),
617            "Error should mention spec reference issue: {}",
618            error_string
619        );
620        assert!(error_string.contains("nonexistent"));
621    }
622
623    #[test]
624    fn test_type_declaration_empty_base_returns_lemma_error() {
625        let mut spec = LemmaSpec::new("test".to_string());
626        let source = Source::new(
627            "test.lemma",
628            Span {
629                start: 0,
630                end: 10,
631                line: 1,
632                col: 0,
633            },
634        );
635        spec.facts.push(LemmaFact::new(
636            Reference {
637                segments: vec![],
638                name: "x".to_string(),
639            },
640            FactValue::TypeDeclaration {
641                base: ParentType::Custom {
642                    name: String::new(),
643                },
644                constraints: None,
645                from: None,
646            },
647            source,
648        ));
649
650        let specs = vec![spec.clone()];
651        let mut sources = HashMap::new();
652        sources.insert(
653            "test.lemma".to_string(),
654            "spec test\nfact x: []".to_string(),
655        );
656
657        let result = plan_single(&spec, &specs, sources);
658        assert!(
659            result.is_err(),
660            "TypeDeclaration with empty base should fail planning"
661        );
662        let errors = result.unwrap_err();
663        let combined = errors
664            .iter()
665            .map(|e| e.to_string())
666            .collect::<Vec<_>>()
667            .join("\n");
668        assert!(
669            combined.contains("TypeDeclaration base cannot be empty"),
670            "Error should mention empty base; got: {}",
671            combined
672        );
673    }
674
675    #[test]
676    fn test_fact_binding_with_custom_type_resolves_in_correct_spec_context() {
677        // This is a planning-level test: ensure fact bindings resolve custom types correctly
678        // when the type is defined in a different spec than the binding.
679        //
680        // spec one:
681        //   type money: number
682        //   fact x: [money]
683        // spec two:
684        //   fact one: spec one
685        //   fact one.x: 7
686        //   rule getx: one.x
687        let code = r#"
688spec one
689type money: number
690fact x: [money]
691
692spec two
693fact one: spec one
694fact one.x: 7
695rule getx: one.x
696"#;
697
698        let specs = parse(code, "test.lemma", &ResourceLimits::default())
699            .unwrap()
700            .specs;
701        let spec_two = specs.iter().find(|d| d.name == "two").unwrap();
702
703        let mut sources = HashMap::new();
704        sources.insert("test.lemma".to_string(), code.to_string());
705        let execution_plan =
706            plan_single(spec_two, &specs, sources).expect("planning should succeed");
707
708        // Verify that one.x has type 'money' (resolved from spec one)
709        let one_x_path = FactPath {
710            segments: vec![PathSegment {
711                fact: "one".to_string(),
712                spec: "one".to_string(),
713            }],
714            fact: "x".to_string(),
715        };
716
717        let one_x_type = execution_plan
718            .facts
719            .get(&one_x_path)
720            .and_then(|d| d.schema_type())
721            .expect("one.x should have a resolved type");
722
723        assert_eq!(
724            one_x_type.name(),
725            "money",
726            "one.x should have type 'money', got: {}",
727            one_x_type.name()
728        );
729        assert!(one_x_type.is_number(), "money should be number-based");
730    }
731
732    #[test]
733    fn test_fact_type_declaration_from_spec_has_import_defining_spec() {
734        let code = r#"
735spec examples
736type money: scale
737  -> unit eur 1.00
738
739spec checkout
740type money: scale
741  -> unit eur 1.00
742fact local_price: [money]
743fact imported_price: [money from examples]
744"#;
745
746        let specs = parse(code, "test.lemma", &ResourceLimits::default())
747            .unwrap()
748            .specs;
749
750        let mut ctx = Context::new();
751        for spec in &specs {
752            ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry)
753                .expect("insert spec");
754        }
755
756        let examples_arc = ctx
757            .get_spec_effective_from("examples", None)
758            .expect("examples spec should be present");
759        let checkout_arc = ctx
760            .get_spec_effective_from("checkout", None)
761            .expect("checkout spec should be present");
762
763        let mut sources = HashMap::new();
764        sources.insert("test.lemma".to_string(), code.to_string());
765
766        let result = plan(&ctx, sources);
767        assert!(
768            result.global_errors.is_empty(),
769            "No global errors expected, got: {:?}",
770            result.global_errors
771        );
772
773        let checkout_result = result
774            .per_spec
775            .iter()
776            .find(|r| Arc::ptr_eq(&r.spec, &checkout_arc))
777            .expect("checkout result should exist");
778        assert!(
779            checkout_result.errors.is_empty(),
780            "No checkout planning errors expected, got: {:?}",
781            checkout_result.errors
782        );
783        assert!(
784            !checkout_result.plans.is_empty(),
785            "checkout should produce at least one plan"
786        );
787        let execution_plan = &checkout_result.plans[0];
788
789        let local_type = execution_plan
790            .facts
791            .get(&FactPath::new(vec![], "local_price".to_string()))
792            .and_then(|d| d.schema_type())
793            .expect("local_price should have schema type");
794        let imported_type = execution_plan
795            .facts
796            .get(&FactPath::new(vec![], "imported_price".to_string()))
797            .and_then(|d| d.schema_type())
798            .expect("imported_price should have schema type");
799
800        match &local_type.extends {
801            TypeExtends::Custom {
802                defining_spec: TypeDefiningSpec::Local,
803                ..
804            } => {}
805            other => panic!(
806                "local_price should resolve as local defining_spec, got {:?}",
807                other
808            ),
809        }
810
811        match &imported_type.extends {
812            TypeExtends::Custom {
813                defining_spec: TypeDefiningSpec::Import { spec, .. },
814                ..
815            } => {
816                assert!(
817                    Arc::ptr_eq(spec, &examples_arc),
818                    "imported_price should point to resolved 'examples' spec arc"
819                );
820            }
821            other => panic!(
822                "imported_price should resolve as import defining_spec, got {:?}",
823                other
824            ),
825        }
826    }
827
828    #[test]
829    fn test_plan_with_registry_style_spec_names() {
830        let source = r#"spec @user/workspace/somespec
831fact quantity: 10
832
833spec user/workspace/example
834fact inventory: spec @user/workspace/somespec
835rule total_quantity: inventory.quantity"#;
836
837        let specs = parse(source, "registry_bundle.lemma", &ResourceLimits::default())
838            .unwrap()
839            .specs;
840        assert_eq!(specs.len(), 2);
841
842        let example_spec = specs
843            .iter()
844            .find(|d| d.name == "user/workspace/example")
845            .expect("should find user/workspace/example");
846
847        let mut sources = HashMap::new();
848        sources.insert("registry_bundle.lemma".to_string(), source.to_string());
849
850        let result = plan_single(example_spec, &specs, sources);
851        assert!(
852            result.is_ok(),
853            "Planning with @... spec names should succeed: {:?}",
854            result.err()
855        );
856    }
857
858    #[test]
859    fn test_multiple_independent_errors_are_all_reported() {
860        // A spec referencing a non-existing type import AND a non-existing
861        // spec should report errors for BOTH, not just stop at the first.
862        let source = r#"spec demo
863type money from nonexistent_type_source
864fact helper: spec nonexistent_spec
865fact price: 10
866rule total: helper.value + price"#;
867
868        let specs = parse(source, "test.lemma", &ResourceLimits::default())
869            .unwrap()
870            .specs;
871
872        let mut sources = HashMap::new();
873        sources.insert("test.lemma".to_string(), source.to_string());
874
875        let result = plan_single(&specs[0], &specs, sources);
876        assert!(result.is_err(), "Planning should fail with multiple errors");
877
878        let errors = result.unwrap_err();
879        let all_messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
880        let combined = all_messages.join("\n");
881
882        assert!(
883            combined.contains("nonexistent_type_source"),
884            "Should report type import error for 'nonexistent_type_source'. Got:\n{}",
885            combined
886        );
887
888        // Must also report the spec reference error (not just the type error)
889        assert!(
890            combined.contains("nonexistent_spec"),
891            "Should report spec reference error for 'nonexistent_spec'. Got:\n{}",
892            combined
893        );
894
895        // Should have at least 2 distinct kinds of errors (type + spec ref)
896        assert!(
897            errors.len() >= 2,
898            "Expected at least 2 errors, got {}: {}",
899            errors.len(),
900            combined
901        );
902    }
903
904    #[test]
905    fn test_type_error_does_not_suppress_cross_spec_fact_error() {
906        // When a type import fails, errors about cross-spec fact references
907        // (e.g. ext.some_fact where ext is a spec ref to a non-existing spec)
908        // must still be reported.
909        let source = r#"spec demo
910type currency from missing_spec
911fact ext: spec also_missing
912rule val: ext.some_fact"#;
913
914        let specs = parse(source, "test.lemma", &ResourceLimits::default())
915            .unwrap()
916            .specs;
917
918        let mut sources = HashMap::new();
919        sources.insert("test.lemma".to_string(), source.to_string());
920
921        let result = plan_single(&specs[0], &specs, sources);
922        assert!(result.is_err());
923
924        let errors = result.unwrap_err();
925        let combined: String = errors
926            .iter()
927            .map(|e| e.to_string())
928            .collect::<Vec<_>>()
929            .join("\n");
930
931        assert!(
932            combined.contains("missing_spec"),
933            "Should report type import error about 'missing_spec'. Got:\n{}",
934            combined
935        );
936
937        // The spec reference error about 'also_missing' should ALSO be reported
938        assert!(
939            combined.contains("also_missing"),
940            "Should report error about 'also_missing'. Got:\n{}",
941            combined
942        );
943    }
944
945    #[test]
946    fn test_spec_order_includes_fact_type_declaration_from_edges() {
947        let source = r#"spec dep
948type money: number
949fact x: [money]
950
951spec consumer
952fact imported_amount: [money from dep]
953rule passthrough: imported_amount"#;
954        let specs = parse(source, "test.lemma", &ResourceLimits::default())
955            .unwrap()
956            .specs;
957
958        let mut ctx = Context::new();
959        for spec in &specs {
960            ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry)
961                .expect("insert spec");
962        }
963
964        let ordered = order_specs_for_planning_graph(ctx.iter().collect())
965            .expect("spec order should succeed");
966        let ordered_names: Vec<String> = ordered.iter().map(|s| s.name.clone()).collect();
967        let dep_idx = ordered_names
968            .iter()
969            .position(|n| n == "dep")
970            .expect("dep must exist");
971        let consumer_idx = ordered_names
972            .iter()
973            .position(|n| n == "consumer")
974            .expect("consumer must exist");
975        assert!(
976            dep_idx < consumer_idx,
977            "dependency must be planned before dependent. order={:?}",
978            ordered_names
979        );
980    }
981
982    #[test]
983    fn test_spec_dependency_cycle_returns_global_error_and_aborts() {
984        let source = r#"spec a
985fact dep_b: spec b
986
987spec b
988fact imported_value: [amount from a]
989"#;
990        let specs = parse(source, "test.lemma", &ResourceLimits::default())
991            .unwrap()
992            .specs;
993
994        let mut ctx = Context::new();
995        for spec in &specs {
996            ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry)
997                .expect("insert spec");
998        }
999
1000        let result = plan(&ctx, HashMap::new());
1001        assert!(
1002            result
1003                .global_errors
1004                .iter()
1005                .any(|e| e.to_string().contains("Spec dependency cycle")),
1006            "expected global cycle error, got {:?}",
1007            result
1008                .global_errors
1009                .iter()
1010                .map(|e| e.to_string())
1011                .collect::<Vec<_>>()
1012        );
1013        assert!(
1014            result.per_spec.is_empty(),
1015            "planning should abort before per-spec planning when cycle exists"
1016        );
1017    }
1018}