Skip to content

Exponents are parsed by leading digits only: m^0.5, m^(2.5), m^2e3 silently yield wrong units #462

Description

@larsbarring

After having run into one example of this issue, I generated the report below with help of an AI. (Hence, the "I" below is essentially the AI.)

Summary

unit_from_string accepts unit strings whose exponent contains characters the parser cannot represent, and returns a valid, non-error precise_unit with the wrong dimension and/or a wrong multiplier, rather than rejecting them. is_error() is false, so callers have no way to detect the corruption.

The common cause is that exponent parsing consumes only the leading run of decimal digits and never checks that the rest of the exponent was consumed. This surfaces through three entry points:

  • A. unit^D.D (unparenthesised): a cleanup pass inserts * before the decimal point, so the fractional suffix becomes an independent numeric factor. m^0.5 → dimensionless 0.5; m^2.00·m².
  • B. unit^(D.D) (parenthesised): the digit scan stops at the . and the remainder of the parenthesised group is discarded. m^(2.5) → exactly , multiplier 1. m^½ reaches this path via Unicode code replacement. m^(-0.5) → exactly dimensionless one.
  • C. unit^Ne M: the same block deliberately excludes e/E, and the exponent parser then discards them. m^2e3, multiplier 1.

Case B (and C) are the more dangerous, since there is no anomalous multiplier to give the mistake away — the result compares == equal to a legitimate unit.

A related but separate defect: sqrt(m) is not rejected as an unsupported function call and resolves to 6.283185…·m²·rad.

Environment

  • github.com/LLNL/units, main @ be4a68555c822898c3283b54bf868adce61b7bb8. The CMake project version on this commit is 0.14.0; note this is the in-development version, not a release tag (the latest tag is v0.9.1).
  • Linux, g++, -std=c++14, built from source (units.cpp, commodities.cpp, r20_conv.cpp, x12_conv.cpp).
  • Results below are identical for the default build and for -DUNITS_BASE_TYPE=uint64_t, so this is not specific to the 32-bit base representation.

Reproduction

#include "units/units.hpp"
#include <iostream>
#include <iomanip>
#include <string>
#include <vector>

using namespace units;

static void show(const std::string& s)
{
    precise_unit u = unit_from_string(s);
    std::cout << std::left << std::setw(12) << s << " -> ";
    if (is_error(u)) { std::cout << "INVALID\n"; return; }
    auto b = u.base_units();
    std::cout << "multiplier=" << std::setw(8) << u.multiplier()
              << " to_string=\"" << to_string(u) << "\""
              << " [m^" << b.meter() << " kg^" << b.kg()
              << " s^" << b.second() << " rad^" << b.radian() << "]\n";
}

int main()
{
    std::cout << "--- A: unit^D.D ---\n";
    for (auto s : std::vector<std::string>{
             "m^0.5", "m**0.5", "m^-0.5", "m^0.25",
             "m^2.5", "m^3.5", "m^2.0", "s^-1.0"})
        show(s);

    std::cout << "\n--- B: unit^(D.D) ---\n";
    for (auto s : std::vector<std::string>{
             "m^(0.5)", "m^(-0.5)", "m^(2.5)", "m^(2.9)", "m^\u00BD"})
        show(s);

    std::cout << "\n--- C: exponent with e/E ---\n";
    for (auto s : std::vector<std::string>{"m^2e3", "m^2E3"}) show(s);

    std::cout << "\n--- D: sqrt(...) ---\n";
    for (auto s : std::vector<std::string>{"sqrt(m)", "sqrt(s)", "sqrt(m/s)"})
        show(s);

    std::cout << "\n--- consequences ---\n";
    std::cout << "unit_from_string(\"m^(0.5)\") == precise::one : "
              << (unit_from_string("m^(0.5)") == precise::one) << "\n";
    std::cout << "unit_from_string(\"m^(2.5)\") == m*m          : "
              << (unit_from_string("m^(2.5)") == precise::m * precise::m) << "\n";
    std::cout << "convert(1.0, \"m^2.0\", \"m^2\")               : "
              << convert(1.0, unit_from_string("m^2.0"),
                         precise::m * precise::m) << "\n";
    std::cout << "convert(1.0, \"m^2\", \"m^2.0\")               : "
              << convert(1.0, precise::m * precise::m,
                         unit_from_string("m^2.0")) << "\n";
    std::cout << "is_error(unit_from_string(\"m^0.5\"))         : "
              << is_error(unit_from_string("m^0.5")) << "\n";
    return 0;
}

Actual output

--- A: unit^D.D ---
m^0.5        -> multiplier=0.5      to_string="0.5"    [m^0 kg^0 s^0  rad^0]
m**0.5       -> multiplier=0.5      to_string="0.5"    [m^0 kg^0 s^0  rad^0]
m^-0.5       -> multiplier=0.5      to_string="0.5"    [m^0 kg^0 s^0  rad^0]
m^0.25       -> multiplier=0.25     to_string="0.25"   [m^0 kg^0 s^0  rad^0]
m^2.5        -> multiplier=0.5      to_string="0.5m^2" [m^2 kg^0 s^0  rad^0]
m^3.5        -> multiplier=0.5      to_string="0.5m^3" [m^3 kg^0 s^0  rad^0]
m^2.0        -> multiplier=0        to_string="0*m^2"  [m^2 kg^0 s^0  rad^0]
s^-1.0       -> multiplier=0        to_string="0*Hz"   [m^0 kg^0 s^-1 rad^0]

--- B: unit^(D.D) ---
m^(0.5)      -> multiplier=1        to_string=""       [m^0 kg^0 s^0 rad^0]
m^(-0.5)     -> multiplier=1        to_string=""       [m^0 kg^0 s^0 rad^0]
m^(2.5)      -> multiplier=1        to_string="m^2"    [m^2 kg^0 s^0 rad^0]
m^(2.9)      -> multiplier=1        to_string="m^2"    [m^2 kg^0 s^0 rad^0]
m^½          -> multiplier=1        to_string=""       [m^0 kg^0 s^0 rad^0]

--- C: exponent with e/E ---
m^2e3        -> multiplier=1        to_string="m^2"    [m^2 kg^0 s^0 rad^0]
m^2E3        -> multiplier=1        to_string="m^2"    [m^2 kg^0 s^0 rad^0]

--- D: sqrt(...) ---
sqrt(m)      -> multiplier=6.28319  to_string="6.28318530717958623m^2*rad"  [m^2 kg^0 s^0  rad^1]
sqrt(s)      -> multiplier=6.28319  to_string="6.28318530717958623rad*s^2"  [m^0 kg^0 s^2  rad^1]
sqrt(m/s)    -> multiplier=6.28319  to_string="6.28318530717958623Gy*rad"   [m^2 kg^0 s^-2 rad^1]

--- consequences ---
unit_from_string("m^(0.5)") == precise::one : 1
unit_from_string("m^(2.5)") == m*m          : 1
convert(1.0, "m^2.0", "m^2")               : 0
convert(1.0, "m^2", "m^2.0")               : inf
is_error(unit_from_string("m^0.5"))         : 0

Expected

Either

  • fractional/rational powers are supported and m^0.5, m^(0.5), m^½ all give a genuine half power of metre; or
  • they are unsupported and all of the above return precise::invalid.

Either is acceptable. Returning a valid unit of a different dimension is not: it converts silently and produces wrong numbers with no signal to the caller. Note in particular that m^2.0 and s^-1.0 are ordinary integer exponents merely written in floating-point form — notation that arises readily when an exponent has passed through a floating-point representation or a generic serialisation pipeline. These currently yield a unit with multiplier 0, so conversions of ordinary finite values are multiplied by zero and return zero, while the reverse conversion divides by zero and returns infinity.

Analysis

Path A — cleanUnitString, units/units.cpp ≈ L4757–4783

The block commented // insert multiplies after ^# scans the digit run after a ^ and, if the following character is not one of *, /, ^, e, E, inserts a *:

if (seq > 1) {
    auto c2 = unit_string[fnd + seq];
    if (c2 != '\0' && c2 != '*' && c2 != '/' && c2 != '^' &&
        c2 != 'e' && c2 != 'E') {
        unit_string.insert(fnd + seq, 1, '*');
    }
}

. is not excluded, so a multiplication operator is inserted immediately before the decimal point and the fractional suffix is then parsed as an independent numeric factor: m^0.5 becomes m^0*.5, i.e. m^0 · 0.5. Verified by instrumenting this block:

=== input: m^0.5   [DBG insert-mult-after-^#] -> m^0*.5
=== input: m^2.5   [DBG insert-mult-after-^#] -> m^2*.5
=== input: m^-0.5  [DBG insert-mult-after-^#] -> m^-0*.5

The sign is also lost for m^-0.5, since ^-0 and ^0 are equivalent.

Path B — unit_from_string_internal, units/units.cpp ≈ L5632–5679

The parser locates ^, optionally skips (, optionally consumes a sign, reads only the consecutive decimal digits, computes the power from those digits, and calls unit_to_the_power_of() with the unit substring preceding ^:

size_t end = sep + 2;
for (; end < unit_string.size() && isDigitCharacter(unit_string[end]); ++end) {
}
auto powerStringLength = end - sep - 1;

For m^(2.5) the run stops at ., giving powerStringLength == 1 and power = 2; .5) is never examined. There is no requirement that the digit run reach the closing ), and no subsequent consumption check. Only the leading integer digit sequence is consumed and everything from the decimal point onward is ignored by this path; for the examples shown this has the same effect as truncation toward zero, but no floating-point value is parsed and no deliberate integer conversion occurs.

m^½ reaches this path through the Unicode code-replacement table, which rewrites ½ to (0.5), giving m^(0.5).

Path C — the e/E carve-out

Because the Path A block excludes e and E, strings such as m^2e3 pass through unmodified and are then handled by the same leading-digits-only parser, which yields and discards e3. This is the same incomplete-consumption defect through a third entry point.

Validator/parser mismatch

checkExponentOperations() (≈L3956) does not catch any of this. Its ^( branch positively permits one decimal point:

bool dpoint_encountered = false;
while (unit_string[cx] != ')') {
    if (!isDigitCharacter(unit_string[cx])) {
        if (unit_string[cx] == '.' && !dpoint_encountered) {
            dpoint_encountered = true;
        } else {
            return false;
        }
    }
    ++cx;
}

So one component validates syntax that another component only partially interprets. This is arguably the core structural problem: exponent validation and exponent parsing implement different grammars.

Path D — sqrt(...)

sqrt is not treated as a root function. The word-modifier table contains modSeq{"sq", "^2", 2, modifier::start_tail} (≈L2472), so the leading sq is interpreted as the "square" modifier; subsequent heuristic or partition-based matching of the remaining characters ultimately introduces a revolution/rotation-related unit, which accounts for the factor 2π·rad. I have not traced every intermediate transformation, and the exact matching sequence is not needed to establish the observed result.

Unlike paths A–C, this one is a consequence of documented behaviour: docs/details/string_parsing_squared.rst states that sq applies to the unit immediately following it, and that is exactly what happens here. So this may well be considered working as designed. It is reported only because the interaction is unguarded — sqrt(m) looks to a caller like a root request and silently yields a valid unit of an unrelated dimension rather than being rejected as unsupported function-call syntax. If the maintainers regard this as intended, the paths A–C report stands independently of it.

Notes

  • Restrictive match_flags do not help. None of the documented restrictive flags I tested — strict_si, strict_ucum, numbers_only, skip_code_replacements, no_recursion, no_commodities, individually and in combination — caused the forms in A, B or C to be rejected; all returned the same wrong units. (skip_code_replacements made no observable difference even to m**0.5 or m^½.) Only sqrt(...) is blocked, by no_recursion.
  • The behaviour is exponent-value dependent, which suggests it is unintended rather than designed. m^1.5, m^1.0 and m^1.25 return INVALID, but only incidentally: the // get rid of ^1 sequences pass (≈L4150) strips ^1, leaving m.5, which then fails to parse. m^10.5 and m^11.5 fail on the two-digit-power representability check. Everything else whose exponent begins with a digit other than 1 misparses.
  • m^(1/2) and m^(3/2) are correctly rejected.

Documentation reviewed

I checked the documentation on this commit before filing, in case any of this is stated behaviour.

  • docs/user-guide/from_string.rst describes string parsing as "intended to be as flexible as possible" and gives exponent examples m*s^-1, meters*seconds^(-1) and (second/meter)^(-1) — all integer. It states no grammar or constraint for exponents, and does not mention decimal points, e/E, or fractional powers.
  • docs/user-guide/conversion_flags.rst mentions power operations only in passing, under numbers_only.
  • docs/details/unit_base.rst documents that base-unit powers are stored as integer bitfields, so non-integer powers are not representable. That establishes why these forms cannot be honoured, but says nothing about how the string parser should treat them.
  • README.md has no exponent grammar.
  • docs/details/string_parsing_squared.rst does document the sq/square modifier rule underlying path D, as noted above.

So paths A, B and C are not documented behaviour, and the representable-power limitation documented in unit_base.rst is not reflected in what the parser accepts. If the intent is in fact that unsupported exponents are accepted on a best-effort basis, that is not stated anywhere I could find, and the zero-multiplier results for m^2.0 and s^-1.0 would still seem hard to justify on that reading.

Additional observation

The two unparenthesised and parenthesised paths are not independent failure modes but the same underlying incomplete-consumption behaviour reached from different directions. To check this I made one experimental edit locally — adding . to the excluded character set in the L4757 block, with no other change — and rebuilt:

string current main with the * insertion suppressed
m^0.5 0.5, mult 0.5, dimensionless exactly precise::one, mult 1
m^2.5 0.5·m² exactly , mult 1
m^2.0 0·m² exactly , mult 1

With the anomalous multiplier gone, unit_from_string("m^0.5") == precise::one and unit_from_string("m^2.5") == m*m both hold. So the cleanup pass is not the origin of the misparse; it only makes Path A's symptom visible in the multiplier. Suppressing it in isolation leaves the wrong dimension in place and removes the one observable signal. Recording this only because it might otherwise look like an obvious one-line change.

Related forms that currently behave correctly

Noted for scope, not as a request: m^2, m^-2, m^(2), m^(-2), 10^-9 m (→ nm), 1e3 m (→ km), 10*3.m (→ km) and 2.54 cm (→ in) all parse as expected on this commit, as do the rejections of m^(1/2), m^(2x) and m^2foo.

I found no existing test in test/ that directly asserts acceptance or the present interpretation of any of the misparsing forms.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions