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