1use crate::computation::{OperationResult, VetoType};
2use crate::evaluation::explanations::Explanation;
3
4use crate::parsing::ast::DateTimeValue;
5use crate::planning::semantics::{LemmaType, LiteralValue, RulePath, Source};
6use crate::planning::unit_family::FamilyUnitCatalog;
7use crate::result_value::{
8 rule_result_value_failure_message, rule_result_value_from_literal, RuleResultValue,
9 RuleResultValueFailure,
10};
11use indexmap::IndexMap;
12use std::sync::Arc;
13
14#[derive(Debug, Clone)]
17pub struct EvaluatedRule {
18 pub name: String,
19 pub path: RulePath,
20 pub source_location: Source,
21 pub rule_type: Arc<LemmaType>,
22}
23
24#[derive(Debug, Clone)]
26pub struct Response {
27 pub spec_name: String,
28 pub effective: String,
29 pub spec_effective_from: Option<DateTimeValue>,
33 pub spec_effective_to: Option<DateTimeValue>,
34 pub results: IndexMap<String, RuleResult>,
35}
36
37#[derive(Debug, Clone)]
39pub struct RuleResult {
40 pub rule: EvaluatedRule,
41 pub veto_detail: Option<VetoType>,
42
43 pub vetoed: bool,
44 pub veto_reason: Option<String>,
45 pub rule_type: String,
46
47 pub result: Option<RuleResultValue>,
49 pub explanation: Option<Explanation>,
50 missing_data: Vec<String>,
55}
56
57impl RuleResult {
58 #[must_use]
60 pub fn result(&self) -> Option<&str> {
61 self.result
62 .as_ref()
63 .and_then(|value| value.result.as_deref())
64 }
65
66 #[must_use]
71 pub fn is_missing_data(&self) -> bool {
72 matches!(
73 self.veto_detail.as_ref(),
74 Some(VetoType::MissingData { .. })
75 )
76 }
77
78 #[must_use]
81 pub fn missing_data(&self) -> &[String] {
82 &self.missing_data
83 }
84
85 pub(crate) fn from_operation_result(
89 rule: EvaluatedRule,
90 operation_result: &OperationResult,
91 rule_type: &LemmaType,
92 family_units: &FamilyUnitCatalog,
93 explanation: Option<Explanation>,
94 missing_data: Vec<String>,
95 ) -> Self {
96 match operation_result {
97 OperationResult::Veto(VetoType::MissingData { data, .. }) => {
98 let key = data.input_key();
99 if !missing_data.iter().any(|listed| listed == &key) {
100 panic!("BUG: MissingData path {key} not in missing_data {missing_data:?}");
101 }
102 }
103 _ => {
104 if !missing_data.is_empty() {
105 panic!(
106 "BUG: missing_data must be empty when result is not MissingData: {missing_data:?}"
107 );
108 }
109 }
110 }
111 let rule_type_name = rule_type.name().to_string();
112 match operation_result {
113 OperationResult::Veto(veto) => Self {
114 rule,
115 veto_detail: Some(veto.clone()),
116 vetoed: true,
117 veto_reason: match &veto {
118 VetoType::UserDefined { message: None } => None,
119 _ => Some(veto.to_string()),
120 },
121 rule_type: rule_type_name,
122 result: None,
123 explanation,
124 missing_data,
125 },
126 OperationResult::Value(bound) => {
127 match rule_result_value_from_literal(&bound.to_literal(), rule_type, family_units) {
128 Ok(value) => Self {
129 rule,
130 veto_detail: None,
131 vetoed: false,
132 veto_reason: None,
133 rule_type: rule_type_name,
134 result: Some(value),
135 explanation,
136 missing_data,
137 },
138 Err(failure) => vetoed_rule_result_for_rule_result_value_failure(
139 rule,
140 rule_type,
141 family_units,
142 explanation,
143 failure,
144 missing_data,
145 ),
146 }
147 }
148 }
149 }
150
151 pub fn to_literal(&self) -> LiteralValue {
155 assert!(
156 !self.vetoed,
157 "BUG: to_literal called on vetoed rule '{}'",
158 self.rule.name
159 );
160 let value = self
161 .result
162 .as_ref()
163 .unwrap_or_else(|| panic!("BUG: non-vetoed rule '{}' missing result", self.rule.name));
164 value.to_literal(&self.rule.rule_type)
165 }
166}
167
168fn vetoed_rule_result_for_rule_result_value_failure(
169 rule: EvaluatedRule,
170 rule_type: &LemmaType,
171 family_units: &FamilyUnitCatalog,
172 explanation: Option<Explanation>,
173 failure: RuleResultValueFailure,
174 missing_data: Vec<String>,
175) -> RuleResult {
176 RuleResult::from_operation_result(
177 rule,
178 &OperationResult::Veto(VetoType::computation(
179 rule_result_value_failure_message(failure).to_string(),
180 )),
181 rule_type,
182 family_units,
183 explanation,
184 missing_data,
185 )
186}
187
188impl Response {
189 pub fn get(&self, rule_name: &str) -> Result<&RuleResult, crate::error::Error> {
193 self.results
194 .get(rule_name)
195 .ok_or_else(|| crate::error::Error::rule_not_found(rule_name, None::<String>))
196 }
197
198 pub fn add_result(&mut self, result: RuleResult) {
199 self.results.insert(result.rule.name.clone(), result);
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::literals::DateGranularity;
207 use crate::parsing::ast::Span;
208 use crate::planning::semantics::{
209 primitive_number_arc, BaseMeasureVector, DataPath, LemmaType, LiteralValue, MeasureUnit,
210 MeasureUnits, RatioUnit, RatioUnits, RulePath, TypeExtends, TypeSpecification, ValueKind,
211 };
212 use crate::planning::unit_family::FamilyUnitCatalog;
213 use crate::planning::unit_index::UnitIndex;
214 use rust_decimal::Decimal;
215 use std::str::FromStr;
216 use std::sync::Arc;
217
218 fn dec(s: &str) -> Decimal {
219 Decimal::from_str(s).expect("BUG: test decimal literal")
220 }
221
222 fn empty_family_catalog() -> FamilyUnitCatalog {
223 FamilyUnitCatalog::default()
224 }
225
226 fn family_catalog_for_index(index: &UnitIndex) -> FamilyUnitCatalog {
227 FamilyUnitCatalog::build(index)
228 }
229
230 fn family_catalog_for_money(money: &LemmaType) -> FamilyUnitCatalog {
231 family_catalog_for_index(&unit_index_for_money(money))
232 }
233
234 fn family_catalog_for_ratio(ratio_type: &LemmaType) -> FamilyUnitCatalog {
235 family_catalog_for_index(&unit_index_for_ratio(ratio_type))
236 }
237
238 fn unit_index_for_money(money: &LemmaType) -> UnitIndex {
239 let mut index = UnitIndex::new();
240 let arc = Arc::new(money.clone());
241 for unit_name in money.measure_unit_names().into_iter().flatten() {
242 index
243 .merge_measure_unit(unit_name.to_string(), &arc, "money", None, "money")
244 .expect("test money unit index");
245 }
246 index
247 }
248
249 fn unit_index_for_ratio(ratio_type: &LemmaType) -> UnitIndex {
250 use crate::planning::semantics::primitive_ratio_arc;
251 let mut index = UnitIndex::new();
252 let arc = Arc::new(ratio_type.clone());
253 let type_name = ratio_type.name();
254 for unit_name in ratio_type.ratio_unit_names().into_iter().flatten() {
255 index
256 .merge_ratio_unit(
257 unit_name.to_string(),
258 &arc,
259 &type_name,
260 None,
261 primitive_ratio_arc(),
262 )
263 .expect("test ratio unit index");
264 }
265 index
266 }
267
268 fn dummy_source() -> Source {
269 Source::new(
270 crate::parsing::source::SourceType::Volatile,
271 Span {
272 start: 0,
273 end: 0,
274 line: 1,
275 col: 1,
276 },
277 )
278 }
279
280 fn dummy_evaluated_rule(name: &str, rule_type: &LemmaType) -> EvaluatedRule {
281 EvaluatedRule {
282 name: name.to_string(),
283 path: RulePath::new(vec![], name.to_string()),
284 source_location: dummy_source(),
285 rule_type: Arc::new(rule_type.clone()),
286 }
287 }
288
289 #[test]
290 fn test_response_serialization() {
291 let mut results = IndexMap::new();
292 results.insert(
293 "test_rule".to_string(),
294 RuleResult::from_operation_result(
295 dummy_evaluated_rule("test_rule", primitive_number_arc().as_ref()),
296 &OperationResult::from_literal(LiteralValue::number_from_decimal(Decimal::from(
297 42,
298 ))),
299 primitive_number_arc().as_ref(),
300 &empty_family_catalog(),
301 None,
302 Vec::new(),
303 ),
304 );
305 let response = Response {
306 spec_name: "test_spec".to_string(),
307 effective: "2026-01-01".to_string(),
308 spec_effective_from: None,
309 spec_effective_to: None,
310 results,
311 };
312
313 let json = serde_json::to_string(&crate::api::Response::from(&response)).unwrap();
314 assert!(json.contains("test_spec"));
315 assert!(json.contains("test_rule"));
316 assert!(json.contains("\"number\":\"42\""));
317 assert!(!json.contains("lemma_type"));
318 }
319
320 #[test]
321 fn response_number_json_never_uses_fraction_notation() {
322 use crate::computation::rational::decimal_to_rational;
323
324 let rational = decimal_to_rational(Decimal::new(1, 1) / Decimal::new(3, 1)).unwrap();
325 let decimal_string = rational.try_to_decimal().unwrap().to_string();
326 let mut results = IndexMap::new();
327 results.insert(
328 "third".to_string(),
329 RuleResult::from_operation_result(
330 dummy_evaluated_rule("third", primitive_number_arc().as_ref()),
331 &OperationResult::from_literal(LiteralValue::number_from_decimal(
332 rational.try_to_decimal().unwrap(),
333 )),
334 primitive_number_arc().as_ref(),
335 &empty_family_catalog(),
336 None,
337 Vec::new(),
338 ),
339 );
340 if let Some(rule) = results.get_mut("third") {
342 let decimal = rational.try_to_decimal().unwrap();
343 rule.result = Some(crate::result_value::RuleResultValue {
344 result: Some(decimal_string.clone()),
345 number: Some(decimal),
346 ..Default::default()
347 });
348 }
349
350 let response = Response {
351 spec_name: "test".to_string(),
352 effective: "test".to_string(),
353 spec_effective_from: None,
354 spec_effective_to: None,
355 results,
356 };
357
358 let json: serde_json::Value = serde_json::from_str(
359 &serde_json::to_string(&crate::api::Response::from(&response)).unwrap(),
360 )
361 .unwrap();
362 let number = json["results"]["third"]["number"]
363 .as_str()
364 .expect("number must be a JSON string");
365 assert!(
366 !number.contains('/'),
367 "API decimal string must not use fraction notation, got {number}"
368 );
369 }
370
371 #[test]
372 fn test_rule_result_veto() {
373 let missing = RuleResult::from_operation_result(
374 dummy_evaluated_rule("rule3", &LemmaType::veto_type()),
375 &OperationResult::Veto(VetoType::missing_data(
376 DataPath::new(vec![], "data1".to_string()),
377 None,
378 )),
379 &LemmaType::veto_type(),
380 &empty_family_catalog(),
381 None,
382 vec!["data1".to_string()],
383 );
384 assert!(missing.vetoed);
385 assert!(missing.veto_reason.as_ref().unwrap().contains("data1"));
386
387 let veto = RuleResult::from_operation_result(
388 dummy_evaluated_rule("rule4", &LemmaType::veto_type()),
389 &OperationResult::Veto(VetoType::UserDefined {
390 message: Some("Vetoed".to_string()),
391 }),
392 &LemmaType::veto_type(),
393 &empty_family_catalog(),
394 None,
395 Vec::new(),
396 );
397 assert_eq!(veto.veto_reason.as_deref(), Some("Vetoed"));
398 }
399
400 #[test]
402 #[should_panic(expected = "BUG: MissingData")]
403 fn missing_data_veto_must_not_attach_empty_missing_data_list() {
404 RuleResult::from_operation_result(
405 dummy_evaluated_rule("rule3", &LemmaType::veto_type()),
406 &OperationResult::Veto(VetoType::missing_data(
407 DataPath::new(vec![], "data1".to_string()),
408 None,
409 )),
410 &LemmaType::veto_type(),
411 &empty_family_catalog(),
412 None,
413 Vec::new(),
414 );
415 }
416
417 #[test]
418 #[should_panic(expected = "BUG: MissingData")]
419 fn missing_data_veto_must_include_veto_path_in_list() {
420 RuleResult::from_operation_result(
421 dummy_evaluated_rule("rule3", &LemmaType::veto_type()),
422 &OperationResult::Veto(VetoType::missing_data(
423 DataPath::new(vec![], "data1".to_string()),
424 None,
425 )),
426 &LemmaType::veto_type(),
427 &empty_family_catalog(),
428 None,
429 vec!["other".to_string()],
430 );
431 }
432
433 #[test]
434 #[should_panic(expected = "BUG: missing_data must be empty")]
435 fn non_missing_data_result_must_not_attach_leftover_missing_data() {
436 RuleResult::from_operation_result(
437 dummy_evaluated_rule("rule4", &LemmaType::veto_type()),
438 &OperationResult::Veto(VetoType::UserDefined {
439 message: Some("Vetoed".to_string()),
440 }),
441 &LemmaType::veto_type(),
442 &empty_family_catalog(),
443 None,
444 vec!["leftover".to_string()],
445 );
446 }
447
448 #[test]
449 fn rule_result_value_out_of_memory_is_not_decimal_limit_veto() {
450 let result = vetoed_rule_result_for_rule_result_value_failure(
451 dummy_evaluated_rule("rule", primitive_number_arc().as_ref()),
452 primitive_number_arc().as_ref(),
453 &empty_family_catalog(),
454 None,
455 RuleResultValueFailure::OutOfMemory,
456 Vec::new(),
457 );
458 assert_eq!(result.veto_reason.as_deref(), Some("out of memory"));
459 assert_ne!(
460 result.veto_reason.as_deref(),
461 Some("Calculated result exceeds decimal value limit")
462 );
463 }
464
465 #[test]
466 fn rule_result_value_decimal_limit_uses_commit_message() {
467 let result = vetoed_rule_result_for_rule_result_value_failure(
468 dummy_evaluated_rule("rule", primitive_number_arc().as_ref()),
469 primitive_number_arc().as_ref(),
470 &empty_family_catalog(),
471 None,
472 RuleResultValueFailure::DecimalLimit,
473 Vec::new(),
474 );
475 assert_eq!(
476 result.veto_reason.as_deref(),
477 Some("Calculated result exceeds decimal value limit")
478 );
479 }
480
481 fn test_money_type() -> LemmaType {
482 LemmaType::new(
483 "money".to_string(),
484 TypeSpecification::Measure {
485 minimum: None,
486 maximum: None,
487 decimals: Some(2),
488 units: MeasureUnits::from(vec![
489 MeasureUnit {
490 name: "eur".to_string(),
491 factor: crate::computation::rational::rational_one(),
492 derived_measure_factors: Vec::new(),
493 decomposition: BaseMeasureVector::new(),
494 minimum: None,
495 maximum: None,
496 suggestion_magnitude: None,
497 },
498 MeasureUnit {
499 name: "usd".to_string(),
500 factor: crate::computation::rational::decimal_to_rational(Decimal::new(
501 91, 2,
502 ))
503 .expect("factor"),
504 derived_measure_factors: Vec::new(),
505 decomposition: BaseMeasureVector::new(),
506 minimum: None,
507 maximum: None,
508 suggestion_magnitude: None,
509 },
510 ]),
511 traits: Vec::new(),
512 decomposition: Some(BaseMeasureVector::new()),
513 help: String::new(),
514 },
515 TypeExtends::Primitive,
516 )
517 }
518
519 #[test]
520 fn measure_rule_result_value_uses_rule_type_when_expression_index_empty() {
521 let money = test_money_type();
522 let family_units = family_catalog_for_money(&money);
523 let ten_usd = LiteralValue {
524 value: ValueKind::Measure(
525 crate::computation::rational::checked_mul(
526 &crate::computation::rational::decimal_to_rational(Decimal::from(10))
527 .expect("ten"),
528 &crate::computation::rational::decimal_to_rational(Decimal::new(91, 2))
529 .expect("usd factor"),
530 )
531 .expect("canonical usd"),
532 ),
533 };
534 let result = RuleResult::from_operation_result(
535 dummy_evaluated_rule("total", &money),
536 &OperationResult::from_literal(ten_usd),
537 &money,
538 &family_units,
539 None,
540 Vec::new(),
541 );
542 let measure = result
543 .result
544 .as_ref()
545 .expect("value")
546 .measure
547 .clone()
548 .expect("measure map");
549 assert_eq!(measure.get("usd"), Some(&dec("10.00")));
550 assert!(measure.contains_key("eur"));
551 }
552
553 #[test]
554 fn test_measure_rule_result_value_multi_unit() {
555 let money = test_money_type();
556 let family_units = family_catalog_for_money(&money);
557 let ten_eur = LiteralValue {
558 value: ValueKind::Measure(
559 crate::computation::rational::decimal_to_rational(Decimal::from(10)).expect("ten"),
560 ),
561 };
562 let result = RuleResult::from_operation_result(
563 dummy_evaluated_rule("total", &money),
564 &OperationResult::from_literal(ten_eur),
565 &money,
566 &family_units,
567 None,
568 Vec::new(),
569 );
570 let measure = result
571 .result
572 .as_ref()
573 .expect("value")
574 .measure
575 .clone()
576 .expect("measure map");
577 assert_eq!(measure.get("eur"), Some(&dec("10.00")));
578 assert_eq!(measure.get("usd"), Some(&dec("10.99")));
579 }
580
581 #[test]
582 fn measure_rule_result_value_respects_decimals_on_unit_conversion() {
583 let money = LemmaType::new(
584 "money".to_string(),
585 TypeSpecification::Measure {
586 minimum: None,
587 maximum: None,
588 decimals: Some(2),
589 units: MeasureUnits::from(vec![
590 MeasureUnit {
591 name: "eur".to_string(),
592 factor: crate::computation::rational::rational_one(),
593 derived_measure_factors: Vec::new(),
594 decomposition: BaseMeasureVector::new(),
595 minimum: None,
596 maximum: None,
597 suggestion_magnitude: None,
598 },
599 MeasureUnit {
600 name: "usd".to_string(),
601 factor: crate::computation::rational::decimal_to_rational(Decimal::new(
602 84, 2,
603 ))
604 .expect("usd factor"),
605 derived_measure_factors: Vec::new(),
606 decomposition: BaseMeasureVector::new(),
607 minimum: None,
608 maximum: None,
609 suggestion_magnitude: None,
610 },
611 ]),
612 traits: Vec::new(),
613 decomposition: Some(BaseMeasureVector::new()),
614 help: String::new(),
615 },
616 TypeExtends::Primitive,
617 );
618 let family_units = family_catalog_for_money(&money);
619 let three_twelve_eur = LiteralValue {
620 value: ValueKind::Measure(
621 crate::computation::rational::decimal_to_rational(Decimal::new(312, 2))
622 .expect("3.12 eur canonical"),
623 ),
624 };
625 let result = RuleResult::from_operation_result(
626 dummy_evaluated_rule("delivery_cost", &money),
627 &OperationResult::from_literal(three_twelve_eur),
628 &money,
629 &family_units,
630 None,
631 Vec::new(),
632 );
633 let measure = result
634 .result
635 .as_ref()
636 .expect("value")
637 .measure
638 .clone()
639 .expect("measure map");
640 assert_eq!(measure.get("eur"), Some(&dec("3.12")));
641 assert_eq!(measure.get("usd"), Some(&dec("3.71")));
642 }
643
644 #[test]
645 fn test_ratio_rule_result_value_multi_unit() {
646 let ratio_type = LemmaType::new(
647 "rate".to_string(),
648 TypeSpecification::Ratio {
649 minimum: None,
650 maximum: None,
651 decimals: None,
652 units: RatioUnits::from(vec![
653 RatioUnit {
654 name: "percent".to_string(),
655 value: crate::computation::rational::decimal_to_rational(Decimal::from(
656 100,
657 ))
658 .expect("percent"),
659 minimum: None,
660 maximum: None,
661 suggestion_magnitude: None,
662 },
663 RatioUnit {
664 name: "basis_points".to_string(),
665 value: crate::computation::rational::decimal_to_rational(Decimal::from(
666 10_000,
667 ))
668 .expect("bp"),
669 minimum: None,
670 maximum: None,
671 suggestion_magnitude: None,
672 },
673 ]),
674 help: String::new(),
675 },
676 TypeExtends::Primitive,
677 );
678 let family_units = family_catalog_for_ratio(&ratio_type);
679 let half = crate::computation::rational::rational_new(1, 2);
680 let lit = LiteralValue {
681 value: ValueKind::Ratio(half),
682 };
683 let result = RuleResult::from_operation_result(
684 dummy_evaluated_rule("rate_out", &ratio_type),
685 &OperationResult::from_literal(lit),
686 &ratio_type,
687 &family_units,
688 None,
689 Vec::new(),
690 );
691 let ratio = result
692 .result
693 .as_ref()
694 .expect("value")
695 .ratio
696 .clone()
697 .expect("ratio map");
698 assert_eq!(ratio.get("percent"), Some(&dec("50")));
699 assert_eq!(ratio.get("basis_points"), Some(&dec("5000")));
700 }
701
702 #[test]
703 fn test_measure_rule_result_value_cross_spec_import() {
704 use crate::parsing::source::SourceType;
705 use crate::Engine;
706
707 let mut engine = Engine::new();
708 engine
709 .load([(
710 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("t.lemma"))),
711 r#"
712spec consumer 2025-01-01
713uses d: dep 2025-10-01
714rule out: d.doubled
715
716spec dep 2025-01-01
717uses c: child 2025-06-01
718data money: c.money
719data p: 5 usd
720rule doubled: p * 2
721
722spec child 2025-01-01
723data money: measure
724 -> unit eur: 1.00
725 -> decimals 2
726
727spec child 2025-06-01
728data money: measure
729 -> unit eur: 1.00
730 -> unit usd: 0.91
731 -> decimals 2
732"#
733 .to_string(),
734 )])
735 .expect("load");
736 let effective = crate::literals::DateTimeValue {
737 year: 2025,
738 month: 3,
739 day: 1,
740 hour: 0,
741 minute: 0,
742 second: 0,
743 microsecond: 0,
744 timezone: None,
745
746 granularity: DateGranularity::Full,
747 };
748 let response = engine
749 .run(
750 None,
751 "consumer",
752 Some(&effective),
753 std::collections::HashMap::new(),
754 None,
755 false,
756 )
757 .expect("run");
758 let out = response.results.get("out").expect("out rule");
759 assert!(!out.vetoed);
760 let measure = out
761 .result
762 .as_ref()
763 .expect("value")
764 .measure
765 .as_ref()
766 .expect("measure map");
767 assert!(measure.contains_key("usd"));
768 assert!(measure.contains_key("eur"));
769 }
770
771 #[test]
772 fn to_literal_roundtrips_number() {
773 let literal = LiteralValue::number_from_decimal(Decimal::from(42));
774 let rule_result = RuleResult::from_operation_result(
775 dummy_evaluated_rule("answer", primitive_number_arc().as_ref()),
776 &OperationResult::from_literal(literal.clone()),
777 primitive_number_arc().as_ref(),
778 &empty_family_catalog(),
779 None,
780 Vec::new(),
781 );
782 assert_eq!(rule_result.to_literal(), literal);
783 }
784
785 #[test]
786 fn to_literal_roundtrips_measure() {
787 let money = test_money_type();
788 let family_units = family_catalog_for_money(&money);
789 let literal = LiteralValue::measure_with_type(
790 crate::computation::rational::rational_new(60, 1),
791 Arc::new(money.clone()),
792 );
793 let rule_result = RuleResult::from_operation_result(
794 dummy_evaluated_rule("pay", &money),
795 &OperationResult::from_literal(literal.clone()),
796 &money,
797 &family_units,
798 None,
799 Vec::new(),
800 );
801 assert_eq!(rule_result.to_literal(), literal);
802 }
803}