Skip to main content

lemma/evaluation/
response.rs

1use crate::computation::{OperationResult, VetoType};
2use crate::evaluation::explanations::Explanation;
3
4use crate::parsing::ast::DateTimeValue;
5use crate::planning::execution_plan::ExecutionPlan;
6use crate::planning::semantics::{
7    DataPath, LemmaType, LiteralValue, PathSegment, RulePath, Source, SpecInstance, ValueKind,
8};
9use crate::planning::unit_family::FamilyUnitCatalog;
10use crate::result_value::{
11    rule_result_value_failure_message, rule_result_value_from_literal, RuleResultValue,
12    RuleResultValueFailure, SpecResult, SpecRuleResult,
13};
14use indexmap::IndexMap;
15use std::collections::BTreeMap;
16use std::sync::Arc;
17
18/// Rule info with resolved expressions for use in evaluation response.
19/// Evaluation uses only semantics types; no parsing types.
20#[derive(Debug, Clone)]
21pub struct EvaluatedRule {
22    pub name: String,
23    pub path: RulePath,
24    pub source_location: Source,
25    pub rule_type: Arc<LemmaType>,
26}
27
28/// Response from evaluating a Lemma spec
29#[derive(Debug, Clone)]
30pub struct Response {
31    pub spec_name: String,
32    pub effective: String,
33    /// Declared temporal window `[spec_effective_from, spec_effective_to)` of the
34    /// resolved spec version. Set by [`crate::Engine::run`] after evaluation;
35    /// `None` here (evaluation-internal construction) until that assignment.
36    pub spec_effective_from: Option<DateTimeValue>,
37    pub spec_effective_to: Option<DateTimeValue>,
38    pub results: IndexMap<String, RuleResult>,
39}
40
41/// Result of evaluating a single rule. Struct fields match the API JSON shape.
42#[derive(Debug, Clone)]
43pub struct RuleResult {
44    pub rule: EvaluatedRule,
45    pub veto_detail: Option<VetoType>,
46
47    pub vetoed: bool,
48    pub veto_reason: Option<String>,
49    pub rule_type: String,
50
51    /// Flattened value fields, including `result` when the rule is not vetoed.
52    pub result: Option<RuleResultValue>,
53    pub explanation: Option<Explanation>,
54    /// Unbound caller data paths still live for this rule under the current run data
55    /// (`DataPath::input_key` strings; subset of `Show.data` keys with non-empty
56    /// `needed_by_rules`). Ordered by evaluation / decision-tree preorder (first key =
57    /// next fact the live tree needs); `Show.data` stays declaration order.
58    missing_data: Vec<String>,
59}
60
61impl RuleResult {
62    /// Engine-rendered one-liner from the flattened [`RuleResultValue`].
63    #[must_use]
64    pub fn result(&self) -> Option<&str> {
65        self.result
66            .as_ref()
67            .and_then(|value| value.result.as_deref())
68    }
69
70    /// True when this result is a [`VetoType::MissingData`] veto.
71    ///
72    /// Value and non-`MissingData` vetoes are settled answers; leftover live keys in
73    /// [`Self::missing_data`] must not drive prompts or human "Missing data" display.
74    #[must_use]
75    pub fn is_missing_data(&self) -> bool {
76        matches!(
77            self.veto_detail.as_ref(),
78            Some(VetoType::MissingData { .. })
79        )
80    }
81
82    /// Unbound caller data paths still live for this rule (`DataPath::input_key`),
83    /// in evaluation / decision-tree order (contrast `Show.data` declaration order).
84    #[must_use]
85    pub fn missing_data(&self) -> &[String] {
86        &self.missing_data
87    }
88
89    /// Build a [`RuleResult`] for API output from a rule evaluation result.
90    ///
91    /// Measure and ratio payloads expand into every unit in the result type's family.
92    pub(crate) fn from_operation_result(
93        rule: EvaluatedRule,
94        operation_result: &OperationResult,
95        rule_type: &LemmaType,
96        family_units: &FamilyUnitCatalog,
97        explanation: Option<Explanation>,
98        missing_data: Vec<String>,
99    ) -> Self {
100        match operation_result {
101            OperationResult::Veto(VetoType::MissingData { data, .. }) => {
102                let key = data.input_key();
103                if !missing_data.iter().any(|listed| listed == &key) {
104                    panic!("BUG: MissingData path {key} not in missing_data {missing_data:?}");
105                }
106            }
107            _ => {
108                if !missing_data.is_empty() {
109                    panic!(
110                        "BUG: missing_data must be empty when result is not MissingData: {missing_data:?}"
111                    );
112                }
113            }
114        }
115        let rule_type_name = rule_type.name().to_string();
116        match operation_result {
117            OperationResult::Veto(veto) => Self {
118                rule,
119                veto_detail: Some(veto.clone()),
120                vetoed: true,
121                veto_reason: match &veto {
122                    VetoType::UserDefined { message: None } => None,
123                    _ => Some(veto.to_string()),
124                },
125                rule_type: rule_type_name,
126                result: None,
127                explanation,
128                missing_data,
129            },
130            OperationResult::Value(bound) => {
131                match rule_result_value_from_literal(&bound.to_literal(), rule_type, family_units) {
132                    Ok(value) => Self {
133                        rule,
134                        veto_detail: None,
135                        vetoed: false,
136                        veto_reason: None,
137                        rule_type: rule_type_name,
138                        result: Some(value),
139                        explanation,
140                        missing_data,
141                    },
142                    Err(failure) => vetoed_rule_result_for_rule_result_value_failure(
143                        rule,
144                        rule_type,
145                        family_units,
146                        explanation,
147                        failure,
148                        missing_data,
149                    ),
150                }
151            }
152        }
153    }
154
155    /// Reconstruct the evaluated [`LiteralValue`] from committed [`RuleResultValue`] fields.
156    ///
157    /// Panics if the rule is vetoed or fields cannot be reconstructed.
158    pub fn to_literal(&self) -> LiteralValue {
159        assert!(
160            !self.vetoed,
161            "BUG: to_literal called on vetoed rule '{}'",
162            self.rule.name
163        );
164        let value = self
165            .result
166            .as_ref()
167            .unwrap_or_else(|| panic!("BUG: non-vetoed rule '{}' missing result", self.rule.name));
168        value.to_literal(&self.rule.rule_type)
169    }
170}
171
172fn vetoed_rule_result_for_rule_result_value_failure(
173    rule: EvaluatedRule,
174    rule_type: &LemmaType,
175    family_units: &FamilyUnitCatalog,
176    explanation: Option<Explanation>,
177    failure: RuleResultValueFailure,
178    missing_data: Vec<String>,
179) -> RuleResult {
180    RuleResult::from_operation_result(
181        rule,
182        &OperationResult::Veto(VetoType::computation(
183            rule_result_value_failure_message(failure).to_string(),
184        )),
185        rule_type,
186        family_units,
187        explanation,
188        missing_data,
189    )
190}
191
192impl Response {
193    /// Looks up a rule result by name.
194    ///
195    /// Returns an error if the rule is not found.
196    pub fn get(&self, rule_name: &str) -> Result<&RuleResult, crate::error::Error> {
197        self.results
198            .get(rule_name)
199            .ok_or_else(|| crate::error::Error::rule_not_found(rule_name, None::<String>))
200    }
201
202    pub fn add_result(&mut self, result: RuleResult) {
203        self.results.insert(result.rule.name.clone(), result);
204    }
205}
206
207/// When `result` is a spec instance, the instance with its data and rules filled.
208pub(crate) fn filled_spec_result(
209    result: &OperationResult,
210    plan: &ExecutionPlan,
211    ctx: &mut crate::evaluation::EvaluationContext,
212) -> Option<SpecResult> {
213    let OperationResult::Value(bound) = result else {
214        return None;
215    };
216    let ValueKind::Spec(instance) = &bound.value else {
217        return None;
218    };
219    let mut stack = Vec::new();
220    Some(spec_result_for_instance(instance, plan, ctx, &mut stack))
221}
222
223fn spec_result_for_instance(
224    instance: &SpecInstance,
225    plan: &ExecutionPlan,
226    ctx: &mut crate::evaluation::EvaluationContext,
227    stack: &mut Vec<SpecInstance>,
228) -> SpecResult {
229    if stack.iter().any(|seen| seen == instance) {
230        panic!("BUG: spec result walked instance '{instance}' twice");
231    }
232    stack.push(instance.clone());
233    let identity = instance_identity(plan, instance);
234    let data = instance_data(plan, ctx, instance);
235    let mut rules = BTreeMap::new();
236    let rule_paths: Vec<RulePath> = plan
237        .rules
238        .keys()
239        .filter(|path| path_under_prefix(&path.segments, &instance.prefix))
240        .cloned()
241        .collect();
242    for path in rule_paths {
243        let exec = plan
244            .rules
245            .get(&path)
246            .expect("BUG: rule path missing from plan");
247        crate::evaluation::tree::ensure_rule_values(plan.rule_index(&path), plan, ctx);
248        let operation = ctx.rule_value(plan, &path).clone();
249        let missing_data = match &operation {
250            OperationResult::Veto(VetoType::MissingData { .. }) => {
251                ctx.missing_data_for_rule(plan, exec.normal_form)
252            }
253            _ => Vec::new(),
254        };
255        let mut entry = spec_entry(
256            &operation,
257            path.input_key(),
258            path.clone(),
259            exec.source.clone(),
260            exec.rule_type.as_ref(),
261            &plan.family_units,
262            missing_data,
263        );
264        if let OperationResult::Value(bound) = &operation {
265            if let ValueKind::Spec(nested) = &bound.value {
266                let nested_result = spec_result_for_instance(nested, plan, ctx, stack);
267                if let Some(value) = entry.result.as_mut() {
268                    value.spec = Some(Box::new(nested_result));
269                }
270            }
271        }
272        let relative = strip_prefix(&path.input_key(), &instance.prefix).unwrap_or_else(|| {
273            panic!(
274                "BUG: rule '{}' is under instance '{instance}' but its key does not start with that path",
275                path.input_key()
276            )
277        });
278        rules.insert(relative, entry);
279    }
280    stack.pop();
281    SpecResult {
282        repository: identity.0,
283        spec: identity.1,
284        effective: identity.2,
285        instance: instance.to_string(),
286        data,
287        rules,
288    }
289}
290
291fn instance_identity(
292    plan: &ExecutionPlan,
293    instance: &SpecInstance,
294) -> (Option<String>, String, String) {
295    let last = instance
296        .prefix
297        .last()
298        .unwrap_or_else(|| panic!("BUG: spec instance '{instance}' has no uses hop"));
299    let import_path = DataPath {
300        segments: instance.prefix[..instance.prefix.len() - 1].to_vec(),
301        data: last.uses.clone(),
302    };
303    let Some(crate::planning::semantics::DataDefinition::Import {
304        repository,
305        target_name,
306        effective,
307        ..
308    }) = plan.data.get(&import_path)
309    else {
310        panic!(
311            "BUG: spec instance '{instance}' is not an import at '{}'",
312            import_path.input_key()
313        );
314    };
315    (
316        repository.clone(),
317        target_name.clone(),
318        effective.to_string(),
319    )
320}
321
322fn spec_entry(
323    operation: &OperationResult,
324    name: String,
325    path: RulePath,
326    source: Source,
327    display_type: &LemmaType,
328    family_units: &FamilyUnitCatalog,
329    missing_data: Vec<String>,
330) -> SpecRuleResult {
331    let built = RuleResult::from_operation_result(
332        EvaluatedRule {
333            name,
334            path,
335            source_location: source,
336            rule_type: Arc::new(display_type.clone()),
337        },
338        operation,
339        display_type,
340        family_units,
341        None,
342        missing_data,
343    );
344    SpecRuleResult {
345        vetoed: built.vetoed,
346        veto_reason: built.veto_reason.clone(),
347        rule_type: built.rule_type.clone(),
348        result: built.result.clone(),
349    }
350}
351
352fn instance_data(
353    plan: &ExecutionPlan,
354    ctx: &crate::evaluation::EvaluationContext,
355    instance: &SpecInstance,
356) -> BTreeMap<String, SpecRuleResult> {
357    let mut data = BTreeMap::new();
358    for (path, definition) in &plan.data {
359        if !path_under_prefix(&path.segments, &instance.prefix) {
360            continue;
361        }
362        if definition.schema_type().is_none() {
363            continue;
364        }
365        let relative = strip_prefix(&path.input_key(), &instance.prefix)
366            .unwrap_or_else(|| panic!("BUG: data '{}' lost its instance prefix", path.input_key()));
367        let Some(slot) = ctx.data_slot(plan, path) else {
368            continue;
369        };
370        if matches!(slot, OperationResult::Veto(VetoType::MissingData { .. })) {
371            continue;
372        }
373        let display_type = ctx.data_display_type(plan, path);
374        let entry = spec_entry(
375            slot,
376            path.input_key(),
377            RulePath {
378                segments: path.segments.clone(),
379                rule: path.data.clone(),
380            },
381            definition.source().clone(),
382            display_type.as_ref(),
383            &plan.family_units,
384            Vec::new(),
385        );
386        data.insert(relative, entry);
387    }
388    data
389}
390
391fn path_under_prefix(segments: &[PathSegment], prefix: &[PathSegment]) -> bool {
392    segments.len() >= prefix.len() && segments[..prefix.len()] == prefix[..]
393}
394
395fn strip_prefix(key: &str, prefix: &[PathSegment]) -> Option<String> {
396    let mut head = String::new();
397    for segment in prefix {
398        head.push_str(&segment.uses);
399        head.push('.');
400    }
401    key.strip_prefix(&head).map(str::to_string)
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::literals::DateGranularity;
408    use crate::parsing::ast::Span;
409    use crate::planning::semantics::{
410        primitive_number_arc, BaseMeasureVector, DataPath, LemmaType, LiteralValue, MeasureUnit,
411        MeasureUnits, RatioUnit, RatioUnits, RulePath, TypeExtends, TypeSpecification, ValueKind,
412    };
413    use crate::planning::unit_family::FamilyUnitCatalog;
414    use crate::planning::unit_index::UnitIndex;
415    use rust_decimal::Decimal;
416    use std::str::FromStr;
417    use std::sync::Arc;
418
419    fn dec(s: &str) -> Decimal {
420        Decimal::from_str(s).expect("BUG: test decimal literal")
421    }
422
423    fn empty_family_catalog() -> FamilyUnitCatalog {
424        FamilyUnitCatalog::default()
425    }
426
427    fn family_catalog_for_index(index: &UnitIndex) -> FamilyUnitCatalog {
428        FamilyUnitCatalog::build(index)
429    }
430
431    fn family_catalog_for_money(money: &LemmaType) -> FamilyUnitCatalog {
432        family_catalog_for_index(&unit_index_for_money(money))
433    }
434
435    fn family_catalog_for_ratio(ratio_type: &LemmaType) -> FamilyUnitCatalog {
436        family_catalog_for_index(&unit_index_for_ratio(ratio_type))
437    }
438
439    fn unit_index_for_money(money: &LemmaType) -> UnitIndex {
440        let mut index = UnitIndex::new();
441        let arc = Arc::new(money.clone());
442        for unit_name in money.measure_unit_names().into_iter().flatten() {
443            index
444                .merge_measure_unit(unit_name.to_string(), &arc, "money", None, "money")
445                .expect("test money unit index");
446        }
447        index
448    }
449
450    fn unit_index_for_ratio(ratio_type: &LemmaType) -> UnitIndex {
451        use crate::planning::semantics::primitive_ratio_arc;
452        let mut index = UnitIndex::new();
453        let arc = Arc::new(ratio_type.clone());
454        let type_name = ratio_type.name();
455        for unit_name in ratio_type.ratio_unit_names().into_iter().flatten() {
456            index
457                .merge_ratio_unit(
458                    unit_name.to_string(),
459                    &arc,
460                    &type_name,
461                    None,
462                    primitive_ratio_arc(),
463                )
464                .expect("test ratio unit index");
465        }
466        index
467    }
468
469    fn dummy_source() -> Source {
470        Source::new(
471            crate::parsing::source::SourceType::Volatile,
472            Span {
473                start: 0,
474                end: 0,
475                line: 1,
476                col: 1,
477            },
478        )
479    }
480
481    fn dummy_evaluated_rule(name: &str, rule_type: &LemmaType) -> EvaluatedRule {
482        EvaluatedRule {
483            name: name.to_string(),
484            path: RulePath::new(vec![], name.to_string()),
485            source_location: dummy_source(),
486            rule_type: Arc::new(rule_type.clone()),
487        }
488    }
489
490    #[test]
491    fn test_response_serialization() {
492        let mut results = IndexMap::new();
493        results.insert(
494            "test_rule".to_string(),
495            RuleResult::from_operation_result(
496                dummy_evaluated_rule("test_rule", primitive_number_arc().as_ref()),
497                &OperationResult::from_literal(LiteralValue::number_from_decimal(Decimal::from(
498                    42,
499                ))),
500                primitive_number_arc().as_ref(),
501                &empty_family_catalog(),
502                None,
503                Vec::new(),
504            ),
505        );
506        let response = Response {
507            spec_name: "test_spec".to_string(),
508            effective: "2026-01-01".to_string(),
509            spec_effective_from: None,
510            spec_effective_to: None,
511            results,
512        };
513
514        let json = serde_json::to_string(&crate::api::Response::from(&response)).unwrap();
515        assert!(json.contains("test_spec"));
516        assert!(json.contains("test_rule"));
517        assert!(json.contains("\"number\":\"42\""));
518        assert!(!json.contains("lemma_type"));
519    }
520
521    #[test]
522    fn response_number_json_never_uses_fraction_notation() {
523        use crate::computation::rational::decimal_to_rational;
524
525        let rational = decimal_to_rational(Decimal::new(1, 1) / Decimal::new(3, 1)).unwrap();
526        let decimal_string = rational.try_to_decimal().unwrap().to_string();
527        let mut results = IndexMap::new();
528        results.insert(
529            "third".to_string(),
530            RuleResult::from_operation_result(
531                dummy_evaluated_rule("third", primitive_number_arc().as_ref()),
532                &OperationResult::from_literal(LiteralValue::number_from_decimal(
533                    rational.try_to_decimal().unwrap(),
534                )),
535                primitive_number_arc().as_ref(),
536                &empty_family_catalog(),
537                None,
538                Vec::new(),
539            ),
540        );
541        // Override committed decimal number field to match serialization path under test
542        if let Some(rule) = results.get_mut("third") {
543            let decimal = rational.try_to_decimal().unwrap();
544            rule.result = Some(crate::result_value::RuleResultValue {
545                result: Some(decimal_string.clone()),
546                number: Some(decimal),
547                ..Default::default()
548            });
549        }
550
551        let response = Response {
552            spec_name: "test".to_string(),
553            effective: "test".to_string(),
554            spec_effective_from: None,
555            spec_effective_to: None,
556            results,
557        };
558
559        let json: serde_json::Value = serde_json::from_str(
560            &serde_json::to_string(&crate::api::Response::from(&response)).unwrap(),
561        )
562        .unwrap();
563        let number = json["results"]["third"]["number"]
564            .as_str()
565            .expect("number must be a JSON string");
566        assert!(
567            !number.contains('/'),
568            "API decimal string must not use fraction notation, got {number}"
569        );
570    }
571
572    #[test]
573    fn test_rule_result_veto() {
574        let missing = RuleResult::from_operation_result(
575            dummy_evaluated_rule("rule3", &LemmaType::veto_type()),
576            &OperationResult::Veto(VetoType::missing_data(
577                DataPath::new(vec![], "data1".to_string()),
578                None,
579            )),
580            &LemmaType::veto_type(),
581            &empty_family_catalog(),
582            None,
583            vec!["data1".to_string()],
584        );
585        assert!(missing.vetoed);
586        assert!(missing.veto_reason.as_ref().unwrap().contains("data1"));
587
588        let veto = RuleResult::from_operation_result(
589            dummy_evaluated_rule("rule4", &LemmaType::veto_type()),
590            &OperationResult::Veto(VetoType::UserDefined {
591                message: Some("Vetoed".to_string()),
592            }),
593            &LemmaType::veto_type(),
594            &empty_family_catalog(),
595            None,
596            Vec::new(),
597        );
598        assert_eq!(veto.veto_reason.as_deref(), Some("Vetoed"));
599    }
600
601    /// Attach hole: MissingData + empty missing_data is a BUG, not a silent stall.
602    #[test]
603    #[should_panic(expected = "BUG: MissingData")]
604    fn missing_data_veto_must_not_attach_empty_missing_data_list() {
605        RuleResult::from_operation_result(
606            dummy_evaluated_rule("rule3", &LemmaType::veto_type()),
607            &OperationResult::Veto(VetoType::missing_data(
608                DataPath::new(vec![], "data1".to_string()),
609                None,
610            )),
611            &LemmaType::veto_type(),
612            &empty_family_catalog(),
613            None,
614            Vec::new(),
615        );
616    }
617
618    #[test]
619    #[should_panic(expected = "BUG: MissingData")]
620    fn missing_data_veto_must_include_veto_path_in_list() {
621        RuleResult::from_operation_result(
622            dummy_evaluated_rule("rule3", &LemmaType::veto_type()),
623            &OperationResult::Veto(VetoType::missing_data(
624                DataPath::new(vec![], "data1".to_string()),
625                None,
626            )),
627            &LemmaType::veto_type(),
628            &empty_family_catalog(),
629            None,
630            vec!["other".to_string()],
631        );
632    }
633
634    #[test]
635    #[should_panic(expected = "BUG: missing_data must be empty")]
636    fn non_missing_data_result_must_not_attach_leftover_missing_data() {
637        RuleResult::from_operation_result(
638            dummy_evaluated_rule("rule4", &LemmaType::veto_type()),
639            &OperationResult::Veto(VetoType::UserDefined {
640                message: Some("Vetoed".to_string()),
641            }),
642            &LemmaType::veto_type(),
643            &empty_family_catalog(),
644            None,
645            vec!["leftover".to_string()],
646        );
647    }
648
649    #[test]
650    fn rule_result_value_out_of_memory_is_not_decimal_limit_veto() {
651        let result = vetoed_rule_result_for_rule_result_value_failure(
652            dummy_evaluated_rule("rule", primitive_number_arc().as_ref()),
653            primitive_number_arc().as_ref(),
654            &empty_family_catalog(),
655            None,
656            RuleResultValueFailure::OutOfMemory,
657            Vec::new(),
658        );
659        assert_eq!(result.veto_reason.as_deref(), Some("out of memory"));
660        assert_ne!(
661            result.veto_reason.as_deref(),
662            Some("Calculated result exceeds decimal value limit")
663        );
664    }
665
666    #[test]
667    fn rule_result_value_decimal_limit_uses_commit_message() {
668        let result = vetoed_rule_result_for_rule_result_value_failure(
669            dummy_evaluated_rule("rule", primitive_number_arc().as_ref()),
670            primitive_number_arc().as_ref(),
671            &empty_family_catalog(),
672            None,
673            RuleResultValueFailure::DecimalLimit,
674            Vec::new(),
675        );
676        assert_eq!(
677            result.veto_reason.as_deref(),
678            Some("Calculated result exceeds decimal value limit")
679        );
680    }
681
682    fn test_money_type() -> LemmaType {
683        LemmaType::new(
684            "money".to_string(),
685            TypeSpecification::Measure {
686                minimum: None,
687                maximum: None,
688                decimals: Some(2),
689                units: MeasureUnits::from(vec![
690                    MeasureUnit {
691                        name: "eur".to_string(),
692                        factor: crate::computation::rational::rational_one(),
693                        derived_measure_factors: Vec::new(),
694                        decomposition: BaseMeasureVector::new(),
695                        minimum: None,
696                        maximum: None,
697                        suggestion_magnitude: None,
698                    },
699                    MeasureUnit {
700                        name: "usd".to_string(),
701                        factor: crate::computation::rational::decimal_to_rational(Decimal::new(
702                            91, 2,
703                        ))
704                        .expect("factor"),
705                        derived_measure_factors: Vec::new(),
706                        decomposition: BaseMeasureVector::new(),
707                        minimum: None,
708                        maximum: None,
709                        suggestion_magnitude: None,
710                    },
711                ]),
712                traits: Vec::new(),
713                decomposition: Some(BaseMeasureVector::new()),
714                unit: None,
715                help: String::new(),
716            },
717            TypeExtends::Primitive,
718        )
719    }
720
721    #[test]
722    fn measure_rule_result_value_uses_rule_type_when_expression_index_empty() {
723        let money = test_money_type();
724        let family_units = family_catalog_for_money(&money);
725        let ten_usd = LiteralValue {
726            value: ValueKind::Measure(
727                crate::computation::rational::checked_mul(
728                    &crate::computation::rational::decimal_to_rational(Decimal::from(10))
729                        .expect("ten"),
730                    &crate::computation::rational::decimal_to_rational(Decimal::new(91, 2))
731                        .expect("usd factor"),
732                )
733                .expect("canonical usd"),
734            ),
735        };
736        let result = RuleResult::from_operation_result(
737            dummy_evaluated_rule("total", &money),
738            &OperationResult::from_literal(ten_usd),
739            &money,
740            &family_units,
741            None,
742            Vec::new(),
743        );
744        let measure = result
745            .result
746            .as_ref()
747            .expect("value")
748            .measure
749            .clone()
750            .expect("measure map");
751        assert_eq!(measure.get("usd"), Some(&dec("10.00")));
752        assert!(measure.contains_key("eur"));
753    }
754
755    #[test]
756    fn test_measure_rule_result_value_multi_unit() {
757        let money = test_money_type();
758        let family_units = family_catalog_for_money(&money);
759        let ten_eur = LiteralValue {
760            value: ValueKind::Measure(
761                crate::computation::rational::decimal_to_rational(Decimal::from(10)).expect("ten"),
762            ),
763        };
764        let result = RuleResult::from_operation_result(
765            dummy_evaluated_rule("total", &money),
766            &OperationResult::from_literal(ten_eur),
767            &money,
768            &family_units,
769            None,
770            Vec::new(),
771        );
772        let measure = result
773            .result
774            .as_ref()
775            .expect("value")
776            .measure
777            .clone()
778            .expect("measure map");
779        assert_eq!(measure.get("eur"), Some(&dec("10.00")));
780        assert_eq!(measure.get("usd"), Some(&dec("10.99")));
781    }
782
783    #[test]
784    fn measure_rule_result_value_respects_decimals_on_unit_conversion() {
785        let money = LemmaType::new(
786            "money".to_string(),
787            TypeSpecification::Measure {
788                minimum: None,
789                maximum: None,
790                decimals: Some(2),
791                units: MeasureUnits::from(vec![
792                    MeasureUnit {
793                        name: "eur".to_string(),
794                        factor: crate::computation::rational::rational_one(),
795                        derived_measure_factors: Vec::new(),
796                        decomposition: BaseMeasureVector::new(),
797                        minimum: None,
798                        maximum: None,
799                        suggestion_magnitude: None,
800                    },
801                    MeasureUnit {
802                        name: "usd".to_string(),
803                        factor: crate::computation::rational::decimal_to_rational(Decimal::new(
804                            84, 2,
805                        ))
806                        .expect("usd factor"),
807                        derived_measure_factors: Vec::new(),
808                        decomposition: BaseMeasureVector::new(),
809                        minimum: None,
810                        maximum: None,
811                        suggestion_magnitude: None,
812                    },
813                ]),
814                traits: Vec::new(),
815                decomposition: Some(BaseMeasureVector::new()),
816                unit: None,
817                help: String::new(),
818            },
819            TypeExtends::Primitive,
820        );
821        let family_units = family_catalog_for_money(&money);
822        let three_twelve_eur = LiteralValue {
823            value: ValueKind::Measure(
824                crate::computation::rational::decimal_to_rational(Decimal::new(312, 2))
825                    .expect("3.12 eur canonical"),
826            ),
827        };
828        let result = RuleResult::from_operation_result(
829            dummy_evaluated_rule("delivery_cost", &money),
830            &OperationResult::from_literal(three_twelve_eur),
831            &money,
832            &family_units,
833            None,
834            Vec::new(),
835        );
836        let measure = result
837            .result
838            .as_ref()
839            .expect("value")
840            .measure
841            .clone()
842            .expect("measure map");
843        assert_eq!(measure.get("eur"), Some(&dec("3.12")));
844        assert_eq!(measure.get("usd"), Some(&dec("3.71")));
845    }
846
847    #[test]
848    fn test_ratio_rule_result_value_multi_unit() {
849        let ratio_type = LemmaType::new(
850            "rate".to_string(),
851            TypeSpecification::Ratio {
852                minimum: None,
853                maximum: None,
854                decimals: None,
855                units: RatioUnits::from(vec![
856                    RatioUnit {
857                        name: "percent".to_string(),
858                        value: crate::computation::rational::decimal_to_rational(Decimal::from(
859                            100,
860                        ))
861                        .expect("percent"),
862                        minimum: None,
863                        maximum: None,
864                        suggestion_magnitude: None,
865                    },
866                    RatioUnit {
867                        name: "basis_points".to_string(),
868                        value: crate::computation::rational::decimal_to_rational(Decimal::from(
869                            10_000,
870                        ))
871                        .expect("bp"),
872                        minimum: None,
873                        maximum: None,
874                        suggestion_magnitude: None,
875                    },
876                ]),
877                unit: None,
878                help: String::new(),
879            },
880            TypeExtends::Primitive,
881        );
882        let family_units = family_catalog_for_ratio(&ratio_type);
883        let half = crate::computation::rational::rational_new(1, 2);
884        let lit = LiteralValue {
885            value: ValueKind::Ratio(half),
886        };
887        let result = RuleResult::from_operation_result(
888            dummy_evaluated_rule("rate_out", &ratio_type),
889            &OperationResult::from_literal(lit),
890            &ratio_type,
891            &family_units,
892            None,
893            Vec::new(),
894        );
895        let ratio = result
896            .result
897            .as_ref()
898            .expect("value")
899            .ratio
900            .clone()
901            .expect("ratio map");
902        assert_eq!(ratio.get("percent"), Some(&dec("50")));
903        assert_eq!(ratio.get("basis_points"), Some(&dec("5000")));
904    }
905
906    #[test]
907    fn test_measure_rule_result_value_cross_spec_import() {
908        use crate::parsing::source::SourceType;
909        use crate::Engine;
910
911        let mut engine = Engine::new();
912        engine
913            .load([(
914                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("t.lemma"))),
915                r#"
916spec consumer 2025-01-01
917uses d: dep 2025-10-01
918rule out: d.doubled
919
920spec dep 2025-01-01
921uses c: child 2025-06-01
922data money: c.money
923data p: 5 usd
924rule doubled: p * 2
925
926spec child 2025-01-01
927data money: measure
928 -> unit eur: 1.00
929 -> decimals 2
930
931spec child 2025-06-01
932data money: measure
933 -> unit eur: 1.00
934 -> unit usd: 0.91
935 -> decimals 2
936"#
937                .to_string(),
938            )])
939            .expect("load");
940        let effective = crate::literals::DateTimeValue {
941            year: 2025,
942            month: 3,
943            day: 1,
944            hour: 0,
945            minute: 0,
946            second: 0,
947            microsecond: 0,
948            timezone: None,
949
950            granularity: DateGranularity::Full,
951        };
952        let response = engine
953            .run(
954                None,
955                "consumer",
956                Some(&effective),
957                std::collections::HashMap::new(),
958                None,
959                false,
960            )
961            .expect("run");
962        let out = response.results.get("out").expect("out rule");
963        assert!(!out.vetoed);
964        let measure = out
965            .result
966            .as_ref()
967            .expect("value")
968            .measure
969            .as_ref()
970            .expect("measure map");
971        assert!(measure.contains_key("usd"));
972        assert!(measure.contains_key("eur"));
973    }
974
975    #[test]
976    fn to_literal_roundtrips_number() {
977        let literal = LiteralValue::number_from_decimal(Decimal::from(42));
978        let rule_result = RuleResult::from_operation_result(
979            dummy_evaluated_rule("answer", primitive_number_arc().as_ref()),
980            &OperationResult::from_literal(literal.clone()),
981            primitive_number_arc().as_ref(),
982            &empty_family_catalog(),
983            None,
984            Vec::new(),
985        );
986        assert_eq!(rule_result.to_literal(), literal);
987    }
988
989    #[test]
990    fn to_literal_roundtrips_measure() {
991        let money = test_money_type();
992        let family_units = family_catalog_for_money(&money);
993        let literal = LiteralValue::measure_with_type(
994            crate::computation::rational::rational_new(60, 1),
995            Arc::new(money.clone()),
996        );
997        let rule_result = RuleResult::from_operation_result(
998            dummy_evaluated_rule("pay", &money),
999            &OperationResult::from_literal(literal.clone()),
1000            &money,
1001            &family_units,
1002            None,
1003            Vec::new(),
1004        );
1005        assert_eq!(rule_result.to_literal(), literal);
1006    }
1007}