1mod 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
918pub 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}