Skip to main content

lemma/planning/
mod.rs

1//! Planning module for Lemma documents
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 document structure and references
7
8pub mod execution_plan;
9pub mod graph;
10pub mod semantics;
11pub mod types;
12pub mod validation;
13
14pub use execution_plan::{Branch, DocumentSchema, ExecutableRule, ExecutionPlan};
15pub use semantics::{
16    ArithmeticComputation, ComparisonComputation, Expression, ExpressionKind, Fact, FactData,
17    FactPath, FactValue, LemmaType, LiteralValue, LogicalComputation, MathematicalComputation,
18    NegationType, PathSegment, RulePath, Source, Span, TypeExtends, ValueKind, VetoExpression,
19};
20pub use types::TypeRegistry;
21
22use crate::parsing::ast::LemmaDoc;
23use crate::LemmaError;
24use std::collections::HashMap;
25
26/// Build execution plans for one or more Lemma documents.
27///
28/// Performs all global work (validation, type registration, type resolution)
29/// **once** across all documents, then builds per-document graphs and
30/// execution plans, collecting all errors in a single pass.
31///
32/// Returns a tuple of (successful plans, all errors). Partial success is
33/// possible: some documents may produce valid plans while others fail.
34///
35/// `docs_to_plan` is the subset of `all_docs` for which execution plans should
36/// be built. `all_docs` includes every document in the workspace (needed for
37/// cross-document type resolution and doc references).
38///
39/// The `sources` parameter maps source IDs (filenames) to their source code,
40/// needed for extracting original expression text in proofs.
41pub fn plan(
42    docs_to_plan: &[&LemmaDoc],
43    all_docs: &[LemmaDoc],
44    sources: HashMap<String, String>,
45) -> (HashMap<String, ExecutionPlan>, Vec<LemmaError>) {
46    let mut plans = HashMap::new();
47    let mut errors: Vec<LemmaError> = Vec::new();
48
49    // 1. Prepare types once (registration + named type resolution + spec validation).
50    let (prepared, type_errors) = graph::Graph::prepare_types(all_docs, &sources);
51    errors.extend(type_errors);
52
53    // 2. Per-doc: build graph + execution plan.
54    for doc in docs_to_plan {
55        match graph::Graph::build(doc, all_docs, sources.clone(), &prepared) {
56            Ok(graph) => {
57                let execution_plan = execution_plan::build_execution_plan(&graph, &doc.name);
58                let value_errors =
59                    execution_plan::validate_literal_facts_against_types(&execution_plan);
60                if value_errors.is_empty() {
61                    plans.insert(doc.name.clone(), execution_plan);
62                } else {
63                    errors.extend(value_errors);
64                }
65            }
66            Err(doc_errors) => errors.extend(doc_errors),
67        }
68    }
69
70    (plans, errors)
71}
72
73// ============================================================================
74// Tests
75// ============================================================================
76
77#[cfg(test)]
78mod internal_tests {
79    use super::plan;
80    use crate::parsing::ast::{FactReference, FactValue, LemmaDoc, LemmaFact, Span};
81    use crate::parsing::source::Source;
82    use crate::planning::execution_plan::ExecutionPlan;
83    use crate::planning::semantics::{FactPath, PathSegment};
84    use crate::{parse, LemmaError, ResourceLimits};
85    use std::collections::HashMap;
86    use std::sync::Arc;
87
88    /// Test helper: plan a single document and return its execution plan.
89    fn plan_single(
90        main_doc: &LemmaDoc,
91        all_docs: &[LemmaDoc],
92        sources: HashMap<String, String>,
93    ) -> Result<ExecutionPlan, Vec<LemmaError>> {
94        let docs_to_plan: Vec<&LemmaDoc> = vec![main_doc];
95        let (mut plans, errors) = plan(&docs_to_plan, all_docs, sources);
96        if !errors.is_empty() {
97            Err(errors)
98        } else {
99            plans.remove(&main_doc.name).map(Ok).unwrap_or_else(|| {
100                Err(vec![LemmaError::engine(
101                    format!(
102                        "No execution plan produced for document '{}'",
103                        main_doc.name
104                    ),
105                    Some(crate::planning::semantics::Source::new(
106                        "<test>",
107                        crate::planning::semantics::Span {
108                            start: 0,
109                            end: 0,
110                            line: 1,
111                            col: 0,
112                        },
113                        main_doc.name.clone(),
114                        std::sync::Arc::from("doc test\nfact x = 1"),
115                    )),
116                    None::<String>,
117                )])
118            })
119        }
120    }
121
122    #[test]
123    fn test_basic_validation() {
124        let input = r#"doc person
125fact name = "John"
126fact age = 25
127rule is_adult = age >= 18"#;
128
129        let docs = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
130
131        let mut sources = HashMap::new();
132        sources.insert("test.lemma".to_string(), input.to_string());
133
134        for doc in &docs {
135            let result = plan_single(doc, &docs, sources.clone());
136            assert!(
137                result.is_ok(),
138                "Basic validation should pass: {:?}",
139                result.err()
140            );
141        }
142    }
143
144    #[test]
145    fn test_duplicate_facts() {
146        let input = r#"doc person
147fact name = "John"
148fact name = "Jane""#;
149
150        let docs = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
151
152        let mut sources = HashMap::new();
153        sources.insert("test.lemma".to_string(), input.to_string());
154
155        let result = plan_single(&docs[0], &docs, sources);
156
157        assert!(
158            result.is_err(),
159            "Duplicate facts should cause validation error"
160        );
161        let errors = result.unwrap_err();
162        let error_string = errors
163            .iter()
164            .map(|e| e.to_string())
165            .collect::<Vec<_>>()
166            .join(", ");
167        assert!(
168            error_string.contains("Duplicate fact"),
169            "Error should mention duplicate fact: {}",
170            error_string
171        );
172        assert!(error_string.contains("name"));
173    }
174
175    #[test]
176    fn test_duplicate_rules() {
177        let input = r#"doc person
178fact age = 25
179rule is_adult = age >= 18
180rule is_adult = age >= 21"#;
181
182        let docs = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
183
184        let mut sources = HashMap::new();
185        sources.insert("test.lemma".to_string(), input.to_string());
186
187        let result = plan_single(&docs[0], &docs, sources);
188
189        assert!(
190            result.is_err(),
191            "Duplicate rules should cause validation error"
192        );
193        let errors = result.unwrap_err();
194        let error_string = errors
195            .iter()
196            .map(|e| e.to_string())
197            .collect::<Vec<_>>()
198            .join(", ");
199        assert!(
200            error_string.contains("Duplicate rule"),
201            "Error should mention duplicate rule: {}",
202            error_string
203        );
204        assert!(error_string.contains("is_adult"));
205    }
206
207    #[test]
208    fn test_circular_dependency() {
209        let input = r#"doc test
210rule a = b?
211rule b = a?"#;
212
213        let docs = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
214
215        let mut sources = HashMap::new();
216        sources.insert("test.lemma".to_string(), input.to_string());
217
218        let result = plan_single(&docs[0], &docs, sources);
219
220        assert!(
221            result.is_err(),
222            "Circular dependency should cause validation error"
223        );
224        let errors = result.unwrap_err();
225        let error_string = errors
226            .iter()
227            .map(|e| e.to_string())
228            .collect::<Vec<_>>()
229            .join(", ");
230        assert!(error_string.contains("Circular dependency") || error_string.contains("circular"));
231    }
232
233    #[test]
234    fn test_reference_type_errors() {
235        let input = r#"doc test
236fact age = 25
237rule is_adult = age >= 18
238rule test1 = age?
239rule test2 = is_adult"#;
240
241        let docs = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
242
243        let mut sources = HashMap::new();
244        sources.insert("test.lemma".to_string(), input.to_string());
245
246        let result = plan_single(&docs[0], &docs, sources);
247
248        assert!(
249            result.is_err(),
250            "Reference type errors should cause validation error"
251        );
252        let errors = result.unwrap_err();
253        let error_string = errors
254            .iter()
255            .map(|e| e.to_string())
256            .collect::<Vec<_>>()
257            .join(", ");
258        assert!(
259            error_string.contains("is a rule, not a fact") || error_string.contains("Reference"),
260            "Error should mention reference issue: {}",
261            error_string
262        );
263    }
264
265    #[test]
266    fn test_multiple_documents() {
267        let input = r#"doc person
268fact name = "John"
269fact age = 25
270
271doc company
272fact name = "Acme Corp"
273fact employee = doc person"#;
274
275        let docs = parse(input, "test.lemma", &ResourceLimits::default()).unwrap();
276
277        let mut sources = HashMap::new();
278        sources.insert("test.lemma".to_string(), input.to_string());
279
280        let result = plan_single(&docs[0], &docs, sources);
281
282        assert!(
283            result.is_ok(),
284            "Multiple documents should validate successfully: {:?}",
285            result.err()
286        );
287    }
288
289    #[test]
290    fn test_invalid_document_reference() {
291        let input = r#"doc person
292fact name = "John"
293fact contract = doc nonexistent"#;
294
295        let docs = 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(&docs[0], &docs, sources);
301
302        assert!(
303            result.is_err(),
304            "Invalid document reference 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("not found") || error_string.contains("Document"),
314            "Error should mention document reference issue: {}",
315            error_string
316        );
317        assert!(error_string.contains("nonexistent"));
318    }
319
320    #[test]
321    fn test_type_declaration_empty_base_returns_lemma_error() {
322        let mut doc = LemmaDoc::new("test".to_string());
323        let source = Source::new(
324            "test.lemma",
325            Span {
326                start: 0,
327                end: 10,
328                line: 1,
329                col: 0,
330            },
331            "test",
332            Arc::from("fact x = []"),
333        );
334        doc.facts.push(LemmaFact::new(
335            FactReference {
336                segments: vec![],
337                fact: "x".to_string(),
338            },
339            FactValue::TypeDeclaration {
340                base: String::new(),
341                constraints: None,
342                from: None,
343            },
344            source,
345        ));
346
347        let docs = vec![doc.clone()];
348        let mut sources = HashMap::new();
349        sources.insert(
350            "test.lemma".to_string(),
351            "doc test\nfact x = []".to_string(),
352        );
353
354        let result = plan_single(&doc, &docs, sources);
355        assert!(
356            result.is_err(),
357            "TypeDeclaration with empty base should fail planning"
358        );
359        let errors = result.unwrap_err();
360        let combined = errors
361            .iter()
362            .map(|e| e.to_string())
363            .collect::<Vec<_>>()
364            .join("\n");
365        assert!(
366            combined.contains("TypeDeclaration base cannot be empty"),
367            "Error should mention empty base; got: {}",
368            combined
369        );
370    }
371
372    #[test]
373    fn test_fact_binding_with_custom_type_resolves_in_correct_document_context() {
374        // This is a planning-level test: ensure fact bindings resolve custom types correctly
375        // when the type is defined in a different document than the binding.
376        //
377        // doc one:
378        //   type money = number
379        //   fact x = [money]
380        // doc two:
381        //   fact one = doc one
382        //   fact one.x = 7
383        //   rule getx = one.x
384        let code = r#"
385doc one
386type money = number
387fact x = [money]
388
389doc two
390fact one = doc one
391fact one.x = 7
392rule getx = one.x
393"#;
394
395        let docs = parse(code, "test.lemma", &ResourceLimits::default()).unwrap();
396        let doc_two = docs.iter().find(|d| d.name == "two").unwrap();
397
398        let mut sources = HashMap::new();
399        sources.insert("test.lemma".to_string(), code.to_string());
400        let execution_plan = plan_single(doc_two, &docs, sources).expect("planning should succeed");
401
402        // Verify that one.x has type 'money' (resolved from doc one)
403        let one_x_path = FactPath {
404            segments: vec![PathSegment {
405                fact: "one".to_string(),
406                doc: "one".to_string(),
407            }],
408            fact: "x".to_string(),
409        };
410
411        let one_x_type = execution_plan
412            .facts
413            .get(&one_x_path)
414            .and_then(|d| d.schema_type())
415            .expect("one.x should have a resolved type");
416
417        assert_eq!(
418            one_x_type.name(),
419            "money",
420            "one.x should have type 'money', got: {}",
421            one_x_type.name()
422        );
423        assert!(one_x_type.is_number(), "money should be number-based");
424    }
425
426    #[test]
427    fn test_plan_with_registry_style_doc_names() {
428        let source = r#"doc user/workspace/somedoc
429fact quantity = 10
430
431doc user/workspace/example
432fact inventory = doc @user/workspace/somedoc
433rule total_quantity = inventory.quantity"#;
434
435        let docs = parse(source, "registry_bundle.lemma", &ResourceLimits::default()).unwrap();
436        assert_eq!(docs.len(), 2);
437
438        let example_doc = docs
439            .iter()
440            .find(|d| d.name == "user/workspace/example")
441            .expect("should find user/workspace/example");
442
443        let mut sources = HashMap::new();
444        sources.insert("registry_bundle.lemma".to_string(), source.to_string());
445
446        let result = plan_single(example_doc, &docs, sources);
447        assert!(
448            result.is_ok(),
449            "Planning with @... document names should succeed: {:?}",
450            result.err()
451        );
452    }
453
454    #[test]
455    fn test_multiple_independent_errors_are_all_reported() {
456        // A document referencing a non-existing type import AND a non-existing
457        // document should report errors for BOTH, not just stop at the first.
458        let source = r#"doc demo
459type money from nonexistent_type_source
460fact helper = doc nonexistent_doc
461fact price = 10
462rule total = helper.value + price"#;
463
464        let docs = parse(source, "test.lemma", &ResourceLimits::default()).unwrap();
465
466        let mut sources = HashMap::new();
467        sources.insert("test.lemma".to_string(), source.to_string());
468
469        let result = plan_single(&docs[0], &docs, sources);
470        assert!(result.is_err(), "Planning should fail with multiple errors");
471
472        let errors = result.unwrap_err();
473        let all_messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
474        let combined = all_messages.join("\n");
475
476        // Must report the type resolution error (shows up as "Unknown type: 'money'")
477        assert!(
478            combined.contains("Unknown type") && combined.contains("money"),
479            "Should report type import error for 'money'. Got:\n{}",
480            combined
481        );
482
483        // Must also report the document reference error (not just the type error)
484        assert!(
485            combined.contains("nonexistent_doc"),
486            "Should report doc reference error for 'nonexistent_doc'. Got:\n{}",
487            combined
488        );
489
490        // Should have at least 2 distinct kinds of errors (type + doc ref)
491        assert!(
492            errors.len() >= 2,
493            "Expected at least 2 errors, got {}: {}",
494            errors.len(),
495            combined
496        );
497    }
498
499    #[test]
500    fn test_type_error_does_not_suppress_cross_doc_fact_error() {
501        // When a type import fails, errors about cross-document fact references
502        // (e.g. ext.some_fact where ext is a doc ref to a non-existing doc)
503        // must still be reported.
504        let source = r#"doc demo
505type currency from missing_doc
506fact ext = doc also_missing
507rule val = ext.some_fact"#;
508
509        let docs = parse(source, "test.lemma", &ResourceLimits::default()).unwrap();
510
511        let mut sources = HashMap::new();
512        sources.insert("test.lemma".to_string(), source.to_string());
513
514        let result = plan_single(&docs[0], &docs, sources);
515        assert!(result.is_err());
516
517        let errors = result.unwrap_err();
518        let combined: String = errors
519            .iter()
520            .map(|e| e.to_string())
521            .collect::<Vec<_>>()
522            .join("\n");
523
524        // The type error about 'currency' should be reported
525        assert!(
526            combined.contains("currency"),
527            "Should report type error about 'currency'. Got:\n{}",
528            combined
529        );
530
531        // The document reference error about 'also_missing' should ALSO be reported
532        assert!(
533            combined.contains("also_missing"),
534            "Should report error about 'also_missing'. Got:\n{}",
535            combined
536        );
537    }
538}