Skip to main content

lemma/planning/
semantics.rs

1//! Resolved semantic types for Lemma
2//!
3//! This module contains all types that represent resolved semantics after planning.
4//! These types are created during the planning phase and used by evaluation, inversion, etc.
5
6// Re-exported parsing types: downstream modules (evaluation, inversion, computation,
7// serialization) import these from `planning::semantics`, never from `parsing` directly.
8pub use crate::parsing::ast::{
9    ArithmeticComputation, ComparisonComputation, MathematicalComputation, NegationType,
10    RangeBound, VetoExpression,
11};
12pub use crate::parsing::source::Source;
13
14/// Logical computation operators (defined in semantics, not used by the parser).
15/// Returns the logical negation of a comparison (used by De Morgan / NOT-normal-form rewriting).
16#[must_use]
17pub fn negated_comparison(op: ComparisonComputation) -> ComparisonComputation {
18    match op {
19        ComparisonComputation::LessThan => ComparisonComputation::GreaterThanOrEqual,
20        ComparisonComputation::LessThanOrEqual => ComparisonComputation::GreaterThan,
21        ComparisonComputation::GreaterThan => ComparisonComputation::LessThanOrEqual,
22        ComparisonComputation::GreaterThanOrEqual => ComparisonComputation::LessThan,
23        ComparisonComputation::Is => ComparisonComputation::IsNot,
24        ComparisonComputation::IsNot => ComparisonComputation::Is,
25    }
26}
27
28/// Returns the operator that states the same fact with the operands swapped:
29/// `k < x` and `x > k` hold on exactly the same values.
30#[must_use]
31pub fn mirrored_comparison(op: ComparisonComputation) -> ComparisonComputation {
32    match op {
33        ComparisonComputation::LessThan => ComparisonComputation::GreaterThan,
34        ComparisonComputation::LessThanOrEqual => ComparisonComputation::GreaterThanOrEqual,
35        ComparisonComputation::GreaterThan => ComparisonComputation::LessThan,
36        ComparisonComputation::GreaterThanOrEqual => ComparisonComputation::LessThanOrEqual,
37        ComparisonComputation::Is => ComparisonComputation::Is,
38        ComparisonComputation::IsNot => ComparisonComputation::IsNot,
39    }
40}
41
42// Internal-only parsing imports (used only within this module for value/type resolution).
43use crate::computation::rational::{checked_div, rational_new, RationalInteger};
44use crate::parsing::ast::Constraint;
45use crate::parsing::ast::{
46    BooleanValue, CalendarPeriodUnit, CommandArg, ConversionTarget, DateCalendarKind,
47    DateRelativeKind, DateTimeValue, PrimitiveKind, TimeValue, TimezoneValue,
48    TypeConstraintCommand,
49};
50use crate::Error;
51use indexmap::IndexMap;
52use rust_decimal::Decimal;
53use serde::{Deserialize, Deserializer, Serialize, Serializer};
54use std::collections::BTreeMap;
55use std::fmt;
56use std::hash::Hash;
57use std::str::FromStr;
58use std::sync::{Arc, OnceLock};
59
60// -----------------------------------------------------------------------------
61// Type specification and units (resolved type shape; apply constraints is planning)
62// -----------------------------------------------------------------------------
63
64// Unit tables live in `crate::literals` (no dependency on parsing/ast). Re-exported
65// here so downstream modules importing from `planning::semantics` keep working.
66pub use crate::literals::{BaseMeasureVector, MeasureUnit, MeasureUnits, RatioUnit, RatioUnits};
67
68/// Combine two `BaseMeasureVector`s by adding (for multiply) or subtracting (for divide) exponents.
69/// Entries that reach zero exponent are removed (they cancel out).
70pub fn combine_decompositions(
71    left: &BaseMeasureVector,
72    right: &BaseMeasureVector,
73    is_multiply: bool,
74) -> BaseMeasureVector {
75    let mut result = left.clone();
76    for (dim, &exp) in right {
77        let delta = if is_multiply { exp } else { -exp };
78        let entry = result.entry(dim.clone()).or_insert(0);
79        *entry += delta;
80        if *entry == 0 {
81            result.remove(dim);
82        }
83    }
84    result
85}
86
87/// Combine two symbolic unit signatures (sorted-by-unit-name, no-zero-exponent vectors)
88/// under multiplication or division. The result is in canonical form: sorted by unit name
89/// ascending, no zero exponents.
90///
91/// Planning resolves measure products via decompositions; this remains the pinned algebra
92/// for signature-index tests.
93#[cfg(test)]
94pub fn combine_signatures(
95    left: &[(String, i32)],
96    right: &[(String, i32)],
97    is_multiply: bool,
98) -> Vec<(String, i32)> {
99    use std::collections::BTreeMap;
100    let mut accumulator: BTreeMap<String, i32> = BTreeMap::new();
101    for (name, exponent) in left {
102        *accumulator.entry(name.clone()).or_insert(0) += exponent;
103    }
104    for (name, exponent) in right {
105        let delta = if is_multiply { *exponent } else { -*exponent };
106        *accumulator.entry(name.clone()).or_insert(0) += delta;
107    }
108    accumulator
109        .into_iter()
110        .filter(|(_, exponent)| *exponent != 0)
111        .collect()
112}
113
114/// Canonicalize a unit signature: sum duplicate entries by name, drop zero exponents,
115/// sort ascending by unit name. Idempotent.
116/// Format a canonical unit signature into human-readable operator style.
117///
118/// Rules:
119/// - Numerator units (positive exponents) sorted alphabetically, joined by `*`.
120/// - Denominator units (negative exponents) sorted alphabetically, joined by `*`, exponents shown
121///   as positive values.
122/// - Separated by `/`. Empty numerator: `1/<denominator>`. No denominator: numerator only.
123/// - Exponents > 1 suffixed as `^n`.
124///
125/// Examples: `eur/hour`, `kilogram*meter^2/second^2`, `1/meter`.
126pub fn format_signature_operator_style(signature: &[(String, i32)]) -> String {
127    let canonical = canonicalize_signature(signature);
128    let mut numerator: Vec<(String, i32)> = Vec::new();
129    let mut denominator: Vec<(String, i32)> = Vec::new();
130    for (name, exponent) in canonical {
131        if exponent > 0 {
132            numerator.push((name, exponent));
133        } else if exponent < 0 {
134            denominator.push((name, -exponent));
135        }
136    }
137    let render = |terms: &[(String, i32)]| -> String {
138        terms
139            .iter()
140            .map(|(name, exp)| {
141                if *exp == 1 {
142                    name.clone()
143                } else {
144                    format!("{name}^{exp}")
145                }
146            })
147            .collect::<Vec<_>>()
148            .join("*")
149    };
150    match (numerator.is_empty(), denominator.is_empty()) {
151        (true, true) => String::new(),
152        (false, true) => render(&numerator),
153        (true, false) => format!("1/{}", render(&denominator)),
154        (false, false) => format!("{}/{}", render(&numerator), render(&denominator)),
155    }
156}
157
158/// Returns the intra-calendar-dimension factor for `name`, if it is a known calendar unit.
159///
160/// Month is canonical (factor 1). Year = 12.
161/// Keys are **singular** (`"month"`, `"year"`).
162///
163/// Returns `None` for names that are not calendar units.
164pub fn calendar_unit_factor(name: &str) -> Option<crate::computation::rational::RationalInteger> {
165    use crate::computation::rational::rational_one;
166    match name {
167        "month" => Some(rational_one()),
168        "year" => Some(rational_new(12, 1)),
169        _ => None,
170    }
171}
172
173fn reject_negative_width_magnitude(magnitude: &RationalInteger, cmd: &str) -> Result<(), String> {
174    use crate::computation::rational::rational_zero;
175    use std::cmp::Ordering;
176    match magnitude.try_cmp(&rational_zero()) {
177        Ok(Ordering::Less) => Err(format!("{cmd} width must not be negative")),
178        Ok(Ordering::Equal | Ordering::Greater) => Ok(()),
179        Err(failure) => Err(format!("{cmd} width compare failed: {failure}")),
180    }
181}
182
183/// Store a width bound as declared `(magnitude, unit)`. Family and factors are resolved
184/// later against `unit_index` during planning validation.
185fn parse_unresolved_width_bound(
186    args: &[CommandArg],
187    cmd: &str,
188) -> Result<(RationalInteger, String), String> {
189    use crate::computation::rational::decimal_to_rational;
190    let lit = require_literal(args, cmd)?;
191    let (magnitude, unit_name) = match lit {
192        crate::literals::Value::NumberWithUnit(n, unit) => (*n, unit.clone()),
193        other => {
194            return Err(format!(
195                "{cmd} requires a measure literal with a unit, got {}",
196                value_kind_name(other)
197            ));
198        }
199    };
200    let magnitude_rational = decimal_to_rational(magnitude)
201        .map_err(|failure| format!("{cmd} literal failed rational lift: {failure}"))?;
202    reject_negative_width_magnitude(&magnitude_rational, cmd)?;
203    Ok((magnitude_rational, unit_name))
204}
205
206/// Planning consistency for range endpoint and width bound declarations.
207///
208/// `unit_index` resolves date/time width units (duration vs calendar). Pass empty for
209/// callers that only check number/ratio/measure ranges.
210pub(crate) fn check_range_bound_consistency(
211    spec: &TypeSpecification,
212    unit_index: &crate::planning::unit_index::UnitIndex,
213) -> Result<(), String> {
214    use std::cmp::Ordering;
215
216    fn endpoint_order_ok_dates(lo: &DateTimeValue, hi: &DateTimeValue) -> bool {
217        compare_semantic_dates(&date_time_to_semantic(lo), &date_time_to_semantic(hi))
218            != Ordering::Greater
219    }
220    fn endpoint_order_ok_times(lo: &TimeValue, hi: &TimeValue) -> bool {
221        compare_semantic_times(&time_to_semantic(lo), &time_to_semantic(hi)) != Ordering::Greater
222    }
223
224    match spec {
225        TypeSpecification::NumberRange {
226            lower,
227            upper,
228            minimum,
229            maximum,
230            ..
231        }
232        | TypeSpecification::RatioRange {
233            lower,
234            upper,
235            minimum,
236            maximum,
237            ..
238        } => {
239            if let (Some(lo), Some(hi)) = (lower, upper) {
240                if lo
241                    .try_cmp(hi)
242                    .map_err(|failure| format!("range endpoint compare failed: {failure}"))?
243                    == Ordering::Greater
244                {
245                    return Err(format!(
246                        "invalid range: lower {} is greater than upper {}",
247                        lo.display_str(),
248                        hi.display_str()
249                    ));
250                }
251            }
252            if let (Some(min_w), Some(max_w)) = (minimum, maximum) {
253                if min_w
254                    .try_cmp(max_w)
255                    .map_err(|failure| format!("range width compare failed: {failure}"))?
256                    == Ordering::Greater
257                {
258                    return Err(format!(
259                        "invalid range: minimum width {} is greater than maximum width {}",
260                        min_w.display_str(),
261                        max_w.display_str()
262                    ));
263                }
264            }
265            Ok(())
266        }
267        TypeSpecification::MeasureRange {
268            lower,
269            upper,
270            minimum,
271            maximum,
272            units,
273            ..
274        } => {
275            if let (Some(lo), Some(hi)) = (lower, upper) {
276                let lo_c =
277                    measure_declared_bound_to_canonical(&lo.0, &lo.1, units, "range", "lower")?;
278                let hi_c =
279                    measure_declared_bound_to_canonical(&hi.0, &hi.1, units, "range", "upper")?;
280                if lo_c
281                    .try_cmp(&hi_c)
282                    .map_err(|failure| format!("range endpoint compare failed: {failure}"))?
283                    == Ordering::Greater
284                {
285                    return Err(format!(
286                        "invalid range: lower {} {} is greater than upper {} {}",
287                        lo.0.display_str(),
288                        lo.1,
289                        hi.0.display_str(),
290                        hi.1
291                    ));
292                }
293            }
294            if let (Some(min_w), Some(max_w)) = (minimum, maximum) {
295                let min_c = measure_declared_bound_to_canonical(
296                    &min_w.0, &min_w.1, units, "range", "minimum",
297                )?;
298                let max_c = measure_declared_bound_to_canonical(
299                    &max_w.0, &max_w.1, units, "range", "maximum",
300                )?;
301                if min_c
302                    .try_cmp(&max_c)
303                    .map_err(|failure| format!("range width compare failed: {failure}"))?
304                    == Ordering::Greater
305                {
306                    return Err(format!(
307                        "invalid range: minimum width {} {} is greater than maximum width {} {}",
308                        min_w.0.display_str(),
309                        min_w.1,
310                        max_w.0.display_str(),
311                        max_w.1
312                    ));
313                }
314            }
315            Ok(())
316        }
317        TypeSpecification::DateRange {
318            lower,
319            upper,
320            minimum,
321            maximum,
322            ..
323        } => {
324            if let (Some(lo), Some(hi)) = (lower, upper) {
325                if !endpoint_order_ok_dates(lo, hi) {
326                    return Err(format!(
327                        "invalid range: lower {lo} is greater than upper {hi}"
328                    ));
329                }
330            }
331            check_temporal_width_pair_consistency(minimum, maximum, unit_index, true)
332        }
333        TypeSpecification::TimeRange {
334            lower,
335            upper,
336            minimum,
337            maximum,
338            ..
339        } => {
340            if let (Some(lo), Some(hi)) = (lower, upper) {
341                if !endpoint_order_ok_times(lo, hi) {
342                    return Err(format!(
343                        "invalid range: lower {lo} is greater than upper {hi}"
344                    ));
345                }
346            }
347            check_temporal_width_pair_consistency(minimum, maximum, unit_index, false)
348        }
349        _ => Ok(()),
350    }
351}
352
353fn check_temporal_width_pair_consistency(
354    minimum: &Option<(RationalInteger, String)>,
355    maximum: &Option<(RationalInteger, String)>,
356    unit_index: &crate::planning::unit_index::UnitIndex,
357    allow_calendar: bool,
358) -> Result<(), String> {
359    use std::cmp::Ordering;
360    let resolve = |bound: &(RationalInteger, String),
361                   command: &str|
362     -> Result<(RationalInteger, Arc<LemmaType>), String> {
363        let (bare, owner) = unit_index.resolve(bound.1.as_str()).map_err(|err| {
364            format!(
365                "{command} width unit '{}': {err} (add `uses lemma units` or declare the unit)",
366                bound.1
367            )
368        })?;
369        if allow_calendar {
370            if !owner.is_duration_like() && !owner.is_calendar_like() {
371                return Err(format!(
372                    "{command} width unit '{bare}' must be a duration or calendar unit",
373                ));
374            }
375        } else if !owner.is_duration_like() {
376            return Err(format!(
377                "{command} width unit '{bare}' must be a duration unit",
378            ));
379        }
380        let TypeSpecification::Measure { units, .. } = &owner.specifications else {
381            return Err(format!(
382                "{command} width unit '{bare}' must resolve to a measure type",
383            ));
384        };
385        let canonical = measure_declared_bound_to_canonical(
386            &bound.0,
387            &bare,
388            units,
389            owner.name().as_str(),
390            command,
391        )?;
392        Ok((canonical, Arc::clone(&owner)))
393    };
394
395    match (minimum, maximum) {
396        (None, None) => Ok(()),
397        (Some(min_w), None) => {
398            let _ = resolve(min_w, "minimum")?;
399            Ok(())
400        }
401        (None, Some(max_w)) => {
402            let _ = resolve(max_w, "maximum")?;
403            Ok(())
404        }
405        (Some(min_w), Some(max_w)) => {
406            let (min_c, min_owner) = resolve(min_w, "minimum")?;
407            let (max_c, max_owner) = resolve(max_w, "maximum")?;
408            if min_owner.is_calendar_like() != max_owner.is_calendar_like() {
409                return Err(
410                    "invalid range: minimum and maximum width must not mix calendar and duration units"
411                        .to_string(),
412                );
413            }
414            if min_c
415                .try_cmp(&max_c)
416                .map_err(|failure| format!("range width compare failed: {failure}"))?
417                == Ordering::Greater
418            {
419                return Err(format!(
420                    "invalid range: minimum width {} {} is greater than maximum width {} {}",
421                    min_w.0.display_str(),
422                    min_w.1,
423                    max_w.0.display_str(),
424                    max_w.1
425                ));
426            }
427            Ok(())
428        }
429    }
430}
431
432fn owner_declares_measure_unit(owner: &LemmaType, unit_name: &str) -> bool {
433    owner
434        .measure_unit_names()
435        .is_some_and(|names| names.contains(&unit_name))
436}
437
438/// Compute the numeric factor of a symbolic unit signature relative to canonical bases.
439///
440/// For each `(unit_name, exponent)` in `signature`:
441/// 1. When `owner` declares the unit, use `owner.measure_unit_factor(unit_name)`.
442/// 2. Fall back to `expression_units[unit_name].measure_unit_factor(unit_name)`.
443/// 3. Unknown names panic with `"BUG: signature_factor called with unresolved unit name"`.
444///    Ambiguous multi-owner names panic asking the caller to pass a declaring owner.
445///
446/// Returns the product of `factor^exponent` over all pairs, or `NumericFailure` on overflow.
447pub fn signature_factor(
448    signature: &[(String, i32)],
449    expression_units: &crate::planning::unit_index::UnitIndex,
450    owner: Option<&LemmaType>,
451) -> Result<
452    crate::computation::rational::RationalInteger,
453    crate::computation::rational::NumericFailure,
454> {
455    use crate::computation::rational::{checked_div, checked_mul, rational_one};
456    let mut acc = rational_one();
457    for (name, exponent) in signature {
458        let factor =
459            if let Some(owner) = owner.filter(|owner| owner_declares_measure_unit(owner, name)) {
460                owner.measure_unit_factor(name).clone()
461            } else if let Some(lemma_type) = expression_units.unique_owner(name) {
462                lemma_type.measure_unit_factor(name).clone()
463            } else if !expression_units.owners_for(name).is_empty() {
464                panic!(
465                "BUG: signature_factor called with ambiguous unit name '{}' (pass declaring owner)",
466                name
467            );
468            } else {
469                panic!(
470                    "BUG: signature_factor called with unresolved unit name '{}'",
471                    name
472                );
473            };
474        let mut term = rational_one();
475        let abs_exp = exponent.unsigned_abs();
476        for _ in 0..abs_exp {
477            term = checked_mul(&term, &factor)?;
478        }
479        if *exponent >= 0 {
480            acc = checked_mul(&acc, &term)?;
481        } else {
482            acc = checked_div(&acc, &term)?;
483        }
484    }
485    Ok(acc)
486}
487
488pub fn canonicalize_signature(signature: &[(String, i32)]) -> Vec<(String, i32)> {
489    use std::collections::BTreeMap;
490    let mut accumulator: BTreeMap<String, i32> = BTreeMap::new();
491    for (name, exponent) in signature {
492        *accumulator.entry(name.clone()).or_insert(0) += exponent;
493    }
494    accumulator
495        .into_iter()
496        .filter(|(_, exponent)| *exponent != 0)
497        .collect()
498}
499
500/// Convert a `BaseMeasureVector` decomposition into canonical signature form.
501pub fn base_measure_vector_as_signature(decomposition: &BaseMeasureVector) -> Vec<(String, i32)> {
502    decomposition
503        .iter()
504        .filter(|(_, exponent)| **exponent != 0)
505        .map(|(name, exponent)| (name.clone(), *exponent))
506        .collect()
507}
508
509pub const DURATION_DIMENSION: &str = "duration";
510pub const CALENDAR_DIMENSION: &str = "calendar";
511
512#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
513#[serde(rename_all = "snake_case")]
514pub enum MeasureTrait {
515    Duration,
516    Calendar,
517}
518
519pub fn duration_decomposition() -> BaseMeasureVector {
520    [(DURATION_DIMENSION.to_string(), 1i32)]
521        .into_iter()
522        .collect()
523}
524
525pub fn calendar_decomposition() -> BaseMeasureVector {
526    [(CALENDAR_DIMENSION.to_string(), 1i32)]
527        .into_iter()
528        .collect()
529}
530
531/// Marker `LemmaType` for a Measure value whose signature has not been resolved to
532/// a named measure type. Carries an empty decomposition; runtime signature is
533/// derived via [`LemmaType::measure_runtime_signature`].
534pub fn anonymous_measure_type() -> LemmaType {
535    LemmaType::anonymous_for_decomposition(BaseMeasureVector::new())
536}
537
538/// Return a copy of `signature` with every exponent negated. Used by Number/Measure
539/// reciprocal construction (`1 / Q`).
540pub fn negate_signature(signature: &[(String, i32)]) -> Vec<(String, i32)> {
541    signature.iter().map(|(n, e)| (n.clone(), -*e)).collect()
542}
543
544#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
545pub enum TypeSpecification {
546    Boolean {
547        help: String,
548    },
549    Measure {
550        minimum: Option<(RationalInteger, String)>,
551        maximum: Option<(RationalInteger, String)>,
552        decimals: Option<u8>,
553        units: MeasureUnits,
554        traits: Vec<MeasureTrait>,
555        /// Common dimensional decomposition vector shared by all units in this measure.
556        /// `None` until the decomposition pass runs. Base measures (no compound unit expression)
557        /// are assigned `Some({measure_name: 1})` by the pass. `Some(empty_map)` means resolved
558        /// to dimensionless (e.g. `kg/kg`).
559        decomposition: Option<BaseMeasureVector>,
560        /// Written unit for this measure value (`100 eur`, `as usd`). Schema types use `None`.
561        unit: Option<String>,
562        help: String,
563    },
564    Number {
565        minimum: Option<RationalInteger>,
566        maximum: Option<RationalInteger>,
567        decimals: Option<u8>,
568        help: String,
569    },
570    NumberRange {
571        lower: Option<RationalInteger>,
572        upper: Option<RationalInteger>,
573        minimum: Option<RationalInteger>,
574        maximum: Option<RationalInteger>,
575        help: String,
576    },
577    Ratio {
578        minimum: Option<RationalInteger>,
579        maximum: Option<RationalInteger>,
580        decimals: Option<u8>,
581        units: RatioUnits,
582        /// Written unit for this ratio value (`25%`, `as percent`). Schema types use `None`.
583        unit: Option<String>,
584        help: String,
585    },
586    RatioRange {
587        lower: Option<RationalInteger>,
588        upper: Option<RationalInteger>,
589        minimum: Option<RationalInteger>,
590        maximum: Option<RationalInteger>,
591        units: RatioUnits,
592        help: String,
593    },
594    Text {
595        length: Option<usize>,
596        options: Vec<String>,
597        help: String,
598    },
599    Date {
600        minimum: Option<DateTimeValue>,
601        maximum: Option<DateTimeValue>,
602        help: String,
603    },
604    DateRange {
605        lower: Option<DateTimeValue>,
606        upper: Option<DateTimeValue>,
607        minimum: Option<(RationalInteger, String)>,
608        maximum: Option<(RationalInteger, String)>,
609        help: String,
610    },
611    Time {
612        minimum: Option<TimeValue>,
613        maximum: Option<TimeValue>,
614        help: String,
615    },
616    TimeRange {
617        lower: Option<TimeValue>,
618        upper: Option<TimeValue>,
619        minimum: Option<(RationalInteger, String)>,
620        maximum: Option<(RationalInteger, String)>,
621        help: String,
622    },
623    MeasureRange {
624        lower: Option<(RationalInteger, String)>,
625        upper: Option<(RationalInteger, String)>,
626        minimum: Option<(RationalInteger, String)>,
627        maximum: Option<(RationalInteger, String)>,
628        units: MeasureUnits,
629        decomposition: Option<BaseMeasureVector>,
630        help: String,
631    },
632    Veto {
633        message: Option<String>,
634    },
635    /// Sentinel used during type inference when the type could not be determined.
636    /// Propagates through expressions without generating cascading errors.
637    /// Must never appear in a successfully validated graph or execution plan.
638    Undetermined,
639    /// A spec instance. Identity is repository, spec name, and the resolved
640    /// temporal row (`effective`). Appended so postcard discriminants of earlier
641    /// variants stay stable.
642    Spec {
643        spec: SpecIdentity,
644    },
645}
646
647/// Resolved spec a value or rule result refers to.
648///
649/// `effective` is the resolved row's `effective_from`, not the run instant.
650#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
651pub struct SpecIdentity {
652    pub repository: Option<String>,
653    pub spec: String,
654    pub effective: crate::parsing::ast::EffectiveDate,
655}
656
657impl fmt::Display for SpecIdentity {
658    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
659        if let Some(repository) = &self.repository {
660            write!(f, "{repository}/")?;
661        }
662        write!(f, "{}", self.spec)?;
663        if let crate::parsing::ast::EffectiveDate::DateTimeValue(at) = &self.effective {
664            write!(f, " {at}")?;
665        }
666        Ok(())
667    }
668}
669
670impl std::fmt::Display for TypeSpecification {
671    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
672        match self {
673            Self::Boolean { .. } => f.write_str("boolean"),
674            Self::Measure { .. } => f.write_str("measure"),
675            Self::MeasureRange { .. } => f.write_str("measure range"),
676            Self::Number { .. } => f.write_str("number"),
677            Self::NumberRange { .. } => f.write_str("number range"),
678            Self::Text { .. } => f.write_str("text"),
679            Self::Date { .. } => f.write_str("date"),
680            Self::DateRange { .. } => f.write_str("date range"),
681            Self::Time { .. } => f.write_str("time"),
682            Self::TimeRange { .. } => f.write_str("time range"),
683            Self::Ratio { .. } => f.write_str("ratio"),
684            Self::RatioRange { .. } => f.write_str("ratio range"),
685            Self::Veto { .. } => f.write_str("veto"),
686            Self::Undetermined => f.write_str("undetermined"),
687            Self::Spec { spec } => write!(f, "{spec}"),
688        }
689    }
690}
691
692impl TypeSpecification {
693    /// Returns the help text associated with this type, or an empty string if none.
694    pub fn help(&self) -> &str {
695        match self {
696            Self::Boolean { help, .. }
697            | Self::Measure { help, .. }
698            | Self::Number { help, .. }
699            | Self::NumberRange { help, .. }
700            | Self::Text { help, .. }
701            | Self::Date { help, .. }
702            | Self::DateRange { help, .. }
703            | Self::Time { help, .. }
704            | Self::TimeRange { help, .. }
705            | Self::Ratio { help, .. }
706            | Self::RatioRange { help, .. }
707            | Self::MeasureRange { help, .. } => help.as_str(),
708            Self::Veto { .. } | Self::Undetermined | Self::Spec { .. } => "",
709        }
710    }
711
712    /// Schema equality that ignores written [`unit`](Self::Measure::unit) /
713    /// [`unit`](Self::Ratio::unit). Used when comparing declared types that may
714    /// carry a value-specific written unit.
715    #[must_use]
716    pub fn equal_ignoring_unit(&self, other: &Self) -> bool {
717        match (self, other) {
718            (
719                Self::Measure {
720                    minimum: a_min,
721                    maximum: a_max,
722                    decimals: a_dec,
723                    units: a_units,
724                    traits: a_traits,
725                    decomposition: a_decomp,
726                    help: a_help,
727                    unit: _,
728                },
729                Self::Measure {
730                    minimum: b_min,
731                    maximum: b_max,
732                    decimals: b_dec,
733                    units: b_units,
734                    traits: b_traits,
735                    decomposition: b_decomp,
736                    help: b_help,
737                    unit: _,
738                },
739            ) => {
740                a_min == b_min
741                    && a_max == b_max
742                    && a_dec == b_dec
743                    && a_units == b_units
744                    && a_traits == b_traits
745                    && a_decomp == b_decomp
746                    && a_help == b_help
747            }
748            (
749                Self::Ratio {
750                    minimum: a_min,
751                    maximum: a_max,
752                    decimals: a_dec,
753                    units: a_units,
754                    help: a_help,
755                    unit: _,
756                },
757                Self::Ratio {
758                    minimum: b_min,
759                    maximum: b_max,
760                    decimals: b_dec,
761                    units: b_units,
762                    help: b_help,
763                    unit: _,
764                },
765            ) => {
766                a_min == b_min
767                    && a_max == b_max
768                    && a_dec == b_dec
769                    && a_units == b_units
770                    && a_help == b_help
771            }
772            _ => self == other,
773        }
774    }
775}
776
777/// Extract a typed [`Value`] from the first `CommandArg`, requiring `Literal` shape.
778///
779/// `Label` args carry identifiers (unit names, option keywords) and never satisfy a
780/// command position that wants a literal value. Returning a typed `Value` keeps the
781/// caller's match exhaustive over [`Value`] variants — no string coercion path.
782fn require_literal<'a>(
783    args: &'a [CommandArg],
784    cmd: &str,
785) -> Result<&'a crate::literals::Value, String> {
786    let arg = args
787        .first()
788        .ok_or_else(|| format!("{} requires an argument", cmd))?;
789    match arg {
790        CommandArg::Literal(v) => Ok(v),
791        CommandArg::Label(name) => Err(format!(
792            "{} requires a literal value, got identifier '{}'",
793            cmd, name
794        )),
795        CommandArg::UnitExpr(_) => Err(format!(
796            "{} requires a literal value, got a unit expression (only valid for 'unit' command)",
797            cmd
798        )),
799    }
800}
801
802fn apply_type_help_command(help: &mut String, args: &[CommandArg]) -> Result<(), String> {
803    match require_literal(args, "help")? {
804        crate::literals::Value::Text(s) => {
805            *help = s.clone();
806            Ok(())
807        }
808        other => Err(format!(
809            "help requires a text literal (quoted string), got {}",
810            value_kind_name(other)
811        )),
812    }
813}
814
815fn format_measure_units_list(units: &MeasureUnits) -> String {
816    units
817        .iter()
818        .map(|u| u.name.as_str())
819        .collect::<Vec<_>>()
820        .join(", ")
821}
822
823/// What kind of value `-> suggest` expects when rejecting a calendar literal.
824#[derive(Debug, Clone, Copy, PartialEq, Eq)]
825pub(crate) enum SuggestionExpectation {
826    MeasureUnits,
827    Text,
828    Number,
829    Boolean,
830    Date,
831    Time,
832    Ratio,
833    NumberRange,
834    DateRange,
835    TimeRange,
836    MeasureRange,
837    RatioRange,
838}
839
840pub(crate) fn suggestion_value_mismatch_error(
841    calendar_unit: &str,
842    type_name: &str,
843    expectation: SuggestionExpectation,
844    measure_units: Option<&MeasureUnits>,
845) -> String {
846    let unit_label = calendar_unit;
847    let first = format!("Unit '{unit_label}' is for calendar data.");
848    match expectation {
849        SuggestionExpectation::MeasureUnits => {
850            let list = measure_units
851                .map(format_measure_units_list)
852                .unwrap_or_default();
853            format!("{first} Valid '{type_name}' units are: {list}.")
854        }
855        SuggestionExpectation::Text => format!(
856            "{first} Please provide a text value in double quotes, for example `-> suggest \"my default value\"`."
857        ),
858        SuggestionExpectation::Number => format!(
859            "{first} Please provide a number, for example `-> suggest 42`."
860        ),
861        SuggestionExpectation::Boolean => format!(
862            "{first} Please provide true or false, for example `-> suggest true`."
863        ),
864        SuggestionExpectation::Date => format!(
865            "{first} Please provide a date, for example `-> suggest 2024-06-15`."
866        ),
867        SuggestionExpectation::Time => format!(
868            "{first} Please provide a time, for example `-> suggest 09:00:00`."
869        ),
870        SuggestionExpectation::Ratio | SuggestionExpectation::RatioRange => format!(
871            "{first} Please provide a ratio, for example `-> suggest 25%`."
872        ),
873        SuggestionExpectation::NumberRange => format!(
874            "{first} Please provide a number range, for example `-> suggest 10...100`."
875        ),
876        SuggestionExpectation::DateRange => format!(
877            "{first} Please provide a date range, for example `-> suggest 2024-01-01...2024-12-31`."
878        ),
879        SuggestionExpectation::TimeRange => format!(
880            "{first} Please provide a time range, for example `-> suggest 09:00...17:00`."
881        ),
882        SuggestionExpectation::MeasureRange => format!(
883            "{first} Please provide a range with units valid for '{type_name}', for example `-> suggest 30 kilogram...35 kilogram`."
884        ),
885    }
886}
887
888fn measure_suggestion_wrong_shape_error(type_name: &str, traits: &[MeasureTrait]) -> String {
889    let example = if traits.contains(&MeasureTrait::Duration) {
890        "4 week"
891    } else if traits.contains(&MeasureTrait::Calendar) {
892        "3 month"
893    } else {
894        "30 kilogram"
895    };
896    format!(
897        "Please provide a value with a unit valid for '{type_name}', for example `-> suggest {example}`."
898    )
899}
900
901fn reject_calendar_for_suggestion(
902    value: &crate::literals::Value,
903    type_name: &str,
904    expectation: SuggestionExpectation,
905    measure_units: Option<&MeasureUnits>,
906) -> Result<(), String> {
907    if let crate::literals::Value::NumberWithUnit(_, unit) = value {
908        if calendar_unit_factor(unit).is_some() {
909            return Err(suggestion_value_mismatch_error(
910                unit,
911                type_name,
912                expectation,
913                measure_units,
914            ));
915        }
916    }
917    Ok(())
918}
919
920/// Human-readable name for a [`Value`] variant — used in mismatch error messages.
921fn value_kind_name(v: &crate::literals::Value) -> &'static str {
922    use crate::literals::Value;
923    match v {
924        Value::Number(_) => "number",
925        Value::NumberWithUnit(_, _) => "number_with_unit",
926        Value::Text(_) => "text",
927        Value::Date(_) => "date",
928        Value::Time(_) => "time",
929        Value::Boolean(_) => "boolean",
930        Value::Range(_, _) => "range",
931    }
932}
933
934fn require_suggestion_range_endpoints<'a>(
935    args: &'a [CommandArg],
936    type_name: &str,
937    expectation: SuggestionExpectation,
938    measure_units: Option<&MeasureUnits>,
939) -> Result<(&'a crate::literals::Value, &'a crate::literals::Value), String> {
940    match require_literal(args, "suggest")? {
941        crate::literals::Value::NumberWithUnit(_, unit)
942            if calendar_unit_factor(unit).is_some() =>
943        {
944            Err(suggestion_value_mismatch_error(
945                unit,
946                type_name,
947                expectation,
948                measure_units,
949            ))
950        }
951        crate::literals::Value::Range(left, right) => Ok((left.as_ref(), right.as_ref())),
952        _ => Err(match expectation {
953            SuggestionExpectation::NumberRange => {
954                "Please provide a number range, for example `-> suggest 10...100`.".to_string()
955            }
956            SuggestionExpectation::DateRange => {
957                "Please provide a date range, for example `-> suggest 2024-01-01...2024-12-31`."
958                    .to_string()
959            }
960            SuggestionExpectation::RatioRange => {
961                "Please provide a ratio range, for example `-> suggest 10%...50%`.".to_string()
962            }
963            SuggestionExpectation::MeasureRange => format!(
964                "Please provide a range with units valid for '{type_name}', for example `-> suggest 30 kilogram...35 kilogram`."
965            ),
966            _ => unreachable!("BUG: require_suggestion_range_endpoints called with non-range expectation"),
967        }),
968    }
969}
970
971fn lift_parser_decimal(decimal: rust_decimal::Decimal) -> Result<RationalInteger, String> {
972    crate::computation::rational::decimal_to_rational(decimal)
973        .map_err(|failure| format!("literal failed rational lift: {failure}"))
974}
975
976/// Element spec for a range type, used for parsing endpoints and lifting literal endpoints.
977pub fn range_element_type_specification(
978    range_spec: &TypeSpecification,
979) -> Option<TypeSpecification> {
980    range_spec.element_from_range()
981}
982
983fn range_endpoints_compatible(left: &LemmaType, right: &LemmaType) -> bool {
984    match (&left.specifications, &right.specifications) {
985        (TypeSpecification::Date { .. }, TypeSpecification::Date { .. }) => true,
986        (TypeSpecification::Time { .. }, TypeSpecification::Time { .. }) => true,
987        (TypeSpecification::Number { .. }, TypeSpecification::Number { .. }) => true,
988        (TypeSpecification::Measure { .. }, TypeSpecification::Measure { .. }) => {
989            left.same_measure_family(right)
990                || left.compatible_with_anonymous_measure(right)
991                || right.compatible_with_anonymous_measure(left)
992        }
993        (TypeSpecification::Ratio { .. }, TypeSpecification::Ratio { .. }) => true,
994        _ => false,
995    }
996}
997
998/// Infer the range type specification from two compatible endpoint types.
999pub fn range_type_specification_from_endpoints(
1000    left: &LemmaType,
1001    right: &LemmaType,
1002) -> Option<TypeSpecification> {
1003    if !range_endpoints_compatible(left, right) {
1004        return None;
1005    }
1006    left.specifications.range_from_element()
1007}
1008
1009/// Resolves a written unit token to a bare declared unit name.
1010pub type UnitTokenResolver<'a> = dyn Fn(&str) -> Result<String, String> + 'a;
1011
1012/// Declared bare unit name on a measure or ratio element type for `token`.
1013fn bare_declared_unit_on_element(
1014    element_spec: &TypeSpecification,
1015    token: &str,
1016) -> Result<String, String> {
1017    match element_spec {
1018        TypeSpecification::Measure { units, .. } => Ok(units.get(token)?.name.clone()),
1019        TypeSpecification::Ratio { units, .. } => Ok(units.get(token)?.name.clone()),
1020        other => panic!("BUG: bare_declared_unit_on_element on non-measure/ratio element {other}"),
1021    }
1022}
1023
1024/// Resolve a written unit token to a bare declared name on `element_spec`.
1025///
1026/// Tries an exact declared name first. When that fails and `resolve_unit` is
1027/// provided, resolves through the unit index and checks the bare name is declared
1028/// on the element.
1029pub fn resolve_endpoint_unit_token(
1030    token: &str,
1031    element_spec: &TypeSpecification,
1032    resolve_unit: Option<&UnitTokenResolver<'_>>,
1033) -> Result<String, String> {
1034    match bare_declared_unit_on_element(element_spec, token) {
1035        Ok(bare) => Ok(bare),
1036        Err(declared_err) => {
1037            let Some(resolve) = resolve_unit else {
1038                return Err(declared_err);
1039            };
1040            let bare = resolve(token)?;
1041            bare_declared_unit_on_element(element_spec, &bare).map_err(|_| {
1042                format!("Unit '{token}' resolves to '{bare}', which is not declared on this type")
1043            })
1044        }
1045    }
1046}
1047
1048/// Lift a parser literal range endpoint to a [`TypedLiteral`] with the element's primitive type.
1049/// Routes [`Value::NumberWithUnit`] through [`parser_value_to_value_kind`] so ratio endpoints
1050/// (e.g. `10%` in a `ratio range`) canonicalize to ratios, not anonymous quantities.
1051///
1052/// When `resolve_unit` is set, qualified tokens are resolved to bare declared names before bind.
1053pub(crate) fn lift_range_endpoint(
1054    value: &crate::parsing::ast::Value,
1055    element_spec: &TypeSpecification,
1056    resolve_unit: Option<&UnitTokenResolver<'_>>,
1057) -> Result<TypedLiteral, String> {
1058    use crate::parsing::ast::Value;
1059    match value {
1060        Value::NumberWithUnit(magnitude, unit_token) => {
1061            let bare = resolve_endpoint_unit_token(unit_token, element_spec, resolve_unit)?;
1062            let rewritten = Value::NumberWithUnit(*magnitude, bare.clone());
1063            let kind = parser_value_to_value_kind(&rewritten, element_spec, None)?;
1064            let lemma_type = match &kind {
1065                ValueKind::Measure(_) | ValueKind::Ratio(_) => {
1066                    Arc::new(LemmaType::primitive(element_spec.clone()).with_unit(bare))
1067                }
1068                _ => Arc::new(LemmaType::primitive(element_spec.clone())),
1069            };
1070            Ok(TypedLiteral {
1071                value: kind,
1072                lemma_type,
1073            })
1074        }
1075        _ => {
1076            let kind = value_to_semantic(value)?;
1077            Ok(TypedLiteral {
1078                value: kind,
1079                lemma_type: Arc::new(LemmaType::primitive(element_spec.clone())),
1080            })
1081        }
1082    }
1083}
1084
1085fn literal_value_from_parser_value(
1086    value: &crate::parsing::ast::Value,
1087) -> Result<TypedLiteral, String> {
1088    use crate::parsing::ast::Value;
1089
1090    match value {
1091        Value::Number(n) => Ok(TypedLiteral::number(lift_parser_decimal(*n)?)),
1092        Value::Text(s) => Ok(TypedLiteral::text(s.clone())),
1093        Value::Date(dt) => Ok(TypedLiteral::date(date_time_to_semantic(dt))),
1094        Value::Time(t) => Ok(TypedLiteral::time(time_to_semantic(t))),
1095        Value::Boolean(b) => Ok(TypedLiteral::from_bool(bool::from(*b))),
1096        Value::NumberWithUnit(n, unit) => Ok(TypedLiteral::number_interpreted_as_measure(
1097            lift_parser_decimal(*n)?,
1098            unit.clone(),
1099        )),
1100        Value::Range(left, right) => {
1101            let left = literal_value_from_parser_value(left)?;
1102            let right = literal_value_from_parser_value(right)?;
1103            let compatible = match (
1104                &left.lemma_type.specifications,
1105                &right.lemma_type.specifications,
1106            ) {
1107                (TypeSpecification::Date { .. }, TypeSpecification::Date { .. }) => true,
1108                (TypeSpecification::Time { .. }, TypeSpecification::Time { .. }) => true,
1109                (TypeSpecification::Number { .. }, TypeSpecification::Number { .. }) => true,
1110                (TypeSpecification::Measure { .. }, TypeSpecification::Measure { .. }) => {
1111                    left.lemma_type.same_measure_family(&right.lemma_type)
1112                        || left
1113                            .lemma_type
1114                            .compatible_with_anonymous_measure(&right.lemma_type)
1115                        || right
1116                            .lemma_type
1117                            .compatible_with_anonymous_measure(&left.lemma_type)
1118                }
1119                (TypeSpecification::Ratio { .. }, TypeSpecification::Ratio { .. }) => true,
1120                _ => false,
1121            };
1122            if !compatible {
1123                return Err(format!(
1124                    "range endpoints must have the same supported base type, got {} and {}",
1125                    left.lemma_type.name(),
1126                    right.lemma_type.name()
1127                ));
1128            }
1129            Ok(TypedLiteral::range(left, right))
1130        }
1131    }
1132}
1133
1134/// Cast a [`RationalInteger`] to `u8`, requiring it to be a non-negative whole number that fits.
1135fn decimal_to_u8(d: RationalInteger, ctx: &str) -> Result<u8, String> {
1136    if !d.is_integer() {
1137        return Err(format!(
1138            "{} requires a whole number, got fractional value",
1139            ctx
1140        ));
1141    }
1142    d.numer_to_u8()
1143        .ok_or_else(|| format!("{} value out of range for u8", ctx))
1144}
1145
1146/// Cast a [`RationalInteger`] to `usize`, requiring it to be a non-negative whole number that fits.
1147fn decimal_to_usize(d: RationalInteger, ctx: &str) -> Result<usize, String> {
1148    if !d.is_integer() {
1149        return Err(format!(
1150            "{} requires a whole number, got fractional value",
1151            ctx
1152        ));
1153    }
1154    d.numer_to_usize()
1155        .ok_or_else(|| format!("{} value out of range for usize", ctx))
1156}
1157
1158/// Extract a number literal from a [`Value::Number`] arg and lift it to [`RationalInteger`].
1159///
1160/// Numeric meta-constraints (`decimals`, `length`, `minimum`/`maximum`
1161/// on `Number` and `Measure`) take a bare number literal — not a ratio, not a measure. Reject
1162/// any other variant to honour the no-coercion contract.
1163fn ratio_bound_to_canonical_rational(
1164    args: &[CommandArg],
1165    cmd: &str,
1166    units: &RatioUnits,
1167) -> Result<RationalInteger, String> {
1168    use crate::computation::rational::{checked_div, decimal_to_rational};
1169    let lit = require_literal(args, cmd)?;
1170    match lit {
1171        crate::literals::Value::NumberWithUnit(magnitude, unit_name) => {
1172            let unit = units.get(unit_name.as_str())?;
1173            let magnitude_rational = decimal_to_rational(*magnitude)
1174                .map_err(|failure| format!("{cmd} literal failed rational lift: {failure}"))?;
1175            checked_div(&magnitude_rational, &unit.value)
1176                .map_err(|failure| format!("{cmd}: unit conversion failed: {failure}"))
1177        }
1178        other => Err(format!(
1179            "{cmd} requires a ratio literal with a unit, got {}",
1180            value_kind_name(other)
1181        )),
1182    }
1183}
1184
1185fn require_decimal_literal(args: &[CommandArg], cmd: &str) -> Result<RationalInteger, String> {
1186    use crate::computation::rational::decimal_to_rational;
1187    match require_literal(args, cmd)? {
1188        crate::literals::Value::Number(d) => decimal_to_rational(*d)
1189            .map_err(|failure| format!("{} literal failed rational lift: {}", cmd, failure)),
1190        other => Err(format!(
1191            "{} requires a number literal, got {}",
1192            cmd,
1193            value_kind_name(other)
1194        )),
1195    }
1196}
1197
1198enum UnitConstraintField {
1199    Minimum,
1200    Maximum,
1201    SuggestionMagnitude,
1202}
1203
1204pub(crate) fn measure_declared_bound_to_canonical(
1205    magnitude: &RationalInteger,
1206    unit_name: &str,
1207    units: &MeasureUnits,
1208    type_name: &str,
1209    command: &str,
1210) -> Result<RationalInteger, String> {
1211    use crate::computation::rational::checked_mul;
1212    let unit = units.get(unit_name).map_err(|_| {
1213        format!(
1214            "Unit '{unit_name}' is not defined on '{type_name}'. Valid units are: {}.",
1215            format_measure_units_list(units)
1216        )
1217    })?;
1218    checked_mul(magnitude, &unit.factor)
1219        .map_err(|failure| format!("{command}: unit conversion overflow: {failure}"))
1220}
1221
1222fn parse_measure_declared_bound(
1223    args: &[CommandArg],
1224    cmd: &str,
1225    units: &MeasureUnits,
1226    type_name: &str,
1227) -> Result<(RationalInteger, String), String> {
1228    use crate::computation::rational::decimal_to_rational;
1229    let lit = require_literal(args, cmd)?;
1230    let (magnitude, unit_name) = match lit {
1231        crate::literals::Value::NumberWithUnit(n, unit) => (*n, unit.clone()),
1232        other => {
1233            return Err(format!(
1234                "{cmd} requires a measure literal with a unit, got {}",
1235                value_kind_name(other)
1236            ));
1237        }
1238    };
1239    units.get(unit_name.as_str()).map_err(|_| {
1240        format!(
1241            "Unit '{unit_name}' is not defined on '{type_name}'. Valid units are: {}.",
1242            format_measure_units_list(units)
1243        )
1244    })?;
1245    let magnitude_rational = decimal_to_rational(magnitude)
1246        .map_err(|failure| format!("{cmd} literal failed rational lift: {failure}"))?;
1247    Ok((magnitude_rational, unit_name))
1248}
1249
1250fn sync_measure_units_from_canonical(
1251    units: &mut MeasureUnits,
1252    canonical: &RationalInteger,
1253    field: UnitConstraintField,
1254) -> Result<(), String> {
1255    use crate::computation::rational::checked_div;
1256    for unit in &mut units.0 {
1257        let magnitude = checked_div(canonical, &unit.factor).map_err(|failure| {
1258            format!(
1259                "cannot derive per-unit constraint for unit '{}': {failure}",
1260                unit.name
1261            )
1262        })?;
1263        match field {
1264            UnitConstraintField::Minimum => unit.minimum = Some(magnitude),
1265            UnitConstraintField::Maximum => unit.maximum = Some(magnitude),
1266            UnitConstraintField::SuggestionMagnitude => unit.suggestion_magnitude = Some(magnitude),
1267        }
1268    }
1269    Ok(())
1270}
1271
1272fn sync_ratio_units_from_canonical(
1273    units: &mut RatioUnits,
1274    canonical: &RationalInteger,
1275    field: UnitConstraintField,
1276) -> Result<(), String> {
1277    use crate::computation::rational::checked_mul;
1278    for unit in &mut units.0 {
1279        let magnitude = checked_mul(canonical, &unit.value).map_err(|failure| {
1280            format!(
1281                "cannot derive per-unit constraint for ratio unit '{}': {failure}",
1282                unit.name
1283            )
1284        })?;
1285        match field {
1286            UnitConstraintField::Minimum => unit.minimum = Some(magnitude),
1287            UnitConstraintField::Maximum => unit.maximum = Some(magnitude),
1288            UnitConstraintField::SuggestionMagnitude => unit.suggestion_magnitude = Some(magnitude),
1289        }
1290    }
1291    Ok(())
1292}
1293
1294fn sync_measure_suggestion_units(
1295    units: &mut MeasureUnits,
1296    default: &ValueKind,
1297    type_name: &str,
1298) -> Result<(), String> {
1299    let ValueKind::Measure(magnitude) = default else {
1300        return Ok(());
1301    };
1302    let unit_name = units
1303        .iter()
1304        .find(|unit| unit.is_canonical_factor())
1305        .or_else(|| units.iter().next())
1306        .map(|unit| unit.name.as_str())
1307        .expect(
1308            "BUG: Measure suggestion value requires at least one declared unit on the measure type",
1309        );
1310    units.get(unit_name).map_err(|_| {
1311        format!("Suggestion unit '{unit_name}' is not defined on measure type '{type_name}'.")
1312    })?;
1313    sync_measure_units_from_canonical(units, magnitude, UnitConstraintField::SuggestionMagnitude)
1314}
1315
1316pub(crate) fn finalize_measure_unit_constraint_magnitudes(
1317    specification: &mut TypeSpecification,
1318    declared_suggestion: Option<&ValueKind>,
1319    type_name: &str,
1320) -> Result<(), String> {
1321    let TypeSpecification::Measure {
1322        minimum,
1323        maximum,
1324        units,
1325        ..
1326    } = specification
1327    else {
1328        return Ok(());
1329    };
1330
1331    if let Some(bound) = minimum.as_ref() {
1332        let canonical =
1333            measure_declared_bound_to_canonical(&bound.0, &bound.1, units, type_name, "minimum")?;
1334        sync_measure_units_from_canonical(units, &canonical, UnitConstraintField::Minimum)?;
1335    }
1336    if let Some(bound) = maximum.as_ref() {
1337        let canonical =
1338            measure_declared_bound_to_canonical(&bound.0, &bound.1, units, type_name, "maximum")?;
1339        sync_measure_units_from_canonical(units, &canonical, UnitConstraintField::Maximum)?;
1340    }
1341    if let Some(default) = declared_suggestion {
1342        sync_measure_suggestion_units(units, default, type_name)?;
1343    }
1344
1345    if minimum.is_some() {
1346        for unit in units.iter() {
1347            assert!(
1348                unit.minimum.is_some(),
1349                "BUG: type '{type_name}' has minimum but unit '{}' missing per-unit minimum after finalize",
1350                unit.name
1351            );
1352        }
1353    }
1354    if maximum.is_some() {
1355        for unit in units.iter() {
1356            assert!(
1357                unit.maximum.is_some(),
1358                "BUG: type '{type_name}' has maximum but unit '{}' missing per-unit maximum after finalize",
1359                unit.name
1360            );
1361        }
1362    }
1363    if declared_suggestion.is_some() {
1364        for unit in units.iter() {
1365            assert!(
1366                unit.suggestion_magnitude.is_some(),
1367                "BUG: type '{type_name}' has default but unit '{}' missing per-unit default after finalize",
1368                unit.name
1369            );
1370        }
1371    }
1372
1373    Ok(())
1374}
1375
1376fn sync_ratio_suggestion_units(units: &mut RatioUnits, default: &ValueKind) -> Result<(), String> {
1377    let ValueKind::Ratio(canonical) = default else {
1378        return Ok(());
1379    };
1380    sync_ratio_units_from_canonical(units, canonical, UnitConstraintField::SuggestionMagnitude)
1381}
1382
1383/// Extract an option name from a single arg.
1384///
1385/// Both `option red` (bare identifier, parsed as `Label`) and `option "red"`
1386/// (quoted text literal) are valid lemma syntax for option enumeration; the
1387/// grammar accepts either form. All other variants are rejected.
1388fn option_name(arg: &CommandArg, cmd: &str) -> Result<String, String> {
1389    match arg {
1390        CommandArg::Literal(crate::literals::Value::Text(s)) => Ok(s.clone()),
1391        CommandArg::Label(name) => Ok(name.clone()),
1392        CommandArg::Literal(other) => Err(format!(
1393            "{} requires a text literal or identifier, got {}",
1394            cmd,
1395            value_kind_name(other)
1396        )),
1397        CommandArg::UnitExpr(_) => Err(format!(
1398            "{} requires a text literal or identifier, got a unit expression",
1399            cmd
1400        )),
1401    }
1402}
1403
1404fn label_name(arg: &CommandArg, cmd: &str) -> Result<String, String> {
1405    match arg {
1406        CommandArg::Label(name) => Ok(name.clone()),
1407        CommandArg::Literal(other) => Err(format!(
1408            "{} requires an identifier, got {}",
1409            cmd,
1410            value_kind_name(other)
1411        )),
1412        CommandArg::UnitExpr(_) => Err(format!(
1413            "{} requires an identifier, got a unit expression",
1414            cmd
1415        )),
1416    }
1417}
1418
1419fn measure_trait_name(measure_trait: MeasureTrait) -> &'static str {
1420    match measure_trait {
1421        MeasureTrait::Duration => "duration",
1422        MeasureTrait::Calendar => "calendar",
1423    }
1424}
1425
1426fn parse_measure_trait(args: &[CommandArg]) -> Result<MeasureTrait, String> {
1427    if args.len() != 1 {
1428        return Err("trait requires exactly one identifier argument".to_string());
1429    }
1430    match label_name(&args[0], "trait")?
1431        .trim()
1432        .to_lowercase()
1433        .as_str()
1434    {
1435        "duration" => Ok(MeasureTrait::Duration),
1436        "calendar" => Ok(MeasureTrait::Calendar),
1437        other => Err(format!("Unknown measure trait '{}'", other)),
1438    }
1439}
1440
1441fn validate_calendar_trait_requirements(units: &MeasureUnits) -> Result<(), String> {
1442    let month_unit = units
1443        .iter()
1444        .find(|unit| unit.name == "month")
1445        .ok_or_else(|| {
1446            "trait calendar requires a canonical 'month' unit declared before 'trait calendar'"
1447                .to_string()
1448        })?;
1449    if !month_unit.is_canonical_factor() {
1450        return Err("trait calendar requires unit month 1".to_string());
1451    }
1452    Ok(())
1453}
1454
1455fn validate_duration_trait_requirements(units: &MeasureUnits) -> Result<(), String> {
1456    let second_unit = units
1457        .iter()
1458        .find(|unit| unit.name == "second")
1459        .ok_or_else(|| {
1460            "trait duration requires a canonical 'second' unit declared before 'trait duration'"
1461                .to_string()
1462        })?;
1463    if !second_unit.is_canonical_factor() {
1464        return Err("trait duration requires unit second 1".to_string());
1465    }
1466    Ok(())
1467}
1468
1469/// Extract a [`DateTimeValue`] from a [`Value::Date`] literal arg.
1470fn require_date_literal(args: &[CommandArg], cmd: &str) -> Result<DateTimeValue, String> {
1471    match require_literal(args, cmd)? {
1472        crate::literals::Value::Date(dt) => Ok(dt.clone()),
1473        other => Err(format!(
1474            "{} requires a date literal (e.g. 2024-01-01), got {}",
1475            cmd,
1476            value_kind_name(other)
1477        )),
1478    }
1479}
1480
1481/// Extract a [`TimeValue`] from a [`Value::Time`] literal arg.
1482fn require_time_literal(args: &[CommandArg], cmd: &str) -> Result<TimeValue, String> {
1483    match require_literal(args, cmd)? {
1484        crate::literals::Value::Time(t) => Ok(t.clone()),
1485        other => Err(format!(
1486            "{} requires a time literal (e.g. 12:30:00), got {}",
1487            cmd,
1488            value_kind_name(other)
1489        )),
1490    }
1491}
1492
1493/// Default `help` for a built-in primitive (goal-oriented; syntax lives in [`LemmaType::example_value`]).
1494#[must_use]
1495pub fn default_help_for_primitive(kind: PrimitiveKind) -> &'static str {
1496    use PrimitiveKind::*;
1497    match kind {
1498        Boolean => "Whether this holds (true or false).",
1499        Number => "A dimensionless number.",
1500        NumberRange => "The lower and upper bound of the number range.",
1501        Text => "A text value.",
1502        Measure => "A numeric amount in one of this type's units.",
1503        MeasureRange => "The lower and upper bound of the measure range.",
1504        Ratio => "A ratio in one of this type's units (e.g. percent).",
1505        RatioRange => "The lower and upper bound of the ratio range.",
1506        Date => "A date, or a date and time with optional timezone.",
1507        DateRange => "The start date and end date of the date range.",
1508        Time => "A time of day, with optional timezone.",
1509        TimeRange => "The start time and end time of the time range.",
1510    }
1511}
1512
1513impl TypeSpecification {
1514    pub fn boolean() -> Self {
1515        TypeSpecification::Boolean {
1516            help: default_help_for_primitive(PrimitiveKind::Boolean).to_string(),
1517        }
1518    }
1519    pub fn measure() -> Self {
1520        TypeSpecification::Measure {
1521            minimum: None,
1522            maximum: None,
1523            decimals: None,
1524            units: MeasureUnits::new(),
1525            traits: Vec::new(),
1526            decomposition: None,
1527            unit: None,
1528            help: default_help_for_primitive(PrimitiveKind::Measure).to_string(),
1529        }
1530    }
1531    pub fn number() -> Self {
1532        TypeSpecification::Number {
1533            minimum: None,
1534            maximum: None,
1535            decimals: None,
1536            help: default_help_for_primitive(PrimitiveKind::Number).to_string(),
1537        }
1538    }
1539    pub fn number_range() -> Self {
1540        TypeSpecification::NumberRange {
1541            lower: None,
1542            upper: None,
1543            minimum: None,
1544            maximum: None,
1545            help: default_help_for_primitive(PrimitiveKind::NumberRange).to_string(),
1546        }
1547    }
1548    pub fn ratio() -> Self {
1549        TypeSpecification::Ratio {
1550            minimum: None,
1551            maximum: None,
1552            decimals: None,
1553            units: RatioUnits(vec![
1554                RatioUnit {
1555                    name: "percent".to_string(),
1556                    value: crate::computation::rational::rational_new(100, 1),
1557                    minimum: None,
1558                    maximum: None,
1559                    suggestion_magnitude: None,
1560                },
1561                RatioUnit {
1562                    name: "permille".to_string(),
1563                    value: crate::computation::rational::rational_new(1000, 1),
1564                    minimum: None,
1565                    maximum: None,
1566                    suggestion_magnitude: None,
1567                },
1568            ]),
1569            unit: None,
1570            help: default_help_for_primitive(PrimitiveKind::Ratio).to_string(),
1571        }
1572    }
1573    pub fn ratio_range() -> Self {
1574        TypeSpecification::RatioRange {
1575            lower: None,
1576            upper: None,
1577            minimum: None,
1578            maximum: None,
1579            units: match TypeSpecification::ratio() {
1580                TypeSpecification::Ratio { units, .. } => units,
1581                _ => unreachable!("BUG: ratio constructor must return a ratio type"),
1582            },
1583            help: default_help_for_primitive(PrimitiveKind::RatioRange).to_string(),
1584        }
1585    }
1586    pub fn text() -> Self {
1587        TypeSpecification::Text {
1588            length: None,
1589            options: vec![],
1590            help: default_help_for_primitive(PrimitiveKind::Text).to_string(),
1591        }
1592    }
1593    pub fn date() -> Self {
1594        TypeSpecification::Date {
1595            minimum: None,
1596            maximum: None,
1597            help: default_help_for_primitive(PrimitiveKind::Date).to_string(),
1598        }
1599    }
1600    pub fn date_range() -> Self {
1601        TypeSpecification::DateRange {
1602            lower: None,
1603            upper: None,
1604            minimum: None,
1605            maximum: None,
1606            help: default_help_for_primitive(PrimitiveKind::DateRange).to_string(),
1607        }
1608    }
1609    pub fn time() -> Self {
1610        TypeSpecification::Time {
1611            minimum: None,
1612            maximum: None,
1613            help: default_help_for_primitive(PrimitiveKind::Time).to_string(),
1614        }
1615    }
1616    pub fn time_range() -> Self {
1617        TypeSpecification::TimeRange {
1618            lower: None,
1619            upper: None,
1620            minimum: None,
1621            maximum: None,
1622            help: default_help_for_primitive(PrimitiveKind::TimeRange).to_string(),
1623        }
1624    }
1625    pub fn measure_range() -> Self {
1626        TypeSpecification::MeasureRange {
1627            lower: None,
1628            upper: None,
1629            minimum: None,
1630            maximum: None,
1631            units: MeasureUnits::new(),
1632            decomposition: None,
1633            help: default_help_for_primitive(PrimitiveKind::MeasureRange).to_string(),
1634        }
1635    }
1636
1637    /// Element spec for a range type (e.g. `MeasureRange` → `Measure`).
1638    #[must_use]
1639    pub fn element_from_range(&self) -> Option<Self> {
1640        match self {
1641            TypeSpecification::NumberRange { lower, upper, .. } => {
1642                Some(TypeSpecification::Number {
1643                    minimum: lower.clone(),
1644                    maximum: upper.clone(),
1645                    decimals: None,
1646                    help: String::new(),
1647                })
1648            }
1649            TypeSpecification::MeasureRange {
1650                lower,
1651                upper,
1652                units,
1653                decomposition,
1654                ..
1655            } => Some(TypeSpecification::Measure {
1656                minimum: lower.clone(),
1657                maximum: upper.clone(),
1658                decimals: None,
1659                units: units.clone(),
1660                traits: Vec::new(),
1661                decomposition: decomposition.clone(),
1662                unit: None,
1663                help: String::new(),
1664            }),
1665            TypeSpecification::DateRange { lower, upper, .. } => Some(TypeSpecification::Date {
1666                minimum: lower.clone(),
1667                maximum: upper.clone(),
1668                help: String::new(),
1669            }),
1670            TypeSpecification::TimeRange { lower, upper, .. } => Some(TypeSpecification::Time {
1671                minimum: lower.clone(),
1672                maximum: upper.clone(),
1673                help: String::new(),
1674            }),
1675            TypeSpecification::RatioRange {
1676                lower,
1677                upper,
1678                units,
1679                ..
1680            } => Some(TypeSpecification::Ratio {
1681                minimum: lower.clone(),
1682                maximum: upper.clone(),
1683                decimals: None,
1684                units: units.clone(),
1685                unit: None,
1686                help: String::new(),
1687            }),
1688            _ => None,
1689        }
1690    }
1691
1692    /// Range spec for an element type (e.g. `Measure` → `MeasureRange`).
1693    #[must_use]
1694    pub fn range_from_element(&self) -> Option<Self> {
1695        match self {
1696            TypeSpecification::Number {
1697                minimum, maximum, ..
1698            } => Some(TypeSpecification::NumberRange {
1699                lower: minimum.clone(),
1700                upper: maximum.clone(),
1701                minimum: None,
1702                maximum: None,
1703                help: default_help_for_primitive(PrimitiveKind::NumberRange).to_string(),
1704            }),
1705            TypeSpecification::Measure {
1706                minimum,
1707                maximum,
1708                units,
1709                decomposition,
1710                ..
1711            } => Some(TypeSpecification::MeasureRange {
1712                lower: minimum.clone(),
1713                upper: maximum.clone(),
1714                minimum: None,
1715                maximum: None,
1716                units: units.clone(),
1717                decomposition: decomposition.clone(),
1718                help: default_help_for_primitive(PrimitiveKind::MeasureRange).to_string(),
1719            }),
1720            TypeSpecification::Date {
1721                minimum, maximum, ..
1722            } => Some(TypeSpecification::DateRange {
1723                lower: minimum.clone(),
1724                upper: maximum.clone(),
1725                minimum: None,
1726                maximum: None,
1727                help: default_help_for_primitive(PrimitiveKind::DateRange).to_string(),
1728            }),
1729            TypeSpecification::Time {
1730                minimum, maximum, ..
1731            } => Some(TypeSpecification::TimeRange {
1732                lower: minimum.clone(),
1733                upper: maximum.clone(),
1734                minimum: None,
1735                maximum: None,
1736                help: default_help_for_primitive(PrimitiveKind::TimeRange).to_string(),
1737            }),
1738            TypeSpecification::Ratio {
1739                minimum,
1740                maximum,
1741                units,
1742                ..
1743            } => Some(TypeSpecification::RatioRange {
1744                lower: minimum.clone(),
1745                upper: maximum.clone(),
1746                minimum: None,
1747                maximum: None,
1748                units: units.clone(),
1749                help: default_help_for_primitive(PrimitiveKind::RatioRange).to_string(),
1750            }),
1751            _ => None,
1752        }
1753    }
1754
1755    /// Minimum bound as decimal for interactive numeric prompts (number, measure, ratio).
1756    #[must_use]
1757    pub fn minimum_decimal(&self) -> Option<Decimal> {
1758        match self {
1759            TypeSpecification::Number { minimum, .. }
1760            | TypeSpecification::Ratio { minimum, .. } => minimum.as_ref().map(|bound| {
1761                bound
1762                    .try_to_decimal()
1763                    .expect("BUG: planned minimum must convert to decimal")
1764            }),
1765            TypeSpecification::Measure { minimum, .. } => minimum.as_ref().map(|(bound, _unit)| {
1766                bound
1767                    .try_to_decimal()
1768                    .expect("BUG: planned minimum must convert to decimal")
1769            }),
1770            _ => None,
1771        }
1772    }
1773
1774    /// Maximum bound as decimal for interactive numeric prompts (number, measure, ratio).
1775    #[must_use]
1776    pub fn maximum_decimal(&self) -> Option<Decimal> {
1777        match self {
1778            TypeSpecification::Number { maximum, .. }
1779            | TypeSpecification::Ratio { maximum, .. } => maximum.as_ref().map(|bound| {
1780                bound
1781                    .try_to_decimal()
1782                    .expect("BUG: planned maximum must convert to decimal")
1783            }),
1784            TypeSpecification::Measure { maximum, .. } => maximum.as_ref().map(|(bound, _unit)| {
1785                bound
1786                    .try_to_decimal()
1787                    .expect("BUG: planned maximum must convert to decimal")
1788            }),
1789            _ => None,
1790        }
1791    }
1792
1793    pub fn veto() -> Self {
1794        TypeSpecification::Veto { message: None }
1795    }
1796
1797    /// Apply a single constraint command to this spec.
1798    ///
1799    /// The `declared_suggestion` and `declared_fill` out-parameters receive default values
1800    /// (if the command is `Suggest` or `Fill`), encoded as [`RawSuggestion`]. Defaults are
1801    /// owned by the data binding or typedef entry, not by the type specification itself;
1802    /// callers thread `&mut Option<RawSuggestion>` for each across constraint applications
1803    /// for one declaration. Duplicate `-> suggest` / `-> fill` / `minimum` / `maximum` /
1804    /// `decimals` on the same declaration are rejected by the caller seen-set before this
1805    /// runs. A child typedef may override an inherited suggest, fill, or bound with one
1806    /// command of that kind. Measure scalars stay raw until unit factors are resolved;
1807    /// callers convert via [`bound_value_kind_from_raw_suggestion`].
1808    pub fn apply_constraint(
1809        &mut self,
1810        type_name: &str,
1811        command: TypeConstraintCommand,
1812        args: &[CommandArg],
1813        declared_suggestion: &mut Option<RawSuggestion>,
1814        declared_fill: &mut Option<RawSuggestion>,
1815        resolve_unit: Option<&UnitTokenResolver<'_>>,
1816    ) -> Result<(), String> {
1817        if command == TypeConstraintCommand::Trait
1818            && !matches!(&self, TypeSpecification::Measure { .. })
1819        {
1820            return Err("trait command is only valid on measure types".to_string());
1821        }
1822        match self {
1823            TypeSpecification::Boolean { help } => match command {
1824                TypeConstraintCommand::Help => {
1825                    apply_type_help_command(help, args)?;
1826                }
1827                TypeConstraintCommand::Suggest | TypeConstraintCommand::Fill => {
1828                    let (target, cmd) = match command {
1829                        TypeConstraintCommand::Suggest => (&mut *declared_suggestion, "suggest"),
1830                        TypeConstraintCommand::Fill => (&mut *declared_fill, "fill"),
1831                        _ => unreachable!("BUG: only Suggest or Fill in this arm"),
1832                    };
1833                    let lit = require_literal(args, cmd)?;
1834                    reject_calendar_for_suggestion(
1835                        lit,
1836                        type_name,
1837                        SuggestionExpectation::Boolean,
1838                        None,
1839                    )?;
1840                    match lit {
1841                        crate::literals::Value::Boolean(bv) => {
1842                            *target =
1843                                Some(RawSuggestion::Value(ValueKind::Boolean(bool::from(bv))));
1844                        }
1845                        _ => {
1846                            return Err(
1847                                "Please provide true or false, for example `-> suggest true`."
1848                                    .to_string(),
1849                            );
1850                        }
1851                    }
1852                }
1853                other => {
1854                    return Err(format!(
1855                        "Invalid command '{}' for boolean type. Valid commands: help, suggest, fill",
1856                        other
1857                    ));
1858                }
1859            },
1860            TypeSpecification::Measure {
1861                decimals,
1862                minimum,
1863                maximum,
1864                units,
1865                traits,
1866                help,
1867                ..
1868            } => match command {
1869                TypeConstraintCommand::Decimals => {
1870                    let d = require_decimal_literal(args, "decimals")?;
1871                    *decimals = Some(decimal_to_u8(d, "decimals")?);
1872                }
1873                TypeConstraintCommand::Unit => {
1874                    let (unit_name, value, derived_measure_factors) = match args {
1875                        [CommandArg::Label(name), CommandArg::UnitExpr(crate::parsing::ast::UnitArg::Factor(v))] => {
1876                            (name.clone(), *v, Vec::new())
1877                        }
1878                        [CommandArg::Label(name), CommandArg::UnitExpr(crate::parsing::ast::UnitArg::Expr(
1879                            prefix,
1880                            factors,
1881                        ))] => {
1882                            let raw: Vec<(String, i32)> = factors
1883                                .iter()
1884                                .map(|f| (f.measure_ref.clone(), f.exp))
1885                                .collect();
1886                            (name.clone(), *prefix, raw)
1887                        }
1888                        _ => {
1889                            return Err(
1890                                "unit requires a unit name followed by a conversion factor or compound unit expression (e.g., 'unit eur 1.00' or 'unit mps meter/second')"
1891                                    .to_string(),
1892                            );
1893                        }
1894                    };
1895                    if let Some(existing) = units.0.iter().find(|u| u.name == unit_name) {
1896                        let new_factor = crate::computation::rational::decimal_to_rational(value)
1897                            .map_err(|failure| failure.to_string())?;
1898                        if existing.factor != new_factor
1899                            || existing.derived_measure_factors != derived_measure_factors
1900                        {
1901                            return Err(format!(
1902                                "Unit '{unit_name}' is already defined in this type's inherited units; \
1903                                 cannot change factor or decomposition. Add a new unit name instead."
1904                            ));
1905                        }
1906                    } else {
1907                        units.0.push(MeasureUnit::from_decimal_factor(
1908                            unit_name,
1909                            value,
1910                            derived_measure_factors,
1911                        )?);
1912                    }
1913                }
1914                TypeConstraintCommand::Trait => {
1915                    let measure_trait = parse_measure_trait(args)?;
1916                    if traits.contains(&measure_trait) {
1917                        return Err(format!(
1918                            "Duplicate trait '{}' for measure type.",
1919                            measure_trait_name(measure_trait)
1920                        ));
1921                    }
1922                    if measure_trait == MeasureTrait::Duration {
1923                        validate_duration_trait_requirements(units)?;
1924                    }
1925                    if measure_trait == MeasureTrait::Calendar {
1926                        validate_calendar_trait_requirements(units)?;
1927                    }
1928                    traits.push(measure_trait);
1929                }
1930                TypeConstraintCommand::Minimum => {
1931                    *minimum = Some(parse_measure_declared_bound(
1932                        args, "minimum", units, type_name,
1933                    )?);
1934                }
1935                TypeConstraintCommand::Maximum => {
1936                    *maximum = Some(parse_measure_declared_bound(
1937                        args, "maximum", units, type_name,
1938                    )?);
1939                }
1940                TypeConstraintCommand::Help => {
1941                    apply_type_help_command(help, args)?;
1942                }
1943                TypeConstraintCommand::Suggest | TypeConstraintCommand::Fill => {
1944                    let (target, cmd) = match command {
1945                        TypeConstraintCommand::Suggest => (&mut *declared_suggestion, "suggest"),
1946                        TypeConstraintCommand::Fill => (&mut *declared_fill, "fill"),
1947                        _ => unreachable!("BUG: only Suggest or Fill in this arm"),
1948                    };
1949                    let lit = require_literal(args, cmd)?;
1950                    if !traits.contains(&MeasureTrait::Calendar) {
1951                        reject_calendar_for_suggestion(
1952                            lit,
1953                            type_name,
1954                            SuggestionExpectation::MeasureUnits,
1955                            Some(units),
1956                        )?;
1957                    }
1958                    match lit {
1959                        crate::literals::Value::NumberWithUnit(_, _) => {
1960                            let (magnitude, unit_name) =
1961                                parse_measure_declared_bound(args, cmd, units, type_name)?;
1962                            *target = Some(RawSuggestion::Measure {
1963                                magnitude,
1964                                unit_name,
1965                            });
1966                        }
1967                        _ => {
1968                            return Err(measure_suggestion_wrong_shape_error(type_name, traits));
1969                        }
1970                    }
1971                }
1972                _ => {
1973                    return Err(format!(
1974                        "Invalid command '{}' for measure type. Valid commands: unit, trait, minimum, maximum, decimals, help, suggest, fill",
1975                        command
1976                    ));
1977                }
1978            },
1979            TypeSpecification::Number {
1980                decimals,
1981                minimum,
1982                maximum,
1983                help,
1984            } => match command {
1985                TypeConstraintCommand::Decimals => {
1986                    let d = require_decimal_literal(args, "decimals")?;
1987                    *decimals = Some(decimal_to_u8(d, "decimals")?);
1988                }
1989                TypeConstraintCommand::Unit => {
1990                    return Err(
1991                        "Invalid command 'unit' for number type. Number types are dimensionless and cannot have units. Use 'measure' type instead.".to_string()
1992                    );
1993                }
1994                TypeConstraintCommand::Minimum => {
1995                    *minimum = Some(require_decimal_literal(args, "minimum")?);
1996                }
1997                TypeConstraintCommand::Maximum => {
1998                    *maximum = Some(require_decimal_literal(args, "maximum")?);
1999                }
2000                TypeConstraintCommand::Help => {
2001                    apply_type_help_command(help, args)?;
2002                }
2003                TypeConstraintCommand::Suggest | TypeConstraintCommand::Fill => {
2004                    let (target, cmd) = match command {
2005                        TypeConstraintCommand::Suggest => (&mut *declared_suggestion, "suggest"),
2006                        TypeConstraintCommand::Fill => (&mut *declared_fill, "fill"),
2007                        _ => unreachable!("BUG: only Suggest or Fill in this arm"),
2008                    };
2009                    let lit = require_literal(args, cmd)?;
2010                    reject_calendar_for_suggestion(
2011                        lit,
2012                        type_name,
2013                        SuggestionExpectation::Number,
2014                        None,
2015                    )?;
2016                    match lit {
2017                        crate::literals::Value::Number(d) => {
2018                            *target = Some(RawSuggestion::Value(ValueKind::Number(
2019                                lift_parser_decimal(*d)?,
2020                            )));
2021                        }
2022                        _ => {
2023                            return Err(
2024                                "Please provide a number, for example `-> suggest 42`.".to_string()
2025                            );
2026                        }
2027                    }
2028                }
2029                _ => {
2030                    return Err(format!(
2031                        "Invalid command '{}' for number type. Valid commands: minimum, maximum, decimals, help, suggest, fill",
2032                        command
2033                    ));
2034                }
2035            },
2036            TypeSpecification::NumberRange {
2037                lower,
2038                upper,
2039                minimum,
2040                maximum,
2041                help,
2042            } => match command {
2043                TypeConstraintCommand::Lower => {
2044                    *lower = Some(require_decimal_literal(args, "lower")?);
2045                }
2046                TypeConstraintCommand::Upper => {
2047                    *upper = Some(require_decimal_literal(args, "upper")?);
2048                }
2049                TypeConstraintCommand::Minimum => {
2050                    let width = require_decimal_literal(args, "minimum")?;
2051                    reject_negative_width_magnitude(&width, "minimum")?;
2052                    *minimum = Some(width);
2053                }
2054                TypeConstraintCommand::Maximum => {
2055                    let width = require_decimal_literal(args, "maximum")?;
2056                    reject_negative_width_magnitude(&width, "maximum")?;
2057                    *maximum = Some(width);
2058                }
2059                TypeConstraintCommand::Help => {
2060                    apply_type_help_command(help, args)?;
2061                }
2062                TypeConstraintCommand::Suggest | TypeConstraintCommand::Fill => {
2063                    let target = match command {
2064                        TypeConstraintCommand::Suggest => &mut *declared_suggestion,
2065                        TypeConstraintCommand::Fill => &mut *declared_fill,
2066                        _ => unreachable!("BUG: only Suggest or Fill in this arm"),
2067                    };
2068                    let (left, right) = require_suggestion_range_endpoints(
2069                        args,
2070                        type_name,
2071                        SuggestionExpectation::NumberRange,
2072                        None,
2073                    )?;
2074                    let left = literal_value_from_parser_value(left)?;
2075                    let right = literal_value_from_parser_value(right)?;
2076                    if !left.lemma_type.is_number() || !right.lemma_type.is_number() {
2077                        return Err(
2078                            "Please provide a number range, for example `-> suggest 10...100`."
2079                                .to_string(),
2080                        );
2081                    }
2082                    *target = Some(RawSuggestion::Value(ValueKind::Range(
2083                        Box::new(left),
2084                        Box::new(right),
2085                    )));
2086                }
2087                _ => {
2088                    return Err(format!(
2089                        "Invalid command '{}' for number range type. Valid commands: lower, upper, minimum, maximum, help, suggest, fill",
2090                        command
2091                    ));
2092                }
2093            },
2094            TypeSpecification::Ratio {
2095                decimals,
2096                minimum,
2097                maximum,
2098                units,
2099                unit: _,
2100                help,
2101            } => match command {
2102                TypeConstraintCommand::Decimals => {
2103                    let d = require_decimal_literal(args, "decimals")?;
2104                    *decimals = Some(decimal_to_u8(d, "decimals")?);
2105                }
2106                TypeConstraintCommand::Unit => {
2107                    let (unit_name, value_dec) = match args {
2108                        [CommandArg::Label(name), CommandArg::UnitExpr(crate::parsing::ast::UnitArg::Factor(v))] => {
2109                            (name.clone(), *v)
2110                        }
2111                        _ => {
2112                            return Err(
2113                                "unit requires a unit name followed by a numeric conversion factor (e.g., 'unit percent 100'). Compound unit expressions are not supported for ratio types."
2114                                    .to_string(),
2115                            );
2116                        }
2117                    };
2118                    let value = crate::computation::rational::decimal_to_rational(value_dec)
2119                        .map_err(|failure| {
2120                            format!(
2121                                "ratio unit value is not exactly representable as a rational: {}",
2122                                failure
2123                            )
2124                        })?;
2125                    if let Some(existing) = units.0.iter().find(|u| u.name == unit_name) {
2126                        if existing.value != value {
2127                            return Err(format!(
2128                                "Unit '{unit_name}' is already defined in this type's inherited units; \
2129                                 cannot change factor. Add a new unit name instead."
2130                            ));
2131                        }
2132                    } else {
2133                        units.0.push(RatioUnit {
2134                            name: unit_name,
2135                            value,
2136                            minimum: None,
2137                            maximum: None,
2138                            suggestion_magnitude: None,
2139                        });
2140                    }
2141                }
2142                TypeConstraintCommand::Minimum => {
2143                    let canonical = ratio_bound_to_canonical_rational(args, "minimum", units)?;
2144                    sync_ratio_units_from_canonical(
2145                        units,
2146                        &canonical,
2147                        UnitConstraintField::Minimum,
2148                    )?;
2149                    *minimum = Some(canonical);
2150                }
2151                TypeConstraintCommand::Maximum => {
2152                    let canonical = ratio_bound_to_canonical_rational(args, "maximum", units)?;
2153                    sync_ratio_units_from_canonical(
2154                        units,
2155                        &canonical,
2156                        UnitConstraintField::Maximum,
2157                    )?;
2158                    *maximum = Some(canonical);
2159                }
2160                TypeConstraintCommand::Help => {
2161                    apply_type_help_command(help, args)?;
2162                }
2163                TypeConstraintCommand::Suggest | TypeConstraintCommand::Fill => {
2164                    let (target, cmd) = match command {
2165                        TypeConstraintCommand::Suggest => (&mut *declared_suggestion, "suggest"),
2166                        TypeConstraintCommand::Fill => (&mut *declared_fill, "fill"),
2167                        _ => unreachable!("BUG: only Suggest or Fill in this arm"),
2168                    };
2169                    let lit = require_literal(args, cmd)?;
2170                    reject_calendar_for_suggestion(
2171                        lit,
2172                        type_name,
2173                        SuggestionExpectation::Ratio,
2174                        None,
2175                    )?;
2176                    match lit {
2177                        crate::literals::Value::NumberWithUnit(_, unit_name) => {
2178                            let element_spec = TypeSpecification::Ratio {
2179                                decimals: *decimals,
2180                                minimum: minimum.clone(),
2181                                maximum: maximum.clone(),
2182                                units: units.clone(),
2183                                unit: None,
2184                                help: help.clone(),
2185                            };
2186                            let value = parser_value_to_value_kind(lit, &element_spec, None)?;
2187                            sync_ratio_suggestion_units(units, &value)?;
2188                            *target = Some(RawSuggestion::UnitBound {
2189                                value,
2190                                unit_name: unit_name.clone(),
2191                            });
2192                        }
2193                        other => {
2194                            return Err(format!(
2195                                "suggest requires a ratio literal with a unit, got {}. Please provide a ratio value with a unit, for example `-> suggest 25%`.",
2196                                value_kind_name(other)
2197                            ));
2198                        }
2199                    }
2200                }
2201                _ => {
2202                    return Err(format!(
2203                        "Invalid command '{}' for ratio type. Valid commands: unit, minimum, maximum, decimals, help, suggest, fill",
2204                        command
2205                    ));
2206                }
2207            },
2208            TypeSpecification::RatioRange {
2209                lower,
2210                upper,
2211                minimum,
2212                maximum,
2213                units,
2214                help,
2215            } => match command {
2216                TypeConstraintCommand::Unit => {
2217                    let (unit_name, value_dec) = match args {
2218                        [CommandArg::Label(name), CommandArg::UnitExpr(crate::parsing::ast::UnitArg::Factor(v))] => {
2219                            (name.clone(), *v)
2220                        }
2221                        _ => {
2222                            return Err(
2223                                "unit requires a unit name followed by a numeric conversion factor (e.g., 'unit percent 100'). Compound unit expressions are not supported for ratio range types."
2224                                    .to_string(),
2225                            );
2226                        }
2227                    };
2228                    let value = crate::computation::rational::decimal_to_rational(value_dec)
2229                        .map_err(|e| {
2230                            format!(
2231                                "ratio unit value is not exactly representable as a rational: {e}"
2232                            )
2233                        })?;
2234                    if let Some(existing) = units.0.iter().find(|u| u.name == unit_name) {
2235                        if existing.value != value {
2236                            return Err(format!(
2237                                "Unit '{unit_name}' is already defined in this type's inherited units; \
2238                                 cannot change factor. Add a new unit name instead."
2239                            ));
2240                        }
2241                    } else {
2242                        units.0.push(RatioUnit {
2243                            name: unit_name,
2244                            value,
2245                            minimum: None,
2246                            maximum: None,
2247                            suggestion_magnitude: None,
2248                        });
2249                    }
2250                }
2251                TypeConstraintCommand::Lower => {
2252                    *lower = Some(ratio_bound_to_canonical_rational(args, "lower", units)?);
2253                }
2254                TypeConstraintCommand::Upper => {
2255                    *upper = Some(ratio_bound_to_canonical_rational(args, "upper", units)?);
2256                }
2257                TypeConstraintCommand::Minimum => {
2258                    let width = ratio_bound_to_canonical_rational(args, "minimum", units)?;
2259                    reject_negative_width_magnitude(&width, "minimum")?;
2260                    *minimum = Some(width);
2261                }
2262                TypeConstraintCommand::Maximum => {
2263                    let width = ratio_bound_to_canonical_rational(args, "maximum", units)?;
2264                    reject_negative_width_magnitude(&width, "maximum")?;
2265                    *maximum = Some(width);
2266                }
2267                TypeConstraintCommand::Help => {
2268                    apply_type_help_command(help, args)?;
2269                }
2270                TypeConstraintCommand::Suggest | TypeConstraintCommand::Fill => {
2271                    let target = match command {
2272                        TypeConstraintCommand::Suggest => &mut *declared_suggestion,
2273                        TypeConstraintCommand::Fill => &mut *declared_fill,
2274                        _ => unreachable!("BUG: only Suggest or Fill in this arm"),
2275                    };
2276                    let (left, right) = require_suggestion_range_endpoints(
2277                        args,
2278                        type_name,
2279                        SuggestionExpectation::RatioRange,
2280                        None,
2281                    )?;
2282                    let element_spec = TypeSpecification::RatioRange {
2283                        lower: lower.clone(),
2284                        upper: upper.clone(),
2285                        minimum: minimum.clone(),
2286                        maximum: maximum.clone(),
2287                        units: units.clone(),
2288                        help: help.clone(),
2289                    }
2290                    .element_from_range()
2291                    .expect("BUG: RatioRange must define element_from_range");
2292                    let left = lift_range_endpoint(left, &element_spec, resolve_unit)?;
2293                    let right = lift_range_endpoint(right, &element_spec, resolve_unit)?;
2294                    if !left.lemma_type.is_ratio() || !right.lemma_type.is_ratio() {
2295                        return Err(
2296                            "Please provide a ratio range, for example `-> suggest 10%...50%`."
2297                                .to_string(),
2298                        );
2299                    }
2300                    *target = Some(RawSuggestion::Value(ValueKind::Range(
2301                        Box::new(left),
2302                        Box::new(right),
2303                    )));
2304                }
2305                _ => {
2306                    return Err(format!(
2307                        "Invalid command '{}' for ratio range type. Valid commands: unit, lower, upper, minimum, maximum, help, suggest, fill",
2308                        command
2309                    ));
2310                }
2311            },
2312            TypeSpecification::Text {
2313                length,
2314                options,
2315                help,
2316            } => match command {
2317                TypeConstraintCommand::Option => {
2318                    if args.len() != 1 {
2319                        return Err("option takes exactly one argument".to_string());
2320                    }
2321                    options.push(option_name(&args[0], "option")?);
2322                }
2323                TypeConstraintCommand::Options => {
2324                    let mut collected = Vec::with_capacity(args.len());
2325                    for arg in args {
2326                        collected.push(option_name(arg, "options")?);
2327                    }
2328                    *options = collected;
2329                }
2330                TypeConstraintCommand::Length => {
2331                    let d = require_decimal_literal(args, "length")?;
2332                    *length = Some(decimal_to_usize(d, "length")?);
2333                }
2334                TypeConstraintCommand::Help => {
2335                    apply_type_help_command(help, args)?;
2336                }
2337                TypeConstraintCommand::Suggest | TypeConstraintCommand::Fill => {
2338                    let (target, cmd) = match command {
2339                        TypeConstraintCommand::Suggest => (&mut *declared_suggestion, "suggest"),
2340                        TypeConstraintCommand::Fill => (&mut *declared_fill, "fill"),
2341                        _ => unreachable!("BUG: only Suggest or Fill in this arm"),
2342                    };
2343                    let lit = require_literal(args, cmd)?;
2344                    reject_calendar_for_suggestion(
2345                        lit,
2346                        type_name,
2347                        SuggestionExpectation::Text,
2348                        None,
2349                    )?;
2350                    match lit {
2351                        crate::literals::Value::Text(s) => {
2352                            *target = Some(RawSuggestion::Value(ValueKind::Text(s.clone())));
2353                        }
2354                        _ => {
2355                            return Err(
2356                                "Please provide a text value in double quotes, for example `-> suggest \"my default value\"`."
2357                                    .to_string(),
2358                            );
2359                        }
2360                    }
2361                }
2362                _ => {
2363                    return Err(format!(
2364                        "Invalid command '{}' for text type. Valid commands: options, length, help, suggest, fill",
2365                        command
2366                    ));
2367                }
2368            },
2369            TypeSpecification::Date {
2370                minimum,
2371                maximum,
2372                help,
2373            } => match command {
2374                TypeConstraintCommand::Minimum => {
2375                    let dt = require_date_literal(args, "minimum")?;
2376                    *minimum = Some(dt);
2377                }
2378                TypeConstraintCommand::Maximum => {
2379                    let dt = require_date_literal(args, "maximum")?;
2380                    *maximum = Some(dt);
2381                }
2382                TypeConstraintCommand::Help => {
2383                    apply_type_help_command(help, args)?;
2384                }
2385                TypeConstraintCommand::Suggest | TypeConstraintCommand::Fill => {
2386                    let (target, cmd) = match command {
2387                        TypeConstraintCommand::Suggest => (&mut *declared_suggestion, "suggest"),
2388                        TypeConstraintCommand::Fill => (&mut *declared_fill, "fill"),
2389                        _ => unreachable!("BUG: only Suggest or Fill in this arm"),
2390                    };
2391                    let lit = require_literal(args, cmd)?;
2392                    reject_calendar_for_suggestion(
2393                        lit,
2394                        type_name,
2395                        SuggestionExpectation::Date,
2396                        None,
2397                    )?;
2398                    match lit {
2399                        crate::literals::Value::Date(dt) => {
2400                            *target = Some(RawSuggestion::Value(ValueKind::Date(
2401                                date_time_to_semantic(dt),
2402                            )));
2403                        }
2404                        _ => {
2405                            return Err(
2406                                "Please provide a date, for example `-> suggest 2024-06-15`."
2407                                    .to_string(),
2408                            );
2409                        }
2410                    }
2411                }
2412                _ => {
2413                    return Err(format!(
2414                        "Invalid command '{}' for date type. Valid commands: minimum, maximum, help, suggest, fill",
2415                        command
2416                    ));
2417                }
2418            },
2419            TypeSpecification::DateRange {
2420                lower,
2421                upper,
2422                minimum,
2423                maximum,
2424                help,
2425            } => match command {
2426                TypeConstraintCommand::Lower => {
2427                    *lower = Some(require_date_literal(args, "lower")?);
2428                }
2429                TypeConstraintCommand::Upper => {
2430                    *upper = Some(require_date_literal(args, "upper")?);
2431                }
2432                TypeConstraintCommand::Minimum => {
2433                    *minimum = Some(parse_unresolved_width_bound(args, "minimum")?);
2434                }
2435                TypeConstraintCommand::Maximum => {
2436                    *maximum = Some(parse_unresolved_width_bound(args, "maximum")?);
2437                }
2438                TypeConstraintCommand::Help => {
2439                    apply_type_help_command(help, args)?;
2440                }
2441                TypeConstraintCommand::Suggest | TypeConstraintCommand::Fill => {
2442                    let target = match command {
2443                        TypeConstraintCommand::Suggest => &mut *declared_suggestion,
2444                        TypeConstraintCommand::Fill => &mut *declared_fill,
2445                        _ => unreachable!("BUG: only Suggest or Fill in this arm"),
2446                    };
2447                    let (left, right) = require_suggestion_range_endpoints(
2448                        args,
2449                        type_name,
2450                        SuggestionExpectation::DateRange,
2451                        None,
2452                    )?;
2453                    let left = literal_value_from_parser_value(left)?;
2454                    let right = literal_value_from_parser_value(right)?;
2455                    if !left.lemma_type.is_date() || !right.lemma_type.is_date() {
2456                        return Err(
2457                            "Please provide a date range, for example `-> suggest 2024-01-01...2024-12-31`."
2458                                .to_string(),
2459                        );
2460                    }
2461                    *target = Some(RawSuggestion::Value(ValueKind::Range(
2462                        Box::new(left),
2463                        Box::new(right),
2464                    )));
2465                }
2466                _ => {
2467                    return Err(format!(
2468                        "Invalid command '{}' for date range type. Valid commands: lower, upper, minimum, maximum, help, suggest, fill",
2469                        command
2470                    ));
2471                }
2472            },
2473            TypeSpecification::Time {
2474                minimum,
2475                maximum,
2476                help,
2477            } => match command {
2478                TypeConstraintCommand::Minimum => {
2479                    let t = require_time_literal(args, "minimum")?;
2480                    *minimum = Some(t);
2481                }
2482                TypeConstraintCommand::Maximum => {
2483                    let t = require_time_literal(args, "maximum")?;
2484                    *maximum = Some(t);
2485                }
2486                TypeConstraintCommand::Help => {
2487                    apply_type_help_command(help, args)?;
2488                }
2489                TypeConstraintCommand::Suggest | TypeConstraintCommand::Fill => {
2490                    let (target, cmd) = match command {
2491                        TypeConstraintCommand::Suggest => (&mut *declared_suggestion, "suggest"),
2492                        TypeConstraintCommand::Fill => (&mut *declared_fill, "fill"),
2493                        _ => unreachable!("BUG: only Suggest or Fill in this arm"),
2494                    };
2495                    let lit = require_literal(args, cmd)?;
2496                    reject_calendar_for_suggestion(
2497                        lit,
2498                        type_name,
2499                        SuggestionExpectation::Time,
2500                        None,
2501                    )?;
2502                    match lit {
2503                        crate::literals::Value::Time(t) => {
2504                            *target =
2505                                Some(RawSuggestion::Value(ValueKind::Time(time_to_semantic(t))));
2506                        }
2507                        _ => {
2508                            return Err(
2509                                "Please provide a time, for example `-> suggest 09:00:00`."
2510                                    .to_string(),
2511                            );
2512                        }
2513                    }
2514                }
2515                _ => {
2516                    return Err(format!(
2517                        "Invalid command '{}' for time type. Valid commands: minimum, maximum, help, suggest, fill",
2518                        command
2519                    ));
2520                }
2521            },
2522            TypeSpecification::TimeRange {
2523                lower,
2524                upper,
2525                minimum,
2526                maximum,
2527                help,
2528            } => match command {
2529                TypeConstraintCommand::Lower => {
2530                    *lower = Some(require_time_literal(args, "lower")?);
2531                }
2532                TypeConstraintCommand::Upper => {
2533                    *upper = Some(require_time_literal(args, "upper")?);
2534                }
2535                TypeConstraintCommand::Minimum => {
2536                    *minimum = Some(parse_unresolved_width_bound(args, "minimum")?);
2537                }
2538                TypeConstraintCommand::Maximum => {
2539                    *maximum = Some(parse_unresolved_width_bound(args, "maximum")?);
2540                }
2541                TypeConstraintCommand::Help => {
2542                    apply_type_help_command(help, args)?;
2543                }
2544                TypeConstraintCommand::Suggest | TypeConstraintCommand::Fill => {
2545                    let target = match command {
2546                        TypeConstraintCommand::Suggest => &mut *declared_suggestion,
2547                        TypeConstraintCommand::Fill => &mut *declared_fill,
2548                        _ => unreachable!("BUG: only Suggest or Fill in this arm"),
2549                    };
2550                    let (left, right) = require_suggestion_range_endpoints(
2551                        args,
2552                        type_name,
2553                        SuggestionExpectation::TimeRange,
2554                        None,
2555                    )?;
2556                    let left = literal_value_from_parser_value(left)?;
2557                    let right = literal_value_from_parser_value(right)?;
2558                    if !left.lemma_type.is_time() || !right.lemma_type.is_time() {
2559                        return Err(
2560                            "Please provide a time range, for example `-> suggest 09:00...17:00`."
2561                                .to_string(),
2562                        );
2563                    }
2564                    *target = Some(RawSuggestion::Value(ValueKind::Range(
2565                        Box::new(left),
2566                        Box::new(right),
2567                    )));
2568                }
2569                _ => {
2570                    return Err(format!(
2571                        "Invalid command '{}' for time range type. Valid commands: lower, upper, minimum, maximum, help, suggest, fill",
2572                        command
2573                    ));
2574                }
2575            },
2576            TypeSpecification::MeasureRange {
2577                lower,
2578                upper,
2579                minimum,
2580                maximum,
2581                units,
2582                decomposition,
2583                help,
2584            } => match command {
2585                TypeConstraintCommand::Unit => {
2586                    let (unit_name, value, derived_measure_factors) = match args {
2587                        [CommandArg::Label(name), CommandArg::UnitExpr(crate::parsing::ast::UnitArg::Factor(v))] => {
2588                            (name.clone(), *v, Vec::new())
2589                        }
2590                        [CommandArg::Label(name), CommandArg::UnitExpr(crate::parsing::ast::UnitArg::Expr(
2591                            prefix,
2592                            factors,
2593                        ))] => {
2594                            let raw: Vec<(String, i32)> = factors
2595                                .iter()
2596                                .map(|f| (f.measure_ref.clone(), f.exp))
2597                                .collect();
2598                            (name.clone(), *prefix, raw)
2599                        }
2600                        _ => {
2601                            return Err(
2602                                "unit requires a unit name followed by a conversion factor or compound unit expression (e.g., 'unit eur 1.00' or 'unit mps meter/second')"
2603                                    .to_string(),
2604                            );
2605                        }
2606                    };
2607                    if let Some(existing) = units.0.iter().find(|u| u.name == unit_name) {
2608                        let new_factor = crate::computation::rational::decimal_to_rational(value)
2609                            .map_err(|failure| failure.to_string())?;
2610                        if existing.factor != new_factor
2611                            || existing.derived_measure_factors != derived_measure_factors
2612                        {
2613                            return Err(format!(
2614                                "Unit '{unit_name}' is already defined in this type's inherited units; \
2615                                 cannot change factor or decomposition. Add a new unit name instead."
2616                            ));
2617                        }
2618                    } else {
2619                        units.0.push(MeasureUnit::from_decimal_factor(
2620                            unit_name,
2621                            value,
2622                            derived_measure_factors,
2623                        )?);
2624                    }
2625                }
2626                TypeConstraintCommand::Lower => {
2627                    *lower = Some(parse_measure_declared_bound(
2628                        args, "lower", units, type_name,
2629                    )?);
2630                }
2631                TypeConstraintCommand::Upper => {
2632                    *upper = Some(parse_measure_declared_bound(
2633                        args, "upper", units, type_name,
2634                    )?);
2635                }
2636                TypeConstraintCommand::Minimum => {
2637                    let width = parse_measure_declared_bound(args, "minimum", units, type_name)?;
2638                    reject_negative_width_magnitude(&width.0, "minimum")?;
2639                    *minimum = Some(width);
2640                }
2641                TypeConstraintCommand::Maximum => {
2642                    let width = parse_measure_declared_bound(args, "maximum", units, type_name)?;
2643                    reject_negative_width_magnitude(&width.0, "maximum")?;
2644                    *maximum = Some(width);
2645                }
2646                TypeConstraintCommand::Help => {
2647                    apply_type_help_command(help, args)?;
2648                }
2649                TypeConstraintCommand::Suggest | TypeConstraintCommand::Fill => {
2650                    let target = match command {
2651                        TypeConstraintCommand::Suggest => &mut *declared_suggestion,
2652                        TypeConstraintCommand::Fill => &mut *declared_fill,
2653                        _ => unreachable!("BUG: only Suggest or Fill in this arm"),
2654                    };
2655                    let (left, right) = require_suggestion_range_endpoints(
2656                        args,
2657                        type_name,
2658                        SuggestionExpectation::MeasureRange,
2659                        Some(units),
2660                    )?;
2661                    let element_spec = TypeSpecification::MeasureRange {
2662                        lower: lower.clone(),
2663                        upper: upper.clone(),
2664                        minimum: minimum.clone(),
2665                        maximum: maximum.clone(),
2666                        units: units.clone(),
2667                        decomposition: decomposition.clone(),
2668                        help: help.clone(),
2669                    }
2670                    .element_from_range()
2671                    .expect("BUG: MeasureRange must define element_from_range");
2672                    let left = lift_range_endpoint(left, &element_spec, resolve_unit)?;
2673                    let right = lift_range_endpoint(right, &element_spec, resolve_unit)?;
2674                    if !left.lemma_type.is_measure() || !right.lemma_type.is_measure() {
2675                        return Err(format!(
2676                            "Please provide a range with units valid for '{type_name}', for example `-> suggest 30 kilogram...35 kilogram`."
2677                        ));
2678                    }
2679                    *target = Some(RawSuggestion::Value(ValueKind::Range(
2680                        Box::new(left),
2681                        Box::new(right),
2682                    )));
2683                }
2684                _ => {
2685                    return Err(format!(
2686                        "Invalid command '{}' for measure range type. Valid commands: unit, lower, upper, minimum, maximum, help, suggest, fill",
2687                        command
2688                    ));
2689                }
2690            },
2691            TypeSpecification::Veto { .. } => {
2692                return Err(format!(
2693                    "Invalid command '{}' for veto type. Veto is not a user-declarable type and cannot have constraints",
2694                    command
2695                ));
2696            }
2697            TypeSpecification::Undetermined => {
2698                return Err(format!(
2699                    "Invalid command '{}' for undetermined sentinel type. Undetermined is an internal type used during type inference and cannot have constraints",
2700                    command
2701                ));
2702            }
2703            TypeSpecification::Spec { .. } => {
2704                return Err(format!(
2705                    "Invalid command '{command}' for spec type. A spec instance has no data constraints"
2706                ));
2707            }
2708        }
2709        Ok(())
2710    }
2711}
2712
2713/// Parse a "number unit" string into a Measure or Ratio value according to the type.
2714/// Caller must have obtained the TypeSpecification via unit_index from the unit in the string.
2715pub fn parse_number_unit(
2716    value_str: &str,
2717    type_spec: &TypeSpecification,
2718) -> Result<crate::parsing::ast::Value, String> {
2719    use crate::literals::{NumberWithUnit, RatioLiteral};
2720    use crate::parsing::ast::Value;
2721
2722    let trimmed = value_str.trim();
2723    match type_spec {
2724        TypeSpecification::Measure { units, .. } => {
2725            if units.is_empty() {
2726                unreachable!(
2727                    "BUG: Measure type has no units; should have been validated during planning"
2728                );
2729            }
2730            match trimmed.parse::<NumberWithUnit>() {
2731                Ok(n) => {
2732                    let unit = units.get(&n.1).map_err(|e| e.to_string())?;
2733                    Ok(Value::NumberWithUnit(n.0, unit.name.clone()))
2734                }
2735                Err(e) => {
2736                    if trimmed.split_whitespace().count() == 1 && !trimmed.is_empty() {
2737                        let valid: Vec<&str> = units.iter().map(|u| u.name.as_str()).collect();
2738                        let example_unit = units
2739                            .iter()
2740                            .next()
2741                            .expect("BUG: units non-empty after guard")
2742                            .name
2743                            .as_str();
2744                        Err(format!(
2745                            "Measure value must include a unit, for example: '{} {}'. Valid units: {}.",
2746                            trimmed,
2747                            example_unit,
2748                            valid.join(", ")
2749                        ))
2750                    } else {
2751                        Err(e)
2752                    }
2753                }
2754            }
2755        }
2756        TypeSpecification::Ratio { units, .. } => {
2757            if units.is_empty() {
2758                unreachable!(
2759                    "BUG: Ratio type has no units; should have been validated during planning"
2760                );
2761            }
2762            match trimmed.parse::<RatioLiteral>()? {
2763                RatioLiteral::Bare(_) => {
2764                    Err("Ratio value requires a unit (e.g. '50%', '500 basis_points').".to_string())
2765                }
2766                RatioLiteral::Percent(n) => {
2767                    let unit = units.get("percent").map_err(|e| e.to_string())?;
2768                    Ok(Value::NumberWithUnit(n, unit.name.clone()))
2769                }
2770                RatioLiteral::Permille(n) => {
2771                    let unit = units.get("permille").map_err(|e| e.to_string())?;
2772                    Ok(Value::NumberWithUnit(n, unit.name.clone()))
2773                }
2774                RatioLiteral::Named { value, unit } => {
2775                    let resolved = units.get(&unit).map_err(|e| e.to_string())?;
2776                    Ok(Value::NumberWithUnit(value, resolved.name.clone()))
2777                }
2778            }
2779        }
2780        _ => Err("parse_number_unit only accepts Measure or Ratio type".to_string()),
2781    }
2782}
2783
2784/// Parse a string value according to a TypeSpecification.
2785/// Used to parse runtime user input into typed values.
2786pub fn parse_value_from_string(
2787    value_str: &str,
2788    type_spec: &TypeSpecification,
2789    source: &Source,
2790) -> Result<crate::parsing::ast::Value, Error> {
2791    use crate::parsing::ast::Value;
2792
2793    let to_err = |msg: String| Error::validation(msg, Some(source.clone()), None::<String>);
2794
2795    let parse_range_value = |element_spec: TypeSpecification| -> Result<Value, Error> {
2796        let (left_str, right_str) = value_str.split_once("...").ok_or_else(|| {
2797            to_err("Range value must use '...' between the two endpoints".to_string())
2798        })?;
2799        if left_str.trim().is_empty() || right_str.trim().is_empty() {
2800            return Err(to_err(
2801                "Range value must contain a non-empty left and right endpoint".to_string(),
2802            ));
2803        }
2804        let left = parse_value_from_string(left_str.trim(), &element_spec, source)?;
2805        let right = parse_value_from_string(right_str.trim(), &element_spec, source)?;
2806        Ok(Value::Range(Box::new(left), Box::new(right)))
2807    };
2808
2809    match type_spec {
2810        TypeSpecification::Text { .. } => value_str
2811            .parse::<crate::literals::TextLiteral>()
2812            .map(|t| Value::Text(t.0))
2813            .map_err(to_err),
2814        TypeSpecification::Number { .. } => value_str
2815            .parse::<crate::literals::NumberLiteral>()
2816            .map(|n| Value::Number(n.0))
2817            .map_err(to_err),
2818        TypeSpecification::Measure { .. } => {
2819            parse_number_unit(value_str, type_spec).map_err(to_err)
2820        }
2821        TypeSpecification::Boolean { .. } => value_str
2822            .parse::<BooleanValue>()
2823            .map(Value::Boolean)
2824            .map_err(to_err),
2825        TypeSpecification::Date { .. } => {
2826            let date = value_str.parse::<DateTimeValue>().map_err(to_err)?;
2827            Ok(Value::Date(date))
2828        }
2829        TypeSpecification::Time { .. } => {
2830            let time = value_str.parse::<TimeValue>().map_err(to_err)?;
2831            Ok(Value::Time(time))
2832        }
2833        TypeSpecification::Ratio { .. } => {
2834            parse_number_unit(value_str, type_spec).map_err(to_err)
2835        }
2836        TypeSpecification::NumberRange { .. }
2837        | TypeSpecification::MeasureRange { .. }
2838        | TypeSpecification::DateRange { .. }
2839        | TypeSpecification::TimeRange { .. }
2840        | TypeSpecification::RatioRange { .. } => {
2841            let element_spec = range_element_type_specification(type_spec).unwrap_or_else(|| {
2842                unreachable!("BUG: range_element_type_specification missing arm for known range type")
2843            });
2844            parse_range_value(element_spec)
2845        }
2846        TypeSpecification::Veto { .. } => Err(to_err(
2847            "Veto type cannot be parsed from string".to_string(),
2848        )),
2849        TypeSpecification::Undetermined => unreachable!(
2850            "BUG: parse_value_from_string called with Undetermined sentinel type; this type exists only during type inference"
2851        ),
2852        TypeSpecification::Spec { .. } => Err(to_err(
2853            "spec values are not run-data scalars".to_string(),
2854        )),
2855    }
2856}
2857
2858// -----------------------------------------------------------------------------
2859// Semantic value types (no parser dependency - used by evaluation, inversion, etc.)
2860// -----------------------------------------------------------------------------
2861
2862#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
2863#[serde(rename_all = "snake_case")]
2864pub enum SemanticCalendarUnit {
2865    Month,
2866    Year,
2867}
2868
2869impl fmt::Display for SemanticCalendarUnit {
2870    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2871        let s = match self {
2872            SemanticCalendarUnit::Month => "month",
2873            SemanticCalendarUnit::Year => "year",
2874        };
2875        write!(f, "{}", s)
2876    }
2877}
2878
2879pub fn semantic_calendar_unit_from_unit_name(unit_name: &str) -> SemanticCalendarUnit {
2880    match unit_name {
2881        "month" => SemanticCalendarUnit::Month,
2882        "year" => SemanticCalendarUnit::Year,
2883        other => unreachable!(
2884            "BUG: calendar measure signature unit must be month or year, got '{other}'"
2885        ),
2886    }
2887}
2888
2889pub fn semantic_calendar_unit_from_measure_signature(
2890    signature: &[(String, i32)],
2891) -> SemanticCalendarUnit {
2892    let unit_name = signature
2893        .first()
2894        .map(|(name, _)| name.as_str())
2895        .expect("BUG: calendar measure must carry a unit signature");
2896    semantic_calendar_unit_from_unit_name(unit_name)
2897}
2898
2899pub fn semantic_calendar_unit_from_measure_type(lemma_type: &LemmaType) -> SemanticCalendarUnit {
2900    if !lemma_type.is_calendar_like() {
2901        unreachable!(
2902            "BUG: semantic_calendar_unit_from_measure_type called on non-calendar type {}",
2903            lemma_type.name()
2904        );
2905    }
2906    let signature = lemma_type.measure_runtime_signature();
2907    if signature.is_empty() {
2908        return SemanticCalendarUnit::Month;
2909    }
2910    semantic_calendar_unit_from_measure_signature(&signature)
2911}
2912
2913/// Target type for `as` casts (semantic; used by evaluation/computation).
2914#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2915pub enum SemanticConversionTarget {
2916    Type(PrimitiveKind),
2917    /// `number as eur` — construct, convert, relabel, or range-span into `unit_name`.
2918    Unit {
2919        unit_name: String,
2920        /// Measure/ratio type resolved in the spec where the conversion was written.
2921        owning_type: Arc<LemmaType>,
2922    },
2923}
2924
2925impl std::hash::Hash for SemanticConversionTarget {
2926    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
2927        match self {
2928            Self::Type(kind) => {
2929                0u8.hash(state);
2930                kind.hash(state);
2931            }
2932            Self::Unit {
2933                unit_name,
2934                owning_type,
2935            } => {
2936                1u8.hash(state);
2937                unit_name.hash(state);
2938                owning_type.hash(state);
2939            }
2940        }
2941    }
2942}
2943
2944impl SemanticConversionTarget {}
2945
2946impl fmt::Display for SemanticConversionTarget {
2947    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2948        match self {
2949            SemanticConversionTarget::Type(kind) => write!(f, "{kind}"),
2950            SemanticConversionTarget::Unit { unit_name, .. } => write!(f, "{unit_name}"),
2951        }
2952    }
2953}
2954
2955/// Timezone for semantic date/time values
2956#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
2957pub struct SemanticTimezone {
2958    pub offset_hours: i8,
2959    pub offset_minutes: u8,
2960}
2961
2962impl fmt::Display for SemanticTimezone {
2963    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2964        if self.offset_hours == 0 && self.offset_minutes == 0 {
2965            write!(f, "Z")
2966        } else {
2967            let sign = if self.offset_hours >= 0 { "+" } else { "-" };
2968            let hour = self.offset_hours.abs();
2969            write!(f, "{}{:02}:{:02}", sign, hour, self.offset_minutes)
2970        }
2971    }
2972}
2973
2974impl Serialize for SemanticTimezone {
2975    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2976        serializer.serialize_str(&self.to_string())
2977    }
2978}
2979
2980impl<'de> Deserialize<'de> for SemanticTimezone {
2981    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2982        let s = String::deserialize(deserializer)?;
2983        Self::from_str(&s).map_err(serde::de::Error::custom)
2984    }
2985}
2986
2987impl FromStr for SemanticTimezone {
2988    type Err = String;
2989
2990    fn from_str(s: &str) -> Result<Self, Self::Err> {
2991        let tz = TimezoneValue::from_str(s)?;
2992        Ok(Self {
2993            offset_hours: tz.offset_hours,
2994            offset_minutes: tz.offset_minutes,
2995        })
2996    }
2997}
2998
2999/// Time-of-day for semantic values
3000#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3001pub struct SemanticTime {
3002    pub hour: u32,
3003    pub minute: u32,
3004    pub second: u32,
3005    pub microsecond: u32,
3006    pub timezone: Option<SemanticTimezone>,
3007}
3008
3009impl fmt::Display for SemanticTime {
3010    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3011        write!(f, "{:02}:{:02}:{:02}", self.hour, self.minute, self.second)?;
3012        if self.microsecond != 0 {
3013            write!(f, ".{:06}", self.microsecond)?;
3014        }
3015        if let Some(timezone) = &self.timezone {
3016            write!(f, "{}", timezone)?;
3017        }
3018        Ok(())
3019    }
3020}
3021
3022impl Serialize for SemanticTime {
3023    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
3024        serializer.serialize_str(&self.to_string())
3025    }
3026}
3027
3028impl<'de> Deserialize<'de> for SemanticTime {
3029    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
3030        let s = String::deserialize(deserializer)?;
3031        Self::from_str(&s).map_err(serde::de::Error::custom)
3032    }
3033}
3034
3035impl FromStr for SemanticTime {
3036    type Err = String;
3037
3038    fn from_str(s: &str) -> Result<Self, Self::Err> {
3039        Ok(time_to_semantic(&TimeValue::from_str(s)?))
3040    }
3041}
3042
3043/// Date-time for semantic values
3044#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3045pub struct SemanticDateTime {
3046    pub year: i32,
3047    pub month: u32,
3048    pub day: u32,
3049    pub hour: u32,
3050    pub minute: u32,
3051    pub second: u32,
3052    pub microsecond: u32,
3053    pub timezone: Option<SemanticTimezone>,
3054}
3055
3056impl fmt::Display for SemanticDateTime {
3057    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3058        let has_time = self.hour != 0
3059            || self.minute != 0
3060            || self.second != 0
3061            || self.microsecond != 0
3062            || self.timezone.is_some();
3063        if !has_time {
3064            write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
3065        } else {
3066            write!(
3067                f,
3068                "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
3069                self.year, self.month, self.day, self.hour, self.minute, self.second
3070            )?;
3071            if self.microsecond != 0 {
3072                write!(f, ".{:06}", self.microsecond)?;
3073            }
3074            if let Some(tz) = &self.timezone {
3075                write!(f, "{}", tz)?;
3076            }
3077            Ok(())
3078        }
3079    }
3080}
3081
3082impl Serialize for SemanticDateTime {
3083    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
3084        serializer.serialize_str(&self.to_string())
3085    }
3086}
3087
3088impl<'de> Deserialize<'de> for SemanticDateTime {
3089    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
3090        let s = String::deserialize(deserializer)?;
3091        Self::from_str(&s).map_err(serde::de::Error::custom)
3092    }
3093}
3094
3095impl FromStr for SemanticDateTime {
3096    type Err = String;
3097
3098    fn from_str(s: &str) -> Result<Self, Self::Err> {
3099        Ok(date_time_to_semantic(&DateTimeValue::from_str(s)?))
3100    }
3101}
3102
3103/// Default captured during type constraint application, before measure unit factors are final.
3104/// Converted into [`ValueKind`] after `resolve_measure_decompositions` (or immediately for
3105/// reference-local defaults, which run after that pass).
3106#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3107pub enum RawSuggestion {
3108    Value(ValueKind),
3109    /// Measure suggest/fill: magnitude in the written unit (canonicalized later).
3110    Measure {
3111        magnitude: RationalInteger,
3112        unit_name: String,
3113    },
3114    /// Ratio (or other) suggest/fill that already holds a canonical [`ValueKind`]
3115    /// plus the written unit name for display binding.
3116    UnitBound {
3117        value: ValueKind,
3118        unit_name: String,
3119    },
3120}
3121
3122/// Canonical suggestion/fill value plus the written measure unit when one was declared.
3123///
3124/// `unit` is the unit from `-> suggest 100 inr` / `-> fill 5 eur`; Show
3125/// display stamps it so the one-liner matches the written unit, not the type's first unit.
3126/// Shared via [`Arc`] so value-table clones bump a refcount instead of allocating.
3127/// For ranges this stays `None`; each endpoint carries its own unit on its type.
3128#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3129pub struct BoundValueKind {
3130    pub value: ValueKind,
3131    pub unit: Option<Arc<str>>,
3132}
3133
3134impl BoundValueKind {
3135    pub fn unbound(value: ValueKind) -> Self {
3136        Self { value, unit: None }
3137    }
3138
3139    pub fn with_binding(value: ValueKind, unit: Option<Arc<str>>) -> Self {
3140        Self { value, unit }
3141    }
3142
3143    pub fn to_literal(&self) -> LiteralValue {
3144        LiteralValue {
3145            value: self.value.clone(),
3146        }
3147    }
3148
3149    /// Written-unit agreement for ± on same-family measure/ratio: equal keep; one Some keep that;
3150    /// conflict → left wins.
3151    #[must_use]
3152    pub fn agree_or_left_binding(
3153        left: Option<&Arc<str>>,
3154        right: Option<&Arc<str>>,
3155    ) -> Option<Arc<str>> {
3156        match (left, right) {
3157            (Some(a), Some(b)) if a.as_ref() == b.as_ref() => Some(Arc::clone(a)),
3158            (Some(a), Some(_)) => Some(Arc::clone(a)),
3159            (Some(a), None) => Some(Arc::clone(a)),
3160            (None, Some(b)) => Some(Arc::clone(b)),
3161            (None, None) => None,
3162        }
3163    }
3164}
3165
3166pub fn bound_value_kind_from_raw_suggestion(
3167    raw: RawSuggestion,
3168    specifications: &TypeSpecification,
3169    type_name: &str,
3170) -> Result<BoundValueKind, String> {
3171    match raw {
3172        RawSuggestion::Value(vk) => Ok(BoundValueKind::unbound(vk)),
3173        RawSuggestion::UnitBound { value, unit_name } => Ok(BoundValueKind {
3174            value,
3175            unit: Some(Arc::from(unit_name)),
3176        }),
3177        RawSuggestion::Measure {
3178            magnitude,
3179            unit_name,
3180        } => {
3181            let TypeSpecification::Measure { units, .. } = specifications else {
3182                return Err(format!(
3183                    "BUG: RawSuggestion::Measure for non-measure type '{type_name}'"
3184                ));
3185            };
3186            let canonical = measure_declared_bound_to_canonical(
3187                &magnitude, &unit_name, units, type_name, "suggest",
3188            )?;
3189            Ok(BoundValueKind {
3190                value: ValueKind::Measure(canonical),
3191                unit: Some(Arc::from(unit_name)),
3192            })
3193        }
3194    }
3195}
3196
3197/// Display one-liner unit: `as` / schema > settled > fill > suggest > none (first declared).
3198///
3199/// Range schemas are skipped: endpoint units live on each endpoint type.
3200#[must_use]
3201pub fn display_binding_unit(
3202    schema: &LemmaType,
3203    settled: Option<&BoundValueKind>,
3204    fill: Option<&BoundValueKind>,
3205    suggestion: Option<&BoundValueKind>,
3206) -> Option<String> {
3207    if schema.is_measure_range() || schema.is_ratio_range() {
3208        return None;
3209    }
3210    if let Some(unit) = schema.unit() {
3211        return Some(unit.to_string());
3212    }
3213    if let Some(unit) = settled.and_then(|bound| bound.unit.as_ref()) {
3214        return Some(unit.to_string());
3215    }
3216    if let Some(unit) = fill.and_then(|bound| bound.unit.as_ref()) {
3217        return Some(unit.to_string());
3218    }
3219    suggestion
3220        .and_then(|bound| bound.unit.as_ref())
3221        .map(|unit| unit.to_string())
3222}
3223
3224/// Schema type stamped with [`display_binding_unit`] when a binding wins.
3225///
3226/// Clones only when the winning unit differs from what `schema` already carries.
3227/// Range types are returned unchanged.
3228#[must_use]
3229pub fn lemma_type_with_display_binding(
3230    schema: &LemmaType,
3231    settled: Option<&BoundValueKind>,
3232    fill: Option<&BoundValueKind>,
3233    suggestion: Option<&BoundValueKind>,
3234) -> LemmaType {
3235    if schema.is_measure_range() || schema.is_ratio_range() {
3236        return schema.clone();
3237    }
3238    match display_binding_unit(schema, settled, fill, suggestion) {
3239        Some(unit) if schema.unit() != Some(unit.as_str()) => schema.clone().with_unit(unit),
3240        Some(_) | None => schema.clone(),
3241    }
3242}
3243
3244/// Like [`lemma_type_with_display_binding`] but reuses `schema` when no override.
3245#[must_use]
3246pub fn lemma_type_arc_with_display_binding(
3247    schema: &Arc<LemmaType>,
3248    settled: Option<&BoundValueKind>,
3249    fill: Option<&BoundValueKind>,
3250    suggestion: Option<&BoundValueKind>,
3251) -> Arc<LemmaType> {
3252    if schema.is_measure_range() || schema.is_ratio_range() {
3253        return Arc::clone(schema);
3254    }
3255    match display_binding_unit(schema.as_ref(), settled, fill, suggestion) {
3256        Some(unit) if schema.unit() != Some(unit.as_str()) => {
3257            Arc::new(schema.as_ref().clone().with_unit(unit))
3258        }
3259        Some(_) | None => Arc::clone(schema),
3260    }
3261}
3262
3263/// Build a [`TypedLiteral`] from an eval bound and the plan type for that cell.
3264///
3265/// Stamps `bound.unit` onto measure/ratio types. Range values already carry
3266/// typed endpoints inside [`ValueKind::Range`].
3267#[must_use]
3268pub fn typed_literal_from_bound(
3269    bound: &BoundValueKind,
3270    lemma_type: &Arc<LemmaType>,
3271) -> TypedLiteral {
3272    let lemma_type = match (&bound.unit, &lemma_type.specifications) {
3273        (Some(unit), TypeSpecification::Measure { .. } | TypeSpecification::Ratio { .. })
3274            if lemma_type.unit() != Some(unit.as_ref()) =>
3275        {
3276            Arc::new(lemma_type.as_ref().clone().with_unit(unit.as_ref()))
3277        }
3278        _ => Arc::clone(lemma_type),
3279    };
3280    TypedLiteral {
3281        value: bound.value.clone(),
3282        lemma_type,
3283    }
3284}
3285
3286/// Value payload (shape of a literal).
3287///
3288/// Scalar variants are type-free: type lives on `NormalForm.result_type` /
3289/// `DataDefinition`. The [`ValueKind::Range`] arm is the exception — each
3290/// endpoint keeps a [`TypedLiteral`] so a mixed-unit range (`10 eur...20 usd`)
3291/// retains both written units.
3292#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3293pub enum ValueKind {
3294    Number(RationalInteger),
3295    /// Measure: magnitude in canonical (base-unit) space.
3296    ///
3297    /// At bind time the user-facing value is multiplied by `unit.factor` to produce
3298    /// the stored magnitude; API unit maps divide back. Unit/signature identity
3299    /// comes from the node/`DataDefinition` type via [`LemmaType::measure_runtime_signature`].
3300    Measure(RationalInteger),
3301    Text(String),
3302    Date(SemanticDateTime),
3303    Time(SemanticTime),
3304    Boolean(bool),
3305    /// Ratio: canonical magnitude. Display unit comes from the node/`DataDefinition` type.
3306    Ratio(RationalInteger),
3307    /// Range endpoints carry their own type (and written unit for measure/ratio).
3308    Range(Box<TypedLiteral>, Box<TypedLiteral>),
3309    /// A spec instance. The prefix is the `uses` path in the consumer plan.
3310    Spec(SpecInstance),
3311}
3312
3313impl ValueKind {
3314    /// Decimal magnitude for numeric variants (number, measure, ratio).
3315    pub fn as_decimal_magnitude(&self) -> Result<Decimal, String> {
3316        match self {
3317            ValueKind::Number(n) | ValueKind::Measure(n) | ValueKind::Ratio(n) => {
3318                n.try_to_decimal().map_err(|failure| failure.to_string())
3319            }
3320            other => Err(format!("expected numeric value kind, got {other}")),
3321        }
3322    }
3323
3324    /// Cheap structural byte-size estimate (no formatting, no rational→decimal).
3325    ///
3326    /// Used by resource-limit checks where the exact string length is not
3327    /// required — only an upper-bound within a constant factor.
3328    pub fn structural_byte_size(&self) -> usize {
3329        fn rational_byte_estimate(r: &RationalInteger) -> usize {
3330            let numer_bytes = (r.numer_magnitude_bits() as usize).div_ceil(8);
3331            let denom_bytes = (r.denom_magnitude_bits() as usize).div_ceil(8);
3332            numer_bytes.max(1) + denom_bytes.max(1)
3333        }
3334        match self {
3335            ValueKind::Number(r) => rational_byte_estimate(r),
3336            ValueKind::Measure(r) => rational_byte_estimate(r),
3337            ValueKind::Ratio(r) => rational_byte_estimate(r),
3338            ValueKind::Text(s) => s.len(),
3339            ValueKind::Date(_) => 30, // "2026-01-01T00:00:00+02:00" upper bound
3340            ValueKind::Time(_) => 12, // "23:59:59" upper bound
3341            ValueKind::Boolean(_) => 5, // "false"
3342            ValueKind::Range(left, right) => {
3343                left.value.structural_byte_size() + 3 + right.value.structural_byte_size()
3344            }
3345            ValueKind::Spec(instance) => instance.structural_byte_size(),
3346        }
3347    }
3348}
3349
3350fn format_rational_magnitude_for_display(rational: &RationalInteger) -> String {
3351    rational.display_str()
3352}
3353
3354fn format_number_with_unit_for_display(rational: &RationalInteger, unit: &str) -> String {
3355    use crate::parsing::ast::Value;
3356    match rational.try_to_decimal() {
3357        Ok(decimal) => format!("{}", Value::NumberWithUnit(decimal, unit.to_string())),
3358        Err(_) => format!("{} {}", rational.display_str(), unit),
3359    }
3360}
3361
3362impl fmt::Display for ValueKind {
3363    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3364        match self {
3365            ValueKind::Number(rational) => {
3366                write!(f, "{}", format_rational_magnitude_for_display(rational))
3367            }
3368            ValueKind::Measure(rational) => {
3369                write!(f, "{}", format_rational_magnitude_for_display(rational))
3370            }
3371            ValueKind::Text(s) => write!(f, "{}", crate::parsing::ast::Value::Text(s.clone())),
3372            ValueKind::Ratio(rational) => {
3373                write!(f, "{}", format_rational_magnitude_for_display(rational))
3374            }
3375            ValueKind::Date(dt) => write!(f, "{}", dt),
3376            ValueKind::Time(t) => write!(
3377                f,
3378                "{}",
3379                crate::parsing::ast::Value::Time(crate::parsing::ast::TimeValue {
3380                    hour: t.hour as u8,
3381                    minute: t.minute as u8,
3382                    second: t.second as u8,
3383                    microsecond: t.microsecond,
3384                    timezone: t
3385                        .timezone
3386                        .as_ref()
3387                        .map(|tz| crate::parsing::ast::TimezoneValue {
3388                            offset_hours: tz.offset_hours,
3389                            offset_minutes: tz.offset_minutes,
3390                        }),
3391                })
3392            ),
3393            ValueKind::Boolean(b) => write!(f, "{}", b),
3394            ValueKind::Range(left, right) => write!(f, "{}...{}", left, right),
3395            ValueKind::Spec(instance) => write!(f, "{instance}"),
3396        }
3397    }
3398}
3399
3400// -----------------------------------------------------------------------------
3401// Resolved path types (moved from parsing::ast)
3402// -----------------------------------------------------------------------------
3403
3404/// A single segment in a resolved path traversal
3405///
3406/// Used in both DataPath and RulePath for cross-spec traversal.
3407/// Each segment contains a `uses` alias, the resolved target repository name
3408/// (`repository`; `None` = unnamed workspace), and the resolved target spec
3409/// name (`spec`).
3410#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
3411pub struct PathSegment {
3412    /// The `uses` alias for this hop (binding key component).
3413    pub uses: String,
3414    /// Target repository name from `Arc<LemmaRepository>.name` at resolve.
3415    /// `None` is the unnamed workspace. Always encoded for postcard (no skip).
3416    pub repository: Option<String>,
3417    /// The spec this hop resolves to (resolved during planning)
3418    pub spec: String,
3419}
3420
3421/// One spec instance in a consumer plan: the `uses` hops that name it.
3422///
3423/// `basic` is one segment. A nested instance is the outer hops plus its own.
3424#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
3425pub struct SpecInstance {
3426    pub prefix: Vec<PathSegment>,
3427}
3428
3429impl SpecInstance {
3430    pub fn structural_byte_size(&self) -> usize {
3431        self.prefix
3432            .iter()
3433            .map(|segment| {
3434                segment.uses.len()
3435                    + segment.spec.len()
3436                    + segment.repository.as_ref().map_or(0, String::len)
3437            })
3438            .sum()
3439    }
3440}
3441
3442impl fmt::Display for SpecInstance {
3443    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3444        for (index, segment) in self.prefix.iter().enumerate() {
3445            if index > 0 {
3446                write!(f, ".")?;
3447            }
3448            write!(f, "{}", segment.uses)?;
3449        }
3450        Ok(())
3451    }
3452}
3453
3454/// Where a field read on one candidate instance lands.
3455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3456pub enum SpecMemberEnd {
3457    Rule(RulePath),
3458    Data(DataPath),
3459    /// The field is a nested `uses`. The value is that instance.
3460    Instance {
3461        instance: SpecInstance,
3462        identity: SpecIdentity,
3463    },
3464}
3465
3466/// One candidate instance of a [`ExpressionKind::SpecMember`], with the field it reads.
3467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3468pub struct SpecMemberTarget {
3469    pub instance: SpecInstance,
3470    pub end: SpecMemberEnd,
3471}
3472
3473/// Resolved path to a data (created during planning from AST DataReference)
3474///
3475/// Represents a fully resolved path through specs to reach a datum.
3476#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
3477pub struct DataPath {
3478    /// Path segments (each is a cross-spec step)
3479    pub segments: Vec<PathSegment>,
3480    /// Final data name
3481    pub data: String,
3482}
3483
3484impl DataPath {
3485    /// Create a data path from segments and data name (matches AST DataReference shape)
3486    pub fn new(segments: Vec<PathSegment>, data: String) -> Self {
3487        Self { segments, data }
3488    }
3489
3490    /// Create a local data path (no cross-spec steps)
3491    pub fn local(data: String) -> Self {
3492        Self {
3493            segments: vec![],
3494            data,
3495        }
3496    }
3497
3498    /// Dot-separated key used for matching user-provided data values (e.g. `"order.payment_method"`).
3499    /// Same string as [`Display`]: alias hops only, no repository or spec names.
3500    pub fn input_key(&self) -> String {
3501        let mut s = String::new();
3502        for segment in &self.segments {
3503            s.push_str(&segment.uses);
3504            s.push('.');
3505        }
3506        s.push_str(&self.data);
3507        s
3508    }
3509}
3510
3511/// Resolved path to a rule (created during planning from a rule reference).
3512///
3513/// Represents a fully resolved path through specs to reach a rule.
3514#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
3515pub struct RulePath {
3516    /// Path segments (each is a cross-spec step)
3517    pub segments: Vec<PathSegment>,
3518    /// Final rule name
3519    pub rule: String,
3520}
3521
3522impl RulePath {
3523    /// Create a rule path from segments and rule name.
3524    pub fn new(segments: Vec<PathSegment>, rule: String) -> Self {
3525        Self { segments, rule }
3526    }
3527
3528    /// Dot-separated key for Show/run (`alias.rule`), twin of [`DataPath::input_key`].
3529    /// Same string as [`Display`]: alias hops only, no repository or spec names.
3530    pub fn input_key(&self) -> String {
3531        if self.segments.is_empty() {
3532            return self.rule.clone();
3533        }
3534        let mut s = String::new();
3535        for segment in &self.segments {
3536            s.push_str(&segment.uses);
3537            s.push('.');
3538        }
3539        s.push_str(&self.rule);
3540        s
3541    }
3542}
3543
3544// -----------------------------------------------------------------------------
3545// Resolved expression types (created during planning)
3546// -----------------------------------------------------------------------------
3547
3548/// Resolved expression (all references resolved to paths, all literals typed)
3549///
3550/// Created during planning from AST Expression. All unresolved references
3551/// are converted to DataPath/RulePath, and all literals are typed.
3552#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3553pub struct Expression {
3554    pub kind: ExpressionKind,
3555    pub source_location: Option<Source>,
3556}
3557
3558impl Expression {
3559    /// Create an expression with an optional source location.
3560    pub fn with_source(kind: ExpressionKind, source_location: Option<Source>) -> Self {
3561        Self {
3562            kind,
3563            source_location,
3564        }
3565    }
3566
3567    /// Collect all DataPath references from this resolved expression tree
3568    pub fn collect_data_paths(&self, data: &mut std::collections::HashSet<DataPath>) {
3569        self.kind.collect_data_paths(data);
3570    }
3571}
3572
3573/// Resolved expression kind (only resolved variants, no unresolved references)
3574#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3575#[serde(rename_all = "snake_case")]
3576pub enum ExpressionKind {
3577    /// Resolved literal with type (boxed to keep enum small)
3578    Literal(Box<TypedLiteral>),
3579    /// Resolved data path
3580    DataPath(DataPath),
3581    /// Resolved rule path
3582    RulePath(RulePath),
3583    LogicalAnd(Arc<Expression>, Arc<Expression>),
3584    Arithmetic(Arc<Expression>, ArithmeticComputation, Arc<Expression>),
3585    Comparison(Arc<Expression>, ComparisonComputation, Arc<Expression>),
3586    UnitConversion(Arc<Expression>, SemanticConversionTarget),
3587    LogicalNegation(Arc<Expression>, NegationType),
3588    MathematicalComputation(MathematicalComputation, Arc<Expression>),
3589    Veto(VetoExpression),
3590    /// The `now` keyword — resolved at evaluation to the effective datetime.
3591    Now,
3592    /// Date-relative sugar: `<date_expr> in past` / `in future`
3593    DateRelative(DateRelativeKind, Arc<Expression>),
3594    /// Calendar-period sugar: `<date_expr> in [past|future] calendar year|month|week`
3595    DateCalendar(DateCalendarKind, CalendarPeriodUnit, Arc<Expression>),
3596    RangeLiteral(Arc<Expression>, Arc<Expression>),
3597    PastFutureRange(DateRelativeKind, Arc<Expression>),
3598    RangeContainment(Arc<Expression>, Arc<Expression>),
3599    /// Ordered endpoint of a range (`lower` included, `upper` excluded).
3600    RangeBound(RangeBound, Arc<Expression>),
3601    /// Whether evaluating the operand produced a veto (no value). Parses as `is veto` syntax.
3602    ResultIsVeto(Arc<Expression>),
3603    /// Field of a spec-valued rule: `top_bracket.tax`, or `top_bracket.finance.fee`.
3604    ///
3605    /// `targets` is filled after candidate instances of `base` are known. Each
3606    /// target is one instance the base can yield.
3607    SpecMember {
3608        base: RulePath,
3609        path: Vec<String>,
3610        name: String,
3611        targets: Vec<SpecMemberTarget>,
3612    },
3613    /// Unless structure: (condition, result) pairs in source order; last true condition wins.
3614    /// First arm is the default (condition is always-true literal).
3615    Piecewise(Vec<(Arc<Expression>, Arc<Expression>)>),
3616}
3617
3618impl ExpressionKind {
3619    /// Collect all DataPath references from this expression kind
3620    pub(crate) fn collect_data_paths(&self, data: &mut std::collections::HashSet<DataPath>) {
3621        match self {
3622            ExpressionKind::DataPath(fp) => {
3623                data.insert(fp.clone());
3624            }
3625            ExpressionKind::LogicalAnd(left, right) => {
3626                left.collect_data_paths(data);
3627                right.collect_data_paths(data);
3628            }
3629            ExpressionKind::Arithmetic(left, _, right)
3630            | ExpressionKind::Comparison(left, _, right)
3631            | ExpressionKind::RangeLiteral(left, right)
3632            | ExpressionKind::RangeContainment(left, right) => {
3633                left.collect_data_paths(data);
3634                right.collect_data_paths(data);
3635            }
3636            ExpressionKind::UnitConversion(inner, _)
3637            | ExpressionKind::LogicalNegation(inner, _)
3638            | ExpressionKind::MathematicalComputation(_, inner)
3639            | ExpressionKind::PastFutureRange(_, inner)
3640            | ExpressionKind::RangeBound(_, inner) => {
3641                inner.collect_data_paths(data);
3642            }
3643            ExpressionKind::DateRelative(_, date_expr) => {
3644                date_expr.collect_data_paths(data);
3645            }
3646            ExpressionKind::DateCalendar(_, _, date_expr) => {
3647                date_expr.collect_data_paths(data);
3648            }
3649            ExpressionKind::Literal(_)
3650            | ExpressionKind::RulePath(_)
3651            | ExpressionKind::Veto(_)
3652            | ExpressionKind::Now
3653            | ExpressionKind::SpecMember { .. } => {}
3654            ExpressionKind::ResultIsVeto(operand) => {
3655                operand.collect_data_paths(data);
3656            }
3657            ExpressionKind::Piecewise(arms) => {
3658                for (condition, result) in arms {
3659                    condition.collect_data_paths(data);
3660                    result.collect_data_paths(data);
3661                }
3662            }
3663        }
3664    }
3665}
3666
3667// -----------------------------------------------------------------------------
3668// Resolved types and values
3669// -----------------------------------------------------------------------------
3670
3671/// Where the custom extension chain is rooted: same spec as this type, or imported from another resolved spec.
3672#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
3673pub enum TypeDefiningSpec {
3674    /// Parent type is defined in the same spec as this type.
3675    Local,
3676    /// Parent type was resolved from types loaded from another spec.
3677    Import,
3678}
3679
3680/// What this type extends (primitive built-in or custom type by name).
3681#[derive(Clone, Debug, Serialize, Deserialize)]
3682pub enum TypeExtends {
3683    /// Extends a primitive built-in type (number, boolean, text, etc.)
3684    Primitive,
3685    /// Extends a custom type: parent is the immediate parent type name; family is the root of the extension chain (topmost custom type name).
3686    /// `defining_spec` records whether the parent chain is local or imported from another spec.
3687    Custom {
3688        parent: String,
3689        family: String,
3690        defining_spec: TypeDefiningSpec,
3691    },
3692}
3693
3694impl PartialEq for TypeExtends {
3695    fn eq(&self, other: &Self) -> bool {
3696        match (self, other) {
3697            (TypeExtends::Primitive, TypeExtends::Primitive) => true,
3698            (
3699                TypeExtends::Custom {
3700                    parent: lp,
3701                    family: lf,
3702                    defining_spec: ld,
3703                },
3704                TypeExtends::Custom {
3705                    parent: rp,
3706                    family: rf,
3707                    defining_spec: rd,
3708                },
3709            ) => lp == rp && lf == rf && ld == rd,
3710            _ => false,
3711        }
3712    }
3713}
3714
3715impl Eq for TypeExtends {}
3716
3717impl std::hash::Hash for TypeExtends {
3718    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
3719        match self {
3720            TypeExtends::Primitive => {
3721                0u8.hash(state);
3722            }
3723            TypeExtends::Custom {
3724                parent,
3725                family,
3726                defining_spec,
3727            } => {
3728                1u8.hash(state);
3729                parent.hash(state);
3730                family.hash(state);
3731                defining_spec.hash(state);
3732            }
3733        }
3734    }
3735}
3736
3737impl TypeExtends {
3738    /// Custom extension in the same spec as the defining type (no cross-spec import for the parent chain).
3739    #[must_use]
3740    pub fn custom_local(parent: String, family: String) -> Self {
3741        TypeExtends::Custom {
3742            parent,
3743            family,
3744            defining_spec: TypeDefiningSpec::Local,
3745        }
3746    }
3747
3748    /// Returns the parent type name if this type extends a custom type.
3749    #[must_use]
3750    pub fn parent_name(&self) -> Option<&str> {
3751        match self {
3752            TypeExtends::Primitive => None,
3753            TypeExtends::Custom { parent, .. } => Some(parent.as_str()),
3754        }
3755    }
3756}
3757
3758/// Resolved type after planning
3759///
3760/// Contains a type specification and optional name. Created during planning
3761/// from TypeSpecification in the AST.
3762#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
3763pub struct LemmaType {
3764    /// Optional type name (e.g., "age", "temperature")
3765    pub name: Option<String>,
3766    /// The type specification (Boolean, Number, Measure, etc.).
3767    pub specifications: TypeSpecification,
3768    /// What this type extends (primitive or custom from a spec)
3769    pub extends: TypeExtends,
3770}
3771
3772impl LemmaType {
3773    /// Functional update of the `Measure` payload (units + decomposition).
3774    /// Non-Measure variants pass through unchanged. The transform receives the owned
3775    /// units and decomposition and returns the replacements.
3776    pub fn map_measure<F>(self, f: F) -> Self
3777    where
3778        F: FnOnce(
3779            MeasureUnits,
3780            Option<BaseMeasureVector>,
3781        ) -> (MeasureUnits, Option<BaseMeasureVector>),
3782    {
3783        let LemmaType {
3784            name,
3785            specifications,
3786            extends,
3787        } = self;
3788        let specifications = match specifications {
3789            TypeSpecification::Measure {
3790                minimum,
3791                maximum,
3792                decimals,
3793                units,
3794                traits,
3795                decomposition,
3796                unit,
3797                help,
3798            } => {
3799                let (units, decomposition) = f(units, decomposition);
3800                TypeSpecification::Measure {
3801                    minimum,
3802                    maximum,
3803                    decimals,
3804                    units,
3805                    traits,
3806                    decomposition,
3807                    unit,
3808                    help,
3809                }
3810            }
3811            other => other,
3812        };
3813        LemmaType {
3814            name,
3815            specifications,
3816            extends,
3817        }
3818    }
3819
3820    /// Create a new type with a name
3821    pub fn new(name: String, specifications: TypeSpecification, extends: TypeExtends) -> Self {
3822        Self {
3823            name: Some(name),
3824            specifications,
3825            extends,
3826        }
3827    }
3828
3829    /// Create a type without a name (anonymous/inline type)
3830    pub fn without_name(specifications: TypeSpecification, extends: TypeExtends) -> Self {
3831        Self {
3832            name: None,
3833            specifications,
3834            extends,
3835        }
3836    }
3837
3838    /// Create a primitive type (no name, extends Primitive)
3839    pub fn primitive(specifications: TypeSpecification) -> Self {
3840        Self {
3841            name: None,
3842            specifications,
3843            extends: TypeExtends::Primitive,
3844        }
3845    }
3846
3847    /// Prefer `unit_name` for [`measure_runtime_signature`] (bind / signature-index hit).
3848    ///
3849    /// Writes `unit` onto `Measure` or `Ratio` specifications. Any other variant panics.
3850    #[must_use]
3851    pub fn with_unit(mut self, unit_name: impl Into<String>) -> Self {
3852        let unit_name = unit_name.into();
3853        match &mut self.specifications {
3854            TypeSpecification::Measure { unit, .. } | TypeSpecification::Ratio { unit, .. } => {
3855                *unit = Some(unit_name);
3856            }
3857            other => panic!(
3858                "BUG: with_unit called on non-measure/non-ratio type {}",
3859                other
3860            ),
3861        }
3862        self
3863    }
3864
3865    /// Written unit on a measure or ratio specification, if any.
3866    #[must_use]
3867    pub fn unit(&self) -> Option<&str> {
3868        match &self.specifications {
3869            TypeSpecification::Measure { unit, .. } | TypeSpecification::Ratio { unit, .. } => {
3870                unit.as_deref()
3871            }
3872            _ => None,
3873        }
3874    }
3875
3876    /// Get the type name, or a default based on the type specification
3877    pub fn name(&self) -> String {
3878        self.name
3879            .clone()
3880            .unwrap_or_else(|| self.specifications.to_string())
3881    }
3882
3883    /// Check if this type is boolean
3884    pub fn is_boolean(&self) -> bool {
3885        matches!(&self.specifications, TypeSpecification::Boolean { .. })
3886    }
3887
3888    pub fn matches_primitive_kind(&self, kind: PrimitiveKind) -> bool {
3889        matches!(
3890            (kind, &self.specifications),
3891            (PrimitiveKind::Number, TypeSpecification::Number { .. })
3892                | (PrimitiveKind::Text, TypeSpecification::Text { .. })
3893                | (PrimitiveKind::Boolean, TypeSpecification::Boolean { .. })
3894                | (PrimitiveKind::Date, TypeSpecification::Date { .. })
3895                | (PrimitiveKind::Time, TypeSpecification::Time { .. })
3896                | (PrimitiveKind::Ratio, TypeSpecification::Ratio { .. })
3897                | (PrimitiveKind::Measure, TypeSpecification::Measure { .. })
3898        )
3899    }
3900
3901    /// Check if this type is measure
3902    pub fn is_measure(&self) -> bool {
3903        matches!(&self.specifications, TypeSpecification::Measure { .. })
3904    }
3905
3906    pub fn is_measure_range(&self) -> bool {
3907        matches!(&self.specifications, TypeSpecification::MeasureRange { .. })
3908    }
3909
3910    /// Check if this type is number (dimensionless)
3911    pub fn is_number(&self) -> bool {
3912        matches!(&self.specifications, TypeSpecification::Number { .. })
3913    }
3914
3915    pub fn is_number_range(&self) -> bool {
3916        matches!(&self.specifications, TypeSpecification::NumberRange { .. })
3917    }
3918
3919    /// Check if this type is numeric (either measure or number)
3920    pub fn is_numeric(&self) -> bool {
3921        matches!(
3922            &self.specifications,
3923            TypeSpecification::Measure { .. } | TypeSpecification::Number { .. }
3924        )
3925    }
3926
3927    /// Check if this type is text
3928    pub fn is_text(&self) -> bool {
3929        matches!(&self.specifications, TypeSpecification::Text { .. })
3930    }
3931
3932    /// Check if this type is date
3933    pub fn is_date(&self) -> bool {
3934        matches!(&self.specifications, TypeSpecification::Date { .. })
3935    }
3936
3937    pub fn is_date_range(&self) -> bool {
3938        matches!(&self.specifications, TypeSpecification::DateRange { .. })
3939    }
3940
3941    pub fn is_time_range(&self) -> bool {
3942        matches!(&self.specifications, TypeSpecification::TimeRange { .. })
3943    }
3944
3945    /// Check if this type is time
3946    pub fn is_time(&self) -> bool {
3947        matches!(&self.specifications, TypeSpecification::Time { .. })
3948    }
3949
3950    pub fn has_trait_duration(&self) -> bool {
3951        match &self.specifications {
3952            TypeSpecification::Measure { traits, .. } => traits.contains(&MeasureTrait::Duration),
3953            _ => false,
3954        }
3955    }
3956
3957    pub fn is_duration_like_measure(&self) -> bool {
3958        if !self.is_measure() {
3959            return false;
3960        }
3961        if self.has_trait_duration() {
3962            return true;
3963        }
3964        self.is_anonymous_measure()
3965            && self
3966                .measure_type_decomposition()
3967                .is_some_and(|d| *d == duration_decomposition())
3968    }
3969
3970    pub fn is_duration_like(&self) -> bool {
3971        self.is_duration_like_measure()
3972    }
3973
3974    pub fn has_trait_calendar(&self) -> bool {
3975        match &self.specifications {
3976            TypeSpecification::Measure { traits, .. } => traits.contains(&MeasureTrait::Calendar),
3977            _ => false,
3978        }
3979    }
3980
3981    pub fn is_calendar_like_measure(&self) -> bool {
3982        if !self.is_measure() {
3983            return false;
3984        }
3985        if self.has_trait_calendar() {
3986            return true;
3987        }
3988        self.is_anonymous_measure()
3989            && self
3990                .measure_type_decomposition()
3991                .is_some_and(|d| *d == calendar_decomposition())
3992    }
3993
3994    pub fn is_calendar_like(&self) -> bool {
3995        self.is_calendar_like_measure()
3996    }
3997
3998    /// Check if this type is ratio
3999    pub fn is_ratio(&self) -> bool {
4000        matches!(&self.specifications, TypeSpecification::Ratio { .. })
4001    }
4002
4003    pub fn is_ratio_range(&self) -> bool {
4004        matches!(&self.specifications, TypeSpecification::RatioRange { .. })
4005    }
4006
4007    pub fn is_calendar_measure_range(&self) -> bool {
4008        matches!(
4009            &self.specifications,
4010            TypeSpecification::MeasureRange { decomposition: Some(decomposition), .. }
4011                if *decomposition == calendar_decomposition()
4012        )
4013    }
4014
4015    pub fn is_calendar_like_range(&self) -> bool {
4016        self.is_calendar_measure_range()
4017    }
4018
4019    pub fn is_range(&self) -> bool {
4020        matches!(
4021            &self.specifications,
4022            TypeSpecification::DateRange { .. }
4023                | TypeSpecification::TimeRange { .. }
4024                | TypeSpecification::NumberRange { .. }
4025                | TypeSpecification::MeasureRange { .. }
4026                | TypeSpecification::RatioRange { .. }
4027        )
4028    }
4029
4030    /// Check if this type is a spec instance.
4031    pub fn is_spec(&self) -> bool {
4032        matches!(&self.specifications, TypeSpecification::Spec { .. })
4033    }
4034
4035    /// Spec identity when this type is a spec instance.
4036    #[must_use]
4037    pub fn spec_identity(&self) -> Option<&SpecIdentity> {
4038        match &self.specifications {
4039            TypeSpecification::Spec { spec } => Some(spec),
4040            _ => None,
4041        }
4042    }
4043
4044    /// Check if this type is veto
4045    pub fn vetoed(&self) -> bool {
4046        matches!(&self.specifications, TypeSpecification::Veto { .. })
4047    }
4048
4049    /// True if this type is the undetermined sentinel (type could not be inferred).
4050    pub fn is_undetermined(&self) -> bool {
4051        matches!(&self.specifications, TypeSpecification::Undetermined)
4052    }
4053
4054    /// Check if two types have the same base type specification (ignoring constraints)
4055    pub fn has_same_base_type(&self, other: &LemmaType) -> bool {
4056        use TypeSpecification::*;
4057        matches!(
4058            (&self.specifications, &other.specifications),
4059            (Boolean { .. }, Boolean { .. })
4060                | (Number { .. }, Number { .. })
4061                | (NumberRange { .. }, NumberRange { .. })
4062                | (Measure { .. }, Measure { .. })
4063                | (MeasureRange { .. }, MeasureRange { .. })
4064                | (Text { .. }, Text { .. })
4065                | (Date { .. }, Date { .. })
4066                | (DateRange { .. }, DateRange { .. })
4067                | (Time { .. }, Time { .. })
4068                | (TimeRange { .. }, TimeRange { .. })
4069                | (Ratio { .. }, Ratio { .. })
4070                | (RatioRange { .. }, RatioRange { .. })
4071                | (Veto { .. }, Veto { .. })
4072                | (Undetermined, Undetermined)
4073                | (Spec { .. }, Spec { .. })
4074        )
4075    }
4076
4077    /// For measure types, returns the family name (root of the extension chain). For Custom extends, returns the family field; for Primitive, returns the type's own name (the type is the root). For non-measure types, returns None.
4078    #[must_use]
4079    pub fn measure_family_name(&self) -> Option<&str> {
4080        if !self.is_measure() {
4081            return None;
4082        }
4083        match &self.extends {
4084            TypeExtends::Custom { family, .. } => Some(family.as_str()),
4085            TypeExtends::Primitive => self.name.as_deref(),
4086        }
4087    }
4088
4089    /// For ratio types, returns the family name (root of the extension chain).
4090    #[must_use]
4091    pub fn ratio_family_name(&self) -> Option<&str> {
4092        if !self.is_ratio() {
4093            return None;
4094        }
4095        match &self.extends {
4096            TypeExtends::Custom { family, .. } => Some(family.as_str()),
4097            TypeExtends::Primitive => self.name.as_deref(),
4098        }
4099    }
4100
4101    /// Measure or ratio family root name.
4102    #[must_use]
4103    pub(crate) fn unit_family_name(&self) -> Option<&str> {
4104        self.measure_family_name()
4105            .or_else(|| self.ratio_family_name())
4106    }
4107
4108    /// Same base kind and, for measures / measure ranges, the same dimensions:
4109    /// the relation under which two types describe the same values. Names and
4110    /// constraints may differ. RatioRange stays base-kind only (endpoint policy).
4111    #[must_use]
4112    pub(crate) fn same_value_type(&self, other: &LemmaType) -> bool {
4113        if !self.has_same_base_type(other) {
4114            return false;
4115        }
4116        if self.is_measure() {
4117            return self.measure_type_decomposition() == other.measure_type_decomposition();
4118        }
4119        if let (
4120            TypeSpecification::Spec { spec: self_spec },
4121            TypeSpecification::Spec { spec: other_spec },
4122        ) = (&self.specifications, &other.specifications)
4123        {
4124            return self_spec == other_spec;
4125        }
4126        if self.is_measure_range() {
4127            let self_element = self
4128                .specifications
4129                .element_from_range()
4130                .expect("BUG: MeasureRange always defines element_from_range");
4131            let other_element = other
4132                .specifications
4133                .element_from_range()
4134                .expect("BUG: MeasureRange always defines element_from_range");
4135            match (&self_element, &other_element) {
4136                (
4137                    TypeSpecification::Measure {
4138                        decomposition: self_decomp,
4139                        ..
4140                    },
4141                    TypeSpecification::Measure {
4142                        decomposition: other_decomp,
4143                        ..
4144                    },
4145                ) => self_decomp == other_decomp,
4146                _ => unreachable!("BUG: element_from_range of MeasureRange yields Measure"),
4147            }
4148        } else {
4149            true
4150        }
4151    }
4152
4153    /// Returns true if both types are measure and belong to the same named measure family.
4154    #[must_use]
4155    pub fn same_measure_family(&self, other: &LemmaType) -> bool {
4156        if !self.is_measure() || !other.is_measure() {
4157            return false;
4158        }
4159        match (self.measure_family_name(), other.measure_family_name()) {
4160            (Some(self_family), Some(other_family)) => self_family == other_family,
4161            _ => false,
4162        }
4163    }
4164
4165    #[must_use]
4166    pub fn compatible_with_anonymous_measure(&self, other: &LemmaType) -> bool {
4167        if !self.is_measure() || !other.is_measure() {
4168            return false;
4169        }
4170        if !self.is_anonymous_measure() && !other.is_anonymous_measure() {
4171            return false;
4172        }
4173        match (
4174            self.measure_type_decomposition(),
4175            other.measure_type_decomposition(),
4176        ) {
4177            (Some(a), Some(b)) => a == b,
4178            _ => false,
4179        }
4180    }
4181
4182    /// Create a Veto LemmaType
4183    pub fn veto_type() -> Self {
4184        Self::primitive(TypeSpecification::veto())
4185    }
4186
4187    /// LemmaType sentinel for undetermined type (used during inference when a type cannot be determined).
4188    /// Propagates through expressions and is never present in a validated graph.
4189    pub fn undetermined_type() -> Self {
4190        Self::primitive(TypeSpecification::Undetermined)
4191    }
4192
4193    /// Decimal places for display (Number, Measure, and Ratio). Used by formatters.
4194    /// Ratio: optional, no default; when None display is normalized (no trailing zeros).
4195    pub fn decimal_places(&self) -> Option<u8> {
4196        match &self.specifications {
4197            TypeSpecification::Number { decimals, .. } => *decimals,
4198            TypeSpecification::Measure { decimals, .. } => *decimals,
4199            TypeSpecification::Ratio { decimals, .. } => *decimals,
4200            _ => None,
4201        }
4202    }
4203
4204    /// Convert a rational magnitude to an API [`Decimal`] (28-scale boundary).
4205    ///
4206    /// Applies this type's `decimal_places` when set. Returns [`NumericFailure::Overflow`]
4207    /// when |magnitude| > Decimal::MAX (callers map this to a decimal-limit Veto).
4208    pub fn try_rational_as_decimal(
4209        &self,
4210        magnitude: &crate::computation::rational::RationalInteger,
4211    ) -> Result<rust_decimal::Decimal, crate::computation::rational::NumericFailure> {
4212        let decimal = magnitude.try_to_decimal()?;
4213        Ok(format_decimal_for_api(decimal, self.decimal_places()))
4214    }
4215
4216    /// Convert a canonical measure magnitude in the named declared unit to an API [`Decimal`].
4217    pub fn try_measure_canonical_as_decimal_in_unit(
4218        &self,
4219        canonical_magnitude: &crate::computation::rational::RationalInteger,
4220        unit_name: &str,
4221    ) -> Result<rust_decimal::Decimal, crate::computation::rational::NumericFailure> {
4222        use crate::computation::rational::checked_div;
4223        let unit_factor = self.measure_unit_factor(unit_name);
4224        let magnitude_in_unit = checked_div(canonical_magnitude, unit_factor)?;
4225        self.try_rational_as_decimal(&magnitude_in_unit)
4226    }
4227
4228    /// Convert a canonical ratio magnitude in the named declared unit to an API [`Decimal`].
4229    pub fn try_ratio_canonical_as_decimal_in_unit(
4230        &self,
4231        canonical_magnitude: &crate::computation::rational::RationalInteger,
4232        unit_name: &str,
4233    ) -> Result<rust_decimal::Decimal, crate::computation::rational::NumericFailure> {
4234        use crate::computation::rational::checked_mul;
4235        let units = match &self.specifications {
4236            TypeSpecification::Ratio { units, .. } => units,
4237            _ => unreachable!(
4238                "BUG: try_ratio_canonical_as_decimal_in_unit called on non-ratio type {}",
4239                self.name()
4240            ),
4241        };
4242        let ratio_unit = units
4243            .iter()
4244            .find(|unit| unit.name == unit_name)
4245            .unwrap_or_else(|| {
4246                let valid: Vec<&str> = units.iter().map(|unit| unit.name.as_str()).collect();
4247                unreachable!(
4248                    "BUG: unknown ratio unit '{}' for type {} (valid: {}); planning must reject invalid units",
4249                    unit_name,
4250                    self.name(),
4251                    valid.join(", ")
4252                )
4253            });
4254        let magnitude_in_unit = checked_mul(canonical_magnitude, &ratio_unit.value)?;
4255        self.try_rational_as_decimal(&magnitude_in_unit)
4256    }
4257
4258    /// Get an example value string for this type, suitable for UI help text
4259    pub fn example_value(&self) -> &'static str {
4260        match &self.specifications {
4261            TypeSpecification::Text { .. } => "\"hello world\"",
4262            TypeSpecification::Measure { .. } => "12.50 eur",
4263            TypeSpecification::MeasureRange { .. } => "30 kilogram...35 kilogram",
4264            TypeSpecification::Number { .. } => "3.14",
4265            TypeSpecification::NumberRange { .. } => "0...100",
4266            TypeSpecification::Boolean { .. } => "true",
4267            TypeSpecification::Date { .. } => "2023-12-25T14:30:00Z",
4268            TypeSpecification::DateRange { .. } => "2024-01-01...2024-12-31",
4269            TypeSpecification::TimeRange { .. } => "09:00...17:00",
4270            TypeSpecification::Veto { .. } => "veto",
4271            TypeSpecification::Time { .. } => "14:30:00",
4272            TypeSpecification::Ratio { .. } => "50%",
4273            TypeSpecification::RatioRange { .. } => "10%...50%",
4274            TypeSpecification::Undetermined => unreachable!(
4275                "BUG: example_value called on Undetermined sentinel type; this type must never reach user-facing code"
4276            ),
4277            TypeSpecification::Spec { .. } => unreachable!(
4278                "BUG: example_value called on a spec type; spec instances are not prompted data"
4279            ),
4280        }
4281    }
4282
4283    /// Factor for a unit of this measure type (for unit conversion during evaluation only).
4284    /// Planning must validate conversions first and return Error for invalid units.
4285    /// If called with a non-measure type or unknown unit name, panics (invariant violation).
4286    #[must_use]
4287    /// Returns the resolved `BaseMeasureVector` for Measure types, or `None` if
4288    /// the decomposition pass has not yet resolved this type.
4289    /// Panics if called on non-Measure types.
4290    pub fn measure_type_decomposition(&self) -> Option<&BaseMeasureVector> {
4291        match &self.specifications {
4292            TypeSpecification::Measure { decomposition, .. } => decomposition.as_ref(),
4293            _ => unreachable!(
4294                "BUG: measure_type_decomposition called on non-measure type {}",
4295                self.name()
4296            ),
4297        }
4298    }
4299
4300    /// Runtime unit signature for measure arithmetic and display.
4301    ///
4302    /// - Named measure with units: `[(canonical_or_first_unit_name, 1)]`. Arithmetic
4303    ///   expands this via `expand_signature_to_base_units` using the unit table.
4304    /// - Anonymous / no units: decomposition converted to signature form.
4305    #[must_use]
4306    pub fn measure_runtime_signature(&self) -> Vec<(String, i32)> {
4307        if let Some(binding) = self.unit() {
4308            return vec![(binding.to_string(), 1)];
4309        }
4310        match &self.specifications {
4311            TypeSpecification::Measure {
4312                units,
4313                decomposition,
4314                ..
4315            } => {
4316                if let Some(canonical) = units.iter().find(|unit| unit.is_canonical_factor()) {
4317                    return vec![(canonical.name.clone(), 1)];
4318                }
4319                if let Some(first) = units.iter().next() {
4320                    return vec![(first.name.clone(), 1)];
4321                }
4322                decomposition
4323                    .as_ref()
4324                    .map(base_measure_vector_as_signature)
4325                    .unwrap_or_default()
4326            }
4327            TypeSpecification::MeasureRange {
4328                units,
4329                decomposition,
4330                ..
4331            } => {
4332                if let Some(canonical) = units.iter().find(|unit| unit.is_canonical_factor()) {
4333                    return vec![(canonical.name.clone(), 1)];
4334                }
4335                if let Some(first) = units.iter().next() {
4336                    return vec![(first.name.clone(), 1)];
4337                }
4338                decomposition
4339                    .as_ref()
4340                    .map(base_measure_vector_as_signature)
4341                    .unwrap_or_default()
4342            }
4343            _ => unreachable!(
4344                "BUG: measure_runtime_signature called on non-measure type {}",
4345                self.name()
4346            ),
4347        }
4348    }
4349
4350    /// Primary declared ratio unit name when the type carries a non-empty unit table.
4351    #[must_use]
4352    pub fn ratio_primary_unit(&self) -> Option<&str> {
4353        match &self.specifications {
4354            TypeSpecification::Ratio { units, .. } if !units.is_empty() => {
4355                units.iter().next().map(|unit| unit.name.as_str())
4356            }
4357            TypeSpecification::RatioRange { units, .. } if !units.is_empty() => {
4358                units.iter().next().map(|unit| unit.name.as_str())
4359            }
4360            _ => None,
4361        }
4362    }
4363
4364    /// Returns true if this is an anonymous (no-name) Measure — i.e. an anonymous
4365    /// intermediate produced by cross-axis arithmetic.
4366    pub fn is_anonymous_measure(&self) -> bool {
4367        self.name.is_none() && matches!(&self.specifications, TypeSpecification::Measure { .. })
4368    }
4369
4370    /// Build an anonymous `LemmaType` for a given dimensional decomposition.
4371    /// Used at plan time to represent the inferred type of cross-axis intermediates.
4372    /// Runtime unit signature is derived from this decomposition via
4373    /// [`LemmaType::measure_runtime_signature`].
4374    pub fn anonymous_for_decomposition(decomposition: BaseMeasureVector) -> Self {
4375        Self {
4376            name: None,
4377            specifications: TypeSpecification::Measure {
4378                minimum: None,
4379                maximum: None,
4380                decimals: None,
4381                units: crate::literals::MeasureUnits::new(),
4382                traits: Vec::new(),
4383                decomposition: Some(decomposition),
4384                unit: None,
4385                help: String::new(),
4386            },
4387            extends: TypeExtends::Primitive,
4388        }
4389    }
4390
4391    /// Declared ratio unit names when the type carries a non-empty unit table (`None` otherwise).
4392    #[must_use]
4393    pub fn ratio_unit_names(&self) -> Option<Vec<&str>> {
4394        match &self.specifications {
4395            TypeSpecification::Ratio { units, .. } if !units.is_empty() => {
4396                Some(units.iter().map(|unit| unit.name.as_str()).collect())
4397            }
4398            TypeSpecification::RatioRange { units, .. } if !units.is_empty() => {
4399                Some(units.iter().map(|unit| unit.name.as_str()).collect())
4400            }
4401            _ => None,
4402        }
4403    }
4404
4405    /// Declared unit names when the type carries a non-empty unit table (`None` otherwise).
4406    #[must_use]
4407    pub fn measure_unit_names(&self) -> Option<Vec<&str>> {
4408        match &self.specifications {
4409            TypeSpecification::Measure { units, .. } if !units.is_empty() => {
4410                Some(units.iter().map(|unit| unit.name.as_str()).collect())
4411            }
4412            TypeSpecification::MeasureRange { units, .. } if !units.is_empty() => {
4413                Some(units.iter().map(|unit| unit.name.as_str()).collect())
4414            }
4415            _ => None,
4416        }
4417    }
4418
4419    /// `age [number]` or `gender [gender_code]` — brackets omitted when type adds nothing.
4420    #[must_use]
4421    pub fn label_for_data_input(&self, input_key: &str) -> String {
4422        let type_label = if let Some(parent) = self.extends.parent_name() {
4423            parent.to_string()
4424        } else {
4425            let type_name = self.name();
4426            if type_name == input_key {
4427                self.specifications.to_string()
4428            } else {
4429                type_name
4430            }
4431        };
4432        if type_label == input_key {
4433            input_key.to_string()
4434        } else {
4435            format!("{input_key} [{type_label}]")
4436        }
4437    }
4438
4439    /// `Data age [number]: {detail}` for runtime data override vetoes.
4440    #[must_use]
4441    pub fn data_veto_message(&self, input_key: &str, detail: &str) -> String {
4442        format!("Data {}: {}", self.label_for_data_input(input_key), detail)
4443    }
4444
4445    /// Whether an empty [`RunDataValue`] should veto before parse (text may accept `""`).
4446    #[must_use]
4447    pub fn empty_runtime_input_vetoes(&self) -> bool {
4448        !matches!(self.specifications, TypeSpecification::Text { .. })
4449    }
4450
4451    /// Return the conversion factor for a declared unit name on this measure type.
4452    pub fn measure_unit_factor(
4453        &self,
4454        unit_name: &str,
4455    ) -> &crate::computation::rational::RationalInteger {
4456        let units = match &self.specifications {
4457            TypeSpecification::Measure { units, .. } => units,
4458            TypeSpecification::MeasureRange { units, .. } => units,
4459            _ => unreachable!(
4460                "BUG: measure_unit_factor called with non-measure type {}; only call during evaluation after planning validated measure conversion",
4461                self.name()
4462            ),
4463        };
4464        match units.get(unit_name) {
4465            Ok(MeasureUnit { factor, .. }) => factor,
4466            Err(_) => {
4467                let valid: Vec<&str> = units.iter().map(|u| u.name.as_str()).collect();
4468                unreachable!(
4469                    "BUG: unknown unit '{}' for measure type {} (valid: {}); planning must reject invalid conversions with Error",
4470                    unit_name,
4471                    self.name(),
4472                    valid.join(", ")
4473                );
4474            }
4475        }
4476    }
4477
4478    pub fn ratio_unit_factor(
4479        &self,
4480        unit_name: &str,
4481    ) -> &crate::computation::rational::RationalInteger {
4482        let units = match &self.specifications {
4483            TypeSpecification::Ratio { units, .. } => units,
4484            _ => unreachable!(
4485                "BUG: ratio_unit_factor called with non-ratio type {}; only call during evaluation after planning validated ratio conversion",
4486                self.name()
4487            ),
4488        };
4489        match units.get(unit_name) {
4490            Ok(RatioUnit { value, .. }) => value,
4491            Err(_) => {
4492                let valid: Vec<&str> = units.0.iter().map(|u| u.name.as_str()).collect();
4493                unreachable!(
4494                    "BUG: unknown unit '{}' for ratio type {} (valid: {}); planning must reject invalid conversions with Error",
4495                    unit_name,
4496                    self.name(),
4497                    valid.join(", ")
4498                );
4499            }
4500        }
4501    }
4502
4503    /// Convert a measure literal to API [`Decimal`]s in the given unit names.
4504    pub(crate) fn measure_literal_unit_map(
4505        &self,
4506        literal: &LiteralValue,
4507        unit_names: &[&str],
4508        factor_source: UnitFactorSource<'_>,
4509    ) -> Result<BTreeMap<String, rust_decimal::Decimal>, LiteralUnitMapFailure> {
4510        use crate::computation::rational::checked_div;
4511
4512        let ValueKind::Measure(magnitude) = &literal.value else {
4513            panic!("BUG: measure_literal_unit_map called with non-measure value");
4514        };
4515        let mut map = BTreeMap::new();
4516        for &unit_name in unit_names {
4517            let unit_factor = factor_source.measure_unit_factor(unit_name);
4518            let magnitude_in_unit = checked_div(magnitude, unit_factor)
4519                .map_err(LiteralUnitMapFailure::UnitConversion)?;
4520            let decimal = self
4521                .try_rational_as_decimal(&magnitude_in_unit)
4522                .map_err(LiteralUnitMapFailure::Commit)?;
4523            map.insert(unit_name.to_string(), decimal);
4524        }
4525        Ok(map)
4526    }
4527
4528    /// Convert a ratio literal to API [`Decimal`]s in the given unit names.
4529    pub(crate) fn ratio_literal_unit_map(
4530        &self,
4531        literal: &LiteralValue,
4532        unit_names: &[&str],
4533        factor_source: UnitFactorSource<'_>,
4534    ) -> Result<BTreeMap<String, rust_decimal::Decimal>, LiteralUnitMapFailure> {
4535        use crate::computation::rational::checked_mul;
4536
4537        let ratio_api_type = match &self.specifications {
4538            TypeSpecification::Ratio { .. } => self,
4539            TypeSpecification::RatioRange { .. } => {
4540                return ratio_element_type_for_api(self).ratio_literal_unit_map(
4541                    literal,
4542                    unit_names,
4543                    factor_source,
4544                );
4545            }
4546            _ => {
4547                panic!(
4548                    "BUG: ratio_literal_unit_map called with non-ratio type {}",
4549                    self.name()
4550                );
4551            }
4552        };
4553        let ValueKind::Ratio(canonical) = &literal.value else {
4554            panic!("BUG: ratio_literal_unit_map called with non-ratio value");
4555        };
4556        if unit_names.is_empty() {
4557            panic!(
4558                "BUG: ratio literal type '{}' must have at least one unit name",
4559                self.name()
4560            );
4561        }
4562        let mut map = BTreeMap::new();
4563        for &unit_name in unit_names {
4564            let unit_factor = factor_source.ratio_unit_factor(unit_name);
4565            let magnitude_in_unit = checked_mul(canonical, unit_factor)
4566                .map_err(LiteralUnitMapFailure::UnitConversion)?;
4567            let decimal = ratio_api_type
4568                .try_rational_as_decimal(&magnitude_in_unit)
4569                .map_err(LiteralUnitMapFailure::Commit)?;
4570            map.insert(unit_name.to_string(), decimal);
4571        }
4572        Ok(map)
4573    }
4574}
4575
4576/// Where to read measure/ratio unit factors when building a per-unit decimal map.
4577pub(crate) enum UnitFactorSource<'a> {
4578    DeclaredOn(&'a LemmaType),
4579    Merged {
4580        measure: Option<&'a MeasureUnits>,
4581        ratio: Option<&'a RatioUnits>,
4582    },
4583}
4584
4585impl UnitFactorSource<'_> {
4586    fn measure_unit_factor(
4587        &self,
4588        unit_name: &str,
4589    ) -> &crate::computation::rational::RationalInteger {
4590        match self {
4591            UnitFactorSource::DeclaredOn(lemma_type) => lemma_type.measure_unit_factor(unit_name),
4592            UnitFactorSource::Merged { measure, .. } => {
4593                let units = measure.unwrap_or_else(|| {
4594                    panic!(
4595                        "BUG: family measure expansion missing merged table for unit '{unit_name}'"
4596                    )
4597                });
4598                &units
4599                    .get(unit_name)
4600                    .unwrap_or_else(|_| {
4601                        panic!("BUG: family unit '{unit_name}' missing from merged measure table")
4602                    })
4603                    .factor
4604            }
4605        }
4606    }
4607
4608    fn ratio_unit_factor(&self, unit_name: &str) -> &crate::computation::rational::RationalInteger {
4609        match self {
4610            UnitFactorSource::DeclaredOn(lemma_type) => lemma_type.ratio_unit_factor(unit_name),
4611            UnitFactorSource::Merged { ratio, .. } => {
4612                let units = ratio.unwrap_or_else(|| {
4613                    panic!(
4614                        "BUG: family ratio expansion missing merged table for unit '{unit_name}'"
4615                    )
4616                });
4617                &units
4618                    .get(unit_name)
4619                    .unwrap_or_else(|_| {
4620                        panic!("BUG: family unit '{unit_name}' missing from merged ratio table")
4621                    })
4622                    .value
4623            }
4624        }
4625    }
4626}
4627
4628/// Primitive ratio type for API unit-map conversion on ratio range wrappers.
4629pub(crate) fn ratio_element_type_for_api(lemma_type: &LemmaType) -> LemmaType {
4630    match &lemma_type.specifications {
4631        TypeSpecification::Ratio { .. } => lemma_type.clone(),
4632        TypeSpecification::RatioRange { .. } => {
4633            let element = range_element_type_specification(&lemma_type.specifications)
4634                .expect("BUG: ratio range type must have ratio element specification");
4635            let TypeSpecification::Ratio {
4636                units, decimals, ..
4637            } = element
4638            else {
4639                panic!("BUG: ratio range element spec must be Ratio");
4640            };
4641            LemmaType::primitive(TypeSpecification::Ratio {
4642                minimum: None,
4643                maximum: None,
4644                decimals,
4645                units,
4646                unit: None,
4647                help: String::new(),
4648            })
4649        }
4650        _ => panic!(
4651            "BUG: ratio_element_type_for_api called with non-ratio type {}",
4652            lemma_type.name()
4653        ),
4654    }
4655}
4656
4657/// Failure while converting a literal to decimal strings across declared units.
4658#[derive(Debug, Clone, PartialEq, Eq)]
4659pub(crate) enum LiteralUnitMapFailure {
4660    Commit(crate::computation::rational::NumericFailure),
4661    UnitConversion(crate::computation::rational::NumericFailure),
4662}
4663
4664/// Runtime literal payload. Type lives on `NormalForm.result_type` / `DataDefinition` /
4665/// explicit computation parameters — not on the value.
4666#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
4667pub struct LiteralValue {
4668    pub value: ValueKind,
4669}
4670
4671impl LiteralValue {
4672    #[inline]
4673    pub fn new(value: ValueKind) -> Self {
4674        Self { value }
4675    }
4676
4677    fn single_measure_signature_unit_name(signature: &[(String, i32)]) -> Option<&str> {
4678        match signature {
4679            [(unit_name, 1)] => Some(unit_name.as_str()),
4680            _ => None,
4681        }
4682    }
4683}
4684
4685/// Planning-time literal: value plus resolved type.
4686///
4687/// Used by `ExpressionKind::Literal` and coerce/typing paths. Runtime
4688/// `OperationResult` / value-table slots store bare [`LiteralValue`]; the type
4689/// is on the node (`NormalForm.result_type`) or data definition.
4690#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
4691pub struct TypedLiteral {
4692    pub value: ValueKind,
4693    pub lemma_type: Arc<LemmaType>,
4694}
4695
4696impl TypedLiteral {
4697    #[inline]
4698    pub fn to_literal(&self) -> LiteralValue {
4699        LiteralValue {
4700            value: self.value.clone(),
4701        }
4702    }
4703
4704    #[inline]
4705    pub fn into_literal(self) -> LiteralValue {
4706        LiteralValue { value: self.value }
4707    }
4708}
4709
4710impl From<TypedLiteral> for LiteralValue {
4711    fn from(typed: TypedLiteral) -> Self {
4712        typed.into_literal()
4713    }
4714}
4715
4716impl From<&TypedLiteral> for LiteralValue {
4717    fn from(typed: &TypedLiteral) -> Self {
4718        typed.to_literal()
4719    }
4720}
4721
4722impl LiteralValue {
4723    pub fn text(s: String) -> Self {
4724        Self {
4725            value: ValueKind::Text(s),
4726        }
4727    }
4728
4729    pub fn number(n: RationalInteger) -> Self {
4730        Self {
4731            value: ValueKind::Number(n),
4732        }
4733    }
4734
4735    pub fn number_from_decimal(decimal: Decimal) -> Self {
4736        Self::number(
4737            crate::literals::rational_from_parsed_decimal(decimal)
4738                .expect("BUG: literal number from decimal must lift at boundary"),
4739        )
4740    }
4741
4742    pub fn measure(n: RationalInteger) -> Self {
4743        Self {
4744            value: ValueKind::Measure(n),
4745        }
4746    }
4747
4748    /// Type arg ignored — type lives on the node / caller.
4749    pub fn measure_with_type(n: RationalInteger, _lemma_type: Arc<LemmaType>) -> Self {
4750        Self::measure(n)
4751    }
4752
4753    pub fn measure_with_bound_unit(
4754        n: RationalInteger,
4755        _unit_name: impl Into<String>,
4756        _lemma_type: Arc<LemmaType>,
4757    ) -> Self {
4758        Self::measure(n)
4759    }
4760
4761    pub fn measure_with_signature(n: RationalInteger, _lemma_type: Arc<LemmaType>) -> Self {
4762        Self::measure(n)
4763    }
4764
4765    pub fn number_with_type(n: RationalInteger, _lemma_type: Arc<LemmaType>) -> Self {
4766        Self::number(n)
4767    }
4768
4769    pub fn number_with_type_from_decimal(decimal: Decimal, _lemma_type: Arc<LemmaType>) -> Self {
4770        Self::number_from_decimal(decimal)
4771    }
4772
4773    pub fn ratio_with_type(r: RationalInteger, _lemma_type: Arc<LemmaType>) -> Self {
4774        Self::ratio(r)
4775    }
4776
4777    pub fn ratio_with_bound_unit(
4778        r: RationalInteger,
4779        _unit_name: impl Into<String>,
4780        _lemma_type: Arc<LemmaType>,
4781    ) -> Self {
4782        Self::ratio(r)
4783    }
4784
4785    pub fn text_with_type(s: String, _lemma_type: Arc<LemmaType>) -> Self {
4786        Self::text(s)
4787    }
4788
4789    pub fn date_with_type(dt: SemanticDateTime, _lemma_type: Arc<LemmaType>) -> Self {
4790        Self::date(dt)
4791    }
4792
4793    pub fn time_with_type(t: SemanticTime, _lemma_type: Arc<LemmaType>) -> Self {
4794        Self::time(t)
4795    }
4796
4797    pub fn calendar(
4798        value: RationalInteger,
4799        _unit: SemanticCalendarUnit,
4800        _lemma_type: Arc<LemmaType>,
4801    ) -> Self {
4802        Self::measure(value)
4803    }
4804
4805    pub fn calendar_from_decimal(
4806        value: Decimal,
4807        unit: SemanticCalendarUnit,
4808        lemma_type: Arc<LemmaType>,
4809    ) -> Self {
4810        Self::calendar(
4811            crate::literals::rational_from_parsed_decimal(value)
4812                .expect("BUG: calendar literal from decimal must lift at boundary"),
4813            unit,
4814            lemma_type,
4815        )
4816    }
4817
4818    pub fn calendar_with_type(
4819        value: RationalInteger,
4820        unit: SemanticCalendarUnit,
4821        lemma_type: Arc<LemmaType>,
4822    ) -> Self {
4823        Self::calendar(value, unit, lemma_type)
4824    }
4825
4826    pub fn number_interpreted_as_measure(value: RationalInteger, _unit_name: String) -> Self {
4827        Self::measure(value)
4828    }
4829
4830    pub fn from_bool(b: bool) -> Self {
4831        Self {
4832            value: ValueKind::Boolean(b),
4833        }
4834    }
4835
4836    pub fn from_datetime(dt: &crate::parsing::ast::DateTimeValue) -> Self {
4837        Self::date(date_time_to_semantic(dt))
4838    }
4839
4840    pub fn date(dt: SemanticDateTime) -> Self {
4841        Self {
4842            value: ValueKind::Date(dt),
4843        }
4844    }
4845
4846    pub fn time(t: SemanticTime) -> Self {
4847        Self {
4848            value: ValueKind::Time(t),
4849        }
4850    }
4851
4852    pub fn ratio(r: RationalInteger) -> Self {
4853        Self {
4854            value: ValueKind::Ratio(r),
4855        }
4856    }
4857
4858    pub fn ratio_from_decimal(r: Decimal) -> Self {
4859        Self::ratio(
4860            crate::literals::rational_from_parsed_decimal(r)
4861                .expect("BUG: ratio literal from decimal must lift at boundary"),
4862        )
4863    }
4864
4865    pub fn range(left: TypedLiteral, right: TypedLiteral) -> Self {
4866        Self {
4867            value: ValueKind::Range(Box::new(left), Box::new(right)),
4868        }
4869    }
4870
4871    /// Display without an explicit type. Measure/ratio fall back to magnitude-only.
4872    pub fn display_value(&self) -> String {
4873        match &self.value {
4874            ValueKind::Measure(_) | ValueKind::Ratio(_) => format!("{}", self.value),
4875            ValueKind::Range(left, right) => {
4876                format!("{}...{}", left.display_value(), right.display_value())
4877            }
4878            _ => format!("{}", self.value),
4879        }
4880    }
4881
4882    /// Display string given an explicit type (measure/ratio need unit identity).
4883    pub fn display_value_with_type(&self, lemma_type: &LemmaType) -> String {
4884        match &self.value {
4885            ValueKind::Measure(n) => {
4886                let signature = lemma_type.measure_runtime_signature();
4887                format_measure_canonical_for_display(n, lemma_type, &signature)
4888            }
4889            ValueKind::Ratio(n) => format_ratio_canonical_for_display(n, lemma_type),
4890            ValueKind::Range(left, right) => {
4891                format!("{}...{}", left.display_value(), right.display_value())
4892            }
4893            _ => format!("{}", self.value),
4894        }
4895    }
4896
4897    /// Structural byte-size estimate for resource limit checks.
4898    pub fn byte_size(&self) -> usize {
4899        self.value.structural_byte_size()
4900    }
4901
4902    /// Magnitude for decimal input prompts (API [`Decimal`]; format at call site).
4903    #[must_use]
4904    pub fn magnitude_suggestion_for_decimal_prompt(
4905        &self,
4906        lemma_type: &LemmaType,
4907    ) -> Option<rust_decimal::Decimal> {
4908        match &self.value {
4909            ValueKind::Number(n) => Some(
4910                lemma_type
4911                    .try_rational_as_decimal(n)
4912                    .expect("BUG: stored number literal must convert to decimal for prompt"),
4913            ),
4914            ValueKind::Measure(n) => {
4915                let signature = lemma_type.measure_runtime_signature();
4916                let unit_name = Self::single_measure_signature_unit_name(&signature).expect(
4917                    "BUG: measure prompt requires exactly one signature unit with exponent 1",
4918                );
4919                Some(
4920                    lemma_type
4921                        .try_measure_canonical_as_decimal_in_unit(n, unit_name)
4922                        .expect("BUG: stored measure literal must convert to decimal for prompt"),
4923                )
4924            }
4925            ValueKind::Ratio(n) => {
4926                if let Some(unit_name) = lemma_type.unit() {
4927                    Some(
4928                        lemma_type
4929                            .try_ratio_canonical_as_decimal_in_unit(n, unit_name)
4930                            .expect("BUG: stored ratio literal must convert to decimal for prompt"),
4931                    )
4932                } else {
4933                    Some(lemma_type.try_rational_as_decimal(n).expect(
4934                        "BUG: stored bare ratio literal must convert to decimal for prompt",
4935                    ))
4936                }
4937            }
4938            _ => None,
4939        }
4940    }
4941
4942    /// Per-unit magnitudes when this literal is a measure with declared units.
4943    #[must_use]
4944    pub fn measure_units(
4945        &self,
4946        lemma_type: &LemmaType,
4947    ) -> Option<BTreeMap<String, rust_decimal::Decimal>> {
4948        if !matches!(self.value, ValueKind::Measure(_)) {
4949            return None;
4950        }
4951        lemma_type.measure_unit_names()?;
4952        let declared: Vec<&str> = lemma_type
4953            .measure_unit_names()
4954            .expect("BUG: measure_unit_names checked above");
4955        lemma_type
4956            .measure_literal_unit_map(self, &declared, UnitFactorSource::DeclaredOn(lemma_type))
4957            .ok()
4958    }
4959
4960    /// Per-unit magnitudes when this literal is a ratio with declared units.
4961    #[must_use]
4962    pub fn ratio_units(
4963        &self,
4964        lemma_type: &LemmaType,
4965    ) -> Option<BTreeMap<String, rust_decimal::Decimal>> {
4966        if !matches!(self.value, ValueKind::Ratio(_)) {
4967            return None;
4968        }
4969        let has_declared_units = match &lemma_type.specifications {
4970            TypeSpecification::Ratio { units, .. } => !units.is_empty(),
4971            TypeSpecification::RatioRange { .. } => true,
4972            _ => return None,
4973        };
4974        if !has_declared_units {
4975            return None;
4976        }
4977        let declared: Vec<&str> = lemma_type
4978            .ratio_unit_names()
4979            .expect("BUG: ratio units checked above");
4980        lemma_type
4981            .ratio_literal_unit_map(self, &declared, UnitFactorSource::DeclaredOn(lemma_type))
4982            .ok()
4983    }
4984
4985    /// Magnitude in a declared unit when this literal is measure or ratio.
4986    #[must_use]
4987    pub fn magnitude_in_unit(
4988        &self,
4989        lemma_type: &LemmaType,
4990        unit: &str,
4991    ) -> Option<rust_decimal::Decimal> {
4992        self.measure_units(lemma_type)
4993            .and_then(|map| map.get(unit).copied())
4994            .or_else(|| {
4995                self.ratio_units(lemma_type)
4996                    .and_then(|map| map.get(unit).copied())
4997            })
4998    }
4999
5000    /// Derive second from a duration measure's canonical magnitude.
5001    pub fn duration_canonical_seconds(&self, lemma_type: &LemmaType) -> RationalInteger {
5002        let ValueKind::Measure(magnitude) = &self.value else {
5003            unreachable!(
5004                "BUG: duration_canonical_seconds called with {:?}",
5005                self.value
5006            );
5007        };
5008        if !lemma_type.is_duration_like_measure() {
5009            unreachable!(
5010                "BUG: duration_canonical_seconds called with type {}",
5011                lemma_type.name()
5012            );
5013        }
5014        let factor = lemma_type.measure_unit_factor("second");
5015        checked_div(magnitude, factor).expect("BUG: duration unit factor cannot be zero")
5016    }
5017
5018    /// Derive month from a calendar measure's canonical magnitude.
5019    pub fn calendar_canonical_months(&self, lemma_type: &LemmaType) -> RationalInteger {
5020        let ValueKind::Measure(magnitude) = &self.value else {
5021            unreachable!(
5022                "BUG: calendar_canonical_months called with {:?}",
5023                self.value
5024            );
5025        };
5026        if !lemma_type.is_calendar_like() {
5027            unreachable!(
5028                "BUG: calendar_canonical_months called with type {}",
5029                lemma_type.name()
5030            );
5031        }
5032        let factor = lemma_type.measure_unit_factor("month");
5033        checked_div(magnitude, factor).expect("BUG: calendar unit factor cannot be zero")
5034    }
5035}
5036
5037impl TypedLiteral {
5038    pub fn text(s: String) -> Self {
5039        Self {
5040            value: ValueKind::Text(s),
5041            lemma_type: primitive_text_arc().clone(),
5042        }
5043    }
5044
5045    pub fn text_with_type(s: String, lemma_type: Arc<LemmaType>) -> Self {
5046        Self {
5047            value: ValueKind::Text(s),
5048            lemma_type,
5049        }
5050    }
5051
5052    pub fn number(n: RationalInteger) -> Self {
5053        Self {
5054            value: ValueKind::Number(n),
5055            lemma_type: primitive_number_arc().clone(),
5056        }
5057    }
5058
5059    pub fn number_from_decimal(decimal: Decimal) -> Self {
5060        Self::number(
5061            crate::literals::rational_from_parsed_decimal(decimal)
5062                .expect("BUG: literal number from decimal must lift at boundary"),
5063        )
5064    }
5065
5066    pub fn number_with_type(n: RationalInteger, lemma_type: Arc<LemmaType>) -> Self {
5067        Self {
5068            value: ValueKind::Number(n),
5069            lemma_type,
5070        }
5071    }
5072
5073    pub fn number_with_type_from_decimal(decimal: Decimal, lemma_type: Arc<LemmaType>) -> Self {
5074        Self::number_with_type(
5075            crate::literals::rational_from_parsed_decimal(decimal)
5076                .expect("BUG: literal number from decimal must lift at boundary"),
5077            lemma_type,
5078        )
5079    }
5080
5081    /// Build a Measure literal bound to a specific declared unit.
5082    pub fn measure_with_bound_unit(
5083        n: RationalInteger,
5084        unit_name: impl Into<String>,
5085        lemma_type: Arc<LemmaType>,
5086    ) -> Self {
5087        Self::measure_with_type(
5088            n,
5089            Arc::new(lemma_type.as_ref().clone().with_unit(unit_name)),
5090        )
5091    }
5092
5093    pub fn measure_with_type(n: RationalInteger, lemma_type: Arc<LemmaType>) -> Self {
5094        Self {
5095            value: ValueKind::Measure(n),
5096            lemma_type,
5097        }
5098    }
5099
5100    pub fn measure_with_signature(n: RationalInteger, lemma_type: Arc<LemmaType>) -> Self {
5101        Self {
5102            value: ValueKind::Measure(n),
5103            lemma_type,
5104        }
5105    }
5106
5107    /// Number interpreted as a measure value in the given unit.
5108    pub fn number_interpreted_as_measure(value: RationalInteger, unit_name: String) -> Self {
5109        let lemma_type = if unit_name.is_empty() {
5110            Arc::new(anonymous_measure_type())
5111        } else {
5112            let mut decomp = BaseMeasureVector::new();
5113            decomp.insert(unit_name, 1);
5114            Arc::new(LemmaType::anonymous_for_decomposition(decomp))
5115        };
5116        Self {
5117            value: ValueKind::Measure(value),
5118            lemma_type,
5119        }
5120    }
5121
5122    pub fn from_bool(b: bool) -> Self {
5123        Self {
5124            value: ValueKind::Boolean(b),
5125            lemma_type: primitive_boolean_arc().clone(),
5126        }
5127    }
5128
5129    pub fn from_datetime(dt: &crate::parsing::ast::DateTimeValue) -> Self {
5130        Self::date(date_time_to_semantic(dt))
5131    }
5132
5133    pub fn date(dt: SemanticDateTime) -> Self {
5134        Self {
5135            value: ValueKind::Date(dt),
5136            lemma_type: primitive_date_arc().clone(),
5137        }
5138    }
5139
5140    pub fn date_with_type(dt: SemanticDateTime, lemma_type: Arc<LemmaType>) -> Self {
5141        Self {
5142            value: ValueKind::Date(dt),
5143            lemma_type,
5144        }
5145    }
5146
5147    pub fn time(t: SemanticTime) -> Self {
5148        Self {
5149            value: ValueKind::Time(t),
5150            lemma_type: primitive_time_arc().clone(),
5151        }
5152    }
5153
5154    pub fn time_with_type(t: SemanticTime, lemma_type: Arc<LemmaType>) -> Self {
5155        Self {
5156            value: ValueKind::Time(t),
5157            lemma_type,
5158        }
5159    }
5160
5161    pub fn calendar(
5162        value: RationalInteger,
5163        unit: SemanticCalendarUnit,
5164        lemma_type: Arc<LemmaType>,
5165    ) -> Self {
5166        let unit_name = unit.to_string();
5167        debug_assert_eq!(
5168            semantic_calendar_unit_from_unit_name(&unit_name),
5169            unit,
5170            "BUG: calendar unit name must round-trip"
5171        );
5172        Self::measure_with_bound_unit(value, unit_name, lemma_type)
5173    }
5174
5175    pub fn calendar_from_decimal(
5176        value: Decimal,
5177        unit: SemanticCalendarUnit,
5178        lemma_type: Arc<LemmaType>,
5179    ) -> Self {
5180        Self::calendar(
5181            crate::literals::rational_from_parsed_decimal(value)
5182                .expect("BUG: calendar literal from decimal must lift at boundary"),
5183            unit,
5184            lemma_type,
5185        )
5186    }
5187
5188    pub fn calendar_with_type(
5189        value: RationalInteger,
5190        unit: SemanticCalendarUnit,
5191        lemma_type: Arc<LemmaType>,
5192    ) -> Self {
5193        Self::calendar(value, unit, lemma_type)
5194    }
5195
5196    pub fn ratio(r: RationalInteger) -> Self {
5197        Self {
5198            value: ValueKind::Ratio(r),
5199            lemma_type: primitive_ratio_arc().clone(),
5200        }
5201    }
5202
5203    pub fn ratio_from_decimal(r: Decimal) -> Self {
5204        Self::ratio(
5205            crate::literals::rational_from_parsed_decimal(r)
5206                .expect("BUG: ratio literal from decimal must lift at boundary"),
5207        )
5208    }
5209
5210    pub fn ratio_with_type(r: RationalInteger, lemma_type: Arc<LemmaType>) -> Self {
5211        Self {
5212            value: ValueKind::Ratio(r),
5213            lemma_type,
5214        }
5215    }
5216
5217    pub fn ratio_with_bound_unit(
5218        r: RationalInteger,
5219        unit_name: impl Into<String>,
5220        lemma_type: Arc<LemmaType>,
5221    ) -> Self {
5222        Self::ratio_with_type(
5223            r,
5224            Arc::new(lemma_type.as_ref().clone().with_unit(unit_name)),
5225        )
5226    }
5227
5228    pub fn range(left: TypedLiteral, right: TypedLiteral) -> Self {
5229        let specifications =
5230            range_type_specification_from_endpoints(&left.lemma_type, &right.lemma_type)
5231                .unwrap_or_else(|| {
5232                    unreachable!(
5233                "BUG: attempted to construct a range literal from incompatible endpoint types"
5234            )
5235                });
5236
5237        Self {
5238            value: ValueKind::Range(Box::new(left), Box::new(right)),
5239            lemma_type: Arc::new(LemmaType::primitive(specifications)),
5240        }
5241    }
5242
5243    pub fn display_value(&self) -> String {
5244        self.to_literal().display_value_with_type(&self.lemma_type)
5245    }
5246
5247    pub fn get_type(&self) -> &LemmaType {
5248        &self.lemma_type
5249    }
5250
5251    pub fn byte_size(&self) -> usize {
5252        self.value.structural_byte_size()
5253    }
5254}
5255
5256impl fmt::Display for TypedLiteral {
5257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5258        write!(f, "{}", self.display_value())
5259    }
5260}
5261
5262/// What a [`DataDefinition::Reference`] copies its value from: either another data path
5263/// or a rule whose result becomes this data's value.
5264#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
5265pub enum ReferenceTarget {
5266    Data(DataPath),
5267    Rule(RulePath),
5268}
5269
5270/// Where a [`DataDefinition::Reference`] chain ends. Computed once per slice in
5271/// [`crate::planning::graph::Graph::validate`] after `compute_data_reference_order`
5272/// rejected cycles.
5273#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
5274pub(crate) enum ReferenceEnd {
5275    Promptable(DataPath),
5276    Rule(RulePath),
5277    Import,
5278}
5279
5280/// Resolved data value for the execution plan: aligned with [`DataValue`] but with source per variant.
5281#[derive(Clone, Debug, Serialize, Deserialize)]
5282pub enum DataDefinition {
5283    /// Value-holding data: current literal (spec or `with` binding).
5284    Value {
5285        value: LiteralValue,
5286        resolved_type: Arc<LemmaType>,
5287        source: Source,
5288    },
5289    /// Type-only data: type known, value to be supplied (e.g. via run data).
5290    /// `declared_suggestion` carries the `-> suggest ...` payload for this binding or
5291    /// the suggestion inherited from the parent type chain, if any; value-promoting code
5292    /// uses it instead of re-deriving suggestions from [`TypeSpecification`].
5293    /// The evaluator never commits a suggestion — unbound stays MissingData.
5294    TypeDeclaration {
5295        resolved_type: Arc<LemmaType>,
5296        declared_suggestion: Option<BoundValueKind>,
5297        declared_fill: Option<BoundValueKind>,
5298        source: Source,
5299    },
5300    /// Import (`uses`): alias for another spec; nested members are flattened onto the plan.
5301    ///
5302    /// `repository` and `effective` are the resolved row, so a rule can return this
5303    /// alias as a spec value.
5304    Import {
5305        target_name: String,
5306        repository: Option<String>,
5307        effective: crate::parsing::ast::EffectiveDate,
5308        source: Source,
5309    },
5310    /// Value-copy reference to another data or a rule result.
5311    ///
5312    /// `resolved_type` is the merged type that the copied value must satisfy at
5313    /// evaluation time. Merging folds together: (1) the LHS's own declared type,
5314    /// if any; (2) the target's type (data declared type or rule return type);
5315    /// (3) any `local_constraints` written after the `->` on the reference itself.
5316    /// Merging happens in a dedicated pass once all data and rule types are
5317    /// known; before that pass, `resolved_type` holds a provisional value and
5318    /// must not be consumed for type checking.
5319    ///
5320    /// `local_constraints` preserves the raw constraint list from the reference's
5321    /// `-> ...` tail (e.g. `minimum 5` in `data license2: law.other -> minimum 5`)
5322    /// for that merging pass. It is `None` when the reference has no trailing
5323    /// constraints.
5324    ///
5325    /// `local_suggestion` carries any `suggest <value>` constraint from the
5326    /// reference's `-> ...` tail. The reference-merge pass extracts it from the
5327    /// constraint list during type resolution. It is a UI hint only
5328    /// ([`Self::suggestion`] / show); the evaluator never commits it — unbound
5329    /// stays MissingData.
5330    ///
5331    /// The reference itself is evaluated by copying the target's value (data path)
5332    /// or the target rule's result in topological order; caller values in
5333    /// [`crate::evaluation::run_data::RunData`] override the reference.
5334    Reference {
5335        target: ReferenceTarget,
5336        resolved_type: Arc<LemmaType>,
5337        local_constraints: Option<Vec<Constraint>>,
5338        local_suggestion: Option<BoundValueKind>,
5339        local_fill: Option<BoundValueKind>,
5340        source: Source,
5341    },
5342}
5343
5344impl DataDefinition {
5345    /// Declared lemma type for value, type-declaration, and reference data; `None` for imports.
5346    pub fn lemma_type(&self) -> Option<&LemmaType> {
5347        self.resolved_type_arc().map(|arc| arc.as_ref())
5348    }
5349
5350    /// Shared [`Arc`] for the declared type; `None` for imports.
5351    pub fn resolved_type_arc(&self) -> Option<&Arc<LemmaType>> {
5352        match self {
5353            DataDefinition::Value { resolved_type, .. } => Some(resolved_type),
5354            DataDefinition::TypeDeclaration { resolved_type, .. } => Some(resolved_type),
5355            DataDefinition::Reference { resolved_type, .. } => Some(resolved_type),
5356            DataDefinition::Import { .. } => None,
5357        }
5358    }
5359
5360    /// Alias for [`Self::lemma_type`] (historical name).
5361    #[inline]
5362    pub fn schema_type(&self) -> Option<&LemmaType> {
5363        self.lemma_type()
5364    }
5365
5366    /// Returns the literal value when the data already holds one, including `-> fill`
5367    /// on a type declaration or reference. A data-target `Reference`'s copied value
5368    /// is produced by the evaluator at runtime, so at plan-time it has no value yet.
5369    pub fn value(&self) -> Option<LiteralValue> {
5370        match self {
5371            DataDefinition::Value { value, .. } => Some(value.clone()),
5372            DataDefinition::TypeDeclaration {
5373                declared_fill: Some(dv),
5374                ..
5375            } => Some(dv.to_literal()),
5376            DataDefinition::Reference {
5377                local_fill: Some(dv),
5378                resolved_type: _,
5379                ..
5380            } => Some(dv.to_literal()),
5381            DataDefinition::TypeDeclaration { .. }
5382            | DataDefinition::Import { .. }
5383            | DataDefinition::Reference { .. } => None,
5384        }
5385    }
5386
5387    /// Fill value with optional written measure unit binding (Show display).
5388    pub(crate) fn bound_fill(&self) -> Option<&BoundValueKind> {
5389        match self {
5390            DataDefinition::TypeDeclaration {
5391                declared_fill: Some(dv),
5392                ..
5393            }
5394            | DataDefinition::Reference {
5395                local_fill: Some(dv),
5396                ..
5397            } => Some(dv),
5398            DataDefinition::Value { .. }
5399            | DataDefinition::TypeDeclaration { .. }
5400            | DataDefinition::Reference { .. }
5401            | DataDefinition::Import { .. } => None,
5402        }
5403    }
5404
5405    /// Suggestion with optional written measure unit binding (Show display).
5406    /// Surfaces in [`crate::planning::execution_plan::ShowData::suggestion`] for
5407    /// show/response/UI; the evaluator never commits it — unbound stays MissingData.
5408    pub(crate) fn bound_suggestion(&self) -> Option<&BoundValueKind> {
5409        match self {
5410            DataDefinition::TypeDeclaration {
5411                declared_suggestion: Some(dv),
5412                ..
5413            }
5414            | DataDefinition::Reference {
5415                local_suggestion: Some(dv),
5416                ..
5417            } => Some(dv),
5418            DataDefinition::Value { .. }
5419            | DataDefinition::TypeDeclaration { .. }
5420            | DataDefinition::Reference { .. }
5421            | DataDefinition::Import { .. } => None,
5422        }
5423    }
5424
5425    /// Returns the source location for this data.
5426    pub fn source(&self) -> &Source {
5427        match self {
5428            DataDefinition::Value { source, .. } => source,
5429            DataDefinition::TypeDeclaration { source, .. } => source,
5430            DataDefinition::Import { source, .. } => source,
5431            DataDefinition::Reference { source, .. } => source,
5432        }
5433    }
5434}
5435
5436/// Bind a type-agnostic [`Value::NumberWithUnit`] using the unit index entry for `unit_name`.
5437pub fn number_with_unit_to_value_kind(
5438    magnitude: rust_decimal::Decimal,
5439    unit_name: &str,
5440    lemma_type: &LemmaType,
5441) -> Result<ValueKind, String> {
5442    match &lemma_type.specifications {
5443        TypeSpecification::Ratio { units, .. } => {
5444            use crate::computation::rational::{checked_div, decimal_to_rational};
5445            let unit = units.get(unit_name)?;
5446            let magnitude_rational = decimal_to_rational(magnitude)
5447                .map_err(|failure| format!("ratio literal failed rational lift: {failure}"))?;
5448            let canonical_rational = checked_div(&magnitude_rational, &unit.value)
5449                .map_err(|failure| format!("ratio literal: unit conversion failed: {failure}"))?;
5450            Ok(ValueKind::Ratio(canonical_rational))
5451        }
5452        TypeSpecification::Measure { units, .. } => {
5453            use crate::computation::rational::checked_mul;
5454            let rational = lift_parser_decimal(magnitude)?;
5455            let unit = units.get(unit_name)?;
5456            let canonical = checked_mul(&rational, &unit.factor)
5457                .map_err(|failure| format!("measure canonicalization overflow: {failure}"))?;
5458            Ok(ValueKind::Measure(canonical))
5459        }
5460        _ => Err(format!(
5461            "Unit '{}' is defined on type '{}' which is not measure or ratio",
5462            unit_name,
5463            lemma_type.name()
5464        )),
5465    }
5466}
5467
5468/// Whether a [`ValueKind`] is structurally compatible with a [`TypeSpecification`].
5469/// Bound validation (min/max/decimals) is separate; this only checks shape.
5470pub(crate) fn value_kind_matches_spec(value: &ValueKind, type_spec: &TypeSpecification) -> bool {
5471    matches!(
5472        (type_spec, value),
5473        (TypeSpecification::Number { .. }, ValueKind::Number(_))
5474            | (TypeSpecification::Text { .. }, ValueKind::Text(_))
5475            | (TypeSpecification::Boolean { .. }, ValueKind::Boolean(_))
5476            | (TypeSpecification::Date { .. }, ValueKind::Date(_))
5477            | (TypeSpecification::Time { .. }, ValueKind::Time(_))
5478            | (TypeSpecification::Measure { .. }, ValueKind::Measure(_))
5479            | (TypeSpecification::Ratio { .. }, ValueKind::Ratio(_))
5480            | (TypeSpecification::Ratio { .. }, ValueKind::Number(_))
5481            | (
5482                TypeSpecification::NumberRange { .. },
5483                ValueKind::Range(_, _)
5484            )
5485            | (TypeSpecification::DateRange { .. }, ValueKind::Range(_, _))
5486            | (TypeSpecification::TimeRange { .. }, ValueKind::Range(_, _))
5487            | (TypeSpecification::RatioRange { .. }, ValueKind::Range(_, _))
5488            | (
5489                TypeSpecification::MeasureRange { .. },
5490                ValueKind::Range(_, _)
5491            )
5492            | (TypeSpecification::Veto { .. }, _)
5493            | (TypeSpecification::Undetermined, _)
5494            | (TypeSpecification::Spec { .. }, ValueKind::Spec(_))
5495    )
5496}
5497
5498fn parser_value_type_mismatch(
5499    value: &crate::literals::Value,
5500    type_spec: &TypeSpecification,
5501) -> String {
5502    use crate::parsing::ast::AsLemmaSource;
5503    let value_str = format!("{}", AsLemmaSource(value));
5504    match type_spec {
5505        TypeSpecification::Measure { units, .. } => {
5506            let unit_hint = units
5507                .iter()
5508                .find(|u| u.factor == crate::computation::rational::rational_one())
5509                .map(|u| u.name.as_str())
5510                .or_else(|| units.iter().next().map(|u| u.name.as_str()))
5511                .unwrap_or("unit");
5512            format!("cannot use {value_str} as {type_spec}: expected `<n> {unit_hint}`")
5513        }
5514        TypeSpecification::Ratio { units, .. } if !units.is_empty() => {
5515            let unit_hint = units
5516                .iter()
5517                .next()
5518                .map(|u| u.name.as_str())
5519                .unwrap_or("unit");
5520            format!(
5521                "cannot use {value_str} as {type_spec}: expected `<n> {unit_hint}` or bare ratio"
5522            )
5523        }
5524        _ => format!("cannot use {value_str} as {type_spec}"),
5525    }
5526}
5527
5528/// Re-canonicalize a measure literal after compound unit factors were resolved.
5529///
5530/// Literals parsed before derived unit resolution were canonicalized with prefix-only
5531/// factors; multiply by `resolved_factor / stored_factor` to align with final factors.
5532pub fn refresh_measure_literal_canonical_magnitude(
5533    lit: &mut LiteralValue,
5534    previous_type: &LemmaType,
5535    resolved_type: &LemmaType,
5536) -> Result<(), crate::computation::rational::NumericFailure> {
5537    use crate::computation::rational::{checked_div, checked_mul};
5538    let ValueKind::Measure(magnitude) = &mut lit.value else {
5539        return Ok(());
5540    };
5541    // Binding unit is no longer on ValueKind; magnitude was canonicalized at bind time
5542    // against the then-current unit table. When a single-term runtime signature unit
5543    // exists on both tables, rescale if that unit's factor changed.
5544    let signature = previous_type.measure_runtime_signature();
5545    let Some((unit_name, 1)) = signature.first().map(|(n, e)| (n.as_str(), *e)) else {
5546        return Ok(());
5547    };
5548    if signature.len() != 1 {
5549        return Ok(());
5550    }
5551    let stored_factor = previous_type.measure_unit_factor(unit_name);
5552    let resolved_factor = resolved_type.measure_unit_factor(unit_name);
5553    if stored_factor == resolved_factor {
5554        return Ok(());
5555    }
5556    let scaled = checked_mul(magnitude, resolved_factor)?;
5557    *magnitude = checked_div(&scaled, stored_factor)?;
5558    Ok(())
5559}
5560
5561/// Convert parser [`Value`] to [`ValueKind`] using the target type (canonicalizes ratio at bind).
5562///
5563/// When `resolve_unit` is set, qualified range-endpoint unit tokens are resolved to bare
5564/// declared names before bind.
5565pub fn parser_value_to_value_kind(
5566    value: &crate::literals::Value,
5567    type_spec: &TypeSpecification,
5568    resolve_unit: Option<&UnitTokenResolver<'_>>,
5569) -> Result<ValueKind, String> {
5570    use crate::computation::rational::decimal_to_rational;
5571    use crate::literals::Value;
5572    match (value, type_spec) {
5573        (Value::NumberWithUnit(magnitude, unit_name), TypeSpecification::Ratio { units, .. }) => {
5574            use crate::computation::rational::checked_div;
5575            let unit = units.get(unit_name.as_str())?;
5576            let magnitude_rational = decimal_to_rational(*magnitude)
5577                .map_err(|failure| format!("ratio literal failed rational lift: {failure}"))?;
5578            let canonical_rational = checked_div(&magnitude_rational, &unit.value)
5579                .map_err(|failure| format!("ratio literal: unit conversion failed: {failure}"))?;
5580            Ok(ValueKind::Ratio(canonical_rational))
5581        }
5582        (Value::NumberWithUnit(magnitude, unit_name), TypeSpecification::Measure { units, .. }) => {
5583            use crate::computation::rational::checked_mul;
5584            let rational = lift_parser_decimal(*magnitude)?;
5585            let unit = units.get(unit_name.as_str())?;
5586            let canonical = checked_mul(&rational, &unit.factor)
5587                .map_err(|failure| format!("measure canonicalization overflow: {failure}"))?;
5588            Ok(ValueKind::Measure(canonical))
5589        }
5590        (Value::NumberWithUnit(_, _), _) => {
5591            Err("number_with_unit literal requires a measure or ratio type".to_string())
5592        }
5593        (Value::Number(n), TypeSpecification::Number { .. }) => {
5594            Ok(ValueKind::Number(lift_parser_decimal(*n)?))
5595        }
5596        (Value::Number(n), TypeSpecification::Ratio { .. }) => {
5597            let r = decimal_to_rational(*n)
5598                .map_err(|failure| format!("ratio literal failed rational lift: {failure}"))?;
5599            Ok(ValueKind::Ratio(r))
5600        }
5601        (Value::Text(s), TypeSpecification::Text { .. }) => Ok(ValueKind::Text(s.clone())),
5602        (Value::Boolean(b), TypeSpecification::Boolean { .. }) => Ok(ValueKind::Boolean(b.into())),
5603        (Value::Date(dt), TypeSpecification::Date { .. }) => {
5604            Ok(ValueKind::Date(date_time_to_semantic(dt)))
5605        }
5606        (Value::Time(t), TypeSpecification::Time { .. }) => {
5607            Ok(ValueKind::Time(time_to_semantic(t)))
5608        }
5609        (
5610            Value::Range(left, right),
5611            range_spec @ (TypeSpecification::NumberRange { .. }
5612            | TypeSpecification::DateRange { .. }
5613            | TypeSpecification::TimeRange { .. }
5614            | TypeSpecification::RatioRange { .. }
5615            | TypeSpecification::MeasureRange { .. }),
5616        ) => {
5617            let endpoint = range_element_type_specification(range_spec).ok_or_else(|| {
5618                "BUG: range_element_type_specification missing arm for range type".to_string()
5619            })?;
5620            let left_lit = lift_range_endpoint(left, &endpoint, resolve_unit)?;
5621            let right_lit = lift_range_endpoint(right, &endpoint, resolve_unit)?;
5622            Ok(ValueKind::Range(Box::new(left_lit), Box::new(right_lit)))
5623        }
5624        (value, type_spec) => Err(parser_value_type_mismatch(value, type_spec)),
5625    }
5626}
5627
5628/// Convert parser Value to ValueKind for primitives and ranges only.
5629///
5630/// [`Value::NumberWithUnit`] requires [`parser_value_to_value_kind`] with a measure or ratio type.
5631pub fn value_to_semantic(value: &crate::parsing::ast::Value) -> Result<ValueKind, String> {
5632    use crate::parsing::ast::Value;
5633    Ok(match value {
5634        Value::Number(n) => ValueKind::Number(lift_parser_decimal(*n)?),
5635        Value::Text(s) => ValueKind::Text(s.clone()),
5636        Value::Boolean(b) => ValueKind::Boolean(bool::from(*b)),
5637        Value::Date(dt) => ValueKind::Date(date_time_to_semantic(dt)),
5638        Value::Time(t) => ValueKind::Time(time_to_semantic(t)),
5639        Value::NumberWithUnit(_, _) => {
5640            return Err(
5641                "number_with_unit literal requires type context (measure or ratio)".to_string(),
5642            );
5643        }
5644        Value::Range(_, _) => literal_value_from_parser_value(value)?.value,
5645    })
5646}
5647
5648/// Convert AST date-time to semantic (for tests and planning).
5649pub(crate) fn date_time_to_semantic(dt: &crate::parsing::ast::DateTimeValue) -> SemanticDateTime {
5650    SemanticDateTime {
5651        year: dt.year,
5652        month: dt.month,
5653        day: dt.day,
5654        hour: dt.hour,
5655        minute: dt.minute,
5656        second: dt.second,
5657        microsecond: dt.microsecond,
5658        timezone: dt.timezone.as_ref().map(|tz| SemanticTimezone {
5659            offset_hours: tz.offset_hours,
5660            offset_minutes: tz.offset_minutes,
5661        }),
5662    }
5663}
5664
5665/// Convert AST time to semantic (for tests and planning).
5666pub(crate) fn time_to_semantic(t: &crate::parsing::ast::TimeValue) -> SemanticTime {
5667    SemanticTime {
5668        hour: t.hour.into(),
5669        minute: t.minute.into(),
5670        second: t.second.into(),
5671        microsecond: t.microsecond,
5672        timezone: t.timezone.as_ref().map(|tz| SemanticTimezone {
5673            offset_hours: tz.offset_hours,
5674            offset_minutes: tz.offset_minutes,
5675        }),
5676    }
5677}
5678
5679/// Compare two semantic date-time values by year, month, day, hour, minute,
5680/// second, then microsecond. Timezone normalisation is a separate concern
5681/// handled at evaluation time.
5682pub(crate) fn compare_semantic_dates(
5683    left: &SemanticDateTime,
5684    right: &SemanticDateTime,
5685) -> std::cmp::Ordering {
5686    left.year
5687        .cmp(&right.year)
5688        .then_with(|| left.month.cmp(&right.month))
5689        .then_with(|| left.day.cmp(&right.day))
5690        .then_with(|| left.hour.cmp(&right.hour))
5691        .then_with(|| left.minute.cmp(&right.minute))
5692        .then_with(|| left.second.cmp(&right.second))
5693        .then_with(|| left.microsecond.cmp(&right.microsecond))
5694}
5695
5696/// Compare two semantic time values by hour, minute, second, then microsecond.
5697/// Timezone is excluded for the same reason as [`compare_semantic_dates`].
5698pub(crate) fn compare_semantic_times(
5699    left: &SemanticTime,
5700    right: &SemanticTime,
5701) -> std::cmp::Ordering {
5702    left.hour
5703        .cmp(&right.hour)
5704        .then_with(|| left.minute.cmp(&right.minute))
5705        .then_with(|| left.second.cmp(&right.second))
5706        .then_with(|| left.microsecond.cmp(&right.microsecond))
5707}
5708
5709/// Convert AST conversion target to semantic (planning boundary; evaluation/computation use only semantic).
5710pub fn conversion_target_to_semantic(
5711    ct: &ConversionTarget,
5712    unit_index: Option<&crate::planning::unit_index::UnitIndex>,
5713    resolved_types: Option<&IndexMap<String, Arc<LemmaType>>>,
5714) -> Result<SemanticConversionTarget, String> {
5715    match ct {
5716        ConversionTarget::Type(kind) => Ok(SemanticConversionTarget::Type(*kind)),
5717        ConversionTarget::Unit { unit_name } => {
5718            let index = unit_index.ok_or_else(|| format!("Unknown unit '{unit_name}'."))?;
5719            let (bare, owning_type) = match resolved_types {
5720                Some(resolved) => index.resolve_with_named_types(unit_name, resolved)?,
5721                None => index.resolve(unit_name)?,
5722            };
5723            Ok(SemanticConversionTarget::Unit {
5724                unit_name: bare,
5725                owning_type,
5726            })
5727        }
5728    }
5729}
5730
5731// -----------------------------------------------------------------------------
5732// Primitive type constructors (moved from parsing::ast)
5733// -----------------------------------------------------------------------------
5734
5735// Statics for lazy initialization of production-used primitive types.
5736static PRIMITIVE_BOOLEAN: OnceLock<Arc<LemmaType>> = OnceLock::new();
5737static PRIMITIVE_NUMBER: OnceLock<Arc<LemmaType>> = OnceLock::new();
5738static PRIMITIVE_TEXT: OnceLock<Arc<LemmaType>> = OnceLock::new();
5739static PRIMITIVE_DATE: OnceLock<Arc<LemmaType>> = OnceLock::new();
5740static PRIMITIVE_DATE_RANGE: OnceLock<Arc<LemmaType>> = OnceLock::new();
5741static PRIMITIVE_TIME: OnceLock<Arc<LemmaType>> = OnceLock::new();
5742static PRIMITIVE_RATIO: OnceLock<Arc<LemmaType>> = OnceLock::new();
5743
5744#[must_use]
5745pub fn primitive_boolean_arc() -> &'static Arc<LemmaType> {
5746    PRIMITIVE_BOOLEAN.get_or_init(|| Arc::new(LemmaType::primitive(TypeSpecification::boolean())))
5747}
5748
5749#[must_use]
5750pub fn primitive_number_arc() -> &'static Arc<LemmaType> {
5751    PRIMITIVE_NUMBER.get_or_init(|| Arc::new(LemmaType::primitive(TypeSpecification::number())))
5752}
5753
5754#[must_use]
5755pub fn primitive_text_arc() -> &'static Arc<LemmaType> {
5756    PRIMITIVE_TEXT.get_or_init(|| Arc::new(LemmaType::primitive(TypeSpecification::text())))
5757}
5758
5759#[must_use]
5760pub fn primitive_date_arc() -> &'static Arc<LemmaType> {
5761    PRIMITIVE_DATE.get_or_init(|| Arc::new(LemmaType::primitive(TypeSpecification::date())))
5762}
5763
5764#[must_use]
5765pub fn primitive_date_range_arc() -> &'static Arc<LemmaType> {
5766    PRIMITIVE_DATE_RANGE
5767        .get_or_init(|| Arc::new(LemmaType::primitive(TypeSpecification::date_range())))
5768}
5769
5770#[must_use]
5771pub fn primitive_time_arc() -> &'static Arc<LemmaType> {
5772    PRIMITIVE_TIME.get_or_init(|| Arc::new(LemmaType::primitive(TypeSpecification::time())))
5773}
5774
5775#[must_use]
5776pub fn primitive_ratio_arc() -> &'static Arc<LemmaType> {
5777    PRIMITIVE_RATIO.get_or_init(|| Arc::new(LemmaType::primitive(TypeSpecification::ratio())))
5778}
5779
5780/// Map PrimitiveKind to TypeSpecification. Single source of truth for primitive type resolution.
5781#[must_use]
5782pub fn type_spec_for_primitive(kind: PrimitiveKind) -> TypeSpecification {
5783    match kind {
5784        PrimitiveKind::Boolean => TypeSpecification::boolean(),
5785        PrimitiveKind::Measure => TypeSpecification::measure(),
5786        PrimitiveKind::MeasureRange => TypeSpecification::measure_range(),
5787        PrimitiveKind::Number => TypeSpecification::number(),
5788        PrimitiveKind::NumberRange => TypeSpecification::number_range(),
5789        PrimitiveKind::Ratio => TypeSpecification::ratio(),
5790        PrimitiveKind::RatioRange => TypeSpecification::ratio_range(),
5791        PrimitiveKind::Text => TypeSpecification::text(),
5792        PrimitiveKind::Date => TypeSpecification::date(),
5793        PrimitiveKind::DateRange => TypeSpecification::date_range(),
5794        PrimitiveKind::Time => TypeSpecification::time(),
5795        PrimitiveKind::TimeRange => TypeSpecification::time_range(),
5796    }
5797}
5798
5799// -----------------------------------------------------------------------------
5800// Display implementations
5801// -----------------------------------------------------------------------------
5802
5803impl fmt::Display for PathSegment {
5804    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5805        match &self.repository {
5806            Some(repository) => write!(f, "{} → {} {}", self.uses, repository, self.spec),
5807            None => write!(f, "{} → {}", self.uses, self.spec),
5808        }
5809    }
5810}
5811
5812impl fmt::Display for DataPath {
5813    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5814        write!(f, "{}", self.input_key())
5815    }
5816}
5817
5818impl fmt::Display for RulePath {
5819    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5820        write!(f, "{}", self.input_key())
5821    }
5822}
5823
5824impl fmt::Display for LemmaType {
5825    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5826        write!(f, "{}", self.name())
5827    }
5828}
5829
5830pub(crate) fn format_decimal_for_api(
5831    decimal: rust_decimal::Decimal,
5832    decimal_places: Option<u8>,
5833) -> rust_decimal::Decimal {
5834    match decimal_places {
5835        Some(decimal_places) => {
5836            let places = u32::from(decimal_places);
5837            let mut rounded = decimal.round_dp(places);
5838            rounded.rescale(places);
5839            rounded
5840        }
5841        None => {
5842            let normalized = decimal.normalize();
5843            if normalized.fract().is_zero() {
5844                normalized.trunc()
5845            } else {
5846                normalized
5847            }
5848        }
5849    }
5850}
5851
5852fn format_decimal_for_human_display(
5853    decimal: rust_decimal::Decimal,
5854    decimal_places: Option<u8>,
5855) -> String {
5856    match decimal_places {
5857        Some(decimal_places) => {
5858            let rounded = decimal.round_dp(u32::from(decimal_places));
5859            format!("{:.prec$}", rounded, prec = decimal_places as usize)
5860        }
5861        None => decimal.normalize().to_string(),
5862    }
5863}
5864
5865fn format_rational_for_human_display(
5866    magnitude: &crate::computation::rational::RationalInteger,
5867    decimal_places: Option<u8>,
5868) -> String {
5869    match magnitude.try_to_decimal() {
5870        Ok(decimal) => format_decimal_for_human_display(decimal, decimal_places),
5871        Err(crate::computation::rational::NumericFailure::Overflow) => magnitude.display_str(),
5872        Err(_) => magnitude.display_str(),
5873    }
5874}
5875
5876fn format_measure_canonical_for_display(
5877    canonical: &crate::computation::rational::RationalInteger,
5878    lemma_type: &LemmaType,
5879    signature: &[(String, i32)],
5880) -> String {
5881    use crate::computation::rational::checked_div;
5882
5883    let decimals = lemma_type.decimal_places();
5884
5885    if let TypeSpecification::Measure { units, .. } = &lemma_type.specifications {
5886        if !units.is_empty() {
5887            // Binding (`as` / suggest / fill) must name a declared unit; otherwise first declared.
5888            let unit = match lemma_type.unit() {
5889                Some(binding) => units
5890                    .iter()
5891                    .find(|unit| unit.name == binding)
5892                    .unwrap_or_else(|| {
5893                        panic!(
5894                            "BUG: measure display binding '{binding}' is not a declared unit on type {}",
5895                            lemma_type.name()
5896                        )
5897                    }),
5898                None => units
5899                    .iter()
5900                    .next()
5901                    .expect("BUG: measure type with non-empty units must have a first unit"),
5902            };
5903            let in_unit = checked_div(canonical, &unit.factor)
5904                .expect("BUG: de-canonicalization for measure display must not fail");
5905            let formatted = format_rational_for_human_display(&in_unit, decimals);
5906            return format!("{} {}", formatted, unit.name);
5907        }
5908    }
5909
5910    let unit_label = match signature {
5911        [] => String::new(),
5912        [(name, 1)] => name.clone(),
5913        _ => format_signature_operator_style(signature),
5914    };
5915    let formatted = format_rational_for_human_display(canonical, decimals);
5916    if unit_label.is_empty() {
5917        formatted
5918    } else {
5919        format!("{formatted} {unit_label}")
5920    }
5921}
5922
5923fn format_ratio_canonical_for_display(
5924    canonical: &crate::computation::rational::RationalInteger,
5925    lemma_type: &LemmaType,
5926) -> String {
5927    use crate::computation::rational::{checked_mul, rational_new};
5928
5929    let display_unit = lemma_type
5930        .unit()
5931        .or_else(|| lemma_type.ratio_primary_unit());
5932
5933    match display_unit {
5934        Some("percent") => match checked_mul(canonical, &rational_new(100, 1)) {
5935            Ok(scaled) => format_number_with_unit_for_display(&scaled, "percent"),
5936            Err(_) => format!("{} percent", canonical.display_str()),
5937        },
5938        Some("permille") => match checked_mul(canonical, &rational_new(1000, 1)) {
5939            Ok(scaled) => format_number_with_unit_for_display(&scaled, "permille"),
5940            Err(_) => format!("{} permille", canonical.display_str()),
5941        },
5942        Some(unit_name) => format_number_with_unit_for_display(canonical, unit_name),
5943        None => format_rational_magnitude_for_display(canonical),
5944    }
5945}
5946
5947impl fmt::Display for LiteralValue {
5948    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5949        // Measure/ratio display needs an explicit type; callers must use
5950        // `display_value_with_type`. Bare Display only covers type-free kinds and
5951        // ranges of those kinds.
5952        match &self.value {
5953            ValueKind::Measure(_) | ValueKind::Ratio(_) => {
5954                write!(f, "{}", self.value)
5955            }
5956            ValueKind::Range(left, right) => write!(f, "{}...{}", left, right),
5957            _ => write!(f, "{}", self.value),
5958        }
5959    }
5960}
5961
5962// -----------------------------------------------------------------------------
5963// Tests
5964// -----------------------------------------------------------------------------
5965
5966#[cfg(test)]
5967pub(crate) mod tests {
5968    use super::*;
5969    use crate::computation::rational::decimal_to_rational;
5970    use crate::literals::DateGranularity;
5971    use crate::literals::Value;
5972    use crate::parsing::ast::{BooleanValue, DateTimeValue, PrimitiveKind, TimeValue};
5973    use rust_decimal::Decimal;
5974    use std::str::FromStr;
5975    use std::sync::{Arc, OnceLock};
5976
5977    static PRIMITIVE_MEASURE: OnceLock<Arc<LemmaType>> = OnceLock::new();
5978
5979    #[must_use]
5980    pub(crate) fn primitive_measure_arc() -> &'static Arc<LemmaType> {
5981        PRIMITIVE_MEASURE
5982            .get_or_init(|| Arc::new(LemmaType::primitive(TypeSpecification::measure())))
5983    }
5984
5985    #[must_use]
5986    pub(crate) fn primitive_measure() -> &'static LemmaType {
5987        primitive_measure_arc().as_ref()
5988    }
5989
5990    #[test]
5991    fn default_primitive_help_is_goal_oriented() {
5992        let kinds = [
5993            PrimitiveKind::Boolean,
5994            PrimitiveKind::Measure,
5995            PrimitiveKind::MeasureRange,
5996            PrimitiveKind::Number,
5997            PrimitiveKind::NumberRange,
5998            PrimitiveKind::Ratio,
5999            PrimitiveKind::RatioRange,
6000            PrimitiveKind::Text,
6001            PrimitiveKind::Date,
6002            PrimitiveKind::DateRange,
6003            PrimitiveKind::Time,
6004            PrimitiveKind::TimeRange,
6005        ];
6006        for kind in kinds {
6007            let spec = type_spec_for_primitive(kind);
6008            let help = match &spec {
6009                TypeSpecification::Boolean { help, .. }
6010                | TypeSpecification::Number { help, .. }
6011                | TypeSpecification::NumberRange { help, .. }
6012                | TypeSpecification::Text { help, .. }
6013                | TypeSpecification::Measure { help, .. }
6014                | TypeSpecification::MeasureRange { help, .. }
6015                | TypeSpecification::Ratio { help, .. }
6016                | TypeSpecification::RatioRange { help, .. }
6017                | TypeSpecification::Date { help, .. }
6018                | TypeSpecification::DateRange { help, .. }
6019                | TypeSpecification::TimeRange { help, .. }
6020                | TypeSpecification::Time { help, .. } => help,
6021                TypeSpecification::Veto { .. }
6022                | TypeSpecification::Undetermined
6023                | TypeSpecification::Spec { .. } => {
6024                    unreachable!(
6025                        "BUG: primitive kind {:?} mapped to non-primitive spec",
6026                        kind
6027                    )
6028                }
6029            };
6030            assert!(!help.is_empty(), "help for {:?}", kind);
6031            assert!(
6032                !help.to_ascii_lowercase().contains("format:"),
6033                "help for {:?} must not describe syntax: {:?}",
6034                kind,
6035                help
6036            );
6037            assert_eq!(help, default_help_for_primitive(kind));
6038        }
6039    }
6040
6041    #[test]
6042    fn data_path_and_rule_path_display_are_input_key() {
6043        let implicit = PathSegment {
6044            uses: "bag".to_string(),
6045            repository: None,
6046            spec: "bag".to_string(),
6047        };
6048        let explicit = PathSegment {
6049            uses: "bag".to_string(),
6050            repository: None,
6051            spec: "nut_bag".to_string(),
6052        };
6053        let named_repo = PathSegment {
6054            uses: "calc".to_string(),
6055            repository: Some("alpha".to_string()),
6056            spec: "pricing".to_string(),
6057        };
6058
6059        assert_eq!(
6060            DataPath::new(vec![implicit.clone()], "weight".to_string()).to_string(),
6061            "bag.weight"
6062        );
6063        assert_eq!(
6064            DataPath::new(vec![explicit.clone()], "weight".to_string()).to_string(),
6065            "bag.weight"
6066        );
6067        assert_eq!(
6068            DataPath::new(vec![named_repo.clone()], "price".to_string()).to_string(),
6069            "calc.price"
6070        );
6071        assert_eq!(
6072            RulePath::new(vec![implicit], "cost".to_string()).to_string(),
6073            "bag.cost"
6074        );
6075        assert_eq!(
6076            RulePath::new(vec![explicit], "cost".to_string()).to_string(),
6077            "bag.cost"
6078        );
6079        assert_eq!(
6080            RulePath::new(vec![named_repo], "price".to_string()).to_string(),
6081            "calc.price"
6082        );
6083
6084        assert_eq!(
6085            PathSegment {
6086                uses: "bag".to_string(),
6087                repository: None,
6088                spec: "nut_bag".to_string(),
6089            }
6090            .to_string(),
6091            "bag → nut_bag"
6092        );
6093        assert_eq!(
6094            PathSegment {
6095                uses: "calc".to_string(),
6096                repository: Some("alpha".to_string()),
6097                spec: "pricing".to_string(),
6098            }
6099            .to_string(),
6100            "calc → alpha pricing"
6101        );
6102    }
6103
6104    #[test]
6105    fn test_negated_comparison() {
6106        assert_eq!(
6107            negated_comparison(ComparisonComputation::LessThan),
6108            ComparisonComputation::GreaterThanOrEqual
6109        );
6110        assert_eq!(
6111            negated_comparison(ComparisonComputation::GreaterThanOrEqual),
6112            ComparisonComputation::LessThan
6113        );
6114        assert_eq!(
6115            negated_comparison(ComparisonComputation::Is),
6116            ComparisonComputation::IsNot
6117        );
6118        assert_eq!(
6119            negated_comparison(ComparisonComputation::IsNot),
6120            ComparisonComputation::Is
6121        );
6122    }
6123
6124    #[test]
6125    fn value_to_semantic_number_is_decimal() {
6126        let kind = value_to_semantic(&Value::Number(Decimal::from(42))).unwrap();
6127        assert!(matches!(kind, ValueKind::Number(d) if d == rational_new(42, 1)));
6128    }
6129
6130    #[test]
6131    fn value_kind_measure_serializes_magnitude_only() {
6132        let kind =
6133            ValueKind::Measure(decimal_to_rational(Decimal::from_str("99.50").unwrap()).unwrap());
6134        let json = serde_json::to_value(crate::api::ValueKind::from(&kind)).unwrap();
6135        assert_eq!(json["measure"]["value"], "99.5");
6136        assert!(json["measure"].get("signature").is_none());
6137    }
6138
6139    #[test]
6140    fn value_kind_measure_roundtrips() {
6141        let original =
6142            ValueKind::Measure(decimal_to_rational(Decimal::from_str("4800").unwrap()).unwrap());
6143        let api = crate::api::ValueKind::from(&original);
6144        let json = serde_json::to_string(&api).unwrap();
6145        let parsed: crate::api::ValueKind = serde_json::from_str(&json).unwrap();
6146        assert_eq!(api, parsed);
6147    }
6148
6149    #[test]
6150    fn value_kind_measure_empty_roundtrips() {
6151        let original =
6152            ValueKind::Measure(decimal_to_rational(Decimal::from_str("12.5").unwrap()).unwrap());
6153        let api = crate::api::ValueKind::from(&original);
6154        let json = serde_json::to_string(&api).unwrap();
6155        let parsed: crate::api::ValueKind = serde_json::from_str(&json).unwrap();
6156        assert_eq!(api, parsed);
6157    }
6158
6159    #[test]
6160    fn literal_value_number_serde_not_rational_array() {
6161        let lit = LiteralValue::number_from_decimal(Decimal::from(20));
6162        let json = serde_json::to_value(crate::api::LiteralValue::from(&lit)).unwrap();
6163        let number = json
6164            .get("value")
6165            .and_then(|v| v.get("number"))
6166            .expect("number field");
6167        assert!(number.is_string());
6168        assert_eq!(number.as_str(), Some("20"));
6169        assert!(
6170            !number.is_array(),
6171            "stored number must not serialize as [n,d]"
6172        );
6173    }
6174
6175    #[test]
6176    fn test_literal_value_to_primitive_type() {
6177        let one = rational_new(1, 1);
6178
6179        assert_eq!(TypedLiteral::text("".to_string()).lemma_type.name(), "text");
6180        assert_eq!(
6181            TypedLiteral::number(one.clone()).lemma_type.name(),
6182            "number"
6183        );
6184        assert_eq!(
6185            TypedLiteral::from_bool(bool::from(BooleanValue::True))
6186                .lemma_type
6187                .name(),
6188            "boolean"
6189        );
6190
6191        let dt = DateTimeValue {
6192            year: 2024,
6193            month: 1,
6194            day: 1,
6195            hour: 0,
6196            minute: 0,
6197            second: 0,
6198            microsecond: 0,
6199            timezone: None,
6200
6201            granularity: DateGranularity::Full,
6202        };
6203        assert_eq!(
6204            TypedLiteral::date(date_time_to_semantic(&dt))
6205                .lemma_type
6206                .name(),
6207            "date"
6208        );
6209        assert_eq!(
6210            TypedLiteral::ratio_from_decimal(Decimal::new(1, 2))
6211                .lemma_type
6212                .name(),
6213            "ratio"
6214        );
6215        let dur_type = LemmaType::new(
6216            "duration".to_string(),
6217            TypeSpecification::Measure {
6218                minimum: None,
6219                maximum: None,
6220                decimals: None,
6221                units: MeasureUnits::from(vec![MeasureUnit {
6222                    name: "second".to_string(),
6223                    factor: crate::computation::rational::rational_one(),
6224                    derived_measure_factors: Vec::new(),
6225                    decomposition: BaseMeasureVector::new(),
6226                    minimum: None,
6227                    maximum: None,
6228                    suggestion_magnitude: None,
6229                }]),
6230                traits: vec![MeasureTrait::Duration],
6231                decomposition: None,
6232                unit: None,
6233                help: String::new(),
6234            },
6235            TypeExtends::Primitive,
6236        );
6237        assert_eq!(
6238            TypedLiteral::measure_with_type(one.clone(), Arc::new(dur_type))
6239                .lemma_type
6240                .name(),
6241            "duration"
6242        );
6243    }
6244
6245    #[test]
6246    fn test_type_display() {
6247        let specs = TypeSpecification::text();
6248        let lemma_type = LemmaType::new("name".to_string(), specs, TypeExtends::Primitive);
6249        assert_eq!(format!("{}", lemma_type), "name");
6250    }
6251
6252    #[test]
6253    fn test_type_serialization() {
6254        let specs = TypeSpecification::number();
6255        let lemma_type = LemmaType::new("dice".to_string(), specs, TypeExtends::Primitive);
6256        let api = crate::api::LemmaType::from(&lemma_type);
6257        let serialized = serde_json::to_string(&api).unwrap();
6258        let deserialized: crate::api::LemmaType = serde_json::from_str(&serialized).unwrap();
6259        assert_eq!(api, deserialized);
6260    }
6261
6262    #[test]
6263    fn test_literal_value_display_value() {
6264        let ten = rational_new(10, 1);
6265
6266        assert_eq!(
6267            LiteralValue::text("hello".to_string()).display_value(),
6268            "hello"
6269        );
6270        assert_eq!(LiteralValue::number(ten).display_value(), "10");
6271        assert_eq!(LiteralValue::from_bool(true).display_value(), "true");
6272        assert_eq!(LiteralValue::from_bool(false).display_value(), "false");
6273
6274        // 0.10 ratio with "percent" binding displays as 10% (unit conversion applied)
6275        let ratio_ty = Arc::new(primitive_ratio_arc().as_ref().clone().with_unit("percent"));
6276        let ten_percent_ratio = LiteralValue::ratio(
6277            crate::literals::rational_from_parsed_decimal(Decimal::new(1, 1))
6278                .expect("ratio decimal"),
6279        );
6280        assert_eq!(
6281            ten_percent_ratio.display_value_with_type(ratio_ty.as_ref()),
6282            "10%"
6283        );
6284
6285        let time = TimeValue {
6286            hour: 14,
6287            minute: 30,
6288            second: 0,
6289            microsecond: 0,
6290            timezone: None,
6291        };
6292        let time_display = LiteralValue::time(time_to_semantic(&time)).display_value();
6293        assert!(time_display.contains("14"));
6294        assert!(time_display.contains("30"));
6295    }
6296
6297    #[test]
6298    fn test_measure_display_respects_type_decimals() {
6299        let money_type = LemmaType {
6300            name: Some("money".to_string()),
6301            specifications: TypeSpecification::Measure {
6302                minimum: None,
6303                maximum: None,
6304                decimals: Some(2),
6305                units: MeasureUnits::from(vec![MeasureUnit {
6306                    name: "eur".to_string(),
6307                    factor: crate::computation::rational::rational_one(),
6308                    derived_measure_factors: Vec::new(),
6309                    decomposition: BaseMeasureVector::new(),
6310                    minimum: None,
6311                    maximum: None,
6312                    suggestion_magnitude: None,
6313                }]),
6314                traits: Vec::new(),
6315                decomposition: None,
6316                unit: None,
6317                help: String::new(),
6318            },
6319            extends: TypeExtends::Primitive,
6320        };
6321        let money_type = Arc::new(money_type);
6322        let val = LiteralValue::measure_with_type(
6323            decimal_to_rational(Decimal::from_str("1.8").unwrap()).unwrap(),
6324            money_type.clone(),
6325        );
6326        assert_eq!(val.display_value_with_type(money_type.as_ref()), "1.80 eur");
6327        let more_precision = LiteralValue::measure_with_type(
6328            decimal_to_rational(Decimal::from_str("1.80000").unwrap()).unwrap(),
6329            money_type.clone(),
6330        );
6331        assert_eq!(
6332            more_precision.display_value_with_type(money_type.as_ref()),
6333            "1.80 eur"
6334        );
6335        let measure_no_decimals = Arc::new(LemmaType {
6336            name: Some("count".to_string()),
6337            specifications: TypeSpecification::Measure {
6338                minimum: None,
6339                maximum: None,
6340                decimals: None,
6341                units: MeasureUnits::from(vec![MeasureUnit {
6342                    name: "items".to_string(),
6343                    factor: crate::computation::rational::rational_one(),
6344                    derived_measure_factors: Vec::new(),
6345                    decomposition: BaseMeasureVector::new(),
6346                    minimum: None,
6347                    maximum: None,
6348                    suggestion_magnitude: None,
6349                }]),
6350                traits: Vec::new(),
6351                decomposition: None,
6352                unit: None,
6353                help: String::new(),
6354            },
6355            extends: TypeExtends::Primitive,
6356        });
6357        let val_any = LiteralValue::measure_with_type(
6358            decimal_to_rational(Decimal::from_str("42.50").unwrap()).unwrap(),
6359            Arc::clone(&measure_no_decimals),
6360        );
6361        assert_eq!(
6362            val_any.display_value_with_type(measure_no_decimals.as_ref()),
6363            "42.5 items"
6364        );
6365    }
6366
6367    #[test]
6368    fn test_literal_value_time_type() {
6369        let time = TimeValue {
6370            hour: 14,
6371            minute: 30,
6372            second: 0,
6373            microsecond: 0,
6374            timezone: None,
6375        };
6376        let lit = TypedLiteral::time(time_to_semantic(&time));
6377        assert_eq!(lit.lemma_type.name(), "time");
6378    }
6379
6380    #[test]
6381    fn test_measure_family_name_primitive_root() {
6382        let measure_spec = TypeSpecification::measure();
6383        let money_primitive = LemmaType::new(
6384            "money".to_string(),
6385            measure_spec.clone(),
6386            TypeExtends::Primitive,
6387        );
6388        assert_eq!(money_primitive.measure_family_name(), Some("money"));
6389    }
6390
6391    #[test]
6392    fn test_measure_family_name_custom() {
6393        let measure_spec = TypeSpecification::measure();
6394        let money_custom = LemmaType::new(
6395            "money".to_string(),
6396            measure_spec,
6397            TypeExtends::custom_local("money".to_string(), "money".to_string()),
6398        );
6399        assert_eq!(money_custom.measure_family_name(), Some("money"));
6400    }
6401
6402    #[test]
6403    fn test_same_measure_family_same_name_different_extends() {
6404        let measure_spec = TypeSpecification::measure();
6405        let money_primitive = LemmaType::new(
6406            "money".to_string(),
6407            measure_spec.clone(),
6408            TypeExtends::Primitive,
6409        );
6410        let money_custom = LemmaType::new(
6411            "money".to_string(),
6412            measure_spec,
6413            TypeExtends::custom_local("money".to_string(), "money".to_string()),
6414        );
6415        assert!(money_primitive.same_measure_family(&money_custom));
6416        assert!(money_custom.same_measure_family(&money_primitive));
6417    }
6418
6419    #[test]
6420    fn test_same_measure_family_parent_and_child() {
6421        let measure_spec = TypeSpecification::measure();
6422        let type_x = LemmaType::new(
6423            "x".to_string(),
6424            measure_spec.clone(),
6425            TypeExtends::Primitive,
6426        );
6427        let type_x2 = LemmaType::new(
6428            "x2".to_string(),
6429            measure_spec,
6430            TypeExtends::custom_local("x".to_string(), "x".to_string()),
6431        );
6432        assert_eq!(type_x.measure_family_name(), Some("x"));
6433        assert_eq!(type_x2.measure_family_name(), Some("x"));
6434        assert!(type_x.same_measure_family(&type_x2));
6435        assert!(type_x2.same_measure_family(&type_x));
6436    }
6437
6438    #[test]
6439    fn test_same_measure_family_siblings() {
6440        let measure_spec = TypeSpecification::measure();
6441        let type_x2_a = LemmaType::new(
6442            "x2a".to_string(),
6443            measure_spec.clone(),
6444            TypeExtends::custom_local("x".to_string(), "x".to_string()),
6445        );
6446        let type_x2_b = LemmaType::new(
6447            "x2b".to_string(),
6448            measure_spec,
6449            TypeExtends::custom_local("x".to_string(), "x".to_string()),
6450        );
6451        assert!(type_x2_a.same_measure_family(&type_x2_b));
6452    }
6453
6454    #[test]
6455    fn test_same_measure_family_different_families() {
6456        let measure_spec = TypeSpecification::measure();
6457        let money = LemmaType::new(
6458            "money".to_string(),
6459            measure_spec.clone(),
6460            TypeExtends::Primitive,
6461        );
6462        let temperature = LemmaType::new(
6463            "temperature".to_string(),
6464            measure_spec,
6465            TypeExtends::Primitive,
6466        );
6467        assert!(!money.same_measure_family(&temperature));
6468        assert!(!temperature.same_measure_family(&money));
6469    }
6470
6471    #[test]
6472    fn test_same_measure_family_measure_vs_non_measure() {
6473        let measure_spec = TypeSpecification::measure();
6474        let number_spec = TypeSpecification::number();
6475        let measure_type =
6476            LemmaType::new("money".to_string(), measure_spec, TypeExtends::Primitive);
6477        let number_type = LemmaType::new("amount".to_string(), number_spec, TypeExtends::Primitive);
6478        assert!(!measure_type.same_measure_family(&number_type));
6479        assert!(!number_type.same_measure_family(&measure_type));
6480    }
6481
6482    #[test]
6483    fn test_same_measure_family_anonymous_measures_are_not_family_compatible() {
6484        let left = LemmaType::anonymous_for_decomposition(duration_decomposition());
6485        let right = LemmaType::anonymous_for_decomposition(duration_decomposition());
6486
6487        assert!(!left.same_measure_family(&right));
6488        assert!(left.compatible_with_anonymous_measure(&right));
6489    }
6490
6491    #[test]
6492    fn test_measure_family_name_non_measure_returns_none() {
6493        let number_spec = TypeSpecification::number();
6494        let number_type = LemmaType::new("amount".to_string(), number_spec, TypeExtends::Primitive);
6495        assert_eq!(number_type.measure_family_name(), None);
6496    }
6497
6498    #[test]
6499    fn test_lemma_type_inequality_local_vs_import_same_shape() {
6500        let measure_spec = TypeSpecification::measure();
6501        let local = LemmaType::new(
6502            "t".to_string(),
6503            measure_spec.clone(),
6504            TypeExtends::custom_local("money".to_string(), "money".to_string()),
6505        );
6506        let imported = LemmaType::new(
6507            "t".to_string(),
6508            measure_spec,
6509            TypeExtends::Custom {
6510                parent: "money".to_string(),
6511                family: "money".to_string(),
6512                defining_spec: TypeDefiningSpec::Import,
6513            },
6514        );
6515        assert_ne!(local, imported);
6516    }
6517
6518    #[test]
6519    fn test_lemma_type_equality_import_unit_variant() {
6520        let measure_spec = TypeSpecification::measure();
6521        let left = LemmaType::new(
6522            "t".to_string(),
6523            measure_spec.clone(),
6524            TypeExtends::Custom {
6525                parent: "money".to_string(),
6526                family: "money".to_string(),
6527                defining_spec: TypeDefiningSpec::Import,
6528            },
6529        );
6530        let right = LemmaType::new(
6531            "t".to_string(),
6532            measure_spec,
6533            TypeExtends::Custom {
6534                parent: "money".to_string(),
6535                family: "money".to_string(),
6536                defining_spec: TypeDefiningSpec::Import,
6537            },
6538        );
6539        assert_eq!(left, right);
6540    }
6541
6542    fn month_suggestion_arg() -> CommandArg {
6543        CommandArg::Literal(crate::literals::Value::NumberWithUnit(
6544            Decimal::ONE,
6545            "month".to_string(),
6546        ))
6547    }
6548
6549    fn unit_factor_arg(name: &str, factor: i64) -> [CommandArg; 2] {
6550        [
6551            CommandArg::Label(name.to_string()),
6552            CommandArg::UnitExpr(crate::parsing::ast::UnitArg::Factor(Decimal::from(factor))),
6553        ]
6554    }
6555
6556    #[test]
6557    fn default_calendar_on_text_reports_hint() {
6558        let mut specs = TypeSpecification::text();
6559        let mut default = None;
6560        let err = specs
6561            .apply_constraint(
6562                "notes",
6563                TypeConstraintCommand::Suggest,
6564                &[month_suggestion_arg()],
6565                &mut default,
6566                &mut None,
6567                None,
6568            )
6569            .unwrap_err();
6570        assert!(err.contains("Unit 'month' is for calendar data"));
6571        assert!(err.contains("double quotes"));
6572    }
6573
6574    #[test]
6575    fn default_calendar_on_duration_reports_valid_units() {
6576        let mut specs = TypeSpecification::measure();
6577        specs
6578            .apply_constraint(
6579                "duration",
6580                TypeConstraintCommand::Unit,
6581                &unit_factor_arg("second", 1),
6582                &mut None,
6583                &mut None,
6584                None,
6585            )
6586            .unwrap();
6587        specs
6588            .apply_constraint(
6589                "duration",
6590                TypeConstraintCommand::Unit,
6591                &unit_factor_arg("week", 604_800),
6592                &mut None,
6593                &mut None,
6594                None,
6595            )
6596            .unwrap();
6597        specs
6598            .apply_constraint(
6599                "duration",
6600                TypeConstraintCommand::Trait,
6601                &[CommandArg::Label("duration".to_string())],
6602                &mut None,
6603                &mut None,
6604                None,
6605            )
6606            .unwrap();
6607        let mut default = None;
6608        let err = specs
6609            .apply_constraint(
6610                "duration",
6611                TypeConstraintCommand::Suggest,
6612                &[month_suggestion_arg()],
6613                &mut default,
6614                &mut None,
6615                None,
6616            )
6617            .unwrap_err();
6618        assert!(err.contains("Unit 'month' is for calendar data"));
6619        assert!(err.contains("Valid 'duration' units are"));
6620        assert!(err.contains("week"));
6621    }
6622
6623    #[test]
6624    fn default_valid_duration_weeks_accepted() {
6625        let mut specs = TypeSpecification::measure();
6626        specs
6627            .apply_constraint(
6628                "duration",
6629                TypeConstraintCommand::Unit,
6630                &unit_factor_arg("second", 1),
6631                &mut None,
6632                &mut None,
6633                None,
6634            )
6635            .unwrap();
6636        specs
6637            .apply_constraint(
6638                "duration",
6639                TypeConstraintCommand::Unit,
6640                &unit_factor_arg("week", 604_800),
6641                &mut None,
6642                &mut None,
6643                None,
6644            )
6645            .unwrap();
6646        specs
6647            .apply_constraint(
6648                "duration",
6649                TypeConstraintCommand::Trait,
6650                &[CommandArg::Label("duration".to_string())],
6651                &mut None,
6652                &mut None,
6653                None,
6654            )
6655            .unwrap();
6656        let mut default = None;
6657        specs
6658            .apply_constraint(
6659                "duration",
6660                TypeConstraintCommand::Suggest,
6661                &[CommandArg::Literal(crate::literals::Value::NumberWithUnit(
6662                    Decimal::from(4),
6663                    "week".to_string(),
6664                ))],
6665                &mut default,
6666                &mut None,
6667                None,
6668            )
6669            .unwrap();
6670        assert!(matches!(
6671            default,
6672            Some(RawSuggestion::Measure {
6673                unit_name,
6674                ..
6675            }) if unit_name == "week"
6676        ));
6677    }
6678
6679    #[test]
6680    fn default_unknown_unit_on_duration_lists_valid_units() {
6681        let mut specs = TypeSpecification::measure();
6682        specs
6683            .apply_constraint(
6684                "duration",
6685                TypeConstraintCommand::Unit,
6686                &unit_factor_arg("second", 1),
6687                &mut None,
6688                &mut None,
6689                None,
6690            )
6691            .unwrap();
6692        specs
6693            .apply_constraint(
6694                "duration",
6695                TypeConstraintCommand::Trait,
6696                &[CommandArg::Label("duration".to_string())],
6697                &mut None,
6698                &mut None,
6699                None,
6700            )
6701            .unwrap();
6702        let mut default = None;
6703        let err = specs
6704            .apply_constraint(
6705                "duration",
6706                TypeConstraintCommand::Suggest,
6707                &[CommandArg::Literal(crate::literals::Value::NumberWithUnit(
6708                    Decimal::ONE,
6709                    "fortnight".to_string(),
6710                ))],
6711                &mut default,
6712                &mut None,
6713                None,
6714            )
6715            .unwrap_err();
6716        assert!(err.contains("fortnight"));
6717        assert!(err.contains("not defined on 'duration'"));
6718        assert!(err.contains("Valid units are"));
6719    }
6720
6721    fn money_measure_type() -> LemmaType {
6722        LemmaType::new(
6723            "Money".to_string(),
6724            TypeSpecification::Measure {
6725                minimum: None,
6726                maximum: None,
6727                decimals: None,
6728                units: MeasureUnits::from(vec![
6729                    MeasureUnit {
6730                        name: "eur".to_string(),
6731                        factor: crate::computation::rational::rational_one(),
6732                        derived_measure_factors: Vec::new(),
6733                        decomposition: BaseMeasureVector::new(),
6734                        minimum: None,
6735                        maximum: None,
6736                        suggestion_magnitude: None,
6737                    },
6738                    MeasureUnit {
6739                        name: "usd".to_string(),
6740                        factor: crate::computation::rational::decimal_to_rational(Decimal::new(
6741                            91, 2,
6742                        ))
6743                        .expect("factor"),
6744                        derived_measure_factors: Vec::new(),
6745                        decomposition: BaseMeasureVector::new(),
6746                        minimum: None,
6747                        maximum: None,
6748                        suggestion_magnitude: None,
6749                    },
6750                ]),
6751                traits: Vec::new(),
6752                decomposition: None,
6753                unit: None,
6754                help: String::new(),
6755            },
6756            TypeExtends::Primitive,
6757        )
6758    }
6759
6760    #[test]
6761    fn measure_unit_names_for_named_measure() {
6762        let money = money_measure_type();
6763        assert_eq!(money.measure_unit_names(), Some(vec!["eur", "usd"]));
6764    }
6765
6766    // ---------------------------------------------------------------------------
6767    // Phase 0 — pin combine_signatures and canonicalize_signature behavior
6768    // ---------------------------------------------------------------------------
6769
6770    fn sig(pairs: &[(&str, i32)]) -> Vec<(String, i32)> {
6771        pairs.iter().map(|(s, e)| (s.to_string(), *e)).collect()
6772    }
6773
6774    #[test]
6775    fn combine_signatures_multiply_adds_exponents() {
6776        let left = sig(&[("eur", 1)]);
6777        let right = sig(&[("hour", -1)]);
6778        let result = combine_signatures(&left, &right, true);
6779        assert_eq!(result, sig(&[("eur", 1), ("hour", -1)]));
6780    }
6781
6782    #[test]
6783    fn combine_signatures_divide_subtracts_exponents() {
6784        let left = sig(&[("eur", 1)]);
6785        let right = sig(&[("hour", 1)]);
6786        let result = combine_signatures(&left, &right, false);
6787        assert_eq!(result, sig(&[("eur", 1), ("hour", -1)]));
6788    }
6789
6790    #[test]
6791    fn combine_signatures_cancels_to_empty() {
6792        let left = sig(&[("ce", 1), ("minute", -1)]);
6793        let right = sig(&[("minute", 1)]);
6794        let result = combine_signatures(&left, &right, true);
6795        // ce * (ce/min * min) = ce; minute cancels
6796        assert_eq!(result, sig(&[("ce", 1)]));
6797    }
6798
6799    #[test]
6800    fn combine_signatures_output_is_canonical_form() {
6801        let left = sig(&[("eur", 1), ("hour", 1)]);
6802        let right = sig(&[("minute", 1)]);
6803        let result = combine_signatures(&left, &right, false); // divide
6804                                                               // [("eur",1),("hour",1)] / [("minute",1)] = [("eur",1),("hour",1),("minute",-1)]
6805        let expected = sig(&[("eur", 1), ("hour", 1), ("minute", -1)]);
6806        assert_eq!(result, expected);
6807    }
6808
6809    #[test]
6810    fn canonicalize_signature_drops_zero_exponents() {
6811        let sig_with_zero = sig(&[("eur", 1), ("hour", 0), ("minute", -1)]);
6812        let result = canonicalize_signature(&sig_with_zero);
6813        assert_eq!(result, sig(&[("eur", 1), ("minute", -1)]));
6814    }
6815
6816    #[test]
6817    fn canonicalize_signature_sorts_by_name() {
6818        let unsorted = sig(&[("minute", -1), ("eur", 1)]);
6819        let result = canonicalize_signature(&unsorted);
6820        assert_eq!(result, sig(&[("eur", 1), ("minute", -1)]));
6821    }
6822
6823    // ---------------------------------------------------------------------------
6824    // Phase 0 — format_signature_operator_style (to be implemented in
6825    // signature_factor_and_display todo)
6826    // ---------------------------------------------------------------------------
6827
6828    #[test]
6829    fn format_signature_operator_style_numerator_only() {
6830        let signature = sig(&[("eur", 1)]);
6831        let result = format_signature_operator_style(&signature);
6832        assert_eq!(result, "eur");
6833    }
6834
6835    #[test]
6836    fn format_signature_operator_style_with_denominator() {
6837        let signature = sig(&[("eur", 1), ("hour", -1)]);
6838        let result = format_signature_operator_style(&signature);
6839        assert_eq!(result, "eur/hour");
6840    }
6841
6842    #[test]
6843    fn format_signature_operator_style_denominator_only() {
6844        let signature = sig(&[("meter", -1)]);
6845        let result = format_signature_operator_style(&signature);
6846        assert_eq!(result, "1/meter");
6847    }
6848
6849    #[test]
6850    fn format_signature_operator_style_with_exponents() {
6851        let signature = sig(&[("meter", 2), ("second", -2)]);
6852        let result = format_signature_operator_style(&signature);
6853        assert_eq!(result, "meter^2/second^2");
6854    }
6855
6856    // ---------------------------------------------------------------------------
6857    // Phase 0 — calendar_unit_factor (to be implemented in builtin_calendar_factor_table)
6858    // ---------------------------------------------------------------------------
6859
6860    #[test]
6861    fn calendar_unit_factor_table_completeness() {
6862        // Every SemanticCalendarUnit Display string must resolve to a factor.
6863        // Today SemanticCalendarUnit only has Month and Year; more may be added.
6864        for unit in &[SemanticCalendarUnit::Month, SemanticCalendarUnit::Year] {
6865            let name = unit.to_string();
6866            assert!(
6867                calendar_unit_factor(&name).is_some(),
6868                "calendar_unit_factor('{}') must return Some",
6869                name
6870            );
6871        }
6872    }
6873
6874    #[test]
6875    fn semantic_calendar_unit_display_returns_singular() {
6876        // Today Month => "month", Year => "year" (plural).
6877        // After singular_calendar_names_everywhere, must be "month" and "year".
6878        assert_eq!(SemanticCalendarUnit::Month.to_string(), "month");
6879        assert_eq!(SemanticCalendarUnit::Year.to_string(), "year");
6880    }
6881
6882    // ---------------------------------------------------------------------------
6883    // Phase 0 — signature_factor (to be implemented in signature_factor_and_display)
6884    // ---------------------------------------------------------------------------
6885
6886    #[test]
6887    fn signature_factor_with_calendar_units() {
6888        let calendar = test_calendar_type_for_signature_factor();
6889        let unit_index = crate::planning::unit_index::UnitIndex::new();
6890        // month factor = 1, year factor = 12.
6891        // [(month,1),(year,-1)] = 1/12
6892        let sig_month_per_year = sig(&[("month", 1), ("year", -1)]);
6893        let factor = signature_factor(&sig_month_per_year, &unit_index, Some(&calendar))
6894            .expect("must not overflow");
6895        let expected = rational_new(1, 12);
6896        assert_eq!(factor, expected, "month/year factor must be 1/12");
6897    }
6898
6899    fn test_calendar_type_for_signature_factor() -> LemmaType {
6900        use crate::computation::rational::{decimal_to_rational, rational_one};
6901        use crate::literals::{MeasureUnit, MeasureUnits};
6902        use rust_decimal::Decimal;
6903        LemmaType::new(
6904            "calendar".to_string(),
6905            TypeSpecification::Measure {
6906                minimum: None,
6907                maximum: None,
6908                decimals: None,
6909                units: MeasureUnits::from(vec![
6910                    MeasureUnit {
6911                        name: "month".to_string(),
6912                        factor: rational_one(),
6913                        minimum: None,
6914                        maximum: None,
6915                        suggestion_magnitude: None,
6916                        decomposition: calendar_decomposition(),
6917                        derived_measure_factors: Vec::new(),
6918                    },
6919                    MeasureUnit {
6920                        name: "year".to_string(),
6921                        factor: decimal_to_rational(Decimal::from(12)).expect("year factor"),
6922                        minimum: None,
6923                        maximum: None,
6924                        suggestion_magnitude: None,
6925                        decomposition: calendar_decomposition(),
6926                        derived_measure_factors: Vec::new(),
6927                    },
6928                ]),
6929                traits: vec![MeasureTrait::Calendar],
6930                decomposition: Some(calendar_decomposition()),
6931                unit: None,
6932                help: String::new(),
6933            },
6934            TypeExtends::Primitive,
6935        )
6936    }
6937
6938    #[test]
6939    #[should_panic(expected = "BUG: signature_factor called with unresolved unit name")]
6940    fn signature_factor_panics_on_unresolved_name() {
6941        let unit_index = crate::planning::unit_index::UnitIndex::new();
6942        let bad_sig = sig(&[("nonexistent_unit_xyz", 1)]);
6943        let _ = signature_factor(&bad_sig, &unit_index, None);
6944    }
6945
6946    #[test]
6947    fn signature_factor_uses_owner_when_expression_index_empty() {
6948        let money = test_money_type_for_signature_factor();
6949        let expression_units = crate::planning::unit_index::UnitIndex::new();
6950        let sig_usd = sig(&[("usd", 1)]);
6951        let factor =
6952            signature_factor(&sig_usd, &expression_units, Some(&money)).expect("must not overflow");
6953        assert_eq!(factor, rational_new(91, 100));
6954    }
6955
6956    fn test_money_type_for_signature_factor() -> LemmaType {
6957        use crate::computation::rational::decimal_to_rational;
6958        use crate::literals::{MeasureUnit, MeasureUnits};
6959        use rust_decimal::Decimal;
6960        LemmaType::new(
6961            "money".to_string(),
6962            TypeSpecification::Measure {
6963                minimum: None,
6964                maximum: None,
6965                decimals: Some(2),
6966                units: MeasureUnits::from(vec![
6967                    MeasureUnit {
6968                        name: "eur".to_string(),
6969                        factor: crate::computation::rational::rational_one(),
6970                        minimum: None,
6971                        maximum: None,
6972                        suggestion_magnitude: None,
6973                        decomposition: BaseMeasureVector::new(),
6974                        derived_measure_factors: Vec::new(),
6975                    },
6976                    MeasureUnit {
6977                        name: "usd".to_string(),
6978                        factor: decimal_to_rational(Decimal::new(91, 2)).expect("usd factor"),
6979                        minimum: None,
6980                        maximum: None,
6981                        suggestion_magnitude: None,
6982                        decomposition: BaseMeasureVector::new(),
6983                        derived_measure_factors: Vec::new(),
6984                    },
6985                ]),
6986                traits: Vec::new(),
6987                decomposition: None,
6988                unit: None,
6989                help: String::new(),
6990            },
6991            TypeExtends::Primitive,
6992        )
6993    }
6994
6995    fn measure_type_with_kilogram() -> TypeSpecification {
6996        use crate::computation::rational::rational_one;
6997        use crate::literals::{MeasureUnit, MeasureUnits};
6998        let mut units = MeasureUnits::new();
6999        units.push(MeasureUnit {
7000            name: "kilogram".to_string(),
7001            factor: rational_one(),
7002            minimum: None,
7003            maximum: None,
7004            suggestion_magnitude: None,
7005            decomposition: BaseMeasureVector::new(),
7006            derived_measure_factors: Vec::new(),
7007        });
7008        TypeSpecification::Measure {
7009            minimum: None,
7010            maximum: None,
7011            decimals: None,
7012            units,
7013            traits: Vec::new(),
7014            decomposition: None,
7015            unit: None,
7016            help: String::new(),
7017        }
7018    }
7019
7020    #[test]
7021    fn parser_value_to_value_kind_rejects_bare_number_for_measure() {
7022        let ten = Value::Number(Decimal::from(10));
7023        let err = parser_value_to_value_kind(&ten, &measure_type_with_kilogram(), None)
7024            .expect_err("bare number must not bind to measure");
7025        assert!(
7026            err.contains("kilogram"),
7027            "error must hint expected unit, got: {err}"
7028        );
7029    }
7030
7031    #[test]
7032    fn parser_value_to_value_kind_accepts_number_with_unit_for_measure() {
7033        let ten_kg = Value::NumberWithUnit(Decimal::from(10), "kilogram".to_string());
7034        let kind = parser_value_to_value_kind(&ten_kg, &measure_type_with_kilogram(), None)
7035            .expect("10 kilogram must bind to measure");
7036        assert!(matches!(kind, ValueKind::Measure(_)));
7037    }
7038
7039    #[test]
7040    fn parser_value_to_value_kind_accepts_bare_number_for_ratio() {
7041        let ten = Value::Number(Decimal::from(10));
7042        let kind = parser_value_to_value_kind(&ten, &TypeSpecification::ratio(), None)
7043            .expect("number -> ratio");
7044        assert!(matches!(kind, ValueKind::Ratio(_)));
7045    }
7046
7047    #[test]
7048    fn value_kind_matches_spec_rejects_number_for_measure() {
7049        let n = ValueKind::Number(rational_new(10, 1));
7050        assert!(!value_kind_matches_spec(&n, &measure_type_with_kilogram()));
7051    }
7052
7053    #[test]
7054    fn apply_constraint_rejects_inherited_unit_factor_change() {
7055        let mut specs = TypeSpecification::measure();
7056        specs
7057            .apply_constraint(
7058                "money",
7059                TypeConstraintCommand::Unit,
7060                &unit_factor_arg("eur", 1),
7061                &mut None,
7062                &mut None,
7063                None,
7064            )
7065            .expect("seed eur");
7066        let err = specs
7067            .apply_constraint(
7068                "money",
7069                TypeConstraintCommand::Unit,
7070                &[
7071                    CommandArg::Label("eur".to_string()),
7072                    CommandArg::UnitExpr(crate::parsing::ast::UnitArg::Factor(Decimal::new(11, 1))),
7073                ],
7074                &mut None,
7075                &mut None,
7076                None,
7077            )
7078            .expect_err("must not change inherited unit factor");
7079        assert!(err.contains("eur"), "error must name unit, got: {err}");
7080        assert!(
7081            err.contains("inherited") || err.contains("cannot change"),
7082            "error must reject factor change, got: {err}"
7083        );
7084    }
7085
7086    #[test]
7087    fn apply_constraint_allows_additive_unit_on_inherited_spec() {
7088        let mut specs = TypeSpecification::measure();
7089        specs
7090            .apply_constraint(
7091                "money",
7092                TypeConstraintCommand::Unit,
7093                &unit_factor_arg("eur", 1),
7094                &mut None,
7095                &mut None,
7096                None,
7097            )
7098            .expect("seed eur");
7099        specs
7100            .apply_constraint(
7101                "money",
7102                TypeConstraintCommand::Unit,
7103                &unit_factor_arg("usd", 1),
7104                &mut None,
7105                &mut None,
7106                None,
7107            )
7108            .expect("add usd");
7109        match &specs {
7110            TypeSpecification::Measure { units, .. } => assert_eq!(units.len(), 2),
7111            other => panic!("expected Measure, got {other:?}"),
7112        }
7113    }
7114
7115    #[test]
7116    fn apply_constraint_idempotent_inherited_unit_redeclare() {
7117        let mut specs = TypeSpecification::measure();
7118        specs
7119            .apply_constraint(
7120                "money",
7121                TypeConstraintCommand::Unit,
7122                &unit_factor_arg("eur", 1),
7123                &mut None,
7124                &mut None,
7125                None,
7126            )
7127            .expect("seed eur");
7128        specs
7129            .apply_constraint(
7130                "money",
7131                TypeConstraintCommand::Unit,
7132                &unit_factor_arg("eur", 1),
7133                &mut None,
7134                &mut None,
7135                None,
7136            )
7137            .expect("idempotent eur");
7138        match &specs {
7139            TypeSpecification::Measure { units, .. } => {
7140                assert_eq!(units.len(), 1);
7141                assert_eq!(
7142                    units.iter().find(|u| u.name == "eur").expect("eur").factor,
7143                    crate::computation::rational::rational_one()
7144                );
7145            }
7146            other => panic!("expected Measure, got {other:?}"),
7147        }
7148    }
7149
7150    #[test]
7151    fn element_from_range_returns_element_for_every_range_primitive() {
7152        type RangeElementMatcher = fn(&TypeSpecification) -> bool;
7153        let cases: [(PrimitiveKind, RangeElementMatcher); 5] = [
7154            (PrimitiveKind::NumberRange, |element| {
7155                matches!(element, TypeSpecification::Number { .. })
7156            }),
7157            (PrimitiveKind::MeasureRange, |element| {
7158                matches!(element, TypeSpecification::Measure { .. })
7159            }),
7160            (PrimitiveKind::RatioRange, |element| {
7161                matches!(element, TypeSpecification::Ratio { .. })
7162            }),
7163            (PrimitiveKind::DateRange, |element| {
7164                matches!(element, TypeSpecification::Date { .. })
7165            }),
7166            (PrimitiveKind::TimeRange, |element| {
7167                matches!(element, TypeSpecification::Time { .. })
7168            }),
7169        ];
7170        for (kind, matches_element) in cases {
7171            let range_spec = type_spec_for_primitive(kind);
7172            let element = range_spec
7173                .element_from_range()
7174                .unwrap_or_else(|| panic!("{kind:?} must define element_from_range"));
7175            assert!(
7176                matches_element(&element),
7177                "{kind:?} element must match documented mapping, got {element:?}"
7178            );
7179        }
7180    }
7181
7182    #[test]
7183    fn element_from_range_returns_none_for_non_range_primitives() {
7184        let non_range = [
7185            type_spec_for_primitive(PrimitiveKind::Boolean),
7186            type_spec_for_primitive(PrimitiveKind::Measure),
7187            TypeSpecification::Undetermined,
7188            TypeSpecification::veto(),
7189        ];
7190        for spec in non_range {
7191            assert!(
7192                spec.element_from_range().is_none(),
7193                "{spec:?} must not define element_from_range"
7194            );
7195        }
7196    }
7197
7198    fn spec_type(name: &str, effective: crate::parsing::ast::EffectiveDate) -> LemmaType {
7199        LemmaType::primitive(TypeSpecification::Spec {
7200            spec: SpecIdentity {
7201                repository: None,
7202                spec: name.to_string(),
7203                effective,
7204            },
7205        })
7206    }
7207
7208    #[test]
7209    fn same_value_type_spec_identity() {
7210        use crate::parsing::ast::EffectiveDate;
7211        let origin = EffectiveDate::Origin;
7212        let dated = EffectiveDate::DateTimeValue(crate::DateTimeValue {
7213            year: 2026,
7214            month: 1,
7215            day: 1,
7216            hour: 0,
7217            minute: 0,
7218            second: 0,
7219            microsecond: 0,
7220            timezone: None,
7221            granularity: crate::literals::DateGranularity::Full,
7222        });
7223        let tax = spec_type("tax_bracket", origin.clone());
7224        let tax_again = spec_type("tax_bracket", origin.clone());
7225        let other = spec_type("menu_item", origin);
7226        let tax_dated = spec_type("tax_bracket", dated);
7227        assert!(tax.same_value_type(&tax_again));
7228        assert!(!tax.same_value_type(&other));
7229        assert!(!tax.same_value_type(&tax_dated));
7230        let mut other_repo = tax.clone();
7231        match &mut other_repo.specifications {
7232            TypeSpecification::Spec { spec } => spec.repository = Some("@jack/finance".to_string()),
7233            other => panic!("expected Spec, got {other:?}"),
7234        }
7235        assert!(!tax.same_value_type(&other_repo));
7236    }
7237
7238    #[test]
7239    fn piecewise_type_keeps_spec_identity() {
7240        use crate::parsing::ast::EffectiveDate;
7241        let tax = Arc::new(spec_type("tax_bracket", EffectiveDate::Origin));
7242        let picked = crate::planning::typing::piecewise_type([
7243            Arc::new(LemmaType::veto_type()),
7244            Arc::clone(&tax),
7245        ]);
7246        assert!(picked.same_value_type(&tax));
7247    }
7248}