1use crate::computation::rational::NumericFailure;
2use crate::evaluation::explanations::Explanation;
3use crate::evaluation::operations::{OperationResult, VetoType};
4
5use crate::parsing::ast::DateTimeValue;
6use crate::planning::semantics::{
7 range_element_type_specification, LemmaType, LiteralUnitMapFailure, LiteralValue, RulePath,
8 SemanticDateTime, SemanticTime, Source, TypeSpecification, ValueKind,
9};
10use indexmap::IndexMap;
11use serde::Serialize;
12use std::collections::BTreeMap;
13use std::sync::Arc;
14
15#[derive(Debug, Clone, Serialize)]
18pub struct EvaluatedRule {
19 pub name: String,
20 pub path: RulePath,
21 pub source_location: Source,
22 pub rule_type: LemmaType,
23}
24
25#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
27pub struct CalendarResult {
28 pub value: String,
29 pub unit: String,
30}
31
32#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
34pub struct RuleResultPayload {
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub measure: Option<BTreeMap<String, String>>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub ratio: Option<BTreeMap<String, String>>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub number: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub boolean: Option<bool>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub text: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub date: Option<SemanticDateTime>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub time: Option<SemanticTime>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub calendar: Option<CalendarResult>,
51}
52
53#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
55pub struct RangeResult {
56 pub from: RuleResultPayload,
57 pub to: RuleResultPayload,
58}
59
60#[derive(Debug, Clone, Serialize)]
62pub struct Response {
63 #[serde(rename = "spec")]
64 pub spec_name: String,
65 pub effective: String,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub spec_hash: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub spec_effective_from: Option<DateTimeValue>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub spec_effective_to: Option<DateTimeValue>,
72 pub results: IndexMap<String, RuleResult>,
73}
74
75#[derive(Debug, Clone, Serialize)]
77pub struct RuleResult {
78 #[serde(skip)]
79 pub rule: EvaluatedRule,
80 #[serde(skip)]
81 pub veto_detail: Option<VetoType>,
82
83 pub vetoed: bool,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub display: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub veto_reason: Option<String>,
88 pub rule_type: String,
89
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub measure: Option<BTreeMap<String, String>>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub ratio: Option<BTreeMap<String, String>>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub number: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub boolean: Option<bool>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub text: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub date: Option<SemanticDateTime>,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub time: Option<SemanticTime>,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub calendar: Option<CalendarResult>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub range: Option<RangeResult>,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub explanation: Option<Explanation>,
110 #[serde(skip_serializing_if = "Vec::is_empty")]
113 pub missing_data: Vec<String>,
114}
115
116impl RuleResult {
117 pub fn from_operation_result(
121 rule: EvaluatedRule,
122 operation_result: &OperationResult,
123 rule_type: &LemmaType,
124 explanation: Option<Explanation>,
125 missing_data: Vec<String>,
126 ) -> Self {
127 let rule_type_name = rule_type.name().to_string();
128 match operation_result {
129 OperationResult::Veto(veto) => Self {
130 rule,
131 veto_detail: Some(veto.clone()),
132 vetoed: true,
133 display: None,
134 veto_reason: match &veto {
135 VetoType::UserDefined { message: None } => None,
136 _ => Some(veto.to_string()),
137 },
138 rule_type: rule_type_name,
139 measure: None,
140 ratio: None,
141 number: None,
142 boolean: None,
143 text: None,
144 date: None,
145 time: None,
146 calendar: None,
147 range: None,
148 explanation,
149 missing_data,
150 },
151 OperationResult::Value(literal) => match &literal.value {
152 ValueKind::Range(from, to) => {
153 let endpoint_type = element_type_from_range_rule(rule_type)
154 .unwrap_or_else(|| rule_type.clone());
155 let from_type = endpoint_materialization_type(from, &endpoint_type);
156 let to_type = endpoint_materialization_type(to, &endpoint_type);
157 match (
158 materialize_payload(from, &from_type),
159 materialize_payload(to, &to_type),
160 ) {
161 (Ok(from_payload), Ok(to_payload)) => Self {
162 rule,
163 veto_detail: None,
164 vetoed: false,
165 display: Some(literal.to_string()),
166 veto_reason: None,
167 rule_type: rule_type_name,
168 measure: None,
169 ratio: None,
170 number: None,
171 boolean: None,
172 text: None,
173 date: None,
174 time: None,
175 calendar: None,
176 range: Some(RangeResult {
177 from: from_payload,
178 to: to_payload,
179 }),
180 explanation,
181 missing_data,
182 },
183 (Err(failure), _) | (_, Err(failure)) => {
184 vetoed_rule_result_for_materialization_failure(
185 rule,
186 rule_type_name,
187 explanation,
188 failure,
189 missing_data,
190 )
191 }
192 }
193 }
194 _ => match materialize_payload(literal, rule_type) {
195 Ok(payload) => Self {
196 rule,
197 veto_detail: None,
198 vetoed: false,
199 display: Some(literal.to_string()),
200 veto_reason: None,
201 rule_type: rule_type_name,
202 measure: payload.measure,
203 ratio: payload.ratio,
204 number: payload.number,
205 boolean: payload.boolean,
206 text: payload.text,
207 date: payload.date,
208 time: payload.time,
209 calendar: payload.calendar,
210 range: None,
211 explanation,
212 missing_data,
213 },
214 Err(failure) => vetoed_rule_result_for_materialization_failure(
215 rule,
216 rule_type_name,
217 explanation,
218 failure,
219 missing_data,
220 ),
221 },
222 },
223 }
224 }
225
226 pub fn materialized_literal(&self) -> LiteralValue {
230 assert!(
231 !self.vetoed,
232 "BUG: materialized_literal called on vetoed rule '{}'",
233 self.rule.name
234 );
235 let rule_type = Arc::new(self.rule.rule_type.clone());
236
237 if let Some(b) = self.boolean {
238 return LiteralValue {
239 value: ValueKind::Boolean(b),
240 lemma_type: rule_type,
241 };
242 }
243 if let Some(number) = &self.number {
244 return LiteralValue::number_with_type_from_decimal(
245 decimal_from_materialized_string(number),
246 rule_type,
247 );
248 }
249 if let Some(calendar) = &self.calendar {
250 use crate::literals::rational_from_parsed_decimal;
251 let rational =
252 rational_from_parsed_decimal(decimal_from_materialized_string(&calendar.value))
253 .expect("BUG: calendar rule result value must lift to rational");
254 return LiteralValue::measure_with_type(rational, calendar.unit.clone(), rule_type);
255 }
256 if let Some(measure) = &self.measure {
257 return literal_from_measure_map(measure, &rule_type);
258 }
259 if let Some(ratio) = &self.ratio {
260 return literal_from_ratio_map(ratio, &rule_type);
261 }
262 if let Some(date) = &self.date {
263 return LiteralValue {
264 value: ValueKind::Date(date.clone()),
265 lemma_type: rule_type,
266 };
267 }
268 if let Some(time) = &self.time {
269 return LiteralValue {
270 value: ValueKind::Time(time.clone()),
271 lemma_type: rule_type,
272 };
273 }
274 if let Some(text) = &self.text {
275 return LiteralValue {
276 value: ValueKind::Text(text.clone()),
277 lemma_type: rule_type,
278 };
279 }
280 if let Some(range) = &self.range {
281 let endpoint_type = element_type_from_range_rule(&rule_type)
282 .unwrap_or_else(|| rule_type.as_ref().clone());
283 let left = payload_to_literal(&range.from, &endpoint_type);
284 let right = payload_to_literal(&range.to, &endpoint_type);
285 return LiteralValue::range(left, right);
286 }
287 panic!(
288 "BUG: rule '{}' materialized fields cannot reconstruct literal",
289 self.rule.name
290 );
291 }
292}
293
294fn decimal_from_materialized_string(value: &str) -> rust_decimal::Decimal {
295 use rust_decimal::Decimal;
296 use std::str::FromStr;
297 Decimal::from_str(value)
298 .unwrap_or_else(|_| panic!("BUG: rule result materialized string must parse as decimal"))
299}
300
301fn literal_from_measure_map(
302 measure: &BTreeMap<String, String>,
303 rule_type: &LemmaType,
304) -> LiteralValue {
305 use crate::computation::rational::checked_mul;
306 use crate::literals::rational_from_parsed_decimal;
307
308 let unit_names = rule_type
309 .measure_unit_names()
310 .expect("BUG: measure rule result must have declared units");
311 let unit_name = unit_names
312 .first()
313 .expect("BUG: measure rule result type must declare at least one unit");
314 let display = measure
315 .get(*unit_name)
316 .unwrap_or_else(|| panic!("BUG: measure map missing unit '{unit_name}'"));
317 let rational = rational_from_parsed_decimal(decimal_from_materialized_string(display))
318 .expect("BUG: measure rule result value must lift to rational");
319 let factor = rule_type.measure_unit_factor(unit_name);
320 let canonical = checked_mul(&rational, factor).unwrap_or_else(|failure| {
321 panic!("BUG: measure canonicalization from materialized fields failed: {failure}")
322 });
323 LiteralValue::measure_with_type(
324 canonical,
325 (*unit_name).to_string(),
326 Arc::new(rule_type.clone()),
327 )
328}
329
330fn literal_from_ratio_map(ratio: &BTreeMap<String, String>, rule_type: &LemmaType) -> LiteralValue {
331 use crate::computation::rational::checked_div;
332 use crate::literals::rational_from_parsed_decimal;
333
334 let units = match &rule_type.specifications {
335 TypeSpecification::Ratio { units, .. } => units,
336 TypeSpecification::RatioRange { .. } => {
337 let element = range_element_type_specification(&rule_type.specifications)
338 .expect("BUG: ratio range rule type must have ratio element specification");
339 let TypeSpecification::Ratio { units, .. } = element else {
340 panic!("BUG: ratio range element spec must be Ratio");
341 };
342 return literal_from_ratio_map(
343 ratio,
344 &LemmaType::primitive(TypeSpecification::Ratio {
345 minimum: None,
346 maximum: None,
347 decimals: None,
348 units,
349 help: String::new(),
350 }),
351 );
352 }
353 _ => panic!(
354 "BUG: ratio rule result type must be Ratio, got {}",
355 rule_type.name()
356 ),
357 };
358 let unit = units
359 .iter()
360 .next()
361 .expect("BUG: ratio rule result type must declare at least one unit");
362 let display = ratio
363 .get(&unit.name)
364 .unwrap_or_else(|| panic!("BUG: ratio map missing unit '{}'", unit.name));
365 let display_rational = rational_from_parsed_decimal(decimal_from_materialized_string(display))
366 .expect("BUG: ratio rule result value must lift to rational");
367 let canonical = checked_div(&display_rational, &unit.value).unwrap_or_else(|failure| {
368 panic!("BUG: ratio canonicalization from materialized fields failed: {failure}")
369 });
370 LiteralValue::ratio_with_type(canonical, None, Arc::new(rule_type.clone()))
371}
372
373fn payload_to_literal(payload: &RuleResultPayload, rule_type: &LemmaType) -> LiteralValue {
374 if let Some(b) = payload.boolean {
375 return LiteralValue {
376 value: ValueKind::Boolean(b),
377 lemma_type: Arc::new(rule_type.clone()),
378 };
379 }
380 if let Some(number) = &payload.number {
381 return LiteralValue::number_with_type_from_decimal(
382 decimal_from_materialized_string(number),
383 Arc::new(rule_type.clone()),
384 );
385 }
386 if let Some(calendar) = &payload.calendar {
387 use crate::literals::rational_from_parsed_decimal;
388 let rational =
389 rational_from_parsed_decimal(decimal_from_materialized_string(&calendar.value))
390 .expect("BUG: calendar payload value must lift to rational");
391 return LiteralValue::measure_with_type(
392 rational,
393 calendar.unit.clone(),
394 Arc::new(rule_type.clone()),
395 );
396 }
397 if let Some(measure) = &payload.measure {
398 return literal_from_measure_map(measure, rule_type);
399 }
400 if let Some(ratio) = &payload.ratio {
401 return literal_from_ratio_map(ratio, rule_type);
402 }
403 if let Some(date) = &payload.date {
404 return LiteralValue {
405 value: ValueKind::Date(date.clone()),
406 lemma_type: Arc::new(rule_type.clone()),
407 };
408 }
409 if let Some(time) = &payload.time {
410 return LiteralValue {
411 value: ValueKind::Time(time.clone()),
412 lemma_type: Arc::new(rule_type.clone()),
413 };
414 }
415 if let Some(text) = &payload.text {
416 return LiteralValue {
417 value: ValueKind::Text(text.clone()),
418 lemma_type: Arc::new(rule_type.clone()),
419 };
420 }
421 panic!("BUG: range endpoint payload cannot reconstruct literal");
422}
423
424fn element_type_from_range_rule(rule_type: &LemmaType) -> Option<LemmaType> {
425 range_element_type_specification(&rule_type.specifications).map(LemmaType::primitive)
426}
427
428fn endpoint_materialization_type(
429 endpoint: &crate::planning::semantics::LiteralValue,
430 range_element_type: &LemmaType,
431) -> LemmaType {
432 if endpoint.lemma_type.measure_unit_names().is_some() {
433 endpoint.lemma_type.as_ref().clone()
434 } else {
435 range_element_type.clone()
436 }
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440enum MaterializationFailure {
441 DecimalLimit,
442 NumericOverflow,
443 OutOfMemory,
444}
445
446fn map_materialization_failure(failure: NumericFailure) -> MaterializationFailure {
447 match failure {
448 NumericFailure::Overflow => MaterializationFailure::DecimalLimit,
449 NumericFailure::OutOfMemory => MaterializationFailure::OutOfMemory,
450 NumericFailure::DivisionByZero => {
451 panic!("BUG: decimal commit encountered division by zero during materialization")
452 }
453 NumericFailure::Irrational => {
454 panic!("BUG: decimal commit encountered irrational result during materialization")
455 }
456 }
457}
458
459fn map_unit_conversion_failure(failure: NumericFailure) -> MaterializationFailure {
460 match failure {
461 NumericFailure::Overflow => MaterializationFailure::NumericOverflow,
462 NumericFailure::OutOfMemory => MaterializationFailure::OutOfMemory,
463 NumericFailure::DivisionByZero => {
464 panic!("BUG: unit conversion encountered division by zero during materialization")
465 }
466 NumericFailure::Irrational => {
467 panic!("BUG: unit conversion encountered irrational result during materialization")
468 }
469 }
470}
471
472fn materialization_failure_message(failure: MaterializationFailure) -> &'static str {
473 match failure {
474 MaterializationFailure::DecimalLimit => "Calculated result exceeds decimal value limit",
475 MaterializationFailure::NumericOverflow => "numeric overflow",
476 MaterializationFailure::OutOfMemory => "out of memory",
477 }
478}
479
480fn vetoed_rule_result_for_materialization_failure(
481 rule: EvaluatedRule,
482 rule_type_name: String,
483 explanation: Option<Explanation>,
484 failure: MaterializationFailure,
485 missing_data: Vec<String>,
486) -> RuleResult {
487 let veto = VetoType::computation(materialization_failure_message(failure).to_string());
488 RuleResult {
489 rule,
490 veto_detail: Some(veto.clone()),
491 vetoed: true,
492 display: None,
493 veto_reason: Some(veto.to_string()),
494 rule_type: rule_type_name,
495 measure: None,
496 ratio: None,
497 number: None,
498 boolean: None,
499 text: None,
500 date: None,
501 time: None,
502 calendar: None,
503 range: None,
504 explanation,
505 missing_data,
506 }
507}
508
509fn materialize_payload(
510 literal: &crate::planning::semantics::LiteralValue,
511 result_type: &LemmaType,
512) -> Result<RuleResultPayload, MaterializationFailure> {
513 match &literal.value {
514 ValueKind::Measure(rational, sig) if literal.lemma_type.is_calendar_like() => {
515 let unit =
516 crate::planning::semantics::semantic_calendar_unit_from_measure_signature(sig);
517 let value = literal
518 .lemma_type
519 .try_materialize_rational_as_decimal_string(rational)
520 .map_err(map_materialization_failure)?;
521 Ok(RuleResultPayload {
522 calendar: Some(CalendarResult {
523 value,
524 unit: unit.to_string(),
525 }),
526 ..RuleResultPayload::default()
527 })
528 }
529 ValueKind::Measure(_, _) => Ok(RuleResultPayload {
530 measure: Some(measure_to_unit_map(literal, result_type)?),
531 ..RuleResultPayload::default()
532 }),
533 ValueKind::Ratio(_, _) => Ok(RuleResultPayload {
534 ratio: Some(ratio_to_unit_map(literal, result_type)?),
535 ..RuleResultPayload::default()
536 }),
537 ValueKind::Number(rational) => {
538 let number = result_type
539 .try_materialize_rational_as_decimal_string(rational)
540 .map_err(map_materialization_failure)?;
541 Ok(RuleResultPayload {
542 number: Some(number),
543 ..RuleResultPayload::default()
544 })
545 }
546 ValueKind::Boolean(b) => Ok(RuleResultPayload {
547 boolean: Some(*b),
548 ..RuleResultPayload::default()
549 }),
550 ValueKind::Text(s) => Ok(RuleResultPayload {
551 text: Some(s.clone()),
552 ..RuleResultPayload::default()
553 }),
554 ValueKind::Date(d) => Ok(RuleResultPayload {
555 date: Some(d.clone()),
556 ..RuleResultPayload::default()
557 }),
558 ValueKind::Time(t) => Ok(RuleResultPayload {
559 time: Some(t.clone()),
560 ..RuleResultPayload::default()
561 }),
562 ValueKind::Range(_, _) => {
563 panic!("BUG: range payload must be built at RuleResult level, not RuleResultPayload")
564 }
565 }
566}
567
568fn map_literal_unit_map_failure(failure: LiteralUnitMapFailure) -> MaterializationFailure {
569 match failure {
570 LiteralUnitMapFailure::Commit(nf) => map_materialization_failure(nf),
571 LiteralUnitMapFailure::UnitConversion(nf) => map_unit_conversion_failure(nf),
572 }
573}
574
575fn measure_to_unit_map(
576 literal: &crate::planning::semantics::LiteralValue,
577 result_type: &LemmaType,
578) -> Result<BTreeMap<String, String>, MaterializationFailure> {
579 result_type
580 .measure_literal_in_all_units(literal)
581 .map_err(map_literal_unit_map_failure)
582}
583
584fn ratio_to_unit_map(
585 literal: &crate::planning::semantics::LiteralValue,
586 result_type: &LemmaType,
587) -> Result<BTreeMap<String, String>, MaterializationFailure> {
588 result_type
589 .ratio_literal_in_all_units(literal)
590 .map_err(map_literal_unit_map_failure)
591}
592
593impl Response {
594 pub fn get(&self, rule_name: &str) -> Result<&RuleResult, crate::error::Error> {
598 self.results
599 .get(rule_name)
600 .ok_or_else(|| crate::error::Error::rule_not_found(rule_name, None::<String>))
601 }
602
603 pub fn add_result(&mut self, result: RuleResult) {
604 self.results.insert(result.rule.name.clone(), result);
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::literals::DateGranularity;
612 use crate::planning::semantics::{
613 primitive_number, BaseMeasureVector, DataPath, LemmaType, LiteralValue, MeasureUnit,
614 MeasureUnits, RatioUnit, RatioUnits, RulePath, Span, TypeExtends, TypeSpecification,
615 };
616 use rust_decimal::Decimal;
617 use std::sync::Arc;
618
619 fn dummy_source() -> Source {
620 Source::new(
621 crate::parsing::source::SourceType::Volatile,
622 Span {
623 start: 0,
624 end: 0,
625 line: 1,
626 col: 1,
627 },
628 )
629 }
630
631 fn dummy_evaluated_rule(name: &str, rule_type: &LemmaType) -> EvaluatedRule {
632 EvaluatedRule {
633 name: name.to_string(),
634 path: RulePath::new(vec![], name.to_string()),
635 source_location: dummy_source(),
636 rule_type: rule_type.clone(),
637 }
638 }
639
640 #[test]
641 fn test_response_serialization() {
642 let mut results = IndexMap::new();
643 results.insert(
644 "test_rule".to_string(),
645 RuleResult::from_operation_result(
646 dummy_evaluated_rule("test_rule", primitive_number()),
647 &OperationResult::from_literal(LiteralValue::number_from_decimal(Decimal::from(
648 42,
649 ))),
650 primitive_number(),
651 None,
652 Vec::new(),
653 ),
654 );
655 let response = Response {
656 spec_name: "test_spec".to_string(),
657 effective: "2026-01-01".to_string(),
658 spec_hash: None,
659 spec_effective_from: None,
660 spec_effective_to: None,
661 results,
662 };
663
664 let json = serde_json::to_string(&response).unwrap();
665 assert!(json.contains("test_spec"));
666 assert!(json.contains("test_rule"));
667 assert!(json.contains("\"number\":\"42\""));
668 assert!(!json.contains("lemma_type"));
669 }
670
671 #[test]
672 fn response_number_json_never_uses_fraction_notation() {
673 use crate::computation::rational::decimal_to_rational;
674
675 let rational = decimal_to_rational(Decimal::new(1, 1) / Decimal::new(3, 1)).unwrap();
676 let decimal_string = rational.try_to_decimal().unwrap().to_string();
677 let mut results = IndexMap::new();
678 results.insert(
679 "third".to_string(),
680 RuleResult::from_operation_result(
681 dummy_evaluated_rule("third", primitive_number()),
682 &OperationResult::from_literal(LiteralValue::number_from_decimal(
683 rational.try_to_decimal().unwrap(),
684 )),
685 primitive_number(),
686 None,
687 Vec::new(),
688 ),
689 );
690 if let Some(rule) = results.get_mut("third") {
692 rule.number = Some(decimal_string.clone());
693 rule.display = Some(decimal_string);
694 }
695
696 let response = Response {
697 spec_name: "test".to_string(),
698 effective: "test".to_string(),
699 spec_hash: None,
700 spec_effective_from: None,
701 spec_effective_to: None,
702 results,
703 };
704
705 let json: serde_json::Value =
706 serde_json::from_str(&serde_json::to_string(&response).unwrap()).unwrap();
707 let number = json["results"]["third"]["number"]
708 .as_str()
709 .expect("number must be a JSON string");
710 assert!(
711 !number.contains('/'),
712 "API decimal string must not use fraction notation, got {number}"
713 );
714 }
715
716 #[test]
717 fn test_rule_result_veto() {
718 let missing = RuleResult::from_operation_result(
719 dummy_evaluated_rule("rule3", &LemmaType::veto_type()),
720 &OperationResult::Veto(VetoType::missing_data(
721 DataPath::new(vec![], "data1".to_string()),
722 None,
723 )),
724 &LemmaType::veto_type(),
725 None,
726 Vec::new(),
727 );
728 assert!(missing.vetoed);
729 assert!(missing.veto_reason.as_ref().unwrap().contains("data1"));
730
731 let veto = RuleResult::from_operation_result(
732 dummy_evaluated_rule("rule4", &LemmaType::veto_type()),
733 &OperationResult::Veto(VetoType::UserDefined {
734 message: Some("Vetoed".to_string()),
735 }),
736 &LemmaType::veto_type(),
737 None,
738 Vec::new(),
739 );
740 assert_eq!(veto.veto_reason.as_deref(), Some("Vetoed"));
741 }
742
743 #[test]
744 fn materialization_out_of_memory_is_not_decimal_limit_veto() {
745 let result = vetoed_rule_result_for_materialization_failure(
746 dummy_evaluated_rule("rule", primitive_number()),
747 "number".to_string(),
748 None,
749 MaterializationFailure::OutOfMemory,
750 Vec::new(),
751 );
752 assert_eq!(result.veto_reason.as_deref(), Some("out of memory"));
753 assert_ne!(
754 result.veto_reason.as_deref(),
755 Some("Calculated result exceeds decimal value limit")
756 );
757 }
758
759 #[test]
760 fn materialization_decimal_limit_uses_commit_message() {
761 let result = vetoed_rule_result_for_materialization_failure(
762 dummy_evaluated_rule("rule", primitive_number()),
763 "number".to_string(),
764 None,
765 MaterializationFailure::DecimalLimit,
766 Vec::new(),
767 );
768 assert_eq!(
769 result.veto_reason.as_deref(),
770 Some("Calculated result exceeds decimal value limit")
771 );
772 }
773
774 fn test_money_type() -> LemmaType {
775 LemmaType::new(
776 "money".to_string(),
777 TypeSpecification::Measure {
778 minimum: None,
779 maximum: None,
780 decimals: Some(2),
781 units: MeasureUnits::from(vec![
782 MeasureUnit {
783 name: "eur".to_string(),
784 factor: crate::computation::rational::rational_one(),
785 derived_measure_factors: Vec::new(),
786 decomposition: BaseMeasureVector::new(),
787 minimum: None,
788 maximum: None,
789 suggestion_magnitude: None,
790 },
791 MeasureUnit {
792 name: "usd".to_string(),
793 factor: crate::computation::rational::decimal_to_rational(Decimal::new(
794 91, 2,
795 ))
796 .expect("factor"),
797 derived_measure_factors: Vec::new(),
798 decomposition: BaseMeasureVector::new(),
799 minimum: None,
800 maximum: None,
801 suggestion_magnitude: None,
802 },
803 ]),
804 traits: Vec::new(),
805 decomposition: Some(BaseMeasureVector::new()),
806 help: String::new(),
807 },
808 TypeExtends::Primitive,
809 )
810 }
811
812 #[test]
813 fn measure_materialization_uses_rule_type_when_expression_index_empty() {
814 let money = test_money_type();
815 let ten_usd = LiteralValue {
816 value: ValueKind::Measure(
817 crate::computation::rational::checked_mul(
818 &crate::computation::rational::decimal_to_rational(Decimal::from(10))
819 .expect("ten"),
820 &crate::computation::rational::decimal_to_rational(Decimal::new(91, 2))
821 .expect("usd factor"),
822 )
823 .expect("canonical usd"),
824 vec![("usd".to_string(), 1)],
825 ),
826 lemma_type: Arc::new(money.clone()),
827 };
828 let result = RuleResult::from_operation_result(
829 dummy_evaluated_rule("total", &money),
830 &OperationResult::from_literal(ten_usd),
831 &money,
832 None,
833 Vec::new(),
834 );
835 let measure = result.measure.expect("measure map");
836 assert_eq!(measure.get("usd"), Some(&"10.00".to_string()));
837 assert!(measure.contains_key("eur"));
838 }
839
840 #[test]
841 fn test_measure_materialization_multi_unit() {
842 let money = test_money_type();
843 let ten_eur = LiteralValue {
844 value: ValueKind::Measure(
845 crate::computation::rational::decimal_to_rational(Decimal::from(10)).expect("ten"),
846 vec![],
847 ),
848 lemma_type: Arc::new(money.clone()),
849 };
850 let result = RuleResult::from_operation_result(
851 dummy_evaluated_rule("total", &money),
852 &OperationResult::from_literal(ten_eur),
853 &money,
854 None,
855 Vec::new(),
856 );
857 let measure = result.measure.expect("measure map");
858 assert_eq!(measure.get("eur"), Some(&"10.00".to_string()));
859 assert_eq!(measure.get("usd"), Some(&"10.99".to_string()));
860 }
861
862 #[test]
863 fn measure_materialization_respects_decimals_on_unit_conversion() {
864 let money = LemmaType::new(
865 "money".to_string(),
866 TypeSpecification::Measure {
867 minimum: None,
868 maximum: None,
869 decimals: Some(2),
870 units: MeasureUnits::from(vec![
871 MeasureUnit {
872 name: "eur".to_string(),
873 factor: crate::computation::rational::rational_one(),
874 derived_measure_factors: Vec::new(),
875 decomposition: BaseMeasureVector::new(),
876 minimum: None,
877 maximum: None,
878 suggestion_magnitude: None,
879 },
880 MeasureUnit {
881 name: "usd".to_string(),
882 factor: crate::computation::rational::decimal_to_rational(Decimal::new(
883 84, 2,
884 ))
885 .expect("usd factor"),
886 derived_measure_factors: Vec::new(),
887 decomposition: BaseMeasureVector::new(),
888 minimum: None,
889 maximum: None,
890 suggestion_magnitude: None,
891 },
892 ]),
893 traits: Vec::new(),
894 decomposition: Some(BaseMeasureVector::new()),
895 help: String::new(),
896 },
897 TypeExtends::Primitive,
898 );
899 let three_twelve_eur = LiteralValue {
900 value: ValueKind::Measure(
901 crate::computation::rational::decimal_to_rational(Decimal::new(312, 2))
902 .expect("3.12 eur canonical"),
903 vec![],
904 ),
905 lemma_type: Arc::new(money.clone()),
906 };
907 let result = RuleResult::from_operation_result(
908 dummy_evaluated_rule("delivery_cost", &money),
909 &OperationResult::from_literal(three_twelve_eur),
910 &money,
911 None,
912 Vec::new(),
913 );
914 let measure = result.measure.expect("measure map");
915 assert_eq!(measure.get("eur"), Some(&"3.12".to_string()));
916 assert_eq!(measure.get("usd"), Some(&"3.71".to_string()));
917 }
918
919 #[test]
920 fn test_ratio_materialization_multi_unit() {
921 let ratio_type = LemmaType::new(
922 "rate".to_string(),
923 TypeSpecification::Ratio {
924 minimum: None,
925 maximum: None,
926 decimals: None,
927 units: RatioUnits::from(vec![
928 RatioUnit {
929 name: "percent".to_string(),
930 value: crate::computation::rational::decimal_to_rational(Decimal::from(
931 100,
932 ))
933 .expect("percent"),
934 minimum: None,
935 maximum: None,
936 suggestion_magnitude: None,
937 },
938 RatioUnit {
939 name: "basis_points".to_string(),
940 value: crate::computation::rational::decimal_to_rational(Decimal::from(
941 10_000,
942 ))
943 .expect("bp"),
944 minimum: None,
945 maximum: None,
946 suggestion_magnitude: None,
947 },
948 ]),
949 help: String::new(),
950 },
951 TypeExtends::Primitive,
952 );
953 let half = crate::computation::rational::rational_new(1, 2);
954 let lit = LiteralValue {
955 value: ValueKind::Ratio(half, Some("percent".to_string())),
956 lemma_type: Arc::new(ratio_type.clone()),
957 };
958 let result = RuleResult::from_operation_result(
959 dummy_evaluated_rule("rate_out", &ratio_type),
960 &OperationResult::from_literal(lit),
961 &ratio_type,
962 None,
963 Vec::new(),
964 );
965 let ratio = result.ratio.expect("ratio map");
966 assert_eq!(ratio.get("percent"), Some(&"50".to_string()));
967 assert_eq!(ratio.get("basis_points"), Some(&"5000".to_string()));
968 }
969
970 #[test]
971 fn test_measure_materialization_cross_spec_import() {
972 use crate::parsing::source::SourceType;
973 use crate::Engine;
974
975 let mut engine = Engine::new();
976 engine
977 .load([(
978 SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("t.lemma"))),
979 r#"
980spec consumer 2025-01-01
981uses d: dep 2025-10-01
982rule out: d.doubled
983
984spec dep 2025-01-01
985uses c: child 2025-06-01
986data money: c.money
987data p: 5 usd
988rule doubled: p * 2
989
990spec child 2025-01-01
991data money: measure
992 -> unit eur 1.00
993 -> decimals 2
994
995spec child 2025-06-01
996data money: measure
997 -> unit eur 1.00
998 -> unit usd 0.91
999 -> decimals 2
1000"#
1001 .to_string(),
1002 )])
1003 .expect("load");
1004 let effective = crate::literals::DateTimeValue {
1005 year: 2025,
1006 month: 3,
1007 day: 1,
1008 hour: 0,
1009 minute: 0,
1010 second: 0,
1011 microsecond: 0,
1012 timezone: None,
1013
1014 granularity: DateGranularity::Full,
1015 };
1016 let response = engine
1017 .run(
1018 None,
1019 "consumer",
1020 Some(&effective),
1021 std::collections::HashMap::new(),
1022 None,
1023 false,
1024 )
1025 .expect("run");
1026 let out = response.results.get("out").expect("out rule");
1027 assert!(!out.vetoed);
1028 let measure = out.measure.as_ref().expect("measure map");
1029 assert!(measure.contains_key("usd"));
1030 assert!(measure.contains_key("eur"));
1031 }
1032
1033 #[test]
1034 fn materialized_literal_roundtrips_number() {
1035 let literal = LiteralValue::number_from_decimal(Decimal::from(42));
1036 let rule_result = RuleResult::from_operation_result(
1037 dummy_evaluated_rule("answer", primitive_number()),
1038 &OperationResult::from_literal(literal.clone()),
1039 primitive_number(),
1040 None,
1041 Vec::new(),
1042 );
1043 assert_eq!(rule_result.materialized_literal(), literal);
1044 }
1045
1046 #[test]
1047 fn materialized_literal_roundtrips_measure() {
1048 let money = test_money_type();
1049 let literal = LiteralValue::measure_with_type(
1050 crate::computation::rational::rational_new(60, 1),
1051 "eur".into(),
1052 Arc::new(money.clone()),
1053 );
1054 let rule_result = RuleResult::from_operation_result(
1055 dummy_evaluated_rule("pay", &money),
1056 &OperationResult::from_literal(literal.clone()),
1057 &money,
1058 None,
1059 Vec::new(),
1060 );
1061 assert_eq!(rule_result.materialized_literal(), literal);
1062 }
1063}