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