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