Skip to main content

lemma/evaluation/
response.rs

1use crate::computation::{OperationResult, VetoType};
2use crate::evaluation::explanations::Explanation;
3
4use crate::parsing::ast::DateTimeValue;
5use crate::planning::semantics::{LemmaType, LiteralValue, RulePath, Source};
6use crate::result_value::{
7    rule_result_value_failure_message, rule_result_value_from_literal, RuleResultValue,
8    RuleResultValueFailure,
9};
10use indexmap::IndexMap;
11use serde::Serialize;
12
13/// Rule info with resolved expressions for use in evaluation response.
14/// Evaluation uses only semantics types; no parsing types.
15#[derive(Debug, Clone, Serialize)]
16pub struct EvaluatedRule {
17    pub name: String,
18    pub path: RulePath,
19    pub source_location: Source,
20    pub rule_type: LemmaType,
21}
22
23/// Response from evaluating a Lemma spec
24#[derive(Debug, Clone, Serialize)]
25pub struct Response {
26    #[serde(rename = "spec")]
27    pub spec_name: String,
28    pub effective: String,
29    /// Declared temporal window `[spec_effective_from, spec_effective_to)` of the
30    /// resolved spec version. Set by [`crate::Engine::run`] after evaluation;
31    /// `None` here (evaluation-internal construction) until that assignment.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub spec_effective_from: Option<DateTimeValue>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub spec_effective_to: Option<DateTimeValue>,
36    pub results: IndexMap<String, RuleResult>,
37}
38
39/// Result of evaluating a single rule. Struct fields match the API JSON shape.
40#[derive(Debug, Clone, Serialize)]
41pub struct RuleResult {
42    #[serde(skip)]
43    pub rule: EvaluatedRule,
44    #[serde(skip)]
45    pub veto_detail: Option<VetoType>,
46
47    pub vetoed: bool,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub veto_reason: Option<String>,
50    pub rule_type: String,
51
52    /// Flattened value fields, including `display` when the rule is not vetoed.
53    #[serde(flatten)]
54    pub value: Option<RuleResultValue>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub explanation: Option<Explanation>,
57    /// Unbound caller data paths still live for this rule under the current run data
58    /// (`DataPath::input_key` strings, same keys as `Show.data`).
59    #[serde(skip_serializing_if = "Vec::is_empty")]
60    missing_data: Vec<String>,
61}
62
63impl RuleResult {
64    /// Engine-rendered display string from the flattened [`RuleResultValue`].
65    #[must_use]
66    pub fn display(&self) -> Option<&str> {
67        self.value
68            .as_ref()
69            .and_then(|value| value.display.as_deref())
70    }
71
72    /// True when this rule still waits on unbound inputs (`MissingData` veto).
73    ///
74    /// Value and non-`MissingData` vetoes are settled answers; leftover live keys in
75    /// [`Self::missing_data`] must not drive prompts or human "Missing data" display.
76    #[must_use]
77    pub fn awaits_missing_data(&self) -> bool {
78        matches!(
79            self.veto_detail.as_ref(),
80            Some(VetoType::MissingData { .. })
81        )
82    }
83
84    /// Unbound caller data paths still live for this rule (`DataPath::input_key`).
85    #[must_use]
86    pub fn missing_data(&self) -> &[String] {
87        &self.missing_data
88    }
89
90    /// Build a [`RuleResult`] for API output from a rule evaluation result.
91    ///
92    /// Measure and ratio payloads expand into every unit declared on `rule_type`.
93    pub fn from_operation_result(
94        rule: EvaluatedRule,
95        operation_result: &OperationResult,
96        rule_type: &LemmaType,
97        explanation: Option<Explanation>,
98        missing_data: Vec<String>,
99    ) -> Self {
100        match operation_result {
101            OperationResult::Veto(VetoType::MissingData { data, .. }) => {
102                let key = data.input_key();
103                if !missing_data.iter().any(|listed| listed == &key) {
104                    panic!("BUG: MissingData path {key} not in missing_data {missing_data:?}");
105                }
106            }
107            _ => {
108                if !missing_data.is_empty() {
109                    panic!(
110                        "BUG: missing_data must be empty when result is not MissingData: {missing_data:?}"
111                    );
112                }
113            }
114        }
115        let rule_type_name = rule_type.name().to_string();
116        match operation_result {
117            OperationResult::Veto(veto) => Self {
118                rule,
119                veto_detail: Some(veto.clone()),
120                vetoed: true,
121                veto_reason: match &veto {
122                    VetoType::UserDefined { message: None } => None,
123                    _ => Some(veto.to_string()),
124                },
125                rule_type: rule_type_name,
126                value: None,
127                explanation,
128                missing_data,
129            },
130            OperationResult::Value(literal) => {
131                match rule_result_value_from_literal(literal, rule_type) {
132                    Ok(value) => Self {
133                        rule,
134                        veto_detail: None,
135                        vetoed: false,
136                        veto_reason: None,
137                        rule_type: rule_type_name,
138                        value: Some(value),
139                        explanation,
140                        missing_data,
141                    },
142                    Err(failure) => vetoed_rule_result_for_rule_result_value_failure(
143                        rule,
144                        rule_type,
145                        explanation,
146                        failure,
147                        missing_data,
148                    ),
149                }
150            }
151        }
152    }
153
154    /// Reconstruct the evaluated [`LiteralValue`] from committed [`RuleResultValue`] fields.
155    ///
156    /// Panics if the rule is vetoed or fields cannot be reconstructed.
157    pub fn to_literal(&self) -> LiteralValue {
158        assert!(
159            !self.vetoed,
160            "BUG: to_literal called on vetoed rule '{}'",
161            self.rule.name
162        );
163        let value = self
164            .value
165            .as_ref()
166            .unwrap_or_else(|| panic!("BUG: non-vetoed rule '{}' missing value", self.rule.name));
167        value.to_literal(&self.rule.rule_type)
168    }
169}
170
171fn vetoed_rule_result_for_rule_result_value_failure(
172    rule: EvaluatedRule,
173    rule_type: &LemmaType,
174    explanation: Option<Explanation>,
175    failure: RuleResultValueFailure,
176    missing_data: Vec<String>,
177) -> RuleResult {
178    RuleResult::from_operation_result(
179        rule,
180        &OperationResult::Veto(VetoType::computation(
181            rule_result_value_failure_message(failure).to_string(),
182        )),
183        rule_type,
184        explanation,
185        missing_data,
186    )
187}
188
189impl Response {
190    /// Looks up a rule result by name.
191    ///
192    /// Returns an error if the rule is not found.
193    pub fn get(&self, rule_name: &str) -> Result<&RuleResult, crate::error::Error> {
194        self.results
195            .get(rule_name)
196            .ok_or_else(|| crate::error::Error::rule_not_found(rule_name, None::<String>))
197    }
198
199    pub fn add_result(&mut self, result: RuleResult) {
200        self.results.insert(result.rule.name.clone(), result);
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::literals::DateGranularity;
208    use crate::parsing::ast::Span;
209    use crate::planning::semantics::{
210        primitive_number_arc, BaseMeasureVector, DataPath, LemmaType, LiteralValue, MeasureUnit,
211        MeasureUnits, RatioUnit, RatioUnits, RulePath, TypeExtends, TypeSpecification, ValueKind,
212    };
213    use rust_decimal::Decimal;
214    use std::sync::Arc;
215
216    fn dummy_source() -> Source {
217        Source::new(
218            crate::parsing::source::SourceType::Volatile,
219            Span {
220                start: 0,
221                end: 0,
222                line: 1,
223                col: 1,
224            },
225        )
226    }
227
228    fn dummy_evaluated_rule(name: &str, rule_type: &LemmaType) -> EvaluatedRule {
229        EvaluatedRule {
230            name: name.to_string(),
231            path: RulePath::new(vec![], name.to_string()),
232            source_location: dummy_source(),
233            rule_type: rule_type.clone(),
234        }
235    }
236
237    #[test]
238    fn test_response_serialization() {
239        let mut results = IndexMap::new();
240        results.insert(
241            "test_rule".to_string(),
242            RuleResult::from_operation_result(
243                dummy_evaluated_rule("test_rule", primitive_number_arc().as_ref()),
244                &OperationResult::from_literal(LiteralValue::number_from_decimal(Decimal::from(
245                    42,
246                ))),
247                primitive_number_arc().as_ref(),
248                None,
249                Vec::new(),
250            ),
251        );
252        let response = Response {
253            spec_name: "test_spec".to_string(),
254            effective: "2026-01-01".to_string(),
255            spec_effective_from: None,
256            spec_effective_to: None,
257            results,
258        };
259
260        let json = serde_json::to_string(&response).unwrap();
261        assert!(json.contains("test_spec"));
262        assert!(json.contains("test_rule"));
263        assert!(json.contains("\"number\":\"42\""));
264        assert!(!json.contains("lemma_type"));
265    }
266
267    #[test]
268    fn response_number_json_never_uses_fraction_notation() {
269        use crate::computation::rational::decimal_to_rational;
270
271        let rational = decimal_to_rational(Decimal::new(1, 1) / Decimal::new(3, 1)).unwrap();
272        let decimal_string = rational.try_to_decimal().unwrap().to_string();
273        let mut results = IndexMap::new();
274        results.insert(
275            "third".to_string(),
276            RuleResult::from_operation_result(
277                dummy_evaluated_rule("third", primitive_number_arc().as_ref()),
278                &OperationResult::from_literal(LiteralValue::number_from_decimal(
279                    rational.try_to_decimal().unwrap(),
280                )),
281                primitive_number_arc().as_ref(),
282                None,
283                Vec::new(),
284            ),
285        );
286        // Override committed decimal number field to match serialization path under test
287        if let Some(rule) = results.get_mut("third") {
288            rule.value = Some(crate::result_value::RuleResultValue {
289                display: Some(decimal_string.clone()),
290                number: Some(decimal_string.clone()),
291                ..Default::default()
292            });
293        }
294
295        let response = Response {
296            spec_name: "test".to_string(),
297            effective: "test".to_string(),
298            spec_effective_from: None,
299            spec_effective_to: None,
300            results,
301        };
302
303        let json: serde_json::Value =
304            serde_json::from_str(&serde_json::to_string(&response).unwrap()).unwrap();
305        let number = json["results"]["third"]["number"]
306            .as_str()
307            .expect("number must be a JSON string");
308        assert!(
309            !number.contains('/'),
310            "API decimal string must not use fraction notation, got {number}"
311        );
312    }
313
314    #[test]
315    fn test_rule_result_veto() {
316        let missing = RuleResult::from_operation_result(
317            dummy_evaluated_rule("rule3", &LemmaType::veto_type()),
318            &OperationResult::Veto(VetoType::missing_data(
319                DataPath::new(vec![], "data1".to_string()),
320                None,
321            )),
322            &LemmaType::veto_type(),
323            None,
324            vec!["data1".to_string()],
325        );
326        assert!(missing.vetoed);
327        assert!(missing.veto_reason.as_ref().unwrap().contains("data1"));
328
329        let veto = RuleResult::from_operation_result(
330            dummy_evaluated_rule("rule4", &LemmaType::veto_type()),
331            &OperationResult::Veto(VetoType::UserDefined {
332                message: Some("Vetoed".to_string()),
333            }),
334            &LemmaType::veto_type(),
335            None,
336            Vec::new(),
337        );
338        assert_eq!(veto.veto_reason.as_deref(), Some("Vetoed"));
339    }
340
341    /// Attach hole: MissingData + empty missing_data is a BUG, not a silent stall.
342    #[test]
343    #[should_panic(expected = "BUG: MissingData")]
344    fn missing_data_veto_must_not_attach_empty_missing_data_list() {
345        RuleResult::from_operation_result(
346            dummy_evaluated_rule("rule3", &LemmaType::veto_type()),
347            &OperationResult::Veto(VetoType::missing_data(
348                DataPath::new(vec![], "data1".to_string()),
349                None,
350            )),
351            &LemmaType::veto_type(),
352            None,
353            Vec::new(),
354        );
355    }
356
357    #[test]
358    #[should_panic(expected = "BUG: MissingData")]
359    fn missing_data_veto_must_include_veto_path_in_list() {
360        RuleResult::from_operation_result(
361            dummy_evaluated_rule("rule3", &LemmaType::veto_type()),
362            &OperationResult::Veto(VetoType::missing_data(
363                DataPath::new(vec![], "data1".to_string()),
364                None,
365            )),
366            &LemmaType::veto_type(),
367            None,
368            vec!["other".to_string()],
369        );
370    }
371
372    #[test]
373    #[should_panic(expected = "BUG: missing_data must be empty")]
374    fn non_missing_data_result_must_not_attach_leftover_missing_data() {
375        RuleResult::from_operation_result(
376            dummy_evaluated_rule("rule4", &LemmaType::veto_type()),
377            &OperationResult::Veto(VetoType::UserDefined {
378                message: Some("Vetoed".to_string()),
379            }),
380            &LemmaType::veto_type(),
381            None,
382            vec!["leftover".to_string()],
383        );
384    }
385
386    #[test]
387    fn rule_result_value_out_of_memory_is_not_decimal_limit_veto() {
388        let result = vetoed_rule_result_for_rule_result_value_failure(
389            dummy_evaluated_rule("rule", primitive_number_arc().as_ref()),
390            primitive_number_arc().as_ref(),
391            None,
392            RuleResultValueFailure::OutOfMemory,
393            Vec::new(),
394        );
395        assert_eq!(result.veto_reason.as_deref(), Some("out of memory"));
396        assert_ne!(
397            result.veto_reason.as_deref(),
398            Some("Calculated result exceeds decimal value limit")
399        );
400    }
401
402    #[test]
403    fn rule_result_value_decimal_limit_uses_commit_message() {
404        let result = vetoed_rule_result_for_rule_result_value_failure(
405            dummy_evaluated_rule("rule", primitive_number_arc().as_ref()),
406            primitive_number_arc().as_ref(),
407            None,
408            RuleResultValueFailure::DecimalLimit,
409            Vec::new(),
410        );
411        assert_eq!(
412            result.veto_reason.as_deref(),
413            Some("Calculated result exceeds decimal value limit")
414        );
415    }
416
417    fn test_money_type() -> LemmaType {
418        LemmaType::new(
419            "money".to_string(),
420            TypeSpecification::Measure {
421                minimum: None,
422                maximum: None,
423                decimals: Some(2),
424                units: MeasureUnits::from(vec![
425                    MeasureUnit {
426                        name: "eur".to_string(),
427                        factor: crate::computation::rational::rational_one(),
428                        derived_measure_factors: Vec::new(),
429                        decomposition: BaseMeasureVector::new(),
430                        minimum: None,
431                        maximum: None,
432                        suggestion_magnitude: None,
433                    },
434                    MeasureUnit {
435                        name: "usd".to_string(),
436                        factor: crate::computation::rational::decimal_to_rational(Decimal::new(
437                            91, 2,
438                        ))
439                        .expect("factor"),
440                        derived_measure_factors: Vec::new(),
441                        decomposition: BaseMeasureVector::new(),
442                        minimum: None,
443                        maximum: None,
444                        suggestion_magnitude: None,
445                    },
446                ]),
447                traits: Vec::new(),
448                decomposition: Some(BaseMeasureVector::new()),
449                help: String::new(),
450            },
451            TypeExtends::Primitive,
452        )
453    }
454
455    #[test]
456    fn measure_rule_result_value_uses_rule_type_when_expression_index_empty() {
457        let money = test_money_type();
458        let ten_usd = LiteralValue {
459            value: ValueKind::Measure(
460                crate::computation::rational::checked_mul(
461                    &crate::computation::rational::decimal_to_rational(Decimal::from(10))
462                        .expect("ten"),
463                    &crate::computation::rational::decimal_to_rational(Decimal::new(91, 2))
464                        .expect("usd factor"),
465                )
466                .expect("canonical usd"),
467                vec![("usd".to_string(), 1)],
468            ),
469            lemma_type: Arc::new(money.clone()),
470        };
471        let result = RuleResult::from_operation_result(
472            dummy_evaluated_rule("total", &money),
473            &OperationResult::from_literal(ten_usd),
474            &money,
475            None,
476            Vec::new(),
477        );
478        let measure = result
479            .value
480            .as_ref()
481            .expect("value")
482            .measure
483            .clone()
484            .expect("measure map");
485        assert_eq!(measure.get("usd"), Some(&"10.00".to_string()));
486        assert!(measure.contains_key("eur"));
487    }
488
489    #[test]
490    fn test_measure_rule_result_value_multi_unit() {
491        let money = test_money_type();
492        let ten_eur = LiteralValue {
493            value: ValueKind::Measure(
494                crate::computation::rational::decimal_to_rational(Decimal::from(10)).expect("ten"),
495                vec![("eur".to_string(), 1)],
496            ),
497            lemma_type: Arc::new(money.clone()),
498        };
499        let result = RuleResult::from_operation_result(
500            dummy_evaluated_rule("total", &money),
501            &OperationResult::from_literal(ten_eur),
502            &money,
503            None,
504            Vec::new(),
505        );
506        let measure = result
507            .value
508            .as_ref()
509            .expect("value")
510            .measure
511            .clone()
512            .expect("measure map");
513        assert_eq!(measure.get("eur"), Some(&"10.00".to_string()));
514        assert_eq!(measure.get("usd"), Some(&"10.99".to_string()));
515    }
516
517    #[test]
518    fn measure_rule_result_value_respects_decimals_on_unit_conversion() {
519        let money = LemmaType::new(
520            "money".to_string(),
521            TypeSpecification::Measure {
522                minimum: None,
523                maximum: None,
524                decimals: Some(2),
525                units: MeasureUnits::from(vec![
526                    MeasureUnit {
527                        name: "eur".to_string(),
528                        factor: crate::computation::rational::rational_one(),
529                        derived_measure_factors: Vec::new(),
530                        decomposition: BaseMeasureVector::new(),
531                        minimum: None,
532                        maximum: None,
533                        suggestion_magnitude: None,
534                    },
535                    MeasureUnit {
536                        name: "usd".to_string(),
537                        factor: crate::computation::rational::decimal_to_rational(Decimal::new(
538                            84, 2,
539                        ))
540                        .expect("usd factor"),
541                        derived_measure_factors: Vec::new(),
542                        decomposition: BaseMeasureVector::new(),
543                        minimum: None,
544                        maximum: None,
545                        suggestion_magnitude: None,
546                    },
547                ]),
548                traits: Vec::new(),
549                decomposition: Some(BaseMeasureVector::new()),
550                help: String::new(),
551            },
552            TypeExtends::Primitive,
553        );
554        let three_twelve_eur = LiteralValue {
555            value: ValueKind::Measure(
556                crate::computation::rational::decimal_to_rational(Decimal::new(312, 2))
557                    .expect("3.12 eur canonical"),
558                vec![("eur".to_string(), 1)],
559            ),
560            lemma_type: Arc::new(money.clone()),
561        };
562        let result = RuleResult::from_operation_result(
563            dummy_evaluated_rule("delivery_cost", &money),
564            &OperationResult::from_literal(three_twelve_eur),
565            &money,
566            None,
567            Vec::new(),
568        );
569        let measure = result
570            .value
571            .as_ref()
572            .expect("value")
573            .measure
574            .clone()
575            .expect("measure map");
576        assert_eq!(measure.get("eur"), Some(&"3.12".to_string()));
577        assert_eq!(measure.get("usd"), Some(&"3.71".to_string()));
578    }
579
580    #[test]
581    fn test_ratio_rule_result_value_multi_unit() {
582        let ratio_type = LemmaType::new(
583            "rate".to_string(),
584            TypeSpecification::Ratio {
585                minimum: None,
586                maximum: None,
587                decimals: None,
588                units: RatioUnits::from(vec![
589                    RatioUnit {
590                        name: "percent".to_string(),
591                        value: crate::computation::rational::decimal_to_rational(Decimal::from(
592                            100,
593                        ))
594                        .expect("percent"),
595                        minimum: None,
596                        maximum: None,
597                        suggestion_magnitude: None,
598                    },
599                    RatioUnit {
600                        name: "basis_points".to_string(),
601                        value: crate::computation::rational::decimal_to_rational(Decimal::from(
602                            10_000,
603                        ))
604                        .expect("bp"),
605                        minimum: None,
606                        maximum: None,
607                        suggestion_magnitude: None,
608                    },
609                ]),
610                help: String::new(),
611            },
612            TypeExtends::Primitive,
613        );
614        let half = crate::computation::rational::rational_new(1, 2);
615        let lit = LiteralValue {
616            value: ValueKind::Ratio(half, Some("percent".to_string())),
617            lemma_type: Arc::new(ratio_type.clone()),
618        };
619        let result = RuleResult::from_operation_result(
620            dummy_evaluated_rule("rate_out", &ratio_type),
621            &OperationResult::from_literal(lit),
622            &ratio_type,
623            None,
624            Vec::new(),
625        );
626        let ratio = result
627            .value
628            .as_ref()
629            .expect("value")
630            .ratio
631            .clone()
632            .expect("ratio map");
633        assert_eq!(ratio.get("percent"), Some(&"50".to_string()));
634        assert_eq!(ratio.get("basis_points"), Some(&"5000".to_string()));
635    }
636
637    #[test]
638    fn test_measure_rule_result_value_cross_spec_import() {
639        use crate::parsing::source::SourceType;
640        use crate::Engine;
641
642        let mut engine = Engine::new();
643        engine
644            .load([(
645                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("t.lemma"))),
646                r#"
647spec consumer 2025-01-01
648uses d: dep 2025-10-01
649rule out: d.doubled
650
651spec dep 2025-01-01
652uses c: child 2025-06-01
653data money: c.money
654data p: 5 usd
655rule doubled: p * 2
656
657spec child 2025-01-01
658data money: measure
659 -> unit eur 1.00
660 -> decimals 2
661
662spec child 2025-06-01
663data money: measure
664 -> unit eur 1.00
665 -> unit usd 0.91
666 -> decimals 2
667"#
668                .to_string(),
669            )])
670            .expect("load");
671        let effective = crate::literals::DateTimeValue {
672            year: 2025,
673            month: 3,
674            day: 1,
675            hour: 0,
676            minute: 0,
677            second: 0,
678            microsecond: 0,
679            timezone: None,
680
681            granularity: DateGranularity::Full,
682        };
683        let response = engine
684            .run(
685                None,
686                "consumer",
687                Some(&effective),
688                std::collections::HashMap::new(),
689                None,
690                false,
691            )
692            .expect("run");
693        let out = response.results.get("out").expect("out rule");
694        assert!(!out.vetoed);
695        let measure = out
696            .value
697            .as_ref()
698            .expect("value")
699            .measure
700            .as_ref()
701            .expect("measure map");
702        assert!(measure.contains_key("usd"));
703        assert!(measure.contains_key("eur"));
704    }
705
706    #[test]
707    fn to_literal_roundtrips_number() {
708        let literal = LiteralValue::number_from_decimal(Decimal::from(42));
709        let rule_result = RuleResult::from_operation_result(
710            dummy_evaluated_rule("answer", primitive_number_arc().as_ref()),
711            &OperationResult::from_literal(literal.clone()),
712            primitive_number_arc().as_ref(),
713            None,
714            Vec::new(),
715        );
716        assert_eq!(rule_result.to_literal(), literal);
717    }
718
719    #[test]
720    fn to_literal_roundtrips_measure() {
721        let money = test_money_type();
722        let literal = LiteralValue::measure_with_type(
723            crate::computation::rational::rational_new(60, 1),
724            "eur".into(),
725            Arc::new(money.clone()),
726        );
727        let rule_result = RuleResult::from_operation_result(
728            dummy_evaluated_rule("pay", &money),
729            &OperationResult::from_literal(literal.clone()),
730            &money,
731            None,
732            Vec::new(),
733        );
734        assert_eq!(rule_result.to_literal(), literal);
735    }
736}