Skip to main content

lemma/evaluation/
response.rs

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