Skip to main content

lemma/evaluation/
response.rs

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