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