Skip to main content

lemma/
result_value.rs

1//! API value for a rule result, `ShowData.fill`, or `ShowData.suggestion`.
2//!
3//! This is the single API-facing value representation shared by all three sites, expanded
4//! into every declared unit for measure/ratio on data inputs, and into every unit in the
5//! result type's family for rule results. It sits at the crate root (not under
6//! `evaluation/`) because `planning::execution_plan::ShowData` needs it too, and planning
7//! must not import evaluation. The plan/eval-internal representation is the canonical
8//! `planning::semantics::LiteralValue`; this module is the boundary between the two.
9//!
10//! Magnitudes are [`Decimal`] (28-scale API boundary). JSON still emits decimal strings via
11//! `Decimal`'s default serde.
12
13use crate::computation::rational::{
14    checked_div, checked_mul, decimal_to_display_str, NumericFailure,
15};
16use crate::literals::rational_from_parsed_decimal;
17use crate::planning::semantics::{
18    format_decimal_for_api, range_element_type_specification, ratio_element_type_for_api,
19    semantic_calendar_unit_from_measure_type, LemmaType, LiteralUnitMapFailure, LiteralValue,
20    SemanticDateTime, SemanticTime, TypeSpecification, UnitFactorSource, ValueKind,
21};
22use crate::planning::unit_family::{declared_bare_names_only, FamilyUnitCatalog, FamilyUnitEntry};
23use rust_decimal::Decimal;
24use serde::{Deserialize, Serialize};
25use std::collections::BTreeMap;
26use std::fmt;
27use std::sync::Arc;
28
29/// Calendar value (a measure whose unit is a calendar unit, e.g. `3 months`) on a result.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct CalendarResult {
32    pub value: Decimal,
33    pub unit: String,
34}
35
36/// Both endpoints of a range result.
37///
38/// Each endpoint is itself a [`RuleResultValue`], but an endpoint's own `range` field is
39/// always `None` — a range endpoint must never itself be a range. Building a
40/// [`RuleResultValue`] and reconstructing a literal both panic if this invariant is
41/// violated.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct RangeResult {
44    pub from: RuleResultValue,
45    pub to: RuleResultValue,
46}
47
48/// API value shared by flattened [`crate::evaluation::response::RuleResult`],
49/// `ShowData.fill` / `suggestion`, and explanation `Data` / `Rule` nodes.
50///
51/// When present: always `result` (from [`LiteralValue::display_value`]), plus exactly
52/// one typed field for a non-range value; `range` is set instead for a range value, and
53/// every other typed field stays `None`.
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
55pub struct RuleResultValue {
56    /// Engine-rendered string for UI (`LiteralValue::display_value`). Present whenever
57    /// this value is present, including range endpoints.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub result: Option<String>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub measure: Option<BTreeMap<String, Decimal>>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub ratio: Option<BTreeMap<String, Decimal>>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub number: Option<Decimal>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub boolean: Option<bool>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub text: Option<String>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub date: Option<SemanticDateTime>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub time: Option<SemanticTime>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub calendar: Option<CalendarResult>,
76    /// Written unit for a measure or ratio value (including range endpoints).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub unit: Option<String>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub range: Option<Box<RangeResult>>,
81    /// Set when the value is a spec instance. `data` holds one evaluated entry per
82    /// data slot of that instance, keyed relative to that spec.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub spec: Option<Box<SpecResult>>,
85}
86
87/// A spec instance returned by a rule, including the values needed to run it again.
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89pub struct SpecResult {
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub repository: Option<String>,
92    pub spec: String,
93    /// Resolved row's effective date. Empty when the row is the origin row.
94    pub effective: String,
95    /// `uses` path of this instance in the consumer (`basic`, `basic.finance`).
96    pub instance: String,
97    /// Evaluated data slots, keyed relative to `spec`. A slot with a value carries
98    /// that value; a vetoed slot carries `vetoed` and `veto_reason`. Unbound slots
99    /// are omitted.
100    pub data: BTreeMap<String, SpecRuleResult>,
101    /// Rule results relative to `spec` (`tax`, `finance.fee`).
102    pub rules: BTreeMap<String, SpecRuleResult>,
103}
104
105/// One rule of a [`SpecResult`], or one evaluated data slot of a [`SpecResult`].
106/// A spec-valued rule nests another [`SpecResult`]
107/// in [`RuleResultValue::spec`].
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109pub struct SpecRuleResult {
110    pub vetoed: bool,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub veto_reason: Option<String>,
113    pub rule_type: String,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub result: Option<RuleResultValue>,
116}
117
118/// Why building a [`RuleResultValue`] from a canonical [`LiteralValue`] failed.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum RuleResultValueFailure {
121    DecimalLimit,
122    NumericOverflow,
123    OutOfMemory,
124}
125
126/// Which units to expand when building a [`RuleResultValue`] from a measure or ratio literal.
127pub(crate) enum UnitExpansion<'a> {
128    /// Show data fill/suggestion: units declared on `lemma_type` only.
129    Declared,
130    /// Rule results: precomputed family entry from the plan catalog.
131    Family(&'a FamilyUnitEntry),
132}
133
134/// Human-readable veto message for a [`RuleResultValueFailure`].
135pub fn rule_result_value_failure_message(failure: RuleResultValueFailure) -> &'static str {
136    match failure {
137        RuleResultValueFailure::DecimalLimit => "Calculated result exceeds decimal value limit",
138        RuleResultValueFailure::NumericOverflow => "numeric overflow",
139        RuleResultValueFailure::OutOfMemory => "out of memory",
140    }
141}
142
143fn map_numeric_to_rule_result_value_failure(failure: NumericFailure) -> RuleResultValueFailure {
144    match failure {
145        NumericFailure::Overflow => RuleResultValueFailure::DecimalLimit,
146        NumericFailure::OutOfMemory => RuleResultValueFailure::OutOfMemory,
147        NumericFailure::DivisionByZero => {
148            panic!(
149                "BUG: decimal commit encountered division by zero while building RuleResultValue"
150            )
151        }
152        NumericFailure::Irrational => {
153            panic!(
154                "BUG: decimal commit encountered irrational result while building RuleResultValue"
155            )
156        }
157    }
158}
159
160fn map_unit_conversion_failure(failure: NumericFailure) -> RuleResultValueFailure {
161    match failure {
162        NumericFailure::Overflow => RuleResultValueFailure::NumericOverflow,
163        NumericFailure::OutOfMemory => RuleResultValueFailure::OutOfMemory,
164        NumericFailure::DivisionByZero => {
165            panic!("BUG: unit conversion division by zero while building RuleResultValue")
166        }
167        NumericFailure::Irrational => {
168            panic!("BUG: unit conversion irrational while building RuleResultValue")
169        }
170    }
171}
172
173fn map_literal_unit_map_failure(failure: LiteralUnitMapFailure) -> RuleResultValueFailure {
174    match failure {
175        LiteralUnitMapFailure::Commit(failure) => map_numeric_to_rule_result_value_failure(failure),
176        LiteralUnitMapFailure::UnitConversion(failure) => map_unit_conversion_failure(failure),
177    }
178}
179
180fn expansion_for_type<'a>(
181    rule_type: &LemmaType,
182    catalog: Option<&'a FamilyUnitCatalog>,
183) -> UnitExpansion<'a> {
184    let Some(catalog) = catalog else {
185        return UnitExpansion::Declared;
186    };
187    match catalog.entry_for_type(rule_type) {
188        Some(entry) => UnitExpansion::Family(entry),
189        None => UnitExpansion::Declared,
190    }
191}
192
193fn unit_names_for_expansion(
194    result_type: &LemmaType,
195    expansion: &UnitExpansion<'_>,
196    catalog: Option<&FamilyUnitCatalog>,
197) -> Arc<[String]> {
198    match expansion {
199        UnitExpansion::Declared => Arc::from(declared_bare_names_only(result_type)),
200        UnitExpansion::Family(_) => catalog
201            .expect("BUG: family expansion requires FamilyUnitCatalog")
202            .ordered_bare_names_for_type(result_type),
203    }
204}
205
206fn measure_factor_source<'a>(
207    result_type: &'a LemmaType,
208    expansion: &'a UnitExpansion<'a>,
209) -> UnitFactorSource<'a> {
210    match expansion {
211        UnitExpansion::Declared => UnitFactorSource::DeclaredOn(result_type),
212        UnitExpansion::Family(entry) => UnitFactorSource::Merged {
213            measure: entry.merged_measure_units.as_ref(),
214            ratio: entry.merged_ratio_units.as_ref(),
215        },
216    }
217}
218
219fn ratio_factor_source<'a>(
220    result_type: &'a LemmaType,
221    expansion: &'a UnitExpansion<'a>,
222) -> UnitFactorSource<'a> {
223    measure_factor_source(result_type, expansion)
224}
225
226fn result_value_from_literal(
227    literal: &LiteralValue,
228    lemma_type: &LemmaType,
229    expansion: &UnitExpansion<'_>,
230    catalog: Option<&FamilyUnitCatalog>,
231) -> Result<RuleResultValue, RuleResultValueFailure> {
232    match &literal.value {
233        ValueKind::Range(from, to) => {
234            let from_value = result_value_from_range_endpoint(
235                &from.to_literal(),
236                from.lemma_type.as_ref(),
237                expansion,
238                catalog,
239            )?;
240            let to_value = result_value_from_range_endpoint(
241                &to.to_literal(),
242                to.lemma_type.as_ref(),
243                expansion,
244                catalog,
245            )?;
246            Ok(RuleResultValue {
247                result: Some(literal.display_value_with_type(lemma_type)),
248                range: Some(Box::new(RangeResult {
249                    from: from_value,
250                    to: to_value,
251                })),
252                ..RuleResultValue::default()
253            })
254        }
255        _ => result_value_from_non_range_literal(literal, lemma_type, expansion, catalog),
256    }
257}
258
259/// Build a [`RuleResultValue`] from a canonical [`LiteralValue`] for a rule result.
260///
261/// Measure and ratio expand into every unit in the result type's family.
262pub(crate) fn rule_result_value_from_literal(
263    literal: &LiteralValue,
264    rule_type: &LemmaType,
265    catalog: &FamilyUnitCatalog,
266) -> Result<RuleResultValue, RuleResultValueFailure> {
267    let expansion = expansion_for_type(rule_type, Some(catalog));
268    result_value_from_literal(literal, rule_type, &expansion, Some(catalog))
269}
270
271/// Build a [`RuleResultValue`] using only units declared on `lemma_type` (Show data fill/suggestion).
272pub fn type_scoped_result_value_from_literal(
273    literal: &LiteralValue,
274    lemma_type: &LemmaType,
275) -> Result<RuleResultValue, RuleResultValueFailure> {
276    let expansion = UnitExpansion::Declared;
277    result_value_from_literal(literal, lemma_type, &expansion, None)
278}
279
280fn result_value_from_range_endpoint(
281    endpoint: &LiteralValue,
282    endpoint_type: &LemmaType,
283    expansion: &UnitExpansion<'_>,
284    catalog: Option<&FamilyUnitCatalog>,
285) -> Result<RuleResultValue, RuleResultValueFailure> {
286    if matches!(&endpoint.value, ValueKind::Range(_, _)) {
287        panic!("BUG: range endpoint must not itself be a range");
288    }
289    result_value_from_non_range_literal(endpoint, endpoint_type, expansion, catalog)
290}
291
292fn result_value_from_non_range_literal(
293    literal: &LiteralValue,
294    result_type: &LemmaType,
295    expansion: &UnitExpansion<'_>,
296    catalog: Option<&FamilyUnitCatalog>,
297) -> Result<RuleResultValue, RuleResultValueFailure> {
298    match &literal.value {
299        ValueKind::Measure(rational) if result_type.is_calendar_like() => {
300            let unit = semantic_calendar_unit_from_measure_type(result_type);
301            let value = result_type
302                .try_rational_as_decimal(rational)
303                .map_err(map_numeric_to_rule_result_value_failure)?;
304            let result = Some(literal.display_value_with_type(result_type));
305            Ok(RuleResultValue {
306                result,
307                calendar: Some(CalendarResult {
308                    value,
309                    unit: unit.to_string(),
310                }),
311                ..RuleResultValue::default()
312            })
313        }
314        ValueKind::Measure(_) => {
315            let result = Some(literal.display_value_with_type(result_type));
316            let unit_names = unit_names_for_expansion(result_type, expansion, catalog);
317            let unit_name_refs: Vec<&str> = unit_names.iter().map(String::as_str).collect();
318            Ok(RuleResultValue {
319                result,
320                measure: Some(
321                    result_type
322                        .measure_literal_unit_map(
323                            literal,
324                            &unit_name_refs,
325                            measure_factor_source(result_type, expansion),
326                        )
327                        .map_err(map_literal_unit_map_failure)?,
328                ),
329                unit: result_type.unit().map(str::to_string),
330                ..RuleResultValue::default()
331            })
332        }
333        ValueKind::Ratio(_) => {
334            let result = Some(literal.display_value_with_type(result_type));
335            let unit_names = unit_names_for_expansion(result_type, expansion, catalog);
336            let unit_name_refs: Vec<&str> = unit_names.iter().map(String::as_str).collect();
337            Ok(RuleResultValue {
338                result,
339                ratio: Some(
340                    result_type
341                        .ratio_literal_unit_map(
342                            literal,
343                            &unit_name_refs,
344                            ratio_factor_source(result_type, expansion),
345                        )
346                        .map_err(map_literal_unit_map_failure)?,
347                ),
348                unit: result_type.unit().map(str::to_string),
349                ..RuleResultValue::default()
350            })
351        }
352        other => scalar_result_value(result_type, other),
353    }
354}
355
356fn scalar_result_value(
357    result_type: &LemmaType,
358    value: &ValueKind,
359) -> Result<RuleResultValue, RuleResultValueFailure> {
360    match value {
361        ValueKind::Number(rational) => {
362            let decimal = rational
363                .try_to_decimal()
364                .map_err(map_numeric_to_rule_result_value_failure)?;
365            let api_decimal = format_decimal_for_api(decimal, result_type.decimal_places());
366            let display_string = decimal_to_display_str(&decimal);
367            Ok(RuleResultValue {
368                result: Some(display_string),
369                number: Some(api_decimal),
370                ..RuleResultValue::default()
371            })
372        }
373        ValueKind::Boolean(b) => {
374            let result = Some(value.to_string());
375            Ok(RuleResultValue {
376                result,
377                boolean: Some(*b),
378                ..RuleResultValue::default()
379            })
380        }
381        ValueKind::Text(_) | ValueKind::Date(_) | ValueKind::Time(_) => {
382            let result = Some(value.to_string());
383            Ok(scalar_result_value_non_numeric(result_type, result, value))
384        }
385        ValueKind::Measure(_) | ValueKind::Ratio(_) => {
386            unreachable!("BUG: measure and ratio must be handled by caller")
387        }
388        ValueKind::Range(_, _) => {
389            unreachable!("BUG: range must be handled by result_value_from_literal")
390        }
391        ValueKind::Spec(instance) => {
392            let identity = result_type.spec_identity().unwrap_or_else(|| {
393                panic!(
394                    "BUG: spec value whose type is not a spec ({})",
395                    result_type.name()
396                )
397            });
398            Ok(RuleResultValue {
399                result: Some(instance.to_string()),
400                spec: Some(Box::new(SpecResult {
401                    repository: identity.repository.clone(),
402                    spec: identity.spec.clone(),
403                    effective: identity.effective.to_string(),
404                    instance: instance.to_string(),
405                    data: BTreeMap::new(),
406                    rules: BTreeMap::new(),
407                })),
408                ..RuleResultValue::default()
409            })
410        }
411    }
412}
413
414fn scalar_result_value_non_numeric(
415    _result_type: &LemmaType,
416    result: Option<String>,
417    value: &ValueKind,
418) -> RuleResultValue {
419    match value {
420        ValueKind::Text(s) => RuleResultValue {
421            result,
422            text: Some(s.clone()),
423            ..RuleResultValue::default()
424        },
425        ValueKind::Date(d) => RuleResultValue {
426            result,
427            date: Some(d.clone()),
428            ..RuleResultValue::default()
429        },
430        ValueKind::Time(t) => RuleResultValue {
431            result,
432            time: Some(t.clone()),
433            ..RuleResultValue::default()
434        },
435        _ => unreachable!("BUG: scalar_result_value_non_numeric called with non-scalar type"),
436    }
437}
438
439fn literal_from_measure_map(
440    measure: &BTreeMap<String, Decimal>,
441    rule_type: &LemmaType,
442) -> LiteralValue {
443    let unit_names = rule_type
444        .measure_unit_names()
445        .expect("BUG: measure rule result must have declared units");
446    let unit_name = rule_type
447        .unit()
448        .map(str::to_string)
449        .or_else(|| unit_names.first().map(|name| (*name).to_string()))
450        .expect("BUG: measure rule result type must declare at least one unit");
451    let magnitude = measure
452        .get(&unit_name)
453        .unwrap_or_else(|| panic!("BUG: measure map missing unit '{unit_name}'"));
454    let rational = rational_from_parsed_decimal(*magnitude)
455        .expect("BUG: measure rule result value must lift to rational");
456    let factor = rule_type.measure_unit_factor(&unit_name);
457    let canonical = checked_mul(&rational, factor).unwrap_or_else(|failure| {
458        panic!("BUG: measure canonicalization from RuleResultValue fields failed: {failure}")
459    });
460    let typed = if rule_type.unit() == Some(unit_name.as_str()) {
461        Arc::new(rule_type.clone())
462    } else {
463        Arc::new(rule_type.clone().with_unit(unit_name))
464    };
465    LiteralValue::measure_with_type(canonical, typed)
466}
467
468fn literal_from_ratio_map(
469    ratio: &BTreeMap<String, Decimal>,
470    rule_type: &LemmaType,
471) -> LiteralValue {
472    let ratio_type = ratio_element_type_for_api(rule_type);
473    let units = match &ratio_type.specifications {
474        TypeSpecification::Ratio { units, .. } => units,
475        _ => panic!(
476            "BUG: ratio rule result type must be Ratio, got {}",
477            rule_type.name()
478        ),
479    };
480    let unit = rule_type
481        .unit()
482        .and_then(|name| units.get(name).ok())
483        .or_else(|| units.iter().next())
484        .expect("BUG: ratio rule result type must declare at least one unit");
485    let magnitude = ratio
486        .get(&unit.name)
487        .unwrap_or_else(|| panic!("BUG: ratio map missing unit '{}'", unit.name));
488    let display_rational = rational_from_parsed_decimal(*magnitude)
489        .expect("BUG: ratio rule result value must lift to rational");
490    let canonical = checked_div(&display_rational, &unit.value).unwrap_or_else(|failure| {
491        panic!("BUG: ratio canonicalization from RuleResultValue fields failed: {failure}")
492    });
493    let typed = if rule_type.unit() == Some(unit.name.as_str()) {
494        Arc::new(rule_type.clone())
495    } else {
496        Arc::new(rule_type.clone().with_unit(unit.name.clone()))
497    };
498    LiteralValue::ratio_with_type(canonical, typed)
499}
500
501impl RuleResultValue {
502    /// Reconstruct the [`LiteralValue`] from this API value's fields.
503    ///
504    /// Panics if the fields cannot reconstruct a literal, or if a range endpoint is
505    /// itself a range (ranges do not nest; enforced here, not by convention).
506    /// A spec instance of more than one `uses` hop is not reconstructed yet.
507    pub fn to_literal(&self, rule_type: &LemmaType) -> LiteralValue {
508        if let Some(spec) = &self.spec {
509            let hops: Vec<&str> = spec
510                .instance
511                .split('.')
512                .filter(|hop| !hop.is_empty())
513                .collect();
514            if hops.len() != 1 {
515                todo!(
516                    "rebuilding spec instance '{}' needs each uses hop's spec",
517                    spec.instance
518                );
519            }
520            let identity = rule_type
521                .spec_identity()
522                .unwrap_or_else(|| panic!("BUG: spec result whose rule type is not a spec"));
523            return LiteralValue::new(crate::planning::semantics::ValueKind::Spec(
524                crate::planning::semantics::SpecInstance {
525                    prefix: vec![crate::planning::semantics::PathSegment {
526                        uses: hops[0].to_string(),
527                        repository: identity.repository.clone(),
528                        spec: identity.spec.clone(),
529                    }],
530                },
531            ));
532        }
533        if let Some(range) = &self.range {
534            if range.from.range.is_some() || range.to.range.is_some() {
535                panic!("BUG: range endpoint must not itself be a range");
536            }
537            let endpoint_schema =
538                element_type_from_range_rule(rule_type).unwrap_or_else(|| rule_type.clone());
539            let left_type =
540                endpoint_type_with_written_unit(&endpoint_schema, range.from.unit.as_deref());
541            let right_type =
542                endpoint_type_with_written_unit(&endpoint_schema, range.to.unit.as_deref());
543            let left = range.from.to_literal(&left_type);
544            let right = range.to.to_literal(&right_type);
545            return LiteralValue::range(
546                crate::planning::semantics::TypedLiteral {
547                    value: left.value,
548                    lemma_type: Arc::new(left_type),
549                },
550                crate::planning::semantics::TypedLiteral {
551                    value: right.value,
552                    lemma_type: Arc::new(right_type),
553                },
554            );
555        }
556
557        if let Some(b) = self.boolean {
558            return LiteralValue::from_bool(b);
559        }
560        let owned_rule_type = match self.unit.as_deref() {
561            Some(unit)
562                if matches!(
563                    &rule_type.specifications,
564                    TypeSpecification::Measure { .. } | TypeSpecification::Ratio { .. }
565                ) && rule_type.unit() != Some(unit) =>
566            {
567                Arc::new(rule_type.clone().with_unit(unit))
568            }
569            _ => Arc::new(rule_type.clone()),
570        };
571        if let Some(number) = self.number {
572            return LiteralValue::number_with_type_from_decimal(number, owned_rule_type);
573        }
574        if let Some(calendar) = &self.calendar {
575            let rational = rational_from_parsed_decimal(calendar.value)
576                .expect("BUG: calendar rule result value must lift to rational");
577            return LiteralValue::measure_with_type(rational, owned_rule_type);
578        }
579        if let Some(measure) = &self.measure {
580            return literal_from_measure_map(measure, owned_rule_type.as_ref());
581        }
582        if let Some(ratio) = &self.ratio {
583            return literal_from_ratio_map(ratio, owned_rule_type.as_ref());
584        }
585        if let Some(date) = &self.date {
586            return LiteralValue::date_with_type(date.clone(), owned_rule_type);
587        }
588        if let Some(time) = &self.time {
589            return LiteralValue::time_with_type(time.clone(), owned_rule_type);
590        }
591        if let Some(text) = &self.text {
592            return LiteralValue::text_with_type(text.clone(), owned_rule_type);
593        }
594        panic!("BUG: rule result value fields cannot reconstruct literal");
595    }
596}
597
598fn endpoint_type_with_written_unit(endpoint_schema: &LemmaType, unit: Option<&str>) -> LemmaType {
599    match unit {
600        Some(unit)
601            if matches!(
602                &endpoint_schema.specifications,
603                TypeSpecification::Measure { .. } | TypeSpecification::Ratio { .. }
604            ) && endpoint_schema.unit() != Some(unit) =>
605        {
606            endpoint_schema.clone().with_unit(unit)
607        }
608        _ => endpoint_schema.clone(),
609    }
610}
611
612fn element_type_from_range_rule(rule_type: &LemmaType) -> Option<LemmaType> {
613    range_element_type_specification(&rule_type.specifications).map(LemmaType::primitive)
614}
615
616fn format_unit_map(map: &BTreeMap<String, Decimal>) -> String {
617    map.iter()
618        .map(|(unit, value)| format!("{value} {unit}"))
619        .collect::<Vec<_>>()
620        .join(", ")
621}
622
623impl fmt::Display for RuleResultValue {
624    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
625        if let Some(range) = &self.range {
626            return write!(f, "{}...{}", range.from, range.to);
627        }
628        if let Some(measure) = &self.measure {
629            return write!(f, "{}", format_unit_map(measure));
630        }
631        if let Some(ratio) = &self.ratio {
632            return write!(f, "{}", format_unit_map(ratio));
633        }
634        if let Some(number) = &self.number {
635            return write!(f, "{number}");
636        }
637        if let Some(b) = self.boolean {
638            return write!(f, "{b}");
639        }
640        if let Some(text) = &self.text {
641            return write!(f, "{text}");
642        }
643        if let Some(date) = &self.date {
644            return write!(f, "{date}");
645        }
646        if let Some(time) = &self.time {
647            return write!(f, "{time}");
648        }
649        if let Some(calendar) = &self.calendar {
650            return write!(f, "{} {}", calendar.value, calendar.unit);
651        }
652        if let Some(display) = &self.result {
653            return write!(f, "{display}");
654        }
655        write!(f, "")
656    }
657}