Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Iudex Mensurarum Mysticarum Yumemi

Yumemi

PHP ordinarily treats meters, feet, and seconds as interchangeable numbers, allowing incorrect arguments and arithmetic to pass unnoticed. Yumemi provides static dimensional analysis for PHPStan and exact runtime unit conversion for PHP.

The PHPStan extension tracks units on ordinary int and float values. It can reject incompatible arguments and arithmetic without requiring runtime wrapper objects. The runtime library uses the same parser, unit catalog, and normalization engine for exact quantity arithmetic, temperature scales and other coordinate points, and conversion.

Start Here

<?php

use function jbboehr\Yumemi\unit;

/** @param unit_float<'meter'> $height */
function setDoorHeight(float $height): void {}

// @akashi-phpstan-error argument.type: unit_float<'meter'>, 6.0&unit_float<'international_foot'> given
setDoorHeight(unit(6.0, 'foot'));

PHPStan reports the unit mismatch while PHP still receives an ordinary float. In tested examples, an @akashi-phpstan-error comment records the stable PHPStan diagnostic identifier and a distinctive fragment of the expected message on the following statement. It is documentation-test notation, not Yumemi syntax. foot is an alias of the catalog’s canonical international_foot unit, and diagnostics use the canonical name after resolving aliases.

  • I want PHPStan to catch unit mistakes in native numbers. Start with Static Analysis.
  • I need exact runtime conversion, quantity arithmetic, or values on temperature scales such as Celsius. Start with Runtime Conversion.

A branded native value is still an ordinary PHP int or float. Types such as unit_float<'meter'> add a unit only inside PHPStan; they do not create runtime wrappers.

Browse Documentation

  • Getting Started provides a complete installation and verification path.
  • Core Concepts helps choose among branded native values, exact quantities, and coordinate points.
  • Recipes shows common integration, conversion, custom-unit, and display tasks.
  • PHPStan defines branded native types, operator inference, helpers, generic quantities, configuration, and diagnostics.
  • Unit Syntax defines the expression language shared by PHPStan and the runtime.
  • Runtime API documents quantities, coordinate points, conversion, numeric output, dimensions, and formatting.
  • Built-in and Custom Units documents UDUNITS2 behavior, custom registries, and introspection.

Yumemi 0.1 is an initial public development release. Patch releases within the 0.1 line preserve the documented contract, while later 0.x minor releases may deliberately introduce documented breaking changes before 1.0. Architecture, implementation status, and deferred work are tracked in the repository planning document.

Getting Started

Upon the day of ashes, draw a narrow door of salt upon the chapel floor. Let the penitent cross it barefoot, naming the restitution already made, and suffer none to sweep behind them. At evening, if the door remaineth whole, their sorrow lacked weight; if their feet have broken it, admit them to the choir, and let the first hymn be for those they harmed.

Ordinances of the Synthetic Dawn 34:72

A barefoot penitent crossing a broken salt threshold in a cobalt-lit chapel

Yumemi requires PHP 8.2 or later and the GMP extension, which provides the arbitrary-precision integers used for exact rational arithmetic and conversion.

Installation

Most applications call Yumemi at runtime and also use its PHPStan extension. Install it as a normal application dependency:

composer require jbboehr/yumemi:^0.1

Yumemi does not install PHPStan automatically in consuming projects. Install PHPStan and the extension installer as development dependencies to enable automatic registration:

composer require --dev phpstan/phpstan:^2.2.5 phpstan/extension-installer

Projects that do not use phpstan/extension-installer should install PHPStan by itself and include Yumemi explicitly from phpstan.neon:

includes:
    - vendor/jbboehr/yumemi/extension.neon

Keep jbboehr/yumemi as a normal dependency whenever application code calls functions such as unit() or unit_to(), or uses runtime classes such as Units and Quantity. A project using Yumemi only during static analysis, with no runtime calls or classes, may install it as a development dependency instead.

Verify Static Analysis

Configure at least one source path for PHPStan. For an application whose PHP code lives under src/, a minimal phpstan.neon is:

parameters:
    level: 8
    paths:
        - src

When automatic extension registration is unavailable, add the includes entry shown in Installation to the same file.

Use unit() to brand an ordinary native value at a system boundary. PHPStan then carries the unit through arithmetic and rejects a deliberately incorrect result. Place this example under a configured path, such as src/YumemiCheck.php:

<?php

require 'vendor/autoload.php';

use function jbboehr\Yumemi\unit;

/** @param unit_float<'meter / second'> $speed */
function saveJourneySpeed(float $speed): void {}

$distance = unit(100.0, 'meter');
$duration = unit(10.0, 'second');
$speed = $distance / $duration;

saveJourneySpeed($speed);
assert($speed === 10.0);

// @akashi-phpstan-error argument.type: unit_float<'meter / second'>, 1000.0&unit_float<'meter * second'> given
saveJourneySpeed($distance * $duration);

Run the PHPStan command used by your project, or the default executable directly:

vendor/bin/phpstan analyse

PHPStan should accept $speed and report the expected unit mismatch for the final call. The @akashi-phpstan-error line records the diagnostic identifier and a distinctive fragment of the expected message; it is an ordinary comment, not a required annotation. Remove the incorrect call once the extension is working.

If PHPStan instead reports unknown unit_int or unit_float PHPDoc types, the extension is not registered. Install phpstan/extension-installer or add Yumemi’s extension.neon include explicitly.

If the deliberately incorrect call produces no diagnostic, confirm that the example file is under one of the configured paths, that the invalid call remains in the file, and that the command is loading the phpstan.neon where Yumemi is registered.

The runtime values remain ordinary floats. The additional unit information exists only in PHPStan’s type system.

Most applications should use Yumemi’s PHPDoc types directly. Libraries that cannot require Yumemi from every consumer can instead use the deliberately opt-in @yumemi-* annotation integration.

Runtime Conversion

The runtime unit engine can be used independently of PHPStan brands and extension registration. Use Units and Quantity when the application must perform a conversion or retain exact rational values:

<?php

require 'vendor/autoload.php';

use jbboehr\Yumemi\Units;

$length = Units::default()->quantity(1, 'mile')->to('kilometer');

assert($length->exactDecimalValueIn('kilometer') === '1.609344');
assert($length->unitToString() === 'kilometer');

For more runtime-only examples, see Preserve Exact Conversion and Convert Temperatures. Continue with Core Concepts, then use the PHPStan, unit syntax, and runtime API references as needed.

Core Concepts

In the chamber above the polar night, the Angel of Boundaries unfolded a black fan, and from each rib issued a road of fire. The roads crossed without mingling, and upon them nations unlike in tongue carried one white stone toward a city not yet built. When the first foundation shone below, a voice forbade the roads to surrender their names.

Revelation of the Artificial Sun 3:52

An angel unfolding a black fan above separate luminous roads leading toward an unbuilt city

Yumemi exposes native values and exact runtime objects over the same unit engine. Use native values when existing PHP numbers primarily need PHPStan protection. Use Quantity when the program must retain a multiplicative unit, perform conversions, or preserve exact fractions. Use PointQuantity for a position on a coordinate scale, such as a temperature in Celsius.

The examples below assume the Composer autoloader has already been loaded as shown in Getting Started.

Choose An API

NeedUse
Add unit checking to existing PHP numbers and operatorsunit_int<'...'> / unit_float<'...'>
Perform exact conversion or unit-aware runtime arithmeticQuantity<'...'>
Represent an exact temperature or other coordinate pointPointQuantity<'...'>
Convert at an application boundary and return a native numberunit_to()

A branded native value is an ordinary PHP int or float; the unit exists only in PHPStan. Branded values therefore fit naturally into existing signatures, arrays, frameworks, serialization, and numerical code, with normal scalar precision. Conversion is explicit through unit_to().

Quantity is the ergonomic precision path. Use it when results such as 1/3 must remain exact, rounding must be deferred, or compatible-unit operations should be expressed through one runtime object.

PointQuantity separates coordinate points from multiplicative differences. For example, celsius identifies an absolute temperature while delta_celsius identifies a temperature interval. Points can be converted and compared; subtracting two points returns a Quantity, and adding or subtracting a compatible Quantity translates a point. Points themselves do not support multiplication, division, powers, or point-plus-point arithmetic.

All three surfaces use the same parser, catalog, dimensions, and conversion semantics, but they are not equivalent interfaces. PHP cannot make native 1 meter + 1 foot produce a correct number without converting one operand. Yumemi therefore rejects that branded-native addition. Quantity::add() performs the conversion at runtime and accepts the same pair.

<?php

use jbboehr\Yumemi\Quantity;
use jbboehr\Yumemi\Units;

use function jbboehr\Yumemi\unit;
use function jbboehr\Yumemi\unit_to;

/** @param unit_float<'kilometer / hour'> $speed */
function sendVehicleSpeed(float $speed): void {}

$nativeSpeed = unit(100.0, 'meter') / unit(10.0, 'second');
$nativeKilometersPerHour = unit_to($nativeSpeed, 'meter / second', 'kilometer / hour');

sendVehicleSpeed($nativeKilometersPerHour);
assert(abs($nativeKilometersPerHour - 36.0) < 1e-12);

$units = Units::default();
/** @var Quantity<'meter / second'> $exactSpeed */
$exactSpeed = $units->quantity(100, 'meter')->div($units->quantity(10, 'second'));

assert($exactSpeed->valueIn('kilometer / hour')->toString() === '36');

PHPStan knows that $nativeSpeed is branded as unit_float<'meter / second'>, but that brand does not exist at runtime: PHP receives only an ordinary float. Runtime code therefore cannot recover the source unit, so unit_to() requires both 'meter / second' and 'kilometer / hour' explicitly.

See the PHPStan reference for branded-native behavior and the runtime reference for exact quantities.

Native Values At Trusted Boundaries

Once a native value has a unit brand, PHP still executes ordinary int or float arithmetic. The brand adds no runtime wrapper, method dispatch, Rational allocation, or unit metadata to those calculations. This makes branded values the natural path when a unit contract can be established at an application boundary and the remaining work should use normal scalar operations.

Use unit($value, 'meter') when runtime validation of the unit expression against the active catalog is useful. The function returns $value unchanged after parsing the expression; it does not prove that the incoming magnitude was physically measured in meters. A trusted parameter, property, return type, third-party stub, or local @var declaration can establish the same static brand without runtime parsing:

<?php

/** @var unit_float<'meter'> $warehouseAisleLength */
$warehouseAisleLength = (float) 18;

/** @param unit_float<'meter'> $length */
function storeWarehouseAisleLength(float $length): void {}

storeWarehouseAisleLength($warehouseAisleLength);

Use declarations or stubs where the unit is already guaranteed, use unit() when catalog validation is desired, and keep repeated arithmetic on branded native values. When exact fractions or runtime unit identity matter more, use Quantity instead.

A trusted string-oriented contract may use unit_numeric_string<'...'> when it exposes a numeric magnitude as text. The value remains a string until an explicit integer or float cast moves it into numerical code. See Numeric Strings for the assignment, cast, and coercion rules.

Choose An Operation

GoalOperation
Brand an existing native magnitudeunit() or a unit_int / unit_float PHPDoc type
Label an exact magnitude without converting itUnits::quantity()
Parse one string containing a magnitude and unitUnits::parseQuantity()
Construct an exact coordinate pointUnits::point()
Construct a multiplicative coordinate differenceUnits::deltaQuantity()
Check whether two units share a dimensionUnits::areCompatible()
Check whether two quantities share a context and dimensionQuantity::isCompatibleWith()
Check whether two points share a context and dimensionPointQuantity::isCompatibleWith()
Convert an exact magnitudeUnits::convert() or Quantity::to() / valueIn()
Convert a native scalarunit_to() or Units::convertFloat()
Obtain an exact multiplicative factorUnits::conversionFactor()
Obtain a native factor whose static units cancelunit_factor()
Take an exact root without implicit unit substitutionQuantity::root()
Take an exact absolute valueQuantity::abs()
Test an exact magnitude for zeroQuantity::isZero()
Substitute definitions without changing stored magnitudeQuantity::normalize()
Substitute definitions and fold scale into magnitudeQuantity::simplify()
Request a particular target unitQuantity::to()
Apply an application profile at an output boundaryQuantity::toPreferred()
Choose an engineering prefix within a named familyQuantity::toCompact()
Change names, symbols, typography, or division layoutFormatting APIs

The important semantic boundaries are documented once in the references:

When in doubt, keep conversion explicit. Symbolic algebra preserves the units chosen by the caller; catalog substitution and magnitude changes occur only through operations whose names communicate that intent.

Recipes

Write the covenant upon fresh clay and place it in the public kiln beside the vessels of common use. If the words blister while the cups endure, summon the oath-givers again; for no promise is strengthened by the fire it was fashioned to escape. But if the tablet darken without division, carry it warm between the households, and let neither claim the colder edge.

Ordinances of the Synthetic Dawn 12:44

A clay covenant tablet enduring a public kiln among household vessels beneath cyan stormlight

These short examples show common application tasks. They assume the Composer autoloader and PHPStan extension are already configured as described in Getting Started. Follow the links after each recipe for the complete semantics and limitations.

Protect An Existing API

Brand incoming data where its unit becomes known, then convert explicitly before calling an API that expects another unit:

<?php

use function jbboehr\Yumemi\unit;
use function jbboehr\Yumemi\unit_to;

/** @param unit_float<'meter'> $height */
function setRideHeight(float $height): void {}

$measuredHeight = unit(6.0, 'foot');

// @akashi-phpstan-error argument.type: unit_float<'meter'>, 6.0&unit_float<'international_foot'> given
setRideHeight($measuredHeight);

setRideHeight(unit_to($measuredHeight, 'foot', 'meter'));

See Branded Native Types and Boundary Helpers.

Keep Unit Setup Outside Hot Loops

When an external invariant already guarantees the input unit, declare that contract once and let repeated work remain ordinary native arithmetic. Compute a conversion factor before the loop so the loop itself performs only float multiplication:

<?php

use function jbboehr\Yumemi\unit_factor;

/** @var list<unit_float<'international_foot'>> $surveyLengths */
$surveyLengths = [1.0, 5.0, 10.0];

$footToMeter = unit_factor('international_foot', 'meter');
$metricLengths = [];

foreach ($surveyLengths as $surveyLength) {
    $metricLengths[] = $surveyLength * $footToMeter;
}

/** @param list<unit_float<'meter'>> $lengths */
function saveMetricSurveyLengths(array $lengths): void {}

saveMetricSurveyLengths($metricLengths);
assert(abs($metricLengths[2] - 3.048) < 1e-12);

The @var declaration asserts that the source data is measured in feet; it does not validate its provenance. Use unit() instead when parsing the unit expression against the runtime catalog is valuable. See Native Values At Trusted Boundaries and Constant Unit Expressions.

Preserve Exact Conversion

Use Quantity when conversion must retain an exact decimal or fraction rather than immediately becoming a float:

<?php

use jbboehr\Yumemi\Units;

$length = Units::default()->quantity(1, 'foot')->to('meter');

assert($length->valueToString() === '381/1250');
assert($length->exactDecimalValueIn('meter') === '0.3048');
assert($length->unitToString() === 'meter');

See Conversion and Comparison and Native Numeric Output.

Convert Temperatures

Temperature scales with different zero points require a full value conversion. Use PointQuantity when the coordinate must remain attached to the value, and use a generated delta unit for temperature differences:

<?php

use jbboehr\Yumemi\Units;

use function jbboehr\Yumemi\unit_to;

$units = Units::default();
$freezing = $units->point(0, 'celsius');
$rise = $units->deltaQuantity(18, 'fahrenheit');

assert(abs(unit_to(98.6, 'fahrenheit', 'celsius') - 37.0) < 1e-12);
assert($freezing->valueIn('kelvin')->toString() === '5463/20');
assert($freezing->add($rise)->valueToString() === '10');
assert($units->point(100, 'celsius')->difference($freezing)->toString() === '100 * delta_celsius');

Do not use celsius itself in products or quotients. delta_celsius is multiplicative, and symbol formatting renders it as Δ°C. See Affine Conversion.

Define Application Units

Put project-specific definitions in one factory, then use that factory for both PHPStan and the runtime context. This prevents one layer from accepting a unit that the other cannot resolve:

<?php

namespace App\Units;

use jbboehr\Yumemi\PHPStan\UnitRegistryFactory;
use jbboehr\Yumemi\Registry\UnitRegistry;
use jbboehr\Yumemi\Registry\UnitRegistryBuilder;
use jbboehr\Yumemi\Units;

use function jbboehr\Yumemi\unit;
use function jbboehr\Yumemi\unit_to;

final class ApplicationUnitRegistryFactory implements UnitRegistryFactory
{
    public static function create(): UnitRegistry
    {
        return UnitRegistryBuilder::default()
            ->define('shipping_pallet = 48 * inch')
            ->alias('shipping_pallets', 'shipping_pallet')
            ->build();
    }
}

$units = new Units(ApplicationUnitRegistryFactory::create());
$width = $units->quantity(2, 'shipping_pallets');

assert($width->exactDecimalValueIn('meter') === '2.4384');

$previous = Units::setDefault($units);

try {
    $nativeWidth = unit(2, 'shipping_pallets');

    assert(abs(unit_to($nativeWidth, 'shipping_pallets', 'meter') - 2.4384) < 1e-12);
} finally {
    Units::setDefault($previous);
}

Select the same factory for PHPStan:

parameters:
    yumemi:
        registryFactory: App\Units\ApplicationUnitRegistryFactory

When an application unit is not derived from the seven SI axes, declare one canonical base with baseUnit() and derive the remaining units through exact definitions. The custom-registry reference shows this pattern for an application-owned currency-rate snapshot.

Instance methods use the registry attached to their Units context. Native helpers use the process-wide default instead; an application may install that context once during bootstrap, while tests and scoped workers should restore the previous context in finally. See Registry Configuration, Custom Registries, and Contexts And Construction for the complete lifecycle and overlay rules.

Format Units For Display

Formatting changes spelling and typography without converting or normalizing the underlying unit:

<?php

use jbboehr\Yumemi\Formatter\FormatOptions;
use jbboehr\Yumemi\Formatter\Typography;
use jbboehr\Yumemi\Formatter\UnitNameStyle;
use jbboehr\Yumemi\Units;

$options = FormatOptions::create()
    ->withUnitNameStyle(UnitNameStyle::Symbol)
    ->withTypography(Typography::Unicode);

assert(Units::default()->format('kilogram * meter / second^2', $options) === 'kg · m / s²');

See Formatting for division styles, dimensionless output, and reusable formatters.

PHPStan Reference

Before judgment, suspend the bronze plummet above the council mosaic, and let neither advocate nor prince touch its cord. If it hangeth toward the floor, hear the cause; but if its weight rise toward the painted heavens, dismiss the court and uncover the dais, for authority hath seated itself where only witness was appointed.

Ordinances of the Synthetic Dawn 57:34

A bronze plummet suspended above a wet council mosaic before an empty dais

Yumemi’s PHPStan extension attaches units to ordinary PHP int and float values and propagates them through supported operations. It can also brand a numeric-string at a string-oriented boundary. The runtime values remain native scalars; the additional unit identity exists only during static analysis.

The extension uses the same parser, catalog, reduction, normalization, and conversion semantics as the runtime API. See the unit syntax reference for accepted expressions and name resolution.

Most applications primarily need branded native types, operator inference, and boundary helpers. Quantity and PointQuantity type inference becomes relevant when exact runtime objects cross analyzed code; registry configuration and optional annotation integration are advanced topics for projects extending the catalog or integrating third-party libraries.

I need to…Start with
Add a unit to an existing native numberunit() and branded types
Brand numeric text from a trusted APINumeric Strings
Infer units through PHP operatorsNative Operators
Convert a native magnitudeBoundary Helpers
Track an exact runtime quantityQuantity Types
Track an exact coordinate pointQuantity Types
Add project-specific unitsRegistry Configuration
Suppress or baseline an errorDiagnostics

Current boundaries: Genuinely dynamic unit strings cannot receive a precise static unit; native helpers diagnose them by default while runtime object APIs may parse them dynamically. Casts other than explicit integer/float casts and unsupported built-ins may erase a brand, and dimensional analysis cannot distinguish concepts with identical physical dimensions. See Limitations for the complete list.

Branded Native Types

unit_int<'unit'> and unit_float<'unit'> are PHPDoc types for native integers and floats with a statically known unit. A branded value is not a wrapper or subclass: it remains an ordinary PHP number at runtime. The types work in ordinary @param, @return, @var, generic, union, intersection, and nullable positions.

Feet are therefore distinct from meters even though both values are native floats:

<?php

/** @param unit_float<'meter'> $length */
function setPlatformHeight(float $length): void {}

/** @var unit_float<'foot'> $height */
$height = 6.0;

// @akashi-phpstan-error argument.type: unit_float<'meter'>, unit_float<'international_foot'> given
setPlatformHeight($height);

In tested examples, an @akashi-phpstan-error comment records the stable PHPStan diagnostic identifier and a distinctive fragment of the expected message on the following statement. It is an ordinary comment used by the documentation tests, not a Yumemi annotation.

The catalog canonicalizes aliases when it constructs a brand, which is why the diagnostic names international_foot. The catalog reference describes canonical names, aliases, symbols, plurals, and prefixes.

Integer Constants And Ranges

Integer precision composes with a unit brand through PHPStan’s ordinary intersection syntax:

<?php

use function jbboehr\Yumemi\unit;

/** @param unit_int<'second'>&int<0, max> $delay */
function scheduleBoundedRetry(int $delay): void {}

scheduleBoundedRetry(unit(30, 'second'));

unit(30, 'second') is inferred as the branded constant 30&unit_int<'second'>. A native int<0, 100> passed to unit($value, 'second') becomes unit_int<'second'>&int<0, 100>. Standard refinements such as positive-int and non-negative-int may be intersected with unit_int in the same way. There is no separate unit_const_int syntax: literal and range precision remain ordinary PHPStan types, while unit_int contributes only the unit identity.

Bounded targets enforce both parts. A bare int<0, 100> lacks the required unit, a branded value outside the range violates the bound, and a value with another unit violates the brand. An unbounded unit_int<'second'> accepts bounded and constant seconds.

Known float values use the same idea. unit(1.5, 'meter') is inferred as 1.5&unit_float<'meter'>: PHPStan’s ordinary constant-float type supplies 1.5, while unit_float supplies the unit. There is no separate unit_const_float syntax, and the intersection shown in a diagnostic is not a runtime wrapper.

Here, “known” means that PHPStan knows the actual PHP binary floating-point value. It does not make 1.5, a conversion ratio, or a calculated result into an exact rational quantity. Use Rational or Quantity when the program must retain exact decimal or fractional semantics at runtime.

Numeric Strings

unit_numeric_string<'unit'> brands a PHPStan numeric-string whose magnitude has a statically known unit. It is useful when a trusted configuration or framework API represents a number as text while its contract defines the unit. At runtime the value remains an ordinary string: Yumemi does not attach metadata, parse a combined value such as "30 second", or validate where the magnitude came from.

A bare numeric-string does not satisfy a unit-bearing parameter, and strings branded with different units are not interchangeable. An explicit integer or float cast preserves the brand on the resulting native number. Convert that number through the normal unit boundary when a different unit is required. Implicit arithmetic, weak parameter coercion, and other string-to-number conversions do not preserve the brand; cast first when entering numerical code:

<?php

interface RetryConfiguration
{
    /** @return unit_numeric_string<'second'> */
    public function retryDelay(): string;
}

/** @param unit_int<'second'> $delay */
function scheduleConfiguredRetry(int $delay): void {}

function applyRetryConfiguration(RetryConfiguration $configuration): void
{
    scheduleConfiguredRetry((int) $configuration->retryDelay());
}

Use this type only when the external contract already guarantees both numeric syntax and the unit. It is a static declaration, not a runtime validation helper; use ordinary validation before branding data whose contents are not yet trusted.

Definitional Equivalence And Compatibility

Native arithmetic cannot change either operand’s magnitude to a different scale. Addition, subtraction, and assignment therefore require definitionally equivalent units: their normalized expressions, including scale, must match.

Units may instead be merely dimensionally compatible. meter and foot both describe length, but assigning or adding their native magnitudes would be incorrect without conversion. Use unit_factor() or unit_to() at that boundary. Runtime Quantity objects can perform the conversion themselves and consequently use dimensional compatibility for add(), sub(), and comparisons.

Native Operators

Yumemi infers native unit types for unary + and - and for these binary operators:

OperatorStatic behavior
+, -Require two definitionally equivalent unit values and preserve their unit
*, /Multiply or divide unit expressions and reduce the result
**Raise the unit to a constant integer power
%Require two unit_int values with definitionally equivalent units
ComparisonsRequire definitionally equivalent units and retain PHP’s native result

Multiplication and division may combine a unit value with a bare numeric scalar. Division always produces a unit_float; operations involving a float-like magnitude also produce a float brand. Yumemi preserves known integer constants and signed ranges through addition, subtraction, multiplication, unary signs, and nonnegative powers. It also preserves known float values through supported arithmetic when every required operand value is known. Exact integer endpoint arithmetic determines the result kind:

Mathematical result relative to PHP’s integer rangeInferred type
Entirely insideBranded constant or bounded unit_int
Entirely outsideunit_float
Partly insideBenevolent union of bounded unit_int and unit_float

For a mixed result, the integer branch is clipped to values PHP can actually retain as integers. Unary negation therefore isolates the PHP_INT_MIN case rather than treating every integer as equally likely to overflow. Modulo preserves a branded constant when both operands are known and the divisor is nonzero; other modulo results remain an unbounded unit_int. Finite operand unions are evaluated arm by arm, and Yumemi rejects the whole operation if any possible pairing is invalid.

Applications that intentionally prefer PHPStan’s integer-preserving approximation for potentially overflowing arithmetic can disable float promotion:

parameters:
    yumemi:
        integerOverflowToFloat: false

This setting changes static inference only; it cannot alter PHP’s runtime overflow behavior. Proven-safe constants and ranges remain precise; a potentially overflowing result widens to an unbounded unit_int because PHPStan cannot represent an integer endpoint outside PHP’s platform range.

For example, distance divided by time is inferred as speed, while distance multiplied by time is rejected at a speed boundary:

<?php

/** @param unit_float<'meter / second'> $speed */
function saveSprintSpeed(float $speed): void {}

/** @var unit_float<'meter'> $distance */
$distance = 100.0;
/** @var unit_float<'second'> $elapsed */
$elapsed = 9.58;

saveSprintSpeed($distance / $elapsed);

// @akashi-phpstan-error argument.type: unit_float<'meter / second'>, unit_float<'meter * second'> given
saveSprintSpeed($distance * $elapsed);

Definitional equivalence understands catalog definitions such as newton = kilogram * meter / second^2. It does not make compatible scales interchangeable: meter + foot remains an error because no runtime conversion occurs.

Equality, identity, ordering, and spaceship comparisons follow the same rule: native PHP compares the stored magnitudes without converting either operand, so dimensionally compatible but differently scaled units remain invalid. Strict identity may still test a nullable or other nonnumeric sentinel arm, as in $duration !== null; a bare numeric arm remains invalid because it can participate in the magnitude comparison.

Native exponentiation requires a statically known integer exponent. For exact runtime quantities, Quantity::root($degree) infers the rooted unit when the degree is one statically known positive integer and every symbolic unit power is divisible by it. PHPStan cannot prove that the runtime rational magnitude has an exact root, so a statically valid call may still throw NonExactRootException. A dynamic degree falls back to the nongeneric Quantity return type. Native sqrt() and integer-exponent pow() support are described below. Rational exponents, approximate real powers, and unlisted unit-transforming native functions are not part of the current model.

Casts And Scalar Functions

Explicit integer and float casts preserve the unit while changing the native numeric kind. Yumemi also tracks brands through a small set of built-in scalar functions. Most retain the input unit; sqrt() transforms it when the symbolic square root is exact:

ExpressionInferred result
(float) $unitIntegerSame unit, retaining a known constant
(int) $unitFloatSame unit, retaining a known constant
(int) $unitNumericStringunit_int<'same unit'>
(float) $unitNumericStringunit_float<'same unit'>
intval()Same as an integer cast when base is omitted or 10
floatval() and doubleval()Same behavior as an explicit float cast
abs($unitFloat)Same unit, retaining a known float constant
abs($unitInteger)Branded integer bounds, with possible overflow promotion
ceil() and floor()Same unit, retaining a known numeric constant
round()Same unit, retaining supported known results
min() and max()Common brand, retaining known extrema or integer bounds
array_sum()Common brand, retaining known sums or integer bounds
array_product()Composed brand for sealed, statically known array shapes
range()List with one common endpoint and step brand
sqrt($unitNumber)Rooted unit, retaining a finite nonnegative constant
fdiv($left, $right)Quotient unit, matching native / unit algebra
intdiv($left, $right)Integer quotient unit with truncation toward zero
fmod($left, $right)Common definitionally equivalent unit
hypot($left, $right)Common definitionally equivalent unit
pow($base, $exponent)Base unit raised to a constant integer exponent
deg2rad($degrees)unit_float<'radian'>
rad2deg($radians)unit_float<'arc_degree'>
sin(), cos(), and tan()unit_float<'1'> from canonical radians
asin(), acos(), and atan()unit_float<'radian'> from an exact unscaled ratio
atan2($y, $x)unit_float<'radian'> from equivalent operand units

For example, these transformations remain ordinary native PHP operations at runtime:

<?php

/**
 * @param unit_float<'meter'> $offset
 * @return unit_float<'meter'>
 */
function absolutePlatformOffset(float $offset): float
{
    return abs($offset);
}

/** @var unit_int<'meter'> $measuredHeight */
$measuredHeight = 12;
/** @var unit_float<'meter'> $displayHeight */
$displayHeight = round((float) $measuredHeight, 1);

/** @var unit_float<'meter^2'> $platformArea */
$platformArea = 144.0;
$platformWidth = sqrt($platformArea);

/** @var unit_float<'arc_degree'> $bearing */
$bearing = 180.0;
$bearingInRadians = deg2rad($bearing);
$horizontalComponent = sin($bearingInRadians);
/** @var unit_float<'1'> $slopeRatio */
$slopeRatio = 0.5;
$inclination = asin($slopeRatio);
/** @var unit_float<'meter'> $rise */
$rise = 3.0;
/** @var unit_float<'meter'> $run */
$run = 4.0;
$direction = atan2($rise, $run);
$platformVolume = pow($platformWidth, 3);
/** @var unit_int<'meter'> $surveyedLength */
$surveyedLength = 7;
/** @var unit_int<'meter'> $additionalSurveyedLength */
$additionalSurveyedLength = 5;
$totalSurveyedLength = array_sum([$surveyedLength, $additionalSurveyedLength]);
$surveyedArea = array_product([$surveyedLength, $additionalSurveyedLength]);
/** @var unit_int<'meter'> $surveyEnd */
$surveyEnd = 11;
$surveyMarkers = range($surveyedLength, $surveyEnd);
$wholeHalfLength = intdiv($surveyedLength, 2);

assert((float) $displayHeight === 12.0);
assert((float) $platformWidth === 12.0);
assert((float) $bearingInRadians === M_PI);
assert((int) $totalSurveyedLength === 12);
assert((int) $surveyedArea === 35);
assert(count($surveyMarkers) === 5);
assert((int) $wholeHalfLength === 3);

Crossing a known integer constant to a float retains both its value and unit. Integer ranges still generalize because PHPStan has no corresponding public float-range type. intval(), floatval(), and doubleval() follow the same brand rules as explicit casts, including moving a unit_numeric_string brand onto the resulting number. For a branded numeric string, intval() preserves the brand only when base is omitted or statically known to be 10; another or dynamic base changes how the text is interpreted, so Yumemi leaves the result unbranded. The base argument does not affect integer or float inputs. abs(), ceil(), and floor() retain a constant value when the input and result are known. sqrt() does so for finite nonnegative inputs. round() retains a finite constant result when the input, precision, and rounding mode are each omitted or resolve completely to supported constants. Finite precision and mode alternatives produce the union of every possible rounded result rather than selecting one path. Dynamic arguments, invalid modes, non-finite values, and excessively large alternative sets retain unit_float<'same unit'> without claiming a constant.

The four longstanding PHP_ROUND_HALF_* modes are supported as integer constants. On PHP 8.4 and later, their corresponding RoundingMode enum cases are also supported when PHPStan’s configured target and the PHP runtime executing PHPStan use the same rounding-semantics era. The four directional enum cases introduced in PHP 8.4 currently retain the unit but generalize the value. A target/runtime mismatch across PHP 8.4 also generalizes the value because configuring a target version does not make the analyzer execute another PHP runtime’s rounding algorithm. On PHP 8.2 and 8.3, native round() still requires the legacy integer modes; the polyfilled enum is available to Yumemi’s runtime APIs but does not change that native signature.

min() and max() preserve a unit when every value they can return is branded with one definitionally equivalent unit. This works with direct arguments, arrays, and unpacked arrays. When every candidate is required and is a known finite constant, the selected integer or float value is retained. Known integer ranges are narrowed when every candidate is required; a general array keeps its declared branded range because its runtime members are not known individually. If a possible nonempty input contains an unbranded value or a different unit, Yumemi does not infer one brand for the result and reports yumemi.invalidUnitSelection. A possible empty-array input does not contribute a result because native min() and max() throw on that path.

array_sum() preserves a unit when every possible array value is a unit_int or unit_float with one definitionally equivalent unit. Exact array shapes retain known sums and integer bounds; general integer arrays also model native overflow promotion according to yumemi.integerOverflowToFloat. The empty result is the branded additive identity when the array’s declared element type supplies a unit, while a literal array_sum([]) remains PHPStan’s bare integer zero. An unbranded or differently branded possible summand reports yumemi.invalidUnitAggregation. Convert compatible units before aggregation, and explicitly cast unit_numeric_string elements before summing them; array_sum() is not an implicit brand-preserving numeric-string conversion.

array_product() composes the units of every factor in a sealed array shape whose possible positions are statically known. Factors may carry different units, and an explicit bare int or float acts as a dimensionless scalar. Fixed constants and integer ranges retain the same multiplication and overflow policy as native branded *; optional keys produce the finite alternatives for presence and absence. A literal array of meters and seconds therefore produces a meter-second unit, while two meter factors produce square meters.

An array with unknown cardinality cannot produce one sound symbolic unit because n values branded with meter yield meter^n. Yumemi reports yumemi.invalidUnitAggregation rather than erasing that uncertainty. Unsealed shapes, a possible nonempty fixed shape without any unit-bearing factor, implicit string coercion, more than 128 possible fixed products, and derived units outside the supported exponent range report the same identifier. Cast branded numeric strings explicitly before multiplying them. A unit-free call, including literal array_product([]), remains owned by PHPStan’s native return type.

range() preserves a unit when both endpoints and any explicit step are branded int or float values with one definitionally equivalent unit. A small range of known constants retains its exact list; larger or dynamic integer endpoints retain their combined bounds, and any possible float endpoint or step contributes a branded float result. On PHP 8.2, an explicit float step that may be NAN produces an ordinary list because that call may return an empty array; successful ranges are otherwise non-empty. The omitted native step is interpreted contextually in the endpoints’ unit. An explicitly supplied step must be branded even when its runtime value is 1, because native PHP carries no unit that Yumemi could safely infer from that bare argument. Constant folding follows PHPStan’s configured target PHP version; a known constant call rejected by that target is left to PHPStan instead of receiving a branded success type.

Mixed or incompatible endpoints and explicit steps report yumemi.invalidUnitRange. Convert compatible values before constructing the range, and cast unit_numeric_string values explicitly. Calls with no branded arguments remain owned by PHPStan’s native range() inference.

Unlike those preserving operations, sqrt() transforms the unit. It infers unit_float<'meter'> from either an integer or float branded as meter^2, because native sqrt() always returns a float. Every symbolic unit power must be divisible by two. A non-rootable brand such as meter produces yumemi.invalidUnitRoot instead of silently losing its unit. A known negative or non-finite magnitude keeps the rooted unit but generalizes to unit_float rather than creating a branded NAN or infinite constant.

The check uses the symbolic expression as written; it does not substitute catalog definitions before taking the root. For example, kilometer * millimeter is dimensionally an area but lacks an exact symbolic square root. Express the native brand with square powers, or use Quantity::simplify()->root(2) when runtime definition substitution and exact magnitude checking are required.

For a union containing only branded numeric alternatives, Yumemi roots every alternative. Any non-rootable branded arm produces the diagnostic. If an otherwise valid union also contains an unbranded numeric arm, PHPStan keeps its ordinary native sqrt() result because one precise unit cannot describe every runtime path.

fdiv() follows the same unit algebra as native /: it divides two unit expressions, preserves a unit when the other operand is a bare numeric scalar, and produces the reciprocal unit when only the divisor is branded. fmod() and hypot() instead require both operands, across every possible numeric union arm, to carry one definitionally equivalent unit; they report yumemi.invalidUnitMathFunction rather than infer a misleading brand from mixed or differently branded numeric operands. Calls containing nonnumeric alternatives are left to PHPStan’s native argument checking. All three functions return unit_float and retain a known finite result when both magnitudes are known. Non-finite results retain only the derived brand. fdiv() also reports yumemi.invalidUnitMathFunction when the quotient unit would exceed Yumemi’s supported exponent range.

intdiv() applies the same quotient unit algebra to integer operands and returns unit_int. Two branded operands produce the quotient of their units; one branded operand preserves that unit or produces its reciprocal according to its position. Known constants and integer ranges retain truncation-toward-zero bounds. A wholly bare call remains under PHPStan’s native inference, while float or otherwise invalid operands remain under its native argument checking. Every possible operand pairing must retain a unit; if union alternatives permit a wholly bare pairing, Yumemi reports yumemi.invalidUnitMathFunction instead of inferring a same-carrier branded/bare union. Division by zero and PHP_INT_MIN / -1 retain PHPStan’s native throw analysis; Yumemi keeps a conservative branded return type for those exceptional paths rather than inventing a successful value. An unrepresentable quotient unit reports yumemi.invalidUnitMathFunction.

pow() follows the same branded-unit contract as native **. Its base may be a branded integer or float, while every possible exponent must be a bare constant integer from -10000 through 10000. The unit is raised to that exponent; negative exponents produce branded floats, and nonnegative integer powers retain integer bounds and the configured overflow-to-float policy. Finite constant exponent alternatives are evaluated independently and may produce a union of result units. A dynamic, fractional, or unit-bearing exponent, a mixed branded-and-bare base, or a resulting unit beyond the exponent limit reports yumemi.invalidUnitMathFunction. Wholly bare calls and calls containing nonnumeric alternatives remain under PHPStan’s native analysis.

deg2rad() accepts arc_degree and aliases that resolve canonically to it, then returns unit_float<'radian'>. rad2deg() accepts canonical radian aliases and returns unit_float<'arc_degree'>. Both functions accept branded integers and floats and retain finite constant results. They do not treat merely equal-scale or dimensionless units as angles: for example, degree_north is not an arc_degree alias, and steradian is not a radian alias. Such calls report yumemi.invalidUnitAngleFunction rather than silently relabeling the result. Bare calls remain ordinary PHPStan float expressions, and an otherwise valid union containing a bare numeric alternative falls back to that native result.

sin(), cos(), and tan() likewise require canonical radian input, because native PHP does not convert another angular scale before evaluating them. Their results are unscaled ratios branded as unit_float<'1'>. asin(), acos(), and atan() reverse that relation: they require an expression that reduces structurally to the unscaled unit 1 and return canonical unit_float<'radian'>. A ratio such as meter / meter qualifies because its symbols cancel. Named dimensionless units such as percent, count, radian, and steradian do not qualify merely because their dimensions or normalized definitions are dimensionless. Convert the magnitude to 1, or deliberately rebrand an already unscaled ratio as unit_float<'1'>, when its ratio semantics are known.

atan2($y, $x) accepts two branded operands only when their units are definitionally equivalent across every possible union pairing. The common unit may be dimensional: meter and 100 * centimeter qualify because their scale is the same, while meter and foot do not because native PHP performs no conversion. Mixing a branded operand with a bare number reports yumemi.invalidUnitAngleFunction. A wholly bare call remains an ordinary PHPStan float expression. The branded result is canonical unit_float<'radian'>.

The modeled trigonometric functions retain finite constant results. A branded call whose native result falls outside that finite set, such as asin(unit(2.0, '1')), retains the output unit but generalizes its magnitude instead of representing NAN as a branded constant.

The identity check applies to the unit expression statically visible at the native call. Yumemi preserves structurally distinct alternatives such as unit_float<'arc_degree'>|unit_float<'degree_north'> so that the call fails closed. Ordinary assignment remains definitionally based, however: passing degree_north through a parameter, property, or return explicitly declared as only arc_degree leaves the native call with that declared type. Convert before that boundary when the nominal distinction must remain visible.

These fixed native contracts require the configured registry’s effective radian and arc_degree semantic records and fully resolved meanings to match the bundled canonical entries. Descriptive catalog prose and record-key order do not affect that check, but redefinitions of dependencies such as pi or rad disable angle inference. An isolated registry or one that changes either canonical meaning leaves angle calls to PHPStan instead of inferring a potentially false brand. Additional aliases remain valid when they resolve to the verified canonical entries. Convert another angular scale explicitly before calling the native function.

For branded integers, abs() retains exact constants and computes the hull of known ranges. The PHP_INT_MIN case can produce a float at runtime, so unbounded or partially exposed ranges follow the same integerOverflowToFloat policy as native arithmetic. With promotion enabled, their result may be a benevolent union of a nonnegative branded integer and a branded float; disabling promotion widens the result to an unbounded unit_int.

Boundary Helpers

Yumemi provides three functions for introducing and converting native unit values:

  • unit($value, $unit) validates a multiplicative unit and returns the unchanged native magnitude branded as unit_int or unit_float, retaining a known scalar value.
  • unit_factor($from, $to) returns a native conversion ratio branded as to / from. Multiplying it by a source value cancels the source unit and produces the target unit; known factors remain float constants during analysis.
  • unit_to($value, $from, $to) performs the conversion directly and returns a float. Multiplicative targets retain a unit_float brand, and a statically known input and conversion retain the resulting float value.
<?php

use function jbboehr\Yumemi\unit;
use function jbboehr\Yumemi\unit_factor;
use function jbboehr\Yumemi\unit_to;

/** @param unit_float<'meter'> $meters */
function acceptHeightInMeters(float $meters): void {}

$height = unit(6, 'foot');
$byFactor = $height * unit_factor('foot', 'meter');
$converted = unit_to($height, 'foot', 'meter');

acceptHeightInMeters($byFactor);
acceptHeightInMeters($converted);

assert($height === 6);
assert(abs($byFactor - $converted) < 1e-12);

unit_factor() supports multiplicative units only. Units::conversionFactor() is the corresponding exact runtime API and returns a Rational; the native helper returns a float so converting a branded integer promotes it to a branded float.

unit_to() also performs affine conversions. A multiplicative target such as kelvin remains branded. An affine target such as celsius is plain float because the current native type model cannot distinguish absolute coordinates from delta temperatures. A known affine conversion may still produce an unbranded PHPStan float constant.

Constant helper results describe the PHP float that the corresponding runtime call will return. They do not promise decimal exactness: for example, a converted binary float may be displayed as 0.9144000000000001 rather than 0.9144. Use the exact runtime object APIs when that distinction matters.

If a branded value is passed to unit_to(), its brand must match the declared source unit. unit() and known helper arguments are also validated against the configured catalog. Unknown expressions and incompatible conversions fail analysis before the result type is used.

Constant Unit Expressions

By default, every unit argument to unit(), unit_factor(), and unit_to() must resolve during analysis to one exact constant string or a finite set of exact alternatives. Class constants and expressions that PHPStan constant-folds are accepted. A broad string or literal-string is not enough because Yumemi cannot recover the expression text to parse and validate it.

Finite alternatives are accepted only when every valid path gives the operation one semantic result. Aliases such as 'meter'|'metre' are therefore valid for unit(). Several source units are valid for unit_to() when one target is fixed, because the target determines the returned brand. Alternatives such as 'meter'|'foot' passed to unit(), or as the target of unit_to(), are ambiguous even though PHP can represent a union of their brands.

<?php

use function jbboehr\Yumemi\unit;

/** @return unit_float<'meter'> */
function brandKnownDistanceAlias(float $value, bool $useAlias): float
{
    return unit($value, $useAlias ? 'meter' : 'metre');
}
function brandDynamicDistance(float $value, string $unitExpression): float
{
    // yumemi.dynamicUnitExpression
    return unit($value, $unitExpression);
}

function brandAmbiguousDistance(float $value, bool $useMetric): float
{
    $unitExpression = $useMetric ? 'meter' : 'foot';

    // yumemi.ambiguousUnitExpression
    return unit($value, $unitExpression);
}

The first rejected call does not expose its expression text. The second exposes two valid units, but they do not normalize to one semantic result.

The two diagnostics preserve the functions’ ordinary native fallback types, so an intentional dynamic boundary can use an identifier-specific local suppression:

<?php

use function jbboehr\Yumemi\unit_to;

function convertUncheckedUnitExpression(float $value, string $sourceUnit, string $targetUnit): float
{
    // @phpstan-ignore yumemi.dynamicUnitExpression
    return unit_to($value, $sourceUnit, $targetUnit);
}

Projects whose native-helper calls fundamentally depend on runtime strings may disable only the dynamic-expression diagnostic:

parameters:
    yumemi:
        requireConstantNativeUnitExpressions: false

Ambiguous finite alternatives remain errors because no one output unit applies; suppress yumemi.ambiguousUnitExpression locally when that loss of precision is deliberate. Runtime parsing APIs such as Units::parse() and the Quantity methods remain the intentional dynamic path and are not affected by this option. Their constant and finite-union inference remains described below.

Statically known expressions are also subject to the shared parser resource limits. An oversized constant helper argument reports yumemi.invalidUnitCall. If the native helper executes, it throws its usual InvalidArgumentException and retains Parser\ExpressionLimitExceededException as the previous exception. Suppressing the PHPStan diagnostic does not relax the runtime budget.

Quantity Types

Runtime quantities have the generic PHPStan forms Quantity<'unit'> and PointQuantity<'coordinate'>. Units::quantity(), parseQuantity(), deltaQuantity(), and point() infer the corresponding type when their relevant string is constant or a finite literal-string union. Fluent methods preserve or transform the generic brand while performing the real exact operation at runtime. Finite unions of branded quantity or point receivers and operands are evaluated arm by arm; an operation is rejected when any possible pairing is incompatible.

<?php

use jbboehr\Yumemi\Quantity;
use jbboehr\Yumemi\Units;

/** @param Quantity<'meter / second'> $speed */
function storeAverageSpeed(Quantity $speed): void {}

$units = Units::default();
$distance = $units->quantity(100, 'meter');
$duration = $units->quantity(10, 'second');
$speed = $distance->div($duration);

storeAverageSpeed($speed);
assert($speed->toString() === '10 * meter / second');

The extension models current unit-sensitive methods, including:

  • arithmetic through abs(), add(), sub(), addWithSameUnit(), subWithSameUnit(), mul(), div(), neg(), pow(), and exact root();
  • conversion through to(), toPreferred(), toCompact(), and valueIn();
  • native extraction through intValueIn(), exactIntValueIn(), decimalValueIn(), significantDecimalValueIn(), exactDecimalValueIn(), and floatValueIn();
  • unit transformation through normalize() and simplify();
  • comparisons through compareTo(), equals(), lessThan(), lessThanOrEqualTo(), greaterThan(), and greaterThanOrEqualTo().

Quantity::isZero(), Quantity::isCompatibleWith(), and PointQuantity::isCompatibleWith() return ordinary native bool values from their declared signatures and require no unit-specific return-type inference. A compatibility check remains valid when PHPStan knows the dimensions differ: its result is false, not a diagnostic.

Known invalid arithmetic, construction, conversion, and comparison calls produce standalone diagnostics even when the method result is unused. A branded magnitude supplied to Units::quantity() must match the unit being assigned: quantity() labels an existing magnitude and does not implicitly convert it.

Integer and float extraction methods return a native brand when their target unit is known. For example, floatValueIn('foot') returns unit_float<'international_foot'>, bridging an exact quantity back to a statically branded native value. Decimal extraction returns a string while retaining static validation of the target unit.

An explicit target can also brand conversion and extraction results from an unbranded Quantity. PHPStan cannot prove the unknown source dimension in that case, but it can represent the requested result. A genuinely dynamic target falls back to an unbranded return type.

toPreferred() and toCompact() also return an unbranded Quantity: the former depends on the runtime contents of a PreferredUnitProfile, while the latter depends on the runtime magnitude. Use to('target') when subsequent static analysis needs one exact quantity brand.

PointQuantity<'celsius'> carries both the coordinate origin and its difference scale. Coordinate aliases are definitionally equivalent, but different scales such as Celsius, Fahrenheit, and Kelvin remain distinct generic types even though their points can be converted and compared. PHPStan models the affine operation rules:

  • PointQuantity::add() and sub() accept a dimensionally compatible Quantity and preserve the point type;
  • difference() accepts a compatible point and returns Quantity<'delta-unit'> in the receiver’s scale;
  • to() returns a point branded with the target coordinate scale;
  • point comparisons and numeric extraction validate constant targets and preserve their native return types.

PointQuantity::isCompatibleWith() remains an ordinary bool predicate. Unlike an operation that combines points, it is valid to call with known incompatible point dimensions and returns false at runtime.

Direct PHPDoc may use forms such as PointQuantity<'celsius'>. Dynamic coordinate strings fall back to unbranded PointQuantity, following the same policy as ordinary quantities.

Registry Configuration

PHPStan uses the default UDUNITS2 catalog unless parameters.yumemi.registryFactory names an autoloadable class implementing UnitRegistryFactory. Its static create() method returns the complete immutable registry used by every Yumemi extension path:

<?php

namespace App\PHPStan;

use jbboehr\Yumemi\Dimension;
use jbboehr\Yumemi\PHPStan\UnitRegistryFactory;
use jbboehr\Yumemi\Registry\UnitRegistry;
use jbboehr\Yumemi\Registry\UnitRegistryBuilder;

final class DocumentationRegistryFactory implements UnitRegistryFactory
{
    public static function create(): UnitRegistry
    {
        return UnitRegistryBuilder::default()
            ->baseUnit('USD', Dimension::CURRENCY)
            ->define('EUR = 100 / 107 * USD')
            ->define('widget = 12 * meter')
            ->alias('widgets', 'widget')
            ->build();
    }
}

Configure the factory in phpstan.neon:

parameters:
    yumemi:
        registryFactory: App\PHPStan\DocumentationRegistryFactory

Use UnitRegistryBuilder::default() to extend or override UDUNITS2, or UnitRegistryBuilder::empty() for an isolated catalog. baseUnit() introduces a named primitive dimension; subsequent define() calls derive related units through ordinary expressions. Unit definitions and primitive-dimension metadata both contribute to PHPStan’s result-cache fingerprint.

The configured registry controls static analysis only. Applications using custom units in both layers should construct their runtime Units context from the same factory. Instance APIs use that context directly; applications using unit(), unit_factor(), or unit_to() should install it with Units::setDefault() and restore the previous context in finally. PHPStan assumes one authoritative registry for an analysis run and does not track a separate catalog identity on each value. See Contexts And Construction for runtime installation and Custom Registries for builder and overlay semantics.

Extension-Optional Annotations

Libraries that cannot require Yumemi from every consumer can pair ordinary fallback PHPDoc with @yumemi-param, @yumemi-return, or @yumemi-var. Enable promotion explicitly after the primary extension:

includes:
    - vendor/jbboehr/yumemi/extension.neon
    - vendor/jbboehr/yumemi/yumemi-tags.neon

Without yumemi-tags.neon, these are unknown tags and the ordinary PHPDoc or native types remain effective. With it, Yumemi promotes them onto PHPStan’s normal type surface for parameters, returns, properties, and local variables.

A Yumemi tag may replace a fallback only when erasing its units produces the same PHPDoc structure. Every unit_int<'...'> must erase to int, every unit_float<'...'> to float, every unit_numeric_string<'...'> to numeric-string, every Quantity<'...'> to Quantity, and every PointQuantity<'...'> to PointQuantity, including within nullable, union, intersection, and generic types. For example, unit_int<'second'>&int<0, max> erases to int<0, max>, while 3&unit_int<'meter'> erases to 3. Parameter references and variadic markers must also match. Union and intersection order and nullable spelling do not matter. @phpstan-* takes priority over the ordinary tag. An already promoted @phpstan-* tag with exactly the same unit-bearing structure is accepted idempotently. Any other mismatch leaves the fallback unchanged and reports a diagnostic.

<?php

use function jbboehr\Yumemi\unit;

/**
 * @param int $length
 *
 * @yumemi-param unit_int<'meter'> $length
 */
function storeWarehouseLength(int $length): void {}

// @akashi-phpstan-error argument.type: unit_int<'meter'>, int given
storeWarehouseLength(5);

// @akashi-phpstan-error argument.type: unit_int<'meter'>, 3&unit_int<'international_foot'> given
storeWarehouseLength(unit(3, 'foot'));

Without tag promotion, both calls are checked against the ordinary int fallback and are valid.

The integration is opt-in because it replaces internal PHPStan parser services for analyzed source and stubs. It may conflict with another extension replacing the same services and remains an upgrade risk. Application code should normally use direct Yumemi types; integrations for third-party libraries can use ordinary PHPStan stubs or the separately packaged integrations described below.

Third-Party Integrations

Curated stubs for third-party packages live in the separately versioned Yumemi Apocrypha package. Apocrypha uses the generic @yumemi-* promotion mechanism above while owning package selection, supported-version policy, upstream fixtures, and integration documentation. Keeping those concerns outside core avoids adding framework scope or dependencies to Yumemi itself.

Diagnostics

Yumemi emits stable rule identifiers so errors can be suppressed or included in a PHPStan baseline at the appropriate scope:

IdentifierReported condition
yumemi.dynamicUnitExpressionA native helper argument does not reveal its complete unit expression during analysis
yumemi.ambiguousUnitExpressionNative helper alternatives produce more than one semantic result unit
yumemi.invalidUnitAggregationNative array_sum() or array_product() cannot derive one sound unit from every possible input
yumemi.invalidUnitAngleFunctionNative angle function received a noncanonical input, or atan2() received mixed or inequivalent operands
yumemi.invalidUnitCallAn invalid constant unit(), unit_factor(), or unit_to() call
yumemi.invalidUnitComparisonA native equality, identity, ordering, or spaceship comparison whose units are not definitionally equivalent
yumemi.invalidUnitMathFunctionNative binary math received incompatible operands, an invalid exponent, or an unrepresentable result unit
yumemi.invalidUnitRangeNative range() received mixed, unbranded, nonnumeric, or differently branded endpoints or an explicit step
yumemi.invalidUnitRootNative sqrt() received a branded unit without an exact symbolic square root
yumemi.invalidUnitSelectionNative min() or max() can return an unbranded or differently branded candidate
yumemi.invalidQuantityConstructionInvalid Units::quantity(), parseQuantity(), deltaQuantity(), or point() construction
yumemi.invalidQuantityArithmeticInvalid quantity arithmetic operands, powers, or exact-root degrees and unit expressions
yumemi.invalidQuantityConversionAn invalid or incompatible Quantity conversion or native-extraction target
yumemi.invalidQuantityComparisonA Quantity comparison whose statically known units are incompatible
yumemi.invalidPointQuantityOperationAn invalid point translation, difference, conversion, extraction, or comparison
yumemi.docTagSyntaxInvalid @yumemi-param, @yumemi-return, or @yumemi-var syntax
yumemi.docTagDuplicateMore than one Yumemi tag targets the same fallback position
yumemi.docTagUnsupportedA Yumemi tag appears on a declaration that does not support that tag kind
yumemi.docTagParameterA parameter name is unknown or an unnamed @yumemi-var fallback is ambiguous
yumemi.docTagTypeA Yumemi tag contains an invalid unit-bearing type
yumemi.docTagTransformErasing the units does not reproduce the fallback PHPDoc structure
binaryOp.invalidInvalid native unit arithmetic; this is PHPStan’s standard binary-operation identifier, not Yumemi’s

Call and operation diagnostics apply even when an invalid call’s result is unused. Syntax diagnostics preserve the runtime parser’s bounded caret excerpt while PHPStan anchors the error to the containing PHP or PHPDoc line.

When a Yumemi-owned diagnostic still has one exact constant unit argument, it uses the caller’s reduced symbolic spelling, such as metres rather than meter. Inferred types and diagnostics formed after unions, arithmetic, or other semantic joins remain canonical because no single source spelling necessarily survives those operations.

Use the identifier to choose the first corrective step:

  • For binaryOp.invalid or yumemi.invalidUnitComparison, remember that native PHP does not convert either operand. Convert explicitly with unit_to() or unit_factor(), or use Quantity when the operation should convert compatible units. See Definitional Equivalence And Compatibility.
  • For yumemi.dynamicUnitExpression or yumemi.ambiguousUnitExpression, provide one statically recoverable semantic result, use an identifier-specific local suppression for an intentional dynamic boundary, or choose an explicit runtime object API. See Constant Unit Expressions.
  • For yumemi.invalidUnitCall, yumemi.invalidQuantityConstruction, or yumemi.invalidQuantityConversion, check the constant unit spelling, the configured registry, dimensional compatibility, and whether an affine coordinate was used where multiplicative algebra requires a delta_* unit.
  • For yumemi.invalidUnitRoot, express the native brand with unit powers divisible by two. Native sqrt() does not substitute catalog definitions; use Quantity::simplify()->root(2) when that runtime transformation is intended.
  • For yumemi.invalidUnitAngleFunction, pass deg2rad() an arc_degree alias and pass rad2deg() or direct trigonometric functions a radian alias. Inverse trigonometric functions require an explicitly unscaled 1 ratio. Convert explicitly rather than relying on dimensional or scale equivalence, for example deg2rad(unit_to($latitude, 'degree_north', 'arc_degree')) or asin(unit_to($grade, 'percent', '1')). When the value is already an unscaled ratio and no conversion is intended, deliberately declare it as unit_float<'1'>. For atan2(), give both operands one definitionally equivalent brand; convert either magnitude before the call when their units are merely compatible.
  • For yumemi.invalidUnitMathFunction, give both fmod() or hypot() operands one definitionally equivalent brand, or ensure every possible intdiv() pairing retains a brand. Reduce fdiv() or intdiv() operand exponents so their quotient unit remains representable. For pow(), use a bare constant integer exponent within the supported range and ensure the resulting unit remains representable. Convert compatible magnitudes explicitly before calling the function.
  • For yumemi.invalidUnitRange, give both endpoints and any explicit step one definitionally equivalent numeric brand. Omit the step to use the contextual native default, or explicitly brand it when choosing another increment. Cast branded numeric strings before constructing the range.
  • For yumemi.invalidUnitSelection, ensure every value that min() or max() can return has one definitionally equivalent unit. Convert compatible but differently branded values before selecting an extreme.
  • For yumemi.invalidUnitAggregation, ensure every possible array_sum() value has one definitionally equivalent numeric brand. For array_product(), use a sealed, statically known shape whose possible nonempty paths include a unit-bearing factor. Convert summands where required and explicitly cast branded numeric strings before aggregation.
  • For quantity arithmetic, comparison, or point diagnostics, verify the statically known dimensions and distinguish a PointQuantity coordinate from a multiplicative difference. Static generic types do not establish runtime context identity; objects combined at runtime must also belong to the same Units context.
  • For yumemi.docTag*, confirm that the optional integration is enabled and that erasing every Yumemi unit type exactly reproduces the ordinary fallback PHPDoc structure.

Limitations

Important limits of the current static model are:

  • Native unit(), unit_factor(), and unit_to() calls require statically recoverable unit expressions by default. Dynamic object parsing and conversion remain supported, but cannot retain a specific generic unit type.
  • PHPStan supports one configured registry and does not track runtime registry identity per value.
  • Explicit integer/float casts and intval()/floatval()/doubleval() preserve native numeric brands and move a unit_numeric_string brand onto the resulting number. Implicit arithmetic and weak numeric coercion do not preserve a numeric-string brand; comparisons still require definitionally equivalent brands. abs(), ceil(), floor(), round(), min(), max(), array_sum(), and range() preserve numeric unit brands when their operation has one sound result unit. array_product() composes brands for sealed, statically known shapes. Supported constant round() calls also retain every possible finite result; dynamic or version-incompatible policies generalize the value. sqrt() transforms exact symbolic roots, fdiv() and intdiv() follow division algebra, fmod()/hypot() require equivalent brands, and pow() raises a branded unit to a constant integer exponent. deg2rad(), rad2deg(), and the trigonometric functions enforce their canonical angle, exact-unscaled-ratio, or equivalent-operand contracts. Other unsupported casts and PHP built-ins can erase brands.
  • Native + and - cannot convert dimensionally compatible magnitudes; use an explicit conversion or Quantity.
  • Native affine targets remain unbranded because native scalars do not retain point-versus-difference identity. Use PointQuantity<'...'> when that identity must remain statically visible.
  • unit_to() and unit_factor() validate the Cartesian product of independent source and target alternatives and reject the call if any pairing is invalid. Valid alternatives must also collapse to one semantic result unit.
  • Unit exponentiation through **, pow(), and runtime exact-value APIs supports integer exponents only; native branded operations additionally require each possible exponent to be statically constant.
  • PHPStan has no corresponding native float-range syntax, so branded floats can retain known constants but not continuous bounds.
  • Dimensional analysis cannot distinguish different physical meanings with the same dimension, such as gray and sievert.

Add targeted PHPStan integrations for demonstrated application workflows rather than assuming every cast, built-in, or third-party API preserves a unit brand.

Unit Syntax Reference

Within the blue cupola, bronze bees circled an empty place in the mosaic where no star had been set, and their wings sounded like rain upon a sealed roof. The learned accused the vacancy, but the swarm entered it with their labor and made no image. Thus was the unfigured place guarded from invention. Let the unfinished heaven remain open above the choir.

Scholia of the Fifth Archive 92:41

Bronze bees circling an unfilled star-shaped place in a blue cupola mosaic

Unit strings can be simple names such as meter, products such as kilogram * meter / second^2, or exact constants such as 100 centimeter. Yumemi uses the same parser and catalog resolver at runtime and in its PHPStan extension, so these strings have the same meaning in Units::parse(), Quantity, unit_int<'...'>, unit_float<'...'>, and unit_numeric_string<'...'>.

Supported Expressions

The semantic unit language supports:

FormExamplesMeaning
Identifiermeter, international_foot, PaCatalog unit name, alias, symbol, or prefixed name
Multiplicationm * s, m s, m.s, m · sProduct of unit expressions
Divisionmeter / secondQuotient of unit expressions
Integer powermeter^2, second^-2, meter², second⁻²Unit expression raised to an integer power
Grouping(meter / second)^2Parenthesized subexpression
Exact constant1000, 1.25, 1e3, 1000 meterExact rational alone or scaling a unit expression

Precedence warning: Exponentiation binds more tightly than multiplication and division. Adjacency, *, ., ·, and / otherwise share precedence and associate left, matching UDUNITS2. Therefore meter / foot second means (meter / foot) * second, not meter / (foot * second).

Whitespace is ignored except that adjacent simple expressions imply multiplication. Use parentheses around a compound denominator:

centimeter / (foot * second)

Decimal and scientific constants are parsed exactly as rational numbers. They are not converted through binary floating point.

For example:

<?php

use jbboehr\Yumemi\Units;

$units = Units::default();

assert($units->parse('meter · second⁻²')->toString() === 'meter * second ^ -2');
assert($units->parse('1000')->toString() === '1000');
assert($units->parse('1.25 meter')->toString() === '5/4 * meter');
assert($units->parse('meter / second kilogram')->equals($units->parse('meter / second * kilogram')));
assert($units->dimension('(meter / second)^2')->toString() === 'length ^ 2 / time ^ 2');

Temperatures And Offset Units

Temperature scales such as Celsius have an offset as well as a scale. Convert them with convert(), convertFloat(), or unit_to(). Use Units::point() when an exact value must retain its coordinate scale.

The parser form identifier @ number defines an affine coordinate origin. For example, kelvin @ 273.15 maps zero in the new coordinate system to exactly 273.15 kelvin. areCompatible() and dimension() inspect the resulting dimension. conversionFactor() succeeds only when the conversion has no offset and otherwise throws NonMultiplicativeConversionException. Custom registry definitions may use the same @ form.

Affine units are not part of ordinary multiplicative expression or quantity algebra. Use their generated difference units, such as delta_celsius, delta_fahrenheit, Δ°C, or Δ°F, when a temperature interval participates in products, quotients, or powers. Yumemi never rewrites celsius / second implicitly; write delta_celsius / second explicitly. See Affine Conversion for executable conversion and point operations.

Unit Names

Unit lookup is case-sensitive. Exact names always win before prefix decomposition. This matters for short symbols:

  • Pa is pascal.
  • pa is pico-are.
  • PA is peta-ampere.

Yumemi does not add case-folded aliases or special-case catalog-valid ambiguities. A wrong-case name is rejected even when a differently cased unit exists.

The generated UDUNITS2 catalog contains canonical names, declared aliases, symbols, explicit plurals, and unambiguous generated plurals. Runtime lookup does not guess additional plural forms. Prefixes are tried only after exact lookup, with longer prefix spellings considered first, and the remaining suffix must itself be an exact catalog name.

For example, micrometer resolves as the micro prefix applied to meter, while an exact catalog entry such as minute is never decomposed merely because its first characters resemble a prefix.

Parsing, Resolution, And Formatting

These operations intentionally answer different questions:

  • Units::parse() parses the complete expression, resolves every identifier through the catalog, and reduces the resulting expression.
  • Units::parseUnit() is an explicit alias of Units::parse().
  • Units::parseQuantity() folds all explicit constants into one exact magnitude and preserves the remaining symbolic unit. Catalog conversion factors are not extracted from named units.
  • Units::unit() resolves one catalog unit name, including dynamic prefix decomposition.
  • Units::format() parses string input symbolically and formats the supplied spelling without requiring every name to exist in the catalog.
  • Units::normalize() parses and resolves string input, then substitutes derived-unit definitions.

Formatting is therefore not a unit-validation API. Use parse(), parseUnit(), parseQuantity(), unit(), conversion, compatibility, or quantity construction when unknown names must fail.

Unicode Syntax

The parser accepts the Unicode middle dot · as multiplication and superscript integers as postfix powers. Superscript + and - signs are accepted only when followed by at least one superscript digit. ASCII and Unicode forms can be mixed in one expression.

Supported examples include:

m · kg / s²
(meter / second)⁺²
second⁻²

Unicode formatter output remains parser-compatible when the dimensionless style is numeric. See the runtime reference for formatting policy.

Semantic Capabilities

The parser recognizes a few UDUNITS2 forms that ordinary multiplicative expressions deliberately do not implement:

  • addition and subtraction inside unit expressions, such as meter + second;
  • affine-offset syntax using @ outside explicit conversion boundaries;
  • non-integer powers, such as meter^0.5;
  • logarithmic unit definitions.

Unsupported syntax written through an expression API throws UnsupportedSyntaxException. The runtime reference defines where standalone affine units can execute, and Catalog Semantic Support defines the introspection model for affine, logarithmic, and unsupported expressions.

Synthesized delta_* and Δ names are ordinary multiplicative catalog entries, not special parser syntax.

Quantity::pow() and PHPStan’s unit exponent inference likewise accept only integer powers. Quantity::root() is a separate exact operation for positive integer degrees; it does not add fractional-power syntax to the parser. Rational exponents and explicit approximate powers remain deferred, and a float exponent will not be silently accepted. Integer exponents are limited to the inclusive range -10000 through 10000, including powers formed by reducing nested expressions; root degrees are limited to 1 through 10000.

Resource Limits

Every unit-expression entry point uses the same fixed parser budget:

Resource$limit keyMaximum
Complete inputinput-bytes4,096 bytes
Non-whitespace lexical tokenstoken-count256
Parenthesis nestingnesting-depth64
One identifier or numeric tokentoken-bytes1,024 bytes

The $limit keys in the table are the stable discriminator values emitted by Yumemi.

Source bytes include whitespace and multibyte UTF-8 encoding. Operators and parentheses count as tokens. The token limit also bounds the size and depth of the resulting expression tree.

The shared parser reports an exceeded limit with Parser\ExpressionLimitExceededException, which extends PHP’s LengthException and implements Yumemi’s common ExceptionInterface. Its limit, maximum, and observed properties identify the failed budget. A token-level failure also provides a span; an input-length failure occurs before tokenization and has no source span. The span is available through both the span property and getSpan(). Direct parser-backed APIs such as Units::parse() throw this exception. The native unit(), unit_factor(), and unit_to() helpers preserve their InvalidArgumentException boundary and retain the limit exception as the previous exception.

When a limit is exceeded inside a catalog or custom definition while resolving a parsed outer expression, the outer exception’s span identifies the unit name supplied by the caller. The definition-local limit failure remains available as the previous exception.

These limits apply to bundled and custom unit definitions as well as direct runtime and PHPStan parsing. They are defense in depth, not a sandbox. An application accepting unit expressions from an external boundary should still apply the smaller input and complexity policy appropriate to that boundary.

Errors And Source Locations

Malformed syntax throws Parser\ParseException. When available, its SourceSpan is a zero-based, half-open byte range in the decoded unit expression. The exception message renders a one-based line and column plus a bounded caret excerpt. Malformed numeric text such as 1.2.3 is reported as syntax, and the source span covers the complete malformed token.

Unknown names throw UnitNotFoundException. Parsed but unsupported constructs throw UnsupportedSyntaxException or a more specific semantic exception. These runtime exceptions expose an optional span property using the same zero-based, half-open byte convention. A direct failure identifies the offending name or construct. When resolution descends through an alias or stored catalog definition, the span remains attached to the outer identifier written by the caller rather than referring to source text that the caller did not provide.

The PHPStan extension uses the same parser and resolver, and its parse-result objects expose the same range through errorSpan(). Its handling of constant and dynamic strings is documented in Limitations.

Runtime Reference

At the longest night, kindle the amber horizon within the underground cloister, yet veil its eastern quarter with linen and leave the brothers one hour of darkness. Let the crafted radiance warm the sick, ripen the winter figs, and guide the late pilgrim, but suffer it not to counterfeit morning. When the linen brightens from the farther side, extinguish every lamp without lament, for the lesser glory hath completed its obedience.

Ordinances of the Synthetic Dawn 14:37

A linen veil dividing an amber horizon within a cobalt-lit underground cloister

Yumemi’s runtime API provides exact unit conversion and quantity arithmetic. Rational is the authoritative magnitude type; conversion to native integers, decimals, or floats is always explicit.

See the unit syntax reference for accepted expressions and the catalog reference for default units and custom registries.

Most applications can start with quantity construction, arithmetic, conversion, and native numeric output. The expression, dimension, formatting, and string-form sections cover lower-level manipulation and presentation when those needs arise.

Common Tasks

I need to…Use
Construct an exact quantityUnits::quantity()
Construct an exact decimal magnitudeRational::fromDecimalString()
Construct an exact coordinate pointUnits::point()
Construct a difference for a coordinate scaleUnits::deltaQuantity()
Parse a value and unit togetherUnits::parseQuantity()
Convert a quantityQuantity::to()
Apply application-preferred unitsQuantity::toPreferred()
Choose an engineering prefix in a named unit familyQuantity::toCompact()
Convert a coordinate pointPointQuantity::to()
Preserve the exact rational result after conversionQuantity::valueIn()
Obtain a minimal exact terminating decimalQuantity::exactDecimalValueIn()
Obtain a rounded decimal with a requested scaleQuantity::decimalValueIn()
Round to a requested significant-digit precisionQuantity::significantDecimalValueIn()
Obtain a native binary floating-point resultQuantity::floatValueIn()
Convert a native scalarunit_to()
Add compatible quantitiesQuantity::add()
Reject implicit unit conversionQuantity::addWithSameUnit()
Take the exact absolute value of a quantityQuantity::abs()
Test whether an exact quantity is zeroQuantity::isZero()
Check whether two quantities are compatibleQuantity::isCompatibleWith()
Check whether two coordinate points are compatiblePointQuantity::isCompatibleWith()
Select symbols or Unicode notationFormatOptions and formatting methods

exactDecimalValueIn() throws when the exact rational result has a non-terminating decimal expansion. Use decimalValueIn() or significantDecimalValueIn() with an explicit rounding mode in that case.

Every throwable explicitly created by Yumemi implements jbboehr\Yumemi\Exception\ExceptionInterface. Yumemi’s wrappers also extend their corresponding native PHP classes, so callers may catch the common interface, a specific Yumemi exception, or a native parent such as InvalidArgumentException. Errors raised directly by PHP or a dependency are not covered by this marker. Direct parser-backed APIs report the shared parser resource limits with Parser\ExpressionLimitExceededException, a LengthException subtype. The native helpers retain their existing InvalidArgumentException boundary and chain the limit exception as the cause.

Contexts And Construction

Units::default() returns one shared context backed by the generated UDUNITS2 catalog. Repeated calls return the same instance, so quantities created by separate calls can be combined.

Use new Units($registry) for an isolated or customized catalog. Quantities can interact only when they belong to the same Units instance. Combining quantities from different contexts throws IncompatibleQuantityContextException, even when their unit strings happen to match.

Native helpers such as unit(), unit_factor(), and unit_to() use the process-wide default context. Applications that configure the PHPStan extension with custom units can install the matching runtime context temporarily. Save and restore the previous context in finally, especially in tests and long-running workers:

<?php

use jbboehr\Yumemi\Registry\UnitRegistryBuilder;
use jbboehr\Yumemi\Units;

use function jbboehr\Yumemi\unit_to;

$units = new Units(
    UnitRegistryBuilder::default()
        ->define('widget = 2 * meter')
        ->build(),
);
$previous = Units::setDefault($units);

try {
    assert(unit_to(3, 'widget', 'meter') === 6.0);
} finally {
    Units::setDefault($previous);
}

setDefault(null) clears the shared context, causing the next default() call to create a fresh built-in context. Already-created quantities retain their original context.

Create quantities through the context:

<?php

use jbboehr\Yumemi\Number\Rational;
use jbboehr\Yumemi\Units;

$units = Units::default();
$distance = $units->quantity(new Rational(3, 2), 'kilometer');

assert($distance->valueToString() === '3/2');
assert($distance->unitToString() === 'kilometer');
assert($distance->valueIn('meter')->toString() === '1500');
assert($distance->dimension()->toString() === 'length');

Quantity strings use the same expression grammar:

<?php

use jbboehr\Yumemi\Units;

$units = Units::default();
$speed = $units->parseQuantity('2 meter / (4 second)');

assert($speed->valueToString() === '1/2');
assert($speed->unitToString() === 'meter / second');
assert($units->parseQuantity('meter')->valueToString() === '1');

parseQuantity() combines every explicit numeric factor into the exact Rational magnitude. It does not extract scales introduced by catalog resolution: 100 centimeter retains value 100 and unit centimeter, while conversions still account for the centi prefix. A constant-only expression is dimensionless.

The public value() and unit() accessors return the exact Rational magnitude and symbolic Expr. The corresponding public readonly properties remain available.

Exact Rational Values

Quantity and point factories accept int|Rational, not float. This prevents a binary floating-point approximation from entering an exact calculation without an explicit conversion. Construct fractions directly, and construct decimal or scientific values from their source strings:

<?php

use jbboehr\Yumemi\Number\Rational;
use jbboehr\Yumemi\Units;

$fraction = new Rational(3, 2);
$decimal = Rational::fromDecimalString('1.25');
$scientific = Rational::fromDecimalString('1e-3');
$length = Units::default()->quantity($decimal, 'meter');

assert($fraction->toString() === '3/2');
assert($decimal->toString() === '5/4');
assert($scientific->toString() === '1/1000');
assert($length->valueToString() === '5/4');

Rational provides exact abs(), add(), sub(), mul(), div(), integer pow(), and integer-degree root() operations, together with compareTo(), equals(), and isZero(). A root succeeds only when the numerator and denominator both have exact integer roots; negative values therefore accept only odd degrees. toString() returns a fraction, toDecimalExact() requires a terminating decimal, and toDecimal() uses an explicit scale and RoundingMode. Conversion to native values remains explicit through toInt(), toIntExact(), and toFloat(). Integer powers, positive root degrees, and the effective decimal exponent accepted by Rational::fromDecimalString() are limited to 10000 in magnitude. Zero powers follow PHP’s computing convention: every base, including zero, raised to zero returns one.

Quantity Arithmetic

Multiplication and division combine magnitudes and reduce the chosen symbolic unit syntax. They do not substitute unit definitions or automatically convert compatible units:

<?php

use jbboehr\Yumemi\Units;

$units = Units::default();
$distance = $units->quantity(2, 'meter / second')->mul($units->quantity(3, 'second'));
$ratio = $units->quantity(3, 'meter')->div($units->quantity(2, 'foot'));
$displacement = $units->quantity(-3, 'meter');

assert($distance->toString() === '6 * meter');
assert($ratio->toString() === '3/2 * meter / foot');
assert($displacement->abs()->toString() === '3 * meter');
assert(!$displacement->isZero());
assert($units->quantity(0, 'meter')->isZero());

mul() and div() also accept an int or Rational scalar. abs() and neg() change only the magnitude, while isZero() tests that exact magnitude without converting or discarding the unit. pow() raises both the magnitude and unit expression to an integer power. pow(0) returns dimensionless one, including when the original magnitude is zero.

root() is the exact inverse for a positive integer degree when both the rational magnitude and every reduced symbolic unit power have exact roots:

<?php

use jbboehr\Yumemi\Number\Rational;
use jbboehr\Yumemi\Units;

$rootedArea = Units::default()
    ->quantity(new Rational(4, 9), 'centimeter^2 / second^4')
    ->root(2);

assert($rootedArea->toString() === '2/3 * centimeter / second ^ 2');

The degree must be between 1 and 10000. A non-exact magnitude, an even root of a negative magnitude, or a symbolic power not divisible by the degree throws NonExactRootException. Rooting preserves the units the caller wrote: for example, kilometer * millimeter is not a symbolic square even though substitution reduces it to meter^2. Call simplify() or normalize() first when that substitution is intentional, then call root() on the explicit result.

Addition and subtraction require compatible dimensions. The right operand is converted exactly into the left operand’s unit, and the result preserves the left symbolic unit:

<?php

use jbboehr\Yumemi\Units;

$units = Units::default();
$total = $units->quantity(1, 'meter')->add($units->quantity(100, 'centimeter'));

assert($total->toString() === '2 * meter');
assert($total->sub($units->quantity(1, 'foot'))->unitToString() === 'meter');

addWithSameUnit() and subWithSameUnit() reject conversions. They require definitionally equivalent normalized units with the same scale. Thus meter and 100 * centimeter may be equivalent, but meter and centimeter are not.

Conversion And Comparison

to() returns a new quantity whose magnitude has been converted to the requested symbolic unit. valueIn() returns only the exact converted magnitude and leaves the quantity unchanged.

compareTo(), equals(), lessThan(), lessThanOrEqualTo(), greaterThan(), and greaterThanOrEqualTo() convert a compatible right operand exactly before comparing. Incompatible dimensions throw IncompatibleUnitException.

isCompatibleWith() checks whether two quantities belong to the same Units context and have compatible dimensions. It returns false for a different context or dimension; it does not convert either magnitude or throw merely because the quantities are incompatible.

Do not use PHP’s object comparison operators as unit-aware comparisons. They compare PHP object state rather than Yumemi’s conversion semantics.

<?php

use jbboehr\Yumemi\Units;

$units = Units::default();
$meter = $units->quantity(1, 'meter');

assert($meter->equals($units->quantity(100, 'centimeter')));
assert($meter->greaterThan($units->quantity(3, 'foot')));
assert($meter->lessThan($units->quantity(4, 'foot')));
assert($meter->compareTo($units->quantity(1000, 'millimeter')) === 0);
assert($meter->isCompatibleWith($units->quantity(1, 'foot')));
assert(!$meter->isCompatibleWith($units->quantity(1, 'second')));

$rate = $units->quantity(2, 'centimeter / second')->div($units->quantity(3, 'foot'));

assert($rate->toString() === '2/3 * centimeter / (foot * second)');
assert($rate->valueIn('1 / second')->toString() === '25/1143');

Preferred Unit Profiles

Use a preferred-unit profile when one application boundary should consistently select explicit output units for several dimensions:

<?php

use jbboehr\Yumemi\Units;

$units = Units::default();
$displayUnits = $units->preferredUnitProfile([
    'kilometer / hour',
    'kilowatt * hour',
]);

$speed = $units->quantity(25, 'meter / second')->toPreferred($displayUnits);
$duration = $units->quantity(5, 'second')->toPreferred($displayUnits);

assert($speed->valueToString() === '90');
assert($speed->unitToString() === 'kilometer / hour');
assert($duration->toPreferred($displayUnits) === $duration);

Each target expression determines its own dimension, and a profile accepts at most one target per dimension. Conversion is exact. When no target matches, toPreferred() returns the same immutable quantity unchanged. The profile and quantity must belong to the same Units instance so custom definitions cannot be silently reinterpreted in another context.

A dimension does not identify a quantity’s purpose. Gray and sievert share dimensions, while information units are dimensionless. Scope each profile to an application boundary where its dimension-to-target choices make sense; use separate profiles or explicit to() calls when the same dimension has several meanings.

The profile is application configuration and is intentionally not serializable. Because its contents are runtime state, PHPStan infers an unbranded Quantity from toPreferred(). Use explicit to('target') conversion when subsequent statically checked arithmetic needs to retain one known unit brand.

Compact Unit Selection

Use toCompact() near an output boundary when the unit family is known but its engineering prefix should follow the exact magnitude:

<?php

use jbboehr\Yumemi\Number\Rational;
use jbboehr\Yumemi\Units;

$units = Units::default();
$distance = $units->quantity(12_500, 'meter')->toCompact('meter');
$mass = $units->quantity(new Rational(1, 100), 'kilogram')->toCompact('gram');

assert($distance->valueToString() === '25/2');
assert($distance->unitToString() === 'kilometer');
assert($mass->valueToString() === '10');
assert($mass->unitToString() === 'gram');

The argument names one multiplicative unit family. Yumemi considers the unprefixed unit and registry prefixes whose exact scales are powers of 1000, then chooses a magnitude in the half-open interval [1, 1000) when an eligible family member exists in that range. Selection and conversion use Rational; no floating-point logarithm or approximation is involved. Negative values use their absolute magnitude for selection and retain their sign. Zero uses the unprefixed base.

At the registry’s prefix limits, the smallest or largest available candidate is used. A candidate name that already exists in the catalog is accepted only when its exact conversion matches the expected prefix scale. This admits kilogram into the gram family without mistaking an unrelated exact-name collision for a valid prefixed unit. Results use canonical prefix and unit names; formatting may render symbols afterward. If an invalid collision leaves a gap, the selector uses the greatest available scale not exceeding the magnitude; the result may therefore remain at or above 1000 rather than crossing into a falsely prefixed unit.

The family root must reduce to one named unit. Compound expressions, powers, and explicit numeric multipliers throw UnsupportedUnitCompactionException; use a preferred-unit profile for a compound target. The caller must choose the family because dimensional compatibility cannot decide among meter, foot, nautical-mile, or application-specific policies. PHPStan returns an unbranded Quantity because the result depends on the runtime magnitude.

Native Numeric Output

Exact Rational values can be extracted after conversion:

  • intValueIn() follows intdiv()-style truncation toward zero and throws if the result does not fit a PHP integer.
  • exactIntValueIn() additionally requires an integral result.
  • decimalValueIn() returns a fixed number of decimal places using an explicit RoundingMode.
  • significantDecimalValueIn() returns a requested number of significant decimal digits in plain or scientific DecimalNotation.
  • exactDecimalValueIn() returns a minimal terminating decimal or throws for non-terminating rational values.
  • floatValueIn() rounds to binary64 with ties to even. Its default FloatRangePolicy::Strict throws if the result overflows to infinity or a nonzero value underflows to zero.

PHP 8.2 and 8.3 receive the PHP 8.4 RoundingMode enum through symfony/polyfill-php84.

<?php

use jbboehr\Yumemi\Units;
use jbboehr\Yumemi\Number\DecimalNotation;
use jbboehr\Yumemi\Number\FloatRangePolicy;
use jbboehr\Yumemi\Number\Rational;

$length = Units::default()->quantity(1, 'foot');

assert($length->intValueIn('meter') === 0);
assert($length->exactDecimalValueIn('meter') === '0.3048');
assert($length->decimalValueIn('meter', 2, \RoundingMode::HalfEven) === '0.30');
assert($length->significantDecimalValueIn('meter', 3, \RoundingMode::HalfEven) === '0.305');
assert($length->significantDecimalValueIn(
    'meter',
    3,
    \RoundingMode::HalfEven,
    DecimalNotation::Scientific,
) === '3.05e-1');
assert($length->floatValueIn('meter') === 0.3048);

$large = Units::default()->quantity(new Rational(gmp_pow(2, 1024)), 'meter');

assert($large->floatValueIn('meter', FloatRangePolicy::Ieee754) === INF);

Scale and precision answer different questions. A scale of 2 requests two places after the decimal point, while a precision of 3 requests three significant digits wherever the decimal point falls. Significant output retains fractional trailing zeros, so zero at precision 3 is 0.00 in plain notation and 0.00e+0 in scientific notation. Plain integral text cannot distinguish whether trailing zeros are significant: use scientific notation when that distinction must remain visible. Precision is limited to 1 through 10000, and scientific exponents use Yumemi’s existing -10000 through 10000 bound.

Pass FloatRangePolicy::Ieee754 to Rational::toFloat(), Quantity::floatValueIn(), or PointQuantity::floatValueIn() when binary64’s signed infinity and signed zero are preferable to range exceptions. Finite values use the same ties-to-even rounding under either policy. This option applies only when extracting a native float from an exact value; convertFloat(), unit_to(), and unit_factor() remain strict native-float boundaries.

Affine Conversion

Explicit conversion supports UDUNITS2 affine temperature units and custom @ definitions. The exact conversion core maps each coordinate into canonical base units as scale * value + offset; decimal catalog constants remain exact Rational values. A custom @ origin may be a signed integer or finite decimal literal:

<?php

use jbboehr\Yumemi\Units;

use function jbboehr\Yumemi\unit_to;

$units = Units::default();

assert($units->convert(0, 'celsius', 'kelvin')->toString() === '5463/20');
assert($units->convert(100, 'celsius', 'fahrenheit')->toString() === '212');
assert($units->convert(-40, 'celsius', 'fahrenheit')->toString() === '-40');
assert(abs($units->convertFloat(37.0, 'celsius', 'fahrenheit') - 98.6) < 1e-12);
assert(unit_to(32, 'fahrenheit', 'celsius') === 0.0);

dimension() and areCompatible() understand the affine unit’s reference dimension. conversionFactor() succeeds for an identity or another offset-free conversion, such as celsius to an equivalent alias, but it cannot represent celsius to kelvin because that result depends on the input value.

Use PointQuantity when a program must retain an exact coordinate point and perform affine arithmetic:

<?php

use jbboehr\Yumemi\Units;

$units = Units::default();
$freezing = $units->point(0, 'celsius');
$rise = $units->deltaQuantity(18, 'fahrenheit');
$warmer = $freezing->add($rise);
$interval = $units->point(100, 'celsius')->difference($freezing);

assert($freezing->valueIn('kelvin')->toString() === '5463/20');
assert($freezing->isCompatibleWith($units->point(32, 'fahrenheit')));
assert(!$freezing->isCompatibleWith($units->point(1, 'meter')));
assert($warmer->toString() === '10 * celsius');
assert($interval->toString() === '100 * delta_celsius');
assert($interval->valueIn('delta_fahrenheit')->toString() === '180');

A PointQuantity retains an exact Rational coordinate and a named scale. to(), valueIn(), comparisons, and native numeric output apply full scale-and-offset conversion. difference() subtracts another compatible point and returns a Quantity in the receiver’s delta unit. add() and sub() translate the point by a compatible Quantity while preserving the point’s coordinate unit. isCompatibleWith() checks for the same Units context and compatible coordinate dimensions without converting either value; a different context or dimension returns false. Operations that combine points still throw for context or dimension incompatibility.

The catalog provides explicit multiplicative difference units such as delta_celsius, delta_fahrenheit, Δ°C, and Δ°F. They participate in ordinary quantity and expression algebra, so delta_celsius / second is valid. Formatter symbol mode renders the named aliases as Δ°C and Δ°F with Unicode typography.

No affine unit is silently rewritten inside algebra. celsius / second and Units::quantity(1, 'celsius') remain invalid; use delta_celsius / second for a rate or Units::point(1, 'celsius') for a coordinate. parse(), parseUnit(), unit(), parseQuantity(), quantity(), normalization, multiplication, division, powers, and prefixes continue to reject the affine unit itself. Logarithmic definitions remain recognized but unevaluable.

Normalization And Simplification

normalize() and simplify() deliberately have different value behavior:

  • Quantity::normalize() substitutes unit definitions but does not change the stored magnitude. Scale remains in the resulting unit expression.
  • Quantity::simplify() substitutes unit definitions and moves the normalized scale into the stored magnitude.
<?php

use jbboehr\Yumemi\Units;

$quantity = Units::default()->quantity(2, 'centimeter');
$normalized = $quantity->normalize();
$simplified = $quantity->simplify();

assert($normalized->valueToString() === '2');
assert($normalized->unitToString() === '1/100 * meter');
assert($simplified->valueToString() === '1/50');
assert($simplified->unitToString() === 'meter');

Neither operation chooses a preferred human-scale unit. Use to() for one explicit target, toPreferred() for an application profile, or toCompact() for a caller-selected engineering-prefix family.

Expression Operations

The Units facade exposes expression-level operations:

  • parse() resolves and reduces a unit expression.
  • parseUnit() is an explicit alias of parse().
  • parseQuantity() separates explicit constants from a symbolic unit expression and returns a Quantity.
  • unit() resolves one catalog unit name.
  • dimension() returns the seven-axis SI dimension vector.
  • areCompatible() checks dimensional compatibility.
  • conversionFactor() returns an exact value-independent factor and throws NonMultiplicativeConversionException when the conversion includes an offset.
  • convert() applies an exact scale-and-offset conversion to an int or Rational.
  • convertFloat() applies the equivalent affine map in binary floating point.
  • deltaUnit() returns the multiplicative unit used for differences on a named coordinate scale.
  • deltaQuantity() constructs an exact difference using a coordinate scale’s multiplicative unit.
  • point() constructs an exact PointQuantity on a named coordinate scale.
  • normalize() substitutes derived definitions and retains their scale in the expression.

The Expr values returned by these APIs expose mul(), div(), integer pow(), exact positive integer-degree root(), reduce(), and structural equals() operations. Expr::root() reduces the expression’s current symbolic factors but does not normalize definitions; it throws NonExactRootException when the constant or any symbolic power has no exact root. Those current factors depend on how the expression was obtained: parse('kilometer * millimeter') resolves the prefixes while parsing and produces meter^2, whose square root is meter. By contrast, a quantity’s unit() preserves the names kilometer * millimeter, so its expression has no exact symbolic square root unless the caller explicitly simplifies or normalizes it first. Expr::root() itself performs neither substitution.

Incompatible conversions throw IncompatibleUnitException; unknown names throw UnitNotFoundException. Native float conversion rejects non-finite inputs, results that overflow to infinity, and nonzero exact results that underflow to zero. Exact results should use convert() instead.

For native arithmetic, unit_factor() returns the conversion factor as a float. PHPStan brands that value as the target unit divided by the source unit, so ordinary multiplication cancels the source brand:

<?php

use function jbboehr\Yumemi\unit;
use function jbboehr\Yumemi\unit_factor;

$meters = unit(3, 'meter');
$feet = $meters * unit_factor('meter', 'foot');

assert(abs($feet - 9.84251968503937) < 1e-12);

When applying the same conversion repeatedly, calculate unit_factor() once outside the loop. The resulting branded float can be reused with ordinary native multiplication; see Keep Unit Setup Outside Hot Loops.

Use conversionFactor() when the exact Rational is required. Both factor APIs reject conversions involving an offset; use convert(), convertFloat(), or unit_to() for affine conversion.

Dimensions

Units::dimension(), Quantity::dimension(), and resolved expressions expose a Dimension. Its ordinary fast path is the seven fixed SI powers in this order:

length, mass, time, electric current, temperature, amount of substance, luminous intensity

Application registries may add sparse named axes through UnitRegistryBuilder::baseUnit(). Physical and nonphysical axes use the same dimension algebra; for example, an application-defined currency / time dimension combines one extension axis with the fixed SI time axis. Dimension::CURRENCY provides the conventional currency extension name without adding a bundled unit or exchange-rate policy.

Dimensions support multiplication, division, integer powers, exact positive integer-degree roots, equality, isDimensionless(), and the existing SI axis accessors. Dimension::root() requires every power to be divisible by the degree. powers() retains its seven-element SI view. namedPowers() returns every nonzero SI and extension power, powerOf() reads either kind by name, and fromNamedPowers() constructs a mixed dimension directly. Extension names use lower snake case and format after SI axes in deterministic bytewise order.

All axes participate equally in compatibility. Dimensional equality still cannot distinguish semantically different quantities with the same dimension, such as gray and sievert; that distinction would require a separate quantity-kind model rather than another dimension subclass.

Formatting

Units::format(), Quantity::format(), and Quantity::formatUnit() accept immutable FormatOptions. Options control:

  • UnitNameStyle::Preserve, Canonical, or Symbol unit names;
  • Typography::Ascii or Unicode operators and powers;
  • DimensionlessStyle::One, Word, or Empty output;
  • DivisionStyle::Fraction or NegativePowers layout.

Formatting changes presentation only. It does not normalize derived definitions, convert magnitudes, or choose preferred or compact units. Units::formatter() returns a reusable formatter for repeated calls with the same options.

The default format preserves supplied names, uses parser-compatible ASCII, renders dimensionless expressions as 1, and uses fraction layout. Unicode output with numeric dimensionless style is also parser-compatible.

Options may be constructed with named arguments or an immutable create()->with...() chain:

<?php

use jbboehr\Yumemi\Formatter\DivisionStyle;
use jbboehr\Yumemi\Formatter\FormatOptions;
use jbboehr\Yumemi\Formatter\Typography;
use jbboehr\Yumemi\Formatter\UnitNameStyle;
use jbboehr\Yumemi\Units;

$units = Units::default();
$options = FormatOptions::create()
    ->withUnitNameStyle(UnitNameStyle::Symbol)
    ->withTypography(Typography::Unicode)
    ->withDivisionStyle(DivisionStyle::NegativePowers);

assert($units->format('kilometers / second^2', $options) === 'km · s⁻²');
assert($units->quantity(3, 'kilometers / second^2')->formatUnit($options) === 'km · s⁻²');

Preserve keeps the names supplied by the caller. Canonical resolves aliases, generated plurals, and one dynamic prefix to canonical names. Symbol selects the shortest deterministic catalog symbol; ASCII falls back to a canonical name when only Unicode symbols exist. Unknown expression leaves are preserved.

Unicode typography emits · and superscript integer powers. The parser accepts those forms, so Unicode output remains round-trippable when the dimensionless style is One. Word and Empty are presentation-only.

NegativePowers moves denominator units to negative powers without rewriting exact rational coefficients. For example, 1/2 * meter / second becomes 1/2 * meter * second ^ -1 in ASCII. The default Fraction style retains the denominator.

Units::format() parses string input symbolically before rendering, whereas an Expr returned by Units::parse() has already been catalog-resolved. Formatting never recovers source spelling that has already been resolved away.

String Forms

Quantity::toString() and unitToString() use the default display formatter. valueToString() returns the exact rational magnitude. Expr::toString() is a structural/debug representation and is not the configurable display API.

Expression equality is structural. It does not compare either display strings or normalized physical dimensions.

Debugging, JSON, And Serialization

Rational, Quantity, PointQuantity, Dimension, and the catalog descriptor value objects implement JsonSerializable. Exact rational components are decimal strings, so JSON encoding never loses precision:

<?php

use jbboehr\Yumemi\Number\Rational;
use jbboehr\Yumemi\Units;

$quantity = Units::default()->quantity(new Rational(1, 3), 'meter / second');
$json = json_encode($quantity, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);

assert($json === '{"value":{"numerator":"1","denominator":"3"},"unit":"meter / second"}');

$restored = unserialize(serialize($quantity));

assert($restored->units() === Units::default());
assert($restored->valueToString() === '1/3');
assert($restored->unitToString() === 'meter / second');

The supported JSON keys are:

  • Rational: numerator and denominator, both decimal strings.
  • Quantity and PointQuantity: value, containing the nested Rational object, and unit, containing the formatted unit string.
  • Dimension: length, mass, time, electricCurrent, temperature, amountOfSubstance, and luminousIntensity, all integers, plus an additionalPowers object mapping names to integers when application-defined axes are present.
  • PrefixDescriptor: string values for matchedName, canonicalName, matchedAs, and definitionExpression.
  • PrefixDecomposition: prefix and unit, containing nested descriptor objects.
  • UnitDescriptor: matchedName, canonicalName, matchedAs, kind, definitionExpression, documentation, comment, aliases, symbols, explicitPlurals, generatedPlurals, semantics, and prefixDecomposition. Names and enum values are strings; definition and prose fields are strings or null; aliases, symbols, and plurals are arrays of strings; and prefix decomposition is a nested object or null.

Descriptor JSON follows these documented properties, renders backed enums as strings, and nests dynamic prefix decomposition. JSON object key order and insignificant whitespace are not compatibility guarantees.

Compact __debugInfo() output follows the same representation. Quantities add only a short context identity; dumping a quantity does not recursively print its Units registry and catalog.

Native serialization is versioned and verifies normalized unit semantics and resolved dimensions when restoring a quantity or point. This detects a custom base-unit name being reassigned to another extension axis. Values created through Units::default() may use PHP’s ordinary serialize() and unserialize() and always return to the shared default context. A quantity from a custom registry must be restored through that context:

<?php

use jbboehr\Yumemi\Registry\UnitRegistryBuilder;
use jbboehr\Yumemi\Units;

$units = new Units(
    UnitRegistryBuilder::default()
        ->define('smoot = 1.7018 * meter')
        ->build(),
);
$serialized = serialize($units->quantity(2, 'smoot'));
$restored = $units->deserialize($serialized);

assert($restored->units() === $units);
assert($restored->valueIn('meter')->toString() === '8509/2500');

Serialization compatibility applies to values emitted by PHP’s serialize() in tagged Yumemi releases. Direct calls to __serialize() and the returned PHP array layout are implementation details. Newly serialized bytes may change while previously released values remain readable.

Raw unserialize() rejects a custom-context quantity with an exception directing the caller to Units::deserialize(). The scoped method restores its previous context in finally, including across nested calls, and forwards PHP’s native unserialize() options. Pass allowed_classes to restrict which classes a known graph may instantiate and max_depth to bound nesting. The allow-list must include every serialized object class in the graph, including Dimension for new quantity and point payloads; allowed_classes: false produces __PHP_Incomplete_Class objects and cannot restore a quantity. Yumemi does not choose a default allow-list because deserialize() may return any caller-defined graph. Serialized unit semantics are checked against the selected registry, so a changed or incorrect registry is rejected rather than silently reinterpreting the value.

One serialized graph may contain default values and values from one custom context. Graphs containing values from several distinct custom contexts require a future registry-identifier resolver. Never pass untrusted data to PHP deserialization; allowed_classes and max_depth reduce exposure but do not make arbitrary payloads safe. Serialize value objects directly: casting one to an array bypasses its controlled serialization representation.

Built-In and Custom Units

The painted eclipse upon the archive ceiling darkeneth no field, yet it preserveth the hour when the proud astronomers confessed their limit. Condemn not every likeness; ask whether it kneels before the event it remembers, or would supplant the heaven.

Scholia of the Fifth Archive 84:36

Astronomers beneath a painted eclipse and an open rose-gold heaven in an imperial archive

Yumemi combines a generated unit catalog derived from UDUNITS2 with a small authored supplement. The same composed catalog drives runtime name resolution, conversion, formatting, and PHPStan analysis.

The imported UDUNITS2 material is distributed under the terms in UDUNITS-COPYRIGHT. Yumemi’s own code remains under the project license described in the root README and license files.

Default Catalog

Units::default() layers the checked-in data/yumemi.php supplement over the generated data/udunits2.php catalog. The generated UDUNITS2 data includes:

  • base, dimensionless, and derived units;
  • canonical names, aliases, symbols, explicit plurals, and unambiguous generated plurals;
  • multiplicative difference units synthesized from affine definitions, including delta_celsius, delta_fahrenheit, Δ°C, and Δ°F;
  • decimal and scientific prefix definitions;
  • source definitions, comments, and documentation when present upstream.

Common accepted spellings include long names such as meter, foot, second, kilogram, and celsius; symbols such as m, ft, s, kg, and Pa; and composed expressions such as kilometer / hour. These examples are not exhaustive. Names and symbols remain case-sensitive, and aliases such as foot may resolve to a more specific canonical name such as international_foot.

The authored supplement provides exact units needed by image and document APIs:

UnitMeaning
pixelBase unit of the nominal image_sample dimension
css_pixelCSS reference length equal to exactly inch / 96
typographic_pointModern publishing point equal to exactly inch / 72
twipTwentieth of a typographic point, exactly inch / 1440
english_metric_unitOffice Open XML EMU, equal to exactly inch / 914400

The corresponding plurals are accepted, and EMU is a symbol for english_metric_unit. The CSS relationships follow the W3C absolute-length definitions. The EMU relationship follows the Microsoft Office Drawing specification.

Raster pixel is deliberately not a length. A conversion from pixels to inches requires a resolution, represented by an expression such as pixel / inch; pixel is therefore incompatible with css_pixel, inch, and meter. css_pixel represents the separate CSS reference length. The ambiguous abbreviation px is not defined.

Existing UDUNITS2 spellings retain their meanings: pt is the US liquid-pint symbol, and pica is the historical printer’s pica based on printers_point. Likewise, dpi and ppi remain prefix decompositions of pi, not density units. Use typographic_point and explicit density expressions rather than relying on those abbreviations.

The supported application path for customization is UnitRegistryBuilder. Its optional alternate generated-catalog file parameters and the concrete registry implementations are lower-level generation and testing boundaries, not stable application APIs. The generated PHP array layout is likewise not an application data format. Use builder definitions and aliases when an application needs custom units.

Lookup is case-sensitive. Exact names win before dynamic prefix decomposition, and prefixes apply only when the remaining suffix is an exact unit name. See the unit syntax reference for examples.

Custom Registries

Use UnitRegistryBuilder::default() to layer custom definitions and aliases over Yumemi’s supplement and UDUNITS2. Use UnitRegistryBuilder::empty() for an isolated catalog. Calling includeUdunits2() on an empty builder adds only the upstream catalog, without the Yumemi supplement.

Definitions use the normal unit language and are parsed against the completed registry on first use. Multiplicative definitions work throughout the runtime. An affine definition such as degree_widget = kelvin @ 100 works as a coordinate scale and receives a generated multiplicative delta_degree_widget definition. The affine name remains unavailable to expression and Quantity algebra; use PointQuantity for coordinates and the generated delta name for differences. The builder is mutable: each fluent method updates and returns the same builder. Every build() call creates an immutable registry snapshot that is unaffected by later builder changes.

<?php

use jbboehr\Yumemi\Registry\UnitRegistryBuilder;
use jbboehr\Yumemi\Units;

$registry = UnitRegistryBuilder::default()
    ->define('widget = 12 * meter')
    ->define('degree_widget = kelvin @ 100')
    ->alias('widgets', 'widget')
    ->build();

$units = new Units($registry);

assert($units->quantity(2, 'widgets')->valueIn('meter')->toString() === '24');
assert($units->describe('widgets')?->canonicalName === 'widget');
assert($units->convert(0, 'degree_widget', 'kelvin')->toString() === '100');
assert($units->point(0, 'degree_widget')->valueIn('kelvin')->toString() === '100');
assert($units->deltaQuantity(2, 'degree_widget')->valueIn('kelvin')->toString() === '2');

Use baseUnit() when an application needs a genuinely independent primitive dimension rather than another unit derived from the seven SI axes. The call declares the canonical base unit and its lower-snake-case dimension name atomically; ordinary define() calls then establish exact relationships to that base:

<?php

use jbboehr\Yumemi\Dimension;
use jbboehr\Yumemi\Registry\UnitRegistryBuilder;
use jbboehr\Yumemi\Units;

$currencyRegistry = UnitRegistryBuilder::default()
    ->baseUnit('USD', Dimension::CURRENCY)
    ->define('EUR = 100 / 107 * USD')
    ->build();
$currencyUnits = new Units($currencyRegistry);
$currencyTotal = $currencyUnits->quantity(100, 'USD')
    ->add($currencyUnits->quantity(107, 'EUR'));

assert($currencyUnits->dimension('EUR')->toString() === 'currency');
assert($currencyUnits->convert(107, 'EUR', 'USD')->toString() === '100');
assert($currencyTotal->valueToString() === '200');
assert($currencyTotal->unitToString() === 'USD');

Dimension::CURRENCY is a conventional extension name, not an eighth fixed dimension. Yumemi neither ships nor fetches exchange rates. The application owns the snapshot’s source, effective time, bid/ask and fee policy, and monetary rounding. Choose one primitive currency per immutable registry snapshot and express every other currency through an exact declared rate. Quantities from different snapshots retain different Units contexts and cannot be combined.

baseUnit() rejects the seven fixed SI dimension names. Define another length, mass, time, current, temperature, substance, or luminous-intensity unit relative to its corresponding SI base with define() so its scale remains explicit.

An overlay definition wins over a base UDUNITS2 record with the same name. Aliases resolve through the composed registry, so an overlay alias may target either another custom definition or a base catalog unit. Affine delta synthesis runs when build() creates the immutable snapshot, after all overlay definitions and aliases are known. An explicit overlay name that conflicts with one of its generated delta_* or Δ names is rejected rather than silently replaced. Reusing a bundled unit name as a custom base deliberately re-roots that name and catalog definitions that depend upon it. Avoid doing so unless replacing that part of the effective catalog is intentional.

For PHPStan, configure one UnitRegistryFactory that returns the complete registry. Runtime code should construct its Units context from the same registry. PHPStan assumes one authoritative registry for an analysis run and does not track a separate catalog identity for each value.

Introspection

Units::describe() first describes an exact catalog spelling and follows aliases to its canonical entry. If no exact entry exists, it applies the same one-prefix-plus-exact-unit decomposition used by ordinary resolution. Units::describePrefix() describes one exact prefix name or symbol. Descriptors preserve whether the matched spelling was canonical, an alias, a symbol, an explicit plural, a generated plural, or dynamically prefixed.

describe() does not accept compound expressions as lookup names, normalize definitions, or materialize dynamically prefixed spellings as exact catalog entries. Generated affine-difference units are exact catalog entries. To report truthful capabilities, introspection lazily resolves the complete canonical or dynamically prefixed spelling against the effective registry. A dynamically prefixed descriptor exposes its prefix and exact residual unit through prefixDecomposition:

<?php

use jbboehr\Yumemi\Catalog\CatalogNameKind;
use jbboehr\Yumemi\Catalog\UnitSemantics;
use jbboehr\Yumemi\Units;

$units = Units::default();
$kilopascal = $units->describe('kPa');

assert($kilopascal !== null);
assert($kilopascal->canonicalName === 'kilopascal');
assert($kilopascal->matchedAs === CatalogNameKind::Prefixed);
assert($kilopascal->definitionExpression === '1e3 * pascal');
assert($kilopascal->isDynamicallyPrefixed());
assert($kilopascal->semantics === UnitSemantics::Multiplicative);
assert($kilopascal->supportsMultiplicativeAlgebra());
assert($kilopascal->supportsConversion());

assert($kilopascal->prefixDecomposition !== null);
assert($kilopascal->prefixDecomposition->prefix->matchedName === 'k');
assert($kilopascal->prefixDecomposition->prefix->canonicalName === 'kilo');
assert($kilopascal->prefixDecomposition->prefix->matchedAs === CatalogNameKind::Symbol);
assert($kilopascal->prefixDecomposition->unit->matchedName === 'Pa');
assert($kilopascal->prefixDecomposition->unit->canonicalName === 'pascal');
assert($kilopascal->prefixDecomposition->unit->matchedAs === CatalogNameKind::Symbol);

The synthesized descriptor’s top-level alias, symbol, and plural lists are empty because it is not an exact catalog entry. The residual descriptor retains the underlying unit’s complete metadata. Exact spellings still take precedence: Pa describes the exact pascal symbol rather than decomposing as peta-are.

Prefixed affine and logarithmic names receive synthesized descriptors whose top-level semantics are UnitSemantics::UnsupportedExpression, because the complete prefixed spelling is executable by neither runtime path. The residual descriptor retains UnitSemantics::Affine or UnitSemantics::Logarithmic, identifying the underlying reason. Use supportsMultiplicativeAlgebra() and supportsConversion() to inspect concrete capabilities.

Catalog Semantic Support

The expression and quantity models intentionally support only multiplicative unit algebra with integer powers. UnitSemantics describes the capabilities of the complete name represented by a descriptor:

  • Multiplicative supports expression algebra and conversion.
  • Affine rejects expression algebra but supports explicit conversion.
  • Logarithmic identifies a direct logarithmic definition and supports neither operation.
  • UnsupportedExpression identifies a complete expression that supports neither operation, including invalid composites, malformed or cyclic custom definitions, missing dependencies, and invalid prefixes.

Known affine and logarithmic UDUNITS2 definitions remain in the generated catalog. Aliases are classified through their canonical entry, and direct custom @ or lg(...) definitions receive the same classification. Descriptors lazily resolve and cache capabilities against the effective registry, so transitive definitions and overlays cannot leave capability methods out of sync with runtime behavior.

Internal catalog records store direct or exact-name-inherited affine and logarithmic markers, but do not eagerly materialize UnsupportedExpression or transitive composite results. Generated delta records are ordinary multiplicative declarations materialized during catalog import or immutable-registry build. This keeps catalog generation deterministic and avoids resolving the full catalog merely for introspection.

Affine classification means “unsupported by multiplicative Expr algebra,” not “unsupported everywhere.” See Affine Conversion for executable boundaries and Limitations for static-analysis behavior. Logarithmic definitions remain unsupported at every execution boundary; their descriptors nevertheless distinguish them from unknown names and preserve the canonical unit, semantics, and original definition for diagnostics.

Contributors changing the imported data or generator should follow Regenerating the UDUNITS2 Catalog.

Regenerating the UDUNITS2 Catalog

In the year of the dim harvest, the western gate was shut with chains, yet a child found wheat springing between its hinges. The elders preserved neither chain nor lock; they carried the green blades through every street, and the city remembered that inheritance returns first in a frail and living sign.

Acts of the Western Court 65:48

Green wheat springing through the hinges of a chained western gate at rose-gold dawn

The checked-in data/udunits2.php file is generated from the UDUNITS2 XML distribution. This procedure is for contributors changing the importer, exporter, source package, or generated catalog; applications using Yumemi do not need to run it.

Rebuild

Do not edit data/udunits2.php manually. In the Nix development shell, run either command:

composer generate-catalog
make generate-catalog

The flake sets UDUNITS_XML_DIR to the installed UDUNITS2 XML directory. Outside the development shell, specify an equivalent directory explicitly:

UDUNITS_XML_DIR=/path/to/share/udunits make generate-catalog

Source Inputs

The Make target supplies these files in the order declared by the upstream udunits2.xml manifest:

  1. udunits2-prefixes.xml
  2. udunits2-base.xml
  3. udunits2-derived.xml
  4. udunits2-accepted.xml
  5. udunits2-common.xml

The generator imports the XML, materializes aliases, plurals, semantic metadata, and affine-difference units such as delta_celsius and Δ°C, then exports deterministic PHP through brick/varexporter. A successful rebuild should leave no diff unless an input listed above or the importer has changed.

Verify

Run the full test suite after regeneration. The catalog smoke tests resolve every supported definition, verify generated affine-difference entries, and pin the known unsupported affine and logarithmic sets, making source-data drift explicit.

Return to Built-in and Custom Units for the user-facing catalog behavior.