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    pub 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    /// Build a [`RuleResult`] for API output from a rule evaluation result.
73    ///
74    /// Measure and ratio payloads expand into every unit declared on `rule_type`.
75    pub fn from_operation_result(
76        rule: EvaluatedRule,
77        operation_result: &OperationResult,
78        rule_type: &LemmaType,
79        explanation: Option<Explanation>,
80        missing_data: Vec<String>,
81    ) -> Self {
82        let rule_type_name = rule_type.name().to_string();
83        match operation_result {
84            OperationResult::Veto(veto) => Self {
85                rule,
86                veto_detail: Some(veto.clone()),
87                vetoed: true,
88                veto_reason: match &veto {
89                    VetoType::UserDefined { message: None } => None,
90                    _ => Some(veto.to_string()),
91                },
92                rule_type: rule_type_name,
93                value: None,
94                explanation,
95                missing_data,
96            },
97            OperationResult::Value(literal) => {
98                match rule_result_value_from_literal(literal, rule_type) {
99                    Ok(value) => Self {
100                        rule,
101                        veto_detail: None,
102                        vetoed: false,
103                        veto_reason: None,
104                        rule_type: rule_type_name,
105                        value: Some(value),
106                        explanation,
107                        missing_data,
108                    },
109                    Err(failure) => vetoed_rule_result_for_rule_result_value_failure(
110                        rule,
111                        rule_type_name,
112                        explanation,
113                        failure,
114                        missing_data,
115                    ),
116                }
117            }
118        }
119    }
120
121    /// Reconstruct the evaluated [`LiteralValue`] from committed [`RuleResultValue`] fields.
122    ///
123    /// Panics if the rule is vetoed or fields cannot be reconstructed.
124    pub fn to_literal(&self) -> LiteralValue {
125        assert!(
126            !self.vetoed,
127            "BUG: to_literal called on vetoed rule '{}'",
128            self.rule.name
129        );
130        let value = self
131            .value
132            .as_ref()
133            .unwrap_or_else(|| panic!("BUG: non-vetoed rule '{}' missing value", self.rule.name));
134        value.to_literal(&self.rule.rule_type)
135    }
136}
137
138fn vetoed_rule_result_for_rule_result_value_failure(
139    rule: EvaluatedRule,
140    rule_type_name: String,
141    explanation: Option<Explanation>,
142    failure: RuleResultValueFailure,
143    missing_data: Vec<String>,
144) -> RuleResult {
145    let veto = VetoType::computation(rule_result_value_failure_message(failure).to_string());
146    RuleResult {
147        rule,
148        veto_detail: Some(veto.clone()),
149        vetoed: true,
150        veto_reason: Some(veto.to_string()),
151        rule_type: rule_type_name,
152        value: None,
153        explanation,
154        missing_data,
155    }
156}
157
158impl Response {
159    /// Looks up a rule result by name.
160    ///
161    /// Returns an error if the rule is not found.
162    pub fn get(&self, rule_name: &str) -> Result<&RuleResult, crate::error::Error> {
163        self.results
164            .get(rule_name)
165            .ok_or_else(|| crate::error::Error::rule_not_found(rule_name, None::<String>))
166    }
167
168    pub fn add_result(&mut self, result: RuleResult) {
169        self.results.insert(result.rule.name.clone(), result);
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::literals::DateGranularity;
177    use crate::parsing::ast::Span;
178    use crate::planning::semantics::{
179        primitive_number_arc, BaseMeasureVector, DataPath, LemmaType, LiteralValue, MeasureUnit,
180        MeasureUnits, RatioUnit, RatioUnits, RulePath, TypeExtends, TypeSpecification, ValueKind,
181    };
182    use rust_decimal::Decimal;
183    use std::sync::Arc;
184
185    fn dummy_source() -> Source {
186        Source::new(
187            crate::parsing::source::SourceType::Volatile,
188            Span {
189                start: 0,
190                end: 0,
191                line: 1,
192                col: 1,
193            },
194        )
195    }
196
197    fn dummy_evaluated_rule(name: &str, rule_type: &LemmaType) -> EvaluatedRule {
198        EvaluatedRule {
199            name: name.to_string(),
200            path: RulePath::new(vec![], name.to_string()),
201            source_location: dummy_source(),
202            rule_type: rule_type.clone(),
203        }
204    }
205
206    #[test]
207    fn test_response_serialization() {
208        let mut results = IndexMap::new();
209        results.insert(
210            "test_rule".to_string(),
211            RuleResult::from_operation_result(
212                dummy_evaluated_rule("test_rule", primitive_number_arc().as_ref()),
213                &OperationResult::from_literal(LiteralValue::number_from_decimal(Decimal::from(
214                    42,
215                ))),
216                primitive_number_arc().as_ref(),
217                None,
218                Vec::new(),
219            ),
220        );
221        let response = Response {
222            spec_name: "test_spec".to_string(),
223            effective: "2026-01-01".to_string(),
224            spec_effective_from: None,
225            spec_effective_to: None,
226            results,
227        };
228
229        let json = serde_json::to_string(&response).unwrap();
230        assert!(json.contains("test_spec"));
231        assert!(json.contains("test_rule"));
232        assert!(json.contains("\"number\":\"42\""));
233        assert!(!json.contains("lemma_type"));
234    }
235
236    #[test]
237    fn response_number_json_never_uses_fraction_notation() {
238        use crate::computation::rational::decimal_to_rational;
239
240        let rational = decimal_to_rational(Decimal::new(1, 1) / Decimal::new(3, 1)).unwrap();
241        let decimal_string = rational.try_to_decimal().unwrap().to_string();
242        let mut results = IndexMap::new();
243        results.insert(
244            "third".to_string(),
245            RuleResult::from_operation_result(
246                dummy_evaluated_rule("third", primitive_number_arc().as_ref()),
247                &OperationResult::from_literal(LiteralValue::number_from_decimal(
248                    rational.try_to_decimal().unwrap(),
249                )),
250                primitive_number_arc().as_ref(),
251                None,
252                Vec::new(),
253            ),
254        );
255        // Override committed decimal number field to match serialization path under test
256        if let Some(rule) = results.get_mut("third") {
257            rule.value = Some(crate::result_value::RuleResultValue {
258                display: Some(decimal_string.clone()),
259                number: Some(decimal_string.clone()),
260                ..Default::default()
261            });
262        }
263
264        let response = Response {
265            spec_name: "test".to_string(),
266            effective: "test".to_string(),
267            spec_effective_from: None,
268            spec_effective_to: None,
269            results,
270        };
271
272        let json: serde_json::Value =
273            serde_json::from_str(&serde_json::to_string(&response).unwrap()).unwrap();
274        let number = json["results"]["third"]["number"]
275            .as_str()
276            .expect("number must be a JSON string");
277        assert!(
278            !number.contains('/'),
279            "API decimal string must not use fraction notation, got {number}"
280        );
281    }
282
283    #[test]
284    fn test_rule_result_veto() {
285        let missing = RuleResult::from_operation_result(
286            dummy_evaluated_rule("rule3", &LemmaType::veto_type()),
287            &OperationResult::Veto(VetoType::missing_data(
288                DataPath::new(vec![], "data1".to_string()),
289                None,
290            )),
291            &LemmaType::veto_type(),
292            None,
293            Vec::new(),
294        );
295        assert!(missing.vetoed);
296        assert!(missing.veto_reason.as_ref().unwrap().contains("data1"));
297
298        let veto = RuleResult::from_operation_result(
299            dummy_evaluated_rule("rule4", &LemmaType::veto_type()),
300            &OperationResult::Veto(VetoType::UserDefined {
301                message: Some("Vetoed".to_string()),
302            }),
303            &LemmaType::veto_type(),
304            None,
305            Vec::new(),
306        );
307        assert_eq!(veto.veto_reason.as_deref(), Some("Vetoed"));
308    }
309
310    #[test]
311    fn rule_result_value_out_of_memory_is_not_decimal_limit_veto() {
312        let result = vetoed_rule_result_for_rule_result_value_failure(
313            dummy_evaluated_rule("rule", primitive_number_arc().as_ref()),
314            "number".to_string(),
315            None,
316            RuleResultValueFailure::OutOfMemory,
317            Vec::new(),
318        );
319        assert_eq!(result.veto_reason.as_deref(), Some("out of memory"));
320        assert_ne!(
321            result.veto_reason.as_deref(),
322            Some("Calculated result exceeds decimal value limit")
323        );
324    }
325
326    #[test]
327    fn rule_result_value_decimal_limit_uses_commit_message() {
328        let result = vetoed_rule_result_for_rule_result_value_failure(
329            dummy_evaluated_rule("rule", primitive_number_arc().as_ref()),
330            "number".to_string(),
331            None,
332            RuleResultValueFailure::DecimalLimit,
333            Vec::new(),
334        );
335        assert_eq!(
336            result.veto_reason.as_deref(),
337            Some("Calculated result exceeds decimal value limit")
338        );
339    }
340
341    fn test_money_type() -> LemmaType {
342        LemmaType::new(
343            "money".to_string(),
344            TypeSpecification::Measure {
345                minimum: None,
346                maximum: None,
347                decimals: Some(2),
348                units: MeasureUnits::from(vec![
349                    MeasureUnit {
350                        name: "eur".to_string(),
351                        factor: crate::computation::rational::rational_one(),
352                        derived_measure_factors: Vec::new(),
353                        decomposition: BaseMeasureVector::new(),
354                        minimum: None,
355                        maximum: None,
356                        suggestion_magnitude: None,
357                    },
358                    MeasureUnit {
359                        name: "usd".to_string(),
360                        factor: crate::computation::rational::decimal_to_rational(Decimal::new(
361                            91, 2,
362                        ))
363                        .expect("factor"),
364                        derived_measure_factors: Vec::new(),
365                        decomposition: BaseMeasureVector::new(),
366                        minimum: None,
367                        maximum: None,
368                        suggestion_magnitude: None,
369                    },
370                ]),
371                traits: Vec::new(),
372                decomposition: Some(BaseMeasureVector::new()),
373                help: String::new(),
374            },
375            TypeExtends::Primitive,
376        )
377    }
378
379    #[test]
380    fn measure_rule_result_value_uses_rule_type_when_expression_index_empty() {
381        let money = test_money_type();
382        let ten_usd = LiteralValue {
383            value: ValueKind::Measure(
384                crate::computation::rational::checked_mul(
385                    &crate::computation::rational::decimal_to_rational(Decimal::from(10))
386                        .expect("ten"),
387                    &crate::computation::rational::decimal_to_rational(Decimal::new(91, 2))
388                        .expect("usd factor"),
389                )
390                .expect("canonical usd"),
391                vec![("usd".to_string(), 1)],
392            ),
393            lemma_type: Arc::new(money.clone()),
394        };
395        let result = RuleResult::from_operation_result(
396            dummy_evaluated_rule("total", &money),
397            &OperationResult::from_literal(ten_usd),
398            &money,
399            None,
400            Vec::new(),
401        );
402        let measure = result
403            .value
404            .as_ref()
405            .expect("value")
406            .measure
407            .clone()
408            .expect("measure map");
409        assert_eq!(measure.get("usd"), Some(&"10.00".to_string()));
410        assert!(measure.contains_key("eur"));
411    }
412
413    #[test]
414    fn test_measure_rule_result_value_multi_unit() {
415        let money = test_money_type();
416        let ten_eur = LiteralValue {
417            value: ValueKind::Measure(
418                crate::computation::rational::decimal_to_rational(Decimal::from(10)).expect("ten"),
419                vec![("eur".to_string(), 1)],
420            ),
421            lemma_type: Arc::new(money.clone()),
422        };
423        let result = RuleResult::from_operation_result(
424            dummy_evaluated_rule("total", &money),
425            &OperationResult::from_literal(ten_eur),
426            &money,
427            None,
428            Vec::new(),
429        );
430        let measure = result
431            .value
432            .as_ref()
433            .expect("value")
434            .measure
435            .clone()
436            .expect("measure map");
437        assert_eq!(measure.get("eur"), Some(&"10.00".to_string()));
438        assert_eq!(measure.get("usd"), Some(&"10.99".to_string()));
439    }
440
441    #[test]
442    fn measure_rule_result_value_respects_decimals_on_unit_conversion() {
443        let money = LemmaType::new(
444            "money".to_string(),
445            TypeSpecification::Measure {
446                minimum: None,
447                maximum: None,
448                decimals: Some(2),
449                units: MeasureUnits::from(vec![
450                    MeasureUnit {
451                        name: "eur".to_string(),
452                        factor: crate::computation::rational::rational_one(),
453                        derived_measure_factors: Vec::new(),
454                        decomposition: BaseMeasureVector::new(),
455                        minimum: None,
456                        maximum: None,
457                        suggestion_magnitude: None,
458                    },
459                    MeasureUnit {
460                        name: "usd".to_string(),
461                        factor: crate::computation::rational::decimal_to_rational(Decimal::new(
462                            84, 2,
463                        ))
464                        .expect("usd factor"),
465                        derived_measure_factors: Vec::new(),
466                        decomposition: BaseMeasureVector::new(),
467                        minimum: None,
468                        maximum: None,
469                        suggestion_magnitude: None,
470                    },
471                ]),
472                traits: Vec::new(),
473                decomposition: Some(BaseMeasureVector::new()),
474                help: String::new(),
475            },
476            TypeExtends::Primitive,
477        );
478        let three_twelve_eur = LiteralValue {
479            value: ValueKind::Measure(
480                crate::computation::rational::decimal_to_rational(Decimal::new(312, 2))
481                    .expect("3.12 eur canonical"),
482                vec![("eur".to_string(), 1)],
483            ),
484            lemma_type: Arc::new(money.clone()),
485        };
486        let result = RuleResult::from_operation_result(
487            dummy_evaluated_rule("delivery_cost", &money),
488            &OperationResult::from_literal(three_twelve_eur),
489            &money,
490            None,
491            Vec::new(),
492        );
493        let measure = result
494            .value
495            .as_ref()
496            .expect("value")
497            .measure
498            .clone()
499            .expect("measure map");
500        assert_eq!(measure.get("eur"), Some(&"3.12".to_string()));
501        assert_eq!(measure.get("usd"), Some(&"3.71".to_string()));
502    }
503
504    #[test]
505    fn test_ratio_rule_result_value_multi_unit() {
506        let ratio_type = LemmaType::new(
507            "rate".to_string(),
508            TypeSpecification::Ratio {
509                minimum: None,
510                maximum: None,
511                decimals: None,
512                units: RatioUnits::from(vec![
513                    RatioUnit {
514                        name: "percent".to_string(),
515                        value: crate::computation::rational::decimal_to_rational(Decimal::from(
516                            100,
517                        ))
518                        .expect("percent"),
519                        minimum: None,
520                        maximum: None,
521                        suggestion_magnitude: None,
522                    },
523                    RatioUnit {
524                        name: "basis_points".to_string(),
525                        value: crate::computation::rational::decimal_to_rational(Decimal::from(
526                            10_000,
527                        ))
528                        .expect("bp"),
529                        minimum: None,
530                        maximum: None,
531                        suggestion_magnitude: None,
532                    },
533                ]),
534                help: String::new(),
535            },
536            TypeExtends::Primitive,
537        );
538        let half = crate::computation::rational::rational_new(1, 2);
539        let lit = LiteralValue {
540            value: ValueKind::Ratio(half, Some("percent".to_string())),
541            lemma_type: Arc::new(ratio_type.clone()),
542        };
543        let result = RuleResult::from_operation_result(
544            dummy_evaluated_rule("rate_out", &ratio_type),
545            &OperationResult::from_literal(lit),
546            &ratio_type,
547            None,
548            Vec::new(),
549        );
550        let ratio = result
551            .value
552            .as_ref()
553            .expect("value")
554            .ratio
555            .clone()
556            .expect("ratio map");
557        assert_eq!(ratio.get("percent"), Some(&"50".to_string()));
558        assert_eq!(ratio.get("basis_points"), Some(&"5000".to_string()));
559    }
560
561    #[test]
562    fn test_measure_rule_result_value_cross_spec_import() {
563        use crate::parsing::source::SourceType;
564        use crate::Engine;
565
566        let mut engine = Engine::new();
567        engine
568            .load([(
569                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("t.lemma"))),
570                r#"
571spec consumer 2025-01-01
572uses d: dep 2025-10-01
573rule out: d.doubled
574
575spec dep 2025-01-01
576uses c: child 2025-06-01
577data money: c.money
578data p: 5 usd
579rule doubled: p * 2
580
581spec child 2025-01-01
582data money: measure
583 -> unit eur 1.00
584 -> decimals 2
585
586spec child 2025-06-01
587data money: measure
588 -> unit eur 1.00
589 -> unit usd 0.91
590 -> decimals 2
591"#
592                .to_string(),
593            )])
594            .expect("load");
595        let effective = crate::literals::DateTimeValue {
596            year: 2025,
597            month: 3,
598            day: 1,
599            hour: 0,
600            minute: 0,
601            second: 0,
602            microsecond: 0,
603            timezone: None,
604
605            granularity: DateGranularity::Full,
606        };
607        let response = engine
608            .run(
609                None,
610                "consumer",
611                Some(&effective),
612                std::collections::HashMap::new(),
613                None,
614                false,
615            )
616            .expect("run");
617        let out = response.results.get("out").expect("out rule");
618        assert!(!out.vetoed);
619        let measure = out
620            .value
621            .as_ref()
622            .expect("value")
623            .measure
624            .as_ref()
625            .expect("measure map");
626        assert!(measure.contains_key("usd"));
627        assert!(measure.contains_key("eur"));
628    }
629
630    #[test]
631    fn to_literal_roundtrips_number() {
632        let literal = LiteralValue::number_from_decimal(Decimal::from(42));
633        let rule_result = RuleResult::from_operation_result(
634            dummy_evaluated_rule("answer", primitive_number_arc().as_ref()),
635            &OperationResult::from_literal(literal.clone()),
636            primitive_number_arc().as_ref(),
637            None,
638            Vec::new(),
639        );
640        assert_eq!(rule_result.to_literal(), literal);
641    }
642
643    #[test]
644    fn to_literal_roundtrips_measure() {
645        let money = test_money_type();
646        let literal = LiteralValue::measure_with_type(
647            crate::computation::rational::rational_new(60, 1),
648            "eur".into(),
649            Arc::new(money.clone()),
650        );
651        let rule_result = RuleResult::from_operation_result(
652            dummy_evaluated_rule("pay", &money),
653            &OperationResult::from_literal(literal.clone()),
654            &money,
655            None,
656            Vec::new(),
657        );
658        assert_eq!(rule_result.to_literal(), literal);
659    }
660}