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    pub derived_measure_factors: Vec<(String, i32)>,
112    pub decomposition: Decomposition,
113    #[serde(skip_serializing_if = "Option::is_none", default)]
114    pub minimum: Option<Decimal>,
115    #[serde(skip_serializing_if = "Option::is_none", default)]
116    pub maximum: Option<Decimal>,
117    #[serde(
118        rename = "suggestion",
119        skip_serializing_if = "Option::is_none",
120        default
121    )]
122    pub suggestion_magnitude: Option<Decimal>,
123}
124
125impl From<&DomainMeasureUnit> for MeasureUnit {
126    fn from(unit: &DomainMeasureUnit) -> Self {
127        let (factor, reference, omit_bounds) = match &unit.scale {
128            crate::literals::UnitScale::Literal(factor) => {
129                (Some(RationalFactor::from_ratio(factor)), None, false)
130            }
131            crate::literals::UnitScale::Unresolved { written, .. } => {
132                (None, Some(written.clone()), true)
133            }
134            crate::literals::UnitScale::Reference(target) => (None, Some(target.to_string()), true),
135        };
136        Self {
137            name: unit.name.clone(),
138            factor,
139            reference,
140            derived_measure_factors: unit.derived_measure_factors.clone(),
141            decomposition: decomposition_from(&unit.decomposition),
142            minimum: if omit_bounds {
143                None
144            } else {
145                unit.minimum.as_ref().map(|minimum| {
146                    minimum
147                        .try_to_decimal()
148                        .expect("BUG: planned measure unit minimum must convert to decimal")
149                })
150            },
151            maximum: if omit_bounds {
152                None
153            } else {
154                unit.maximum.as_ref().map(|maximum| {
155                    maximum
156                        .try_to_decimal()
157                        .expect("BUG: planned measure unit maximum must convert to decimal")
158                })
159            },
160            suggestion_magnitude: if omit_bounds {
161                None
162            } else {
163                unit.suggestion_magnitude.as_ref().map(|suggestion| {
164                    suggestion
165                        .try_to_decimal()
166                        .expect("BUG: planned measure unit suggestion must convert to decimal")
167                })
168            },
169        }
170    }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct RatioUnit {
175    pub name: String,
176    pub value: RationalFactor,
177    #[serde(skip_serializing_if = "Option::is_none", default)]
178    pub minimum: Option<Decimal>,
179    #[serde(skip_serializing_if = "Option::is_none", default)]
180    pub maximum: Option<Decimal>,
181    #[serde(
182        rename = "suggestion",
183        skip_serializing_if = "Option::is_none",
184        default
185    )]
186    pub suggestion_magnitude: Option<Decimal>,
187}
188
189impl From<&DomainRatioUnit> for RatioUnit {
190    fn from(unit: &DomainRatioUnit) -> Self {
191        Self {
192            name: unit.name.clone(),
193            value: RationalFactor::from_ratio(&unit.value),
194            minimum: unit.minimum.as_ref().map(|minimum| {
195                minimum
196                    .try_to_decimal()
197                    .expect("BUG: planned ratio unit minimum must convert to decimal")
198            }),
199            maximum: unit.maximum.as_ref().map(|maximum| {
200                maximum
201                    .try_to_decimal()
202                    .expect("BUG: planned ratio unit maximum must convert to decimal")
203            }),
204            suggestion_magnitude: unit.suggestion_magnitude.as_ref().map(|suggestion| {
205                suggestion
206                    .try_to_decimal()
207                    .expect("BUG: planned ratio unit suggestion must convert to decimal")
208            }),
209        }
210    }
211}
212
213fn measure_units_from(units: &MeasureUnits) -> Vec<MeasureUnit> {
214    units.iter().map(MeasureUnit::from).collect()
215}
216
217fn ratio_units_from(units: &RatioUnits) -> Vec<RatioUnit> {
218    units.iter().map(RatioUnit::from).collect()
219}
220
221#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
222#[serde(tag = "kind", rename_all = "snake_case")]
223pub enum TypeDefiningSpec {
224    Local,
225    Import,
226}
227
228impl From<&DomainTypeDefiningSpec> for TypeDefiningSpec {
229    fn from(value: &DomainTypeDefiningSpec) -> Self {
230        match value {
231            DomainTypeDefiningSpec::Local => Self::Local,
232            DomainTypeDefiningSpec::Import => Self::Import,
233        }
234    }
235}
236
237#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
238#[serde(tag = "kind", rename_all = "snake_case")]
239pub enum TypeExtends {
240    Primitive,
241    Custom {
242        parent: String,
243        family: String,
244        defining_spec: TypeDefiningSpec,
245    },
246}
247
248impl From<&DomainTypeExtends> for TypeExtends {
249    fn from(value: &DomainTypeExtends) -> Self {
250        match value {
251            DomainTypeExtends::Primitive => Self::Primitive,
252            DomainTypeExtends::Custom {
253                parent,
254                family,
255                defining_spec,
256            } => Self::Custom {
257                parent: parent.clone(),
258                family: family.to_string(),
259                defining_spec: TypeDefiningSpec::from(defining_spec),
260            },
261        }
262    }
263}
264
265#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(tag = "kind", rename_all = "snake_case")]
267pub enum TypeSpecification {
268    Boolean {
269        help: String,
270    },
271    Measure {
272        #[serde(default)]
273        minimum: Option<NamedBound>,
274        #[serde(default)]
275        maximum: Option<NamedBound>,
276        units: Vec<MeasureUnit>,
277        #[serde(default)]
278        traits: Vec<MeasureTrait>,
279        #[serde(default)]
280        decomposition: Option<Decomposition>,
281        #[serde(default, skip_serializing_if = "Option::is_none")]
282        unit: Option<String>,
283        help: String,
284    },
285    Number {
286        #[serde(default)]
287        minimum: Option<Decimal>,
288        #[serde(default)]
289        maximum: Option<Decimal>,
290        decimals: Option<u8>,
291        help: String,
292    },
293    NumberRange {
294        #[serde(default)]
295        lower: Option<Decimal>,
296        #[serde(default)]
297        upper: Option<Decimal>,
298        #[serde(default)]
299        minimum: Option<Decimal>,
300        #[serde(default)]
301        maximum: Option<Decimal>,
302        help: String,
303    },
304    Ratio {
305        #[serde(default)]
306        minimum: Option<Decimal>,
307        #[serde(default)]
308        maximum: Option<Decimal>,
309        units: Vec<RatioUnit>,
310        #[serde(default, skip_serializing_if = "Option::is_none")]
311        unit: Option<String>,
312        help: String,
313    },
314    RatioRange {
315        #[serde(default)]
316        lower: Option<Decimal>,
317        #[serde(default)]
318        upper: Option<Decimal>,
319        #[serde(default)]
320        minimum: Option<Decimal>,
321        #[serde(default)]
322        maximum: Option<Decimal>,
323        units: Vec<RatioUnit>,
324        help: String,
325    },
326    Text {
327        length: Option<usize>,
328        options: Vec<String>,
329        help: String,
330    },
331    Date {
332        minimum: Option<DateTimeValue>,
333        maximum: Option<DateTimeValue>,
334        help: String,
335    },
336    DateRange {
337        lower: Option<DateTimeValue>,
338        upper: Option<DateTimeValue>,
339        #[serde(default)]
340        minimum: Option<NamedBound>,
341        #[serde(default)]
342        maximum: Option<NamedBound>,
343        help: String,
344    },
345    Time {
346        minimum: Option<TimeValue>,
347        maximum: Option<TimeValue>,
348        help: String,
349    },
350    TimeRange {
351        lower: Option<TimeValue>,
352        upper: Option<TimeValue>,
353        #[serde(default)]
354        minimum: Option<NamedBound>,
355        #[serde(default)]
356        maximum: Option<NamedBound>,
357        help: String,
358    },
359    MeasureRange {
360        #[serde(default)]
361        lower: Option<NamedBound>,
362        #[serde(default)]
363        upper: Option<NamedBound>,
364        #[serde(default)]
365        minimum: Option<NamedBound>,
366        #[serde(default)]
367        maximum: Option<NamedBound>,
368        units: Vec<MeasureUnit>,
369        #[serde(default)]
370        decomposition: Option<Decomposition>,
371        help: String,
372    },
373    Veto {
374        message: Option<String>,
375    },
376    Undetermined,
377    Spec {
378        #[serde(default, skip_serializing_if = "Option::is_none")]
379        repository: Option<String>,
380        spec: String,
381        effective: String,
382    },
383    List {
384        element: Box<TypeSpecification>,
385        key: ListKey,
386    },
387}
388
389/// How a list addresses its rows: by row number, or by the ordered `-> key` fields.
390#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
391#[serde(rename_all = "snake_case")]
392pub enum ListKey {
393    Numbered,
394    Fields(Vec<String>),
395}
396
397impl From<&crate::planning::semantics::ListKey> for ListKey {
398    fn from(key: &crate::planning::semantics::ListKey) -> Self {
399        match key {
400            crate::planning::semantics::ListKey::Numbered => Self::Numbered,
401            crate::planning::semantics::ListKey::Fields(fields) => {
402                Self::Fields(fields.iter().map(|field| field.name.clone()).collect())
403            }
404        }
405    }
406}
407
408impl From<&DomainTypeSpecification> for TypeSpecification {
409    fn from(spec: &DomainTypeSpecification) -> Self {
410        let mut list_keys = Vec::new();
411        let mut leaf = spec;
412        while let DomainTypeSpecification::List { element, key, .. } = leaf {
413            list_keys.push(ListKey::from(key));
414            leaf = &element.specifications;
415        }
416        let mut converted = Self::non_list_from(leaf);
417        while let Some(key) = list_keys.pop() {
418            converted = Self::List {
419                element: Box::new(converted),
420                key,
421            };
422        }
423        converted
424    }
425}
426
427impl TypeSpecification {
428    fn non_list_from(spec: &DomainTypeSpecification) -> Self {
429        match spec {
430            DomainTypeSpecification::Boolean { help } => Self::Boolean { help: help.clone() },
431            DomainTypeSpecification::Measure {
432                minimum,
433                maximum,
434                units,
435                traits,
436                decomposition,
437                unit,
438                help,
439            } => Self::Measure {
440                minimum: optional_named_bound(minimum),
441                maximum: optional_named_bound(maximum),
442                units: measure_units_from(units),
443                traits: traits.iter().copied().map(MeasureTrait::from).collect(),
444                decomposition: optional_decomposition_from(decomposition),
445                unit: unit.clone(),
446                help: help.clone(),
447            },
448            DomainTypeSpecification::Number {
449                minimum,
450                maximum,
451                decimals,
452                help,
453            } => Self::Number {
454                minimum: optional_decimal_bound(minimum),
455                maximum: optional_decimal_bound(maximum),
456                decimals: *decimals,
457                help: help.clone(),
458            },
459            DomainTypeSpecification::NumberRange {
460                lower,
461                upper,
462                minimum,
463                maximum,
464                help,
465            } => Self::NumberRange {
466                lower: optional_decimal_bound(lower),
467                upper: optional_decimal_bound(upper),
468                minimum: optional_decimal_bound(minimum),
469                maximum: optional_decimal_bound(maximum),
470                help: help.clone(),
471            },
472            DomainTypeSpecification::Ratio {
473                minimum,
474                maximum,
475                units,
476                unit,
477                help,
478            } => Self::Ratio {
479                minimum: optional_decimal_bound(minimum),
480                maximum: optional_decimal_bound(maximum),
481                units: ratio_units_from(units),
482                unit: unit.clone(),
483                help: help.clone(),
484            },
485            DomainTypeSpecification::RatioRange {
486                lower,
487                upper,
488                minimum,
489                maximum,
490                units,
491                help,
492            } => Self::RatioRange {
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                units: ratio_units_from(units),
498                help: help.clone(),
499            },
500            DomainTypeSpecification::Text { length, help } => Self::Text {
501                length: *length,
502                options: Vec::new(),
503                help: help.clone(),
504            },
505            DomainTypeSpecification::Date {
506                minimum,
507                maximum,
508                help,
509            } => Self::Date {
510                minimum: minimum.clone(),
511                maximum: maximum.clone(),
512                help: help.clone(),
513            },
514            DomainTypeSpecification::DateRange {
515                lower,
516                upper,
517                minimum,
518                maximum,
519                help,
520            } => Self::DateRange {
521                lower: lower.clone(),
522                upper: upper.clone(),
523                minimum: optional_named_bound(minimum),
524                maximum: optional_named_bound(maximum),
525                help: help.clone(),
526            },
527            DomainTypeSpecification::Time {
528                minimum,
529                maximum,
530                help,
531            } => Self::Time {
532                minimum: minimum.clone(),
533                maximum: maximum.clone(),
534                help: help.clone(),
535            },
536            DomainTypeSpecification::TimeRange {
537                lower,
538                upper,
539                minimum,
540                maximum,
541                help,
542            } => Self::TimeRange {
543                lower: lower.clone(),
544                upper: upper.clone(),
545                minimum: optional_named_bound(minimum),
546                maximum: optional_named_bound(maximum),
547                help: help.clone(),
548            },
549            DomainTypeSpecification::MeasureRange {
550                lower,
551                upper,
552                minimum,
553                maximum,
554                units,
555                decomposition,
556                help,
557            } => Self::MeasureRange {
558                lower: optional_named_bound(lower),
559                upper: optional_named_bound(upper),
560                minimum: optional_named_bound(minimum),
561                maximum: optional_named_bound(maximum),
562                units: measure_units_from(units),
563                decomposition: optional_decomposition_from(decomposition),
564                help: help.clone(),
565            },
566            DomainTypeSpecification::Veto { message } => Self::Veto {
567                message: message.clone(),
568            },
569            DomainTypeSpecification::Undetermined => Self::Undetermined,
570            DomainTypeSpecification::Spec { spec } => Self::Spec {
571                repository: spec.repository.clone(),
572                spec: spec.spec.clone(),
573                effective: spec.effective.to_string(),
574            },
575            DomainTypeSpecification::List { .. } => {
576                unreachable!("BUG: list specifications are unwound by TypeSpecification::from")
577            }
578        }
579    }
580}
581
582#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
583pub struct LemmaType {
584    pub name: Option<String>,
585    #[serde(flatten)]
586    pub specifications: TypeSpecification,
587    pub extends: TypeExtends,
588}
589
590impl From<&DomainLemmaType> for LemmaType {
591    fn from(lemma_type: &DomainLemmaType) -> Self {
592        let mut specifications = TypeSpecification::from(&lemma_type.specifications);
593        if let TypeSpecification::Text { options, .. } = &mut specifications {
594            *options = lemma_type
595                .options_for_check()
596                .values
597                .iter()
598                .filter_map(|option| match &option.value.value {
599                    crate::planning::semantics::ValueKind::Text(text) => Some(text.to_string()),
600                    _ => None,
601                })
602                .collect();
603        }
604        Self {
605            name: lemma_type.name.clone(),
606            specifications,
607            extends: TypeExtends::from(&lemma_type.extends),
608        }
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use crate::planning::semantics::TypeFamily;
616    use serde_json::json;
617
618    fn family(repository: Option<&str>, spec: &str, name: &str) -> Dimension {
619        Dimension::Family(TypeFamily {
620            repository: repository.map(str::to_string),
621            spec: spec.to_string(),
622            effective: crate::parsing::ast::EffectiveDate::Origin,
623            name: name.to_string(),
624        })
625    }
626
627    #[test]
628    fn decomposition_keys_families_by_declaring_spec() {
629        let mut domain = BaseMeasureVector::new();
630        domain.insert(family(None, "pricing", "money"), 1);
631        domain.insert(family(Some("lemma"), "units", "mass"), -1);
632        domain.insert(Dimension::Duration, -1);
633        let json = serde_json::to_value(decomposition_from(&domain)).expect("JSON");
634        assert_eq!(
635            json,
636            json!({ "pricing.money": 1, "lemma units.mass": -1, "duration": -1 })
637        );
638    }
639
640    #[test]
641    fn decomposition_keeps_same_named_families_apart() {
642        let mut domain = BaseMeasureVector::new();
643        domain.insert(family(None, "a", "money"), 1);
644        domain.insert(family(None, "b", "money"), -1);
645        let json = serde_json::to_value(decomposition_from(&domain)).expect("JSON");
646        assert_eq!(json, json!({ "a.money": 1, "b.money": -1 }));
647    }
648
649    #[test]
650    fn decomposition_keeps_builtin_apart_from_same_named_family() {
651        let mut domain = BaseMeasureVector::new();
652        domain.insert(family(None, "clock", "duration"), 1);
653        domain.insert(Dimension::Duration, -1);
654        let json = serde_json::to_value(decomposition_from(&domain)).expect("JSON");
655        assert_eq!(json, json!({ "clock.duration": 1, "duration": -1 }));
656    }
657}