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 content_hash;
9pub mod execution_plan;
10pub mod graph;
11pub mod semantics;
12pub mod slice_interface;
13pub mod temporal;
14pub mod types;
15pub mod validation;
16pub use execution_plan::{Branch, ExecutableRule, ExecutionPlan, SpecSchema};
17pub use semantics::{
18    negated_comparison, ArithmeticComputation, ComparisonComputation, Expression, ExpressionKind,
19    Fact, FactData, FactPath, FactValue, LemmaType, LiteralValue, LogicalComputation,
20    MathematicalComputation, NegationType, PathSegment, RulePath, Source, Span, TypeExtends,
21    ValueKind, VetoExpression,
22};
23pub use types::TypeResolver;
24
25use crate::engine::Context;
26use crate::parsing::ast::LemmaSpec;
27use crate::Error;
28use std::collections::HashMap;
29use std::sync::Arc;
30
31/// Result of planning a single spec: the spec, its execution plans (if any), and errors produced while planning it.
32#[derive(Debug, Clone)]
33pub struct SpecPlanningResult {
34    /// The spec we were planning (the one this result is for).
35    pub spec: Arc<LemmaSpec>,
36    /// Execution plans for that spec (one per temporal interval; empty if planning failed).
37    pub plans: Vec<ExecutionPlan>,
38    /// All planning errors produced while planning this spec.
39    pub errors: Vec<Error>,
40    /// Content hash of this spec (hash pin, 8 lowercase hex chars).
41    pub hash_pin: String,
42}
43
44/// Result of running plan() across the context: per-spec results and global errors (e.g. temporal coverage).
45#[derive(Debug, Clone)]
46pub struct PlanningResult {
47    /// One result per spec we attempted to plan.
48    pub per_spec: Vec<SpecPlanningResult>,
49    /// Errors not tied to a single spec (e.g. from validate_temporal_coverage).
50    pub global_errors: Vec<Error>,
51}
52
53/// Build execution plans for one or more Lemma specs.
54///
55/// Context is immutable — types are resolved transiently and never stored in
56/// Context. The flow:
57/// 1. TypeResolver registers + resolves named types → HashMap
58/// 2. Per-spec Graph::build augments the HashMap with inline types
59/// 3. ExecutionPlan is built from the graph (types baked into facts/rules)
60///
61/// Returns a PlanningResult: per-spec results (spec, plans, errors) and global errors.
62/// When displaying errors, iterate per_spec and for each with non-empty errors output "In spec 'X':" then each error.
63pub fn plan(context: &Context, sources: HashMap<String, String>) -> PlanningResult {
64    let mut global_errors: Vec<Error> = Vec::new();
65    global_errors.extend(temporal::validate_temporal_coverage(context));
66
67    let mut type_resolver = TypeResolver::new();
68    let all_specs: Vec<_> = context.iter().collect();
69    for spec_arc in &all_specs {
70        global_errors.extend(type_resolver.register_all(spec_arc));
71    }
72    let (mut resolved_types, type_errors) = type_resolver.resolve(all_specs.clone());
73    global_errors.extend(type_errors);
74
75    let mut per_spec: Vec<SpecPlanningResult> = Vec::new();
76
77    if !global_errors.is_empty() {
78        return PlanningResult {
79            per_spec,
80            global_errors,
81        };
82    }
83
84    // Compute content hashes for all specs (own content only for now).
85    // TODO: bottom-up transitive hashing once dep resolution order is settled.
86    let spec_hashes: graph::SpecContentHashes = all_specs
87        .iter()
88        .map(|s| (graph::spec_hash_key(s), content_hash::hash_spec(s, &[])))
89        .collect();
90
91    for spec_arc in &all_specs {
92        let slices = temporal::compute_temporal_slices(spec_arc, context);
93        let mut spec_plans: Vec<ExecutionPlan> = Vec::new();
94        let mut spec_errors: Vec<Error> = Vec::new();
95        let mut slice_resolved_types: Vec<HashMap<Arc<LemmaSpec>, types::ResolvedSpecTypes>> =
96            Vec::new();
97
98        for slice in &slices {
99            match graph::Graph::build(
100                spec_arc,
101                context,
102                sources.clone(),
103                &type_resolver,
104                &resolved_types,
105                slice.from.clone(),
106                &spec_hashes,
107            ) {
108                Ok((graph, slice_types)) => {
109                    for (arc, types) in &slice_types {
110                        resolved_types.insert(Arc::clone(arc), types.clone());
111                    }
112                    let execution_plan = execution_plan::build_execution_plan(
113                        &graph,
114                        spec_arc.name.as_str(),
115                        slice.from.clone(),
116                        slice.to.clone(),
117                    );
118                    let value_errors =
119                        execution_plan::validate_literal_facts_against_types(&execution_plan);
120                    if value_errors.is_empty() {
121                        spec_plans.push(execution_plan);
122                    } else {
123                        spec_errors.extend(value_errors);
124                    }
125                    slice_resolved_types.push(slice_types);
126                }
127                Err(build_errors) => {
128                    spec_errors.extend(build_errors);
129                }
130            }
131        }
132
133        if spec_errors.is_empty() && spec_plans.len() > 1 {
134            spec_errors.extend(slice_interface::validate_slice_interfaces(
135                &spec_arc.name,
136                &spec_plans,
137                &slice_resolved_types,
138            ));
139        }
140
141        let hash = spec_hashes
142            .get(&graph::spec_hash_key(spec_arc))
143            .cloned()
144            .unwrap_or_else(|| {
145                unreachable!("BUG: spec '{}' missing from spec_hashes", spec_arc.name)
146            });
147
148        per_spec.push(SpecPlanningResult {
149            spec: Arc::clone(spec_arc),
150            plans: spec_plans,
151            errors: spec_errors,
152            hash_pin: hash,
153        });
154    }
155
156    PlanningResult {
157        per_spec,
158        global_errors,
159    }
160}
161
162// ============================================================================
163// Tests
164// ============================================================================
165
166#[cfg(test)]
167mod internal_tests {
168    use super::plan;
169    use crate::engine::Context;
170    use crate::parsing::ast::{FactValue, LemmaFact, LemmaSpec, Reference, Span};
171    use crate::parsing::source::Source;
172    use crate::planning::execution_plan::ExecutionPlan;
173    use crate::planning::semantics::{FactPath, PathSegment};
174    use crate::{parse, Error, ResourceLimits};
175    use std::collections::HashMap;
176    use std::sync::Arc;
177
178    /// Test helper: plan a single spec and return its execution plan.
179    fn plan_single(
180        main_spec: &LemmaSpec,
181        all_specs: &[LemmaSpec],
182        sources: HashMap<String, String>,
183    ) -> Result<ExecutionPlan, Vec<Error>> {
184        let mut ctx = Context::new();
185        for spec in all_specs {
186            if let Err(e) = ctx.insert_spec(Arc::new(spec.clone())) {
187                return Err(vec![e]);
188            }
189        }
190        let main_spec_arc = ctx
191            .get_spec_effective_from(main_spec.name.as_str(), main_spec.effective_from())
192            .expect("main_spec must be in all_specs");
193        let result = plan(&ctx, sources);
194        let all_errors: Vec<Error> = result
195            .global_errors
196            .into_iter()
197            .chain(
198                result
199                    .per_spec
200                    .iter()
201                    .flat_map(|r| r.errors.clone())
202                    .collect::<Vec<_>>(),
203            )
204            .collect();
205        if !all_errors.is_empty() {
206            return Err(all_errors);
207        }
208        match result
209            .per_spec
210            .into_iter()
211            .find(|r| Arc::ptr_eq(&r.spec, &main_spec_arc))
212        {
213            Some(spec_result) if !spec_result.plans.is_empty() => {
214                let mut plans = spec_result.plans;
215                Ok(plans.remove(0))
216            }
217            _ => Err(vec![Error::validation(
218                format!("No execution plan produced for spec '{}'", main_spec.name),
219                Some(crate::planning::semantics::Source::new(
220                    "<test>",
221                    crate::planning::semantics::Span {
222                        start: 0,
223                        end: 0,
224                        line: 1,
225                        col: 0,
226                    },
227                    main_spec.name.clone(),
228                    std::sync::Arc::from("spec test\nfact x: 1"),
229                )),
230                None::<String>,
231            )]),
232        }
233    }
234
235    #[test]
236    fn test_basic_validation() {
237        let input = r#"spec person
238fact name: "John"
239fact age: 25
240rule is_adult: age >= 18"#;
241
242        let specs = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
243
244        let mut sources = HashMap::new();
245        sources.insert("test.lemma".to_string(), input.to_string());
246
247        for spec in &specs {
248            let result = plan_single(spec, &specs, sources.clone());
249            assert!(
250                result.is_ok(),
251                "Basic validation should pass: {:?}",
252                result.err()
253            );
254        }
255    }
256
257    #[test]
258    fn test_duplicate_facts() {
259        let input = r#"spec person
260fact name: "John"
261fact name: "Jane""#;
262
263        let specs = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
264
265        let mut sources = HashMap::new();
266        sources.insert("test.lemma".to_string(), input.to_string());
267
268        let result = plan_single(&specs[0], &specs, sources);
269
270        assert!(
271            result.is_err(),
272            "Duplicate facts should cause validation error"
273        );
274        let errors = result.unwrap_err();
275        let error_string = errors
276            .iter()
277            .map(|e| e.to_string())
278            .collect::<Vec<_>>()
279            .join(", ");
280        assert!(
281            error_string.contains("Duplicate fact"),
282            "Error should mention duplicate fact: {}",
283            error_string
284        );
285        assert!(error_string.contains("name"));
286    }
287
288    #[test]
289    fn test_duplicate_rules() {
290        let input = r#"spec person
291fact age: 25
292rule is_adult: age >= 18
293rule is_adult: age >= 21"#;
294
295        let specs = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
296
297        let mut sources = HashMap::new();
298        sources.insert("test.lemma".to_string(), input.to_string());
299
300        let result = plan_single(&specs[0], &specs, sources);
301
302        assert!(
303            result.is_err(),
304            "Duplicate rules should cause validation error"
305        );
306        let errors = result.unwrap_err();
307        let error_string = errors
308            .iter()
309            .map(|e| e.to_string())
310            .collect::<Vec<_>>()
311            .join(", ");
312        assert!(
313            error_string.contains("Duplicate rule"),
314            "Error should mention duplicate rule: {}",
315            error_string
316        );
317        assert!(error_string.contains("is_adult"));
318    }
319
320    #[test]
321    fn test_circular_dependency() {
322        let input = r#"spec test
323rule a: b
324rule b: a"#;
325
326        let specs = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
327
328        let mut sources = HashMap::new();
329        sources.insert("test.lemma".to_string(), input.to_string());
330
331        let result = plan_single(&specs[0], &specs, sources);
332
333        assert!(
334            result.is_err(),
335            "Circular dependency should cause validation error"
336        );
337        let errors = result.unwrap_err();
338        let error_string = errors
339            .iter()
340            .map(|e| e.to_string())
341            .collect::<Vec<_>>()
342            .join(", ");
343        assert!(error_string.contains("Circular dependency") || error_string.contains("circular"));
344    }
345
346    #[test]
347    fn test_unified_references_work() {
348        let input = r#"spec test
349fact age: 25
350rule is_adult: age >= 18
351rule test1: age
352rule test2: is_adult"#;
353
354        let specs = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
355
356        let mut sources = HashMap::new();
357        sources.insert("test.lemma".to_string(), input.to_string());
358
359        let result = plan_single(&specs[0], &specs, sources);
360
361        assert!(
362            result.is_ok(),
363            "Unified references should work: {:?}",
364            result.err()
365        );
366    }
367
368    #[test]
369    fn test_multiple_specs() {
370        let input = r#"spec person
371fact name: "John"
372fact age: 25
373
374spec company
375fact name: "Acme Corp"
376fact employee: spec person"#;
377
378        let specs = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
379
380        let mut sources = HashMap::new();
381        sources.insert("test.lemma".to_string(), input.to_string());
382
383        let result = plan_single(&specs[0], &specs, sources);
384
385        assert!(
386            result.is_ok(),
387            "Multiple specs should validate successfully: {:?}",
388            result.err()
389        );
390    }
391
392    #[test]
393    fn test_invalid_spec_reference() {
394        let input = r#"spec person
395fact name: "John"
396fact contract: spec nonexistent"#;
397
398        let specs = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
399
400        let mut sources = HashMap::new();
401        sources.insert("test.lemma".to_string(), input.to_string());
402
403        let result = plan_single(&specs[0], &specs, sources);
404
405        assert!(
406            result.is_err(),
407            "Invalid spec reference should cause validation error"
408        );
409        let errors = result.unwrap_err();
410        let error_string = errors
411            .iter()
412            .map(|e| e.to_string())
413            .collect::<Vec<_>>()
414            .join(", ");
415        assert!(
416            error_string.contains("not found")
417                || error_string.contains("Spec")
418                || (error_string.contains("nonexistent") && error_string.contains("depends")),
419            "Error should mention spec reference issue: {}",
420            error_string
421        );
422        assert!(error_string.contains("nonexistent"));
423    }
424
425    #[test]
426    fn test_type_declaration_empty_base_returns_lemma_error() {
427        let mut spec = LemmaSpec::new("test".to_string());
428        let source = Source::new(
429            "test.lemma",
430            Span {
431                start: 0,
432                end: 10,
433                line: 1,
434                col: 0,
435            },
436            "test",
437            Arc::from("fact x: []"),
438        );
439        spec.facts.push(LemmaFact::new(
440            Reference {
441                segments: vec![],
442                name: "x".to_string(),
443            },
444            FactValue::TypeDeclaration {
445                base: String::new(),
446                constraints: None,
447                from: None,
448            },
449            source,
450        ));
451
452        let specs = vec![spec.clone()];
453        let mut sources = HashMap::new();
454        sources.insert(
455            "test.lemma".to_string(),
456            "spec test\nfact x: []".to_string(),
457        );
458
459        let result = plan_single(&spec, &specs, sources);
460        assert!(
461            result.is_err(),
462            "TypeDeclaration with empty base should fail planning"
463        );
464        let errors = result.unwrap_err();
465        let combined = errors
466            .iter()
467            .map(|e| e.to_string())
468            .collect::<Vec<_>>()
469            .join("\n");
470        assert!(
471            combined.contains("TypeDeclaration base cannot be empty"),
472            "Error should mention empty base; got: {}",
473            combined
474        );
475    }
476
477    #[test]
478    fn test_fact_binding_with_custom_type_resolves_in_correct_spec_context() {
479        // This is a planning-level test: ensure fact bindings resolve custom types correctly
480        // when the type is defined in a different spec than the binding.
481        //
482        // spec one:
483        //   type money: number
484        //   fact x: [money]
485        // spec two:
486        //   fact one: spec one
487        //   fact one.x: 7
488        //   rule getx: one.x
489        let code = r#"
490spec one
491type money: number
492fact x: [money]
493
494spec two
495fact one: spec one
496fact one.x: 7
497rule getx: one.x
498"#;
499
500        let specs = parse(code, "test.lemma", &ResourceLimits::default()).unwrap();
501        let spec_two = specs.iter().find(|d| d.name == "two").unwrap();
502
503        let mut sources = HashMap::new();
504        sources.insert("test.lemma".to_string(), code.to_string());
505        let execution_plan =
506            plan_single(spec_two, &specs, sources).expect("planning should succeed");
507
508        // Verify that one.x has type 'money' (resolved from spec one)
509        let one_x_path = FactPath {
510            segments: vec![PathSegment {
511                fact: "one".to_string(),
512                spec: "one".to_string(),
513            }],
514            fact: "x".to_string(),
515        };
516
517        let one_x_type = execution_plan
518            .facts
519            .get(&one_x_path)
520            .and_then(|d| d.schema_type())
521            .expect("one.x should have a resolved type");
522
523        assert_eq!(
524            one_x_type.name(),
525            "money",
526            "one.x should have type 'money', got: {}",
527            one_x_type.name()
528        );
529        assert!(one_x_type.is_number(), "money should be number-based");
530    }
531
532    #[test]
533    fn test_plan_with_registry_style_spec_names() {
534        let source = r#"spec @user/workspace/somespec
535fact quantity: 10
536
537spec user/workspace/example
538fact inventory: spec @user/workspace/somespec
539rule total_quantity: inventory.quantity"#;
540
541        let specs = parse(source, "registry_bundle.lemma", &ResourceLimits::default()).unwrap();
542        assert_eq!(specs.len(), 2);
543
544        let example_spec = specs
545            .iter()
546            .find(|d| d.name == "user/workspace/example")
547            .expect("should find user/workspace/example");
548
549        let mut sources = HashMap::new();
550        sources.insert("registry_bundle.lemma".to_string(), source.to_string());
551
552        let result = plan_single(example_spec, &specs, sources);
553        assert!(
554            result.is_ok(),
555            "Planning with @... spec names should succeed: {:?}",
556            result.err()
557        );
558    }
559
560    #[test]
561    fn test_multiple_independent_errors_are_all_reported() {
562        // A spec referencing a non-existing type import AND a non-existing
563        // spec should report errors for BOTH, not just stop at the first.
564        let source = r#"spec demo
565type money from nonexistent_type_source
566fact helper: spec nonexistent_spec
567fact price: 10
568rule total: helper.value + price"#;
569
570        let specs = parse(source, "test.lemma", &ResourceLimits::default()).unwrap();
571
572        let mut sources = HashMap::new();
573        sources.insert("test.lemma".to_string(), source.to_string());
574
575        let result = plan_single(&specs[0], &specs, sources);
576        assert!(result.is_err(), "Planning should fail with multiple errors");
577
578        let errors = result.unwrap_err();
579        let all_messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
580        let combined = all_messages.join("\n");
581
582        // Must report the type resolution error (shows up as "Unknown type: 'money'")
583        assert!(
584            combined.contains("Unknown type") && combined.contains("money"),
585            "Should report type import error for 'money'. Got:\n{}",
586            combined
587        );
588
589        // Must also report the spec reference error (not just the type error)
590        assert!(
591            combined.contains("nonexistent_spec"),
592            "Should report spec reference error for 'nonexistent_spec'. Got:\n{}",
593            combined
594        );
595
596        // Should have at least 2 distinct kinds of errors (type + spec ref)
597        assert!(
598            errors.len() >= 2,
599            "Expected at least 2 errors, got {}: {}",
600            errors.len(),
601            combined
602        );
603    }
604
605    #[test]
606    fn test_type_error_does_not_suppress_cross_spec_fact_error() {
607        // When a type import fails, errors about cross-spec fact references
608        // (e.g. ext.some_fact where ext is a spec ref to a non-existing spec)
609        // must still be reported.
610        let source = r#"spec demo
611type currency from missing_spec
612fact ext: spec also_missing
613rule val: ext.some_fact"#;
614
615        let specs = parse(source, "test.lemma", &ResourceLimits::default()).unwrap();
616
617        let mut sources = HashMap::new();
618        sources.insert("test.lemma".to_string(), source.to_string());
619
620        let result = plan_single(&specs[0], &specs, sources);
621        assert!(result.is_err());
622
623        let errors = result.unwrap_err();
624        let combined: String = errors
625            .iter()
626            .map(|e| e.to_string())
627            .collect::<Vec<_>>()
628            .join("\n");
629
630        // The type error about 'currency' should be reported
631        assert!(
632            combined.contains("currency"),
633            "Should report type error about 'currency'. Got:\n{}",
634            combined
635        );
636
637        // The spec reference error about 'also_missing' should ALSO be reported
638        assert!(
639            combined.contains("also_missing"),
640            "Should report error about 'also_missing'. Got:\n{}",
641            combined
642        );
643    }
644}