Skip to main content

lemma/api/
types.rs

1//! LemmaType and related JSON shapes matching today's domain serde.
2
3use crate::computation::rational::RationalInteger;
4use crate::literals::{
5    BaseMeasureVector, DateTimeValue, MeasureUnit as DomainMeasureUnit, MeasureUnits,
6    RatioUnit as DomainRatioUnit, RatioUnits, TimeValue,
7};
8use crate::planning::semantics::{
9    Dimension, LemmaType as DomainLemmaType, MeasureTrait as DomainMeasureTrait,
10    TypeDefiningSpec as DomainTypeDefiningSpec, TypeExtends as DomainTypeExtends,
11    TypeSpecification as DomainTypeSpecification,
12};
13use rust_decimal::Decimal;
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16
17/// Exact rational factor as reduced numer/denom integer strings.
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct RationalFactor {
20    pub numer: String,
21    pub denom: String,
22}
23
24impl RationalFactor {
25    fn from_ratio(value: &RationalInteger) -> Self {
26        let reduced = value
27            .clone()
28            .try_reduce()
29            .expect("BUG: stored measure unit factor must reduce");
30        Self {
31            numer: reduced.numer_to_string(),
32            denom: reduced.denom_to_string(),
33        }
34    }
35}
36
37/// Unit-scoped bound `{ value, unit }` (decimal magnitude + unit name).
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct NamedBound {
40    pub value: Decimal,
41    pub unit: String,
42}
43
44fn planned_bound_decimal(rational: &RationalInteger) -> Decimal {
45    rational
46        .try_to_decimal()
47        .expect("BUG: planned bound must convert to decimal")
48}
49
50fn named_bound_from(bound: &(RationalInteger, String)) -> NamedBound {
51    NamedBound {
52        value: planned_bound_decimal(&bound.0),
53        unit: bound.1.clone(),
54    }
55}
56
57fn optional_named_bound(bound: &Option<(RationalInteger, String)>) -> Option<NamedBound> {
58    bound.as_ref().map(named_bound_from)
59}
60
61fn optional_decimal_bound(bound: &Option<RationalInteger>) -> Option<Decimal> {
62    bound.as_ref().map(planned_bound_decimal)
63}
64
65/// JSON object of dimension to exponent. A family is keyed by its root type qualified
66/// with the declaring spec (`"pricing.money"`, `"lemma units.mass"`); builtin axes are
67/// `"duration"` and `"calendar"`: `{"pricing.money": 1, "duration": -1}`.
68pub type Decomposition = BTreeMap<String, i32>;
69
70fn decomposition_from(domain: &BaseMeasureVector) -> Decomposition {
71    let mut converted = Decomposition::new();
72    for (dimension, exponent) in domain {
73        let key = match dimension {
74            Dimension::Family(family) => family.to_string(),
75            Dimension::Duration | Dimension::Calendar => dimension.name().to_string(),
76        };
77        if converted.insert(key.clone(), *exponent).is_some() {
78            panic!("BUG: distinct dimensions share API key '{key}'");
79        }
80    }
81    converted
82}
83
84fn optional_decomposition_from(domain: &Option<BaseMeasureVector>) -> Option<Decomposition> {
85    domain.as_ref().map(decomposition_from)
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum MeasureTrait {
91    Duration,
92    Calendar,
93}
94
95impl From<DomainMeasureTrait> for MeasureTrait {
96    fn from(value: DomainMeasureTrait) -> Self {
97        match value {
98            DomainMeasureTrait::Duration => Self::Duration,
99            DomainMeasureTrait::Calendar => Self::Calendar,
100        }
101    }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct MeasureUnit {
106    pub name: String,
107    #[serde(skip_serializing_if = "Option::is_none", default)]
108    pub factor: Option<RationalFactor>,
109    #[serde(skip_serializing_if = "Option::is_none", default)]
110    pub reference: Option<String>,
111    /// Exponent applied to the settled reference for a dynamic scale. Omitted when 1.
112    #[serde(skip_serializing_if = "Option::is_none", default)]
113    pub scale_exponent: Option<i32>,
114    pub derived_measure_factors: Vec<(String, i32)>,
115    pub decomposition: Decomposition,
116    #[serde(skip_serializing_if = "Option::is_none", default)]
117    pub minimum: Option<Decimal>,
118    #[serde(skip_serializing_if = "Option::is_none", default)]
119    pub maximum: Option<Decimal>,
120    #[serde(
121        rename = "suggestion",
122        skip_serializing_if = "Option::is_none",
123        default
124    )]
125    pub suggestion_magnitude: Option<Decimal>,
126}
127
128impl From<&DomainMeasureUnit> for MeasureUnit {
129    fn from(unit: &DomainMeasureUnit) -> Self {
130        let (factor, reference, scale_exponent, omit_bounds) = match &unit.scale {
131            crate::literals::UnitScale::Literal(factor) => {
132                (Some(RationalFactor::from_ratio(factor)), None, None, false)
133            }
134            crate::literals::UnitScale::Unresolved {
135                written,
136                coefficient,
137                exponent,
138                ..
139            } => {
140                let factor = if *coefficient == crate::computation::rational::rational_one() {
141                    None
142                } else {
143                    Some(RationalFactor::from_ratio(coefficient))
144                };
145                let scale_exponent = (*exponent != 1).then_some(*exponent);
146                (factor, Some(written.clone()), scale_exponent, true)
147            }
148            crate::literals::UnitScale::Reference {
149                target,
150                coefficient,
151                exponent,
152            } => {
153                let factor = if *coefficient == crate::computation::rational::rational_one() {
154                    None
155                } else {
156                    Some(RationalFactor::from_ratio(coefficient))
157                };
158                let scale_exponent = (*exponent != 1).then_some(*exponent);
159                (factor, Some(target.to_string()), scale_exponent, true)
160            }
161        };
162        Self {
163            name: unit.name.clone(),
164            factor,
165            reference,
166            scale_exponent,
167            derived_measure_factors: unit.derived_measure_factors.clone(),
168            decomposition: decomposition_from(&unit.decomposition),
169            minimum: if omit_bounds {
170                None
171            } else {
172                unit.minimum.as_ref().map(|minimum| {
173                    minimum
174                        .try_to_decimal()
175                        .expect("BUG: planned measure unit minimum must convert to decimal")
176                })
177            },
178            maximum: if omit_bounds {
179                None
180            } else {
181                unit.maximum.as_ref().map(|maximum| {
182                    maximum
183                        .try_to_decimal()
184                        .expect("BUG: planned measure unit maximum must convert to decimal")
185                })
186            },
187            suggestion_magnitude: if omit_bounds {
188                None
189            } else {
190                unit.suggestion_magnitude.as_ref().map(|suggestion| {
191                    suggestion
192                        .try_to_decimal()
193                        .expect("BUG: planned measure unit suggestion must convert to decimal")
194                })
195            },
196        }
197    }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct RatioUnit {
202    pub name: String,
203    pub value: RationalFactor,
204    #[serde(skip_serializing_if = "Option::is_none", default)]
205    pub minimum: Option<Decimal>,
206    #[serde(skip_serializing_if = "Option::is_none", default)]
207    pub maximum: Option<Decimal>,
208    #[serde(
209        rename = "suggestion",
210        skip_serializing_if = "Option::is_none",
211        default
212    )]
213    pub suggestion_magnitude: Option<Decimal>,
214}
215
216impl From<&DomainRatioUnit> for RatioUnit {
217    fn from(unit: &DomainRatioUnit) -> Self {
218        Self {
219            name: unit.name.clone(),
220            value: RationalFactor::from_ratio(&unit.value),
221            minimum: unit.minimum.as_ref().map(|minimum| {
222                minimum
223                    .try_to_decimal()
224                    .expect("BUG: planned ratio unit minimum must convert to decimal")
225            }),
226            maximum: unit.maximum.as_ref().map(|maximum| {
227                maximum
228                    .try_to_decimal()
229                    .expect("BUG: planned ratio unit maximum must convert to decimal")
230            }),
231            suggestion_magnitude: unit.suggestion_magnitude.as_ref().map(|suggestion| {
232                suggestion
233                    .try_to_decimal()
234                    .expect("BUG: planned ratio unit suggestion must convert to decimal")
235            }),
236        }
237    }
238}
239
240fn measure_units_from(units: &MeasureUnits) -> Vec<MeasureUnit> {
241    units.iter().map(MeasureUnit::from).collect()
242}
243
244fn ratio_units_from(units: &RatioUnits) -> Vec<RatioUnit> {
245    units.iter().map(RatioUnit::from).collect()
246}
247
248#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
249#[serde(tag = "kind", rename_all = "snake_case")]
250pub enum TypeDefiningSpec {
251    Local,
252    Import,
253}
254
255impl From<&DomainTypeDefiningSpec> for TypeDefiningSpec {
256    fn from(value: &DomainTypeDefiningSpec) -> Self {
257        match value {
258            DomainTypeDefiningSpec::Local => Self::Local,
259            DomainTypeDefiningSpec::Import => Self::Import,
260        }
261    }
262}
263
264#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
265#[serde(tag = "kind", rename_all = "snake_case")]
266pub enum TypeExtends {
267    Primitive,
268    Custom {
269        parent: String,
270        family: String,
271        defining_spec: TypeDefiningSpec,
272    },
273}
274
275impl From<&DomainTypeExtends> for TypeExtends {
276    fn from(value: &DomainTypeExtends) -> Self {
277        match value {
278            DomainTypeExtends::Primitive => Self::Primitive,
279            DomainTypeExtends::Custom {
280                parent,
281                family,
282                defining_spec,
283            } => Self::Custom {
284                parent: parent.clone(),
285                family: family.to_string(),
286                defining_spec: TypeDefiningSpec::from(defining_spec),
287            },
288        }
289    }
290}
291
292#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(tag = "kind", rename_all = "snake_case")]
294pub enum TypeSpecification {
295    Boolean {
296        help: String,
297    },
298    Measure {
299        #[serde(default)]
300        minimum: Option<NamedBound>,
301        #[serde(default)]
302        maximum: Option<NamedBound>,
303        units: Vec<MeasureUnit>,
304        #[serde(default)]
305        traits: Vec<MeasureTrait>,
306        #[serde(default)]
307        decomposition: Option<Decomposition>,
308        #[serde(default, skip_serializing_if = "Option::is_none")]
309        unit: Option<String>,
310        help: String,
311    },
312    Number {
313        #[serde(default)]
314        minimum: Option<Decimal>,
315        #[serde(default)]
316        maximum: Option<Decimal>,
317        decimals: Option<u8>,
318        help: String,
319    },
320    NumberRange {
321        #[serde(default)]
322        lower: Option<Decimal>,
323        #[serde(default)]
324        upper: Option<Decimal>,
325        #[serde(default)]
326        minimum: Option<Decimal>,
327        #[serde(default)]
328        maximum: Option<Decimal>,
329        help: String,
330    },
331    Ratio {
332        #[serde(default)]
333        minimum: Option<Decimal>,
334        #[serde(default)]
335        maximum: Option<Decimal>,
336        units: Vec<RatioUnit>,
337        #[serde(default, skip_serializing_if = "Option::is_none")]
338        unit: Option<String>,
339        help: String,
340    },
341    RatioRange {
342        #[serde(default)]
343        lower: Option<Decimal>,
344        #[serde(default)]
345        upper: Option<Decimal>,
346        #[serde(default)]
347        minimum: Option<Decimal>,
348        #[serde(default)]
349        maximum: Option<Decimal>,
350        units: Vec<RatioUnit>,
351        help: String,
352    },
353    Text {
354        length: Option<usize>,
355        options: Vec<String>,
356        help: String,
357    },
358    Date {
359        minimum: Option<DateTimeValue>,
360        maximum: Option<DateTimeValue>,
361        help: String,
362    },
363    DateRange {
364        lower: Option<DateTimeValue>,
365        upper: Option<DateTimeValue>,
366        #[serde(default)]
367        minimum: Option<NamedBound>,
368        #[serde(default)]
369        maximum: Option<NamedBound>,
370        help: String,
371    },
372    Time {
373        minimum: Option<TimeValue>,
374        maximum: Option<TimeValue>,
375        help: String,
376    },
377    TimeRange {
378        lower: Option<TimeValue>,
379        upper: Option<TimeValue>,
380        #[serde(default)]
381        minimum: Option<NamedBound>,
382        #[serde(default)]
383        maximum: Option<NamedBound>,
384        help: String,
385    },
386    MeasureRange {
387        #[serde(default)]
388        lower: Option<NamedBound>,
389        #[serde(default)]
390        upper: Option<NamedBound>,
391        #[serde(default)]
392        minimum: Option<NamedBound>,
393        #[serde(default)]
394        maximum: Option<NamedBound>,
395        units: Vec<MeasureUnit>,
396        #[serde(default)]
397        decomposition: Option<Decomposition>,
398        help: String,
399    },
400    Veto {
401        message: Option<String>,
402    },
403    Undetermined,
404    Spec {
405        #[serde(default, skip_serializing_if = "Option::is_none")]
406        repository: Option<String>,
407        spec: String,
408        effective: String,
409    },
410    List {
411        element: Box<TypeSpecification>,
412        key: ListKey,
413    },
414}
415
416/// How a list addresses its rows: by row number, or by the ordered `-> key` fields.
417#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(rename_all = "snake_case")]
419pub enum ListKey {
420    Numbered,
421    Fields(Vec<String>),
422}
423
424impl From<&crate::planning::semantics::ListKey> for ListKey {
425    fn from(key: &crate::planning::semantics::ListKey) -> Self {
426        match key {
427            crate::planning::semantics::ListKey::Numbered => Self::Numbered,
428            crate::planning::semantics::ListKey::Fields(fields) => {
429                Self::Fields(fields.iter().map(|field| field.name.clone()).collect())
430            }
431        }
432    }
433}
434
435impl From<&DomainTypeSpecification> for TypeSpecification {
436    fn from(spec: &DomainTypeSpecification) -> Self {
437        let mut list_keys = Vec::new();
438        let mut leaf = spec;
439        while let DomainTypeSpecification::List { element, key, .. } = leaf {
440            list_keys.push(ListKey::from(key));
441            leaf = &element.specifications;
442        }
443        let mut converted = Self::non_list_from(leaf);
444        while let Some(key) = list_keys.pop() {
445            converted = Self::List {
446                element: Box::new(converted),
447                key,
448            };
449        }
450        converted
451    }
452}
453
454impl TypeSpecification {
455    fn non_list_from(spec: &DomainTypeSpecification) -> Self {
456        match spec {
457            DomainTypeSpecification::Boolean { help } => Self::Boolean { help: help.clone() },
458            DomainTypeSpecification::Measure {
459                minimum,
460                maximum,
461                units,
462                traits,
463                decomposition,
464                unit,
465                help,
466            } => Self::Measure {
467                minimum: optional_named_bound(minimum),
468                maximum: optional_named_bound(maximum),
469                units: measure_units_from(units),
470                traits: traits.iter().copied().map(MeasureTrait::from).collect(),
471                decomposition: optional_decomposition_from(decomposition),
472                unit: unit.clone(),
473                help: help.clone(),
474            },
475            DomainTypeSpecification::Number {
476                minimum,
477                maximum,
478                decimals,
479                help,
480            } => Self::Number {
481                minimum: optional_decimal_bound(minimum),
482                maximum: optional_decimal_bound(maximum),
483                decimals: *decimals,
484                help: help.clone(),
485            },
486            DomainTypeSpecification::NumberRange {
487                lower,
488                upper,
489                minimum,
490                maximum,
491                help,
492            } => Self::NumberRange {
493                lower: optional_decimal_bound(lower),
494                upper: optional_decimal_bound(upper),
495                minimum: optional_decimal_bound(minimum),
496                maximum: optional_decimal_bound(maximum),
497                help: help.clone(),
498            },
499            DomainTypeSpecification::Ratio {
500                minimum,
501                maximum,
502                units,
503                unit,
504                help,
505            } => Self::Ratio {
506                minimum: optional_decimal_bound(minimum),
507                maximum: optional_decimal_bound(maximum),
508                units: ratio_units_from(units),
509                unit: unit.clone(),
510                help: help.clone(),
511            },
512            DomainTypeSpecification::RatioRange {
513                lower,
514                upper,
515                minimum,
516                maximum,
517                units,
518                help,
519            } => Self::RatioRange {
520                lower: optional_decimal_bound(lower),
521                upper: optional_decimal_bound(upper),
522                minimum: optional_decimal_bound(minimum),
523                maximum: optional_decimal_bound(maximum),
524                units: ratio_units_from(units),
525                help: help.clone(),
526            },
527            DomainTypeSpecification::Text { length, help } => Self::Text {
528                length: *length,
529                options: Vec::new(),
530                help: help.clone(),
531            },
532            DomainTypeSpecification::Date {
533                minimum,
534                maximum,
535                help,
536            } => Self::Date {
537                minimum: minimum.clone(),
538                maximum: maximum.clone(),
539                help: help.clone(),
540            },
541            DomainTypeSpecification::DateRange {
542                lower,
543                upper,
544                minimum,
545                maximum,
546                help,
547            } => Self::DateRange {
548                lower: lower.clone(),
549                upper: upper.clone(),
550                minimum: optional_named_bound(minimum),
551                maximum: optional_named_bound(maximum),
552                help: help.clone(),
553            },
554            DomainTypeSpecification::Time {
555                minimum,
556                maximum,
557                help,
558            } => Self::Time {
559                minimum: minimum.clone(),
560                maximum: maximum.clone(),
561                help: help.clone(),
562            },
563            DomainTypeSpecification::TimeRange {
564                lower,
565                upper,
566                minimum,
567                maximum,
568                help,
569            } => Self::TimeRange {
570                lower: lower.clone(),
571                upper: upper.clone(),
572                minimum: optional_named_bound(minimum),
573                maximum: optional_named_bound(maximum),
574                help: help.clone(),
575            },
576            DomainTypeSpecification::MeasureRange {
577                lower,
578                upper,
579                minimum,
580                maximum,
581                units,
582                decomposition,
583                help,
584            } => Self::MeasureRange {
585                lower: optional_named_bound(lower),
586                upper: optional_named_bound(upper),
587                minimum: optional_named_bound(minimum),
588                maximum: optional_named_bound(maximum),
589                units: measure_units_from(units),
590                decomposition: optional_decomposition_from(decomposition),
591                help: help.clone(),
592            },
593            DomainTypeSpecification::Veto { message } => Self::Veto {
594                message: message.clone(),
595            },
596            DomainTypeSpecification::Undetermined => Self::Undetermined,
597            DomainTypeSpecification::Spec { spec } => Self::Spec {
598                repository: spec.repository.clone(),
599                spec: spec.spec.clone(),
600                effective: spec.effective.to_string(),
601            },
602            DomainTypeSpecification::List { .. } => {
603                unreachable!("BUG: list specifications are unwound by TypeSpecification::from")
604            }
605        }
606    }
607}
608
609#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
610pub struct LemmaType {
611    pub name: Option<String>,
612    #[serde(flatten)]
613    pub specifications: TypeSpecification,
614    pub extends: TypeExtends,
615}
616
617impl From<&DomainLemmaType> for LemmaType {
618    fn from(lemma_type: &DomainLemmaType) -> Self {
619        let mut specifications = TypeSpecification::from(&lemma_type.specifications);
620        if let TypeSpecification::Text { options, .. } = &mut specifications {
621            *options = lemma_type
622                .options_for_check()
623                .values
624                .iter()
625                .filter_map(|option| match &option.value.value {
626                    crate::planning::semantics::ValueKind::Text(text) => Some(text.to_string()),
627                    _ => None,
628                })
629                .collect();
630        }
631        Self {
632            name: lemma_type.name.clone(),
633            specifications,
634            extends: TypeExtends::from(&lemma_type.extends),
635        }
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642    use crate::planning::semantics::TypeFamily;
643    use serde_json::json;
644
645    fn family(repository: Option<&str>, spec: &str, name: &str) -> Dimension {
646        Dimension::Family(TypeFamily {
647            repository: repository.map(str::to_string),
648            spec: spec.to_string(),
649            effective: crate::parsing::ast::EffectiveDate::Origin,
650            name: name.to_string(),
651        })
652    }
653
654    #[test]
655    fn decomposition_keys_families_by_declaring_spec() {
656        let mut domain = BaseMeasureVector::new();
657        domain.insert(family(None, "pricing", "money"), 1);
658        domain.insert(family(Some("lemma"), "units", "mass"), -1);
659        domain.insert(Dimension::Duration, -1);
660        let json = serde_json::to_value(decomposition_from(&domain)).expect("JSON");
661        assert_eq!(
662            json,
663            json!({ "pricing.money": 1, "lemma units.mass": -1, "duration": -1 })
664        );
665    }
666
667    #[test]
668    fn decomposition_keeps_same_named_families_apart() {
669        let mut domain = BaseMeasureVector::new();
670        domain.insert(family(None, "a", "money"), 1);
671        domain.insert(family(None, "b", "money"), -1);
672        let json = serde_json::to_value(decomposition_from(&domain)).expect("JSON");
673        assert_eq!(json, json!({ "a.money": 1, "b.money": -1 }));
674    }
675
676    #[test]
677    fn decomposition_keeps_builtin_apart_from_same_named_family() {
678        let mut domain = BaseMeasureVector::new();
679        domain.insert(family(None, "clock", "duration"), 1);
680        domain.insert(Dimension::Duration, -1);
681        let json = serde_json::to_value(decomposition_from(&domain)).expect("JSON");
682        assert_eq!(json, json!({ "clock.duration": 1, "duration": -1 }));
683    }
684}