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