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