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(String::new()),
642                constraints: None,
643                from: None,
644            },
645            source,
646        ));
647
648        let specs = vec![spec.clone()];
649        let mut sources = HashMap::new();
650        sources.insert(
651            "test.lemma".to_string(),
652            "spec test\nfact x: []".to_string(),
653        );
654
655        let result = plan_single(&spec, &specs, sources);
656        assert!(
657            result.is_err(),
658            "TypeDeclaration with empty base should fail planning"
659        );
660        let errors = result.unwrap_err();
661        let combined = errors
662            .iter()
663            .map(|e| e.to_string())
664            .collect::<Vec<_>>()
665            .join("\n");
666        assert!(
667            combined.contains("TypeDeclaration base cannot be empty"),
668            "Error should mention empty base; got: {}",
669            combined
670        );
671    }
672
673    #[test]
674    fn test_fact_binding_with_custom_type_resolves_in_correct_spec_context() {
675        // This is a planning-level test: ensure fact bindings resolve custom types correctly
676        // when the type is defined in a different spec than the binding.
677        //
678        // spec one:
679        //   type money: number
680        //   fact x: [money]
681        // spec two:
682        //   fact one: spec one
683        //   fact one.x: 7
684        //   rule getx: one.x
685        let code = r#"
686spec one
687type money: number
688fact x: [money]
689
690spec two
691fact one: spec one
692fact one.x: 7
693rule getx: one.x
694"#;
695
696        let specs = parse(code, "test.lemma", &ResourceLimits::default())
697            .unwrap()
698            .specs;
699        let spec_two = specs.iter().find(|d| d.name == "two").unwrap();
700
701        let mut sources = HashMap::new();
702        sources.insert("test.lemma".to_string(), code.to_string());
703        let execution_plan =
704            plan_single(spec_two, &specs, sources).expect("planning should succeed");
705
706        // Verify that one.x has type 'money' (resolved from spec one)
707        let one_x_path = FactPath {
708            segments: vec![PathSegment {
709                fact: "one".to_string(),
710                spec: "one".to_string(),
711            }],
712            fact: "x".to_string(),
713        };
714
715        let one_x_type = execution_plan
716            .facts
717            .get(&one_x_path)
718            .and_then(|d| d.schema_type())
719            .expect("one.x should have a resolved type");
720
721        assert_eq!(
722            one_x_type.name(),
723            "money",
724            "one.x should have type 'money', got: {}",
725            one_x_type.name()
726        );
727        assert!(one_x_type.is_number(), "money should be number-based");
728    }
729
730    #[test]
731    fn test_fact_type_declaration_from_spec_has_import_defining_spec() {
732        let code = r#"
733spec examples
734type money: scale
735  -> unit eur 1.00
736
737spec checkout
738type money: scale
739  -> unit eur 1.00
740fact local_price: [money]
741fact imported_price: [money from examples]
742"#;
743
744        let specs = parse(code, "test.lemma", &ResourceLimits::default())
745            .unwrap()
746            .specs;
747
748        let mut ctx = Context::new();
749        for spec in &specs {
750            ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry)
751                .expect("insert spec");
752        }
753
754        let examples_arc = ctx
755            .get_spec_effective_from("examples", None)
756            .expect("examples spec should be present");
757        let checkout_arc = ctx
758            .get_spec_effective_from("checkout", None)
759            .expect("checkout spec should be present");
760
761        let mut sources = HashMap::new();
762        sources.insert("test.lemma".to_string(), code.to_string());
763
764        let result = plan(&ctx, sources);
765        assert!(
766            result.global_errors.is_empty(),
767            "No global errors expected, got: {:?}",
768            result.global_errors
769        );
770
771        let checkout_result = result
772            .per_spec
773            .iter()
774            .find(|r| Arc::ptr_eq(&r.spec, &checkout_arc))
775            .expect("checkout result should exist");
776        assert!(
777            checkout_result.errors.is_empty(),
778            "No checkout planning errors expected, got: {:?}",
779            checkout_result.errors
780        );
781        assert!(
782            !checkout_result.plans.is_empty(),
783            "checkout should produce at least one plan"
784        );
785        let execution_plan = &checkout_result.plans[0];
786
787        let local_type = execution_plan
788            .facts
789            .get(&FactPath::new(vec![], "local_price".to_string()))
790            .and_then(|d| d.schema_type())
791            .expect("local_price should have schema type");
792        let imported_type = execution_plan
793            .facts
794            .get(&FactPath::new(vec![], "imported_price".to_string()))
795            .and_then(|d| d.schema_type())
796            .expect("imported_price should have schema type");
797
798        match &local_type.extends {
799            TypeExtends::Custom {
800                defining_spec: TypeDefiningSpec::Local,
801                ..
802            } => {}
803            other => panic!(
804                "local_price should resolve as local defining_spec, got {:?}",
805                other
806            ),
807        }
808
809        match &imported_type.extends {
810            TypeExtends::Custom {
811                defining_spec: TypeDefiningSpec::Import { spec, .. },
812                ..
813            } => {
814                assert!(
815                    Arc::ptr_eq(spec, &examples_arc),
816                    "imported_price should point to resolved 'examples' spec arc"
817                );
818            }
819            other => panic!(
820                "imported_price should resolve as import defining_spec, got {:?}",
821                other
822            ),
823        }
824    }
825
826    #[test]
827    fn test_plan_with_registry_style_spec_names() {
828        let source = r#"spec @user/workspace/somespec
829fact quantity: 10
830
831spec user/workspace/example
832fact inventory: spec @user/workspace/somespec
833rule total_quantity: inventory.quantity"#;
834
835        let specs = parse(source, "registry_bundle.lemma", &ResourceLimits::default())
836            .unwrap()
837            .specs;
838        assert_eq!(specs.len(), 2);
839
840        let example_spec = specs
841            .iter()
842            .find(|d| d.name == "user/workspace/example")
843            .expect("should find user/workspace/example");
844
845        let mut sources = HashMap::new();
846        sources.insert("registry_bundle.lemma".to_string(), source.to_string());
847
848        let result = plan_single(example_spec, &specs, sources);
849        assert!(
850            result.is_ok(),
851            "Planning with @... spec names should succeed: {:?}",
852            result.err()
853        );
854    }
855
856    #[test]
857    fn test_multiple_independent_errors_are_all_reported() {
858        // A spec referencing a non-existing type import AND a non-existing
859        // spec should report errors for BOTH, not just stop at the first.
860        let source = r#"spec demo
861type money from nonexistent_type_source
862fact helper: spec nonexistent_spec
863fact price: 10
864rule total: helper.value + price"#;
865
866        let specs = parse(source, "test.lemma", &ResourceLimits::default())
867            .unwrap()
868            .specs;
869
870        let mut sources = HashMap::new();
871        sources.insert("test.lemma".to_string(), source.to_string());
872
873        let result = plan_single(&specs[0], &specs, sources);
874        assert!(result.is_err(), "Planning should fail with multiple errors");
875
876        let errors = result.unwrap_err();
877        let all_messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
878        let combined = all_messages.join("\n");
879
880        assert!(
881            combined.contains("nonexistent_type_source"),
882            "Should report type import error for 'nonexistent_type_source'. Got:\n{}",
883            combined
884        );
885
886        // Must also report the spec reference error (not just the type error)
887        assert!(
888            combined.contains("nonexistent_spec"),
889            "Should report spec reference error for 'nonexistent_spec'. Got:\n{}",
890            combined
891        );
892
893        // Should have at least 2 distinct kinds of errors (type + spec ref)
894        assert!(
895            errors.len() >= 2,
896            "Expected at least 2 errors, got {}: {}",
897            errors.len(),
898            combined
899        );
900    }
901
902    #[test]
903    fn test_type_error_does_not_suppress_cross_spec_fact_error() {
904        // When a type import fails, errors about cross-spec fact references
905        // (e.g. ext.some_fact where ext is a spec ref to a non-existing spec)
906        // must still be reported.
907        let source = r#"spec demo
908type currency from missing_spec
909fact ext: spec also_missing
910rule val: ext.some_fact"#;
911
912        let specs = parse(source, "test.lemma", &ResourceLimits::default())
913            .unwrap()
914            .specs;
915
916        let mut sources = HashMap::new();
917        sources.insert("test.lemma".to_string(), source.to_string());
918
919        let result = plan_single(&specs[0], &specs, sources);
920        assert!(result.is_err());
921
922        let errors = result.unwrap_err();
923        let combined: String = errors
924            .iter()
925            .map(|e| e.to_string())
926            .collect::<Vec<_>>()
927            .join("\n");
928
929        assert!(
930            combined.contains("missing_spec"),
931            "Should report type import error about 'missing_spec'. Got:\n{}",
932            combined
933        );
934
935        // The spec reference error about 'also_missing' should ALSO be reported
936        assert!(
937            combined.contains("also_missing"),
938            "Should report error about 'also_missing'. Got:\n{}",
939            combined
940        );
941    }
942
943    #[test]
944    fn test_spec_order_includes_fact_type_declaration_from_edges() {
945        let source = r#"spec dep
946type money: number
947fact x: [money]
948
949spec consumer
950fact imported_amount: [money from dep]
951rule passthrough: imported_amount"#;
952        let specs = parse(source, "test.lemma", &ResourceLimits::default())
953            .unwrap()
954            .specs;
955
956        let mut ctx = Context::new();
957        for spec in &specs {
958            ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry)
959                .expect("insert spec");
960        }
961
962        let ordered = order_specs_for_planning_graph(ctx.iter().collect())
963            .expect("spec order should succeed");
964        let ordered_names: Vec<String> = ordered.iter().map(|s| s.name.clone()).collect();
965        let dep_idx = ordered_names
966            .iter()
967            .position(|n| n == "dep")
968            .expect("dep must exist");
969        let consumer_idx = ordered_names
970            .iter()
971            .position(|n| n == "consumer")
972            .expect("consumer must exist");
973        assert!(
974            dep_idx < consumer_idx,
975            "dependency must be planned before dependent. order={:?}",
976            ordered_names
977        );
978    }
979
980    #[test]
981    fn test_spec_dependency_cycle_returns_global_error_and_aborts() {
982        let source = r#"spec a
983fact dep_b: spec b
984
985spec b
986fact imported_value: [amount from a]
987"#;
988        let specs = parse(source, "test.lemma", &ResourceLimits::default())
989            .unwrap()
990            .specs;
991
992        let mut ctx = Context::new();
993        for spec in &specs {
994            ctx.insert_spec(Arc::new(spec.clone()), spec.from_registry)
995                .expect("insert spec");
996        }
997
998        let result = plan(&ctx, HashMap::new());
999        assert!(
1000            result
1001                .global_errors
1002                .iter()
1003                .any(|e| e.to_string().contains("Spec dependency cycle")),
1004            "expected global cycle error, got {:?}",
1005            result
1006                .global_errors
1007                .iter()
1008                .map(|e| e.to_string())
1009                .collect::<Vec<_>>()
1010        );
1011        assert!(
1012            result.per_spec.is_empty(),
1013            "planning should abort before per-spec planning when cycle exists"
1014        );
1015    }
1016}