Skip to main content

lemma/evaluation/
explanations.rs

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