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