Skip to main content

lemma/evaluation/
mod.rs

1//! Pure Rust evaluation engine for Lemma
2//!
3//! Executes pre-validated execution plans in dependency order.
4//! The execution plan is self-contained with all rules flattened into branches.
5//! The evaluator executes rules linearly without recursion or tree traversal.
6
7pub mod explanation;
8pub mod expression;
9pub mod operations;
10pub mod response;
11
12use crate::evaluation::explanation::{ExplanationNode, ValueSource};
13use crate::evaluation::operations::VetoType;
14use crate::evaluation::response::EvaluatedRule;
15use crate::planning::execution_plan::validate_value_against_type;
16use crate::planning::semantics::{
17    Data, DataDefinition, DataPath, DataValue, Expression, LemmaType, LiteralValue,
18    ReferenceTarget, RulePath, ValueKind,
19};
20use crate::planning::ExecutionPlan;
21use indexmap::IndexMap;
22pub use operations::{ComputationKind, OperationKind, OperationRecord, OperationResult};
23pub use response::{DataGroup, Response, RuleResult};
24use std::collections::{HashMap, HashSet};
25
26/// Evaluation context for storing intermediate results
27pub(crate) struct EvaluationContext {
28    data_values: HashMap<DataPath, LiteralValue>,
29    pub(crate) rule_results: HashMap<RulePath, OperationResult>,
30    rule_explanations: HashMap<RulePath, crate::evaluation::explanation::Explanation>,
31    operations: Option<Vec<crate::OperationRecord>>,
32    explanation_nodes: HashMap<usize, crate::evaluation::explanation::ExplanationNode>,
33    now: LiteralValue,
34    /// Map of rule-target reference data paths to their target rule path.
35    /// Used by [`Self::lazy_rule_reference_resolve`] at first read of the
36    /// reference data path: if the target rule produced a `Value`, we copy
37    /// it into `data_values` (memoizing further reads); if it produced a
38    /// `Veto`, we propagate that exact veto reason. The target rule is
39    /// guaranteed to have been evaluated already because planning injected
40    /// a `depends_on_rules` edge from every consumer rule to the target.
41    rule_references: HashMap<DataPath, RulePath>,
42    /// Constraint-violation vetoes on reference data paths discovered at
43    /// run-time. Populated for data-target references when the copied
44    /// value fails validation against the merged `resolved_type`; used by
45    /// the data-path read in [`expression`] so consumers see a precise
46    /// `Computation` veto naming the violated constraint instead of a
47    /// generic missing-data veto.
48    reference_vetoes: HashMap<DataPath, VetoType>,
49    /// Merged `resolved_type` per reference data path, used to validate
50    /// rule-target reference values lazily in
51    /// [`Self::lazy_rule_reference_resolve`].
52    reference_types: HashMap<DataPath, LemmaType>,
53}
54
55impl EvaluationContext {
56    fn new(plan: &ExecutionPlan, now: LiteralValue, record_operations: bool) -> Self {
57        let mut data_values: HashMap<DataPath, LiteralValue> = plan
58            .data
59            .iter()
60            .filter_map(|(path, d)| d.value().map(|v| (path.clone(), v.clone())))
61            .collect();
62
63        let rule_references: HashMap<DataPath, RulePath> =
64            build_transitive_rule_references(&plan.data);
65
66        let reference_types: HashMap<DataPath, LemmaType> = plan
67            .data
68            .iter()
69            .filter_map(|(path, def)| match def {
70                DataDefinition::Reference { resolved_type, .. } => {
71                    Some((path.clone(), resolved_type.clone()))
72                }
73                _ => None,
74            })
75            .collect();
76        let mut reference_vetoes: HashMap<DataPath, VetoType> = HashMap::new();
77
78        // Resolve data-target references: copy the target's value into the
79        // reference path. Must happen in dependency order so reference-of-
80        // reference chains see their target already populated. A reference
81        // whose target value is missing simply stays missing here; any
82        // rule/expression that later reads the reference will produce a
83        // `MissingData` veto, matching the existing missing-data semantics
84        // for type-declaration data with no value.
85        //
86        // A caller-supplied value (via `with_data_values`) replaces the
87        // `DataDefinition::Reference` entry with `DataDefinition::Value`
88        // before evaluation, so any path that is no longer a `Reference` is
89        // skipped here — the user-provided value has already been placed in
90        // `data_values` and wins over the reference copy.
91        //
92        // Rule-target references are intentionally absent from
93        // `reference_evaluation_order` (filtered in planning); they are
94        // resolved lazily at the consumer's read site once the target rule
95        // has been evaluated.
96        for reference_path in &plan.reference_evaluation_order {
97            match plan.data.get(reference_path) {
98                Some(DataDefinition::Reference {
99                    target: ReferenceTarget::Data(target_path),
100                    resolved_type,
101                    local_default,
102                    ..
103                }) => {
104                    let copied_kind: Option<ValueKind> = data_values
105                        .get(target_path)
106                        .map(|v| v.value.clone())
107                        .or_else(|| local_default.clone());
108                    if let Some(value_kind) = copied_kind {
109                        let value = LiteralValue {
110                            value: value_kind,
111                            lemma_type: resolved_type.clone(),
112                        };
113                        match validate_value_against_type(resolved_type, &value) {
114                            Ok(()) => {
115                                data_values.insert(reference_path.clone(), value);
116                            }
117                            Err(msg) => {
118                                reference_vetoes.insert(
119                                    reference_path.clone(),
120                                    VetoType::computation(format!(
121                                        "Reference '{}' violates declared constraint: {}",
122                                        reference_path, msg
123                                    )),
124                                );
125                            }
126                        }
127                    }
128                }
129                Some(DataDefinition::Reference {
130                    target: ReferenceTarget::Rule(_),
131                    ..
132                }) => {
133                    // Rule-target references are resolved lazily on first
134                    // read. They should never appear in
135                    // `reference_evaluation_order` (planning filters them
136                    // out), but skip defensively.
137                }
138                Some(_) => {
139                    // User-provided value has replaced the reference;
140                    // nothing to copy.
141                }
142                None => unreachable!(
143                    "BUG: reference_evaluation_order references missing data path '{}'",
144                    reference_path
145                ),
146            }
147        }
148
149        Self {
150            data_values,
151            rule_results: HashMap::new(),
152            rule_explanations: HashMap::new(),
153            operations: if record_operations {
154                Some(Vec::new())
155            } else {
156                None
157            },
158            explanation_nodes: HashMap::new(),
159            now,
160            rule_references,
161            reference_vetoes,
162            reference_types,
163        }
164    }
165
166    /// Resolve a rule-target reference data path lazily from the already-
167    /// evaluated target rule's result. Returns:
168    /// - `Some(Ok(value))` — target rule produced a value; memoized into `data_values`.
169    /// - `Some(Err(veto))` — target rule produced a veto, or the rule's
170    ///   value violates the reference's merged `resolved_type` constraints.
171    /// - `None` — the path is not a rule-target reference.
172    pub(crate) fn lazy_rule_reference_resolve(
173        &mut self,
174        data_path: &DataPath,
175    ) -> Option<Result<LiteralValue, crate::evaluation::operations::VetoType>> {
176        let rule_path = self.rule_references.get(data_path)?.clone();
177        let result = self
178            .rule_results
179            .get(&rule_path)
180            .cloned()
181            .unwrap_or_else(|| {
182                unreachable!(
183                    "BUG: rule-target reference '{}' read before target rule '{}' evaluated; \
184                 planning must have injected the dependency edge",
185                    data_path, rule_path
186                );
187            });
188        match result {
189            OperationResult::Value(v) => {
190                let v = *v;
191                let v = match self.reference_types.get(data_path) {
192                    Some(ref_type) => {
193                        let retyped = LiteralValue {
194                            value: v.value,
195                            lemma_type: ref_type.clone(),
196                        };
197                        if let Err(msg) = validate_value_against_type(ref_type, &retyped) {
198                            return Some(Err(VetoType::computation(format!(
199                                "Reference '{}' violates declared constraint: {}",
200                                data_path, msg
201                            ))));
202                        }
203                        retyped
204                    }
205                    None => v,
206                };
207                self.data_values.insert(data_path.clone(), v.clone());
208                Some(Ok(v))
209            }
210            OperationResult::Veto(veto) => Some(Err(veto)),
211        }
212    }
213
214    /// Returns a recorded constraint-violation veto for a reference data
215    /// path, if any. Populated in [`Self::new`] for data-target references
216    /// whose copied value failed `validate_value_against_type`.
217    pub(crate) fn get_reference_veto(&self, data_path: &DataPath) -> Option<&VetoType> {
218        self.reference_vetoes.get(data_path)
219    }
220
221    pub(crate) fn now(&self) -> &LiteralValue {
222        &self.now
223    }
224
225    fn get_data(&self, data_path: &DataPath) -> Option<&LiteralValue> {
226        self.data_values.get(data_path)
227    }
228
229    fn push_operation(&mut self, kind: OperationKind) {
230        if let Some(ref mut ops) = self.operations {
231            ops.push(OperationRecord { kind });
232        }
233    }
234
235    fn set_explanation_node(
236        &mut self,
237        expression: &Expression,
238        node: crate::evaluation::explanation::ExplanationNode,
239    ) {
240        self.explanation_nodes
241            .insert(expression as *const Expression as usize, node);
242    }
243
244    fn get_explanation_node(
245        &self,
246        expression: &Expression,
247    ) -> Option<&crate::evaluation::explanation::ExplanationNode> {
248        self.explanation_nodes
249            .get(&(expression as *const Expression as usize))
250    }
251
252    fn get_rule_explanation(
253        &self,
254        rule_path: &RulePath,
255    ) -> Option<&crate::evaluation::explanation::Explanation> {
256        self.rule_explanations.get(rule_path)
257    }
258
259    fn set_rule_explanation(
260        &mut self,
261        rule_path: RulePath,
262        explanation: crate::evaluation::explanation::Explanation,
263    ) {
264        self.rule_explanations.insert(rule_path, explanation);
265    }
266}
267
268/// For every reference data path in `data`, follow the data-target reference
269/// chain and record the eventual rule-target (if any). Includes direct
270/// rule-target references. A visited set guards against pathological cycles
271/// that planning should already have rejected.
272fn build_transitive_rule_references(
273    data: &IndexMap<DataPath, DataDefinition>,
274) -> HashMap<DataPath, RulePath> {
275    let mut out: HashMap<DataPath, RulePath> = HashMap::new();
276    for (path, def) in data {
277        if !matches!(def, DataDefinition::Reference { .. }) {
278            continue;
279        }
280        let mut visited: HashSet<DataPath> = HashSet::new();
281        let mut cursor: DataPath = path.clone();
282        loop {
283            if !visited.insert(cursor.clone()) {
284                break;
285            }
286            let Some(DataDefinition::Reference { target, .. }) = data.get(&cursor) else {
287                break;
288            };
289            match target {
290                ReferenceTarget::Data(next) => cursor = next.clone(),
291                ReferenceTarget::Rule(rule_path) => {
292                    out.insert(path.clone(), rule_path.clone());
293                    break;
294                }
295            }
296        }
297    }
298    out
299}
300
301fn collect_used_data_from_explanation(
302    node: &ExplanationNode,
303    out: &mut HashMap<DataPath, LiteralValue>,
304) {
305    match node {
306        ExplanationNode::Value {
307            value,
308            source: ValueSource::Data { data_ref },
309            ..
310        } => {
311            out.entry(data_ref.clone()).or_insert_with(|| value.clone());
312        }
313        ExplanationNode::Value { .. } => {}
314        ExplanationNode::RuleReference { expansion, .. } => {
315            collect_used_data_from_explanation(expansion.as_ref(), out);
316        }
317        ExplanationNode::Computation { operands, .. } => {
318            for op in operands {
319                collect_used_data_from_explanation(op, out);
320            }
321        }
322        ExplanationNode::Branches {
323            matched,
324            non_matched,
325            ..
326        } => {
327            if let Some(ref cond) = matched.condition {
328                collect_used_data_from_explanation(cond, out);
329            }
330            collect_used_data_from_explanation(&matched.result, out);
331            for nm in non_matched {
332                collect_used_data_from_explanation(&nm.condition, out);
333                if let Some(ref res) = nm.result {
334                    collect_used_data_from_explanation(res, out);
335                }
336            }
337        }
338        ExplanationNode::Condition { operands, .. } => {
339            for op in operands {
340                collect_used_data_from_explanation(op, out);
341            }
342        }
343        ExplanationNode::Veto { .. } => {}
344    }
345}
346
347#[cfg(test)]
348mod runtime_invariant_tests {
349    use super::*;
350    use crate::parsing::ast::DateTimeValue;
351    use crate::Engine;
352
353    /// At evaluation time the LiteralValue stored under a reference data path
354    /// must carry the reference's own `resolved_type`, not the (possibly looser)
355    /// type embedded in the target's LiteralValue. The merge pass folds in the
356    /// LHS-declared constraints (e.g. a binding's parent-spec type chain) so
357    /// the reference's `resolved_type` is the contract the evaluator promises;
358    /// any consumer that reads `data_values[ref].lemma_type` directly must see
359    /// that contract, not the target's loose shape.
360    #[test]
361    fn reference_runtime_value_carries_resolved_type_not_target_type() {
362        let code = r#"
363spec inner
364data slot: number -> minimum 0 -> maximum 100
365
366spec source_spec
367data v: number -> default 5
368
369spec outer
370with i: inner
371with src: source_spec
372data i.slot: src.v
373rule r: i.slot
374"#;
375        let mut engine = Engine::new();
376        engine
377            .load(code, crate::SourceType::Labeled("ref_invariant.lemma"))
378            .expect("must load");
379
380        let now = DateTimeValue::now();
381        let plan = engine
382            .get_plan("outer", Some(&now))
383            .expect("must plan")
384            .clone();
385
386        let now_lit = LiteralValue {
387            value: crate::planning::semantics::ValueKind::Date(
388                crate::planning::semantics::date_time_to_semantic(&now),
389            ),
390            lemma_type: crate::planning::semantics::primitive_date().clone(),
391        };
392        let context = EvaluationContext::new(&plan, now_lit, false);
393
394        let reference_path = plan
395            .data
396            .iter()
397            .find_map(|(path, def)| match def {
398                DataDefinition::Reference { .. } => Some(path.clone()),
399                _ => None,
400            })
401            .expect("plan must contain the reference for `i.slot`");
402
403        let resolved_type = match plan.data.get(&reference_path).expect("entry exists") {
404            DataDefinition::Reference { resolved_type, .. } => resolved_type.clone(),
405            _ => unreachable!("filter above kept only Reference entries"),
406        };
407
408        let stored = context
409            .data_values
410            .get(&reference_path)
411            .expect("EvaluationContext must populate reference path with the copied value");
412
413        assert_eq!(
414            stored.lemma_type, resolved_type,
415            "stored LiteralValue must carry the reference's resolved_type \
416             (LHS-merged), not the target's loose type. \
417             stored = {:?}, resolved = {:?}",
418            stored.lemma_type, resolved_type
419        );
420    }
421}
422
423/// Evaluates Lemma rules within their spec context
424#[derive(Default)]
425pub(crate) struct Evaluator;
426
427impl Evaluator {
428    /// Evaluate an execution plan.
429    ///
430    /// Executes rules in pre-computed dependency order with all data pre-loaded.
431    /// Rules are already flattened into executable branches with data prefixes resolved.
432    ///
433    /// After planning, evaluation is guaranteed to complete. This function never returns
434    /// a Error — runtime issues (division by zero, missing data, user-defined veto)
435    /// produce Vetoes, which are valid evaluation outcomes.
436    ///
437    /// When `record_operations` is true, each rule's evaluation records a trace of
438    /// operations (data used, rules used, computations, branch evaluations) into
439    /// `RuleResult::operations`. When false, no trace is recorded.
440    pub(crate) fn evaluate(
441        &self,
442        plan: &ExecutionPlan,
443        now: LiteralValue,
444        record_operations: bool,
445    ) -> Response {
446        let mut context = EvaluationContext::new(plan, now, record_operations);
447
448        let mut response = Response {
449            spec_name: plan.spec_name.clone(),
450            spec_hash: None,
451            spec_effective_from: None,
452            spec_effective_to: None,
453            data: Vec::new(),
454            results: IndexMap::new(),
455        };
456
457        // Execute each rule in topological order (already sorted by ExecutionPlan)
458        for exec_rule in &plan.rules {
459            if let Some(ref mut ops) = context.operations {
460                ops.clear();
461            }
462            context.explanation_nodes.clear();
463
464            let (result, explanation) = expression::evaluate_rule(exec_rule, &mut context);
465
466            context
467                .rule_results
468                .insert(exec_rule.path.clone(), result.clone());
469            context.set_rule_explanation(exec_rule.path.clone(), explanation.clone());
470
471            let rule_operations = context.operations.clone().unwrap_or_default();
472
473            if !exec_rule.path.segments.is_empty() {
474                continue;
475            }
476
477            let unless_branches: Vec<(Option<Expression>, Expression)> = exec_rule.branches[1..]
478                .iter()
479                .map(|b| (b.condition.clone(), b.result.clone()))
480                .collect();
481
482            response.add_result(RuleResult {
483                rule: EvaluatedRule {
484                    name: exec_rule.name.clone(),
485                    path: exec_rule.path.clone(),
486                    default_expression: exec_rule.branches[0].result.clone(),
487                    unless_branches,
488                    source_location: exec_rule.source.clone(),
489                    rule_type: exec_rule.rule_type.clone(),
490                },
491                result,
492                data: vec![],
493                operations: rule_operations,
494                explanation: Some(explanation),
495                rule_type: exec_rule.rule_type.clone(),
496            });
497        }
498
499        let mut used_data: HashMap<DataPath, LiteralValue> = HashMap::new();
500        for rule_result in response.results.values() {
501            if let Some(ref explanation) = rule_result.explanation {
502                collect_used_data_from_explanation(explanation.tree.as_ref(), &mut used_data);
503            }
504        }
505
506        // Build data list in definition order (plan.data is an IndexMap)
507        let data_list: Vec<Data> = plan
508            .data
509            .keys()
510            .filter_map(|path| {
511                used_data.remove(path).map(|value| Data {
512                    path: path.clone(),
513                    value: DataValue::Literal(value),
514                    source: None,
515                })
516            })
517            .collect();
518
519        if !data_list.is_empty() {
520            response.data = vec![DataGroup {
521                data_path: String::new(),
522                referencing_data_name: String::new(),
523                data: data_list,
524            }];
525        }
526
527        response
528    }
529}