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