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