Skip to main content

bicmath_units/
lib.rs

1//! Units module: a versioned unit registry, dimensional algebra, and exact
2//! conversions built on the shared quantity representation in `bicmath-core`.
3
4mod check;
5mod registry;
6
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10use bicmath_core::context::ExecContext;
11use bicmath_core::contract::{
12    Args, Assumption, CostClass, Example, Function, FunctionDescriptor, Module, ModuleDescriptor,
13    Outcome, ParamDescriptor, SimpleFunction,
14};
15use bicmath_core::envelope::Exactness;
16use bicmath_core::error::{EngineError, ErrorCode};
17use bicmath_core::limits::Limits;
18use bicmath_core::number::{Decimal, Float64, Number, NumberResult, NumericContext, NumericMode};
19use bicmath_core::schema::{FieldSchema, ValueSchema};
20use bicmath_core::value::{DIM_TEMPERATURE, DIMENSION_NAMES, Dimension, Value};
21use num_bigint::BigInt;
22use num_rational::BigRational;
23use num_traits::{ToPrimitive, Zero};
24
25pub use registry::REGISTRY_VERSION;
26
27use registry::{Factor, Unit};
28
29fn all_modes() -> Vec<NumericMode> {
30    vec![
31        NumericMode::Exact,
32        NumericMode::Auto,
33        NumericMode::Scientific,
34    ]
35}
36
37fn temperature_dimension() -> Dimension {
38    Dimension::new([0, 0, 0, 0, 1, 0, 0, 0])
39}
40
41fn length_dimension() -> Dimension {
42    Dimension::new([1, 0, 0, 0, 0, 0, 0, 0])
43}
44
45fn area_dimension() -> Dimension {
46    Dimension::new([2, 0, 0, 0, 0, 0, 0, 0])
47}
48
49fn volume_dimension() -> Dimension {
50    Dimension::new([3, 0, 0, 0, 0, 0, 0, 0])
51}
52
53fn time_dimension() -> Dimension {
54    Dimension::new([0, 0, 1, 0, 0, 0, 0, 0])
55}
56
57#[cfg(test)]
58fn speed_dimension() -> Dimension {
59    Dimension::new([1, 0, -1, 0, 0, 0, 0, 0])
60}
61
62#[cfg(test)]
63fn pressure_dimension() -> Dimension {
64    Dimension::new([-1, 1, -2, 0, 0, 0, 0, 0])
65}
66
67fn exact_number(value: BigRational, limits: &Limits) -> Number {
68    if value.is_integer() {
69        return Number::Integer(value.to_integer());
70    }
71    match Decimal::from_rational(&value, &NumericContext::exact(), limits) {
72        Ok((decimal, false)) => Number::Decimal(decimal),
73        _ => Number::Rational(value),
74    }
75}
76
77fn dimension_value(dimension: Dimension) -> Value {
78    let mut fields = BTreeMap::new();
79    for (index, name) in DIMENSION_NAMES.iter().enumerate() {
80        let exponent = dimension.get(index);
81        if exponent != 0 {
82            fields.insert(
83                (*name).to_string(),
84                Value::Number(Number::Integer(BigInt::from(exponent))),
85            );
86        }
87    }
88    Value::Record(fields)
89}
90
91fn factor_value(factor: &Factor, limits: &Limits) -> Value {
92    match factor {
93        Factor::Exact(value) => Value::Number(exact_number(value.clone(), limits)),
94        Factor::Approx(value) => Value::Number(Number::Float64(
95            Float64::new(*value).expect("registered unit factors are finite"),
96        )),
97    }
98}
99
100fn unit_record(unit: &Unit, with_notes: bool, limits: &Limits) -> Value {
101    let mut fields = BTreeMap::new();
102    fields.insert("id".to_string(), Value::text(unit.id));
103    fields.insert("symbol".to_string(), Value::text(unit.symbol));
104    fields.insert("name".to_string(), Value::text(unit.name));
105    fields.insert("unit_kind".to_string(), Value::text(unit.kind));
106    fields.insert("dimension".to_string(), dimension_value(unit.dimension));
107    fields.insert("factor".to_string(), factor_value(&unit.factor, limits));
108    fields.insert(
109        "offset".to_string(),
110        match &unit.offset {
111            Some(value) => Value::Number(exact_number(value.clone(), limits)),
112            None => Value::Null,
113        },
114    );
115    fields.insert("exact_factor".to_string(), Value::Bool(unit.exact_factor));
116    fields.insert(
117        "aliases".to_string(),
118        Value::Array(
119            unit.aliases
120                .iter()
121                .map(|alias| Value::text(*alias))
122                .collect(),
123        ),
124    );
125    fields.insert("affine".to_string(), Value::Bool(unit.affine));
126    if with_notes {
127        fields.insert("notes".to_string(), Value::text(unit.notes));
128    }
129    Value::Record(fields)
130}
131
132fn unit_record_schema(with_notes: bool) -> ValueSchema {
133    let mut fields = vec![
134        FieldSchema::required("id", ValueSchema::text()),
135        FieldSchema::required("symbol", ValueSchema::text()),
136        FieldSchema::required("name", ValueSchema::text()),
137        FieldSchema::required("unit_kind", ValueSchema::text()),
138        FieldSchema::required("dimension", ValueSchema::Any),
139        FieldSchema::required("factor", ValueSchema::Any),
140        FieldSchema::required("offset", ValueSchema::Any),
141        FieldSchema::required("exact_factor", ValueSchema::Bool),
142        FieldSchema::required("aliases", ValueSchema::array(ValueSchema::text())),
143        FieldSchema::required("affine", ValueSchema::Bool),
144    ];
145    if with_notes {
146        fields.push(FieldSchema::required("notes", ValueSchema::text()));
147    }
148    ValueSchema::Record {
149        fields,
150        allow_extra: false,
151    }
152}
153
154fn quantity_value(value: Number, dimension: Dimension) -> Value {
155    Value::Quantity {
156        value: Box::new(Value::Number(value)),
157        dimension,
158    }
159}
160
161fn int_quantity(value: i64, dimension: Dimension) -> Value {
162    quantity_value(Number::Integer(BigInt::from(value)), dimension)
163}
164
165fn decimal_quantity(value: &str, dimension: Dimension) -> Value {
166    quantity_value(
167        Number::Decimal(Decimal::parse_default(value).expect("example decimal literal")),
168        dimension,
169    )
170}
171
172fn example_args(pairs: &[(&str, Value)]) -> BTreeMap<String, Value> {
173    pairs
174        .iter()
175        .map(|(name, value)| ((*name).to_string(), value.clone()))
176        .collect()
177}
178
179fn describe_example(id: &str) -> Value {
180    match registry::find_unit(id) {
181        Ok(unit) => unit_record(unit, true, &Limits::conservative()),
182        Err(_) => Value::Null,
183    }
184}
185
186struct Magnitude {
187    value: Number,
188    dimension: Dimension,
189    is_quantity: bool,
190}
191
192fn magnitude(value: &Value, name: &str) -> Result<Magnitude, EngineError> {
193    match value {
194        Value::Quantity { value, dimension } => Ok(Magnitude {
195            value: value.as_number()?.clone(),
196            dimension: *dimension,
197            is_quantity: true,
198        }),
199        Value::Number(number) => Ok(Magnitude {
200            value: number.clone(),
201            dimension: Dimension::DIMENSIONLESS,
202            is_quantity: false,
203        }),
204        Value::Money { .. } => Err(EngineError::domain(
205            "money is not a physical quantity; currency is not a dimension",
206        )
207        .with_path(name.to_string())),
208        other => Err(EngineError::malformed(format!(
209            "{name} must be a quantity or a number, found {}",
210            other.kind_name()
211        ))
212        .with_path(name.to_string())),
213    }
214}
215
216fn extract_magnitude(
217    raw: &Value,
218    expected: Dimension,
219    context: &str,
220) -> Result<(Number, bool), EngineError> {
221    match raw {
222        Value::Quantity { value, dimension } => {
223            if *dimension != expected {
224                return Err(EngineError::new(
225                    ErrorCode::IncompatibleUnits,
226                    format!("{context} has dimension {dimension}, expected {expected}"),
227                ));
228            }
229            Ok((value.as_number()?.clone(), false))
230        }
231        Value::Number(number) => Ok((number.clone(), true)),
232        Value::Money { .. } => Err(EngineError::domain(
233            "money is not a physical quantity; currency is not a dimension",
234        )),
235        other => Err(EngineError::malformed(format!(
236            "{context} must be a quantity or a number, found {}",
237            other.kind_name()
238        ))),
239    }
240}
241
242fn offset_exact(unit: &Unit, apply: bool) -> BigRational {
243    if !apply {
244        return BigRational::zero();
245    }
246    unit.offset.clone().unwrap_or_else(BigRational::zero)
247}
248
249fn offset_f64(unit: &Unit, apply: bool) -> f64 {
250    if !apply {
251        return 0.0;
252    }
253    unit.offset
254        .as_ref()
255        .and_then(|value| value.to_f64())
256        .unwrap_or(0.0)
257}
258
259fn apply_conversion(
260    input: &Number,
261    plain: bool,
262    from: &Unit,
263    to: &Unit,
264    apply_offsets: bool,
265    ctx: &ExecContext,
266) -> Result<(Number, Exactness), EngineError> {
267    if let Number::Float64(value) = input {
268        if ctx.numeric.mode != NumericMode::Scientific {
269            return Err(EngineError::new(
270                ErrorCode::UnsupportedNumericMode,
271                "converting a float64 quantity requires scientific mode",
272            ));
273        }
274        let base = if plain {
275            value.get()
276        } else {
277            value.get() * from.factor.to_f64() + offset_f64(from, apply_offsets)
278        };
279        let converted = (base - offset_f64(to, apply_offsets)) / to.factor.to_f64();
280        return Ok((Number::float(converted)?, Exactness::Approximate));
281    }
282    if let (Some(from_factor), Some(to_factor)) = (from.factor.as_exact(), to.factor.as_exact()) {
283        let exact = input.to_exact_rational().ok_or_else(|| {
284            EngineError::internal("exact unit operand is not representable as a rational")
285        })?;
286        let base = if plain {
287            exact
288        } else {
289            exact * from_factor + offset_exact(from, apply_offsets)
290        };
291        let converted = (base - offset_exact(to, apply_offsets)) / to_factor;
292        return Ok((exact_number(converted, &ctx.limits), Exactness::Exact));
293    }
294    let approximate = input
295        .to_f64()
296        .ok_or_else(|| EngineError::domain("value is not representable as float64"))?;
297    let base = if plain {
298        approximate
299    } else {
300        approximate * from.factor.to_f64() + offset_f64(from, apply_offsets)
301    };
302    let converted = (base - offset_f64(to, apply_offsets)) / to.factor.to_f64();
303    Ok((Number::float(converted)?, Exactness::Approximate))
304}
305
306fn classify(result: &NumberResult) -> Exactness {
307    if matches!(result.value, Number::Float64(_)) {
308        Exactness::Approximate
309    } else if result.rounded {
310        Exactness::Rounded
311    } else {
312        Exactness::Exact
313    }
314}
315
316fn arithmetic_outcome(result: NumberResult, dimension: Dimension, as_quantity: bool) -> Outcome {
317    let exactness = classify(&result);
318    let value = if as_quantity {
319        quantity_value(result.value, dimension)
320    } else {
321        Value::Number(result.value)
322    };
323    Outcome::new(value, exactness)
324}
325
326fn reject_temperature(value: &Magnitude, operation: &str) -> Result<(), EngineError> {
327    if value.dimension.get(DIM_TEMPERATURE) != 0 {
328        return Err(EngineError::domain(format!(
329            "cannot {operation} an absolute affine temperature; only temperature differences \
330             may participate in dimensional algebra"
331        )));
332    }
333    Ok(())
334}
335
336fn invoke_list_units(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
337    let filter = args.optional_text("quantity_kind")?;
338    if let Some(kind) = filter {
339        let kinds = valid_kinds();
340        if !kinds.contains(&kind) {
341            return Err(EngineError::domain(format!(
342                "unknown quantity kind {kind:?}; expected one of: {}",
343                kinds.join(", ")
344            )));
345        }
346    }
347    let records: Vec<Value> = registry::registry()
348        .iter()
349        .filter(|unit| filter.is_none_or(|kind| unit.kind == kind))
350        .map(|unit| unit_record(unit, false, &ctx.limits))
351        .collect();
352    Ok(Outcome::exact(Value::Array(records)))
353}
354
355fn valid_kinds() -> Vec<&'static str> {
356    let mut kinds: Vec<&'static str> = registry::registry().iter().map(|unit| unit.kind).collect();
357    kinds.sort_unstable();
358    kinds.dedup();
359    kinds
360}
361
362fn invoke_describe_unit(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
363    let unit = registry::find_unit(args.text("unit")?)?;
364    Ok(Outcome::exact(unit_record(unit, true, &ctx.limits)))
365}
366
367fn invoke_convert(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
368    ctx.check()?;
369    let from = registry::find_unit(args.text("from")?)?;
370    let to = registry::find_unit(args.text("to")?)?;
371    if from.dimension != to.dimension {
372        return Err(EngineError::new(
373            ErrorCode::IncompatibleUnits,
374            format!(
375                "cannot convert from {} ({}) to {} ({})",
376                from.id, from.dimension, to.id, to.dimension
377            ),
378        ));
379    }
380    let raw = args.require("value")?;
381    let (number, plain) =
382        extract_magnitude(raw, from.dimension, &format!("value for unit {}", from.id))?;
383    let (converted, exactness) = apply_conversion(&number, plain, from, to, true, ctx)?;
384    Ok(Outcome::new(
385        quantity_value(converted, to.dimension),
386        exactness,
387    ))
388}
389
390fn invoke_temperature_difference(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
391    ctx.check()?;
392    let from = registry::find_unit(args.text("from")?)?;
393    let to = registry::find_unit(args.text("to")?)?;
394    let temperature = temperature_dimension();
395    if from.dimension != temperature || to.dimension != temperature {
396        return Err(EngineError::domain(
397            "temperature_difference requires temperature units for both from and to",
398        ));
399    }
400    let raw = args.require("value")?;
401    let (number, plain) =
402        extract_magnitude(raw, temperature, &format!("value for unit {}", from.id))?;
403    let (converted, exactness) = apply_conversion(&number, plain, from, to, false, ctx)?;
404    let outcome = Outcome::new(quantity_value(converted, temperature), exactness).with_assumption(
405        Assumption::checked(
406            "temperature_delta",
407            "the result is a temperature difference; affine offsets are not applied",
408        ),
409    );
410    Ok(outcome)
411}
412
413fn invoke_multiply(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
414    ctx.check()?;
415    let a = magnitude(args.require("a")?, "a")?;
416    let b = magnitude(args.require("b")?, "b")?;
417    reject_temperature(&a, "multiply")?;
418    reject_temperature(&b, "multiply")?;
419    let dimension = a.dimension.multiply(&b.dimension)?;
420    let result = a.value.mul(&b.value, &ctx.numeric, &ctx.limits)?;
421    Ok(arithmetic_outcome(
422        result,
423        dimension,
424        a.is_quantity || b.is_quantity,
425    ))
426}
427
428fn invoke_divide(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
429    ctx.check()?;
430    let a = magnitude(args.require("a")?, "a")?;
431    let b = magnitude(args.require("b")?, "b")?;
432    reject_temperature(&a, "divide")?;
433    reject_temperature(&b, "divide")?;
434    let dimension = a.dimension.divide(&b.dimension)?;
435    let result = a.value.div(&b.value, &ctx.numeric, &ctx.limits)?;
436    Ok(arithmetic_outcome(
437        result,
438        dimension,
439        a.is_quantity || b.is_quantity,
440    ))
441}
442
443fn invoke_power(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
444    ctx.check()?;
445    let base = magnitude(args.require("a")?, "a")?;
446    reject_temperature(&base, "raise to a power")?;
447    let exponent = args.number("exponent")?;
448    let exponent_rational = exponent.to_exact_rational();
449    let dimension = match &exponent_rational {
450        Some(value) if value.is_integer() => {
451            let exponent = value.to_integer().to_i32().ok_or_else(|| {
452                EngineError::domain("dimension exponent is out of the supported range")
453            })?;
454            base.dimension.pow(exponent)?
455        }
456        Some(_) if base.is_quantity => {
457            return Err(EngineError::domain(
458                "raising a quantity to a power requires an integer exponent",
459            ));
460        }
461        _ => Dimension::DIMENSIONLESS,
462    };
463    let result = base.value.pow(exponent, &ctx.numeric, &ctx.limits)?;
464    Ok(arithmetic_outcome(
465        result,
466        dimension,
467        base.is_quantity || !dimension.is_dimensionless(),
468    ))
469}
470
471fn invoke_add(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
472    sum_or_difference(args, ctx, false)
473}
474
475fn invoke_subtract(args: &Args, ctx: &ExecContext) -> Result<Outcome, EngineError> {
476    sum_or_difference(args, ctx, true)
477}
478
479fn sum_or_difference(
480    args: &Args,
481    ctx: &ExecContext,
482    subtract: bool,
483) -> Result<Outcome, EngineError> {
484    ctx.check()?;
485    let a = magnitude(args.require("a")?, "a")?;
486    let b = magnitude(args.require("b")?, "b")?;
487    if a.dimension != b.dimension {
488        return Err(EngineError::new(
489            ErrorCode::IncompatibleUnits,
490            format!(
491                "cannot combine quantities with dimensions {} and {}",
492                a.dimension, b.dimension
493            ),
494        ));
495    }
496    let is_temperature = a.dimension.get(DIM_TEMPERATURE) != 0;
497    if is_temperature && !subtract {
498        return Err(EngineError::domain(
499            "adding absolute temperatures is not defined; subtract them to obtain a \
500             temperature difference",
501        ));
502    }
503    let result = if subtract {
504        a.value.sub(&b.value, &ctx.numeric, &ctx.limits)?
505    } else {
506        a.value.add(&b.value, &ctx.numeric, &ctx.limits)?
507    };
508    let mut outcome = arithmetic_outcome(result, a.dimension, a.is_quantity || b.is_quantity);
509    if is_temperature && subtract {
510        outcome = outcome.with_assumption(Assumption::checked(
511            "temperature_delta",
512            "subtracting absolute temperatures yields a temperature difference",
513        ));
514    }
515    Ok(outcome)
516}
517
518fn invoke_is_dimensionless(args: &Args, _ctx: &ExecContext) -> Result<Outcome, EngineError> {
519    let value = magnitude(args.require("value")?, "value")?;
520    Ok(Outcome::exact(Value::Bool(
521        value.dimension.is_dimensionless(),
522    )))
523}
524
525fn list_units_descriptor() -> FunctionDescriptor {
526    FunctionDescriptor::new(
527        "units.list_units",
528        "units",
529        "1.0.0",
530        "List units",
531        "List the versioned unit registry, optionally filtered by quantity kind.",
532    )
533    .with_description(
534        "Returns one record per registered unit with its id, symbol, name, quantity kind, \
535         dimension, exact factor to SI base units, affine offset (temperature only), and \
536         aliases. Factors are exact rationals except the pi-based angle units, which are \
537         flagged with exact_factor=false.",
538    )
539    .with_parameters(vec![ParamDescriptor::optional(
540        "quantity_kind",
541        "Optional kind filter: length, mass, time, current, temperature_absolute, amount, \
542         luminous, angle, area, volume, speed, pressure, energy, or power.",
543        ValueSchema::text(),
544    )])
545    .with_output(
546        ValueSchema::array(unit_record_schema(false)),
547        "Array of unit records.",
548    )
549    .with_modes(all_modes())
550    .with_cost(CostClass::Linear)
551    .with_method_ref("docs/methods/units.md#list_units")
552    .with_examples(vec![Example::new(
553        "list length units",
554        example_args(&[("quantity_kind", Value::text("length"))]),
555    )])
556}
557
558fn describe_unit_descriptor() -> FunctionDescriptor {
559    FunctionDescriptor::new(
560        "units.describe_unit",
561        "units",
562        "1.0.0",
563        "Describe unit",
564        "Describe one unit in the versioned registry, including notes.",
565    )
566    .with_description(
567        "Ambiguous identifiers such as gallon, ton, and fluid_ounce are rejected with the \
568         qualified alternatives.",
569    )
570    .with_parameters(vec![ParamDescriptor::required(
571        "unit",
572        "Registry unit id, symbol, or unambiguous alias, e.g. meter or us_gallon.",
573        ValueSchema::text(),
574    )])
575    .with_output(unit_record_schema(true), "Unit record with notes.")
576    .with_modes(all_modes())
577    .with_method_ref("docs/methods/units.md#describe_unit")
578    .with_examples(vec![
579        Example::new(
580            "describe the meter",
581            example_args(&[("unit", Value::text("meter"))]),
582        )
583        .with_value(describe_example("meter")),
584        Example::new(
585            "ambiguous identifier",
586            example_args(&[("unit", Value::text("gallon"))]),
587        )
588        .with_error(ErrorCode::DomainViolation),
589    ])
590}
591
592fn convert_descriptor() -> FunctionDescriptor {
593    FunctionDescriptor::new(
594        "units.convert",
595        "units",
596        "1.0.0",
597        "Convert units",
598        "Convert a quantity between units of the same dimension.",
599    )
600    .with_description(
601        "from and to are registry unit ids. A quantity carries its dimension and is checked \
602         against the from unit; a plain number is interpreted in the SI base units of the \
603         from unit's dimension. Absolute temperatures apply the affine offset; temperature \
604         differences use units.temperature_difference. Results are exact when both factors \
605         are exact and approximate when a factor is an approximation such as pi/180.",
606    )
607    .with_parameters(vec![
608        ParamDescriptor::required(
609            "value",
610            "Quantity, or plain number in SI base units of the from unit's dimension.",
611            ValueSchema::Any,
612        ),
613        ParamDescriptor::required("from", "Source unit id, e.g. mile.", ValueSchema::text()),
614        ParamDescriptor::required("to", "Target unit id, e.g. kilometer.", ValueSchema::text()),
615    ])
616    .with_output(
617        ValueSchema::Quantity {
618            dimension: None,
619            allow_delta: true,
620        },
621        "Converted quantity with the target unit's dimension.",
622    )
623    .with_modes(all_modes())
624    .with_cost(CostClass::Constant)
625    .with_method_ref("docs/methods/units.md#convert")
626    .with_examples(vec![
627        Example::new(
628            "one mile in kilometers",
629            example_args(&[
630                ("value", int_quantity(1, length_dimension())),
631                ("from", Value::text("mile")),
632                ("to", Value::text("kilometer")),
633            ]),
634        )
635        .with_value(decimal_quantity("1.609344", length_dimension())),
636        Example::new(
637            "zero degrees Celsius in kelvin",
638            example_args(&[
639                ("value", int_quantity(0, temperature_dimension())),
640                ("from", Value::text("degree_celsius")),
641                ("to", Value::text("kelvin")),
642            ]),
643        )
644        .with_value(decimal_quantity("273.15", temperature_dimension())),
645        Example::new(
646            "ambiguous unit identifier",
647            example_args(&[
648                ("value", int_quantity(1, volume_dimension())),
649                ("from", Value::text("gallon")),
650                ("to", Value::text("liter")),
651            ]),
652        )
653        .with_error(ErrorCode::DomainViolation),
654    ])
655}
656
657fn multiply_descriptor() -> FunctionDescriptor {
658    FunctionDescriptor::new(
659        "units.multiply",
660        "units",
661        "1.0.0",
662        "Multiply quantities",
663        "Multiply quantities, adding their dimension exponents.",
664    )
665    .with_description(
666        "A plain number is treated as dimensionless. Absolute affine temperatures are \
667         rejected; only temperature differences may participate in dimensional algebra.",
668    )
669    .with_parameters(vec![
670        ParamDescriptor::required("a", "Left quantity or number.", ValueSchema::Any),
671        ParamDescriptor::required("b", "Right quantity or number.", ValueSchema::Any),
672    ])
673    .with_output(
674        ValueSchema::Any,
675        "Product as a quantity, or a plain number when both operands are plain numbers.",
676    )
677    .with_modes(all_modes())
678    .with_cost(CostClass::Constant)
679    .with_method_ref("docs/methods/units.md#multiply")
680    .with_examples(vec![
681        Example::new(
682            "area from two lengths",
683            example_args(&[
684                ("a", int_quantity(2, length_dimension())),
685                ("b", int_quantity(3, length_dimension())),
686            ]),
687        )
688        .with_value(int_quantity(6, area_dimension())),
689    ])
690}
691
692fn divide_descriptor() -> FunctionDescriptor {
693    FunctionDescriptor::new(
694        "units.divide",
695        "units",
696        "1.0.0",
697        "Divide quantities",
698        "Divide quantities, subtracting their dimension exponents.",
699    )
700    .with_description(
701        "A plain number is treated as dimensionless. Absolute affine temperatures are \
702         rejected; only temperature differences may participate in dimensional algebra.",
703    )
704    .with_parameters(vec![
705        ParamDescriptor::required("a", "Dividend quantity or number.", ValueSchema::Any),
706        ParamDescriptor::required(
707            "b",
708            "Divisor quantity or number; must not be zero.",
709            ValueSchema::Any,
710        ),
711    ])
712    .with_output(
713        ValueSchema::Any,
714        "Quotient as a quantity, or a plain number when both operands are plain numbers.",
715    )
716    .with_modes(all_modes())
717    .with_cost(CostClass::Constant)
718    .with_method_ref("docs/methods/units.md#divide")
719    .with_examples(vec![
720        Example::new(
721            "length from an area and a length",
722            example_args(&[
723                ("a", int_quantity(6, area_dimension())),
724                ("b", int_quantity(2, length_dimension())),
725            ]),
726        )
727        .with_value(int_quantity(3, length_dimension())),
728    ])
729}
730
731fn power_descriptor() -> FunctionDescriptor {
732    FunctionDescriptor::new(
733        "units.power",
734        "units",
735        "1.0.0",
736        "Power of a quantity",
737        "Raise a quantity to an integer power, scaling its dimension exponents.",
738    )
739    .with_description(
740        "The exponent must be an integer when the base is a quantity. A plain number accepts \
741         any exponent supported by the arithmetic contract. Absolute affine temperatures are \
742         rejected.",
743    )
744    .with_parameters(vec![
745        ParamDescriptor::required("a", "Base quantity or number.", ValueSchema::Any),
746        ParamDescriptor::required("exponent", "Exponent.", ValueSchema::exact()),
747    ])
748    .with_output(
749        ValueSchema::Any,
750        "Power as a quantity, or a plain number for a plain-number base.",
751    )
752    .with_modes(all_modes())
753    .with_cost(CostClass::Constant)
754    .with_method_ref("docs/methods/units.md#power")
755    .with_examples(vec![
756        Example::new(
757            "volume from a length",
758            example_args(&[
759                ("a", int_quantity(2, length_dimension())),
760                ("exponent", Value::integer(BigInt::from(3))),
761            ]),
762        )
763        .with_value(int_quantity(8, volume_dimension())),
764    ])
765}
766
767fn add_descriptor() -> FunctionDescriptor {
768    FunctionDescriptor::new(
769        "units.add",
770        "units",
771        "1.0.0",
772        "Add quantities",
773        "Add quantities of identical dimension.",
774    )
775    .with_description(
776        "Operands must have identical dimensions; a plain number is dimensionless. Adding \
777         absolute temperatures is rejected because the result would not be an absolute \
778         temperature.",
779    )
780    .with_parameters(vec![
781        ParamDescriptor::required("a", "Left quantity or number.", ValueSchema::Any),
782        ParamDescriptor::required("b", "Right quantity or number.", ValueSchema::Any),
783    ])
784    .with_output(
785        ValueSchema::Any,
786        "Sum as a quantity, or a plain number when both operands are plain numbers.",
787    )
788    .with_modes(all_modes())
789    .with_cost(CostClass::Constant)
790    .with_method_ref("docs/methods/units.md#add")
791    .with_examples(vec![
792        Example::new(
793            "sum two durations",
794            example_args(&[
795                ("a", int_quantity(2, time_dimension())),
796                ("b", int_quantity(3, time_dimension())),
797            ]),
798        )
799        .with_value(int_quantity(5, time_dimension())),
800    ])
801}
802
803fn subtract_descriptor() -> FunctionDescriptor {
804    FunctionDescriptor::new(
805        "units.subtract",
806        "units",
807        "1.0.0",
808        "Subtract quantities",
809        "Subtract quantities of identical dimension.",
810    )
811    .with_description(
812        "Operands must have identical dimensions. Subtracting absolute temperatures yields \
813         a temperature difference.",
814    )
815    .with_parameters(vec![
816        ParamDescriptor::required("a", "Minuend quantity or number.", ValueSchema::Any),
817        ParamDescriptor::required("b", "Subtrahend quantity or number.", ValueSchema::Any),
818    ])
819    .with_output(
820        ValueSchema::Any,
821        "Difference as a quantity, or a plain number when both operands are plain numbers.",
822    )
823    .with_modes(all_modes())
824    .with_cost(CostClass::Constant)
825    .with_method_ref("docs/methods/units.md#subtract")
826    .with_examples(vec![
827        Example::new(
828            "difference of two lengths",
829            example_args(&[
830                ("a", int_quantity(5, length_dimension())),
831                ("b", int_quantity(2, length_dimension())),
832            ]),
833        )
834        .with_value(int_quantity(3, length_dimension())),
835    ])
836}
837
838fn temperature_difference_descriptor() -> FunctionDescriptor {
839    FunctionDescriptor::new(
840        "units.temperature_difference",
841        "units",
842        "1.0.0",
843        "Temperature difference",
844        "Convert a temperature difference between scales without applying affine offsets.",
845    )
846    .with_description(
847        "Only the scale factors are used, so 5 degrees Celsius equals 9 delta degrees \
848         Fahrenheit. The result is a temperature-delta quantity, not an absolute \
849         temperature.",
850    )
851    .with_parameters(vec![
852        ParamDescriptor::required(
853            "value",
854            "Quantity, or plain number in kelvin.",
855            ValueSchema::Any,
856        ),
857        ParamDescriptor::required("from", "Source temperature unit id.", ValueSchema::text()),
858        ParamDescriptor::required("to", "Target temperature unit id.", ValueSchema::text()),
859    ])
860    .with_output(
861        ValueSchema::Quantity {
862            dimension: Some(temperature_dimension()),
863            allow_delta: true,
864        },
865        "Temperature difference in the target scale.",
866    )
867    .with_modes(all_modes())
868    .with_cost(CostClass::Constant)
869    .with_method_ref("docs/methods/units.md#temperature_difference")
870    .with_examples(vec![
871        Example::new(
872            "celsius difference in fahrenheit",
873            example_args(&[
874                ("value", int_quantity(5, temperature_dimension())),
875                ("from", Value::text("degree_celsius")),
876                ("to", Value::text("degree_fahrenheit")),
877            ]),
878        )
879        .with_value(int_quantity(9, temperature_dimension())),
880    ])
881}
882
883fn is_dimensionless_descriptor() -> FunctionDescriptor {
884    FunctionDescriptor::new(
885        "units.is_dimensionless",
886        "units",
887        "1.0.0",
888        "Is dimensionless",
889        "True when a value is a plain number or a dimensionless quantity.",
890    )
891    .with_description("Currency is not a physical dimension and is never accepted here.")
892    .with_parameters(vec![ParamDescriptor::required(
893        "value",
894        "Quantity or number.",
895        ValueSchema::Any,
896    )])
897    .with_output(
898        ValueSchema::Bool,
899        "True when all dimension exponents are zero.",
900    )
901    .with_modes(all_modes())
902    .with_cost(CostClass::Constant)
903    .with_method_ref("docs/methods/units.md#is_dimensionless")
904    .with_examples(vec![
905        Example::new(
906            "plain number",
907            example_args(&[("value", Value::integer(BigInt::from(5)))]),
908        )
909        .with_value(Value::Bool(true)),
910        Example::new(
911            "length quantity",
912            example_args(&[("value", int_quantity(5, length_dimension()))]),
913        )
914        .with_value(Value::Bool(false)),
915    ])
916}
917
918/// Build the units module with all of its registered functions.
919pub fn module() -> Module {
920    let functions: Vec<Arc<dyn Function>> = vec![
921        SimpleFunction::arc(list_units_descriptor(), invoke_list_units),
922        SimpleFunction::arc(describe_unit_descriptor(), invoke_describe_unit),
923        SimpleFunction::arc(convert_descriptor(), invoke_convert),
924        SimpleFunction::arc(multiply_descriptor(), invoke_multiply),
925        SimpleFunction::arc(divide_descriptor(), invoke_divide),
926        SimpleFunction::arc(power_descriptor(), invoke_power),
927        SimpleFunction::arc(add_descriptor(), invoke_add),
928        SimpleFunction::arc(subtract_descriptor(), invoke_subtract),
929        SimpleFunction::arc(
930            temperature_difference_descriptor(),
931            invoke_temperature_difference,
932        ),
933        SimpleFunction::arc(is_dimensionless_descriptor(), invoke_is_dimensionless),
934        check::function(),
935    ];
936    let descriptor = ModuleDescriptor::new(
937        "units",
938        "Units",
939        "1.0.0",
940        "Versioned unit registry, dimensional algebra, and exact conversions.",
941    )
942    .with_capabilities(vec![
943        "unit_registry",
944        "exact_conversions",
945        "affine_temperature",
946        "dimensional_algebra",
947    ])
948    .with_dependencies(vec!["core"])
949    .with_modes(all_modes())
950    .with_source("crates/bicmath-units");
951    Module::new(descriptor, functions)
952}
953
954#[cfg(test)]
955mod tests {
956    use super::*;
957
958    fn ctx() -> ExecContext {
959        ExecContext::conservative()
960    }
961
962    fn args_json(pairs: &[(&str, Value)]) -> serde_json::Value {
963        let mut map = serde_json::Map::new();
964        for (name, value) in pairs {
965            map.insert(
966                (*name).to_string(),
967                serde_json::to_value(value).expect("value serializes"),
968            );
969        }
970        serde_json::Value::Object(map)
971    }
972
973    fn call(id: &str, raw: serde_json::Value) -> Result<Outcome, EngineError> {
974        let module = module();
975        let function = module
976            .functions
977            .iter()
978            .find(|function| function.descriptor().id == id)
979            .expect("function exists");
980        let args = raw.as_object().expect("object args");
981        let mut values = BTreeMap::new();
982        for (name, value) in args {
983            let parameter = function
984                .descriptor()
985                .parameter(name)
986                .expect("parameter exists");
987            values.insert(
988                name.clone(),
989                parameter
990                    .schema
991                    .coerce(value, name, &ctx().limits, true)
992                    .expect("argument coerces"),
993            );
994        }
995        function.invoke(&Args::new(values), &ctx())
996    }
997
998    fn convert_quantity(value: Value, from: &str, to: &str) -> Result<Outcome, EngineError> {
999        call(
1000            "units.convert",
1001            args_json(&[
1002                ("value", value),
1003                ("from", Value::text(from)),
1004                ("to", Value::text(to)),
1005            ]),
1006        )
1007    }
1008
1009    fn quantity_of(outcome: &Outcome) -> (Number, Dimension) {
1010        match &outcome.value {
1011            Value::Quantity { value, dimension } => {
1012                (value.as_number().expect("number").clone(), *dimension)
1013            }
1014            other => panic!("expected a quantity, found {}", other.kind_name()),
1015        }
1016    }
1017
1018    fn rational(numer: i64, denom: i64) -> BigRational {
1019        BigRational::new(BigInt::from(numer), BigInt::from(denom))
1020    }
1021
1022    fn exact_rational(number: &Number) -> BigRational {
1023        number.to_exact_rational().expect("exact number")
1024    }
1025
1026    #[test]
1027    fn zero_celsius_to_kelvin_is_exact() {
1028        let outcome = convert_quantity(
1029            int_quantity(0, temperature_dimension()),
1030            "degree_celsius",
1031            "kelvin",
1032        )
1033        .unwrap();
1034        assert_eq!(outcome.exactness, Exactness::Exact);
1035        let (number, dimension) = quantity_of(&outcome);
1036        assert_eq!(exact_rational(&number), rational(27315, 100));
1037        assert_eq!(dimension, temperature_dimension());
1038    }
1039
1040    #[test]
1041    fn zero_celsius_to_fahrenheit_is_32() {
1042        let outcome = convert_quantity(
1043            int_quantity(0, temperature_dimension()),
1044            "degree_celsius",
1045            "degree_fahrenheit",
1046        )
1047        .unwrap();
1048        let (number, _) = quantity_of(&outcome);
1049        assert_eq!(exact_rational(&number), rational(32, 1));
1050    }
1051
1052    #[test]
1053    fn minus_forty_celsius_equals_minus_forty_fahrenheit() {
1054        let celsius = convert_quantity(
1055            int_quantity(-40, temperature_dimension()),
1056            "degree_celsius",
1057            "kelvin",
1058        )
1059        .unwrap();
1060        let fahrenheit = convert_quantity(
1061            int_quantity(-40, temperature_dimension()),
1062            "degree_fahrenheit",
1063            "kelvin",
1064        )
1065        .unwrap();
1066        let (celsius, _) = quantity_of(&celsius);
1067        let (fahrenheit, _) = quantity_of(&fahrenheit);
1068        assert_eq!(exact_rational(&celsius), exact_rational(&fahrenheit));
1069    }
1070
1071    #[test]
1072    fn mile_to_meter_is_exact() {
1073        let outcome =
1074            convert_quantity(int_quantity(1, length_dimension()), "mile", "meter").unwrap();
1075        let (number, dimension) = quantity_of(&outcome);
1076        assert_eq!(exact_rational(&number), rational(1609344, 1000));
1077        assert_eq!(dimension, length_dimension());
1078    }
1079
1080    #[test]
1081    fn inch_to_centimeter() {
1082        let outcome =
1083            convert_quantity(int_quantity(1, length_dimension()), "inch", "centimeter").unwrap();
1084        let (number, _) = quantity_of(&outcome);
1085        assert_eq!(exact_rational(&number), rational(254, 100));
1086    }
1087
1088    #[test]
1089    fn us_and_imperial_gallons_differ() {
1090        let us =
1091            convert_quantity(int_quantity(1, volume_dimension()), "us_gallon", "liter").unwrap();
1092        let imperial = convert_quantity(
1093            int_quantity(1, volume_dimension()),
1094            "imperial_gallon",
1095            "liter",
1096        )
1097        .unwrap();
1098        let (us, _) = quantity_of(&us);
1099        let (imperial, _) = quantity_of(&imperial);
1100        assert_eq!(exact_rational(&us), rational(3785411784, 1_000_000_000));
1101        assert_eq!(exact_rational(&imperial), rational(454609, 100_000));
1102        assert_ne!(exact_rational(&us), exact_rational(&imperial));
1103    }
1104
1105    #[test]
1106    fn psi_to_pascal() {
1107        let outcome =
1108            convert_quantity(int_quantity(1, pressure_dimension()), "psi", "pascal").unwrap();
1109        let (number, _) = quantity_of(&outcome);
1110        assert_eq!(
1111            exact_rational(&number),
1112            rational(6894757293168, 1_000_000_000)
1113        );
1114    }
1115
1116    #[test]
1117    fn kmh_to_ms_is_rational() {
1118        let outcome = convert_quantity(
1119            int_quantity(100, speed_dimension()),
1120            "kilometer_per_hour",
1121            "meter_per_second",
1122        )
1123        .unwrap();
1124        let (number, dimension) = quantity_of(&outcome);
1125        assert!(matches!(number, Number::Rational(_)));
1126        assert_eq!(exact_rational(&number), rational(250, 9));
1127        assert_eq!(dimension, speed_dimension());
1128    }
1129
1130    #[test]
1131    fn degrees_to_radians() {
1132        let outcome =
1133            convert_quantity(int_quantity(180, Dimension::ANGLE), "degree", "radian").unwrap();
1134        assert_eq!(outcome.exactness, Exactness::Approximate);
1135        let (number, _) = quantity_of(&outcome);
1136        let value = number.to_f64().expect("float");
1137        assert!((value - std::f64::consts::PI).abs() < 1e-15);
1138    }
1139
1140    #[test]
1141    fn turn_to_degrees() {
1142        let outcome =
1143            convert_quantity(int_quantity(1, Dimension::ANGLE), "turn", "degree").unwrap();
1144        let (number, _) = quantity_of(&outcome);
1145        let value = number.to_f64().expect("float");
1146        assert!((value - 360.0).abs() < 1e-12);
1147    }
1148
1149    #[test]
1150    fn hour_plus_thirty_minutes_is_5400_seconds() {
1151        let hour = convert_quantity(int_quantity(1, time_dimension()), "hour", "second").unwrap();
1152        let minute =
1153            convert_quantity(int_quantity(30, time_dimension()), "minute", "second").unwrap();
1154        let sum = call(
1155            "units.add",
1156            args_json(&[("a", hour.value), ("b", minute.value)]),
1157        )
1158        .unwrap();
1159        let (number, dimension) = quantity_of(&sum);
1160        assert_eq!(exact_rational(&number), rational(5400, 1));
1161        assert_eq!(dimension, time_dimension());
1162    }
1163
1164    #[test]
1165    fn multiply_two_lengths_is_an_area() {
1166        let outcome = call(
1167            "units.multiply",
1168            args_json(&[
1169                ("a", int_quantity(2, length_dimension())),
1170                ("b", int_quantity(3, length_dimension())),
1171            ]),
1172        )
1173        .unwrap();
1174        let (number, dimension) = quantity_of(&outcome);
1175        assert_eq!(exact_rational(&number), rational(6, 1));
1176        assert_eq!(dimension, area_dimension());
1177    }
1178
1179    #[test]
1180    fn divide_area_by_length_is_a_length() {
1181        let outcome = call(
1182            "units.divide",
1183            args_json(&[
1184                ("a", int_quantity(6, area_dimension())),
1185                ("b", int_quantity(2, length_dimension())),
1186            ]),
1187        )
1188        .unwrap();
1189        let (number, dimension) = quantity_of(&outcome);
1190        assert_eq!(exact_rational(&number), rational(3, 1));
1191        assert_eq!(dimension, length_dimension());
1192    }
1193
1194    #[test]
1195    fn add_meter_and_second_is_incompatible() {
1196        let error = call(
1197            "units.add",
1198            args_json(&[
1199                ("a", int_quantity(1, length_dimension())),
1200                ("b", int_quantity(1, time_dimension())),
1201            ]),
1202        )
1203        .unwrap_err();
1204        assert_eq!(error.code, ErrorCode::IncompatibleUnits);
1205    }
1206
1207    #[test]
1208    fn multiply_absolute_temperature_is_rejected() {
1209        let error = call(
1210            "units.multiply",
1211            args_json(&[
1212                ("a", int_quantity(20, temperature_dimension())),
1213                ("b", int_quantity(2, Dimension::DIMENSIONLESS)),
1214            ]),
1215        )
1216        .unwrap_err();
1217        assert_eq!(error.code, ErrorCode::DomainViolation);
1218        assert!(error.message.contains("temperature differences"));
1219    }
1220
1221    #[test]
1222    fn temperature_difference_celsius_to_fahrenheit() {
1223        let outcome = call(
1224            "units.temperature_difference",
1225            args_json(&[
1226                ("value", int_quantity(5, temperature_dimension())),
1227                ("from", Value::text("degree_celsius")),
1228                ("to", Value::text("degree_fahrenheit")),
1229            ]),
1230        )
1231        .unwrap();
1232        let (number, dimension) = quantity_of(&outcome);
1233        assert_eq!(exact_rational(&number), rational(9, 1));
1234        assert_eq!(dimension, temperature_dimension());
1235    }
1236
1237    #[test]
1238    fn subtract_absolute_temperatures_yields_a_delta() {
1239        let outcome = call(
1240            "units.subtract",
1241            args_json(&[
1242                ("a", int_quantity(20, temperature_dimension())),
1243                ("b", int_quantity(5, temperature_dimension())),
1244            ]),
1245        )
1246        .unwrap();
1247        let (number, dimension) = quantity_of(&outcome);
1248        assert_eq!(exact_rational(&number), rational(15, 1));
1249        assert_eq!(dimension, temperature_dimension());
1250        assert!(!outcome.assumptions.is_empty());
1251    }
1252
1253    #[test]
1254    fn add_absolute_temperatures_is_rejected() {
1255        let error = call(
1256            "units.add",
1257            args_json(&[
1258                ("a", int_quantity(20, temperature_dimension())),
1259                ("b", int_quantity(5, temperature_dimension())),
1260            ]),
1261        )
1262        .unwrap_err();
1263        assert_eq!(error.code, ErrorCode::DomainViolation);
1264    }
1265
1266    #[test]
1267    fn power_is_dimensional() {
1268        let outcome = call(
1269            "units.power",
1270            args_json(&[
1271                ("a", int_quantity(2, length_dimension())),
1272                ("exponent", Value::integer(BigInt::from(3))),
1273            ]),
1274        )
1275        .unwrap();
1276        let (number, dimension) = quantity_of(&outcome);
1277        assert_eq!(exact_rational(&number), rational(8, 1));
1278        assert_eq!(dimension, volume_dimension());
1279    }
1280
1281    #[test]
1282    fn plain_number_is_si_base() {
1283        let outcome =
1284            convert_quantity(Value::integer(BigInt::from(1)), "meter", "centimeter").unwrap();
1285        let (number, dimension) = quantity_of(&outcome);
1286        assert_eq!(exact_rational(&number), rational(100, 1));
1287        assert_eq!(dimension, length_dimension());
1288    }
1289
1290    #[test]
1291    fn ambiguous_unit_is_rejected_with_alternatives() {
1292        let error = call(
1293            "units.describe_unit",
1294            args_json(&[("unit", Value::text("gallon"))]),
1295        )
1296        .unwrap_err();
1297        assert_eq!(error.code, ErrorCode::DomainViolation);
1298        assert!(error.message.contains("us_gallon"));
1299        assert!(error.message.contains("imperial_gallon"));
1300    }
1301
1302    #[test]
1303    fn unknown_unit_is_rejected() {
1304        let error =
1305            convert_quantity(int_quantity(1, length_dimension()), "furlong", "meter").unwrap_err();
1306        assert_eq!(error.code, ErrorCode::DomainViolation);
1307    }
1308
1309    #[test]
1310    fn convert_incompatible_dimensions_is_rejected() {
1311        let error =
1312            convert_quantity(int_quantity(1, length_dimension()), "meter", "second").unwrap_err();
1313        assert_eq!(error.code, ErrorCode::IncompatibleUnits);
1314    }
1315
1316    #[test]
1317    fn money_is_not_a_dimension() {
1318        let money = Value::Money {
1319            amount: Box::new(Value::integer(BigInt::from(5))),
1320            currency: "USD".to_string(),
1321        };
1322        let error = call("units.is_dimensionless", args_json(&[("value", money)])).unwrap_err();
1323        assert_eq!(error.code, ErrorCode::DomainViolation);
1324    }
1325
1326    #[test]
1327    fn list_units_filters_by_kind() {
1328        let outcome = call(
1329            "units.list_units",
1330            args_json(&[("quantity_kind", Value::text("length"))]),
1331        )
1332        .unwrap();
1333        let records = outcome.value.as_array().expect("array");
1334        assert_eq!(records.len(), 12);
1335        for record in records {
1336            let fields = record.as_record().expect("record");
1337            assert_eq!(fields["unit_kind"].as_text().expect("text"), "length");
1338        }
1339    }
1340
1341    #[test]
1342    fn describe_unit_reports_metadata() {
1343        let outcome = call(
1344            "units.describe_unit",
1345            args_json(&[("unit", Value::text("degree_celsius"))]),
1346        )
1347        .unwrap();
1348        let fields = outcome.value.as_record().expect("record");
1349        assert_eq!(
1350            fields["unit_kind"].as_text().expect("text"),
1351            "temperature_absolute"
1352        );
1353        assert!(fields["affine"].as_bool().expect("bool"));
1354        assert!(fields["exact_factor"].as_bool().expect("bool"));
1355        assert!(fields.contains_key("notes"));
1356
1357        let degree = call(
1358            "units.describe_unit",
1359            args_json(&[("unit", Value::text("degree"))]),
1360        )
1361        .unwrap();
1362        let fields = degree.value.as_record().expect("record");
1363        assert!(!fields["exact_factor"].as_bool().expect("bool"));
1364        assert_eq!(fields["unit_kind"].as_text().expect("text"), "angle");
1365    }
1366
1367    #[test]
1368    fn is_dimensionless_checks_dimension() {
1369        let length = call(
1370            "units.is_dimensionless",
1371            args_json(&[("value", int_quantity(5, length_dimension()))]),
1372        )
1373        .unwrap();
1374        assert_eq!(length.value, Value::Bool(false));
1375        let plain = call(
1376            "units.is_dimensionless",
1377            args_json(&[("value", Value::integer(BigInt::from(5)))]),
1378        )
1379        .unwrap();
1380        assert_eq!(plain.value, Value::Bool(true));
1381    }
1382
1383    #[test]
1384    fn examples_are_declared_for_every_function() {
1385        for function in module().functions {
1386            assert!(
1387                !function.descriptor().examples.is_empty(),
1388                "function {} has no examples",
1389                function.descriptor().id
1390            );
1391        }
1392    }
1393
1394    #[test]
1395    fn module_identity_is_stable() {
1396        let module = module();
1397        assert_eq!(module.descriptor.id, "units");
1398        assert_eq!(module.descriptor.version, "1.0.0");
1399        assert_eq!(REGISTRY_VERSION, "1.0.0");
1400        for function in &module.functions {
1401            assert_eq!(function.descriptor().module, "units");
1402            assert_eq!(function.descriptor().version, "1.0.0");
1403            assert!(function.descriptor().id.starts_with("units."));
1404            assert!(
1405                function
1406                    .descriptor()
1407                    .method_ref
1408                    .starts_with("docs/methods/units.md#")
1409            );
1410        }
1411    }
1412}