Skip to main content

lemma/evaluation/
response.rs

1use crate::evaluation::evaluation_trace::EvaluationTrace;
2use crate::evaluation::operations::{OperationResult, VetoType};
3use crate::parsing::ast::DateTimeValue;
4use crate::planning::semantics::{
5    range_element_type_specification, DataPath, LemmaType, RulePath, SemanticDateTime,
6    SemanticTime, Source, TypeSpecification, ValueKind,
7};
8use indexmap::IndexMap;
9use serde::Serialize;
10use std::collections::{BTreeMap, BTreeSet};
11use std::sync::Arc;
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/// Grouped data from a specific spec (semantics types only).
24#[derive(Debug, Clone, Serialize)]
25pub struct DataGroup {
26    pub data_path: String,
27    pub referencing_data_name: String,
28    pub data: Vec<crate::planning::semantics::Data>,
29}
30
31/// Calendar value on a rule result.
32#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
33pub struct CalendarResult {
34    pub value: String,
35    pub unit: String,
36}
37
38/// One endpoint of a range rule result.
39#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
40pub struct RuleResultPayload {
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub quantity: Option<BTreeMap<String, String>>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub ratio: Option<BTreeMap<String, String>>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub number: Option<String>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub boolean: Option<bool>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub text: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub date: Option<SemanticDateTime>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub time: Option<SemanticTime>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub calendar: Option<CalendarResult>,
57}
58
59/// Range rule result.
60#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
61pub struct RangeResult {
62    pub from: RuleResultPayload,
63    pub to: RuleResultPayload,
64}
65
66/// Response from evaluating a Lemma spec
67#[derive(Debug, Clone, Serialize)]
68pub struct Response {
69    #[serde(rename = "spec")]
70    pub spec_name: String,
71    pub effective: String,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub spec_hash: Option<String>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub spec_effective_from: Option<DateTimeValue>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub spec_effective_to: Option<DateTimeValue>,
78    #[serde(skip_serializing_if = "Vec::is_empty")]
79    pub data: Vec<DataGroup>,
80    pub results: IndexMap<String, RuleResult>,
81}
82
83/// Result of evaluating a single rule. Struct fields match the API JSON shape.
84#[derive(Debug, Clone, Serialize)]
85pub struct RuleResult {
86    #[serde(skip)]
87    pub rule: EvaluatedRule,
88    #[serde(skip)]
89    pub veto_detail: Option<VetoType>,
90
91    pub vetoed: bool,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub display: Option<String>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub veto_reason: Option<String>,
96    pub rule_type: String,
97
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub quantity: Option<BTreeMap<String, String>>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub ratio: Option<BTreeMap<String, String>>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub number: Option<String>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub boolean: Option<bool>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub text: Option<String>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub date: Option<SemanticDateTime>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub time: Option<SemanticTime>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub calendar: Option<CalendarResult>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub range: Option<RangeResult>,
116
117    #[serde(skip_serializing_if = "Option::is_none")]
118    #[serde(rename = "explanation")]
119    pub trace: Option<EvaluationTrace>,
120}
121
122impl RuleResult {
123    /// Materialize a rule evaluation result for the wire/API.
124    ///
125    /// `expression_units` is the plan's expression-scope index
126    /// ([`crate::planning::ExecutionPlan::expression_unit_index`]).
127    /// Declared units on `rule_type` are used first; the expression index covers compound signatures.
128    pub fn from_operation_result(
129        rule: EvaluatedRule,
130        operation_result: OperationResult,
131        rule_type: &LemmaType,
132        expression_units: &std::collections::HashMap<String, Arc<LemmaType>>,
133        trace: Option<EvaluationTrace>,
134    ) -> Self {
135        let rule_type_name = rule_type.name().to_string();
136        match operation_result {
137            OperationResult::Veto(veto) => Self {
138                rule,
139                veto_detail: Some(veto.clone()),
140                vetoed: true,
141                display: None,
142                veto_reason: Some(veto.to_string()),
143                rule_type: rule_type_name,
144                quantity: None,
145                ratio: None,
146                number: None,
147                boolean: None,
148                text: None,
149                date: None,
150                time: None,
151                calendar: None,
152                range: None,
153                trace,
154            },
155            OperationResult::Value(literal) => {
156                let mut result = Self {
157                    rule,
158                    veto_detail: None,
159                    vetoed: false,
160                    display: None,
161                    veto_reason: None,
162                    rule_type: rule_type_name,
163                    quantity: None,
164                    ratio: None,
165                    number: None,
166                    boolean: None,
167                    text: None,
168                    date: None,
169                    time: None,
170                    calendar: None,
171                    range: None,
172                    trace,
173                };
174                match &literal.value {
175                    ValueKind::Range(from, to) => {
176                        let endpoint_type = element_type_from_range_rule(rule_type)
177                            .unwrap_or_else(|| rule_type.clone());
178                        result.range = Some(RangeResult {
179                            from: materialize_payload(
180                                from,
181                                &endpoint_materialization_type(from, &endpoint_type),
182                                expression_units,
183                            ),
184                            to: materialize_payload(
185                                to,
186                                &endpoint_materialization_type(to, &endpoint_type),
187                                expression_units,
188                            ),
189                        });
190                        result.display = Some(literal.to_string());
191                    }
192                    _ => {
193                        let payload =
194                            materialize_payload(literal.as_ref(), rule_type, expression_units);
195                        result.quantity = payload.quantity;
196                        result.ratio = payload.ratio;
197                        result.number = payload.number;
198                        result.boolean = payload.boolean;
199                        result.text = payload.text;
200                        result.date = payload.date;
201                        result.time = payload.time;
202                        result.calendar = payload.calendar;
203                        result.display = Some(literal.to_string());
204                    }
205                }
206                result
207            }
208        }
209    }
210}
211
212fn element_type_from_range_rule(rule_type: &LemmaType) -> Option<LemmaType> {
213    range_element_type_specification(&rule_type.specifications).map(LemmaType::primitive)
214}
215
216fn endpoint_materialization_type(
217    endpoint: &crate::planning::semantics::LiteralValue,
218    range_element_type: &LemmaType,
219) -> LemmaType {
220    if endpoint.lemma_type.quantity_unit_names().is_some() {
221        endpoint.lemma_type.as_ref().clone()
222    } else {
223        range_element_type.clone()
224    }
225}
226
227fn materialize_payload(
228    literal: &crate::planning::semantics::LiteralValue,
229    result_type: &LemmaType,
230    _expression_units: &std::collections::HashMap<String, Arc<LemmaType>>,
231) -> RuleResultPayload {
232    match &literal.value {
233        ValueKind::Quantity(rational, sig) if literal.lemma_type.is_calendar_like() => {
234            let unit =
235                crate::planning::semantics::semantic_calendar_unit_from_quantity_signature(sig);
236            RuleResultPayload {
237                calendar: Some(CalendarResult {
238                    value: rational_to_wire_string(rational),
239                    unit: unit.to_string(),
240                }),
241                ..RuleResultPayload::default()
242            }
243        }
244        ValueKind::Quantity(_, _) => RuleResultPayload {
245            quantity: Some(quantity_to_unit_map(literal, result_type)),
246            ..RuleResultPayload::default()
247        },
248        ValueKind::Ratio(_, _) => RuleResultPayload {
249            ratio: Some(ratio_to_unit_map(literal, result_type)),
250            ..RuleResultPayload::default()
251        },
252        ValueKind::Number(rational) => RuleResultPayload {
253            number: Some(rational_to_wire_string(rational)),
254            ..RuleResultPayload::default()
255        },
256        ValueKind::Boolean(b) => RuleResultPayload {
257            boolean: Some(*b),
258            ..RuleResultPayload::default()
259        },
260        ValueKind::Text(s) => RuleResultPayload {
261            text: Some(s.clone()),
262            ..RuleResultPayload::default()
263        },
264        ValueKind::Date(d) => RuleResultPayload {
265            date: Some(d.clone()),
266            ..RuleResultPayload::default()
267        },
268        ValueKind::Time(t) => RuleResultPayload {
269            time: Some(t.clone()),
270            ..RuleResultPayload::default()
271        },
272        ValueKind::Range(_, _) => {
273            panic!("BUG: range payload must be built at RuleResult level, not RuleResultPayload")
274        }
275    }
276}
277
278fn rational_to_wire_string(rational: &crate::computation::rational::RationalInteger) -> String {
279    crate::literals::rational_to_serialized_str(rational)
280        .expect("BUG: rule result magnitude must serialize to decimal string")
281}
282
283fn quantity_to_unit_map(
284    literal: &crate::planning::semantics::LiteralValue,
285    result_type: &LemmaType,
286) -> BTreeMap<String, String> {
287    use crate::computation::rational::checked_div;
288
289    let unit_names = result_type
290        .quantity_unit_names()
291        .expect("BUG: rule result quantity must have declared units");
292    let ValueKind::Quantity(magnitude, _signature) = &literal.value else {
293        panic!("BUG: quantity_to_unit_map called with non-quantity value");
294    };
295    let mut map = BTreeMap::new();
296    for unit_name in unit_names {
297        let to_factor = result_type.quantity_unit_factor(unit_name);
298        let converted = checked_div(magnitude, to_factor).unwrap_or_else(|failure| {
299            panic!(
300                "BUG: quantity unit conversion to '{}' failed at rule result materialization: {}",
301                unit_name, failure
302            )
303        });
304        map.insert(unit_name.to_string(), rational_to_wire_string(&converted));
305    }
306    map
307}
308
309fn ratio_to_unit_map(
310    literal: &crate::planning::semantics::LiteralValue,
311    result_type: &LemmaType,
312) -> BTreeMap<String, String> {
313    use crate::computation::rational::checked_mul;
314
315    let units = match &result_type.specifications {
316        TypeSpecification::Ratio { units, .. } => units,
317        TypeSpecification::RatioRange { .. } => {
318            let element = range_element_type_specification(&result_type.specifications)
319                .expect("BUG: ratio range rule type must have ratio element specification");
320            let TypeSpecification::Ratio { units, .. } = element else {
321                panic!("BUG: ratio range element spec must be Ratio");
322            };
323            return ratio_to_unit_map(
324                literal,
325                &LemmaType::primitive(TypeSpecification::Ratio {
326                    minimum: None,
327                    maximum: None,
328                    decimals: None,
329                    units,
330                    help: String::new(),
331                }),
332            );
333        }
334        _ => {
335            panic!(
336                "BUG: ratio_to_unit_map called with non-ratio type {}",
337                result_type.name()
338            );
339        }
340    };
341    let ValueKind::Ratio(canonical, _) = &literal.value else {
342        panic!("BUG: ratio_to_unit_map called with non-ratio value");
343    };
344    if units.is_empty() {
345        panic!(
346            "BUG: rule result ratio type '{}' must have declared units",
347            result_type.name()
348        );
349    }
350    let mut map = BTreeMap::new();
351    for unit in units.iter() {
352        let display = checked_mul(canonical, &unit.value).unwrap_or_else(|failure| {
353            panic!(
354                "BUG: ratio unit conversion to '{}' failed at rule result materialization: {}",
355                unit.name, failure
356            )
357        });
358        map.insert(unit.name.clone(), rational_to_wire_string(&display));
359    }
360    map
361}
362
363impl Response {
364    /// Looks up a rule result by name.
365    ///
366    /// Returns an error if the rule is not found.
367    pub fn get(&self, rule_name: &str) -> Result<&RuleResult, crate::error::Error> {
368        self.results
369            .get(rule_name)
370            .ok_or_else(|| crate::error::Error::rule_not_found(rule_name, None::<String>))
371    }
372
373    pub fn add_result(&mut self, result: RuleResult) {
374        self.results.insert(result.rule.name.clone(), result);
375    }
376
377    pub fn filter_rules(&mut self, rule_names: &[String]) {
378        self.results.retain(|name, _| rule_names.contains(name));
379    }
380
381    /// All [`DataPath`]s reported as missing by any rule result (`VetoType::MissingData`).
382    #[must_use]
383    pub fn missing_data(&self) -> BTreeSet<DataPath> {
384        self.missing_data_ordered().into_iter().collect()
385    }
386
387    /// [`DataPath`]s with `MissingData` vetos, in **rule result order** (matches evaluation order),
388    /// first occurrence only.
389    #[must_use]
390    pub fn missing_data_ordered(&self) -> Vec<DataPath> {
391        let mut seen = std::collections::HashSet::new();
392        let mut out = Vec::new();
393        for rr in self.results.values() {
394            if let Some(VetoType::MissingData { data }) = &rr.veto_detail {
395                if seen.insert(data.clone()) {
396                    out.push(data.clone());
397                }
398            }
399        }
400        out
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::planning::semantics::{
408        primitive_boolean, primitive_number, BaseQuantityVector, LemmaType, LiteralValue,
409        QuantityUnit, QuantityUnits, RatioUnit, RatioUnits, RulePath, Span, TypeExtends,
410        TypeSpecification,
411    };
412    use rust_decimal::Decimal;
413    use std::collections::HashMap;
414    use std::sync::Arc;
415
416    fn dummy_source() -> Source {
417        Source::new(
418            crate::parsing::source::SourceType::Volatile,
419            Span {
420                start: 0,
421                end: 0,
422                line: 1,
423                col: 1,
424            },
425        )
426    }
427
428    fn dummy_evaluated_rule(name: &str) -> EvaluatedRule {
429        EvaluatedRule {
430            name: name.to_string(),
431            path: RulePath::new(vec![], name.to_string()),
432            source_location: dummy_source(),
433            rule_type: primitive_number().clone(),
434        }
435    }
436
437    #[test]
438    fn test_response_serialization() {
439        let mut results = IndexMap::new();
440        let expression_units = std::collections::HashMap::new();
441        results.insert(
442            "test_rule".to_string(),
443            RuleResult::from_operation_result(
444                dummy_evaluated_rule("test_rule"),
445                OperationResult::Value(Box::new(LiteralValue::number_from_decimal(Decimal::from(
446                    42,
447                )))),
448                primitive_number(),
449                &expression_units,
450                None,
451            ),
452        );
453        let response = Response {
454            spec_name: "test_spec".to_string(),
455            effective: "2026-01-01".to_string(),
456            spec_hash: None,
457            spec_effective_from: None,
458            spec_effective_to: None,
459            data: vec![],
460            results,
461        };
462
463        let json = serde_json::to_string(&response).unwrap();
464        assert!(json.contains("test_spec"));
465        assert!(json.contains("test_rule"));
466        assert!(json.contains("\"number\":\"42\""));
467        assert!(!json.contains("lemma_type"));
468    }
469
470    #[test]
471    fn response_number_json_never_uses_fraction_notation() {
472        use crate::computation::rational::{commit_rational_to_decimal, decimal_to_rational};
473
474        let rational = decimal_to_rational(Decimal::new(1, 1) / Decimal::new(3, 1)).unwrap();
475        let wire_number = commit_rational_to_decimal(&rational).unwrap().to_string();
476        let mut results = IndexMap::new();
477        results.insert(
478            "third".to_string(),
479            RuleResult::from_operation_result(
480                dummy_evaluated_rule("third"),
481                OperationResult::Value(Box::new(LiteralValue::number_from_decimal(
482                    commit_rational_to_decimal(&rational).unwrap(),
483                ))),
484                primitive_number(),
485                &std::collections::HashMap::new(),
486                None,
487            ),
488        );
489        // Override wire number field to match serialization path under test
490        if let Some(rule) = results.get_mut("third") {
491            rule.number = Some(wire_number.clone());
492            rule.display = Some(wire_number);
493        }
494
495        let response = Response {
496            spec_name: "test".to_string(),
497            effective: "test".to_string(),
498            spec_hash: None,
499            spec_effective_from: None,
500            spec_effective_to: None,
501            data: vec![],
502            results,
503        };
504
505        let json: serde_json::Value =
506            serde_json::from_str(&serde_json::to_string(&response).unwrap()).unwrap();
507        let number = json["results"]["third"]["number"]
508            .as_str()
509            .expect("number must be a JSON string");
510        assert!(
511            !number.contains('/'),
512            "wire number must not use fraction notation, got {number}"
513        );
514    }
515
516    #[test]
517    fn test_response_filter_rules() {
518        let mut results = IndexMap::new();
519        let expression_units = std::collections::HashMap::new();
520        results.insert(
521            "rule1".to_string(),
522            RuleResult::from_operation_result(
523                dummy_evaluated_rule("rule1"),
524                OperationResult::Value(Box::new(LiteralValue::from_bool(true))),
525                primitive_boolean(),
526                &expression_units,
527                None,
528            ),
529        );
530        results.insert(
531            "rule2".to_string(),
532            RuleResult::from_operation_result(
533                dummy_evaluated_rule("rule2"),
534                OperationResult::Value(Box::new(LiteralValue::from_bool(false))),
535                primitive_boolean(),
536                &expression_units,
537                None,
538            ),
539        );
540        let mut response = Response {
541            spec_name: "test_spec".to_string(),
542            effective: "2026-01-01".to_string(),
543            spec_hash: None,
544            spec_effective_from: None,
545            spec_effective_to: None,
546            data: vec![],
547            results,
548        };
549
550        response.filter_rules(&["rule1".to_string()]);
551
552        assert_eq!(response.results.len(), 1);
553        assert_eq!(response.results.values().next().unwrap().rule.name, "rule1");
554    }
555
556    #[test]
557    fn test_rule_result_veto() {
558        let expression_units = std::collections::HashMap::new();
559        let missing = RuleResult::from_operation_result(
560            dummy_evaluated_rule("rule3"),
561            OperationResult::Veto(VetoType::MissingData {
562                data: DataPath::new(vec![], "data1".to_string()),
563            }),
564            &LemmaType::veto_type(),
565            &expression_units,
566            None,
567        );
568        assert!(missing.vetoed);
569        assert!(missing.veto_reason.as_ref().unwrap().contains("data1"));
570
571        let veto = RuleResult::from_operation_result(
572            dummy_evaluated_rule("rule4"),
573            OperationResult::Veto(VetoType::UserDefined {
574                message: Some("Vetoed".to_string()),
575            }),
576            &LemmaType::veto_type(),
577            &expression_units,
578            None,
579        );
580        assert_eq!(veto.veto_reason.as_deref(), Some("Vetoed"));
581    }
582
583    fn test_money_type() -> LemmaType {
584        LemmaType::new(
585            "money".to_string(),
586            TypeSpecification::Quantity {
587                minimum: None,
588                maximum: None,
589                decimals: Some(2),
590                units: QuantityUnits::from(vec![
591                    QuantityUnit {
592                        name: "eur".to_string(),
593                        factor: crate::computation::rational::rational_one(),
594                        derived_quantity_factors: Vec::new(),
595                        decomposition: BaseQuantityVector::new(),
596                        minimum: None,
597                        maximum: None,
598                        default_magnitude: None,
599                    },
600                    QuantityUnit {
601                        name: "usd".to_string(),
602                        factor: crate::computation::rational::decimal_to_rational(Decimal::new(
603                            91, 2,
604                        ))
605                        .expect("factor"),
606                        derived_quantity_factors: Vec::new(),
607                        decomposition: BaseQuantityVector::new(),
608                        minimum: None,
609                        maximum: None,
610                        default_magnitude: None,
611                    },
612                ]),
613                traits: Vec::new(),
614                decomposition: Some(BaseQuantityVector::new()),
615                help: String::new(),
616            },
617            TypeExtends::Primitive,
618        )
619    }
620
621    #[test]
622    fn quantity_materialization_uses_rule_type_when_expression_index_empty() {
623        let money = test_money_type();
624        let ten_usd = LiteralValue {
625            value: ValueKind::Quantity(
626                crate::computation::rational::checked_mul(
627                    &crate::computation::rational::decimal_to_rational(Decimal::from(10))
628                        .expect("ten"),
629                    &crate::computation::rational::decimal_to_rational(Decimal::new(91, 2))
630                        .expect("usd factor"),
631                )
632                .expect("canonical usd"),
633                vec![("usd".to_string(), 1)],
634            ),
635            lemma_type: Arc::new(money.clone()),
636        };
637        let expression_units = HashMap::new();
638        let result = RuleResult::from_operation_result(
639            dummy_evaluated_rule("total"),
640            OperationResult::Value(Box::new(ten_usd)),
641            &money,
642            &expression_units,
643            None,
644        );
645        let quantity = result.quantity.expect("quantity map");
646        assert_eq!(quantity.get("usd"), Some(&"10".to_string()));
647        assert!(quantity.contains_key("eur"));
648    }
649
650    #[test]
651    fn test_quantity_materialization_multi_unit() {
652        let money = test_money_type();
653        let expression_units = HashMap::new();
654        let ten_eur = LiteralValue {
655            value: ValueKind::Quantity(
656                crate::computation::rational::decimal_to_rational(Decimal::from(10)).expect("ten"),
657                vec![],
658            ),
659            lemma_type: Arc::new(money.clone()),
660        };
661        let result = RuleResult::from_operation_result(
662            dummy_evaluated_rule("total"),
663            OperationResult::Value(Box::new(ten_eur)),
664            &money,
665            &expression_units,
666            None,
667        );
668        let quantity = result.quantity.expect("quantity map");
669        assert_eq!(quantity.get("eur"), Some(&"10".to_string()));
670        assert!(quantity.contains_key("usd"));
671        assert!(quantity["usd"].starts_with("10.9"));
672    }
673
674    #[test]
675    fn test_ratio_materialization_multi_unit() {
676        let ratio_type = LemmaType::new(
677            "rate".to_string(),
678            TypeSpecification::Ratio {
679                minimum: None,
680                maximum: None,
681                decimals: None,
682                units: RatioUnits::from(vec![
683                    RatioUnit {
684                        name: "percent".to_string(),
685                        value: crate::computation::rational::decimal_to_rational(Decimal::from(
686                            100,
687                        ))
688                        .expect("percent"),
689                        minimum: None,
690                        maximum: None,
691                        default_magnitude: None,
692                    },
693                    RatioUnit {
694                        name: "basis_points".to_string(),
695                        value: crate::computation::rational::decimal_to_rational(Decimal::from(
696                            10_000,
697                        ))
698                        .expect("bp"),
699                        minimum: None,
700                        maximum: None,
701                        default_magnitude: None,
702                    },
703                ]),
704                help: String::new(),
705            },
706            TypeExtends::Primitive,
707        );
708        let expression_units = HashMap::new();
709        let half = crate::computation::rational::RationalInteger::new(1, 2);
710        let lit = LiteralValue {
711            value: ValueKind::Ratio(half, Some("percent".to_string())),
712            lemma_type: Arc::new(ratio_type.clone()),
713        };
714        let result = RuleResult::from_operation_result(
715            dummy_evaluated_rule("rate_out"),
716            OperationResult::Value(Box::new(lit)),
717            &ratio_type,
718            &expression_units,
719            None,
720        );
721        let ratio = result.ratio.expect("ratio map");
722        assert_eq!(ratio.get("percent"), Some(&"50".to_string()));
723        assert_eq!(ratio.get("basis_points"), Some(&"5000".to_string()));
724    }
725
726    #[test]
727    fn test_quantity_materialization_cross_spec_import() {
728        use crate::parsing::source::SourceType;
729        use crate::Engine;
730
731        let mut engine = Engine::new();
732        engine
733            .load(
734                r#"
735spec consumer 2025-01-01
736uses d: dep 2025-10-01
737rule out: d.doubled
738
739spec dep 2025-01-01
740uses c: child 2025-06-01
741data money: c.money
742data p: 5 usd
743rule doubled: p * 2
744
745spec child 2025-01-01
746data money: quantity
747 -> unit eur 1.00
748 -> decimals 2
749
750spec child 2025-06-01
751data money: quantity
752 -> unit eur 1.00
753 -> unit usd 0.91
754 -> decimals 2
755"#,
756                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("t.lemma"))),
757            )
758            .expect("load");
759        let effective = crate::literals::DateTimeValue {
760            year: 2025,
761            month: 3,
762            day: 1,
763            hour: 0,
764            minute: 0,
765            second: 0,
766            microsecond: 0,
767            timezone: None,
768        };
769        let response = engine
770            .run(
771                None,
772                "consumer",
773                Some(&effective),
774                std::collections::HashMap::new(),
775                false,
776            )
777            .expect("run");
778        let out = response.results.get("out").expect("out rule");
779        assert!(!out.vetoed);
780        let quantity = out.quantity.as_ref().expect("quantity map");
781        assert!(quantity.contains_key("usd"));
782        assert!(quantity.contains_key("eur"));
783    }
784}