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