Skip to main content

lemma/evaluation/
response.rs

1use crate::computation::rational::NumericFailure;
2use crate::evaluation::explanations::Explanation;
3use crate::evaluation::operations::{OperationResult, VetoType};
4
5use crate::parsing::ast::DateTimeValue;
6use crate::planning::semantics::{
7    range_element_type_specification, LemmaType, LiteralUnitMapFailure, LiteralValue, RulePath,
8    SemanticDateTime, SemanticTime, Source, TypeSpecification, ValueKind,
9};
10use indexmap::IndexMap;
11use serde::Serialize;
12use std::collections::BTreeMap;
13use std::sync::Arc;
14
15/// Rule info with resolved expressions for use in evaluation response.
16/// Evaluation uses only semantics types; no parsing types.
17#[derive(Debug, Clone, Serialize)]
18pub struct EvaluatedRule {
19    pub name: String,
20    pub path: RulePath,
21    pub source_location: Source,
22    pub rule_type: LemmaType,
23}
24
25/// Calendar value on a rule result.
26#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
27pub struct CalendarResult {
28    pub value: String,
29    pub unit: String,
30}
31
32/// One endpoint of a range rule result.
33#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
34pub struct RuleResultPayload {
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub measure: Option<BTreeMap<String, String>>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub ratio: Option<BTreeMap<String, String>>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub number: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub boolean: Option<bool>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub text: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub date: Option<SemanticDateTime>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub time: Option<SemanticTime>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub calendar: Option<CalendarResult>,
51}
52
53/// Range rule result.
54#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
55pub struct RangeResult {
56    pub from: RuleResultPayload,
57    pub to: RuleResultPayload,
58}
59
60/// Response from evaluating a Lemma spec
61#[derive(Debug, Clone, Serialize)]
62pub struct Response {
63    #[serde(rename = "spec")]
64    pub spec_name: String,
65    pub effective: String,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub spec_hash: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub spec_effective_from: Option<DateTimeValue>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub spec_effective_to: Option<DateTimeValue>,
72    pub results: IndexMap<String, RuleResult>,
73}
74
75/// Result of evaluating a single rule. Struct fields match the API JSON shape.
76#[derive(Debug, Clone, Serialize)]
77pub struct RuleResult {
78    #[serde(skip)]
79    pub rule: EvaluatedRule,
80    #[serde(skip)]
81    pub veto_detail: Option<VetoType>,
82
83    pub vetoed: bool,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub display: Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub veto_reason: Option<String>,
88    pub rule_type: String,
89
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub measure: Option<BTreeMap<String, String>>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub ratio: Option<BTreeMap<String, String>>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub number: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub boolean: Option<bool>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub text: Option<String>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub date: Option<SemanticDateTime>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub time: Option<SemanticTime>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub calendar: Option<CalendarResult>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub range: Option<RangeResult>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub explanation: Option<Explanation>,
110    /// Unbound caller data paths still live for this rule under the current overlay
111    /// (`DataPath::input_key` strings, same keys as `Show.data`).
112    #[serde(skip_serializing_if = "Vec::is_empty")]
113    pub missing_data: Vec<String>,
114}
115
116impl RuleResult {
117    /// Materialize a rule evaluation result for API output.
118    ///
119    /// Measure and ratio payloads expand into every unit declared on `rule_type`.
120    pub fn from_operation_result(
121        rule: EvaluatedRule,
122        operation_result: &OperationResult,
123        rule_type: &LemmaType,
124        explanation: Option<Explanation>,
125        missing_data: Vec<String>,
126    ) -> Self {
127        let rule_type_name = rule_type.name().to_string();
128        match operation_result {
129            OperationResult::Veto(veto) => Self {
130                rule,
131                veto_detail: Some(veto.clone()),
132                vetoed: true,
133                display: None,
134                veto_reason: match &veto {
135                    VetoType::UserDefined { message: None } => None,
136                    _ => Some(veto.to_string()),
137                },
138                rule_type: rule_type_name,
139                measure: None,
140                ratio: None,
141                number: None,
142                boolean: None,
143                text: None,
144                date: None,
145                time: None,
146                calendar: None,
147                range: None,
148                explanation,
149                missing_data,
150            },
151            OperationResult::Value(literal) => match &literal.value {
152                ValueKind::Range(from, to) => {
153                    let endpoint_type = element_type_from_range_rule(rule_type)
154                        .unwrap_or_else(|| rule_type.clone());
155                    let from_type = endpoint_materialization_type(from, &endpoint_type);
156                    let to_type = endpoint_materialization_type(to, &endpoint_type);
157                    match (
158                        materialize_payload(from, &from_type),
159                        materialize_payload(to, &to_type),
160                    ) {
161                        (Ok(from_payload), Ok(to_payload)) => Self {
162                            rule,
163                            veto_detail: None,
164                            vetoed: false,
165                            display: Some(literal.to_string()),
166                            veto_reason: None,
167                            rule_type: rule_type_name,
168                            measure: None,
169                            ratio: None,
170                            number: None,
171                            boolean: None,
172                            text: None,
173                            date: None,
174                            time: None,
175                            calendar: None,
176                            range: Some(RangeResult {
177                                from: from_payload,
178                                to: to_payload,
179                            }),
180                            explanation,
181                            missing_data,
182                        },
183                        (Err(failure), _) | (_, Err(failure)) => {
184                            vetoed_rule_result_for_materialization_failure(
185                                rule,
186                                rule_type_name,
187                                explanation,
188                                failure,
189                                missing_data,
190                            )
191                        }
192                    }
193                }
194                _ => match materialize_payload(literal, rule_type) {
195                    Ok(payload) => Self {
196                        rule,
197                        veto_detail: None,
198                        vetoed: false,
199                        display: Some(literal.to_string()),
200                        veto_reason: None,
201                        rule_type: rule_type_name,
202                        measure: payload.measure,
203                        ratio: payload.ratio,
204                        number: payload.number,
205                        boolean: payload.boolean,
206                        text: payload.text,
207                        date: payload.date,
208                        time: payload.time,
209                        calendar: payload.calendar,
210                        range: None,
211                        explanation,
212                        missing_data,
213                    },
214                    Err(failure) => vetoed_rule_result_for_materialization_failure(
215                        rule,
216                        rule_type_name,
217                        explanation,
218                        failure,
219                        missing_data,
220                    ),
221                },
222            },
223        }
224    }
225
226    /// Reconstruct the evaluated [`LiteralValue`] from committed materialized fields.
227    ///
228    /// Panics if the rule is vetoed or materialized fields cannot be reconstructed.
229    pub fn materialized_literal(&self) -> LiteralValue {
230        assert!(
231            !self.vetoed,
232            "BUG: materialized_literal called on vetoed rule '{}'",
233            self.rule.name
234        );
235        let rule_type = Arc::new(self.rule.rule_type.clone());
236
237        if let Some(b) = self.boolean {
238            return LiteralValue {
239                value: ValueKind::Boolean(b),
240                lemma_type: rule_type,
241            };
242        }
243        if let Some(number) = &self.number {
244            return LiteralValue::number_with_type_from_decimal(
245                decimal_from_materialized_string(number),
246                rule_type,
247            );
248        }
249        if let Some(calendar) = &self.calendar {
250            use crate::literals::rational_from_parsed_decimal;
251            let rational =
252                rational_from_parsed_decimal(decimal_from_materialized_string(&calendar.value))
253                    .expect("BUG: calendar rule result value must lift to rational");
254            return LiteralValue::measure_with_type(rational, calendar.unit.clone(), rule_type);
255        }
256        if let Some(measure) = &self.measure {
257            return literal_from_measure_map(measure, &rule_type);
258        }
259        if let Some(ratio) = &self.ratio {
260            return literal_from_ratio_map(ratio, &rule_type);
261        }
262        if let Some(date) = &self.date {
263            return LiteralValue {
264                value: ValueKind::Date(date.clone()),
265                lemma_type: rule_type,
266            };
267        }
268        if let Some(time) = &self.time {
269            return LiteralValue {
270                value: ValueKind::Time(time.clone()),
271                lemma_type: rule_type,
272            };
273        }
274        if let Some(text) = &self.text {
275            return LiteralValue {
276                value: ValueKind::Text(text.clone()),
277                lemma_type: rule_type,
278            };
279        }
280        if let Some(range) = &self.range {
281            let endpoint_type = element_type_from_range_rule(&rule_type)
282                .unwrap_or_else(|| rule_type.as_ref().clone());
283            let left = payload_to_literal(&range.from, &endpoint_type);
284            let right = payload_to_literal(&range.to, &endpoint_type);
285            return LiteralValue::range(left, right);
286        }
287        panic!(
288            "BUG: rule '{}' materialized fields cannot reconstruct literal",
289            self.rule.name
290        );
291    }
292}
293
294fn decimal_from_materialized_string(value: &str) -> rust_decimal::Decimal {
295    use rust_decimal::Decimal;
296    use std::str::FromStr;
297    Decimal::from_str(value)
298        .unwrap_or_else(|_| panic!("BUG: rule result materialized string must parse as decimal"))
299}
300
301fn literal_from_measure_map(
302    measure: &BTreeMap<String, String>,
303    rule_type: &LemmaType,
304) -> LiteralValue {
305    use crate::computation::rational::checked_mul;
306    use crate::literals::rational_from_parsed_decimal;
307
308    let unit_names = rule_type
309        .measure_unit_names()
310        .expect("BUG: measure rule result must have declared units");
311    let unit_name = unit_names
312        .first()
313        .expect("BUG: measure rule result type must declare at least one unit");
314    let display = measure
315        .get(*unit_name)
316        .unwrap_or_else(|| panic!("BUG: measure map missing unit '{unit_name}'"));
317    let rational = rational_from_parsed_decimal(decimal_from_materialized_string(display))
318        .expect("BUG: measure rule result value must lift to rational");
319    let factor = rule_type.measure_unit_factor(unit_name);
320    let canonical = checked_mul(&rational, factor).unwrap_or_else(|failure| {
321        panic!("BUG: measure canonicalization from materialized fields failed: {failure}")
322    });
323    LiteralValue::measure_with_type(
324        canonical,
325        (*unit_name).to_string(),
326        Arc::new(rule_type.clone()),
327    )
328}
329
330fn literal_from_ratio_map(ratio: &BTreeMap<String, String>, rule_type: &LemmaType) -> LiteralValue {
331    use crate::computation::rational::checked_div;
332    use crate::literals::rational_from_parsed_decimal;
333
334    let units = match &rule_type.specifications {
335        TypeSpecification::Ratio { units, .. } => units,
336        TypeSpecification::RatioRange { .. } => {
337            let element = range_element_type_specification(&rule_type.specifications)
338                .expect("BUG: ratio range rule type must have ratio element specification");
339            let TypeSpecification::Ratio { units, .. } = element else {
340                panic!("BUG: ratio range element spec must be Ratio");
341            };
342            return literal_from_ratio_map(
343                ratio,
344                &LemmaType::primitive(TypeSpecification::Ratio {
345                    minimum: None,
346                    maximum: None,
347                    decimals: None,
348                    units,
349                    help: String::new(),
350                }),
351            );
352        }
353        _ => panic!(
354            "BUG: ratio rule result type must be Ratio, got {}",
355            rule_type.name()
356        ),
357    };
358    let unit = units
359        .iter()
360        .next()
361        .expect("BUG: ratio rule result type must declare at least one unit");
362    let display = ratio
363        .get(&unit.name)
364        .unwrap_or_else(|| panic!("BUG: ratio map missing unit '{}'", unit.name));
365    let display_rational = rational_from_parsed_decimal(decimal_from_materialized_string(display))
366        .expect("BUG: ratio rule result value must lift to rational");
367    let canonical = checked_div(&display_rational, &unit.value).unwrap_or_else(|failure| {
368        panic!("BUG: ratio canonicalization from materialized fields failed: {failure}")
369    });
370    LiteralValue::ratio_with_type(canonical, None, Arc::new(rule_type.clone()))
371}
372
373fn payload_to_literal(payload: &RuleResultPayload, rule_type: &LemmaType) -> LiteralValue {
374    if let Some(b) = payload.boolean {
375        return LiteralValue {
376            value: ValueKind::Boolean(b),
377            lemma_type: Arc::new(rule_type.clone()),
378        };
379    }
380    if let Some(number) = &payload.number {
381        return LiteralValue::number_with_type_from_decimal(
382            decimal_from_materialized_string(number),
383            Arc::new(rule_type.clone()),
384        );
385    }
386    if let Some(calendar) = &payload.calendar {
387        use crate::literals::rational_from_parsed_decimal;
388        let rational =
389            rational_from_parsed_decimal(decimal_from_materialized_string(&calendar.value))
390                .expect("BUG: calendar payload value must lift to rational");
391        return LiteralValue::measure_with_type(
392            rational,
393            calendar.unit.clone(),
394            Arc::new(rule_type.clone()),
395        );
396    }
397    if let Some(measure) = &payload.measure {
398        return literal_from_measure_map(measure, rule_type);
399    }
400    if let Some(ratio) = &payload.ratio {
401        return literal_from_ratio_map(ratio, rule_type);
402    }
403    if let Some(date) = &payload.date {
404        return LiteralValue {
405            value: ValueKind::Date(date.clone()),
406            lemma_type: Arc::new(rule_type.clone()),
407        };
408    }
409    if let Some(time) = &payload.time {
410        return LiteralValue {
411            value: ValueKind::Time(time.clone()),
412            lemma_type: Arc::new(rule_type.clone()),
413        };
414    }
415    if let Some(text) = &payload.text {
416        return LiteralValue {
417            value: ValueKind::Text(text.clone()),
418            lemma_type: Arc::new(rule_type.clone()),
419        };
420    }
421    panic!("BUG: range endpoint payload cannot reconstruct literal");
422}
423
424fn element_type_from_range_rule(rule_type: &LemmaType) -> Option<LemmaType> {
425    range_element_type_specification(&rule_type.specifications).map(LemmaType::primitive)
426}
427
428fn endpoint_materialization_type(
429    endpoint: &crate::planning::semantics::LiteralValue,
430    range_element_type: &LemmaType,
431) -> LemmaType {
432    if endpoint.lemma_type.measure_unit_names().is_some() {
433        endpoint.lemma_type.as_ref().clone()
434    } else {
435        range_element_type.clone()
436    }
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440enum MaterializationFailure {
441    DecimalLimit,
442    NumericOverflow,
443    OutOfMemory,
444}
445
446fn map_materialization_failure(failure: NumericFailure) -> MaterializationFailure {
447    match failure {
448        NumericFailure::Overflow => MaterializationFailure::DecimalLimit,
449        NumericFailure::OutOfMemory => MaterializationFailure::OutOfMemory,
450        NumericFailure::DivisionByZero => {
451            panic!("BUG: decimal commit encountered division by zero during materialization")
452        }
453        NumericFailure::Irrational => {
454            panic!("BUG: decimal commit encountered irrational result during materialization")
455        }
456    }
457}
458
459fn map_unit_conversion_failure(failure: NumericFailure) -> MaterializationFailure {
460    match failure {
461        NumericFailure::Overflow => MaterializationFailure::NumericOverflow,
462        NumericFailure::OutOfMemory => MaterializationFailure::OutOfMemory,
463        NumericFailure::DivisionByZero => {
464            panic!("BUG: unit conversion encountered division by zero during materialization")
465        }
466        NumericFailure::Irrational => {
467            panic!("BUG: unit conversion encountered irrational result during materialization")
468        }
469    }
470}
471
472fn materialization_failure_message(failure: MaterializationFailure) -> &'static str {
473    match failure {
474        MaterializationFailure::DecimalLimit => "Calculated result exceeds decimal value limit",
475        MaterializationFailure::NumericOverflow => "numeric overflow",
476        MaterializationFailure::OutOfMemory => "out of memory",
477    }
478}
479
480fn vetoed_rule_result_for_materialization_failure(
481    rule: EvaluatedRule,
482    rule_type_name: String,
483    explanation: Option<Explanation>,
484    failure: MaterializationFailure,
485    missing_data: Vec<String>,
486) -> RuleResult {
487    let veto = VetoType::computation(materialization_failure_message(failure).to_string());
488    RuleResult {
489        rule,
490        veto_detail: Some(veto.clone()),
491        vetoed: true,
492        display: None,
493        veto_reason: Some(veto.to_string()),
494        rule_type: rule_type_name,
495        measure: None,
496        ratio: None,
497        number: None,
498        boolean: None,
499        text: None,
500        date: None,
501        time: None,
502        calendar: None,
503        range: None,
504        explanation,
505        missing_data,
506    }
507}
508
509fn materialize_payload(
510    literal: &crate::planning::semantics::LiteralValue,
511    result_type: &LemmaType,
512) -> Result<RuleResultPayload, MaterializationFailure> {
513    match &literal.value {
514        ValueKind::Measure(rational, sig) if literal.lemma_type.is_calendar_like() => {
515            let unit =
516                crate::planning::semantics::semantic_calendar_unit_from_measure_signature(sig);
517            let value = literal
518                .lemma_type
519                .try_materialize_rational_as_decimal_string(rational)
520                .map_err(map_materialization_failure)?;
521            Ok(RuleResultPayload {
522                calendar: Some(CalendarResult {
523                    value,
524                    unit: unit.to_string(),
525                }),
526                ..RuleResultPayload::default()
527            })
528        }
529        ValueKind::Measure(_, _) => Ok(RuleResultPayload {
530            measure: Some(measure_to_unit_map(literal, result_type)?),
531            ..RuleResultPayload::default()
532        }),
533        ValueKind::Ratio(_, _) => Ok(RuleResultPayload {
534            ratio: Some(ratio_to_unit_map(literal, result_type)?),
535            ..RuleResultPayload::default()
536        }),
537        ValueKind::Number(rational) => {
538            let number = result_type
539                .try_materialize_rational_as_decimal_string(rational)
540                .map_err(map_materialization_failure)?;
541            Ok(RuleResultPayload {
542                number: Some(number),
543                ..RuleResultPayload::default()
544            })
545        }
546        ValueKind::Boolean(b) => Ok(RuleResultPayload {
547            boolean: Some(*b),
548            ..RuleResultPayload::default()
549        }),
550        ValueKind::Text(s) => Ok(RuleResultPayload {
551            text: Some(s.clone()),
552            ..RuleResultPayload::default()
553        }),
554        ValueKind::Date(d) => Ok(RuleResultPayload {
555            date: Some(d.clone()),
556            ..RuleResultPayload::default()
557        }),
558        ValueKind::Time(t) => Ok(RuleResultPayload {
559            time: Some(t.clone()),
560            ..RuleResultPayload::default()
561        }),
562        ValueKind::Range(_, _) => {
563            panic!("BUG: range payload must be built at RuleResult level, not RuleResultPayload")
564        }
565    }
566}
567
568fn map_literal_unit_map_failure(failure: LiteralUnitMapFailure) -> MaterializationFailure {
569    match failure {
570        LiteralUnitMapFailure::Commit(nf) => map_materialization_failure(nf),
571        LiteralUnitMapFailure::UnitConversion(nf) => map_unit_conversion_failure(nf),
572    }
573}
574
575fn measure_to_unit_map(
576    literal: &crate::planning::semantics::LiteralValue,
577    result_type: &LemmaType,
578) -> Result<BTreeMap<String, String>, MaterializationFailure> {
579    result_type
580        .measure_literal_in_all_units(literal)
581        .map_err(map_literal_unit_map_failure)
582}
583
584fn ratio_to_unit_map(
585    literal: &crate::planning::semantics::LiteralValue,
586    result_type: &LemmaType,
587) -> Result<BTreeMap<String, String>, MaterializationFailure> {
588    result_type
589        .ratio_literal_in_all_units(literal)
590        .map_err(map_literal_unit_map_failure)
591}
592
593impl Response {
594    /// Looks up a rule result by name.
595    ///
596    /// Returns an error if the rule is not found.
597    pub fn get(&self, rule_name: &str) -> Result<&RuleResult, crate::error::Error> {
598        self.results
599            .get(rule_name)
600            .ok_or_else(|| crate::error::Error::rule_not_found(rule_name, None::<String>))
601    }
602
603    pub fn add_result(&mut self, result: RuleResult) {
604        self.results.insert(result.rule.name.clone(), result);
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::literals::DateGranularity;
612    use crate::planning::semantics::{
613        primitive_number, BaseMeasureVector, DataPath, LemmaType, LiteralValue, MeasureUnit,
614        MeasureUnits, RatioUnit, RatioUnits, RulePath, Span, TypeExtends, TypeSpecification,
615    };
616    use rust_decimal::Decimal;
617    use std::sync::Arc;
618
619    fn dummy_source() -> Source {
620        Source::new(
621            crate::parsing::source::SourceType::Volatile,
622            Span {
623                start: 0,
624                end: 0,
625                line: 1,
626                col: 1,
627            },
628        )
629    }
630
631    fn dummy_evaluated_rule(name: &str, rule_type: &LemmaType) -> EvaluatedRule {
632        EvaluatedRule {
633            name: name.to_string(),
634            path: RulePath::new(vec![], name.to_string()),
635            source_location: dummy_source(),
636            rule_type: rule_type.clone(),
637        }
638    }
639
640    #[test]
641    fn test_response_serialization() {
642        let mut results = IndexMap::new();
643        results.insert(
644            "test_rule".to_string(),
645            RuleResult::from_operation_result(
646                dummy_evaluated_rule("test_rule", primitive_number()),
647                &OperationResult::from_literal(LiteralValue::number_from_decimal(Decimal::from(
648                    42,
649                ))),
650                primitive_number(),
651                None,
652                Vec::new(),
653            ),
654        );
655        let response = Response {
656            spec_name: "test_spec".to_string(),
657            effective: "2026-01-01".to_string(),
658            spec_hash: None,
659            spec_effective_from: None,
660            spec_effective_to: None,
661            results,
662        };
663
664        let json = serde_json::to_string(&response).unwrap();
665        assert!(json.contains("test_spec"));
666        assert!(json.contains("test_rule"));
667        assert!(json.contains("\"number\":\"42\""));
668        assert!(!json.contains("lemma_type"));
669    }
670
671    #[test]
672    fn response_number_json_never_uses_fraction_notation() {
673        use crate::computation::rational::decimal_to_rational;
674
675        let rational = decimal_to_rational(Decimal::new(1, 1) / Decimal::new(3, 1)).unwrap();
676        let decimal_string = rational.try_to_decimal().unwrap().to_string();
677        let mut results = IndexMap::new();
678        results.insert(
679            "third".to_string(),
680            RuleResult::from_operation_result(
681                dummy_evaluated_rule("third", primitive_number()),
682                &OperationResult::from_literal(LiteralValue::number_from_decimal(
683                    rational.try_to_decimal().unwrap(),
684                )),
685                primitive_number(),
686                None,
687                Vec::new(),
688            ),
689        );
690        // Override committed decimal number field to match serialization path under test
691        if let Some(rule) = results.get_mut("third") {
692            rule.number = Some(decimal_string.clone());
693            rule.display = Some(decimal_string);
694        }
695
696        let response = Response {
697            spec_name: "test".to_string(),
698            effective: "test".to_string(),
699            spec_hash: None,
700            spec_effective_from: None,
701            spec_effective_to: None,
702            results,
703        };
704
705        let json: serde_json::Value =
706            serde_json::from_str(&serde_json::to_string(&response).unwrap()).unwrap();
707        let number = json["results"]["third"]["number"]
708            .as_str()
709            .expect("number must be a JSON string");
710        assert!(
711            !number.contains('/'),
712            "API decimal string must not use fraction notation, got {number}"
713        );
714    }
715
716    #[test]
717    fn test_rule_result_veto() {
718        let missing = RuleResult::from_operation_result(
719            dummy_evaluated_rule("rule3", &LemmaType::veto_type()),
720            &OperationResult::Veto(VetoType::missing_data(
721                DataPath::new(vec![], "data1".to_string()),
722                None,
723            )),
724            &LemmaType::veto_type(),
725            None,
726            Vec::new(),
727        );
728        assert!(missing.vetoed);
729        assert!(missing.veto_reason.as_ref().unwrap().contains("data1"));
730
731        let veto = RuleResult::from_operation_result(
732            dummy_evaluated_rule("rule4", &LemmaType::veto_type()),
733            &OperationResult::Veto(VetoType::UserDefined {
734                message: Some("Vetoed".to_string()),
735            }),
736            &LemmaType::veto_type(),
737            None,
738            Vec::new(),
739        );
740        assert_eq!(veto.veto_reason.as_deref(), Some("Vetoed"));
741    }
742
743    #[test]
744    fn materialization_out_of_memory_is_not_decimal_limit_veto() {
745        let result = vetoed_rule_result_for_materialization_failure(
746            dummy_evaluated_rule("rule", primitive_number()),
747            "number".to_string(),
748            None,
749            MaterializationFailure::OutOfMemory,
750            Vec::new(),
751        );
752        assert_eq!(result.veto_reason.as_deref(), Some("out of memory"));
753        assert_ne!(
754            result.veto_reason.as_deref(),
755            Some("Calculated result exceeds decimal value limit")
756        );
757    }
758
759    #[test]
760    fn materialization_decimal_limit_uses_commit_message() {
761        let result = vetoed_rule_result_for_materialization_failure(
762            dummy_evaluated_rule("rule", primitive_number()),
763            "number".to_string(),
764            None,
765            MaterializationFailure::DecimalLimit,
766            Vec::new(),
767        );
768        assert_eq!(
769            result.veto_reason.as_deref(),
770            Some("Calculated result exceeds decimal value limit")
771        );
772    }
773
774    fn test_money_type() -> LemmaType {
775        LemmaType::new(
776            "money".to_string(),
777            TypeSpecification::Measure {
778                minimum: None,
779                maximum: None,
780                decimals: Some(2),
781                units: MeasureUnits::from(vec![
782                    MeasureUnit {
783                        name: "eur".to_string(),
784                        factor: crate::computation::rational::rational_one(),
785                        derived_measure_factors: Vec::new(),
786                        decomposition: BaseMeasureVector::new(),
787                        minimum: None,
788                        maximum: None,
789                        suggestion_magnitude: None,
790                    },
791                    MeasureUnit {
792                        name: "usd".to_string(),
793                        factor: crate::computation::rational::decimal_to_rational(Decimal::new(
794                            91, 2,
795                        ))
796                        .expect("factor"),
797                        derived_measure_factors: Vec::new(),
798                        decomposition: BaseMeasureVector::new(),
799                        minimum: None,
800                        maximum: None,
801                        suggestion_magnitude: None,
802                    },
803                ]),
804                traits: Vec::new(),
805                decomposition: Some(BaseMeasureVector::new()),
806                help: String::new(),
807            },
808            TypeExtends::Primitive,
809        )
810    }
811
812    #[test]
813    fn measure_materialization_uses_rule_type_when_expression_index_empty() {
814        let money = test_money_type();
815        let ten_usd = LiteralValue {
816            value: ValueKind::Measure(
817                crate::computation::rational::checked_mul(
818                    &crate::computation::rational::decimal_to_rational(Decimal::from(10))
819                        .expect("ten"),
820                    &crate::computation::rational::decimal_to_rational(Decimal::new(91, 2))
821                        .expect("usd factor"),
822                )
823                .expect("canonical usd"),
824                vec![("usd".to_string(), 1)],
825            ),
826            lemma_type: Arc::new(money.clone()),
827        };
828        let result = RuleResult::from_operation_result(
829            dummy_evaluated_rule("total", &money),
830            &OperationResult::from_literal(ten_usd),
831            &money,
832            None,
833            Vec::new(),
834        );
835        let measure = result.measure.expect("measure map");
836        assert_eq!(measure.get("usd"), Some(&"10.00".to_string()));
837        assert!(measure.contains_key("eur"));
838    }
839
840    #[test]
841    fn test_measure_materialization_multi_unit() {
842        let money = test_money_type();
843        let ten_eur = LiteralValue {
844            value: ValueKind::Measure(
845                crate::computation::rational::decimal_to_rational(Decimal::from(10)).expect("ten"),
846                vec![],
847            ),
848            lemma_type: Arc::new(money.clone()),
849        };
850        let result = RuleResult::from_operation_result(
851            dummy_evaluated_rule("total", &money),
852            &OperationResult::from_literal(ten_eur),
853            &money,
854            None,
855            Vec::new(),
856        );
857        let measure = result.measure.expect("measure map");
858        assert_eq!(measure.get("eur"), Some(&"10.00".to_string()));
859        assert_eq!(measure.get("usd"), Some(&"10.99".to_string()));
860    }
861
862    #[test]
863    fn measure_materialization_respects_decimals_on_unit_conversion() {
864        let money = LemmaType::new(
865            "money".to_string(),
866            TypeSpecification::Measure {
867                minimum: None,
868                maximum: None,
869                decimals: Some(2),
870                units: MeasureUnits::from(vec![
871                    MeasureUnit {
872                        name: "eur".to_string(),
873                        factor: crate::computation::rational::rational_one(),
874                        derived_measure_factors: Vec::new(),
875                        decomposition: BaseMeasureVector::new(),
876                        minimum: None,
877                        maximum: None,
878                        suggestion_magnitude: None,
879                    },
880                    MeasureUnit {
881                        name: "usd".to_string(),
882                        factor: crate::computation::rational::decimal_to_rational(Decimal::new(
883                            84, 2,
884                        ))
885                        .expect("usd factor"),
886                        derived_measure_factors: Vec::new(),
887                        decomposition: BaseMeasureVector::new(),
888                        minimum: None,
889                        maximum: None,
890                        suggestion_magnitude: None,
891                    },
892                ]),
893                traits: Vec::new(),
894                decomposition: Some(BaseMeasureVector::new()),
895                help: String::new(),
896            },
897            TypeExtends::Primitive,
898        );
899        let three_twelve_eur = LiteralValue {
900            value: ValueKind::Measure(
901                crate::computation::rational::decimal_to_rational(Decimal::new(312, 2))
902                    .expect("3.12 eur canonical"),
903                vec![],
904            ),
905            lemma_type: Arc::new(money.clone()),
906        };
907        let result = RuleResult::from_operation_result(
908            dummy_evaluated_rule("delivery_cost", &money),
909            &OperationResult::from_literal(three_twelve_eur),
910            &money,
911            None,
912            Vec::new(),
913        );
914        let measure = result.measure.expect("measure map");
915        assert_eq!(measure.get("eur"), Some(&"3.12".to_string()));
916        assert_eq!(measure.get("usd"), Some(&"3.71".to_string()));
917    }
918
919    #[test]
920    fn test_ratio_materialization_multi_unit() {
921        let ratio_type = LemmaType::new(
922            "rate".to_string(),
923            TypeSpecification::Ratio {
924                minimum: None,
925                maximum: None,
926                decimals: None,
927                units: RatioUnits::from(vec![
928                    RatioUnit {
929                        name: "percent".to_string(),
930                        value: crate::computation::rational::decimal_to_rational(Decimal::from(
931                            100,
932                        ))
933                        .expect("percent"),
934                        minimum: None,
935                        maximum: None,
936                        suggestion_magnitude: None,
937                    },
938                    RatioUnit {
939                        name: "basis_points".to_string(),
940                        value: crate::computation::rational::decimal_to_rational(Decimal::from(
941                            10_000,
942                        ))
943                        .expect("bp"),
944                        minimum: None,
945                        maximum: None,
946                        suggestion_magnitude: None,
947                    },
948                ]),
949                help: String::new(),
950            },
951            TypeExtends::Primitive,
952        );
953        let half = crate::computation::rational::rational_new(1, 2);
954        let lit = LiteralValue {
955            value: ValueKind::Ratio(half, Some("percent".to_string())),
956            lemma_type: Arc::new(ratio_type.clone()),
957        };
958        let result = RuleResult::from_operation_result(
959            dummy_evaluated_rule("rate_out", &ratio_type),
960            &OperationResult::from_literal(lit),
961            &ratio_type,
962            None,
963            Vec::new(),
964        );
965        let ratio = result.ratio.expect("ratio map");
966        assert_eq!(ratio.get("percent"), Some(&"50".to_string()));
967        assert_eq!(ratio.get("basis_points"), Some(&"5000".to_string()));
968    }
969
970    #[test]
971    fn test_measure_materialization_cross_spec_import() {
972        use crate::parsing::source::SourceType;
973        use crate::Engine;
974
975        let mut engine = Engine::new();
976        engine
977            .load([(
978                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("t.lemma"))),
979                r#"
980spec consumer 2025-01-01
981uses d: dep 2025-10-01
982rule out: d.doubled
983
984spec dep 2025-01-01
985uses c: child 2025-06-01
986data money: c.money
987data p: 5 usd
988rule doubled: p * 2
989
990spec child 2025-01-01
991data money: measure
992 -> unit eur 1.00
993 -> decimals 2
994
995spec child 2025-06-01
996data money: measure
997 -> unit eur 1.00
998 -> unit usd 0.91
999 -> decimals 2
1000"#
1001                .to_string(),
1002            )])
1003            .expect("load");
1004        let effective = crate::literals::DateTimeValue {
1005            year: 2025,
1006            month: 3,
1007            day: 1,
1008            hour: 0,
1009            minute: 0,
1010            second: 0,
1011            microsecond: 0,
1012            timezone: None,
1013
1014            granularity: DateGranularity::Full,
1015        };
1016        let response = engine
1017            .run(
1018                None,
1019                "consumer",
1020                Some(&effective),
1021                std::collections::HashMap::new(),
1022                None,
1023                false,
1024            )
1025            .expect("run");
1026        let out = response.results.get("out").expect("out rule");
1027        assert!(!out.vetoed);
1028        let measure = out.measure.as_ref().expect("measure map");
1029        assert!(measure.contains_key("usd"));
1030        assert!(measure.contains_key("eur"));
1031    }
1032
1033    #[test]
1034    fn materialized_literal_roundtrips_number() {
1035        let literal = LiteralValue::number_from_decimal(Decimal::from(42));
1036        let rule_result = RuleResult::from_operation_result(
1037            dummy_evaluated_rule("answer", primitive_number()),
1038            &OperationResult::from_literal(literal.clone()),
1039            primitive_number(),
1040            None,
1041            Vec::new(),
1042        );
1043        assert_eq!(rule_result.materialized_literal(), literal);
1044    }
1045
1046    #[test]
1047    fn materialized_literal_roundtrips_measure() {
1048        let money = test_money_type();
1049        let literal = LiteralValue::measure_with_type(
1050            crate::computation::rational::rational_new(60, 1),
1051            "eur".into(),
1052            Arc::new(money.clone()),
1053        );
1054        let rule_result = RuleResult::from_operation_result(
1055            dummy_evaluated_rule("pay", &money),
1056            &OperationResult::from_literal(literal.clone()),
1057            &money,
1058            None,
1059            Vec::new(),
1060        );
1061        assert_eq!(rule_result.materialized_literal(), literal);
1062    }
1063}