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.0 → 0·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 m², 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 → m², 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 m² 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 m², mult 1 |
m^2.0 |
0·m² |
exactly m², 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.
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_stringaccepts unit strings whose exponent contains characters the parser cannot represent, and returns a valid, non-errorprecise_unitwith 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:
unit^D.D(unparenthesised): a cleanup pass inserts*before the decimal point, so the fractional suffix becomes an independent numeric factor.m^0.5→ dimensionless0.5;m^2.0→0·m².unit^(D.D)(parenthesised): the digit scan stops at the.and the remainder of the parenthesised group is discarded.m^(2.5)→ exactlym², multiplier 1.m^½reaches this path via Unicode code replacement.m^(-0.5)→ exactly dimensionless one.unit^Ne M: the same block deliberately excludese/E, and the exponent parser then discards them.m^2e3→m², 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 to6.283185…·m²·rad.Environment
github.com/LLNL/units,main@be4a68555c822898c3283b54bf868adce61b7bb8. The CMake project version on this commit is0.14.0; note this is the in-development version, not a release tag (the latest tag isv0.9.1).-std=c++14, built from source (units.cpp,commodities.cpp,r20_conv.cpp,x12_conv.cpp).-DUNITS_BASE_TYPE=uint64_t, so this is not specific to the 32-bit base representation.Reproduction
Actual output
Expected
Either
m^0.5,m^(0.5),m^½all give a genuine half power of metre; orprecise::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.0ands^-1.0are 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–4783The block commented
// insert multiplies after ^#scans the digit run after a^and, if the following character is not one of*,/,^,e,E, inserts a*:.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.5becomesm^0*.5, i.e.m^0 · 0.5. Verified by instrumenting this block:The sign is also lost for
m^-0.5, since^-0and^0are equivalent.Path B —
unit_from_string_internal,units/units.cpp≈ L5632–5679The parser locates
^, optionally skips(, optionally consumes a sign, reads only the consecutive decimal digits, computes the power from those digits, and callsunit_to_the_power_of()with the unit substring preceding^:For
m^(2.5)the run stops at., givingpowerStringLength == 1andpower = 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), givingm^(0.5).Path C — the
e/Ecarve-outBecause the Path A block excludes
eandE, strings such asm^2e3pass through unmodified and are then handled by the same leading-digits-only parser, which yieldsm²and discardse3. 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: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(...)sqrtis not treated as a root function. The word-modifier table containsmodSeq{"sq", "^2", 2, modifier::start_tail}(≈L2472), so the leadingsqis 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.rststates thatsqapplies 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
match_flagsdo 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_replacementsmade no observable difference even tom**0.5orm^½.) Onlysqrt(...)is blocked, byno_recursion.m^1.5,m^1.0andm^1.25return INVALID, but only incidentally: the// get rid of ^1 sequencespass (≈L4150) strips^1, leavingm.5, which then fails to parse.m^10.5andm^11.5fail on the two-digit-power representability check. Everything else whose exponent begins with a digit other than 1 misparses.m^(1/2)andm^(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.rstdescribes string parsing as "intended to be as flexible as possible" and gives exponent examplesm*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.rstmentions power operations only in passing, undernumbers_only.docs/details/unit_base.rstdocuments 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.mdhas no exponent grammar.docs/details/string_parsing_squared.rstdoes document thesq/squaremodifier 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.rstis 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 form^2.0ands^-1.0would 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:main*insertion suppressedm^0.50.5, mult 0.5, dimensionlessprecise::one, mult 1m^2.50.5·m²m², mult 1m^2.00·m²m², mult 1With the anomalous multiplier gone,
unit_from_string("m^0.5") == precise::oneandunit_from_string("m^2.5") == m*mboth 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) and2.54 cm(→ in) all parse as expected on this commit, as do the rejections ofm^(1/2),m^(2x)andm^2foo.I found no existing test in
test/that directly asserts acceptance or the present interpretation of any of the misparsing forms.