1use 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 UnitEquivalence { text: String },
86}
87
88#[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 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 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
396enum WinningSourceBranch<'a> {
398 BranchResult {
400 result_expression: &'a Expression,
401 causes: Vec<Cause>,
402 },
403 ConditionVeto {
407 condition_expression: &'a Expression,
408 causes: Vec<Cause>,
409 },
410}
411
412struct 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
424fn 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 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 decisions.sort_by_key(|(arm, _)| *arm);
469 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 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 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 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 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
587fn stated_fact(condition: &Expression, held: bool) -> (String, String) {
594 if held {
595 return match &condition.kind {
596 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 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 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
695fn 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 let mut nodes = unit_equivalence_nodes(operands, ctx);
715 nodes.extend(
716 operands
717 .iter()
718 .filter(|operand| !is_literal(operand))
721 .map(|operand| build_expression_node(operand, ctx)),
722 );
723 nodes
724}
725
726fn 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
744fn 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
758fn 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 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
891fn 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 None => Vec::new(),
961 };
962
963 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 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 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 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 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}