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                    std::sync::Arc::from("spec test\nfact x: 1"),
228                )),
229                None::<String>,
230            )]),
231        }
232    }
233
234    #[test]
235    fn test_basic_validation() {
236        let input = r#"spec person
237fact name: "John"
238fact age: 25
239rule is_adult: age >= 18"#;
240
241        let specs = parse(input, "test.lemma", &ResourceLimits::default())
242            .unwrap()
243            .specs;
244
245        let mut sources = HashMap::new();
246        sources.insert("test.lemma".to_string(), input.to_string());
247
248        for spec in &specs {
249            let result = plan_single(spec, &specs, sources.clone());
250            assert!(
251                result.is_ok(),
252                "Basic validation should pass: {:?}",
253                result.err()
254            );
255        }
256    }
257
258    #[test]
259    fn test_duplicate_facts() {
260        let input = r#"spec person
261fact name: "John"
262fact name: "Jane""#;
263
264        let specs = parse(input, "test.lemma", &ResourceLimits::default())
265            .unwrap()
266            .specs;
267
268        let mut sources = HashMap::new();
269        sources.insert("test.lemma".to_string(), input.to_string());
270
271        let result = plan_single(&specs[0], &specs, sources);
272
273        assert!(
274            result.is_err(),
275            "Duplicate facts should cause validation error"
276        );
277        let errors = result.unwrap_err();
278        let error_string = errors
279            .iter()
280            .map(|e| e.to_string())
281            .collect::<Vec<_>>()
282            .join(", ");
283        assert!(
284            error_string.contains("Duplicate fact"),
285            "Error should mention duplicate fact: {}",
286            error_string
287        );
288        assert!(error_string.contains("name"));
289    }
290
291    #[test]
292    fn test_duplicate_rules() {
293        let input = r#"spec person
294fact age: 25
295rule is_adult: age >= 18
296rule is_adult: age >= 21"#;
297
298        let specs = parse(input, "test.lemma", &ResourceLimits::default())
299            .unwrap()
300            .specs;
301
302        let mut sources = HashMap::new();
303        sources.insert("test.lemma".to_string(), input.to_string());
304
305        let result = plan_single(&specs[0], &specs, sources);
306
307        assert!(
308            result.is_err(),
309            "Duplicate rules should cause validation error"
310        );
311        let errors = result.unwrap_err();
312        let error_string = errors
313            .iter()
314            .map(|e| e.to_string())
315            .collect::<Vec<_>>()
316            .join(", ");
317        assert!(
318            error_string.contains("Duplicate rule"),
319            "Error should mention duplicate rule: {}",
320            error_string
321        );
322        assert!(error_string.contains("is_adult"));
323    }
324
325    #[test]
326    fn test_circular_dependency() {
327        let input = r#"spec test
328rule a: b
329rule b: a"#;
330
331        let specs = parse(input, "test.lemma", &ResourceLimits::default())
332            .unwrap()
333            .specs;
334
335        let mut sources = HashMap::new();
336        sources.insert("test.lemma".to_string(), input.to_string());
337
338        let result = plan_single(&specs[0], &specs, sources);
339
340        assert!(
341            result.is_err(),
342            "Circular dependency should cause validation error"
343        );
344        let errors = result.unwrap_err();
345        let error_string = errors
346            .iter()
347            .map(|e| e.to_string())
348            .collect::<Vec<_>>()
349            .join(", ");
350        assert!(error_string.contains("Circular dependency") || error_string.contains("circular"));
351    }
352
353    #[test]
354    fn test_unified_references_work() {
355        let input = r#"spec test
356fact age: 25
357rule is_adult: age >= 18
358rule test1: age
359rule test2: is_adult"#;
360
361        let specs = parse(input, "test.lemma", &ResourceLimits::default())
362            .unwrap()
363            .specs;
364
365        let mut sources = HashMap::new();
366        sources.insert("test.lemma".to_string(), input.to_string());
367
368        let result = plan_single(&specs[0], &specs, sources);
369
370        assert!(
371            result.is_ok(),
372            "Unified references should work: {:?}",
373            result.err()
374        );
375    }
376
377    #[test]
378    fn test_multiple_specs() {
379        let input = r#"spec person
380fact name: "John"
381fact age: 25
382
383spec company
384fact name: "Acme Corp"
385fact employee: spec person"#;
386
387        let specs = parse(input, "test.lemma", &ResourceLimits::default())
388            .unwrap()
389            .specs;
390
391        let mut sources = HashMap::new();
392        sources.insert("test.lemma".to_string(), input.to_string());
393
394        let result = plan_single(&specs[0], &specs, sources);
395
396        assert!(
397            result.is_ok(),
398            "Multiple specs should validate successfully: {:?}",
399            result.err()
400        );
401    }
402
403    #[test]
404    fn test_invalid_spec_reference() {
405        let input = r#"spec person
406fact name: "John"
407fact contract: spec nonexistent"#;
408
409        let specs = parse(input, "test.lemma", &ResourceLimits::default())
410            .unwrap()
411            .specs;
412
413        let mut sources = HashMap::new();
414        sources.insert("test.lemma".to_string(), input.to_string());
415
416        let result = plan_single(&specs[0], &specs, sources);
417
418        assert!(
419            result.is_err(),
420            "Invalid spec reference should cause validation error"
421        );
422        let errors = result.unwrap_err();
423        let error_string = errors
424            .iter()
425            .map(|e| e.to_string())
426            .collect::<Vec<_>>()
427            .join(", ");
428        assert!(
429            error_string.contains("not found")
430                || error_string.contains("Spec")
431                || (error_string.contains("nonexistent") && error_string.contains("depends")),
432            "Error should mention spec reference issue: {}",
433            error_string
434        );
435        assert!(error_string.contains("nonexistent"));
436    }
437
438    #[test]
439    fn test_type_declaration_empty_base_returns_lemma_error() {
440        let mut spec = LemmaSpec::new("test".to_string());
441        let source = Source::new(
442            "test.lemma",
443            Span {
444                start: 0,
445                end: 10,
446                line: 1,
447                col: 0,
448            },
449            Arc::from("fact x: []"),
450        );
451        spec.facts.push(LemmaFact::new(
452            Reference {
453                segments: vec![],
454                name: "x".to_string(),
455            },
456            FactValue::TypeDeclaration {
457                base: String::new(),
458                constraints: None,
459                from: None,
460            },
461            source,
462        ));
463
464        let specs = vec![spec.clone()];
465        let mut sources = HashMap::new();
466        sources.insert(
467            "test.lemma".to_string(),
468            "spec test\nfact x: []".to_string(),
469        );
470
471        let result = plan_single(&spec, &specs, sources);
472        assert!(
473            result.is_err(),
474            "TypeDeclaration with empty base should fail planning"
475        );
476        let errors = result.unwrap_err();
477        let combined = errors
478            .iter()
479            .map(|e| e.to_string())
480            .collect::<Vec<_>>()
481            .join("\n");
482        assert!(
483            combined.contains("TypeDeclaration base cannot be empty"),
484            "Error should mention empty base; got: {}",
485            combined
486        );
487    }
488
489    #[test]
490    fn test_fact_binding_with_custom_type_resolves_in_correct_spec_context() {
491        // This is a planning-level test: ensure fact bindings resolve custom types correctly
492        // when the type is defined in a different spec than the binding.
493        //
494        // spec one:
495        //   type money: number
496        //   fact x: [money]
497        // spec two:
498        //   fact one: spec one
499        //   fact one.x: 7
500        //   rule getx: one.x
501        let code = r#"
502spec one
503type money: number
504fact x: [money]
505
506spec two
507fact one: spec one
508fact one.x: 7
509rule getx: one.x
510"#;
511
512        let specs = parse(code, "test.lemma", &ResourceLimits::default())
513            .unwrap()
514            .specs;
515        let spec_two = specs.iter().find(|d| d.name == "two").unwrap();
516
517        let mut sources = HashMap::new();
518        sources.insert("test.lemma".to_string(), code.to_string());
519        let execution_plan =
520            plan_single(spec_two, &specs, sources).expect("planning should succeed");
521
522        // Verify that one.x has type 'money' (resolved from spec one)
523        let one_x_path = FactPath {
524            segments: vec![PathSegment {
525                fact: "one".to_string(),
526                spec: "one".to_string(),
527            }],
528            fact: "x".to_string(),
529        };
530
531        let one_x_type = execution_plan
532            .facts
533            .get(&one_x_path)
534            .and_then(|d| d.schema_type())
535            .expect("one.x should have a resolved type");
536
537        assert_eq!(
538            one_x_type.name(),
539            "money",
540            "one.x should have type 'money', got: {}",
541            one_x_type.name()
542        );
543        assert!(one_x_type.is_number(), "money should be number-based");
544    }
545
546    #[test]
547    fn test_plan_with_registry_style_spec_names() {
548        let source = r#"spec @user/workspace/somespec
549fact quantity: 10
550
551spec user/workspace/example
552fact inventory: spec @user/workspace/somespec
553rule total_quantity: inventory.quantity"#;
554
555        let specs = parse(source, "registry_bundle.lemma", &ResourceLimits::default())
556            .unwrap()
557            .specs;
558        assert_eq!(specs.len(), 2);
559
560        let example_spec = specs
561            .iter()
562            .find(|d| d.name == "user/workspace/example")
563            .expect("should find user/workspace/example");
564
565        let mut sources = HashMap::new();
566        sources.insert("registry_bundle.lemma".to_string(), source.to_string());
567
568        let result = plan_single(example_spec, &specs, sources);
569        assert!(
570            result.is_ok(),
571            "Planning with @... spec names should succeed: {:?}",
572            result.err()
573        );
574    }
575
576    #[test]
577    fn test_multiple_independent_errors_are_all_reported() {
578        // A spec referencing a non-existing type import AND a non-existing
579        // spec should report errors for BOTH, not just stop at the first.
580        let source = r#"spec demo
581type money from nonexistent_type_source
582fact helper: spec nonexistent_spec
583fact price: 10
584rule total: helper.value + price"#;
585
586        let specs = parse(source, "test.lemma", &ResourceLimits::default())
587            .unwrap()
588            .specs;
589
590        let mut sources = HashMap::new();
591        sources.insert("test.lemma".to_string(), source.to_string());
592
593        let result = plan_single(&specs[0], &specs, sources);
594        assert!(result.is_err(), "Planning should fail with multiple errors");
595
596        let errors = result.unwrap_err();
597        let all_messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
598        let combined = all_messages.join("\n");
599
600        // Must report the type resolution error (shows up as "Unknown type: 'money'")
601        assert!(
602            combined.contains("Unknown type") && combined.contains("money"),
603            "Should report type import error for 'money'. Got:\n{}",
604            combined
605        );
606
607        // Must also report the spec reference error (not just the type error)
608        assert!(
609            combined.contains("nonexistent_spec"),
610            "Should report spec reference error for 'nonexistent_spec'. Got:\n{}",
611            combined
612        );
613
614        // Should have at least 2 distinct kinds of errors (type + spec ref)
615        assert!(
616            errors.len() >= 2,
617            "Expected at least 2 errors, got {}: {}",
618            errors.len(),
619            combined
620        );
621    }
622
623    #[test]
624    fn test_type_error_does_not_suppress_cross_spec_fact_error() {
625        // When a type import fails, errors about cross-spec fact references
626        // (e.g. ext.some_fact where ext is a spec ref to a non-existing spec)
627        // must still be reported.
628        let source = r#"spec demo
629type currency from missing_spec
630fact ext: spec also_missing
631rule val: ext.some_fact"#;
632
633        let specs = parse(source, "test.lemma", &ResourceLimits::default())
634            .unwrap()
635            .specs;
636
637        let mut sources = HashMap::new();
638        sources.insert("test.lemma".to_string(), source.to_string());
639
640        let result = plan_single(&specs[0], &specs, sources);
641        assert!(result.is_err());
642
643        let errors = result.unwrap_err();
644        let combined: String = errors
645            .iter()
646            .map(|e| e.to_string())
647            .collect::<Vec<_>>()
648            .join("\n");
649
650        // The type error about 'currency' should be reported
651        assert!(
652            combined.contains("currency"),
653            "Should report type error about 'currency'. Got:\n{}",
654            combined
655        );
656
657        // The spec reference error about 'also_missing' should ALSO be reported
658        assert!(
659            combined.contains("also_missing"),
660            "Should report error about 'also_missing'. Got:\n{}",
661            combined
662        );
663    }
664}