Skip to main content

lemma/evaluation/
explanations.rs

1//! Structural explanation trees for evaluated rules.
2
3use crate::computation::rational::checked_div;
4use crate::computation::UnitResolutionContext;
5use crate::evaluation::expression::{resolve_data_path_value, resolve_source_expression_values};
6use crate::evaluation::operations::{OperationResult, VetoType};
7use crate::evaluation::EvaluationContext;
8use crate::planning::execution_plan::{ExecutableRule, ExecutionPlan};
9use crate::planning::semantics::{
10    compare_semantic_dates, ArithmeticComputation, DataPath, Expression, ExpressionKind, LemmaType,
11    LiteralValue, NegationType, RulePath, SemanticConversionTarget, TypeSpecification, ValueKind,
12};
13use serde::ser::SerializeMap;
14use serde::{Serialize, Serializer};
15use std::cmp::Ordering;
16use std::collections::{HashMap, HashSet};
17
18fn serialize_rule_name<S>(path: &RulePath, serializer: S) -> Result<S::Ok, S::Error>
19where
20    S: Serializer,
21{
22    serializer.serialize_str(&path.rule)
23}
24
25fn serialize_data_input_key<S>(path: &DataPath, serializer: S) -> Result<S::Ok, S::Error>
26where
27    S: Serializer,
28{
29    serializer.serialize_str(&path.input_key())
30}
31
32#[derive(Debug, Clone)]
33pub struct Explanation {
34    pub rule: RulePath,
35    pub result: OperationResult,
36    pub body: String,
37    pub causes: Vec<Cause>,
38    pub children: Vec<ExplanationNode>,
39}
40
41#[derive(Debug, Clone, Serialize)]
42#[serde(tag = "type", rename_all = "snake_case")]
43pub enum ExplanationNode {
44    Rule {
45        #[serde(serialize_with = "serialize_rule_name")]
46        rule: RulePath,
47        body: String,
48        #[serde(skip_serializing_if = "Vec::is_empty")]
49        causes: Vec<Cause>,
50        #[serde(skip_serializing_if = "Vec::is_empty")]
51        children: Vec<ExplanationNode>,
52    },
53    Compose {
54        expression: String,
55        operands: Vec<ExplanationNode>,
56    },
57    DataInput {
58        #[serde(serialize_with = "serialize_data_input_key")]
59        data: DataPath,
60        display: String,
61    },
62    Conversion {
63        expression: String,
64        steps: Vec<SerializedConversionTraceStep>,
65        operands: Vec<ExplanationNode>,
66    },
67    Veto {
68        #[serde(skip_serializing_if = "Option::is_none")]
69        message: Option<String>,
70    },
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
74pub struct Cause {
75    pub condition: String,
76    pub value: String,
77}
78
79#[derive(Debug, Clone)]
80pub enum ConversionTraceRole {
81    Outcome,
82    Rule,
83    Source,
84}
85
86#[derive(Debug, Clone)]
87pub struct ConversionTraceStep {
88    pub role: ConversionTraceRole,
89    pub text: String,
90    pub data_ref: Option<DataPath>,
91}
92
93fn build_conversion_steps(
94    value: &LiteralValue,
95    target: &SemanticConversionTarget,
96    result: &LiteralValue,
97    data_ref: Option<&DataPath>,
98    resolution_context: UnitResolutionContext<'_>,
99) -> Vec<ConversionTraceStep> {
100    let mut steps = Vec::new();
101    steps.push(ConversionTraceStep {
102        role: ConversionTraceRole::Outcome,
103        text: result.to_string(),
104        data_ref: None,
105    });
106
107    if let Some(rule_text) = conversion_rule_step_text(value, target, result, resolution_context) {
108        steps.push(ConversionTraceStep {
109            role: ConversionTraceRole::Rule,
110            text: rule_text,
111            data_ref: None,
112        });
113    }
114
115    steps.push(ConversionTraceStep {
116        role: ConversionTraceRole::Source,
117        text: conversion_source_step_text(value, data_ref),
118        data_ref: data_ref.cloned(),
119    });
120
121    steps
122}
123
124fn conversion_source_step_text(operand: &LiteralValue, data_ref: Option<&DataPath>) -> String {
125    let type_name = type_specification_display_name(&operand.lemma_type);
126    let value_display = operand.to_string();
127    match data_ref {
128        Some(path) => format!("The {type_name} of {path} is {value_display}"),
129        None => format!("The {type_name} is {value_display}"),
130    }
131}
132
133fn type_specification_display_name(lemma_type: &LemmaType) -> &'static str {
134    match &lemma_type.specifications {
135        TypeSpecification::Boolean { .. } => "boolean",
136        TypeSpecification::Quantity { .. } => "quantity",
137        TypeSpecification::QuantityRange { .. } => "quantity range",
138        TypeSpecification::Number { .. } => "number",
139        TypeSpecification::NumberRange { .. } => "number range",
140        TypeSpecification::Text { .. } => "text",
141        TypeSpecification::Date { .. } => "date",
142        TypeSpecification::DateRange { .. } => "date range",
143        TypeSpecification::TimeRange { .. } => "time range",
144        TypeSpecification::Time { .. } => "time",
145        TypeSpecification::Ratio { .. } => "ratio",
146        TypeSpecification::RatioRange { .. } => "ratio range",
147        TypeSpecification::Veto { .. } => "veto",
148        TypeSpecification::Undetermined => "undetermined",
149    }
150}
151
152fn conversion_rule_step_text(
153    value: &LiteralValue,
154    target: &SemanticConversionTarget,
155    result: &LiteralValue,
156    resolution_context: UnitResolutionContext<'_>,
157) -> Option<String> {
158    match &value.value {
159        ValueKind::Range(left, right) => range_span_rule_step_text(left, right, result),
160        ValueKind::Quantity(_, from_signature) if !value.lemma_type.is_calendar_like() => {
161            match target {
162                SemanticConversionTarget::Unit { unit_name } => {
163                    quantity_unit_equivalence_step_text(
164                        from_signature,
165                        unit_name,
166                        &value.lemma_type,
167                        resolution_context,
168                    )
169                }
170                _ => None,
171            }
172        }
173        ValueKind::Number(_) => None,
174        ValueKind::Ratio(_, _) => None,
175        ValueKind::Quantity(_, _) if value.lemma_type.is_calendar_like() => None,
176        _ => None,
177    }
178}
179
180fn format_explanation_multiplier(
181    rational: &crate::computation::rational::RationalInteger,
182) -> String {
183    let reduced = rational
184        .clone()
185        .try_reduce()
186        .unwrap_or_else(|_| rational.clone());
187    if reduced.denom() == &crate::computation::rational::BigInt::one() {
188        reduced.numer().to_string()
189    } else {
190        format!("{}/{}", reduced.numer(), reduced.denom())
191    }
192}
193
194fn quantity_unit_equivalence_step_text(
195    from_signature: &[(String, i32)],
196    to_unit: &str,
197    lemma_type: &LemmaType,
198    resolution_context: UnitResolutionContext<'_>,
199) -> Option<String> {
200    let from_unit = from_signature
201        .first()
202        .map(|(name, _)| name.as_str())
203        .unwrap_or("");
204
205    let both_units_in_lemma_type = match &lemma_type.specifications {
206        TypeSpecification::Quantity { units, .. } => {
207            !from_unit.is_empty()
208                && from_signature.len() == 1
209                && units.get(from_unit).is_ok()
210                && units.get(to_unit).is_ok()
211        }
212        _ => false,
213    };
214
215    if both_units_in_lemma_type {
216        let from_factor = lemma_type.quantity_unit_factor(from_unit);
217        let to_factor = lemma_type.quantity_unit_factor(to_unit);
218        let multiplier = checked_div(from_factor, to_factor).ok()?;
219        let multiplier_display = format_explanation_multiplier(&multiplier);
220        if multiplier_display == "1" {
221            return None;
222        }
223        return Some(format!("1 {from_unit} is {multiplier_display} {to_unit}"));
224    }
225
226    let UnitResolutionContext::WithIndex(unit_index) = resolution_context else {
227        return None;
228    };
229    let target_type = unit_index.get(to_unit)?;
230    let to_factor = target_type.quantity_unit_factor(to_unit).clone();
231    let from_factor =
232        crate::planning::semantics::signature_factor(from_signature, unit_index, None);
233    let multiplier = checked_div(&from_factor, &to_factor).ok()?;
234    let multiplier_display = format_explanation_multiplier(&multiplier);
235    if multiplier_display == "1" {
236        return None;
237    }
238    let source_label = crate::planning::semantics::format_signature_operator_style(from_signature);
239    Some(format!(
240        "1 {source_label} is {multiplier_display} {to_unit}"
241    ))
242}
243
244fn range_span_rule_step_text(
245    left: &LiteralValue,
246    right: &LiteralValue,
247    result: &LiteralValue,
248) -> Option<String> {
249    match (&left.value, &right.value) {
250        (ValueKind::Date(left_date), ValueKind::Date(right_date)) => {
251            let (lower, upper) = ordered_date_pair(left_date, right_date);
252            let lower_literal = LiteralValue::date(lower.clone());
253            let upper_literal = LiteralValue::date(upper.clone());
254            Some(format!("{upper_literal} − {lower_literal} = {result}"))
255        }
256        (ValueKind::Number(_), ValueKind::Number(_)) => {
257            let (lower, upper) = ordered_number_pair(left, right);
258            Some(format!("{upper} − {lower} = {result}"))
259        }
260        (ValueKind::Quantity(_, _), ValueKind::Quantity(_, _)) => {
261            let (lower, upper) = ordered_quantity_pair(left, right);
262            Some(format!("{upper} − {lower} = {result}"))
263        }
264        _ => None,
265    }
266}
267
268fn ordered_date_pair<'a>(
269    left: &'a crate::planning::semantics::SemanticDateTime,
270    right: &'a crate::planning::semantics::SemanticDateTime,
271) -> (
272    &'a crate::planning::semantics::SemanticDateTime,
273    &'a crate::planning::semantics::SemanticDateTime,
274) {
275    match compare_semantic_dates(left, right) {
276        Ordering::Less | Ordering::Equal => (left, right),
277        Ordering::Greater => (right, left),
278    }
279}
280
281fn ordered_number_pair<'a>(
282    left: &'a LiteralValue,
283    right: &'a LiteralValue,
284) -> (&'a LiteralValue, &'a LiteralValue) {
285    let ValueKind::Number(left_number) = &left.value else {
286        unreachable!("BUG: ordered_number_pair called with non-number operand");
287    };
288    let ValueKind::Number(right_number) = &right.value else {
289        unreachable!("BUG: ordered_number_pair called with non-number operand");
290    };
291    if left_number <= right_number {
292        (left, right)
293    } else {
294        (right, left)
295    }
296}
297
298fn ordered_quantity_pair<'a>(
299    left: &'a LiteralValue,
300    right: &'a LiteralValue,
301) -> (&'a LiteralValue, &'a LiteralValue) {
302    let ValueKind::Quantity(left_magnitude, _) = &left.value else {
303        unreachable!("BUG: ordered_quantity_pair called with non-quantity operand");
304    };
305    let ValueKind::Quantity(right_magnitude, _) = &right.value else {
306        unreachable!("BUG: ordered_quantity_pair called with non-quantity operand");
307    };
308    if *left_magnitude <= *right_magnitude {
309        (left, right)
310    } else {
311        (right, left)
312    }
313}
314
315#[derive(Debug, Clone, Serialize)]
316pub struct SerializedConversionTraceStep {
317    role: String,
318    text: String,
319}
320
321impl Explanation {
322    fn as_rule_node(&self) -> ExplanationNode {
323        ExplanationNode::Rule {
324            rule: self.rule.clone(),
325            body: self.body.clone(),
326            causes: self.causes.clone(),
327            children: self.children.clone(),
328        }
329    }
330}
331
332impl Serialize for Explanation {
333    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
334    where
335        S: serde::Serializer,
336    {
337        let mut map = serializer.serialize_map(None)?;
338        map.serialize_entry("rule", &self.rule.rule)?;
339        map.serialize_entry("result", &format_operation_result(&self.result))?;
340        map.serialize_entry("body", &self.body)?;
341        if !self.causes.is_empty() {
342            map.serialize_entry("causes", &self.causes)?;
343        }
344        map.serialize_entry("children", &self.children)?;
345        map.end()
346    }
347}
348
349fn format_operation_result(result: &OperationResult) -> String {
350    match result {
351        OperationResult::Value(value) => value.display_value(),
352        OperationResult::Veto(VetoType::UserDefined { message: None }) => String::new(),
353        OperationResult::Veto(veto) => veto.to_string(),
354    }
355}
356
357/// Which source expression explains the rule's result.
358pub(crate) enum WinningSourceBranch<'a> {
359    /// A branch was selected: its result expression is the rule's body.
360    BranchResult {
361        result_expression: &'a Expression,
362        causes: Vec<Cause>,
363    },
364    /// An unless condition vetoed before any branch was selected: the rule's
365    /// result is that veto and no branch body applies. The condition
366    /// expression explains where the veto arose.
367    ConditionVeto {
368        condition_expression: &'a Expression,
369        causes: Vec<Cause>,
370    },
371}
372
373/// Causes for one evaluated unless condition: per referenced data path, or a
374/// single condition-level cause when the condition references no data.
375fn unless_condition_causes<'plan>(
376    condition: &Expression,
377    condition_result: &OperationResult,
378    context: &mut EvaluationContext<'plan>,
379) -> Vec<Cause> {
380    let mut data_paths = std::collections::HashSet::new();
381    condition.collect_data_paths(&mut data_paths);
382    let mut paths: Vec<DataPath> = data_paths.into_iter().collect();
383    paths.sort();
384    if paths.is_empty() {
385        let value = match condition_result {
386            OperationResult::Veto(veto) => veto.to_string(),
387            OperationResult::Value(literal) => match &literal.value {
388                ValueKind::Boolean(value) => LiteralValue::from_bool(*value).display_value(),
389                _ => {
390                    unreachable!("BUG: unless condition non-boolean after type validation")
391                }
392            },
393        };
394        return vec![Cause {
395            condition: format_expression(condition),
396            value,
397        }];
398    }
399    paths
400        .into_iter()
401        .map(|data_path| {
402            let path_expr = Expression::with_source(
403                ExpressionKind::DataPath(data_path.clone()),
404                condition.source_location.clone(),
405            );
406            let value = match resolve_source_expression_values(&path_expr, context) {
407                OperationResult::Value(literal) => literal.display_value(),
408                OperationResult::Veto(veto) => context
409                    .unique_data_value_by_name(&data_path.data)
410                    .map(|value| value.display_value())
411                    .unwrap_or_else(|| veto.to_string()),
412            };
413            Cause {
414                condition: data_path.data.clone(),
415                value,
416            }
417        })
418        .collect()
419}
420
421pub(crate) fn winning_source_branch_and_causes<'a, 'plan>(
422    exec_rule: &'a ExecutableRule,
423    context: &mut EvaluationContext<'plan>,
424) -> WinningSourceBranch<'a> {
425    use crate::evaluation::branch_semantics::{unless_condition_outcome, BranchOutcome};
426    use crate::planning::execution_plan::JumpVetoSemantics;
427
428    if exec_rule.branches.len() == 1 {
429        return WinningSourceBranch::BranchResult {
430            result_expression: &exec_rule.branches[0].result,
431            causes: Vec::new(),
432        };
433    }
434
435    // Mirror the compiled piecewise exactly (`compile_piecewise_rule`):
436    // unless conditions are evaluated in reverse source order (last match
437    // wins) under rule-reference veto semantics. The first condition that
438    // vetoes propagates as the rule result; the first that is true selects
439    // its branch; only the conditions actually evaluated produce causes.
440    let mut evaluated_in_reverse_order: Vec<Vec<Cause>> = Vec::new();
441    for branch in exec_rule.branches[1..].iter().rev() {
442        let condition = branch
443            .condition
444            .as_ref()
445            .expect("BUG: unless branch missing condition");
446        let condition_result = resolve_source_expression_values(condition, context);
447        let causes = unless_condition_causes(condition, &condition_result, context);
448        match unless_condition_outcome(&condition_result, JumpVetoSemantics::UnlessRuleReference) {
449            BranchOutcome::Propagate(_) => {
450                evaluated_in_reverse_order.push(causes);
451                return WinningSourceBranch::ConditionVeto {
452                    condition_expression: condition,
453                    causes: causes_in_source_order(evaluated_in_reverse_order, false),
454                };
455            }
456            BranchOutcome::Taken => {
457                evaluated_in_reverse_order.push(causes);
458                return WinningSourceBranch::BranchResult {
459                    result_expression: &branch.result,
460                    causes: causes_in_source_order(evaluated_in_reverse_order, false),
461                };
462            }
463            BranchOutcome::NotTaken => {
464                evaluated_in_reverse_order.push(causes);
465            }
466        }
467    }
468
469    WinningSourceBranch::BranchResult {
470        result_expression: &exec_rule.branches[0].result,
471        causes: causes_in_source_order(evaluated_in_reverse_order, true),
472    }
473}
474
475/// Flatten per-condition causes (collected in reverse evaluation order) back
476/// into source order. When the default branch wins every condition was
477/// evaluated; duplicate condition texts are deduplicated, keeping the first
478/// occurrence in source order (preserving the established output shape).
479fn causes_in_source_order(
480    evaluated_in_reverse_order: Vec<Vec<Cause>>,
481    deduplicate: bool,
482) -> Vec<Cause> {
483    let mut causes: Vec<Cause> = evaluated_in_reverse_order
484        .into_iter()
485        .rev()
486        .flatten()
487        .collect();
488    if deduplicate {
489        let mut seen = std::collections::HashSet::new();
490        causes.retain(|cause| seen.insert(cause.condition.clone()));
491    }
492    causes
493}
494
495pub fn build_explanation<'plan>(
496    exec_rule: &ExecutableRule,
497    context: &mut EvaluationContext<'plan>,
498    plan: &ExecutionPlan,
499    built: &HashMap<RulePath, Explanation>,
500) -> Explanation {
501    let authoritative_result = context
502        .rule_results
503        .get(&exec_rule.path)
504        .expect("BUG: rule evaluated before explain")
505        .clone();
506
507    let (body, causes, children) = match winning_source_branch_and_causes(exec_rule, context) {
508        WinningSourceBranch::BranchResult {
509            result_expression,
510            causes,
511        } => (
512            format_expression(result_expression),
513            causes,
514            build_expression_children(result_expression, context, plan, built),
515        ),
516        WinningSourceBranch::ConditionVeto {
517            condition_expression,
518            causes,
519        } => (
520            // No branch was selected: the rule result is the condition's
521            // veto, so the body names the vetoing condition and the
522            // children show its operands.
523            format_expression(condition_expression),
524            causes,
525            build_expression_children(condition_expression, context, plan, built),
526        ),
527    };
528
529    Explanation {
530        rule: exec_rule.path.clone(),
531        result: authoritative_result,
532        body,
533        causes,
534        children,
535    }
536}
537
538fn embed_rule(rule_path: &RulePath, built: &HashMap<RulePath, Explanation>) -> ExplanationNode {
539    built
540        .get(rule_path)
541        .expect("BUG: rule explanation must be built before dependents")
542        .as_rule_node()
543}
544
545fn build_expression_children<'plan>(
546    expr: &Expression,
547    context: &mut EvaluationContext<'plan>,
548    plan: &ExecutionPlan,
549    built: &HashMap<RulePath, Explanation>,
550) -> Vec<ExplanationNode> {
551    if let Some(rule_paths) = direct_in_spec_rule_children(expr) {
552        return rule_paths
553            .into_iter()
554            .map(|rule_path| embed_rule(&rule_path, built))
555            .collect();
556    }
557
558    if let Some(data_paths) = direct_data_children(expr) {
559        return data_paths
560            .into_iter()
561            .map(|data_path| build_data_input_node(&data_path, context))
562            .collect();
563    }
564
565    if matches!(expr.kind, ExpressionKind::Literal(_)) {
566        return Vec::new();
567    }
568
569    match &expr.kind {
570        ExpressionKind::Arithmetic(left, _, right)
571        | ExpressionKind::Comparison(left, _, right)
572        | ExpressionKind::LogicalAnd(left, right)
573        | ExpressionKind::LogicalOr(left, right)
574        | ExpressionKind::RangeLiteral(left, right)
575        | ExpressionKind::RangeContainment(left, right) => {
576            let operands = vec![
577                build_expression_node(left, context, plan, built),
578                build_expression_node(right, context, plan, built),
579            ];
580            let mut rule_paths = HashSet::new();
581            expr.collect_rule_paths(&mut rule_paths);
582            if !rule_paths.is_empty() {
583                return vec![ExplanationNode::Compose {
584                    expression: format_expression(expr),
585                    operands,
586                }];
587            }
588            // Keep the operand nodes: users need the values that drove the
589            // comparison or computation, not only its outcome.
590            operands
591        }
592        ExpressionKind::LogicalNegation(operand, _)
593        | ExpressionKind::MathematicalComputation(_, operand)
594        | ExpressionKind::ResultIsVeto(operand)
595        | ExpressionKind::PastFutureRange(_, operand) => {
596            let operands = vec![build_expression_node(operand, context, plan, built)];
597            let mut rule_paths = HashSet::new();
598            expr.collect_rule_paths(&mut rule_paths);
599            if !rule_paths.is_empty() {
600                return vec![ExplanationNode::Compose {
601                    expression: format_expression(expr),
602                    operands,
603                }];
604            }
605            operands
606        }
607        ExpressionKind::DateRelative(_, date_expr)
608        | ExpressionKind::DateCalendar(_, _, date_expr) => {
609            let operands = vec![build_expression_node(date_expr, context, plan, built)];
610            let mut rule_paths = HashSet::new();
611            expr.collect_rule_paths(&mut rule_paths);
612            if !rule_paths.is_empty() {
613                return vec![ExplanationNode::Compose {
614                    expression: format_expression(expr),
615                    operands,
616                }];
617            }
618            operands
619        }
620        ExpressionKind::Veto(veto_expr) => {
621            if veto_expr.message.is_none() {
622                Vec::new()
623            } else {
624                vec![ExplanationNode::Veto {
625                    message: veto_expr.message.clone(),
626                }]
627            }
628        }
629        ExpressionKind::UnitConversion(value_expr, target) => {
630            vec![build_conversion_node(
631                value_expr, target, expr, context, plan, built,
632            )]
633        }
634        ExpressionKind::Now => Vec::new(),
635        ExpressionKind::Piecewise(_) => {
636            unreachable!("BUG: Piecewise in source expression for explanation")
637        }
638        ExpressionKind::RulePath(_) | ExpressionKind::DataPath(_) | ExpressionKind::Literal(_) => {
639            unreachable!(
640                "BUG: expression kind must be handled by build_expression_children fast path"
641            )
642        }
643    }
644}
645
646fn build_expression_node<'plan>(
647    expr: &Expression,
648    context: &mut EvaluationContext<'plan>,
649    plan: &ExecutionPlan,
650    built: &HashMap<RulePath, Explanation>,
651) -> ExplanationNode {
652    match &expr.kind {
653        ExpressionKind::RulePath(rule_path) => embed_rule(rule_path, built),
654        ExpressionKind::DataPath(data_path) => build_data_input_node(data_path, context),
655        ExpressionKind::Literal(lit) => ExplanationNode::DataInput {
656            data: DataPath::local(String::new()),
657            display: lit.display_value(),
658        },
659        ExpressionKind::UnitConversion(value_expr, target) => {
660            build_conversion_node(value_expr, target, expr, context, plan, built)
661        }
662        ExpressionKind::Veto(veto_expr) => ExplanationNode::Veto {
663            message: veto_expr.message.clone(),
664        },
665        ExpressionKind::Arithmetic(left, _, right)
666        | ExpressionKind::Comparison(left, _, right)
667        | ExpressionKind::LogicalAnd(left, right)
668        | ExpressionKind::LogicalOr(left, right)
669        | ExpressionKind::RangeLiteral(left, right)
670        | ExpressionKind::RangeContainment(left, right) => {
671            let operands = vec![
672                build_expression_node(left, context, plan, built),
673                build_expression_node(right, context, plan, built),
674            ];
675            ExplanationNode::Compose {
676                expression: format_expression(expr),
677                operands,
678            }
679        }
680        ExpressionKind::LogicalNegation(operand, _)
681        | ExpressionKind::MathematicalComputation(_, operand)
682        | ExpressionKind::ResultIsVeto(operand)
683        | ExpressionKind::PastFutureRange(_, operand) => {
684            let operands = vec![build_expression_node(operand, context, plan, built)];
685            ExplanationNode::Compose {
686                expression: format_expression(expr),
687                operands,
688            }
689        }
690        ExpressionKind::DateRelative(_, date_expr)
691        | ExpressionKind::DateCalendar(_, _, date_expr) => {
692            let operands = vec![build_expression_node(date_expr, context, plan, built)];
693            ExplanationNode::Compose {
694                expression: format_expression(expr),
695                operands,
696            }
697        }
698        ExpressionKind::Now => ExplanationNode::DataInput {
699            data: DataPath::local(String::new()),
700            display: context.now().display_value(),
701        },
702        ExpressionKind::Piecewise(_) => {
703            unreachable!("BUG: Piecewise in source expression for explanation")
704        }
705    }
706}
707
708fn build_conversion_node<'plan>(
709    value_expr: &Expression,
710    target: &SemanticConversionTarget,
711    expr: &Expression,
712    context: &mut EvaluationContext<'plan>,
713    plan: &ExecutionPlan,
714    built: &HashMap<RulePath, Explanation>,
715) -> ExplanationNode {
716    let operand_result = resolve_source_expression_values(value_expr, context);
717    let OperationResult::Value(operand_value) = operand_result else {
718        if let OperationResult::Veto(veto) = operand_result {
719            return ExplanationNode::Veto {
720                message: Some(veto.to_string()),
721            };
722        }
723        unreachable!("BUG: conversion operand missing value");
724    };
725
726    let converted_result = resolve_source_expression_values(expr, context);
727    let OperationResult::Value(converted_value) = converted_result else {
728        if let OperationResult::Veto(veto) = converted_result {
729            return ExplanationNode::Veto {
730                message: Some(veto.to_string()),
731            };
732        }
733        unreachable!("BUG: conversion result missing value");
734    };
735
736    let data_ref = data_path_in_expression(value_expr);
737    let steps = build_conversion_steps(
738        &operand_value,
739        target,
740        &converted_value,
741        data_ref.as_ref(),
742        UnitResolutionContext::WithIndex(plan.expression_unit_index()),
743    );
744    assert!(
745        !steps.is_empty(),
746        "BUG: unit conversion succeeded but explanation steps are empty"
747    );
748
749    ExplanationNode::Conversion {
750        expression: format_expression(expr),
751        steps: steps
752            .iter()
753            .map(SerializedConversionTraceStep::from)
754            .collect(),
755        operands: vec![build_expression_node(value_expr, context, plan, built)],
756    }
757}
758
759impl From<&ConversionTraceStep> for SerializedConversionTraceStep {
760    fn from(step: &ConversionTraceStep) -> Self {
761        Self {
762            role: match step.role {
763                ConversionTraceRole::Outcome => "outcome".to_string(),
764                ConversionTraceRole::Rule => "rule".to_string(),
765                ConversionTraceRole::Source => "source".to_string(),
766            },
767            text: step.text.clone(),
768        }
769    }
770}
771
772fn build_data_input_node(
773    data_path: &DataPath,
774    context: &mut EvaluationContext<'_>,
775) -> ExplanationNode {
776    match resolve_data_path_value(data_path, context) {
777        OperationResult::Value(value) => ExplanationNode::DataInput {
778            data: data_path.clone(),
779            display: value.display_value(),
780        },
781        OperationResult::Veto(veto) => ExplanationNode::Veto {
782            message: Some(veto.to_string()),
783        },
784    }
785}
786
787fn data_path_in_expression(value_expr: &Expression) -> Option<DataPath> {
788    if let ExpressionKind::DataPath(data_path) = &value_expr.kind {
789        Some(data_path.clone())
790    } else {
791        None
792    }
793}
794
795fn direct_in_spec_rule_children(expr: &Expression) -> Option<Vec<RulePath>> {
796    collect_flat_in_spec_add_rule_paths(expr)
797}
798
799fn collect_flat_in_spec_add_rule_paths(expr: &Expression) -> Option<Vec<RulePath>> {
800    match &expr.kind {
801        ExpressionKind::Arithmetic(left, ArithmeticComputation::Add, right) => {
802            let mut paths = collect_flat_in_spec_add_rule_paths(left)?;
803            paths.extend(collect_flat_in_spec_add_rule_paths(right)?);
804            Some(paths)
805        }
806        _ => Some(vec![single_in_spec_rule(expr)?]),
807    }
808}
809
810fn single_in_spec_rule(expr: &Expression) -> Option<RulePath> {
811    match &expr.kind {
812        ExpressionKind::RulePath(path) => Some(path.clone()),
813        _ => None,
814    }
815}
816
817fn direct_data_children(expr: &Expression) -> Option<Vec<DataPath>> {
818    let mut leaves = Vec::new();
819    if !collect_data_leaves(expr, &mut leaves) {
820        return None;
821    }
822    if leaves.is_empty() {
823        return None;
824    }
825    Some(leaves)
826}
827
828fn collect_data_leaves(expr: &Expression, out: &mut Vec<DataPath>) -> bool {
829    match &expr.kind {
830        ExpressionKind::DataPath(path) => {
831            out.push(path.clone());
832            true
833        }
834        ExpressionKind::Arithmetic(left, _, right) => {
835            collect_data_leaves(left, out) && collect_data_leaves(right, out)
836        }
837        _ => false,
838    }
839}
840
841pub fn format_explanation(explanation: &Explanation) -> String {
842    let mut lines = Vec::new();
843    lines.push(format!(
844        "{}: {}",
845        explanation.rule.rule,
846        format_operation_result(&explanation.result)
847    ));
848    let mut ctx = FormatContext {
849        lines: &mut lines,
850        indent: String::new(),
851    };
852    if !explanation.body.is_empty() {
853        ctx.push_line(Connector::Last, &explanation.body);
854    }
855    let child_indent = ctx.child_indent(Connector::Last);
856    let mut child_ctx = FormatContext {
857        lines: ctx.lines,
858        indent: child_indent,
859    };
860    child_ctx.render_causes_and_children(
861        &explanation.body,
862        &explanation.causes,
863        &explanation.children,
864    );
865    lines.join("\n")
866}
867
868#[derive(Copy, Clone)]
869enum Connector {
870    Branch,
871    Last,
872}
873
874struct FormatContext<'a> {
875    lines: &'a mut Vec<String>,
876    indent: String,
877}
878
879impl<'a> FormatContext<'a> {
880    fn push_line(&mut self, connector: Connector, text: &str) {
881        self.lines.push(format!(
882            "{}{} {text}",
883            self.indent,
884            connector_str(connector)
885        ));
886    }
887
888    fn child_indent(&self, connector: Connector) -> String {
889        match connector {
890            Connector::Branch => format!("{}│  ", self.indent),
891            Connector::Last => format!("{}   ", self.indent),
892        }
893    }
894
895    fn render_causes_and_children(
896        &mut self,
897        parent_body: &str,
898        causes: &[Cause],
899        children: &[ExplanationNode],
900    ) {
901        let visible: Vec<_> = children
902            .iter()
903            .filter(|child| !should_skip_in_text(child, parent_body))
904            .collect();
905        let total = causes.len() + visible.len();
906        let mut index = 0;
907
908        for cause in causes {
909            index += 1;
910            let connector = if index == total {
911                Connector::Last
912            } else {
913                Connector::Branch
914            };
915            self.push_line(
916                connector,
917                &format!("{} is {}", cause.condition, cause.value),
918            );
919        }
920
921        for child in visible {
922            index += 1;
923            let connector = if index == total {
924                Connector::Last
925            } else {
926                Connector::Branch
927            };
928            self.render_node(child, connector, Some(parent_body));
929        }
930    }
931
932    fn render_node(
933        &mut self,
934        node: &ExplanationNode,
935        connector: Connector,
936        parent_body: Option<&str>,
937    ) {
938        match node {
939            ExplanationNode::Rule {
940                rule,
941                body,
942                causes,
943                children,
944            } => {
945                let style = rule_line_style(body, children);
946                match style {
947                    RuleLineStyle::NameOnly => {
948                        self.push_line(connector, &rule.rule);
949                        let child_indent = self.child_indent(connector);
950                        let mut child_ctx = FormatContext {
951                            lines: self.lines,
952                            indent: child_indent,
953                        };
954                        if !body.is_empty() {
955                            child_ctx.push_line(Connector::Last, body);
956                            let body_child_indent = child_ctx.child_indent(Connector::Last);
957                            let mut nested = FormatContext {
958                                lines: child_ctx.lines,
959                                indent: body_child_indent,
960                            };
961                            nested.render_causes_and_children(body, causes, children);
962                        } else {
963                            child_ctx.render_causes_and_children(body, causes, children);
964                        }
965                    }
966                    RuleLineStyle::NameWithBody => {
967                        self.push_line(connector, &format!("{}: {body}", rule.rule));
968                        let child_indent = self.child_indent(connector);
969                        let mut child_ctx = FormatContext {
970                            lines: self.lines,
971                            indent: child_indent,
972                        };
973                        child_ctx.render_causes_and_children(body, causes, children);
974                    }
975                }
976            }
977            ExplanationNode::Compose {
978                expression,
979                operands,
980            } => {
981                self.push_line(connector, expression);
982                let child_indent = self.child_indent(connector);
983                let mut child_ctx = FormatContext {
984                    lines: self.lines,
985                    indent: child_indent,
986                };
987                let len = operands.len();
988                for (i, operand) in operands.iter().enumerate() {
989                    let child_connector = if i + 1 == len {
990                        Connector::Last
991                    } else {
992                        Connector::Branch
993                    };
994                    child_ctx.render_node(operand, child_connector, None);
995                }
996            }
997            ExplanationNode::DataInput { data, display } => {
998                if data.data.is_empty() {
999                    self.push_line(connector, display);
1000                } else {
1001                    self.push_line(connector, &format!("{data}: {display}"));
1002                }
1003            }
1004            ExplanationNode::Conversion {
1005                expression,
1006                steps,
1007                operands,
1008                ..
1009            } => {
1010                let expression_is_parent_body = parent_body.is_some_and(|body| body == expression);
1011                if !expression_is_parent_body {
1012                    self.push_line(connector, expression);
1013                }
1014                render_conversion_steps(self, connector, steps);
1015                let child_indent = self.child_indent(connector);
1016                let mut child_ctx = FormatContext {
1017                    lines: self.lines,
1018                    indent: child_indent,
1019                };
1020                let len = operands.len();
1021                for (i, operand) in operands.iter().enumerate() {
1022                    let child_connector = if i + 1 == len {
1023                        Connector::Last
1024                    } else {
1025                        Connector::Branch
1026                    };
1027                    child_ctx.render_node(operand, child_connector, None);
1028                }
1029            }
1030            ExplanationNode::Veto { message } => {
1031                self.push_line(
1032                    connector,
1033                    message
1034                        .as_deref()
1035                        .expect("BUG: veto explanation must carry message"),
1036                );
1037            }
1038        }
1039    }
1040}
1041
1042fn connector_str(connector: Connector) -> &'static str {
1043    match connector {
1044        Connector::Branch => "├─",
1045        Connector::Last => "└─",
1046    }
1047}
1048
1049fn should_skip_in_text(node: &ExplanationNode, parent_body: &str) -> bool {
1050    match node {
1051        ExplanationNode::Compose { expression, .. } => expression == parent_body,
1052        _ => false,
1053    }
1054}
1055
1056fn render_conversion_steps(
1057    ctx: &mut FormatContext<'_>,
1058    connector: Connector,
1059    steps: &[SerializedConversionTraceStep],
1060) {
1061    if steps.is_empty() {
1062        return;
1063    }
1064    let child_indent = ctx.child_indent(connector);
1065    let mut step_ctx = FormatContext {
1066        lines: ctx.lines,
1067        indent: child_indent,
1068    };
1069    for (index, step) in steps.iter().enumerate() {
1070        let step_connector = if index + 1 == steps.len() {
1071            Connector::Last
1072        } else {
1073            Connector::Branch
1074        };
1075        step_ctx.push_line(step_connector, &step.text);
1076    }
1077}
1078
1079enum RuleLineStyle {
1080    NameOnly,
1081    NameWithBody,
1082}
1083
1084fn rule_line_style(body: &str, children: &[ExplanationNode]) -> RuleLineStyle {
1085    if children
1086        .iter()
1087        .all(|child| matches!(child, ExplanationNode::Rule { .. }))
1088        && children.len() >= 2
1089    {
1090        return RuleLineStyle::NameOnly;
1091    }
1092    if children.len() == 1 {
1093        if let ExplanationNode::Compose { expression, .. } = &children[0] {
1094            if expression == body {
1095                return RuleLineStyle::NameWithBody;
1096            }
1097        }
1098    }
1099    RuleLineStyle::NameWithBody
1100}
1101
1102fn expression_precedence(kind: &ExpressionKind) -> u8 {
1103    match kind {
1104        ExpressionKind::LogicalAnd(..) | ExpressionKind::LogicalOr(..) => 2,
1105        ExpressionKind::LogicalNegation(..) => 3,
1106        ExpressionKind::Comparison(..) | ExpressionKind::ResultIsVeto(..) => 4,
1107        ExpressionKind::RangeContainment(..) => 4,
1108        ExpressionKind::DateRelative(..) | ExpressionKind::DateCalendar(..) => 4,
1109        ExpressionKind::Arithmetic(_, op, _) => match op {
1110            ArithmeticComputation::Add | ArithmeticComputation::Subtract => 5,
1111            ArithmeticComputation::Multiply
1112            | ArithmeticComputation::Divide
1113            | ArithmeticComputation::Modulo => 6,
1114            ArithmeticComputation::Power => 7,
1115        },
1116        ExpressionKind::UnitConversion(..) => 8,
1117        ExpressionKind::RangeLiteral(..) => 9,
1118        ExpressionKind::MathematicalComputation(..) | ExpressionKind::PastFutureRange(..) => 10,
1119        ExpressionKind::Literal(_)
1120        | ExpressionKind::DataPath(_)
1121        | ExpressionKind::RulePath(_)
1122        | ExpressionKind::Now
1123        | ExpressionKind::Veto(_)
1124        | ExpressionKind::Piecewise(_) => 10,
1125    }
1126}
1127
1128fn write_expression_child(out: &mut String, child: &Expression, parent_prec: u8) {
1129    let child_prec = expression_precedence(&child.kind);
1130    if child_prec < parent_prec {
1131        out.push('(');
1132        out.push_str(&format_expression(child));
1133        out.push(')');
1134    } else {
1135        out.push_str(&format_expression(child));
1136    }
1137}
1138
1139pub fn format_expression(expr: &Expression) -> String {
1140    match &expr.kind {
1141        ExpressionKind::Literal(lit) => lit.display_value(),
1142        ExpressionKind::DataPath(path) => path.to_string(),
1143        ExpressionKind::RulePath(path) => path.to_string(),
1144        ExpressionKind::Arithmetic(left, op, right) => {
1145            let my_prec = expression_precedence(&expr.kind);
1146            let mut out = String::new();
1147            write_expression_child(&mut out, left, my_prec);
1148            out.push(' ');
1149            out.push_str(&op.to_string());
1150            out.push(' ');
1151            write_expression_child(&mut out, right, my_prec);
1152            out
1153        }
1154        ExpressionKind::Comparison(left, op, right) => {
1155            let my_prec = expression_precedence(&expr.kind);
1156            let mut out = String::new();
1157            write_expression_child(&mut out, left, my_prec);
1158            out.push(' ');
1159            out.push_str(&op.to_string());
1160            out.push(' ');
1161            write_expression_child(&mut out, right, my_prec);
1162            out
1163        }
1164        ExpressionKind::UnitConversion(value, target) => {
1165            let my_prec = expression_precedence(&expr.kind);
1166            let mut out = String::new();
1167            write_expression_child(&mut out, value, my_prec);
1168            out.push_str(" as ");
1169            out.push_str(&target.to_string());
1170            out
1171        }
1172        ExpressionKind::LogicalNegation(inner, negation) => {
1173            if let (NegationType::Not, ExpressionKind::ResultIsVeto(operand)) =
1174                (negation, &inner.kind)
1175            {
1176                let my_prec = expression_precedence(&expr.kind);
1177                let mut out = String::new();
1178                write_expression_child(&mut out, operand, my_prec);
1179                out.push_str(" is not veto");
1180                out
1181            } else {
1182                let my_prec = expression_precedence(&expr.kind);
1183                let mut out = String::from("not ");
1184                write_expression_child(&mut out, inner, my_prec);
1185                out
1186            }
1187        }
1188        ExpressionKind::ResultIsVeto(operand) => {
1189            let my_prec = expression_precedence(&expr.kind);
1190            let mut out = String::new();
1191            write_expression_child(&mut out, operand, my_prec);
1192            out.push_str(" is veto");
1193            out
1194        }
1195        ExpressionKind::LogicalAnd(left, right) => {
1196            let my_prec = expression_precedence(&expr.kind);
1197            let mut out = String::new();
1198            write_expression_child(&mut out, left, my_prec);
1199            out.push_str(" and ");
1200            write_expression_child(&mut out, right, my_prec);
1201            out
1202        }
1203        ExpressionKind::LogicalOr(left, right) => {
1204            let my_prec = expression_precedence(&expr.kind);
1205            let mut out = String::new();
1206            write_expression_child(&mut out, left, my_prec);
1207            out.push_str(" or ");
1208            write_expression_child(&mut out, right, my_prec);
1209            out
1210        }
1211        ExpressionKind::MathematicalComputation(op, operand) => {
1212            let my_prec = expression_precedence(&expr.kind);
1213            let mut out = format!("{op} ");
1214            write_expression_child(&mut out, operand, my_prec);
1215            out
1216        }
1217        ExpressionKind::Veto(veto) => match &veto.message {
1218            Some(msg) => format!("veto \"{msg}\""),
1219            None => "veto".to_string(),
1220        },
1221        ExpressionKind::Now => "now".to_string(),
1222        ExpressionKind::DateRelative(kind, date_expr) => {
1223            format!("{} {}", format_expression(date_expr), kind)
1224        }
1225        ExpressionKind::DateCalendar(kind, unit, date_expr) => {
1226            format!("{} {} {}", format_expression(date_expr), kind, unit)
1227        }
1228        ExpressionKind::RangeLiteral(left, right) => {
1229            let my_prec = expression_precedence(&expr.kind);
1230            let mut out = String::new();
1231            write_expression_child(&mut out, left, my_prec);
1232            out.push_str("...");
1233            write_expression_child(&mut out, right, my_prec);
1234            out
1235        }
1236        ExpressionKind::PastFutureRange(kind, offset_expr) => {
1237            let my_prec = expression_precedence(&expr.kind);
1238            let mut out = format!("{} ", kind);
1239            write_expression_child(&mut out, offset_expr, my_prec);
1240            out
1241        }
1242        ExpressionKind::RangeContainment(value, range) => {
1243            let my_prec = expression_precedence(&expr.kind);
1244            let mut out = String::new();
1245            write_expression_child(&mut out, value, my_prec);
1246            out.push_str(" in ");
1247            write_expression_child(&mut out, range, my_prec);
1248            out
1249        }
1250        ExpressionKind::Piecewise(_) => {
1251            unreachable!("BUG: Piecewise in source expression for explanation formatting")
1252        }
1253    }
1254}
1255
1256#[cfg(test)]
1257mod tests {
1258    use super::*;
1259    use crate::computation::rational::rational_new;
1260    use crate::computation::UnitResolutionContext;
1261    use crate::limits::ResourceLimits;
1262    use crate::literals::DateGranularity;
1263    use crate::literals::QuantityUnit;
1264    use crate::parsing::ast::DateTimeValue;
1265    use crate::parsing::source::SourceType;
1266    use crate::planning::data_input::DataValueInput;
1267    use crate::planning::execution_plan::DataOverlay;
1268    use crate::planning::semantics::{
1269        date_time_to_semantic, DataPath, LemmaType, LiteralValue, QuantityUnits, RulePath,
1270        SemanticConversionTarget, TypeSpecification, ValueKind,
1271    };
1272    use crate::Engine;
1273    use rust_decimal::Decimal;
1274    use std::collections::HashMap;
1275    use std::path::PathBuf;
1276    use std::sync::Arc;
1277
1278    const CALC_SPEC: &str = r#"
1279spec calc
1280
1281data money: quantity
1282  -> decimals 2
1283  -> unit eur 1
1284
1285data hourly_rate: 85.00 eur
1286data hours_worked: 37.5
1287data is_rush: boolean
1288data is_super_rush: boolean
1289
1290rule labor: hourly_rate * hours_worked
1291rule rush_surcharge: 0 eur
1292  unless is_rush then labor * 25%
1293  unless is_super_rush then labor * 50%
1294rule subtotal: labor + rush_surcharge
1295rule vat: subtotal * 21%
1296rule total: subtotal + vat
1297"#;
1298
1299    const CALC_TOTAL_IS_RUSH_ONLY_GOLDEN_JSON: &str = r#"{
1300  "rule": "total",
1301  "result": "4821.09 eur",
1302  "body": "subtotal + vat",
1303  "children": [
1304    {
1305      "type": "rule",
1306      "rule": "subtotal",
1307      "body": "labor + rush_surcharge",
1308      "children": [
1309        {
1310          "type": "rule",
1311          "rule": "labor",
1312          "body": "hourly_rate * hours_worked",
1313          "children": [
1314            {
1315              "type": "data_input",
1316              "data": "hourly_rate",
1317              "display": "85.00 eur"
1318            },
1319            {
1320              "type": "data_input",
1321              "data": "hours_worked",
1322              "display": "37.5"
1323            }
1324          ]
1325        },
1326        {
1327          "type": "rule",
1328          "rule": "rush_surcharge",
1329          "body": "labor * 25%",
1330          "causes": [
1331            { "condition": "is_rush", "value": "true" },
1332            { "condition": "is_super_rush", "value": "false" }
1333          ],
1334          "children": [
1335            {
1336              "type": "compose",
1337              "expression": "labor * 25%",
1338              "operands": [
1339                {
1340                  "type": "rule",
1341                  "rule": "labor",
1342                  "body": "hourly_rate * hours_worked",
1343                  "children": [
1344                    {
1345                      "type": "data_input",
1346                      "data": "hourly_rate",
1347                      "display": "85.00 eur"
1348                    },
1349                    {
1350                      "type": "data_input",
1351                      "data": "hours_worked",
1352                      "display": "37.5"
1353                    }
1354                  ]
1355                },
1356                {
1357                  "type": "data_input",
1358                  "data": "",
1359                  "display": "25%"
1360                }
1361              ]
1362            }
1363          ]
1364        }
1365      ]
1366    },
1367    {
1368      "type": "rule",
1369      "rule": "vat",
1370      "body": "subtotal * 21%",
1371      "children": [
1372        {
1373          "type": "compose",
1374          "expression": "subtotal * 21%",
1375          "operands": [
1376            {
1377              "type": "rule",
1378              "rule": "subtotal",
1379              "body": "labor + rush_surcharge",
1380              "children": [
1381                {
1382                  "type": "rule",
1383                  "rule": "labor",
1384                  "body": "hourly_rate * hours_worked",
1385                  "children": [
1386                    {
1387                      "type": "data_input",
1388                      "data": "hourly_rate",
1389                      "display": "85.00 eur"
1390                    },
1391                    {
1392                      "type": "data_input",
1393                      "data": "hours_worked",
1394                      "display": "37.5"
1395                    }
1396                  ]
1397                },
1398                {
1399                  "type": "rule",
1400                  "rule": "rush_surcharge",
1401                  "body": "labor * 25%",
1402                  "causes": [
1403                    { "condition": "is_rush", "value": "true" },
1404                    { "condition": "is_super_rush", "value": "false" }
1405                  ],
1406                  "children": [
1407                    {
1408                      "type": "compose",
1409                      "expression": "labor * 25%",
1410                      "operands": [
1411                        {
1412                          "type": "rule",
1413                          "rule": "labor",
1414                          "body": "hourly_rate * hours_worked",
1415                          "children": [
1416                            {
1417                              "type": "data_input",
1418                              "data": "hourly_rate",
1419                              "display": "85.00 eur"
1420                            },
1421                            {
1422                              "type": "data_input",
1423                              "data": "hours_worked",
1424                              "display": "37.5"
1425                            }
1426                          ]
1427                        },
1428                        {
1429                          "type": "data_input",
1430                          "data": "",
1431                          "display": "25%"
1432                        }
1433                      ]
1434                    }
1435                  ]
1436                }
1437              ]
1438            },
1439            {
1440              "type": "data_input",
1441              "data": "",
1442              "display": "21%"
1443            }
1444          ]
1445        }
1446      ]
1447    }
1448  ]
1449}"#;
1450
1451    fn rush_surcharge_causes(data: HashMap<String, String>) -> Vec<Cause> {
1452        let mut engine = Engine::new();
1453        engine
1454            .load(CALC_SPEC, crate::SourceType::Volatile)
1455            .expect("calc spec loads");
1456        let now = DateTimeValue::now();
1457        let plan = engine
1458            .get_plan(None, "calc", Some(&now))
1459            .expect("calc plan");
1460        let overlay = DataOverlay::resolve(
1461            plan,
1462            data.into_iter()
1463                .map(|(k, v)| (k, DataValueInput::convenience(v)))
1464                .collect(),
1465            &ResourceLimits::default(),
1466        )
1467        .expect("overlay");
1468        let now_lit = LiteralValue {
1469            value: ValueKind::Date(crate::planning::semantics::date_time_to_semantic(&now)),
1470            lemma_type: crate::planning::semantics::primitive_date_arc().clone(),
1471        };
1472        let mut context = EvaluationContext::new(plan, &overlay, now_lit);
1473        let rush_rule = plan
1474            .get_rule("rush_surcharge")
1475            .expect("rush_surcharge rule");
1476        match winning_source_branch_and_causes(rush_rule, &mut context) {
1477            WinningSourceBranch::BranchResult { causes, .. } => causes,
1478            WinningSourceBranch::ConditionVeto { causes, .. } => causes,
1479        }
1480    }
1481
1482    #[test]
1483    fn unless_causes_neither_matches() {
1484        let mut data = HashMap::new();
1485        data.insert("is_rush".into(), "false".into());
1486        data.insert("is_super_rush".into(), "false".into());
1487        let causes = rush_surcharge_causes(data);
1488        assert_eq!(
1489            causes,
1490            vec![
1491                Cause {
1492                    condition: "is_rush".to_string(),
1493                    value: "false".to_string(),
1494                },
1495                Cause {
1496                    condition: "is_super_rush".to_string(),
1497                    value: "false".to_string(),
1498                },
1499            ]
1500        );
1501    }
1502
1503    #[test]
1504    fn calc_total_is_rush_only_serializes_to_golden_json() {
1505        let mut data = HashMap::new();
1506        data.insert("is_rush".into(), "true".into());
1507        data.insert("is_super_rush".into(), "false".into());
1508
1509        let mut engine = Engine::new();
1510        engine
1511            .load(CALC_SPEC, crate::SourceType::Volatile)
1512            .expect("calc spec loads");
1513        let now = DateTimeValue::now();
1514        let response = engine
1515            .run(None, "calc", Some(&now), data, true, None)
1516            .expect("calc eval succeeds");
1517        let explanation = response
1518            .results
1519            .get("total")
1520            .expect("total rule evaluated")
1521            .explanation
1522            .as_ref()
1523            .expect("explanation always built");
1524
1525        let actual: serde_json::Value =
1526            serde_json::to_value(explanation).expect("explanation serializes");
1527        let expected: serde_json::Value =
1528            serde_json::from_str(CALC_TOTAL_IS_RUSH_ONLY_GOLDEN_JSON).expect("golden json parses");
1529        assert_eq!(actual, expected);
1530    }
1531
1532    #[test]
1533    fn unless_causes_is_rush_only() {
1534        let mut data = HashMap::new();
1535        data.insert("is_rush".into(), "true".into());
1536        data.insert("is_super_rush".into(), "false".into());
1537        let causes = rush_surcharge_causes(data);
1538        assert_eq!(
1539            causes,
1540            vec![
1541                Cause {
1542                    condition: "is_rush".to_string(),
1543                    value: "true".to_string(),
1544                },
1545                Cause {
1546                    condition: "is_super_rush".to_string(),
1547                    value: "false".to_string(),
1548                },
1549            ]
1550        );
1551    }
1552
1553    #[test]
1554    fn unless_causes_is_super_rush() {
1555        let mut data = HashMap::new();
1556        data.insert("is_rush".into(), "true".into());
1557        data.insert("is_super_rush".into(), "true".into());
1558        let causes = rush_surcharge_causes(data);
1559        assert_eq!(
1560            causes,
1561            vec![Cause {
1562                condition: "is_super_rush".to_string(),
1563                value: "true".to_string(),
1564            }]
1565        );
1566    }
1567
1568    #[test]
1569    fn conversion_source_step_text_with_data_reference() {
1570        let operand = LiteralValue::quantity_with_type(
1571            rational_new(2, 1),
1572            "kilogram".to_string(),
1573            Arc::new(LemmaType::primitive(TypeSpecification::quantity())),
1574        );
1575        let path = DataPath::local("mass".to_string());
1576        let text = conversion_source_step_text(&operand, Some(&path));
1577        assert_eq!(text, "The quantity of mass is 2 kilogram");
1578    }
1579
1580    #[test]
1581    fn build_conversion_steps_scalar_quantity() {
1582        let mut units = QuantityUnits::new();
1583        units.0.push(
1584            QuantityUnit::from_decimal_factor("kilogram".to_string(), Decimal::ONE, vec![])
1585                .unwrap(),
1586        );
1587        units.0.push(
1588            QuantityUnit::from_decimal_factor("gram".to_string(), Decimal::new(1, 3), vec![])
1589                .unwrap(),
1590        );
1591        let lemma_type = Arc::new(LemmaType::primitive(TypeSpecification::Quantity {
1592            minimum: None,
1593            maximum: None,
1594            decimals: None,
1595            units,
1596            traits: vec![],
1597            decomposition: Default::default(),
1598            help: String::new(),
1599        }));
1600        let operand = LiteralValue::quantity_with_type(
1601            rational_new(2, 1),
1602            "kilogram".to_string(),
1603            Arc::clone(&lemma_type),
1604        );
1605        let result =
1606            LiteralValue::quantity_with_type(rational_new(2, 1), "gram".to_string(), lemma_type);
1607        let path = DataPath::local("mass".to_string());
1608        let steps = build_conversion_steps(
1609            &operand,
1610            &SemanticConversionTarget::Unit {
1611                unit_name: "gram".to_string(),
1612            },
1613            &result,
1614            Some(&path),
1615            UnitResolutionContext::NamedQuantityOnly,
1616        );
1617        assert_eq!(steps.len(), 3);
1618        assert!(matches!(steps[0].role, ConversionTraceRole::Outcome));
1619        assert_eq!(steps[0].text, "2000 gram");
1620        assert!(matches!(steps[1].role, ConversionTraceRole::Rule));
1621        assert_eq!(steps[1].text, "1 kilogram is 1000 gram");
1622        assert!(matches!(steps[2].role, ConversionTraceRole::Source));
1623        assert_eq!(steps[2].text, "The quantity of mass is 2 kilogram");
1624        assert_eq!(steps[2].data_ref, Some(path));
1625    }
1626
1627    #[test]
1628    fn build_conversion_steps_date_range() {
1629        let left = LiteralValue::date(date_time_to_semantic(&DateTimeValue {
1630            year: 2024,
1631            month: 6,
1632            day: 1,
1633            hour: 0,
1634            minute: 0,
1635            second: 0,
1636            microsecond: 0,
1637            timezone: None,
1638
1639            granularity: DateGranularity::Full,
1640        }));
1641        let right = LiteralValue::date(date_time_to_semantic(&DateTimeValue {
1642            year: 2024,
1643            month: 6,
1644            day: 15,
1645            hour: 0,
1646            minute: 0,
1647            second: 0,
1648            microsecond: 0,
1649            timezone: None,
1650
1651            granularity: DateGranularity::Full,
1652        }));
1653        let range = LiteralValue {
1654            value: ValueKind::Range(Box::new(left), Box::new(right)),
1655            lemma_type: Arc::new(LemmaType::primitive(TypeSpecification::date_range())),
1656        };
1657        let result = LiteralValue::quantity_with_type(
1658            rational_new(14, 1),
1659            "days".to_string(),
1660            Arc::new(LemmaType::primitive(TypeSpecification::quantity())),
1661        );
1662        let path = DataPath::local("age".to_string());
1663        let steps = build_conversion_steps(
1664            &range,
1665            &SemanticConversionTarget::Unit {
1666                unit_name: "days".to_string(),
1667            },
1668            &result,
1669            Some(&path),
1670            UnitResolutionContext::WithIndex(&HashMap::new()),
1671        );
1672        assert_eq!(steps.len(), 3);
1673        assert!(steps[1].text.contains('−'));
1674        assert!(steps[1].text.contains("2024-06-15"));
1675        assert!(steps[1].text.contains("2024-06-01"));
1676        assert!(steps[1].text.contains("14"));
1677        assert!(steps[2].text.contains("The date range of age is"));
1678    }
1679
1680    #[test]
1681    fn build_conversion_steps_identity_omits_rule() {
1682        let mut units = QuantityUnits::new();
1683        units.0.push(
1684            QuantityUnit::from_decimal_factor("kilogram".to_string(), Decimal::ONE, vec![])
1685                .unwrap(),
1686        );
1687        let lemma_type = Arc::new(LemmaType::primitive(TypeSpecification::Quantity {
1688            minimum: None,
1689            maximum: None,
1690            decimals: None,
1691            units,
1692            traits: vec![],
1693            decomposition: Default::default(),
1694            help: String::new(),
1695        }));
1696        let operand = LiteralValue::quantity_with_type(
1697            rational_new(2, 1),
1698            "kilogram".to_string(),
1699            Arc::clone(&lemma_type),
1700        );
1701        let result = LiteralValue::quantity_with_type(
1702            rational_new(2, 1),
1703            "kilogram".to_string(),
1704            lemma_type,
1705        );
1706        let steps = build_conversion_steps(
1707            &operand,
1708            &SemanticConversionTarget::Unit {
1709                unit_name: "kilogram".to_string(),
1710            },
1711            &result,
1712            None,
1713            UnitResolutionContext::NamedQuantityOnly,
1714        );
1715        assert_eq!(steps.len(), 2);
1716        assert!(matches!(steps[0].role, ConversionTraceRole::Outcome));
1717        assert!(matches!(steps[1].role, ConversionTraceRole::Source));
1718    }
1719
1720    #[test]
1721    fn conversion_trace_step_roundtrip() {
1722        let step = ConversionTraceStep {
1723            role: ConversionTraceRole::Rule,
1724            text: "1 kilogram is 1000 gram".to_string(),
1725            data_ref: Some(DataPath::local("mass".to_string())),
1726        };
1727        assert_eq!(step.text, "1 kilogram is 1000 gram");
1728        assert!(matches!(step.role, ConversionTraceRole::Rule));
1729    }
1730
1731    #[test]
1732    fn explanation_for_compound_signature_uses_signature_factor() {
1733        let code = r#"spec t
1734uses lemma units
1735data money: quantity
1736  -> unit eur 1
1737data rate: quantity
1738  -> unit eur_per_minute eur/minute
1739data r: 40 eur_per_minute
1740data h: 2 hour
1741rule cost: (r * h) as eur
1742"#;
1743        let mut engine = Engine::new();
1744        engine
1745            .load(code, SourceType::Path(Arc::new(PathBuf::from("t.lemma"))))
1746            .expect("must load");
1747        let response = engine
1748            .run(None, "t", None, HashMap::new(), true, None)
1749            .expect("must eval");
1750        let cost_result = response.results.get("cost").expect("rule must exist");
1751        let display = cost_result
1752            .display
1753            .as_deref()
1754            .expect("must have display value");
1755        assert!(
1756            display.contains("4800") && display.contains("eur"),
1757            "expected 4800 eur, got: {display}"
1758        );
1759    }
1760
1761    #[test]
1762    fn render_veto_with_none_message_must_not_use_placeholder_text() {
1763        use crate::evaluation::operations::{OperationResult, VetoType};
1764
1765        let explanation = Explanation {
1766            rule: RulePath::new(vec![], "r".into()),
1767            result: OperationResult::Veto(VetoType::computation("test")),
1768            body: "expr".into(),
1769            causes: vec![],
1770            children: vec![ExplanationNode::Veto { message: None }],
1771        };
1772        let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1773            format_explanation(&explanation);
1774        }));
1775        assert!(
1776            panic.is_err(),
1777            "veto node without message must crash, not render placeholder"
1778        );
1779    }
1780}