Skip to main content

earthsci_ast/
units.rs

1//! Unit parsing and dimensional analysis.
2//!
3//! This module implements a hand-rolled dimensional algebra for ESM
4//! expressions. Two layers are exposed:
5//!
6//! 1. [`parse_unit`] parses a unit string (e.g. `"kg*m/s^2"`, `"J/(mol*K)"`)
7//!    into a [`Unit`] carrying a `HashMap<Dimension, i32>` and a scale
8//!    factor. Used for variable/parameter unit metadata.
9//!
10//! 2. [`Unit::propagate`] walks an [`Expr`] AST and returns the resulting
11//!    [`Unit`] for the whole expression. This is the expression-level
12//!    dimensional propagation that lets a caller verify, for example, that
13//!    `D(h)/dt` has the same dimensions as a `v` of units `m/s`.
14//!
15//! The propagation rules match the Python implementation
16//! (`earthsci_ast/units.py::_get_expression_dimension`) and the Julia
17//! implementation (`EarthSciAST.jl/src/units.jl::
18//! get_expression_dimensions`).
19
20use crate::types::{Equation, Expr, ExpressionNode};
21use std::collections::{BTreeMap, HashMap};
22use thiserror::Error;
23
24/// A dimension exponent.
25///
26/// Exponents are RATIONAL, not integer: `1/s^0.5` — the intensity of an SDE
27/// noise term — is a legitimate unit that appears in the shared corpus
28/// (`tests/fixtures/sde/*.esm`), and `sqrt` halves whatever it is given. An
29/// integer exponent could represent neither `sqrt(m^3)` nor `s^0.5`, and since
30/// a dimensional mismatch is a hard error, legitimate files would be
31/// rejected.
32///
33/// Always stored in lowest terms with a positive denominator, so `PartialEq`
34/// is exact dimensional equality (½ and 2/4 compare equal) and `HashMap` lookups
35/// are sound.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub struct Rational {
38    num: i32,
39    den: i32,
40}
41
42impl Rational {
43    /// A whole number exponent.
44    #[must_use]
45    pub fn int(n: i32) -> Self {
46        Rational { num: n, den: 1 }
47    }
48
49    /// `num/den`, reduced. A zero denominator is treated as `0/1`.
50    #[must_use]
51    pub fn new(num: i32, den: i32) -> Self {
52        if den == 0 {
53            return Rational { num: 0, den: 1 };
54        }
55        let sign = if den < 0 { -1 } else { 1 };
56        let (num, den) = (num * sign, den * sign);
57        let g = gcd(num.abs(), den);
58        if g == 0 {
59            return Rational { num: 0, den: 1 };
60        }
61        Rational {
62            num: num / g,
63            den: den / g,
64        }
65    }
66
67    /// Convert a decimal literal (`0.5`, `-1.5`, `2`) to an exact rational.
68    ///
69    /// Goes through the DECIMAL representation rather than the binary float, so
70    /// `0.5` is exactly ½ and `0.1` is exactly 1/10 — a continued-fraction
71    /// expansion of the `f64` would introduce a spurious huge denominator.
72    /// Returns `None` for a non-finite value or one needing more precision than
73    /// an `i32` numerator can hold.
74    #[must_use]
75    pub fn from_f64(x: f64) -> Option<Self> {
76        if !x.is_finite() {
77            return None;
78        }
79        if x.fract() == 0.0 && x.abs() < i32::MAX as f64 {
80            return Some(Rational::int(x as i32));
81        }
82        // Up to 6 decimal places is far more than any real unit exponent needs.
83        for places in 1..=6u32 {
84            let den = 10i32.checked_pow(places)?;
85            let scaled = x * f64::from(den);
86            if scaled.fract().abs() < 1e-9 && scaled.abs() < i32::MAX as f64 {
87                return Some(Rational::new(scaled.round() as i32, den));
88            }
89        }
90        None
91    }
92
93    fn add(self, other: Self) -> Self {
94        Rational::new(
95            self.num * other.den + other.num * self.den,
96            self.den * other.den,
97        )
98    }
99
100    fn sub(self, other: Self) -> Self {
101        Rational::new(
102            self.num * other.den - other.num * self.den,
103            self.den * other.den,
104        )
105    }
106
107    fn mul(self, other: Self) -> Self {
108        Rational::new(self.num * other.num, self.den * other.den)
109    }
110
111    fn is_zero(self) -> bool {
112        self.num == 0
113    }
114
115    /// `num/den` from 64-bit parts, reduced; `None` when the reduced value does
116    /// not fit the `i32` representation (or `den` is zero).
117    fn checked_from_i64(num: i64, den: i64) -> Option<Self> {
118        if den == 0 {
119            return None;
120        }
121        let sign = if den < 0 { -1 } else { 1 };
122        let (num, den) = (num.checked_mul(sign)?, den.checked_mul(sign)?);
123        let g = gcd_i64(num.abs(), den).max(1);
124        Some(Rational {
125            num: i32::try_from(num / g).ok()?,
126            den: i32::try_from(den / g).ok()?,
127        })
128    }
129
130    /// Exact sum, or `None` on overflow of the `i32` representation.
131    fn checked_add(self, other: Self) -> Option<Self> {
132        let (a, b, c, d) = self.parts_with(other);
133        Rational::checked_from_i64(a * d + c * b, b * d)
134    }
135
136    /// Exact difference, or `None` on overflow.
137    fn checked_sub(self, other: Self) -> Option<Self> {
138        let (a, b, c, d) = self.parts_with(other);
139        Rational::checked_from_i64(a * d - c * b, b * d)
140    }
141
142    /// Exact product, or `None` on overflow.
143    fn checked_mul(self, other: Self) -> Option<Self> {
144        let (a, b, c, d) = self.parts_with(other);
145        Rational::checked_from_i64(a * c, b * d)
146    }
147
148    /// Exact quotient, or `None` on overflow or a zero divisor.
149    fn checked_div(self, other: Self) -> Option<Self> {
150        let (a, b, c, d) = self.parts_with(other);
151        Rational::checked_from_i64(a * d, b * c)
152    }
153
154    /// Both operands' parts widened to `i64`, where a product of two cannot
155    /// overflow.
156    fn parts_with(self, other: Self) -> (i64, i64, i64, i64) {
157        (
158            i64::from(self.num),
159            i64::from(self.den),
160            i64::from(other.num),
161            i64::from(other.den),
162        )
163    }
164
165    /// As an `f64`, for scaling a unit's magnitude by this exponent.
166    fn as_f64(self) -> f64 {
167        f64::from(self.num) / f64::from(self.den)
168    }
169}
170
171impl std::fmt::Display for Rational {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        if self.den == 1 {
174            write!(f, "{}", self.num)
175        } else {
176            write!(f, "{}/{}", self.num, self.den)
177        }
178    }
179}
180
181fn gcd(a: i32, b: i32) -> i32 {
182    if b == 0 { a } else { gcd(b, a % b) }
183}
184
185fn gcd_i64(a: i64, b: i64) -> i64 {
186    if b == 0 { a } else { gcd_i64(b, a % b) }
187}
188
189/// One factor of an [`ExactScale`]: a prime number, or π.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
191pub enum ScaleFactor {
192    /// A prime number.
193    Prime(u64),
194    /// The circle constant, which only the degree of arc carries (`deg` is π/180 rad).
195    Pi,
196}
197
198/// The EXACT scale of a unit relative to SI (esm-spec §4.8.1 "Scales are EXACT").
199///
200/// Stored as a product of prime powers and a power of π, each with a rational
201/// exponent: `mi` is 2⁴·3²·5⁻³·11·127. Multiplying units adds exponents, dividing
202/// subtracts them and a rational power multiplies them, so a composite unit's scale
203/// never rounds and `sqrt(km)` stays exact. Equality is exact, which is what lets a
204/// checker decide `m + km` or `m/s = mi/h` without a floating-point tolerance. The
205/// floating-point [`Unit::scale`] is kept for NUMERIC conversion only.
206#[derive(Debug, Clone, PartialEq, Eq, Default)]
207pub struct ExactScale {
208    factors: BTreeMap<ScaleFactor, Rational>,
209}
210
211impl ExactScale {
212    /// The scale 1.
213    #[must_use]
214    pub fn one() -> Self {
215        Self::default()
216    }
217
218    /// A positive whole number.
219    ///
220    /// # Panics
221    ///
222    /// Panics on zero, which is not a scale.
223    #[must_use]
224    pub fn integer(n: u64) -> Self {
225        assert!(n > 0, "a unit scale must be positive");
226        let mut out = Self::one();
227        let mut rest = n;
228        let mut p = 2u64;
229        while p * p <= rest {
230            while rest.is_multiple_of(p) {
231                out.bump(ScaleFactor::Prime(p), Rational::int(1));
232                rest /= p;
233            }
234            p += if p == 2 { 1 } else { 2 };
235        }
236        if rest > 1 {
237            out.bump(ScaleFactor::Prime(rest), Rational::int(1));
238        }
239        out
240    }
241
242    /// `num/den` for positive whole numbers.
243    #[must_use]
244    pub fn ratio(num: u64, den: u64) -> Self {
245        Self::integer(num).divide(&Self::integer(den))
246    }
247
248    /// `10^k`.
249    #[must_use]
250    pub fn power_of_ten(k: i32) -> Self {
251        let mut out = Self::one();
252        out.bump(ScaleFactor::Prime(2), Rational::int(k));
253        out.bump(ScaleFactor::Prime(5), Rational::int(k));
254        out
255    }
256
257    /// π.
258    #[must_use]
259    pub fn pi() -> Self {
260        let mut out = Self::one();
261        out.bump(ScaleFactor::Pi, Rational::int(1));
262        out
263    }
264
265    /// The exact value of a positive decimal literal as the registry writes it
266    /// (`"0.3048"`, `"133.322387415"`, `"2.6867e20"`).
267    ///
268    /// # Panics
269    ///
270    /// Panics on anything that is not such a literal: the registry is a fixed
271    /// table, so a typo in it is a bug to fail on loudly.
272    #[must_use]
273    pub fn decimal(s: &str) -> Self {
274        let (mantissa, exp) = match s.split_once(['e', 'E']) {
275            Some((m, e)) => (m, e.parse::<i32>().expect("registry decimal exponent")),
276            None => (s, 0),
277        };
278        let (int_part, frac_part) = mantissa.split_once('.').unwrap_or((mantissa, ""));
279        let digits = format!("{int_part}{frac_part}");
280        let n: u64 = digits
281            .trim_start_matches('0')
282            .parse()
283            .expect("registry decimal mantissa");
284        let places = i32::try_from(frac_part.len()).expect("registry decimal places");
285        Self::integer(n).multiply(&Self::power_of_ten(exp - places))
286    }
287
288    fn bump(&mut self, factor: ScaleFactor, by: Rational) {
289        let e = self.factors.entry(factor).or_insert(Rational::int(0));
290        *e = e.add(by);
291        if e.is_zero() {
292            self.factors.remove(&factor);
293        }
294    }
295
296    /// The scale of a product of units.
297    #[must_use]
298    pub fn multiply(&self, other: &Self) -> Self {
299        let mut out = self.clone();
300        for (f, e) in &other.factors {
301            out.bump(*f, *e);
302        }
303        out
304    }
305
306    /// The scale of a quotient of units.
307    #[must_use]
308    pub fn divide(&self, other: &Self) -> Self {
309        let mut out = self.clone();
310        for (f, e) in &other.factors {
311            out.bump(*f, e.mul(Rational::int(-1)));
312        }
313        out
314    }
315
316    /// The scale of a unit raised to a rational power.
317    #[must_use]
318    pub fn power(&self, exponent: Rational) -> Self {
319        let mut out = Self::one();
320        for (f, e) in &self.factors {
321            out.bump(*f, e.mul(exponent));
322        }
323        out
324    }
325
326    /// Whether this is exactly 1.
327    #[must_use]
328    pub fn is_one(&self) -> bool {
329        self.factors.is_empty()
330    }
331
332    /// The nearest `f64`, for diagnostics and tests. Two scales are never
333    /// compared through this.
334    #[must_use]
335    pub fn to_f64(&self) -> f64 {
336        self.factors.iter().fold(1.0, |acc, (f, e)| {
337            let base = match f {
338                ScaleFactor::Prime(p) => *p as f64,
339                ScaleFactor::Pi => std::f64::consts::PI,
340            };
341            acc * base.powf(e.as_f64())
342        })
343    }
344
345    /// `p/q`, `p/q*pi` or `p/q*pi^k` in lowest terms (`/q` omitted when it is 1),
346    /// the spelling `tests/conformance/unit_registry` pins. `None` when an exponent
347    /// is not whole (`sqrt(km)`) or the numbers do not fit in 128 bits.
348    #[must_use]
349    pub fn ratio_string(&self) -> Option<String> {
350        let (mut num, mut den, mut pi) = (1u128, 1u128, 0i32);
351        for (f, e) in &self.factors {
352            if e.den != 1 {
353                return None;
354            }
355            match f {
356                ScaleFactor::Pi => pi = e.num,
357                ScaleFactor::Prime(p) => {
358                    let pow = u128::from(*p).checked_pow(e.num.unsigned_abs())?;
359                    if e.num > 0 {
360                        num = num.checked_mul(pow)?;
361                    } else {
362                        den = den.checked_mul(pow)?;
363                    }
364                }
365            }
366        }
367        let mut s = num.to_string();
368        if den != 1 {
369            s = format!("{s}/{den}");
370        }
371        Some(match pi {
372            0 => s,
373            1 => format!("{s}*pi"),
374            k => format!("{s}*pi^{k}"),
375        })
376    }
377}
378
379impl std::fmt::Display for ExactScale {
380    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381        match self.ratio_string() {
382            Some(s) => write!(f, "{s}"),
383            None => write!(f, "{:e}", self.to_f64()),
384        }
385    }
386}
387
388/// Represents a physical unit with dimensions
389#[derive(Debug, Clone, PartialEq)]
390pub struct Unit {
391    /// Base dimensions with their (rational) powers
392    dimensions: HashMap<Dimension, Rational>,
393    /// Scale factor for NUMERIC unit conversions. It never decides a
394    /// dimensional verdict; see [`ExactScale`].
395    scale: f64,
396    /// The exact scale every scale agreement is decided on (esm-spec §4.8.1).
397    exact: ExactScale,
398}
399
400/// Base physical dimensions — the eight canonical axes shared across the
401/// bindings (`m kg s mol K A cd rad`).
402///
403/// `Angle` is a full axis rather than a synonym for dimensionless, matching the
404/// Go reference. The trigonometric functions therefore accept an angle OR a
405/// dimensionless argument (see `propagate_transcendental_dim`); `exp`/`log` still
406/// demand strict dimensionlessness.
407#[derive(Debug, Clone, PartialEq, Eq, Hash)]
408pub enum Dimension {
409    Mass,        // M   — kg
410    Length,      // L   — m
411    Time,        // T   — s
412    Current,     // I   — A  (electric current)
413    Temperature, // Θ   — K  (thermodynamic temperature)
414    Amount,      // N   — mol (amount of substance)
415    Luminosity,  // J   — cd (luminous intensity)
416    Angle,       // rad (plane angle)
417}
418
419/// Error types for unit operations
420#[derive(Debug, Clone, PartialEq, Error)]
421pub enum UnitError {
422    #[error("Parse error: {0}")]
423    ParseError(String),
424    #[error("Dimension mismatch: {0}")]
425    DimensionMismatch(String),
426    #[error("Unknown unit: {0}")]
427    UnknownUnit(String),
428}
429
430/// Severity of a dimensional finding — the cross-binding policy that decides
431/// whether a finding blocks validation.
432///
433/// The split is drawn at *provability*, and it is decided AT THE POINT the
434/// finding is raised (mirroring the TypeScript reference, whose `UnitWarning`
435/// carries the same classification):
436///
437/// * [`UnitSeverity::Error`] — a PROVABLE dimensional inconsistency: every
438///   operand's dimension was determined, and they are incompatible. That is a
439///   defect in the FILE, so it is promoted by `structural.rs` to a
440///   `unit_inconsistency` structural error and makes `is_valid` false. The
441///   shared corpus requires this: `tests/invalid/expected_errors.json` pins the
442///   `units_*.esm` fixtures as `is_valid: false` with a structural error, so a
443///   binding that keeps them as warnings ACCEPTS files the corpus pins invalid.
444///
445/// * [`UnitSeverity::Analysis`] — the checker could not DETERMINE a dimension
446///   (unknown variable, unparseable unit string, symbolic/non-literal exponent,
447///   wrong arity, a broadcast missing its `fn`). This reports what the checker
448///   could not conclude, NOT a defect in the file, so it stays a non-blocking
449///   warning and the affected subexpression propagates as [`Dim::Unknown`].
450///
451/// An operator with NO dimensional rule (a rewrite-target sugar op such as
452/// `grad`/`div`/`laplacian`, or any unregistered user op) is the one
453/// undeterminable case that emits NO finding at all: per esm-spec §4.8.4 the
454/// checker reports UNKNOWN and skips silently, matching the other four bindings
455/// (Julia/TS/Python/Go). See [`propagate_operator_dim`].
456///
457/// An unknown *variable* stays `Analysis` here only because it is already a
458/// hard `undefined_variable` structural error — the file is still rejected; we
459/// just do not double-report it.
460#[derive(Debug, Clone, Copy, PartialEq, Eq)]
461pub enum UnitSeverity {
462    /// Provable dimensional inconsistency — blocks validation.
463    Error,
464    /// Undeterminable dimension — non-blocking warning.
465    Analysis,
466}
467
468/// The cross-binding `code` values a dimensional finding can carry, as surfaced
469/// on [`crate::validate::UnitWarning::code`]. Mirrors Go's `UnitFinding*`
470/// constants (`pkg/earthsci-ast-go/pkg/esm/units.go`) and TypeScript's
471/// `UnitWarning['code']` union (`pkg/earthsci-ast-ts/src/units.ts`).
472///
473/// * [`UNIT_FINDING_DIMENSIONAL_MISMATCH`] — a PROVABLE inconsistency: adding
474///   metres to kilograms, an equation whose sides cannot agree. A defect in the
475///   FILE; this binding promotes it to a `unit_inconsistency` structural error.
476/// * [`UNIT_FINDING_UNPARSEABLE`] — a declared unit string that does not denote
477///   a real unit. Also a defect in the FILE; promoted to `unit_parse_error` at
478///   the declaration's own pointer.
479/// * [`UNIT_FINDING_ANALYSIS`] — the checker cannot DETERMINE a dimension. A
480///   limit of the ANALYSIS, not a defect, so it stays a non-blocking warning.
481///
482/// These strings are a cross-binding contract; they must not change.
483pub const UNIT_FINDING_DIMENSIONAL_MISMATCH: &str = crate::diagnostic::codes::DIMENSIONAL_MISMATCH;
484/// See [`UNIT_FINDING_DIMENSIONAL_MISMATCH`].
485pub const UNIT_FINDING_UNPARSEABLE: &str = crate::diagnostic::codes::UNPARSEABLE_UNIT;
486/// See [`UNIT_FINDING_DIMENSIONAL_MISMATCH`].
487pub const UNIT_FINDING_ANALYSIS: &str = crate::diagnostic::codes::ANALYSIS;
488
489/// A single dimensional finding produced while propagating an expression.
490#[derive(Debug, Clone, PartialEq)]
491pub struct UnitFinding {
492    /// Whether this finding blocks validation. See [`UnitSeverity`].
493    pub severity: UnitSeverity,
494    /// Human-readable description of the finding.
495    pub message: String,
496}
497
498impl UnitFinding {
499    fn error(message: impl Into<String>) -> Self {
500        UnitFinding {
501            severity: UnitSeverity::Error,
502            message: message.into(),
503        }
504    }
505
506    fn analysis(message: impl Into<String>) -> Self {
507        UnitFinding {
508            severity: UnitSeverity::Analysis,
509            message: message.into(),
510        }
511    }
512
513    /// True when this finding is a provable inconsistency.
514    pub fn is_error(&self) -> bool {
515        self.severity == UnitSeverity::Error
516    }
517
518    /// The cross-binding finding code implied by this finding's severity, taken
519    /// at the raise site rather than recovered from the message prose.
520    ///
521    /// An unparseable declared unit never reaches a [`UnitFinding`] in this
522    /// binding — it is caught at the declaration and reported directly as a
523    /// `unit_parse_error` structural error — so only the two severities map
524    /// here. See [`UNIT_FINDING_DIMENSIONAL_MISMATCH`].
525    pub fn code(&self) -> &'static str {
526        match self.severity {
527            UnitSeverity::Error => UNIT_FINDING_DIMENSIONAL_MISMATCH,
528            UnitSeverity::Analysis => UNIT_FINDING_ANALYSIS,
529        }
530    }
531}
532
533/// The dimension of a subexpression: either determined, or not.
534///
535/// `Unknown` is what makes multi-finding collection sound. Propagation NEVER
536/// aborts an expression at the first finding — it records the finding, yields
537/// `Unknown` for that subtree, and keeps walking its siblings. An
538/// undeterminable operand therefore cannot SUPPRESS a provable mismatch
539/// elsewhere in the same expression (the old `Result`-based propagation bailed
540/// at the first `Err`, so a single unknown variable hid every real error after
541/// it), and it equally cannot MANUFACTURE one: `Unknown` compares against
542/// nothing, so no finding is raised for it.
543#[derive(Debug, Clone, PartialEq)]
544enum Dim {
545    /// Dimension determined.
546    Known(Unit),
547    /// Dimension could not be determined. Most undeterminable causes record an
548    /// `Analysis` finding; an op with no dimensional rule records none (it is
549    /// reported as unknown and skipped, per esm-spec §4.8.4).
550    Unknown,
551}
552
553impl Dim {
554    fn known(&self) -> Option<&Unit> {
555        match self {
556            Dim::Known(u) => Some(u),
557            Dim::Unknown => None,
558        }
559    }
560}
561
562impl Unit {
563    /// The multiplicative factor taking this unit to its SI base (`atm` → 101325).
564    ///
565    /// NOTE: the representation is purely multiplicative — there is no affine
566    /// OFFSET — so `degC` and `K` are indistinguishable here. A check that must
567    /// separate them (the `default_units` identity check) cannot do so by
568    /// dimension and scale alone.
569    pub fn scale(&self) -> f64 {
570        self.scale
571    }
572
573    /// The exact scale this unit's agreements are decided on (esm-spec §4.8.1).
574    pub fn exact_scale(&self) -> &ExactScale {
575        &self.exact
576    }
577
578    /// Whether two units agree in dimension AND exact scale — what `+`, `-`,
579    /// comparisons and the two sides of an equation require (esm-spec §4.8.3).
580    pub fn same_unit(&self, other: &Unit) -> bool {
581        self.is_compatible(other) && self.exact == other.exact
582    }
583
584    /// This unit with its exact scale set. Every registry entry whose scale is
585    /// not 1 is built through this.
586    fn with_exact(mut self, exact: ExactScale) -> Self {
587        self.exact = exact;
588        self
589    }
590
591    /// Whether two units measure the same physical quantity, ignoring scale
592    /// (`atm` and `Pa` are the same dimension at different scales).
593    pub fn same_dimensions(&self, other: &Unit) -> bool {
594        self.dimensions == other.dimensions
595    }
596
597    /// Create a dimensionless unit
598    pub fn dimensionless() -> Self {
599        Unit {
600            dimensions: HashMap::new(),
601            scale: 1.0,
602            exact: ExactScale::one(),
603        }
604    }
605
606    /// Create a unit with a single dimension raised to an integer power.
607    ///
608    /// `scale` is the NUMERIC conversion factor only; the unit's
609    /// [`ExactScale`] is 1, so a scaled unit built here agrees exactly with its
610    /// unscaled base (esm-spec §4.8.3). Use [`parse_unit`] for a scaled unit.
611    pub fn base(dimension: Dimension, power: i32, scale: f64) -> Self {
612        Unit::base_rational(dimension, Rational::int(power), scale)
613    }
614
615    /// Create a unit with a single dimension raised to a RATIONAL power (e.g.
616    /// `s^-1/2`, the dimension of an SDE noise intensity). As with [`Unit::base`],
617    /// the exact scale is 1 whatever `scale` is.
618    pub fn base_rational(dimension: Dimension, power: Rational, scale: f64) -> Self {
619        let mut dimensions = HashMap::new();
620        if !power.is_zero() {
621            dimensions.insert(dimension, power);
622        }
623        Unit {
624            dimensions,
625            scale,
626            exact: ExactScale::one(),
627        }
628    }
629
630    /// Check if two units have compatible dimensions
631    pub fn is_compatible(&self, other: &Unit) -> bool {
632        self.dimensions == other.dimensions
633    }
634
635    /// Check if this unit is dimensionless
636    pub fn is_dimensionless(&self) -> bool {
637        self.dimensions.is_empty()
638    }
639
640    /// True when this unit is dimensionless **at scale 1** — a PURE NUMBER.
641    ///
642    /// This is what esm-spec §4.8.3's "the argument MUST be dimensionless"
643    /// means for a strict transcendental (issue #409): `ppm` and `percent` are
644    /// dimensionless too, and `log(x [ppm])` has two defensible readings —
645    /// the log of the ppm NUMBER, or the log of the mole fraction — that differ
646    /// by `ln(1e-6) = 13.8155…`. Dimension alone cannot tell them apart, so the
647    /// checker refuses rather than picking one.
648    pub fn is_pure_number(&self) -> bool {
649        self.dimensions.is_empty() && self.exact.is_one()
650    }
651
652    /// True when this unit is a plane angle to the FIRST power (`rad`, `deg`,
653    /// `mrad`) — the shape a circular trig function's argument may carry.
654    ///
655    /// The exponent matters because the argument is CONVERTED to radians
656    /// (issue #409): `rad^2` is not an angle any conversion turns into one, and
657    /// accepting it would multiply by `scale` where `scale^2` was meant.
658    pub fn is_plane_angle(&self) -> bool {
659        self.dimensions.len() == 1
660            && self
661                .dimensions
662                .get(&Dimension::Angle)
663                .is_some_and(|p| *p == Rational::int(1))
664    }
665
666    /// Multiply two units
667    pub fn multiply(&self, other: &Unit) -> Unit {
668        let mut unit = self.combine(other, Rational::add, self.scale * other.scale);
669        unit.exact = self.exact.multiply(&other.exact);
670        unit
671    }
672
673    /// Divide two units
674    pub fn divide(&self, other: &Unit) -> Unit {
675        let mut unit = self.combine(other, Rational::sub, self.scale / other.scale);
676        unit.exact = self.exact.divide(&other.exact);
677        unit
678    }
679
680    /// Merge `other`'s exponents into a copy of `self`'s with `op`, dropping any
681    /// axis whose exponent cancels to zero.
682    fn combine(&self, other: &Unit, op: fn(Rational, Rational) -> Rational, scale: f64) -> Unit {
683        let mut dimensions = self.dimensions.clone();
684        for (dim, power) in &other.dimensions {
685            let entry = dimensions.entry(dim.clone()).or_insert(Rational::int(0));
686            *entry = op(*entry, *power);
687            if entry.is_zero() {
688                dimensions.remove(dim);
689            }
690        }
691        Unit {
692            dimensions,
693            scale,
694            exact: ExactScale::one(),
695        }
696    }
697
698    /// Raise unit to an integer power.
699    pub fn power(&self, exponent: i32) -> Unit {
700        self.power_rational(Rational::int(exponent))
701    }
702
703    /// Raise unit to a RATIONAL power. `sqrt` is `power_rational(1/2)`, and a
704    /// literal `^0.5` in an expression lands here too.
705    pub fn power_rational(&self, exponent: Rational) -> Unit {
706        let dimensions = self
707            .dimensions
708            .iter()
709            .map(|(dim, power)| (dim.clone(), power.mul(exponent)))
710            .filter(|(_, power)| !power.is_zero())
711            .collect();
712
713        Unit {
714            dimensions,
715            scale: self.scale.powf(exponent.as_f64()),
716            exact: self.exact.power(exponent),
717        }
718    }
719
720    /// Propagate units through an expression AST.
721    ///
722    /// Given a variable→unit environment, returns the [`Unit`] of the whole
723    /// expression by recursively walking its AST. Returns a
724    /// [`UnitError::DimensionMismatch`] when the expression is dimensionally
725    /// inconsistent (e.g. adding `m` to `s`, using a non-dimensionless
726    /// exponent, or passing a dimensional argument to `exp`/`log`/`sin`).
727    ///
728    /// The `t` identifier is treated as the implicit time variable with units
729    /// of seconds if not otherwise present in `env`.
730    ///
731    /// This is the single-outcome (`Result`) view, kept for callers that only
732    /// need "does this expression have a unit". Validation uses
733    /// [`check_expression_dimensions`], which reports EVERY finding rather than
734    /// only the first.
735    ///
736    /// # Arguments
737    ///
738    /// * `expr` - Expression to analyse.
739    /// * `env`  - Map from variable name to its [`Unit`].
740    pub fn propagate(expr: &Expr, env: &HashMap<String, Unit>) -> Result<Unit, UnitError> {
741        Self::propagate_in(expr, env)
742    }
743
744    /// [`Unit::propagate`] against any [`UnitEnv`].
745    pub(crate) fn propagate_in(expr: &Expr, env: &dyn UnitEnv) -> Result<Unit, UnitError> {
746        let mut findings = Vec::new();
747        let dim = propagate_dim(expr, env, &mut findings);
748        // A provable inconsistency outranks an undeterminable dimension.
749        if let Some(err) = findings.iter().find(|f| f.is_error()) {
750            return Err(UnitError::DimensionMismatch(err.message.clone()));
751        }
752        match dim {
753            Dim::Known(unit) => Ok(unit),
754            Dim::Unknown => Err(UnitError::UnknownUnit(
755                findings
756                    .first()
757                    .map(|f| f.message.clone())
758                    .unwrap_or_else(|| "undetermined dimension".to_string()),
759            )),
760        }
761    }
762}
763
764/// Propagate the dimension of `expr`, recording every finding in `findings`.
765///
766/// Never aborts: an undeterminable subtree yields [`Dim::Unknown`] and the walk
767/// continues, so all provable mismatches in the expression are reported.
768fn propagate_dim(expr: &Expr, env: &dyn UnitEnv, findings: &mut Vec<UnitFinding>) -> Dim {
769    match expr {
770        // A BARE NUMERIC LITERAL has an INDETERMINATE dimension, not a
771        // dimensionless one: nothing in the AST says whether `273.15` is a pure
772        // number or a temperature offset, or whether `6.022e23` is Avogadro's
773        // constant. Calling it dimensionless manufactured a mismatch on every
774        // line of the valid corpus that uses an implicit-unit constant
775        // (`T - 273.15`, `1 - phi`).
776        //
777        // This matters BECAUSE these findings are now hard errors: a checker
778        // that fails the build must not fabricate a dimension it cannot know.
779        // It costs nothing on the pinned invalid corpus — every fixture there
780        // states its inconsistency between DECLARED quantities (`length + mass`,
781        // `ln(mass)`, `m^kg`), never via a literal. Literals still behave
782        // correctly wherever their meaning IS determined: additively they are
783        // neutral and adopt their sibling's dimension, an all-literal expression
784        // is dimensionless, and an exponent is read BY VALUE.
785        Expr::Number(_) | Expr::Integer(_) => Dim::Unknown,
786        Expr::Variable(name) => {
787            if let Some(unit) = env.unit_of(name) {
788                Dim::Known(unit.clone())
789            } else if name == "t" {
790                // Implicit time variable.
791                Dim::Known(Unit::base(Dimension::Time, 1, 1.0))
792            } else {
793                // Dimension unknown — but NOT reported here, because the reason
794                // is always already reported at the variable itself, and this
795                // path fires once per REFERENCE:
796                //   * genuinely undeclared  ⇒ hard `undefined_variable` error;
797                //   * unparseable unit      ⇒ `build_unit_env` warns once;
798                //   * no `units` field      ⇒ a legitimate choice, not a defect.
799                // Re-reporting per reference would only duplicate those.
800                Dim::Unknown
801            }
802        }
803        Expr::Operator(op) => propagate_operator_dim(op, env, findings),
804    }
805}
806
807/// Propagate every argument, returning their dimensions positionally.
808fn propagate_args(
809    op: &ExpressionNode,
810    env: &dyn UnitEnv,
811    findings: &mut Vec<UnitFinding>,
812) -> Vec<Dim> {
813    op.args
814        .iter()
815        .map(|a| propagate_dim(a, env, findings))
816        .collect()
817}
818
819/// Require exactly `n` arguments. Arity is enforced by the operator registry
820/// and the schema; here a wrong arity simply means we cannot analyse the node,
821/// so it is an `Analysis` finding rather than a dimensional defect.
822fn require_arity(op: &ExpressionNode, n: usize, findings: &mut Vec<UnitFinding>) -> bool {
823    if op.args.len() == n {
824        return true;
825    }
826    findings.push(UnitFinding::analysis(format!(
827        "'{}' expects {} argument(s) but has {}; not dimension-checked",
828        op.op,
829        n,
830        op.args.len()
831    )));
832    false
833}
834
835/// Like [`require_arity`], but a LOWER bound: the node is well-formed when it has
836/// at least `n` operands. Used for the ops whose operand list is open-ended at
837/// this end — a rewrite-target `D` carrying trailing auxiliary boundary operands
838/// (esm-spec §4.2 "Arity of `D`").
839fn require_min_arity(op: &ExpressionNode, n: usize, findings: &mut Vec<UnitFinding>) -> bool {
840    if op.args.len() >= n {
841        return true;
842    }
843    findings.push(UnitFinding::analysis(format!(
844        "'{}' expects at least {} argument(s) but has {}; not dimension-checked",
845        op.op,
846        n,
847        op.args.len()
848    )));
849    false
850}
851
852/// Dispatch one operator node to its family's propagation helper. Kept as a
853/// thin match so each family's dimensional rules live in one named function;
854/// the rules mirror the Python (`units.py::_get_expression_dimension`) and
855/// Julia (`units.jl::get_expression_dimensions`) implementations.
856fn propagate_operator_dim(
857    op: &ExpressionNode,
858    env: &dyn UnitEnv,
859    findings: &mut Vec<UnitFinding>,
860) -> Dim {
861    match op.op.as_str() {
862        "+" | "-" => propagate_additive_dim(op, env, findings),
863        "*" | "/" => propagate_multiplicative_dim(op, env, findings),
864        "^" | "power" | "pow" => propagate_power_dim(op, env, findings),
865        "D" | "ic" => propagate_calculus_dim(op, env, findings),
866        "exp" | "log" | "log10" | "ln" | "sin" | "cos" | "tan" | "asin" | "acos" | "atan"
867        | "sinh" | "cosh" | "tanh" | "asinh" | "acosh" | "atanh" => {
868            propagate_transcendental_dim(op, env, findings)
869        }
870        "sqrt" => propagate_sqrt_dim(op, env, findings),
871        // abs/sign/floor/ceil preserve dimensions; `Pre` is an initial-value
872        // marker — same dimensions as its argument.
873        "abs" | "floor" | "ceil" | "round" | "sign" | "Pre" => {
874            if !require_arity(op, 1, findings) {
875                return Dim::Unknown;
876            }
877            propagate_dim(&op.args[0], env, findings)
878        }
879        "min" | "max" => propagate_matching_dim(op, env, findings),
880        "atan2" => {
881            if !require_arity(op, 2, findings) {
882                return Dim::Unknown;
883            }
884            // Both arguments must share dimensions (their RATIO is what is fed
885            // to the arctangent). The result is an ANGLE — `atan2(y, x)` is the
886            // canonical spelling of a bearing.
887            propagate_matching_dim(op, env, findings);
888            Dim::Known(radian())
889        }
890        "ifelse" => propagate_ifelse_dim(op, env, findings),
891        ">" | "<" | ">=" | "<=" | "==" | "!=" => {
892            if !require_arity(op, 2, findings) {
893                return Dim::Known(Unit::dimensionless());
894            }
895            // Operands must be comparable; the flag itself is dimensionless.
896            propagate_matching_dim(op, env, findings);
897            Dim::Known(Unit::dimensionless())
898        }
899        "and" | "or" | "not" => {
900            // A boolean connective's result is a dimensionless boolean. Its
901            // operands carry no unit requirement of their own, but they are
902            // walked so a mismatch inside one (`not(x [m] > z [kg])`) is
903            // reported (esm-spec §4.8.3).
904            propagate_args(op, env, findings);
905            Dim::Known(Unit::dimensionless())
906        }
907        // Array operators: propagate the element dimension. Shape and
908        // indexing are orthogonal to dimension (see gt-t5c / gt-vt3 — shapes
909        // are a separate concern from unit checking).
910        "faq" | "makearray" | "index" | "reshape" | "transpose" | "concat" | "broadcast" => {
911            propagate_array_dim(op, env, findings)
912        }
913        // A `const` that DECLARES its units has that unit (esm-spec §4.8.5);
914        // without `units` it is undeterminable, like a bare literal. An
915        // unresolvable string is reported at the containing expression field by
916        // `structural.rs` (see [`unresolvable_const_units`]), not here.
917        "const" => match op.units.as_deref().map(parse_unit) {
918            Some(Ok(unit)) => Dim::Known(unit),
919            _ => Dim::Unknown,
920        },
921        // No dimensional rule for this operator — an unregistered user op, or a
922        // rewrite-target sugar op (`grad`/`div`/`laplacian`/`fn`/`table_lookup`/
923        // `godunov_hamiltonian`/…) whose dimension is UNDETERMINABLE until a
924        // discretization rule lowers it (esm-spec §4.8.4). The checker reports
925        // UNKNOWN and SKIPS: it emits NO finding, matching the other four
926        // bindings (Julia/TS/Python/Go), which return an unknown dimension and
927        // let their callers skip the check. The node's dimension is `Unknown` —
928        // NOT a silent `dimensionless`, which would manufacture false mismatches
929        // in the enclosing expression. `findings` is left untouched so nothing
930        // is singled out; `_` treats `grad` exactly like any other no-rule op.
931        _ => Dim::Unknown,
932    }
933}
934
935/// Report a provable mismatch among the operands that DID resolve, and return
936/// the shared dimension. Used by `+`/`-`, `min`/`max`, comparisons and `atan2`.
937///
938/// Two operands are only ever compared when BOTH dimensions were determined —
939/// an undeterminable operand is skipped, never assumed dimensionless, so it can
940/// neither hide nor manufacture a mismatch (esm-spec §4.8.3). A numeric literal
941/// is undeterminable, so it adopts the dimension of what it is combined with
942/// (`T - 273.15` is a temperature), and `x + 0.5 * y` has the dimension of `x`.
943/// If NO operand resolves (`1 + 2`), the result is undeterminable, never
944/// dimensionless (§4.8.4).
945fn propagate_matching_dim(
946    op: &ExpressionNode,
947    env: &dyn UnitEnv,
948    findings: &mut Vec<UnitFinding>,
949) -> Dim {
950    let mut first: Option<Unit> = None;
951    for arg in &op.args {
952        let dim = propagate_dim(arg, env, findings);
953        let Some(unit) = dim.known() else {
954            continue;
955        };
956        match &first {
957            None => first = Some(unit.clone()),
958            Some(f) if !f.is_compatible(unit) => {
959                findings.push(UnitFinding::error(format!(
960                    "Incompatible dimensions in '{}': {} vs {}",
961                    op.op,
962                    describe(f),
963                    describe(unit)
964                )));
965            }
966            Some(f) if f.exact != unit.exact => {
967                findings.push(UnitFinding::error(format!(
968                    "Incompatible scales in '{}': {} vs {}",
969                    op.op,
970                    describe_scaled(f),
971                    describe_scaled(unit)
972                )));
973            }
974            _ => {}
975        }
976    }
977    match first {
978        Some(unit) => Dim::Known(unit),
979        None => Dim::Unknown,
980    }
981}
982
983/// `+` / `-`: every operand must share dimensions; the result carries them.
984/// A unary `+` or `-` carries its single operand's dimension unchanged, so
985/// `+(2)` is as undeterminable as `2`.
986fn propagate_additive_dim(
987    op: &ExpressionNode,
988    env: &dyn UnitEnv,
989    findings: &mut Vec<UnitFinding>,
990) -> Dim {
991    propagate_matching_dim(op, env, findings)
992}
993
994/// `*` / `/`: dimensions multiply / divide, no compatibility requirement.
995fn propagate_multiplicative_dim(
996    op: &ExpressionNode,
997    env: &dyn UnitEnv,
998    findings: &mut Vec<UnitFinding>,
999) -> Dim {
1000    let dims = propagate_args(op, env, findings);
1001    if op.op == "*" {
1002        let mut result = Unit::dimensionless();
1003        for d in &dims {
1004            match d.known() {
1005                Some(u) => result = result.multiply(u),
1006                None => return Dim::Unknown,
1007            }
1008        }
1009        return Dim::Known(result);
1010    }
1011    if !require_arity(op, 2, findings) {
1012        return Dim::Unknown;
1013    }
1014    match (dims[0].known(), dims[1].known()) {
1015        (Some(num), Some(den)) => Dim::Known(num.divide(den)),
1016        _ => Dim::Unknown,
1017    }
1018}
1019
1020/// `^` / `power` / `pow`: the exponent must be dimensionless; a dimensional
1021/// base additionally requires a literal integer exponent (only integer powers
1022/// of dimensions are representable).
1023fn propagate_power_dim(
1024    op: &ExpressionNode,
1025    env: &dyn UnitEnv,
1026    findings: &mut Vec<UnitFinding>,
1027) -> Dim {
1028    if !require_arity(op, 2, findings) {
1029        return Dim::Unknown;
1030    }
1031    let base = propagate_dim(&op.args[0], env, findings);
1032    let exp = propagate_dim(&op.args[1], env, findings);
1033
1034    // A dimensional exponent is provably wrong regardless of the base.
1035    if let Some(e) = exp.known()
1036        && !e.is_dimensionless()
1037    {
1038        findings.push(UnitFinding::error(format!(
1039            "Exponent in '{}' must be dimensionless, got {}",
1040            op.op,
1041            describe(e)
1042        )));
1043        return Dim::Unknown;
1044    }
1045
1046    let Some(base_unit) = base.known() else {
1047        return Dim::Unknown;
1048    };
1049    // A dimensionless base of scale 1 stays exactly that under any exponent, so
1050    // a non-literal exponent is only a problem for a DIMENSIONAL or SCALED base
1051    // (`x^2` with `x` in `%` has scale 1/10000).
1052    if base_unit.is_dimensionless() && base_unit.exact.is_one() {
1053        return Dim::Known(Unit::dimensionless());
1054    }
1055
1056    // The exponent is read BY VALUE, not by dimension — a literal's dimension is
1057    // indeterminate, but its VALUE is exactly what a power needs. A constant
1058    // expression over literals (`3/2`, `-(1)`) has an exact value too, and is
1059    // folded to it (esm-spec §4.8.3 `^`). Exponents are RATIONAL, so a
1060    // fractional power of a dimensional quantity (`x^0.5`) propagates exactly.
1061    if let Some(r) = constant_exponent(&op.args[1]) {
1062        return Dim::Known(base_unit.power_rational(r));
1063    }
1064    match &op.args[1] {
1065        Expr::Integer(_) | Expr::Number(_) => {
1066            let n = literal_f64(&op.args[1]).unwrap_or(f64::NAN);
1067            findings.push(UnitFinding::analysis(format!(
1068                "Exponent {n} is not a representable rational, so the dimension of a \
1069                 dimensional base is unknown"
1070            )));
1071        }
1072        // Symbolic exponent (`x^n` for a parameter `n`): the resulting
1073        // dimension depends on a value we do not have. Undeterminable, NOT a
1074        // mismatch (§4.8.4).
1075        _ => {
1076            findings.push(UnitFinding::analysis(format!(
1077                "Exponent of '{}' is not a constant expression, so the dimension of a \
1078                 dimensional base is unknown",
1079                op.op
1080            )));
1081        }
1082    }
1083    Dim::Unknown
1084}
1085
1086/// A numeric literal's value. It may arrive as EITHER `Expr::Number` (JSON
1087/// `2.0`) or `Expr::Integer` (JSON `2`).
1088fn literal_f64(expr: &Expr) -> Option<f64> {
1089    match expr {
1090        Expr::Integer(i) => Some(*i as f64),
1091        Expr::Number(n) => Some(*n),
1092        _ => None,
1093    }
1094}
1095
1096/// The exact rational value of a CONSTANT-EXPRESSION exponent (esm-spec §4.8.3
1097/// `^`), or `None` when the exponent is symbolic.
1098///
1099/// Folds: a numeric literal; a `const` that declares no `units`, or a
1100/// dimensionless unit at exact scale 1 (its value IS the pure number it
1101/// denotes); and `+`, `*` (one or more operands), `-` (unary negation, or a left
1102/// fold of two or more) and `/` (exactly two operands, nonzero divisor) over
1103/// constant expressions, nested to any depth. Anything else — a variable, an
1104/// expression containing one, a `const` at a scale other than 1 (`%`: its value
1105/// and the quantity it denotes disagree), any other operator, an overflow of
1106/// the rational representation — is symbolic, which leaves the power of a
1107/// dimensional base undeterminable (§4.8.4).
1108pub fn constant_exponent(expr: &Expr) -> Option<Rational> {
1109    if let Some(n) = literal_f64(expr) {
1110        return Rational::from_f64(n);
1111    }
1112    let Expr::Operator(node) = expr else {
1113        return None;
1114    };
1115    if node.op == "const" {
1116        if let Some(units) = &node.units {
1117            let unit = parse_unit(units).ok()?;
1118            if !unit.is_pure_number() {
1119                return None;
1120            }
1121        }
1122        return node
1123            .value
1124            .as_ref()
1125            .and_then(serde_json::Value::as_f64)
1126            .and_then(Rational::from_f64);
1127    }
1128    let values = node
1129        .args
1130        .iter()
1131        .map(constant_exponent)
1132        .collect::<Option<Vec<Rational>>>()?;
1133    let (first, rest) = values.split_first()?;
1134    match node.op.as_str() {
1135        "+" => rest.iter().try_fold(*first, |acc, v| acc.checked_add(*v)),
1136        "*" => rest.iter().try_fold(*first, |acc, v| acc.checked_mul(*v)),
1137        "-" if rest.is_empty() => Rational::int(0).checked_sub(*first),
1138        "-" => rest.iter().try_fold(*first, |acc, v| acc.checked_sub(*v)),
1139        "/" if rest.len() == 1 => first.checked_div(rest[0]),
1140        _ => None,
1141    }
1142}
1143
1144/// Calculus-family operators: the time/coordinate derivative `D` divides its
1145/// argument's dimensions by the `wrt` variable's, and the initial-condition
1146/// marker `ic` (v0.8.0) is dimension-preserving — the initial value of a field
1147/// carries the same units as the field itself.
1148fn propagate_calculus_dim(
1149    op: &ExpressionNode,
1150    env: &dyn UnitEnv,
1151    findings: &mut Vec<UnitFinding>,
1152) -> Dim {
1153    // esm-spec §4.2 "Arity of `D`": a REWRITE-TARGET `D` (spatial `wrt`) MAY
1154    // carry trailing auxiliary operands after `args[0]` — the per-face
1155    // boundary/halo values a discretization rule binds and consumes. They carry
1156    // no evaluator semantics, so the dimensional rule reads `args[0]` alone and
1157    // MUST NOT report them as an arity defect. The STRUCTURAL time derivative
1158    // (and `ic`) stay strictly unary.
1159    let well_formed = if crate::op_registry::is_rewrite_target_derivative(op) {
1160        require_min_arity(op, 1, findings)
1161    } else {
1162        require_arity(op, 1, findings)
1163    };
1164    if !well_formed {
1165        return Dim::Unknown;
1166    }
1167    let arg = propagate_dim(&op.args[0], env, findings);
1168    if op.op == "ic" {
1169        return arg;
1170    }
1171    let wrt = crate::op_registry::derivative_wrt(op);
1172    // The independent variable's unit is only known if it was DECLARED. We do
1173    // not assume an undeclared `t` means seconds: that would give `D(x)/dt` a
1174    // dimension of `[x]/second`, and every dimensionless toy model
1175    // (`D(x)/dt = -x`, the idiom used across the shared corpus) would then look
1176    // like a mismatch of `time^-1` against `dimensionless`. An undeclared
1177    // independent variable leaves the derivative INDETERMINATE here; the
1178    // equation-level time-ratio rule in `check_equation_dimensions` still
1179    // catches derivative equations that no time unit could reconcile.
1180    let (Some(arg_unit), Some(wrt_unit)) = (arg.known(), env.unit_of(wrt)) else {
1181        return Dim::Unknown;
1182    };
1183    Dim::Known(arg_unit.divide(wrt_unit))
1184}
1185
1186/// True when `expr` is `D(x)` taken with respect to an UNDECLARED independent
1187/// variable, which makes its dimension indeterminate. Mirrors the TypeScript
1188/// reference (`units.ts::derivativeOfUndeclaredTime`).
1189fn derivative_of_undeclared_time<'a>(expr: &'a Expr, env: &dyn UnitEnv) -> Option<&'a Expr> {
1190    let Expr::Operator(op) = expr else {
1191        return None;
1192    };
1193    if op.op != "D" || op.args.len() != 1 {
1194        return None;
1195    }
1196    let wrt = crate::op_registry::derivative_wrt(op);
1197    (!env.contains(wrt)).then(|| &op.args[0])
1198}
1199
1200/// The weaker-but-still-provable rule for `d(state)/dt = rhs` when the time
1201/// unit is unknown: whatever time unit `t` carries, it can only ever contribute
1202/// powers of TIME to the ratio. So if `dims(state) / dims(rhs)` contains any
1203/// NON-time dimension, no choice of time unit could reconcile the two sides and
1204/// the equation is provably inconsistent. A ratio that is a pure power of time
1205/// (including time^0) is accepted. Mirrors `units.ts::derivativeTimeMismatch`.
1206fn derivative_time_mismatch(state: &Unit, rhs: &Unit) -> Option<String> {
1207    let ratio = state.divide(rhs);
1208    let unreconcilable = ratio
1209        .dimensions
1210        .iter()
1211        .any(|(d, p)| *d != Dimension::Time && !p.is_zero());
1212    unreconcilable.then(|| {
1213        format!(
1214            "No time unit can reconcile d({})/dt with {} (their ratio {} is not a power of time)",
1215            describe(state),
1216            describe(rhs),
1217            describe(&ratio)
1218        )
1219    })
1220}
1221
1222/// Transcendental and trigonometric functions: argument must be dimensionless,
1223/// result is dimensionless.
1224///
1225/// `sqrt` is NOT in this family — it halves its argument's dimensions (see
1226/// [`propagate_sqrt_dim`]).
1227///
1228/// The TRIGONOMETRIC members additionally accept a plane ANGLE at ANY scale:
1229/// `rad` is a real dimension axis here, so `sin(theta)` with `theta` in radians
1230/// — or in `deg` — is correct, not an error, and the flatten path converts the
1231/// scaled spelling to radians before anything evaluates it. Everything else
1232/// demands a PURE NUMBER: dimensionless AND at scale 1 (issue #409).
1233fn propagate_transcendental_dim(
1234    op: &ExpressionNode,
1235    env: &dyn UnitEnv,
1236    findings: &mut Vec<UnitFinding>,
1237) -> Dim {
1238    // `rad` is a real axis here, so the circular functions are NOT symmetric —
1239    // and getting this wrong is silently destructive in both directions:
1240    //
1241    // * FORWARD circular (`sin`/`cos`/`tan`) map an ANGLE to a ratio: they
1242    //   accept an angle OR a dimensionless argument, and return DIMENSIONLESS.
1243    //   (`sin(kg)` is still an error.)
1244    // * INVERSE circular (`asin`/`acos`/`atan`) map a ratio to an ANGLE: they
1245    //   require a DIMENSIONLESS argument and RETURN AN ANGLE. Asserting a
1246    //   dimensionless result while `rad` is an axis makes every
1247    //   `zenith: "rad"` computed by `acos(...)` a guaranteed false mismatch.
1248    // * The HYPERBOLIC family takes and returns pure numbers — a hyperbolic
1249    //   "angle" is an area, not a plane angle, so `rad` is not involved.
1250    // * `exp`/`log` demand strict dimensionlessness: their Taylor series adds
1251    //   powers of the argument.
1252    let is_inverse_circular = matches!(op.op.as_str(), "asin" | "acos" | "atan");
1253    let result = if is_inverse_circular {
1254        radian()
1255    } else {
1256        Unit::dimensionless()
1257    };
1258
1259    if !require_arity(op, 1, findings) {
1260        // The result is fixed by the operator whatever the argument turns out
1261        // to be.
1262        return Dim::Known(result);
1263    }
1264
1265    // Only the forward circular functions accept an angle.
1266    let angle_ok = matches!(op.op.as_str(), "sin" | "cos" | "tan");
1267
1268    let arg = propagate_dim(&op.args[0], env, findings);
1269    if let Some(u) = arg.known() {
1270        // Three outcomes, and the middle one is the point of issue #409.
1271        //
1272        // * A PURE NUMBER (dimensionless at scale 1) is always admissible.
1273        // * An ANGLE is admissible to `sin`/`cos`/`tan` WHATEVER its scale,
1274        //   because `deg` → `rad` is exact and has no second reading: the
1275        //   evaluation path converts it (`normalize_angle_arguments`).
1276        // * Dimensionless at a scale OTHER than 1 (`ppm`, `percent`) is
1277        //   REFUSED. It passes any dimension-only test, which is how
1278        //   `log(x [ppm])` evaluated `log` of the ppm number with no
1279        //   diagnostic; the two readings differ by `ln(1e-6)` and nothing in
1280        //   the document says which was meant.
1281        if angle_ok && u.is_plane_angle() {
1282            // Converted, not refused.
1283        } else if u.is_pure_number() {
1284            // Nothing to say.
1285        } else if u.is_dimensionless() {
1286            findings.push(UnitFinding::error(scaled_dimensionless_message(&op.op, u)));
1287        } else {
1288            findings.push(UnitFinding::error(format!(
1289                "Argument to '{}' must be dimensionless{}, got {}",
1290                op.op,
1291                if angle_ok { " (or an angle)" } else { "" },
1292                describe(u)
1293            )));
1294        }
1295    }
1296    Dim::Known(result)
1297}
1298
1299/// The factor that brings a quantity in `unit` to RADIANS, when `unit` is a
1300/// plane angle at a scale other than 1 (esm-spec §4.8.3, issue #409).
1301///
1302/// `None` for anything else — a pure number, a `rad`, a dimensional unit — so a
1303/// document that declares no scaled angle is rewritten not at all.
1304#[must_use]
1305pub fn angle_normalization_factor(unit: &Unit) -> Option<f64> {
1306    (unit.is_plane_angle() && !unit.exact.is_one()).then_some(unit.scale)
1307}
1308
1309/// Rewrite every `sin`/`cos`/`tan` whose argument is an angle at a scale other
1310/// than 1 so the argument reaches the evaluator in RADIANS.
1311///
1312/// esm-spec §4.8.3 names angles as *the* exception to the dimensionless-argument
1313/// rule, and `deg` is a registry unit at scale π/180 — so `sin(theta)` with
1314/// `theta` in `deg` is a CONFORMING document, and before this it evaluated
1315/// `sin(90)` = 0.894 where 1 was meant, with no diagnostic (issue #409). The
1316/// conversion is exact and has no second reading, which is why this half
1317/// converts where the `ppm` half refuses.
1318///
1319/// Returns `None` when nothing changed, so the common case clones nothing.
1320/// The rewrite runs on the EVALUATION path only (the flatten funnel); the
1321/// checker still sees the authored spelling, and so still reports the argument's
1322/// declared unit rather than a literal-poisoned `unknown`.
1323#[must_use]
1324pub fn normalize_angle_arguments(expr: &Expr, env: &HashMap<String, Unit>) -> Option<Expr> {
1325    let Expr::Operator(node) = expr else {
1326        return None;
1327    };
1328
1329    // Rewrite the children first, so a nested `sin(deg)` is converted too —
1330    // EVERY expression-bearing child (`ExpressionNode::for_each_child`), not a
1331    // hand list: a trig call in a `makearray` region value, a `table_lookup`
1332    // axis input, a `faq` filter or an aggregate key is as much an evaluated
1333    // argument as one in `args`. The node is cloned only when something in it
1334    // changes.
1335    let mut children: Vec<Option<Expr>> = Vec::new();
1336    node.for_each_child(&mut |child| children.push(normalize_angle_arguments(child, env)));
1337
1338    // Then this node: a circular trig whose (possibly rewritten) argument is a
1339    // scaled angle gets the factor folded in. `args` are the first children
1340    // visited, so `children[0]` is the rewritten `args[0]`.
1341    let mut factor = None;
1342    if matches!(node.op.as_str(), "sin" | "cos" | "tan") && node.args.len() == 1 {
1343        let arg = children[0].as_ref().unwrap_or(&node.args[0]);
1344        let mut findings = Vec::new();
1345        factor = propagate_dim(arg, env, &mut findings)
1346            .known()
1347            .and_then(angle_normalization_factor);
1348    }
1349
1350    if factor.is_none() && children.iter().all(Option::is_none) {
1351        return None;
1352    }
1353    let mut out = (**node).clone();
1354    let mut rewritten = children.into_iter();
1355    out.for_each_child_mut(&mut |child| {
1356        if let Some(Some(next)) = rewritten.next() {
1357            *child = next;
1358        }
1359    });
1360    if let Some(factor) = factor {
1361        let scaled = ExpressionNode {
1362            op: "*".to_string(),
1363            args: vec![out.args[0].clone(), Expr::Number(factor)],
1364            ..Default::default()
1365        };
1366        out.args[0] = Expr::operator(scaled);
1367    }
1368    Some(Expr::operator(out))
1369}
1370
1371/// The radian — the unit of the `Angle` axis, returned by the inverse circular
1372/// functions.
1373fn radian() -> Unit {
1374    Unit::base(Dimension::Angle, 1, 1.0)
1375}
1376
1377/// The registry spellings of the dimensionless-but-scaled units, most common
1378/// first, used to NAME a scale in a diagnostic (`… (ppm); divide by 1 ppm …`).
1379///
1380/// A fixed, ordered table rather than a reverse sweep of the registry: the
1381/// registry is a `HashMap`, so a sweep would pick `ppmv` or `ppm` depending on
1382/// iteration order and the five bindings would print different messages for the
1383/// same document. The same table, in the same order, is in every binding.
1384const SCALED_DIMENSIONLESS_SPELLINGS: &[(&str, i32)] =
1385    &[("percent", -2), ("ppm", -6), ("ppb", -9), ("ppt", -12)];
1386
1387/// The canonical registry spelling of a dimensionless unit at `exact`, when one
1388/// exists (`1/100` → `percent`, `1e-6` → `ppm`).
1389#[must_use]
1390pub fn scaled_dimensionless_spelling(exact: &ExactScale) -> Option<&'static str> {
1391    SCALED_DIMENSIONLESS_SPELLINGS
1392        .iter()
1393        .find(|(_, k)| ExactScale::power_of_ten(*k) == *exact)
1394        .map(|(name, _)| *name)
1395}
1396
1397/// The §4.8.3 refusal for a dimensionless-but-SCALED argument to an op that
1398/// requires a pure number (esm-spec §9.6.6 `unit_transcendental_scaled_argument`).
1399///
1400/// It names the REPAIR, not only the refusal: the author states the reading by
1401/// dividing by a quantity carrying the scale, which costs one node and records
1402/// the decision in the document. Normalizing silently instead would change the
1403/// numbers of every document that already passes a `percent` or a `ppm` into
1404/// `exp`/`log`.
1405fn scaled_dimensionless_message(op: &str, unit: &Unit) -> String {
1406    match scaled_dimensionless_spelling(&unit.exact) {
1407        Some(name) => format!(
1408            "Argument to '{op}' must be dimensionless at scale 1, but is \
1409             dimensionless at scale {} ({name}); divide by 1 {name}, or by the \
1410             scale you mean, to state which reading is intended",
1411            unit.exact
1412        ),
1413        None => format!(
1414            "Argument to '{op}' must be dimensionless at scale 1, but is \
1415             dimensionless at scale {}; divide by a quantity carrying that \
1416             scale to state which reading is intended",
1417            unit.exact
1418        ),
1419    }
1420}
1421
1422/// Square root HALVES its argument's dimensions — it is not a transcendental
1423/// function and does NOT require a dimensionless argument. Because exponents are
1424/// rational, `sqrt(m^3)` is exactly `m^3/2` rather than "not representable".
1425fn propagate_sqrt_dim(
1426    op: &ExpressionNode,
1427    env: &dyn UnitEnv,
1428    findings: &mut Vec<UnitFinding>,
1429) -> Dim {
1430    if !require_arity(op, 1, findings) {
1431        return Dim::Unknown;
1432    }
1433    let arg = propagate_dim(&op.args[0], env, findings);
1434    match arg.known() {
1435        Some(unit) => Dim::Known(unit.power_rational(Rational::new(1, 2))),
1436        None => Dim::Unknown,
1437    }
1438}
1439
1440/// `ifelse`: the two branches follow the `+` rule (esm-spec §4.8.3). When both
1441/// are determinable they must share dimension and scale; when only one is, the
1442/// result is its unit (`ifelse(c, x, 0.5 * y)` has the unit of `x`); when
1443/// neither is (`ifelse(c, 1, 2)`), the result is undeterminable.
1444fn propagate_ifelse_dim(
1445    op: &ExpressionNode,
1446    env: &dyn UnitEnv,
1447    findings: &mut Vec<UnitFinding>,
1448) -> Dim {
1449    if !require_arity(op, 3, findings) {
1450        return Dim::Unknown;
1451    }
1452    // The condition (arg 0) need not be dimensionless — comparison ops already
1453    // produce a dimensionless Boolean, and we don't want to reject bare scalars
1454    // used as truthiness flags. It is still walked so findings inside it (e.g.
1455    // a mismatched comparison) are reported.
1456    propagate_dim(&op.args[0], env, findings);
1457    let t = propagate_dim(&op.args[1], env, findings);
1458    let f = propagate_dim(&op.args[2], env, findings);
1459    match (t.known(), f.known()) {
1460        (Some(a), Some(b)) if !a.is_compatible(b) => {
1461            findings.push(UnitFinding::error(format!(
1462                "'ifelse' branches must share dimensions: {} vs {}",
1463                describe(a),
1464                describe(b)
1465            )));
1466            Dim::Unknown
1467        }
1468        (Some(a), Some(b)) if a.exact != b.exact => {
1469            findings.push(UnitFinding::error(format!(
1470                "'ifelse' branches must share a scale: {} vs {}",
1471                describe_scaled(a),
1472                describe_scaled(b)
1473            )));
1474            Dim::Unknown
1475        }
1476        (Some(a), _) | (None, Some(a)) => Dim::Known(a.clone()),
1477        (None, None) => Dim::Unknown,
1478    }
1479}
1480
1481/// Array operators carry their ELEMENT's unit (esm-spec §4.8.3, "Array
1482/// operators"). Shape and indexing are orthogonal to dimension (gt-t5c /
1483/// gt-vt3); what an array holds is not. The same rules serve the checker and
1484/// the evaluation path's angle conversion, so the two cannot disagree.
1485///
1486/// * `index`, `reshape`, `transpose` — the unit of the source array
1487///   (`args[0]`); the subscripts and shape operands are not part of it, but
1488///   are walked for findings like any other operand.
1489/// * `concat` — its operands agree like those of `+`.
1490/// * `makearray` — its region `values` agree like the operands of `+`: an
1491///   undeterminable region is skipped, two determinable ones that disagree
1492///   are a provable mismatch.
1493/// * `broadcast` — the unit of `{op: fn, args}`.
1494/// * `faq` — decided by its ⊕ (see [`faq_unit`]); `filter`, `expr` and any
1495///   operands are always walked for findings.
1496///
1497/// Every operand of every array operator is walked, so a mismatch inside a
1498/// subscript (`index(lat, a [m] + b [kg])`) is reported.
1499///
1500/// A node with nothing to read a unit from is UNDETERMINABLE, never
1501/// dimensionless (§4.8.4).
1502fn propagate_array_dim(
1503    op: &ExpressionNode,
1504    env: &dyn UnitEnv,
1505    findings: &mut Vec<UnitFinding>,
1506) -> Dim {
1507    match op.op.as_str() {
1508        "faq" => {
1509            // The `filter` is a boolean over the node's ranges. Its value never
1510            // reaches the element unit, but it is an expression like any other,
1511            // so a comparison in it is checked by the §4.8.3 rules exactly as
1512            // the same comparison in the body is (EarthSciAST#414).
1513            if let Some(filter) = &op.filter {
1514                propagate_dim(filter, env, findings);
1515            }
1516            let body = op
1517                .expr
1518                .as_ref()
1519                .map(|body| propagate_dim(body, env, findings));
1520            propagate_args(op, env, findings);
1521            match body {
1522                Some(body) => faq_unit(op, body),
1523                None => Dim::Unknown,
1524            }
1525        }
1526        "makearray" => {
1527            propagate_args(op, env, findings);
1528            let mut first: Option<Unit> = None;
1529            for value in op.values.iter().flatten() {
1530                let dim = propagate_dim(value, env, findings);
1531                let Some(unit) = dim.known() else {
1532                    continue;
1533                };
1534                match &first {
1535                    None => first = Some(unit.clone()),
1536                    Some(f) if !f.same_unit(unit) => {
1537                        findings.push(UnitFinding::error(format!(
1538                            "'makearray' regions must share units: {} vs {}",
1539                            describe_scaled(f),
1540                            describe_scaled(unit)
1541                        )));
1542                    }
1543                    _ => {}
1544                }
1545            }
1546            first.map_or(Dim::Unknown, Dim::Known)
1547        }
1548        "concat" => propagate_matching_dim(op, env, findings),
1549        "broadcast" => {
1550            // Elementwise map over arrays with `fn` naming the scalar
1551            // operator. Construct a synthetic scalar node and recurse so we
1552            // reuse the same dimensional rules. (That synthetic node is the
1553            // same identity the EVALUATORS now implement: `broadcast(fn=F, A)`
1554            // means `{op: F, args: A}` applied element-wise — see
1555            // `op_registry::check_broadcast_fn`.)
1556            //
1557            // A missing or unusable `fn` is a HARD `invalid_broadcast_fn`
1558            // structural error raised by `structural.rs`; what is reported here
1559            // is only the consequence for the DIMENSION, which the checker
1560            // genuinely cannot determine.
1561            let Some(fn_name) = op.broadcast_fn.as_deref() else {
1562                propagate_args(op, env, findings);
1563                findings.push(UnitFinding::analysis(
1564                    "'broadcast' is missing its 'fn'; its dimension is unknown".to_string(),
1565                ));
1566                return Dim::Unknown;
1567            };
1568            let synthetic = ExpressionNode {
1569                op: fn_name.to_string(),
1570                args: op.args.clone(),
1571                ..ExpressionNode::default()
1572            };
1573            propagate_operator_dim(&synthetic, env, findings)
1574        }
1575        // "index" | "reshape" | "transpose": the source array's unit.
1576        _ => propagate_args(op, env, findings)
1577            .into_iter()
1578            .next()
1579            .unwrap_or(Dim::Unknown),
1580    }
1581}
1582
1583/// The unit of an `faq` node whose body has unit `body` (esm-spec §4.8.3).
1584///
1585/// The ⊗ of the semiring is written by the author inside `expr`, so it is
1586/// already typed; `join` clauses are key equalities and contribute no factor.
1587/// What remains is the ⊕ reduction over the contracted indices:
1588///
1589/// * `+`, `max`, `min` (`sum_product`, `max_product`, `min_sum`, `max_sum`,
1590///   or `reduce` `+`/`max`/`min`) — the body's unit;
1591/// * `*` (`reduce: "*"`) — the body's unit only when the body is a pure
1592///   number, otherwise undeterminable: the power is the range length;
1593/// * `∨` (`bool_and_or`) — dimensionless, a boolean;
1594/// * value invention (`distinct`, or a `key`) — undeterminable: the result
1595///   is ids, not a quantity. So is an unknown ⊕ spelling, which is rejected
1596///   elsewhere.
1597fn faq_unit(op: &ExpressionNode, body: Dim) -> Dim {
1598    if op.distinct == Some(true) || op.key.is_some() {
1599        return Dim::Unknown;
1600    }
1601    match crate::faq::effective_reduce_kind(op.semiring.as_deref(), op.reduce.as_deref()) {
1602        Ok(
1603            crate::faq::ReduceKind::Sum | crate::faq::ReduceKind::Max | crate::faq::ReduceKind::Min,
1604        ) => body,
1605        Ok(crate::faq::ReduceKind::Product) => {
1606            if body.known().is_some_and(Unit::is_pure_number) {
1607                body
1608            } else {
1609                Dim::Unknown
1610            }
1611        }
1612        Ok(crate::faq::ReduceKind::Or | crate::faq::ReduceKind::And) => {
1613            Dim::Known(Unit::dimensionless())
1614        }
1615        Err(_) => Dim::Unknown,
1616    }
1617}
1618
1619/// Render a unit's dimensions for a diagnostic message.
1620fn describe(unit: &Unit) -> String {
1621    if unit.is_dimensionless() {
1622        return "dimensionless".to_string();
1623    }
1624    let mut parts: Vec<String> = unit
1625        .dimensions
1626        .iter()
1627        .map(|(d, p)| {
1628            let name = match d {
1629                Dimension::Mass => "mass",
1630                Dimension::Length => "length",
1631                Dimension::Time => "time",
1632                Dimension::Current => "current",
1633                Dimension::Temperature => "temperature",
1634                Dimension::Amount => "amount",
1635                Dimension::Luminosity => "luminosity",
1636                Dimension::Angle => "angle",
1637            };
1638            if *p == Rational::int(1) {
1639                name.to_string()
1640            } else {
1641                format!("{name}^{p}")
1642            }
1643        })
1644        .collect();
1645    parts.sort();
1646    parts.join("*")
1647}
1648
1649/// [`describe`], followed by the exact scale when it is not 1 — what a scale
1650/// disagreement has to show, since the dimensions alone are identical.
1651fn describe_scaled(unit: &Unit) -> String {
1652    if unit.exact.is_one() {
1653        describe(unit)
1654    } else {
1655        format!("{} (scale {})", describe(unit), unit.exact)
1656    }
1657}
1658
1659/// Build a `HashMap<String, Unit>` environment from model variable metadata,
1660/// together with a list of warnings for any variables whose declared unit
1661/// string could not be parsed.
1662///
1663/// A variable is entered into the environment ONLY when its dimension is
1664/// actually known. Both an *unparseable* unit string and a *missing* one leave
1665/// the variable OUT of the map, so [`propagate_dim`] yields [`Dim::Unknown`] for
1666/// it and every expression containing it is skipped for dimensional checking.
1667///
1668/// Neither case is coerced to dimensionless. Doing so would both HIDE genuine
1669/// mismatches (when the variable is used consistently) and MANUFACTURE false
1670/// ones (when the real unit was, e.g., `m`) — and since a provable mismatch is
1671/// now a hard validation error, a fabricated dimension would fail the build on
1672/// a legitimate file. A model that declares no units at all (a common idiom in
1673/// the shared corpus) is therefore simply not dimension-checked, rather than
1674/// being treated as an all-dimensionless model in which `D(u)/dt = A*sin(w*t)`
1675/// looks inconsistent.
1676///
1677/// The two cases are NOT alike, and only one of them is a defect:
1678///
1679/// * A MISSING `units` field is a legitimate choice — the variable is simply
1680///   not dimension-checked.
1681/// * An UNPARSEABLE `units` field is a HARD ERROR (`unit_parse_error`,
1682///   esm-spec §4.8.4). A unit string either resolves against the shared
1683///   registry or it is a defect in the file: silently treating a typo
1684///   (`1/time`, `m/s2`) as "dimension unknown" disables every dimensional check
1685///   downstream, which is precisely the class of bug the checker exists to
1686///   catch. The failures are returned to the caller, which reports each one at
1687///   the JSON-Pointer of the offending DECLARATION.
1688///
1689/// Note this is a *different* case from a genuinely UNDETERMINABLE dimension (a
1690/// symbolic exponent), which stays a non-blocking warning: there the checker
1691/// cannot conclude, whereas here the author wrote something that is not a unit.
1692pub fn build_unit_env(
1693    variables: &indexmap::IndexMap<String, crate::ModelVariable>,
1694) -> (HashMap<String, Unit>, Vec<UnitParseFailure>) {
1695    let mut env = HashMap::new();
1696    let mut failures = Vec::new();
1697    // Many declarations share one unit string; each is parsed once.
1698    let mut parsed: HashMap<&str, Option<Unit>> = HashMap::new();
1699    for (name, var) in variables {
1700        let Some(declared) = &var.units else {
1701            // No declared units — dimension unknown, not dimensionless.
1702            continue;
1703        };
1704        let unit = parsed
1705            .entry(declared.as_str())
1706            .or_insert_with(|| parse_unit(declared).ok());
1707        match unit {
1708            Some(unit) => {
1709                env.insert(name.clone(), unit.clone());
1710            }
1711            None => failures.push(UnitParseFailure {
1712                name: name.clone(),
1713                units: declared.clone(),
1714            }),
1715        }
1716    }
1717    // `variables` is a HashMap, so iteration order is nondeterministic; sort so
1718    // a multi-failure file reports its errors in a stable order.
1719    failures.sort_by(|a, b| a.name.cmp(&b.name));
1720    (env, failures)
1721}
1722
1723/// Where the dimensional checker looks up the unit of a name. A name it does
1724/// not know is UNDETERMINABLE, never dimensionless (esm-spec §4.8.4).
1725pub(crate) trait UnitEnv {
1726    /// The unit `name` carries, or `None` when it is undeterminable.
1727    fn unit_of(&self, name: &str) -> Option<&Unit>;
1728
1729    /// Whether `name` has a known unit.
1730    fn contains(&self, name: &str) -> bool {
1731        self.unit_of(name).is_some()
1732    }
1733}
1734
1735impl<S: std::hash::BuildHasher> UnitEnv for HashMap<String, Unit, S> {
1736    fn unit_of(&self, name: &str) -> Option<&Unit> {
1737        self.get(name)
1738    }
1739}
1740
1741/// The units of every `<System>.<member>` scoped reference (esm-spec §4.9.2)
1742/// into the document's top-level models and reaction systems, at any depth,
1743/// each parsed once. Members that declare no units, or units that do not
1744/// parse (reported at their own declaration), are left out.
1745///
1746/// It depends only on the document, so validation builds it once and layers
1747/// each component's own names over it ([`ComponentUnitEnv`]).
1748pub(crate) fn document_scoped_units(esm_file: &crate::EsmFile) -> HashMap<String, Unit> {
1749    let mut parsed: HashMap<String, Option<Unit>> = HashMap::new();
1750    crate::structural::toplevel_reference_units(esm_file)
1751        .into_iter()
1752        .filter_map(|(name, units)| {
1753            parsed
1754                .entry(units)
1755                .or_insert_with_key(|units| parse_unit(units).ok())
1756                .clone()
1757                .map(|unit| (name, unit))
1758        })
1759        .collect()
1760}
1761
1762/// The unit environment one component's expressions are checked against: its
1763/// own declarations, then the `<sub>.<member>` paths relative to its own
1764/// subsystems, then the document's top-level scoped references
1765/// ([`document_scoped_units`]), in that precedence.
1766///
1767/// A mounted component is, after resolution, identical to one written inline
1768/// (esm-spec §4.7), so a variable read across a `{ref}` mount edge carries its
1769/// declared units into the mounting component exactly as a local one does
1770/// (§4.8.4, EarthSciAST#414). A relative path occupies its name even when its
1771/// target declares no units (or units that do not parse): it then shadows a
1772/// top-level member of the same dotted name and is undeterminable, so a check
1773/// that reads it is skipped rather than judged against the wrong declaration.
1774pub(crate) struct ComponentUnitEnv<'d> {
1775    own: HashMap<String, Unit>,
1776    relative: HashMap<String, Option<Unit>>,
1777    document: &'d HashMap<String, Unit>,
1778}
1779
1780impl<'d> ComponentUnitEnv<'d> {
1781    /// Layer a component's own unit environment `own` and its `subsystems`
1782    /// over the document's top-level scoped units.
1783    pub(crate) fn new(
1784        own: HashMap<String, Unit>,
1785        subsystems: Option<&indexmap::IndexMap<String, serde_json::Value>>,
1786        document: &'d HashMap<String, Unit>,
1787    ) -> Self {
1788        let mut parsed: HashMap<String, Option<Unit>> = HashMap::new();
1789        let relative = subsystems
1790            .map(crate::structural::subsystem_reference_units)
1791            .unwrap_or_default()
1792            .into_iter()
1793            .filter(|(name, _)| !own.contains_key(name))
1794            .map(|(name, units)| {
1795                let unit = units.and_then(|units| {
1796                    parsed
1797                        .entry(units)
1798                        .or_insert_with_key(|units| parse_unit(units).ok())
1799                        .clone()
1800                });
1801                (name, unit)
1802            })
1803            .collect();
1804        ComponentUnitEnv {
1805            own,
1806            relative,
1807            document,
1808        }
1809    }
1810
1811    /// Every name with a known unit, own declarations winning, as one map.
1812    pub(crate) fn into_map(self) -> HashMap<String, Unit> {
1813        let mut env = self.own;
1814        for (name, unit) in self.document {
1815            if !env.contains_key(name) && !self.relative.contains_key(name) {
1816                env.insert(name.clone(), unit.clone());
1817            }
1818        }
1819        for (name, unit) in self.relative {
1820            if let Some(unit) = unit {
1821                env.entry(name).or_insert(unit);
1822            }
1823        }
1824        env
1825    }
1826}
1827
1828impl UnitEnv for ComponentUnitEnv<'_> {
1829    fn unit_of(&self, name: &str) -> Option<&Unit> {
1830        if let Some(unit) = self.own.get(name) {
1831            return Some(unit);
1832        }
1833        match self.relative.get(name) {
1834            Some(unit) => unit.as_ref(),
1835            None => self.document.get(name),
1836        }
1837    }
1838}
1839
1840/// The unit environment a model's equations are checked against, as one map:
1841/// its own declarations ([`build_unit_env`]) plus every scoped reference its
1842/// expressions can make, with the precedence of [`ComponentUnitEnv`]. The
1843/// parse failures returned are the model's own.
1844pub fn model_unit_env(
1845    esm_file: &crate::EsmFile,
1846    model: &crate::Model,
1847) -> (HashMap<String, Unit>, Vec<UnitParseFailure>) {
1848    let document = document_scoped_units(esm_file);
1849    let (own, failures) = build_unit_env(&model.variables);
1850    let env = ComponentUnitEnv::new(own, model.subsystems.as_ref(), &document).into_map();
1851    (env, failures)
1852}
1853
1854/// A declared unit string that denotes no real unit.
1855///
1856/// Carries the NAME of the declaration alongside the offending string so the
1857/// caller can report `unit_parse_error` at the declaration's JSON-Pointer
1858/// rather than at the model.
1859#[derive(Debug, Clone, PartialEq, Eq)]
1860pub struct UnitParseFailure {
1861    /// The declaration's name (variable, species, or parameter).
1862    pub name: String,
1863    /// The unit string exactly as the author wrote it.
1864    pub units: String,
1865}
1866
1867/// Validate that an equation's LHS and RHS have matching dimensions.
1868///
1869/// Returns `Ok(())` when both sides propagate to dimensionally-equal units,
1870/// and a [`UnitError`] with a descriptive message otherwise. Propagation
1871/// errors from either side are surfaced verbatim.
1872///
1873/// This is the single-outcome view. Validation uses
1874/// [`check_equation_dimensions`], which reports EVERY finding and separates
1875/// provable mismatches from undeterminable dimensions.
1876///
1877/// A time derivative `D(x)/Dt` whose `t` declares no units has an
1878/// undeterminable left-hand side (esm-spec §4.8.4), so the full comparison is
1879/// skipped and the weaker rule applies: if no time unit can reconcile the two
1880/// sides, the result is [`UnitError::DimensionMismatch`]; if one can, it is
1881/// [`UnitError::UnknownUnit`] ("skipped"), not `Ok(())`.
1882pub fn validate_equation_dimensions(
1883    eq: &Equation,
1884    env: &HashMap<String, Unit>,
1885) -> Result<(), UnitError> {
1886    if let Some(err) = check_equation_dimensions(eq, env)
1887        .iter()
1888        .find(|f| f.is_error())
1889    {
1890        return Err(UnitError::DimensionMismatch(err.message.clone()));
1891    }
1892    // Single-outcome contract: distinguish "checked, consistent" (`Ok`) from
1893    // "could not be checked" (`UnknownUnit`) — an equation with an indeterminate
1894    // side was SKIPPED, and reporting `Ok` would claim we verified it.
1895    let mut sink = Vec::new();
1896    let lhs = propagate_dim(&eq.lhs, env, &mut sink);
1897    let rhs = propagate_dim(&eq.rhs, env, &mut sink);
1898    if lhs.known().is_none() || rhs.known().is_none() {
1899        return Err(UnitError::UnknownUnit(
1900            "equation has an indeterminate side; skipped for dimensional checking".to_string(),
1901        ));
1902    }
1903    Ok(())
1904}
1905
1906/// Dimension-check an equation, returning EVERY finding.
1907///
1908/// Both sides are always walked — a finding on the LHS never short-circuits the
1909/// RHS — so one undeterminable subexpression cannot hide a provable mismatch
1910/// elsewhere in the same equation. The LHS/RHS comparison itself is only made
1911/// when BOTH sides resolved: comparing against an unknown dimension could only
1912/// ever produce a false mismatch.
1913pub fn check_equation_dimensions(eq: &Equation, env: &HashMap<String, Unit>) -> Vec<UnitFinding> {
1914    equation_findings(eq, env)
1915}
1916
1917/// [`check_equation_dimensions`] against any [`UnitEnv`].
1918pub(crate) fn equation_findings(eq: &Equation, env: &dyn UnitEnv) -> Vec<UnitFinding> {
1919    let mut findings = Vec::new();
1920    let lhs = propagate_dim(&eq.lhs, env, &mut findings);
1921    let rhs = propagate_dim(&eq.rhs, env, &mut findings);
1922
1923    // `D(x)/dt` with an undeclared `t` is indeterminate, so the plain LHS-vs-RHS
1924    // comparison below cannot see it. Apply the weaker time-ratio rule instead.
1925    if let Some(state) = derivative_of_undeclared_time(&eq.lhs, env) {
1926        let state_dim = propagate_dim(state, env, &mut Vec::new());
1927        if let (Some(s), Some(r)) = (state_dim.known(), rhs.known())
1928            && let Some(message) = derivative_time_mismatch(s, r)
1929        {
1930            findings.push(UnitFinding::error(message));
1931        }
1932        return findings;
1933    }
1934
1935    if let (Some(l), Some(r)) = (lhs.known(), rhs.known())
1936        && !l.same_unit(r)
1937    {
1938        findings.push(UnitFinding::error(format!(
1939            "Left-hand side has units of {} but right-hand side has units of {}",
1940            describe_scaled(l),
1941            describe_scaled(r)
1942        )));
1943    }
1944    findings
1945}
1946
1947/// Dimension-check a standalone expression (an observed variable's defining
1948/// expression), returning EVERY finding.
1949///
1950/// When `declared` is `Some`, the expression's dimension is additionally
1951/// compared against the variable's declared units — the observed-variable
1952/// analogue of the equation LHS/RHS check.
1953pub fn check_expression_dimensions(
1954    expr: &Expr,
1955    declared: Option<&Unit>,
1956    env: &HashMap<String, Unit>,
1957) -> Vec<UnitFinding> {
1958    expression_findings(expr, declared, env)
1959}
1960
1961/// [`check_expression_dimensions`] against any [`UnitEnv`].
1962pub(crate) fn expression_findings(
1963    expr: &Expr,
1964    declared: Option<&Unit>,
1965    env: &dyn UnitEnv,
1966) -> Vec<UnitFinding> {
1967    let mut findings = Vec::new();
1968    let actual = propagate_dim(expr, env, &mut findings);
1969
1970    // If the expression is ALREADY internally inconsistent, its overall
1971    // dimension is whatever the first resolved operand happened to be — so
1972    // comparing it against the declaration would report the SAME defect a
1973    // second time at the same path. Report the internal mismatch only.
1974    if findings.iter().any(UnitFinding::is_error) {
1975        return findings;
1976    }
1977
1978    if let (Some(declared), Some(actual)) = (declared, actual.known())
1979        && !declared.same_unit(actual)
1980    {
1981        findings.push(UnitFinding::error(format!(
1982            "Declared units of {} do not match the expression's units of {}",
1983            describe_scaled(declared),
1984            describe_scaled(actual)
1985        )));
1986    }
1987    findings
1988}
1989
1990/// The dimensional analyser's verdict on one equation, in the form a report
1991/// prints: checked and consistent, provably inconsistent, or not checked.
1992#[derive(Debug, Clone, PartialEq)]
1993pub enum EquationDimensionVerdict {
1994    /// Both sides resolved and agree. Carries the propagated dimension, spelled
1995    /// over the canonical axes (`length*time^-1`).
1996    Consistent(String),
1997    /// A provable mismatch. Carries every error finding's message.
1998    Mismatch(Vec<String>),
1999    /// A side's dimension could not be determined, so the comparison was
2000    /// skipped (esm-spec §4.8.4). Carries the side and the operand to blame.
2001    NotChecked(String),
2002}
2003
2004/// Classify one equation the way [`check_equation_dimensions`] judges it, and
2005/// say WHY when it could not be judged.
2006///
2007/// A `Consistent` verdict is only returned when the comparison actually ran, so
2008/// an equation skipped because of an indeterminate operand — a bare literal in a
2009/// product, a `const` node that declares no `units`, a variable with no declared
2010/// units — is never reported as fine.
2011pub fn equation_dimension_verdict(
2012    eq: &Equation,
2013    env: &HashMap<String, Unit>,
2014) -> EquationDimensionVerdict {
2015    let errors: Vec<String> = check_equation_dimensions(eq, env)
2016        .into_iter()
2017        .filter(UnitFinding::is_error)
2018        .map(|f| f.message)
2019        .collect();
2020    if !errors.is_empty() {
2021        return EquationDimensionVerdict::Mismatch(errors);
2022    }
2023    let rhs = propagate_dim(&eq.rhs, env, &mut Vec::new());
2024    // `D(x)` against an undeclared time is judged by the time-ratio rule, which
2025    // needs the state's dimension rather than the derivative's.
2026    let (lhs_label, lhs_expr) = match derivative_of_undeclared_time(&eq.lhs, env) {
2027        Some(state) => ("differentiated state", state),
2028        None => ("left-hand side", &eq.lhs),
2029    };
2030    let lhs = propagate_dim(lhs_expr, env, &mut Vec::new());
2031    match (lhs.known(), rhs.known()) {
2032        (Some(_), Some(r)) => EquationDimensionVerdict::Consistent(describe(r)),
2033        (None, _) => EquationDimensionVerdict::NotChecked(format!(
2034            "{lhs_label}: {}",
2035            indeterminate_operand(lhs_expr, env)
2036        )),
2037        (_, None) => EquationDimensionVerdict::NotChecked(format!(
2038            "right-hand side: {}",
2039            indeterminate_operand(&eq.rhs, env)
2040        )),
2041    }
2042}
2043
2044/// Name the innermost operand that makes `expr`'s dimension indeterminate.
2045/// Called only on an expression whose dimension did not resolve.
2046fn indeterminate_operand(expr: &Expr, env: &dyn UnitEnv) -> String {
2047    match expr {
2048        Expr::Number(n) => format!("operand `{n}` has an indeterminate dimension"),
2049        Expr::Integer(n) => format!("operand `{n}` has an indeterminate dimension"),
2050        Expr::Variable(name) => format!("variable `{name}` has no declared units"),
2051        Expr::Operator(op) => {
2052            // A bare literal is the least informative operand to blame — it is
2053            // indeterminate everywhere — so a NAMED operand that is equally
2054            // indeterminate is reported in preference to it.
2055            let mut literal: Option<String> = None;
2056            for arg in &op.args {
2057                if propagate_dim(arg, env, &mut Vec::new()).known().is_some() {
2058                    continue;
2059                }
2060                if matches!(arg, Expr::Number(_) | Expr::Integer(_)) {
2061                    literal.get_or_insert_with(|| indeterminate_operand(arg, env));
2062                    continue;
2063                }
2064                return indeterminate_operand(arg, env);
2065            }
2066            literal.unwrap_or_else(|| format!("`{}` node has no determinable dimension", op.op))
2067        }
2068    }
2069}
2070
2071/// Every declared `const` unit string in `expr` that does not resolve against
2072/// the registry (esm-spec §4.8.5 item 2), in walk order.
2073pub fn unresolvable_const_units(expr: &Expr) -> Vec<String> {
2074    fn walk(expr: &Expr, out: &mut Vec<String>) {
2075        if let Expr::Operator(node) = expr {
2076            if node.op == "const"
2077                && let Some(units) = &node.units
2078                && parse_unit(units).is_err()
2079            {
2080                out.push(units.clone());
2081            }
2082            node.for_each_child(&mut |child| walk(child, out));
2083        }
2084    }
2085    let mut out = Vec::new();
2086    walk(expr, &mut out);
2087    out
2088}
2089
2090/// Reject declared `units` on any expression node in a document declaring
2091/// `esm` < 1.2.0 (esm-spec §4.8.5 item 6), naming the first offending node.
2092/// Runs on the raw JSON before schema validation, like the `solver` gate.
2093pub fn reject_const_units_pre_v12(
2094    view: &serde_json::Value,
2095) -> Result<(), crate::diagnostic::DiagnosticError> {
2096    reject_const_units_pre_v12_found(view, || {
2097        crate::json_visit::find_value_path(view, &mut |v| {
2098            v.as_object().is_some_and(|o| {
2099                crate::json_visit::small_get(o, "op").is_some()
2100                    && crate::json_visit::small_get(o, "units").is_some()
2101            })
2102        })
2103    })
2104}
2105
2106/// [`reject_const_units_pre_v12`] with the search for the first expression node
2107/// declaring `units` supplied by the caller, and run only when the declared
2108/// version is below 1.2.0.
2109pub(crate) fn reject_const_units_pre_v12_found(
2110    view: &serde_json::Value,
2111    offending: impl FnOnce() -> Option<String>,
2112) -> Result<(), crate::diagnostic::DiagnosticError> {
2113    let Some(esm) = view.get("esm").and_then(|v| v.as_str()) else {
2114        return Ok(());
2115    };
2116    let Some((major, minor, _)) = crate::diagnostic::parse_semver(esm) else {
2117        return Ok(());
2118    };
2119    if (major, minor) >= (1, 2) {
2120        return Ok(());
2121    }
2122    match offending() {
2123        None => Ok(()),
2124        Some(path) => Err(crate::diagnostic::err(
2125            crate::diagnostic::codes::CONST_UNITS_VERSION_TOO_OLD,
2126            format!(
2127                "declared `units` on an expression node require esm >= 1.2.0; file declares \
2128                 {esm}. Offending path: {path}"
2129            ),
2130        )),
2131    }
2132}
2133
2134/// Parse a unit string into a Unit struct
2135///
2136/// Supports common scientific unit notations:
2137/// - base units (`"m"`, `"s"`, `"kg"`, `"mol"`, `"K"`, `"Pa"`, …)
2138/// - products (`"kg*m"`, `"N*m"`)
2139/// - quotients (`"m/s"`, `"mol/L"`)
2140/// - parenthesised groups (`"J/(mol*K)"`)
2141/// - integer powers (`"m^2"`, `"s^-1"`)
2142/// - dimensionless (`""`, `"1"`, `"dimensionless"`)
2143pub fn parse_unit(unit_str: &str) -> Result<Unit, UnitError> {
2144    parse_normalized(&normalize_unit_str(unit_str), unit_str)
2145}
2146
2147/// Rewrite the ordinary earth-science spellings of a unit into the ASCII
2148/// grammar, BEFORE parsing.
2149///
2150/// These are not exotic: `W/m²`, `cm³`, `J/(kg·K)`, `kg⋅m/s`, `μg/m^3`, `°C` and
2151/// `Pa*m**3` all appear in the shared corpus and all failed to parse, leaving
2152/// their variables dimensionless-unknown and silently unchecked.
2153///
2154/// * superscript digits `⁰¹²³⁴⁵⁶⁷⁸⁹` and superscript minus `⁻` → `^n`
2155/// * middot `·` (U+00B7) and dot-operator `⋅` (U+22C5) → `*`
2156/// * `µ` (U+00B5) / `μ` (U+03BC) → `u`  (so `μg` is the `u` + `g` prefix form)
2157/// * `°C` → `degC`, `Ω` → `Ohm`
2158/// * `**` → `^`  (the Python spelling of a power)
2159fn normalize_unit_str(s: &str) -> String {
2160    // `°C` and `**` are two-character sequences, so handle them first.
2161    let s = s.replace("°C", "degC").replace("**", "^");
2162
2163    let mut out = String::with_capacity(s.len());
2164    let mut in_superscript = false;
2165    for c in s.chars() {
2166        let sup = match c {
2167            '⁰' => Some('0'),
2168            '¹' => Some('1'),
2169            '²' => Some('2'),
2170            '³' => Some('3'),
2171            '⁴' => Some('4'),
2172            '⁵' => Some('5'),
2173            '⁶' => Some('6'),
2174            '⁷' => Some('7'),
2175            '⁸' => Some('8'),
2176            '⁹' => Some('9'),
2177            '⁻' => Some('-'),
2178            _ => None,
2179        };
2180        match sup {
2181            // A run of superscripts is one exponent: emit a single `^` before it.
2182            Some(ascii) => {
2183                if !in_superscript {
2184                    out.push('^');
2185                    in_superscript = true;
2186                }
2187                out.push(ascii);
2188            }
2189            None => {
2190                in_superscript = false;
2191                match c {
2192                    '·' | '⋅' | '×' => out.push('*'),
2193                    'µ' | 'μ' => out.push('u'),
2194                    'Ω' => out.push_str("Ohm"),
2195                    _ => out.push(c),
2196                }
2197            }
2198        }
2199    }
2200    out
2201}
2202
2203/// Parse an already-normalized unit string. `original` is carried only for
2204/// error messages, so a diagnostic quotes what the author actually wrote.
2205fn parse_normalized(s: &str, original: &str) -> Result<Unit, UnitError> {
2206    let s = s.trim();
2207    if s.is_empty() || s == "1" || s == "dimensionless" {
2208        return Ok(Unit::dimensionless());
2209    }
2210
2211    // Strip a single layer of surrounding parentheses when they balance.
2212    if s.starts_with('(') && s.ends_with(')') && parens_balance(&s[1..s.len() - 1]) {
2213        return parse_normalized(&s[1..s.len() - 1], original);
2214    }
2215
2216    let base_units = get_base_units();
2217    if let Some(unit) = base_units.get(s) {
2218        return Ok(unit.clone());
2219    }
2220    // An SI-PREFIXED symbol (`mg`, `um`, `nm`, `mL`, `umol`, `dm`). Tried only
2221    // after the exact table, so a name that merely LOOKS prefixed (`min`, `mol`,
2222    // `cd`, `day`) keeps its own meaning. This is what makes the `µ`/`μ` → `u`
2223    // normalization above mean anything: `μg/m^3` reaches here as `ug/m^3`.
2224    if let Some(unit) = parse_si_prefixed(s, base_units) {
2225        return Ok(unit);
2226    }
2227
2228    // Product / quotient. `*` and `/` share one precedence level and bind
2229    // LEFT-associatively, so we split at the LAST top-level operator and
2230    // recurse on the left. Splitting on `/` as a separate, looser level (as
2231    // this parser used to) pulls every `*` factor appearing after the last `/`
2232    // into the DENOMINATOR: `J/mol*K` parsed as `J/(mol*K)`, silently negating
2233    // K's exponent, and `kg/m^3*s` came out as `kg/(m^3*s)`. Equal-precedence
2234    // left association is the reading of the Go reference parser and of
2235    // pint/Unitful.
2236    //
2237    // WHITESPACE between two terms is an implicit multiplication (`ppb^-1 s^-1`),
2238    // so it is a top-level operator too.
2239    if let Some((idx, len, sym)) = find_last_top_level_operator(s) {
2240        let (left, right) = (&s[..idx], &s[idx + len..]);
2241        if left.trim().is_empty() || right.trim().is_empty() {
2242            return Err(UnitError::ParseError(format!(
2243                "Dangling '{sym}' in unit \"{original}\""
2244            )));
2245        }
2246        let l = parse_normalized(left, original)?;
2247        let r = parse_normalized(right, original)?;
2248        return Ok(match sym {
2249            '/' => l.divide(&r),
2250            _ => l.multiply(&r),
2251        });
2252    }
2253
2254    // Power: right-most '^'. The exponent is RATIONAL — `s^0.5` and `m^(1/2)`
2255    // are legitimate (an SDE noise intensity carries `1/s^0.5`).
2256    if let Some(idx) = s.rfind('^') {
2257        let (base_s, pow_s) = (&s[..idx], &s[idx + 1..]);
2258        let base_unit = parse_normalized(base_s, original)?;
2259        let power = parse_exponent(pow_s).ok_or_else(|| {
2260            UnitError::ParseError(format!(
2261                "Invalid exponent \"{pow_s}\" in unit \"{original}\""
2262            ))
2263        })?;
2264        return Ok(base_unit.power_rational(power));
2265    }
2266
2267    // A numeric atom is admissible ONLY when its value is exactly 1 -- the
2268    // leading `1` of `1/s`. Any other number is a SCALING FACTOR, and a unit string
2269    // denotes a UNIT, not a quantity. This is not pedantry: `(m/s)^-1/3` -- an author
2270    // reaching for a rational exponent -- parses under this grammar as `((m/s)^-1)/3`,
2271    // and the five bindings gave it three different meanings (magnitude dropped,
2272    // magnitude retained, whole string rejected). Rejecting the scaling factor makes
2273    // `^(-1/3)` the only spelling of that unit, so the same string cannot silently
2274    // mean two things.
2275    //
2276    // Before this rule Rust RETAINED the magnitude here, so `"1000/s"` came out
2277    // as 1/s scaled by 1000 while Julia read the same string as a bare 1/s.
2278    if let Ok(v) = s.parse::<f64>() {
2279        if v != 1.0 {
2280            return Err(UnitError::ParseError(format!(
2281                "unit \"{original}\": a number other than 1 is a scaling factor, not a unit (esm-spec 4.8.2); a rational exponent is spelled ^(p/q)"
2282            )));
2283        }
2284        return Ok(Unit {
2285            dimensions: HashMap::new(),
2286            scale: 1.0,
2287            exact: ExactScale::one(),
2288        });
2289    }
2290
2291    Err(UnitError::UnknownUnit(original.to_string()))
2292}
2293
2294/// Decompose an SI-prefixed symbol into prefix × base (`mg` → milli × gram).
2295///
2296/// Only the symbols in [`PREFIXABLE`] take a prefix, which keeps the
2297/// decomposition from inventing units out of arbitrary identifiers — `not_a_unit`
2298/// must still fail to parse, and a count like `units` must not be read as
2299/// micro-something. Prefixes are tried LONGEST-first so `da` (deca) is not
2300/// mistaken for `d` (deci).
2301fn parse_si_prefixed(s: &str, base_units: &HashMap<String, Unit>) -> Option<Unit> {
2302    /// (symbol, factor, power of ten), longest symbol first.
2303    const PREFIXES: [(&str, f64, i32); 20] = [
2304        ("da", 1e1, 1),
2305        ("Y", 1e24, 24),
2306        ("Z", 1e21, 21),
2307        ("E", 1e18, 18),
2308        ("P", 1e15, 15),
2309        ("T", 1e12, 12),
2310        ("G", 1e9, 9),
2311        ("M", 1e6, 6),
2312        ("k", 1e3, 3),
2313        ("h", 1e2, 2),
2314        ("d", 1e-1, -1),
2315        ("c", 1e-2, -2),
2316        ("m", 1e-3, -3),
2317        ("u", 1e-6, -6),
2318        ("n", 1e-9, -9),
2319        ("p", 1e-12, -12),
2320        ("f", 1e-15, -15),
2321        ("a", 1e-18, -18),
2322        ("z", 1e-21, -21),
2323        ("y", 1e-24, -24),
2324    ];
2325    /// Symbols that admit an SI prefix.
2326    const PREFIXABLE: [&str; 13] = [
2327        "m", "g", "s", "mol", "K", "A", "cd", "L", "N", "Pa", "J", "W", "rad",
2328    ];
2329
2330    for (prefix, factor, power) in PREFIXES {
2331        let Some(base) = s.strip_prefix(prefix) else {
2332            continue;
2333        };
2334        if !PREFIXABLE.contains(&base) {
2335            continue;
2336        }
2337        let mut unit = base_units.get(base)?.clone();
2338        unit.scale *= factor;
2339        unit.exact = unit.exact.multiply(&ExactScale::power_of_ten(power));
2340        return Some(unit);
2341    }
2342    None
2343}
2344
2345/// Parse a unit exponent: an integer (`2`, `-1`), a decimal (`0.5`, `-1.5`), or
2346/// a parenthesised fraction (`(1/2)`).
2347fn parse_exponent(s: &str) -> Option<Rational> {
2348    let s = s.trim();
2349    let inner = s
2350        .strip_prefix('(')
2351        .and_then(|rest| rest.strip_suffix(')'))
2352        .unwrap_or(s)
2353        .trim();
2354    if let Some((num, den)) = inner.split_once('/') {
2355        return Some(Rational::new(
2356            num.trim().parse().ok()?,
2357            den.trim().parse().ok()?,
2358        ));
2359    }
2360    if let Ok(n) = inner.parse::<i32>() {
2361        return Some(Rational::int(n));
2362    }
2363    Rational::from_f64(inner.parse::<f64>().ok()?)
2364}
2365
2366/// Find the *last* top-level (outside any parenthesised group) `*` or `/`,
2367/// returning its byte index and which operator it is.
2368///
2369/// `parse_unit` splits here and recurses on the *left* substring, which makes
2370/// the two operators share a precedence level and associate left-to-right:
2371/// `a/b/c` is `(a/b)/c`, `a*b/c` is `(a*b)/c`, and `a/b*c` is `(a/b)*c`. This
2372/// gives the conventional reading of `W/m^2/K` as `W/(m^2*K)` while keeping
2373/// `J/mol*K` as `(J/mol)*K`, and matches the sibling parsers (Go's recursive
2374/// descent, Python/pint, Julia's Unitful `uparse`).
2375/// Returns `(byte index, byte length, operator)`. The length matters because an
2376/// implicit multiplication is a RUN of whitespace, which the caller must skip
2377/// whole.
2378fn find_last_top_level_operator(s: &str) -> Option<(usize, usize, char)> {
2379    let bytes = s.as_bytes();
2380    let mut depth = 0i32;
2381    let mut last = None;
2382    let mut i = 0usize;
2383    while i < bytes.len() {
2384        let c = bytes[i] as char;
2385        match c {
2386            '(' => depth += 1,
2387            ')' => depth -= 1,
2388            '*' | '/' if depth == 0 => last = Some((i, 1, c)),
2389            ' ' | '\t' if depth == 0 => {
2390                // A run of whitespace BETWEEN two terms is an implicit `*`.
2391                // Whitespace merely padding an explicit operator (`m / s`) is
2392                // not: the operator arm above already claimed that position, and
2393                // a run adjacent to one must not be treated as a second operator.
2394                let start = i;
2395                while i < bytes.len() && matches!(bytes[i] as char, ' ' | '\t') {
2396                    i += 1;
2397                }
2398                let before = s[..start].trim_end().chars().last();
2399                let after = s[i..].chars().next();
2400                let padding = matches!(before, Some('*' | '/' | '^') | None)
2401                    || matches!(after, Some('*' | '/' | '^') | None);
2402                if !padding {
2403                    last = Some((start, i - start, '*'));
2404                }
2405                continue;
2406            }
2407            _ => {}
2408        }
2409        i += 1;
2410    }
2411    last
2412}
2413
2414fn parens_balance(s: &str) -> bool {
2415    let mut depth = 0i32;
2416    for c in s.chars() {
2417        match c {
2418            '(' => depth += 1,
2419            ')' => {
2420                depth -= 1;
2421                if depth < 0 {
2422                    return false;
2423                }
2424            }
2425            _ => {}
2426        }
2427    }
2428    depth == 0
2429}
2430
2431/// Get commonly used base units
2432/// The cached base-unit registry. `parse_unit` recurses (parentheses, `/`,
2433/// `*` splits) and runs inside per-equation validation loops, so rebuilding
2434/// the ~50-entry map on every call was pure waste; the table is immutable
2435/// and deterministic, so one process-wide build is safe.
2436fn get_base_units() -> &'static HashMap<String, Unit> {
2437    static BASE_UNITS: std::sync::LazyLock<HashMap<String, Unit>> =
2438        std::sync::LazyLock::new(build_base_units);
2439    &BASE_UNITS
2440}
2441
2442fn build_base_units() -> HashMap<String, Unit> {
2443    let mut units = HashMap::new();
2444
2445    // Length units
2446    units.insert("m".to_string(), Unit::base(Dimension::Length, 1, 1.0));
2447    units.insert(
2448        "cm".to_string(),
2449        Unit::base(Dimension::Length, 1, 0.01).with_exact(ExactScale::power_of_ten(-2)),
2450    );
2451    units.insert(
2452        "km".to_string(),
2453        Unit::base(Dimension::Length, 1, 1000.0).with_exact(ExactScale::power_of_ten(3)),
2454    );
2455    units.insert(
2456        "mm".to_string(),
2457        Unit::base(Dimension::Length, 1, 0.001).with_exact(ExactScale::power_of_ten(-3)),
2458    );
2459    units.insert("meter".to_string(), Unit::base(Dimension::Length, 1, 1.0));
2460    units.insert("meters".to_string(), Unit::base(Dimension::Length, 1, 1.0));
2461    // The international foot, exact by definition since 1959: 1 ft = 0.3048 m.
2462    // Emission inventories are written in it — the EPA FF10 point-source format
2463    // stores STKHGT and STKDIAM in feet — and a format for air-quality models
2464    // that cannot spell the unit its own input files use forces every such
2465    // column to be declared in a unit it is not stored in.
2466    units.insert(
2467        "ft".to_string(),
2468        Unit::base(Dimension::Length, 1, 0.3048).with_exact(ExactScale::decimal("0.3048")),
2469    );
2470    // The international mile, exact by definition since the same 1959
2471    // agreement: 1 mi = 5280 ft = 1609.344 m. It is the unit the US onroad
2472    // transportation inventory is written in end to end — EPA MOVES stores
2473    // `link.linkLength` in miles, `link.linkAvgSpeed` and
2474    // `driveSchedule.averageSpeed` in `mi/h`, and its whole activity model is
2475    // built on vehicle-MILES travelled — so a table that has `ft` and not `mi`
2476    // can spell a stack height and not a road.
2477    //
2478    // `mi/h` composes from this entry and `h`; `mph` is deliberately NOT a
2479    // separate name, because it is a fused spelling of a compound the grammar
2480    // already builds, and every fused alias is a second way to say one thing.
2481    // Nor are `mile`/`miles` here: `foot`/`feet` are pinned as REJECTS by
2482    // tests/conformance/unit_registry, so the imperial family is symbol-only
2483    // by contract and this follows it.
2484    //
2485    // The foot is READ BACK OUT of the table rather than retyped as 1609.344,
2486    // so this entry cannot drift away from the one that defines it.
2487    units.insert(
2488        "mi".to_string(),
2489        Unit::base(Dimension::Length, 1, 5280.0 * units["ft"].scale)
2490            .with_exact(ExactScale::integer(5280).multiply(&units["ft"].exact)),
2491    );
2492
2493    // Time units
2494    units.insert("s".to_string(), Unit::base(Dimension::Time, 1, 1.0));
2495    units.insert(
2496        "min".to_string(),
2497        Unit::base(Dimension::Time, 1, 60.0).with_exact(ExactScale::integer(60)),
2498    );
2499    units.insert(
2500        "h".to_string(),
2501        Unit::base(Dimension::Time, 1, 3600.0).with_exact(ExactScale::integer(3600)),
2502    );
2503    units.insert(
2504        "hr".to_string(),
2505        Unit::base(Dimension::Time, 1, 3600.0).with_exact(ExactScale::integer(3600)),
2506    );
2507    units.insert(
2508        "hour".to_string(),
2509        Unit::base(Dimension::Time, 1, 3600.0).with_exact(ExactScale::integer(3600)),
2510    );
2511    // The canonical spelling of the day is `day`. A bare `d` is deliberately NOT
2512    // a unit (esm-spec §4.8.1): a one-letter symbol reads as the deci- prefix or
2513    // as a differential, so admitting it would make `d` ambiguous at every site.
2514    units.insert(
2515        "day".to_string(),
2516        Unit::base(Dimension::Time, 1, 86400.0).with_exact(ExactScale::integer(86400)),
2517    );
2518
2519    // Mass units
2520    units.insert("kg".to_string(), Unit::base(Dimension::Mass, 1, 1.0));
2521    units.insert(
2522        "g".to_string(),
2523        Unit::base(Dimension::Mass, 1, 0.001).with_exact(ExactScale::power_of_ten(-3)),
2524    );
2525    // The international avoirdupois pound, exact by definition since 1959:
2526    // 1 lb = 0.45359237 kg. US emission rates are tabulated in it — MOVES's
2527    // NONROAD brake-specific fuel consumption is `lb/(hp*h)` and its gasoline
2528    // density constant CMFGAS is 6.237 lb/gal — and it is the unit `short_ton`
2529    // below is DEFINED in, so having the ton and not the pound left the
2530    // derived unit spellable and the base one not.
2531    units.insert(
2532        "lb".to_string(),
2533        Unit::base(Dimension::Mass, 1, 0.45359237).with_exact(ExactScale::decimal("0.45359237")),
2534    );
2535    // The two tons, both spelled UNAMBIGUOUSLY and neither spelled `ton`. A bare
2536    // `ton` is three different masses (short 907.18474 kg, metric 1000 kg, long
2537    // 1016.0469088 kg), and a table whose job is to make a declared unit mean
2538    // ONE thing cannot hold a name that means three. `t` is excluded for the
2539    // same reason `d` is. `short_ton` is exactly 2000 international pounds —
2540    // what a US emissions inventory means by "tons", and exactly InMAP's
2541    // 907184740000 ug/short-ton emission-conversion constant.
2542    // The pound is READ BACK OUT of the table rather than retyped as
2543    // 907.18474, for the same reason `mi` reads the foot back out.
2544    units.insert(
2545        "short_ton".to_string(),
2546        Unit::base(Dimension::Mass, 1, 2000.0 * units["lb"].scale)
2547            .with_exact(ExactScale::integer(2000).multiply(&units["lb"].exact)),
2548    );
2549    units.insert(
2550        "tonne".to_string(),
2551        Unit::base(Dimension::Mass, 1, 1000.0).with_exact(ExactScale::power_of_ten(3)),
2552    );
2553
2554    // Amount units
2555    units.insert("mol".to_string(), Unit::base(Dimension::Amount, 1, 1.0));
2556
2557    // Temperature units. `degC` (and its `°C` spelling, normalized to `degC`
2558    // before parsing) shares the TEMPERATURE dimension with kelvin: the two
2559    // differ by an affine OFFSET, which a multiplicative dimension algebra
2560    // cannot express, and dimensional analysis only cares about the axis.
2561    units.insert("K".to_string(), Unit::base(Dimension::Temperature, 1, 1.0));
2562    units.insert(
2563        "degC".to_string(),
2564        Unit::base(Dimension::Temperature, 1, 1.0),
2565    );
2566    units.insert(
2567        "Celsius".to_string(),
2568        Unit::base(Dimension::Temperature, 1, 1.0),
2569    );
2570
2571    // Current / luminous intensity
2572    units.insert("A".to_string(), Unit::base(Dimension::Current, 1, 1.0));
2573    units.insert("cd".to_string(), Unit::base(Dimension::Luminosity, 1, 1.0));
2574
2575    // Plane angle — a real axis (the eighth), not a synonym for dimensionless.
2576    // The trig functions accept it; see `propagate_transcendental_dim`.
2577    units.insert("rad".to_string(), Unit::base(Dimension::Angle, 1, 1.0));
2578    // Degrees of arc — the same axis, scaled. (`deg` is the short spelling; the
2579    // TEMPERATURE `degC`/`degF` are separate entries and are matched first, so
2580    // there is no collision.)
2581    let degree = Unit::base(Dimension::Angle, 1, std::f64::consts::PI / 180.0)
2582        .with_exact(ExactScale::pi().divide(&ExactScale::integer(180)));
2583    units.insert("degrees".to_string(), degree.clone());
2584    units.insert("degree".to_string(), degree.clone());
2585    units.insert("deg".to_string(), degree);
2586
2587    // Solid angle — the steradian, `sr = rad²` (esm-spec §4.8.1). `rad` is the
2588    // Angle axis, so a solid angle is that axis SQUARED, not a ninth axis, just
2589    // as an area is `m²` and not a new axis. Spherical-mesh cell areas (a patch
2590    // of the unit sphere: MPAS Voronoi cells, Girard triangles) declare
2591    // `units: "sr"`, and this makes those declarations resolve.
2592    units.insert("sr".to_string(), Unit::base(Dimension::Angle, 2, 1.0));
2593
2594    // Volume (L = dm³ = 10⁻³ m³)
2595    units.insert(
2596        "L".to_string(),
2597        Unit::base(Dimension::Length, 3, 0.001).with_exact(ExactScale::power_of_ten(-3)),
2598    );
2599    // The US liquid gallon, exact by definition: 231 in³ = 3.785 411 784 L
2600    // (NIST SP 811 App. B). It is spelled `gal` and not `gallon` for the same
2601    // reason `ft` has no `foot`, and it is the US liquid gallon and not the
2602    // imperial one — those differ by 20%, which is exactly the kind of silent
2603    // scale error tests/conformance/unit_registry exists to pin. US fuel data
2604    // is per gallon: MOVES stores `fueltype.fuelDensity` in g/gal, its
2605    // refuelling spill rate in g/gal, and its dioxin and metal emission rates
2606    // in g/gal.
2607    units.insert(
2608        "gal".to_string(),
2609        Unit::base(Dimension::Length, 3, 0.003_785_411_784)
2610            .with_exact(ExactScale::decimal("0.003785411784")),
2611    );
2612
2613    // Frequency: Hz = s⁻¹.
2614    units.insert("Hz".to_string(), Unit::base(Dimension::Time, -1, 1.0));
2615
2616    // Fahrenheit shares the TEMPERATURE axis with kelvin, like `degC`, at a
2617    // scale of exactly 5/9 K (esm-spec §4.8.1). Its affine offset is not
2618    // modelled, and not checked.
2619    units.insert(
2620        "degF".to_string(),
2621        Unit::base(Dimension::Temperature, 1, 5.0 / 9.0).with_exact(ExactScale::ratio(5, 9)),
2622    );
2623
2624    // Year — the JULIAN year, 365.25 days = 31_557_600 s exactly. That is the
2625    // astronomical/climate convention and it is what Julia, Python, Go and
2626    // TypeScript all carry; Rust alone had 3.1536e7 (a 365-day year), which is
2627    // 0.0685% short. A scale error is INVISIBLE to dimensional analysis — the
2628    // dimension is [time] either way — so nothing in the corpus could see it
2629    // until `tests/conformance/unit_registry` started pinning scales, which is
2630    // exactly what that fixture is for.
2631    units.insert(
2632        "year".to_string(),
2633        Unit::base(Dimension::Time, 1, 31_557_600.0).with_exact(ExactScale::integer(31_557_600)),
2634    );
2635    units.insert(
2636        "yr".to_string(),
2637        Unit::base(Dimension::Time, 1, 31_557_600.0).with_exact(ExactScale::integer(31_557_600)),
2638    );
2639
2640    // Derived units
2641    // Velocity: m/s
2642    units.insert(
2643        "m/s".to_string(),
2644        Unit::base(Dimension::Length, 1, 1.0).divide(&Unit::base(Dimension::Time, 1, 1.0)),
2645    );
2646
2647    // Concentration: mol/L (mol/m^3)
2648    let liter = Unit::base(Dimension::Length, 3, 0.001).with_exact(ExactScale::power_of_ten(-3));
2649    units.insert(
2650        "mol/L".to_string(),
2651        Unit::base(Dimension::Amount, 1, 1.0).divide(&liter),
2652    );
2653    units.insert(
2654        "M".to_string(),
2655        Unit::base(Dimension::Amount, 1, 1.0).divide(&liter),
2656    );
2657
2658    // Force: kg*m/s^2 (Newton)
2659    let newton = Unit::base(Dimension::Mass, 1, 1.0)
2660        .multiply(&Unit::base(Dimension::Length, 1, 1.0))
2661        .divide(&Unit::base(Dimension::Time, 2, 1.0));
2662    units.insert("N".to_string(), newton.clone());
2663
2664    // Pressure: kg/(m*s^2) (Pascal)
2665    let pascal = Unit::base(Dimension::Mass, 1, 1.0)
2666        .divide(&Unit::base(Dimension::Length, 1, 1.0))
2667        .divide(&Unit::base(Dimension::Time, 2, 1.0));
2668    units.insert("Pa".to_string(), pascal);
2669
2670    // Energy: kg*m^2/s^2 (Joule)
2671    let joule = Unit::base(Dimension::Mass, 1, 1.0)
2672        .multiply(&Unit::base(Dimension::Length, 2, 1.0))
2673        .divide(&Unit::base(Dimension::Time, 2, 1.0));
2674    units.insert("J".to_string(), joule.clone());
2675    let mut kj = joule.clone();
2676    kj.scale *= 1000.0;
2677    kj.exact = kj.exact.multiply(&ExactScale::power_of_ten(3));
2678    units.insert("kJ".to_string(), kj);
2679    let mut cal = joule.clone();
2680    cal.scale *= 4.184;
2681    cal.exact = cal.exact.multiply(&ExactScale::decimal("4.184"));
2682    units.insert("cal".to_string(), cal);
2683    let mut kcal = joule.clone();
2684    kcal.scale *= 4184.0;
2685    kcal.exact = kcal.exact.multiply(&ExactScale::integer(4184));
2686    units.insert("kcal".to_string(), kcal);
2687
2688    // Power: kg*m^2/s^3 (Watt)
2689    let watt = Unit::base(Dimension::Mass, 1, 1.0)
2690        .multiply(&Unit::base(Dimension::Length, 2, 1.0))
2691        .divide(&Unit::base(Dimension::Time, 3, 1.0));
2692    units.insert("W".to_string(), watt.clone());
2693
2694    // Mechanical (imperial) horsepower — 550 ft·lbf/s, which is
2695    // 745.699 871 582 270 2 W: NIST SP 811 App. B gives 7.456 999 E+02 W. The
2696    // foot and the pound are READ BACK OUT of the table rather than retyped as
2697    // literals here, so this entry cannot drift away from the two entries that
2698    // define it. NOT the metric horsepower (PS, 735.49875 W), which is a
2699    // different unit by 1.4%.
2700    //
2701    // Engine ratings are the axis MOVES's NONROAD model bins on:
2702    // `nrsourceusetype.hpAvg` is horsepower and every `nremissionrate` row is
2703    // `g/(hp*h)`, so without this entry the rate tables that ARE the model have
2704    // no honest declaration.
2705    let foot = units["ft"].scale;
2706    let pound = units["lb"].scale;
2707    let mut horsepower = watt.clone();
2708    horsepower.scale *= 550.0 * foot * pound * 9.80665;
2709    horsepower.exact = horsepower.exact.multiply(
2710        &ExactScale::integer(550)
2711            .multiply(&units["ft"].exact)
2712            .multiply(&units["lb"].exact)
2713            .multiply(&ExactScale::decimal("9.80665")),
2714    );
2715    units.insert("hp".to_string(), horsepower);
2716
2717    // Electromagnetic family, all derived from the ampere.
2718    //
2719    // `C` is the COULOMB (A·s), NOT Celsius — degrees Celsius is spelled
2720    // `degC`, and the `°C` form normalizes to it before parsing. A unit string
2721    // carries DIMENSIONS ONLY, so a bare `C` can never mean a chemical species
2722    // tag either.
2723    let ampere = Unit::base(Dimension::Current, 1, 1.0);
2724    let coulomb = ampere.multiply(&Unit::base(Dimension::Time, 1, 1.0));
2725    units.insert("C".to_string(), coulomb.clone());
2726
2727    // Volt = W/A = kg*m^2/(s^3*A)
2728    let volt = watt.divide(&ampere);
2729    units.insert("V".to_string(), volt.clone());
2730
2731    // Ohm = V/A. Reached from the `Ω` spelling too, which normalizes to `Ohm`.
2732    units.insert("Ohm".to_string(), volt.divide(&ampere));
2733
2734    // Farad = C/V (so `F/m`, the permittivity of the corpus, is F ÷ metre).
2735    units.insert("F".to_string(), coulomb.divide(&volt));
2736
2737    // Tesla = kg/(s^2*A). The weber appears in the corpus as the composite
2738    // `V*s`, which the parser builds from `V` and `s`, so it needs no entry.
2739    units.insert(
2740        "T".to_string(),
2741        Unit::base(Dimension::Mass, 1, 1.0)
2742            .divide(&Unit::base(Dimension::Time, 2, 1.0))
2743            .divide(&ampere),
2744    );
2745
2746    // Non-SI energies, as multiples of the joule.
2747    let joule_scaled = |scale: f64, exact: ExactScale| {
2748        let mut u = joule.clone();
2749        u.scale *= scale;
2750        u.exact = u.exact.multiply(&exact);
2751        u
2752    };
2753    units.insert(
2754        "erg".to_string(),
2755        joule_scaled(1e-7, ExactScale::power_of_ten(-7)),
2756    );
2757    units.insert(
2758        "BTU".to_string(),
2759        joule_scaled(1_055.055_852_62, ExactScale::decimal("1055.05585262")),
2760    );
2761    // The watt-hour, exactly 3600 J, and the kilowatt-hour it is the base of.
2762    // esm-spec §4.8.1's Energy row lists BOTH; Julia, Python, Go and TypeScript
2763    // carried both while this table had only `kWh`, so `units: "Wh"` resolved in
2764    // four bindings and was a hard error in this one. `W*h` composes either way,
2765    // which is why the gap was silent.
2766    units.insert(
2767        "Wh".to_string(),
2768        joule_scaled(3600.0, ExactScale::integer(3600)),
2769    );
2770    units.insert(
2771        "kWh".to_string(),
2772        joule_scaled(3.6e6, ExactScale::integer(3_600_000)),
2773    );
2774
2775    // ESM-specific units standard (docs/units-standard.md).
2776    // Mole-fraction family: dimensionless with scale factors.
2777    // ppmv/ppbv/pptv are volume-mixing-ratio aliases of ppm/ppb/ppt under
2778    // the ideal-gas approximation — identical dimension and scale.
2779    let dimensionless_scaled = |scale: f64, exact: ExactScale| Unit {
2780        dimensions: HashMap::new(),
2781        scale,
2782        exact,
2783    };
2784    units.insert(
2785        "ppm".to_string(),
2786        dimensionless_scaled(1e-6, ExactScale::power_of_ten(-6)),
2787    );
2788    units.insert(
2789        "ppmv".to_string(),
2790        dimensionless_scaled(1e-6, ExactScale::power_of_ten(-6)),
2791    );
2792    units.insert(
2793        "ppb".to_string(),
2794        dimensionless_scaled(1e-9, ExactScale::power_of_ten(-9)),
2795    );
2796    units.insert(
2797        "ppbv".to_string(),
2798        dimensionless_scaled(1e-9, ExactScale::power_of_ten(-9)),
2799    );
2800    units.insert(
2801        "ppt".to_string(),
2802        dimensionless_scaled(1e-12, ExactScale::power_of_ten(-12)),
2803    );
2804    units.insert(
2805        "pptv".to_string(),
2806        dimensionless_scaled(1e-12, ExactScale::power_of_ten(-12)),
2807    );
2808    // Percent — dimensionless with a scale, like the mole-fraction family.
2809    units.insert(
2810        "%".to_string(),
2811        dimensionless_scaled(0.01, ExactScale::power_of_ten(-2)),
2812    );
2813    units.insert(
2814        "percent".to_string(),
2815        dimensionless_scaled(0.01, ExactScale::power_of_ten(-2)),
2816    );
2817
2818    // COUNTS carry no dimension axis: they count entities, they do not measure
2819    // one. Composites therefore fall out of the parser with the right dimension
2820    // on their own — `molec/cm^3` is `[length]^-3`, `individuals/km^2` is
2821    // `[length]^-2`.
2822    for count in [
2823        "molec",
2824        "molecule",
2825        "count",
2826        "individuals",
2827        "vehicles",
2828        "units",
2829    ] {
2830        units.insert(count.to_string(), Unit::dimensionless());
2831    }
2832
2833    // Practical salinity — dimensionless by definition (PSS-78).
2834    units.insert("psu".to_string(), Unit::dimensionless());
2835
2836    // Pressure: atmosphere, and the micro-atmosphere used for seawater pCO2.
2837    units.insert(
2838        "atm".to_string(),
2839        pascal_scaled(&units, 101_325.0, ExactScale::integer(101_325)),
2840    );
2841    units.insert(
2842        "uatm".to_string(),
2843        pascal_scaled(&units, 0.101_325, ExactScale::decimal("0.101325")),
2844    );
2845    // Non-SI pressures, as multiples of the pascal.
2846    units.insert(
2847        "bar".to_string(),
2848        pascal_scaled(&units, 1e5, ExactScale::power_of_ten(5)),
2849    );
2850    // Torr is EXACTLY atm/760 (esm-spec §4.8.1) — not a rounded decimal, and not
2851    // mmHg, which is the conventional 133.322387415 Pa.
2852    units.insert(
2853        "Torr".to_string(),
2854        pascal_scaled(&units, 101_325.0 / 760.0, ExactScale::ratio(101_325, 760)),
2855    );
2856    units.insert(
2857        "mmHg".to_string(),
2858        pascal_scaled(
2859            &units,
2860            133.322_387_415,
2861            ExactScale::decimal("133.322387415"),
2862        ),
2863    );
2864    // Inch of mercury — exactly 25.4 mmHg, the conventional value (NIST SP 811).
2865    // US barometric datasets store pressure in inHg; without this entry such a
2866    // column has no honest declaration, because a unit string carries no numeric
2867    // scale factor, so `25.4 mmHg` cannot be spelled either.
2868    units.insert(
2869        "inHg".to_string(),
2870        pascal_scaled(
2871            &units,
2872            3_386.388_640_341,
2873            ExactScale::decimal("3386.388640341"),
2874        ),
2875    );
2876    // psi is lbf/in² = pound × standard gravity ÷ inch², every factor exact
2877    // (esm-spec §4.8.1), folded in that order for the numeric scale.
2878    let psi_scale = units["lb"].scale * 9.80665 / (0.0254 * 0.0254);
2879    let psi_exact = units["lb"]
2880        .exact
2881        .multiply(&ExactScale::decimal("9.80665"))
2882        .divide(&ExactScale::decimal("0.0254").power(Rational::int(2)));
2883    units.insert(
2884        "psi".to_string(),
2885        pascal_scaled(&units, psi_scale, psi_exact),
2886    );
2887
2888    // Dobson unit: areal number density of ozone molecules.
2889    // Since `molec` is dimensionless, Dobson resolves to `[length]^-2` with
2890    // scale 2.6867e20 molec/m^2.
2891    units.insert(
2892        "Dobson".to_string(),
2893        Unit::base(Dimension::Length, -2, 2.6867e20).with_exact(ExactScale::decimal("2.6867e20")),
2894    );
2895    units.insert(
2896        "DU".to_string(),
2897        Unit::base(Dimension::Length, -2, 2.6867e20).with_exact(ExactScale::decimal("2.6867e20")),
2898    );
2899
2900    units
2901}
2902
2903/// A pressure unit expressed as a multiple of the pascal already in `units`.
2904fn pascal_scaled(units: &HashMap<String, Unit>, scale: f64, exact: ExactScale) -> Unit {
2905    let mut u = units["Pa"].clone();
2906    u.scale *= scale;
2907    u.exact = u.exact.multiply(&exact);
2908    u
2909}
2910
2911/// Check dimensional consistency of an equation
2912///
2913/// # Arguments
2914///
2915/// * `lhs_unit` - Units of the left-hand side
2916/// * `rhs_unit` - Units of the right-hand side
2917///
2918/// # Returns
2919///
2920/// * `Result<(), UnitError>` - Ok if consistent, error otherwise
2921pub fn check_dimensional_consistency(lhs_unit: &Unit, rhs_unit: &Unit) -> Result<(), UnitError> {
2922    if lhs_unit.is_compatible(rhs_unit) {
2923        Ok(())
2924    } else {
2925        Err(UnitError::DimensionMismatch(
2926            "Left and right sides have incompatible dimensions".to_string(),
2927        ))
2928    }
2929}
2930
2931/// Convert between compatible units
2932///
2933/// # Arguments
2934///
2935/// * `value` - Value to convert
2936/// * `from_unit` - Source unit
2937/// * `to_unit` - Target unit
2938///
2939/// # Returns
2940///
2941/// * `Result<f64, UnitError>` - Converted value or error
2942pub fn convert_units(value: f64, from_unit: &Unit, to_unit: &Unit) -> Result<f64, UnitError> {
2943    if !from_unit.is_compatible(to_unit) {
2944        return Err(UnitError::DimensionMismatch(
2945            "Units have incompatible dimensions".to_string(),
2946        ));
2947    }
2948
2949    Ok(value * from_unit.scale / to_unit.scale)
2950}
2951
2952#[cfg(test)]
2953mod tests {
2954    use super::*;
2955    use crate::types::ExpressionNode;
2956
2957    fn op(name: &str, args: Vec<Expr>) -> Expr {
2958        Expr::operator(ExpressionNode {
2959            op: name.to_string(),
2960            args,
2961            ..ExpressionNode::default()
2962        })
2963    }
2964
2965    fn op_with_wrt(name: &str, args: Vec<Expr>, wrt: &str) -> Expr {
2966        Expr::operator(ExpressionNode {
2967            op: name.to_string(),
2968            args,
2969            wrt: Some(wrt.to_string()),
2970            ..ExpressionNode::default()
2971        })
2972    }
2973
2974    fn env_of(pairs: &[(&str, &str)]) -> HashMap<String, Unit> {
2975        pairs
2976            .iter()
2977            .map(|(k, v)| ((*k).to_string(), parse_unit(v).unwrap()))
2978            .collect()
2979    }
2980
2981    #[test]
2982    fn test_parse_dimensionless() {
2983        assert!(parse_unit("").unwrap().is_dimensionless());
2984        assert!(parse_unit("1").unwrap().is_dimensionless());
2985        assert!(parse_unit("dimensionless").unwrap().is_dimensionless());
2986    }
2987
2988    /// Exponents are RATIONAL. `1/s^0.5` is the intensity of an SDE noise term
2989    /// and appears in the shared corpus (`tests/fixtures/sde/*.esm`); an
2990    /// integer-only grammar could not parse it at all.
2991    #[test]
2992    fn test_rational_exponents() {
2993        let half = Rational::new(-1, 2);
2994        assert_eq!(
2995            parse_unit("1/s^0.5")
2996                .unwrap()
2997                .dimensions
2998                .get(&Dimension::Time),
2999            Some(&half)
3000        );
3001        // The fraction and decimal spellings agree.
3002        assert_eq!(
3003            parse_unit("s^(-1/2)")
3004                .unwrap()
3005                .dimensions
3006                .get(&Dimension::Time),
3007            Some(&half)
3008        );
3009        // `**` is the Python spelling of a power.
3010        assert_eq!(
3011            parse_unit("m**3")
3012                .unwrap()
3013                .dimensions
3014                .get(&Dimension::Length),
3015            Some(&Rational::int(3))
3016        );
3017        // Reduced to lowest terms, so 2/4 IS 1/2.
3018        assert_eq!(Rational::new(2, 4), Rational::new(1, 2));
3019        assert_eq!(Rational::from_f64(0.5), Some(Rational::new(1, 2)));
3020    }
3021
3022    /// Ordinary earth-science spellings must parse: superscripts, the middot and
3023    /// dot-operator, micro, degree-Celsius and ohm.
3024    #[test]
3025    fn test_unicode_normalization() {
3026        // W/m² — superscript exponent.
3027        let u = parse_unit("W/m²").unwrap();
3028        assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
3029        assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-3)));
3030        // Length cancels: W carries m^2, divided by m^2.
3031        assert_eq!(u.dimensions.get(&Dimension::Length), None);
3032
3033        assert_eq!(
3034            parse_unit("cm³")
3035                .unwrap()
3036                .dimensions
3037                .get(&Dimension::Length),
3038            Some(&Rational::int(3))
3039        );
3040        // Superscript minus.
3041        assert_eq!(
3042            parse_unit("m⁻³")
3043                .unwrap()
3044                .dimensions
3045                .get(&Dimension::Length),
3046            Some(&Rational::int(-3))
3047        );
3048        // Middot (U+00B7) and dot-operator (U+22C5) are multiplication.
3049        assert_eq!(
3050            parse_unit("J/(kg·K)").unwrap(),
3051            parse_unit("J/(kg*K)").unwrap()
3052        );
3053        assert_eq!(parse_unit("kg⋅m/s").unwrap(), parse_unit("kg*m/s").unwrap());
3054        // Micro, in both its spellings.
3055        assert_eq!(parse_unit("μg/m^3").unwrap(), parse_unit("ug/m^3").unwrap());
3056        assert_eq!(parse_unit("µg").unwrap(), parse_unit("ug").unwrap());
3057        // °C shares the temperature axis with K (they differ by an affine
3058        // offset, which dimensional analysis does not model).
3059        assert!(
3060            parse_unit("°C")
3061                .unwrap()
3062                .is_compatible(&parse_unit("K").unwrap())
3063        );
3064    }
3065
3066    /// Whitespace between two terms is an implicit multiplication.
3067    #[test]
3068    fn test_implicit_multiplication() {
3069        assert_eq!(
3070            parse_unit("ppb^-1 s^-1").unwrap(),
3071            parse_unit("ppb^-1*s^-1").unwrap()
3072        );
3073        // But whitespace merely PADDING an explicit operator is not a second
3074        // operator.
3075        assert_eq!(parse_unit("m / s").unwrap(), parse_unit("m/s").unwrap());
3076        assert_eq!(parse_unit("kg * m").unwrap(), parse_unit("kg*m").unwrap());
3077    }
3078
3079    /// Inches of mercury — exactly 25.4 mmHg BY DEFINITION, so the two must agree
3080    /// to the last bit rather than merely within a tolerance. `inHg` is how US
3081    /// barometric data is stored, and before this entry the registry had `mmHg`
3082    /// and no route to it: unit strings carry no numeric scale factor, so
3083    /// `25.4 mmHg` was not spellable either, and such a column had to declare a
3084    /// WRONG unit or none at all.
3085    #[test]
3086    fn test_inches_of_mercury() {
3087        let inhg = parse_unit("inHg").unwrap();
3088        let mmhg = parse_unit("mmHg").unwrap();
3089        assert!(
3090            inhg.is_compatible(&parse_unit("Pa").unwrap()),
3091            "inHg must carry the pressure dimension"
3092        );
3093        assert_eq!(
3094            inhg.scale,
3095            mmhg.scale * 25.4,
3096            "inHg must be EXACTLY 25.4 mmHg, not merely close to it"
3097        );
3098    }
3099
3100    /// The international mile — EXACTLY 5280 international feet by the same
3101    /// 1959 agreement that fixes `ft`, so it is asserted against the neighbouring
3102    /// entry bit-for-bit rather than within a tolerance: the two cannot be
3103    /// allowed to drift apart, and 5280 × 0.3048 is 1609.344 with no rounding in
3104    /// binary64. Before this entry the table could spell a stack height in feet
3105    /// and not a road in miles, which is the unit the entire US onroad
3106    /// transportation inventory is written in.
3107    #[test]
3108    fn test_international_mile() {
3109        let mi = parse_unit("mi").unwrap();
3110        let ft = parse_unit("ft").unwrap();
3111        assert!(
3112            mi.is_compatible(&parse_unit("m").unwrap()),
3113            "mi must carry the length dimension"
3114        );
3115        assert_eq!(mi.scale, 1609.344, "the international mile is 1609.344 m");
3116        assert_eq!(
3117            mi.scale,
3118            ft.scale * 5280.0,
3119            "mi must be EXACTLY 5280 ft, not merely close to it"
3120        );
3121        // `mph` is NOT a name; the compound is what spells a speed, and it
3122        // composes from the two entries without a fused alias.
3123        let mph = parse_unit("mi/h").unwrap();
3124        assert!(mph.is_compatible(&parse_unit("m/s").unwrap()));
3125        // 1 mi/h is EXACTLY 0.44704 m/s — the constant MOVES's own SQL writes.
3126        assert_eq!(mph.scale, 0.44704);
3127        assert!(
3128            parse_unit("mph").is_err(),
3129            "`mph` must stay unresolvable: a fused spelling of a compound the \
3130             grammar already builds is a second way to say one thing"
3131        );
3132    }
3133
3134    /// The international avoirdupois pound — EXACTLY 0.45359237 kg, and exactly
3135    /// a two-thousandth of the `short_ton` already in the table, which is what
3136    /// the assertion pins. Having the ton and not the pound meant the DERIVED
3137    /// unit was spellable and the one it is defined in was not.
3138    #[test]
3139    fn test_avoirdupois_pound() {
3140        let lb = parse_unit("lb").unwrap();
3141        assert!(lb.is_compatible(&parse_unit("kg").unwrap()));
3142        assert_eq!(lb.scale, 0.45359237);
3143        assert_eq!(
3144            parse_unit("short_ton").unwrap().scale,
3145            lb.scale * 2000.0,
3146            "short_ton is DEFINED as 2000 lb; the two must agree to the last bit"
3147        );
3148    }
3149
3150    /// Mechanical horsepower — 550 ft·lbf/s = 745.6998715822702 W (NIST SP 811
3151    /// gives 7.456 999 E+02 W). Asserted BOTH ways: against the literal, and
3152    /// against the foot / pound / standard-gravity product it is built from, so
3153    /// a future edit to `ft` or `lb` that forgot this entry would fail here.
3154    /// It is emphatically not the metric horsepower (PS, 735.49875 W).
3155    #[test]
3156    fn test_mechanical_horsepower() {
3157        let hp = parse_unit("hp").unwrap();
3158        assert!(hp.is_compatible(&parse_unit("W").unwrap()));
3159        assert_eq!(hp.scale, 745.699_871_582_270_2);
3160        assert_eq!(
3161            hp.scale,
3162            550.0 * parse_unit("ft").unwrap().scale * parse_unit("lb").unwrap().scale * 9.80665,
3163            "hp must be exactly 550 ft*lbf/s in terms of the table's own ft and lb"
3164        );
3165        assert!(
3166            (hp.scale - 735.498_75).abs() > 1.0,
3167            "hp must be the MECHANICAL horsepower, not the metric one"
3168        );
3169    }
3170
3171    /// The US liquid gallon — exactly 231 in³ = 3.785411784 L. Pinned against
3172    /// the litre already in the table, and pinned as NOT the imperial gallon,
3173    /// which is 20% larger: a dimension-only check cannot tell the two apart.
3174    #[test]
3175    fn test_us_liquid_gallon() {
3176        let gal = parse_unit("gal").unwrap();
3177        let l = parse_unit("L").unwrap();
3178        assert_eq!(
3179            gal.dimensions.get(&Dimension::Length),
3180            Some(&Rational::int(3)),
3181            "a gallon is a volume, i.e. [length]^3"
3182        );
3183        assert_eq!(gal.scale, 0.003_785_411_784);
3184        assert!(
3185            (gal.scale / l.scale - 3.785_411_784).abs() < 1e-12,
3186            "the US liquid gallon is 3.785411784 L"
3187        );
3188        assert!(
3189            (gal.scale - 0.004_546_09).abs() > 1e-6,
3190            "must be the US liquid gallon, not the imperial one"
3191        );
3192    }
3193
3194    /// The compounds the four new entries exist to make spellable. Each is a
3195    /// real MOVES column: `mi/h` is `link.linkAvgSpeed`, `g/(hp*h)` is
3196    /// `nremissionrate.meanBaseRate`, `lb/(hp*h)` is the brake-specific fuel
3197    /// consumption carrier, and `g/gal` is `fueltype.fuelDensity`. None of them
3198    /// needs a table entry of its own — they fall out of the grammar — which is
3199    /// the point of adding the BASE units rather than the compounds.
3200    #[test]
3201    fn test_moves_compounds_derive() {
3202        assert!(
3203            parse_unit("mi/h")
3204                .unwrap()
3205                .is_compatible(&parse_unit("m/s").unwrap())
3206        );
3207        assert!(
3208            parse_unit("g/(hp*h)")
3209                .unwrap()
3210                .is_compatible(&parse_unit("kg/J").unwrap())
3211        );
3212        assert!(
3213            parse_unit("lb/(hp*h)")
3214                .unwrap()
3215                .is_compatible(&parse_unit("kg/J").unwrap())
3216        );
3217        assert!(
3218            parse_unit("g/gal")
3219                .unwrap()
3220                .is_compatible(&parse_unit("kg/m^3").unwrap())
3221        );
3222        assert!(
3223            parse_unit("g/mi")
3224                .unwrap()
3225                .is_compatible(&parse_unit("kg/m").unwrap())
3226        );
3227        assert!(
3228            parse_unit("kJ/gal")
3229                .unwrap()
3230                .is_compatible(&parse_unit("J/m^3").unwrap())
3231        );
3232    }
3233
3234    /// SI prefixes resolve against the prefixable symbols — which is what makes
3235    /// the `µ` → `u` normalization useful — but must not invent units out of
3236    /// arbitrary identifiers, nor shadow a name that merely looks prefixed.
3237    #[test]
3238    fn test_si_prefixes() {
3239        assert_eq!(
3240            parse_unit("mg").unwrap().dimensions.get(&Dimension::Mass),
3241            Some(&Rational::int(1))
3242        );
3243        assert!((parse_unit("mg").unwrap().scale - 1e-6).abs() < 1e-18);
3244        assert!((parse_unit("um").unwrap().scale - 1e-6).abs() < 1e-18);
3245        assert!((parse_unit("nm").unwrap().scale - 1e-9).abs() < 1e-21);
3246        assert_eq!(parse_unit("mL").unwrap(), {
3247            let mut l = parse_unit("L").unwrap();
3248            l.scale *= 1e-3;
3249            l.exact = l.exact.multiply(&ExactScale::power_of_ten(-3));
3250            l
3251        });
3252        assert_eq!(
3253            parse_unit("umol")
3254                .unwrap()
3255                .dimensions
3256                .get(&Dimension::Amount),
3257            Some(&Rational::int(1))
3258        );
3259        // Exact names win over a prefix reading: `min` is minutes, not milli-in.
3260        assert!((parse_unit("min").unwrap().scale - 60.0).abs() < 1e-9);
3261        assert_eq!(
3262            parse_unit("mol")
3263                .unwrap()
3264                .dimensions
3265                .get(&Dimension::Amount),
3266            Some(&Rational::int(1))
3267        );
3268        // A prefix on a non-prefixable identifier is NOT a unit.
3269        assert!(parse_unit("not_a_unit").is_err());
3270    }
3271
3272    /// Counts carry no dimension axis, so composites come out with the right
3273    /// dimension on their own.
3274    #[test]
3275    fn test_counts_are_dimensionless() {
3276        for c in [
3277            "molec",
3278            "molecule",
3279            "count",
3280            "individuals",
3281            "vehicles",
3282            "units",
3283        ] {
3284            assert!(
3285                parse_unit(c).unwrap().is_dimensionless(),
3286                "{c} must be dimensionless"
3287            );
3288        }
3289        // individuals/km^2 is an inverse area.
3290        assert_eq!(
3291            parse_unit("individuals/km^2")
3292                .unwrap()
3293                .dimensions
3294                .get(&Dimension::Length),
3295            Some(&Rational::int(-2))
3296        );
3297        assert!(parse_unit("psu").unwrap().is_dimensionless());
3298        assert!(parse_unit("%").unwrap().is_dimensionless());
3299        assert!((parse_unit("%").unwrap().scale - 0.01).abs() < 1e-12);
3300        // uatm is a pressure.
3301        assert!(
3302            parse_unit("uatm")
3303                .unwrap()
3304                .is_compatible(&parse_unit("Pa").unwrap())
3305        );
3306    }
3307
3308    /// `sqrt` HALVES a dimension — it is not a transcendental and does not
3309    /// require a dimensionless argument. With rational exponents `sqrt(m^3)` is
3310    /// exactly `m^3/2` rather than "not representable".
3311    #[test]
3312    fn test_sqrt_halves_dimensions() {
3313        let env = env_of(&[("area", "m^2"), ("vol", "m^3")]);
3314
3315        let e = op("sqrt", vec![Expr::Variable("area".into())]);
3316        let u = Unit::propagate(&e, &env).unwrap();
3317        assert_eq!(
3318            u.dimensions.get(&Dimension::Length),
3319            Some(&Rational::int(1))
3320        );
3321        assert!(check_expression_dimensions(&e, None, &env).is_empty());
3322
3323        let e = op("sqrt", vec![Expr::Variable("vol".into())]);
3324        let u = Unit::propagate(&e, &env).unwrap();
3325        assert_eq!(
3326            u.dimensions.get(&Dimension::Length),
3327            Some(&Rational::new(3, 2))
3328        );
3329        assert!(
3330            !check_expression_dimensions(&e, None, &env)
3331                .iter()
3332                .any(UnitFinding::is_error),
3333            "sqrt of an odd power is representable, not an error"
3334        );
3335    }
3336
3337    /// `rad` is a real axis, so a trig function accepts an ANGLE — but `exp` and
3338    /// `log` still demand strict dimensionlessness.
3339    #[test]
3340    fn test_trig_accepts_angle_but_exp_does_not() {
3341        let env = env_of(&[("theta", "rad"), ("m", "m")]);
3342
3343        let e = op("sin", vec![Expr::Variable("theta".into())]);
3344        assert!(
3345            !check_expression_dimensions(&e, None, &env)
3346                .iter()
3347                .any(UnitFinding::is_error),
3348            "sin(theta) with theta in radians is correct"
3349        );
3350
3351        // A LENGTH is still wrong for sin.
3352        let e = op("sin", vec![Expr::Variable("m".into())]);
3353        assert!(
3354            check_expression_dimensions(&e, None, &env)
3355                .iter()
3356                .any(UnitFinding::is_error)
3357        );
3358
3359        // exp of an angle is a dimensional argument.
3360        let e = op("exp", vec![Expr::Variable("theta".into())]);
3361        assert!(
3362            check_expression_dimensions(&e, None, &env)
3363                .iter()
3364                .any(UnitFinding::is_error),
3365            "exp requires strict dimensionlessness"
3366        );
3367    }
3368
3369    /// esm-spec §4.8.3 / issue #409: "dimensionless" means dimensionless AT
3370    /// SCALE 1 for every op whose argument the table requires to be
3371    /// dimensionless, and an ANGLE is admitted to a circular function at ANY
3372    /// scale because the evaluation path converts it.
3373    ///
3374    /// Before this, `ppm` and `percent` satisfied every dimension-only test, so
3375    /// `log(c [ppm])` was accepted and evaluated the log of the ppm NUMBER with
3376    /// no diagnostic — and the two readings differ by `ln(1e-6)`.
3377    #[test]
3378    fn test_transcendental_argument_must_be_at_scale_one() {
3379        let env = env_of(&[
3380            ("c", "ppm"),
3381            ("p", "percent"),
3382            ("n", "1"),
3383            ("theta", "rad"),
3384            ("lat", "deg"),
3385            ("solid", "sr"),
3386        ]);
3387        let errors = |e: &Expr| {
3388            check_expression_dimensions(e, None, &env)
3389                .iter()
3390                .filter(|f| f.is_error())
3391                .map(|f| f.message.clone())
3392                .collect::<Vec<_>>()
3393        };
3394
3395        // The strict transcendentals and the inverse circular functions refuse a
3396        // dimensionless-but-SCALED argument, and the message names the repair.
3397        for name in [
3398            "log", "ln", "log10", "exp", "sinh", "cosh", "tanh", "asinh", "acosh", "atanh", "asin",
3399            "acos", "atan",
3400        ] {
3401            let found = errors(&op(name, vec![Expr::Variable("c".into())]));
3402            assert_eq!(found.len(), 1, "{name}(c [ppm]) must be refused");
3403            assert!(
3404                found[0].contains("dimensionless at scale 1")
3405                    && found[0].contains("(ppm)")
3406                    && found[0].contains("divide by 1 ppm"),
3407                "{name}: the diagnostic must name the repair, got {}",
3408                found[0]
3409            );
3410            assert!(
3411                errors(&op(name, vec![Expr::Variable("n".into())])).is_empty(),
3412                "{name}(n [1]) is a pure number and stays accepted"
3413            );
3414        }
3415        assert!(
3416            errors(&op("exp", vec![Expr::Variable("p".into())]))[0].contains("divide by 1 percent"),
3417            "the percent spelling is named too"
3418        );
3419
3420        // A circular function CONVERTS an angle rather than refusing it, at any
3421        // scale — but a dimensionless-but-scaled argument is refused there too.
3422        for name in ["sin", "cos", "tan"] {
3423            assert!(
3424                errors(&op(name, vec![Expr::Variable("lat".into())])).is_empty(),
3425                "{name}(lat [deg]) is a conforming document"
3426            );
3427            assert!(
3428                errors(&op(name, vec![Expr::Variable("theta".into())])).is_empty(),
3429                "{name}(theta [rad]) is a conforming document"
3430            );
3431            assert_eq!(
3432                errors(&op(name, vec![Expr::Variable("p".into())])).len(),
3433                1,
3434                "{name}(p [percent]) leaves the reading unstated"
3435            );
3436            // `sr` is rad^2. No conversion turns a solid angle into a plane one,
3437            // so accepting it would multiply by `scale` where `scale^2` was meant.
3438            assert_eq!(
3439                errors(&op(name, vec![Expr::Variable("solid".into())])).len(),
3440                1,
3441                "{name}(solid [sr]) is not a plane angle"
3442            );
3443        }
3444    }
3445
3446    /// The repair the diagnostic names — dividing by a quantity whose DECLARED
3447    /// units carry the scale — reaches scale 1 exactly and is accepted.
3448    #[test]
3449    fn test_scaled_dimensionless_repair_is_accepted() {
3450        let env = env_of(&[("c", "ppm"), ("one_ppm", "ppm")]);
3451        let quotient = op(
3452            "/",
3453            vec![Expr::Variable("c".into()), Expr::Variable("one_ppm".into())],
3454        );
3455        assert!(
3456            !check_expression_dimensions(&op("log", vec![quotient]), None, &env)
3457                .iter()
3458                .any(UnitFinding::is_error),
3459            "log(c / one_ppm) states the reading and must be accepted"
3460        );
3461    }
3462
3463    /// The angle-normalization factor is `None` for everything a rewrite must
3464    /// leave alone, so a document declaring no scaled angle is not walked.
3465    #[test]
3466    fn test_angle_normalization_factor() {
3467        assert!(angle_normalization_factor(&parse_unit("rad").unwrap()).is_none());
3468        assert!(angle_normalization_factor(&parse_unit("1").unwrap()).is_none());
3469        assert!(angle_normalization_factor(&parse_unit("ppm").unwrap()).is_none());
3470        assert!(angle_normalization_factor(&parse_unit("m").unwrap()).is_none());
3471        assert!(angle_normalization_factor(&parse_unit("sr").unwrap()).is_none());
3472        let deg = angle_normalization_factor(&parse_unit("deg").unwrap()).unwrap();
3473        assert_eq!(deg, std::f64::consts::PI / 180.0);
3474        // 90 deg is exactly a quarter turn under this factor.
3475        assert_eq!((90.0 * deg).sin(), 1.0);
3476    }
3477
3478    /// The rewrite folds the factor into the ARGUMENT, leaves `rad` alone, and
3479    /// reaches a `sin` nested inside another argument.
3480    #[test]
3481    fn test_normalize_angle_arguments_rewrite() {
3482        let env = env_of(&[("lat", "deg"), ("theta", "rad")]);
3483        let untouched = op("sin", vec![Expr::Variable("theta".into())]);
3484        assert!(
3485            normalize_angle_arguments(&untouched, &env).is_none(),
3486            "a `rad` argument is at scale 1 and must not be rewritten"
3487        );
3488
3489        let nested = op(
3490            "+",
3491            vec![
3492                Expr::Number(1.0),
3493                op("cos", vec![Expr::Variable("lat".into())]),
3494            ],
3495        );
3496        let rewritten =
3497            normalize_angle_arguments(&nested, &env).expect("the `deg` cos is rewritten");
3498        let Expr::Operator(sum) = &rewritten else {
3499            panic!("expected the sum back")
3500        };
3501        let Expr::Operator(cos) = &sum.args[1] else {
3502            panic!("expected the cos back")
3503        };
3504        let Expr::Operator(product) = &cos.args[0] else {
3505            panic!("expected the argument to be a product")
3506        };
3507        assert_eq!(product.op, "*");
3508        assert_eq!(product.args[0], Expr::Variable("lat".into()));
3509        assert_eq!(
3510            product.args[1],
3511            Expr::Number(std::f64::consts::PI / 180.0),
3512            "the factor is the declared scale, applied ONCE"
3513        );
3514    }
3515
3516    /// The rewrite reaches a circular function in EVERY expression-bearing
3517    /// field, not only `args`: a `makearray` region value, a `table_lookup`
3518    /// axis input, a `faq` filter and an aggregate key.
3519    #[test]
3520    fn test_normalize_angle_arguments_reaches_every_child() {
3521        let env = env_of(&[("lat", "deg")]);
3522        let sin = || op("sin", vec![Expr::Variable("lat".into())]);
3523        let folded = |e: &Expr| {
3524            let Expr::Operator(n) = e else { return false };
3525            n.op == "sin"
3526                && matches!(&n.args[0], Expr::Operator(p) if p.op == "*"
3527                    && p.args[1] == Expr::Number(std::f64::consts::PI / 180.0))
3528        };
3529        let nodes = [
3530            ExpressionNode {
3531                op: "makearray".into(),
3532                values: Some(vec![sin()]),
3533                ..Default::default()
3534            },
3535            ExpressionNode {
3536                op: "table_lookup".into(),
3537                axes: Some([("x".to_string(), sin())].into_iter().collect()),
3538                ..Default::default()
3539            },
3540            ExpressionNode {
3541                op: "faq".into(),
3542                filter: Some(Box::new(sin())),
3543                key: Some(Box::new(sin())),
3544                ..Default::default()
3545            },
3546        ];
3547        for node in nodes {
3548            let op_name = node.op.clone();
3549            let rewritten = normalize_angle_arguments(&Expr::operator(node), &env)
3550                .unwrap_or_else(|| panic!("{op_name}: the nested `deg` sin was not reached"));
3551            let Expr::Operator(out) = &rewritten else {
3552                panic!("{op_name}: expected the node back")
3553            };
3554            let mut children = 0;
3555            out.for_each_child(&mut |c| {
3556                assert!(folded(c), "{op_name}: {c:?} was not folded");
3557                children += 1;
3558            });
3559            assert!(children > 0, "{op_name}");
3560        }
3561    }
3562
3563    /// The fold is IDEMPOTENT. It has to be: the array runtime runs the same
3564    /// rewrite on a `FlattenedSystem` that `flatten` already folded
3565    /// (`simulate_array::compile::normalize_model_angle_arguments`), and a
3566    /// second application would evaluate `sin(90 · 0.01745²)` with no error.
3567    /// What makes it idempotent is the rewrite's own output: the product
3568    /// carries a BARE LITERAL, so its unit is undeterminable (§4.8.4) and
3569    /// `angle_normalization_factor` is never reached a second time.
3570    #[test]
3571    fn test_normalize_angle_arguments_is_idempotent() {
3572        let env = env_of(&[("lat", "deg")]);
3573        let once = normalize_angle_arguments(&op("sin", vec![Expr::Variable("lat".into())]), &env)
3574            .expect("the `deg` argument is folded on the first pass");
3575        assert!(
3576            normalize_angle_arguments(&once, &env).is_none(),
3577            "a second pass must find nothing to rewrite, got {once:?}"
3578        );
3579    }
3580
3581    /// The derivative rule leaves the TIME exponent free and requires only that
3582    /// the non-time dimensions reconcile — so `x` in metres with an RHS in
3583    /// `m/s^2` is accepted (some time unit reconciles it), while `m` against
3584    /// `kg` never can be.
3585    #[test]
3586    fn test_derivative_time_exponent_is_free() {
3587        let env = env_of(&[("x", "m"), ("accel", "m/s^2"), ("mass", "kg")]);
3588
3589        let eq = Equation {
3590            comment: None,
3591            lhs: op_with_wrt("D", vec![Expr::Variable("x".into())], "t"),
3592            rhs: Expr::Variable("accel".into()),
3593        };
3594        assert!(
3595            !check_equation_dimensions(&eq, &env)
3596                .iter()
3597                .any(UnitFinding::is_error),
3598            "ratio m/(m/s^2) = s^2 is a power of time ⇒ reconcilable"
3599        );
3600
3601        let eq = Equation {
3602            comment: None,
3603            lhs: op_with_wrt("D", vec![Expr::Variable("x".into())], "t"),
3604            rhs: Expr::Variable("mass".into()),
3605        };
3606        assert!(
3607            check_equation_dimensions(&eq, &env)
3608                .iter()
3609                .any(UnitFinding::is_error),
3610            "ratio m/kg is not a power of time ⇒ provable mismatch"
3611        );
3612    }
3613
3614    /// Every registry entry carries an exact scale, and it is the same number as
3615    /// the floating-point one used for numeric conversion. An entry that forgot
3616    /// its exact form would silently default to 1 and fail here.
3617    #[test]
3618    fn every_registry_scale_has_a_matching_exact_form() {
3619        for (name, unit) in build_base_units() {
3620            let exact = unit.exact_scale().to_f64();
3621            assert!(
3622                (exact - unit.scale()).abs() <= 1e-12 * unit.scale().abs(),
3623                "{name}: exact scale {exact} disagrees with the float {}",
3624                unit.scale()
3625            );
3626        }
3627    }
3628
3629    /// The three entries the bindings disagreed on, and the scales whose exact
3630    /// form is least obvious (esm-spec §4.8.1).
3631    #[test]
3632    fn reconciled_registry_scales_are_exact() {
3633        let exact = |s: &str| parse_unit(s).unwrap().exact_scale().ratio_string().unwrap();
3634        assert_eq!(exact("Torr"), "20265/152");
3635        assert_eq!(exact("psi"), "8896443230521/1290320000");
3636        assert_eq!(exact("degF"), "5/9");
3637        assert_eq!(exact("deg"), "1/180*pi");
3638        assert_eq!(exact("mi"), "201168/125");
3639        assert_eq!(exact("hp"), "37284993579113511/50000000000000");
3640        assert_eq!(exact("DU"), "268670000000000000000");
3641        assert_eq!(parse_unit("psi").unwrap().scale(), 6_894.757_293_168_361);
3642        assert_eq!(parse_unit("Torr").unwrap().scale(), 101_325.0 / 760.0);
3643        assert_eq!(parse_unit("degF").unwrap().scale(), 5.0 / 9.0);
3644    }
3645
3646    #[test]
3647    fn adding_metres_to_kilometres_is_a_scale_mismatch() {
3648        let env = env_of(&[("x", "m"), ("y", "km"), ("z", "m")]);
3649        let var = |n: &str| Expr::Variable(n.into());
3650        let findings = check_expression_dimensions(&op("+", vec![var("x"), var("y")]), None, &env);
3651        assert!(
3652            findings
3653                .iter()
3654                .any(|f| f.is_error() && f.message.contains("scale")),
3655            "m + km must be a scale mismatch: {findings:?}"
3656        );
3657        let findings = check_expression_dimensions(&op("+", vec![var("x"), var("z")]), None, &env);
3658        assert!(!findings.iter().any(UnitFinding::is_error), "{findings:?}");
3659    }
3660
3661    #[test]
3662    fn an_equation_between_m_per_s_and_mi_per_h_is_a_scale_mismatch() {
3663        let env = env_of(&[
3664            ("speed_ms", "m/s"),
3665            ("speed_mph", "mi/h"),
3666            ("ms_per_mph", "m*h/(mi*s)"),
3667        ]);
3668        let var = |n: &str| Expr::Variable(n.into());
3669        let bare = Equation {
3670            comment: None,
3671            lhs: var("speed_ms"),
3672            rhs: var("speed_mph"),
3673        };
3674        assert!(
3675            check_equation_dimensions(&bare, &env)
3676                .iter()
3677                .any(UnitFinding::is_error),
3678            "m/s = mi/h agrees in dimension and not in scale"
3679        );
3680        let converted = Equation {
3681            comment: None,
3682            lhs: var("speed_ms"),
3683            rhs: op("*", vec![var("speed_mph"), var("ms_per_mph")]),
3684        };
3685        assert!(
3686            !check_equation_dimensions(&converted, &env)
3687                .iter()
3688                .any(UnitFinding::is_error),
3689            "mi/h times m*h/(mi*s) is exactly m/s"
3690        );
3691    }
3692
3693    #[test]
3694    fn ifelse_branches_must_agree_in_scale() {
3695        let env = env_of(&[("flag", "1"), ("x", "m"), ("y", "km")]);
3696        let var = |n: &str| Expr::Variable(n.into());
3697        let e = op("ifelse", vec![var("flag"), var("x"), var("y")]);
3698        assert!(
3699            check_expression_dimensions(&e, None, &env)
3700                .iter()
3701                .any(UnitFinding::is_error)
3702        );
3703    }
3704
3705    #[test]
3706    fn an_observed_expression_must_agree_with_its_declared_scale() {
3707        let env = env_of(&[("y", "km")]);
3708        let declared = parse_unit("m").unwrap();
3709        assert!(
3710            check_expression_dimensions(&Expr::Variable("y".into()), Some(&declared), &env)
3711                .iter()
3712                .any(UnitFinding::is_error)
3713        );
3714    }
3715
3716    /// A `const` that declares its units has that unit, a `const` without units
3717    /// stays undeterminable, and an unresolvable declared unit is listed for the
3718    /// structural layer (esm-spec §4.8.5).
3719    #[test]
3720    fn a_const_with_declared_units_has_that_unit() {
3721        let konst = |units: Option<&str>| {
3722            Expr::Operator(std::sync::Arc::new(ExpressionNode {
3723                op: "const".into(),
3724                value: Some(serde_json::json!(0.44704)),
3725                units: units.map(str::to_string),
3726                ..ExpressionNode::default()
3727            }))
3728        };
3729        let env = env_of(&[
3730            ("speed_mph", "mi/h"),
3731            ("speed_ms", "m/s"),
3732            ("speed_kg", "kg"),
3733        ]);
3734        let rhs = |units| op("*", vec![Expr::Variable("speed_mph".into()), konst(units)]);
3735        let eq = |lhs: &str, units| Equation {
3736            comment: None,
3737            lhs: Expr::Variable(lhs.into()),
3738            rhs: rhs(units),
3739        };
3740        assert!(
3741            !check_equation_dimensions(&eq("speed_ms", Some("m*h/(mi*s)")), &env)
3742                .iter()
3743                .any(UnitFinding::is_error),
3744            "mi/h times a const declared m*h/(mi*s) is exactly m/s"
3745        );
3746        assert!(
3747            check_equation_dimensions(&eq("speed_kg", Some("m*h/(mi*s)")), &env)
3748                .iter()
3749                .any(UnitFinding::is_error),
3750            "declared kg against m/s is a provable mismatch"
3751        );
3752        assert!(
3753            !check_equation_dimensions(&eq("speed_kg", None), &env)
3754                .iter()
3755                .any(UnitFinding::is_error),
3756            "a const without units is undeterminable, so nothing is checked"
3757        );
3758        assert_eq!(
3759            unresolvable_const_units(&rhs(Some("mph"))),
3760            vec!["mph".to_string()]
3761        );
3762        assert!(unresolvable_const_units(&rhs(Some("m*h/(mi*s)"))).is_empty());
3763    }
3764
3765    #[test]
3766    fn const_units_are_gated_at_esm_1_2_0() {
3767        let doc = |esm: &str| {
3768            serde_json::json!({
3769                "esm": esm,
3770                "models": {"M": {"equations": [{"lhs": "x", "rhs":
3771                    {"op": "const", "args": [], "value": 1.0, "units": "m"}}]}}
3772            })
3773        };
3774        let err = reject_const_units_pre_v12(&doc("1.1.0")).expect_err("1.1.0 must be rejected");
3775        assert!(
3776            err.to_string().contains("const_units_version_too_old"),
3777            "{err}"
3778        );
3779        assert!(
3780            err.to_string().contains("/models/M/equations/0/rhs"),
3781            "{err}"
3782        );
3783        assert!(reject_const_units_pre_v12(&doc("1.2.0")).is_ok());
3784    }
3785
3786    #[test]
3787    fn scaled_powers_and_roots_stay_exact() {
3788        assert_eq!(
3789            parse_unit("km^2")
3790                .unwrap()
3791                .power_rational(Rational::new(1, 2))
3792                .exact_scale(),
3793            parse_unit("km").unwrap().exact_scale()
3794        );
3795        let env = env_of(&[("p", "%"), ("q", "1")]);
3796        let eq = Equation {
3797            comment: None,
3798            lhs: Expr::Variable("q".into()),
3799            rhs: op("^", vec![Expr::Variable("p".into()), Expr::Integer(2)]),
3800        };
3801        assert!(
3802            check_equation_dimensions(&eq, &env)
3803                .iter()
3804                .any(UnitFinding::is_error),
3805            "a percentage squared has scale 1/10000, not 1"
3806        );
3807    }
3808
3809    #[test]
3810    fn test_parse_base_units() {
3811        assert_eq!(
3812            parse_unit("m").unwrap().dimensions.get(&Dimension::Length),
3813            Some(&Rational::int(1))
3814        );
3815        assert_eq!(
3816            parse_unit("s").unwrap().dimensions.get(&Dimension::Time),
3817            Some(&Rational::int(1))
3818        );
3819        assert_eq!(
3820            parse_unit("kg").unwrap().dimensions.get(&Dimension::Mass),
3821            Some(&Rational::int(1))
3822        );
3823    }
3824
3825    #[test]
3826    fn test_parse_compound_units() {
3827        let u = parse_unit("m/s").unwrap();
3828        assert_eq!(
3829            u.dimensions.get(&Dimension::Length),
3830            Some(&Rational::int(1))
3831        );
3832        assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
3833
3834        let u = parse_unit("mol/L").unwrap();
3835        assert_eq!(
3836            u.dimensions.get(&Dimension::Amount),
3837            Some(&Rational::int(1))
3838        );
3839        assert_eq!(
3840            u.dimensions.get(&Dimension::Length),
3841            Some(&Rational::int(-3))
3842        );
3843    }
3844
3845    #[test]
3846    fn test_parse_parenthesised_units() {
3847        // J/(mol*K) == kg*m^2/(s^2*mol*K)
3848        let u = parse_unit("J/(mol*K)").unwrap();
3849        assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
3850        assert_eq!(
3851            u.dimensions.get(&Dimension::Length),
3852            Some(&Rational::int(2))
3853        );
3854        assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-2)));
3855        assert_eq!(
3856            u.dimensions.get(&Dimension::Amount),
3857            Some(&Rational::int(-1))
3858        );
3859        assert_eq!(
3860            u.dimensions.get(&Dimension::Temperature),
3861            Some(&Rational::int(-1))
3862        );
3863    }
3864
3865    #[test]
3866    fn test_parse_pascal_and_kg_per_m3() {
3867        let pa = parse_unit("Pa").unwrap();
3868        assert_eq!(pa.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
3869        assert_eq!(
3870            pa.dimensions.get(&Dimension::Length),
3871            Some(&Rational::int(-1))
3872        );
3873        assert_eq!(
3874            pa.dimensions.get(&Dimension::Time),
3875            Some(&Rational::int(-2))
3876        );
3877
3878        let rho = parse_unit("kg/m^3").unwrap();
3879        assert_eq!(
3880            rho.dimensions.get(&Dimension::Mass),
3881            Some(&Rational::int(1))
3882        );
3883        assert_eq!(
3884            rho.dimensions.get(&Dimension::Length),
3885            Some(&Rational::int(-3))
3886        );
3887    }
3888
3889    #[test]
3890    fn test_parse_negative_power() {
3891        let inv_s = parse_unit("s^-1").unwrap();
3892        assert_eq!(
3893            inv_s.dimensions.get(&Dimension::Time),
3894            Some(&Rational::int(-1))
3895        );
3896    }
3897
3898    #[test]
3899    fn test_division_is_left_associative() {
3900        // `W/m^2/K` must parse as `(W/m^2)/K = W/(m^2*K)`, not the
3901        // right-associative `W/(m^2/K) = W*K/m^2`. W = kg*m^2/s^3, so the
3902        // correct result is kg*s^-3*K^-1: Temperature power -1 (a +1 would
3903        // betray the old right-associative bug).
3904        let u = parse_unit("W/m^2/K").unwrap();
3905        assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
3906        assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-3)));
3907        assert_eq!(
3908            u.dimensions.get(&Dimension::Temperature),
3909            Some(&Rational::int(-1))
3910        );
3911        // Length cancels: m^2 (from W) divided by m^2 leaves power 0 (absent).
3912        assert_eq!(u.dimensions.get(&Dimension::Length), None);
3913    }
3914
3915    #[test]
3916    fn test_find_last_top_level_operator() {
3917        // `a/b/c` splits at the *last* top-level operator so parse_unit recurses
3918        // on the left `a/b`, yielding left-associative `(a/b)/c`.
3919        assert_eq!(find_last_top_level_operator("a/b/c"), Some((3, 1, '/')));
3920        assert_eq!(find_last_top_level_operator("a*b*c"), Some((3, 1, '*')));
3921        // `*` and `/` share one precedence level, so the LAST of either wins:
3922        // `a/b*c` is `(a/b)*c`, not `a/(b*c)`.
3923        assert_eq!(find_last_top_level_operator("a/b*c"), Some((3, 1, '*')));
3924        // Separators inside parentheses are hidden from the top-level scan.
3925        assert_eq!(find_last_top_level_operator("a/(b/c)"), Some((1, 1, '/')));
3926        assert_eq!(find_last_top_level_operator("kg"), None);
3927    }
3928
3929    /// `*` and `/` are the SAME precedence level and associate left-to-right.
3930    /// Splitting `/` first (looser) pulled every factor after the last `/` into
3931    /// the denominator, so `J/mol*K` parsed as `J/(mol*K)` — silently negating
3932    /// K's exponent.
3933    #[test]
3934    fn test_mixed_product_quotient_is_left_associative() {
3935        let u = parse_unit("J/mol*K").unwrap();
3936        // (J/mol)*K ⇒ temperature appears with power +1, not -1.
3937        assert_eq!(
3938            u.dimensions.get(&Dimension::Temperature),
3939            Some(&Rational::int(1))
3940        );
3941        assert_eq!(
3942            u.dimensions.get(&Dimension::Amount),
3943            Some(&Rational::int(-1))
3944        );
3945
3946        let u = parse_unit("kg/m^3*s").unwrap();
3947        assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(1)));
3948        assert_eq!(
3949            u.dimensions.get(&Dimension::Length),
3950            Some(&Rational::int(-3))
3951        );
3952
3953        // Repeated division stays left-associative: m/s/s == m/s^2.
3954        let u = parse_unit("m/s/s").unwrap();
3955        assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-2)));
3956        assert_eq!(
3957            u.dimensions.get(&Dimension::Length),
3958            Some(&Rational::int(1))
3959        );
3960
3961        // Parenthesised denominators are unaffected.
3962        let u = parse_unit("J/(mol*K)").unwrap();
3963        assert_eq!(
3964            u.dimensions.get(&Dimension::Temperature),
3965            Some(&Rational::int(-1))
3966        );
3967        assert_eq!(
3968            u.dimensions.get(&Dimension::Amount),
3969            Some(&Rational::int(-1))
3970        );
3971    }
3972
3973    /// A dangling operator is malformed, not silently dimensionless. `"m/"` used
3974    /// to parse as metres and `"/s"` as s^-1.
3975    #[test]
3976    fn test_dangling_operator_is_rejected() {
3977        assert!(matches!(parse_unit("m/"), Err(UnitError::ParseError(_))));
3978        assert!(matches!(parse_unit("/s"), Err(UnitError::ParseError(_))));
3979        assert!(matches!(parse_unit("kg*"), Err(UnitError::ParseError(_))));
3980    }
3981
3982    #[test]
3983    fn test_parse_mixed_product_quotient() {
3984        // Mixed `kg*m/s^2` is the Newton: mass^1 length^1 time^-2.
3985        let u = parse_unit("kg*m/s^2").unwrap();
3986        assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
3987        assert_eq!(
3988            u.dimensions.get(&Dimension::Length),
3989            Some(&Rational::int(1))
3990        );
3991        assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-2)));
3992    }
3993
3994    #[test]
3995    fn test_unit_compatibility() {
3996        let m = parse_unit("m").unwrap();
3997        let cm = parse_unit("cm").unwrap();
3998        let s = parse_unit("s").unwrap();
3999        assert!(m.is_compatible(&cm));
4000        assert!(!m.is_compatible(&s));
4001    }
4002
4003    #[test]
4004    fn test_unit_arithmetic() {
4005        let m = parse_unit("m").unwrap();
4006        let s = parse_unit("s").unwrap();
4007        let v = m.divide(&s);
4008        assert_eq!(
4009            v.dimensions.get(&Dimension::Length),
4010            Some(&Rational::int(1))
4011        );
4012        assert_eq!(v.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
4013        assert_eq!(
4014            m.power(2).dimensions.get(&Dimension::Length),
4015            Some(&Rational::int(2))
4016        );
4017    }
4018
4019    #[test]
4020    fn test_dimensional_consistency() {
4021        let m = parse_unit("m").unwrap();
4022        let cm = parse_unit("cm").unwrap();
4023        let s = parse_unit("s").unwrap();
4024        assert!(check_dimensional_consistency(&m, &cm).is_ok());
4025        assert!(check_dimensional_consistency(&m, &s).is_err());
4026    }
4027
4028    #[test]
4029    fn test_unit_conversion() {
4030        let m = parse_unit("m").unwrap();
4031        let cm = parse_unit("cm").unwrap();
4032        assert!((convert_units(1.0, &m, &cm).unwrap() - 100.0).abs() < 1e-10);
4033        assert!((convert_units(100.0, &cm, &m).unwrap() - 1.0).abs() < 1e-10);
4034    }
4035
4036    #[test]
4037    fn test_unknown_unit() {
4038        let result = parse_unit("unknown_unit");
4039        assert!(matches!(result, Err(UnitError::UnknownUnit(_))));
4040    }
4041
4042    // ---- propagate ---------------------------------------------------
4043
4044    #[test]
4045    fn propagate_literal_is_indeterminate() {
4046        // A bare numeric literal carries NO knowable dimension: `273.15` may be
4047        // a pure number or a temperature offset. It is therefore indeterminate,
4048        // not dimensionless — a checker whose findings are hard errors must not
4049        // fabricate a dimension it cannot know.
4050        let env = HashMap::new();
4051        assert!(matches!(
4052            Unit::propagate(&Expr::Number(2.5), &env),
4053            Err(UnitError::UnknownUnit(_))
4054        ));
4055
4056        // Additively, though, a literal is NEUTRAL: it adopts its sibling's
4057        // dimension, so `T - 273.15` is a temperature and raises no finding.
4058        let env = env_of(&[("T", "K")]);
4059        let expr = op("-", vec![Expr::Variable("T".into()), Expr::Number(273.15)]);
4060        let u = Unit::propagate(&expr, &env).unwrap();
4061        assert_eq!(
4062            u.dimensions.get(&Dimension::Temperature),
4063            Some(&Rational::int(1))
4064        );
4065        assert!(check_expression_dimensions(&expr, None, &env).is_empty());
4066
4067        // But a sum with NO determinable operand is undeterminable, never
4068        // dimensionless (esm-spec §4.8.3, §4.8.4): for an integer as well as a
4069        // float literal, in `min`/`max` as in `+`/`-`, and under a unary `+`.
4070        let literal_pairs = [
4071            vec![Expr::Number(1.0), Expr::Number(2.0)],
4072            vec![Expr::Integer(1), Expr::Integer(2)],
4073        ];
4074        for args in literal_pairs {
4075            for name in ["+", "-", "min", "max"] {
4076                assert!(
4077                    matches!(
4078                        Unit::propagate(&op(name, args.clone()), &HashMap::new()),
4079                        Err(UnitError::UnknownUnit(_))
4080                    ),
4081                    "{name} over literals must be undeterminable"
4082                );
4083            }
4084        }
4085        for lit in [Expr::Integer(2), Expr::Number(2.5)] {
4086            assert!(matches!(
4087                Unit::propagate(&op("+", vec![lit]), &HashMap::new()),
4088                Err(UnitError::UnknownUnit(_))
4089            ));
4090        }
4091    }
4092
4093    /// A sum whose operands are partly undeterminable has the unit of the
4094    /// determinable ones (esm-spec §4.8.3), and a unary `+` carries its
4095    /// operand's unit.
4096    #[test]
4097    fn propagate_sum_with_undeterminable_operand_takes_known_unit() {
4098        let env = env_of(&[("x", "m"), ("y", "m")]);
4099        let half_y = op("*", vec![Expr::Number(0.5), Expr::Variable("y".into())]);
4100        for name in ["+", "-", "min", "max"] {
4101            let expr = op(name, vec![Expr::Variable("x".into()), half_y.clone()]);
4102            let u = Unit::propagate(&expr, &env).unwrap();
4103            assert_eq!(
4104                u.dimensions.get(&Dimension::Length),
4105                Some(&Rational::int(1)),
4106                "{name}"
4107            );
4108        }
4109        let plus_x = op("+", vec![Expr::Variable("x".into())]);
4110        let u = Unit::propagate(&plus_x, &env).unwrap();
4111        assert_eq!(
4112            u.dimensions.get(&Dimension::Length),
4113            Some(&Rational::int(1))
4114        );
4115    }
4116
4117    /// `ifelse` takes the unit of its determinable branch, whichever branch that
4118    /// is, and with no determinable branch it is undeterminable (esm-spec
4119    /// §4.8.3).
4120    #[test]
4121    fn ifelse_takes_the_unit_of_its_determinable_branch() {
4122        let env = env_of(&[("x", "m"), ("y", "m"), ("c", "1")]);
4123        let cond = op(">", vec![Expr::Variable("c".into()), Expr::Integer(0)]);
4124        let x = Expr::Variable("x".into());
4125        let half_y = op("*", vec![Expr::Number(0.5), Expr::Variable("y".into())]);
4126        let branch_pairs = [
4127            (x.clone(), half_y.clone()),
4128            (half_y.clone(), x.clone()),
4129            (Expr::Integer(2), x.clone()),
4130        ];
4131        for (then_branch, else_branch) in branch_pairs {
4132            let expr = op("ifelse", vec![cond.clone(), then_branch, else_branch]);
4133            let u = Unit::propagate(&expr, &env).unwrap();
4134            assert_eq!(
4135                u.dimensions.get(&Dimension::Length),
4136                Some(&Rational::int(1))
4137            );
4138        }
4139        let literal_branches = op("ifelse", vec![cond, Expr::Integer(1), Expr::Number(2.0)]);
4140        assert!(matches!(
4141            Unit::propagate(&literal_branches, &env),
4142            Err(UnitError::UnknownUnit(_))
4143        ));
4144    }
4145
4146    /// A boolean connective places no requirement on its operands' units, but a
4147    /// mismatch inside an operand is still reported (esm-spec §4.8.3).
4148    #[test]
4149    fn connectives_report_a_mismatch_inside_their_operands() {
4150        let env = env_of(&[("x", "m"), ("z", "kg"), ("c", "1")]);
4151        let mismatch = op(
4152            ">",
4153            vec![Expr::Variable("x".into()), Expr::Variable("z".into())],
4154        );
4155        let ok = op(">", vec![Expr::Variable("c".into()), Expr::Integer(0)]);
4156        for expr in [
4157            op("not", vec![mismatch.clone()]),
4158            op("and", vec![mismatch.clone(), ok.clone()]),
4159            op("or", vec![ok.clone(), mismatch.clone()]),
4160        ] {
4161            assert!(
4162                !check_expression_dimensions(&expr, None, &env).is_empty(),
4163                "{expr:?}"
4164            );
4165        }
4166        let dimensional_operands = op(
4167            "and",
4168            vec![Expr::Variable("x".into()), Expr::Variable("z".into())],
4169        );
4170        assert!(check_expression_dimensions(&dimensional_operands, None, &env).is_empty());
4171    }
4172
4173    /// An exponent is read BY VALUE, so `L^2` still yields an area even though
4174    /// the literal `2` has no dimension of its own.
4175    #[test]
4176    fn propagate_literal_exponent_is_read_by_value() {
4177        let env = env_of(&[("L", "m")]);
4178        let expr = op("^", vec![Expr::Variable("L".into()), Expr::Integer(2)]);
4179        let u = Unit::propagate(&expr, &env).unwrap();
4180        assert_eq!(
4181            u.dimensions.get(&Dimension::Length),
4182            Some(&Rational::int(2))
4183        );
4184    }
4185
4186    /// An `Expr::Integer` exponent is an i64 and is read at full width. A value
4187    /// above `i32::MAX` has no representable rational dimension exponent, so it
4188    /// reports `UnknownUnit` naming the ORIGINAL value — it must not truncate
4189    /// to a bogus i32 and propagate a confidently wrong dimension.
4190    #[test]
4191    fn propagate_huge_integer_exponent_does_not_wrap() {
4192        let env = env_of(&[("L", "m")]);
4193        let exp = i64::from(i32::MAX) + 6;
4194        let expr = op("^", vec![Expr::Variable("L".into()), Expr::Integer(exp)]);
4195        let err = Unit::propagate(&expr, &env).unwrap_err();
4196        match err {
4197            UnitError::UnknownUnit(msg) => assert!(
4198                msg.contains(&exp.to_string()),
4199                "finding must name the untruncated exponent, got: {msg}"
4200            ),
4201            other => panic!("expected UnknownUnit, got {other:?}"),
4202        }
4203    }
4204
4205    #[test]
4206    fn propagate_variable_lookup() {
4207        let env = env_of(&[("v", "m/s")]);
4208        let u = Unit::propagate(&Expr::Variable("v".into()), &env).unwrap();
4209        assert_eq!(
4210            u.dimensions.get(&Dimension::Length),
4211            Some(&Rational::int(1))
4212        );
4213        assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
4214    }
4215
4216    #[test]
4217    fn propagate_time_variable_default() {
4218        let env = HashMap::new();
4219        let u = Unit::propagate(&Expr::Variable("t".into()), &env).unwrap();
4220        assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(1)));
4221    }
4222
4223    #[test]
4224    fn propagate_unknown_variable_errors() {
4225        let env = HashMap::new();
4226        let err = Unit::propagate(&Expr::Variable("nope".into()), &env).unwrap_err();
4227        assert!(matches!(err, UnitError::UnknownUnit(_)));
4228    }
4229
4230    #[test]
4231    fn propagate_addition_matches() {
4232        // Both in metres: `+` needs its operands to agree in scale as well as
4233        // dimension (esm-spec §4.8.3).
4234        let env = env_of(&[("h1", "m"), ("h2", "m")]);
4235        let e = op(
4236            "+",
4237            vec![Expr::Variable("h1".into()), Expr::Variable("h2".into())],
4238        );
4239        let u = Unit::propagate(&e, &env).unwrap();
4240        assert_eq!(
4241            u.dimensions.get(&Dimension::Length),
4242            Some(&Rational::int(1))
4243        );
4244    }
4245
4246    #[test]
4247    fn propagate_addition_mismatch_errors() {
4248        let env = env_of(&[("h", "m"), ("t", "s")]);
4249        let e = op(
4250            "+",
4251            vec![Expr::Variable("h".into()), Expr::Variable("t".into())],
4252        );
4253        let err = Unit::propagate(&e, &env).unwrap_err();
4254        assert!(matches!(err, UnitError::DimensionMismatch(_)));
4255    }
4256
4257    #[test]
4258    fn propagate_multiplication_combines_dims() {
4259        let env = env_of(&[("rho", "kg/m^3"), ("v", "m/s")]);
4260        let e = op(
4261            "*",
4262            vec![Expr::Variable("rho".into()), Expr::Variable("v".into())],
4263        );
4264        let u = Unit::propagate(&e, &env).unwrap();
4265        assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
4266        assert_eq!(
4267            u.dimensions.get(&Dimension::Length),
4268            Some(&Rational::int(-2))
4269        );
4270        assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
4271    }
4272
4273    #[test]
4274    fn propagate_division_subtracts_dims() {
4275        let env = env_of(&[("m1", "kg"), ("V", "m^3")]);
4276        let e = op(
4277            "/",
4278            vec![Expr::Variable("m1".into()), Expr::Variable("V".into())],
4279        );
4280        let u = Unit::propagate(&e, &env).unwrap();
4281        assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
4282        assert_eq!(
4283            u.dimensions.get(&Dimension::Length),
4284            Some(&Rational::int(-3))
4285        );
4286    }
4287
4288    #[test]
4289    fn propagate_power_integer() {
4290        let env = env_of(&[("s", "m")]);
4291        let e = op("^", vec![Expr::Variable("s".into()), Expr::Number(3.0)]);
4292        let u = Unit::propagate(&e, &env).unwrap();
4293        assert_eq!(
4294            u.dimensions.get(&Dimension::Length),
4295            Some(&Rational::int(3))
4296        );
4297    }
4298
4299    #[test]
4300    fn propagate_power_rejects_dimensional_exponent() {
4301        // h ^ t is invalid: exponent must be dimensionless.
4302        let env = env_of(&[("h", "m"), ("t", "s")]);
4303        let e = op(
4304            "^",
4305            vec![Expr::Variable("h".into()), Expr::Variable("t".into())],
4306        );
4307        let err = Unit::propagate(&e, &env).unwrap_err();
4308        assert!(matches!(err, UnitError::DimensionMismatch(_)));
4309    }
4310
4311    #[test]
4312    fn propagate_derivative_divides_by_wrt() {
4313        // Canonical bead example: D(h)/dt should have dimensions of v (m/s).
4314        // `t` must be DECLARED for the derivative to be determinate — an
4315        // undeclared independent variable leaves it unknown (see
4316        // `derivative_of_undeclared_time`).
4317        let env = env_of(&[("h", "m"), ("v", "m/s"), ("t", "s")]);
4318        let dh = op_with_wrt("D", vec![Expr::Variable("h".into())], "t");
4319        let dh_dim = Unit::propagate(&dh, &env).unwrap();
4320        let v_dim = Unit::propagate(&Expr::Variable("v".into()), &env).unwrap();
4321        assert!(dh_dim.is_compatible(&v_dim));
4322    }
4323
4324    /// With an UNDECLARED `t` the derivative's dimension is unknown, so no time
4325    /// unit is assumed and the plain LHS/RHS comparison is skipped.
4326    #[test]
4327    fn propagate_derivative_of_undeclared_time_is_unknown() {
4328        let env = env_of(&[("h", "m")]);
4329        let dh = op_with_wrt("D", vec![Expr::Variable("h".into())], "t");
4330        assert!(matches!(
4331            Unit::propagate(&dh, &env),
4332            Err(UnitError::UnknownUnit(_))
4333        ));
4334    }
4335
4336    #[test]
4337    fn propagate_exp_requires_dimensionless_arg() {
4338        let env = env_of(&[("h", "m")]);
4339        let e = op("exp", vec![Expr::Variable("h".into())]);
4340        let err = Unit::propagate(&e, &env).unwrap_err();
4341        assert!(matches!(err, UnitError::DimensionMismatch(_)));
4342    }
4343
4344    #[test]
4345    fn propagate_sqrt_halves_even_powers() {
4346        let env = env_of(&[("a", "m^2")]);
4347        let e = op("sqrt", vec![Expr::Variable("a".into())]);
4348        let u = Unit::propagate(&e, &env).unwrap();
4349        assert_eq!(
4350            u.dimensions.get(&Dimension::Length),
4351            Some(&Rational::int(1))
4352        );
4353    }
4354
4355    #[test]
4356    fn propagate_ifelse_branches_must_match() {
4357        let env = env_of(&[("h", "m"), ("x", "s")]);
4358        let e = op(
4359            "ifelse",
4360            vec![
4361                Expr::Number(1.0),
4362                Expr::Variable("h".into()),
4363                Expr::Variable("x".into()),
4364            ],
4365        );
4366        assert!(matches!(
4367            Unit::propagate(&e, &env).unwrap_err(),
4368            UnitError::DimensionMismatch(_)
4369        ));
4370    }
4371
4372    #[test]
4373    fn propagate_comparison_requires_matching_dims() {
4374        let env = env_of(&[("h", "m"), ("x", "s")]);
4375        let e = op(
4376            ">",
4377            vec![Expr::Variable("h".into()), Expr::Variable("x".into())],
4378        );
4379        assert!(matches!(
4380            Unit::propagate(&e, &env).unwrap_err(),
4381            UnitError::DimensionMismatch(_)
4382        ));
4383    }
4384
4385    /// The spatial-calculus sugar ops (`grad` / `div` / `laplacian` / `curl` /
4386    /// `∇` / `integral`) are ORDINARY open-tier rewrite targets with NO
4387    /// privileged dimensional rule (esm-spec §4.2 / §4.8.3). Their dimension is
4388    /// UNDETERMINABLE until a discretization rule lowers them, so propagation
4389    /// yields an unknown dimension via the same no-rule path as any unregistered
4390    /// user op — never the old bespoke "divide by a metre denominator"
4391    /// behaviour. Per esm-spec §4.8.4 an unlowered op is reported as UNKNOWN and
4392    /// SKIPPED: it emits NO finding at all, matching the other four bindings
4393    /// (Julia/TS/Python/Go). `grad` is not singled out.
4394    #[test]
4395    fn propagate_spatial_sugar_ops_are_undeterminable() {
4396        let env = env_of(&[("c", "mol/m^3")]);
4397        for op_name in ["grad", "div", "laplacian", "curl", "∇", "integral"] {
4398            let e = op(op_name, vec![Expr::Variable("c".into())]);
4399            // No dimensional rule ⇒ undeterminable, so `propagate` cannot
4400            // conclude a unit.
4401            assert!(
4402                matches!(Unit::propagate(&e, &env), Err(UnitError::UnknownUnit(_))),
4403                "{op_name} must have an undeterminable dimension"
4404            );
4405            // Report-unknown-and-skip: NO finding at all — not a promotable
4406            // `Error`, and not even a non-blocking `Analysis`. The checker just
4407            // declines to fabricate a dimension it cannot know.
4408            let findings = check_expression_dimensions(&e, None, &env);
4409            assert!(
4410                findings.is_empty(),
4411                "{op_name} must emit no finding (report unknown + skip): {findings:?}"
4412            );
4413        }
4414    }
4415
4416    #[test]
4417    fn propagate_faq_body() {
4418        // aggregate { expr: x * scale } with x in m/s and a dimensionless
4419        // `scale` -> result m/s. (A bare numeric literal would leave the product
4420        // INDETERMINATE — see `propagate_literal_is_indeterminate` — so the
4421        // element dimension is exercised with a declared factor.)
4422        let env = env_of(&[("x", "m/s"), ("scale", "1")]);
4423        let body = op(
4424            "*",
4425            vec![Expr::Variable("x".into()), Expr::Variable("scale".into())],
4426        );
4427        let node = Expr::operator(ExpressionNode {
4428            op: "faq".to_string(),
4429            args: vec![],
4430            expr: Some(Box::new(body)),
4431            ..ExpressionNode::default()
4432        });
4433        let u = Unit::propagate(&node, &env).unwrap();
4434        assert_eq!(
4435            u.dimensions.get(&Dimension::Length),
4436            Some(&Rational::int(1))
4437        );
4438        assert_eq!(u.dimensions.get(&Dimension::Time), Some(&Rational::int(-1)));
4439    }
4440
4441    #[test]
4442    fn propagate_index_inherits_from_source() {
4443        let env = env_of(&[("arr", "kg")]);
4444        let node = op(
4445            "index",
4446            vec![Expr::Variable("arr".into()), Expr::Number(0.0)],
4447        );
4448        let u = Unit::propagate(&node, &env).unwrap();
4449        assert_eq!(u.dimensions.get(&Dimension::Mass), Some(&Rational::int(1)));
4450    }
4451
4452    #[test]
4453    fn propagate_broadcast_dispatches_to_fn() {
4454        // broadcast(fn='+', a, b) with matching dims works.
4455        let env = env_of(&[("a", "m"), ("b", "m")]);
4456        let node = Expr::operator(ExpressionNode {
4457            op: "broadcast".to_string(),
4458            broadcast_fn: Some("+".to_string()),
4459            args: vec![Expr::Variable("a".into()), Expr::Variable("b".into())],
4460            ..ExpressionNode::default()
4461        });
4462        let u = Unit::propagate(&node, &env).unwrap();
4463        assert_eq!(
4464            u.dimensions.get(&Dimension::Length),
4465            Some(&Rational::int(1))
4466        );
4467    }
4468
4469    #[test]
4470    fn propagate_unknown_operator_reports_unknown_and_skips() {
4471        // An operator with no dimensional rule means the CHECKER cannot
4472        // conclude anything — it is not a defect in the file. Per esm-spec
4473        // §4.8.4 it reports UNKNOWN and SKIPS: an unknown dimension and NO
4474        // finding at all (matching Julia/TS/Python/Go), never a promotable
4475        // `Error` and never a silent `dimensionless`.
4476        let env = HashMap::new();
4477        let e = op("zorp", vec![Expr::Number(1.0)]);
4478        let findings = check_expression_dimensions(&e, None, &env);
4479        assert!(findings.is_empty(), "expected no finding, got {findings:?}");
4480        assert!(matches!(
4481            Unit::propagate(&e, &env),
4482            Err(UnitError::UnknownUnit(_))
4483        ));
4484    }
4485
4486    /// An undeterminable operand must not SUPPRESS a provable mismatch
4487    /// elsewhere in the same expression: propagation yields `Unknown` for it and
4488    /// keeps walking instead of bailing at the first one.
4489    #[test]
4490    fn undeterminable_operand_does_not_hide_a_real_mismatch() {
4491        let env = env_of(&[("len", "m"), ("mass", "kg")]);
4492        // (zorp(1) + len) + mass — the unknown op is undeterminable (reported as
4493        // unknown and skipped, so it emits NO finding of its own), but the `len`
4494        // vs `mass` mismatch after it is still proven.
4495        let expr = op(
4496            "+",
4497            vec![
4498                op("zorp", vec![Expr::Number(1.0)]),
4499                Expr::Variable("len".into()),
4500                Expr::Variable("mass".into()),
4501            ],
4502        );
4503        let findings = check_expression_dimensions(&expr, None, &env);
4504        assert!(
4505            findings.iter().any(UnitFinding::is_error),
4506            "the m-vs-kg mismatch must still be proven: {findings:?}"
4507        );
4508        // The no-rule op adds nothing: the only finding is the real mismatch.
4509        assert!(
4510            findings.iter().all(UnitFinding::is_error),
4511            "the undeterminable op must not add a finding of its own: {findings:?}"
4512        );
4513    }
4514
4515    #[test]
4516    fn validate_equation_dimensions_passes() {
4517        // `t` is declared, so D(h)/dt is determinate (m/s) and matches v.
4518        let env = env_of(&[("h", "m"), ("v", "m/s"), ("t", "s")]);
4519        let eq = Equation {
4520            comment: None,
4521            lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
4522            rhs: Expr::Variable("v".into()),
4523        };
4524        validate_equation_dimensions(&eq, &env).unwrap();
4525    }
4526
4527    #[test]
4528    fn validate_equation_dimensions_detects_mismatch() {
4529        // With `t` DECLARED the derivative is determinate, so the plain
4530        // LHS-vs-RHS comparison proves the mismatch: D(h)/dt is m/s, h is m.
4531        let env = env_of(&[("h", "m"), ("v", "m/s"), ("t", "s")]);
4532        let eq = Equation {
4533            comment: None,
4534            lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
4535            rhs: Expr::Variable("h".into()),
4536        };
4537        assert!(validate_equation_dimensions(&eq, &env).is_err());
4538
4539        // The consistent equation is accepted.
4540        let eq = Equation {
4541            comment: None,
4542            lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
4543            rhs: Expr::Variable("v".into()),
4544        };
4545        assert!(validate_equation_dimensions(&eq, &env).is_ok());
4546    }
4547
4548    /// With an UNDECLARED `t`, the weaker time-ratio rule still proves a
4549    /// derivative equation that NO time unit could reconcile — while accepting
4550    /// one that some time unit could.
4551    #[test]
4552    fn derivative_time_ratio_rule() {
4553        let env = env_of(&[("h", "m"), ("v", "m/s"), ("mass", "kg")]);
4554
4555        // d(m)/dt = kg: ratio m/kg is not a power of time ⇒ provable mismatch.
4556        let eq = Equation {
4557            comment: None,
4558            lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
4559            rhs: Expr::Variable("mass".into()),
4560        };
4561        let findings = check_equation_dimensions(&eq, &env);
4562        assert!(findings.iter().any(UnitFinding::is_error), "{findings:?}");
4563
4564        // d(m)/dt = m/s: ratio is exactly `s` ⇒ reconcilable, no finding.
4565        let eq = Equation {
4566            comment: None,
4567            lhs: op_with_wrt("D", vec![Expr::Variable("h".into())], "t"),
4568            rhs: Expr::Variable("v".into()),
4569        };
4570        assert!(
4571            !check_equation_dimensions(&eq, &env)
4572                .iter()
4573                .any(UnitFinding::is_error)
4574        );
4575
4576        // The dimensionless toy idiom `D(x)/dt = -x` (ratio time^0) is accepted:
4577        // it is the idiom used across the shared valid corpus.
4578        let env = env_of(&[("x", "dimensionless")]);
4579        let eq = Equation {
4580            comment: None,
4581            lhs: op_with_wrt("D", vec![Expr::Variable("x".into())], "t"),
4582            rhs: op("-", vec![Expr::Variable("x".into())]),
4583        };
4584        assert!(
4585            !check_equation_dimensions(&eq, &env)
4586                .iter()
4587                .any(UnitFinding::is_error)
4588        );
4589    }
4590
4591    /// Build a bare state [`crate::ModelVariable`] carrying only a declared
4592    /// unit string (all other metadata omitted).
4593    fn state_var_with_units(units: Option<&str>) -> crate::ModelVariable {
4594        crate::test_support::var(crate::VariableType::Unknown, units)
4595    }
4596
4597    #[test]
4598    fn build_unit_env_unparseable_unit_is_unknown_not_dimensionless() {
4599        let mut variables = indexmap::IndexMap::new();
4600        // `x` has an unparseable unit; `y` has a real unit of metres.
4601        variables.insert("x".to_string(), state_var_with_units(Some("not_a_unit")));
4602        variables.insert("y".to_string(), state_var_with_units(Some("m")));
4603
4604        let (env, failures) = build_unit_env(&variables);
4605
4606        // The unparseable variable is OMITTED (unknown), NOT coerced to a
4607        // dimensionless unit; the parseable one is present.
4608        assert!(
4609            !env.contains_key("x"),
4610            "unparseable unit must not be in env"
4611        );
4612        assert!(env.contains_key("y"));
4613
4614        // The failure is REPORTED, naming the offending variable and the string
4615        // the author actually wrote, so the caller can raise a hard
4616        // `unit_parse_error` at `/models/<M>/variables/x` (esm-spec §4.8.4).
4617        assert_eq!(
4618            failures,
4619            vec![UnitParseFailure {
4620                name: "x".to_string(),
4621                units: "not_a_unit".to_string(),
4622            }]
4623        );
4624
4625        // An equation `y = x` referencing the unknown-unit variable propagates
4626        // to UnknownUnit — a non-blocking, skip-for-dim-checking outcome — NOT
4627        // a false DimensionMismatch (which a dimensionless coercion of `x`
4628        // against `y`'s metres would have produced).
4629        let eq = Equation {
4630            comment: None,
4631            lhs: Expr::Variable("y".into()),
4632            rhs: Expr::Variable("x".into()),
4633        };
4634        let result = validate_equation_dimensions(&eq, &env);
4635        assert!(
4636            matches!(result, Err(UnitError::UnknownUnit(_))),
4637            "expected UnknownUnit (skipped), not a false mismatch, got {result:?}"
4638        );
4639    }
4640
4641    fn konst(value: f64, units: Option<&str>) -> Expr {
4642        Expr::operator(ExpressionNode {
4643            op: "const".to_string(),
4644            value: Some(serde_json::json!(value)),
4645            units: units.map(str::to_string),
4646            ..ExpressionNode::default()
4647        })
4648    }
4649
4650    #[test]
4651    fn constant_exponents_fold_to_exact_rationals() {
4652        let int = Expr::Integer;
4653        let cases = [
4654            (op("/", vec![int(3), int(2)]), Rational::new(3, 2)),
4655            (op("-", vec![int(1)]), Rational::int(-1)),
4656            (op("-", vec![int(5), int(3)]), Rational::int(2)),
4657            (
4658                op(
4659                    "/",
4660                    vec![op("+", vec![int(1), int(2)]), op("*", vec![int(4), int(2)])],
4661                ),
4662                Rational::new(3, 8),
4663            ),
4664            (konst(1.5, None), Rational::new(3, 2)),
4665            (konst(0.5, Some("1")), Rational::new(1, 2)),
4666            (Expr::Number(0.25), Rational::new(1, 4)),
4667        ];
4668        for (expr, expected) in cases {
4669            assert_eq!(constant_exponent(&expr), Some(expected), "{expr:?}");
4670        }
4671    }
4672
4673    #[test]
4674    fn symbolic_exponents_do_not_fold() {
4675        let int = Expr::Integer;
4676        let var = |n: &str| Expr::Variable(n.into());
4677        for expr in [
4678            var("n"),
4679            op("+", vec![var("n"), int(1)]),
4680            konst(50.0, Some("%")),
4681            konst(2.0, Some("m")),
4682            op("/", vec![int(1), int(0)]),
4683            op("/", vec![int(1), int(2), int(3)]),
4684            op("sqrt", vec![int(4)]),
4685            op(
4686                "*",
4687                vec![int(i64::from(i32::MAX)), int(i64::from(i32::MAX))],
4688            ),
4689        ] {
4690            assert_eq!(constant_exponent(&expr), None, "{expr:?}");
4691        }
4692    }
4693
4694    #[test]
4695    fn a_folded_exponent_is_checked_and_a_symbolic_one_is_skipped() {
4696        let env = env_of(&[("T", "K"), ("n", "1"), ("T_32", "K^(3/2)"), ("T_2", "K^2")]);
4697        let var = |n: &str| Expr::Variable(n.into());
4698        let three_halves = op("/", vec![Expr::Integer(3), Expr::Integer(2)]);
4699        let pow = op("^", vec![var("T"), three_halves]);
4700        let ok = check_expression_dimensions(&pow, env.get("T_32"), &env);
4701        assert!(!ok.iter().any(UnitFinding::is_error), "{ok:?}");
4702        let bad = check_expression_dimensions(&pow, env.get("T_2"), &env);
4703        assert!(bad.iter().any(UnitFinding::is_error), "{bad:?}");
4704        let sym = op("^", vec![var("T"), var("n")]);
4705        let skipped = check_expression_dimensions(&sym, env.get("T_2"), &env);
4706        assert!(!skipped.iter().any(UnitFinding::is_error), "{skipped:?}");
4707    }
4708
4709    #[test]
4710    fn an_faq_filter_is_dimension_checked() {
4711        let env = env_of(&[("x", "m"), ("y", "m"), ("z", "kg")]);
4712        let var = |n: &str| Expr::Variable(n.into());
4713        let faq = |filter: Expr| {
4714            Expr::operator(ExpressionNode {
4715                op: "faq".to_string(),
4716                filter: Some(Box::new(filter)),
4717                expr: Some(Box::new(konst(1.0, Some("1")))),
4718                ..ExpressionNode::default()
4719            })
4720        };
4721        let bad = check_expression_dimensions(&faq(op("<=", vec![var("x"), var("z")])), None, &env);
4722        assert!(bad.iter().any(UnitFinding::is_error), "{bad:?}");
4723        let ok = check_expression_dimensions(&faq(op("<=", vec![var("x"), var("y")])), None, &env);
4724        assert!(!ok.iter().any(UnitFinding::is_error), "{ok:?}");
4725    }
4726
4727    fn faq_node(body: Expr, semiring: Option<&str>, reduce: Option<&str>) -> ExpressionNode {
4728        ExpressionNode {
4729            op: "faq".to_string(),
4730            expr: Some(Box::new(body)),
4731            semiring: semiring.map(str::to_string),
4732            reduce: reduce.map(str::to_string),
4733            ..ExpressionNode::default()
4734        }
4735    }
4736
4737    #[test]
4738    fn array_operators_carry_their_element_unit() {
4739        let env = env_of(&[("v", "h"), ("w", "1"), ("a", "m"), ("b", "kg")]);
4740        let var = |n: &str| Expr::Variable(n.into());
4741        let unit = |e: &Expr| propagate_dim(e, &env, &mut Vec::new());
4742        let h = parse_unit("h").unwrap();
4743        let idx = op("index", vec![var("v"), var("i")]);
4744        assert_eq!(unit(&idx).known(), Some(&h));
4745        assert!(unit(&op("index", vec![])).known().is_none());
4746        // faq by semiring.
4747        for (sr, rd) in [
4748            (Some("sum_product"), None),
4749            (Some("max_product"), None),
4750            (Some("min_sum"), None),
4751            (None, Some("max")),
4752            (None, None),
4753        ] {
4754            let f = Expr::operator(faq_node(idx.clone(), sr, rd));
4755            assert_eq!(unit(&f).known(), Some(&h), "{sr:?} {rd:?}");
4756        }
4757        let prod = Expr::operator(faq_node(idx.clone(), None, Some("*")));
4758        assert!(unit(&prod).known().is_none());
4759        let wprod = Expr::operator(faq_node(
4760            op("index", vec![var("w"), var("i")]),
4761            None,
4762            Some("*"),
4763        ));
4764        assert!(unit(&wprod).known().is_some_and(Unit::is_pure_number));
4765        let any = Expr::operator(faq_node(idx.clone(), Some("bool_and_or"), None));
4766        assert!(unit(&any).known().is_some_and(Unit::is_dimensionless));
4767        let mut distinct = faq_node(idx.clone(), None, None);
4768        distinct.distinct = Some(true);
4769        assert!(unit(&Expr::operator(distinct)).known().is_none());
4770        // makearray: a literal region is skipped; disagreeing regions are a mismatch.
4771        let mk = |values: Vec<Expr>| {
4772            Expr::operator(ExpressionNode {
4773                op: "makearray".to_string(),
4774                values: Some(values),
4775                ..ExpressionNode::default()
4776            })
4777        };
4778        assert_eq!(
4779            unit(&mk(vec![var("v"), Expr::Number(0.0)])).known(),
4780            Some(&h)
4781        );
4782        assert!(unit(&mk(vec![])).known().is_none());
4783        let mut f = Vec::new();
4784        propagate_dim(&mk(vec![var("a"), var("b")]), &env, &mut f);
4785        assert!(f.iter().any(UnitFinding::is_error), "{f:?}");
4786        // concat agrees like +.
4787        let mut f = Vec::new();
4788        propagate_dim(&op("concat", vec![var("a"), var("b")]), &env, &mut f);
4789        assert!(f.iter().any(UnitFinding::is_error), "{f:?}");
4790    }
4791
4792    #[test]
4793    fn array_operators_check_every_operand() {
4794        let env = env_of(&[("v", "h"), ("a", "m"), ("b", "kg")]);
4795        let var = |n: &str| Expr::Variable(n.into());
4796        let h = parse_unit("h").unwrap();
4797        let bad = || op("+", vec![var("a"), var("b")]);
4798        // A mismatch in a subscript or shape operand is reported, while the
4799        // result is still the source array's unit.
4800        for name in ["index", "reshape", "transpose"] {
4801            let mut f = Vec::new();
4802            let dim = propagate_dim(&op(name, vec![var("v"), bad()]), &env, &mut f);
4803            assert_eq!(dim.known(), Some(&h), "{name}");
4804            assert!(f.iter().any(UnitFinding::is_error), "{name}: {f:?}");
4805            // A dimensional subscript is not itself an error.
4806            let mut f = Vec::new();
4807            propagate_dim(&op(name, vec![var("v"), var("a")]), &env, &mut f);
4808            assert!(!f.iter().any(UnitFinding::is_error), "{name}: {f:?}");
4809        }
4810        // faq, makearray and a broadcast with no `fn` walk their operands too.
4811        let mut fq = faq_node(var("v"), None, None);
4812        fq.args = vec![bad()];
4813        let mut mk = ExpressionNode {
4814            op: "makearray".to_string(),
4815            values: Some(vec![var("v")]),
4816            ..ExpressionNode::default()
4817        };
4818        mk.args = vec![bad()];
4819        let bc = ExpressionNode {
4820            op: "broadcast".to_string(),
4821            args: vec![bad()],
4822            ..ExpressionNode::default()
4823        };
4824        for node in [fq, mk, bc] {
4825            let name = node.op.clone();
4826            let mut f = Vec::new();
4827            propagate_dim(&Expr::operator(node), &env, &mut f);
4828            assert!(f.iter().any(UnitFinding::is_error), "{name}: {f:?}");
4829        }
4830    }
4831}