Skip to main content

lemma/evaluation/
response.rs

1use crate::evaluation::explanations::Explanation;
2use crate::evaluation::operations::{OperationResult, VetoType};
3use crate::parsing::ast::DateTimeValue;
4use crate::planning::semantics::{
5    range_element_type_specification, DataPath, LemmaType, LiteralValue, RulePath,
6    SemanticDateTime, 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    pub data: Vec<DataGroup>,
79    pub results: IndexMap<String, RuleResult>,
80}
81
82/// Result of evaluating a single rule. Struct fields match the API JSON shape.
83#[derive(Debug, Clone, Serialize)]
84pub struct RuleResult {
85    #[serde(skip)]
86    pub rule: EvaluatedRule,
87    #[serde(skip)]
88    pub veto_detail: Option<VetoType>,
89
90    pub vetoed: bool,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub display: Option<String>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub veto_reason: Option<String>,
95    pub rule_type: String,
96
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub quantity: Option<BTreeMap<String, String>>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub ratio: Option<BTreeMap<String, String>>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub number: Option<String>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub boolean: Option<bool>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub text: Option<String>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub date: Option<SemanticDateTime>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub time: Option<SemanticTime>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub calendar: Option<CalendarResult>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub range: Option<RangeResult>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub explanation: Option<Explanation>,
117}
118
119impl RuleResult {
120    /// Materialize a rule evaluation result for API output.
121    ///
122    /// `expression_units` is the plan's expression-scope index
123    /// ([`crate::planning::ExecutionPlan::expression_unit_index`]).
124    /// Declared units on `rule_type` are used first; the expression index covers compound signatures.
125    pub fn from_operation_result(
126        rule: EvaluatedRule,
127        operation_result: OperationResult,
128        rule_type: &LemmaType,
129        expression_units: &std::collections::HashMap<String, Arc<LemmaType>>,
130        explanation: Option<Explanation>,
131    ) -> Self {
132        let rule_type_name = rule_type.name().to_string();
133        match operation_result {
134            OperationResult::Veto(veto) => Self {
135                rule,
136                veto_detail: Some(veto.clone()),
137                vetoed: true,
138                display: None,
139                veto_reason: match &veto {
140                    VetoType::UserDefined { message: None } => None,
141                    _ => Some(veto.to_string()),
142                },
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                explanation,
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                    explanation,
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 = materialize_payload(&literal, rule_type, expression_units);
194                        result.quantity = payload.quantity;
195                        result.ratio = payload.ratio;
196                        result.number = payload.number;
197                        result.boolean = payload.boolean;
198                        result.text = payload.text;
199                        result.date = payload.date;
200                        result.time = payload.time;
201                        result.calendar = payload.calendar;
202                        result.display = Some(literal.to_string());
203                    }
204                }
205                result
206            }
207        }
208    }
209
210    /// Reconstruct the evaluated [`LiteralValue`] from committed materialized fields.
211    ///
212    /// Panics if the rule is vetoed or materialized fields cannot be reconstructed.
213    pub fn materialized_literal(&self) -> LiteralValue {
214        assert!(
215            !self.vetoed,
216            "BUG: materialized_literal called on vetoed rule '{}'",
217            self.rule.name
218        );
219        let rule_type = Arc::new(self.rule.rule_type.clone());
220
221        if let Some(b) = self.boolean {
222            return LiteralValue {
223                value: ValueKind::Boolean(b),
224                lemma_type: rule_type,
225            };
226        }
227        if let Some(number) = &self.number {
228            return LiteralValue::number_with_type_from_decimal(
229                decimal_from_materialized_string(number),
230                rule_type,
231            );
232        }
233        if let Some(calendar) = &self.calendar {
234            use crate::literals::rational_from_parsed_decimal;
235            let rational =
236                rational_from_parsed_decimal(decimal_from_materialized_string(&calendar.value))
237                    .expect("BUG: calendar rule result value must lift to rational");
238            return LiteralValue::quantity_with_type(rational, calendar.unit.clone(), rule_type);
239        }
240        if let Some(quantity) = &self.quantity {
241            return literal_from_quantity_map(quantity, &rule_type);
242        }
243        if let Some(ratio) = &self.ratio {
244            return literal_from_ratio_map(ratio, &rule_type);
245        }
246        if let Some(date) = &self.date {
247            return LiteralValue {
248                value: ValueKind::Date(date.clone()),
249                lemma_type: rule_type,
250            };
251        }
252        if let Some(time) = &self.time {
253            return LiteralValue {
254                value: ValueKind::Time(time.clone()),
255                lemma_type: rule_type,
256            };
257        }
258        if let Some(text) = &self.text {
259            return LiteralValue {
260                value: ValueKind::Text(text.clone()),
261                lemma_type: rule_type,
262            };
263        }
264        if let Some(range) = &self.range {
265            let endpoint_type = element_type_from_range_rule(&rule_type)
266                .unwrap_or_else(|| rule_type.as_ref().clone());
267            let left = payload_to_literal(&range.from, &endpoint_type);
268            let right = payload_to_literal(&range.to, &endpoint_type);
269            return LiteralValue::range(left, right);
270        }
271        panic!(
272            "BUG: rule '{}' materialized fields cannot reconstruct literal",
273            self.rule.name
274        );
275    }
276}
277
278fn decimal_from_materialized_string(value: &str) -> rust_decimal::Decimal {
279    use rust_decimal::Decimal;
280    use std::str::FromStr;
281    Decimal::from_str(value)
282        .unwrap_or_else(|_| panic!("BUG: rule result materialized string must parse as decimal"))
283}
284
285fn literal_from_quantity_map(
286    quantity: &BTreeMap<String, String>,
287    rule_type: &LemmaType,
288) -> LiteralValue {
289    use crate::computation::rational::checked_mul;
290    use crate::literals::rational_from_parsed_decimal;
291
292    let unit_names = rule_type
293        .quantity_unit_names()
294        .expect("BUG: quantity rule result must have declared units");
295    let unit_name = unit_names
296        .first()
297        .expect("BUG: quantity rule result type must declare at least one unit");
298    let display = quantity
299        .get(*unit_name)
300        .unwrap_or_else(|| panic!("BUG: quantity map missing unit '{unit_name}'"));
301    let rational = rational_from_parsed_decimal(decimal_from_materialized_string(display))
302        .expect("BUG: quantity rule result value must lift to rational");
303    let factor = rule_type.quantity_unit_factor(unit_name);
304    let canonical = checked_mul(&rational, factor).unwrap_or_else(|failure| {
305        panic!("BUG: quantity canonicalization from materialized fields failed: {failure}")
306    });
307    LiteralValue::quantity_with_type(
308        canonical,
309        (*unit_name).to_string(),
310        Arc::new(rule_type.clone()),
311    )
312}
313
314fn literal_from_ratio_map(ratio: &BTreeMap<String, String>, rule_type: &LemmaType) -> LiteralValue {
315    use crate::computation::rational::checked_div;
316    use crate::literals::rational_from_parsed_decimal;
317
318    let units = match &rule_type.specifications {
319        TypeSpecification::Ratio { units, .. } => units,
320        TypeSpecification::RatioRange { .. } => {
321            let element = range_element_type_specification(&rule_type.specifications)
322                .expect("BUG: ratio range rule type must have ratio element specification");
323            let TypeSpecification::Ratio { units, .. } = element else {
324                panic!("BUG: ratio range element spec must be Ratio");
325            };
326            return literal_from_ratio_map(
327                ratio,
328                &LemmaType::primitive(TypeSpecification::Ratio {
329                    minimum: None,
330                    maximum: None,
331                    decimals: None,
332                    units,
333                    help: String::new(),
334                }),
335            );
336        }
337        _ => panic!(
338            "BUG: ratio rule result type must be Ratio, got {}",
339            rule_type.name()
340        ),
341    };
342    let unit = units
343        .iter()
344        .next()
345        .expect("BUG: ratio rule result type must declare at least one unit");
346    let display = ratio
347        .get(&unit.name)
348        .unwrap_or_else(|| panic!("BUG: ratio map missing unit '{}'", unit.name));
349    let display_rational = rational_from_parsed_decimal(decimal_from_materialized_string(display))
350        .expect("BUG: ratio rule result value must lift to rational");
351    let canonical = checked_div(&display_rational, &unit.value).unwrap_or_else(|failure| {
352        panic!("BUG: ratio canonicalization from materialized fields failed: {failure}")
353    });
354    LiteralValue::ratio_with_type(canonical, None, Arc::new(rule_type.clone()))
355}
356
357fn payload_to_literal(payload: &RuleResultPayload, rule_type: &LemmaType) -> LiteralValue {
358    if let Some(b) = payload.boolean {
359        return LiteralValue {
360            value: ValueKind::Boolean(b),
361            lemma_type: Arc::new(rule_type.clone()),
362        };
363    }
364    if let Some(number) = &payload.number {
365        return LiteralValue::number_with_type_from_decimal(
366            decimal_from_materialized_string(number),
367            Arc::new(rule_type.clone()),
368        );
369    }
370    if let Some(calendar) = &payload.calendar {
371        use crate::literals::rational_from_parsed_decimal;
372        let rational =
373            rational_from_parsed_decimal(decimal_from_materialized_string(&calendar.value))
374                .expect("BUG: calendar payload value must lift to rational");
375        return LiteralValue::quantity_with_type(
376            rational,
377            calendar.unit.clone(),
378            Arc::new(rule_type.clone()),
379        );
380    }
381    if let Some(quantity) = &payload.quantity {
382        return literal_from_quantity_map(quantity, rule_type);
383    }
384    if let Some(ratio) = &payload.ratio {
385        return literal_from_ratio_map(ratio, rule_type);
386    }
387    if let Some(date) = &payload.date {
388        return LiteralValue {
389            value: ValueKind::Date(date.clone()),
390            lemma_type: Arc::new(rule_type.clone()),
391        };
392    }
393    if let Some(time) = &payload.time {
394        return LiteralValue {
395            value: ValueKind::Time(time.clone()),
396            lemma_type: Arc::new(rule_type.clone()),
397        };
398    }
399    if let Some(text) = &payload.text {
400        return LiteralValue {
401            value: ValueKind::Text(text.clone()),
402            lemma_type: Arc::new(rule_type.clone()),
403        };
404    }
405    panic!("BUG: range endpoint payload cannot reconstruct literal");
406}
407
408fn element_type_from_range_rule(rule_type: &LemmaType) -> Option<LemmaType> {
409    range_element_type_specification(&rule_type.specifications).map(LemmaType::primitive)
410}
411
412fn endpoint_materialization_type(
413    endpoint: &crate::planning::semantics::LiteralValue,
414    range_element_type: &LemmaType,
415) -> LemmaType {
416    if endpoint.lemma_type.quantity_unit_names().is_some() {
417        endpoint.lemma_type.as_ref().clone()
418    } else {
419        range_element_type.clone()
420    }
421}
422
423fn materialize_payload(
424    literal: &crate::planning::semantics::LiteralValue,
425    result_type: &LemmaType,
426    _expression_units: &std::collections::HashMap<String, Arc<LemmaType>>,
427) -> RuleResultPayload {
428    match &literal.value {
429        ValueKind::Quantity(rational, sig) if literal.lemma_type.is_calendar_like() => {
430            let unit =
431                crate::planning::semantics::semantic_calendar_unit_from_quantity_signature(sig);
432            RuleResultPayload {
433                calendar: Some(CalendarResult {
434                    value: rational_to_decimal_string(rational),
435                    unit: unit.to_string(),
436                }),
437                ..RuleResultPayload::default()
438            }
439        }
440        ValueKind::Quantity(_, _) => RuleResultPayload {
441            quantity: Some(quantity_to_unit_map(literal, result_type)),
442            ..RuleResultPayload::default()
443        },
444        ValueKind::Ratio(_, _) => RuleResultPayload {
445            ratio: Some(ratio_to_unit_map(literal, result_type)),
446            ..RuleResultPayload::default()
447        },
448        ValueKind::Number(rational) => RuleResultPayload {
449            number: Some(rational_to_decimal_string(rational)),
450            ..RuleResultPayload::default()
451        },
452        ValueKind::Boolean(b) => RuleResultPayload {
453            boolean: Some(*b),
454            ..RuleResultPayload::default()
455        },
456        ValueKind::Text(s) => RuleResultPayload {
457            text: Some(s.clone()),
458            ..RuleResultPayload::default()
459        },
460        ValueKind::Date(d) => RuleResultPayload {
461            date: Some(d.clone()),
462            ..RuleResultPayload::default()
463        },
464        ValueKind::Time(t) => RuleResultPayload {
465            time: Some(t.clone()),
466            ..RuleResultPayload::default()
467        },
468        ValueKind::Range(_, _) => {
469            panic!("BUG: range payload must be built at RuleResult level, not RuleResultPayload")
470        }
471    }
472}
473
474fn rational_to_decimal_string(rational: &crate::computation::rational::RationalInteger) -> String {
475    crate::literals::rational_to_serialized_str(rational)
476        .expect("BUG: rule result magnitude must serialize to decimal string")
477}
478
479fn quantity_to_unit_map(
480    literal: &crate::planning::semantics::LiteralValue,
481    result_type: &LemmaType,
482) -> BTreeMap<String, String> {
483    use crate::computation::rational::checked_div;
484
485    let unit_names = result_type
486        .quantity_unit_names()
487        .expect("BUG: rule result quantity must have declared units");
488    let ValueKind::Quantity(magnitude, _signature) = &literal.value else {
489        panic!("BUG: quantity_to_unit_map called with non-quantity value");
490    };
491    let mut map = BTreeMap::new();
492    for unit_name in unit_names {
493        let to_factor = result_type.quantity_unit_factor(unit_name);
494        let converted = checked_div(magnitude, to_factor).unwrap_or_else(|failure| {
495            panic!(
496                "BUG: quantity unit conversion to '{}' failed at rule result materialization: {}",
497                unit_name, failure
498            )
499        });
500        map.insert(
501            unit_name.to_string(),
502            rational_to_decimal_string(&converted),
503        );
504    }
505    map
506}
507
508fn ratio_to_unit_map(
509    literal: &crate::planning::semantics::LiteralValue,
510    result_type: &LemmaType,
511) -> BTreeMap<String, String> {
512    use crate::computation::rational::checked_mul;
513
514    let units = match &result_type.specifications {
515        TypeSpecification::Ratio { units, .. } => units,
516        TypeSpecification::RatioRange { .. } => {
517            let element = range_element_type_specification(&result_type.specifications)
518                .expect("BUG: ratio range rule type must have ratio element specification");
519            let TypeSpecification::Ratio { units, .. } = element else {
520                panic!("BUG: ratio range element spec must be Ratio");
521            };
522            return ratio_to_unit_map(
523                literal,
524                &LemmaType::primitive(TypeSpecification::Ratio {
525                    minimum: None,
526                    maximum: None,
527                    decimals: None,
528                    units,
529                    help: String::new(),
530                }),
531            );
532        }
533        _ => {
534            panic!(
535                "BUG: ratio_to_unit_map called with non-ratio type {}",
536                result_type.name()
537            );
538        }
539    };
540    let ValueKind::Ratio(canonical, _) = &literal.value else {
541        panic!("BUG: ratio_to_unit_map called with non-ratio value");
542    };
543    if units.is_empty() {
544        panic!(
545            "BUG: rule result ratio type '{}' must have declared units",
546            result_type.name()
547        );
548    }
549    let mut map = BTreeMap::new();
550    for unit in units.iter() {
551        let display = checked_mul(canonical, &unit.value).unwrap_or_else(|failure| {
552            panic!(
553                "BUG: ratio unit conversion to '{}' failed at rule result materialization: {}",
554                unit.name, failure
555            )
556        });
557        map.insert(unit.name.clone(), rational_to_decimal_string(&display));
558    }
559    map
560}
561
562impl Response {
563    /// Looks up a rule result by name.
564    ///
565    /// Returns an error if the rule is not found.
566    pub fn get(&self, rule_name: &str) -> Result<&RuleResult, crate::error::Error> {
567        self.results
568            .get(rule_name)
569            .ok_or_else(|| crate::error::Error::rule_not_found(rule_name, None::<String>))
570    }
571
572    pub fn add_result(&mut self, result: RuleResult) {
573        self.results.insert(result.rule.name.clone(), result);
574    }
575
576    /// All [`DataPath`]s reported as missing by any rule result (`VetoType::MissingData`).
577    #[must_use]
578    pub fn missing_data(&self) -> BTreeSet<DataPath> {
579        self.missing_data_ordered().into_iter().collect()
580    }
581
582    /// [`DataPath`]s with `MissingData` vetos, in **rule result order** (matches evaluation order),
583    /// first occurrence only.
584    #[must_use]
585    pub fn missing_data_ordered(&self) -> Vec<DataPath> {
586        let mut seen = std::collections::HashSet::new();
587        let mut out = Vec::new();
588        for rr in self.results.values() {
589            if let Some(VetoType::MissingData { data }) = &rr.veto_detail {
590                if seen.insert(data.clone()) {
591                    out.push(data.clone());
592                }
593            }
594        }
595        out
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use crate::literals::DateGranularity;
603    use crate::planning::semantics::{
604        primitive_number, BaseQuantityVector, LemmaType, LiteralValue, QuantityUnit, QuantityUnits,
605        RatioUnit, RatioUnits, RulePath, Span, TypeExtends, TypeSpecification,
606    };
607    use rust_decimal::Decimal;
608    use std::collections::HashMap;
609    use std::sync::Arc;
610
611    fn dummy_source() -> Source {
612        Source::new(
613            crate::parsing::source::SourceType::Volatile,
614            Span {
615                start: 0,
616                end: 0,
617                line: 1,
618                col: 1,
619            },
620        )
621    }
622
623    fn dummy_evaluated_rule(name: &str, rule_type: &LemmaType) -> EvaluatedRule {
624        EvaluatedRule {
625            name: name.to_string(),
626            path: RulePath::new(vec![], name.to_string()),
627            source_location: dummy_source(),
628            rule_type: rule_type.clone(),
629        }
630    }
631
632    #[test]
633    fn test_response_serialization() {
634        let mut results = IndexMap::new();
635        let expression_units = std::collections::HashMap::new();
636        results.insert(
637            "test_rule".to_string(),
638            RuleResult::from_operation_result(
639                dummy_evaluated_rule("test_rule", primitive_number()),
640                OperationResult::Value(LiteralValue::number_from_decimal(Decimal::from(42))),
641                primitive_number(),
642                &expression_units,
643                None,
644            ),
645        );
646        let response = Response {
647            spec_name: "test_spec".to_string(),
648            effective: "2026-01-01".to_string(),
649            spec_hash: None,
650            spec_effective_from: None,
651            spec_effective_to: None,
652            data: vec![],
653            results,
654        };
655
656        let json = serde_json::to_string(&response).unwrap();
657        assert!(json.contains("test_spec"));
658        assert!(json.contains("test_rule"));
659        assert!(json.contains("\"number\":\"42\""));
660        assert!(!json.contains("lemma_type"));
661    }
662
663    #[test]
664    fn response_number_json_never_uses_fraction_notation() {
665        use crate::computation::rational::{commit_rational_to_decimal, decimal_to_rational};
666
667        let rational = decimal_to_rational(Decimal::new(1, 1) / Decimal::new(3, 1)).unwrap();
668        let decimal_string = commit_rational_to_decimal(&rational).unwrap().to_string();
669        let mut results = IndexMap::new();
670        results.insert(
671            "third".to_string(),
672            RuleResult::from_operation_result(
673                dummy_evaluated_rule("third", primitive_number()),
674                OperationResult::Value(LiteralValue::number_from_decimal(
675                    commit_rational_to_decimal(&rational).unwrap(),
676                )),
677                primitive_number(),
678                &std::collections::HashMap::new(),
679                None,
680            ),
681        );
682        // Override committed decimal number field to match serialization path under test
683        if let Some(rule) = results.get_mut("third") {
684            rule.number = Some(decimal_string.clone());
685            rule.display = Some(decimal_string);
686        }
687
688        let response = Response {
689            spec_name: "test".to_string(),
690            effective: "test".to_string(),
691            spec_hash: None,
692            spec_effective_from: None,
693            spec_effective_to: None,
694            data: vec![],
695            results,
696        };
697
698        let json: serde_json::Value =
699            serde_json::from_str(&serde_json::to_string(&response).unwrap()).unwrap();
700        let number = json["results"]["third"]["number"]
701            .as_str()
702            .expect("number must be a JSON string");
703        assert!(
704            !number.contains('/'),
705            "API decimal string must not use fraction notation, got {number}"
706        );
707    }
708
709    #[test]
710    fn test_rule_result_veto() {
711        let expression_units = std::collections::HashMap::new();
712        let missing = RuleResult::from_operation_result(
713            dummy_evaluated_rule("rule3", &LemmaType::veto_type()),
714            OperationResult::Veto(VetoType::MissingData {
715                data: DataPath::new(vec![], "data1".to_string()),
716            }),
717            &LemmaType::veto_type(),
718            &expression_units,
719            None,
720        );
721        assert!(missing.vetoed);
722        assert!(missing.veto_reason.as_ref().unwrap().contains("data1"));
723
724        let veto = RuleResult::from_operation_result(
725            dummy_evaluated_rule("rule4", &LemmaType::veto_type()),
726            OperationResult::Veto(VetoType::UserDefined {
727                message: Some("Vetoed".to_string()),
728            }),
729            &LemmaType::veto_type(),
730            &expression_units,
731            None,
732        );
733        assert_eq!(veto.veto_reason.as_deref(), Some("Vetoed"));
734    }
735
736    fn test_money_type() -> LemmaType {
737        LemmaType::new(
738            "money".to_string(),
739            TypeSpecification::Quantity {
740                minimum: None,
741                maximum: None,
742                decimals: Some(2),
743                units: QuantityUnits::from(vec![
744                    QuantityUnit {
745                        name: "eur".to_string(),
746                        factor: crate::computation::rational::rational_one(),
747                        derived_quantity_factors: Vec::new(),
748                        decomposition: BaseQuantityVector::new(),
749                        minimum: None,
750                        maximum: None,
751                        default_magnitude: None,
752                    },
753                    QuantityUnit {
754                        name: "usd".to_string(),
755                        factor: crate::computation::rational::decimal_to_rational(Decimal::new(
756                            91, 2,
757                        ))
758                        .expect("factor"),
759                        derived_quantity_factors: Vec::new(),
760                        decomposition: BaseQuantityVector::new(),
761                        minimum: None,
762                        maximum: None,
763                        default_magnitude: None,
764                    },
765                ]),
766                traits: Vec::new(),
767                decomposition: Some(BaseQuantityVector::new()),
768                help: String::new(),
769            },
770            TypeExtends::Primitive,
771        )
772    }
773
774    #[test]
775    fn quantity_materialization_uses_rule_type_when_expression_index_empty() {
776        let money = test_money_type();
777        let ten_usd = LiteralValue {
778            value: ValueKind::Quantity(
779                crate::computation::rational::checked_mul(
780                    &crate::computation::rational::decimal_to_rational(Decimal::from(10))
781                        .expect("ten"),
782                    &crate::computation::rational::decimal_to_rational(Decimal::new(91, 2))
783                        .expect("usd factor"),
784                )
785                .expect("canonical usd"),
786                vec![("usd".to_string(), 1)],
787            ),
788            lemma_type: Arc::new(money.clone()),
789        };
790        let expression_units = HashMap::new();
791        let result = RuleResult::from_operation_result(
792            dummy_evaluated_rule("total", &money),
793            OperationResult::Value(ten_usd),
794            &money,
795            &expression_units,
796            None,
797        );
798        let quantity = result.quantity.expect("quantity map");
799        assert_eq!(quantity.get("usd"), Some(&"10".to_string()));
800        assert!(quantity.contains_key("eur"));
801    }
802
803    #[test]
804    fn test_quantity_materialization_multi_unit() {
805        let money = test_money_type();
806        let expression_units = HashMap::new();
807        let ten_eur = LiteralValue {
808            value: ValueKind::Quantity(
809                crate::computation::rational::decimal_to_rational(Decimal::from(10)).expect("ten"),
810                vec![],
811            ),
812            lemma_type: Arc::new(money.clone()),
813        };
814        let result = RuleResult::from_operation_result(
815            dummy_evaluated_rule("total", &money),
816            OperationResult::Value(ten_eur),
817            &money,
818            &expression_units,
819            None,
820        );
821        let quantity = result.quantity.expect("quantity map");
822        assert_eq!(quantity.get("eur"), Some(&"10".to_string()));
823        assert!(quantity.contains_key("usd"));
824        assert!(quantity["usd"].starts_with("10.9"));
825    }
826
827    #[test]
828    fn test_ratio_materialization_multi_unit() {
829        let ratio_type = LemmaType::new(
830            "rate".to_string(),
831            TypeSpecification::Ratio {
832                minimum: None,
833                maximum: None,
834                decimals: None,
835                units: RatioUnits::from(vec![
836                    RatioUnit {
837                        name: "percent".to_string(),
838                        value: crate::computation::rational::decimal_to_rational(Decimal::from(
839                            100,
840                        ))
841                        .expect("percent"),
842                        minimum: None,
843                        maximum: None,
844                        default_magnitude: None,
845                    },
846                    RatioUnit {
847                        name: "basis_points".to_string(),
848                        value: crate::computation::rational::decimal_to_rational(Decimal::from(
849                            10_000,
850                        ))
851                        .expect("bp"),
852                        minimum: None,
853                        maximum: None,
854                        default_magnitude: None,
855                    },
856                ]),
857                help: String::new(),
858            },
859            TypeExtends::Primitive,
860        );
861        let expression_units = HashMap::new();
862        let half = crate::computation::rational::rational_new(1, 2);
863        let lit = LiteralValue {
864            value: ValueKind::Ratio(half, Some("percent".to_string())),
865            lemma_type: Arc::new(ratio_type.clone()),
866        };
867        let result = RuleResult::from_operation_result(
868            dummy_evaluated_rule("rate_out", &ratio_type),
869            OperationResult::Value(lit),
870            &ratio_type,
871            &expression_units,
872            None,
873        );
874        let ratio = result.ratio.expect("ratio map");
875        assert_eq!(ratio.get("percent"), Some(&"50".to_string()));
876        assert_eq!(ratio.get("basis_points"), Some(&"5000".to_string()));
877    }
878
879    #[test]
880    fn test_quantity_materialization_cross_spec_import() {
881        use crate::parsing::source::SourceType;
882        use crate::Engine;
883
884        let mut engine = Engine::new();
885        engine
886            .load(
887                r#"
888spec consumer 2025-01-01
889uses d: dep 2025-10-01
890rule out: d.doubled
891
892spec dep 2025-01-01
893uses c: child 2025-06-01
894data money: c.money
895data p: 5 usd
896rule doubled: p * 2
897
898spec child 2025-01-01
899data money: quantity
900 -> unit eur 1.00
901 -> decimals 2
902
903spec child 2025-06-01
904data money: quantity
905 -> unit eur 1.00
906 -> unit usd 0.91
907 -> decimals 2
908"#,
909                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("t.lemma"))),
910            )
911            .expect("load");
912        let effective = crate::literals::DateTimeValue {
913            year: 2025,
914            month: 3,
915            day: 1,
916            hour: 0,
917            minute: 0,
918            second: 0,
919            microsecond: 0,
920            timezone: None,
921
922            granularity: DateGranularity::Full,
923        };
924        let response = engine
925            .run(
926                None,
927                "consumer",
928                Some(&effective),
929                std::collections::HashMap::new(),
930                false,
931                None,
932            )
933            .expect("run");
934        let out = response.results.get("out").expect("out rule");
935        assert!(!out.vetoed);
936        let quantity = out.quantity.as_ref().expect("quantity map");
937        assert!(quantity.contains_key("usd"));
938        assert!(quantity.contains_key("eur"));
939    }
940
941    #[test]
942    fn materialized_literal_roundtrips_number() {
943        let expression_units = HashMap::new();
944        let literal = LiteralValue::number_from_decimal(Decimal::from(42));
945        let rule_result = RuleResult::from_operation_result(
946            dummy_evaluated_rule("answer", primitive_number()),
947            OperationResult::Value(literal.clone()),
948            primitive_number(),
949            &expression_units,
950            None,
951        );
952        assert_eq!(rule_result.materialized_literal(), literal);
953    }
954
955    #[test]
956    fn materialized_literal_roundtrips_quantity() {
957        let expression_units = HashMap::new();
958        let money = test_money_type();
959        let literal = LiteralValue::quantity_with_type(
960            crate::computation::rational::rational_new(60, 1),
961            "eur".into(),
962            Arc::new(money.clone()),
963        );
964        let rule_result = RuleResult::from_operation_result(
965            dummy_evaluated_rule("pay", &money),
966            OperationResult::Value(literal.clone()),
967            &money,
968            &expression_units,
969            None,
970        );
971        assert_eq!(rule_result.materialized_literal(), literal);
972    }
973}